






















11import type { AgentMessage } from "openclaw/plugin-sdk/agent-core";
22import { describe, expect, it } from "vitest";
33import {
4+DEFAULT_MISSING_TOOL_RESULT_TEXT,
45sanitizeToolCallInputs,
56sanitizeToolUseResultPairing,
67repairToolUseResultPairing,
@@ -417,6 +418,251 @@ describe("sanitizeToolUseResultPairing", () => {
417418});
418419});
419420421+describe("repairToolUseResultPairing prefers real result over synthetic error", () => {
422+function makeSyntheticResult(toolCallId: string) {
423+return {
424+role: "toolResult" as const,
425+ toolCallId,
426+toolName: "read",
427+content: [{ type: "text", text: DEFAULT_MISSING_TOOL_RESULT_TEXT }],
428+isError: true,
429+};
430+}
431+432+function makeCustomSyntheticResult(toolCallId: string, text: string) {
433+return {
434+role: "toolResult" as const,
435+ toolCallId,
436+toolName: "read",
437+content: [{ type: "text", text }],
438+details: { openclawSyntheticMissingToolResult: true },
439+isError: true,
440+};
441+}
442+443+function makeRealResult(toolCallId: string, text = "real output") {
444+return {
445+role: "toolResult" as const,
446+ toolCallId,
447+toolName: "read",
448+content: [{ type: "text", text }],
449+isError: false,
450+};
451+}
452+453+function makeAssistant(toolCallId: string) {
454+return {
455+role: "assistant" as const,
456+content: [{ type: "toolCall", id: toolCallId, name: "read", arguments: {} }],
457+};
458+}
459+460+it("synthetic first, real second → keeps real", () => {
461+const input = castAgentMessages([
462+makeAssistant("call_1"),
463+makeSyntheticResult("call_1"),
464+makeRealResult("call_1"),
465+]);
466+467+const result = repairToolUseResultPairing(input);
468+469+const toolResults = result.messages.filter((m) => m.role === "toolResult") as Array<{
470+isError?: boolean;
471+content?: Array<{ text?: string }>;
472+}>;
473+expect(toolResults).toHaveLength(1);
474+expect(toolResults[0]?.isError).not.toBe(true);
475+expect(toolResults[0]?.content?.[0]?.text).toBe("real output");
476+});
477+478+it("custom synthetic text first, real second → keeps real when configured", () => {
479+const input = castAgentMessages([
480+makeAssistant("call_1"),
481+makeCustomSyntheticResult("call_1", "aborted"),
482+makeRealResult("call_1"),
483+]);
484+485+const result = repairToolUseResultPairing(input, { missingToolResultText: "aborted" });
486+487+const toolResults = result.messages.filter((m) => m.role === "toolResult") as Array<{
488+isError?: boolean;
489+content?: Array<{ text?: string }>;
490+}>;
491+expect(toolResults).toHaveLength(1);
492+expect(toolResults[0]?.isError).not.toBe(true);
493+expect(toolResults[0]?.content?.[0]?.text).toBe("real output");
494+});
495+496+it("real error matching custom synthetic text stays first without marker", () => {
497+const input = castAgentMessages([
498+makeAssistant("call_1"),
499+{
500+role: "toolResult" as const,
501+toolCallId: "call_1",
502+toolName: "read",
503+content: [{ type: "text", text: "aborted" }],
504+isError: true,
505+},
506+makeRealResult("call_1"),
507+]);
508+509+const result = repairToolUseResultPairing(input, { missingToolResultText: "aborted" });
510+511+const toolResults = result.messages.filter((m) => m.role === "toolResult") as Array<{
512+isError?: boolean;
513+content?: Array<{ text?: string }>;
514+}>;
515+expect(toolResults).toHaveLength(1);
516+expect(toolResults[0]?.isError).toBe(true);
517+expect(toolResults[0]?.content?.[0]?.text).toBe("aborted");
518+});
519+520+it("real first, synthetic second → keeps real", () => {
521+const input = castAgentMessages([
522+makeAssistant("call_1"),
523+makeRealResult("call_1"),
524+makeSyntheticResult("call_1"),
525+]);
526+527+const result = repairToolUseResultPairing(input);
528+529+const toolResults = result.messages.filter((m) => m.role === "toolResult") as Array<{
530+isError?: boolean;
531+content?: Array<{ text?: string }>;
532+}>;
533+expect(toolResults).toHaveLength(1);
534+expect(toolResults[0]?.isError).not.toBe(true);
535+expect(toolResults[0]?.content?.[0]?.text).toBe("real output");
536+});
537+538+it("late real result after another assistant turn replaces prior synthetic", () => {
539+const input = castAgentMessages([
540+makeAssistant("call_1"),
541+makeSyntheticResult("call_1"),
542+{
543+role: "assistant" as const,
544+content: [{ type: "toolCall", id: "call_2", name: "write", arguments: {} }],
545+},
546+makeRealResult("call_1"),
547+{
548+role: "toolResult" as const,
549+toolCallId: "call_2",
550+toolName: "write",
551+content: [{ type: "text", text: "second output" }],
552+isError: false,
553+},
554+]);
555+556+const result = repairToolUseResultPairing(input);
557+558+const toolResults = result.messages.filter((m) => m.role === "toolResult") as Array<{
559+toolCallId?: string;
560+isError?: boolean;
561+content?: Array<{ text?: string }>;
562+}>;
563+expect(toolResults).toHaveLength(2);
564+expect(toolResults[0]?.toolCallId).toBe("call_1");
565+expect(toolResults[0]?.isError).not.toBe(true);
566+expect(toolResults[0]?.content?.[0]?.text).toBe("real output");
567+expect(toolResults[1]?.toolCallId).toBe("call_2");
568+expect(toolResults[1]?.content?.[0]?.text).toBe("second output");
569+});
570+571+it("two real results → keeps first (unchanged behavior)", () => {
572+const input = castAgentMessages([
573+makeAssistant("call_1"),
574+makeRealResult("call_1", "first real"),
575+makeRealResult("call_1", "second real"),
576+]);
577+578+const result = repairToolUseResultPairing(input);
579+580+const toolResults = result.messages.filter((m) => m.role === "toolResult") as Array<{
581+content?: Array<{ text?: string }>;
582+}>;
583+expect(toolResults).toHaveLength(1);
584+expect(toolResults[0]?.content?.[0]?.text).toBe("first real");
585+});
586+587+it("two synthetic errors → keeps first (unchanged behavior)", () => {
588+const input = castAgentMessages([
589+makeAssistant("call_1"),
590+makeSyntheticResult("call_1"),
591+makeSyntheticResult("call_1"),
592+]);
593+594+const result = repairToolUseResultPairing(input);
595+596+const toolResults = result.messages.filter((m) => m.role === "toolResult") as Array<{
597+isError?: boolean;
598+content?: Array<{ text?: string }>;
599+}>;
600+expect(toolResults).toHaveLength(1);
601+expect(toolResults[0]?.isError).toBe(true);
602+expect(toolResults[0]?.content?.[0]?.text).toBe(DEFAULT_MISSING_TOOL_RESULT_TEXT);
603+});
604+605+it("span-level: synthetic then real in span → picks real", () => {
606+const input = castAgentMessages([
607+makeAssistant("call_1"),
608+makeSyntheticResult("call_1"),
609+makeRealResult("call_1"),
610+{ role: "user", content: "next" },
611+]);
612+613+const result = repairToolUseResultPairing(input);
614+615+const toolResults = result.messages.filter((m) => m.role === "toolResult") as Array<{
616+isError?: boolean;
617+content?: Array<{ text?: string }>;
618+}>;
619+expect(toolResults).toHaveLength(1);
620+expect(toolResults[0]?.isError).not.toBe(true);
621+expect(toolResults[0]?.content?.[0]?.text).toBe("real output");
622+});
623+624+it("does not treat real error containing marker substring as synthetic", () => {
625+const input = castAgentMessages([
626+makeAssistant("call_1"),
627+{
628+role: "toolResult" as const,
629+toolCallId: "call_1",
630+toolName: "read",
631+content: [
632+{
633+type: "text",
634+text: DEFAULT_MISSING_TOOL_RESULT_TEXT + " (extra context from real error)",
635+},
636+],
637+isError: true,
638+},
639+makeRealResult("call_1"),
640+]);
641+642+const result = repairToolUseResultPairing(input);
643+644+const toolResults = result.messages.filter((m) => m.role === "toolResult") as Array<{
645+isError?: boolean;
646+content?: Array<{ text?: string }>;
647+}>;
648+expect(toolResults).toHaveLength(1);
649+expect(toolResults[0]?.content?.[0]?.text).toContain("extra context from real error");
650+});
651+652+it("changed flag is true when duplicates are dropped", () => {
653+const input = castAgentMessages([
654+makeAssistant("call_1"),
655+makeRealResult("call_1"),
656+makeRealResult("call_1", "duplicate"),
657+]);
658+659+const result = repairToolUseResultPairing(input);
660+661+expect(result.messages).not.toBe(input);
662+expect(result.droppedDuplicateCount).toBeGreaterThan(0);
663+});
664+});
665+420666describe("sanitizeToolCallInputs legacy block filtering", () => {
421667it("drops malformed snake_case tool call blocks", () => {
422668const input = castAgentMessages([
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。