diff --git a/CHANGELOG.md b/CHANGELOG.md index efcbf4b95e9..e9a73899bb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Control UI: keep session-specific assistant identity loads authoritative after WebSocket connect, so non-main agent chat sessions do not show the main agent name in the header after bootstrap refreshes. Fixes #72776. Thanks @rockytian-top. - Media-understanding/audio: migrate deprecated `{input}` placeholders in legacy `audio.transcription.command` configs to `{{MediaPath}}`, so custom audio transcribers no longer receive the literal placeholder after doctor repair. Fixes #72760. Thanks @krisfanue3-hash. - Ollama/onboarding: de-dupe suggested bare local models against installed `:latest` tags and skip redundant pulls, so setup shows the installed model once and no longer says it is downloading an already available model. Fixes #68952. Thanks @tleyden. - Control UI/Gateway: preserve WebChat client version labels across localhost, 127.0.0.1, and IPv6 loopback aliases on the same port, avoiding misleading `vcontrol-ui` connection logs while investigating duplicate-message reports. Refs #72753 and #72742. Thanks @LumenFromTheFuture and @allesgutefy. diff --git a/ui/src/ui/app-gateway.node.test.ts b/ui/src/ui/app-gateway.node.test.ts index 99ad789c8fd..cef47174610 100644 --- a/ui/src/ui/app-gateway.node.test.ts +++ b/ui/src/ui/app-gateway.node.test.ts @@ -649,7 +649,7 @@ describe("connectGateway", () => { client.emitHello(); expect(loadControlUiBootstrapConfigMock).toHaveBeenCalledTimes(1); - expect(loadControlUiBootstrapConfigMock).toHaveBeenCalledWith(host); + expect(loadControlUiBootstrapConfigMock).toHaveBeenCalledWith(host, { applyIdentity: false }); }); it("sends queued chat aborts after reconnect before clearing pending state", async () => { diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index 978a66ecb55..02782f194fe 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -431,6 +431,7 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption applySnapshot(host, hello); void loadControlUiBootstrapConfig( host as unknown as Parameters[0], + { applyIdentity: false }, ); // Process any pending abort from before the disconnect. if (host.pendingAbort) { diff --git a/ui/src/ui/controllers/assistant-identity.test.ts b/ui/src/ui/controllers/assistant-identity.test.ts index 79e7707ae23..33fa82ce9b9 100644 --- a/ui/src/ui/controllers/assistant-identity.test.ts +++ b/ui/src/ui/controllers/assistant-identity.test.ts @@ -2,7 +2,60 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createStorageMock } from "../../test-helpers/storage.ts"; import { loadLocalAssistantIdentity } from "../storage.ts"; -import { setAssistantAvatarOverride } from "./assistant-identity.ts"; +import { loadAssistantIdentity, setAssistantAvatarOverride } from "./assistant-identity.ts"; + +function createDeferred() { + let resolve!: (value: T) => void; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +} + +describe("loadAssistantIdentity", () => { + beforeEach(() => { + vi.stubGlobal("localStorage", createStorageMock()); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("ignores stale identity responses after the active session changes", async () => { + const first = createDeferred(); + const second = createDeferred(); + const request = vi.fn().mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); + const state: Parameters[0] = { + client: { request } as never, + connected: true, + sessionKey: "agent:main:main", + assistantName: "Main", + assistantAvatar: null, + assistantAgentId: "main", + }; + + const firstLoad = loadAssistantIdentity(state); + state.sessionKey = "agent:worker:main"; + const secondLoad = loadAssistantIdentity(state); + + second.resolve({ agentId: "worker", name: "Worker", avatar: "W" }); + await secondLoad; + expect(state.assistantName).toBe("Worker"); + expect(state.assistantAgentId).toBe("worker"); + + first.resolve({ agentId: "main", name: "Main After", avatar: "M" }); + await firstLoad; + + expect(state.assistantName).toBe("Worker"); + expect(state.assistantAvatar).toBe("W"); + expect(state.assistantAgentId).toBe("worker"); + expect(request).toHaveBeenNthCalledWith(1, "agent.identity.get", { + sessionKey: "agent:main:main", + }); + expect(request).toHaveBeenNthCalledWith(2, "agent.identity.get", { + sessionKey: "agent:worker:main", + }); + }); +}); describe("setAssistantAvatarOverride", () => { beforeEach(() => { diff --git a/ui/src/ui/controllers/assistant-identity.ts b/ui/src/ui/controllers/assistant-identity.ts index 13471a67cff..161fbe73456 100644 --- a/ui/src/ui/controllers/assistant-identity.ts +++ b/ui/src/ui/controllers/assistant-identity.ts @@ -21,6 +21,26 @@ export type AssistantAvatarOverrideState = { assistantAvatarReason?: string | null; }; +const assistantIdentityRequestVersions = new WeakMap(); + +function beginAssistantIdentityRequest(state: AssistantIdentityState): number { + const key = state as object; + const nextVersion = (assistantIdentityRequestVersions.get(key) ?? 0) + 1; + assistantIdentityRequestVersions.set(key, nextVersion); + return nextVersion; +} + +function shouldApplyAssistantIdentityResult( + state: AssistantIdentityState, + version: number, + sessionKey: string, +): boolean { + return ( + assistantIdentityRequestVersions.get(state as object) === version && + state.sessionKey.trim() === sessionKey + ); +} + export async function loadAssistantIdentity( state: AssistantIdentityState, opts?: { sessionKey?: string }, @@ -30,8 +50,12 @@ export async function loadAssistantIdentity( } const sessionKey = opts?.sessionKey?.trim() || state.sessionKey.trim(); const params = sessionKey ? { sessionKey } : {}; + const requestVersion = beginAssistantIdentityRequest(state); try { const res = await state.client.request("agent.identity.get", params); + if (!shouldApplyAssistantIdentityResult(state, requestVersion, sessionKey)) { + return; + } if (!res) { return; } diff --git a/ui/src/ui/controllers/control-ui-bootstrap.test.ts b/ui/src/ui/controllers/control-ui-bootstrap.test.ts index 97a28b51fa8..831534a7219 100644 --- a/ui/src/ui/controllers/control-ui-bootstrap.test.ts +++ b/ui/src/ui/controllers/control-ui-bootstrap.test.ts @@ -58,6 +58,49 @@ describe("loadControlUiBootstrapConfig", () => { vi.unstubAllGlobals(); }); + it("can refresh runtime bootstrap settings without clobbering session identity", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + basePath: "", + assistantName: "Main", + assistantAvatar: "M", + assistantAgentId: "main", + serverVersion: "2026.4.27", + localMediaPreviewRoots: ["/tmp/openclaw"], + embedSandbox: "trusted", + allowExternalEmbedUrls: true, + }), + }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const state = { + basePath: "", + assistantName: "Worker", + assistantAvatar: "W", + assistantAvatarSource: null, + assistantAvatarStatus: null, + assistantAvatarReason: null, + assistantAgentId: "worker", + localMediaPreviewRoots: [], + embedSandboxMode: "scripts" as const, + allowExternalEmbedUrls: false, + serverVersion: null, + }; + + await loadControlUiBootstrapConfig(state, { applyIdentity: false }); + + expect(state.assistantName).toBe("Worker"); + expect(state.assistantAvatar).toBe("W"); + expect(state.assistantAgentId).toBe("worker"); + expect(state.serverVersion).toBe("2026.4.27"); + expect(state.localMediaPreviewRoots).toEqual(["/tmp/openclaw"]); + expect(state.embedSandboxMode).toBe("trusted"); + expect(state.allowExternalEmbedUrls).toBe(true); + + vi.unstubAllGlobals(); + }); + it("ignores failures", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: false }); vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); diff --git a/ui/src/ui/controllers/control-ui-bootstrap.ts b/ui/src/ui/controllers/control-ui-bootstrap.ts index 17706472068..f94474bd02d 100644 --- a/ui/src/ui/controllers/control-ui-bootstrap.ts +++ b/ui/src/ui/controllers/control-ui-bootstrap.ts @@ -25,7 +25,10 @@ export type ControlUiBootstrapState = { password?: string | null; }; -export async function loadControlUiBootstrapConfig(state: ControlUiBootstrapState) { +export async function loadControlUiBootstrapConfig( + state: ControlUiBootstrapState, + opts?: { applyIdentity?: boolean }, +) { if (typeof window === "undefined") { return; } @@ -66,27 +69,29 @@ export async function loadControlUiBootstrapConfig(state: ControlUiBootstrapStat return; } const parsed = (await res.json()) as ControlUiBootstrapConfig; - const normalized = normalizeAssistantIdentity({ - agentId: parsed.assistantAgentId ?? null, - name: parsed.assistantName, - avatar: parsed.assistantAvatar ?? null, - avatarSource: parsed.assistantAvatarSource ?? null, - avatarStatus: parsed.assistantAvatarStatus ?? null, - avatarReason: parsed.assistantAvatarReason ?? null, - }); - state.assistantName = normalized.name; - state.assistantAvatar = normalized.avatar; - state.assistantAvatarSource = normalized.avatarSource ?? null; - state.assistantAvatarStatus = normalized.avatarStatus ?? null; - state.assistantAvatarReason = normalized.avatarReason ?? null; - state.assistantAgentId = normalized.agentId ?? null; - // Local override always wins — same pattern as the user avatar. - const localAvatar = loadLocalAssistantIdentity().avatar; - if (localAvatar) { - state.assistantAvatar = localAvatar; - state.assistantAvatarSource = localAvatar; - state.assistantAvatarStatus = "data"; - state.assistantAvatarReason = null; + if (opts?.applyIdentity !== false) { + const normalized = normalizeAssistantIdentity({ + agentId: parsed.assistantAgentId ?? null, + name: parsed.assistantName, + avatar: parsed.assistantAvatar ?? null, + avatarSource: parsed.assistantAvatarSource ?? null, + avatarStatus: parsed.assistantAvatarStatus ?? null, + avatarReason: parsed.assistantAvatarReason ?? null, + }); + state.assistantName = normalized.name; + state.assistantAvatar = normalized.avatar; + state.assistantAvatarSource = normalized.avatarSource ?? null; + state.assistantAvatarStatus = normalized.avatarStatus ?? null; + state.assistantAvatarReason = normalized.avatarReason ?? null; + state.assistantAgentId = normalized.agentId ?? null; + // Local override always wins — same pattern as the user avatar. + const localAvatar = loadLocalAssistantIdentity().avatar; + if (localAvatar) { + state.assistantAvatar = localAvatar; + state.assistantAvatarSource = localAvatar; + state.assistantAvatarStatus = "data"; + state.assistantAvatarReason = null; + } } state.serverVersion = parsed.serverVersion ?? null; state.localMediaPreviewRoots = Array.isArray(parsed.localMediaPreviewRoots)