refactor(scout): resolve configured reference mentions (#26701)
This commit is contained in:
@@ -26,7 +26,6 @@ import * as Option from "effect/Option"
|
|||||||
import * as OtelTracer from "@effect/opentelemetry/Tracer"
|
import * as OtelTracer from "@effect/opentelemetry/Tracer"
|
||||||
import { zod } from "@opencode-ai/core/effect-zod"
|
import { zod } from "@opencode-ai/core/effect-zod"
|
||||||
import { withStatics, type DeepMutable } from "@opencode-ai/core/schema"
|
import { withStatics, type DeepMutable } from "@opencode-ai/core/schema"
|
||||||
import { Reference } from "@/reference/reference"
|
|
||||||
|
|
||||||
export const Info = Schema.Struct({
|
export const Info = Schema.Struct({
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
@@ -301,76 +300,6 @@ export const layer = Layer.effect(
|
|||||||
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
|
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function referencePrompt(reference: Reference.Resolved) {
|
|
||||||
if (reference.kind === "local") {
|
|
||||||
return [
|
|
||||||
`You are configured reference @${reference.name}, a read-only research agent for external reference material.`,
|
|
||||||
`Local directory: ${reference.path}`,
|
|
||||||
`Inspect this directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches. Do not edit files.`,
|
|
||||||
`Return exact absolute file paths for findings whenever possible.`,
|
|
||||||
].join("\n\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reference.kind === "invalid") {
|
|
||||||
return [
|
|
||||||
`You are configured reference @${reference.name}, but this reference is not usable yet.`,
|
|
||||||
`Configured repository: ${reference.repository}`,
|
|
||||||
`Problem: ${reference.message}`,
|
|
||||||
`Explain this configuration problem if invoked. Do not edit files or attempt fallback clones.`,
|
|
||||||
].join("\n\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
`You are configured reference @${reference.name}, a read-only research agent for external reference material.`,
|
|
||||||
`Repository: ${reference.repository}`,
|
|
||||||
...(reference.branch ? [`Branch/ref: ${reference.branch}`] : []),
|
|
||||||
`Cached directory: ${reference.path}`,
|
|
||||||
`OpenCode materializes this configured repository before use. Do not call repo_clone for this reference.`,
|
|
||||||
`Inspect the cached directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches, then use Glob, Grep, and Read inside that directory. Do not edit files.`,
|
|
||||||
`Return exact absolute file paths for findings whenever possible.`,
|
|
||||||
].join("\n\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
function referenceDescription(reference: Reference.Resolved) {
|
|
||||||
if (reference.kind === "local") return `Scout reference for local directory ${reference.path}`
|
|
||||||
if (reference.kind === "git") return `Scout reference for repository ${reference.repository}`
|
|
||||||
return `Invalid Scout reference for repository ${reference.repository}`
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Flag.OPENCODE_EXPERIMENTAL_SCOUT) {
|
|
||||||
const resolvedReferences = Reference.resolveAll({
|
|
||||||
references: cfg.reference ?? {},
|
|
||||||
directory: ctx.directory,
|
|
||||||
worktree: ctx.worktree,
|
|
||||||
})
|
|
||||||
for (const resolved of resolvedReferences) {
|
|
||||||
if (agents[resolved.name]) continue
|
|
||||||
const localPath = resolved.kind === "invalid" ? undefined : resolved.path
|
|
||||||
agents[resolved.name] = {
|
|
||||||
name: resolved.name,
|
|
||||||
description: referenceDescription(resolved),
|
|
||||||
permission: Permission.merge(
|
|
||||||
agents.scout.permission,
|
|
||||||
Permission.fromConfig({
|
|
||||||
repo_clone: "deny",
|
|
||||||
...(localPath
|
|
||||||
? {
|
|
||||||
external_directory: {
|
|
||||||
[localPath]: "allow",
|
|
||||||
[path.join(localPath, "*")]: "allow",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
prompt: referencePrompt(resolved),
|
|
||||||
options: { reference: cfg.reference?.[resolved.name], resolved },
|
|
||||||
mode: "subagent",
|
|
||||||
native: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure Truncate.GLOB is allowed unless explicitly configured
|
// Ensure Truncate.GLOB is allowed unless explicitly configured
|
||||||
for (const name in agents) {
|
for (const name in agents) {
|
||||||
const agent = agents[name]
|
const agent = agents[name]
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ import { EffectBridge } from "@/effect/bridge"
|
|||||||
import { SyncEvent } from "@/sync"
|
import { SyncEvent } from "@/sync"
|
||||||
import { SessionEvent } from "@/v2/session-event"
|
import { SessionEvent } from "@/v2/session-event"
|
||||||
import { Modelv2 } from "@/v2/model"
|
import { Modelv2 } from "@/v2/model"
|
||||||
import { AgentAttachment, FileAttachment, Source } from "@/v2/session-prompt"
|
import { AgentAttachment, FileAttachment, ReferenceAttachment, Source } from "@/v2/session-prompt"
|
||||||
|
import { Reference } from "@/reference/reference"
|
||||||
import * as DateTime from "effect/DateTime"
|
import * as DateTime from "effect/DateTime"
|
||||||
import { eq } from "@/storage/db"
|
import { eq } from "@/storage/db"
|
||||||
import * as Database from "@/storage/db"
|
import * as Database from "@/storage/db"
|
||||||
@@ -81,6 +82,45 @@ const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested struc
|
|||||||
const log = Log.create({ service: "session.prompt" })
|
const log = Log.create({ service: "session.prompt" })
|
||||||
const elog = EffectLogger.create({ service: "session.prompt" })
|
const elog = EffectLogger.create({ service: "session.prompt" })
|
||||||
|
|
||||||
|
type ReferencePromptMetadata = {
|
||||||
|
name: string
|
||||||
|
kind: "local" | "git" | "invalid"
|
||||||
|
path?: string
|
||||||
|
repository?: string
|
||||||
|
branch?: string
|
||||||
|
target?: string
|
||||||
|
targetPath?: string
|
||||||
|
problem?: string
|
||||||
|
source: { value: string; start: number; end: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringField(record: Record<string, unknown>, key: string) {
|
||||||
|
return typeof record[key] === "string" ? record[key] : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function referencePromptMetadata(input: unknown): ReferencePromptMetadata | undefined {
|
||||||
|
if (!input || typeof input !== "object" || Array.isArray(input)) return
|
||||||
|
const record = input as Record<string, unknown>
|
||||||
|
const name = stringField(record, "name")
|
||||||
|
const kind = stringField(record, "kind")
|
||||||
|
if (!name || (kind !== "local" && kind !== "git" && kind !== "invalid")) return
|
||||||
|
if (!record.source || typeof record.source !== "object" || Array.isArray(record.source)) return
|
||||||
|
const source = record.source as Record<string, unknown>
|
||||||
|
const value = stringField(source, "value")
|
||||||
|
if (!value || typeof source.start !== "number" || typeof source.end !== "number") return
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
kind,
|
||||||
|
path: stringField(record, "path"),
|
||||||
|
repository: stringField(record, "repository"),
|
||||||
|
branch: stringField(record, "branch"),
|
||||||
|
target: stringField(record, "target"),
|
||||||
|
targetPath: stringField(record, "targetPath"),
|
||||||
|
problem: stringField(record, "problem"),
|
||||||
|
source: { value, start: source.start, end: source.end },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
|
readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
|
||||||
readonly prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts>
|
readonly prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts>
|
||||||
@@ -119,6 +159,7 @@ export const layer = Layer.effect(
|
|||||||
const summary = yield* SessionSummary.Service
|
const summary = yield* SessionSummary.Service
|
||||||
const sys = yield* SystemPrompt.Service
|
const sys = yield* SystemPrompt.Service
|
||||||
const llm = yield* LLM.Service
|
const llm = yield* LLM.Service
|
||||||
|
const references = yield* Reference.Service
|
||||||
const sync = yield* SyncEvent.Service
|
const sync = yield* SyncEvent.Service
|
||||||
const runner = Effect.fn("SessionPrompt.runner")(function* () {
|
const runner = Effect.fn("SessionPrompt.runner")(function* () {
|
||||||
return yield* EffectBridge.make()
|
return yield* EffectBridge.make()
|
||||||
@@ -141,12 +182,116 @@ export const layer = Layer.effect(
|
|||||||
const parts: Types.DeepMutable<PromptInput["parts"]> = [{ type: "text", text: template }]
|
const parts: Types.DeepMutable<PromptInput["parts"]> = [{ type: "text", text: template }]
|
||||||
const files = ConfigMarkdown.files(template)
|
const files = ConfigMarkdown.files(template)
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
|
const mentionSource = (match: RegExpMatchArray) => {
|
||||||
|
const start = match.index ?? 0
|
||||||
|
return { value: match[0], start, end: start + match[0].length }
|
||||||
|
}
|
||||||
|
const referenceTextPart = (input: {
|
||||||
|
reference: Reference.Resolved
|
||||||
|
source: ReturnType<typeof mentionSource>
|
||||||
|
target?: string
|
||||||
|
targetPath?: string
|
||||||
|
problem?: string
|
||||||
|
}): MessageV2.TextPartInput => {
|
||||||
|
const metadata: ReferencePromptMetadata = {
|
||||||
|
name: input.reference.name,
|
||||||
|
kind: input.reference.kind,
|
||||||
|
...(input.reference.kind === "invalid"
|
||||||
|
? { repository: input.reference.repository }
|
||||||
|
: { path: input.reference.path }),
|
||||||
|
...(input.reference.kind === "git"
|
||||||
|
? { repository: input.reference.repository, branch: input.reference.branch }
|
||||||
|
: {}),
|
||||||
|
...(input.target === undefined ? {} : { target: input.target }),
|
||||||
|
...(input.targetPath ? { targetPath: input.targetPath } : {}),
|
||||||
|
problem: input.problem ?? (input.reference.kind === "invalid" ? input.reference.message : undefined),
|
||||||
|
source: input.source,
|
||||||
|
}
|
||||||
|
const label = metadata.target === undefined ? `@${metadata.name}` : `@${metadata.name}/${metadata.target}`
|
||||||
|
return {
|
||||||
|
type: "text",
|
||||||
|
synthetic: true,
|
||||||
|
text: [
|
||||||
|
`Referenced configured reference ${label}.`,
|
||||||
|
...(metadata.kind === "local" ? ["Kind: local directory"] : []),
|
||||||
|
...(metadata.kind === "git" ? ["Kind: git repository"] : []),
|
||||||
|
...(metadata.repository ? [`Repository: ${metadata.repository}`] : []),
|
||||||
|
...(metadata.branch ? [`Branch/ref: ${metadata.branch}`] : []),
|
||||||
|
...(metadata.path ? [`Reference root: ${metadata.path}`] : []),
|
||||||
|
...(metadata.targetPath ? [`Resolved path: ${metadata.targetPath}`] : []),
|
||||||
|
...(metadata.problem
|
||||||
|
? [`Problem: ${metadata.problem}`]
|
||||||
|
: [
|
||||||
|
"For targeted context, inspect the reference path directly with Read, Glob, and Grep. For broader research, call the task tool with subagent scout and include this reference path.",
|
||||||
|
]),
|
||||||
|
].join("\n"),
|
||||||
|
metadata: { reference: metadata },
|
||||||
|
}
|
||||||
|
}
|
||||||
yield* Effect.forEach(
|
yield* Effect.forEach(
|
||||||
files,
|
files,
|
||||||
Effect.fnUntraced(function* (match) {
|
Effect.fnUntraced(function* (match) {
|
||||||
const name = match[1]
|
const name = match[1]
|
||||||
|
if (!name) return
|
||||||
if (seen.has(name)) return
|
if (seen.has(name)) return
|
||||||
seen.add(name)
|
seen.add(name)
|
||||||
|
|
||||||
|
const slash = name.indexOf("/")
|
||||||
|
const alias = slash === -1 ? name : name.slice(0, slash)
|
||||||
|
const reference = yield* references.get(alias)
|
||||||
|
if (reference) {
|
||||||
|
const source = mentionSource(match)
|
||||||
|
if (reference.kind === "invalid") {
|
||||||
|
parts.push(
|
||||||
|
referenceTextPart({ reference, source, target: slash === -1 ? undefined : name.slice(slash + 1) }),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* references.ensure(reference.path)
|
||||||
|
if (slash === -1) {
|
||||||
|
parts.push(referenceTextPart({ reference, source }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = name.slice(slash + 1)
|
||||||
|
const targetPath = path.resolve(reference.path, target)
|
||||||
|
if (!AppFileSystem.contains(reference.path, targetPath)) {
|
||||||
|
parts.push(
|
||||||
|
referenceTextPart({
|
||||||
|
reference,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
targetPath,
|
||||||
|
problem: `Path escapes configured reference @${alias}: ${target}`,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = yield* fsys.stat(targetPath).pipe(Effect.option)
|
||||||
|
if (Option.isNone(info)) {
|
||||||
|
parts.push(
|
||||||
|
referenceTextPart({
|
||||||
|
reference,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
targetPath,
|
||||||
|
problem: `Path does not exist inside configured reference @${alias}: ${target}`,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push({
|
||||||
|
type: "file",
|
||||||
|
url: pathToFileURL(targetPath).href,
|
||||||
|
filename: name,
|
||||||
|
mime: info.value.type === "Directory" ? "application/x-directory" : "text/plain",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const filepath = name.startsWith("~/")
|
const filepath = name.startsWith("~/")
|
||||||
? path.join(os.homedir(), name.slice(2))
|
? path.join(os.homedir(), name.slice(2))
|
||||||
: path.resolve(ctx.worktree, name)
|
: path.resolve(ctx.worktree, name)
|
||||||
@@ -1326,6 +1471,26 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
if (part.type === "text") {
|
if (part.type === "text") {
|
||||||
if (part.synthetic) result.synthetic.push(part.text)
|
if (part.synthetic) result.synthetic.push(part.text)
|
||||||
else result.text.push(part.text)
|
else result.text.push(part.text)
|
||||||
|
const reference = referencePromptMetadata(part.metadata?.reference)
|
||||||
|
if (reference) {
|
||||||
|
result.references.push(
|
||||||
|
new ReferenceAttachment({
|
||||||
|
name: reference.name,
|
||||||
|
kind: reference.kind,
|
||||||
|
uri: reference.path ? pathToFileURL(reference.path).href : undefined,
|
||||||
|
repository: reference.repository,
|
||||||
|
branch: reference.branch,
|
||||||
|
target: reference.target,
|
||||||
|
targetUri: reference.targetPath ? pathToFileURL(reference.targetPath).href : undefined,
|
||||||
|
problem: reference.problem,
|
||||||
|
source: new Source({
|
||||||
|
start: reference.source.start,
|
||||||
|
end: reference.source.end,
|
||||||
|
text: reference.source.value,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (part.type === "file") {
|
if (part.type === "file") {
|
||||||
result.files.push(
|
result.files.push(
|
||||||
@@ -1363,6 +1528,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
text: [] as string[],
|
text: [] as string[],
|
||||||
files: [] as FileAttachment[],
|
files: [] as FileAttachment[],
|
||||||
agents: [] as AgentAttachment[],
|
agents: [] as AgentAttachment[],
|
||||||
|
references: [] as ReferenceAttachment[],
|
||||||
synthetic: [] as string[],
|
synthetic: [] as string[],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -1375,6 +1541,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
text: nextPrompt.text.join("\n"),
|
text: nextPrompt.text.join("\n"),
|
||||||
files: nextPrompt.files,
|
files: nextPrompt.files,
|
||||||
agents: nextPrompt.agents,
|
agents: nextPrompt.agents,
|
||||||
|
references: nextPrompt.references,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1817,6 +1984,7 @@ export const defaultLayer = Layer.suspend(() =>
|
|||||||
Agent.defaultLayer,
|
Agent.defaultLayer,
|
||||||
SystemPrompt.defaultLayer,
|
SystemPrompt.defaultLayer,
|
||||||
LLM.defaultLayer,
|
LLM.defaultLayer,
|
||||||
|
Reference.defaultLayer,
|
||||||
Bus.layer,
|
Bus.layer,
|
||||||
CrossSpawnSpawner.defaultLayer,
|
CrossSpawnSpawner.defaultLayer,
|
||||||
SyncEvent.defaultLayer,
|
SyncEvent.defaultLayer,
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ export function update<Result>(adapter: Adapter<Result>, event: SessionEvent.Eve
|
|||||||
text: event.data.prompt.text,
|
text: event.data.prompt.text,
|
||||||
files: event.data.prompt.files,
|
files: event.data.prompt.files,
|
||||||
agents: event.data.prompt.agents,
|
agents: event.data.prompt.agents,
|
||||||
|
references: event.data.prompt.references,
|
||||||
time: { created: event.data.timestamp },
|
time: { created: event.data.timestamp },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export class User extends Schema.Class<User>("Session.Message.User")({
|
|||||||
text: Prompt.fields.text,
|
text: Prompt.fields.text,
|
||||||
files: Prompt.fields.files,
|
files: Prompt.fields.files,
|
||||||
agents: Prompt.fields.agents,
|
agents: Prompt.fields.agents,
|
||||||
|
references: Prompt.fields.references,
|
||||||
type: Schema.Literal("user"),
|
type: Schema.Literal("user"),
|
||||||
time: Schema.Struct({
|
time: Schema.Struct({
|
||||||
created: V2Schema.DateTimeUtcFromMillis,
|
created: V2Schema.DateTimeUtcFromMillis,
|
||||||
|
|||||||
@@ -29,8 +29,21 @@ export class AgentAttachment extends Schema.Class<AgentAttachment>("Prompt.Agent
|
|||||||
source: Source.pipe(Schema.optional),
|
source: Source.pipe(Schema.optional),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
|
export class ReferenceAttachment extends Schema.Class<ReferenceAttachment>("Prompt.ReferenceAttachment")({
|
||||||
|
name: Schema.String,
|
||||||
|
kind: Schema.Literals(["local", "git", "invalid"]),
|
||||||
|
uri: Schema.String.pipe(Schema.optional),
|
||||||
|
repository: Schema.String.pipe(Schema.optional),
|
||||||
|
branch: Schema.String.pipe(Schema.optional),
|
||||||
|
target: Schema.String.pipe(Schema.optional),
|
||||||
|
targetUri: Schema.String.pipe(Schema.optional),
|
||||||
|
problem: Schema.String.pipe(Schema.optional),
|
||||||
|
source: Source.pipe(Schema.optional),
|
||||||
|
}) {}
|
||||||
|
|
||||||
export class Prompt extends Schema.Class<Prompt>("Prompt")({
|
export class Prompt extends Schema.Class<Prompt>("Prompt")({
|
||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
files: Schema.Array(FileAttachment).pipe(Schema.optional),
|
files: Schema.Array(FileAttachment).pipe(Schema.optional),
|
||||||
agents: Schema.Array(AgentAttachment).pipe(Schema.optional),
|
agents: Schema.Array(AgentAttachment).pipe(Schema.optional),
|
||||||
|
references: Schema.Array(ReferenceAttachment).pipe(Schema.optional),
|
||||||
}) {}
|
}) {}
|
||||||
|
|||||||
@@ -141,16 +141,12 @@ test("scout agent allows repo cloning and repo cache reads", async () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("reference config creates scout-backed subagents", async () => {
|
test("reference config does not create subagents", async () => {
|
||||||
await withExperimentalScout(true, async () => {
|
await withExperimentalScout(true, async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
config: {
|
config: {
|
||||||
reference: {
|
reference: {
|
||||||
effect: "github.com/effect/effect-smol",
|
effect: "github.com/effect/effect-smol",
|
||||||
effectDev: {
|
|
||||||
repository: "https://github.com/effect/effect-smol",
|
|
||||||
branch: "dev",
|
|
||||||
},
|
|
||||||
effectFull: {
|
effectFull: {
|
||||||
repository: "Effect-TS/effect",
|
repository: "Effect-TS/effect",
|
||||||
branch: "main",
|
branch: "main",
|
||||||
@@ -165,45 +161,13 @@ test("reference config creates scout-backed subagents", async () => {
|
|||||||
await WithInstance.provide({
|
await WithInstance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const effect = await load(tmp.path, (svc) => svc.get("effect"))
|
const agents = await load(tmp.path, (svc) => svc.list())
|
||||||
const effectDev = await load(tmp.path, (svc) => svc.get("effectDev"))
|
const names = agents.map((agent) => agent.name)
|
||||||
const effectFull = await load(tmp.path, (svc) => svc.get("effectFull"))
|
expect(names).toContain("scout")
|
||||||
const local = await load(tmp.path, (svc) => svc.get("localdocs"))
|
expect(names).not.toContain("effect")
|
||||||
const localFull = await load(tmp.path, (svc) => svc.get("localdocsFull"))
|
expect(names).not.toContain("effectFull")
|
||||||
|
expect(names).not.toContain("localdocs")
|
||||||
expect(effect).toBeDefined()
|
expect(names).not.toContain("localdocsFull")
|
||||||
expect(effect?.mode).toBe("subagent")
|
|
||||||
expect(effect?.prompt).toContain("Repository: github.com/effect/effect-smol")
|
|
||||||
expect(effect?.prompt).toContain(
|
|
||||||
`Cached directory: ${path.join(Global.Path.repos, "github.com", "effect", "effect-smol")}`,
|
|
||||||
)
|
|
||||||
expect(effect?.prompt).toContain("Do not call repo_clone")
|
|
||||||
expect(evalPerm(effect, "repo_clone")).toBe("deny")
|
|
||||||
|
|
||||||
expect(effectDev).toBeDefined()
|
|
||||||
expect(effectDev?.prompt).toContain("Problem: Reference conflicts with @effect")
|
|
||||||
expect(effectDev?.prompt).not.toContain("Cached directory:")
|
|
||||||
|
|
||||||
expect(effectFull).toBeDefined()
|
|
||||||
expect(effectFull?.mode).toBe("subagent")
|
|
||||||
expect(effectFull?.prompt).toContain("Repository: Effect-TS/effect")
|
|
||||||
expect(effectFull?.prompt).toContain("Branch/ref: main")
|
|
||||||
expect(evalPerm(effectFull, "repo_clone")).toBe("deny")
|
|
||||||
|
|
||||||
expect(local).toBeDefined()
|
|
||||||
expect(local?.mode).toBe("subagent")
|
|
||||||
expect(local?.prompt).toContain(`Local directory: ${path.resolve(tmp.path, "../docs")}`)
|
|
||||||
expect(
|
|
||||||
Permission.evaluate(
|
|
||||||
"external_directory",
|
|
||||||
path.join(path.resolve(tmp.path, "../docs"), "README.md"),
|
|
||||||
local!.permission,
|
|
||||||
).action,
|
|
||||||
).toBe("allow")
|
|
||||||
|
|
||||||
expect(localFull).toBeDefined()
|
|
||||||
expect(localFull?.mode).toBe("subagent")
|
|
||||||
expect(localFull?.prompt).toContain(`Local directory: ${path.resolve(tmp.path, "../local-docs")}`)
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NodeFileSystem } from "@effect/platform-node"
|
|||||||
import { FetchHttpClient } from "effect/unstable/http"
|
import { FetchHttpClient } from "effect/unstable/http"
|
||||||
import { expect } from "bun:test"
|
import { expect } from "bun:test"
|
||||||
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
|
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
|
||||||
|
import fs from "fs/promises"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
import { NamedError } from "@opencode-ai/core/util/error"
|
import { NamedError } from "@opencode-ai/core/util/error"
|
||||||
@@ -203,6 +204,7 @@ function makeHttp() {
|
|||||||
SessionPrompt.layer.pipe(
|
SessionPrompt.layer.pipe(
|
||||||
Layer.provide(SessionRevert.defaultLayer),
|
Layer.provide(SessionRevert.defaultLayer),
|
||||||
Layer.provide(Image.defaultLayer),
|
Layer.provide(Image.defaultLayer),
|
||||||
|
Layer.provide(Reference.defaultLayer),
|
||||||
Layer.provide(summary),
|
Layer.provide(summary),
|
||||||
Layer.provideMerge(run),
|
Layer.provideMerge(run),
|
||||||
Layer.provideMerge(compact),
|
Layer.provideMerge(compact),
|
||||||
@@ -1791,6 +1793,101 @@ it.live("keeps stored part order stable when file resolution is async", () =>
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("resolves configured reference mentions before workspace paths and agents", () =>
|
||||||
|
provideTmpdirInstance(
|
||||||
|
(dir) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const docs = path.join(dir, "external-docs")
|
||||||
|
yield* Effect.promise(() => fs.mkdir(path.join(docs, "guide"), { recursive: true }))
|
||||||
|
yield* Effect.promise(() => fs.mkdir(path.join(dir, "docs"), { recursive: true }))
|
||||||
|
yield* Effect.promise(() => Bun.write(path.join(docs, "README.md"), "reference readme"))
|
||||||
|
yield* Effect.promise(() => Bun.write(path.join(docs, "guide", "intro.md"), "reference intro"))
|
||||||
|
yield* Effect.promise(() => Bun.write(path.join(dir, "docs", "README.md"), "workspace readme"))
|
||||||
|
|
||||||
|
const prompt = yield* SessionPrompt.Service
|
||||||
|
const parts = yield* prompt.resolvePromptParts(
|
||||||
|
"Use @docs and @docs/README.md and @docs/guide and @docs/missing.md and @docs/README.md and @build",
|
||||||
|
)
|
||||||
|
const references = parts.filter(
|
||||||
|
(part): part is MessageV2.TextPartInput =>
|
||||||
|
part.type === "text" && part.synthetic === true && part.text.startsWith("Referenced configured reference "),
|
||||||
|
)
|
||||||
|
const files = parts.filter((part): part is MessageV2.FilePartInput => part.type === "file")
|
||||||
|
const agents = parts.filter((part): part is MessageV2.AgentPartInput => part.type === "agent")
|
||||||
|
const bare = references.find((part) => part.text.includes("@docs."))
|
||||||
|
const missing = references.find((part) => part.text.includes("@docs/missing.md"))
|
||||||
|
const guide = files.find((part) => part.filename === "docs/guide")
|
||||||
|
|
||||||
|
expect(references.length).toBe(2)
|
||||||
|
expect(bare?.metadata?.reference).toMatchObject({
|
||||||
|
name: "docs",
|
||||||
|
kind: "local",
|
||||||
|
path: docs,
|
||||||
|
})
|
||||||
|
expect(missing?.text).toContain("Path does not exist inside configured reference @docs")
|
||||||
|
expect(missing?.metadata?.reference).toMatchObject({
|
||||||
|
target: "missing.md",
|
||||||
|
targetPath: path.join(docs, "missing.md"),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(files.length).toBe(2)
|
||||||
|
expect(files.map((file) => fileURLToPath(file.url)).sort()).toEqual(
|
||||||
|
[path.join(docs, "README.md"), path.join(docs, "guide")].sort(),
|
||||||
|
)
|
||||||
|
expect(guide?.mime).toBe("application/x-directory")
|
||||||
|
expect(agents.map((agent) => agent.name)).toEqual(["build"])
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
git: true,
|
||||||
|
config: {
|
||||||
|
...cfg,
|
||||||
|
reference: {
|
||||||
|
docs: "./external-docs",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("injects metadata for bare configured reference mentions", () =>
|
||||||
|
provideTmpdirInstance(
|
||||||
|
(dir) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const docs = path.join(dir, "external-docs")
|
||||||
|
yield* Effect.promise(() => fs.mkdir(docs, { recursive: true }))
|
||||||
|
|
||||||
|
const prompt = yield* SessionPrompt.Service
|
||||||
|
const sessions = yield* Session.Service
|
||||||
|
const session = yield* sessions.create({})
|
||||||
|
const message = yield* prompt.prompt({
|
||||||
|
sessionID: session.id,
|
||||||
|
noReply: true,
|
||||||
|
parts: yield* prompt.resolvePromptParts("Use @docs for context"),
|
||||||
|
})
|
||||||
|
|
||||||
|
const stored = MessageV2.get({ sessionID: session.id, messageID: message.info.id })
|
||||||
|
const synthetic = stored.parts
|
||||||
|
.filter((part): part is MessageV2.TextPart => part.type === "text" && part.synthetic === true)
|
||||||
|
const reference = synthetic.find((part) => part.text.startsWith("Referenced configured reference @docs."))
|
||||||
|
|
||||||
|
expect(reference?.metadata?.reference).toMatchObject({ name: "docs", kind: "local", path: docs })
|
||||||
|
expect(synthetic.some((part) => part.text.includes(`Reference root: ${docs}`))).toBe(true)
|
||||||
|
expect(synthetic.some((part) => part.text.includes("subagent scout"))).toBe(true)
|
||||||
|
|
||||||
|
yield* sessions.remove(session.id)
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
git: true,
|
||||||
|
config: {
|
||||||
|
...cfg,
|
||||||
|
reference: {
|
||||||
|
docs: "./external-docs",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
// Special characters in filenames
|
// Special characters in filenames
|
||||||
|
|
||||||
it.live("handles filenames with # character", () =>
|
it.live("handles filenames with # character", () =>
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ function makeHttp() {
|
|||||||
SessionPrompt.layer.pipe(
|
SessionPrompt.layer.pipe(
|
||||||
Layer.provide(SessionRevert.defaultLayer),
|
Layer.provide(SessionRevert.defaultLayer),
|
||||||
Layer.provide(Image.defaultLayer),
|
Layer.provide(Image.defaultLayer),
|
||||||
|
Layer.provide(Reference.defaultLayer),
|
||||||
Layer.provide(SessionSummary.defaultLayer),
|
Layer.provide(SessionSummary.defaultLayer),
|
||||||
Layer.provideMerge(run),
|
Layer.provideMerge(run),
|
||||||
Layer.provideMerge(compact),
|
Layer.provideMerge(compact),
|
||||||
|
|||||||
@@ -771,6 +771,7 @@ export type Prompt = {
|
|||||||
text: string
|
text: string
|
||||||
files?: Array<PromptFileAttachment>
|
files?: Array<PromptFileAttachment>
|
||||||
agents?: Array<PromptAgentAttachment>
|
agents?: Array<PromptAgentAttachment>
|
||||||
|
references?: Array<PromptReferenceAttachment>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GlobalEvent = {
|
export type GlobalEvent = {
|
||||||
@@ -2718,6 +2719,18 @@ export type PromptAgentAttachment = {
|
|||||||
source?: PromptSource
|
source?: PromptSource
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PromptReferenceAttachment = {
|
||||||
|
name: string
|
||||||
|
kind: "local" | "git" | "invalid"
|
||||||
|
uri?: string
|
||||||
|
repository?: string
|
||||||
|
branch?: string
|
||||||
|
target?: string
|
||||||
|
targetUri?: string
|
||||||
|
problem?: string
|
||||||
|
source?: PromptSource
|
||||||
|
}
|
||||||
|
|
||||||
export type EventSessionNextPrompted = {
|
export type EventSessionNextPrompted = {
|
||||||
id: string
|
id: string
|
||||||
type: "session.next.prompted"
|
type: "session.next.prompted"
|
||||||
@@ -3121,6 +3134,7 @@ export type SessionMessageUser = {
|
|||||||
text: string
|
text: string
|
||||||
files?: Array<PromptFileAttachment>
|
files?: Array<PromptFileAttachment>
|
||||||
agents?: Array<PromptAgentAttachment>
|
agents?: Array<PromptAgentAttachment>
|
||||||
|
references?: Array<PromptReferenceAttachment>
|
||||||
type: "user"
|
type: "user"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user