Skip to content

Commit 4aba273

Browse files
lsr911lsr911vincentkoc
authored
fix: scope assistant avatar override to agent ID (#93712)
* fix: scope assistant avatar override to agent ID The local assistant avatar override was stored globally in localStorage without an agentId, causing the same avatar to apply to all agents. Setting an avatar for agent A would overwrite the avatar for agent B. Fix: include agentId when saving the local avatar override, and filter by agentId when loading. An override saved for one agent no longer bleeds into other agents. Fixes #90890 * fix(ui): persist assistant avatars per agent * fix(ui): satisfy scoped avatar checks --------- Co-authored-by: lsr911 <[email protected]> Co-authored-by: Vincent Koc <[email protected]>
1 parent ff5d657 commit 4aba273

8 files changed

Lines changed: 249 additions & 28 deletions

ui/src/ui/app-render.assistant-avatar.test.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ beforeEach(async () => {
239239
describe("renderApp assistant avatar routing", () => {
240240
it("passes the browser-local assistant override to Quick Settings ahead of stale identity metadata", () => {
241241
const dataUrl = "data:image/png;base64,bG9jYWwtYXNzaXN0YW50";
242-
saveLocalAssistantIdentity({ avatar: dataUrl });
242+
saveLocalAssistantIdentity({ avatar: dataUrl, agentId: "main" });
243243

244244
renderApp(createState());
245245

@@ -251,6 +251,80 @@ describe("renderApp assistant avatar routing", () => {
251251
expect(quickSettingsProps.current?.assistantAvatarOverride).toBe(dataUrl);
252252
});
253253

254+
it("uses the active session agent override while identity metadata is stale", () => {
255+
saveLocalAssistantIdentity({
256+
avatar: "data:image/png;base64,bWFpbg==",
257+
agentId: "main",
258+
});
259+
saveLocalAssistantIdentity({
260+
avatar: "data:image/png;base64,d29ya2Vy",
261+
agentId: "worker",
262+
});
263+
264+
renderApp(
265+
createState({
266+
sessionKey: "agent:worker:main",
267+
assistantAgentId: "main",
268+
}),
269+
);
270+
271+
expect(quickSettingsProps.current?.assistantAvatarOverride).toBe(
272+
"data:image/png;base64,d29ya2Vy",
273+
);
274+
});
275+
276+
it("uses the default agent override for a bare main session while identity metadata is stale", () => {
277+
saveLocalAssistantIdentity({
278+
avatar: "data:image/png;base64,bWFpbg==",
279+
agentId: "main",
280+
});
281+
saveLocalAssistantIdentity({
282+
avatar: "data:image/png;base64,d29ya2Vy",
283+
agentId: "worker",
284+
});
285+
286+
renderApp(
287+
createState({
288+
sessionKey: "main",
289+
assistantAgentId: "worker",
290+
}),
291+
);
292+
293+
expect(quickSettingsProps.current?.assistantAvatarOverride).toBe(
294+
"data:image/png;base64,bWFpbg==",
295+
);
296+
});
297+
298+
it("reloads the default agent identity after clearing its override from a bare main session", async () => {
299+
const loadAssistantIdentity = vi.fn(async () => undefined);
300+
saveLocalAssistantIdentity({
301+
avatar: "data:image/png;base64,YWxwaGE=",
302+
agentId: "alpha",
303+
});
304+
305+
renderApp(
306+
createState({
307+
sessionKey: "main",
308+
assistantAgentId: "worker",
309+
agentsList: {
310+
defaultId: "alpha",
311+
agents: [
312+
{ id: "alpha", name: "Alpha" },
313+
{ id: "worker", name: "Worker" },
314+
],
315+
} as AppViewState["agentsList"],
316+
loadAssistantIdentity,
317+
}),
318+
);
319+
320+
await quickSettingsProps.current?.onAssistantAvatarClearOverride?.();
321+
322+
expect(loadAssistantIdentity).toHaveBeenCalledWith({
323+
sessionKey: "agent:alpha:main",
324+
expectedSessionKey: "main",
325+
});
326+
});
327+
254328
it("applies the configured chat message width as a shell CSS variable", () => {
255329
const container = document.createElement("div");
256330

ui/src/ui/app-render.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1404,8 +1404,10 @@ export function renderApp(state: AppViewState) {
14041404
const dashboardHeaderContext = resolveDashboardHeaderContext(state);
14051405
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
14061406
const showToolCalls = state.onboarding ? true : state.settings.chatShowToolCalls;
1407+
const activeAssistantAgentId = resolveSidebarSelectedAgentId(state);
14071408
const localAssistantAvatarOverride =
1408-
normalizeOptionalString(loadLocalAssistantIdentity().avatar) ?? null;
1409+
normalizeOptionalString(loadLocalAssistantIdentity({ agentId: activeAssistantAgentId }).avatar) ??
1410+
null;
14091411
const assistantAvatarUrl = resolveAssistantAvatarUrl(state);
14101412
const chatAssistantAvatarStatus = localAssistantAvatarOverride
14111413
? "data"
@@ -1905,7 +1907,7 @@ export function renderApp(state: AppViewState) {
19051907
assistantAvatarUploadBusy: state.assistantAvatarUploadBusy,
19061908
assistantAvatarUploadError: state.assistantAvatarUploadError,
19071909
onAssistantAvatarOverrideChange: (dataUrl) => {
1908-
setAssistantAvatarOverride(state, dataUrl);
1910+
setAssistantAvatarOverride(state, dataUrl, activeAssistantAgentId);
19091911
state.chatAvatarUrl = dataUrl;
19101912
state.chatAvatarSource = dataUrl;
19111913
state.chatAvatarStatus = "data";
@@ -1914,13 +1916,21 @@ export function renderApp(state: AppViewState) {
19141916
requestHostUpdate?.();
19151917
},
19161918
onAssistantAvatarClearOverride: () => {
1917-
setAssistantAvatarOverride(state, null);
1919+
setAssistantAvatarOverride(state, null, activeAssistantAgentId);
19181920
state.chatAvatarUrl = null;
19191921
state.chatAvatarSource = null;
19201922
state.chatAvatarStatus = null;
19211923
state.chatAvatarReason = null;
19221924
state.assistantAvatarUploadError = null;
1923-
void state.loadAssistantIdentity?.().finally(() => requestHostUpdate?.());
1925+
const identitySessionKey = buildAgentMainSessionKey({
1926+
agentId: activeAssistantAgentId,
1927+
});
1928+
void state
1929+
.loadAssistantIdentity?.({
1930+
sessionKey: identitySessionKey,
1931+
expectedSessionKey: state.sessionKey,
1932+
})
1933+
.finally(() => requestHostUpdate?.());
19241934
requestHostUpdate?.();
19251935
},
19261936
basePath: state.basePath ?? "",

ui/src/ui/app-view-state.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -487,7 +487,10 @@ export type AppViewState = {
487487
applySettings: (next: UiSettings) => void;
488488
applyLocalUserIdentity?: (next: { name?: string | null; avatar?: string | null }) => void;
489489
loadOverview: (opts?: { refresh?: boolean }) => Promise<void>;
490-
loadAssistantIdentity: () => Promise<void>;
490+
loadAssistantIdentity: (opts?: {
491+
sessionKey?: string;
492+
expectedSessionKey?: string;
493+
}) => Promise<void>;
491494
loadCron: () => Promise<void>;
492495
handleWhatsAppStart: (force: boolean) => Promise<void>;
493496
handleWhatsAppWait: () => Promise<void>;

ui/src/ui/app.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -928,8 +928,8 @@ export class OpenClawApp extends LitElement {
928928
);
929929
}
930930

931-
async loadAssistantIdentity() {
932-
await loadAssistantIdentityInternal(this);
931+
async loadAssistantIdentity(opts?: { sessionKey?: string; expectedSessionKey?: string }) {
932+
await loadAssistantIdentityInternal(this, opts);
933933
}
934934

935935
applySettings(next: UiSettings) {

ui/src/ui/controllers/assistant-identity.test.ts

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,34 @@ describe("loadAssistantIdentity", () => {
5858
sessionKey: "agent:worker:main",
5959
});
6060
});
61+
62+
it("applies a scoped identity request while its expected UI session remains active", async () => {
63+
const request = vi.fn().mockResolvedValue({
64+
agentId: "alpha",
65+
name: "Alpha",
66+
avatar: "A",
67+
});
68+
const state: Parameters<typeof loadAssistantIdentity>[0] = {
69+
client: { request } as never,
70+
connected: true,
71+
sessionKey: "main",
72+
assistantName: "Worker",
73+
assistantAvatar: null,
74+
assistantAgentId: "worker",
75+
};
76+
77+
await loadAssistantIdentity(state, {
78+
sessionKey: "agent:alpha:main",
79+
expectedSessionKey: "main",
80+
});
81+
82+
expect(state.assistantName).toBe("Alpha");
83+
expect(state.assistantAvatar).toBe("A");
84+
expect(state.assistantAgentId).toBe("alpha");
85+
expect(request).toHaveBeenCalledWith("agent.identity.get", {
86+
sessionKey: "agent:alpha:main",
87+
});
88+
});
6189
});
6290

6391
describe("setAssistantAvatarOverride", () => {
@@ -71,13 +99,15 @@ describe("setAssistantAvatarOverride", () => {
7199
it("persists the assistant avatar locally and mirrors the user avatar pattern", () => {
72100
const state: Parameters<typeof setAssistantAvatarOverride>[0] = {};
73101

74-
setAssistantAvatarOverride(state, "data:image/png;base64,YXZhdGFy");
102+
setAssistantAvatarOverride(state, "data:image/png;base64,YXZhdGFy", "main");
75103

76104
expect(state.assistantAvatar).toBe("data:image/png;base64,YXZhdGFy");
77105
expect(state.assistantAvatarSource).toBe("data:image/png;base64,YXZhdGFy");
78106
expect(state.assistantAvatarStatus).toBe("data");
79107
expect(state.assistantAvatarReason).toBeNull();
80-
expect(loadLocalAssistantIdentity().avatar).toBe("data:image/png;base64,YXZhdGFy");
108+
expect(loadLocalAssistantIdentity({ agentId: "main" }).avatar).toBe(
109+
"data:image/png;base64,YXZhdGFy",
110+
);
81111
});
82112

83113
it("clears the local override", () => {
@@ -86,14 +116,58 @@ describe("setAssistantAvatarOverride", () => {
86116
assistantAvatarSource: "data:image/png;base64,YXZhdGFy",
87117
assistantAvatarStatus: "data",
88118
};
89-
setAssistantAvatarOverride(state, "data:image/png;base64,YXZhdGFy");
119+
setAssistantAvatarOverride(state, "data:image/png;base64,YXZhdGFy", "main");
90120

91-
setAssistantAvatarOverride(state, null);
121+
setAssistantAvatarOverride(state, null, "main");
92122

93123
expect(state.assistantAvatar).toBeNull();
94124
expect(state.assistantAvatarSource).toBeNull();
95125
expect(state.assistantAvatarStatus).toBeNull();
96126
expect(state.assistantAvatarReason).toBeNull();
97-
expect(loadLocalAssistantIdentity().avatar).toBeNull();
127+
expect(loadLocalAssistantIdentity({ agentId: "main" }).avatar).toBeNull();
128+
});
129+
130+
it("keeps assistant avatar overrides isolated by agent", () => {
131+
setAssistantAvatarOverride({}, "data:image/png;base64,bWFpbg==", "main");
132+
setAssistantAvatarOverride({}, "data:image/png;base64,d29ya2Vy", "worker");
133+
134+
expect(loadLocalAssistantIdentity({ agentId: "main" }).avatar).toBe(
135+
"data:image/png;base64,bWFpbg==",
136+
);
137+
expect(loadLocalAssistantIdentity({ agentId: "worker" }).avatar).toBe(
138+
"data:image/png;base64,d29ya2Vy",
139+
);
140+
141+
setAssistantAvatarOverride({}, null, "worker");
142+
143+
expect(loadLocalAssistantIdentity({ agentId: "main" }).avatar).toBe(
144+
"data:image/png;base64,bWFpbg==",
145+
);
146+
expect(loadLocalAssistantIdentity({ agentId: "worker" }).avatar).toBeNull();
147+
});
148+
149+
it("migrates the legacy global override to the first loaded agent", () => {
150+
localStorage.setItem(
151+
"openclaw.control.assistant.v1",
152+
JSON.stringify({ avatar: "data:image/png;base64,bGVnYWN5" }),
153+
);
154+
155+
expect(loadLocalAssistantIdentity({ agentId: "main" }).avatar).toBe(
156+
"data:image/png;base64,bGVnYWN5",
157+
);
158+
expect(loadLocalAssistantIdentity({ agentId: "worker" }).avatar).toBeNull();
159+
});
160+
161+
it("supports prototype-like agent IDs without inherited avatar values", () => {
162+
setAssistantAvatarOverride({}, "data:image/png;base64,Y29uc3RydWN0b3I=", "constructor");
163+
setAssistantAvatarOverride({}, "data:image/png;base64,cHJvdG8=", "__proto__");
164+
165+
expect(loadLocalAssistantIdentity({ agentId: "constructor" }).avatar).toBe(
166+
"data:image/png;base64,Y29uc3RydWN0b3I=",
167+
);
168+
expect(loadLocalAssistantIdentity({ agentId: "__proto__" }).avatar).toBe(
169+
"data:image/png;base64,cHJvdG8=",
170+
);
171+
expect(loadLocalAssistantIdentity({ agentId: "toString" }).avatar).toBeNull();
98172
});
99173
});

ui/src/ui/controllers/assistant-identity.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,17 +44,18 @@ function shouldApplyAssistantIdentityResult(
4444

4545
export async function loadAssistantIdentity(
4646
state: AssistantIdentityState,
47-
opts?: { sessionKey?: string },
47+
opts?: { sessionKey?: string; expectedSessionKey?: string },
4848
) {
4949
if (!state.client || !state.connected) {
5050
return;
5151
}
5252
const sessionKey = opts?.sessionKey?.trim() || state.sessionKey.trim();
53+
const expectedSessionKey = opts?.expectedSessionKey?.trim() || sessionKey;
5354
const params = sessionKey ? { sessionKey } : {};
5455
const requestVersion = beginAssistantIdentityRequest(state);
5556
try {
5657
const res = await state.client.request("agent.identity.get", params);
57-
if (!shouldApplyAssistantIdentityResult(state, requestVersion, sessionKey)) {
58+
if (!shouldApplyAssistantIdentityResult(state, requestVersion, expectedSessionKey)) {
5859
return;
5960
}
6061
if (!res) {
@@ -67,8 +68,9 @@ export async function loadAssistantIdentity(
6768
state.assistantAvatarStatus = normalized.avatarStatus ?? null;
6869
state.assistantAvatarReason = normalized.avatarReason ?? null;
6970
state.assistantAgentId = normalized.agentId ?? null;
70-
// Local override always wins — same pattern as the user avatar.
71-
const localAvatar = loadLocalAssistantIdentity().avatar;
71+
const localAvatar = loadLocalAssistantIdentity({
72+
agentId: state.assistantAgentId,
73+
}).avatar;
7274
if (localAvatar) {
7375
state.assistantAvatar = localAvatar;
7476
state.assistantAvatarSource = localAvatar;
@@ -83,8 +85,9 @@ export async function loadAssistantIdentity(
8385
export function setAssistantAvatarOverride(
8486
state: AssistantAvatarOverrideState,
8587
avatar: string | null,
88+
agentId?: string | null,
8689
) {
87-
saveLocalAssistantIdentity({ avatar });
90+
saveLocalAssistantIdentity({ avatar, agentId });
8891
if (avatar) {
8992
state.assistantAvatar = avatar;
9093
state.assistantAvatarSource = avatar;

ui/src/ui/controllers/control-ui-bootstrap.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ function resolveBootstrapAgentId(value: string | null | undefined): string | nul
4646
}
4747

4848
function applyLocalAssistantAvatarOverride(state: ControlUiBootstrapState) {
49-
const localAvatar = loadLocalAssistantIdentity().avatar;
49+
const localAvatar = loadLocalAssistantIdentity({ agentId: resolveActiveAgentId(state) }).avatar;
5050
if (!localAvatar) {
5151
return;
5252
}
@@ -119,7 +119,6 @@ export async function loadControlUiBootstrapConfig(
119119
state.assistantAvatarReason = normalized.avatarReason ?? null;
120120
state.assistantAgentId = normalized.agentId ?? null;
121121
}
122-
// Local override always wins — same pattern as the user avatar.
123122
applyLocalAssistantAvatarOverride(state);
124123
}
125124
state.serverVersion = parsed.serverVersion ?? null;

0 commit comments

Comments
 (0)