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:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user