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 | |
| 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')
| -rw-r--r-- | packages/session-orchestrator/src/orchestrator.test.ts | 107 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/orchestrator.ts | 24 | ||||
| -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 |
5 files changed, 210 insertions, 17 deletions
diff --git a/packages/session-orchestrator/src/orchestrator.test.ts b/packages/session-orchestrator/src/orchestrator.test.ts index 3b79bd5..940538c 100644 --- a/packages/session-orchestrator/src/orchestrator.test.ts +++ b/packages/session-orchestrator/src/orchestrator.test.ts @@ -3327,11 +3327,18 @@ function createFakeSystemPromptService( cwd: string, context?: { readonly model?: string }, ) => Promise<string>, - getImpl: (conversationId: string) => Promise<string | null> = () => Promise.resolve(null), + getWithMetaImpl: ( + conversationId: string, + ) => Promise<{ readonly prompt: string | null; readonly cwd: string | null }> = () => + Promise.resolve({ prompt: null, cwd: null }), ): SystemPromptService { return { construct: constructImpl, - get: getImpl, + async get(conversationId) { + const meta = await getWithMetaImpl(conversationId); + return meta.prompt; + }, + getWithMeta: getWithMetaImpl, async getTemplate() { return ""; }, @@ -3393,7 +3400,7 @@ describe("system prompt: regular turn flow", () => { expect(captured[0]?.providerOpts?.systemPrompt).toBe("CONSTRUCTED_PROMPT"); }); - it("Subsequent turn: get called — existing conversation (meta non-null) → get called → result set on providerOpts.systemPrompt", async () => { + it("Subsequent turn: stored cwd === effective cwd → uses cached prompt (no construct)", async () => { const store = createInMemoryStore(); // Seed an existing conversation so getConversationMeta returns non-null. await store.append("conv-sp-sub", [ @@ -3405,7 +3412,7 @@ describe("system prompt: regular turn flow", () => { const { captured, captureRunTurn } = createCapturingRunTurn(); const constructCalls: string[] = []; - const getCalls: string[] = []; + const getWithMetaCalls: string[] = []; const { orchestrator } = createSessionOrchestrator({ conversationStore: store, @@ -3420,8 +3427,8 @@ describe("system prompt: regular turn flow", () => { return "SHOULD_NOT_BE_USED"; }, async (conversationId) => { - getCalls.push(conversationId); - return "PERSISTED_PROMPT"; + getWithMetaCalls.push(conversationId); + return { prompt: "PERSISTED_PROMPT", cwd: "/work/dir" }; }, ), }); @@ -3430,17 +3437,18 @@ describe("system prompt: regular turn flow", () => { conversationId: "conv-sp-sub", text: "second", onEvent: () => {}, + cwd: "/work/dir", }); - expect(getCalls).toHaveLength(1); - expect(getCalls[0]).toBe("conv-sp-sub"); + expect(getWithMetaCalls).toHaveLength(1); + expect(getWithMetaCalls[0]).toBe("conv-sp-sub"); expect(constructCalls).toHaveLength(0); expect(captured).toHaveLength(1); expect(captured[0]?.providerOpts?.systemPrompt).toBe("PERSISTED_PROMPT"); }); - it("Subsequent turn: get returns null → systemPrompt omitted from providerOpts", async () => { + it("Subsequent turn: no stored prompt (getWithMeta returns null) → calls construct", async () => { const store = createInMemoryStore(); await store.append("conv-sp-null", [ { role: "user", chunks: [{ type: "text", text: "first" }] }, @@ -3450,6 +3458,12 @@ describe("system prompt: regular turn flow", () => { const provider: ProviderContract = { id: "p", stream: async function* () {} }; const { captured, captureRunTurn } = createCapturingRunTurn(); + const constructCalls: Array<{ + conversationId: string; + cwd: string; + model: string | undefined; + }> = []; + const { orchestrator } = createSessionOrchestrator({ conversationStore: store, resolveProvider: () => provider, @@ -3458,8 +3472,11 @@ describe("system prompt: regular turn flow", () => { runTurn: captureRunTurn, resolveSystemPrompt: () => createFakeSystemPromptService( - async () => "SHOULD_NOT_BE_USED", - async () => null, + async (conversationId, cwd, context) => { + constructCalls.push({ conversationId, cwd, model: context?.model }); + return "RECONSTRUCTED_PROMPT"; + }, + async () => ({ prompt: null, cwd: null }), ), }); @@ -3467,10 +3484,76 @@ describe("system prompt: regular turn flow", () => { conversationId: "conv-sp-null", text: "second", onEvent: () => {}, + cwd: "/work/dir", + modelName: "my-model", }); + expect(constructCalls).toHaveLength(1); + expect(constructCalls[0]?.conversationId).toBe("conv-sp-null"); + expect(constructCalls[0]?.cwd).toBe("/work/dir"); + expect(constructCalls[0]?.model).toBe("my-model"); + expect(captured).toHaveLength(1); - expect(captured[0]?.providerOpts?.systemPrompt).toBeUndefined(); + expect(captured[0]?.providerOpts?.systemPrompt).toBe("RECONSTRUCTED_PROMPT"); + }); + + it("Subsequent turn: stored cwd ≠ effective cwd → calls construct with new cwd (prompt rebuilt)", async () => { + const store = createInMemoryStore(); + await store.append("conv-sp-cwd-change", [ + { role: "user", chunks: [{ type: "text", text: "first" }] }, + { role: "assistant", chunks: [{ type: "text", text: "reply" }] }, + ]); + + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const constructCalls: Array<{ + conversationId: string; + cwd: string; + model: string | undefined; + }> = []; + const getWithMetaCalls: string[] = []; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + resolveSystemPrompt: () => + createFakeSystemPromptService( + async (conversationId, cwd, context) => { + constructCalls.push({ conversationId, cwd, model: context?.model }); + return "REBUILT_PROMPT"; + }, + async (conversationId) => { + getWithMetaCalls.push(conversationId); + // Stored prompt was built against an OLD cwd. + return { prompt: "STALE_PROMPT", cwd: "/old/dir" }; + }, + ), + }); + + await orchestrator.handleMessage({ + conversationId: "conv-sp-cwd-change", + text: "second", + onEvent: () => {}, + // Current turn's effective cwd differs from the stored cwd. + cwd: "/new/dir", + modelName: "my-model", + }); + + expect(getWithMetaCalls).toHaveLength(1); + expect(getWithMetaCalls[0]).toBe("conv-sp-cwd-change"); + + expect(constructCalls).toHaveLength(1); + expect(constructCalls[0]?.conversationId).toBe("conv-sp-cwd-change"); + expect(constructCalls[0]?.cwd).toBe("/new/dir"); + expect(constructCalls[0]?.model).toBe("my-model"); + + expect(captured).toHaveLength(1); + // The rebuilt prompt is used — NOT the stale cached one. + expect(captured[0]?.providerOpts?.systemPrompt).toBe("REBUILT_PROMPT"); }); it("Service unavailable: no system prompt — resolveSystemPrompt is undefined → providerOpts.systemPrompt is NOT set", async () => { diff --git a/packages/session-orchestrator/src/orchestrator.ts b/packages/session-orchestrator/src/orchestrator.ts index 1403288..6a49ee1 100644 --- a/packages/session-orchestrator/src/orchestrator.ts +++ b/packages/session-orchestrator/src/orchestrator.ts @@ -496,10 +496,15 @@ export function createSessionOrchestrator( // Resolve the system prompt for this turn (cache-safe). On the // FIRST turn of a new conversation, construct it once (resolves all // template variables + persists the result). On subsequent turns, - // reuse the persisted prompt via `get` (no reconstruction — the - // system prompt is part of the cacheable prefix). When the - // system-prompt service isn't loaded, no system prompt is sent - // (current behavior preserved). + // reuse the persisted prompt via `getWithMeta` — but ONLY when the + // stored cwd matches the current effective cwd. If the cwd changed + // since the prompt was constructed (or no prompt was ever stored), + // reconstruct against the new cwd so the prompt is never stale. + // This preserves the cache-safe design (construct once per cwd, + // reuse on subsequent turns with the same cwd) while fixing the bug + // where a cwd change left the prompt stale. When the system-prompt + // service isn't loaded, no system prompt is sent (current behavior + // preserved). const systemPromptService = deps.resolveSystemPrompt?.(); let systemPrompt: string | undefined; if (systemPromptService !== undefined) { @@ -513,7 +518,16 @@ export function createSessionOrchestrator( }, ); } else { - systemPrompt = (await systemPromptService.get(conversationId)) ?? undefined; + const meta = await systemPromptService.getWithMeta(conversationId); + const currentCwd = effectiveCwd ?? process.cwd(); + if (meta.prompt !== null && meta.cwd === currentCwd) { + systemPrompt = meta.prompt; + } else { + systemPrompt = await systemPromptService.construct(conversationId, currentCwd, { + ...(modelName !== undefined ? { model: modelName } : {}), + ...(workspaceId !== undefined ? { workspaceId } : {}), + }); + } } } 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>; |
