mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 05:41:37 +00:00
fix(google): make continuity reset generation-idempotent
This commit is contained in:
@@ -1071,6 +1071,89 @@ describe("buildGoogleRealtimeVoiceProvider", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("emits one continuity reset across failed fresh reconnect attempts", async () => {
|
||||
vi.useFakeTimers();
|
||||
const provider = buildGoogleRealtimeVoiceProvider();
|
||||
const onClose = vi.fn();
|
||||
const onEvent = vi.fn();
|
||||
const bridge = provider.createBridge({
|
||||
providerConfig: { apiKey: "gemini-key", sessionResumption: false },
|
||||
onAudio: vi.fn(),
|
||||
onClearAudio: vi.fn(),
|
||||
onClose,
|
||||
onEvent,
|
||||
});
|
||||
|
||||
await bridge.connect();
|
||||
const firstSession = lastConnectParams().callbacks;
|
||||
firstSession.onmessage({ setupComplete: {} });
|
||||
connectMock
|
||||
.mockRejectedValueOnce(new Error("connect failed 1"))
|
||||
.mockRejectedValueOnce(new Error("connect failed 2"))
|
||||
.mockRejectedValueOnce(new Error("connect failed 3"));
|
||||
firstSession.onclose({ code: 1011, reason: "temporary" });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_750);
|
||||
|
||||
expect(connectMock).toHaveBeenCalledTimes(4);
|
||||
expect(onEvent.mock.calls).toEqual([
|
||||
[{ direction: "client", type: "session.continuity.reset" }],
|
||||
]);
|
||||
expect(onClose).toHaveBeenCalledWith("error");
|
||||
});
|
||||
|
||||
it("rearms continuity reset after pre-return setup selects a fresh session", async () => {
|
||||
vi.useFakeTimers();
|
||||
const pendingSession = createDeferred<MockGoogleLiveSession>();
|
||||
const freshSession = createMockGoogleLiveSession();
|
||||
connectMock
|
||||
.mockReturnValueOnce(Promise.resolve(session))
|
||||
.mockReturnValueOnce(pendingSession.promise);
|
||||
const provider = buildGoogleRealtimeVoiceProvider();
|
||||
const onEvent = vi.fn();
|
||||
const onReady = vi.fn();
|
||||
const onTranscript = vi.fn();
|
||||
const bridge = provider.createBridge({
|
||||
providerConfig: { apiKey: "gemini-key", sessionResumption: false },
|
||||
onAudio: vi.fn(),
|
||||
onClearAudio: vi.fn(),
|
||||
onEvent,
|
||||
onReady,
|
||||
onTranscript,
|
||||
});
|
||||
|
||||
await bridge.connect();
|
||||
const firstCallbacks = lastConnectParams().callbacks;
|
||||
firstCallbacks.onopen();
|
||||
firstCallbacks.onmessage({ setupComplete: {} });
|
||||
firstCallbacks.onclose({ code: 1011, reason: "temporary" });
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
const freshCallbacks = lastConnectParams().callbacks;
|
||||
expect(onEvent).toHaveBeenCalledTimes(1);
|
||||
freshCallbacks.onopen();
|
||||
freshCallbacks.onmessage({
|
||||
setupComplete: {},
|
||||
serverContent: { inputTranscription: { text: "Fresh partial " } },
|
||||
});
|
||||
expect(onReady).toHaveBeenCalledTimes(1);
|
||||
|
||||
pendingSession.resolve(freshSession);
|
||||
await vi.waitFor(() => {
|
||||
expect(onReady).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
freshCallbacks.onclose({ code: 1011, reason: "temporary again" });
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
expect(onEvent).toHaveBeenCalledTimes(2);
|
||||
lastConnectParams().callbacks.onmessage({
|
||||
serverContent: { inputTranscription: { text: "Next", finished: true } },
|
||||
});
|
||||
expect(onTranscript.mock.calls.filter((call) => call[2] === true)).toEqual([
|
||||
["user", "Next", true],
|
||||
]);
|
||||
});
|
||||
|
||||
it("waits for the returned session after setup completion before activating", async () => {
|
||||
const pendingSession = createDeferred<MockGoogleLiveSession>();
|
||||
const connectedSession = createMockGoogleLiveSession();
|
||||
|
||||
@@ -482,6 +482,7 @@ class GoogleRealtimeVoiceBridge implements RealtimeVoiceBridge {
|
||||
private reconnectAttempts = 0;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
private hasConnectedSession = false;
|
||||
private continuityResetEmitted = false;
|
||||
private terminalError: Error | undefined;
|
||||
private closeNotified = false;
|
||||
private connectionOwner: GoogleLiveConnectionAttempt | undefined;
|
||||
@@ -532,10 +533,11 @@ class GoogleRealtimeVoiceBridge implements RealtimeVoiceBridge {
|
||||
private async connectOwned(attempt: GoogleLiveConnectionAttempt): Promise<void> {
|
||||
const canResumeSession =
|
||||
this.config.sessionResumption !== false && Boolean(this.resumptionHandle);
|
||||
if (this.hasConnectedSession && !canResumeSession) {
|
||||
if (this.hasConnectedSession && !canResumeSession && !this.continuityResetEmitted) {
|
||||
// An unfinished recognition hypothesis cannot cross into a fresh server session.
|
||||
// Notify consumers before connect because the SDK can replay fresh-session
|
||||
// callbacks before its connect promise returns.
|
||||
this.continuityResetEmitted = true;
|
||||
this.resetPendingTranscripts();
|
||||
this.config.onEvent?.({
|
||||
direction: "client",
|
||||
@@ -868,6 +870,11 @@ class GoogleRealtimeVoiceBridge implements RealtimeVoiceBridge {
|
||||
}
|
||||
|
||||
private handleSetupComplete(): void {
|
||||
if (!this.setupCompleteReceived) {
|
||||
// setupComplete proves Google selected a new server session. A later
|
||||
// continuity loss therefore owns a new reset generation.
|
||||
this.continuityResetEmitted = false;
|
||||
}
|
||||
this.setupCompleteReceived = true;
|
||||
this.maybeActivateSession();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user