From f8cba234d198c72ff83743a3bfeb3606313e2f5a Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 09:49:51 -0700 Subject: [PATCH] feat(hub-zero)!: web reads chat wire types from chat-ui, not @corbits/chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @corbits/chat is a server-only package (drizzle, postgres, hono) that apps/web and packages/chat-ui only ever reached for pure wire schemas (parts, blocks, mentions, participants, display-name, agent-address, reaction-emoji, workbench-host-naming, stream-events, consumer-inference-text) — never for a client. Per CL-8129's ruling that @corbits/chat is not allowed in the browser surface, those schemas now live at packages/chat-ui/src/wire/*, exported as @corbits/chat-ui/wire/ subpaths, and apps/web/packages/chat-ui no longer depend on @corbits/chat at all. The hub still mounts @corbits/chat's routes unchanged (T5a/T5c own that cutover), so the wire copy here mirrors the same contract those routes speak; it becomes the one source of truth once the hub moves onto native mail threads. Claude-Session: https://claude.ai/code/session_01RweuMUskU2nvcFhdckc7EG --- apps/web/package.json | 1 - apps/web/src/block-response-actions.ts | 2 +- apps/web/src/insights-stats.ts | 2 +- apps/web/src/pages/new-workbench-picker.tsx | 2 +- apps/web/src/profile-relations.ts | 2 +- apps/web/src/skill-display-name.ts | 2 +- apps/web/src/workbench-timeline-merge.ts | 4 +- bun.lock | 6 - packages/chat-ui/package.json | 13 +- packages/chat-ui/src/agent-part-adapter.ts | 4 +- packages/chat-ui/src/api.ts | 35 ++- packages/chat-ui/src/blocks/approve-block.tsx | 2 +- .../chat-ui/src/blocks/block-responses.ts | 2 +- .../blocks/connect-github-block-container.tsx | 2 +- .../connect-service-block-container.tsx | 2 +- packages/chat-ui/src/blocks/form-block.tsx | 2 +- packages/chat-ui/src/blocks/metrics-block.tsx | 2 +- packages/chat-ui/src/blocks/poll-block.tsx | 2 +- .../chat-ui/src/blocks/question-block.tsx | 2 +- packages/chat-ui/src/blocks/registry.tsx | 6 +- packages/chat-ui/src/blocks/steps-block.tsx | 2 +- packages/chat-ui/src/blocks/stream-block.tsx | 2 +- packages/chat-ui/src/chat-workspace.tsx | 8 +- packages/chat-ui/src/inference-failure.ts | 2 +- packages/chat-ui/src/invite-agent-dialog.tsx | 4 +- packages/chat-ui/src/mentions.ts | 6 +- packages/chat-ui/src/profile-subject.ts | 2 +- packages/chat-ui/src/shared-workbenches.ts | 2 +- packages/chat-ui/src/streaming-reply.ts | 4 +- packages/chat-ui/src/timeline.tsx | 6 +- packages/chat-ui/src/tool-activity.test.ts | 2 +- packages/chat-ui/src/tool-activity.ts | 4 +- packages/chat-ui/src/wire/agent-address.ts | 25 ++ packages/chat-ui/src/wire/blocks.ts | 294 ++++++++++++++++++ .../src/wire/consumer-inference-text.ts | 80 +++++ packages/chat-ui/src/wire/display-name.ts | 107 +++++++ packages/chat-ui/src/wire/id-leak-guard.ts | 50 +++ packages/chat-ui/src/wire/mentions.ts | 62 ++++ packages/chat-ui/src/wire/participants.ts | 124 ++++++++ packages/chat-ui/src/wire/parts.ts | 124 ++++++++ packages/chat-ui/src/wire/reaction-emoji.ts | 21 ++ packages/chat-ui/src/wire/stream-events.ts | 116 +++++++ .../chat-ui/src/wire/tools-unsupported.ts | 30 ++ .../chat-ui/src/wire/workbench-host-naming.ts | 48 +++ .../workbench-settings/members-section.tsx | 2 +- .../src/workbench-settings/surface.tsx | 2 +- 46 files changed, 1154 insertions(+), 70 deletions(-) create mode 100644 packages/chat-ui/src/wire/agent-address.ts create mode 100644 packages/chat-ui/src/wire/blocks.ts create mode 100644 packages/chat-ui/src/wire/consumer-inference-text.ts create mode 100644 packages/chat-ui/src/wire/display-name.ts create mode 100644 packages/chat-ui/src/wire/id-leak-guard.ts create mode 100644 packages/chat-ui/src/wire/mentions.ts create mode 100644 packages/chat-ui/src/wire/participants.ts create mode 100644 packages/chat-ui/src/wire/parts.ts create mode 100644 packages/chat-ui/src/wire/reaction-emoji.ts create mode 100644 packages/chat-ui/src/wire/stream-events.ts create mode 100644 packages/chat-ui/src/wire/tools-unsupported.ts create mode 100644 packages/chat-ui/src/wire/workbench-host-naming.ts diff --git a/apps/web/package.json b/apps/web/package.json index 6d4d0e9f3b..315ca10396 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,7 +22,6 @@ "@corbits/approvals": "workspace:*", "@corbits/artifact-ui": "workspace:*", "@corbits/bench-ui": "workspace:*", - "@corbits/chat": "workspace:*", "@corbits/chat-ui": "workspace:*", "@corbits/client-log": "workspace:*", "@corbits/command-palette": "workspace:*", diff --git a/apps/web/src/block-response-actions.ts b/apps/web/src/block-response-actions.ts index 2e26ab82e8..a42afc2719 100644 --- a/apps/web/src/block-response-actions.ts +++ b/apps/web/src/block-response-actions.ts @@ -1,7 +1,7 @@ // Builds the `BlockResponseActions` port `ChatWorkspace` (`@corbits/chat-ui`) // calls for its in-chat poll/form cards, mirroring `createChatApprovalActions` // in `./approval-actions.ts`: `@corbits/chat-ui` owns no session, so the host -// supplies the concrete fetches against `@corbits/chat`'s own response +// supplies the concrete fetches against the hub's chat response // routes (`getBlockResponses`/`submitPollResponse`/`submitFormResponse` in // `api.ts`). diff --git a/apps/web/src/insights-stats.ts b/apps/web/src/insights-stats.ts index 47757b1465..c7e250ec14 100644 --- a/apps/web/src/insights-stats.ts +++ b/apps/web/src/insights-stats.ts @@ -2,7 +2,7 @@ // routines). No new analytics backend — I1 is an honest live surface on // existing endpoints. -import { isWorkbenchHostDefinitionName } from "@corbits/chat/workbench-host-naming"; +import { isWorkbenchHostDefinitionName } from "@corbits/chat-ui/wire/workbench-host-naming"; import { runOutcomeStatus, withListingAbandoned, diff --git a/apps/web/src/pages/new-workbench-picker.tsx b/apps/web/src/pages/new-workbench-picker.tsx index 82a14f23fc..406dd6be9d 100644 --- a/apps/web/src/pages/new-workbench-picker.tsx +++ b/apps/web/src/pages/new-workbench-picker.tsx @@ -23,7 +23,7 @@ import { listTenantInvitableDefinitions, WorkbenchLoadingState, } from "@corbits/chat-ui"; -import { humanizeSlug } from "@corbits/chat/display-name"; +import { humanizeSlug } from "@corbits/chat-ui/wire/display-name"; import { useEffect, useRef, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ApiQueryError, describeApiError } from "@corbits/api-query"; diff --git a/apps/web/src/profile-relations.ts b/apps/web/src/profile-relations.ts index 806ad7737b..a2e0bb9e65 100644 --- a/apps/web/src/profile-relations.ts +++ b/apps/web/src/profile-relations.ts @@ -4,7 +4,7 @@ // participant instead of by a fixed title, and fall back to creating one — // to any profile subject rather than one hardcoded agent. -import { localPartOf } from "@corbits/chat/agent-address"; +import { localPartOf } from "@corbits/chat-ui/wire/agent-address"; import { createWorkbench, findDirectWorkbenchWith, diff --git a/apps/web/src/skill-display-name.ts b/apps/web/src/skill-display-name.ts index 55a3b08dd5..4fefe76723 100644 --- a/apps/web/src/skill-display-name.ts +++ b/apps/web/src/skill-display-name.ts @@ -3,7 +3,7 @@ // `displayTitle` (or the native skill asset's `displayName`, which the // stock asset routes carry as of CL-8086) or a Title Case reading of that // slug (CL-6747). -import { humanizeSlug } from "@corbits/chat/display-name"; +import { humanizeSlug } from "@corbits/chat-ui/wire/display-name"; export function skillDisplayName(skill: { readonly name: string; diff --git a/apps/web/src/workbench-timeline-merge.ts b/apps/web/src/workbench-timeline-merge.ts index 97f520a1c9..6b6fe44cb0 100644 --- a/apps/web/src/workbench-timeline-merge.ts +++ b/apps/web/src/workbench-timeline-merge.ts @@ -4,8 +4,8 @@ // array. No new backend: every input here is already fetched by an // existing page (chat-ui, api.ts). -import { isAgentAddress } from "@corbits/chat/mentions"; -import { localPartOf } from "@corbits/chat/agent-address"; +import { isAgentAddress } from "@corbits/chat-ui/wire/mentions"; +import { localPartOf } from "@corbits/chat-ui/wire/agent-address"; import type { WorkbenchThread, MessageItem } from "@corbits/chat-ui"; import type { PendingApproval } from "./pending-approvals"; diff --git a/bun.lock b/bun.lock index 27365a1dc8..377c934fa8 100644 --- a/bun.lock +++ b/bun.lock @@ -158,7 +158,6 @@ "@corbits/approvals": "workspace:*", "@corbits/artifact-ui": "workspace:*", "@corbits/bench-ui": "workspace:*", - "@corbits/chat": "workspace:*", "@corbits/chat-ui": "workspace:*", "@corbits/client-log": "workspace:*", "@corbits/command-palette": "workspace:*", @@ -487,7 +486,6 @@ "version": "0.0.1", "dependencies": { "@corbits/api-query": "workspace:*", - "@corbits/chat": "workspace:*", "@corbits/connections": "workspace:*", "@corbits/context-menu": "workspace:*", "@corbits/error-sink": "workspace:*", @@ -3485,8 +3483,6 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@corbits/inbox/@corbits/mailbox": ["@corbits/mailbox@github:corbitsdev/corbits-mailbox#118a2cd", { "dependencies": { "@hono/standard-validator": "0.2.3", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "arktype": "2.1.29", "hono-openapi": "1.3.1" }, "peerDependencies": { "@intx/log": "^0.2.2", "@intx/mime": "^0.2.2", "@intx/types": "^0.2.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" } }, "corbitsdev-corbits-mailbox-118a2cd", "sha512-jVRl6/IH34JBPVUz7mqWyBm1w7uJxvYAI27gjw7zL1dFO4L+D0E5q/8b+mpwKMNOKKlGXU6UF5pdHnY8ilYx8g=="], - "@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], "@corbits/memory-hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], @@ -3565,8 +3561,6 @@ "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "@corbits/inbox/@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], diff --git a/packages/chat-ui/package.json b/packages/chat-ui/package.json index 6aed0ebba0..bf9ab1a24a 100644 --- a/packages/chat-ui/package.json +++ b/packages/chat-ui/package.json @@ -8,7 +8,17 @@ "sideEffects": false, "exports": { ".": "./src/index.ts", - "./styles.css": "./src/styles.css" + "./styles.css": "./src/styles.css", + "./wire/parts": "./src/wire/parts.ts", + "./wire/blocks": "./src/wire/blocks.ts", + "./wire/mentions": "./src/wire/mentions.ts", + "./wire/participants": "./src/wire/participants.ts", + "./wire/display-name": "./src/wire/display-name.ts", + "./wire/agent-address": "./src/wire/agent-address.ts", + "./wire/reaction-emoji": "./src/wire/reaction-emoji.ts", + "./wire/workbench-host-naming": "./src/wire/workbench-host-naming.ts", + "./wire/stream-events": "./src/wire/stream-events.ts", + "./wire/consumer-inference-text": "./src/wire/consumer-inference-text.ts" }, "scripts": { "typecheck": "tsc --noEmit", @@ -16,7 +26,6 @@ }, "dependencies": { "@corbits/api-query": "workspace:*", - "@corbits/chat": "workspace:*", "@corbits/context-menu": "workspace:*", "@corbits/error-sink": "workspace:*", "@corbits/inference-settings": "workspace:*", diff --git a/packages/chat-ui/src/agent-part-adapter.ts b/packages/chat-ui/src/agent-part-adapter.ts index ed0e975c43..9c781d94b8 100644 --- a/packages/chat-ui/src/agent-part-adapter.ts +++ b/packages/chat-ui/src/agent-part-adapter.ts @@ -1,4 +1,4 @@ -// Adapting `@corbits/chat`'s parts to the shapes `@corbits/react-ui` +// Adapting the chat wire's parts (`./wire/parts`) to the shapes `@corbits/react-ui` // renders (CL-6318). // // Only reasoning crosses this boundary now. Tool calls used to as well, @@ -8,7 +8,7 @@ // turn's calls into rounds and never shows a reader JSON at all. import type { PartReasoning } from "@corbits/react-ui"; -import type { ReasoningPart } from "@corbits/chat/parts"; +import type { ReasoningPart } from "./wire/parts"; /** chat's reasoning carries no duration; react-ui's is optional, so it * stays absent rather than being fabricated. */ diff --git a/packages/chat-ui/src/api.ts b/packages/chat-ui/src/api.ts index d223fc483a..a4d21d6c0b 100644 --- a/packages/chat-ui/src/api.ts +++ b/packages/chat-ui/src/api.ts @@ -1,19 +1,20 @@ -// The chat surface's one seam to `@corbits/chat`'s HTTP routes (see -// packages/chat/src/routes.ts). Every fetch the chat/* components make goes -// through a function here, and every response is parsed with an arktype -// schema at the boundary — a route shape change is a one-file fix. +// The chat surface's one seam to the hub's chat HTTP routes (see +// packages/chat/src/routes.ts, still the routes' implementation). Every +// fetch the chat/* components make goes through a function here, and every +// response is parsed with an arktype schema at the boundary — a route +// shape change is a one-file fix. // -// The wire-level `Part` and participant schemas are imported from -// `@corbits/chat` rather than redefined here: this UI validates the wire -// contract at its own boundary, but against the one real schema rather than -// a second, hand-copied one. +// The wire-level `Part` and participant schemas live in this package's own +// `./wire` (mirrored off the hub's wire contract) rather than being +// imported from `@corbits/chat` directly, so this browser bundle never +// carries a server-only dependency. import { type } from "arktype"; import type { ArkErrors } from "arktype"; -import { Part } from "@corbits/chat/parts"; -import { parseParticipants } from "@corbits/chat/participants"; -import type { ParticipantRecord } from "@corbits/chat/participants"; -import type { WorkbenchOnboardingStep } from "@corbits/chat/blocks"; +import { Part } from "./wire/parts"; +import { parseParticipants } from "./wire/participants"; +import type { ParticipantRecord } from "./wire/participants"; +import type { WorkbenchOnboardingStep } from "./wire/blocks"; import { UnauthenticatedError } from "@corbits/api-query"; import { InferenceSettingsApiError } from "@corbits/inference-settings"; import { jimmyAgentRequest } from "@workbench/templates"; @@ -27,14 +28,14 @@ export { FilePart, EventPart, Part, -} from "@corbits/chat/parts"; -export type { ParticipantRecord } from "@corbits/chat/participants"; +} from "./wire/parts"; +export type { ParticipantRecord } from "./wire/participants"; export type { OnboardingStepLabel, WorkbenchOnboardingStep, -} from "@corbits/chat/blocks"; -export { REACTION_EMOJI } from "@corbits/chat/reaction-emoji"; -export type { ReactionEmoji } from "@corbits/chat/reaction-emoji"; +} from "./wire/blocks"; +export { REACTION_EMOJI } from "./wire/reaction-emoji"; +export type { ReactionEmoji } from "./wire/reaction-emoji"; export const WorkbenchKind = type("'workbench' | 'chat'"); export type WorkbenchKind = typeof WorkbenchKind.infer; diff --git a/packages/chat-ui/src/blocks/approve-block.tsx b/packages/chat-ui/src/blocks/approve-block.tsx index 89ee8ce7d3..e72d54e7b8 100644 --- a/packages/chat-ui/src/blocks/approve-block.tsx +++ b/packages/chat-ui/src/blocks/approve-block.tsx @@ -12,7 +12,7 @@ // pre-round-trip framing: fixed disabled buttons, no fetch. import { Button, toast } from "@corbits/react-ui"; -import type { ApproveBlockData } from "@corbits/chat/blocks"; +import type { ApproveBlockData } from "../wire/blocks"; import { useEffect, useState } from "react"; import { CHAT_STRINGS } from "../strings"; diff --git a/packages/chat-ui/src/blocks/block-responses.ts b/packages/chat-ui/src/blocks/block-responses.ts index 422ffe2c60..af579b7714 100644 --- a/packages/chat-ui/src/blocks/block-responses.ts +++ b/packages/chat-ui/src/blocks/block-responses.ts @@ -1,4 +1,4 @@ -// The in-chat poll/form cards' one seam to `@corbits/chat`'s response +// The in-chat poll/form cards' one seam to the hub's chat response // routes. `@corbits/chat-ui` owns no session, so it never fetches or // mutates responses itself -- the host supplies this port, mirroring how // `ApprovalActions` threads the approve card's round-trip through diff --git a/packages/chat-ui/src/blocks/connect-github-block-container.tsx b/packages/chat-ui/src/blocks/connect-github-block-container.tsx index 4c47172168..1c44c583a4 100644 --- a/packages/chat-ui/src/blocks/connect-github-block-container.tsx +++ b/packages/chat-ui/src/blocks/connect-github-block-container.tsx @@ -21,7 +21,7 @@ // known-good connection. An explicit `disconnected` result clears the // snapshot so a real disconnect still shows Connect. import { useCallback, useEffect, useRef, useState } from "react"; -import type { ConnectGithubBlockData } from "@corbits/chat/blocks"; +import type { ConnectGithubBlockData } from "../wire/blocks"; import { reportError } from "@corbits/error-sink"; import { CHAT_STRINGS } from "../strings"; diff --git a/packages/chat-ui/src/blocks/connect-service-block-container.tsx b/packages/chat-ui/src/blocks/connect-service-block-container.tsx index 52977caa5a..c371699450 100644 --- a/packages/chat-ui/src/blocks/connect-service-block-container.tsx +++ b/packages/chat-ui/src/blocks/connect-service-block-container.tsx @@ -8,7 +8,7 @@ // connect that goes nowhere, matching the "no port, no feature" // fallback every other block uses. import { useEffect, useState } from "react"; -import type { ConnectServiceBlockData } from "@corbits/chat/blocks"; +import type { ConnectServiceBlockData } from "../wire/blocks"; import type { ConnectServiceActions, diff --git a/packages/chat-ui/src/blocks/form-block.tsx b/packages/chat-ui/src/blocks/form-block.tsx index aa88433c7b..8fab486ac8 100644 --- a/packages/chat-ui/src/blocks/form-block.tsx +++ b/packages/chat-ui/src/blocks/form-block.tsx @@ -9,7 +9,7 @@ import { Button } from "@corbits/react-ui"; import { useEffect, useState } from "react"; import type { FormEvent } from "react"; -import type { FormBlockData } from "@corbits/chat/blocks"; +import type { FormBlockData } from "../wire/blocks"; import { CHAT_STRINGS } from "../strings"; import { BlockCard } from "./block-card"; diff --git a/packages/chat-ui/src/blocks/metrics-block.tsx b/packages/chat-ui/src/blocks/metrics-block.tsx index 67b1f46911..0005bbf613 100644 --- a/packages/chat-ui/src/blocks/metrics-block.tsx +++ b/packages/chat-ui/src/blocks/metrics-block.tsx @@ -1,4 +1,4 @@ -import type { MetricsBlockData } from "@corbits/chat/blocks"; +import type { MetricsBlockData } from "../wire/blocks"; import { BlockCard } from "./block-card"; diff --git a/packages/chat-ui/src/blocks/poll-block.tsx b/packages/chat-ui/src/blocks/poll-block.tsx index c37e6a8a22..efd10dd468 100644 --- a/packages/chat-ui/src/blocks/poll-block.tsx +++ b/packages/chat-ui/src/blocks/poll-block.tsx @@ -5,7 +5,7 @@ // nothing here is ever trusted twice. import { useEffect, useState } from "react"; -import type { PollBlockData } from "@corbits/chat/blocks"; +import type { PollBlockData } from "../wire/blocks"; import { CHAT_STRINGS } from "../strings"; import { BlockCard } from "./block-card"; diff --git a/packages/chat-ui/src/blocks/question-block.tsx b/packages/chat-ui/src/blocks/question-block.tsx index c39131b46c..fb21f59e8c 100644 --- a/packages/chat-ui/src/blocks/question-block.tsx +++ b/packages/chat-ui/src/blocks/question-block.tsx @@ -12,7 +12,7 @@ // collapsing as if the agent had already been reached. import { useEffect, useRef, useState, type FormEvent } from "react"; -import type { QuestionBlockData } from "@corbits/chat/blocks"; +import type { QuestionBlockData } from "../wire/blocks"; import { Check } from "@corbits/icons"; import { CHAT_STRINGS } from "../strings"; diff --git a/packages/chat-ui/src/blocks/registry.tsx b/packages/chat-ui/src/blocks/registry.tsx index 835826eed0..6045481684 100644 --- a/packages/chat-ui/src/blocks/registry.tsx +++ b/packages/chat-ui/src/blocks/registry.tsx @@ -3,9 +3,9 @@ // and routes each known type to its view; anything unknown or malformed // renders a labeled fallback card instead of raw JSON or a crash. -import type { Block } from "@corbits/chat/blocks"; -import { parseBlock } from "@corbits/chat/blocks"; -import type { BlockPart } from "@corbits/chat/parts"; +import type { Block } from "../wire/blocks"; +import { parseBlock } from "../wire/blocks"; +import type { BlockPart } from "../wire/parts"; import type { ReactElement } from "react"; import { CHAT_STRINGS } from "../strings"; diff --git a/packages/chat-ui/src/blocks/steps-block.tsx b/packages/chat-ui/src/blocks/steps-block.tsx index b14d874109..fcdadc898e 100644 --- a/packages/chat-ui/src/blocks/steps-block.tsx +++ b/packages/chat-ui/src/blocks/steps-block.tsx @@ -1,4 +1,4 @@ -import type { StepsBlockData } from "@corbits/chat/blocks"; +import type { StepsBlockData } from "../wire/blocks"; import { BlockCard } from "./block-card"; diff --git a/packages/chat-ui/src/blocks/stream-block.tsx b/packages/chat-ui/src/blocks/stream-block.tsx index 0c8bc53329..7ea38f908d 100644 --- a/packages/chat-ui/src/blocks/stream-block.tsx +++ b/packages/chat-ui/src/blocks/stream-block.tsx @@ -1,4 +1,4 @@ -import type { StreamBlockData } from "@corbits/chat/blocks"; +import type { StreamBlockData } from "../wire/blocks"; import { BlockCard } from "./block-card"; diff --git a/packages/chat-ui/src/chat-workspace.tsx b/packages/chat-ui/src/chat-workspace.tsx index fbe4ec0882..914c0556d5 100644 --- a/packages/chat-ui/src/chat-workspace.tsx +++ b/packages/chat-ui/src/chat-workspace.tsx @@ -9,9 +9,9 @@ // `@workbench/web` that is the same `/api/me/principals` call the Home // and Settings pages use), so `ChatWorkspace` takes a small // `TenantResolution` value rather than importing app code: the same -// narrow-port shape `@corbits/chat`'s `routes.ts` uses for `ChatPlatform`. +// narrow-port shape the hub's chat routes uses for `ChatPlatform`. -import { isAgentAddress } from "@corbits/chat/mentions"; +import { isAgentAddress } from "./wire/mentions"; import { Button, EmptyState, toast } from "@corbits/react-ui"; import { reportError } from "@corbits/error-sink"; import { getResolvedCatalog } from "@corbits/inference-settings"; @@ -124,7 +124,7 @@ import { ChatPinEventData, ChatReactionEventData, ChatSettingsEventData, -} from "@corbits/chat/stream-events"; +} from "./wire/stream-events"; import { useThreadNavigation } from "./use-thread-navigation"; import { mergePendingSends, useOptimisticSends } from "./use-optimistic-sends"; export { @@ -459,7 +459,7 @@ const REPLY_TIMED_OUT_ITEM_ID = "reply_timed_out_notice"; * (`PENDING_REPLY_CLEAR_MS`) has fired — a turn that opened but never got a * single token and never closed out either, so the reader was left staring * at a typing indicator that just vanished with no explanation. This is - * the same class of failure `postUndeliveredNotice` (`@corbits/chat`) + * the same class of failure `postUndeliveredNotice` (the hub's chat routes) * already gives an honest, actionable backstop to when the dispatch fails * loud enough for the server to see it — a cold-waking agent that never * streams a token back fails silently instead, so before CL-6677 this diff --git a/packages/chat-ui/src/inference-failure.ts b/packages/chat-ui/src/inference-failure.ts index 65906aab6f..7948604441 100644 --- a/packages/chat-ui/src/inference-failure.ts +++ b/packages/chat-ui/src/inference-failure.ts @@ -32,7 +32,7 @@ export const CLASSIFIED_INFERENCE_FAILURE_PREAMBLES: readonly string[] = [ export { CONSUMER_INFERENCE_FAILURE_NOTICE, consumerFacingInferenceText, -} from "@corbits/chat/consumer-inference-text"; +} from "./wire/consumer-inference-text"; export function isClassifiedInferenceFailureText(text: string): boolean { return CLASSIFIED_INFERENCE_FAILURE_PREAMBLES.some((preamble) => diff --git a/packages/chat-ui/src/invite-agent-dialog.tsx b/packages/chat-ui/src/invite-agent-dialog.tsx index 66bc7120b6..f718152b62 100644 --- a/packages/chat-ui/src/invite-agent-dialog.tsx +++ b/packages/chat-ui/src/invite-agent-dialog.tsx @@ -21,7 +21,7 @@ import { import { Users, WarningCircle } from "@corbits/icons"; import { useEffect, useState } from "react"; -import { deriveDisplayName } from "@corbits/chat/display-name"; +import { deriveDisplayName } from "./wire/display-name"; import { ChatApiError, describeChatError, @@ -158,7 +158,7 @@ export function InviteAgentDialog({ // set at creation, else a humanized reading of the // immutable slug — never a scattered reimplementation // that could drift (or leak an internal id as a fake - // name) beside `@corbits/chat/display-name`. + // name) beside `@corbits/chat-ui/wire/display-name`. const displayName = deriveDisplayName({ name: definition.name, ...(definition.description !== undefined diff --git a/packages/chat-ui/src/mentions.ts b/packages/chat-ui/src/mentions.ts index 3afb3bfbfa..bcd69043d8 100644 --- a/packages/chat-ui/src/mentions.ts +++ b/packages/chat-ui/src/mentions.ts @@ -9,13 +9,13 @@ // `packages/chat/src/participants.ts`), the same friendly, deduplicated // mention name `mentionedParticipants` in `packages/chat/src/routes.ts` // matches against — never the instance-id local part. Filtering to agent -// addresses delegates to `@corbits/chat`'s `isAgentAddress`, the same +// addresses delegates to this package's own `isAgentAddress` (`./wire/mentions`), the same // function the server's fan-out uses, so the candidate set is always // exactly the set the server will fan a copy to. `label` is a friendlier // string shown alongside the handle in the popover only. -import { isAgentAddress } from "@corbits/chat/mentions"; -import { handleFromName } from "@corbits/chat/participants"; +import { isAgentAddress } from "./wire/mentions"; +import { handleFromName } from "./wire/participants"; import type { ParticipantRecord } from "./api"; import { displayNameForAddress, diff --git a/packages/chat-ui/src/profile-subject.ts b/packages/chat-ui/src/profile-subject.ts index 9f65c74c98..2dc0b46b7d 100644 --- a/packages/chat-ui/src/profile-subject.ts +++ b/packages/chat-ui/src/profile-subject.ts @@ -1,7 +1,7 @@ // Identity payload for opening a ProfileCard in the shell canvas. Chat-ui // never mounts the card itself — the host canvas column owns that surface. -import { isAgentAddress } from "@corbits/chat/mentions"; +import { isAgentAddress } from "./wire/mentions"; import type { ParticipantRecord } from "./api"; diff --git a/packages/chat-ui/src/shared-workbenches.ts b/packages/chat-ui/src/shared-workbenches.ts index fb552d0846..b6f20e0642 100644 --- a/packages/chat-ui/src/shared-workbenches.ts +++ b/packages/chat-ui/src/shared-workbenches.ts @@ -4,7 +4,7 @@ // (`workbenchOrder.filter(members.some(subject))`, capped at 4) over the real // `Workbench.participants` feed instead of the mock's in-memory fixture. -import { localPartOf } from "@corbits/chat/agent-address"; +import { localPartOf } from "./wire/agent-address"; import type { Workbench } from "./api"; diff --git a/packages/chat-ui/src/streaming-reply.ts b/packages/chat-ui/src/streaming-reply.ts index a32d6b819d..029fad08ab 100644 --- a/packages/chat-ui/src/streaming-reply.ts +++ b/packages/chat-ui/src/streaming-reply.ts @@ -13,7 +13,7 @@ import { useEffect, useRef, useState } from "react"; -import { isAgentAddress, mentionedParticipants } from "@corbits/chat/mentions"; +import { isAgentAddress, mentionedParticipants } from "./wire/mentions"; import { reportError } from "@corbits/error-sink"; import type { Part, ParticipantRecord } from "./api"; import { REAL_CLOCK, type Clock } from "./clock"; @@ -314,7 +314,7 @@ export function useStreamingReply( /** Set once the backstop above has fired for the turn just cleared — a * `reportError` refId (CL-6677) the host's honest notice quotes, the * same "ref id + Retry" treatment `postUndeliveredNotice` - * (`@corbits/chat`) gives a dispatch failure that surfaces server-side. + * (the hub's chat routes) gives a dispatch failure that surfaces server-side. * This is the same class of failure with no server signal at all (a * cold-waking agent that never streams back a token), so it deserves * the same backstop rather than the ref-less, action-less notice this diff --git a/packages/chat-ui/src/timeline.tsx b/packages/chat-ui/src/timeline.tsx index 774c268395..22e9d20619 100644 --- a/packages/chat-ui/src/timeline.tsx +++ b/packages/chat-ui/src/timeline.tsx @@ -9,7 +9,7 @@ // an agent address), and anything else falls back to a deterministic // "Member" label with an initials avatar — never the address. -import { isAgentAddress } from "@corbits/chat/mentions"; +import { isAgentAddress } from "./wire/mentions"; import { ContextMenuView, contextMenuItem, @@ -782,7 +782,7 @@ function FailedTurnStrip({ * whose turn failed, never its raw handle slug. */ readonly agentDisplayNames?: AgentDisplayNames; /** The undelivered-turn notice's own text (`postUndeliveredNotice`, - * `@corbits/chat`) — already cause-aware server-side (a missing model + * the hub's chat routes) — already cause-aware server-side (a missing model * credential reads "add or check your model key," a generic dispatch * failure reads "send it again"). Shown as this strip's own detail * rather than the fixed `turnFailedSub` string, so the person reads @@ -2142,7 +2142,7 @@ export function WorkbenchTimeline({ * approve card in its pre-round-trip fixed-disabled framing. */ readonly approvalActions?: ApprovalActions; /** The poll/form blocks' live round-trip — the host's read/vote/submit - * against `@corbits/chat`'s response routes. Undefined renders every + * against the hub's chat response routes. Undefined renders every * poll/form card in its pre-round-trip fixed-disabled framing. */ readonly blockResponses?: BlockResponseActions; /** The connect-github block's live round-trip — see diff --git a/packages/chat-ui/src/tool-activity.test.ts b/packages/chat-ui/src/tool-activity.test.ts index f499177d26..91d82ff754 100644 --- a/packages/chat-ui/src/tool-activity.test.ts +++ b/packages/chat-ui/src/tool-activity.test.ts @@ -2,7 +2,7 @@ // a reader as an identifier or as JSON. Every assertion below is either // "this reads like a sentence" or "this string never appears". import { describe, expect, test } from "bun:test"; -import type { Part, ToolTracePart } from "@corbits/chat/parts"; +import type { Part, ToolTracePart } from "./wire/parts"; import { describeToolCall, diff --git a/packages/chat-ui/src/tool-activity.ts b/packages/chat-ui/src/tool-activity.ts index 037d66300d..6b686194e9 100644 --- a/packages/chat-ui/src/tool-activity.ts +++ b/packages/chat-ui/src/tool-activity.ts @@ -1,7 +1,7 @@ // Turning a turn's tool calls into something a person can read. // // The wire carries a tool call as an identifier plus an argument bag plus -// whatever the tool handed back (`ToolTracePart` in `@corbits/chat/parts`, +// whatever the tool handed back (`ToolTracePart` in `@corbits/chat-ui/wire/parts`, // assembled by the chat orchestrator from `inferenceDoneBlocks` / // `toolDoneResult`). None of those three is fit to show anyone: the // identifier is a symbol (`slack__post_message`, or an Interchange qualified @@ -20,7 +20,7 @@ // "pricing"" while it runs and "Searched the web for "pricing"" once it // settles. -import type { Part, ToolTracePart } from "@corbits/chat/parts"; +import type { Part, ToolTracePart } from "./wire/parts"; export type ToolActivityStatus = "pending" | "running" | "success" | "failed"; diff --git a/packages/chat-ui/src/wire/agent-address.ts b/packages/chat-ui/src/wire/agent-address.ts new file mode 100644 index 0000000000..17398d0b6c --- /dev/null +++ b/packages/chat-ui/src/wire/agent-address.ts @@ -0,0 +1,25 @@ +// Mirrored from packages/chat/src (CL-8148 T5b): apps/web and @corbits/chat-ui +// must not import @corbits/chat, a server-only package. This is the browser- +// facing half of the same wire contract the hub's chat routes still speak; +// once the hub moves onto native mail threads (T5a/T5c) this file becomes +// the one source of truth and packages/chat's copy goes away. + +// Agent-address helpers shared by this package's own fan-out +// (`routes.ts`) and by `@corbits/chat-ui`'s composer: splitting an +// address into its local part and domain. Mirrors the split +// `@intx/types/src/agent-address.ts`'s `parseAgentAddress` does, +// but stays permissive about the left-hand side on purpose — the +// mention rule (see `mentions.ts`) only ever needs the substring before +// the first "@", never a validated `ins_`-prefixed instance id, so it +// tolerates any participant string rather than rejecting non-address +// ones the way `parseAgentAddress` does. + +export function localPartOf(address: string): string { + const at = address.indexOf("@"); + return at === -1 ? address : address.slice(0, at); +} + +export function domainOf(address: string): string | undefined { + const at = address.indexOf("@"); + return at === -1 ? undefined : address.slice(at + 1); +} diff --git a/packages/chat-ui/src/wire/blocks.ts b/packages/chat-ui/src/wire/blocks.ts new file mode 100644 index 0000000000..327c15eef8 --- /dev/null +++ b/packages/chat-ui/src/wire/blocks.ts @@ -0,0 +1,294 @@ +// Mirrored from packages/chat/src (CL-8148 T5b): apps/web and @corbits/chat-ui +// must not import @corbits/chat, a server-only package. This is the browser- +// facing half of the same wire contract the hub's chat routes still speak; +// once the hub moves onto native mail threads (T5a/T5c) this file becomes +// the one source of truth and packages/chat's copy goes away. + +import { type } from "arktype"; + +import type { BlockPart } from "./parts"; + +// The typed vocabulary for `BlockPart.block` payloads. `BlockPart` stays +// `{ type: string, data: unknown }` on the wire so unknown types degrade to +// a labeled fallback instead of failing the whole message; `parseBlock` is +// the render-boundary parse that turns that envelope into a typed block. +// Every schema strips undeclared keys deeply: agent-authored extras (say, a +// hostile `tally` object on a poll) must never ride along on the parsed +// object a renderer trusts. + +// An approve block carries only a reference to a platform approval plus the +// agent's framing. It deliberately has no action labels and no resolved +// state: free-form button text on an approval card is a spoofing surface, +// and the decision's status lives on the approval record, never in the +// message. +export const ApproveBlockData = type({ + approvalId: "string", + title: "string", + "risk?": "'low' | 'medium' | 'high'", + "riskNote?": "string", + "body?": "string", +}).onDeepUndeclaredKey("delete"); +export type ApproveBlockData = typeof ApproveBlockData.infer; + +export const StepsBlockData = type({ + title: "string", + steps: type({ + label: "string", + state: "'queued' | 'running' | 'done' | 'error'", + "note?": "string", + }).array(), +}).onDeepUndeclaredKey("delete"); +export type StepsBlockData = typeof StepsBlockData.infer; + +export const MetricsBlockData = type({ + title: "string", + metrics: type({ + label: "string", + value: "string", + "detail?": "string", + // Coloring a delta is a semantic, not a string: without a declared + // trend the detail renders neutral, so "+40% error rate" can't paint + // itself success-green. + "trend?": "'up' | 'down'", + }).array(), + "bars?": type({ + label: "string", + percent: "0 <= number <= 100", + }).array(), +}).onDeepUndeclaredKey("delete"); +export type MetricsBlockData = typeof MetricsBlockData.infer; + +// Choices carry no percentages or counts: tallies come from stored +// responses, not agent-authored numbers. +export const PollBlockData = type({ + pollId: "string", + title: "string", + choices: type({ + id: "string", + label: "string", + }).array(), + "multi?": "boolean", + "closesAt?": "string.date.iso", +}).onDeepUndeclaredKey("delete"); +export type PollBlockData = typeof PollBlockData.infer; + +// A select field must bring its options; the other inputs have none. The +// discriminated union makes that a parse-time guarantee and lets renderers +// narrow on `input` without defaulting a missing list. +const SelectFormField = type({ + id: "string", + label: "string", + input: "'select'", + options: "string[] > 0", + "value?": "string", + "required?": "boolean", +}); + +const PlainFormField = type({ + id: "string", + label: "string", + input: "'text' | 'textarea' | 'checkbox'", + "value?": "string", + "required?": "boolean", +}); + +export const FormBlockData = type({ + formId: "string", + title: "string", + fields: SelectFormField.or(PlainFormField).array(), + "submitLabel?": "string", +}).onDeepUndeclaredKey("delete"); +export type FormBlockData = typeof FormBlockData.infer; + +export const StreamBlockData = type({ + title: "string", + text: "string", + done: "boolean", +}).onDeepUndeclaredKey("delete"); +export type StreamBlockData = typeof StreamBlockData.infer; + +// An interview question with lettered options, posed by an agent +// (`ask_user`, `@corbits/interaction-tools`) and answered in-thread. Like +// `PollBlockData`, it carries no resolved answer — the chosen option (or +// free-text) lives in the block-response row, read back through the same +// `/blocks/:blockId/responses` surface polls use, never in the message. +export const QuestionBlockData = type({ + questionId: "string", + question: "string", + "subtitle?": "string", + options: "2 <= string[] <= 6", + "allowFreeText?": "boolean", +}).onDeepUndeclaredKey("delete"); +export type QuestionBlockData = typeof QuestionBlockData.infer; + +// A template room's inline GitHub connect card (CL-6345). Like +// `ApproveBlockData`, this carries only the agent/server-authored +// framing that decided the card exists at all — which connector the +// template still needs, and (once connected) the display-only org +// login. It never carries the live repo list, the person's selection, +// or a connected/disconnected verdict beyond what the room's own +// settings say: an agent authoring this block could otherwise spoof +// "already connected" or plant a fake repo list next to live buttons, +// exactly the confused-deputy failure mode this file's other blocks +// guard against. The card's actual live state — repos, selection, and +// the connected verdict itself — comes from a host-supplied actions +// port at render time, resolved against the room's real connection and +// settings, never from this data. +// One labelled step of a room's onboarding walkthrough: what the person +// does, and why it matters to them. The definition that owns the +// walkthrough owns the copy — the card renders its step rail from these +// labels rather than holding step text of its own. +export const OnboardingStepLabel = type({ + title: "string > 0", + why: "string > 0", +}).onDeepUndeclaredKey("delete"); +export type OnboardingStepLabel = typeof OnboardingStepLabel.infer; + +// The body `POST /workbenches/:id/onboarding` parses: one declared +// onboarding step a host may post into a room, never an arbitrary block. +// A `.or(...)` here is the extension point when a second kind of step +// earns one. +export const WorkbenchOnboardingStep = type({ + kind: "'connect-github'", + requiredForTemplate: "string > 0", + promise: "string > 0", + steps: OnboardingStepLabel.array(), +}).onDeepUndeclaredKey("delete"); +export type WorkbenchOnboardingStep = typeof WorkbenchOnboardingStep.infer; + +// `promise` and `steps` are optional so cards persisted before the +// onboarding route existed still parse; the route always writes both. +const ConnectGithubDisconnectedData = type({ + requiredForTemplate: "string > 0", + state: "'disconnected'", + "promise?": "string > 0", + "steps?": OnboardingStepLabel.array(), +}).onDeepUndeclaredKey("delete"); + +const ConnectGithubConnectedData = type({ + requiredForTemplate: "string > 0", + state: "'connected'", + orgName: "string", + "promise?": "string > 0", + "steps?": OnboardingStepLabel.array(), +}).onDeepUndeclaredKey("delete"); + +export const ConnectGithubBlockData = ConnectGithubDisconnectedData.or( + ConnectGithubConnectedData, +); +export type ConnectGithubBlockData = typeof ConnectGithubBlockData.infer; + +// An agent-authored "connect this service" card (CL-6393), the +// generalization of `connect-github` to every connector and MCP preset: +// `request_connection` posts one of these instead of a prose deep link. +// It carries only the framing the agent decided — which service, and the +// consumer-language reason it would unlock — never an auth mode or a +// connected/disconnected verdict: an agent that could author "connected" +// (or steer OAuth vs key-paste) would be spoofing live state next to a +// live button. The card's real state and connect affordance come from a +// host-supplied actions port resolved against the tenant's actual +// connections at render time. +export const ConnectServiceBlockData = type({ + connectorId: "string > 0", + displayName: "string > 0", + reason: "string > 0", +}).onDeepUndeclaredKey("delete"); +export type ConnectServiceBlockData = typeof ConnectServiceBlockData.infer; + +export type Block = + | { readonly type: "approve"; readonly data: ApproveBlockData } + | { readonly type: "steps"; readonly data: StepsBlockData } + | { readonly type: "metrics"; readonly data: MetricsBlockData } + | { readonly type: "poll"; readonly data: PollBlockData } + | { readonly type: "form"; readonly data: FormBlockData } + | { readonly type: "stream"; readonly data: StreamBlockData } + | { readonly type: "question"; readonly data: QuestionBlockData } + | { readonly type: "connect-github"; readonly data: ConnectGithubBlockData } + | { + readonly type: "connect-service"; + readonly data: ConnectServiceBlockData; + }; + +export type BlockParseResult = + | { readonly ok: true; readonly block: Block } + | { readonly ok: false; readonly type: string; readonly summary: string }; + +/** + * Parse a `BlockPart` envelope into a typed block at the render boundary. + * An unknown type or invalid data yields an `ok: false` result for the + * caller's fallback card — never a throw, so one malformed block can't take + * down a timeline. + */ +export function parseBlock(envelope: BlockPart["block"]): BlockParseResult { + switch (envelope.type) { + case "approve": { + const data = ApproveBlockData(envelope.data); + if (data instanceof type.errors) { + return { ok: false, type: envelope.type, summary: data.summary }; + } + return { ok: true, block: { type: "approve", data } }; + } + case "steps": { + const data = StepsBlockData(envelope.data); + if (data instanceof type.errors) { + return { ok: false, type: envelope.type, summary: data.summary }; + } + return { ok: true, block: { type: "steps", data } }; + } + case "metrics": { + const data = MetricsBlockData(envelope.data); + if (data instanceof type.errors) { + return { ok: false, type: envelope.type, summary: data.summary }; + } + return { ok: true, block: { type: "metrics", data } }; + } + case "poll": { + const data = PollBlockData(envelope.data); + if (data instanceof type.errors) { + return { ok: false, type: envelope.type, summary: data.summary }; + } + return { ok: true, block: { type: "poll", data } }; + } + case "form": { + const data = FormBlockData(envelope.data); + if (data instanceof type.errors) { + return { ok: false, type: envelope.type, summary: data.summary }; + } + return { ok: true, block: { type: "form", data } }; + } + case "stream": { + const data = StreamBlockData(envelope.data); + if (data instanceof type.errors) { + return { ok: false, type: envelope.type, summary: data.summary }; + } + return { ok: true, block: { type: "stream", data } }; + } + case "question": { + const data = QuestionBlockData(envelope.data); + if (data instanceof type.errors) { + return { ok: false, type: envelope.type, summary: data.summary }; + } + return { ok: true, block: { type: "question", data } }; + } + case "connect-github": { + const data = ConnectGithubBlockData(envelope.data); + if (data instanceof type.errors) { + return { ok: false, type: envelope.type, summary: data.summary }; + } + return { ok: true, block: { type: "connect-github", data } }; + } + case "connect-service": { + const data = ConnectServiceBlockData(envelope.data); + if (data instanceof type.errors) { + return { ok: false, type: envelope.type, summary: data.summary }; + } + return { ok: true, block: { type: "connect-service", data } }; + } + default: + return { + ok: false, + type: envelope.type, + summary: `unknown block type "${envelope.type}"`, + }; + } +} diff --git a/packages/chat-ui/src/wire/consumer-inference-text.ts b/packages/chat-ui/src/wire/consumer-inference-text.ts new file mode 100644 index 0000000000..e7b707e2b8 --- /dev/null +++ b/packages/chat-ui/src/wire/consumer-inference-text.ts @@ -0,0 +1,80 @@ +// Mirrored from packages/chat/src (CL-8148 T5b): apps/web and @corbits/chat-ui +// must not import @corbits/chat, a server-only package. This is the browser- +// facing half of the same wire contract the hub's chat routes still speak; +// once the hub moves onto native mail threads (T5a/T5c) this file becomes +// the one source of truth and packages/chat's copy goes away. + +/** + * Person-facing inference copy. HTTP status, raw provider dumps, and + * JSON error objects never belong on the timeline, in a sidebar preview, + * or next to the composer — DESIGN.md Honesty is one consumer sentence. + */ + +import { + TOOLS_UNSUPPORTED_CONSUMER_MESSAGE, + isToolsUnsupportedInferenceText, +} from "./tools-unsupported"; + +const HTTP_STATUS_MARK = /\[HTTP\s+\d+\]/i; +const TRAILING_HTTP_DUMP = /\s*\[HTTP\s+\d+\]:[\s\S]*$/i; + +export const CONSUMER_INFERENCE_FAILURE_NOTICE = + "This didn't go through. Try again, or check the connection in Settings."; + +/** + * Byte-for-byte copies of the two preambles `@intx/inference`'s + * `formatInferenceError` writes for `credential_failure` and + * `quota_exhausted`. Kept here so the bench-list preview path (CL-6735) + * can refuse them without depending on `@corbits/chat-ui`. The chat-ui + * drift guard still owns matching these against the published director. + */ +export const CLASSIFIED_INFERENCE_FAILURE_PREAMBLES: readonly string[] = [ + "This agent could not complete your request due to a credential error", + "This agent could not complete your request because the API quota has been exhausted", +]; + +function isProviderJsonDump(raw: string): boolean { + const trimmed = raw.trim(); + if (!(trimmed.startsWith("{") || trimmed.startsWith("["))) return false; + return /"error"/i.test(trimmed); +} + +function needsSanitization(raw: string): boolean { + return HTTP_STATUS_MARK.test(raw) || isProviderJsonDump(raw); +} + +/** Drop HTTP status, raw provider text, and classified failure preambles. */ +export function consumerFacingInferenceText(raw: string): string { + if (isToolsUnsupportedInferenceText(raw)) { + return TOOLS_UNSUPPORTED_CONSUMER_MESSAGE; + } + if (!needsSanitization(raw) && !isClassifiedInferenceFailureText(raw)) { + return raw; + } + const stripped = raw.replace(TRAILING_HTTP_DUMP, "").trim(); + if ( + stripped.length > 0 && + !needsSanitization(stripped) && + !isClassifiedInferenceFailureText(stripped) + ) { + return stripped; + } + return CONSUMER_INFERENCE_FAILURE_NOTICE; +} + +/** True when `text` is (or starts with) a classified inference-failure preamble. */ +export function isClassifiedInferenceFailureText(text: string): boolean { + return CLASSIFIED_INFERENCE_FAILURE_PREAMBLES.some((preamble) => + text.startsWith(preamble), + ); +} + +/** + * Bench-list / sidebar preview copy (CL-6735): never the full failure + * paragraph, never HTTP/raw provider dumps — a short consumer sentence + * when the text is a classified failure. + */ +export function activityPreviewText(raw: string): string { + const facing = consumerFacingInferenceText(raw); + return facing; +} diff --git a/packages/chat-ui/src/wire/display-name.ts b/packages/chat-ui/src/wire/display-name.ts new file mode 100644 index 0000000000..08d5953395 --- /dev/null +++ b/packages/chat-ui/src/wire/display-name.ts @@ -0,0 +1,107 @@ +// Mirrored from packages/chat/src (CL-8148 T5b): apps/web and @corbits/chat-ui +// must not import @corbits/chat, a server-only package. This is the browser- +// facing half of the same wire contract the hub's chat routes still speak; +// once the hub moves onto native mail threads (T5a/T5c) this file becomes +// the one source of truth and packages/chat's copy goes away. + +// A workflow definition's person-facing display name, derived once here +// so every caller that decides "what does this agent look like to a +// person" reads it the same way — never a scattered `description ?? name` +// (or worse, a raw address/run id) reimplemented per call site. +// +// Lives in `@corbits/chat-ui/wire` (mirrored from `@corbits/chat`, which +// `@corbits/agent-directory` still depends on for its own copy of +// `workbench-host-naming`) so this browser-facing surface never needs a +// server-only chat import; `@corbits/agent-directory/client.ts` keeps its +// own re-export of `deriveDisplayName`/`humanizeSlug` for its callers. +import { type } from "arktype"; +import { ID_LEAK_PATTERN } from "./id-leak-guard"; + +const DisplayNameSource = type({ + name: "string", + "description?": "string | null", +}); + +/** kebab-case identifier -> Title Case words: `"research-analyst"` -> + * `"Research Analyst"`. Words that aren't hyphen-separated (a name that + * already reads as prose) pass through with only their case fixed up, so + * this is safe to run over a definition's raw `name` unconditionally — + * PROVIDED that `name` is never an internal id in disguise; see + * `deriveDisplayName`'s own guard for why the raw run-id case never + * reaches this function at all. */ +export function humanizeSlug(slug: string): string { + return slug + .split(/[-_\s]+/) + .filter((word) => word.length > 0) + .map((word) => word.slice(0, 1).toUpperCase() + word.slice(1)) + .join(" "); +} + +/** + * The display name a definition should render as: its own description + * when one was set at creation, otherwise a humanized reading of its + * immutable slug. A whitespace-only description reads as absent — never + * a blank display name — since it carries nothing a person actually + * typed. Throws on a shape that isn't at least `{ name }` — this is a + * trust boundary, not a formatting helper, so a malformed record fails + * loudly rather than rendering "undefined". + * + * Also throws when `name` is itself an internal-id shape (`run_…`, + * `wfd_…`, …, see `./id-leak-guard`) — the product rule is that a person + * never sees an internal identifier, so a caller that reaches this + * function with a run id where a definition's slug belongs gets a loud + * failure instead of a Title-Cased leak like "Run 737a058d…" (CL-6471). + */ +export function deriveDisplayName(definition: { + readonly name: string; + readonly description?: string | null; +}): string { + const parsed = DisplayNameSource(definition); + if (parsed instanceof type.errors) { + throw new Error( + `deriveDisplayName: invalid agent record: ${parsed.summary}`, + ); + } + const description = parsed.description?.trim(); + if (description !== undefined && description !== "") { + if (ID_LEAK_PATTERN.test(description)) { + throw new Error( + `deriveDisplayName: description "${description}" for agent record ` + + `"${parsed.name}" carries an internal identifier; refusing to ` + + "render it as a display name", + ); + } + return description; + } + if (ID_LEAK_PATTERN.test(parsed.name)) { + throw new Error( + `deriveDisplayName: agent record name "${parsed.name}" is an internal ` + + "identifier, not a slug; refusing to humanize it into a fake " + + "display name", + ); + } + return humanizeSlug(parsed.name); +} + +export type UserFacingAgentDefinition = { + readonly id: string; + readonly name: string; + readonly description?: string | null; +}; + +export type WithDisplayName = T & { readonly displayName: string }; + +/** Projects `deriveDisplayName` onto a definition, keeping every other + * field untouched — the read-boundary derivation done once here rather + * than as scattered `??` fallbacks in UI code. */ +export function withDisplayName( + definition: T, +): WithDisplayName { + return { ...definition, displayName: deriveDisplayName(definition) }; +} + +export function withDisplayNames( + definitions: readonly T[], +): readonly WithDisplayName[] { + return definitions.map(withDisplayName); +} diff --git a/packages/chat-ui/src/wire/id-leak-guard.ts b/packages/chat-ui/src/wire/id-leak-guard.ts new file mode 100644 index 0000000000..547d040b54 --- /dev/null +++ b/packages/chat-ui/src/wire/id-leak-guard.ts @@ -0,0 +1,50 @@ +// Mirrored from packages/chat/src (CL-8148 T5b): apps/web and @corbits/chat-ui +// must not import @corbits/chat, a server-only package. This is the browser- +// facing half of the same wire contract the hub's chat routes still speak; +// once the hub moves onto native mail threads (T5a/T5c) this file becomes +// the one source of truth and packages/chat's copy goes away. + +// The systemic guard CL-6471 calls for: a person never sees an internal +// identifier — not as a participant's name, not in a join/system line, +// not in a greeting, not in a chat title. This has recurred repeatedly +// (a raw definitionId/runId in insights, a `writing_systems` id in a +// Myra reply, and CL-6471's own "Run 737a058d…" / "I'm run_737a…") each +// time as a one-off display-time patch; this module is the one place +// every id-generating prefix is named, so a new leak is a missed test +// run rather than a missed grep. +// +// Prefix words mirror `@intx/hub-common`'s `generateId` (vendored; +// upstream source: `packages/hub-common/src/ids.ts`'s `PREFIXES`) for +// the six kinds CL-6471 names explicitly — a workflow run, a workflow +// definition, a tenant, a principal, an asset, and a git token — each +// normally followed by `_` and 32 lowercase hex characters (`run_737a…`). +// +// Matched with either `_`, a space, or `-` as the separator, not just +// `_`: `humanizeSlug`'s Title Case reading of a leaked id (CL-6471's own +// "Run 737a058d48006e2bde12559576f422e0") replaces the underscore with a +// space, so the raw and the humanized-leak forms both need to trip this. +const ID_PREFIX_WORDS = ["run", "wfd", "tnt", "prn", "ast", "gtk"] as const; + +export const ID_LEAK_PATTERN = new RegExp( + `\\b(?:${ID_PREFIX_WORDS.join("|")})[_\\s-][0-9a-f]{16,}\\b`, + "i", +); + +/** + * Throws when `value` carries an internal identifier — a raw id, or a + * humanized reading of one (`"Run 737a058d…"`, produced by title-casing + * `run_737a058d…`'s underscore split). The humanized case is why this + * checks the ORIGINAL prefixes case-insensitively rather than requiring + * the literal lowercase prefix: `humanizeSlug` capitalizes the first + * letter of the leading word, so a leaked run id renders as "Run …", not + * "run …", by the time it would reach a person. + */ +export function assertNoLeakedInternalId(value: string, context: string): void { + if (ID_LEAK_PATTERN.test(value)) { + throw new Error( + `${context} carries an internal identifier ("${value}"); a person ` + + "must never see one — resolve the real name at the source instead " + + "of rendering the id (CL-6471)", + ); + } +} diff --git a/packages/chat-ui/src/wire/mentions.ts b/packages/chat-ui/src/wire/mentions.ts new file mode 100644 index 0000000000..9babe28636 --- /dev/null +++ b/packages/chat-ui/src/wire/mentions.ts @@ -0,0 +1,62 @@ +// Mirrored from packages/chat/src (CL-8148 T5b): apps/web and @corbits/chat-ui +// must not import @corbits/chat, a server-only package. This is the browser- +// facing half of the same wire contract the hub's chat routes still speak; +// once the hub moves onto native mail threads (T5a/T5c) this file becomes +// the one source of truth and packages/chat's copy goes away. + +// The "@ + handle" mention rule: the single wire-level contract between +// the composer (`@corbits/chat-ui`'s `mentions.ts`, which derives +// candidates from a workbench's participant records and splices the +// picked handle into the draft) and this package's own fan-out +// (`routes.ts`'s `POST /workbenches/:id/messages`). Both sides work off +// the same `ParticipantRecord[]` (see `participants.ts`), so a message +// the composer thinks mentions a participant is always exactly the set +// the server fans a copy to — the handle is the friendly, settings-held +// mention name, never the instance-id local part. + +import type { Part as PartType } from "./parts"; +import type { ParticipantRecord } from "./participants"; + +/** + * A participant is an agent address (mention-fannable) when it has the + * `local@domain` shape every agent address has and is not a principal. + * Humans are `prn_…` — bare or `prn_…@tenant.domain` — and read the + * workbench's own timeline directly, so they are never fanned a copy. + */ +export function isAgentAddress(participant: string): boolean { + return participant.includes("@") && !participant.startsWith("prn_"); +} + +function escapeForRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * The participants an ordinary message @mentions, restricted to agent + * addresses: the fan-out set for `POST /workbenches/:id/messages`. A + * mention is structural — `@` followed by the participant's own handle + * at a word boundary, appearing in any `TextPart` of the message — not + * a full parse of mention syntax; kept minimal per the anchor-mailbox + * rework's scope. + */ +export function mentionedParticipants( + parts: readonly PartType[], + participants: readonly ParticipantRecord[], +): string[] { + const texts = parts + .filter( + (part): part is Extract => + part.kind === "text", + ) + .map((part) => part.text); + if (texts.length === 0) return []; + return participants + .filter((participant) => isAgentAddress(participant.address)) + .filter((participant) => { + const mentionPattern = new RegExp( + `@${escapeForRegExp(participant.handle)}\\b`, + ); + return texts.some((text) => mentionPattern.test(text)); + }) + .map((participant) => participant.address); +} diff --git a/packages/chat-ui/src/wire/participants.ts b/packages/chat-ui/src/wire/participants.ts new file mode 100644 index 0000000000..b8075c6643 --- /dev/null +++ b/packages/chat-ui/src/wire/participants.ts @@ -0,0 +1,124 @@ +// Mirrored from packages/chat/src (CL-8148 T5b): apps/web and @corbits/chat-ui +// must not import @corbits/chat, a server-only package. This is the browser- +// facing half of the same wire contract the hub's chat routes still speak; +// once the hub moves onto native mail threads (T5a/T5c) this file becomes +// the one source of truth and packages/chat's copy goes away. + +// Workbench participant records: the settings-backed source of mention +// handles. `chat/participants` (see `routes.ts`'s `ChatNamespaceSchemas`) +// holds `ParticipantRecord[]` — an address plus the short, +// unique-within-workbench handle a mention actually types (`@echo`), never +// the unusable instance-id local part (`@ins_cd03d8e3...`). Reading +// tolerates a bare address string too — a participant seeded before this +// record shape existed, or a caller PATCHing `chat/participants` with +// plain addresses — always upgrading it to a record on the way out (its +// handle defaults to the address's own local part); writing always +// produces records, never bare strings. + +import { type } from "arktype"; +import { localPartOf } from "./agent-address"; + +export interface ParticipantRecord { + readonly address: string; + readonly handle: string; +} + +const ParticipantRecordSchema = type({ + address: "string", + handle: "string", +}); + +/** The permissive shape a single `chat/participants` entry may carry on + * the wire: a bare address string, or an already-upgraded record. */ +export const ParticipantEntry = type("string").or(ParticipantRecordSchema); + +/** The full `chat/participants` setting value's permissive wire shape. */ +export const ParticipantsSetting = ParticipantEntry.array(); + +function parseParticipantEntry(entry: unknown): ParticipantRecord { + if (typeof entry === "string") { + return { address: entry, handle: localPartOf(entry) }; + } + const result = ParticipantRecordSchema(entry); + if (result instanceof type.errors) { + throw new Error(`invalid participant entry: ${result.summary}`); + } + return result; +} + +/** + * Parses a whole `chat/participants` setting value into records, + * tolerant of the mixed shape a workbench written before and after this + * rollout might carry: some entries bare strings, some already records. + * Anything other than an array is treated as no participants at all. + */ +export function parseParticipants(raw: unknown): ParticipantRecord[] { + if (!Array.isArray(raw)) return []; + return raw.map(parseParticipantEntry); +} + +/** + * A short, word-safe handle derived from a workflow definition's name — + * e.g. "Echo Bot" -> "echo-bot" — lowercased, non-alphanumeric runs + * collapsed to a single hyphen, and leading/trailing hyphens trimmed. + * Falls back to the given address's own local part when the name yields + * nothing usable (e.g. a name of all punctuation, or empty). + */ +export function handleFromName(name: string, fallbackAddress: string): string { + const slug = name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return slug.length > 0 ? slug : localPartOf(fallbackAddress); +} + +/** + * De-duplicates a candidate handle against the handles already in use in + * a workbench: "echo" becomes "echo-2", then "echo-3", etc. — the first + * suffix not already taken. + */ +export function dedupeHandle( + handle: string, + taken: ReadonlySet, +): string { + if (!taken.has(handle)) return handle; + let suffix = 2; + while (taken.has(`${handle}-${suffix}`)) suffix += 1; + return `${handle}-${suffix}`; +} + +/** + * Appends a new participant to an existing record list, de-duplicating + * the desired handle against every handle already in the workbench. + * Same-address retries return the existing list by identity so a caller + * can tell "already present" apart from "appended a row". + */ +export function addParticipant( + existing: readonly ParticipantRecord[], + address: string, + desiredHandle: string, +): ParticipantRecord[] { + if (existing.some((participant) => participant.address === address)) { + return existing as ParticipantRecord[]; + } + const taken = new Set(existing.map((participant) => participant.handle)); + const handle = dedupeHandle(desiredHandle, taken); + return [...existing, { address, handle }]; +} + +/** + * Drops a participant from an existing record list by address — the + * inverse of `addParticipant`. Returns the same array reference when + * the address names no participant, so a caller can tell "nothing + * changed" apart from "removed the last matching entry" by identity. + */ +export function removeParticipant( + existing: readonly ParticipantRecord[], + address: string, +): ParticipantRecord[] { + if (!existing.some((participant) => participant.address === address)) { + return existing as ParticipantRecord[]; + } + return existing.filter((participant) => participant.address !== address); +} diff --git a/packages/chat-ui/src/wire/parts.ts b/packages/chat-ui/src/wire/parts.ts new file mode 100644 index 0000000000..e8a6ccda16 --- /dev/null +++ b/packages/chat-ui/src/wire/parts.ts @@ -0,0 +1,124 @@ +// Mirrored from packages/chat/src (CL-8148 T5b): apps/web and @corbits/chat-ui +// must not import @corbits/chat, a server-only package. This is the browser- +// facing half of the same wire contract the hub's chat routes still speak; +// once the hub moves onto native mail threads (T5a/T5c) this file becomes +// the one source of truth and packages/chat's copy goes away. + +import { type } from "arktype"; + +// The wire contract for chat message content. Every message a thread +// carries is a `Part[]`; each part is a structural arktype schema with a +// `kind` discriminant, parsed at the trust boundary rather than cast. + +export const TextPart = type({ + kind: "'text'", + text: "string", + /** Set only on the undelivered-turn notice `postUndeliveredNotice` + * posts in an unreachable agent's own voice (CL-6332) — the client's + * one signal that this particular text bubble is a failed turn's + * notice, not an ordinary reply, so it renders the failed-turn strip + * (`PrFailedTurnStrip`) instead of a plain bubble. Absent on every + * other text part. */ + "turnFailed?": "boolean", + /** Set with `turnFailed` when the cause is a missing/unresolvable + * model (`InferenceResolutionError`) or a model that cannot use tools + * — the failed-turn strip renders named recovery (picker + Settings + * hop) instead of Retry. Absent on every other text part. */ + "turnFailedReason?": "'model_unavailable' | 'tools_unsupported'", + /** Set only on the cancelled-turn notice `postCancelledNotice` + * (`./workbench-service.ts`) posts in the cancelled agent's own voice + * (CL-7201) — distinct from `turnFailed`: a user cancelling a turn is + * not a failure, and the frontend renders it with its own honest copy + * rather than `FailedTurnStrip`'s "didn't reply" framing. Absent on + * every other text part. */ + "turnCancelled?": "boolean", +}); +export type TextPart = typeof TextPart.infer; + +export const ReasoningPart = type({ + kind: "'reasoning'", + text: "string", +}); +export type ReasoningPart = typeof ReasoningPart.infer; + +export const ToolTracePart = type({ + kind: "'tool-trace'", + name: "string", + input: "unknown", + "output?": "unknown", + status: "'pending' | 'running' | 'success' | 'error'", +}); +export type ToolTracePart = typeof ToolTracePart.infer; + +export const BlockPart = type({ + kind: "'block'", + block: { + type: "string", + data: "unknown", + }, +}); +export type BlockPart = typeof BlockPart.infer; + +// A file rides either as a reference into platform blob storage (`blobId`, +// for content already persisted) or as inline base64 bytes (`data`, for +// content the codec is encoding fresh). Exactly one of those two must be +// present. `artifactId` is an orthogonal, optional link back to a Library +// artifact (see `@corbits/artifacts`) — set when this file is also a +// persisted Library row (e.g. a workflow finalize tool's output, CL-6000), +// independent of whether the bytes themselves also live in chat's own blob +// store. +export const FilePart = type({ + kind: "'file'", + name: "string", + mediaType: "string", + "blobId?": "string", + "data?": "string", + "artifactId?": "string", +}).narrow((part, ctx) => { + const hasBlobId = part.blobId !== undefined; + const hasData = part.data !== undefined; + // An artifact-backed file needs neither: its bytes live in the Library + // artifact row `artifactId` names, not in chat's own blob store, so + // `blobId`/`data` stay optional (but still mutually exclusive) once + // `artifactId` is set. + if (part.artifactId !== undefined) { + if (hasBlobId && hasData) { + return ctx.reject("`blobId` and `data` cannot both be set on a FilePart"); + } + return true; + } + if (hasBlobId === hasData) { + return ctx.reject( + "exactly one of `blobId` or `data` must be set on a FilePart", + ); + } + return true; +}); +export type FilePart = typeof FilePart.infer; + +export const EventPart = type({ + kind: "'event'", + event: "string", + data: "unknown", +}); +export type EventPart = typeof EventPart.infer; + +export const Part = TextPart.or(ReasoningPart) + .or(ToolTracePart) + .or(BlockPart) + .or(FilePart) + .or(EventPart); +export type Part = typeof Part.infer; + +/** + * Parse untrusted data as a `Part`, throwing a precise error rather than + * returning malformed or partially-trusted data. The only supported way + * to bring external JSON into the `Part` type. + */ +export function parsePart(data: unknown): Part { + const result = Part(data); + if (result instanceof type.errors) { + throw new Error(`invalid message part: ${result.summary}`); + } + return result; +} diff --git a/packages/chat-ui/src/wire/reaction-emoji.ts b/packages/chat-ui/src/wire/reaction-emoji.ts new file mode 100644 index 0000000000..2f01e77a3c --- /dev/null +++ b/packages/chat-ui/src/wire/reaction-emoji.ts @@ -0,0 +1,21 @@ +// Mirrored from packages/chat/src (CL-8148 T5b): apps/web and @corbits/chat-ui +// must not import @corbits/chat, a server-only package. This is the browser- +// facing half of the same wire contract the hub's chat routes still speak; +// once the hub moves onto native mail threads (T5a/T5c) this file becomes +// the one source of truth and packages/chat's copy goes away. + +// The curated set of emoji a message reaction can use — deliberately +// small (Slack's own default quick-react row is the same shape) rather +// than free-form input, so a reaction chip is always one of a handful +// of glyphs a reader recognizes at a glance, never an arbitrary string +// an agent or a client could stuff into the row. Pure data, no drizzle +// import, so both `./routes.ts` (server-side validation) and +// `@corbits/chat-ui` (the picker) import this one list rather than +// keeping two in sync. +export const REACTION_EMOJI = ["👍", "❤️", "😂", "🎉", "👀", "🚀"] as const; + +export type ReactionEmoji = (typeof REACTION_EMOJI)[number]; + +export function isKnownReactionEmoji(value: string): value is ReactionEmoji { + return (REACTION_EMOJI as readonly string[]).includes(value); +} diff --git a/packages/chat-ui/src/wire/stream-events.ts b/packages/chat-ui/src/wire/stream-events.ts new file mode 100644 index 0000000000..38175a2922 --- /dev/null +++ b/packages/chat-ui/src/wire/stream-events.ts @@ -0,0 +1,116 @@ +// Mirrored from packages/chat/src (CL-8148 T5b): apps/web and @corbits/chat-ui +// must not import @corbits/chat, a server-only package. This is the browser- +// facing half of the same wire contract the hub's chat routes still speak; +// once the hub moves onto native mail threads (T5a/T5c) this file becomes +// the one source of truth and packages/chat's copy goes away. + +// The wire contract for every event a workbench's `/stream` SSE +// connection carries — arktype schemas so a payload is validated at the +// one boundary that matters (about to leave the process on the wire), +// rather than trusted by convention the way a plain TS interface would +// leave it. `chat-ui`'s subscriber types are meant to mirror these +// `.infer` types exactly: this file is the contract, not a description +// of one. +// +// The organizing rule every event here follows: a subscriber must be +// able to render (or update its own state) from the event alone, with +// no follow-up GET. `chat.message` carries the full rendered row: a +// `GET /workbenches/:id/messages` page item and a freshly published +// `chat.message` are structurally the same shape, minus the fields +// (`reactions`, `pinned`, `clientId`) that are always absent on a +// message nobody has reacted to, pinned, or reconciled yet. Reaction +// and pin events already carry the full changed row for their own +// narrow concern (which emoji, which principal, pinned by whom, when) +// — a subscriber folds that delta into state it already has rather +// than refetching the row it's about. +import { type } from "arktype"; +import { Part } from "./parts"; + +const RoomMessageSender = type({ + name: "string | null", + address: "string", +}); + +/** + * The full rendered timeline row a `chat.message` event carries — + * everything `postRoomMessage`'s caller already has in hand from the + * insert it just did, so a subscriber never needs to refetch the + * message it was just told about. `ref` names the workbench the row + * lives on; the mail headers ride along only when the row was actually + * mailed (a human send through the mailbox fan-out) — Message-ID always, + * In-Reply-To and References when the row answers a thread — and are + * absent, never invented, for a row nobody mailed. + */ +export const ChatMessageEventData = type({ + id: "string", + workbenchId: "string", + ref: type({ kind: "'workbench'", id: "string" }), + createdAt: "string", + threadId: "string | null", + sender: RoomMessageSender, + parts: Part.array(), + "messageId?": "string", + "inReplyTo?": "string", + "references?": "string[]", +}); +export type ChatMessageEventData = typeof ChatMessageEventData.infer; + +/** The full post-change settings object — never a diff a subscriber + * would have to fold against state it might not hold yet. */ +export const ChatSettingsEventData = type({ + updatedBy: "string", + settings: "Record", +}); +export type ChatSettingsEventData = typeof ChatSettingsEventData.infer; + +export const ChatReactionEventData = type({ + messageId: "string", + emoji: "string", + principalId: "string", + added: "boolean", +}); +export type ChatReactionEventData = typeof ChatReactionEventData.infer; + +export const ChatPinEventData = type({ + messageId: "string", + pinned: "boolean", + "pinnedBy?": "string", + "pinnedAt?": "string", +}); +export type ChatPinEventData = typeof ChatPinEventData.infer; + +export const ChatTypingEventData = type({ + principalId: "string", +}); +export type ChatTypingEventData = typeof ChatTypingEventData.infer; + +/** + * A presence delta: one principal either just became reachable on this + * stream (`"online"`, fired the moment its SSE connection opens, and + * again on an explicit `POST .../presence` ping) or just stopped being + * reachable (`"offline"`, fired once its *last* open connection for + * this workbench closes — a second open tab never fires it). Never + * persisted and never the answer to a poll: a subscriber folds this + * into the roster it already holds from `chat.presence.snapshot`. + */ +export const ChatPresenceEventData = type({ + principalId: "string", + state: "'online' | 'offline'", + lastActiveAt: "string", +}); +export type ChatPresenceEventData = typeof ChatPresenceEventData.infer; + +/** + * The one-time roster a freshly opened stream is handed before any + * delta — what lets a subscriber render "who's here" immediately on + * connect without a separate fetch. Sent only to the connecting + * stream itself, never broadcast. + */ +export const ChatPresenceSnapshotEventData = type({ + members: type({ + principalId: "string", + lastActiveAt: "string", + }).array(), +}); +export type ChatPresenceSnapshotEventData = + typeof ChatPresenceSnapshotEventData.infer; diff --git a/packages/chat-ui/src/wire/tools-unsupported.ts b/packages/chat-ui/src/wire/tools-unsupported.ts new file mode 100644 index 0000000000..ac8f327662 --- /dev/null +++ b/packages/chat-ui/src/wire/tools-unsupported.ts @@ -0,0 +1,30 @@ +// Mirrored from packages/chat/src (CL-8148 T5b): apps/web and @corbits/chat-ui +// must not import @corbits/chat, a server-only package. This is the browser- +// facing half of the same wire contract the hub's chat routes still speak; +// once the hub moves onto native mail threads (T5a/T5c) this file becomes +// the one source of truth and packages/chat's copy goes away. + +/** + * Named consumer copy for an inference failure whose real cause is that + * the agent's model cannot use tools. Provider dumps, HTTP status, and + * catalog/registry capability strings never belong on the timeline. + */ + +export const TOOLS_UNSUPPORTED_CONSUMER_MESSAGE = + "This agent's model can't use tools."; + +/** + * Conservative match: provider/inference errors about tools / + * function-calling plus unsupported / not supported / a missing required + * capability. An unrelated HTTP 400, an embedding model's "does not + * support generate", an ordinary reply that happens to mention tools, + * or connector prose like "The grep tool is not supported in this + * sandbox" must not match. Singular "tool" next to "not supported" is + * ordinary tool-availability copy, not a model-capability failure. + */ +const TOOLS_UNSUPPORTED_RE = + /\b(?:tools|tool use)\b[^.]{0,80}\b(?:is |are |use is )?not supported\b|\b(?:is |are )?not supported\b[^.]{0,80}\b(?:tools\b|tool use)\b|\bdoes(?: not|n't) support (?:tools?|tool use|function[\s-]?calling)\b|\bfunction[\s-]?calling\b[^.]{0,80}\b(?:not supported|unsupported|required|missing)\b|\b(?:not supported|unsupported|required|missing)\b[^.]{0,80}\bfunction[\s-]?calling\b/i; + +export function isToolsUnsupportedInferenceText(raw: string): boolean { + return TOOLS_UNSUPPORTED_RE.test(raw); +} diff --git a/packages/chat-ui/src/wire/workbench-host-naming.ts b/packages/chat-ui/src/wire/workbench-host-naming.ts new file mode 100644 index 0000000000..7bb25afb6d --- /dev/null +++ b/packages/chat-ui/src/wire/workbench-host-naming.ts @@ -0,0 +1,48 @@ +// Mirrored from packages/chat/src (CL-8148 T5b): apps/web and @corbits/chat-ui +// must not import @corbits/chat, a server-only package. This is the browser- +// facing half of the same wire contract the hub's chat routes still speak; +// once the hub moves onto native mail threads (T5a/T5c) this file becomes +// the one source of truth and packages/chat's copy goes away. + +// The naming contract for workbench-host workflow definitions, shared +// between the server-side platform adapter (which mints the names) and +// any UI that lists workflow runs (which must keep the chat anchor +// machinery's runs out of user-facing listings). Browser-safe on +// purpose: no platform imports, so a web bundle can consume it via the +// `@corbits/chat-ui/wire/workbench-host-naming` subpath without dragging the +// server surface in. + +/** + * Asset names are constrained to `^[a-z0-9]+(-[a-z0-9]+)*$`; a workbench + * id (`generateId("instance")`) may carry characters outside that set, + * so this derives a compliant name deterministically rather than + * storing a second identifier. The workflow definition folded over the + * asset inherits this name verbatim (see `@intx/hub-sessions`'s + * `ensureWorkflowDefinitionForAsset`), which is what makes the name a + * reliable discriminator for workbench-host runs. + */ +export function workbenchHostAssetName(workbenchId: string): string { + return workbenchId + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +/** + * Every workbench host's asset is named via `workbenchHostAssetName` off a + * generated workbench id: `POST /workbenches` mints `generateId("workflowRun")` + * (`run_<32 hex>`), and older workbenches carry `generateId("instance")` + * (`ins_<32 hex>`). Slugified, both become `-<32 hex>` — the + * exact shape this matcher recognizes. The platform adapter uses it to + * exclude workbench hosts from the invitable set, and workflow listings + * use it to exclude the anchor machinery's runs — no separate "is this + * a workbench host" column needed anywhere. Requiring the full 32-hex + * body keeps user-authored names like `run-my-report` out of the match. + */ +const WORKBENCH_HOST_DEFINITION_NAME = /^(?:ins|run)-[0-9a-f]{32}$/; + +/** Whether a workflow definition name belongs to a workbench-host anchor + * rather than a purpose-run workflow. */ +export function isWorkbenchHostDefinitionName(definitionName: string): boolean { + return WORKBENCH_HOST_DEFINITION_NAME.test(definitionName); +} diff --git a/packages/chat-ui/src/workbench-settings/members-section.tsx b/packages/chat-ui/src/workbench-settings/members-section.tsx index a0914a7b3b..650705a4f4 100644 --- a/packages/chat-ui/src/workbench-settings/members-section.tsx +++ b/packages/chat-ui/src/workbench-settings/members-section.tsx @@ -9,7 +9,7 @@ import { useState } from "react"; import { Button, ConfirmButton } from "@corbits/react-ui"; -import { isAgentAddress } from "@corbits/chat/mentions"; +import { isAgentAddress } from "../wire/mentions"; import { UserPlus } from "@corbits/icons"; import type { ParticipantRecord } from "../api"; diff --git a/packages/chat-ui/src/workbench-settings/surface.tsx b/packages/chat-ui/src/workbench-settings/surface.tsx index cf3d511dcd..e363864e17 100644 --- a/packages/chat-ui/src/workbench-settings/surface.tsx +++ b/packages/chat-ui/src/workbench-settings/surface.tsx @@ -9,7 +9,7 @@ // only re-houses what the panel already rendered. import { Button, toast } from "@corbits/react-ui"; -import { isAgentAddress } from "@corbits/chat/mentions"; +import { isAgentAddress } from "../wire/mentions"; import { useEffect, useState } from "react"; import type { APIQuery } from "@corbits/api-query";