fix(advisor): address review — dedupe obfuscation, restore dedup map on rollback, complete fingerprint fields

This commit is contained in:
cuipengfei
2026-08-01 19:23:41 +08:00
parent b506c8ee65
commit 5b25030720
8 changed files with 263 additions and 100 deletions
+5
View File
@@ -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
@@ -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[];
}
+97 -70
View File
@@ -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<string, unknown>;
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<string, string>();
/**
* 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;
@@ -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;
@@ -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<string | AgentMessage[]> = [];
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<string | AgentMessage[]> = [];
const agent = makeAgent(promptInputs);
@@ -3088,7 +3135,11 @@ describe("advisor", () => {
) as AgentMessage;
};
const waitForPrompts = async (prompts: Array<string | AgentMessage[]>, count: number, timeoutMs = 10_000): Promise<void> => {
const waitForPrompts = async (
prompts: Array<string | AgentMessage[]>,
count: number,
timeoutMs = 10_000,
): Promise<void> => {
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 ");
@@ -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,
@@ -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);
});
});
@@ -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<string, unknown> = {}): AgentMessage {
function mkMsg(
role: AgentMessage["role"],
text: string,
timestamp: number,
extra: Record<string, unknown> = {},
): 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);
});
});