























@@ -0,0 +1,138 @@
1+import { afterEach, describe, expect, it, vi } from "vitest";
2+import { VoiceClawGeminiLiveAdapter } from "./gemini-live.js";
3+4+describe("VoiceClawGeminiLiveAdapter watchdog", () => {
5+afterEach(() => {
6+vi.useRealTimers();
7+});
8+9+it("stays paused while async OpenClaw tool work is still running", () => {
10+vi.useFakeTimers();
11+const adapter = new VoiceClawGeminiLiveAdapter();
12+const internals = adapter as unknown as {
13+watchdogEnabled: boolean;
14+resetWatchdog: () => void;
15+sendUpstream: (message: Record<string, unknown>) => void;
16+};
17+const sendUpstream = vi.fn();
18+internals.watchdogEnabled = true;
19+internals.sendUpstream = sendUpstream;
20+21+adapter.beginAsyncToolCall("call-1");
22+internals.resetWatchdog();
23+vi.advanceTimersByTime(21_000);
24+25+expect(sendUpstream).not.toHaveBeenCalled();
26+27+adapter.finishAsyncToolCall("call-1");
28+vi.advanceTimersByTime(20_000);
29+30+expect(sendUpstream).toHaveBeenCalledOnce();
31+expect(sendUpstream.mock.calls[0][0]).toMatchObject({
32+realtimeInput: {
33+text: expect.stringContaining("user has been silent"),
34+},
35+});
36+});
37+});
38+39+describe("VoiceClawGeminiLiveAdapter tool cancellation", () => {
40+afterEach(() => {
41+vi.useRealTimers();
42+});
43+44+it("releases the watchdog hold when Gemini cancels an already-acked async tool", () => {
45+vi.useFakeTimers();
46+const adapter = new VoiceClawGeminiLiveAdapter();
47+const events: unknown[] = [];
48+const sendUpstream = vi.fn();
49+const internals = adapter as unknown as {
50+asyncToolCallIds: Set<string>;
51+handleServerMessage: (message: Record<string, unknown>) => void;
52+sendToClient: (event: unknown) => void;
53+sendUpstream: (message: Record<string, unknown>) => void;
54+watchdogEnabled: boolean;
55+};
56+internals.sendToClient = (event) => events.push(event);
57+internals.sendUpstream = sendUpstream;
58+internals.watchdogEnabled = true;
59+60+adapter.beginAsyncToolCall("call-1");
61+internals.handleServerMessage({ toolCallCancellation: { ids: ["call-1"] } });
62+vi.advanceTimersByTime(20_000);
63+64+expect(events).toContainEqual({ type: "tool.cancelled", callIds: ["call-1"] });
65+expect(internals.asyncToolCallIds.size).toBe(0);
66+expect(sendUpstream).toHaveBeenCalledOnce();
67+});
68+69+it("cancels async OpenClaw tool work when Gemini closes after the working ack", () => {
70+const adapter = new VoiceClawGeminiLiveAdapter();
71+const events: unknown[] = [];
72+const internals = adapter as unknown as {
73+asyncToolCallIds: Set<string>;
74+handleUpstreamClose: (code: number) => void;
75+sendToClient: (event: unknown) => void;
76+};
77+internals.sendToClient = (event) => events.push(event);
78+79+adapter.beginAsyncToolCall("call-1");
80+internals.handleUpstreamClose(1000);
81+82+expect(events).toContainEqual({ type: "tool.cancelled", callIds: ["call-1"] });
83+expect(events).toContainEqual({
84+type: "error",
85+message: "Gemini Live closed while a tool call was in flight",
86+code: 502,
87+});
88+expect(internals.asyncToolCallIds.size).toBe(0);
89+});
90+91+it("defers goAway rotation until async OpenClaw tool work finishes", () => {
92+const adapter = new VoiceClawGeminiLiveAdapter();
93+const reconnect = vi.fn();
94+const internals = adapter as unknown as {
95+currentlyResumable: boolean;
96+handleServerMessage: (message: Record<string, unknown>) => void;
97+reconnect: (reason: string) => void;
98+resumptionHandle: string;
99+rotateAfterToolCalls: boolean;
100+};
101+internals.currentlyResumable = true;
102+internals.resumptionHandle = "resume-1";
103+internals.reconnect = reconnect;
104+105+adapter.beginAsyncToolCall("call-1");
106+internals.handleServerMessage({ goAway: {} });
107+108+expect(reconnect).not.toHaveBeenCalled();
109+expect(internals.rotateAfterToolCalls).toBe(true);
110+111+adapter.finishAsyncToolCall("call-1");
112+113+expect(internals.rotateAfterToolCalls).toBe(false);
114+expect(reconnect).toHaveBeenCalledWith("deferred goAway");
115+});
116+117+it("rotates after goAway when Gemini cancels the deferred async tool", () => {
118+const adapter = new VoiceClawGeminiLiveAdapter();
119+const reconnect = vi.fn();
120+const internals = adapter as unknown as {
121+currentlyResumable: boolean;
122+handleServerMessage: (message: Record<string, unknown>) => void;
123+reconnect: (reason: string) => void;
124+resumptionHandle: string;
125+rotateAfterToolCalls: boolean;
126+};
127+internals.currentlyResumable = true;
128+internals.resumptionHandle = "resume-1";
129+internals.reconnect = reconnect;
130+131+adapter.beginAsyncToolCall("call-1");
132+internals.handleServerMessage({ goAway: {} });
133+internals.handleServerMessage({ toolCallCancellation: { ids: ["call-1"] } });
134+135+expect(internals.rotateAfterToolCalls).toBe(false);
136+expect(reconnect).toHaveBeenCalledWith("deferred goAway");
137+});
138+});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。