fix(agent): include provider payloads in append-only digests

Responses-style providers serialize providerPayload history items instead of the
visible message blocks when replaying native history. Include providerPayload in
the append-only per-message digest so payload-only history rewrites stop the
stable-prefix walk and re-sync the changed message before any later divergent
tail.

Add a regression where an assistant message keeps identical visible content and
id but changes its openaiResponsesHistory providerPayload while a later message
also diverges; syncMessages must preserve the prefix before the assistant and
refresh the assistant payload.

Fixes #3406
This commit is contained in:
roboomp
2026-06-24 23:11:12 +00:00
parent 5b9e009a8b
commit 2b68785e82
2 changed files with 48 additions and 4 deletions
+5 -4
View File
@@ -285,16 +285,17 @@ export class AppendOnlyContextManager {
}
/** Deterministic digest over every field the provider may serialize — role,
* content, tool calls (both `toolCalls` and OpenAI-wire `tool_calls`),
* tool-result ids/names/error flags (both internal camelCase and wire
* snake_case), and assistant `id` — so an in-place rewrite of *any* of
* these fields is visible to {@link #longestStablePrefix}. */
* content, provider-native replay payloads, tool calls (both `toolCalls` and
* OpenAI-wire `tool_calls`), tool-result ids/names/error flags (both internal
* camelCase and wire snake_case), and assistant `id` — so an in-place rewrite
* of *any* of these fields is visible to {@link #longestStablePrefix}. */
#messageDigest(msg: unknown): number {
if (!msg || typeof msg !== "object") return 0;
const m = msg as Record<string, unknown>;
const payload = JSON.stringify({
r: m.role ?? null,
c: m.content ?? null,
pp: m.providerPayload ?? null,
tc: m.toolCalls ?? m.tool_calls ?? null,
tcid: m.toolCallId ?? m.tool_call_id ?? null,
tn: m.toolName ?? m.name ?? null,
@@ -572,6 +572,49 @@ describe("message sync", () => {
expect((entries[2] as { content: unknown }).content).toBe("a1-pruned");
});
it("detects providerPayload-only rewrites before preserving a later prefix (#3406)", () => {
const mgr = new AppendOnlyContextManager();
mgr.build(makeContext(), BUILD_OPTS);
const original0 = { role: "user", content: "q1" } as any;
const original1 = {
role: "assistant",
content: [{ type: "text", text: "same visible output" }],
id: "assistant-1",
providerPayload: {
type: "openaiResponsesHistory",
provider: "openai",
items: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "old native" }] }],
},
} as any;
const original2 = { role: "user", content: "q2" } as any;
mgr.syncMessages([original0, original1, original2]);
mgr.syncMessages([
{ role: "user", content: "q1" },
{
role: "assistant",
content: [{ type: "text", text: "same visible output" }],
id: "assistant-1",
providerPayload: {
type: "openaiResponsesHistory",
provider: "openai",
items: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "new native" }] }],
},
},
{ role: "user", content: "q2-rewritten" },
] as any);
const entries = mgr.log.entries();
expect(entries).toHaveLength(3);
expect(entries[0]).toBe(original0);
expect(
(entries[1] as { providerPayload?: { items?: Array<{ content?: Array<{ text?: string }> }> } }).providerPayload
?.items?.[0]?.content?.[0]?.text,
).toBe("new native");
expect((entries[2] as { content: unknown }).content).toBe("q2-rewritten");
});
it("does not reuse a stable prefix longer than the current log after direct log clear (#3406)", () => {
const mgr = new AppendOnlyContextManager();
mgr.build(makeContext(), BUILD_OPTS);