From c1198d2f41344011089d9846ec201fe3a7e45425 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:25:54 -0400 Subject: [PATCH] refactor(connector): the desktop takes Discord and Linear conversations as well as Slack (#33927) The desktop's connector accepts conversations from Slack, Discord and Linear and lists all three sources in its hello; per-source details (the Sent from line) come from one table. Nothing changes until those adapters are configured; behind the experimentalConnectors setting. --- src/cli/specs/connector.ts | 4 ++-- .../connector-cloud/connector-deliveries.ts | 2 +- src/main/connector-cloud/connector-hello.ts | 4 ++-- .../connector-cloud/connector-places.test.ts | 20 +++++++++++-------- src/main/connector-cloud/connector-places.ts | 8 +++++--- .../connector-poll-client.test.ts | 2 +- .../connector-snapshot.test.ts | 11 ++++++++++ .../connector-cloud/connector-snapshot.ts | 11 +++------- .../connector-cloud/connector-turn-events.ts | 13 ++++++++---- src/main/runtime/rpc/methods/connector.ts | 7 ++++--- src/shared/connector-cloud-contract.ts | 16 +++++++++++++-- 11 files changed, 64 insertions(+), 34 deletions(-) diff --git a/src/cli/specs/connector.ts b/src/cli/specs/connector.ts index 7feb5ae3c87..d974079d687 100644 --- a/src/cli/specs/connector.ts +++ b/src/cli/specs/connector.ts @@ -2,7 +2,7 @@ import type { CommandSpec } from '../args' import { GLOBAL_FLAGS } from '../args' const CONNECTOR_NOTES = [ - 'Development only: drives the connector seam as a `dev` conversation, without Slack. The host refuses it unless its experimentalConnectors setting is on.', + 'Development only: drives the connector seam as a `dev` conversation, without Orca Cloud. The host refuses it unless its experimentalConnectors setting is on.', 'A retry with the same --delivery-id and payload returns the first answer instead of delivering again.' ] @@ -90,7 +90,7 @@ export const CONNECTOR_COMMAND_SPECS: CommandSpec[] = [ usage: 'orca connector reply --text [--json]', allowedFlags: [...GLOBAL_FLAGS, 'text'], notes: [ - 'Run it from an agent a connector message started (Slack today). Orca posts the text to that thread through Orca Cloud; this machine needs no token for the service.', + 'Run it from an agent a Slack, Discord or Linear message started. Orca posts the text to that thread through Orca Cloud; this machine needs no token for the service.', 'Experimental: needs the experimentalConnectors setting and a desktop signed in to Orca.' ] } diff --git a/src/main/connector-cloud/connector-deliveries.ts b/src/main/connector-cloud/connector-deliveries.ts index 2cd5bd69634..3dcda7281b8 100644 --- a/src/main/connector-cloud/connector-deliveries.ts +++ b/src/main/connector-cloud/connector-deliveries.ts @@ -65,7 +65,7 @@ export function dispatchDelivery( let done: Promise switch (delivery.kind) { case 'start': { - const target = placeLaunchTarget(delivery.placeId, deliveryId) + const target = placeLaunchTarget(delivery.placeId, deliveryId, key.source) if (!target) { return refuse(deliveryId, { outcome: 'unsupported' }) } diff --git a/src/main/connector-cloud/connector-hello.ts b/src/main/connector-cloud/connector-hello.ts index 39243b7c433..4e13aecb2e7 100644 --- a/src/main/connector-cloud/connector-hello.ts +++ b/src/main/connector-cloud/connector-hello.ts @@ -9,8 +9,8 @@ import { jsonBytes } from './connector-snapshot' const FEATURES = ['answer', 'cancel', 'reply'] const PROMPT_KINDS = ['approval', 'question'] -// Only Slack conversations reach this machine's seam so far. -const SOURCES = ['slack'] +// Listed by hand: a source joins only once this build's seam handles its conversations. +const SOURCES = ['slack', 'discord', 'linear'] export type ConnectorHelloSaid = { agents: string[]; projects: ConnectorProject[] } diff --git a/src/main/connector-cloud/connector-places.test.ts b/src/main/connector-cloud/connector-places.test.ts index 6dc55fc126d..92a5bc32aae 100644 --- a/src/main/connector-cloud/connector-places.test.ts +++ b/src/main/connector-cloud/connector-places.test.ts @@ -55,7 +55,7 @@ describe('connector places', () => { ]) expect(JSON.stringify(projects)).not.toContain('/src/') const [local, remote] = projects - expect(placeLaunchTarget(local!.placeId, 'T1:C9:1')).toEqual({ + expect(placeLaunchTarget(local!.placeId, 'T1:C9:1', 'slack')).toEqual({ kind: 'create-worktree', create: { repo: 'id:orca', @@ -64,7 +64,7 @@ describe('connector places', () => { nameWasGenerated: true } }) - expect(placeLaunchTarget(remote!.placeId, 'T1:C9:1')).toMatchObject({ + expect(placeLaunchTarget(remote!.placeId, 'T1:C9:1', 'slack')).toMatchObject({ create: { repo: 'id:orca', repoHostId: 'ssh:box' } }) }) @@ -80,7 +80,7 @@ describe('connector places', () => { ) ) expect(folder).toMatchObject({ name: 'Docs', kind: 'folder', where: { kind: 'local' } }) - expect(placeLaunchTarget(folder!.placeId, 'd1')).toEqual({ + expect(placeLaunchTarget(folder!.placeId, 'd1', 'slack')).toEqual({ kind: 'create-folder-workspace', create: { projectGroupId: 'g1', name: expect.stringMatching(/^slack-[0-9a-f]{8}$/) } }) @@ -88,13 +88,17 @@ describe('connector places', () => { it('derives the same worktree name for a resent start, and refuses a place it cannot read', () => { const [project] = connectorProjects(store([repo({ id: 'orca' })])) - expect(placeLaunchTarget(project!.placeId, 'd1')).toEqual( - placeLaunchTarget(project!.placeId, 'd1') + expect(placeLaunchTarget(project!.placeId, 'd1', 'slack')).toEqual( + placeLaunchTarget(project!.placeId, 'd1', 'slack') ) - expect(placeLaunchTarget(project!.placeId, 'd1')).not.toEqual( - placeLaunchTarget(project!.placeId, 'd2') + expect(placeLaunchTarget(project!.placeId, 'd1', 'slack')).not.toEqual( + placeLaunchTarget(project!.placeId, 'd2', 'slack') ) - expect(placeLaunchTarget('not-a-place', 'd1')).toBeNull() + expect(placeLaunchTarget('not-a-place', 'd1', 'slack')).toBeNull() + // The workspace is named for where the conversation came from. + expect(placeLaunchTarget(project!.placeId, 'd1', 'linear')).toMatchObject({ + create: { name: expect.stringMatching(/^linear-[0-9a-f]{8}$/) } + }) }) it('lists the most recently used projects first, at most maxHelloProjects, with short names', () => { diff --git a/src/main/connector-cloud/connector-places.ts b/src/main/connector-cloud/connector-places.ts index d378c5a2e4d..7d8dc7391a4 100644 --- a/src/main/connector-cloud/connector-places.ts +++ b/src/main/connector-cloud/connector-places.ts @@ -1,8 +1,9 @@ -// A place is where a Slack start may run; its id rebuilds the launch target with no lookup, so twins on other hosts stay apart. +// A place is where a connector start may run; its id rebuilds the launch target with no lookup, so twins on other hosts stay apart. import { createHash } from 'node:crypto' import { z } from 'zod' import { CONNECTOR_CLIENT_LIMITS, + type ConnectorConversation, type ConnectorProject } from '../../shared/connector-cloud-contract' import { @@ -55,13 +56,14 @@ function decodePlace(placeId: string): Place | null { /** A pure function of the delivery, so a resent start hashes to the same receipt. */ export function placeLaunchTarget( placeId: string, - deliveryId: string + deliveryId: string, + source: ConnectorConversation['source'] ): AgentLaunchParams['target'] | null { const place = decodePlace(placeId) if (!place) { return null } - const name = `slack-${createHash('sha256').update(deliveryId).digest('hex').slice(0, 8)}` + const name = `${source}-${createHash('sha256').update(deliveryId).digest('hex').slice(0, 8)}` if (place[0] === 'folder') { return { kind: 'create-folder-workspace', create: { projectGroupId: place[1], name } } } diff --git a/src/main/connector-cloud/connector-poll-client.test.ts b/src/main/connector-cloud/connector-poll-client.test.ts index 6528dffb1b3..ad8bc1fa064 100644 --- a/src/main/connector-cloud/connector-poll-client.test.ts +++ b/src/main/connector-cloud/connector-poll-client.test.ts @@ -84,7 +84,7 @@ describe('ConnectorPollClient', () => { agents: ['claude', 'codex'], promptKinds: ['approval', 'question'], features: ['answer', 'cancel', 'reply'], - sources: ['slack'], + sources: ['slack', 'discord', 'linear'], projects: [{ placeId: project!.placeId, name: 'orca', kind: 'git' }] }) expect(fake.events).toEqual([{ kind: 'snapshot', conversations: [] }]) diff --git a/src/main/connector-cloud/connector-snapshot.test.ts b/src/main/connector-cloud/connector-snapshot.test.ts index 16ec84ce240..3ab8fcaf1fa 100644 --- a/src/main/connector-cloud/connector-snapshot.test.ts +++ b/src/main/connector-cloud/connector-snapshot.test.ts @@ -87,4 +87,15 @@ describe('connectorSnapshot', () => { ) expect(snapshot.conversations[0]?.paneKey).toBe(`0${escaped}`) }) + + it('lists every cloud conversation, Slack, Discord or Linear, and never a dev one', () => { + const conversations = (['slack', 'discord', 'linear', 'dev'] as const).map((source) => + entry(source, NOW, { conversation: { source, externalId: `${source}-1` } }) + ) + expect( + connectorSnapshot({ kind: 'snapshot', conversations }, NOW).snapshot.conversations.map( + (item) => item.conversation.source + ) + ).toEqual(['slack', 'discord', 'linear']) + }) }) diff --git a/src/main/connector-cloud/connector-snapshot.ts b/src/main/connector-cloud/connector-snapshot.ts index 7da83e6ce3e..f944804102b 100644 --- a/src/main/connector-cloud/connector-snapshot.ts +++ b/src/main/connector-cloud/connector-snapshot.ts @@ -1,12 +1,11 @@ -// What a hello's snapshot tells the cloud: recently active Slack conversations, within the contract's caps. +// What a hello's snapshot tells the cloud: recently active cloud conversations, within the contract's caps. import type { PendingPrompt } from '../../shared/agent-pending-prompt' import { + cloudConversation, CONNECTOR_CLIENT_LIMITS, - type ConnectorConversation, type ConnectorEvent } from '../../shared/connector-cloud-contract' import type { ConversationSnapshot } from '../external-conversations/external-conversation-events' -import type { ConversationKey } from '../external-conversations/external-conversation-types' type Snapshot = Extract @@ -18,10 +17,6 @@ export const unsupported = (prompt: PendingPrompt): PendingPrompt => ({ promptId: prompt.promptId }) -export function slackConversation(key: ConversationKey): ConnectorConversation | null { - return key.source === 'slack' ? { source: 'slack', externalId: key.externalId } : null -} - /** * Linked conversations active within the window, newest first, at most maxSnapshotConversations. Working * or prompting counts as active now. Past the event cap, prompts go unsupported, then the least recent go. @@ -34,7 +29,7 @@ export function connectorSnapshot( const since = now - CONNECTOR_CLIENT_LIMITS.snapshotActiveWindowMs const recent = event.conversations .flatMap((entry) => { - const conversation = slackConversation(entry.conversation) + const conversation = cloudConversation(entry.conversation) const activeAt = entry.working || entry.prompt ? now : entry.activeAt return conversation && activeAt >= since ? [{ ...entry, conversation, activeAt }] : [] }) diff --git a/src/main/connector-cloud/connector-turn-events.ts b/src/main/connector-cloud/connector-turn-events.ts index 932fdb73327..1710e1d2bad 100644 --- a/src/main/connector-cloud/connector-turn-events.ts +++ b/src/main/connector-cloud/connector-turn-events.ts @@ -2,7 +2,12 @@ import { readFileSync, rmSync } from 'node:fs' import { join } from 'node:path' import { z } from 'zod' -import { CONNECTOR_CLIENT_LIMITS, type ConnectorEvent } from '../../shared/connector-cloud-contract' +import { + cloudConversation, + CONNECTOR_CLIENT_LIMITS, + ConnectorConversation, + type ConnectorEvent +} from '../../shared/connector-cloud-contract' import { writeSecureJsonFile } from '../../shared/secure-file' import type { ConversationEvent, @@ -10,7 +15,7 @@ import type { } from '../external-conversations/external-conversation-events' import { isMissingFileError } from '../runtime/device-registry-corruption' import { ConnectorOutbox, type ConnectorOutboxHead, type ConnectorStamp } from './connector-outbox' -import { connectorSnapshot, jsonBytes, slackConversation, unsupported } from './connector-snapshot' +import { connectorSnapshot, jsonBytes, unsupported } from './connector-snapshot' const TURN_OUTBOX_FILE = 'connector-turn-outbox.json' const MAX_TURN_ENDS = 200 @@ -27,7 +32,7 @@ const StoredOutbox = z.object({ turnEnds: z.array( z.object({ kind: z.literal('turn-ended'), - conversation: z.object({ source: z.literal('slack'), externalId: z.string().min(1) }), + conversation: ConnectorConversation, paneKey: z.string().min(1), turnId: z.number(), at: z.number(), @@ -75,7 +80,7 @@ function promptEvent(event: Extract): Connec } export function toConnectorEvent(event: ConversationEvent): ConnectorEvent | null { - const conversation = slackConversation(event.conversation) + const conversation = cloudConversation(event.conversation) if (!conversation) { return null } diff --git a/src/main/runtime/rpc/methods/connector.ts b/src/main/runtime/rpc/methods/connector.ts index b1343637541..1a7c5a9ae03 100644 --- a/src/main/runtime/rpc/methods/connector.ts +++ b/src/main/runtime/rpc/methods/connector.ts @@ -1,6 +1,7 @@ /** Gate every connector.* conversation handler on this host's experimentalConnectors setting, regardless of caller. */ import { delayUnlessAborted } from '../../../../shared/abort-aware-delay' +import { cloudConversation } from '../../../../shared/connector-cloud-contract' import { ConnectorAnswer, ConnectorCancel, @@ -94,20 +95,20 @@ export const CONNECTOR_METHODS = [ deliveryId ) }), - // `orca connector reply`: the calling pane names its conversation and turn; Orca Cloud posts it. + // `orca connector reply`: the calling pane names its cloud conversation and turn; Orca Cloud posts it. defineMethod({ name: 'connector.reply', permission: 'workspace', params: ConnectorReply, handler: async ({ paneKey, replyId, text }, context) => { const target = requireSeam(context.runtime).replyTarget(paneKey) - if (target?.key.source !== 'slack') { + const conversation = target && cloudConversation(target.key) + if (!target || !conversation) { throw new Error('not_a_cloud_conversation') } if (target.turnId === null) { throw new Error('no_current_turn') } - const conversation = { source: 'slack' as const, externalId: target.key.externalId } const sent = await context.runtime .getConnectorCloud() ?.reply({ replyId, conversation, turnId: target.turnId, text }) diff --git a/src/shared/connector-cloud-contract.ts b/src/shared/connector-cloud-contract.ts index 515e88bd621..01ea1305e11 100644 --- a/src/shared/connector-cloud-contract.ts +++ b/src/shared/connector-cloud-contract.ts @@ -37,12 +37,24 @@ export const ConnectorErrorResponse = z.object({ error: z.string().min(1) }) // Each hello gets a revision above every earlier one for this machine; polls, acks and events carry it. export const ConnectorHelloResponse = z.object({ helloRev: z.number().int().positive() }) -const ConnectorConversation = z.object({ - source: z.literal('slack'), +/** Where an Orca Cloud conversation comes from. */ +export const CONNECTOR_CLOUD_SOURCES = ['slack', 'discord', 'linear'] as const + +export const ConnectorConversation = z.object({ + source: z.enum(CONNECTOR_CLOUD_SOURCES), externalId: z.string().min(1) }) export type ConnectorConversation = z.infer +/** The conversation as Orca Cloud names it; null for one that never came through the cloud (e.g. `dev`). */ +export function cloudConversation(key: { + source: string + externalId: string +}): ConnectorConversation | null { + const parsed = ConnectorConversation.safeParse(key) + return parsed.success ? parsed.data : null +} + const DeliveryBase = { deliveryId: z.string().min(1), expiresAt: z.number().int().positive(),