diff --git a/src/agents/cache-trace.ts b/src/agents/cache-trace.ts index 0cc770dabe7..1edfd086f7a 100644 --- a/src/agents/cache-trace.ts +++ b/src/agents/cache-trace.ts @@ -129,12 +129,18 @@ function stableStringify(value: unknown): string { }); } if (Array.isArray(value)) { - return `[${value.map((entry) => stableStringify(entry)).join(",")}]`; + const serializedEntries: string[] = []; + for (const entry of value) { + serializedEntries.push(stableStringify(entry)); + } + return `[${serializedEntries.join(",")}]`; } const record = value as Record; - const keys = Object.keys(record).toSorted(); - const entries = keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`); - return `{${entries.join(",")}}`; + const serializedFields: string[] = []; + for (const key of Object.keys(record).toSorted()) { + serializedFields.push(`${JSON.stringify(key)}:${stableStringify(record[key])}`); + } + return `{${serializedFields.join(",")}}`; } function digest(value: unknown): string { diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts index f45653d7bb8..c9ac6fee023 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts @@ -135,6 +135,21 @@ const waitFor = async (predicate: () => boolean, timeoutMs = 2000) => { } }; +function expectSingleCompletionSend( + calls: GatewayRequest[], + expected: { sessionKey: string; channel: string; to: string; message: string }, +) { + const sendCalls = calls.filter((call) => call.method === "send"); + expect(sendCalls).toHaveLength(1); + const send = sendCalls[0]?.params as + | { sessionKey?: string; channel?: string; to?: string; message?: string } + | undefined; + expect(send?.sessionKey).toBe(expected.sessionKey); + expect(send?.channel).toBe(expected.channel); + expect(send?.to).toBe(expected.to); + expect(send?.message).toBe(expected.message); +} + describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { beforeEach(() => { resetSessionsSpawnConfigOverride(); @@ -204,15 +219,12 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { expect(first?.lane).toBe("subagent"); // Direct send should route completion to the requester channel/session. - const sendCalls = ctx.calls.filter((c) => c.method === "send"); - expect(sendCalls).toHaveLength(1); - const send = sendCalls[0]?.params as - | { sessionKey?: string; channel?: string; to?: string; message?: string } - | undefined; - expect(send?.sessionKey).toBe("agent:main:main"); - expect(send?.channel).toBe("whatsapp"); - expect(send?.to).toBe("+123"); - expect(send?.message).toBe("✅ Subagent main finished\n\ndone"); + expectSingleCompletionSend(ctx.calls, { + sessionKey: "agent:main:main", + channel: "whatsapp", + to: "+123", + message: "✅ Subagent main finished\n\ndone", + }); expect(child.sessionKey?.startsWith("agent:main:subagent:")).toBe(true); }); @@ -289,15 +301,12 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { expect(first?.sessionKey?.startsWith("agent:main:subagent:")).toBe(true); expect(child.sessionKey?.startsWith("agent:main:subagent:")).toBe(true); - const sendCalls = ctx.calls.filter((c) => c.method === "send"); - expect(sendCalls).toHaveLength(1); - const send = sendCalls[0]?.params as - | { sessionKey?: string; channel?: string; to?: string; message?: string } - | undefined; - expect(send?.sessionKey).toBe("agent:main:discord:group:req"); - expect(send?.channel).toBe("discord"); - expect(send?.to).toBe("discord:dm:u123"); - expect(send?.message).toBe("✅ Subagent main finished"); + expectSingleCompletionSend(ctx.calls, { + sessionKey: "agent:main:discord:group:req", + channel: "discord", + to: "discord:dm:u123", + message: "✅ Subagent main finished", + }); expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true); }); @@ -356,15 +365,12 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { const first = agentCalls[0]?.params as { lane?: string } | undefined; expect(first?.lane).toBe("subagent"); - const sendCalls = ctx.calls.filter((c) => c.method === "send"); - expect(sendCalls).toHaveLength(1); - const send = sendCalls[0]?.params as - | { sessionKey?: string; channel?: string; to?: string; message?: string } - | undefined; - expect(send?.sessionKey).toBe("agent:main:discord:group:req"); - expect(send?.channel).toBe("discord"); - expect(send?.to).toBe("discord:dm:u123"); - expect(send?.message).toBe("✅ Subagent main finished\n\ndone"); + expectSingleCompletionSend(ctx.calls, { + sessionKey: "agent:main:discord:group:req", + channel: "discord", + to: "discord:dm:u123", + message: "✅ Subagent main finished\n\ndone", + }); // Session should be deleted expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true); diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 7c08ccef94c..253074ddd35 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -1,6 +1,7 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; import type { OpenClawConfig } from "../../config/config.js"; import { formatSandboxToolPolicyBlockedMessage } from "../sandbox.js"; +import { stableStringify } from "../stable-stringify.js"; import type { FailoverReason } from "./types.js"; export function formatBillingErrorMessage(provider?: string): string { @@ -309,19 +310,6 @@ function parseApiErrorPayload(raw: string): ErrorPayload | null { return null; } -function stableStringify(value: unknown): string { - if (!value || typeof value !== "object") { - return JSON.stringify(value) ?? "null"; - } - if (Array.isArray(value)) { - return `[${value.map((entry) => stableStringify(entry)).join(",")}]`; - } - const record = value as Record; - const keys = Object.keys(record).toSorted(); - const entries = keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`); - return `{${entries.join(",")}}`; -} - export function getApiErrorPayloadFingerprint(raw?: string): string | null { if (!raw) { return null; diff --git a/src/agents/stable-stringify.ts b/src/agents/stable-stringify.ts new file mode 100644 index 00000000000..d86b937b7f8 --- /dev/null +++ b/src/agents/stable-stringify.ts @@ -0,0 +1,12 @@ +export function stableStringify(value: unknown): string { + if (value === null || typeof value !== "object") { + return JSON.stringify(value) ?? "null"; + } + if (Array.isArray(value)) { + return `[${value.map((entry) => stableStringify(entry)).join(",")}]`; + } + const record = value as Record; + const keys = Object.keys(record).toSorted(); + const entries = keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`); + return `{${entries.join(",")}}`; +}