Compare commits

...

1 Commits

Author SHA1 Message Date
Aiden Cline 0ed6a9c1a9 fix(core): deduplicate direct instruction reads 2026-07-28 02:11:07 +00:00
3 changed files with 43 additions and 15 deletions
+7 -5
View File
@@ -20,7 +20,7 @@ export interface Interface {
readonly load: (input: {
readonly sessionID: SessionSchema.ID
readonly paths: ReadonlyArray<string>
}) => Effect.Effect<void, MessageDecodeError | FSUtil.Error>
}) => Effect.Effect<ReadonlyArray<string>, MessageDecodeError | FSUtil.Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionInstructions") {}
@@ -53,10 +53,10 @@ const layer = Layer.effect(
next.set(input.sessionID, new Set([...existing, ...newlyClaimed]))
return [newlyClaimed, next]
})
if (claimed.length === 0) return
if (claimed.length === 0) return []
const alreadyInjected = yield* previouslyInjected(store, input.sessionID)
const toInject = claimed.filter((path) => !alreadyInjected.has(path))
if (toInject.length === 0) return
if (toInject.length === 0) return []
const files = yield* Effect.forEach(
toInject,
(path) =>
@@ -66,7 +66,8 @@ const layer = Layer.effect(
{ concurrency: "unbounded" },
)
const readable = files.filter((file): file is { path: string; content: string } => file !== undefined)
if (readable.length === 0) return
if (readable.length === 0) return []
const paths = readable.map((file) => file.path)
// Publish directly rather than through Session.synthetic: a Location-scoped layer
// cannot depend on Session (it routes through LocationServiceMap, forming a type
// cycle with this node). The durable publish is what makes the synthetic visible on
@@ -76,8 +77,9 @@ const layer = Layer.effect(
sessionID: input.sessionID,
text: readable.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n"),
description: `Loaded ${readable.map((file) => describePath(root, file.path)).join(", ")}`,
metadata: { instruction: { paths: readable.map((file) => file.path) } },
metadata: { instruction: { paths } },
})
return paths
})
return Service.of({ load })
+11 -10
View File
@@ -92,8 +92,8 @@ export const Plugin = {
// directory listing the walk starts at the directory itself (so its own AGENTS.md
// is discovered); for a file it starts at the file's dirname. External reads are
// skipped, and discovery failures never fail the read.
yield* Effect.gen(function* () {
if (target.externalDirectory !== undefined) return
const instructions = yield* Effect.gen(function* () {
if (target.externalDirectory !== undefined) return [] as string[]
const resolved = yield* fs.resolve(target.canonical)
const root = yield* fs.resolve(location.directory)
// up() searches its stop directory, so the Location-root AGENTS.md (already
@@ -106,19 +106,20 @@ export const Plugin = {
const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter(
(file) => dirname(file) !== root,
)
if (candidates.length === 0) return
yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates })
return yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates })
}).pipe(
Effect.catch(() => Effect.void),
Effect.catchDefect(() => Effect.void),
Effect.catch(() => Effect.succeed([] as string[])),
Effect.catchDefect(() => Effect.succeed([] as string[])),
)
if (content.type === "file" && content.encoding === "base64" && !SUPPORTED_MEDIA_MIMES.has(content.mime))
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource }))
return content
return { output: content, instructions, absolute }
}).pipe(
Effect.map((output) => ({
output,
content: toModelContent(input.path, input.offset, output),
Effect.map((result) => ({
output: result.output,
content: result.instructions.includes(result.absolute)
? `Read ${input.path}; its full contents are loaded as session instructions.`
: toModelContent(input.path, input.offset, result.output),
})),
Effect.mapError((error) => {
if (error instanceof ToolFailure) return error
@@ -272,6 +272,31 @@ describe("SessionInstructions", () => {
}),
)
it.effect("returns a receipt when a direct AGENTS.md read injects the same content", () =>
Effect.gen(function* () {
const location = yield* Location.Service
const dir = location.directory
const subPath = path.resolve(dir, "sub", "AGENTS.md")
yield* mkdir(path.dirname(subPath))
yield* writeAgents(subPath, "sub-instructions")
const session = yield* Session.Service
const registry = yield* Tool.Service
const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
const result = yield* executeTool(registry, readCall(sessionID, "call-agents", "sub/AGENTS.md"))
expect(result.status).toBe("completed")
if (result.status !== "completed") return
expect(result.output.content).toBe("sub-instructions")
expect(result.content).toEqual([
{ type: "text", text: "Read sub/AGENTS.md; its full contents are loaded as session instructions." },
])
expect((yield* synthetics(sessionID)).map((message) => message.text)).toEqual([
`Instructions from: ${subPath}\nsub-instructions`,
])
}),
)
it.effect("loads instructions directly without a read", () =>
Effect.gen(function* () {
const location = yield* Location.Service