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
14 changes: 10 additions & 4 deletions apps/hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ import {
import {
deliveryWorkbenchRequiredForWorkflowName,
isAutomatableWorkflowName,
isConversationalWorkflowName,
validateTriggerFieldsAtCreate,
workflowCatalogEntry,
workflowDisplayName,
Expand Down Expand Up @@ -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
Expand Down
61 changes: 42 additions & 19 deletions packages/chat-ui/src/invite-agent-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -151,31 +152,53 @@ export function InviteAgentDialog({
/>
) : (
<ul className="chat-invitable-list">
{state.items.map((definition) => (
<li
key={definition.id}
className="chat-invitable-item"
data-testid="invitable-definition"
>
<span>{definition.description ?? definition.name}</span>
<Button
variant="outline"
size="sm"
disabled={invitingId !== null}
onClick={() => 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 (
<li
key={definition.id}
className="chat-invitable-item"
data-testid="invitable-definition"
>
{invitingId === definition.id
? CHAT_STRINGS.inviteAgentInviting
: CHAT_STRINGS.inviteAgentAction}
</Button>
</li>
))}
<span className="chat-invitable-item-name">
{displayName}
</span>
<Button
variant="outline"
size="sm"
disabled={invitingId !== null}
onClick={() => void handleInvite(definition.id)}
>
{invitingId === definition.id
? CHAT_STRINGS.inviteAgentInviting
: CHAT_STRINGS.inviteAgentAction}
</Button>
</li>
);
})}
{jimmyMissing && (
<li
className="chat-invitable-item"
data-testid="quick-create-jimmy"
>
<span>{JIMMY_QUICK_CREATE.description}</span>
<span className="chat-invitable-item-info">
<span className="chat-invitable-item-name">
{JIMMY_QUICK_CREATE.name}
<span className="chat-invitable-item-attribution">
{CHAT_STRINGS.inviteAgentFirstPartyAttribution}
</span>
</span>
<span className="chat-invitable-item-description">
{JIMMY_QUICK_CREATE.description}
</span>
</span>
<Button
variant="outline"
size="sm"
Expand Down
1 change: 1 addition & 0 deletions packages/chat-ui/src/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export const CHAT_STRINGS = {
inviteAgentQuickCreateAction: "Add",
inviteAgentQuickCreating: "Adding…",
inviteAgentQuickCreateError: "Couldn't add Jimmy — try again.",
inviteAgentFirstPartyAttribution: "by Corbits",
forkThreadAction: "Fork",
forkThreadError: "Couldn't fork that message into a thread — try again.",
replyInThreadAction: "Reply in thread",
Expand Down
25 changes: 25 additions & 0 deletions packages/chat-ui/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -3322,6 +3322,31 @@
font-size: 0.875rem;
}

.chat-invitable-item-info {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}

.chat-invitable-item-name {
font-weight: 600;
}

.chat-invitable-item-attribution {
margin-left: 0.375rem;
font-weight: 400;
color: var(--muted-foreground);
}

.chat-invitable-item-description {
color: var(--muted-foreground);
font-size: 0.8125rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

/* Guided-dialog stepper chrome: uppercase step label over a thin segmented
rail, filled in the host's accent as the flow advances, with one calm
guidance sentence underneath. Mirrors the onboarding wizard rail's visual
Expand Down
18 changes: 18 additions & 0 deletions packages/chat-ui/test/invite-agent-dialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }],
Expand Down
31 changes: 29 additions & 2 deletions packages/chat/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2685,11 +2685,38 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono<TenantEnv> {
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)),
});
},
);

Expand Down
35 changes: 35 additions & 0 deletions packages/chat/test/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
12 changes: 12 additions & 0 deletions packages/workflow-catalog/test/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
51 changes: 16 additions & 35 deletions scripts/e2e/chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 === "") {
Expand Down Expand Up @@ -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<string> {
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 () => {
Expand Down
Loading