Skip to content

Commit 5f84700

Browse files
author
忻役
committed
fix: tighten synthetic detection, add length assertions, fix duplicate count
- Extract DEFAULT_MISSING_TOOL_RESULT_TEXT constant and use exact match instead of substring .includes() to prevent false positives on real error output containing the marker text - Add toHaveLength(1) assertions to all duplicate-preference tests so they verify deduplication actually removes the extra entry - Increment droppedDuplicateCount in span-level and pushToolResult replacement paths that were previously undercounting - Add edge case tests: substring false-positive guard and changed flag verification Refs #84134
1 parent cf1fae0 commit 5f84700

2 files changed

Lines changed: 71 additions & 27 deletions

File tree

src/agents/session-transcript-repair.test.ts

Lines changed: 62 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { AgentMessage } from "openclaw/plugin-sdk/agent-core";
22
import { describe, expect, it } from "vitest";
33
import {
4+
DEFAULT_MISSING_TOOL_RESULT_TEXT,
45
sanitizeToolCallInputs,
56
sanitizeToolUseResultPairing,
67
repairToolUseResultPairing,
@@ -418,15 +419,12 @@ describe("sanitizeToolUseResultPairing", () => {
418419
});
419420

420421
describe("repairToolUseResultPairing prefers real result over synthetic error", () => {
421-
const SYNTHETIC_TEXT =
422-
"[openclaw] missing tool result in session history; inserted synthetic error result for transcript repair.";
423-
424422
function makeSyntheticResult(toolCallId: string) {
425423
return {
426424
role: "toolResult" as const,
427425
toolCallId,
428426
toolName: "read",
429-
content: [{ type: "text", text: SYNTHETIC_TEXT }],
427+
content: [{ type: "text", text: DEFAULT_MISSING_TOOL_RESULT_TEXT }],
430428
isError: true,
431429
};
432430
}
@@ -457,12 +455,13 @@ describe("repairToolUseResultPairing prefers real result over synthetic error",
457455

458456
const result = repairToolUseResultPairing(input);
459457

460-
const toolResult = result.messages.find((m) => m.role === "toolResult") as {
458+
const toolResults = result.messages.filter((m) => m.role === "toolResult") as Array<{
461459
isError?: boolean;
462460
content?: Array<{ text?: string }>;
463-
};
464-
expect(toolResult?.isError).not.toBe(true);
465-
expect(toolResult?.content?.[0]?.text).toBe("real output");
461+
}>;
462+
expect(toolResults).toHaveLength(1);
463+
expect(toolResults[0]?.isError).not.toBe(true);
464+
expect(toolResults[0]?.content?.[0]?.text).toBe("real output");
466465
});
467466

468467
it("real first, synthetic second → keeps real", () => {
@@ -474,12 +473,13 @@ describe("repairToolUseResultPairing prefers real result over synthetic error",
474473

475474
const result = repairToolUseResultPairing(input);
476475

477-
const toolResult = result.messages.find((m) => m.role === "toolResult") as {
476+
const toolResults = result.messages.filter((m) => m.role === "toolResult") as Array<{
478477
isError?: boolean;
479478
content?: Array<{ text?: string }>;
480-
};
481-
expect(toolResult?.isError).not.toBe(true);
482-
expect(toolResult?.content?.[0]?.text).toBe("real output");
479+
}>;
480+
expect(toolResults).toHaveLength(1);
481+
expect(toolResults[0]?.isError).not.toBe(true);
482+
expect(toolResults[0]?.content?.[0]?.text).toBe("real output");
483483
});
484484

485485
it("two real results → keeps first (unchanged behavior)", () => {
@@ -491,10 +491,11 @@ describe("repairToolUseResultPairing prefers real result over synthetic error",
491491

492492
const result = repairToolUseResultPairing(input);
493493

494-
const toolResult = result.messages.find((m) => m.role === "toolResult") as {
494+
const toolResults = result.messages.filter((m) => m.role === "toolResult") as Array<{
495495
content?: Array<{ text?: string }>;
496-
};
497-
expect(toolResult?.content?.[0]?.text).toBe("first real");
496+
}>;
497+
expect(toolResults).toHaveLength(1);
498+
expect(toolResults[0]?.content?.[0]?.text).toBe("first real");
498499
});
499500

500501
it("two synthetic errors → keeps first (unchanged behavior)", () => {
@@ -506,12 +507,13 @@ describe("repairToolUseResultPairing prefers real result over synthetic error",
506507

507508
const result = repairToolUseResultPairing(input);
508509

509-
const toolResult = result.messages.find((m) => m.role === "toolResult") as {
510+
const toolResults = result.messages.filter((m) => m.role === "toolResult") as Array<{
510511
isError?: boolean;
511512
content?: Array<{ text?: string }>;
512-
};
513-
expect(toolResult?.isError).toBe(true);
514-
expect(toolResult?.content?.[0]?.text).toBe(SYNTHETIC_TEXT);
513+
}>;
514+
expect(toolResults).toHaveLength(1);
515+
expect(toolResults[0]?.isError).toBe(true);
516+
expect(toolResults[0]?.content?.[0]?.text).toBe(DEFAULT_MISSING_TOOL_RESULT_TEXT);
515517
});
516518

517519
it("span-level: synthetic then real in span → picks real", () => {
@@ -532,6 +534,47 @@ describe("repairToolUseResultPairing prefers real result over synthetic error",
532534
expect(toolResults[0]?.isError).not.toBe(true);
533535
expect(toolResults[0]?.content?.[0]?.text).toBe("real output");
534536
});
537+
538+
it("does not treat real error containing marker substring as synthetic", () => {
539+
const input = castAgentMessages([
540+
makeAssistant("call_1"),
541+
{
542+
role: "toolResult" as const,
543+
toolCallId: "call_1",
544+
toolName: "read",
545+
content: [
546+
{
547+
type: "text",
548+
text: DEFAULT_MISSING_TOOL_RESULT_TEXT + " (extra context from real error)",
549+
},
550+
],
551+
isError: true,
552+
},
553+
makeRealResult("call_1"),
554+
]);
555+
556+
const result = repairToolUseResultPairing(input);
557+
558+
const toolResults = result.messages.filter((m) => m.role === "toolResult") as Array<{
559+
isError?: boolean;
560+
content?: Array<{ text?: string }>;
561+
}>;
562+
expect(toolResults).toHaveLength(1);
563+
expect(toolResults[0]?.content?.[0]?.text).toContain("extra context from real error");
564+
});
565+
566+
it("changed flag is true when duplicates are dropped", () => {
567+
const input = castAgentMessages([
568+
makeAssistant("call_1"),
569+
makeRealResult("call_1"),
570+
makeRealResult("call_1", "duplicate"),
571+
]);
572+
573+
const result = repairToolUseResultPairing(input);
574+
575+
expect(result.messages).not.toBe(input);
576+
expect(result.droppedDuplicateCount).toBeGreaterThan(0);
577+
});
535578
});
536579

537580
describe("sanitizeToolCallInputs legacy block filtering", () => {

src/agents/session-transcript-repair.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,9 @@ function hasSessionsSpawnAttachmentToolCall(content: unknown[]): boolean {
142142
return false;
143143
}
144144

145+
const DEFAULT_MISSING_TOOL_RESULT_TEXT =
146+
"[openclaw] missing tool result in session history; inserted synthetic error result for transcript repair.";
147+
145148
function makeMissingToolResult(params: {
146149
toolCallId: string;
147150
toolName?: string;
@@ -159,9 +162,7 @@ function makeMissingToolResult(params: {
159162
content: [
160163
{
161164
type: "text",
162-
text:
163-
params.text ??
164-
"[openclaw] missing tool result in session history; inserted synthetic error result for transcript repair.",
165+
text: params.text ?? DEFAULT_MISSING_TOOL_RESULT_TEXT,
165166
},
166167
],
167168
isError: true,
@@ -193,8 +194,7 @@ function isSyntheticMissingToolResult(msg: Extract<AgentMessage, { role: "toolRe
193194
typeof block === "object" &&
194195
block !== null &&
195196
(block as { type?: string }).type === "text" &&
196-
typeof (block as { text?: string }).text === "string" &&
197-
(block as { text: string }).text.includes("[openclaw] missing tool result"),
197+
(block as { text?: string }).text === DEFAULT_MISSING_TOOL_RESULT_TEXT,
198198
);
199199
}
200200

@@ -238,7 +238,7 @@ function normalizeLegacyToolResultId(
238238
return { ...message, toolCallId: toolCall.id, isError: true };
239239
}
240240

241-
export { makeMissingToolResult };
241+
export { DEFAULT_MISSING_TOOL_RESULT_TEXT, makeMissingToolResult };
242242

243243
type ToolCallInputRepairReport = {
244244
messages: AgentMessage[];
@@ -532,6 +532,7 @@ export function repairToolUseResultPairing(
532532
if (addedIdx !== -1) {
533533
added.splice(addedIdx, 1);
534534
}
535+
droppedDuplicateCount += 1;
535536
changed = true;
536537
return;
537538
}
@@ -635,10 +636,10 @@ export function repairToolUseResultPairing(
635636
!isSyntheticMissingToolResult(normalizedToolResult)
636637
) {
637638
spanResultsById.set(id, normalizedToolResult);
639+
droppedDuplicateCount += 1;
638640
changed = true;
639641
} else {
640-
// Dropping a duplicate tool result (either synthetic or repeated real);
641-
// the transcript changes since this occurrence will be removed.
642+
droppedDuplicateCount += 1;
642643
changed = true;
643644
}
644645
continue;

0 commit comments

Comments
 (0)