From 3858b5234349b0dcfd8524f8966ba14c7006f61b Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 21:38:33 -0700 Subject: [PATCH 1/2] Add tests for sidebar rows keeping deliberately created agent workbenches --- apps/web/src/shell/sidebar-rows.test.ts | 58 +++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/apps/web/src/shell/sidebar-rows.test.ts b/apps/web/src/shell/sidebar-rows.test.ts index 8f43362f1..5584324af 100644 --- a/apps/web/src/shell/sidebar-rows.test.ts +++ b/apps/web/src/shell/sidebar-rows.test.ts @@ -95,6 +95,64 @@ describe("buildSidebarRows", () => { expect(rows).toEqual([{ kind: "workbench", workbench: freshDm }]); }); + test("every workbench deliberately created against the same agent definition keeps its own row (CL-6459)", () => { + const created = ["ch_new_1", "ch_new_2", "ch_new_3"].map((id, index) => + workbench({ + id, + kind: "chat", + title: "New Workbench", + definitionId: "wfd_myra", + participants: [{ address: "myra@acme.localhost", handle: "myra" }], + lastActivityAt: `2026-01-0${index + 1}T00:00:00.000Z`, + }), + ); + + const rows = buildSidebarRows([], created); + + expect(rows.map((row) => row.workbench.id)).toEqual([ + "ch_new_3", + "ch_new_2", + "ch_new_1", + ]); + }); + + test("a stale cross-tenant sibling drops without taking the live definition's deliberate workbenches with it", () => { + const staleAncestorDm = workbench({ + id: "ch_myra_ancestor", + kind: "chat", + title: "Myra", + definitionId: "wfd_myra_ancestor", + participants: [{ address: "myra@acme.localhost", handle: "myra" }], + lastActivityAt: "2026-01-01T00:00:00.000Z", + }); + const homeDm = workbench({ + id: "ch_myra_home", + kind: "chat", + title: "Myra", + definitionId: "wfd_myra_leaf", + participants: [{ address: "myra@acme.localhost", handle: "myra" }], + lastActivityAt: "2026-01-04T00:00:00.000Z", + }); + const createdWorkbench = workbench({ + id: "ch_myra_new", + kind: "chat", + title: "New Workbench", + definitionId: "wfd_myra_leaf", + participants: [{ address: "myra@acme.localhost", handle: "myra" }], + lastActivityAt: "2026-01-05T00:00:00.000Z", + }); + + const rows = buildSidebarRows( + [], + [staleAncestorDm, homeDm, createdWorkbench], + ); + + expect(rows.map((row) => row.workbench.id)).toEqual([ + "ch_myra_new", + "ch_myra_home", + ]); + }); + test("two distinct agents whose slugs humanize to the same title never collapse into one row (CL-6413)", () => { const researchAnalystHyphen = workbench({ id: "ch_research_analyst_hyphen", From 14a4f2ff97142d6f67f44cade45123768145ec62 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 21:40:01 -0700 Subject: [PATCH 2/2] Sidebar rows: dedupe only superseded agent definitions, not deliberate workbenches --- apps/web/src/shell/sidebar-rows.ts | 70 ++++++++++++++++++------------ 1 file changed, 42 insertions(+), 28 deletions(-) diff --git a/apps/web/src/shell/sidebar-rows.ts b/apps/web/src/shell/sidebar-rows.ts index e7c0aa858..21a5863a3 100644 --- a/apps/web/src/shell/sidebar-rows.ts +++ b/apps/web/src/shell/sidebar-rows.ts @@ -31,49 +31,63 @@ function agentIdentityKey(chat: Workbench): string { return agentParticipant?.handle ?? chat.title; } +type AgentChat = Workbench & { readonly definitionId: string }; + +/** A group workbench never carries a `definitionId`; every agent DM does. */ +function isAgentChat(chat: Workbench): chat is AgentChat { + return chat.definitionId !== null && chat.definitionId !== undefined; +} + +function activityOf(chat: Workbench): number { + return chat.lastActivityAt ? Date.parse(chat.lastActivityAt) : 0; +} + /** - * Collapse agent-DM chats down to one row per agent identity (CL-6271). + * Drop agent-DM chats minted against a superseded sibling definition of + * the same agent (CL-6271), keeping every chat that belongs to the + * live one (CL-6459). + * * Shadowing already picks a single nearest definition per name in * `listVisibleAgentDefinitions`, but a DM chat is minted against a * specific definition id, so a caller who has separately DM'd the same * named agent (e.g. "Myra") launched from more than one ancestor tenant * ends up with one durable `Workbench` row per instance — the dedupe - * there never runs again once a chat exists. Group every agent-DM chat - * (identified by `definitionId` being set — a group workbench never - * carries one) by `agentIdentityKey` and keep only the most recently - * active row per group; the rest are stale siblings of an identity the - * caller only ever needs to see once. + * there never runs again once a chat exists. + * + * `definitionId` is the discriminator that separates those stale + * cross-tenant siblings from workbenches a person deliberately created: + * "+ New Workbench" always mints against the bench's own currently + * resolved definition (`instant-agent-create.ts`), so N deliberate + * creations share one definition id and each keeps its row, while an + * ancestor tenant's leftover DM carries a different one. Per agent + * identity, the most recently active chat names the live definition; + * chats under any other definition are the stale siblings. */ -function dedupeAgentChatsByTitle(chats: readonly Workbench[]): Workbench[] { - const groupChats = chats.filter( - (chat) => chat.definitionId === null || chat.definitionId === undefined, - ); - const agentChats = chats.filter( - (chat) => chat.definitionId !== null && chat.definitionId !== undefined, - ); +function dropSupersededAgentChats(chats: readonly Workbench[]): Workbench[] { + const groupChats = chats.filter((chat) => !isAgentChat(chat)); + const agentChats = chats.filter(isAgentChat); - const newestByIdentity = new Map(); + const liveDefinitionByIdentity = new Map(); for (const chat of agentChats) { const key = agentIdentityKey(chat); - const current = newestByIdentity.get(key); - const chatActivity = chat.lastActivityAt - ? Date.parse(chat.lastActivityAt) - : 0; - const currentActivity = current?.lastActivityAt - ? Date.parse(current.lastActivityAt) - : 0; - if (current === undefined || chatActivity > currentActivity) { - newestByIdentity.set(key, chat); + const current = liveDefinitionByIdentity.get(key); + if (current === undefined || activityOf(chat) > activityOf(current)) { + liveDefinitionByIdentity.set(key, chat); } } - return [...groupChats, ...newestByIdentity.values()]; + return [ + ...groupChats, + ...agentChats.filter( + (chat) => + chat.definitionId === + liveDefinitionByIdentity.get(agentIdentityKey(chat))?.definitionId, + ), + ]; } function recencyOf(row: SidebarRow): number { - return row.workbench.lastActivityAt - ? Date.parse(row.workbench.lastActivityAt) - : 0; + return activityOf(row.workbench); } function isPinned(row: SidebarRow): boolean { @@ -90,7 +104,7 @@ export function buildSidebarRows( workbenches: readonly Workbench[], chats: readonly Workbench[], ): readonly SidebarRow[] { - const dedupedChats = dedupeAgentChatsByTitle(chats); + const dedupedChats = dropSupersededAgentChats(chats); const rows: SidebarRow[] = [ ...workbenches.map( (workbench) => ({ kind: "workbench", workbench }) as const,