From 0726f09b05f89602b97638a8fc5ddf38ba2e1efa Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 6 Jul 2026 14:22:53 -0400 Subject: [PATCH] feat(core): make path-local instruction discovery durable Replace synthetic-message instruction injection with a durable discovery projection so discovered AGENTS.md files survive compaction, forks, reverts, and restarts. - Add the instruction_file table and the durable session.instructions.discovered event; projection stores each discovered path and content with its owning assistant-message boundary and durable discovery order. - Fold discovered files into the core/instructions source through InstructionDiscovery, absorbing SessionInstructions. Completed compaction rebaselines restate discovered instructions instead of summarizing them away. - Re-read discovered files live at each observation so mid-session edits reach the model; the frozen discovery content stands in only when a file becomes unreadable. - Narrate instruction file changes as per-file deltas via diffByKey, restating the full set only for pure reorderings. - Fork copies discoveries within the inherited transcript, committed revert removes discoveries past the revert boundary, and Session movement clears them so the destination initializes a complete baseline. --- AGENTS.md | 2 +- CONTEXT.md | 9 +- .../client/src/promise/generated/types.ts | 28 ++ packages/core/schema.json | 95 +++++- packages/core/src/database/migration.gen.ts | 1 + .../20260706181957_add_instruction_file.ts | 22 ++ packages/core/src/database/schema.gen.ts | 12 + packages/core/src/instruction-discovery.ts | 149 ++++++-- packages/core/src/location-services.ts | 4 +- packages/core/src/plugin/internal.ts | 6 +- packages/core/src/session/instructions.ts | 113 ------- packages/core/src/session/projector.ts | 108 +++++- packages/core/src/session/runner/llm.ts | 2 +- packages/core/src/session/sql.ts | 17 + packages/core/src/tool/read.ts | 29 +- packages/core/test/database-migration.test.ts | 16 + .../core/test/instruction-discovery.test.ts | 231 ++++++++++++- .../core/test/session-instructions.test.ts | 319 ------------------ packages/core/test/session-projector.test.ts | 223 ++++++++++++ .../core/test/session-runner-recorded.test.ts | 14 +- packages/core/test/session-runner.test.ts | 197 +++++++---- packages/core/test/tool-read.test.ts | 17 +- packages/schema/src/session-event.ts | 13 + packages/schema/test/event-manifest.test.ts | 2 +- packages/sdk/js/script/build.ts | 4 +- packages/sdk/js/src/v2/gen/types.gen.ts | 101 ++++++ specs/v2/schema-changelog.md | 20 +- specs/v2/session.md | 10 +- 28 files changed, 1166 insertions(+), 598 deletions(-) create mode 100644 packages/core/src/database/migration/20260706181957_add_instruction_file.ts delete mode 100644 packages/core/src/session/instructions.ts delete mode 100644 packages/core/test/session-instructions.test.ts diff --git a/AGENTS.md b/AGENTS.md index c89653ba95ad..546c023f1d19 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -161,5 +161,5 @@ const table = sqliteTable("session", { - Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe step boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's step allowance; a batch of steers resets it once. - One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle. - Keep EventV2 replay owner claims separate from clustered Session execution ownership. -- Keep the Instructions algebra and built-ins in `src/instructions`; keep instruction producers with their observed domains, and keep Session History selection plus `InstructionCheckpoint` and `InstructionEntry` persistence Session-owned. `InstructionDiscovery` observes ambient global and upward-project instructions. The runner composes built-ins, discovery, guidance, and entries explicitly in `loadInstructions`; there is no instruction registry. +- Keep the Instructions algebra and built-ins in `src/instructions`; keep instruction producers with their observed domains, and keep Session History selection plus `InstructionCheckpoint` and `InstructionEntry` persistence Session-owned. `InstructionDiscovery` combines ambient global/upward-project instructions with durable path-local instructions discovered after successful reads. The runner composes built-ins, discovery, guidance, and entries explicitly in `loadInstructions`; there is no instruction registry. - The durable `Instructions.Applied` record is what the model was last told, per instruction source. Reconciliation narrates drift through `session.instructions.updated` and never rewrites the baseline; only completed compaction rebaselines, while Session movement or committed revert resets the `InstructionCheckpoint`. Unavailable sources keep the model's prior belief, blocking only a Session's first instruction baseline. diff --git a/CONTEXT.md b/CONTEXT.md index ce880380d199..24b8d907fa65 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -24,7 +24,7 @@ _Avoid_: Prompt fragment One API-managed, durable, per-Session instruction value. Its slash-free client key maps to the `api/` **Instruction Source** key. Entries deliberately render to the model as mechanism-neutral `` blocks: the model sees session context, not how it was attached. **InstructionDiscovery**: -The Location-scoped service that observes ambient global and upward-project `AGENTS.md` files as one ordered aggregate **Instruction Source**. +The Location-scoped service that combines ambient global and upward-project `AGENTS.md` files with durable per-Session path-local `AGENTS.md` files discovered after successful reads. **InstructionCheckpoint**: The Session-owned durable instruction baseline, baseline sequence, and `Instructions.Applied` record used to prepare later Steps. @@ -135,12 +135,13 @@ _Avoid_: Response envelope - Completed compaction rebaselines the **InstructionCheckpoint** from current **Instructions** and removes earlier **Instruction Updates** from active projected model history while preserving durable audit history. - A newly composed **Instruction Source** absent from **Applied Instructions** emits its baseline rendering once at the next **Safe Step Boundary**. - **Unavailable Instruction Source** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text. -- **InstructionDiscovery** observes ambient instructions as one ordered aggregate **Instruction Source**. +- **InstructionDiscovery** combines ambient instructions with durable path-local instructions as one ordered aggregate **Instruction Source**. - Ambient discovery reads global and upward-project `AGENTS.md` files and honors `OPENCODE_DISABLE_PROJECT_CONFIG` for project files. -- After a successful internal file or directory read, nearby `AGENTS.md` files toward the Location root are injected once per Session as durable synthetic instruction messages. +- After a successful internal file or directory read, path-local discovery searches nearby `AGENTS.md` files toward the Location root and publishes `session.instructions.discovered` with the owning assistant message and Location. Projection freezes newly readable files per Session, and the next **Safe Step Boundary** admits the combined instruction change. +- Ambient files precede path-local files, duplicate paths are removed, and path-local files retain durable admission and discovery order. - **InstructionEntry** stores API-managed per-Session JSON values. Each entry contributes one `api/` **Instruction Source**, so adding, replacing, or removing an entry is reconciled at the next **Safe Step Boundary**. - Location-scoped instruction producers naturally re-resolve when a moved Session next runs in its destination Location. -- Moving a Session resets its **InstructionCheckpoint**, so the destination must initialize a complete baseline before another prompt can promote. Committed revert also resets the checkpoint. +- Moving a Session resets its **InstructionCheckpoint** and path-local discoveries, so the destination must initialize a complete baseline before another prompt can promote. Committed revert also resets the checkpoint and removes path-local discoveries admitted after the revert boundary. - Selected-agent available-skill guidance is an **Instruction Source** composed explicitly by the runner. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool. - The selected agent and model are sampled when a **Step** starts. Changes admitted after that boundary apply to the next Step and do not restart the current Step. - An agent switch that changes selected-agent guidance produces an **Instruction Update** while preserving the current baseline. diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 5b3d6c165fe9..1d8233cd8dfa 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -1222,6 +1222,20 @@ export type SessionLogOutput = readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly text: string } } + | { + readonly id: string + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.instructions.discovered" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly sessionID: string + readonly assistantMessageID: string + readonly location: { readonly directory: string; readonly workspaceID?: string } + readonly files: ReadonlyArray<{ readonly path: string; readonly content: string }> + } + } | { readonly id: string readonly created: number @@ -4473,6 +4487,20 @@ export type EventSubscribeOutput = readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly text: string } } + | { + readonly id: string + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.instructions.discovered" + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly sessionID: string + readonly assistantMessageID: string + readonly location: { readonly directory: string; readonly workspaceID?: string } + readonly files: ReadonlyArray<{ readonly path: string; readonly content: string }> + } + } | { readonly id: string readonly created: number diff --git a/packages/core/schema.json b/packages/core/schema.json index 222cc7b39928..7a835f304b1f 100644 --- a/packages/core/schema.json +++ b/packages/core/schema.json @@ -1,9 +1,9 @@ { "version": "7", "dialect": "sqlite", - "id": "992b24b9-f3e9-41f5-87a5-4917d1423169", + "id": "4689ab8e-76e3-429e-832c-403de6b7de57", "prevIds": [ - "96e9fe64-660f-4a73-9414-b38bb7eac290" + "992b24b9-f3e9-41f5-87a5-4917d1423169" ], "ddl": [ { @@ -58,6 +58,10 @@ "name": "instruction_entry", "entityType": "tables" }, + { + "name": "instruction_file", + "entityType": "tables" + }, { "name": "message", "entityType": "tables" @@ -876,6 +880,66 @@ "entityType": "columns", "table": "instruction_entry" }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "instruction_file" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "path", + "entityType": "columns", + "table": "instruction_file" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "instruction_file" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_seq", + "entityType": "columns", + "table": "instruction_file" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "discovered_seq", + "entityType": "columns", + "table": "instruction_file" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "instruction_file" + }, { "type": "text", "notNull": false, @@ -1651,6 +1715,21 @@ "entityType": "fks", "table": "instruction_entry" }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_instruction_file_session_id_session_id_fk", + "entityType": "fks", + "table": "instruction_file" + }, { "columns": [ "session_id" @@ -1786,6 +1865,16 @@ "entityType": "pks", "table": "instruction_entry" }, + { + "columns": [ + "session_id", + "path" + ], + "nameExplicit": false, + "name": "instruction_file_pk", + "entityType": "pks", + "table": "instruction_file" + }, { "columns": [ "session_id", @@ -2252,4 +2341,4 @@ } ], "renames": [] -} +} \ No newline at end of file diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 9d3e4e673f99..6e88aec4cfdc 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -45,5 +45,6 @@ export const migrations = ( import("./migration/20260703181610_event_created_column"), import("./migration/20260703190000_reset_v2_shell_event_payloads"), import("./migration/20260705180000_rename_instructions"), + import("./migration/20260706181957_add_instruction_file"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260706181957_add_instruction_file.ts b/packages/core/src/database/migration/20260706181957_add_instruction_file.ts new file mode 100644 index 000000000000..4ad6d4c60a76 --- /dev/null +++ b/packages/core/src/database/migration/20260706181957_add_instruction_file.ts @@ -0,0 +1,22 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260706181957_add_instruction_file", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE \`instruction_file\` ( + \`session_id\` text NOT NULL, + \`path\` text NOT NULL, + \`content\` text NOT NULL, + \`message_seq\` integer NOT NULL, + \`discovered_seq\` integer NOT NULL, + \`position\` integer NOT NULL, + CONSTRAINT \`instruction_file_pk\` PRIMARY KEY(\`session_id\`, \`path\`), + CONSTRAINT \`fk_instruction_file_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE + ); + `) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts index dfa24f9b9444..bcfe76d62f22 100644 --- a/packages/core/src/database/schema.gen.ts +++ b/packages/core/src/database/schema.gen.ts @@ -145,6 +145,18 @@ export default { CONSTRAINT \`fk_instruction_entry_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE ); `) + yield* tx.run(` + CREATE TABLE \`instruction_file\` ( + \`session_id\` text NOT NULL, + \`path\` text NOT NULL, + \`content\` text NOT NULL, + \`message_seq\` integer NOT NULL, + \`discovered_seq\` integer NOT NULL, + \`position\` integer NOT NULL, + CONSTRAINT \`instruction_file_pk\` PRIMARY KEY(\`session_id\`, \`path\`), + CONSTRAINT \`fk_instruction_file_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE + ); + `) yield* tx.run(` CREATE TABLE \`message\` ( \`id\` text PRIMARY KEY, diff --git a/packages/core/src/instruction-discovery.ts b/packages/core/src/instruction-discovery.ts index 0ac7b8a12d62..6c29e68836ea 100644 --- a/packages/core/src/instruction-discovery.ts +++ b/packages/core/src/instruction-discovery.ts @@ -1,14 +1,21 @@ export * as InstructionDiscovery from "./instruction-discovery" -import { Array, Context, Effect, Layer, Schema } from "effect" +import { asc, eq } from "drizzle-orm" +import { Array, Context, Effect, Layer, Schema, Semaphore } from "effect" import { isAbsolute, join, relative, sep } from "path" +import { Database } from "./database/database" +import { makeLocationNode } from "./effect/app-node" +import { EventV2 } from "./event" import { FSUtil } from "./fs-util" import { Flag } from "./flag/flag" import { Global } from "./global" +import { Instructions } from "./instructions/index" import { Location } from "./location" import { AbsolutePath } from "./schema" -import { Instructions } from "./instructions/index" -import { makeLocationNode } from "./effect/app-node" +import { SessionSchema } from "./session/schema" +import { InstructionFileTable } from "./session/sql" +import { SessionEvent } from "./session/event" +import { SessionMessage } from "./session/message" class File extends Schema.Class("InstructionDiscovery.File")({ path: AbsolutePath, @@ -19,7 +26,12 @@ const Files = Schema.Array(File) const key = Instructions.Key.make("core/instructions") export interface Interface { - readonly load: () => Effect.Effect + readonly load: (sessionID: SessionSchema.ID) => Effect.Effect + readonly discover: (input: { + readonly sessionID: SessionSchema.ID + readonly assistantMessageID: SessionMessage.ID + readonly paths: ReadonlyArray + }) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/InstructionDiscovery") {} @@ -27,9 +39,12 @@ export class Service extends Context.Service()("@opencode/v2 const layer = Layer.effect( Service, Effect.gen(function* () { + const { db } = yield* Database.Service + const events = yield* EventV2.Service const fs = yield* FSUtil.Service const global = yield* Global.Service const location = yield* Location.Service + const lock = Semaphore.makeUnsafe(1) const source = (value: ReadonlyArray | Instructions.Unavailable) => Instructions.make({ @@ -37,12 +52,11 @@ const layer = Layer.effect( codec: Schema.toCodecJson(Files), load: Effect.succeed(value), baseline: render, - update: (_previous, current) => - `These instructions replace all previously loaded ambient instructions.\n\n${render(current)}`, + update, removed: () => "Previously loaded instructions no longer apply.", }) - const observe = Effect.fn("InstructionDiscovery.observe")(function* () { + const observeAmbient = Effect.fn("InstructionDiscovery.observeAmbient")(function* () { const start = yield* fs.resolve(location.directory) const stop = yield* fs.resolve(location.project.directory) const fromProject = relative(stop, start) @@ -78,25 +92,116 @@ const layer = Layer.effect( return files.filter((file): file is File => file !== undefined) }) - return Service.of({ - load: () => - observe().pipe( - Effect.map((files) => - files === Instructions.unavailable - ? source(files) - : files.length === 0 - ? Instructions.empty - : source(files), - ), - Effect.catch(() => Effect.succeed(source(Instructions.unavailable))), - Effect.catchDefect(() => Effect.succeed(source(Instructions.unavailable))), - ), + const observe = Effect.fnUntraced(function* (sessionID: SessionSchema.ID) { + const ambient = yield* observeAmbient() + if (ambient === Instructions.unavailable) return source(ambient) + const stored = yield* db + .select({ path: InstructionFileTable.path, content: InstructionFileTable.content }) + .from(InstructionFileTable) + .where(eq(InstructionFileTable.session_id, sessionID)) + .orderBy( + asc(InstructionFileTable.discovered_seq), + asc(InstructionFileTable.position), + asc(InstructionFileTable.path), + ) + .all() + .pipe(Effect.orDie) + const seen = new Set(ambient.map((file) => file.path)) + // Discovered files are re-observed live so mid-session edits reach the model; + // the frozen discovery content only stands in when the file cannot be read. + const discovered = yield* Effect.forEach( + stored.filter((file) => !seen.has(file.path)), + (file) => + fs + .readFileStringSafe(file.path) + .pipe(Effect.map((content) => new File({ path: file.path, content: content ?? file.content }))), + { concurrency: "unbounded" }, + ) + const files = [...ambient, ...discovered] + return files.length === 0 ? Instructions.empty : source(files) }) + + const load = Effect.fn("InstructionDiscovery.load")(function* (sessionID: SessionSchema.ID) { + return yield* observe(sessionID).pipe(Effect.catch(() => Effect.succeed(source(Instructions.unavailable)))) + }) + + const admit = Effect.fnUntraced(function* (input: { + readonly sessionID: SessionSchema.ID + readonly assistantMessageID: SessionMessage.ID + readonly paths: ReadonlyArray + }) { + const paths = Array.dedupe(yield* Effect.forEach(input.paths, fs.resolve)) + if (paths.length === 0) return + const existing = new Set( + (yield* db + .select({ path: InstructionFileTable.path }) + .from(InstructionFileTable) + .where(eq(InstructionFileTable.session_id, input.sessionID)) + .all() + .pipe(Effect.orDie)).map((row) => row.path), + ) + const files = yield* Effect.forEach( + paths.filter((path) => !existing.has(AbsolutePath.make(path))), + (path) => + fs + .readFileStringSafe(path) + .pipe( + Effect.map((content) => (content === undefined ? undefined : { path: AbsolutePath.make(path), content })), + ), + { concurrency: "unbounded" }, + ) + const readable = files.filter((file): file is { path: AbsolutePath; content: string } => file !== undefined) + if (readable.length === 0) return + yield* events.publish(SessionEvent.InstructionsDiscovered, { + sessionID: input.sessionID, + assistantMessageID: input.assistantMessageID, + location: Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }), + files: readable, + }) + }) + + const discover = Effect.fn("InstructionDiscovery.discover")(function* (input: Parameters[0]) { + yield* lock.withPermit(admit(input)) + }) + + return Service.of({ load, discover }) }), ) -export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Global.node, Location.node] }) +export const node = makeLocationNode({ + service: Service, + layer, + deps: [Database.node, EventV2.node, FSUtil.node, Global.node, Location.node], +}) function render(files: ReadonlyArray) { - return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n") + return files.map(renderFile).join("\n\n") +} + +function renderFile(file: File) { + return `Instructions from: ${file.path}\n${file.content}` +} + +// Per-file deltas keep chronological updates small as discoveries accumulate. A +// pure reordering has no per-file story to tell, so it restates the full set. +function update(previous: ReadonlyArray, current: ReadonlyArray) { + const diff = Instructions.diffByKey( + previous, + current, + (file) => file.path, + (before, after) => before.content !== after.content, + ) + if (diff.added.length === 0 && diff.removed.length === 0 && diff.changed.length === 0) + return ["These instructions replace all previously loaded instructions.", render(current)].join("\n\n") + return [ + ...diff.added.map(renderFile), + ...diff.changed.map( + (change) => `The instructions from ${change.current.path} changed to:\n${change.current.content}`, + ), + ...(diff.removed.length === 0 + ? [] + : [ + `Instructions from the following files no longer apply: ${diff.removed.map((file) => file.path).join(", ")}.`, + ]), + ].join("\n\n") } diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index fe2301651f7d..e948a786f8dc 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -47,7 +47,6 @@ import { Snapshot } from "./snapshot" import { InstructionDiscovery } from "./instruction-discovery" import { InstructionBuiltIns } from "./instructions/builtins" import { InstructionEntry } from "./session/instruction-entry" -import { SessionInstructions } from "./session/instructions" import { McpTool } from "./tool/mcp" import { ReadToolFileSystem } from "./tool/read-filesystem" import { ToolRegistry } from "./tool/registry" @@ -85,7 +84,7 @@ const pluginSupervisorNode = makeLocationNode({ ReadToolFileSystem.node, Reference.node, Ripgrep.node, - SessionInstructions.node, + InstructionDiscovery.node, SessionTodo.node, Shell.node, SkillV2.node, @@ -131,7 +130,6 @@ const locationServiceNodes = [ Generate.node, ReadToolFileSystem.node, McpTool.node, - SessionInstructions.node, SessionRunnerModel.node, SessionCompaction.node, SessionTitle.node, diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 2789cc3c2e5b..6ad1f463a474 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -27,7 +27,7 @@ import { Npm } from "../npm" import { PermissionV2 } from "../permission" import { Reference } from "../reference" import { Ripgrep } from "../ripgrep" -import { SessionInstructions } from "../session/instructions" +import { InstructionDiscovery } from "../instruction-discovery" import { SessionTodo } from "../session/todo" import { Shell } from "../shell" import { SkillV2 } from "../skill" @@ -77,7 +77,7 @@ const services = Effect.fn("PluginInternal.services")(function* () { const read = yield* ReadToolFileSystem.Service const reference = yield* Reference.Service const ripgrep = yield* Ripgrep.Service - const instructions = yield* SessionInstructions.Service + const discovery = yield* InstructionDiscovery.Service const todo = yield* SessionTodo.Service const shell = yield* Shell.Service const skill = yield* SkillV2.Service @@ -106,7 +106,7 @@ const services = Effect.fn("PluginInternal.services")(function* () { Context.make(ReadToolFileSystem.Service, read), Context.make(Reference.Service, reference), Context.make(Ripgrep.Service, ripgrep), - Context.make(SessionInstructions.Service, instructions), + Context.make(InstructionDiscovery.Service, discovery), Context.make(SessionTodo.Service, todo), Context.make(Shell.Service, shell), Context.make(SkillV2.Service, skill), diff --git a/packages/core/src/session/instructions.ts b/packages/core/src/session/instructions.ts deleted file mode 100644 index 22f003e5eafb..000000000000 --- a/packages/core/src/session/instructions.ts +++ /dev/null @@ -1,113 +0,0 @@ -export * as SessionInstructions from "./instructions" - -import { relative } from "path" -import { Context, DateTime, Effect, Layer, Option, Ref, Schema } from "effect" -import { makeLocationNode } from "../effect/app-node" -import { EventV2 } from "../event" -import { FSUtil } from "../fs-util" -import { Location } from "../location" -import { SessionEvent } from "./event" -import { MessageDecodeError } from "./error" -import { SessionMessage } from "./message" -import { SessionSchema } from "./schema" -import { SessionStore } from "./store" - -const InjectedMetadata = Schema.Struct({ - instruction: Schema.Struct({ paths: Schema.Array(Schema.String) }), -}) - -export interface Interface { - readonly load: (input: { - readonly sessionID: SessionSchema.ID - readonly paths: ReadonlyArray - }) => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/v2/SessionInstructions") {} - -const layer = Layer.effect( - Service, - Effect.gen(function* () { - const events = yield* EventV2.Service - const fs = yield* FSUtil.Service - const store = yield* SessionStore.Service - const location = yield* Location.Service - // Resolved once for the Location layer; the synthetic text and dedup ledger keep - // absolute paths, but the human-facing description shows paths relative to the project - // 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()) - - const load = Effect.fn("SessionInstructions.load")(function* (input: { - readonly sessionID: SessionSchema.ID - readonly paths: ReadonlyArray - }) { - const claimed = yield* Ref.modify(injected, (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] - const next = new Map(map) - next.set(input.sessionID, new Set([...existing, ...newlyClaimed])) - 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" }, - ) - const readable = files.filter((file): file is { path: string; content: string } => file !== undefined) - if (readable.length === 0) return - // Publish directly rather than through SessionV2.synthetic: a Location-scoped layer - // cannot depend on SessionV2 (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* events.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 }) - }), -) - -function previouslyInjected(store: SessionStore.Interface, sessionID: SessionSchema.ID) { - return Effect.gen(function* () { - const history = yield* store.context(sessionID) - return new Set( - history - .filter((message): message is SessionMessage.Synthetic => message.type === "synthetic") - .flatMap( - (message) => - Option.getOrUndefined(Schema.decodeUnknownOption(InjectedMetadata)(message.metadata))?.instruction.paths ?? - [], - ), - ) - }) -} - -// Paths are normally discovered under the project root, so the description shows them -// relative to it. A directly-loaded path outside the root falls back to its absolute form -// rather than emitting `../..` chains. -function describePath(root: string, path: string) { - return FSUtil.contains(root, path) ? relative(root, path) : path -} - -export const node = makeLocationNode({ - name: "session-instructions", - layer, - deps: [EventV2.node, FSUtil.node, Location.node, SessionStore.node], -}) diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index bbe9ce53b147..655cd52a58d6 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -1,6 +1,6 @@ export * as SessionProjector from "./projector" -import { and, asc, desc, eq, gt, gte, inArray, lt, or, sql } from "drizzle-orm" +import { and, asc, desc, eq, gt, gte, inArray, lt, ne, or, sql } from "drizzle-orm" import { DateTime, Effect, Layer, Schema } from "effect" import { Database } from "../database/database" import { EventV2 } from "../event" @@ -18,11 +18,12 @@ import { MessageTable, PartTable, InstructionCheckpointTable, + InstructionFileTable, SessionInputTable, SessionMessageTable, SessionTable, } from "./sql" -import type { DeepMutable } from "../schema" +import { AbsolutePath, type DeepMutable } from "../schema" import { Slug } from "../util/slug" type DatabaseService = Database.Interface["db"] @@ -218,12 +219,14 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( // The fork inherits the parent's transcript, so it inherits the context // checkpoint that transcript was built against: copied message seqs keep // folding at the same baseline horizon. - const checkpoint = yield* db - .select() - .from(InstructionCheckpointTable) - .where(eq(InstructionCheckpointTable.session_id, event.data.parentID)) - .get() - .pipe(Effect.orDie) + const checkpoint = event.data.from + ? undefined + : yield* db + .select() + .from(InstructionCheckpointTable) + .where(eq(InstructionCheckpointTable.session_id, event.data.parentID)) + .get() + .pipe(Effect.orDie) if (checkpoint) { yield* db .insert(InstructionCheckpointTable) @@ -232,6 +235,28 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( .pipe(Effect.orDie) } + const instructionFiles = + copiedSeq === 0 + ? [] + : yield* db + .select() + .from(InstructionFileTable) + .where( + and( + eq(InstructionFileTable.session_id, event.data.parentID), + lt(InstructionFileTable.message_seq, copiedSeq + 1), + ), + ) + .all() + .pipe(Effect.orDie) + if (instructionFiles.length > 0) { + yield* db + .insert(InstructionFileTable) + .values(instructionFiles.map((file) => ({ ...file, session_id: event.data.sessionID }))) + .run() + .pipe(Effect.orDie) + } + const usage = emptyUsage() let cursor = -1 while (true) { @@ -243,6 +268,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( eq(SessionMessageTable.session_id, event.data.parentID), gt(SessionMessageTable.seq, cursor), copiedSeq === 0 ? undefined : lt(SessionMessageTable.seq, copiedSeq + 1), + event.data.from ? ne(SessionMessageTable.type, "system") : undefined, ), ) .orderBy(asc(SessionMessageTable.seq)) @@ -331,7 +357,8 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( .where(eq(SessionTable.id, event.data.sessionID)) .run() .pipe(Effect.orDie) - if (copiedSeq > 0) yield* EventV2.reserveSequence(db, event.data.sessionID, copiedSeq) + const inheritedSeq = Math.max(copiedSeq, ...instructionFiles.map((file) => file.discovered_seq)) + if (inheritedSeq > 0) yield* EventV2.reserveSequence(db, event.data.sessionID, inheritedSeq) }) function run(db: DatabaseService, event: MessageEvent) { @@ -498,6 +525,11 @@ const layer = Layer.effectDiscard( .run() .pipe(Effect.orDie) yield* InstructionCheckpoint.reset(db, event.data.sessionID) + yield* db + .delete(InstructionFileTable) + .where(eq(InstructionFileTable.session_id, event.data.sessionID)) + .run() + .pipe(Effect.orDie) }), ) yield* events.project(SessionV1.Event.Deleted, (event) => @@ -635,6 +667,54 @@ const layer = Layer.effectDiscard( }), ) yield* events.project(SessionEvent.InstructionsUpdated, (event) => run(db, event)) + yield* events.project(SessionEvent.InstructionsDiscovered, (event) => + Effect.gen(function* () { + if (event.durable === undefined) + return yield* Effect.die(new Error("Durable instruction discovery is missing aggregate sequence")) + const session = yield* db + .select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id }) + .from(SessionTable) + .where(eq(SessionTable.id, event.data.sessionID)) + .get() + .pipe(Effect.orDie) + if ( + !session || + session.directory !== event.data.location.directory || + (session.workspaceID ?? undefined) !== event.data.location.workspaceID + ) + return + const message = yield* db + .select({ seq: SessionMessageTable.seq }) + .from(SessionMessageTable) + .where( + and( + eq(SessionMessageTable.session_id, event.data.sessionID), + eq(SessionMessageTable.id, event.data.assistantMessageID), + ), + ) + .get() + .pipe(Effect.orDie) + if (!message) + return yield* Effect.die( + new Error(`Instruction discovery message not found: ${event.data.assistantMessageID}`), + ) + yield* db + .insert(InstructionFileTable) + .values( + event.data.files.map((file, position) => ({ + session_id: event.data.sessionID, + path: AbsolutePath.make(file.path), + content: file.content, + message_seq: message.seq, + discovered_seq: event.durable!.seq, + position, + })), + ) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + }), + ) yield* events.project(SessionEvent.Synthetic, (event) => run(db, event)) yield* events.project(SessionEvent.Skill.Activated, (event) => insertMessage(db, event, { @@ -719,6 +799,16 @@ const layer = Layer.effectDiscard( .run() .pipe(Effect.orDie) yield* InstructionCheckpoint.reset(db, event.data.sessionID) + yield* db + .delete(InstructionFileTable) + .where( + and( + eq(InstructionFileTable.session_id, event.data.sessionID), + gt(InstructionFileTable.message_seq, boundary.seq), + ), + ) + .run() + .pipe(Effect.orDie) }), ) }), diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index d696dd7ff0b2..e267bbe70267 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -162,7 +162,7 @@ const layer = Layer.effect( Effect.all( [ builtins.load(), - discovery.load(), + discovery.load(sessionID), skillGuidance.load(agent), referenceGuidance.load(), mcpGuidance.load(agent), diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index 3e6f79863c29..2c0131ded346 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -12,6 +12,7 @@ import type { MessageID, PartID, SessionV1 } from "../v1/session" import { WorkspaceV2 } from "../workspace" import { Timestamps } from "../database/schema.sql" import type { Instructions } from "../instructions/index" +import type { AbsolutePath } from "../schema" import type { Revert } from "@opencode-ai/schema/revert" import type { Schema } from "effect" @@ -188,3 +189,19 @@ export const InstructionCheckpointTable = sqliteTable("instruction_checkpoint", snapshot: text({ mode: "json" }).notNull().$type(), baseline_seq: integer().notNull(), }) + +export const InstructionFileTable = sqliteTable( + "instruction_file", + { + session_id: text() + .$type() + .notNull() + .references(() => SessionTable.id, { onDelete: "cascade" }), + path: text().$type().notNull(), + content: text().notNull(), + message_seq: integer().notNull(), + discovered_seq: integer().notNull(), + position: integer().notNull(), + }, + (table) => [primaryKey({ columns: [table.session_id, table.path] })], +) diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index 9ee5b4c42f08..6a53543642ca 100644 --- a/packages/core/src/tool/read.ts +++ b/packages/core/src/tool/read.ts @@ -10,7 +10,7 @@ import { Image } from "../image" import { Location } from "../location" import { LocationMutation } from "../location-mutation" import { PermissionV2 } from "../permission" -import { SessionInstructions } from "../session/instructions" +import { InstructionDiscovery } from "../instruction-discovery" import { AbsolutePath } from "../schema" import { ReadToolFileSystem } from "./read-filesystem" import { Tool } from "./tool" @@ -37,7 +37,7 @@ export const Plugin = { const mutation = yield* LocationMutation.Service const image = yield* Image.Service const permission = yield* PermissionV2.Service - const sessionInstructions = yield* SessionInstructions.Service + const instructionDiscovery = yield* InstructionDiscovery.Service const fs = yield* FSUtil.Service const location = yield* Location.Service @@ -92,17 +92,10 @@ export const Plugin = { offset: input.offset, limit: input.limit, }) - // After a successful read, discover nearby AGENTS.md walking up to the Location - // root exclusive and inject them as durable synthetic instructions. For a - // 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* () { + const discoverInstructions = Effect.gen(function* () { if (target.externalDirectory !== undefined) return 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 - // supplied by the core/instructions baseline) is dropped by the dirname filter. const discovered = yield* fs.up({ targets: [FILENAME], start: type === "directory" ? resolved : dirname(resolved), @@ -112,18 +105,22 @@ export const Plugin = { (file) => dirname(file) !== root, ) if (candidates.length === 0) return - yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates }) - }).pipe( - Effect.catch(() => Effect.void), - Effect.catchDefect(() => Effect.void), - ) + yield* instructionDiscovery.discover({ + sessionID: context.sessionID, + assistantMessageID: context.assistantMessageID, + paths: candidates, + }) + }).pipe(Effect.catch(() => Effect.void)) if ("encoding" in content && content.encoding === "base64" && SUPPORTED_IMAGE_MIMES.has(content.mime)) { - return yield* image + const normalized = yield* image .normalize(resource, { ...content, encoding: "base64" }) .pipe(Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content))) + yield* discoverInstructions + return normalized } if ("encoding" in content && content.encoding === "base64") return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource })) + yield* discoverInstructions return content }).pipe( Effect.mapError((error) => { diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index eda14d14b100..c2f77a17d4cf 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -16,6 +16,7 @@ import contextEpochAgentMigration from "@opencode-ai/core/database/migration/202 import simplifyIntegrationCredentialsMigration from "@opencode-ai/core/database/migration/20260611192811_lush_chimera" import simplifySessionInputMigration from "@opencode-ai/core/database/migration/20260622202450_simplify_session_input" import renameInstructionsMigration from "@opencode-ai/core/database/migration/20260705180000_rename_instructions" +import addInstructionFileMigration from "@opencode-ai/core/database/migration/20260706181957_add_instruction_file" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" @@ -168,6 +169,21 @@ describe("DatabaseMigration", () => { ) }) + test("adds the instruction file table", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`) + + yield* DatabaseMigration.applyOnly(db, [addInstructionFileMigration]) + + expect( + yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'instruction_file'`), + ).toEqual({ name: "instruction_file" }) + }), + ) + }) + test("keeps legacy credential fields nullable", async () => { await run( Effect.gen(function* () { diff --git a/packages/core/test/instruction-discovery.test.ts b/packages/core/test/instruction-discovery.test.ts index 2477d0512469..56bbaa016887 100644 --- a/packages/core/test/instruction-discovery.test.ts +++ b/packages/core/test/instruction-discovery.test.ts @@ -1,15 +1,24 @@ import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { DateTime, Effect, Layer, Schema } from "effect" import fs from "fs/promises" import path from "path" +import { Database } from "@opencode-ai/core/database/database" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" import { Global } from "@opencode-ai/core/global" import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery" +import { Instructions } from "@opencode-ai/core/instructions" import { Location } from "@opencode-ai/core/location" +import { ModelV2 } from "@opencode-ai/core/model" +import { Project } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" -import { Instructions } from "@opencode-ai/core/instructions" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionMessage } from "@opencode-ai/core/session/message" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { InstructionFileTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" @@ -27,6 +36,76 @@ const instructionLayer = (input: { ...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []), ]) +const sessionID = SessionV2.ID.make("ses_instruction_discovery_test") +const assistantMessageID = SessionMessage.ID.make("msg_instruction_discovery") + +const durableLayer = (input: { config: string; directory: string }) => + AppNodeBuilder.build(LayerNode.group([Database.node, InstructionDiscovery.node, SessionProjector.node]), [ + [Global.node, Global.layerWith({ config: input.config })], + [ + Location.node, + Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(input.directory) }))), + ], + ]) + +const withDurableDiscovery = ( + run: (input: { directory: string; config: string; sessionID: SessionV2.ID }) => Effect.Effect, +) => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => { + const directory = path.join(tmp.path, "project") + const config = path.join(tmp.path, "global") + return Effect.promise(() => + Promise.all([fs.mkdir(directory, { recursive: true }), fs.mkdir(config, { recursive: true })]), + ).pipe( + Effect.andThen( + Effect.gen(function* () { + const sessionID = SessionV2.ID.create() + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make(directory), sandboxes: [] }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + slug: sessionID, + directory: AbsolutePath.make(directory), + title: "instruction discovery", + version: "test", + }) + .run() + .pipe(Effect.orDie) + const encoded = Schema.encodeSync(SessionMessage.Message)( + SessionMessage.Assistant.make({ + id: assistantMessageID, + type: "assistant", + agent: "build", + model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, + content: [], + time: { created: DateTime.makeUnsafe(0) }, + }), + ) + const { id: _, type, ...data } = encoded + yield* db + .insert(SessionMessageTable) + .values({ id: assistantMessageID, session_id: sessionID, type, seq: 1, time_created: 0, data }) + .run() + .pipe(Effect.orDie) + return yield* run({ directory, config, sessionID }) + }), + ), + Effect.provide(durableLayer({ directory, config })), + ) + }), + ) + describe("InstructionDiscovery", () => { it.live("loads global and upward project AGENTS.md files as one aggregate context", () => Effect.acquireRelease( @@ -52,7 +131,7 @@ describe("InstructionDiscovery", () => { }) const load = InstructionDiscovery.Service.pipe( - Effect.flatMap((service) => service.load()), + Effect.flatMap((service) => service.load(sessionID)), Effect.provide( instructionLayer({ config: global, @@ -82,18 +161,14 @@ describe("InstructionDiscovery", () => { yield* Effect.promise(() => fs.writeFile(packageFile, "changed")) expect(yield* Instructions.reconcile(yield* load, initialized.applied)).toMatchObject({ _tag: "Updated", - text: expect.stringContaining(`Instructions from: ${packageFile}\nchanged`), + text: `The instructions from ${packageFile} changed to:\nchanged`, }) yield* Effect.promise(() => fs.rm(packageFile)) const partial = yield* Instructions.reconcile(yield* load, initialized.applied) expect(partial).toEqual({ _tag: "Updated", - text: [ - "These instructions replace all previously loaded ambient instructions.", - `Instructions from: ${globalFile}\nglobal`, - `Instructions from: ${projectFile}\nproject`, - ].join("\n\n"), + text: `Instructions from the following files no longer apply: ${packageFile}.`, applied: expect.any(Object), }) @@ -118,7 +193,7 @@ describe("InstructionDiscovery", () => { const file = path.join(tmp.path, "AGENTS.md") yield* Effect.promise(() => fs.writeFile(file, "")) const context = yield* InstructionDiscovery.Service.pipe( - Effect.flatMap((service) => service.load()), + Effect.flatMap((service) => service.load(sessionID)), Effect.provide( instructionLayer({ config: path.join(tmp.path, "global"), @@ -136,6 +211,132 @@ describe("InstructionDiscovery", () => { ), ) + it.live("stores discovered file content at admission time", () => + withDurableDiscovery(({ directory, sessionID }) => + Effect.gen(function* () { + const file = path.join(directory, "src", "AGENTS.md") + yield* Effect.promise(() => fs.mkdir(path.dirname(file), { recursive: true })) + yield* Effect.promise(() => fs.writeFile(file, "frozen")) + const discovery = yield* InstructionDiscovery.Service + + yield* discovery.discover({ sessionID, assistantMessageID, paths: [file] }) + yield* Effect.promise(() => fs.writeFile(file, "changed")) + + const database = yield* Database.Service + expect(yield* database.db.select().from(InstructionFileTable).all().pipe(Effect.orDie)).toMatchObject([ + { session_id: sessionID, path: file, content: "frozen" }, + ]) + }), + ), + ) + + it.live("re-reads discovered files so mid-session edits reach the model", () => + withDurableDiscovery(({ directory, sessionID }) => + Effect.gen(function* () { + const file = path.join(directory, "src", "AGENTS.md") + yield* Effect.promise(() => fs.mkdir(path.dirname(file), { recursive: true })) + yield* Effect.promise(() => fs.writeFile(file, "frozen")) + const discovery = yield* InstructionDiscovery.Service + yield* discovery.discover({ sessionID, assistantMessageID, paths: [file] }) + + const initialized = yield* Instructions.initialize(yield* discovery.load(sessionID)) + expect(initialized.text).toContain(`Instructions from: ${file}\nfrozen`) + + yield* Effect.promise(() => fs.writeFile(file, "edited")) + expect(yield* Instructions.reconcile(yield* discovery.load(sessionID), initialized.applied)).toMatchObject({ + _tag: "Updated", + text: `The instructions from ${file} changed to:\nedited`, + }) + }), + ), + ) + + it.live("falls back to frozen content when a discovered file disappears", () => + withDurableDiscovery(({ directory, sessionID }) => + Effect.gen(function* () { + const file = path.join(directory, "src", "AGENTS.md") + yield* Effect.promise(() => fs.mkdir(path.dirname(file), { recursive: true })) + yield* Effect.promise(() => fs.writeFile(file, "frozen")) + const discovery = yield* InstructionDiscovery.Service + yield* discovery.discover({ sessionID, assistantMessageID, paths: [file] }) + yield* Effect.promise(() => fs.rm(file)) + + const initialized = yield* Instructions.initialize(yield* discovery.load(sessionID)) + expect(initialized.text).toContain(`Instructions from: ${file}\nfrozen`) + }), + ), + ) + + it.live("deduplicates repeated and parallel discovery", () => + withDurableDiscovery(({ directory, sessionID }) => + Effect.gen(function* () { + const first = path.join(directory, "one", "AGENTS.md") + const second = path.join(directory, "two", "AGENTS.md") + yield* Effect.promise(() => + Promise.all([ + fs.mkdir(path.dirname(first), { recursive: true }).then(() => fs.writeFile(first, "one")), + fs.mkdir(path.dirname(second), { recursive: true }).then(() => fs.writeFile(second, "two")), + ]), + ) + const discovery = yield* InstructionDiscovery.Service + + yield* Effect.all( + [ + discovery.discover({ sessionID, assistantMessageID, paths: [first, first, second] }), + discovery.discover({ sessionID, assistantMessageID, paths: [second, first] }), + discovery.discover({ sessionID, assistantMessageID, paths: [first] }), + ], + { concurrency: "unbounded" }, + ) + yield* discovery.discover({ sessionID, assistantMessageID, paths: [first, second, first] }) + + const database = yield* Database.Service + const rows = yield* database.db + .select({ path: InstructionFileTable.path }) + .from(InstructionFileTable) + .all() + .pipe(Effect.orDie) + expect(rows.map((row) => row.path).sort()).toEqual([AbsolutePath.make(first), AbsolutePath.make(second)].sort()) + }), + ), + ) + + it.live("loads ambient and stored instructions together", () => + withDurableDiscovery(({ directory, sessionID }) => + Effect.gen(function* () { + const ambient = path.join(directory, "AGENTS.md") + const stored = path.join(directory, "src", "AGENTS.md") + yield* Effect.promise(() => fs.writeFile(ambient, "ambient")) + yield* Effect.promise(() => fs.mkdir(path.dirname(stored), { recursive: true })) + yield* Effect.promise(() => fs.writeFile(stored, "stored")) + const discovery = yield* InstructionDiscovery.Service + + yield* discovery.discover({ sessionID, assistantMessageID, paths: [stored] }) + + expect((yield* Instructions.initialize(yield* discovery.load(sessionID))).text).toBe( + `Instructions from: ${ambient}\nambient\n\nInstructions from: ${stored}\nstored`, + ) + }), + ), + ) + + it.live("does not emit synthetic messages during discovery", () => + withDurableDiscovery(({ directory, sessionID }) => + Effect.gen(function* () { + const file = path.join(directory, "src", "AGENTS.md") + yield* Effect.promise(() => fs.mkdir(path.dirname(file), { recursive: true })) + yield* Effect.promise(() => fs.writeFile(file, "stored")) + + const discovery = yield* InstructionDiscovery.Service + yield* discovery.discover({ sessionID, assistantMessageID, paths: [file] }) + + const database = yield* Database.Service + const messages = yield* database.db.select().from(SessionMessageTable).all().pipe(Effect.orDie) + expect(messages.filter((message) => message.type === "synthetic")).toEqual([]) + }), + ), + ) + it.effect("preserves admitted instructions while observation is unavailable", () => Effect.gen(function* () { const failingFS = Layer.effect( @@ -147,7 +348,7 @@ describe("InstructionDiscovery", () => { ), ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) const context = yield* InstructionDiscovery.Service.pipe( - Effect.flatMap((service) => service.load()), + Effect.flatMap((service) => service.load(sessionID)), Effect.provide( instructionLayer({ config: "/global", @@ -187,7 +388,7 @@ describe("InstructionDiscovery", () => { ), ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) const context = yield* InstructionDiscovery.Service.pipe( - Effect.flatMap((service) => service.load()), + Effect.flatMap((service) => service.load(sessionID)), Effect.provide( instructionLayer({ config: "/global", @@ -231,7 +432,7 @@ describe("InstructionDiscovery", () => { ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) yield* InstructionDiscovery.Service.pipe( - Effect.flatMap((service) => service.load()), + Effect.flatMap((service) => service.load(sessionID)), Effect.provide( instructionLayer({ config: "/global", @@ -261,7 +462,7 @@ describe("InstructionDiscovery", () => { process.env.OPENCODE_DISABLE_PROJECT_CONFIG = "1" yield* InstructionDiscovery.Service.pipe( - Effect.flatMap((service) => service.load()), + Effect.flatMap((service) => service.load(sessionID)), Effect.provide( instructionLayer({ config: "/global", @@ -293,7 +494,7 @@ describe("InstructionDiscovery", () => { Effect.gen(function* () { let scanned = false yield* InstructionDiscovery.Service.pipe( - Effect.flatMap((service) => service.load()), + Effect.flatMap((service) => service.load(sessionID)), Effect.provide( instructionLayer({ config: "/global", diff --git a/packages/core/test/session-instructions.test.ts b/packages/core/test/session-instructions.test.ts deleted file mode 100644 index 854cd73d4be1..000000000000 --- a/packages/core/test/session-instructions.test.ts +++ /dev/null @@ -1,319 +0,0 @@ -import { describe, expect, test } from "bun:test" -import fs from "fs/promises" -import path from "path" -import { DateTime, Effect, Layer } from "effect" -import { Message } from "@opencode-ai/llm" -import { AgentV2 } from "@opencode-ai/core/agent" -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" -import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { Config } from "@opencode-ai/core/config" -import { Database } from "@opencode-ai/core/database/database" -import { EventV2 } from "@opencode-ai/core/event" -import { FSUtil } from "@opencode-ai/core/fs-util" -import { Global } from "@opencode-ai/core/global" -import { Image } from "@opencode-ai/core/image" -import { Location } from "@opencode-ai/core/location" -import { LocationMutation } from "@opencode-ai/core/location-mutation" -import { ModelV2 } from "@opencode-ai/core/model" -import { PermissionV2 } from "@opencode-ai/core/permission" -import { ProjectV2 } from "@opencode-ai/core/project" -import { ProviderV2 } from "@opencode-ai/core/provider" -import { ReadTool } from "@opencode-ai/core/tool/read" -import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem" -import { SessionEvent } from "@opencode-ai/core/session/event" -import { SessionExecution } from "@opencode-ai/core/session/execution" -import { SessionInstructions } from "@opencode-ai/core/session/instructions" -import { SessionMessage } from "@opencode-ai/core/session/message" -import { SessionProjector } from "@opencode-ai/core/session/projector" -import { SessionStore } from "@opencode-ai/core/session/store" -import { SessionV2 } from "@opencode-ai/core/session" -import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message" -import { ToolHooks } from "@opencode-ai/core/tool/hooks" -import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" -import { ToolRegistry } from "@opencode-ai/core/tool/registry" -import { tempLocationLayer } from "./fixture/location" -import { makeLocationNode } from "@opencode-ai/core/effect/app-node" -import { testEffect } from "./lib/effect" -import { registerToolPlugin, settleTool, testModel } from "./lib/tool" - -const readToolNode = makeLocationNode({ - name: "test/read-tool-plugin", - layer: Layer.effectDiscard(registerToolPlugin(ReadTool.Plugin)), - deps: [ - ToolRegistry.toolsNode, - ReadToolFileSystem.node, - LocationMutation.node, - Image.node, - PermissionV2.node, - SessionInstructions.node, - FSUtil.node, - Location.node, - ], -}) - -const projects = Layer.succeed( - ProjectV2.Service, - ProjectV2.Service.of({ - list: () => Effect.succeed([]), - resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }), - directories: () => Effect.succeed([]), - commit: () => Effect.void, - }), -) -const permission = Layer.succeed( - PermissionV2.Service, - PermissionV2.Service.of({ - assert: () => Effect.void, - ask: () => Effect.die("unused"), - reply: () => Effect.die("unused"), - get: () => Effect.die("unused"), - forSession: () => Effect.die("unused"), - list: () => Effect.die("unused"), - }), -) -const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) })) -const imageLayer = AppNodeBuilder.build(Image.node, [[Config.node, config]]) - -const testLayer = AppNodeBuilder.build( - LayerNode.group([ - Database.node, - EventV2.node, - SessionProjector.node, - SessionStore.node, - SessionV2.node, - Location.node, - FSUtil.node, - LocationMutation.node, - ReadToolFileSystem.node, - readToolNode, - ToolRegistry.node, - ToolRegistry.toolsNode, - ToolHooks.node, - SessionInstructions.node, - Global.node, - ToolOutputStore.node, - Image.node, - ]), - [ - [ProjectV2.node, projects], - [SessionExecution.node, SessionExecution.noopLayer], - [Location.node, tempLocationLayer], - [PermissionV2.node, permission], - [Config.node, config], - [Image.node, imageLayer], - [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], - ], -) as unknown as Layer.Layer - -const it = testEffect(testLayer) - -const identity = { - agent: AgentV2.ID.make("build"), - assistantMessageID: SessionMessage.ID.make("msg_nearby"), -} -const readCall = (sessionID: SessionV2.ID, id: string, readPath: string): ToolRegistry.ExecuteInput => ({ - sessionID, - ...identity, - call: { type: "tool-call", id, name: "read", input: { path: readPath } }, -}) - -const writeAgents = (file: string, content: string) => Effect.promise(() => fs.writeFile(file, content)) -const mkdir = (dir: string) => Effect.promise(() => fs.mkdir(dir, { recursive: true })) - -const synthetics = (sessionID: SessionV2.ID) => - Effect.gen(function* () { - const store = yield* SessionStore.Service - return (yield* store.context(sessionID)).filter((message) => message.type === "synthetic") - }) - -// Seed a prior synthetic message with an instruction dedup ledger, simulating a prior turn -// after the Location layer was reopened (in-memory set empty). -const seedSynthetic = (sessionID: SessionV2.ID, paths: string[]) => - Effect.gen(function* () { - const events = yield* EventV2.Service - yield* events.publish(SessionEvent.Synthetic, { - sessionID, - text: `Instructions from: ${paths[0]}\nprior`, - description: `Loaded ${paths[0]}`, - metadata: { instruction: { paths } }, - }) - }) - -describe("SessionInstructions", () => { - it.effect("injects AGENTS.md files above a read, excludes the Location root, and dedups across reads", () => - Effect.gen(function* () { - const location = yield* Location.Service - const dir = location.directory - const rootPath = path.resolve(dir, "AGENTS.md") - const subPath = path.resolve(dir, "sub", "AGENTS.md") - const deepPath = path.resolve(dir, "sub", "deep", "AGENTS.md") - const otherPath = path.resolve(dir, "sub", "other", "AGENTS.md") - yield* mkdir(path.dirname(deepPath)) - yield* mkdir(path.dirname(otherPath)) - yield* writeAgents(rootPath, "root-instructions") - yield* writeAgents(subPath, "sub-instructions") - yield* writeAgents(deepPath, "deep-instructions") - yield* writeAgents(otherPath, "other-instructions") - yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "sub", "deep", "file.txt"), "file content")) - yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "sub", "other", "file2.txt"), "file content 2")) - - const session = yield* SessionV2.Service - const registry = yield* ToolRegistry.Service - const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id - - // A read deep under sub/ discovers deep and sub AGENTS.md, walking up to but - // excluding the Location root (already supplied by the core/instructions baseline). - yield* settleTool(registry, readCall(sessionID, "call-deep", "sub/deep/file.txt")) - - const firstInjected = yield* synthetics(sessionID) - expect(firstInjected).toHaveLength(1) - expect(firstInjected[0]!.text).toBe( - `Instructions from: ${deepPath}\ndeep-instructions\n\nInstructions from: ${subPath}\nsub-instructions`, - ) - expect(firstInjected[0]!.description).toBe( - `Loaded ${path.relative(dir, deepPath)}, ${path.relative(dir, subPath)}`, - ) - // The synthetic's metadata carries the durable dedup ledger. - expect(firstInjected[0]!.metadata).toEqual({ instruction: { paths: [deepPath, subPath] } }) - expect(firstInjected[0]!.text).not.toContain("root-instructions") - - // A sibling read under sub/other discovers only the new AGENTS.md; sub is already - // injected for this session so it is not re-emitted, and the root is still excluded. - yield* settleTool(registry, readCall(sessionID, "call-other", "sub/other/file2.txt")) - - const secondInjected = yield* synthetics(sessionID) - expect(secondInjected).toHaveLength(2) - expect(secondInjected[1]!.text).toBe(`Instructions from: ${otherPath}\nother-instructions`) - expect(secondInjected[1]!.description).toBe(`Loaded ${path.relative(dir, otherPath)}`) - expect(secondInjected[1]!.metadata).toEqual({ instruction: { paths: [otherPath] } }) - expect(secondInjected.some((message) => message.text.includes("root-instructions"))).toBe(false) - }), - ) - - it.effect("does not re-inject paths already recorded in durable session history", () => - Effect.gen(function* () { - const location = yield* Location.Service - const dir = location.directory - const rootPath = path.resolve(dir, "AGENTS.md") - const subPath = path.resolve(dir, "sub", "AGENTS.md") - yield* mkdir(path.resolve(dir, "sub")) - yield* writeAgents(rootPath, "root-instructions") - yield* writeAgents(subPath, "sub-instructions") - yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "sub", "file.txt"), "content")) - - const session = yield* SessionV2.Service - const registry = yield* ToolRegistry.Service - const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id - - // Seed the durable history with a prior synthetic that already claims sub's AGENTS.md - // via the instruction metadata ledger. - yield* seedSynthetic(sessionID, [subPath]) - expect(yield* synthetics(sessionID)).toHaveLength(1) - - yield* settleTool(registry, readCall(sessionID, "call-sub", "sub/file.txt")) - - // The durable claim on the prior synthetic prevents re-injection; no new synthetic. - expect(yield* synthetics(sessionID)).toHaveLength(1) - }), - ) - - it.effect( - "discovers AGENTS.md on a directory listing, including the listed directory's own, and dedups with a later file read", - () => - Effect.gen(function* () { - const location = yield* Location.Service - const dir = location.directory - const rootPath = path.resolve(dir, "AGENTS.md") - const pkgPath = path.resolve(dir, "packages", "foo", "AGENTS.md") - yield* mkdir(path.resolve(dir, "packages", "foo")) - yield* writeAgents(rootPath, "root-instructions") - yield* writeAgents(pkgPath, "pkg-instructions") - yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "packages", "foo", "file.txt"), "content")) - - const session = yield* SessionV2.Service - const registry = yield* ToolRegistry.Service - const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id - - // Listing packages/foo/ discovers its own AGENTS.md, walking up to but excluding - // the Location root (already supplied by the core/instructions baseline). - yield* settleTool(registry, readCall(sessionID, "call-list", "packages/foo")) - - const firstInjected = yield* synthetics(sessionID) - expect(firstInjected).toHaveLength(1) - expect(firstInjected[0]!.text).toBe(`Instructions from: ${pkgPath}\npkg-instructions`) - expect(firstInjected[0]!.description).toBe(`Loaded ${path.relative(dir, pkgPath)}`) - expect(firstInjected[0]!.metadata).toEqual({ instruction: { paths: [pkgPath] } }) - expect(firstInjected[0]!.text).not.toContain("root-instructions") - - // A subsequent file read under the listed directory is a dedup: pkg's AGENTS.md is - // already injected for this session, so nothing new is emitted. - yield* settleTool(registry, readCall(sessionID, "call-file", "packages/foo/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 - const dir = location.directory - const rootPath = path.resolve(dir, "AGENTS.md") - const subPath = path.resolve(dir, "sub", "AGENTS.md") - yield* mkdir(path.resolve(dir, "sub")) - yield* writeAgents(rootPath, "root-instructions") - yield* writeAgents(subPath, "sub-instructions") - - const session = yield* SessionV2.Service - const registry = yield* ToolRegistry.Service - const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id - - // The walk starts and stops at the Location root: the root AGENTS.md is searched but - // dropped by the dirname filter, and up() only walks upward so nested dirs are unseen. - yield* settleTool(registry, readCall(sessionID, "call-root-list", ".")) - - expect(yield* synthetics(sessionID)).toHaveLength(0) - }), - ) - - it.effect("loads instructions directly without a read", () => - 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(subPath, "sub-instructions") - - const session = yield* SessionV2.Service - const sessionInstructions = yield* SessionInstructions.Service - const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id - - yield* sessionInstructions.load({ sessionID, paths: [subPath] }) - - const injected = yield* synthetics(sessionID) - expect(injected).toHaveLength(1) - expect(injected[0]!.text).toBe(`Instructions from: ${subPath}\nsub-instructions`) - expect(injected[0]!.description).toBe(`Loaded ${path.relative(dir, subPath)}`) - expect(injected[0]!.metadata).toEqual({ instruction: { paths: [subPath] } }) - }), - ) - - test("toLLMMessages does not forward synthetic metadata to the provider", () => { - const created = DateTime.makeUnsafe(0) - const model = ModelV2.Ref.make({ id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }) - const synthetic = SessionMessage.Synthetic.make({ - id: SessionMessage.ID.make("msg_synthetic"), - type: "synthetic", - sessionID: SessionV2.ID.make("ses_test"), - text: "Instructions from: /repo/sub/AGENTS.md\ncontent", - description: "Loaded /repo/sub/AGENTS.md", - metadata: { instruction: { paths: ["/repo/sub/AGENTS.md"] } }, - time: { created }, - }) - const messages = toLLMMessages([synthetic], model) - expect(messages).toHaveLength(1) - expect(messages[0]!.role).toBe("user") - expect(messages[0]!.content).toEqual([{ type: "text", text: "Instructions from: /repo/sub/AGENTS.md\ncontent" }]) - // Metadata is bookkeeping for the dedup ledger; the model must not see it. - expect(messages[0]!.metadata).toBeUndefined() - }) -}) diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index 77dd272b120c..fa34c1b1cb96 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -10,6 +10,7 @@ import { ModelV2 } from "@opencode-ai/core/model" import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { ProviderV2 } from "@opencode-ai/core/provider" +import { Location } from "@opencode-ai/core/location" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { SessionEvent } from "@opencode-ai/core/session/event" @@ -22,6 +23,7 @@ import { SessionInput } from "@opencode-ai/core/session/input" import { Shell } from "@opencode-ai/schema/shell" import { InstructionCheckpointTable, + InstructionFileTable, SessionInputTable, SessionMessageTable, SessionTable, @@ -50,6 +52,15 @@ const assistantRow = ( return { id, session_id: sessionID, type, seq, time_created: DateTime.toEpochMillis(time.created), data } } +const systemRow = (id: SessionMessage.ID, seq: number) => { + const { + id: _, + type, + ...data + } = encodeMessage(SessionMessage.System.make({ id, type: "system", text: "historical update", time: { created } })) + return { id, session_id: sessionID, type, seq, time_created: DateTime.toEpochMillis(created), data } +} + describe("SessionProjector", () => { it.effect("projects staged, cleared, and committed reverts", () => Effect.gen(function* () { @@ -111,6 +122,218 @@ describe("SessionProjector", () => { }), ) + it.effect("copies only instruction files admitted within a fork boundary", () => + Effect.gen(function* () { + const db = (yield* Database.Service).db + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .run() + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + slug: "test", + directory: "/project", + title: "test", + version: "test", + }) + .run() + const boundary = SessionMessage.ID.make("msg_fork_boundary") + yield* db + .insert(SessionMessageTable) + .values([ + assistantRow(SessionMessage.ID.make("msg_copied"), 2), + systemRow(SessionMessage.ID.make("msg_instruction_update"), 3), + assistantRow(boundary, 4), + ]) + .run() + yield* db + .insert(InstructionCheckpointTable) + .values({ session_id: sessionID, baseline: "future", snapshot: {}, baseline_seq: 4 }) + .run() + yield* db + .insert(InstructionFileTable) + .values([ + { + session_id: sessionID, + path: AbsolutePath.make("/project/early.md"), + content: "early", + message_seq: 1, + discovered_seq: 1, + position: 0, + }, + { + session_id: sessionID, + path: AbsolutePath.make("/project/edge.md"), + content: "edge", + message_seq: 2, + discovered_seq: 2, + position: 1, + }, + { + session_id: sessionID, + path: AbsolutePath.make("/project/late.md"), + content: "late", + message_seq: 4, + discovered_seq: 3, + position: 2, + }, + ]) + .run() + const forkedID = SessionV2.ID.make("ses_projector_fork") + + const events = yield* EventV2.Service + yield* events.publish(SessionEvent.Forked, { + sessionID: forkedID, + parentID: sessionID, + from: boundary, + }) + + expect( + yield* db + .select({ path: InstructionFileTable.path, message_seq: InstructionFileTable.message_seq }) + .from(InstructionFileTable) + .where(eq(InstructionFileTable.session_id, forkedID)) + .orderBy(asc(InstructionFileTable.message_seq)) + .all(), + ).toEqual([ + { path: AbsolutePath.make("/project/early.md"), message_seq: 1 }, + { path: AbsolutePath.make("/project/edge.md"), message_seq: 2 }, + ]) + expect( + yield* db + .select() + .from(InstructionCheckpointTable) + .where(eq(InstructionCheckpointTable.session_id, forkedID)) + .get(), + ).toBeUndefined() + expect( + yield* db + .select({ type: SessionMessageTable.type }) + .from(SessionMessageTable) + .where(eq(SessionMessageTable.session_id, forkedID)) + .all(), + ).toEqual([{ type: "assistant" }]) + }), + ) + + it.effect("clears admitted instruction files when a session moves", () => + Effect.gen(function* () { + const db = (yield* Database.Service).db + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .run() + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + slug: "test", + directory: "/project", + title: "test", + version: "test", + }) + .run() + yield* db + .insert(InstructionFileTable) + .values({ + session_id: sessionID, + path: AbsolutePath.make("/project/AGENTS.md"), + content: "instructions", + message_seq: 1, + discovered_seq: 1, + position: 0, + }) + .run() + + const events = yield* EventV2.Service + yield* events.publish(SessionEvent.Moved, { + sessionID, + location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }), + }) + + expect( + yield* db.select().from(InstructionFileTable).where(eq(InstructionFileTable.session_id, sessionID)).all(), + ).toEqual([]) + }), + ) + + it.effect("removes only instruction files admitted after a committed revert boundary", () => + Effect.gen(function* () { + const db = (yield* Database.Service).db + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .run() + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + slug: "test", + directory: "/project", + title: "test", + version: "test", + }) + .run() + const boundary = SessionMessage.ID.make("msg_instruction_boundary") + yield* db + .insert(SessionMessageTable) + .values([assistantRow(boundary, 2), assistantRow(SessionMessage.ID.make("msg_instruction_later"), 4)]) + .run() + yield* db + .insert(InstructionFileTable) + .values([ + { + session_id: sessionID, + path: AbsolutePath.make("/project/early.md"), + content: "early", + message_seq: 1, + discovered_seq: 1, + position: 0, + }, + { + session_id: sessionID, + path: AbsolutePath.make("/project/edge.md"), + content: "edge", + message_seq: 2, + discovered_seq: 2, + position: 1, + }, + { + session_id: sessionID, + path: AbsolutePath.make("/project/late.md"), + content: "late", + message_seq: 3, + discovered_seq: 3, + position: 2, + }, + ]) + .run() + + const events = yield* EventV2.Service + yield* events.publish(SessionEvent.RevertEvent.Committed, { + sessionID, + messageID: boundary, + }) + + expect( + yield* db + .select({ path: InstructionFileTable.path, message_seq: InstructionFileTable.message_seq }) + .from(InstructionFileTable) + .where(eq(InstructionFileTable.session_id, sessionID)) + .orderBy(asc(InstructionFileTable.message_seq)) + .all(), + ).toEqual([ + { path: AbsolutePath.make("/project/early.md"), message_seq: 1 }, + { path: AbsolutePath.make("/project/edge.md"), message_seq: 2 }, + ]) + }), + ) + it.effect("orders projected messages and context by durable aggregate sequence", () => Effect.gen(function* () { const { db } = yield* Database.Service diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index aa2484d34f8f..f071be314835 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -73,8 +73,10 @@ const model = OpenAIChat.route }) .model({ id: "gpt-4o-mini" }) const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model))) -const systemContext = Layer.mock(InstructionBuiltIns.Service, { load: () => Effect.succeed(Instructions.empty) }) -const instructionContext = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) }) +const instructionBuiltIns = Layer.mock(InstructionBuiltIns.Service, { load: () => Effect.succeed(Instructions.empty) }) +const instructionDiscovery = Layer.mock(InstructionDiscovery.Service, { + load: (_sessionID) => Effect.succeed(Instructions.empty), +}) const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.succeed(Instructions.empty) }) const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(Instructions.empty) }) const mcpGuidance = Layer.mock(McpGuidance.Service, { load: () => Effect.succeed(Instructions.empty) }) @@ -83,8 +85,8 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ [Snapshot.node, Snapshot.noopLayer], [LayerNodePlatform.llmClient, client], [SessionRunnerModel.node, models], - [InstructionBuiltIns.node, systemContext], - [InstructionDiscovery.node, instructionContext], + [InstructionBuiltIns.node, instructionBuiltIns], + [InstructionDiscovery.node, instructionDiscovery], [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], [SkillGuidance.node, skillGuidance], [ReferenceGuidance.node, referenceGuidance], @@ -133,8 +135,8 @@ const it = testEffect( [PermissionV2.node, permission], [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], [SessionRunnerModel.node, models], - [InstructionBuiltIns.node, systemContext], - [InstructionDiscovery.node, instructionContext], + [InstructionBuiltIns.node, instructionBuiltIns], + [InstructionDiscovery.node, instructionDiscovery], [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], [SkillGuidance.node, skillGuidance], [ReferenceGuidance.node, referenceGuidance], diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 83d7e04abf1c..dd57753930df 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -1,4 +1,6 @@ import { describe, expect } from "bun:test" +import fs from "fs/promises" +import path from "path" import { LLMClient, LLMError, @@ -44,6 +46,7 @@ import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { AgentV2 } from "@opencode-ai/core/agent" import { Config } from "@opencode-ai/core/config" import { ConfigCompaction } from "@opencode-ai/core/config/compaction" +import { Global } from "@opencode-ai/core/global" import { Tool } from "@opencode-ai/core/tool/tool" import { InstructionCheckpointTable, @@ -65,6 +68,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect" import { asc, eq } from "drizzle-orm" import { testEffect } from "./lib/effect" +import { tmpdir } from "./fixture/tmpdir" const requests: LLMRequest[] = [] let response: LLMEvent[] = [] @@ -207,7 +211,9 @@ const systemContext = Layer.mock(InstructionBuiltIns.Service, { ), ), }) -const instructionContext = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) }) +const instructionContext = Layer.mock(InstructionDiscovery.Service, { + load: (_sessionID) => Effect.succeed(Instructions.empty), +}) const skillGuidance = Layer.mock(SkillGuidance.Service, { load: (agent) => Effect.succeed( @@ -242,76 +248,80 @@ const config = Layer.succeed( ]), }), ) -const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ - [Snapshot.node, Snapshot.noopLayer], - [LayerNodePlatform.llmClient, client], - [SessionRunnerModel.node, models], - [InstructionBuiltIns.node, systemContext], - [InstructionDiscovery.node, instructionContext], - [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], - [SkillGuidance.node, skillGuidance], - [ReferenceGuidance.node, referenceGuidance], - [PermissionV2.node, permission], - [Config.node, config], - [McpGuidance.node, mcpGuidance], - [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], +const makeRunnerLayer = (discovery?: typeof instructionContext) => + AppNodeBuilder.build(SessionRunnerLLM.node, [ + [Snapshot.node, Snapshot.noopLayer], + [LayerNodePlatform.llmClient, client], + [SessionRunnerModel.node, models], + [InstructionBuiltIns.node, systemContext], + ...(discovery ? [[InstructionDiscovery.node, discovery] as const] : []), + [Global.node, Global.layerWith({ config: "/nonexistent/opencode-test-config" })], + [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], + [SkillGuidance.node, skillGuidance], + [ReferenceGuidance.node, referenceGuidance], + [PermissionV2.node, permission], + [Config.node, config], + [McpGuidance.node, mcpGuidance], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + ]) +const makeExecution = (runnerLayer: ReturnType) => + Layer.effect( + SessionExecution.Service, + Effect.gen(function* () { + const sessionRunner = yield* SessionRunner.Service + const coordinator = yield* SessionRunCoordinator.make({ + drain: (sessionID, force) => sessionRunner.drain({ sessionID, force }), + }) + return SessionExecution.Service.of({ + active: coordinator.active, + resume: coordinator.run, + wake: coordinator.wake, + interrupt: coordinator.interrupt, + awaitIdle: coordinator.awaitIdle, + }) + }), + ).pipe(Layer.provide(runnerLayer)) +const testNode = LayerNode.group([ + Database.node, + EventV2.node, + Form.node, + SessionProjector.node, + SessionStore.node, + AgentV2.node, + ToolRegistry.node, + ToolRegistry.toolsNode, + echoNode, + SessionRunnerModel.node, + InstructionBuiltIns.node, + InstructionDiscovery.node, + InstructionEntry.node, + SkillGuidance.node, + ReferenceGuidance.node, + Config.node, + Snapshot.node, + SessionRunnerLLM.node, + SessionExecution.node, + SessionV2.node, ]) -const execution = Layer.effect( - SessionExecution.Service, - Effect.gen(function* () { - const sessionRunner = yield* SessionRunner.Service - const coordinator = yield* SessionRunCoordinator.make({ - drain: (sessionID, force) => sessionRunner.drain({ sessionID, force }), - }) - return SessionExecution.Service.of({ - active: coordinator.active, - resume: coordinator.run, - wake: coordinator.wake, - interrupt: coordinator.interrupt, - awaitIdle: coordinator.awaitIdle, - }) - }), -).pipe(Layer.provide(runnerLayer)) -const it = testEffect( - AppNodeBuilder.build( - LayerNode.group([ - Database.node, - EventV2.node, - Form.node, - SessionProjector.node, - SessionStore.node, - AgentV2.node, - ToolRegistry.node, - ToolRegistry.toolsNode, - echoNode, - SessionRunnerModel.node, - InstructionBuiltIns.node, - InstructionDiscovery.node, - InstructionEntry.node, - SkillGuidance.node, - ReferenceGuidance.node, - Config.node, - Snapshot.node, - SessionRunnerLLM.node, - SessionExecution.node, - SessionV2.node, - ]), - [ - [LayerNodePlatform.llmClient, client], - [PermissionV2.node, permission], - [SessionRunnerModel.node, models], - [InstructionBuiltIns.node, systemContext], - [InstructionDiscovery.node, instructionContext], - [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], - [SkillGuidance.node, skillGuidance], - [ReferenceGuidance.node, referenceGuidance], - [Snapshot.node, Snapshot.noopLayer], - [SessionExecution.node, execution], - [Config.node, config], - [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], - ], - ), -) +const makeTestLayer = (discovery: typeof instructionContext | undefined, execution: ReturnType) => + AppNodeBuilder.build(testNode, [ + [LayerNodePlatform.llmClient, client], + [PermissionV2.node, permission], + [SessionRunnerModel.node, models], + [InstructionBuiltIns.node, systemContext], + ...(discovery ? [[InstructionDiscovery.node, discovery] as const] : []), + [Global.node, Global.layerWith({ config: "/nonexistent/opencode-test-config" })], + [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], + [SkillGuidance.node, skillGuidance], + [ReferenceGuidance.node, referenceGuidance], + [Snapshot.node, Snapshot.noopLayer], + [SessionExecution.node, execution], + [Config.node, config], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + ]) +const runnerLayer = makeRunnerLayer(instructionContext) +const it = testEffect(makeTestLayer(instructionContext, makeExecution(runnerLayer))) +const integrationIt = testEffect(makeTestLayer(undefined, makeExecution(makeRunnerLayer()))) const sessionID = SessionV2.ID.make("ses_runner_test") const otherSessionID = SessionV2.ID.make("ses_runner_other") @@ -858,6 +868,53 @@ describe("SessionRunnerLLM", () => { }), ) + integrationIt.live("admits persisted path-local instructions as a chronological System update", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const discovery = yield* InstructionDiscovery.Service + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + + requests.length = 0 + response = fragmentFixture("text", "text-instruction-discovery", ["Done"]).completeEvents + yield* session.resume(sessionID) + const assistantMessageID = (yield* session.context(sessionID)).findLast( + (message): message is SessionMessage.Assistant => message.type === "assistant", + )?.id + if (!assistantMessageID) return yield* Effect.die(new Error("Expected an assistant message")) + + const file = path.join(tmp.path, "src", "AGENTS.md") + yield* Effect.promise(() => fs.mkdir(path.dirname(file), { recursive: true })) + yield* Effect.promise(() => fs.writeFile(file, "Persisted path-local instructions")) + yield* discovery.discover({ sessionID, assistantMessageID, paths: [file] }) + yield* Effect.promise(() => fs.writeFile(file, "Changed after discovery")) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) + yield* session.resume(sessionID) + + // Discovery records the path durably; observation re-reads content live, + // so the post-discovery edit is what the model gets told. + const update = `Instructions from: ${file}\nChanged after discovery` + expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "system", "user"]) + expect(requests[1]?.messages.at(2)?.content).toEqual([{ type: "text", text: update }]) + expect((yield* session.context(sessionID)).map((message) => message.type)).toEqual([ + "user", + "assistant", + "system", + "user", + "assistant", + ]) + expect(yield* recordedEventTypes(sessionID)).toContain("session.instructions.updated.1") + expect(yield* recordedEventTypes(sessionID)).not.toContain("session.synthetic.1") + }), + ), + ), + ) + it.effect("uses the selected model family prompt when the agent does not override it", () => Effect.gen(function* () { yield* setup diff --git a/packages/core/test/tool-read.test.ts b/packages/core/test/tool-read.test.ts index 227be289e927..f45fe4fa8fd1 100644 --- a/packages/core/test/tool-read.test.ts +++ b/packages/core/test/tool-read.test.ts @@ -20,7 +20,8 @@ import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { ReadTool } from "@opencode-ai/core/tool/read" import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem" import { makeLocationNode } from "@opencode-ai/core/effect/app-node" -import { SessionInstructions } from "@opencode-ai/core/session/instructions" +import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery" +import { Instructions } from "@opencode-ai/core/instructions" import { testEffect } from "./lib/effect" import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool" @@ -33,7 +34,7 @@ const readToolNode = makeLocationNode({ LocationMutation.node, Image.node, PermissionV2.node, - SessionInstructions.node, + InstructionDiscovery.node, FSUtil.node, Location.node, ], @@ -47,6 +48,8 @@ const readCalls: { page: ReadToolFileSystem.PageInput }[] = [] const listCalls: ReadToolFileSystem.PageInput[] = [] +const discoveredInstructions: string[][] = [] +let instructionPaths: string[] = [] let resolvedType: "file" | "directory" = "file" let resolveFailure: unknown let readResult: FileSystem.Content | ReadToolFileSystem.TextPage = { @@ -108,6 +111,7 @@ const testFileSystem = Layer.effect( }), ) : Effect.succeed(path), + up: () => Effect.succeed(instructionPaths), }), ), ), @@ -146,6 +150,10 @@ const unavailableImage = Layer.succeed( Image.Service, Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) }), ) +const instructionDiscovery = Layer.mock(InstructionDiscovery.Service, { + load: (_sessionID) => Effect.succeed(Instructions.empty), + discover: (input) => Effect.sync(() => void discoveredInstructions.push([...input.paths])), +}) const readLayer = (imageLayer: Layer.Layer) => AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, readToolNode]), [ [ReadToolFileSystem.node, reader], @@ -155,6 +163,7 @@ const readLayer = (imageLayer: Layer.Layer) => [LocationMutation.node, mutation], [FSUtil.node, testFileSystem], [Location.node, locationLayer], + [InstructionDiscovery.node, instructionDiscovery], [Global.node, Global.layerWith({ data: Global.Path.data })], [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], ]) @@ -167,6 +176,8 @@ describe("ReadTool", () => { assertions.length = 0 readCalls.length = 0 listCalls.length = 0 + discoveredInstructions.length = 0 + instructionPaths = [] allow = true resolvedType = "file" resolveFailure = undefined @@ -678,6 +689,7 @@ describe("ReadTool", () => { mime: "application/octet-stream", } const registry = yield* ToolRegistry.Service + instructionPaths = [path.join(process.cwd(), "sub", "AGENTS.md")] expect( yield* executeTool(registry, { @@ -686,6 +698,7 @@ describe("ReadTool", () => { call: { type: "tool-call", id: "call-direct-binary", name: "read", input: { path: "late-binary" } }, }), ).toEqual({ type: "error", value: "Cannot read binary file: late-binary" }) + expect(discoveredInstructions).toEqual([]) }), ) }) diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index c79b0da7462b..e68517ce1f56 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -140,6 +140,18 @@ export const InstructionsUpdated = Event.durable({ }) export type InstructionsUpdated = typeof InstructionsUpdated.Type +export const InstructionsDiscovered = Event.durable({ + type: "session.instructions.discovered", + ...options, + schema: { + ...Base, + assistantMessageID: SessionMessage.ID, + location: Location.Ref, + files: Schema.Array(Schema.Struct({ path: Schema.String, content: Schema.String })), + }, +}) +export type InstructionsDiscovered = typeof InstructionsDiscovered.Type + export const Synthetic = Event.durable({ type: "session.synthetic", ...options, @@ -495,6 +507,7 @@ export const Definitions = Event.inventory( PromptAdmitted, ExecutionSettled, InstructionsUpdated, + InstructionsDiscovered, Synthetic, Skill.Activated, Shell.Started, diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index 51400b2e39f4..7a852dd451f5 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -104,7 +104,7 @@ describe("public event manifest", () => { "session.forked.1", "session.prompt.promoted.1", "session.prompt.admitted.1", - + "session.instructions.discovered.1", "session.instructions.updated.1", "session.synthetic.1", "session.skill.activated.1", diff --git a/packages/sdk/js/script/build.ts b/packages/sdk/js/script/build.ts index fdc1d76bc628..2bef8d1b89ad 100755 --- a/packages/sdk/js/script/build.ts +++ b/packages/sdk/js/script/build.ts @@ -60,7 +60,7 @@ if (schemas) { visit({ ...document, components: { ...document.components, schemas: undefined } }) for (const name of Object.keys(schemas)) { if ( - /^(SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionPromptPromoted|SessionPromptAdmitted|SessionExecutionSettled|SessionInstructionsUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetried|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)1$/.test( + /^(SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionPromptPromoted|SessionPromptAdmitted|SessionExecutionSettled|SessionInstructionsUpdated|SessionInstructionsDiscovered|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetried|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)1$/.test( name, ) && !reachable.has(name) @@ -100,7 +100,7 @@ await createClient({ const generatedTypesPath = "./src/v2/gen/types.gen.ts" const generatedTypes = await Bun.file(generatedTypesPath).text() if ( - /export type (SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionPromptPromoted|SessionPromptAdmitted|SessionExecutionSettled|SessionInstructionsUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetried|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)1 =/.test( + /export type (SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionPromptPromoted|SessionPromptAdmitted|SessionExecutionSettled|SessionInstructionsUpdated|SessionInstructionsDiscovered|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetried|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)1 =/.test( generatedTypes, ) ) { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 70b85f2e0bb6..153071db0ed5 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -26,6 +26,7 @@ export type Event = | EventSessionPromptAdmitted | EventSessionExecutionSettled | EventSessionInstructionsUpdated + | EventSessionInstructionsDiscovered | EventSessionSynthetic | EventSessionSkillActivated | EventSessionShellStarted @@ -935,6 +936,19 @@ export type GlobalEvent = { text: string } } + | { + id: string + type: "session.instructions.discovered" + properties: { + sessionID: string + assistantMessageID: string + location: LocationRef + files: Array<{ + path: string + content: string + }> + } + } | { id: string type: "session.synthetic" @@ -1732,6 +1746,7 @@ export type GlobalEvent = { | SyncEventSessionPromptPromoted | SyncEventSessionPromptAdmitted | SyncEventSessionInstructionsUpdated + | SyncEventSessionInstructionsDiscovered | SyncEventSessionSynthetic | SyncEventSessionSkillActivated | SyncEventSessionShellStarted @@ -2895,6 +2910,7 @@ export type SessionDurableEvent = | SessionPromptPromoted | SessionPromptAdmitted | SessionInstructionsUpdated + | SessionInstructionsDiscovered | SessionSynthetic | SessionSkillActivated | SessionShellStarted @@ -3036,6 +3052,7 @@ export type V2Event = | SessionPromptAdmitted | SessionExecutionSettled | SessionInstructionsUpdated + | SessionInstructionsDiscovered | SessionSynthetic | SessionSkillActivated | SessionShellStarted @@ -3758,6 +3775,26 @@ export type SyncEventSessionInstructionsUpdated = { } } +export type SyncEventSessionInstructionsDiscovered = { + type: "sync" + id: string + syncEvent: { + type: "session.instructions.discovered.1" + id: string + seq: number + aggregateID: string + data: { + sessionID: string + assistantMessageID: string + location: LocationRef + files: Array<{ + path: string + content: string + }> + } + } +} + export type SyncEventSessionSynthetic = { type: "sync" id: string @@ -4702,6 +4739,30 @@ export type SessionInstructionsUpdated = { } } +export type SessionInstructionsDiscovered = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.instructions.discovered" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + assistantMessageID: string + location: LocationRef + files: Array<{ + path: string + content: string + }> + } +} + export type SessionSynthetic = { id: string created: number @@ -6834,6 +6895,20 @@ export type EventSessionInstructionsUpdated = { } } +export type EventSessionInstructionsDiscovered = { + id: string + type: "session.instructions.discovered" + properties: { + sessionID: string + assistantMessageID: string + location: LocationRef + files: Array<{ + path: string + content: string + }> + } +} + export type EventSessionSynthetic = { id: string type: "session.synthetic" @@ -8409,6 +8484,30 @@ export type SessionInstructionsUpdated2 = { } } +export type SessionInstructionsDiscovered2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.instructions.discovered" + durable: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef2 + data: { + sessionID: string + assistantMessageID: string + location: LocationRef2 + files: Array<{ + path: string + content: string + }> + } +} + export type SessionSynthetic2 = { id: string created: number @@ -8990,6 +9089,7 @@ export type SessionDurableEventV2 = | SessionPromptPromoted2 | SessionPromptAdmitted2 | SessionInstructionsUpdated2 + | SessionInstructionsDiscovered2 | SessionSynthetic2 | SessionSkillActivated2 | SessionShellStarted2 @@ -11180,6 +11280,7 @@ export type V2EventV2 = | SessionPromptAdmitted2 | SessionExecutionSettled2 | SessionInstructionsUpdated2 + | SessionInstructionsDiscovered2 | SessionSynthetic2 | SessionSkillActivated2 | SessionShellStarted2 diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index 62c85ef3e838..ffbc644bfe91 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -1,17 +1,31 @@ # V2 Schema Changelog +## 2026-07-06: Make Path-Local Instruction Discovery Durable + +- Add the `instruction_file` table recording which path-local `AGENTS.md` files a Session has discovered, with the owning assistant-message boundary and durable discovery order. +- Add the durable `session.instructions.discovered` event; projection stores the discovered path and content per Session. Observation re-reads discovered files live, falling back to the stored content only when a file becomes unreadable. +- Replace synthetic-message instruction injection with the durable discovery path: `InstructionDiscovery` folds discovered files into the `core/instructions` source, so completed compaction rebaselines restate them instead of summarizing them away. +- Narrate instruction file changes as per-file deltas; the full-set restatement survives only for pure reorderings. +- Fork copies discoveries within the inherited transcript, committed revert removes discoveries past the revert boundary, and Session movement clears them so the destination initializes a complete baseline. + +Compatibility: + +- Previously emitted synthetic instruction messages remain historical Session history. New discoveries use the durable path; the migration does not infer structured files from old free-form synthetic text. + ## 2026-07-05: Rename Session Context Contracts To Instructions - Rename the System Context algebra to `Instructions`, API-managed `SessionContextEntry` records to `InstructionEntry`, and the session-owned context checkpoint to `InstructionCheckpoint`. -- Rename the tables `session_context_entry` and `session_context_epoch` to `instruction_entry` and `instruction_checkpoint`. -- Rename the durable update event from `session.context.updated` to `session.instructions.updated`; the migration rewrites existing durable event types in place. +- Rename the durable update event from `session.context.updated` to `session.instructions.updated`. - Rename the API-managed entry routes from `/api/session/:sessionID/context-entry` to `/api/session/:sessionID/instructions/entries` and their operation identifiers from `session.context.entry.*` to `session.instructions.entry.*`, keeping bare `session.instructions.*` free for the composed instruction surface. +- Add durable per-Session path-local instruction files and make `InstructionDiscovery` combine them with ambient global and upward-project `AGENTS.md` files. +- Record successful path-local discovery as `session.instructions.discovered`; projection stores the discovered path and content with the owning assistant-message boundary before `InstructionCheckpoint` admits it. Observation re-reads discovered files live, falling back to the stored content only when a file becomes unreadable. +- Remove the context registry seam. The runner explicitly composes instruction built-ins, discovery, selected-agent skill guidance, reference guidance, MCP guidance, and `InstructionEntry` values. Compatibility: - The V2 contracts remain experimental. The renamed tables, event, endpoints, schemas, and generated client names are intentionally breaking changes for beta consumers. - Existing changelog entries retain the names that were accurate when those changes occurred. -- Behavior is unchanged: this is a vocabulary and contract rename only. +- Previously emitted synthetic instruction messages remain historical Session history. New discoveries use the instruction checkpoint path; the migration does not infer structured files from old free-form synthetic text. ## 2026-07-03: Require Durable Envelope On Durable Events diff --git a/specs/v2/session.md b/specs/v2/session.md index 1fea99dcb662..cfd3fdd3f295 100644 --- a/specs/v2/session.md +++ b/specs/v2/session.md @@ -58,7 +58,7 @@ V2 Sessions persist the exact privileged instructions shown to the model. `Instr The runner has no instruction registry. `loadInstructions` explicitly loads these producers concurrently and combines them in this fixed order: 1. Instruction built-ins, currently environment facts and the host-local date. -2. `InstructionDiscovery`, observing ambient `AGENTS.md` files. +2. `InstructionDiscovery`, combining ambient and path-local `AGENTS.md` files. 3. Selected-agent available-skill guidance. 4. Reference guidance. 5. Selected-agent MCP guidance. @@ -88,7 +88,7 @@ Client Runner Explicit producers InstructionChe Agent and model selection are step-scoped. The runner selects the agent before loading agent-specific guidance; a switch admitted after the current boundary applies to the next step without restarting the current one. Changed guidance is admitted through `session.instructions.updated` while preserving the baseline. Model selection affects Model Context assembly but is not an instruction source and does not itself replace the instruction baseline. -A completed compaction causes the next physical attempt to rebaseline from current instructions. Temporarily unavailable sources are restated from the model's last applied belief where possible. A Session move resets `InstructionCheckpoint` so the destination Location initializes a complete baseline on its next run. Committed revert also resets the checkpoint. +A completed compaction causes the next physical attempt to rebaseline from current instructions. Temporarily unavailable sources are restated from the model's last applied belief where possible. A Session move resets `InstructionCheckpoint` and all path-local discoveries so the destination Location initializes a complete baseline on its next run. Committed revert also resets the checkpoint and removes path-local discoveries admitted after the revert boundary. ```text Session InstructionCheckpoint @@ -104,9 +104,9 @@ Session InstructionCheckpoint ├─ move or committed revert ────────▶ reset ``` -`InstructionDiscovery` observes ambient instructions as one ordered aggregate source. Ambient discovery canonicalizes traversal within the project root, reads global and upward-project `AGENTS.md` files, and honors `OPENCODE_DISABLE_PROJECT_CONFIG` for project files. +`InstructionDiscovery` combines ambient and path-local instructions into one ordered aggregate source. Ambient discovery canonicalizes traversal within the project root, reads global and upward-project `AGENTS.md` files, and honors `OPENCODE_DISABLE_PROJECT_CONFIG` for project files. After a successful internal `read`, path-local discovery walks from the read file's directory, or the listed directory itself, toward the Location root. `session.instructions.discovered` records newly readable files with their owning assistant-message and Location; projection stores their path and content per Session, and they join the aggregate at the next step boundary. Every observation re-reads ambient and discovered files live, so mid-session edits narrate as per-file updates; the stored discovery content stands in only when a discovered file becomes unreadable. Ambient files precede path-local files, duplicate paths are removed, and path-local files retain durable discovery order. -An unavailable observation preserves the previously applied value. A confirmed partial instruction removal emits the complete remaining aggregate with explicit supersession text; removing the final instruction emits a revocation message. +An unavailable discovery observation preserves the previously applied aggregate. A confirmed partial removal emits the complete remaining aggregate with explicit supersession text; removing the final discovered instruction emits a revocation message. Current instruction follow-ups: @@ -141,7 +141,7 @@ Status: `complete` is usable in the native V2 path, `partial` covers only part o | Durable Instruction Source | Environment facts and host-local date | partial | Keep selected provider/model identity in step request assembly rather than a stale Location-wide instruction value. | | Durable Instruction Source | Global and upward project instructions | partial | Decide whether V2 also discovers legacy `CLAUDE.md` and deprecated `CONTEXT.md`. | | Durable Instruction Source | Configured local/glob and remote URL instructions | missing | Add independent sources with explicit precedence, unavailable, and removal semantics. | -| Durable Instruction Source | Nearby nested instructions discovered after successful reads | missing | Persist discoveries and admit them at the next safe step boundary. | +| Durable Instruction Source | Nearby path-local instructions discovered after successful reads | complete | None. | | Durable Instruction Source | Selected-agent available skill guidance and skill-body loading | partial | Guidance and body exposure are permission-filtered; remove globally denied skill definitions during request-time tool materialization. | | Step request assembly | Placement, selected model, chronological history, and canonical lowering | complete | None. | | Step request assembly | Selected agent, agent prompt, and effective permissions | partial | V2 uses selected-agent permissions for skill guidance and tool authorization; still apply the agent system prompt and request policy. |