From 8b1a9ccaa4818cdac9eaad42e7bfd7b406f3123e Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Sat, 1 Aug 2026 13:23:24 +0800 Subject: [PATCH] fix(voice-call): preserve tool invocation context (#116856) * fix(voice-call): preserve tool invocation context * fix(voice-call): trust requester tool context * style(voice-call): format invocation context test --- extensions/voice-call/index.test.ts | 48 +++++++++++++------ extensions/voice-call/index.ts | 43 +++++++++-------- test/e2e/qa-lab/runtime/voice-call-gateway.ts | 11 +++-- 3 files changed, 63 insertions(+), 39 deletions(-) diff --git a/extensions/voice-call/index.test.ts b/extensions/voice-call/index.test.ts index 5b71bf3cd40a..d29c851a4383 100644 --- a/extensions/voice-call/index.test.ts +++ b/extensions/voice-call/index.test.ts @@ -758,23 +758,41 @@ describe("voice-call plugin", () => { expect(error?.message).not.toContain("endedAt="); }); - it("freezes the invoking agent on tool-created calls", async () => { - const { tools } = setup({ provider: "mock" }, { agentId: "support" }); - const tool = tools[0] as { - execute: (id: string, params: unknown) => Promise; - }; + it.each([{ action: "initiate_call", message: "Hello" }, { message: "Hello" }])( + "freezes invocation context for tool-created calls ($action)", + async (params) => { + const { tools } = setup( + { provider: "mock" }, + { agentId: "support", sessionKey: "agent:support:discord:channel:general" }, + ); + const tool = tools[0] as { + execute: (id: string, params: unknown) => Promise; + }; - await tool.execute("id", { - action: "initiate_call", - to: "+15550001234", - message: "Hello", - }); + await tool.execute("id", { + ...params, + to: "+15550001234", + requesterSessionKey: "agent:spoofed:requester", + sessionKey: "agent:support:voice:call-1", + }); - expect(runtimeStub.manager["initiateCall"]).toHaveBeenCalledWith( - "+15550001234", - undefined, - expect.objectContaining({ agentId: "support", message: "Hello" }), - ); + expect(runtimeStub.manager["initiateCall"]).toHaveBeenCalledWith( + "+15550001234", + "agent:support:voice:call-1", + expect.objectContaining({ + agentId: "support", + message: "Hello", + requesterSessionKey: "agent:support:discord:channel:general", + }), + ); + }, + ); + + it("does not expose requester session identity to the model", () => { + const { tools } = setup({ provider: "mock" }); + const tool = tools[0] as { parameters: unknown }; + + expect(JSON.stringify(tool.parameters)).not.toContain("requesterSessionKey"); }); it("tool get_status returns json payload", async () => { diff --git a/extensions/voice-call/index.ts b/extensions/voice-call/index.ts index bbea0855a597..083d311511e2 100644 --- a/extensions/voice-call/index.ts +++ b/extensions/voice-call/index.ts @@ -2,7 +2,7 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { ErrorCodes, errorShape } from "openclaw/plugin-sdk/gateway-runtime"; import { timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime"; -import { normalizeAgentId } from "openclaw/plugin-sdk/routing"; +import { normalizeAgentId, parseAgentSessionKey } from "openclaw/plugin-sdk/routing"; import { asOptionalRecord, normalizeOptionalString, @@ -189,9 +189,6 @@ const VoiceCallToolSchema = Type.Union([ message: Type.String({ description: "Intro message" }), mode: Type.Optional(Type.Union([Type.Literal("notify"), Type.Literal("conversation")])), sessionKey: Type.Optional(Type.String({ description: "OpenClaw session key for the call" })), - requesterSessionKey: Type.Optional( - Type.String({ description: "OpenClaw session key that initiated the call" }), - ), dtmfSequence: Type.Optional(Type.String({ description: "DTMF digits to play before connect" })), }), Type.Object({ @@ -223,9 +220,6 @@ const VoiceCallToolSchema = Type.Union([ sid: Type.Optional(Type.String({ description: "Call SID" })), message: Type.Optional(Type.String({ description: "Optional intro message" })), sessionKey: Type.Optional(Type.String({ description: "OpenClaw session key for the call" })), - requesterSessionKey: Type.Optional( - Type.String({ description: "OpenClaw session key that initiated the call" }), - ), dtmfSequence: Type.Optional(Type.String({ description: "DTMF digits to play before connect" })), }), ]); @@ -723,7 +717,13 @@ export default definePluginEntry({ parameters: VoiceCallToolSchema, async execute(_toolCallId, params) { const rawParams = asParamRecord(params); - const agentId = normalizeOptionalString(toolContext.agentId); + const requesterSessionKey = normalizeOptionalString(toolContext.sessionKey); + // Agent ownership and requester lineage come from trusted tool context. + // Some harnesses omit agentId but retain its canonical session key. + const contextAgentId = + normalizeOptionalString(toolContext.agentId) ?? + parseAgentSessionKey(requesterSessionKey)?.agentId; + const agentId = contextAgentId ? normalizeAgentId(contextAgentId) : undefined; try { const rt = await ensureRuntime(); @@ -738,15 +738,20 @@ export default definePluginEntry({ if (!to) { throw new Error("to required"); } - const result = await rt.manager.initiateCall(to, undefined, { - message, - dtmfSequence: normalizeOptionalString(rawParams.dtmfSequence), - mode: - rawParams.mode === "notify" || rawParams.mode === "conversation" - ? rawParams.mode - : undefined, - ...(agentId ? { agentId } : {}), - }); + const result = await rt.manager.initiateCall( + to, + normalizeOptionalString(rawParams.sessionKey), + { + message, + dtmfSequence: normalizeOptionalString(rawParams.dtmfSequence), + mode: + rawParams.mode === "notify" || rawParams.mode === "conversation" + ? rawParams.mode + : undefined, + ...(agentId ? { agentId } : {}), + ...(requesterSessionKey ? { requesterSessionKey } : {}), + }, + ); if (!result.success) { throw new Error(result.error || "initiate failed"); } @@ -833,9 +838,7 @@ export default definePluginEntry({ dtmfSequence: normalizeOptionalString(rawParams.dtmfSequence), message: normalizeOptionalString(rawParams.message), ...(agentId ? { agentId } : {}), - ...(normalizeOptionalString(rawParams.requesterSessionKey) - ? { requesterSessionKey: normalizeOptionalString(rawParams.requesterSessionKey) } - : {}), + ...(requesterSessionKey ? { requesterSessionKey } : {}), }, ); if (!result.success) { diff --git a/test/e2e/qa-lab/runtime/voice-call-gateway.ts b/test/e2e/qa-lab/runtime/voice-call-gateway.ts index 866e1965fa2f..23be3ad8e8b9 100644 --- a/test/e2e/qa-lab/runtime/voice-call-gateway.ts +++ b/test/e2e/qa-lab/runtime/voice-call-gateway.ts @@ -207,7 +207,7 @@ async function runVoiceCallProof(options: ProducerOptions): Promise { to: "+15550002222", message: "Gateway RPC fixture", mode: "conversation", - sessionKey: "agent:main:voice-rpc", + sessionKey: "agent:qa:voice-rpc", }); const rpcCallId = findStringByKey(rpc, "callId"); if (!rpcCallId) { @@ -215,14 +215,13 @@ async function runVoiceCallProof(options: ProducerOptions): Promise { } const tool = await gateway.call("tools.invoke", { name: "voice_call", - sessionKey: "agent:main:requester", + sessionKey: "agent:qa:requester", args: { action: "initiate_call", to: "+15550003333", message: "Agent tool fixture", mode: "conversation", - sessionKey: "agent:main:voice-consult", - requesterSessionKey: "agent:main:requester", + sessionKey: "agent:qa:voice-consult", }, }); const toolCallId = findStringByKey(tool, "callId"); @@ -255,6 +254,10 @@ async function runVoiceCallProof(options: ProducerOptions): Promise { streamUrl: stream.streamUrl, }); const toolResults = await waitForFinalToolResult(fixture.toolResultsPath); + const finalToolResult = toolResults.final.result as Record; + if (typeof finalToolResult.error === "string") { + throw new Error(`embedded consult failed: ${finalToolResult.error}`); + } const bridgeCalls = (await fs.readFile(fixture.bridgeCallsPath, "utf8")) .split("\n") .map((line) => line.trim())