From e36a767a10e590f070dab6424f2a016c851a75e9 Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Thu, 28 May 2026 17:02:58 +1000 Subject: [PATCH] fix: support legacy OpenClaw realtime consults --- src/lib/gateway-client-realtime.test.ts | 83 ++++++++++++++++++++++++ src/lib/gateway-client.ts | 85 ++++++++++++++++++++++++- 2 files changed, 165 insertions(+), 3 deletions(-) diff --git a/src/lib/gateway-client-realtime.test.ts b/src/lib/gateway-client-realtime.test.ts index 1898cbc..a839f5d 100644 --- a/src/lib/gateway-client-realtime.test.ts +++ b/src/lib/gateway-client-realtime.test.ts @@ -95,6 +95,51 @@ describe("GatewayClient realtime Talk compatibility", () => { }); }); + it("skips interim continuing tool results when only the legacy relay API is available", async () => { + const client = new GatewayClient("ws://localhost:18789", null, device); + const rpc = vi.spyOn(client, "rpc") + .mockRejectedValueOnce(new Error("method not found")); + + await expect(client.realtimeRelayToolResult({ + relaySessionId: "relay_1", + callId: "call_1", + result: { status: "working" }, + options: { willContinue: true }, + })).resolves.toEqual({ ok: true }); + + expect(rpc).toHaveBeenCalledTimes(1); + expect(rpc).toHaveBeenCalledWith("talk.session.submitToolResult", { + sessionId: "relay_1", + callId: "call_1", + result: { status: "working" }, + options: { willContinue: true }, + }); + }); + + it("strips unsupported tool-result options when falling back to the legacy relay API", async () => { + const client = new GatewayClient("ws://localhost:18789", null, device); + const rpc = vi.spyOn(client, "rpc") + .mockRejectedValueOnce(new Error("method not found")) + .mockResolvedValueOnce({ ok: true }); + + await expect(client.realtimeRelayToolResult({ + relaySessionId: "relay_1", + callId: "call_1", + result: { text: "Done" }, + })).resolves.toEqual({ ok: true }); + + expect(rpc).toHaveBeenNthCalledWith(1, "talk.session.submitToolResult", { + sessionId: "relay_1", + callId: "call_1", + result: { text: "Done" }, + }); + expect(rpc).toHaveBeenNthCalledWith(2, "talk.realtime.relayToolResult", { + relaySessionId: "relay_1", + callId: "call_1", + result: { text: "Done" }, + }); + }); + it("keeps relay mark acknowledgements local for the current OpenClaw API", async () => { const client = new GatewayClient("ws://localhost:18789", null, device); const rpc = vi.spyOn(client, "rpc"); @@ -139,4 +184,42 @@ describe("GatewayClient realtime Talk compatibility", () => { args: { prompt: "Inspect this repo" }, }); }); + + it("falls back to chat.send when OpenClaw client tool calls are unavailable", async () => { + const client = new GatewayClient("ws://localhost:18789", null, device); + const rpc = vi.spyOn(client, "rpc") + .mockRejectedValueOnce(new Error("unknown method")) + .mockResolvedValueOnce({ runId: "call_1", status: "started" }); + + await expect(client.realtimeClientToolCall({ + sessionKey: "main", + relaySessionId: "relay_1", + callId: "call_1", + name: "openclaw_agent_consult", + args: { + prompt: "Inspect this repo", + context: "Current screen is CrewCMD chat.", + }, + })).resolves.toEqual({ runId: "call_1", status: "started" }); + + expect(rpc).toHaveBeenNthCalledWith(1, "talk.client.toolCall", { + sessionKey: "main", + relaySessionId: "relay_1", + callId: "call_1", + name: "openclaw_agent_consult", + args: { + prompt: "Inspect this repo", + context: "Current screen is CrewCMD chat.", + }, + }); + expect(rpc).toHaveBeenNthCalledWith(2, "chat.send", expect.objectContaining({ + sessionKey: "main", + idempotencyKey: "call_1", + thinking: "low", + message: expect.stringContaining("Question:\nInspect this repo"), + })); + expect(rpc).toHaveBeenNthCalledWith(2, "chat.send", expect.objectContaining({ + message: expect.stringContaining("Context:\nCurrent screen is CrewCMD chat."), + })); + }); }); diff --git a/src/lib/gateway-client.ts b/src/lib/gateway-client.ts index 6044a8b..b3fea1d 100644 --- a/src/lib/gateway-client.ts +++ b/src/lib/gateway-client.ts @@ -223,6 +223,8 @@ export interface GatewayRealtimeClientToolCallResult { idempotencyKey?: string; } +const REALTIME_AGENT_CONSULT_TOOL = "openclaw_agent_consult"; + export interface GatewayTalkCatalogProvider { id: string; label?: string; @@ -355,6 +357,57 @@ function isLikelyMissingGatewayMethod(err: unknown): boolean { ); } +function isRpcTimeoutFor(err: unknown, method: string): boolean { + const message = err instanceof Error ? err.message : String(err); + return message === `RPC timeout: ${method}`; +} + +function buildRealtimeAgentConsultFallbackMessage(args: unknown): string { + const parsed = normalizeRealtimeAgentConsultArgs(args); + const question = firstTrimmedString(parsed.question, parsed.prompt, parsed.message, parsed.input); + const context = firstTrimmedString(parsed.context); + const responseStyle = firstTrimmedString(parsed.responseStyle, parsed.response_style); + const rawArgs = question ? null : stringifyCompact(args); + + return [ + "Realtime voice requested an OpenClaw agent consult.", + question ? `Question:\n${question}` : rawArgs ? `Tool arguments:\n${rawArgs}` : null, + context ? `Context:\n${context}` : null, + responseStyle ? `Spoken style:\n${responseStyle}` : null, + "Return only the concise answer the realtime voice agent should speak next.", + ].filter(Boolean).join("\n\n"); +} + +function normalizeRealtimeAgentConsultArgs(args: unknown): Record { + if (args && typeof args === "object" && !Array.isArray(args)) return args as Record; + if (typeof args !== "string") return {}; + try { + const parsed = JSON.parse(args); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed as Record + : { question: args }; + } catch { + return { question: args }; + } +} + +function firstTrimmedString(...values: unknown[]) { + for (const value of values) { + if (typeof value === "string" && value.trim()) return value.trim(); + } + return null; +} + +function stringifyCompact(value: unknown) { + if (value === undefined || value === null) return null; + if (typeof value === "string") return value.trim() || null; + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + /** * Build v3 device auth payload string for signing. */ @@ -846,15 +899,41 @@ export class GatewayClient { options: params.options, })); } catch (err) { - if (!isLikelyMissingGatewayMethod(err)) throw err; - return this.rpc<{ ok?: boolean }>("talk.realtime.relayToolResult", params); + if ( + !isLikelyMissingGatewayMethod(err) && + !(params.options?.willContinue && isRpcTimeoutFor(err, "talk.session.submitToolResult")) + ) throw err; + if (params.options?.willContinue) { + return { ok: true }; + } + return this.rpc<{ ok?: boolean }>("talk.realtime.relayToolResult", withoutUndefined({ + relaySessionId: params.relaySessionId, + callId: params.callId, + result: params.result, + })); } } async realtimeClientToolCall( params: GatewayRealtimeClientToolCallParams, ): Promise { - return this.rpc("talk.client.toolCall", withoutUndefined(params)); + try { + return await this.rpc("talk.client.toolCall", withoutUndefined(params)); + } catch (err) { + if ( + ( + !isLikelyMissingGatewayMethod(err) && + !isRpcTimeoutFor(err, "talk.client.toolCall") + ) || + params.name !== REALTIME_AGENT_CONSULT_TOOL + ) throw err; + return this.chatSend({ + sessionKey: params.sessionKey, + idempotencyKey: params.callId, + message: buildRealtimeAgentConsultFallbackMessage(params.args), + thinking: "low", + }); + } } async realtimeRelayStop(relaySessionId: string): Promise<{ ok?: boolean }> {