From ef793477636ae46ef3b43a1bedb9da591246c23a Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 01:05:10 -0700 Subject: [PATCH] fix(ui): retry errored talk sessions --- CHANGELOG.md | 1 + ui/src/ui/app.talk.test.ts | 57 ++++++++++++++++++++++++++++++++++++++ ui/src/ui/app.ts | 19 ++++++++----- 3 files changed, 70 insertions(+), 7 deletions(-) create mode 100644 ui/src/ui/app.talk.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 71aebca5a3f..20ee0758b9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ Docs: https://docs.openclaw.ai - Control UI/Talk: make failed Talk startup errors dismissable and clear the stale Talk error state when dismissed, so missing realtime voice provider configuration does not leave a permanent chat banner. Fixes #77071. Thanks @ijoshdavis. - Control UI/Talk: stop and clear failed realtime Talk sessions when dismissing runtime error banners, so the next Talk click starts a fresh session instead of only stopping the stale one. Thanks @vincentkoc. +- Control UI/Talk: retry from a failed realtime Talk session on the next Talk click instead of requiring a separate stale-session stop click first. Thanks @vincentkoc. - Google Chat: create an isolated Google auth transport per auth client, so google-auth-library interceptor mutations do not accumulate across webhook verification and access-token clients. Thanks @vincentkoc. - Control UI/performance: cap long-task and long-animation-frame diagnostics in the shared event log, so slow-render telemetry does not evict gateway/plugin events from the Debug and Overview views. Thanks @vincentkoc. - Web fetch: late-bind `web_fetch` config and provider fallback metadata from the active runtime snapshot, matching `web_search` so long-lived tools do not use stale fetch provider settings. Thanks @vincentkoc. diff --git a/ui/src/ui/app.talk.test.ts b/ui/src/ui/app.talk.test.ts new file mode 100644 index 00000000000..fc702b3e9b9 --- /dev/null +++ b/ui/src/ui/app.talk.test.ts @@ -0,0 +1,57 @@ +/* @vitest-environment jsdom */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { realtimeTalkCtor, startMock, stopMock } = vi.hoisted(() => ({ + realtimeTalkCtor: vi.fn(), + startMock: vi.fn(), + stopMock: vi.fn(), +})); + +vi.mock("./chat/realtime-talk.ts", () => ({ + RealtimeTalkSession: realtimeTalkCtor, +})); + +describe("OpenClawApp Talk controls", () => { + beforeEach(() => { + realtimeTalkCtor.mockReset(); + startMock.mockReset(); + stopMock.mockReset(); + realtimeTalkCtor.mockImplementation( + function MockRealtimeTalkSession(this: { start: typeof startMock; stop: typeof stopMock }) { + this.start = startMock; + this.stop = stopMock; + }, + ); + startMock.mockResolvedValue(undefined); + }); + + it("retries Talk immediately when the previous session is already in error state", async () => { + const { OpenClawApp } = await import("./app.ts"); + const app = new OpenClawApp() as unknown as { + client: unknown; + connected: boolean; + realtimeTalkActive: boolean; + realtimeTalkStatus: string; + realtimeTalkSession: { stop(): void } | null; + sessionKey: string; + toggleRealtimeTalk(): Promise; + }; + const staleStop = vi.fn(); + app.client = { request: vi.fn() } as never; + app.connected = true; + app.sessionKey = "main"; + app.realtimeTalkActive = true; + app.realtimeTalkStatus = "error"; + app.realtimeTalkSession = { stop: staleStop }; + + await app.toggleRealtimeTalk(); + + expect(staleStop).toHaveBeenCalledOnce(); + expect(realtimeTalkCtor).toHaveBeenCalledOnce(); + expect(startMock).toHaveBeenCalledOnce(); + expect(stopMock).not.toHaveBeenCalled(); + expect(app.realtimeTalkStatus).toBe("connecting"); + expect(app.realtimeTalkSession).not.toBeNull(); + }); +}); diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index fb668b6da80..43061f6b40c 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -908,13 +908,18 @@ export class OpenClawApp extends LitElement { async toggleRealtimeTalk() { if (this.realtimeTalkSession) { - this.realtimeTalkSession.stop(); - this.realtimeTalkSession = null; - this.realtimeTalkActive = false; - this.realtimeTalkStatus = "idle"; - this.realtimeTalkDetail = null; - this.realtimeTalkTranscript = null; - return; + if (this.realtimeTalkStatus === "error") { + this.realtimeTalkSession.stop(); + this.realtimeTalkSession = null; + } else { + this.realtimeTalkSession.stop(); + this.realtimeTalkSession = null; + this.realtimeTalkActive = false; + this.realtimeTalkStatus = "idle"; + this.realtimeTalkDetail = null; + this.realtimeTalkTranscript = null; + return; + } } if (!this.client || !this.connected) { this.lastError = "Gateway not connected";