fix(advisor): address review — dedupe obfuscation, restore dedup map on rollback, complete fingerprint fields
This commit is contained in:
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user