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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user