From 38bbe183747325b745c463eb9e43a01f80250fca Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 22 Aug 2026 06:42:27 -0700 Subject: [PATCH 1/3] Add tests for invite dialog filtering, participant exclusion, and Jimmy's row name CL-6649: pins the exact trap that let a routine's delivery workflow and Echo leak into the invite-agent picker (non-automatable but also non-conversational), covers excluding an already-present participant from /workbenches/:id/invitable, and covers the quick-create Jimmy row rendering his name instead of his description. --- .../chat-ui/test/invite-agent-dialog.test.tsx | 18 ++++++++++ packages/chat/test/routes.test.ts | 35 +++++++++++++++++++ .../workflow-catalog/test/catalog.test.ts | 12 +++++++ 3 files changed, 65 insertions(+) diff --git a/packages/chat-ui/test/invite-agent-dialog.test.tsx b/packages/chat-ui/test/invite-agent-dialog.test.tsx index dc3b4cb3..745adb3e 100644 --- a/packages/chat-ui/test/invite-agent-dialog.test.tsx +++ b/packages/chat-ui/test/invite-agent-dialog.test.tsx @@ -91,6 +91,24 @@ describe("InviteAgentDialog's Jimmy quick-create row", () => { expect(row?.textContent).toContain(JIMMY_QUICK_CREATE.description); }); + // CL-6649: the row used to render only `JIMMY_QUICK_CREATE.description` + // ("Searches Giphy and replies with a GIF") — Jimmy's own name never + // appeared at all. The name must lead, with the description as a + // secondary line and a first-party attribution alongside the name. + test("renders Jimmy's name prominently, attributed to Corbits, with the description as a secondary line", async () => { + const el = await mount({ + invitable: () => [{ id: "wfd_echo", name: "echo" }], + onInvite: async () => undefined, + onOpenChange: () => undefined, + }); + const row = el.querySelector('[data-testid="quick-create-jimmy"]'); + const name = row?.querySelector(".chat-invitable-item-name"); + const description = row?.querySelector(".chat-invitable-item-description"); + expect(name?.textContent).toContain(JIMMY_QUICK_CREATE.name); + expect(name?.textContent).toContain("by Corbits"); + expect(description?.textContent).toBe(JIMMY_QUICK_CREATE.description); + }); + test("is absent once the tenant's invitable list already includes Jimmy", async () => { const el = await mount({ invitable: () => [{ id: "wfd_jimmy", name: JIMMY_QUICK_CREATE.handle }], diff --git a/packages/chat/test/routes.test.ts b/packages/chat/test/routes.test.ts index 203786af..61a48103 100644 --- a/packages/chat/test/routes.test.ts +++ b/packages/chat/test/routes.test.ts @@ -2363,6 +2363,41 @@ describe("GET /workbenches/:id/invitable", () => { expect(body.items).toEqual([{ id: "wfd_echo", name: "echo" }]); }); + // CL-6649: a definition already invited into the room isn't invitable + // again — the dialog must never re-offer someone already present. + test("excludes a definition whose agent is already a participant", async () => { + const deps = buildDeps({ + platform: fakePlatform({ + invitable: [ + { id: "wfd_echo", name: "echo" }, + { id: "wfd_myra", name: "assistant", description: "Myra" }, + ], + resolveDefinitionIdByAddress: async (address) => + address === "ins_invited1@acme.example" ? "wfd_echo" : undefined, + }), + }); + const app = mountAs(createChatRoutes(deps), "prn_alice"); + const { body: workbench } = await createWorkbench(app, { + kind: "workbench", + }); + await app.request(`/workbenches/${workbench.id}/invite`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ definitionId: "wfd_echo" }), + }); + + const response = await app.request( + `/workbenches/${workbench.id}/invitable`, + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { + items: { id: string; name: string; description?: string }[]; + }; + expect(body.items).toEqual([ + { id: "wfd_myra", name: "assistant", description: "Myra" }, + ]); + }); + test("a denied grant is rejected", async () => { const deps = buildDeps({ requireGrant: () => async (c) => diff --git a/packages/workflow-catalog/test/catalog.test.ts b/packages/workflow-catalog/test/catalog.test.ts index 7eaa1735..2b5a7ff2 100644 --- a/packages/workflow-catalog/test/catalog.test.ts +++ b/packages/workflow-catalog/test/catalog.test.ts @@ -116,6 +116,18 @@ describe("workflow catalog", () => { expect(isConversationalWorkflowName("wfd_deadbeef")).toBe(true); }); + // CL-6649: `echo` and `last-30-days-research` are both non-automatable + // AND non-conversational — the exact combination that let a picker + // gated on `!isAutomatableWorkflowName` alone (rather than + // `isConversationalWorkflowName`) mistake a routine's delivery + // workflow, and the Echo wiring check, for an invitable chat agent. + test("a non-automatable utility is still non-conversational (the CL-6649 trap)", () => { + for (const name of ["echo", "last-30-days-research"]) { + expect(isAutomatableWorkflowName(name)).toBe(false); + expect(isConversationalWorkflowName(name)).toBe(false); + } + }); + test("every catalog entry declares a conversational flag", () => { for (const entry of WORKFLOW_CATALOG) { expect(typeof entry.conversational).toBe("boolean"); From 55652ab58d389f859993c62deae83827ba55e735 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 22 Aug 2026 06:42:36 -0700 Subject: [PATCH 2/3] Exclude routines and current participants from the invite-agent listing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The invite picker's conversational-agent ruling checked !isAutomatableWorkflowName, not isConversationalWorkflowName — a non-automatable, non-conversational catalog entry (a routine's delivery workflow like "Last 30 days research report", the Echo wiring check) slipped through as if it were a real chat agent. Switch to the flag that actually means "conversational", matching the sidebar's own DM listing. /workbenches/:id/invitable also excluded nothing: a definition already resident in the room (e.g. Myra) was re-offered for invite. Resolve the room's current agent participants back to their definition ids and drop them from the listing. Echo's e2e wiring-check tests resolved its definition id through this same (now correctly filtered) endpoint; point them at the agent-definitions by-name lookup instead, since Echo is intentionally no longer conversational/invitable. --- apps/hub/src/index.ts | 14 +++++++--- packages/chat/src/routes.ts | 31 ++++++++++++++++++++-- scripts/e2e/chat.test.ts | 51 ++++++++++++------------------------- 3 files changed, 55 insertions(+), 41 deletions(-) diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 1c27b2cd..e2ffe56a 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -169,6 +169,7 @@ import { import { deliveryWorkbenchRequiredForWorkflowName, isAutomatableWorkflowName, + isConversationalWorkflowName, validateTriggerFieldsAtCreate, workflowCatalogEntry, workflowDisplayName, @@ -1483,11 +1484,16 @@ export async function createHub(config: HubConfig) { // The one "is this a conversational agent?" ruling, shared by every // picker that offers agents to a person and by a routine's `"agent"`-kind - // trigger-field validation below: automatable catalog workflows - // (routines material) and workbench-host anchor definitions (chat's own - // plumbing, never a person-facing agent) belong in neither. + // trigger-field validation below: a catalog workflow whose entry says + // `conversational: false` (routine/automation material — Echo, "Last 30 + // days research report", …) and workbench-host anchor definitions + // (chat's own plumbing, never a person-facing agent) belong in neither. + // `isConversationalWorkflowName`, not `isAutomatableWorkflowName`: a + // non-automatable utility workflow (Echo, the research report a routine + // delivers) is still not conversational, and the old automatable-only + // check let both leak into every agent picker (CL-6649). const isConversationalAgentDefinition = (definition: { name: string }) => - !isAutomatableWorkflowName(definition.name) && + isConversationalWorkflowName(definition.name) && !isWorkbenchHostDefinitionName(definition.name); // A second, narrower ruling layered on top of the ruling above, for diff --git a/packages/chat/src/routes.ts b/packages/chat/src/routes.ts index 99527db0..ebecaa35 100644 --- a/packages/chat/src/routes.ts +++ b/packages/chat/src/routes.ts @@ -2685,11 +2685,38 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { async (c) => { const tenant = c.get("tenant"); const workbenchId = c.req.param("id"); - if (!(await workbenchInTenant(deps.store, tenant.id, workbenchId))) { + const existing = await deps.store.getWorkbenchSettings( + tenant.id, + workbenchId, + ); + if ( + existing === undefined && + !(await workbenchInTenant(deps.store, tenant.id, workbenchId)) + ) { return c.json(ErrorEnvelope("not_found", "workbench not found"), 404); } + + // A definition already in the room isn't invitable — resolve each + // current agent participant's address back to its definitionId so + // the listing never re-offers someone already present (CL-6649). + const presentDefinitionIds = new Set( + ( + await Promise.all( + (existing !== undefined ? participantsOf(existing.settings) : []) + .filter((participant) => isAgentAddress(participant.address)) + .map((participant) => + deps.platform.resolveDefinitionIdByAddress(participant.address), + ), + ) + ).filter((id): id is string => id !== undefined), + ); + const items = await deps.platform.listInvitableDefinitions(tenant.id); - return c.json({ items: items.filter(deps.isInvitableDefinition) }); + return c.json({ + items: items + .filter(deps.isInvitableDefinition) + .filter((item) => !presentDefinitionIds.has(item.id)), + }); }, ); diff --git a/scripts/e2e/chat.test.ts b/scripts/e2e/chat.test.ts index 94010743..d7802a9b 100644 --- a/scripts/e2e/chat.test.ts +++ b/scripts/e2e/chat.test.ts @@ -685,29 +685,17 @@ describe.skipIf(databaseUrl === undefined)("chat e2e", () => { }, 90_000); test("inviting the echo agent launches its own run, joins the workbench, and receives @mentions", async () => { - const invitableRes = await api( - "GET", - `/api/tenants/${tenantId}/chat/workbenches/${workbenchId}/invitable`, - undefined, - user1.cookies, - ); - expectStatus("list invitable definitions", invitableRes, 200); - const invitable = arrayField( - invitableRes.data, - "items", - "list invitable definitions", - ) as { id: string; name: string }[]; - const echoDefinition = invitable.find((item) => item.name === "echo"); - if (echoDefinition === undefined) { - throw new Error( - `no invitable definition named "echo": ${JSON.stringify(invitable)}`, - ); - } + // Echo is a non-conversational wiring check (`conversational: false` + // in the workflow catalog, CL-6649) — the invite dialog's own + // listing correctly excludes it, so this test resolves its + // definition id directly by name rather than through that filtered + // listing. + const echoId = await echoDefinitionId(); const invited = await api( "POST", `/api/tenants/${tenantId}/chat/workbenches/${workbenchId}/invite`, - { definitionId: echoDefinition.id }, + { definitionId: echoId }, user1.cookies, ); expectStatus("invite echo agent", invited, 201); @@ -717,7 +705,7 @@ describe.skipIf(databaseUrl === undefined)("chat e2e", () => { "invite echo agent", ); expect(stringField(invited.data, "definitionId", "invite echo agent")).toBe( - echoDefinition.id, + echoId, ); const invitedLocalPart = invitedAddress.split("@")[0]; if (invitedLocalPart === undefined || invitedLocalPart === "") { @@ -786,26 +774,19 @@ describe.skipIf(databaseUrl === undefined)("chat e2e", () => { expect(fresh.length).toBeGreaterThan(0); }, 90_000); + // Echo is a non-conversational wiring check (`conversational: false` in + // the workflow catalog, CL-6649) — it never appears in the invite + // dialog's own (correctly filtered) listing, so its definition id is + // resolved directly by name instead. async function echoDefinitionId(): Promise { - const invitableRes = await api( + const byNameRes = await api( "GET", - `/api/tenants/${tenantId}/chat/workbenches/${workbenchId}/invitable`, + `/api/tenants/${tenantId}/agent-definitions/by-name/echo`, undefined, user1.cookies, ); - expectStatus("list invitable definitions", invitableRes, 200); - const invitable = arrayField( - invitableRes.data, - "items", - "list invitable definitions", - ) as { id: string; name: string }[]; - const echoDefinition = invitable.find((item) => item.name === "echo"); - if (echoDefinition === undefined) { - throw new Error( - `no invitable definition named "echo": ${JSON.stringify(invitable)}`, - ); - } - return echoDefinition.id; + expectStatus("resolve echo definition by name", byNameRes, 200); + return stringField(byNameRes.data, "id", "resolve echo definition by name"); } test("a chat auto-invites the echo agent and delivers un-mentioned messages to it", async () => { From 251469e496105746e76c19ffb5080e63e2054ab6 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 22 Aug 2026 06:42:42 -0700 Subject: [PATCH 3/3] Invite dialog: render Jimmy's name and Corbits attribution, not his description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quick-create Jimmy row rendered only his description ("Searches Giphy and replies with a GIF") and never his name at all. Render the name prominently with a "by Corbits" attribution for first-party agents, and the description as a secondary line — same treatment for every other row's display name/slug fallback. --- packages/chat-ui/src/invite-agent-dialog.tsx | 61 ++++++++++++++------ packages/chat-ui/src/strings.ts | 1 + packages/chat-ui/src/styles.css | 25 ++++++++ 3 files changed, 68 insertions(+), 19 deletions(-) diff --git a/packages/chat-ui/src/invite-agent-dialog.tsx b/packages/chat-ui/src/invite-agent-dialog.tsx index cc55eacb..dd91c88f 100644 --- a/packages/chat-ui/src/invite-agent-dialog.tsx +++ b/packages/chat-ui/src/invite-agent-dialog.tsx @@ -21,6 +21,7 @@ import { import { Users, WarningCircle } from "@corbits/icons"; import { useEffect, useState } from "react"; +import { humanizeSlug } from "@corbits/chat/display-name"; import { ChatApiError, describeChatError, @@ -151,31 +152,53 @@ export function InviteAgentDialog({ /> ) : (
    - {state.items.map((definition) => ( -
  • - {definition.description ?? definition.name} - -
  • - ))} + + {displayName} + + + + ); + })} {jimmyMissing && (
  • - {JIMMY_QUICK_CREATE.description} + + + {JIMMY_QUICK_CREATE.name} + + {CHAT_STRINGS.inviteAgentFirstPartyAttribution} + + + + {JIMMY_QUICK_CREATE.description} + +