diff --git a/docs/system-prompt-customization.md b/docs/system-prompt-customization.md index 3fd66ede7..eb366635d 100644 --- a/docs/system-prompt-customization.md +++ b/docs/system-prompt-customization.md @@ -47,7 +47,9 @@ The custom template keeps these generated surfaces: - always-apply rules and the rulebook listing; - secret-redaction guidance when enabled. -The separate project/environment footer remains and carries workstation data, deeper-directory context pointers, optional workspace information, current date/cwd, and the final completion requirements. Optional extra system blocks, such as computer-tool safety and active nested-repository context, also remain when applicable. +The separate project/environment footer remains and carries workstation data, deeper-directory context pointers, optional workspace information, and the final completion requirements. Optional extra system blocks, such as computer-tool safety and active nested-repository context, also remain when applicable. + +The current date and working directory no longer live in the footer: they are emitted as a `` block on the first user turn of each provider request (`date-cwd-reminder.md`). Keeping per-request bytes out of the system prompt lets open-weight providers (DeepSeek, Qwen, GLM, …) that render tool schemas after the system content keep their prefix cache, and lets a session crossing midnight refresh the date without rebuilding the prompt (#7404). What disappears is the content unique to the default instruction template: its built-in role/personality text, tool inventory and general tool policy, internal-URL catalog, exploration/delegation/workflow rules, and `xd://` protocol guidance. Generated skills and rules are **not** lost; the custom template renders them explicitly. @@ -79,7 +81,7 @@ on {{#if hasMemoryRoot}}Memory enabled.{{/if}} ``` -those characters reach the model literally. Internal values such as `cwd`, `date`, `skills`, `rules`, and `toolRefs` are private template implementation details, not a user templating API. +those characters reach the model literally. Internal values such as `cwd`, `skills`, `rules`, and `toolRefs` are private template implementation details, not a user templating API. The calendar date is deliberately not exposed as a template value anymore — it rides the per-request first-turn reminder instead (see above). ## Recipes diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6f6108547..80f55c0a1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed prompt caching on open-weight providers (DeepSeek, Qwen, GLM, …) by moving the per-request date/cwd line out of the system prompt into a `` block on the first user turn, so the tool schemas rendered after the system content stay cached across directory changes and midnight rollovers ([#7404](https://github.com/can1357/oh-my-pi/issues/7404)). + ## [17.3.3] - 2026-08-14 ### Fixed diff --git a/packages/coding-agent/src/prompts/system/date-cwd-reminder.md b/packages/coding-agent/src/prompts/system/date-cwd-reminder.md new file mode 100644 index 000000000..ac76906ed --- /dev/null +++ b/packages/coding-agent/src/prompts/system/date-cwd-reminder.md @@ -0,0 +1,3 @@ + +Today: {{date}}; current working directory: '{{cwd}}'. Do not repeat this information in your reply. + diff --git a/packages/coding-agent/src/prompts/system/project-prompt.md b/packages/coding-agent/src/prompts/system/project-prompt.md index a35e4a0c0..fc88ef222 100644 --- a/packages/coding-agent/src/prompts/system/project-prompt.md +++ b/packages/coding-agent/src/prompts/system/project-prompt.md @@ -47,7 +47,6 @@ Additional workspace directories. This CURRENT workspace state supersedes worksp {{/each}} {{/if}} -Today: {{date}}; current working directory: '{{cwd}}'. - Each response MUST advance the task; completion only stopping condition. diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 65f3f5acd..7ad294893 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -136,6 +136,7 @@ import { import { AgentSession, type InitialRetryFallbackState, type PlanYolo, type Prewalk } from "./session/agent-session"; import { discoverAuthStorage as discoverAuthStorageFromConfig } from "./session/auth-broker-config"; import type { AuthStorage } from "./session/auth-storage"; +import { withDateCwdReminder } from "./session/date-cwd-reminder"; import { createInterruptedTurnAbortMessage } from "./session/exit-diagnostics"; import { type CustomMessage, @@ -222,6 +223,8 @@ import { ttsTool } from "./tools/tts"; import { resolveActiveRepoContext } from "./utils/active-repo-context"; import { EventBus } from "./utils/event-bus"; import { normalizeProviderContextImagesForModel } from "./utils/image-loading"; +import { formatLocalCalendarDate } from "./utils/local-date"; +import { normalizePromptPath } from "./utils/prompt-path"; import { buildNamedToolChoice } from "./utils/tool-choice"; import { VibeSessionRegistry } from "./vibe/runtime"; import { buildWorkspaceTree, type WorkspaceTree } from "./workspace-tree"; @@ -3130,7 +3133,15 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro let transformed = obfuscator ? obfuscateProviderContext(obfuscator, context) : context; if (snapcompactInline) transformed = await snapcompactInline.transform(transformed, transformModel); transformed = clampProviderContextImages(transformed, transformModel); - return await normalizeProviderContextImagesForModel(transformed, transformModel); + transformed = await normalizeProviderContextImagesForModel(transformed, transformModel); + // Keep per-request volatility out of the system prompt: the date/cwd + // reminder rides on the first user turn so open-weight providers keep + // their tool-schema prefix cache (#7404). + return withDateCwdReminder( + transformed, + formatLocalCalendarDate(), + normalizePromptPath(sessionManager.getCwd()), + ); }; const onPayload = async (payload: unknown, model?: Model) => { return await extensionRunner.emitBeforeProviderRequest(payload, model); @@ -3771,7 +3782,12 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro transformProviderContext: async (context, transformModel) => { let transformed = obfuscator ? obfuscateProviderContext(obfuscator, context) : context; transformed = clampProviderContextImages(transformed, transformModel); - return await normalizeProviderContextImagesForModel(transformed, transformModel); + transformed = await normalizeProviderContextImagesForModel(transformed, transformModel); + return withDateCwdReminder( + transformed, + formatLocalCalendarDate(), + normalizePromptPath(sessionManager.getCwd()), + ); }, thinkingBudgets: agent.thinkingBudgets, temperature: agent.temperature, diff --git a/packages/coding-agent/src/session/agent-session-types.ts b/packages/coding-agent/src/session/agent-session-types.ts index 54182a25a..50f3be5b8 100644 --- a/packages/coding-agent/src/session/agent-session-types.ts +++ b/packages/coding-agent/src/session/agent-session-types.ts @@ -211,8 +211,6 @@ export interface AgentSessionConfig { toolNames: string[], tools: Map, ) => Promise<{ systemPrompt: string[]; xdevCatalogNames?: readonly string[] }>; - /** Local calendar date provider used by prompt-cache invalidation. */ - getLocalCalendarDate?: () => string; /** Tools mounted under `xd://`, for `/tools` display. */ getXdevToolEntries?: () => Array<{ name: string; summary: string }>; /** `xd://` presentation state backed by the canonical tool map. */ diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index cd5679440..c53d44c8e 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -1295,7 +1295,6 @@ export class AgentSession { presentationPinnedToolNames: config.presentationPinnedToolNames, ensureWriteRegistered: config.ensureWriteRegistered, rebuildSystemPrompt: config.rebuildSystemPrompt, - getLocalCalendarDate: config.getLocalCalendarDate, getMcpServerInstructions: config.getMcpServerInstructions, xdev: config.xdev, setActiveToolNames: config.setActiveToolNames, diff --git a/packages/coding-agent/src/session/date-cwd-reminder.ts b/packages/coding-agent/src/session/date-cwd-reminder.ts new file mode 100644 index 000000000..e49e97d29 --- /dev/null +++ b/packages/coding-agent/src/session/date-cwd-reminder.ts @@ -0,0 +1,77 @@ +/** + * Date/cwd reminder injection. + * + * The system prompt must stay byte-stable so open-weight chat templates that + * render tool schemas *after* the system content keep their prefix cache + * (#7404). The per-request date/cwd line used to live at the tail of the + * system prompt (`project-prompt.md`), which invalidated the whole tool array + * on every directory change or day rollover. It now rides on the first user + * turn of each provider request instead: built at request time (never stored + * in the session), deterministic per `(date, cwd)`, so the bytes are stable + * for the lifetime of a session/day and refresh automatically at midnight. + */ +import type { Context, Message } from "@oh-my-pi/pi-ai"; +import { prompt } from "@oh-my-pi/pi-utils"; +import dateCwdReminderTemplate from "../prompts/system/date-cwd-reminder.md" with { type: "text" }; + +/** Renders the reminder text for the given local calendar date and cwd. */ +export function renderDateCwdReminder(date: string, cwd: string): string { + return prompt.render(dateCwdReminderTemplate, { date, cwd }).trim(); +} + +/** + * Prepends `reminder` to the content of the first user message in `messages`, + * returning a new array. The input is never mutated. Returns the input + * unchanged when there is no user message to attach to, when the first user + * message already carries the exact reminder, or when an identical input was + * injected before with the same reminder. + * + * The memo is required by the append-only context path: it syncs the converted + * message objects into its log and reuses them across requests, and callers + * assert that identity is preserved for the stable prefix. Re-injecting the + * same pristine first user message with the same reminder must hand back the + * same injected message object, not a fresh clone. Keyed on the pristine + * message object (the append-only log hands back fresh array copies every + * turn, so array identity is not stable), with entries garbage-collected + * alongside the messages they belong to. + */ +const injectCache = new WeakMap(); + +export function injectDateCwdReminder(messages: Message[], reminder: string): Message[] { + const index = messages.findIndex(message => message.role === "user"); + if (index < 0) return messages; + const first = messages[index]!; + if (typeof first.content === "string") { + if (first.content.startsWith(reminder)) return messages; + } else if (first.content[0]?.type === "text" && first.content[0].text === reminder) { + return messages; + } + const cached = injectCache.get(first); + if (cached !== undefined && cached.reminder === reminder) { + const out = messages.slice(); + out[index] = cached.injected; + return out; + } + const content = + typeof first.content === "string" + ? `${reminder}\n\n${first.content}` + : ([{ type: "text", text: reminder }, ...first.content] as Message["content"]); + const injected = { ...first, content } as Message; + injectCache.set(first, { reminder, injected }); + const out = messages.slice(); + out[index] = injected; + return out; +} + +/** + * Applies the date/cwd reminder to a provider `Context`, keeping the system + * prompt byte-stable for prompt caching. Skips NULL_PROMPT-style contexts + * (empty system prompt) so a no-prompt session stays byte-for-byte unchanged. + */ +export function withDateCwdReminder(context: Context, date: string, cwd: string): Context { + if (!context.systemPrompt || context.systemPrompt.length === 0) return context; + if (context.messages.length === 0) return context; + const reminder = renderDateCwdReminder(date, cwd); + const messages = injectDateCwdReminder(context.messages, reminder); + return messages === context.messages ? context : { ...context, messages }; +} diff --git a/packages/coding-agent/src/session/session-tools.ts b/packages/coding-agent/src/session/session-tools.ts index 682fa2b9d..cfc03b3b4 100644 --- a/packages/coding-agent/src/session/session-tools.ts +++ b/packages/coding-agent/src/session/session-tools.ts @@ -26,7 +26,6 @@ import { ToolAbortError, ToolError } from "../tools/tool-errors"; import { isMountableUnderXdev, listXdevTools, type XdevState, xdevDocsFor, xdevEntries } from "../tools/xdev"; import { type EditMode, resolveEditMode } from "../utils/edit-mode"; import { type InspectImageMode, isInspectImageToolActive } from "../utils/inspect-image-mode"; -import { formatLocalCalendarDate } from "../utils/local-date"; import { extractPermissionLocations, getPermissionIntent, @@ -82,7 +81,6 @@ interface SessionToolsOptions { toolNames: string[], tools: Map, ) => Promise<{ systemPrompt: string[]; xdevCatalogNames?: readonly string[] }>; - getLocalCalendarDate?: () => string; getMcpServerInstructions?: () => Map | undefined; xdev?: XdevState; setActiveToolNames?: (names: Iterable) => void; @@ -229,7 +227,6 @@ export class SessionTools { #toolRegistryMutationTail: Promise = Promise.resolve(); #promptModelKey: string | undefined; #rebuildSystemPrompt: SessionToolsOptions["rebuildSystemPrompt"]; - #getLocalCalendarDate: () => string; #getMcpServerInstructions: SessionToolsOptions["getMcpServerInstructions"]; #setActiveToolNames: SessionToolsOptions["setActiveToolNames"]; #ensureWriteRegistered: SessionToolsOptions["ensureWriteRegistered"]; @@ -262,7 +259,6 @@ export class SessionTools { this.#presentationPinnedToolNames = options.presentationPinnedToolNames; this.#ensureWriteRegistered = options.ensureWriteRegistered; this.#rebuildSystemPrompt = options.rebuildSystemPrompt; - this.#getLocalCalendarDate = options.getLocalCalendarDate ?? formatLocalCalendarDate; this.#getMcpServerInstructions = options.getMcpServerInstructions; this.#xdev = options.xdev; if (this.#xdev && this.#xdev.tools !== this.#toolRegistry) { @@ -1434,10 +1430,10 @@ export class SessionTools { * For everything else, callers must explicitly call {@link refreshBaseSystemPrompt} * after side-effecting changes; see the memory hooks and {@link syncAfterModelChange}. * - * The current calendar date IS covered (appended as a segment) because - * `buildSystemPrompt` injects it into the prompt body (`Today is '{{date}}'`). - * Without this, a session spanning midnight with only tool-stable MCP - * reconnects would keep yesterday's date indefinitely. + * The calendar date is deliberately NOT part of the signature: the date/cwd + * reminder rides on the first user turn at request time (`date-cwd-reminder`), + * so a session spanning midnight must NOT rebuild a prompt that no longer + * embeds the date — the reminder picks up the new day on its own. */ #computeAppliedToolSignature(toolNames: string[], tools: AgentTool[]): string { // Order-preserving join: any reorder must produce a different signature so @@ -1471,8 +1467,7 @@ export class SessionTools { // the provider cache prefix byte-stable. Mounted MCP routes are the narrow // exception above, bounded to the exact projection rendered in the global // route guidance so churn wholly behind its fallback does not rebuild. - const date = this.#getLocalCalendarDate(); - return `${nameSegment}\u0003${descriptionSegment}\u0007${instructionsSegment}\u0008${mountedMCPRouteSegment}|${date}`; + return `${nameSegment}\u0003${descriptionSegment}\u0007${instructionsSegment}\u0008${mountedMCPRouteSegment}`; } /** diff --git a/packages/coding-agent/src/system-prompt.ts b/packages/coding-agent/src/system-prompt.ts index 408a4f9da..bea09d051 100644 --- a/packages/coding-agent/src/system-prompt.ts +++ b/packages/coding-agent/src/system-prompt.ts @@ -27,7 +27,6 @@ import systemPromptTemplate from "./prompts/system/system-prompt.md" with { type import { normalizeConcurrencyLimit } from "./task/parallel"; import { usesCodexTaskPrompt } from "./task/prompt-policy"; import { type ActiveRepoContext, resolveActiveRepoContext } from "./utils/active-repo-context"; -import { formatLocalCalendarDate } from "./utils/local-date"; import { normalizePromptPath } from "./utils/prompt-path"; import { AGENTS_MD_LIMIT, buildWorkspaceTree, type WorkspaceTree } from "./workspace-tree"; @@ -784,8 +783,6 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): } } - const date = formatLocalCalendarDate(); - const dateTime = date; const promptCwd = normalizePromptPath(resolvedCwd); const activeRepoContextPrompt = renderActiveRepoContextPrompt(activeRepoContext); @@ -870,8 +867,6 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): skills: filteredSkills, rules: rules ?? [], alwaysApplyRules: injectedAlwaysApplyRules, - date, - dateTime, cwd: promptCwd, additionalWorkspaceRoots: additionalWorkspaceRoots.filter(d => path.resolve(d) !== path.resolve(resolvedCwd)), model: includeModelInPrompt ? (model ?? "") : "", diff --git a/packages/coding-agent/test/agent-session-message-pipeline.test.ts b/packages/coding-agent/test/agent-session-message-pipeline.test.ts index fcf3823e0..251252339 100644 --- a/packages/coding-agent/test/agent-session-message-pipeline.test.ts +++ b/packages/coding-agent/test/agent-session-message-pipeline.test.ts @@ -333,7 +333,10 @@ describe("AgentSession message pipeline", () => { expect(contexts).toHaveLength(1); const userMessage = contexts[0]!.messages.find(message => message.role === "user"); + // The date/cwd reminder rides on the first user turn (#7404); the contract + // here is that the undecodable WebP is replaced by the placeholder text. expect(userMessage?.content).toEqual([ + { type: "text", text: expect.stringContaining("") }, { type: "text", text: "inspect this" }, { type: "text", text: "[image omitted: WebP could not be decoded for this model]" }, ]); diff --git a/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts b/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts index 7856cce05..9de7b0c14 100644 --- a/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts +++ b/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts @@ -98,7 +98,6 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => { interface NewSessionOptions { getMcpServerInstructions?: () => Map | undefined; - getLocalCalendarDate?: () => string; xdev?: XdevState; lazyWrite?: boolean; /** Scripted mock model responses; enables driving `session.prompt()`. */ @@ -183,7 +182,6 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => { return { systemPrompt: [`${base}\nxd:// catalog: ${catalog.join(",")}`], xdevCatalogNames: catalog }; }, getMcpServerInstructions: options.getMcpServerInstructions, - getLocalCalendarDate: options.getLocalCalendarDate, xdev: options.xdev, }); sessions.push(session); @@ -822,39 +820,7 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => { await session.refreshMCPTools([dynamicTool]); expect(rebuildCount).toBe(baseline + 1); }); - it("rebuilds when the local calendar date rolls over between tool-stable MCP refreshes", async () => { - // `buildSystemPrompt` injects today's local date into the prompt body. The - // signature reads the same date provider so a session spanning local midnight - // must rebuild after an MCP reconnect with an otherwise identical tool set. - let currentDate = "2026-06-30"; - let rebuildCount = 0; - const { session } = newSession( - async toolNames => { - rebuildCount++; - return `tools:${toolNames.join(",")}`; - }, - { getLocalCalendarDate: () => currentDate }, - ); - const tool = createMcpCustomTool("mcp__nucleus_search", "nucleus", "search", "Search"); - // First refresh: no signature yet, must rebuild. - await session.refreshMCPTools([tool]); - expect(rebuildCount).toBe(1); - - // Same tools, same local day: signature matches, skip. - await session.refreshMCPTools([tool]); - expect(rebuildCount).toBe(1); - - currentDate = "2026-07-01"; - - // Same tools, new local calendar day: date segment changed, must rebuild. - await session.refreshMCPTools([tool]); - expect(rebuildCount).toBe(2); - - // Same tools, same new local day: skip again. - await session.refreshMCPTools([tool]); - expect(rebuildCount).toBe(2); - }); it("does not rebuild when MCP server instructions change only beyond the 4000-char truncation boundary", async () => { // `rebuildSystemPrompt` (sdk.ts) truncates each server instruction to 4000 chars // before embedding it. The `getMcpServerInstructions` callback must therefore @@ -1268,19 +1234,17 @@ These tools became available: it("rolls back MCP catalog replacement when prompt rebuild fails", async () => { let failRebuild = false; - let date = "2026-07-16"; const xdevState = createTestXdevState(); const { session } = newSession( async toolNames => { if (failRebuild) throw new Error("rebuild failed"); return `tools:${toolNames.join(",")}`; }, - { xdev: xdevState, getLocalCalendarDate: () => date }, + { xdev: xdevState }, ); const oldTool = createMcpCustomTool("mcp__nucleus_old", "nucleus", "old", "Old tool"); const newTool = createMcpCustomTool("mcp__nucleus_new", "nucleus", "new", "New tool"); await session.refreshMCPTools([oldTool]); - date = "2026-07-17"; failRebuild = true; await expect(session.refreshMCPTools([newTool])).rejects.toThrow("rebuild failed"); @@ -1297,30 +1261,32 @@ These tools became available: it("rolls back RPC catalog replacement when prompt rebuild fails", async () => { let failRebuild = false; - let date = "2026-07-16"; const xdevState = createTestXdevState(); const { session } = newSession( async toolNames => { if (failRebuild) throw new Error("rebuild failed"); return `tools:${toolNames.join(",")}`; }, - { xdev: xdevState, getLocalCalendarDate: () => date }, + { xdev: xdevState }, ); - const oldTool = { ...createBasicTool("rpc_old", "RPC Old"), loadMode: "discoverable" as const }; - const newTool = { ...createBasicTool("rpc_new", "RPC New"), loadMode: "discoverable" as const }; + // Non-discoverable RPC tools stay active top-level, so replacing the catalog + // (old → new) changes the rebuild signature on its own — the replacement + // itself must trigger the failing rebuild that gets rolled back. + const oldTool = createBasicTool("rpc_old", "RPC Old"); + const newTool = createBasicTool("rpc_new", "RPC New"); await session.refreshRpcHostTools([oldTool]); - date = "2026-07-17"; failRebuild = true; await expect(session.refreshRpcHostTools([newTool])).rejects.toThrow("rebuild failed"); expect(session.getToolByName(oldTool.name)).toBeDefined(); expect(session.getToolByName(newTool.name)).toBeUndefined(); - expect(session.getMountedXdevToolNames()).toContain(oldTool.name); + expect(session.getActiveToolNames()).toContain(oldTool.name); + expect(session.getActiveToolNames()).not.toContain(newTool.name); failRebuild = false; await session.refreshRpcHostTools([newTool]); expect(session.getToolByName(oldTool.name)).toBeUndefined(); expect(session.getToolByName(newTool.name)).toBeDefined(); - expect(session.getMountedXdevToolNames()).toContain(newTool.name); + expect(session.getActiveToolNames()).toContain(newTool.name); }); }); diff --git a/packages/coding-agent/test/date-cwd-reminder.test.ts b/packages/coding-agent/test/date-cwd-reminder.test.ts new file mode 100644 index 000000000..f08ed16ca --- /dev/null +++ b/packages/coding-agent/test/date-cwd-reminder.test.ts @@ -0,0 +1,223 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import type { Api, Context, Message, Model, ModelSpec } from "@oh-my-pi/pi-ai"; +import { clearCustomApis, registerCustomApi } from "@oh-my-pi/pi-ai"; +import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; +import { buildModel } from "@oh-my-pi/pi-catalog/build"; +import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { createAgentSession } from "@oh-my-pi/pi-coding-agent/sdk"; +import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import { + injectDateCwdReminder, + renderDateCwdReminder, + withDateCwdReminder, +} from "@oh-my-pi/pi-coding-agent/session/date-cwd-reminder"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { formatLocalCalendarDate } from "@oh-my-pi/pi-coding-agent/utils/local-date"; +import { normalizePromptPath } from "@oh-my-pi/pi-coding-agent/utils/prompt-path"; +import { TempDir } from "@oh-my-pi/pi-utils"; +import { createAssistantMessage } from "./helpers/agent-session-setup"; + +describe("date-cwd-reminder", () => { + afterEach(() => { + clearCustomApis(); + }); + + describe("renderDateCwdReminder", () => { + it("renders a system-reminder block carrying the date and cwd with a do-not-repeat instruction", () => { + const reminder = renderDateCwdReminder("2026-08-14", "C:/work/omp"); + + expect(reminder.startsWith("")).toBe(true); + expect(reminder.endsWith("")).toBe(true); + expect(reminder).toContain("2026-08-14"); + expect(reminder).toContain("C:/work/omp"); + expect(reminder).toContain("Do not repeat"); + }); + }); + + describe("injectDateCwdReminder", () => { + it("prepends the reminder to the first user message with string content without mutating the input", () => { + const messages: Message[] = [{ role: "user", content: "hello", timestamp: 1 }, createAssistantMessage("hi")]; + const original = [...messages]; + + const out = injectDateCwdReminder(messages, "x"); + + expect(out).not.toBe(messages); + expect(out[0]).toEqual({ + role: "user", + content: "x\n\nhello", + timestamp: 1, + }); + expect(out[1]).toBe(messages[1]); + expect(messages).toEqual(original); + }); + + it("prepends a text part before image parts when the first user message has array content", () => { + const messages: Message[] = [ + { + role: "user", + content: [{ type: "image", data: "img", mimeType: "image/png" }], + timestamp: 1, + }, + ]; + + const out = injectDateCwdReminder(messages, "x"); + + expect(out[0]?.content).toEqual([ + { type: "text", text: "x" }, + { type: "image", data: "img", mimeType: "image/png" }, + ]); + }); + + it("returns the input unchanged when there is no user message", () => { + const messages: Message[] = [createAssistantMessage("hi")]; + + expect(injectDateCwdReminder(messages, "x")).toBe(messages); + expect(injectDateCwdReminder([], "x")).toEqual([]); + }); + + it("reuses the same injected message object for the same pristine first user message and reminder", () => { + // The append-only context path hands back fresh array copies every turn + // but reuses the same message objects; the injected first-turn message + // must keep its identity so the stable prefix is preserved (and the + // provider prompt cache is not churned by fresh clones). + const pristine: Message = { role: "user", content: "first", timestamp: 1 }; + const reminder = "x"; + + const first = injectDateCwdReminder([pristine], reminder)[0]!; + const second = injectDateCwdReminder([pristine], reminder)[0]!; + expect(second).toBe(first); + + // A changed reminder (e.g. midnight rollover) must re-inject fresh. + const refreshed = injectDateCwdReminder([pristine], "y")[0]!; + expect(refreshed).not.toBe(first); + expect(refreshed.content).toContain("y"); + }); + + it("does not double-wrap when the first user message already carries the reminder", () => { + const reminder = "x"; + const messages: Message[] = [{ role: "user", content: `${reminder}\n\nfirst`, timestamp: 1 }]; + + expect(injectDateCwdReminder(messages, reminder)).toBe(messages); + }); + }); + + describe("withDateCwdReminder", () => { + it("leaves NULL_PROMPT-style contexts (empty system prompt) untouched", () => { + const context: Context = { systemPrompt: [], messages: [{ role: "user", content: "hi", timestamp: 1 }] }; + expect(withDateCwdReminder(context, "2026-08-14", "/cwd")).toBe(context); + }); + + it("injects the reminder into the first user message and keeps the system prompt bytes", () => { + const systemPrompt = ["PROJECT\n\n- Must act.\n"]; + const context: Context = { + systemPrompt, + messages: [{ role: "user", content: "do the thing", timestamp: 1 }], + }; + + const out = withDateCwdReminder(context, "2026-08-14", "/work/omp"); + + expect(out).not.toBe(context); + expect(out.systemPrompt).toBe(systemPrompt); + expect(out.messages[0]).toEqual({ + role: "user", + content: `${renderDateCwdReminder("2026-08-14", "/work/omp")}\n\ndo the thing`, + timestamp: 1, + }); + }); + }); +}); + +describe("date-cwd reminder on the provider wire", () => { + const sessions: Array<{ dispose(): Promise }> = []; + + afterEach(async () => { + clearCustomApis(); + for (const session of sessions.splice(0)) { + await session.dispose(); + } + }); + + it("keeps the date/cwd out of the system prompt and pins the reminder to the first user turn across requests", async () => { + using tempDir = TempDir.createSync("@pi-date-cwd-reminder-"); + const api = "test-date-cwd-reminder"; + const contexts: Context[] = []; + registerCustomApi(api, (_model, context) => { + contexts.push(context); + const stream = new AssistantMessageEventStream(); + queueMicrotask(() => { + const message = createAssistantMessage("ok"); + stream.push({ type: "text_delta", contentIndex: 0, delta: "ok", partial: message }); + stream.push({ type: "done", reason: "stop", message }); + }); + return stream; + }); + const model = buildModel({ + id: "date-cwd-reminder", + name: "Date cwd reminder", + api, + provider: "managed-primary", + baseUrl: "http://127.0.0.1:8080/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 4096, + maxTokens: 1024, + } as ModelSpec) as Model; + const authStorage = await AuthStorage.create(tempDir.join("auth.db")); + authStorage.setRuntimeApiKey(model.provider, "test-key"); + const modelRegistry = new ModelRegistry(authStorage, tempDir.join("models.yml")); + const { session } = await createAgentSession({ + cwd: tempDir.path(), + agentDir: tempDir.path(), + sessionManager: SessionManager.inMemory(tempDir.path()), + authStorage, + modelRegistry, + settings: Settings.isolated({ "compaction.enabled": false }), + model, + disableExtensionDiscovery: true, + skills: [], + contextFiles: [], + promptTemplates: [], + slashCommands: [], + enableMCP: false, + enableLsp: false, + skipPythonPreflight: true, + taskDepth: 1, + agentId: "SubAgent", + }); + sessions.push(session); + + try { + await session.sendUserMessage("first"); + + expect(contexts).toHaveLength(1); + // The volatile line must no longer live in the system prompt: open-weight + // chat templates render tool schemas after the system content, so any + // per-request byte there invalidates the whole tool-schema cache (#7404). + const systemPrompt = contexts[0]!.systemPrompt?.join("\n") ?? ""; + expect(systemPrompt).not.toContain("Today"); + expect(systemPrompt).not.toContain("current working directory"); + expect(systemPrompt).not.toContain(formatLocalCalendarDate()); + + const firstUser = contexts[0]!.messages[0]!; + expect(firstUser.role).toBe("user"); + const firstText = + typeof firstUser.content === "string" ? firstUser.content : JSON.stringify(firstUser.content); + expect(firstText).toContain(""); + expect(firstText).toContain(formatLocalCalendarDate()); + expect(firstText).toContain(normalizePromptPath(tempDir.path())); + + // A second request must re-emit byte-identical reminder bytes so the + // conversation prefix (system + tools + first turn) stays cached. + await session.sendUserMessage("second"); + expect(contexts).toHaveLength(2); + const secondFirst = contexts[1]!.messages[0]!; + expect(secondFirst.role).toBe("user"); + expect(typeof secondFirst.content).toBe(typeof firstUser.content); + expect(secondFirst.content).toEqual(firstUser.content); + } finally { + authStorage.close(); + } + }); +}); diff --git a/packages/coding-agent/test/system-prompt-dedup.test.ts b/packages/coding-agent/test/system-prompt-dedup.test.ts index b38c8e574..4d8cd6504 100644 --- a/packages/coding-agent/test/system-prompt-dedup.test.ts +++ b/packages/coding-agent/test/system-prompt-dedup.test.ts @@ -39,7 +39,11 @@ describe("SYSTEM.md prompt assembly", () => { afterEach(cleanupTempHome(() => ({ tempDir, tempHomeDir, originalHome }))); - it("renders an absolute cwd beneath the user's home directory", async () => { + it("keeps per-request date/cwd out of the system prompt footer", async () => { + // The date/cwd line was moved out of the system prompt and onto the first + // user turn (#7404): any byte that changes per request at the tail of the + // system block invalidates the tool-schema prefix cache on open-weight + // providers. The footer must not interpolate the cwd or the date. const projectDir = path.join(os.homedir(), "project"); const { systemPrompt } = await buildSystemPrompt({ cwd: projectDir, @@ -59,8 +63,9 @@ describe("SYSTEM.md prompt assembly", () => { const promptText = systemPrompt.join("\n\n"); const normalizedProjectDir = projectDir.replace(/\\/g, "/"); - // cwd interpolation: the quoted absolute path appears in the footer line. - expect(promptText).toContain(`'${normalizedProjectDir}'`); + expect(promptText).not.toContain(normalizedProjectDir); + expect(promptText).not.toContain("Today"); + expect(promptText).not.toContain("current working directory"); }); it("renders SYSTEM.md exactly once when it is used as the custom base prompt", async () => { @@ -156,13 +161,14 @@ describe("SYSTEM.md prompt assembly", () => { }); const promptText = systemPrompt.join("\n\n"); - const normalizedProjectDir = projectDir.replace(/\\/g, "/"); const appendMatches = promptText.match(new RegExp(escapeRegExp(appendPrompt), "g")) ?? []; expect(systemPrompt).toHaveLength(2); expect(promptText).toContain("CLI custom prompt"); expect(promptText).toContain(""); expect(promptText).toContain(""); - expect(promptText).toContain(`'${normalizedProjectDir}'`); + // The project/environment footer survives even though the date/cwd line was + // relocated out of it; is rendered only by that footer. + expect(promptText).toContain(""); expect(appendMatches).toHaveLength(1); expect(promptText).not.toContain("Discovered project SYSTEM prompt"); }); diff --git a/packages/coding-agent/test/system-prompt-model.test.ts b/packages/coding-agent/test/system-prompt-model.test.ts index ed07a7dd9..a1edda2e8 100644 --- a/packages/coding-agent/test/system-prompt-model.test.ts +++ b/packages/coding-agent/test/system-prompt-model.test.ts @@ -34,31 +34,21 @@ async function expectPromptDateFromStartupTimezone(options: { await Bun.write( scenarioPath, `import { setSystemTime } from "bun:test"; -import { buildSystemPrompt } from ${JSON.stringify(path.resolve(import.meta.dir, "../src/system-prompt.ts"))}; +import { renderDateCwdReminder } from ${JSON.stringify( + path.resolve(import.meta.dir, "../src/session/date-cwd-reminder.ts"), + )}; +import { formatLocalCalendarDate } from ${JSON.stringify(path.resolve(import.meta.dir, "../src/utils/local-date.ts"))}; setSystemTime(new Date(process.env.OMP_TEST_NOW!)); try { - const { systemPrompt } = await buildSystemPrompt({ - cwd: process.cwd(), - contextFiles: [], - skills: [], - rules: [], - toolNames: [], - workspaceTree: { - rootPath: process.cwd(), - rendered: "", - truncated: false, - totalLines: 0, - agentsMdFiles: [], - }, - activeRepoContext: null, - }); - const rendered = systemPrompt.join("\\n\\n"); - if (!rendered.includes(\`Today: \${process.env.OMP_EXPECTED_DATE}\`)) { - throw new Error(\`Prompt did not contain expected local date:\\n\${rendered}\`); + // The date/cwd reminder is built per request in the startup local timezone; + // the system prompt no longer embeds the date (#7404). + const reminder = renderDateCwdReminder(formatLocalCalendarDate(), "/cwd"); + if (!reminder.includes(\`Today: \${process.env.OMP_EXPECTED_DATE}\`)) { + throw new Error(\`Reminder did not contain expected local date:\\n\${reminder}\`); } - if (rendered.includes(\`Today: \${process.env.OMP_REJECTED_DATE}\`)) { - throw new Error(\`Prompt contained rejected UTC date:\\n\${rendered}\`); + if (reminder.includes(\`Today: \${process.env.OMP_REJECTED_DATE}\`)) { + throw new Error(\`Reminder contained rejected UTC date:\\n\${reminder}\`); } } finally { setSystemTime(); @@ -114,7 +104,7 @@ describe("system prompt model identifier", () => { expect(systemPrompt.join("\n\n")).toContain("Model: anthropic/claude-opus-4"); }); - it("renders the prompt date from the startup local timezone rather than UTC", async () => { + it("renders the first-turn reminder date from the startup local timezone rather than UTC", async () => { await expectPromptDateFromStartupTimezone({ tempDir, tempHomeDir,