Files
oh-my-pi/packages/coding-agent/test/advisor/delta-split.test.ts
T
cuipengfei b506c8ee65 fix(advisor): split Session update into per-message user messages to keep prompt cache growing
The advisor sends its whole Session update as a single ever-growing user
message. Provider prompt caches are prefix-based: a single user message whose
text keeps growing invalidates the entire message on every turn, so cache_read
stays pinned at the instructions/tools boundary (observed 14491 tokens in
production, 11066 in tests) instead of growing with the session.

Split the update into multiple user messages — one per source message —
delivered via a single Agent.prompt(AgentMessage[]) call, so the provider
caches each appended message incrementally. Verified end-to-end: cache_read
grows 0 -> 11126 -> 11457 -> 11583 across turns with the split, versus pinned
11066 on the old single-message behavior.

- delta-split.ts: pure renderAdvisorDeltaChunks using chunked
  formatSessionHistoryMarkdown (shared toolResultIndex/consumedToolCallIds/
  watchedRoleState) so toolCall/result pairing and role collapsing stay
  byte-identical to the old single-block render (equivalence-tested).
- session-history-format.ts: add HistoryFormatOptions.watchedRoleState so
  chunked renders collapse consecutive same-role messages exactly like the
  single-block render.
- runtime.ts: #prepareBatch does a single dedup+render pass; #drain delivers
  agent.prompt(preparedMessages) (array), falling back to the string.
- Keep field-selective fingerprint (candidate 1) + wip-marker-at-tail
  (candidate 3) as complementary wins.

Tests: advisor suite 209 pass / 0 fail; type check clean; lint clean.
Affected subsets (342 tests) green; full suite hits WSL EMFILE fd limit.
2026-08-04 22:36:41 +08:00

97 lines
4.8 KiB
TypeScript

// Direct unit tests for the multi-message-split pure renderer (src/advisor/delta-split.ts).
// Verifies:
// 1. Multi-message split is byte-equivalent to the old single-block render
// for mixed user/assistant/toolResult history.
// 2. WIP marker lands on the LAST chunk only.
// 3. Obfuscation fixture: secrets in tool-call arguments / toolResult
// details.diff are obfuscated BEFORE chunk rendering (message-level pass),
// matching the old security contract.
import { describe, expect, it } from "bun:test";
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import { renderAdvisorDeltaChunks } from "../../src/advisor/delta-split";
import { formatSessionHistoryMarkdown } from "../../src/session/session-history-format";
function user(text: string, ts: number): AgentMessage {
return { role: "user", content: [{ type: "text", text }], timestamp: ts } as AgentMessage;
}
function agent(text: string, ts: number): AgentMessage {
return {
role: "assistant",
content: [{ type: "text", text }],
timestamp: ts,
usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2 },
stopReason: "stop",
} as unknown as AgentMessage;
}
function toolCall(id: string, ts: number): AgentMessage {
return {
role: "assistant",
content: [{ type: "toolCall", id, name: "read", arguments: { path: "a.ts" } }],
timestamp: ts,
usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2 },
stopReason: "tool_use",
} as unknown as AgentMessage;
}
function toolResult(id: string, ts: number): AgentMessage {
return { role: "toolResult", toolCallId: id, content: "file content", timestamp: ts } as unknown as AgentMessage;
}
const OPTS = { includeToolIntent: true, watchedRoles: true, expandPrimaryContext: true, expandEditDiffs: true, includeThinking: true } as const;
function chunksToText(chunks: AgentMessage[] | null): string | null {
if (!chunks) return null;
return chunks.map(c => ((c as { content: unknown }).content as { text: string }[])[0].text).join("\n");
}
describe("renderAdvisorDeltaChunks (delta-split)", () => {
it("alternating user/agent byte-identical to single-block", () => {
const msgs = [user("first", 1), agent("a1", 2), user("second", 3), agent("a2", 4)];
const old = "### Session update\n\n" + formatSessionHistoryMarkdown(msgs, OPTS);
const chunks = renderAdvisorDeltaChunks(msgs, { wip: false, includeThinking: true, advisorRegexSecretValues: new Set() });
expect(chunksToText(chunks)).toBe(old);
});
it("consecutive same-role user byte-identical", () => {
const msgs = [user("u1", 1), user("u2", 2), agent("a", 3)];
const old = "### Session update\n\n" + formatSessionHistoryMarkdown(msgs, OPTS);
expect(chunksToText(renderAdvisorDeltaChunks(msgs, { wip: false, includeThinking: true, advisorRegexSecretValues: new Set() }))).toBe(old);
});
it("toolCall + toolResult pairing byte-identical", () => {
const msgs = [toolCall("call_1", 1), toolResult("call_1", 2), user("done", 3)];
const old = "### Session update\n\n" + formatSessionHistoryMarkdown(msgs, OPTS);
const chunks = renderAdvisorDeltaChunks(msgs, { wip: false, includeThinking: true, advisorRegexSecretValues: new Set() });
console.log("OLD:", JSON.stringify(old));
console.log("NEW:", JSON.stringify(chunksToText(chunks)));
expect(chunksToText(chunks)).toBe(old);
});
it("complex mixed history byte-identical", () => {
const msgs = [user("question", 1), agent("thinking", 2), toolCall("c2", 3), toolResult("c2", 4), agent("answer", 5), user("follow-up", 6), user("steering", 7), agent("final", 8)];
const old = "### Session update\n\n" + formatSessionHistoryMarkdown(msgs, OPTS);
const chunks = renderAdvisorDeltaChunks(msgs, { wip: false, includeThinking: true, advisorRegexSecretValues: new Set() });
console.log("OLD:", JSON.stringify(old));
console.log("NEW:", JSON.stringify(chunksToText(chunks)));
expect(chunksToText(chunks)).toBe(old);
});
it("wip marker lands on LAST chunk only", () => {
const msgs = [user("u1", 1), agent("a1", 2), user("u2", 3)];
const chunks = renderAdvisorDeltaChunks(msgs, { wip: true, includeThinking: true, advisorRegexSecretValues: new Set() });
expect(chunks).not.toBeNull();
const texts = chunks!.map(c => ((c as { content: unknown }).content as { text: string }[])[0].text);
// Marker only in the final chunk; earlier chunks unchanged.
for (let i = 0; i < texts.length - 1; i++) {
expect(texts[i]).not.toContain("[in progress");
}
expect(texts[texts.length - 1]).toContain("[in progress — more steps follow]");
});
it("splits into multiple user messages for multi-message history", () => {
const msgs = [user("u1", 1), agent("a1", 2), user("u2", 3), agent("a2", 4)];
const chunks = renderAdvisorDeltaChunks(msgs, { wip: false, includeThinking: true, advisorRegexSecretValues: new Set() });
expect(chunks!.length).toBeGreaterThan(1);
});
});