From 9166976f679e381890ef30ea6b5c80459a2cbb7c Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 10:08:23 -0700 Subject: [PATCH 1/4] fix(chat-ui): read block and part types from chat-ui's own wire modules Two chat-ui test files still imported `ConnectGithubBlockData` and `ToolTracePart` from `@corbits/chat` subpaths after the wire types moved into `packages/chat-ui/src/wire`. `@corbits/chat` is not a chat-ui dependency, so both imports were unresolvable and typecheck failed. Claude-Session: https://claude.ai/code/session_01RweuMUskU2nvcFhdckc7EG --- packages/chat-ui/test/connect-github-block-container.test.tsx | 2 +- packages/chat-ui/test/tool-activity-view.test.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/chat-ui/test/connect-github-block-container.test.tsx b/packages/chat-ui/test/connect-github-block-container.test.tsx index 04b014b36f..5b2afe0854 100644 --- a/packages/chat-ui/test/connect-github-block-container.test.tsx +++ b/packages/chat-ui/test/connect-github-block-container.test.tsx @@ -9,7 +9,7 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import type { Root } from "react-dom/client"; -import type { ConnectGithubBlockData } from "@corbits/chat/blocks"; +import type { ConnectGithubBlockData } from "../src/wire/blocks"; import type { ConnectGithubActions, diff --git a/packages/chat-ui/test/tool-activity-view.test.tsx b/packages/chat-ui/test/tool-activity-view.test.tsx index 2466082e14..1ec8eca3b0 100644 --- a/packages/chat-ui/test/tool-activity-view.test.tsx +++ b/packages/chat-ui/test/tool-activity-view.test.tsx @@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; -import type { ToolTracePart } from "@corbits/chat/parts"; +import type { ToolTracePart } from "../src/wire/parts"; import { toToolActivityRow, type ToolActivityRow } from "../src/tool-activity"; import { LiveToolActivity, ToolActivityGroup } from "../src/tool-activity-view"; From 2bcdacc249d804b7cd3eb0b3e6941b2b22f25e21 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 10:08:23 -0700 Subject: [PATCH 2/4] feat(hub)!: an outbound frame's workbench ref is its own tenant A workbench is a plain tenant, so the workbench a mail frame belongs to is exactly the tenant `authorizeSender` already resolved from the sender run's address. `resolveRefs` no longer chases a second id: the chat-store and room-message lookups, the `In-Reply-To`/`References` header walk and the participant-scan fallback behind them are all gone, and the seam is a plain function rather than a factory over two stores. The tests for the retired shape (no-participation stamps no ref, a header disambiguating two workbenches) go with it; the remaining suite asserts the tenant ref on every row. Also drops three imports the hub no longer uses at all: `localPartOf`, `parseParticipants` and `postRoomMessage`. Claude-Session: https://claude.ai/code/session_01RweuMUskU2nvcFhdckc7EG --- apps/hub/src/index.ts | 8 +- apps/hub/src/mailbox-persist.ts | 64 +++------- apps/hub/test/mailbox-persist.test.ts | 164 ++------------------------ 3 files changed, 30 insertions(+), 206 deletions(-) diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index d651dee023..58f10251af 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -131,7 +131,7 @@ import { } from "@corbits/mailbox"; import { createHubMailboxAuthorizeSender, - createHubMailboxResolveRefs, + hubMailboxResolveRefs, createHubPersistMailWithSessionEnsure, } from "./mailbox-persist"; import { @@ -637,9 +637,7 @@ export async function createHub(config: HubConfig) { grantStore: createGrantStore(db), }); // Hoisted ahead of their other uses below (chat routes, the room - // timeline store at CL-6327) so `createHubMailboxResolveRefs` - // can share these two instances rather than constructing its own just - // for the mailbox wiring. + // timeline store at CL-6327). const chatStore = createDrizzleChatStore(db); const roomMessages = createDrizzleRoomMessageStore(db); const lookups = { @@ -662,7 +660,7 @@ export async function createHub(config: HubConfig) { ), authorizeSender: createHubMailboxAuthorizeSender(db), bus: mailboxBus, - resolveRefs: createHubMailboxResolveRefs(chatStore, roomMessages), + resolveRefs: hubMailboxResolveRefs, }), async registerSignalCorrelation( args: Parameters[0], diff --git a/apps/hub/src/mailbox-persist.ts b/apps/hub/src/mailbox-persist.ts index 3232db07a9..9ea178f71c 100644 --- a/apps/hub/src/mailbox-persist.ts +++ b/apps/hub/src/mailbox-persist.ts @@ -20,11 +20,6 @@ import type { MailboxRef, } from "@corbits/mailbox"; import { resolveRoutableAddress } from "@intx/hub-sessions"; -import { - resolveWorkbenchIdForAgentFrame, - type ChatStore, - type RoomMessageStore, -} from "@corbits/chat"; import { ensureRunSession, type EventCollectorPort } from "@corbits/workflows"; import { reportError } from "@corbits/error-sink"; import { getLogger } from "@intx/log"; @@ -142,50 +137,21 @@ export function createHubMailboxAuthorizeSender( } /** - * Build the `resolveRefs` seam: stamps - * `refs: [{ kind: "workbench", id: chatId }]` onto every recipient row - * of one frame. + * The `resolveRefs` seam: stamps `refs: [{ kind: "workbench", id }]` onto + * every recipient row of one frame. * - * The chat id is NOT `senderAuthorization.tenantId`. An agent run is - * launched in its parent BENCH tenant -- that tenant is what - * `authorizeSender` resolves, and it is also what the resulting - * `principal_mail` rows themselves are scoped under (same tenant the - * addressed human principals belong to). The chat the run is a - * participant of is a separate id `@corbits/chat` tracks inside that same - * bench tenant, on `workbench_settings.workbenchId` -- one bench tenant - * hosts many chats. Stamping the bench's tenant id here instead would - * point every row at an id no chat thread read can ever resolve. - * - * Header-first (CL-7449): an agent that participates in several - * chats at once has no single "the" chat a bare participant - * scan can name honestly, so `@corbits/chat`'s - * `resolveWorkbenchIdForAgentFrame` reads the frame's own `In-Reply-To` / - * `References` and maps that Message-ID back to the timeline row it - * answers -- that row's `workbenchId` is authoritative. Only when the - * frame carries no such header does it fall back to the participant scan, - * and only takes that scan's answer when it is unambiguous (exactly one - * chat); this seam is a thin adapter handing that helper the two - * stores it needs (`chatStore`, `roomMessages`) plus the frame's own - * decoded headers. + * A workbench IS a tenant, so the workbench a frame belongs to is exactly + * the tenant `authorizeSender` already resolved from the sender run's own + * address — the same tenant the resulting `principal_mail` rows are scoped + * under. There is no second id to chase and no header or participant scan + * to disambiguate: an agent run is launched in the workbench it serves. */ -export function createHubMailboxResolveRefs( - chatStore: ChatStore, - roomMessages: Pick, -): ResolveMailboxRefs { - return async ({ senderAddress, senderAuthorization, decoded }) => { - const inReplyTo = decoded?.headers.get("in-reply-to") ?? undefined; - const references = decoded?.references; - const chatId = await resolveWorkbenchIdForAgentFrame( - { chatStore, roomMessages }, - senderAuthorization.tenantId, - { - senderAddress, - ...(inReplyTo !== undefined ? { inReplyTo } : {}), - ...(references !== undefined ? { references } : {}), - }, - ); - if (chatId === undefined) return undefined; - const ref: MailboxRef = { kind: "workbench", id: chatId }; - return [ref]; +export const hubMailboxResolveRefs: ResolveMailboxRefs = ({ + senderAuthorization, +}) => { + const ref: MailboxRef = { + kind: "workbench", + id: senderAuthorization.tenantId, }; -} + return Promise.resolve([ref]); +}; diff --git a/apps/hub/test/mailbox-persist.test.ts b/apps/hub/test/mailbox-persist.test.ts index c9d991b63c..681489247b 100644 --- a/apps/hub/test/mailbox-persist.test.ts +++ b/apps/hub/test/mailbox-persist.test.ts @@ -1,6 +1,6 @@ // CL-7449: proves the real composition -- `createHubSessionLookups`'s // `persistMail` wrapped by `createMailboxPersist` via -// `createHubMailboxAuthorizeSender` / `createHubMailboxResolveRefs` +// `createHubMailboxAuthorizeSender` / `hubMailboxResolveRefs` // (`../src/mailbox-persist.ts`) -- against a real Postgres, matching how // `createHub` wires them in `../src/index.ts`. DB-gated: skipped when // DATABASE_URL is unreachable, matching every other suite in this @@ -28,14 +28,9 @@ import { createHubSessionLookups, type AgentRepoStore, } from "@intx/hub-sessions"; -import { - createDrizzleChatStore, - createDrizzleRoomMessageStore, -} from "@corbits/chat"; - import { createHubMailboxAuthorizeSender, - createHubMailboxResolveRefs, + hubMailboxResolveRefs, } from "../src/mailbox-persist"; import { dbGate } from "../../../scripts/e2e/db-gate"; @@ -72,18 +67,8 @@ function uid(label: string): string { * a live `agent_session` row -- `hub-session-lookups.ts`'s `persistMail` * throws "has no session" for a sender run with none, which is exactly the * upstream-failure branch the dual-write-independence test below exercises. - * - * `workbenchIds` optionally seeds one `workbench_settings` row per entry, - * each naming the sender address as a participant -- so - * `findWorkbenchIdsByParticipantAddress` (and therefore the participant-scan - * fallback inside `createHubMailboxResolveRefs`) has one or several - * workbenches to resolve to. Omitted, the sender belongs to no workbench -- - * the plain-workflow-mail case `resolveRefs` must also handle by stamping - * no ref. */ -async function setup( - opts: { withSession?: boolean; workbenchIds?: string[] } = {}, -) { +async function setup(opts: { withSession?: boolean } = {}) { const withSession = opts.withSession ?? true; const { db, close: closeDb } = createDB(dbConfigFromUrl(databaseUrl)); @@ -157,20 +142,6 @@ async function setup( }); } - const chatStore = createDrizzleChatStore(db); - const roomMessages = createDrizzleRoomMessageStore(db); - - for (const workbenchId of opts.workbenchIds ?? []) { - await chatStore.createWorkbenchSettings({ - tenantId, - workbenchId, - settings: { - "chat/participants": [{ address: senderAddress, handle: "sender" }], - }, - updatedBy: human1Id, - }); - } - const baseLookups = createHubSessionLookups({ db, // persistMail (the only lookup this suite exercises) never touches @@ -182,8 +153,6 @@ async function setup( return { db, mailboxDb, - chatStore, - roomMessages, baseLookups, domain, tenantId, @@ -195,13 +164,10 @@ async function setup( } describeIfDb("hub persistMail wrapped with createMailboxPersist", () => { - test("an outbound frame to two humans and one agent produces exactly two principal_mail rows, an SSE event per mailbox carrying the workbench ref already, and a durable session_mail row upstream", async () => { - const workbenchId = uid("wb_mbxpw"); + test("an outbound frame to two humans and one agent produces exactly two principal_mail rows, an SSE event per mailbox carrying the workbench (tenant) ref already, and a durable session_mail row upstream", async () => { const { db, mailboxDb, - chatStore, - roomMessages, baseLookups, domain, tenantId, @@ -209,7 +175,7 @@ describeIfDb("hub persistMail wrapped with createMailboxPersist", () => { agentRecipientAddress, human1Id, human2Id, - } = await setup({ workbenchIds: [workbenchId] }); + } = await setup(); const mailboxBus = createInMemoryMailboxEventBus(); // Asserted INSIDE the subscriber, at event-fire time -- no polling @@ -241,7 +207,7 @@ describeIfDb("hub persistMail wrapped with createMailboxPersist", () => { upstream: baseLookups.persistMail, authorizeSender: createHubMailboxAuthorizeSender(db), bus: mailboxBus, - resolveRefs: createHubMailboxResolveRefs(chatStore, roomMessages), + resolveRefs: hubMailboxResolveRefs, }); const raw = new TextEncoder().encode( @@ -292,133 +258,31 @@ describeIfDb("hub persistMail wrapped with createMailboxPersist", () => { [human1Id, human2Id].sort(), ); for (const row of rows) { - expect(row.refs).toEqual([{ kind: "workbench", id: workbenchId }]); + expect(row.refs).toEqual([{ kind: "workbench", id: tenantId }]); } // Every SSE subscriber saw the ref already populated at event time. expect(seenRefsByPrincipalId.size).toBe(2); for (const refs of seenRefsByPrincipalId.values()) { - expect(refs).toEqual([{ kind: "workbench", id: workbenchId }]); + expect(refs).toEqual([{ kind: "workbench", id: tenantId }]); } }); - test("a sender run with no workbench participation gets mailbox rows with no ref stamped", async () => { - const { - db, - mailboxDb, - chatStore, - roomMessages, - baseLookups, - domain, - tenantId, - senderAddress, - human1Id, - } = await setup(); - - const persistMail = createMailboxPersist(mailboxDb, { - upstream: baseLookups.persistMail, - authorizeSender: createHubMailboxAuthorizeSender(db), - resolveRefs: createHubMailboxResolveRefs(chatStore, roomMessages), - }); - - const raw = new TextEncoder().encode( - [ - `From: ${senderAddress}`, - `To: usr_${human1Id}@${domain}`, - "Subject: Plain workflow mail", - "", - "Body", - ].join("\r\n"), - ); - await persistMail({ - senderAddress, - recipients: [`usr_${human1Id}@${domain}`], - raw, - }); - - const [row] = await mailboxDb - .select() - .from(principalMail) - .where(eq(principalMail.tenantId, tenantId)); - expect(row?.refs).toBeNull(); - }); - - test("header resolves to the parent row's workbench even when the agent participates in two workbenches", async () => { - const [workbenchOne, workbenchTwo] = [uid("wb_mbxpw"), uid("wb_mbxpw")]; - const { - mailboxDb, - chatStore, - roomMessages, - baseLookups, - db, - domain, - tenantId, - senderAddress, - human1Id, - } = await setup({ workbenchIds: [workbenchOne, workbenchTwo] }); - - const parentRow = await roomMessages.insertMessage({ - id: uid("msg_mbxpw_parent"), - tenantId, - workbenchId: workbenchTwo, - sender: { address: `usr_${human1Id}@${domain}`, name: "Human" }, - parts: [{ kind: "text", text: "the row this reply answers" }], - }); - const mailMessageId = `<${parentRow.id}@${domain}>`; - await roomMessages.stampMailMessageId({ - tenantId, - workbenchId: workbenchTwo, - messageId: parentRow.id, - mailMessageId, - }); - - const persistMail = createMailboxPersist(mailboxDb, { - upstream: baseLookups.persistMail, - authorizeSender: createHubMailboxAuthorizeSender(db), - resolveRefs: createHubMailboxResolveRefs(chatStore, roomMessages), - }); - - const raw = new TextEncoder().encode( - [ - `From: ${senderAddress}`, - `To: usr_${human1Id}@${domain}`, - `In-Reply-To: ${mailMessageId}`, - "Subject: Re: the row this reply answers", - "", - "Body", - ].join("\r\n"), - ); - await persistMail({ - senderAddress, - recipients: [`usr_${human1Id}@${domain}`], - raw, - }); - - const [row] = await mailboxDb - .select() - .from(principalMail) - .where(eq(principalMail.tenantId, tenantId)); - expect(row?.refs).toEqual([{ kind: "workbench", id: workbenchTwo }]); - }); - test("dual-write independence: a sender run with no live session makes upstream throw, the mailbox rows still get written, and zero session_mail rows land", async () => { - const workbenchId = uid("wb_mbxpw"); const { db, mailboxDb, - chatStore, - roomMessages, baseLookups, domain, tenantId, senderAddress, human1Id, - } = await setup({ withSession: false, workbenchIds: [workbenchId] }); + } = await setup({ withSession: false }); const persistMail = createMailboxPersist(mailboxDb, { upstream: baseLookups.persistMail, authorizeSender: createHubMailboxAuthorizeSender(db), - resolveRefs: createHubMailboxResolveRefs(chatStore, roomMessages), + resolveRefs: hubMailboxResolveRefs, }); const raw = new TextEncoder().encode( @@ -444,9 +308,7 @@ describeIfDb("hub persistMail wrapped with createMailboxPersist", () => { .from(principalMail) .where(eq(principalMail.tenantId, tenantId)); expect(mailboxRows).toHaveLength(1); - expect(mailboxRows[0]?.refs).toEqual([ - { kind: "workbench", id: workbenchId }, - ]); + expect(mailboxRows[0]?.refs).toEqual([{ kind: "workbench", id: tenantId }]); const sessionMailRows = await db .select() @@ -459,8 +321,6 @@ describeIfDb("hub persistMail wrapped with createMailboxPersist", () => { const { db, mailboxDb, - chatStore, - roomMessages, baseLookups, domain, tenantId, @@ -471,7 +331,7 @@ describeIfDb("hub persistMail wrapped with createMailboxPersist", () => { const persistMail = createMailboxPersist(mailboxDb, { upstream: baseLookups.persistMail, authorizeSender: createHubMailboxAuthorizeSender(db), - resolveRefs: createHubMailboxResolveRefs(chatStore, roomMessages), + resolveRefs: hubMailboxResolveRefs, }); const raw = new TextEncoder().encode( From 0ef8e986001685554bfc76de235be9795e13bbe1 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 10:19:08 -0700 Subject: [PATCH 3/4] feat(chat)!: invitable definitions walk the tenant ancestor chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A workflow definition deployed in a parent tenant is invitable from a child workbench like a plugin, so `listInvitableDefinitions` reads the whole ancestor chain — the same walk credential and catalog resolution already perform — instead of the workbench's own tenant alone. When an ancestor and a descendant both expose a definition of the same name, the descendant shadows the ancestor, matching how asset and catalog resolution already resolve a name across the chain. Visibility is all that is inherited. A grant is not: every participant and permission check stays scoped to the workbench tenant itself. Claude-Session: https://claude.ai/code/session_01RweuMUskU2nvcFhdckc7EG --- packages/chat/src/platform-adapter.ts | 37 +++++++-- packages/chat/test/platform-adapter.test.ts | 85 ++++++++++++++++++++- 2 files changed, 116 insertions(+), 6 deletions(-) diff --git a/packages/chat/src/platform-adapter.ts b/packages/chat/src/platform-adapter.ts index a927d5656b..b5420b65d4 100644 --- a/packages/chat/src/platform-adapter.ts +++ b/packages/chat/src/platform-adapter.ts @@ -12,7 +12,7 @@ import { DEFAULT_WAKE_TIMEOUT_MS, } from "@corbits/agent-lifecycle"; import { reportError } from "@corbits/error-sink"; -import { and, desc, eq } from "drizzle-orm"; +import { and, desc, eq, inArray } from "drizzle-orm"; import { authoredDefinitionCandidates, DefinitionProjectionMissingError, @@ -51,7 +51,7 @@ import { } from "./agent-binding"; import type { RelaunchNoticePort } from "./relaunch-notice"; import type { DB } from "@intx/db"; -import { listVisibleOfferings } from "@intx/db"; +import { getAncestorChain, listVisibleOfferings } from "@intx/db"; import { asset as assetTable, principal as principalTable, @@ -1251,18 +1251,45 @@ export function createHubChatPlatform( async listInvitableDefinitions( tenantId, ): Promise { + // A definition deployed in a parent tenant is invitable from a child + // workbench like a plugin, so this reads the whole ancestor chain -- + // the same walk credential and catalog resolution already perform -- + // rather than the workbench's own tenant alone. Inheritance stops at + // visibility: a grant is never inherited, so every participant and + // permission check stays scoped to the workbench tenant itself. + const chain = await getAncestorChain(deps.db, tenantId); const rows = await deps.db.query.workflowDefinition.findMany({ where: and( - eq(workflowDefinition.tenantId, tenantId), + inArray(workflowDefinition.tenantId, chain), eq(workflowDefinition.status, "deployed"), ), orderBy: desc(workflowDefinition.createdAt), }); + // `getAncestorChain` returns leaf-to-root, so a lower index is a + // nearer tenant: when an ancestor and a descendant both expose a + // definition of the same name, the descendant shadows the ancestor + // (the convention asset and catalog resolution already follow). + const distanceByTenantId = new Map( + chain.map((ancestorId, distance) => [ancestorId, distance]), + ); + const distanceOf = (row: { readonly tenantId: string }): number => + distanceByTenantId.get(row.tenantId) ?? Number.MAX_SAFE_INTEGER; // Only hub-authored definitions are invitable: the run-deploy // clones sharing an agent's name are deploy records, and listing // them would offer N stale copies of every agent that has run. - return authoredDefinitionCandidates(rows) - .filter((row) => !isWorkbenchHostDefinitionName(row.name)) + const candidates = authoredDefinitionCandidates(rows).filter( + (row) => !isWorkbenchHostDefinitionName(row.name), + ); + const nearestByName = new Map(); + for (const row of candidates) { + const distance = distanceOf(row); + const incumbent = nearestByName.get(row.name); + if (incumbent === undefined || distance < incumbent.distance) { + nearestByName.set(row.name, { id: row.id, distance }); + } + } + return candidates + .filter((row) => nearestByName.get(row.name)?.id === row.id) .map((row) => { const base = { id: row.id, name: row.name }; return typeof row.description === "string" && row.description !== "" diff --git a/packages/chat/test/platform-adapter.test.ts b/packages/chat/test/platform-adapter.test.ts index 4e7215c8da..2cb234be8e 100644 --- a/packages/chat/test/platform-adapter.test.ts +++ b/packages/chat/test/platform-adapter.test.ts @@ -322,6 +322,14 @@ function createFakeDb(opts: { }[] | undefined; tenantRow?: { id: string; domain: string } | undefined; + /** + * A tenant hierarchy keyed by id, for the ancestor walk + * `listInvitableDefinitions` performs. A row whose `parentId` is null + * (or absent from this record) ends the chain. + */ + tenantRowsById?: + | Record + | undefined; workbenchLaunchRow?: | { tenantId: string; @@ -526,7 +534,17 @@ function createFakeDb(opts: { : []), }, tenant: { - findFirst: async () => opts.tenantRow, + // `getAncestorChain` walks parent by parent, so a test that seeds + // a hierarchy needs each id answered with its OWN row rather than + // one fixed row for every lookup — which would re-answer the same + // parent forever and trip the walk's own cycle guard. + findFirst: async (args?: { where?: unknown }) => { + if (opts.tenantRowsById === undefined) return opts.tenantRow; + const [queriedId] = boundStringValues(args?.where); + return queriedId === undefined + ? undefined + : opts.tenantRowsById[queriedId]; + }, }, }, select(..._cols: unknown[]) { @@ -1978,6 +1996,71 @@ describe("createHubChatPlatform", () => { ]); }); + test("listInvitableDefinitions walks the tenant ancestors, and a workbench's own definition shadows its parent's of the same name", async () => { + const db = createFakeDb({ + assetRow: { + tenantId: "ten_child", + creatorPrincipalId: "prin_creator", + name: "workbench-1", + displayName: null, + }, + definitionId: "wfd_workbench1", + tenantRowsById: { + ten_child: { id: "ten_child", parentId: "ten_root" }, + ten_root: { id: "ten_root", parentId: null }, + }, + workflowDefinitionRows: [ + { + id: "wfd_echo_child", + tenantId: "ten_child", + status: "deployed", + origin: "authored", + name: "echo", + description: "Echo, this workbench's own", + }, + { + id: "wfd_echo_root", + tenantId: "ten_root", + status: "deployed", + origin: "authored", + name: "echo", + description: "Echo, inherited", + }, + { + id: "wfd_researcher_root", + tenantId: "ten_root", + status: "deployed", + origin: "authored", + name: "researcher", + description: "Researcher", + }, + ], + }); + const platform = createPlatform({ + toolGrantsForPins: async () => [], + db: db as never, + runTrigger: createFakeRunTrigger(), + sidecarRouter: createFakeSidecarRouter(), + eventCollectors: createFakeEventCollectors(), + }); + + const items = await platform.listInvitableDefinitions("ten_child"); + // The root's `researcher` is invitable from the child like a plugin; + // its `echo` is not, because the child deploys its own. + expect(items).toEqual([ + { + id: "wfd_echo_child", + name: "echo", + description: "Echo, this workbench's own", + }, + { + id: "wfd_researcher_root", + name: "researcher", + description: "Researcher", + }, + ]); + }); + test("subscribeToWorkbench resolves the run's address and subscribes on the sidecar router", async () => { const db = createFakeDb({ assetRow: { From 1de36dc9d0f2d50c61b704b097ecc4e35817ecea Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 10:32:57 -0700 Subject: [PATCH 4/4] chore: retrigger CI Claude-Session: https://claude.ai/code/session_01RweuMUskU2nvcFhdckc7EG