diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 60a20f7929f..6f80739e757 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -329,6 +329,7 @@ import type { TerminalPaneLayoutNode, TerminalTab } from '../../shared/terminal-tab-types' +import { resolvePublishedPaneAgentIdentity } from '../../shared/published-pane-agent-identity' import type { TuiAgent } from '../../shared/tui-agent' import type { BranchPrefixStrategy } from '../../shared/ui-chrome-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' @@ -33029,6 +33030,16 @@ export class OrcaRuntimeService { return null } + /** Thin adapter so the summary builders stay declarative; the decision lives in `src/shared`. */ + private resolvePaneAgentIdentityField( + launchAgent: TuiAgent | null | undefined, + foregroundAgent: TuiAgent | null | undefined, + title: string | null + ): { agentIdentity?: TuiAgent } { + const agentIdentity = resolvePublishedPaneAgentIdentity({ launchAgent, foregroundAgent, title }) + return agentIdentity ? { agentIdentity } : {} + } + private buildTerminalSummary( leaf: RuntimeLeafRecord, worktreesById: Map, @@ -33069,7 +33080,12 @@ export class OrcaRuntimeService { lastOutputAt: leaf.lastOutputAt, preview: leaf.preview, ...(leaf.lastExitCause ? { exitCause: leaf.lastExitCause } : {}), - ...this.terminalExecutionHostField(leaf.ptyId, leaf.worktreeId) + ...this.terminalExecutionHostField(leaf.ptyId, leaf.worktreeId), + ...this.resolvePaneAgentIdentityField( + pty?.launchAgent, + pty?.foregroundAgent, + getLatestLeafTitle(leaf, tab?.title ?? null) + ) } } @@ -35048,7 +35064,12 @@ export class OrcaRuntimeService { lastOutputAt: pty.lastOutputAt, preview: pty.preview, ...(pty.lastExitCause ? { exitCause: pty.lastExitCause } : {}), - ...this.terminalExecutionHostField(pty.ptyId, pty.worktreeId) + ...this.terminalExecutionHostField(pty.ptyId, pty.worktreeId), + ...this.resolvePaneAgentIdentityField( + pty.launchAgent, + pty.foregroundAgent, + getLatestPtyTitle(pty) + ) } } diff --git a/src/main/runtime/orchestration/groups.test.ts b/src/main/runtime/orchestration/groups.test.ts index 81c2345c8b2..7fb30f733f0 100644 --- a/src/main/runtime/orchestration/groups.test.ts +++ b/src/main/runtime/orchestration/groups.test.ts @@ -18,7 +18,10 @@ function makeSummary( connected: opts.connected ?? true, writable: opts.writable ?? true, lastOutputAt: opts.lastOutputAt ?? null, - preview: opts.preview ?? '' + preview: opts.preview ?? '', + // Why spread and not a default: `agentIdentity` absent is meaningful (unknown), so the + // helper must be able to produce a summary that genuinely lacks the field. + ...(opts.agentIdentity ? { agentIdentity: opts.agentIdentity } : {}) } } @@ -97,209 +100,108 @@ describe('resolveGroupAddress', () => { }) describe('agent name groups', () => { - it('matches @claude by terminal title', () => { + // Why identity and not title: these groups used to match `@` against the terminal + // title, so any pane whose task text mentioned an agent received that agent's messages. + // Routing now reads the identity the host resolved from launch/process evidence it owns. + + it('routes to every pane the host resolved as that agent', () => { const terminals = [ - makeSummary('term_a', { title: 'Claude Code' }), - makeSummary('term_b', { title: 'Claude Code' }), - makeSummary('term_c', { title: 'Codex CLI' }) + makeSummary('term_a', { agentIdentity: 'claude' }), + makeSummary('term_b', { agentIdentity: 'claude' }), + makeSummary('term_c', { agentIdentity: 'codex' }) ] - const result = resolveGroupAddress('@claude', 'term_a', terminals, noStatus) - expect(result).toEqual(['term_b']) + expect(resolveGroupAddress('@claude', 'term_a', terminals, noStatus)).toEqual(['term_b']) }) - it('matches @mimo by terminal title', () => { + it.each([ + ['@codex', 'codex'], + ['@openclaude', 'openclaude'], + ['@mimo', 'mimo-code'], + ['@gemini', 'gemini'], + ['@droid', 'droid'], + ['@grok', 'grok'], + ['@cursor', 'cursor'], + ['@opencode', 'opencode'] + ])('routes %s to its agent id', (group, agentIdentity) => { const terminals = [ - makeSummary('term_a', { title: 'mimo' }), - makeSummary('term_b', { title: 'MiMo Code session' }), - makeSummary('term_c', { title: 'OpenCode' }) + makeSummary('sender'), + makeSummary('target', { agentIdentity: agentIdentity as never }), + makeSummary('other', { agentIdentity: 'claude' }) ] - const result = resolveGroupAddress('@mimo', 'term_a', terminals, noStatus) - expect(result).toEqual(['term_b']) + const expected = agentIdentity === 'claude' ? ['target', 'other'] : ['target'] + expect(resolveGroupAddress(group, 'sender', terminals, noStatus)).toEqual(expected) }) - it('matches @openclaude by terminal title', () => { - const terminals = [ - makeSummary('term_a', { title: 'OpenClaude' }), - makeSummary('term_b', { title: 'OpenClaude running' }), - makeSummary('term_c', { title: 'Claude Code' }) - ] - const result = resolveGroupAddress('@openclaude', 'term_a', terminals, noStatus) - expect(result).toEqual(['term_b']) + it('is case-insensitive for the group address', () => { + const terminals = [makeSummary('sender'), makeSummary('target', { agentIdentity: 'claude' })] + expect(resolveGroupAddress('@CLAUDE', 'sender', terminals, noStatus)).toEqual(['target']) }) - it('does not match OpenClaude titles through @claude', () => { + it('excludes the sender even when the sender is that agent', () => { const terminals = [ - makeSummary('term_a', { title: 'Claude Code' }), - makeSummary('term_b', { title: 'OpenClaude running' }) + makeSummary('sender', { agentIdentity: 'grok' }), + makeSummary('target', { agentIdentity: 'grok' }) ] - const result = resolveGroupAddress('@claude', 'term_a', terminals, noStatus) - expect(result).toEqual([]) + expect(resolveGroupAddress('@grok', 'sender', terminals, noStatus)).toEqual(['target']) }) - it('matches @codex by terminal title', () => { - const terminals = [ - makeSummary('term_a', { title: 'Codex CLI' }), - makeSummary('term_b', { title: 'Codex CLI' }) - ] - const result = resolveGroupAddress('@codex', 'term_a', terminals, noStatus) - expect(result).toEqual(['term_b']) + describe('a task title can no longer redirect a message', () => { + // The bug. Recorded titles of this exact shape exist: a Grok pane named + // "Switch Claude and Codex off the load balancer… - grok" received both @claude and @codex. + it('does not route @claude to a Codex pane whose task text names Claude', () => { + const terminals = [ + makeSummary('sender'), + makeSummary('codex_pane', { + agentIdentity: 'codex', + title: 'Review the Claude session-history fix' + }) + ] + expect(resolveGroupAddress('@claude', 'sender', terminals, noStatus)).toEqual([]) + }) + + it('does not route @codex to a Grok pane whose task text names Codex', () => { + const terminals = [ + makeSummary('sender'), + makeSummary('grok_pane', { + agentIdentity: 'grok', + title: 'Switch Claude and Codex off the load balancer… - grok' + }) + ] + expect(resolveGroupAddress('@codex', 'sender', terminals, noStatus)).toEqual([]) + expect(resolveGroupAddress('@grok', 'sender', terminals, noStatus)).toEqual(['grok_pane']) + }) + + it('does not route @cursor to a pane merely discussing a text cursor', () => { + const terminals = [ + makeSummary('sender'), + makeSummary('claude_pane', { + agentIdentity: 'claude', + title: 'fix the text cursor blink' + }) + ] + expect(resolveGroupAddress('@cursor', 'sender', terminals, noStatus)).toEqual([]) + }) }) - it('matches @droid by terminal title and excludes sender', () => { - const terminals = [ - makeSummary('term_a', { title: 'Droid ready' }), - makeSummary('term_b', { title: 'Droid ready' }), - makeSummary('term_c', { title: 'Droid - action required' }) - ] - const result = resolveGroupAddress('@droid', 'term_a', terminals, noStatus) - expect(result).toEqual(['term_b', 'term_c']) + describe('unknown identity fails closed', () => { + // Why: `agentIdentity` is absent when the host predates the field or had no evidence + // beyond the title. Delivery is an action, so unknown must not deliver. The sender sees + // no recipients, which is visible and recoverable; a message in the wrong agent's prompt + // is neither. + it('does not route to a pane with no resolved identity, whatever its title says', () => { + const terminals = [makeSummary('sender'), makeSummary('unknown', { title: 'Claude Code' })] + expect(resolveGroupAddress('@claude', 'sender', terminals, noStatus)).toEqual([]) + }) + + it('still routes the identity-free groups, which do not depend on the field', () => { + const terminals = [makeSummary('sender'), makeSummary('other', { title: 'Claude Code' })] + expect(resolveGroupAddress('@all', 'sender', terminals, noStatus)).toEqual(['other']) + }) }) - it('does not match Android, path, or hyphenated tokens through @droid', () => { - const terminals = [ - makeSummary('term_a', { title: 'Codex CLI' }), - makeSummary('term_b', { title: 'Android build' }), - makeSummary('term_c', { title: '/tmp/android' }), - makeSummary('term_d', { title: 'my-droid-worker' }) - ] - const result = resolveGroupAddress('@droid', 'term_a', terminals, noStatus) - expect(result).toEqual([]) - }) - - it('is case-insensitive for group address', () => { - const terminals = [makeSummary('term_a'), makeSummary('term_b', { title: 'Claude Code' })] - const result = resolveGroupAddress('@Claude', 'term_a', terminals, noStatus) - expect(result).toEqual(['term_b']) - }) - - it('matches @grok as a standalone title token and excludes sender', () => { - const terminals = [ - makeSummary('term_a', { title: 'Grok' }), - makeSummary('term_b', { title: 'GROK CLI' }), - makeSummary('term_c', { title: '⠋ Grok' }), - makeSummary('term_d', { title: 'ngrok' }), - makeSummary('term_e', { title: '/tmp/grok' }), - makeSummary('term_f', { title: 'my-grok-worker' }), - makeSummary('term_g', { title: 'Codex CLI' }) - ] - - const result = resolveGroupAddress('@GrOk', 'term_a', terminals, noStatus) - - expect(result).toEqual(['term_b', 'term_c']) - }) - - // Why: the resolver sees the raw OSC title, and Grok CLI's real working/session - // titles carry a trailing " - grok" identity or a spinner-collapsed "⠋ grok" - // (see terminal-title-agent-type.ts). Prove those production shapes resolve. - it('matches real Grok OSC working and session titles', () => { - const terminals = [ - makeSummary('coordinator', { title: 'Coordinator' }), - makeSummary('term_rotating', { title: '⠋ - fix the flaky suite - grok' }), - makeSummary('term_collapsed', { title: '⠋ grok' }), - makeSummary('term_session', { title: 'Fix the auth bug - grok' }) - ] - - const result = resolveGroupAddress('@grok', 'coordinator', terminals, noStatus) - - expect(result).toEqual(['term_rotating', 'term_collapsed', 'term_session']) - }) - - // Why: Windows agent titles can surface the launcher process name (`grok.exe`); - // the shared matcher accepts .exe/.cmd/.bat/.ps1 suffixes but still rejects - // arbitrary dotted fragments like `grok.py`. - it('matches Windows launcher-suffix titles but not arbitrary dotted tokens', () => { - const terminals = [ - makeSummary('coordinator', { title: 'Coordinator' }), - makeSummary('term_exe', { title: 'grok.exe' }), - makeSummary('term_cmd', { title: 'grok.cmd running' }), - makeSummary('term_dotted', { title: 'grok.py' }) - ] - - const result = resolveGroupAddress('@grok', 'coordinator', terminals, noStatus) - - expect(result).toEqual(['term_exe', 'term_cmd']) - }) - - // Why: these are the titles cursor-agent natively emits and the ones Orca synthesizes - // from Cursor hooks, so each must resolve. - it('matches native and Orca-synthesized Cursor titles', () => { - const terminals = [ - makeSummary('coordinator', { title: 'Coordinator' }), - makeSummary('term_native', { title: 'Cursor Agent' }), - makeSummary('term_working', { title: '⠋ Cursor Agent' }), - makeSummary('term_idle', { title: 'Cursor ready' }), - makeSummary('term_permission', { title: 'Cursor - action required' }) - ] - - const result = resolveGroupAddress('@cursor', 'coordinator', terminals, noStatus) - - expect(result).toEqual(['term_native', 'term_working', 'term_idle', 'term_permission']) - }) - - it('is case-insensitive for @cursor', () => { - const terminals = [ - makeSummary('coordinator', { title: 'Coordinator' }), - makeSummary('term_b', { title: 'CURSOR READY' }) - ] - - const result = resolveGroupAddress('@CuRsOr', 'coordinator', terminals, noStatus) - - expect(result).toEqual(['term_b']) - }) - - // Why: this is the false positive that a whole-token matcher cannot catch. Claude - // and Codex put their task summary in the OSC title, and none of Claude's status - // prefixes carries its own name, so "cursor" as ordinary editor vocabulary is the - // only agent token such a title has. Routing @cursor there would write into a live - // non-Cursor agent's prompt. - it('does not match another agent whose task title mentions a text cursor', () => { - const terminals = [ - makeSummary('coordinator', { title: 'Coordinator' }), - makeSummary('term_claude_working', { - title: '⠋ preserve cursor visibility across replays' - }), - makeSummary('term_claude_idle', { title: '✳ Fix the text cursor blink' }), - makeSummary('term_claude_dot', { title: '. fix cursor position' }), - makeSummary('term_claude_star', { title: '* cursor rendering done' }), - makeSummary('term_codex', { title: '⠋ Codex: fix cursor offsets' }), - makeSummary('term_grok', { title: '⠋ - restoring cursor state - grok' }), - makeSummary('term_shell', { title: 'Terminal Cursor and Orca slows down' }) - ] - - const result = resolveGroupAddress('@cursor', 'coordinator', terminals, noStatus) - - expect(result).toEqual([]) - }) - - // Why: pin the deliberate tradeoff. A renamed Cursor pane outranks its OSC title, so it - // is group-unaddressable and resolves to nothing instead of resolving loosely. A silent - // miss (address it by handle) is preferred over delivering into another agent's prompt. - it('does not match a renamed Cursor terminal or the bare process name', () => { - const terminals = [ - makeSummary('coordinator', { title: 'Coordinator' }), - makeSummary('term_renamed', { title: 'Cursor - reviewer' }), - makeSummary('term_worker', { title: 'cursor worker 2' }), - makeSummary('term_process', { title: 'cursor-agent' }) - ] - - const result = resolveGroupAddress('@cursor', 'coordinator', terminals, noStatus) - - expect(result).toEqual([]) - }) - - it('does not match cursor paths, hyphenated compounds, or dotted tokens', () => { - const terminals = [ - makeSummary('coordinator', { title: 'Coordinator' }), - makeSummary('term_rules', { title: '~/cursor-rules' }), - makeSummary('term_path', { title: '/tmp/cursor' }), - makeSummary('term_worker', { title: 'my-cursor-worker' }), - makeSummary('term_file', { title: 'render-cursor-after-bracketed-paste' }), - makeSummary('term_dotted', { title: 'cursor.ts' }) - ] - - const result = resolveGroupAddress('@cursor', 'coordinator', terminals, noStatus) - - expect(result).toEqual([]) + it('returns no recipients for an unknown group', () => { + const terminals = [makeSummary('sender'), makeSummary('target', { agentIdentity: 'claude' })] + expect(resolveGroupAddress('@nonsense', 'sender', terminals, noStatus)).toEqual([]) }) }) diff --git a/src/main/runtime/orchestration/groups.ts b/src/main/runtime/orchestration/groups.ts index 6f31f5df261..64c0900bfb9 100644 --- a/src/main/runtime/orchestration/groups.ts +++ b/src/main/runtime/orchestration/groups.ts @@ -1,6 +1,5 @@ -import { isCursorAgentTitle } from '../../../shared/agent-title-core' -import { buildAgentNameRe } from '../../../shared/agent-name-token-match' import type { RuntimeTerminalSummary } from '../../../shared/runtime-types' +import type { TuiAgent } from '../../../shared/tui-agent' // Why: group addresses enable broadcast messaging to logical groups of agents. // Resolution is done at send-time: one message record per recipient, same thread_id, @@ -24,23 +23,36 @@ export function isGroupAddress(to: string): boolean { return to.startsWith('@') } -// Why: a name token identifies an agent only when the name is a coined word. `cursor` is -// also ordinary vocabulary in another agent's task-summary title ("fix the text cursor -// blink"), so token-matching it would route @cursor into a live Claude/Codex prompt. Names -// with that ambiguity register the identity predicate delivery already applies to them. -const GROUP_TITLE_MATCHERS: Partial boolean>> = { - cursor: isCursorAgentTitle +/** Group name to the agent id the host publishes for a pane. */ +const GROUP_AGENT_IDS: Record = { + claude: 'claude', + openclaude: 'openclaude', + codex: 'codex', + opencode: 'opencode', + mimo: 'mimo-code', + gemini: 'gemini', + droid: 'droid', + grok: 'grok', + cursor: 'cursor' } -function titleMatchesAgentNameGroup(title: string, agentName: string): boolean { - const identityMatcher = GROUP_TITLE_MATCHERS[agentName as AgentNameGroup] - if (identityMatcher) { - return identityMatcher(title) - } - // Why: reuse the shared whole-token matcher so orchestration groups honor the - // same Windows launcher-suffix rule (e.g. `grok.exe`) as the rest of Orca's - // agent-title detection, instead of maintaining a divergent regex here. - return buildAgentNameRe(agentName).test(title) +/** + * Whether this terminal IS the addressed agent. + * + * Why the host's resolved identity and not the title: a terminal title is a decoration channel + * that routinely contains other agents' names, because people describe agent work in their task + * titles. Matching `@claude` against the title delivered the message to any pane whose task text + * happened to say "claude" — a Codex pane reviewing a Claude PR received Claude's instructions. + * Recorded titles like "Switch Claude and Codex off the load balancer… - grok" are the ordinary + * case, not a contrived one. + * + * Why an absent identity means NO: `agentIdentity` is absent when the host predates the field or + * had no evidence beyond the title. Delivery is an action, so unknown fails closed. Not + * delivering is visible and recoverable — the sender sees no recipients; delivering to the wrong + * agent is neither. + */ +function terminalIsAgent(terminal: RuntimeTerminalSummary, agentName: AgentNameGroup): boolean { + return terminal.agentIdentity === GROUP_AGENT_IDS[agentName] } export function resolveGroupAddress( @@ -76,9 +88,9 @@ export function resolveGroupAddress( .map((t) => t.handle) } - // Why: agent-name groups (@claude, @droid, etc.) match by terminal title so - // the sender can address all instances of a particular agent type without - // knowing their handles. + // Why: agent-name groups (@claude, @droid, etc.) resolve against the identity the HOST + // published for each pane, so the sender can address every instance of an agent without + // knowing their handles — and without a task title being able to redirect the message. const agentName = group.slice(1) // remove @ if ((AGENT_NAME_GROUPS as readonly string[]).includes(agentName)) { return terminals @@ -86,7 +98,7 @@ export function resolveGroupAddress( if (t.handle === senderHandle) { return false } - return titleMatchesAgentNameGroup(t.title ?? '', agentName) + return terminalIsAgent(t, agentName as AgentNameGroup) }) .map((t) => t.handle) } diff --git a/src/main/runtime/rpc/methods/orchestration-send.test.ts b/src/main/runtime/rpc/methods/orchestration-send.test.ts index dafcbcc4327..34f8ef04995 100644 --- a/src/main/runtime/rpc/methods/orchestration-send.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-send.test.ts @@ -587,7 +587,10 @@ describe('orchestration RPC methods', () => { connected: opts.connected ?? true, writable: opts.writable ?? true, lastOutputAt: opts.lastOutputAt ?? null, - preview: opts.preview ?? '' + preview: opts.preview ?? '', + // Why spread: absent `agentIdentity` means unknown, so the helper must be able to + // produce a summary that genuinely lacks the field. + ...(opts.agentIdentity ? { agentIdentity: opts.agentIdentity } : {}) } } @@ -722,11 +725,11 @@ describe('orchestration RPC methods', () => { expect(result.messages[0].to_handle).toBe('term_b') }) - it('fans out agent name group (@claude) by title match', async () => { + it('fans out an agent name group by host-resolved identity', async () => { setupWithTerminals([ - makeSummary('term_a', { title: 'Claude Code' }), - makeSummary('term_b', { title: 'Claude Code' }), - makeSummary('term_c', { title: 'Codex' }) + makeSummary('term_a', { agentIdentity: 'claude' }), + makeSummary('term_b', { agentIdentity: 'claude' }), + makeSummary('term_c', { agentIdentity: 'codex' }) ]) const result = (await call('orchestration.send', { @@ -739,11 +742,13 @@ describe('orchestration RPC methods', () => { expect(result.messages[0].to_handle).toBe('term_b') }) - it('fans out @droid by title match', async () => { + it('fans out @droid without claiming a pane whose title merely contains the word', async () => { setupWithTerminals([ - makeSummary('term_a', { title: 'Codex' }), - makeSummary('term_b', { title: 'Droid ready' }), - makeSummary('term_c', { title: 'Android build' }) + makeSummary('term_a', { agentIdentity: 'codex' }), + makeSummary('term_b', { agentIdentity: 'droid' }), + // Why kept: "Android build" contains `droid` as a substring. It was excluded before by + // whole-token matching and is excluded now because its identity is not droid. + makeSummary('term_c', { agentIdentity: 'claude', title: 'Android build' }) ]) const result = (await call('orchestration.send', { @@ -756,11 +761,12 @@ describe('orchestration RPC methods', () => { expect(result.messages[0].to_handle).toBe('term_b') }) - it('fans out @cursor by title match without claiming a cursor-mentioning title', async () => { + it('fans out @cursor without claiming a Claude pane discussing a text cursor', async () => { setupWithTerminals([ - makeSummary('term_a', { title: 'Codex' }), - makeSummary('term_b', { title: 'Cursor ready' }), - makeSummary('term_c', { title: '✳ Fix the text cursor blink' }) + makeSummary('term_a', { agentIdentity: 'codex' }), + makeSummary('term_b', { agentIdentity: 'cursor' }), + // The original hazard, now excluded structurally rather than by a bespoke predicate. + makeSummary('term_c', { agentIdentity: 'claude', title: '✳ Fix the text cursor blink' }) ]) const result = (await call('orchestration.send', { diff --git a/src/shared/published-pane-agent-identity.test.ts b/src/shared/published-pane-agent-identity.test.ts new file mode 100644 index 00000000000..2a0a5ef3418 --- /dev/null +++ b/src/shared/published-pane-agent-identity.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { resolvePublishedPaneAgentIdentity } from './published-pane-agent-identity' + +const resolve = resolvePublishedPaneAgentIdentity + +describe('resolvePublishedPaneAgentIdentity', () => { + describe('a task title cannot name the pane', () => { + // Minimized from real recorded titles. Each is a pane of one agent whose task text names + // another; before this, `@` routing delivered to them. + it.each([ + ['Switch Claude and Codex off the load balancer… - grok', 'grok'], + ['Review the Claude session-history fix', 'codex'], + ['✳ Fix the text cursor blink', 'claude'] + ])('keeps %j as its launched agent', (title, launchAgent) => { + expect(resolve({ launchAgent: launchAgent as never, title })).toBe(launchAgent) + }) + }) + + it('keeps the launch record when an unambiguous title names a different agent', () => { + // The ranking assertion. Every other case here either yields no title evidence or agrees with + // the launch record, so without this one the suite passes even with title ranked FIRST — + // verified by mutation. `✳ Claude Code` is a parseable, unambiguous Claude title, and it still + // must not rename a pane Orca launched as Codex. + expect(resolve({ launchAgent: 'codex', title: '✳ Claude Code' })).toBe('codex') + }) + + it('prefers the live foreground process to the launch record', () => { + // The pane was launched as Claude and the user then started Codex in it. The process is the + // more direct observation, so it wins. + expect(resolve({ launchAgent: 'claude', foregroundAgent: 'codex' })).toBe('codex') + }) + + it('falls back to the title when the host has no process or launch evidence', () => { + // Hook-less agents over SSH surface only a decorated title; dropping it would make those + // panes unaddressable. It is last, not absent. + expect(resolve({ title: '✳ Claude Code' })).toBe('claude') + }) + + it('publishes nothing when the title names no agent unambiguously', () => { + // Absence is meaningful: it tells a caller to fail closed rather than guess. + expect(resolve({ title: '◐ Rebase PR #14624 onto main' })).toBeUndefined() + expect(resolve({ title: 'Fix the codex bug' })).toBeUndefined() + expect(resolve({})).toBeUndefined() + }) + + it('publishes nothing for a hyphenated worktree name that contains an agent word', () => { + expect(resolve({ title: 'review-14600-codex' })).toBeUndefined() + }) +}) diff --git a/src/shared/published-pane-agent-identity.ts b/src/shared/published-pane-agent-identity.ts new file mode 100644 index 00000000000..1531c447dbd --- /dev/null +++ b/src/shared/published-pane-agent-identity.ts @@ -0,0 +1,36 @@ +import { collectAgentTitleEvidence } from './agent-title-evidence' +import { resolvePaneAgentIdentity } from './pane-agent-identity-resolver' +import type { TuiAgent } from './tui-agent' + +/** + * The agent a host publishes for a pane, for consumers that ACT on identity. + * + * Kept out of the runtime class so it can be tested without one, and so routing, delivery and the + * UI all read the same decision instead of each re-deriving it. + * + * Title is included but ranks last, and contributes only when the evidence parser finds an + * unambiguous name. A task title that merely mentions an agent yields no title evidence at all, + * which is the whole point: "Switch Claude and Codex off the load balancer… - grok" is a Grok + * pane, and used to receive both `@claude` and `@codex`. + * + * Returns undefined when nothing is known, and absence is published as absence. A caller that + * authorizes an action must fail closed on it rather than falling back to parsing the title. + */ +export function resolvePublishedPaneAgentIdentity(args: { + launchAgent?: TuiAgent | null + foregroundAgent?: TuiAgent | null + title?: string | null +}): TuiAgent | undefined { + const titleAgent = args.title ? collectAgentTitleEvidence(args.title).agent : null + return ( + resolvePaneAgentIdentity({ + evidence: [ + ...(args.foregroundAgent + ? [{ source: 'process' as const, agent: args.foregroundAgent }] + : []), + ...(args.launchAgent ? [{ source: 'launch' as const, agent: args.launchAgent }] : []), + ...(titleAgent ? [{ source: 'title' as const, agent: titleAgent }] : []) + ] + }).agent ?? undefined + ) +} diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 7b1b224435c..2f98417501c 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -479,6 +479,19 @@ export type RuntimeTerminalSummary = { /** Where this terminal actually runs. Absent when the host predates the field * or could not name the host — never read an absent value as local. */ executionHostId?: ExecutionHostId + /** + * Which agent the HOST resolved for this pane, from launch/process evidence it owns. + * + * Why the host and not the reader: a reader has only `title`, and a title is a decoration + * channel — a Codex pane whose task text says "claude" is indistinguishable from a Claude pane + * to anyone parsing the string. Consumers that act on identity (message routing, delivery) + * must use this, not `title`. + * + * Absent means UNKNOWN, never "no agent": the host predates the field, or it had no evidence + * beyond the title. Callers that authorize an action on identity must fail closed on absence + * rather than falling back to parsing `title`. + */ + agentIdentity?: TuiAgent } export type RuntimeTerminalVisualTerminalNode = {