fix(ui): preserve session assistant identity

This commit is contained in:
Peter Steinberger
2026-04-27 12:20:29 +01:00
parent 6f6e2765e2
commit dca9fa471f
7 changed files with 151 additions and 24 deletions

View File

@@ -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.

View File

@@ -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 () => {

View File

@@ -431,6 +431,7 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption
applySnapshot(host, hello);
void loadControlUiBootstrapConfig(
host as unknown as Parameters<typeof loadControlUiBootstrapConfig>[0],
{ applyIdentity: false },
);
// Process any pending abort from before the disconnect.
if (host.pendingAbort) {

View File

@@ -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<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((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<unknown>();
const second = createDeferred<unknown>();
const request = vi.fn().mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
const state: Parameters<typeof loadAssistantIdentity>[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(() => {

View File

@@ -21,6 +21,26 @@ export type AssistantAvatarOverrideState = {
assistantAvatarReason?: string | null;
};
const assistantIdentityRequestVersions = new WeakMap<object, number>();
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;
}

View File

@@ -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);

View File

@@ -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)