From 17c116852bb24c731ec4117f59a80e15afd37f11 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 22:26:01 -0700 Subject: [PATCH 1/2] Rewrite sidebar-rows tests: every workbench keeps its row The CL-6271 dedup heuristic collapsed same-agent chats onto the newest definitionId. Since CL-6452 clones a fresh definition per workbench creation, every workbench now looks like a stale sibling of the newest one, so it hid every workbench but one. These tests assert every row survives. --- apps/web/src/shell/sidebar-rows.test.ts | 82 +++++-------------------- 1 file changed, 16 insertions(+), 66 deletions(-) diff --git a/apps/web/src/shell/sidebar-rows.test.ts b/apps/web/src/shell/sidebar-rows.test.ts index 5584324a..474c6597 100644 --- a/apps/web/src/shell/sidebar-rows.test.ts +++ b/apps/web/src/shell/sidebar-rows.test.ts @@ -74,34 +74,18 @@ describe("buildSidebarRows", () => { expect(rows).toEqual([{ kind: "workbench", workbench: dm }]); }); - test("DMs with the same agent identity minted from different ancestor tenants collapse to the most recent one (CL-6271)", () => { - const staleDm = workbench({ - id: "ch_myra_ancestor", - kind: "chat", - title: "Myra", - definitionId: "wfd_myra_ancestor", - lastActivityAt: "2026-01-01T00:00:00.000Z", - }); - const freshDm = workbench({ - id: "ch_myra_leaf", - kind: "chat", - title: "Myra", - definitionId: "wfd_myra_leaf", - lastActivityAt: "2026-01-05T00:00:00.000Z", - }); - - const rows = buildSidebarRows([], [staleDm, freshDm]); - - expect(rows).toEqual([{ kind: "workbench", workbench: freshDm }]); - }); - - test("every workbench deliberately created against the same agent definition keeps its own row (CL-6459)", () => { + test("every created workbench keeps its row even when each minted its own definition (CL-6621)", () => { + // Creation now clones a fresh definition per workbench (CL-6452), so + // sibling rows for the same agent legitimately carry distinct + // definitionIds. The old CL-6271 collapse keyed on exactly that and + // hid every workbench but the newest — a person creating a second + // workbench watched the first vanish. const created = ["ch_new_1", "ch_new_2", "ch_new_3"].map((id, index) => workbench({ id, kind: "chat", title: "New Workbench", - definitionId: "wfd_myra", + definitionId: `wfd_myra_${id}`, participants: [{ address: "myra@acme.localhost", handle: "myra" }], lastActivityAt: `2026-01-0${index + 1}T00:00:00.000Z`, }), @@ -116,43 +100,6 @@ describe("buildSidebarRows", () => { ]); }); - 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", @@ -192,15 +139,15 @@ describe("buildSidebarRows", () => { ]); }); - test("DMs sharing an agent identity still collapse when only their titles, not their participant handles, are known (legacy rows)", () => { - const staleDm = workbench({ + test("same-titled DMs both stay: a title is not identity (CL-6621)", () => { + const olderDm = workbench({ id: "ch_legacy_ancestor", kind: "chat", title: "Assist", definitionId: "wfd_legacy_ancestor", lastActivityAt: "2026-01-01T00:00:00.000Z", }); - const freshDm = workbench({ + const newerDm = workbench({ id: "ch_legacy_leaf", kind: "chat", title: "Assist", @@ -208,12 +155,15 @@ describe("buildSidebarRows", () => { lastActivityAt: "2026-01-05T00:00:00.000Z", }); - const rows = buildSidebarRows([], [staleDm, freshDm]); + const rows = buildSidebarRows([], [olderDm, newerDm]); - expect(rows).toEqual([{ kind: "workbench", workbench: freshDm }]); + expect(rows.map((row) => row.workbench.id)).toEqual([ + "ch_legacy_leaf", + "ch_legacy_ancestor", + ]); }); - test("group workbenches are never mistaken for agent DMs during dedupe", () => { + test("group workbenches with identical titles each keep their row", () => { const groupOne = workbench({ id: "ch_group_1", title: "Launch plan" }); const groupTwo = workbench({ id: "ch_group_2", title: "Launch plan" }); From b1d6194e483ce69d7f165f8ad548276cda18e50e Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 22:26:01 -0700 Subject: [PATCH 2/2] sidebar-rows: drop the same-definitionId dedup heuristic CL-6271 collapsed same-agent chats onto the newest definitionId to hide stale cross-tenant DM siblings. CL-6452 made workbench creation clone a fresh definition per workbench, invalidating that heuristic's premise: every workbench now reads as a stale sibling of the newest one, so only one workbench ever showed in the sidebar (CL-6621). Nothing was deleted server-side; the rows were always in Postgres. If stale cross-tenant DM siblings resurface, the fix belongs server-side at list time, not a client-side identity guess. --- apps/web/src/shell/sidebar-rows.ts | 93 ++++-------------------------- 1 file changed, 12 insertions(+), 81 deletions(-) diff --git a/apps/web/src/shell/sidebar-rows.ts b/apps/web/src/shell/sidebar-rows.ts index 21a5863a..91cd9d4a 100644 --- a/apps/web/src/shell/sidebar-rows.ts +++ b/apps/web/src/shell/sidebar-rows.ts @@ -4,88 +4,16 @@ // conversation act, not a standing nav item. import type { Workbench } from "@corbits/chat-ui"; -import { isAgentAddress } from "@corbits/chat/mentions"; export type SidebarRow = { readonly kind: "workbench"; readonly workbench: Workbench; }; -/** - * The stable identity a DM chat collapses on: an agent participant's - * mention `handle` is the same immutable slug - * (`buildAgentDefinitionWorkflow`'s `input.handle`) regardless which - * ancestor tenant minted the definition, so two chats DM'ing "the same" - * agent across tenants share this key even though their `definitionId`s - * genuinely differ. `title` is a display-only fallback for a chat with no - * recorded agent participant (a pre-participant-record legacy row) — never - * the primary key, since a display title can be produced two different - * ways for two entirely different agents (CL-6413's `humanizeSlug` - * backfill, or simply two creators picking the same human name) and must - * never be mistaken for identity. - */ -function agentIdentityKey(chat: Workbench): string { - const agentParticipant = chat.participants.find((participant) => - isAgentAddress(participant.address), - ); - 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; } -/** - * 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. - * - * `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 dropSupersededAgentChats(chats: readonly Workbench[]): Workbench[] { - const groupChats = chats.filter((chat) => !isAgentChat(chat)); - const agentChats = chats.filter(isAgentChat); - - const liveDefinitionByIdentity = new Map(); - for (const chat of agentChats) { - const key = agentIdentityKey(chat); - const current = liveDefinitionByIdentity.get(key); - if (current === undefined || activityOf(chat) > activityOf(current)) { - liveDefinitionByIdentity.set(key, chat); - } - } - - return [ - ...groupChats, - ...agentChats.filter( - (chat) => - chat.definitionId === - liveDefinitionByIdentity.get(agentIdentityKey(chat))?.definitionId, - ), - ]; -} - function recencyOf(row: SidebarRow): number { return activityOf(row.workbench); } @@ -104,15 +32,18 @@ export function buildSidebarRows( workbenches: readonly Workbench[], chats: readonly Workbench[], ): readonly SidebarRow[] { - const dedupedChats = dropSupersededAgentChats(chats); - const rows: SidebarRow[] = [ - ...workbenches.map( - (workbench) => ({ kind: "workbench", workbench }) as const, - ), - ...dedupedChats.map( - (workbench) => ({ kind: "workbench", workbench }) as const, - ), - ]; + // Every row a person can see in Postgres appears here. An earlier + // heuristic (CL-6271) collapsed same-agent chats onto the newest + // definitionId to hide stale cross-tenant DM siblings; once creation + // began cloning a fresh definition per workbench (CL-6452), that + // heuristic could no longer tell a stale sibling from a deliberately + // created workbench and hid every workbench but the newest (CL-6621). + // Hiding real workbenches reads as data loss; a duplicate stale DM is + // merely untidy. If stale siblings resurface, fix them server-side at + // list time, not with a client-side identity guess. + const rows: SidebarRow[] = [...workbenches, ...chats].map( + (workbench) => ({ kind: "workbench", workbench }) as const, + ); const byRecency = (a: SidebarRow, b: SidebarRow) => recencyOf(b) - recencyOf(a); return [