diff --git a/packages/core/src/session/instructions.ts b/packages/core/src/session/instructions.ts index 4688c79bb3..2e20c871a8 100644 --- a/packages/core/src/session/instructions.ts +++ b/packages/core/src/session/instructions.ts @@ -20,7 +20,7 @@ export interface Interface { readonly load: (input: { readonly sessionID: SessionSchema.ID readonly paths: ReadonlyArray - }) => Effect.Effect + }) => Effect.Effect, MessageDecodeError | FSUtil.Error> } export class Service extends Context.Service()("@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 }) diff --git a/packages/core/src/tool/plugin/read.ts b/packages/core/src/tool/plugin/read.ts index 5ff8bee77f..a32ce4a4ad 100644 --- a/packages/core/src/tool/plugin/read.ts +++ b/packages/core/src/tool/plugin/read.ts @@ -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 diff --git a/packages/core/test/session-instructions.test.ts b/packages/core/test/session-instructions.test.ts index 1ccfec2068..24964d1e2f 100644 --- a/packages/core/test/session-instructions.test.ts +++ b/packages/core/test/session-instructions.test.ts @@ -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