diff options
| author | Adam Malczewski <[email protected]> | 2026-06-24 04:02:09 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-24 04:02:09 +0900 |
| commit | b180cc1e03f90a139c90ef498d1c1fb449508fd7 (patch) | |
| tree | 2daaf7bf051072bdf73b8857ea9daf30f77f3af7 /packages/system-prompt/src | |
| parent | 69f89ab49be842d9826fb0b1621cc8c8dea5f14c (diff) | |
| download | dispatch-b180cc1e03f90a139c90ef498d1c1fb449508fd7.tar.gz dispatch-b180cc1e03f90a139c90ef498d1c1fb449508fd7.zip | |
fix(system-prompt): reconstruct on cwd change via getWithMeta
The system-prompt service cached the resolved prompt on first turn and reused
it on subsequent turns via get(). But the prompt is cwd-sensitive (file:AGENTS.md,
prompt:cwd variables). When a conversation's cwd changed after the first turn,
the cached prompt was stale — referenced files from the new cwd were not loaded.
system-prompt: added getWithMeta(conversationId) returning { prompt, cwd } and
stores resolved-cwd:<id> alongside resolved:<id> in construct().
session-orchestrator: subsequent turns now call getWithMeta, compare stored cwd
vs effective cwd, and reconstruct if they differ. Compaction path (always
constructs) and warm path (no system prompt) are unaffected.
1411 vitest pass; tsc + biome clean.
Diffstat (limited to 'packages/system-prompt/src')
| -rw-r--r-- | packages/system-prompt/src/service.test.ts | 76 | ||||
| -rw-r--r-- | packages/system-prompt/src/service.ts | 10 | ||||
| -rw-r--r-- | packages/system-prompt/src/types.ts | 10 |
3 files changed, 96 insertions, 0 deletions
diff --git a/packages/system-prompt/src/service.test.ts b/packages/system-prompt/src/service.test.ts index 91592f8..cd850e3 100644 --- a/packages/system-prompt/src/service.test.ts +++ b/packages/system-prompt/src/service.test.ts @@ -148,4 +148,80 @@ describe("system-prompt service", () => { expect(DEFAULT_TEMPLATE).toContain("[file:AGENTS.md]"); expect(DEFAULT_TEMPLATE).toContain("[prompt:cwd]"); }); + + it("getWithMeta on a never-constructed conversation returns { prompt: null, cwd: null }", async () => { + // 1. never constructed → both fields null. + const service = createSystemPromptService({ + storage: memoryStorage(), + adapters: adapters(new Map()), + }); + + const meta = await service.getWithMeta("never-constructed"); + expect(meta).toEqual({ prompt: null, cwd: null }); + }); + + it("getWithMeta after construct returns the resolved prompt and the exact cwd", async () => { + // 2. after construct → prompt + exact cwd passed to construct. + const service = createSystemPromptService({ + storage: memoryStorage(), + adapters: adapters(new Map([["/proj/AGENTS.md", "RULES"]])), + }); + + const result = await service.construct("conv-meta", "/proj", { model: "gpt-4" }); + const meta = await service.getWithMeta("conv-meta"); + + expect(meta.prompt).toBe(result); + expect(meta.cwd).toBe("/proj"); + }); + + it("get still returns the same value as before (backward compat)", async () => { + // 3. get() behavior is unchanged by the additive getWithMeta. + const service = createSystemPromptService({ + storage: memoryStorage(), + adapters: adapters(new Map()), + }); + + // before construct → null + expect(await service.get("conv-bc")).toBeNull(); + + const result = await service.construct("conv-bc", "/proj"); + expect(await service.get("conv-bc")).toBe(result); + }); + + it("construct called twice with different cwds stores the latest cwd", async () => { + // 4. second construct overwrites the cwd (not the first). + const storage = memoryStorage(); + await storage.set("template", "[prompt:cwd]"); + const service = createSystemPromptService({ + storage, + adapters: adapters(new Map()), + }); + + await service.construct("conv-twice", "/first"); + expect(await storage.get("resolved-cwd:conv-twice")).toBe("/first"); + + const second = await service.construct("conv-twice", "/second"); + expect(second).toBe("/second"); + expect(await storage.get("resolved-cwd:conv-twice")).toBe("/second"); + expect(await storage.get("resolved-cwd:conv-twice")).not.toBe("/first"); + }); + + it("getWithMeta after a second construct with a different cwd returns the new cwd and new prompt", async () => { + // 5. getWithMeta reflects the latest construct, not the first. + const storage = memoryStorage(); + await storage.set("template", "[prompt:cwd]"); + const service = createSystemPromptService({ + storage, + adapters: adapters(new Map()), + }); + + const first = await service.construct("conv-second", "/dir-a"); + const firstMeta = await service.getWithMeta("conv-second"); + expect(firstMeta).toEqual({ prompt: first, cwd: "/dir-a" }); + + const second = await service.construct("conv-second", "/dir-b"); + const secondMeta = await service.getWithMeta("conv-second"); + expect(secondMeta).toEqual({ prompt: second, cwd: "/dir-b" }); + expect(secondMeta.cwd).not.toBe("/dir-a"); + }); }); diff --git a/packages/system-prompt/src/service.ts b/packages/system-prompt/src/service.ts index 6fdae51..60977bf 100644 --- a/packages/system-prompt/src/service.ts +++ b/packages/system-prompt/src/service.ts @@ -27,6 +27,7 @@ The current working directory is [prompt:cwd]. /** Storage keys. */ const TEMPLATE_KEY = "template"; const resolvedKey = (conversationId: string): string => `resolved:${conversationId}`; +const resolvedCwdKey = (conversationId: string): string => `resolved-cwd:${conversationId}`; export interface SystemPromptServiceDeps { /** Namespaced KV (`host.storage("system-prompt")`). */ @@ -58,6 +59,7 @@ export function createSystemPromptService(deps: SystemPromptServiceDeps): System const result = parseTemplate(template, vars); await deps.storage.set(resolvedKey(conversationId), result); + await deps.storage.set(resolvedCwdKey(conversationId), cwd); return result; }, @@ -65,6 +67,14 @@ export function createSystemPromptService(deps: SystemPromptServiceDeps): System return deps.storage.get(resolvedKey(conversationId)); }, + async getWithMeta(conversationId) { + const [prompt, cwd] = await Promise.all([ + deps.storage.get(resolvedKey(conversationId)), + deps.storage.get(resolvedCwdKey(conversationId)), + ]); + return { prompt, cwd }; + }, + async getTemplate() { const stored = await deps.storage.get(TEMPLATE_KEY); return stored ?? DEFAULT_TEMPLATE; diff --git a/packages/system-prompt/src/types.ts b/packages/system-prompt/src/types.ts index 2690355..4e1db0b 100644 --- a/packages/system-prompt/src/types.ts +++ b/packages/system-prompt/src/types.ts @@ -30,6 +30,16 @@ export interface SystemPromptService { /** Read the persisted resolved system prompt, or `null` if never constructed. */ get(conversationId: string): Promise<string | null>; + /** + * Read the persisted resolved system prompt AND the cwd it was built + * against. Returns `{ prompt: null, cwd: null }` if never constructed. + * Consumers use this to detect whether the cached prompt is stale + * relative to the current effective cwd. + */ + getWithMeta( + conversationId: string, + ): Promise<{ readonly prompt: string | null; readonly cwd: string | null }>; + /** Read the global template (or `DEFAULT_TEMPLATE` when none is stored). */ getTemplate(): Promise<string>; |
