summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/session-orchestrator/src/orchestrator.test.ts107
-rw-r--r--packages/session-orchestrator/src/orchestrator.ts24
-rw-r--r--packages/system-prompt/src/service.test.ts76
-rw-r--r--packages/system-prompt/src/service.ts10
-rw-r--r--packages/system-prompt/src/types.ts10
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>;