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-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}
- void handleInvite(definition.id)}
+ {state.items.map((definition) => {
+ // `description` carries the definition's real display
+ // name (set at creation), never a summary sentence — see
+ // `InvitableDefinition`'s own doc comment — so the
+ // fallback below is "no display name was ever set", not
+ // "no description exists". `name` is the immutable slug
+ // and never belongs in the title slot on its own.
+ const displayName =
+ definition.description ?? humanizeSlug(definition.name);
+ return (
+
- {invitingId === definition.id
- ? CHAT_STRINGS.inviteAgentInviting
- : CHAT_STRINGS.inviteAgentAction}
-
-
- ))}
+
+ {displayName}
+
+ void handleInvite(definition.id)}
+ >
+ {invitingId === definition.id
+ ? CHAT_STRINGS.inviteAgentInviting
+ : CHAT_STRINGS.inviteAgentAction}
+
+
+ );
+ })}
{jimmyMissing && (
- {JIMMY_QUICK_CREATE.description}
+
+
+ {JIMMY_QUICK_CREATE.name}
+
+ {CHAT_STRINGS.inviteAgentFirstPartyAttribution}
+
+
+
+ {JIMMY_QUICK_CREATE.description}
+
+
{
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/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/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");
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 () => {