diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 84a743e43..d6eee3c7e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -141,6 +141,11 @@ - Fixed ephemeral side turns and native compaction bypassing an explicit or fork-inherited prompt cache key ([#7218](https://github.com/can1357/oh-my-pi/issues/7218)). - Fixed the live Ask dialog crashing the whole session with a `replaceTabs` TypeError when a question reached `AskDialogComponent` without a string `question` field; questions are now normalized at dialog entry, mirroring the transcript renderer ([#7211](https://github.com/can1357/oh-my-pi/issues/7211)). - Fixed Codex web search collapsing backend errors to `Codex error (): Unknown error`; the SSE error parser now preserves the backend code and message from top-level, nested `error`, and `response.error` envelopes ([#7200](https://github.com/can1357/oh-my-pi/issues/7200)). +### Fixed + +- Split the advisor Session update delivery into per-source-message user messages (single `Agent.prompt(AgentMessage[])` call) so provider prompt caches grow with the session instead of staying pinned at the instructions/tools boundary; rendering stays byte-identical to the old single-block update. +- Restore the advisor primary-context dedup map when a failed advisor turn is rolled back, so retried batches re-deliver first-time plan/goal context in full instead of collapsing it to "(unchanged — still in effect)". +- Include all renderer-read fields (excludeFromContext, bashExecution command, pythonExecution code, branch/compaction summary + fromId, fileMention files) in advisor prefix fingerprints so clones changing only those fields correctly trigger a re-render. ## [17.2.2] - 2026-07-31 diff --git a/packages/coding-agent/src/advisor/delta-split.ts b/packages/coding-agent/src/advisor/delta-split.ts index 9ae122fc5..7c3dce99a 100644 --- a/packages/coding-agent/src/advisor/delta-split.ts +++ b/packages/coding-agent/src/advisor/delta-split.ts @@ -60,23 +60,20 @@ export function renderAdvisorDeltaChunks( }); const heading = "### Session update"; - const chunks: AgentMessage[] = []; + // Concrete local chunk type: content blocks are minted here, so the WIP + // marker append below is a plain field access (no double-cast). + const chunks: { role: "user"; content: TextContent[]; timestamp: number }[] = []; for (let i = 0; i < delta.length; i++) { let text = renderChunk([delta[i]]); if (!text.trim()) continue; if (opts.obfuscator) text = opts.obfuscator.obfuscate(text, opts.advisorRegexSecretValues); if (i === 0) text = `${heading}\n\n${text}`; - chunks.push({ - role: "user", - content: [{ type: "text", text }], - timestamp: Date.now(), - } as AgentMessage); + chunks.push({ role: "user", content: [{ type: "text", text }], timestamp: Date.now() }); } if (chunks.length === 0) return null; if (opts.wip) { const last = chunks[chunks.length - 1]; - const blocks = (last as { content: unknown }).content as TextContent[]; - blocks[0].text += `\n\n---\n\n[in progress — more steps follow]`; + last.content[0].text += `\n\n---\n\n[in progress — more steps follow]`; } - return chunks; + return chunks as AgentMessage[]; } diff --git a/packages/coding-agent/src/advisor/runtime.ts b/packages/coding-agent/src/advisor/runtime.ts index 85ea8373d..3c2e3133c 100644 --- a/packages/coding-agent/src/advisor/runtime.ts +++ b/packages/coding-agent/src/advisor/runtime.ts @@ -261,7 +261,10 @@ function fingerprintMessage(message: AgentMessage): bigint | undefined { // churns on provider round-trips and would otherwise trigger a full // transcript replay for a no-op change. Rendered fields (from // session-history-format.ts): role, content, customType, display, isError, - // toolResult: cancelled/exitCode/output, custom: details. + // toolResult: cancelled/exitCode/output, custom: details, plus the + // execution/branch/compaction/file-mention fields the formatter reads: + // excludeFromContext, command (bashExecution), code (pythonExecution), + // summary + fromId (branch/compaction), files (fileMention). const m = message as unknown as Record; const payload = JSON.stringify({ r: m.role ?? null, @@ -275,6 +278,12 @@ function fingerprintMessage(message: AgentMessage): bigint | undefined { exit: m.exitCode ?? null, out: m.output ?? null, det: m.details ?? null, + xfc: m.excludeFromContext ?? null, + cmd: m.command ?? null, + code: m.code ?? null, + sum: m.summary ?? null, + from: m.fromId ?? null, + files: m.files ?? null, }); if (payload === undefined) return undefined; return Bun.hash.wyhash(payload); @@ -294,8 +303,17 @@ export class AdvisorRuntime { * approved plan). These prompts are re-injected verbatim every primary turn; * this lets {@link #renderDelta} collapse an unchanged copy to a one-line * marker so the advisor isn't re-fed the full ~1k-token rules each turn. - * Cleared on every re-prime/seed and when a failed batch is dropped. */ + /** Cleared on every re-prime/seed and when a failed batch is dropped. */ #seenContext = new Map(); + /** + * Snapshot of {@link #seenContext} taken by #prepareBatch before the + * in-flight batch's first dedup mutation. Restored by + * {@link #rollbackFailedTurn} when the turn fails and its rawMessages are + * requeued, so first-time primary-context is re-delivered in full instead + * of collapsing to "(unchanged — still in effect)" against an advisor + * history that no longer contains it. Cleared on turn success. + */ + #seenContextInFlight: [string, string][] | undefined; /** Incremented whenever the advisor loses context so queued raw deltas are re-rendered against fresh dedupe state. */ #renderRevision = 0; /** Regex secret values observed in primary deltas and retained until advisor context resets. */ @@ -467,6 +485,7 @@ export class AdvisorRuntime { #clearSeenContext(): void { this.#seenContext.clear(); + this.#seenContextInFlight = undefined; this.#advisorRegexSecretValues.clear(); this.#renderRevision++; } @@ -597,6 +616,57 @@ export class AdvisorRuntime { // messages lets the provider cache each appended message (verified // experimentally: cache_read 11066 → 11091 → 11112 vs pinned 11066). // + /** + * Shared obfuscation side effects for BOTH render paths (single-block + * {@link #renderPreparedDelta} and multi-message + * {@link #formatRawDeltaMessageChunks}): collect regex secret values from + * primary-context custom messages and the rendered markdown, scrub the + * advisor's own history, and refresh pending placeholder prefixes when new + * secrets appear. Returns whether new secret values were discovered. + * Idempotent across the two calls one drain makes for the same prepared + * list: the second call discovers nothing new and skips the strip. + */ + #collectAdvisorSecrets(obfuscator: SecretObfuscator, delta: AgentMessage[], renderedMd: string): boolean { + let discoveredNewRegexSecretValue = false; + const addRegexValues = (text: string): void => { + for (const secretValue of obfuscator.collectRegexSecretValuesForObfuscation(text) ?? []) { + if (this.#advisorRegexSecretValues.has(secretValue)) continue; + this.#advisorRegexSecretValues.add(secretValue); + discoveredNewRegexSecretValue = true; + } + }; + for (const message of delta) { + if ( + message.role === "custom" && + PRIMARY_CONTEXT_CUSTOM_TYPES.has(message.customType) && + typeof message.content === "string" + ) { + addRegexValues(message.content); + } + } + addRegexValues(renderedMd); + scrubAdvisorHistory(obfuscator, this.agent.state.messages, this.#advisorRegexSecretValues); + if (discoveredNewRegexSecretValue) { + this.#pending = this.#pending.map(delta => ({ + ...delta, + text: obfuscator.stripUnsafeFriendlyPlaceholderPrefixes(delta.text, this.#advisorRegexSecretValues), + })); + } + return discoveredNewRegexSecretValue; + } + + /** + * Map primary-context custom messages through the obfuscator. Shared by + * both render paths so the byte-equivalence contract lives in one place. + */ + #obfuscatePrimaryContextMessages(obfuscator: SecretObfuscator, delta: AgentMessage[]): AgentMessage[] { + return delta.map(message => + message.role === "custom" && PRIMARY_CONTEXT_CUSTOM_TYPES.has(message.customType) + ? obfuscateAdvisorMessage(obfuscator, message, this.#advisorRegexSecretValues) + : message, + ); + } + // Each source message is rendered INDEPENDENTLY via // formatSessionHistoryMarkdown in chunked mode (shared toolResultIndex + // consumedToolCallIds over the WHOLE delta), so a toolCall finds its @@ -614,38 +684,16 @@ export class AdvisorRuntime { if (delta.length === 0) return null; const obfuscator = this.host.obfuscator; - // Side effects the pure renderer cannot own: scrub the advisor's own - // history and refresh pending placeholder prefixes. - let discoveredNewRegexSecretValue = false; - const addRegexValues = (text: string): void => { - for (const secretValue of obfuscator?.collectRegexSecretValuesForObfuscation(text) ?? []) { - if (this.#advisorRegexSecretValues.has(secretValue)) continue; - this.#advisorRegexSecretValues.add(secretValue); - discoveredNewRegexSecretValue = true; - } - }; + // Side effects the pure renderer cannot own: collect secrets, scrub the + // advisor's own history and refresh pending placeholder prefixes (shared + // helper — see #collectAdvisorSecrets; idempotent for this drain's + // single-block pass over the same prepared list). const probeMd = formatSessionHistoryMarkdown(delta, { ...ADVISOR_RENDER_OPTIONS, includeThinking: this.#includeThinking, }); if (obfuscator?.hasSecrets()) { - for (const message of delta) { - if ( - message.role === "custom" && - PRIMARY_CONTEXT_CUSTOM_TYPES.has(message.customType) && - typeof message.content === "string" - ) { - addRegexValues(message.content); - } - } - addRegexValues(probeMd); - scrubAdvisorHistory(obfuscator, this.agent.state.messages, this.#advisorRegexSecretValues); - if (discoveredNewRegexSecretValue) { - this.#pending = this.#pending.map(delta => ({ - ...delta, - text: obfuscator.stripUnsafeFriendlyPlaceholderPrefixes(delta.text, this.#advisorRegexSecretValues), - })); - } + this.#collectAdvisorSecrets(obfuscator, delta, probeMd); } // Message-level obfuscation mirrors the old #formatRawDelta path EXACTLY: @@ -653,14 +701,7 @@ export class AdvisorRuntime { // structured fields), because the old path's contract is whole-delta text // obfuscation as the final pass. Expanding to every role would mint // different placeholders and break byte-equivalence with the old render. - const renderDelta = - obfuscator?.hasSecrets() - ? delta.map(message => - message.role === "custom" && PRIMARY_CONTEXT_CUSTOM_TYPES.has(message.customType) - ? obfuscateAdvisorMessage(obfuscator, message, this.#advisorRegexSecretValues) - : message, - ) - : delta; + const renderDelta = obfuscator?.hasSecrets() ? this.#obfuscatePrimaryContextMessages(obfuscator, delta) : delta; const chunks = renderAdvisorDeltaChunks(renderDelta, { wip, @@ -713,39 +754,11 @@ export class AdvisorRuntime { }); if (!md.trim()) return null; if (obfuscator?.hasSecrets()) { - let discoveredNewRegexSecretValue = false; - const addRegexValues = (text: string): void => { - for (const secretValue of obfuscator.collectRegexSecretValuesForObfuscation(text)) { - if (this.#advisorRegexSecretValues.has(secretValue)) continue; - this.#advisorRegexSecretValues.add(secretValue); - discoveredNewRegexSecretValue = true; - } - }; - for (const message of delta) { - if ( - message.role === "custom" && - PRIMARY_CONTEXT_CUSTOM_TYPES.has(message.customType) && - typeof message.content === "string" - ) { - addRegexValues(message.content); - } - } - addRegexValues(md); - scrubAdvisorHistory(obfuscator, this.agent.state.messages, this.#advisorRegexSecretValues); - if (discoveredNewRegexSecretValue) { - this.#pending = this.#pending.map(delta => ({ - ...delta, - text: obfuscator.stripUnsafeFriendlyPlaceholderPrefixes(delta.text, this.#advisorRegexSecretValues), - })); - } - md = formatSessionHistoryMarkdown( - delta.map(message => - message.role === "custom" && PRIMARY_CONTEXT_CUSTOM_TYPES.has(message.customType) - ? obfuscateAdvisorMessage(obfuscator, message, this.#advisorRegexSecretValues) - : message, - ), - { ...ADVISOR_RENDER_OPTIONS, includeThinking: this.#includeThinking }, - ); + this.#collectAdvisorSecrets(obfuscator, delta, md); + md = formatSessionHistoryMarkdown(this.#obfuscatePrimaryContextMessages(obfuscator, delta), { + ...ADVISOR_RENDER_OPTIONS, + includeThinking: this.#includeThinking, + }); md = obfuscator.obfuscate(md, this.#advisorRegexSecretValues); } // Candidate 3: keep the heading byte-identical between wip and final turns @@ -858,6 +871,15 @@ export class AdvisorRuntime { * that hand-roll a minimal facade. */ #rollbackFailedTurn(snapshot: number): void { + // Restore the primary-context dedup map to its pre-batch state: the + // failed turn never reached the advisor, so first-time context collapsed + // to "(unchanged…)" by this batch's #prepareBatch must expand again on + // the retry/requeue pass. + if (this.#seenContextInFlight) { + this.#seenContext.clear(); + for (const [key, value] of this.#seenContextInFlight) this.#seenContext.set(key, value); + this.#seenContextInFlight = undefined; + } const messages = this.agent.state.messages; if (messages.length <= snapshot) return; try { @@ -1012,6 +1034,10 @@ export class AdvisorRuntime { // was ALREADY shown collapses to "(unchanged…)", while a FIRST delivery // in this batch stays expanded. This pass advances the live map exactly // once per batch — #renderDelta's text is a preview and must not set it. + // Snapshot the dedup map BEFORE this batch's first mutation so a failed + // turn can restore it (see #rollbackFailedTurn). `??=` keeps the first + // snapshot across coalescing re-prepares within one in-flight batch. + this.#seenContextInFlight ??= [...this.#seenContext]; const preparedMessages = rawMessages .filter(message => !(message.role === "custom" && message.customType === "advisor")) .map(message => this.#dedupContextMessage(message)); @@ -1128,6 +1154,7 @@ export class AdvisorRuntime { const turnError = getAdvisorTurnError(this.agent.state.messages.slice(messageSnapshot)); if (turnError) throw turnError; success = true; + this.#seenContextInFlight = undefined; this.#failing = false; this.#consecutiveFailures = 0; this.#failureNotified = false; diff --git a/packages/coding-agent/src/session/session-advisors.ts b/packages/coding-agent/src/session/session-advisors.ts index b679bbbc1..f182b8055 100644 --- a/packages/coding-agent/src/session/session-advisors.ts +++ b/packages/coding-agent/src/session/session-advisors.ts @@ -839,7 +839,10 @@ export class SessionAdvisors { currentAdvisorInput = Array.isArray(input) ? formatSessionHistoryMarkdown(input, { watchedRoles: true }) : input; - await (Array.isArray(input) ? advisorAgent.prompt(input) : advisorAgent.prompt(input)); + // Agent.prompt's overloads accept string OR AgentMessage[] but not + // the union, so narrow first; both branches intentionally identical. + if (Array.isArray(input)) await advisorAgent.prompt(input); + else await advisorAgent.prompt(input); quarantined = quarantinedAdvisorOutput; } finally { quarantinedAdvisorOutput = undefined; diff --git a/packages/coding-agent/test/advisor/advisor.test.ts b/packages/coding-agent/test/advisor/advisor.test.ts index bd169f6b0..a62d04d84 100644 --- a/packages/coding-agent/test/advisor/advisor.test.ts +++ b/packages/coding-agent/test/advisor/advisor.test.ts @@ -1957,7 +1957,11 @@ describe("advisor", () => { runtime.onTurnEnd(); await runtime.waitForCatchup(1000, 1); - agent.state.messages.push({ role: "user", content: promptText(promptInputs[0]!), timestamp: 1 } as AgentMessage); + agent.state.messages.push({ + role: "user", + content: promptText(promptInputs[0]!), + timestamp: 1, + } as AgentMessage); expect(firstStoredPrompt()).toContain("TOKABC123_"); messages.push({ role: "user", content: "later tok_abc123", timestamp: 2 } as AgentMessage); @@ -2226,6 +2230,49 @@ describe("advisor", () => { expect(promptText(promptInputs[1])).not.toContain("except the single plan file named below"); }); + it("re-expands first-time primary context when a failed turn is retried", async () => { + // Regression: the failed turn is rolled back, so the advisor history no + // longer contains the full plan-mode context; the retry must not collapse + // it to "(unchanged — still in effect)" against the pre-failure dedup map. + const promptInputs: Array = []; + const state: { messages: AgentMessage[]; error?: string } = { messages: [] }; + let promptCalls = 0; + const agent: AdvisorAgent = { + prompt: async input => { + promptInputs.push(input); + promptCalls++; + state.error = promptCalls === 1 ? "transient provider 500" : undefined; + }, + abort: () => {}, + reset: () => {}, + state, + }; + const rule = + "Plan mode is active. You MUST perform READ-ONLY work only:\n- You NEVER create, edit, or delete files — except the single plan file named below."; + const messages: AgentMessage[] = []; + const host: AdvisorRuntimeHost = { + snapshotMessages: () => messages, + enqueueAdvice: () => {}, + }; + const runtime = new AdvisorRuntime(agent, host, 0); + + messages.push({ role: "user", content: "start planning", timestamp: 1 } as AgentMessage); + messages.push({ + role: "custom", + customType: "plan-mode-context", + content: rule, + display: false, + timestamp: 2, + } as AgentMessage); + runtime.onTurnEnd(); + await settleUntil(() => promptInputs.length >= 2 && runtime.backlog === 0); + + expect(promptInputs).toHaveLength(2); + expect(promptText(promptInputs[0])).toContain("except the single plan file named below"); + expect(promptText(promptInputs[1])).toContain("except the single plan file named below"); + expect(promptText(promptInputs[1])).not.toContain("unchanged — still in effect"); + }); + it("renders the watched delta with a heading, watched-role labels, and no inner ## headings", async () => { const promptInputs: Array = []; const agent = makeAgent(promptInputs); @@ -3088,7 +3135,11 @@ describe("advisor", () => { ) as AgentMessage; }; - const waitForPrompts = async (prompts: Array, count: number, timeoutMs = 10_000): Promise => { + const waitForPrompts = async ( + prompts: Array, + count: number, + timeoutMs = 10_000, + ): Promise => { const deadline = Date.now() + timeoutMs; while (prompts.length < count && Date.now() < deadline) await Bun.sleep(5); }; @@ -3222,7 +3273,9 @@ describe("advisor", () => { await waitForPrompts(promptInputs, 1); // The aborted pre-reset render must not have advanced the cursor: // the post-reset replay carries the whole transcript. - const replay = promptInputs.find(input => promptText(input).includes("msg-0 ") && promptText(input).includes("msg-399 ")); + const replay = promptInputs.find( + input => promptText(input).includes("msg-0 ") && promptText(input).includes("msg-399 "), + ); expect(replay).toBeDefined(); runtime.dispose(); }, 20_000); @@ -3248,7 +3301,14 @@ describe("advisor", () => { messages.push({ role: "user", content: "late-arrival tail", timestamp: 300 } as AgentMessage); runtime.onTurnEnd(messages); const deadline = Date.now() + 10_000; - while (Date.now() < deadline && !promptInputs.map(i => promptText(i)).join("\n").includes("late-arrival tail")) await Bun.sleep(5); + while ( + Date.now() < deadline && + !promptInputs + .map(i => promptText(i)) + .join("\n") + .includes("late-arrival tail") + ) + await Bun.sleep(5); const combined = promptInputs.map(i => promptText(i)).join("\n"); // Every message exactly once, ordering preserved. expect(combined).toContain("msg-0 "); diff --git a/packages/coding-agent/test/advisor/delta-split-obfuscation.test.ts b/packages/coding-agent/test/advisor/delta-split-obfuscation.test.ts index fa174360a..eefa571d8 100644 --- a/packages/coding-agent/test/advisor/delta-split-obfuscation.test.ts +++ b/packages/coding-agent/test/advisor/delta-split-obfuscation.test.ts @@ -40,7 +40,11 @@ describe("renderAdvisorDeltaChunks obfuscation", () => { }); it("redacts secrets in user message text", () => { - const msg = { role: "user", content: [{ type: "text", text: "prefix SECRETVALUE123 suffix" }], timestamp: 1 } as AgentMessage; + const msg = { + role: "user", + content: [{ type: "text", text: "prefix SECRETVALUE123 suffix" }], + timestamp: 1, + } as AgentMessage; const chunks = renderAdvisorDeltaChunks([msg], { wip: false, includeThinking: true, diff --git a/packages/coding-agent/test/advisor/delta-split.test.ts b/packages/coding-agent/test/advisor/delta-split.test.ts index 46f53c171..e25d64ea7 100644 --- a/packages/coding-agent/test/advisor/delta-split.test.ts +++ b/packages/coding-agent/test/advisor/delta-split.test.ts @@ -37,7 +37,13 @@ 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; +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; @@ -48,37 +54,62 @@ 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() }); + 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); + 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))); + const chunks = renderAdvisorDeltaChunks(msgs, { + wip: false, + includeThinking: true, + advisorRegexSecretValues: new Set(), + }); 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 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))); + const chunks = renderAdvisorDeltaChunks(msgs, { + wip: false, + includeThinking: true, + advisorRegexSecretValues: new Set(), + }); 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() }); + 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. @@ -90,7 +121,11 @@ describe("renderAdvisorDeltaChunks (delta-split)", () => { 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() }); + const chunks = renderAdvisorDeltaChunks(msgs, { + wip: false, + includeThinking: true, + advisorRegexSecretValues: new Set(), + }); expect(chunks!.length).toBeGreaterThan(1); }); }); diff --git a/packages/coding-agent/test/advisor/fingerprint-multi-message.test.ts b/packages/coding-agent/test/advisor/fingerprint-multi-message.test.ts index 0c3f77c91..c635e66ec 100644 --- a/packages/coding-agent/test/advisor/fingerprint-multi-message.test.ts +++ b/packages/coding-agent/test/advisor/fingerprint-multi-message.test.ts @@ -16,9 +16,14 @@ import { describe, expect, it } from "bun:test"; import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; -import { AdvisorRuntime, type AdvisorAgent, type AdvisorRuntimeHost } from "../../src/advisor/runtime"; +import { type AdvisorAgent, AdvisorRuntime, type AdvisorRuntimeHost } from "../../src/advisor/runtime"; -function mkMsg(role: AgentMessage["role"], text: string, timestamp: number, extra: Record = {}): AgentMessage { +function mkMsg( + role: AgentMessage["role"], + text: string, + timestamp: number, + extra: Record = {}, +): AgentMessage { return { role, content: text, timestamp, ...extra } as AgentMessage; } @@ -97,7 +102,10 @@ describe("fingerprint: field-selective fingerprint (applied)", () => { const { prompts } = await runScenario( history(["seed-body-000", "seed-body-001"]), messages => { - messages[0] = { ...messages[0], content: "[shaken ~10 tokens — recover: artifact://1 (region 1)]" } as AgentMessage; + messages[0] = { + ...messages[0], + content: "[shaken ~10 tokens — recover: artifact://1 (region 1)]", + } as AgentMessage; }, [mkMsg("user", "tail-body-002", 3)], ); @@ -153,4 +161,28 @@ describe("fingerprint: field-selective fingerprint (applied)", () => { expect(d.full).toBe(false); expect(d.tailOnly).toBe(true); }); + + it("scenario G: rendered field change (bashExecution.command) triggers FULL replay", async () => { + // command is rendered by formatSessionHistoryMarkdown (executionLine), so + // a clone changing only command must not pass the prefix check. + const { prompts } = await runScenario( + [mkMsg("bashExecution", "", 1, { command: "ls -la" }), mkMsg("user", "seed-body-001", 2)], + messages => { + messages[0] = { ...messages[0], command: "ls -la /tmp" } as unknown as AgentMessage; + }, + [mkMsg("user", "tail-body-002", 3)], + ); + expect(describeDelta(prompts).full).toBe(true); + }); + + it("scenario H: rendered field change (compaction summary) triggers FULL replay", async () => { + const { prompts } = await runScenario( + [mkMsg("compactionSummary", "", 1, { summary: "seed summary" }), mkMsg("user", "seed-body-001", 2)], + messages => { + messages[0] = { ...messages[0], summary: "rewritten summary" } as unknown as AgentMessage; + }, + [mkMsg("user", "tail-body-002", 3)], + ); + expect(describeDelta(prompts).full).toBe(true); + }); });