fix(coding-agent): keep the system prompt byte-stable across date/cwd changes

Move the per-request date/cwd line out of the system prompt into a
first-turn system-reminder so open-weight providers keep their tool-schema
prefix cache; the reminder refreshes itself at midnight. Closes #7404.

Generated with Codebuff 🤖
Co-Authored-By: Codebuff <noreply@codebuff.com>
This commit is contained in:
MertSoylu
2026-08-14 13:04:39 +03:00
parent ad318c7572
commit 30f92ad986
15 changed files with 370 additions and 94 deletions
+4 -2
View File
@@ -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 `<system-reminder>` 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
+4
View File
@@ -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 `<system-reminder>` 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
@@ -0,0 +1,3 @@
<system-reminder>
Today: {{date}}; current working directory: '{{cwd}}'. Do not repeat this information in your reply.
</system-reminder>
@@ -47,7 +47,6 @@ Additional workspace directories. This CURRENT workspace state supersedes worksp
{{/each}}
</workspace-roots>
{{/if}}
Today: {{date}}; current working directory: '{{cwd}}'.
<critical>
- Each response MUST advance the task; completion only stopping condition.
+18 -2
View File
@@ -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,
@@ -211,8 +211,6 @@ export interface AgentSessionConfig {
toolNames: string[],
tools: Map<string, AgentTool>,
) => 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. */
@@ -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,
@@ -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<Message, { reminder: string; injected: Message }>();
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 };
}
@@ -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<string, AgentTool>,
) => Promise<{ systemPrompt: string[]; xdevCatalogNames?: readonly string[] }>;
getLocalCalendarDate?: () => string;
getMcpServerInstructions?: () => Map<string, string> | undefined;
xdev?: XdevState;
setActiveToolNames?: (names: Iterable<string>) => void;
@@ -229,7 +227,6 @@ export class SessionTools {
#toolRegistryMutationTail: Promise<void> = 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}`;
}
/**
@@ -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 ?? "") : "",
@@ -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("<system-reminder>") },
{ type: "text", text: "inspect this" },
{ type: "text", text: "[image omitted: WebP could not be decoded for this model]" },
]);
@@ -98,7 +98,6 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => {
interface NewSessionOptions {
getMcpServerInstructions?: () => Map<string, string> | 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);
});
});
@@ -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("<system-reminder>")).toBe(true);
expect(reminder.endsWith("</system-reminder>")).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, "<system-reminder>x</system-reminder>");
expect(out).not.toBe(messages);
expect(out[0]).toEqual({
role: "user",
content: "<system-reminder>x</system-reminder>\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, "<system-reminder>x</system-reminder>");
expect(out[0]?.content).toEqual([
{ type: "text", text: "<system-reminder>x</system-reminder>" },
{ 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, "<system-reminder>x</system-reminder>")).toBe(messages);
expect(injectDateCwdReminder([], "<system-reminder>x</system-reminder>")).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 = "<system-reminder>x</system-reminder>";
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], "<system-reminder>y</system-reminder>")[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 = "<system-reminder>x</system-reminder>";
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<critical>\n- Must act.\n</critical>"];
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<void> }> = [];
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<Api>) as Model<Api>;
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("<system-reminder>");
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();
}
});
});
@@ -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("<workspace-tree>");
expect(promptText).toContain("<dir-context>");
expect(promptText).toContain(`'${normalizedProjectDir}'`);
// The project/environment footer survives even though the date/cwd line was
// relocated out of it; <workstation> is rendered only by that footer.
expect(promptText).toContain("<workstation>");
expect(appendMatches).toHaveLength(1);
expect(promptText).not.toContain("Discovered project SYSTEM prompt");
});
@@ -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,