Skip to content

Commit 57dfd3f

Browse files
Merge pull request #302 from corbitsdev/cl-6594-agent-identity
CL-6594: resolve invited agents into real identities everywhere
2 parents bde4063 + e07cbb8 commit 57dfd3f

9 files changed

Lines changed: 234 additions & 46 deletions

File tree

apps/web/src/instant-agent-create.test.ts

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { afterEach, describe, expect, test } from "bun:test";
2+
import { QueryClient } from "@tanstack/react-query";
23
import {
34
CODE_REVIEW_TEMPLATE,
45
serializeWorkbenchTemplateManifest,
@@ -9,6 +10,12 @@ import {
910
NEW_WORKBENCH_TITLE,
1011
} from "./instant-agent-create";
1112

13+
function newQueryClient(): QueryClient {
14+
return new QueryClient({
15+
defaultOptions: { queries: { retry: false } },
16+
});
17+
}
18+
1219
describe("createWorkbenchFromTemplate (CL-6387)", () => {
1320
const realFetch = globalThis.fetch;
1421

@@ -70,11 +77,17 @@ describe("createWorkbenchFromTemplate (CL-6387)", () => {
7077
throw new Error(`unexpected fetch: ${path}`);
7178
});
7279

73-
await createWorkbenchFromTemplate("tnt_1", "blank", (to) =>
74-
navigated.push(to),
80+
await createWorkbenchFromTemplate(
81+
"tnt_1",
82+
"blank",
83+
(to) => navigated.push(to),
84+
newQueryClient(),
7585
);
76-
await createWorkbenchFromTemplate("tnt_1", "blank", (to) =>
77-
navigated.push(to),
86+
await createWorkbenchFromTemplate(
87+
"tnt_1",
88+
"blank",
89+
(to) => navigated.push(to),
90+
newQueryClient(),
7891
);
7992

8093
const createCalls = calls.filter((call) =>
@@ -145,8 +158,18 @@ describe("createWorkbenchFromTemplate (CL-6387)", () => {
145158
throw new Error(`unexpected fetch: ${path}`);
146159
});
147160

148-
await createWorkbenchFromTemplate("tnt_1", "code-review", (to) =>
149-
navigated.push(to),
161+
const queryClient = newQueryClient();
162+
// Seed the cache the way a person browsing before creating this
163+
// workbench would have: a `workbenches` list fetched before any of
164+
// the reviewer roster below was invited.
165+
const staleQueryKey = ["tenant", "tnt_1", "workbenches", "chat"] as const;
166+
queryClient.setQueryData(staleQueryKey, { items: [] });
167+
168+
await createWorkbenchFromTemplate(
169+
"tnt_1",
170+
"code-review",
171+
(to) => navigated.push(to),
172+
queryClient,
150173
);
151174

152175
const createCall = calls.find((call) =>
@@ -174,5 +197,12 @@ describe("createWorkbenchFromTemplate (CL-6387)", () => {
174197
);
175198
expect(invitedIds.sort()).toEqual(createdIds.sort());
176199
expect(navigated).toEqual(["/w/chan-1"]);
200+
201+
// CL-6594: a room this function navigates to must never carry a
202+
// `workbenches` cache captured before its own reviewer roster
203+
// finished being invited — that staleness is what left an invited
204+
// agent with no name, no avatar, and no `@mention` in the room the
205+
// owner reported it from.
206+
expect(queryClient.getQueryState(staleQueryKey)?.isInvalidated).toBe(true);
177207
});
178208
});

apps/web/src/instant-agent-create.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,14 @@
1414
// (Settings → Agents), unchanged.
1515

1616
import { getLogger } from "@corbits/client-log";
17+
import type { QueryClient } from "@tanstack/react-query";
1718
import {
1819
createWorkbench,
1920
getConnectGithubState,
2021
inviteAgent,
2122
patchWorkbenchSettings,
2223
startReviewingGithubRepos,
24+
workbenchesQueryKeyPrefix,
2325
type ConnectGithubRepo,
2426
} from "@corbits/chat-ui";
2527
import { listPluginsForTenant } from "@workbench/connections/plugins";
@@ -111,11 +113,22 @@ export type PickGithubRepos = (args: {
111113
* GitHub is already connected for this tenant, this also drives
112114
* CL-6386's "select on new-workbench" step — see `PickGithubRepos`'s
113115
* own doc.
116+
*
117+
* `queryClient` invalidates the workbenches list once every template
118+
* participant has been invited (CL-6594) — `ChatWorkspace`'s own
119+
* in-room "Invite agent" dialog does the same
120+
* (`workbenchesQueryKeyPrefix`, `chat-workspace.tsx`'s
121+
* `refreshWorkbenchLists`) so the room the invite landed in never
122+
* shows a participant it already has data for as if it never joined.
123+
* Without this, the room this function `navigate`s to can start life
124+
* holding a `workbenches` query cached from before the last invite
125+
* resolved.
114126
*/
115127
export async function createWorkbenchFromTemplate(
116128
tenantId: string,
117129
templateId: WorkbenchTemplateId,
118130
navigate: (to: string) => void,
131+
queryClient: QueryClient,
119132
pickGithubRepos?: PickGithubRepos,
120133
): Promise<void> {
121134
const definitions = await listAgentDefinitions(tenantId);
@@ -217,6 +230,9 @@ export async function createWorkbenchFromTemplate(
217230
for (const todo of result.webhookTriggerTodos) {
218231
log.error(todo);
219232
}
233+
await queryClient.invalidateQueries({
234+
queryKey: workbenchesQueryKeyPrefix(tenantId),
235+
});
220236
}
221237

222238
navigate(workbenchPath(workbench.id));

apps/web/src/pages/new-workbench-picker.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
WorkbenchLoadingState,
2222
} from "@corbits/chat-ui";
2323
import { useState } from "react";
24+
import { useQueryClient } from "@tanstack/react-query";
2425
import { getLogger } from "@corbits/client-log";
2526
import { ApiQueryError, describeApiError } from "@corbits/api-query";
2627

@@ -94,6 +95,7 @@ const BLANK_TEMPLATE_ID: WorkbenchTemplateId = "blank";
9495

9596
export function NewWorkbenchPickerRoute() {
9697
const navigate = useNavigate();
98+
const queryClient = useQueryClient();
9799
const { selectedTenantId } = useBench();
98100
const library = useAPIQuery(
99101
selectedTenantId === null
@@ -147,6 +149,7 @@ export function NewWorkbenchPickerRoute() {
147149
selectedTenantId,
148150
selectedId,
149151
navigate,
152+
queryClient,
150153
pickGithubRepos,
151154
);
152155
} catch (cause) {
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { describe, expect, test } from "bun:test";
2+
3+
import { buildTeamAvatarStack } from "./chat-workspace";
4+
import type { ParticipantRecord } from "./api";
5+
6+
describe("buildTeamAvatarStack (CL-6594)", () => {
7+
test("gives every agent participant its own initial and its own generated color, never a shared fallback", () => {
8+
const participants: readonly ParticipantRecord[] = [
9+
{ address: "run_myra@dana.localhost", handle: "myra" },
10+
{ address: "run_scout@dana.localhost", handle: "scout" },
11+
];
12+
13+
const stack = buildTeamAvatarStack(participants, []);
14+
15+
expect(stack).toHaveLength(2);
16+
expect(stack.map((entry) => entry.initials)).toEqual(["M", "S"]);
17+
expect(stack.map((entry) => entry.label)).toEqual(["myra", "scout"]);
18+
expect(stack.every((entry) => entry.tone === "agent")).toBe(true);
19+
20+
const [myra, scout] = stack;
21+
expect(myra?.color).toBeDefined();
22+
expect(scout?.color).toBeDefined();
23+
// Distinct addresses must never collapse onto the same fallback
24+
// fill — this is exactly what a shared CSS accent color did before
25+
// CL-6594: two agents in one room rendered as indistinguishable
26+
// avatars.
27+
expect(myra?.color).not.toBe(scout?.color);
28+
});
29+
30+
test("keeps every agent visible alongside live humans, agents first", () => {
31+
const participants: readonly ParticipantRecord[] = [
32+
{ address: "run_myra@dana.localhost", handle: "myra" },
33+
{ address: "run_scout@dana.localhost", handle: "scout" },
34+
];
35+
36+
const stack = buildTeamAvatarStack(participants, [
37+
{
38+
principalId: "prn_dana",
39+
displayName: "Dana",
40+
color: "hsl(10 70% 60%)",
41+
textColor: "#000000",
42+
},
43+
]);
44+
45+
expect(stack.map((entry) => entry.label)).toEqual([
46+
"myra",
47+
"scout",
48+
"Dana",
49+
]);
50+
});
51+
});

packages/chat-ui/src/chat-workspace.tsx

Lines changed: 33 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -155,19 +155,30 @@ export const TEAM_AVATAR_STACK_LIMIT = 6;
155155
* are always "active" — they have no presence concept of their own) plus
156156
* every human currently reflected in live presence. Agents first since
157157
* they're a workbench's stable roster; humans are who's here right now.
158+
*
159+
* Each agent gets its own `generatedAvatarStyle` fill keyed by address
160+
* (CL-6594) — the same deterministic-per-principal machinery humans
161+
* already use — rather than one shared CSS accent color for every
162+
* agent, so two agents in the same room never render as
163+
* indistinguishable avatars.
158164
*/
159165
export function buildTeamAvatarStack(
160166
participants: readonly ParticipantRecord[],
161167
presenceMembers: readonly PresenceMember[],
162168
): readonly TeamAvatarEntry[] {
163169
const agents = participants
164170
.filter((participant) => isAgentAddress(participant.address))
165-
.map((participant) => ({
166-
key: participant.address,
167-
initials: participant.handle,
168-
label: participant.handle,
169-
tone: "agent" as const,
170-
}));
171+
.map((participant) => {
172+
const style = generatedAvatarStyle(participant.address);
173+
return {
174+
key: participant.address,
175+
initials: participant.handle.slice(0, 1).toUpperCase(),
176+
label: participant.handle,
177+
tone: "agent" as const,
178+
color: style["--avatar-identity-bg"],
179+
textColor: style["--avatar-identity-fg"],
180+
};
181+
});
171182
const humans = presenceMembers.map((member) => ({
172183
key: member.principalId,
173184
initials: member.displayName.slice(0, 1).toUpperCase(),
@@ -1142,30 +1153,22 @@ function ChatWorkspaceInner({
11421153
className="chat-team-stack"
11431154
aria-label={CHAT_STRINGS.workbenchMembersLabel}
11441155
>
1145-
{visibleTeamStack.map((entry) =>
1146-
entry.tone === "agent" ? (
1147-
<span
1148-
key={entry.key}
1149-
className="chat-presence-avatar"
1150-
data-agent="true"
1151-
title={entry.label}
1152-
>
1153-
{entry.initials.slice(0, 1).toUpperCase()}
1154-
</span>
1155-
) : (
1156-
<span
1157-
key={entry.key}
1158-
className="chat-presence-avatar"
1159-
style={{
1160-
backgroundColor: entry.color,
1161-
color: entry.textColor,
1162-
}}
1163-
title={entry.label}
1164-
>
1165-
{entry.initials}
1166-
</span>
1167-
),
1168-
)}
1156+
{visibleTeamStack.map((entry) => (
1157+
<span
1158+
key={entry.key}
1159+
className="chat-presence-avatar"
1160+
data-agent={
1161+
entry.tone === "agent" ? "true" : undefined
1162+
}
1163+
style={{
1164+
backgroundColor: entry.color,
1165+
color: entry.textColor,
1166+
}}
1167+
title={entry.label}
1168+
>
1169+
{entry.initials}
1170+
</span>
1171+
))}
11691172
{teamStackOverflow > 0 ? (
11701173
<span
11711174
className="chat-team-stack-overflow"

packages/chat-ui/src/styles.css

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2841,13 +2841,6 @@
28412841
margin-left: 0;
28422842
}
28432843

2844-
/* Agents in the header stack keep their accent color but share the
2845-
humans' circular shape — one motif for the whole roster row. */
2846-
.chat-presence-avatar[data-agent="true"] {
2847-
background: var(--chat-agent-accent, var(--primary));
2848-
color: var(--primary-foreground, var(--background));
2849-
}
2850-
28512844
/* Existing reaction chips + pin toggle, indented to align under the bubble
28522845
text the same way `.chat-thread-affordance` does. Chips are content —
28532846
they always show once a reaction exists. The pin toggle fades in on
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { describe, expect, test } from "bun:test";
2+
3+
import { friendlyEventText } from "./timeline";
4+
import type { ParticipantRecord } from "./api";
5+
import type { Part } from "./api";
6+
7+
function agentJoinedPart(address: string): Part & { kind: "event" } {
8+
return {
9+
kind: "event",
10+
event: "workbench.agent-joined",
11+
data: { address },
12+
};
13+
}
14+
15+
describe("friendlyEventText — workbench.agent-joined (CL-6594)", () => {
16+
test("names the agent by its participant handle", () => {
17+
const participants: readonly ParticipantRecord[] = [
18+
{ address: "run_scout@dana.localhost", handle: "scout" },
19+
];
20+
expect(
21+
friendlyEventText(
22+
agentJoinedPart("run_scout@dana.localhost"),
23+
participants,
24+
),
25+
).toBe("Scout joined");
26+
});
27+
28+
test("falls back to the address's own local part, never a generic noun, when the roster hasn't caught up with this address yet", () => {
29+
const participants: readonly ParticipantRecord[] = [
30+
{ address: "run_myra@dana.localhost", handle: "myra" },
31+
];
32+
expect(
33+
friendlyEventText(
34+
agentJoinedPart("run_scout@dana.localhost"),
35+
participants,
36+
),
37+
).toBe("Run Scout joined");
38+
});
39+
40+
test("falls back to the generic line only when the event itself carries no address at all", () => {
41+
const part: Part & { kind: "event" } = {
42+
kind: "event",
43+
event: "workbench.agent-joined",
44+
data: {},
45+
};
46+
expect(friendlyEventText(part, [])).toBe("An agent joined");
47+
});
48+
});

packages/chat-ui/src/timeline.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -577,7 +577,7 @@ function TextBubble({
577577
* anything else falls back to the event name with its separators turned
578578
* into spaces.
579579
*/
580-
function friendlyEventText(
580+
export function friendlyEventText(
581581
part: Part & { kind: "event" },
582582
participants: readonly ParticipantRecord[],
583583
): string {
@@ -589,10 +589,15 @@ function friendlyEventText(
589589
data !== undefined && typeof data.address === "string"
590590
? data.address
591591
: undefined;
592+
// The participant record's own handle is the friendly, settings-held
593+
// name (see `packages/chat/src/participants.ts`); when the roster
594+
// hasn't caught up with this address yet, the address's own local
595+
// part (CL-6594) is still a real identifier — never the generic "An
596+
// agent joined", which hides a name the event already carries.
592597
const handle =
593598
address !== undefined
594-
? participants.find((participant) => participant.address === address)
595-
?.handle
599+
? (participants.find((participant) => participant.address === address)
600+
?.handle ?? localPartOf(address))
596601
: undefined;
597602

598603
switch (part.event) {

0 commit comments

Comments
 (0)