diff --git a/.changeset/nested-instruction-reinjection.md b/.changeset/nested-instruction-reinjection.md new file mode 100644 index 000000000000..fc40fbb45884 --- /dev/null +++ b/.changeset/nested-instruction-reinjection.md @@ -0,0 +1,5 @@ +--- +"@opencode-ai/core": patch +--- + +Nested AGENTS.md instructions are re-injected after compaction. Previously the in-memory dedup claim outlived the synthetic message that compaction dropped from model-visible history, so nested instructions were silently lost for the rest of the process lifetime. The claim now only guards in-flight loads; the synthetic message metadata in durable history is the sole lasting ledger, so any history truncation (compaction, revert) self-heals on the next read in that subtree. diff --git a/packages/core/src/session/instructions.ts b/packages/core/src/session/instructions.ts index 15ba72d7a58c..4a51988bec80 100644 --- a/packages/core/src/session/instructions.ts +++ b/packages/core/src/session/instructions.ts @@ -37,15 +37,17 @@ const layer = Layer.effect( // root so opening a subdirectory still describes paths from the project root. const root = yield* fs.resolve(location.project.directory) // Same-step parallel reads settle concurrently, so an in-memory claim guards each - // Session/path pair before any filesystem work. The durable history check below covers - // paths injected in earlier steps after this Location layer was reopened. - const injected = yield* Ref.make>>(new Map()) + // Session/path pair while a load is in flight. The claim is released once the load + // settles: the synthetic message metadata scanned below is the only lasting ledger, + // so paths whose synthetics drop out of model-visible history (compaction, revert) + // are re-discovered and re-injected instead of staying silently lost. + const inFlight = yield* Ref.make>>(new Map()) const load = Effect.fn("SessionInstructions.load")(function* (input: { readonly sessionID: SessionSchema.ID readonly paths: ReadonlyArray }) { - const claimed = yield* Ref.modify(injected, (map) => { + const claimed = yield* Ref.modify(inFlight, (map) => { const existing = map.get(input.sessionID) ?? new Set() const newlyClaimed = input.paths.filter((path) => !existing.has(path)) if (newlyClaimed.length === 0) return [newlyClaimed, map] @@ -54,30 +56,43 @@ const layer = Layer.effect( return [newlyClaimed, next] }) 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 - const files = yield* Effect.forEach( - toInject, - (path) => - fs - .readFileStringSafe(path) - .pipe(Effect.map((content) => (content === undefined ? undefined : { path, content }))), - { concurrency: "unbounded" }, + yield* Effect.gen(function* () { + const alreadyInjected = yield* previouslyInjected(store, input.sessionID) + const toInject = claimed.filter((path) => !alreadyInjected.has(path)) + if (toInject.length === 0) return + const files = yield* Effect.forEach( + toInject, + (path) => + fs + .readFileStringSafe(path) + .pipe(Effect.map((content) => (content === undefined ? undefined : { path, content }))), + { concurrency: "unbounded" }, + ) + const readable = files.filter((file): file is { path: string; content: string } => file !== undefined) + if (readable.length === 0) return + // 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 commits the synthetic and its metadata + // ledger atomically, so releasing the claim afterwards cannot readmit the paths. + yield* bus.publish(SessionEvent.Synthetic, { + 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) } }, + }) + }).pipe( + Effect.ensuring( + Ref.update(inFlight, (map) => { + const existing = map.get(input.sessionID) + if (!existing) return map + const remaining = new Set([...existing].filter((path) => !claimed.includes(path))) + const next = new Map(map) + if (remaining.size === 0) next.delete(input.sessionID) + else next.set(input.sessionID, remaining) + return next + }), + ), ) - const readable = files.filter((file): file is { path: string; content: string } => file !== undefined) - if (readable.length === 0) return - // 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 - // the next projected history reload. The dedup ledger lives on the synthetic message - // metadata so it survives across Location layer restarts. - yield* bus.publish(SessionEvent.Synthetic, { - 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) } }, - }) }) return Service.of({ load }) diff --git a/packages/core/test/session-instructions.test.ts b/packages/core/test/session-instructions.test.ts index e17204e1940d..3a77cdd369dd 100644 --- a/packages/core/test/session-instructions.test.ts +++ b/packages/core/test/session-instructions.test.ts @@ -233,6 +233,37 @@ describe("SessionInstructions", () => { }), ) + it.effect("re-injects nested instructions dropped from history by compaction", () => + Effect.gen(function* () { + const location = yield* Location.Service + const dir = location.directory + const subPath = path.resolve(dir, "sub", "AGENTS.md") + yield* mkdir(path.resolve(dir, "sub")) + yield* writeAgents(path.resolve(dir, "AGENTS.md"), "root-instructions") + yield* writeAgents(subPath, "sub-instructions") + yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "sub", "file.txt"), "content")) + + const session = yield* Session.Service + const registry = yield* Tool.Service + const bus = yield* Bus.Service + const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id + + yield* executeTool(registry, readCall(sessionID, "call-before", "sub/file.txt")) + expect(yield* synthetics(sessionID)).toHaveLength(1) + + // A completed compaction truncates model-visible history at its boundary, dropping + // the synthetic that carried sub's instructions. + yield* bus.publish(SessionEvent.Compaction.Started, { sessionID, reason: "manual", recent: "" }) + yield* bus.publish(SessionEvent.Compaction.Ended, { sessionID, reason: "manual", text: "summary", recent: "" }) + expect(yield* synthetics(sessionID)).toHaveLength(0) + + // The model no longer has the rules, so the next read under the subtree must + // re-inject them rather than trusting a stale in-memory claim. + yield* executeTool(registry, readCall(sessionID, "call-after", "sub/file.txt")) + expect(yield* synthetics(sessionID)).toHaveLength(1) + }), + ) + it.effect("listing the Location root directory injects no instructions", () => Effect.gen(function* () { const location = yield* Location.Service