mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:10:44 +00:00
fix(ui): preserve session assistant identity
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user