Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 67 additions & 1 deletion src/app/api/runtimes/[id]/talk/realtime/relay/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ describe("POST /api/runtimes/[id]/talk/realtime/relay", () => {
event: "chat",
runId: "run_1",
state: "final",
message: { content: "The repo is a CrewCMD app." },
message: { content: [{ type: "text", text: "The repo is a CrewCMD app." }] },
});

const response = await responsePromise;
Expand Down Expand Up @@ -185,6 +185,72 @@ describe("POST /api/runtimes/[id]/talk/realtime/relay", () => {
expect(mockReleaseClient).toHaveBeenCalledWith(client);
});

it("extracts final realtime consult text from OpenClaw trace artifacts", async () => {
let gatewayHandler: ((payload: unknown) => void) | null = null;
const client = {
realtimeClientToolCall: vi.fn().mockResolvedValue({ ok: true, runId: "run_1" }),
realtimeRelayToolResult: vi.fn().mockResolvedValue({ ok: true }),
on: vi.fn((event: string, handler: (payload: unknown) => void) => {
if (event === "*") gatewayHandler = handler;
}),
off: vi.fn(),
};
mockRuntimeRows.push({ id: "rt_1", ownerUserId: "user_1" });
mockGetGatewayClientForRuntime.mockResolvedValue(client);

const responsePromise = POST(
new Request("http://localhost/api/runtimes/rt_1/talk/realtime/relay", {
method: "POST",
body: JSON.stringify({
action: "toolCall",
relaySessionId: "relay_1",
sessionKey: "main",
callId: "call_1",
name: "openclaw_agent_consult",
args: { prompt: "Inspect this repo" },
}),
}),
{ params: Promise.resolve({ id: "rt_1" }) },
);

await vi.waitFor(() => {
expect(client.realtimeClientToolCall).toHaveBeenCalledWith({
relaySessionId: "relay_1",
sessionKey: "main",
callId: "call_1",
name: "openclaw_agent_consult",
args: { prompt: "Inspect this repo" },
});
expect(gatewayHandler).toBeTypeOf("function");
});

(gatewayHandler as ((payload: unknown) => void) | null)?.({
event: "trace.artifacts",
runId: "run_1",
state: "completed",
data: {
assistantTexts: ["The README describes the ClutchCut content engine."],
},
});

const response = await responsePromise;
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
result: {
delegated: true,
runId: "run_1",
finalText: "The README describes the ClutchCut content engine.",
result: { ok: true },
},
});
expect(client.realtimeRelayToolResult).toHaveBeenNthCalledWith(2, {
relaySessionId: "relay_1",
callId: "call_1",
result: { text: "The README describes the ClutchCut content engine." },
});
expect(mockReleaseClient).toHaveBeenCalledWith(client);
});

it("rejects invalid relay actions before calling the gateway", async () => {
mockRuntimeRows.push({ id: "rt_1", ownerUserId: "user_1" });

Expand Down
6 changes: 4 additions & 2 deletions src/app/api/runtimes/[id]/talk/realtime/relay/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ function waitForChatFinal(client: GatewayClient, runId: string, timeoutMs = 110_

const state = firstString(event.state, event.status)?.toLowerCase();
if (state === "final" || state === "complete" || state === "completed") {
const text = extractText(event.message ?? event);
const text = extractText(event.message) || extractText(event);
cleanup();
resolve(text || "OpenClaw completed without returning text.");
return;
Expand Down Expand Up @@ -263,15 +263,17 @@ function extractText(value: unknown, seen = new WeakSet<object>()): string {
}

const record = value as Record<string, unknown>;
const direct = firstString(record.text, record.content, record.output, record.result);
const direct = firstString(record.text, record.output, record.result, record.finalText);
if (direct) return direct;

return [
record.content,
record.message,
record.delta,
record.data,
record.payload,
record.parts,
record.items,
record.assistantTexts,
].map((item) => extractText(item, seen)).filter(Boolean).join("");
}
Loading