mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-15 00:52:35 +00:00
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 { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics, type DeepMutable } from "@opencode-ai/core/schema"
|
||||
import { Reference } from "@/reference/reference"
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
name: Schema.String,
|
||||
@@ -301,76 +300,6 @@ export const layer = Layer.effect(
|
||||
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
|
||||
for (const name in agents) {
|
||||
const agent = agents[name]
|
||||
|
||||
@@ -56,7 +56,8 @@ import { EffectBridge } from "@/effect/bridge"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { SessionEvent } from "@/v2/session-event"
|
||||
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 { eq } 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 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 {
|
||||
readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
|
||||
readonly prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts>
|
||||
@@ -119,6 +159,7 @@ export const layer = Layer.effect(
|
||||
const summary = yield* SessionSummary.Service
|
||||
const sys = yield* SystemPrompt.Service
|
||||
const llm = yield* LLM.Service
|
||||
const references = yield* Reference.Service
|
||||
const sync = yield* SyncEvent.Service
|
||||
const runner = Effect.fn("SessionPrompt.runner")(function* () {
|
||||
return yield* EffectBridge.make()
|
||||
@@ -141,12 +182,116 @@ export const layer = Layer.effect(
|
||||
const parts: Types.DeepMutable<PromptInput["parts"]> = [{ type: "text", text: template }]
|
||||
const files = ConfigMarkdown.files(template)
|
||||
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(
|
||||
files,
|
||||
Effect.fnUntraced(function* (match) {
|
||||
const name = match[1]
|
||||
if (!name) return
|
||||
if (seen.has(name)) return
|
||||
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("~/")
|
||||
? path.join(os.homedir(), name.slice(2))
|
||||
: 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.synthetic) result.synthetic.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") {
|
||||
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[],
|
||||
files: [] as FileAttachment[],
|
||||
agents: [] as AgentAttachment[],
|
||||
references: [] as ReferenceAttachment[],
|
||||
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"),
|
||||
files: nextPrompt.files,
|
||||
agents: nextPrompt.agents,
|
||||
references: nextPrompt.references,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1817,6 +1984,7 @@ export const defaultLayer = Layer.suspend(() =>
|
||||
Agent.defaultLayer,
|
||||
SystemPrompt.defaultLayer,
|
||||
LLM.defaultLayer,
|
||||
Reference.defaultLayer,
|
||||
Bus.layer,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
SyncEvent.defaultLayer,
|
||||
|
||||
@@ -123,6 +123,7 @@ export function update<Result>(adapter: Adapter<Result>, event: SessionEvent.Eve
|
||||
text: event.data.prompt.text,
|
||||
files: event.data.prompt.files,
|
||||
agents: event.data.prompt.agents,
|
||||
references: event.data.prompt.references,
|
||||
time: { created: event.data.timestamp },
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -34,6 +34,7 @@ export class User extends Schema.Class<User>("Session.Message.User")({
|
||||
text: Prompt.fields.text,
|
||||
files: Prompt.fields.files,
|
||||
agents: Prompt.fields.agents,
|
||||
references: Prompt.fields.references,
|
||||
type: Schema.Literal("user"),
|
||||
time: Schema.Struct({
|
||||
created: V2Schema.DateTimeUtcFromMillis,
|
||||
|
||||
@@ -29,8 +29,21 @@ export class AgentAttachment extends Schema.Class<AgentAttachment>("Prompt.Agent
|
||||
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")({
|
||||
text: Schema.String,
|
||||
files: Schema.Array(FileAttachment).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 using tmp = await tmpdir({
|
||||
config: {
|
||||
reference: {
|
||||
effect: "github.com/effect/effect-smol",
|
||||
effectDev: {
|
||||
repository: "https://github.com/effect/effect-smol",
|
||||
branch: "dev",
|
||||
},
|
||||
effectFull: {
|
||||
repository: "Effect-TS/effect",
|
||||
branch: "main",
|
||||
@@ -165,45 +161,13 @@ test("reference config creates scout-backed subagents", async () => {
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const effect = await load(tmp.path, (svc) => svc.get("effect"))
|
||||
const effectDev = await load(tmp.path, (svc) => svc.get("effectDev"))
|
||||
const effectFull = await load(tmp.path, (svc) => svc.get("effectFull"))
|
||||
const local = await load(tmp.path, (svc) => svc.get("localdocs"))
|
||||
const localFull = await load(tmp.path, (svc) => svc.get("localdocsFull"))
|
||||
|
||||
expect(effect).toBeDefined()
|
||||
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")}`)
|
||||
const agents = await load(tmp.path, (svc) => svc.list())
|
||||
const names = agents.map((agent) => agent.name)
|
||||
expect(names).toContain("scout")
|
||||
expect(names).not.toContain("effect")
|
||||
expect(names).not.toContain("effectFull")
|
||||
expect(names).not.toContain("localdocs")
|
||||
expect(names).not.toContain("localdocsFull")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { expect } from "bun:test"
|
||||
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
@@ -203,6 +204,7 @@ function makeHttp() {
|
||||
SessionPrompt.layer.pipe(
|
||||
Layer.provide(SessionRevert.defaultLayer),
|
||||
Layer.provide(Image.defaultLayer),
|
||||
Layer.provide(Reference.defaultLayer),
|
||||
Layer.provide(summary),
|
||||
Layer.provideMerge(run),
|
||||
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
|
||||
|
||||
it.live("handles filenames with # character", () =>
|
||||
|
||||
@@ -154,6 +154,7 @@ function makeHttp() {
|
||||
SessionPrompt.layer.pipe(
|
||||
Layer.provide(SessionRevert.defaultLayer),
|
||||
Layer.provide(Image.defaultLayer),
|
||||
Layer.provide(Reference.defaultLayer),
|
||||
Layer.provide(SessionSummary.defaultLayer),
|
||||
Layer.provideMerge(run),
|
||||
Layer.provideMerge(compact),
|
||||
|
||||
@@ -771,6 +771,7 @@ export type Prompt = {
|
||||
text: string
|
||||
files?: Array<PromptFileAttachment>
|
||||
agents?: Array<PromptAgentAttachment>
|
||||
references?: Array<PromptReferenceAttachment>
|
||||
}
|
||||
|
||||
export type GlobalEvent = {
|
||||
@@ -2718,6 +2719,18 @@ export type PromptAgentAttachment = {
|
||||
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 = {
|
||||
id: string
|
||||
type: "session.next.prompted"
|
||||
@@ -3121,6 +3134,7 @@ export type SessionMessageUser = {
|
||||
text: string
|
||||
files?: Array<PromptFileAttachment>
|
||||
agents?: Array<PromptAgentAttachment>
|
||||
references?: Array<PromptReferenceAttachment>
|
||||
type: "user"
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user