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.
This commit is contained in:
Jinwoo Hong
2026-10-26 19:25:54 -04:00
committed by GitHub
parent 0b30b284c6
commit c1198d2f41
11 changed files with 64 additions and 34 deletions
+2 -2
View File
@@ -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 <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.'
]
}
@@ -65,7 +65,7 @@ export function dispatchDelivery(
let done: Promise<ConnectorDeliveryResult>
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' })
}
+2 -2
View File
@@ -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[] }
@@ -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', () => {
+5 -3
View File
@@ -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 } }
}
@@ -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: [] }])
@@ -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'])
})
})
@@ -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<ConnectorEvent, { kind: 'snapshot' }>
@@ -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 }] : []
})
@@ -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<ConnectorEvent, { kind: 'prompt' }>): Connec
}
export function toConnectorEvent(event: ConversationEvent): ConnectorEvent | null {
const conversation = slackConversation(event.conversation)
const conversation = cloudConversation(event.conversation)
if (!conversation) {
return null
}
+4 -3
View File
@@ -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 })
+14 -2
View File
@@ -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<typeof ConnectorConversation>
/** 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(),