mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
feat(agent-status): add the pane agent identity resolver
Four ladders answer "which agent is in this pane" independently — the tab icon, the open-tab/search occupant, the sidebar title rows, and the sidebar hook-row fallback — and they disagree. Two consult the terminal title before the launch record, so a string Orca parsed outranks a fact Orca owns. resolvePaneAgentIdentity is the single ranked answer. Two rules, one of which is not an ordering: 1. Evidence is ranked by how directly it observes the process; a display title is last. 2. Each observation carries the runId of the agent run it describes. Evidence from a superseded run is INELIGIBLE, not merely outranked. Rule 2 is the part reordering could never supply. A completed hook naming A plus a title naming B is either a bug (hook right, title stale) or a legitimate pane reclaim (title right) — identical signals, opposite correct answers. Run ids make them different facts: in the bug both belong to the current run; in the reclaim the hook belongs to a previous one. That pair ships as a test asserting the two produce opposite answers from the same evidence. Missing run ids are treated as eligible. Absence means "this peer does not publish them", not "this is stale", so an old host's rows are never blanked. Sibling evidence is opt-in so pane-scoped consumers cannot inherit another pane's agent. No consumer imports this yet; each migrates separately with its own evidence. Verified non-vacuous: reversing the authority order fails 10 of 18 assertions and removing the run filter fails 3.
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
PANE_AGENT_EVIDENCE_SOURCES,
|
||||
type PaneAgentEvidence,
|
||||
resolvePaneAgentIdentity
|
||||
} from './pane-agent-identity-resolver'
|
||||
|
||||
const resolve = (evidence: PaneAgentEvidence[], extra = {}) =>
|
||||
resolvePaneAgentIdentity({ evidence, ...extra })
|
||||
|
||||
describe('resolvePaneAgentIdentity', () => {
|
||||
describe('a display title is the last thing consulted', () => {
|
||||
it.each(PANE_AGENT_EVIDENCE_SOURCES.filter((s) => s !== 'title' && s !== 'sibling'))(
|
||||
'lets %s outrank a conflicting title',
|
||||
(source) => {
|
||||
const result = resolve([
|
||||
{ source: 'title', agent: 'codex' },
|
||||
{ source, agent: 'grok' }
|
||||
])
|
||||
expect(result.agent).toBe('grok')
|
||||
expect(result.source).toBe(source)
|
||||
}
|
||||
)
|
||||
|
||||
it('uses the title only when nothing else is eligible', () => {
|
||||
expect(resolve([{ source: 'title', agent: 'codex' }])).toMatchObject({
|
||||
agent: 'codex',
|
||||
source: 'title'
|
||||
})
|
||||
})
|
||||
|
||||
it('outranks the launch record with nothing weaker than the launch record', () => {
|
||||
// The tab ladder currently puts the parsed title ABOVE activeLaunchAgent, so a string Orca
|
||||
// parsed beats a fact Orca owns. That inversion cannot be expressed here.
|
||||
const result = resolve([
|
||||
{ source: 'launch', agent: 'claude' },
|
||||
{ source: 'title', agent: 'gemini' }
|
||||
])
|
||||
expect(result.agent).toBe('claude')
|
||||
})
|
||||
})
|
||||
|
||||
describe('run generation separates the bug from the legitimate reclaim', () => {
|
||||
// Both shapes are `completed hook = A, title = B`. Ordering alone cannot tell them apart.
|
||||
const shape = (hookRun: number, titleRun: number): PaneAgentEvidence[] => [
|
||||
{ source: 'completed-hook', agent: 'claude', runId: hookRun },
|
||||
{ source: 'title', agent: 'codex', runId: titleRun }
|
||||
]
|
||||
|
||||
it('keeps the completed hook when both belong to the current run', () => {
|
||||
// The reported bug: nothing new started, so the hook is still the truth.
|
||||
const result = resolvePaneAgentIdentity({ evidence: shape(7, 7), currentRunId: 7 })
|
||||
expect(result).toMatchObject({ agent: 'claude', source: 'completed-hook' })
|
||||
expect(result.supersededSources).toEqual([])
|
||||
})
|
||||
|
||||
it('drops the completed hook once a new run has started', () => {
|
||||
// The legitimate reclaim: the pane was reused, so run 7's hook describes an agent that is
|
||||
// no longer there. It is ineligible, not merely outranked.
|
||||
const result = resolvePaneAgentIdentity({ evidence: shape(7, 8), currentRunId: 8 })
|
||||
expect(result).toMatchObject({ agent: 'codex', source: 'title' })
|
||||
expect(result.supersededSources).toEqual(['completed-hook'])
|
||||
})
|
||||
|
||||
it('produces opposite answers from identical evidence, given only the run ids', () => {
|
||||
// The whole point, stated as one assertion.
|
||||
const bug = resolvePaneAgentIdentity({ evidence: shape(7, 7), currentRunId: 7 })
|
||||
const reclaim = resolvePaneAgentIdentity({ evidence: shape(7, 8), currentRunId: 8 })
|
||||
expect(bug.agent).not.toBe(reclaim.agent)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mixed-version peers', () => {
|
||||
it('treats evidence with no run id as eligible', () => {
|
||||
// An old host publishes no run ids. Treating unknown as stale would blank every row.
|
||||
const result = resolvePaneAgentIdentity({
|
||||
evidence: [{ source: 'completed-hook', agent: 'claude' }],
|
||||
currentRunId: 9
|
||||
})
|
||||
expect(result.agent).toBe('claude')
|
||||
})
|
||||
|
||||
it('disables run filtering entirely when the pane has no current run', () => {
|
||||
const result = resolvePaneAgentIdentity({
|
||||
evidence: [{ source: 'completed-hook', agent: 'claude', runId: 3 }]
|
||||
})
|
||||
expect(result).toMatchObject({ agent: 'claude', supersededSources: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('siblings are tab-scoped', () => {
|
||||
it('ignores a sibling by default', () => {
|
||||
expect(resolve([{ source: 'sibling', agent: 'codex' }]).agent).toBeNull()
|
||||
})
|
||||
|
||||
it('accepts a sibling when the caller opts in', () => {
|
||||
expect(resolve([{ source: 'sibling', agent: 'codex' }], { allowSibling: true }).agent).toBe(
|
||||
'codex'
|
||||
)
|
||||
})
|
||||
|
||||
it('still ranks a sibling above a title', () => {
|
||||
const result = resolve(
|
||||
[
|
||||
{ source: 'title', agent: 'grok' },
|
||||
{ source: 'sibling', agent: 'codex' }
|
||||
],
|
||||
{ allowSibling: true }
|
||||
)
|
||||
expect(result.source).toBe('sibling')
|
||||
})
|
||||
})
|
||||
|
||||
describe('no eligible evidence', () => {
|
||||
it('returns null rather than guessing', () => {
|
||||
expect(resolve([])).toMatchObject({ agent: null, source: null })
|
||||
})
|
||||
|
||||
it('returns null when every source belongs to a superseded run', () => {
|
||||
const result = resolvePaneAgentIdentity({
|
||||
evidence: [
|
||||
{ source: 'live-hook', agent: 'claude', runId: 1 },
|
||||
{ source: 'title', agent: 'codex', runId: 1 }
|
||||
],
|
||||
currentRunId: 2
|
||||
})
|
||||
expect(result.agent).toBeNull()
|
||||
expect(result.supersededSources).toEqual(['live-hook', 'title'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('input order does not decide the answer', () => {
|
||||
it('resolves the same regardless of how evidence is listed', () => {
|
||||
const evidence: PaneAgentEvidence[] = [
|
||||
{ source: 'title', agent: 'codex' },
|
||||
{ source: 'launch', agent: 'grok' },
|
||||
{ source: 'live-hook', agent: 'claude' }
|
||||
]
|
||||
const forward = resolve([...evidence])
|
||||
const reverse = resolve(evidence.toReversed())
|
||||
expect(forward).toEqual(reverse)
|
||||
expect(forward.source).toBe('live-hook')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { TuiAgent } from './tui-agent'
|
||||
|
||||
/**
|
||||
* One place that answers "which agent is in this pane".
|
||||
*
|
||||
* Today four ladders answer it independently — the tab icon, the open-tab/search occupant, the
|
||||
* sidebar title rows, and the sidebar hook-row fallback — and they disagree. Two of them consult
|
||||
* the terminal title before the launch record, so a string Orca parsed outranks a fact Orca owns.
|
||||
*
|
||||
* Two rules make this resolvable where reordering alone could not:
|
||||
*
|
||||
* 1. Evidence is ranked by how directly it observes the process, and a display title is last.
|
||||
* 2. Every observation carries the `runId` of the agent run it belongs to. Evidence from a run
|
||||
* that has since been replaced is INELIGIBLE rather than merely outranked.
|
||||
*
|
||||
* Rule 2 is what separates two situations that are otherwise identical. A completed hook naming
|
||||
* A plus a title naming B is either a bug (the hook is right, the title is stale) or a legitimate
|
||||
* reclaim (the pane was reused and B really is there). Same signals, opposite answers. With run
|
||||
* ids they are different facts: in the bug both belong to the current run, and in the reclaim the
|
||||
* hook belongs to a previous one.
|
||||
*
|
||||
* A run is advanced only on positive evidence that a new agent started in the pane — an accepted
|
||||
* launch, a recognized command at a shell prompt, a host-confirmed foreground change, a new
|
||||
* provider session. Never by a title changing, and never by transport loss.
|
||||
*/
|
||||
export const PANE_AGENT_EVIDENCE_SOURCES = [
|
||||
/** A live provider hook for a turn in progress. The agent is running and said so. */
|
||||
'live-hook',
|
||||
/** The pane's foreground process, as read on the execution host. */
|
||||
'process',
|
||||
/** Orca launched, resumed, or accepted a command for this agent. A fact Orca owns. */
|
||||
'launch',
|
||||
/** A provider hook from a turn that finished. Still authoritative about identity. */
|
||||
'completed-hook',
|
||||
/** A sleeping session record restored for this pane. */
|
||||
'sleeping-session',
|
||||
/** Another pane in the same tab. Tab-level surfaces only; never pane-scoped routing. */
|
||||
'sibling',
|
||||
/** Parsed from the terminal title. A decoration channel; anyone can type an agent's name. */
|
||||
'title'
|
||||
] as const
|
||||
export type PaneAgentEvidenceSource = (typeof PANE_AGENT_EVIDENCE_SOURCES)[number]
|
||||
|
||||
/** Authority order, strongest first. Position here is the ONLY place precedence is expressed. */
|
||||
const SOURCE_RANK: readonly PaneAgentEvidenceSource[] = PANE_AGENT_EVIDENCE_SOURCES
|
||||
|
||||
export type PaneAgentEvidence = {
|
||||
source: PaneAgentEvidenceSource
|
||||
agent: TuiAgent
|
||||
/**
|
||||
* The agent run this evidence describes. Evidence whose run is not the pane's current run is
|
||||
* ineligible. Undefined means unknown — from an old peer that does not publish run ids, or an
|
||||
* ingress not yet stamped — and is treated as eligible, so a missing field never blanks a row.
|
||||
*/
|
||||
runId?: number
|
||||
}
|
||||
|
||||
export type PaneAgentIdentityInput = {
|
||||
evidence: readonly PaneAgentEvidence[]
|
||||
/** The pane's current run. Undefined disables run filtering entirely (old peer, mixed version). */
|
||||
currentRunId?: number
|
||||
/**
|
||||
* Pane-scoped consumers must not inherit another pane's agent. Sibling evidence is dropped
|
||||
* unless the caller is a tab-level surface that opted in.
|
||||
*/
|
||||
allowSibling?: boolean
|
||||
}
|
||||
|
||||
export type PaneAgentIdentity = {
|
||||
agent: TuiAgent | null
|
||||
/** Which class of evidence decided it. Null when nothing eligible remained. */
|
||||
source: PaneAgentEvidenceSource | null
|
||||
/** Evidence discarded because it belongs to a superseded run. Surfaced for diagnostics. */
|
||||
supersededSources: readonly PaneAgentEvidenceSource[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves one pane's agent from ranked evidence.
|
||||
*
|
||||
* Returns null rather than guessing. A pane with no eligible evidence shows no agent, which is
|
||||
* recoverable; showing the wrong agent is not, and at the action surfaces (orchestration routing,
|
||||
* mailbox delivery, prompt-cache timers) it is a misdelivery rather than a cosmetic slip.
|
||||
*/
|
||||
export function resolvePaneAgentIdentity(input: PaneAgentIdentityInput): PaneAgentIdentity {
|
||||
const superseded: PaneAgentEvidenceSource[] = []
|
||||
const eligible = input.evidence.filter((item) => {
|
||||
if (item.source === 'sibling' && input.allowSibling !== true) {
|
||||
return false
|
||||
}
|
||||
// Why undefined is eligible: absence means "this peer does not publish run ids", not "this
|
||||
// belongs to an old run". Treating unknown as stale would blank every row from an old host.
|
||||
if (input.currentRunId === undefined || item.runId === undefined) {
|
||||
return true
|
||||
}
|
||||
if (item.runId === input.currentRunId) {
|
||||
return true
|
||||
}
|
||||
superseded.push(item.source)
|
||||
return false
|
||||
})
|
||||
|
||||
for (const source of SOURCE_RANK) {
|
||||
const match = eligible.find((item) => item.source === source)
|
||||
if (match) {
|
||||
return { agent: match.agent, source, supersededSources: superseded }
|
||||
}
|
||||
}
|
||||
return { agent: null, source: null, supersededSources: superseded }
|
||||
}
|
||||
Reference in New Issue
Block a user