diff options
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>; |
