Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 16 additions & 66 deletions apps/web/src/shell/sidebar-rows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
}),
Expand All @@ -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",
Expand Down Expand Up @@ -192,28 +139,31 @@ 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",
definitionId: "wfd_legacy_leaf",
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" });

Expand Down
93 changes: 12 additions & 81 deletions apps/web/src/shell/sidebar-rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, AgentChat>();
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);
}
Expand All @@ -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 [
Expand Down
Loading