fix(orchestration): route @agent messages by resolved identity, not terminal title

`@claude` picked its recipients with `buildAgentNameRe('claude').test(title)`, so any pane whose
TITLE contained the word received Claude's messages. Terminal titles carry task text, and people
describe agent work in them, so this is the ordinary case rather than a contrived one: the
recorded title "Switch Claude and Codex off the load balancer… - grok" is a Grok pane that
received both @claude and @codex. Misdelivered instructions, not a cosmetic slip.

The cause is that `RuntimeTerminalSummary` carried no identity at all — `title` was the only
identity-ish field on it, so routing by title was the only option available. Fix the input:

- `RuntimeTerminalSummary.agentIdentity?: TuiAgent` — optional, host-resolved from launch and
  foreground-process evidence the host owns, with the title ranked last and contributing only
  when the evidence parser finds an unambiguous name. A title that merely mentions an agent
  yields no evidence, which is the whole point.
- `resolvePublishedPaneAgentIdentity` in `src/shared` rather than inside the runtime class, so
  the decision is testable without a runtime and so routing, delivery and the UI cannot drift.
- Groups match `agentIdentity`; the title matcher and its bespoke Cursor predicate are deleted.

Unknown fails closed. `agentIdentity` is absent when the host predates the field or had no
evidence beyond the title, and delivery is an action: not delivering is visible and recoverable
(the sender sees no recipients), while delivering to the wrong agent is neither. The optional
field is additive, so an old client simply ignores it (wire rule 1).

This is also the first real caller of the evidence parser and the identity resolver.

Tests: 27 in groups, 8 for the publisher, 3 RPC fan-out cases updated to the new contract. The
`@cursor`-must-not-match-"text cursor blink" hazard is now excluded structurally instead of by a
per-agent predicate.

Verified non-vacuous by mutation: swapping the process/title ranks fails 2 publisher assertions,
and reverting groups to title matching fails 15 of 27. One earlier mutation silently failed to
apply after formatting reflowed the block — the file was checked before trusting the result.
This commit is contained in:
Brennan Benson
2026-08-24 14:57:49 -07:00
parent ce834946ea
commit 709022ea8f
7 changed files with 261 additions and 222 deletions
+23 -2
View File
@@ -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<string, ResolvedWorktree>,
@@ -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)
)
}
}
+88 -186
View File
@@ -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 `@<name>` 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([])
})
})
+33 -21
View File
@@ -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<Record<AgentNameGroup, (title: string) => boolean>> = {
cursor: isCursorAgentTitle
/** Group name to the agent id the host publishes for a pane. */
const GROUP_AGENT_IDS: Record<AgentNameGroup, TuiAgent> = {
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)
}
@@ -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', {
@@ -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, `@<other>` 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()
})
})
@@ -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
)
}
+13
View File
@@ -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 = {