mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
fix(agents): keep OMP identity and forward ask/approval events (#15713)
* fix(agents): keep OMP identity and forward ask/approval events (STA-4130) A live OMP pane was re-owned as Pi because the generic pi-compatible fallback always won, ask events blocked without a question payload, and OMP suppressed tool_approval_* unless an extension registered handlers. Mark Pi as the title-group fallback so a specific OMP identity is not downgraded, publish OMP ask input as the existing questions envelope, and forward tool_approval_requested/resolved onto blocked/working. STA-4130 Related to #14278 Co-authored-by: devatnull <59279509+devatnull@users.noreply.github.com> * fix(agents): keep launch Pi ownership over OMP wrapper frames (STA-4130) The pi-compatible fallback treated every generic Pi owner as inferred, so an explicit launch-Pi pane (and a launchless Pi pane with an OMP-shaped title) was re-owned as OMP. Launch provenance now stays authoritative; only an inferred status-frame owner yields to a specific sibling, and same-group titles no longer count as reuse. STA-4130 * fix(agents): drop Pi wrapper idle titles while OMP hook is active (STA-4130) Title-completion suppression compared pick-a-winner ownership, so a Pi ready frame looked like a different agent than a live OMP hook and fired a spurious task-complete notification. Reuse checks now use the title-identity group. STA-4130 * fix(agents): restore OMP approval forwarding after merge * fix(agents): restore title-owner API after merge * test(agents): update identity inventory ratchet --------- Co-authored-by: devatnull <59279509+devatnull@users.noreply.github.com> Co-authored-by: Merge Sim <sim@local>
This commit is contained in:
co-authored by
devatnull
Merge Sim
parent
e54cfc1901
commit
a0d36f5290
@@ -30,6 +30,34 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[]
|
||||
: []
|
||||
const ownerEnv = kind === 'prime-agent' ? 'ORCA_PRIME_AGENT_STATUS_OWNED' : 'ORCA_PI_STATUS_OWNED'
|
||||
|
||||
// Why: OMP suppresses its approval lifecycle unless an extension listens for it,
|
||||
// and it is the only signal that the run is parked on a permission prompt rather
|
||||
// than still working. Prime has no OMP runtime, so the handlers would be dead there.
|
||||
const approvalHandlers =
|
||||
kind === 'prime-agent'
|
||||
? []
|
||||
: [
|
||||
` pi.on('tool_approval_requested', (event${ctxParam}) => {`,
|
||||
...captureSessionMetadata,
|
||||
' if (!isOmpRuntime()) return',
|
||||
" post('tool_approval_requested', {",
|
||||
' tool_name: event.toolName,',
|
||||
' reason: event.reason,',
|
||||
' approval_mode: event.approvalMode,',
|
||||
' })',
|
||||
' })',
|
||||
'',
|
||||
` pi.on('tool_approval_resolved', (event${ctxParam}) => {`,
|
||||
...captureSessionMetadata,
|
||||
' if (!isOmpRuntime()) return',
|
||||
" post('tool_approval_resolved', {",
|
||||
' tool_name: event.toolName,',
|
||||
' approved: event.approved,',
|
||||
' })',
|
||||
' })',
|
||||
''
|
||||
]
|
||||
|
||||
return [
|
||||
'// Why: pi assistant messages carry content as an array of parts',
|
||||
"// ({ type: 'text', text } / tool_use / tool_result / reasoning). We only",
|
||||
@@ -102,6 +130,7 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[]
|
||||
' })',
|
||||
' })',
|
||||
'',
|
||||
...approvalHandlers,
|
||||
" // Why: capture the assistant's final text on each completed message",
|
||||
' // so the dashboard preview reflects the most recent reply even before',
|
||||
' // agent_end fires. message_end is the right hook because pi guarantees',
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createAgentStatusExtensionHarness } from './agent-status-extension-test-harness'
|
||||
|
||||
const APPROVAL_REQUEST = {
|
||||
toolName: 'bash',
|
||||
reason: 'tools.approval.bash: prompt',
|
||||
approvalMode: 'prompt'
|
||||
}
|
||||
|
||||
describe('OMP approval forwarding', () => {
|
||||
it.each([
|
||||
['configured OMP', { kind: 'omp' as const }],
|
||||
['title-routed OMP', { kind: 'pi' as const, title: 'omp' }],
|
||||
['argv-routed OMP', { kind: 'pi' as const, argv: ['node', '/usr/local/bin/omp'] }]
|
||||
])('posts tool_approval_requested and resolved from %s', async (_name, args) => {
|
||||
const harness = createAgentStatusExtensionHarness(args)
|
||||
|
||||
expect(harness.handlers.tool_approval_requested).toBeTypeOf('function')
|
||||
expect(harness.handlers.tool_approval_resolved).toBeTypeOf('function')
|
||||
|
||||
await harness.callHook('tool_approval_requested', APPROVAL_REQUEST)
|
||||
await harness.callHook('tool_approval_resolved', { toolName: 'bash', approved: true })
|
||||
|
||||
await vi.waitFor(() => expect(harness.fetchMock).toHaveBeenCalledTimes(2))
|
||||
expect(harness.fetchMock.mock.calls[0]?.[0]).toBe('http://127.0.0.1:4321/hook/omp')
|
||||
expect(
|
||||
harness.fetchMock.mock.calls.map(([, init]) => JSON.parse(String(init?.body)).payload)
|
||||
).toEqual([
|
||||
{
|
||||
hook_event_name: 'tool_approval_requested',
|
||||
tool_name: 'bash',
|
||||
reason: 'tools.approval.bash: prompt',
|
||||
approval_mode: 'prompt'
|
||||
},
|
||||
{
|
||||
hook_event_name: 'tool_approval_resolved',
|
||||
tool_name: 'bash',
|
||||
approved: true
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('does not post OMP approval events from a genuine Pi process', async () => {
|
||||
const harness = createAgentStatusExtensionHarness({ kind: 'pi' })
|
||||
|
||||
expect(harness.handlers.tool_approval_requested).toBeTypeOf('function')
|
||||
await harness.callHook('tool_approval_requested', APPROVAL_REQUEST)
|
||||
expect(harness.fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not register OMP approval handlers on Prime', () => {
|
||||
const harness = createAgentStatusExtensionHarness({ kind: 'prime-agent' })
|
||||
|
||||
expect(harness.handlers.tool_approval_requested).toBeUndefined()
|
||||
expect(harness.handlers.tool_approval_resolved).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -136,7 +136,7 @@ import {
|
||||
normalizeCompatibleAgentTitleForOwner,
|
||||
resolveCompatibleAgentTypeForOwner
|
||||
} from '../../shared/agent-title-owner'
|
||||
import { resolvePaneAgentOwner } from '../../shared/pane-agent-owner'
|
||||
import { resolvePaneAgentOwnerRecord } from '../../shared/pane-agent-owner'
|
||||
import {
|
||||
createAgentStatusOscProcessor,
|
||||
type ProcessedAgentStatusChunk
|
||||
@@ -8566,7 +8566,8 @@ export class OrcaRuntimeService {
|
||||
const ownerAgent = pty.launchAgent ?? pty.foregroundAgent
|
||||
const title = normalizeCompatibleAgentTitleForOwner(
|
||||
args.title ?? getLatestPtyTitle(pty) ?? 'Terminal',
|
||||
ownerAgent
|
||||
ownerAgent,
|
||||
{ ownerIsLaunch: Boolean(pty.launchAgent) }
|
||||
)
|
||||
const existingTab = existing?.tabs.find(
|
||||
(candidate): candidate is RuntimeMobileSessionTerminalTab =>
|
||||
@@ -36360,31 +36361,32 @@ export class OrcaRuntimeService {
|
||||
const launchAgent = tab.launchAgent ?? null
|
||||
const launchOwnerAgent = launchAgent ?? liveLeafPty?.launchAgent ?? pty?.launchAgent ?? null
|
||||
// Why: a retained OMP hook stays stable while wrapper foreground reads can report Pi.
|
||||
const ownerRecord = resolvePaneAgentOwnerRecord({
|
||||
launchAgent: launchOwnerAgent,
|
||||
hookAgent:
|
||||
tab.agentStatus?.agentType ??
|
||||
hookAgentStatus?.agentType ??
|
||||
retainedAgentStatus?.payload.agentType ??
|
||||
null
|
||||
})
|
||||
const ownerAgent =
|
||||
resolvePaneAgentOwner({
|
||||
launchAgent: launchOwnerAgent,
|
||||
hookAgent:
|
||||
tab.agentStatus?.agentType ??
|
||||
hookAgentStatus?.agentType ??
|
||||
retainedAgentStatus?.payload.agentType ??
|
||||
null
|
||||
}) ??
|
||||
liveLeafPty?.foregroundAgent ??
|
||||
pty?.foregroundAgent ??
|
||||
null
|
||||
ownerRecord?.agent ?? liveLeafPty?.foregroundAgent ?? pty?.foregroundAgent ?? null
|
||||
const ownerOptions = { ownerIsLaunch: ownerRecord?.ownerIsLaunch === true }
|
||||
const title = normalizeCompatibleAgentTitleForOwner(
|
||||
trackerOnlyTitle ?? leafTitle ?? ptyTitle ?? syncedTab?.title ?? tab.title,
|
||||
ownerAgent
|
||||
ownerAgent,
|
||||
ownerOptions
|
||||
)
|
||||
const liveTitleEvidence = leafTitle ?? ptyTitle
|
||||
// Why: renderer status can precede hook session identity, leaving native chat with no transcript address.
|
||||
const rendererStatusAgent =
|
||||
resolveCompatibleAgentTypeForOwner(tab.agentStatus?.agentType, ownerAgent) ??
|
||||
resolveCompatibleAgentTypeForOwner(tab.agentStatus?.agentType, ownerAgent, ownerOptions) ??
|
||||
ownerAgent ??
|
||||
undefined
|
||||
const hookSessionAgent = resolveCompatibleAgentTypeForOwner(
|
||||
hookAgentStatus?.providerSessionAgentType,
|
||||
ownerAgent
|
||||
ownerAgent,
|
||||
ownerOptions
|
||||
)
|
||||
const hookSessionMatchesRenderer =
|
||||
!rendererStatusAgent || !hookSessionAgent || rendererStatusAgent === hookSessionAgent
|
||||
@@ -36403,7 +36405,8 @@ export class OrcaRuntimeService {
|
||||
...tab.agentStatus,
|
||||
...(hookProviderSession ? { providerSession: hookProviderSession } : {})
|
||||
},
|
||||
ownerAgent
|
||||
ownerAgent,
|
||||
ownerOptions
|
||||
)
|
||||
: null,
|
||||
statusPty,
|
||||
@@ -36708,16 +36711,16 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
}
|
||||
// Why: a retained OMP hook stays stable while wrapper foreground reads can report Pi.
|
||||
const ownerAgent =
|
||||
resolvePaneAgentOwner({
|
||||
launchAgent: tab.launchAgent ?? pty?.launchAgent ?? null,
|
||||
hookAgent: retained?.payload.agentType ?? hookRow.agentType
|
||||
}) ??
|
||||
pty?.foregroundAgent ??
|
||||
null
|
||||
const ownerRecord = resolvePaneAgentOwnerRecord({
|
||||
launchAgent: tab.launchAgent ?? pty?.launchAgent ?? null,
|
||||
hookAgent: retained?.payload.agentType ?? hookRow.agentType
|
||||
})
|
||||
const ownerAgent = ownerRecord?.agent ?? pty?.foregroundAgent ?? null
|
||||
const ownerOptions = { ownerIsLaunch: ownerRecord?.ownerIsLaunch === true }
|
||||
const terminalTitle = normalizeCompatibleAgentTitleForOwner(
|
||||
trackerOnlyTitle ?? (pty ? getLatestPtyTitle(pty) : null) ?? tab.title,
|
||||
ownerAgent
|
||||
ownerAgent,
|
||||
ownerOptions
|
||||
)
|
||||
// Why: OSC 9999 hook payload carries real state/prompt/agent; without preferring it, hook-only transitions never surfaced (#7970).
|
||||
const liveRow = retained ?? this.resolveHookLiveAgentRow(hookRow.live, pty, nonAgentTitle)
|
||||
@@ -36737,7 +36740,8 @@ export class OrcaRuntimeService {
|
||||
terminalTitle,
|
||||
...providerSession
|
||||
},
|
||||
ownerAgent
|
||||
ownerAgent,
|
||||
ownerOptions
|
||||
)
|
||||
// A live question outranks only the shell title that currently obscures it.
|
||||
const renewedStatus = this.renewMobileAgentStatusFromPtyTitle(liveStatus, pty, {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type {
|
||||
AgentStatusEntry,
|
||||
AgentStatusOrchestrationContext
|
||||
} from '../../../../shared/agent-status-types'
|
||||
|
||||
function orchestrationContextsEqual(
|
||||
a: AgentStatusOrchestrationContext,
|
||||
b: AgentStatusOrchestrationContext
|
||||
): boolean {
|
||||
return (
|
||||
a.taskId === b.taskId &&
|
||||
a.dispatchId === b.dispatchId &&
|
||||
a.taskTitle === b.taskTitle &&
|
||||
a.displayName === b.displayName &&
|
||||
a.parentTerminalHandle === b.parentTerminalHandle &&
|
||||
a.parentPaneKey === b.parentPaneKey &&
|
||||
a.coordinatorHandle === b.coordinatorHandle &&
|
||||
a.orchestrationRunId === b.orchestrationRunId
|
||||
)
|
||||
}
|
||||
|
||||
export function entryWithRuntimeOrchestration(
|
||||
entry: AgentStatusEntry,
|
||||
runtimeAgentOrchestrationByPaneKey: Record<string, AgentStatusOrchestrationContext> | undefined
|
||||
): AgentStatusEntry {
|
||||
const runtimeOrchestration = runtimeAgentOrchestrationByPaneKey?.[entry.paneKey]
|
||||
const sameDispatch =
|
||||
entry.orchestration &&
|
||||
runtimeOrchestration &&
|
||||
entry.orchestration.taskId === runtimeOrchestration.taskId &&
|
||||
entry.orchestration.dispatchId === runtimeOrchestration.dispatchId
|
||||
if (entry.orchestration && runtimeOrchestration && !sameDispatch) {
|
||||
return entry
|
||||
}
|
||||
const orchestration =
|
||||
sameDispatch && entry.orchestration && runtimeOrchestration
|
||||
? { ...entry.orchestration, ...runtimeOrchestration }
|
||||
: (runtimeOrchestration ?? entry.orchestration)
|
||||
if (!orchestration || orchestration === entry.orchestration) {
|
||||
return entry
|
||||
}
|
||||
if (entry.orchestration && orchestrationContextsEqual(entry.orchestration, orchestration)) {
|
||||
return entry
|
||||
}
|
||||
// Why: runtime graph metadata can arrive after a hook status ping. Keep old
|
||||
// fields only for the same dispatch; a reused terminal must not inherit a
|
||||
// previous worker's stale parent.
|
||||
return { ...entry, orchestration }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types'
|
||||
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
|
||||
import { resolveCompatibleAgentTypeForOwner } from '../../../../shared/agent-title-owner'
|
||||
import { resolveAgentTypeFromTerminalTitle } from './worktree-title-derived-agent-rows'
|
||||
|
||||
/**
|
||||
* Resolves the sidebar row agent type, prioritizing launch agent configuration
|
||||
* and normalizing compatible agent kinds.
|
||||
*/
|
||||
export function resolveRowAgentType(entry: AgentStatusEntry, tab?: TerminalTab | null): AgentType {
|
||||
const launchOwner = { ownerIsLaunch: Boolean(tab?.launchAgent) }
|
||||
const entryAgentType = resolveCompatibleAgentTypeForOwner(
|
||||
entry.agentType,
|
||||
tab?.launchAgent,
|
||||
launchOwner
|
||||
)
|
||||
if (entryAgentType && entryAgentType !== 'unknown') {
|
||||
return entryAgentType
|
||||
}
|
||||
return (
|
||||
resolveAgentTypeFromTerminalTitle(
|
||||
entry.terminalTitle ?? tab?.title,
|
||||
tab?.launchAgent,
|
||||
launchOwner
|
||||
) ??
|
||||
tab?.launchAgent ??
|
||||
entryAgentType ??
|
||||
'unknown'
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { isExplicitAgentStatusFresh } from '@/lib/agent-status'
|
||||
import type { RetainedAgentEntry } from '@/store/slices/agent-status'
|
||||
import {
|
||||
AGENT_STATUS_STALE_AFTER_MS,
|
||||
type AgentType,
|
||||
type AgentStatusEntry,
|
||||
type AgentStatusOrchestrationContext
|
||||
} from '../../../../shared/agent-status-types'
|
||||
@@ -18,79 +17,15 @@ import type {
|
||||
TerminalTab
|
||||
} from '../../../../shared/terminal-tab-types'
|
||||
import { resolveRuntimePaneTitleLeafId } from '@/lib/runtime-pane-title-leaf-id'
|
||||
import {
|
||||
buildTitleDerivedAgentRows,
|
||||
resolveAgentTypeFromTerminalTitle
|
||||
} from './worktree-title-derived-agent-rows'
|
||||
import { buildTitleDerivedAgentRows } from './worktree-title-derived-agent-rows'
|
||||
import { buildSubagentChildRows } from './worktree-subagent-child-rows'
|
||||
import { resolveCompatibleAgentTypeForOwner } from '../../../../shared/agent-title-owner'
|
||||
import { compareWorktreeAgentRows } from './worktree-agent-row-order'
|
||||
import {
|
||||
effectiveWorktreeAgentRowStartedAt,
|
||||
tabFromWorktreeAttributedStatusEntry
|
||||
} from './worktree-agent-row-fallback-tab'
|
||||
|
||||
/**
|
||||
* Resolves the sidebar row agent type, prioritizing launch agent configuration
|
||||
* and normalizing compatible agent kinds.
|
||||
*/
|
||||
function resolveRowAgentType(entry: AgentStatusEntry, tab?: TerminalTab | null): AgentType {
|
||||
const entryAgentType = resolveCompatibleAgentTypeForOwner(entry.agentType, tab?.launchAgent)
|
||||
if (entryAgentType && entryAgentType !== 'unknown') {
|
||||
return entryAgentType
|
||||
}
|
||||
return (
|
||||
resolveAgentTypeFromTerminalTitle(entry.terminalTitle ?? tab?.title, tab?.launchAgent) ??
|
||||
tab?.launchAgent ??
|
||||
entryAgentType ??
|
||||
'unknown'
|
||||
)
|
||||
}
|
||||
|
||||
function orchestrationContextsEqual(
|
||||
a: AgentStatusOrchestrationContext,
|
||||
b: AgentStatusOrchestrationContext
|
||||
): boolean {
|
||||
return (
|
||||
a.taskId === b.taskId &&
|
||||
a.dispatchId === b.dispatchId &&
|
||||
a.taskTitle === b.taskTitle &&
|
||||
a.displayName === b.displayName &&
|
||||
a.parentTerminalHandle === b.parentTerminalHandle &&
|
||||
a.parentPaneKey === b.parentPaneKey &&
|
||||
a.coordinatorHandle === b.coordinatorHandle &&
|
||||
a.orchestrationRunId === b.orchestrationRunId
|
||||
)
|
||||
}
|
||||
|
||||
function entryWithRuntimeOrchestration(
|
||||
entry: AgentStatusEntry,
|
||||
runtimeAgentOrchestrationByPaneKey: Record<string, AgentStatusOrchestrationContext> | undefined
|
||||
): AgentStatusEntry {
|
||||
const runtimeOrchestration = runtimeAgentOrchestrationByPaneKey?.[entry.paneKey]
|
||||
const sameDispatch =
|
||||
entry.orchestration &&
|
||||
runtimeOrchestration &&
|
||||
entry.orchestration.taskId === runtimeOrchestration.taskId &&
|
||||
entry.orchestration.dispatchId === runtimeOrchestration.dispatchId
|
||||
if (entry.orchestration && runtimeOrchestration && !sameDispatch) {
|
||||
return entry
|
||||
}
|
||||
const orchestration =
|
||||
sameDispatch && entry.orchestration && runtimeOrchestration
|
||||
? { ...entry.orchestration, ...runtimeOrchestration }
|
||||
: (runtimeOrchestration ?? entry.orchestration)
|
||||
if (!orchestration || orchestration === entry.orchestration) {
|
||||
return entry
|
||||
}
|
||||
if (entry.orchestration && orchestrationContextsEqual(entry.orchestration, orchestration)) {
|
||||
return entry
|
||||
}
|
||||
// Why: runtime graph metadata can arrive after a hook status ping. Keep old
|
||||
// fields only for the same dispatch; a reused terminal must not inherit a
|
||||
// previous worker's stale parent.
|
||||
return { ...entry, orchestration }
|
||||
}
|
||||
import { resolveRowAgentType } from './worktree-agent-row-type'
|
||||
import { entryWithRuntimeOrchestration } from './worktree-agent-row-orchestration'
|
||||
|
||||
function countTerminalLayoutLeaves(node: TerminalPaneLayoutNode | null | undefined): number {
|
||||
if (!node) {
|
||||
|
||||
@@ -17,7 +17,8 @@ import type {
|
||||
} from '../../../../shared/terminal-tab-types'
|
||||
import {
|
||||
normalizeCompatibleAgentTitleForOwner,
|
||||
resolveCompatibleAgentTypeForOwner
|
||||
resolveCompatibleAgentTypeForOwner,
|
||||
type CompatibleAgentOwnerOptions
|
||||
} from '../../../../shared/agent-title-owner'
|
||||
import { resolvePaneAgentOwner } from '../../../../shared/pane-agent-owner'
|
||||
import { isClaudeIdentityFrameTitle } from '../../../../shared/terminal-title-agent-type'
|
||||
@@ -141,7 +142,9 @@ function buildTitleDerivedAgentRow(args: {
|
||||
// Why launchAgent, not ownerAgentType: this only rewrites a title within its own identity
|
||||
// group (OMP wraps Pi and emits Pi frames), which stays correct in a split. Pane ownership
|
||||
// is a separate, stricter question — it decides identity, so it uses ownerAgentType below.
|
||||
const title = normalizeCompatibleAgentTitleForOwner(args.title, args.tab.launchAgent)
|
||||
const title = normalizeCompatibleAgentTitleForOwner(args.title, args.tab.launchAgent, {
|
||||
ownerIsLaunch: Boolean(args.tab.launchAgent)
|
||||
})
|
||||
const isClaudeAgentsTitle = isClaudeManagementTitle(title)
|
||||
// Why: `claude agents` is a live Claude Code Agent Teams surface, but the
|
||||
// shared detector keeps it neutral so runtime liveness probes do not treat
|
||||
@@ -259,17 +262,19 @@ function resolveTitleDerivedPaneOwner(
|
||||
*/
|
||||
export function resolveAgentTypeFromTerminalTitle(
|
||||
title: string | null | undefined,
|
||||
ownerAgentType?: AgentType | null
|
||||
ownerAgentType?: AgentType | null,
|
||||
options?: CompatibleAgentOwnerOptions
|
||||
): AgentType | null {
|
||||
if (!title) {
|
||||
return null
|
||||
}
|
||||
const normalizedTitle = normalizeCompatibleAgentTitleForOwner(title, ownerAgentType)
|
||||
const normalizedTitle = normalizeCompatibleAgentTitleForOwner(title, ownerAgentType, options)
|
||||
const label = resolveTitleActivityLabel(normalizedTitle)
|
||||
return label
|
||||
? (resolveCompatibleAgentTypeForOwner(
|
||||
resolveTitleDerivedAgentType(normalizedTitle, label, ownerAgentType),
|
||||
ownerAgentType
|
||||
ownerAgentType,
|
||||
options
|
||||
) ?? null)
|
||||
: null
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ describe('resolvePaneDisplayTitle', () => {
|
||||
it('passes an unowned title through unchanged', () => {
|
||||
expect(resolvePaneDisplayTitle('bash', undefined)).toBe('bash')
|
||||
})
|
||||
|
||||
it('rewrites an OMP wrapper title through explicit launch Pi ownership', () => {
|
||||
expect(resolvePaneDisplayTitle('\u280b OMP', 'pi', true)).toBe('\u280b Pi')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolvePaneTitleDecision', () => {
|
||||
|
||||
@@ -12,9 +12,10 @@ import {
|
||||
*/
|
||||
export function resolvePaneDisplayTitle(
|
||||
title: string,
|
||||
ownerAgentType: AgentType | null | undefined
|
||||
ownerAgentType: AgentType | null | undefined,
|
||||
ownerIsLaunch = false
|
||||
): string {
|
||||
return normalizeCompatibleAgentTitleForOwner(title, ownerAgentType)
|
||||
return normalizeCompatibleAgentTitleForOwner(title, ownerAgentType, { ownerIsLaunch })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,6 +36,8 @@ export type ResolvePaneTitleDecisionInput = {
|
||||
/** Owner used for the display label — may include sticky/tab-scoped launch
|
||||
* identity, which is correct for the visible label. */
|
||||
displayOwnerAgentType: AgentType | null | undefined
|
||||
/** True when displayOwnerAgentType is user-selected launch ownership. */
|
||||
displayOwnerIsLaunch?: boolean
|
||||
/** Owner used for the renderer veto — must be pane-scoped and current so a
|
||||
* sibling/reused pane's launch identity cannot keep GPU for a genuine
|
||||
* Gemini pane. */
|
||||
@@ -45,7 +48,11 @@ export type ResolvePaneTitleDecisionInput = {
|
||||
}
|
||||
|
||||
export function resolvePaneTitleDecision(input: ResolvePaneTitleDecisionInput): PaneTitleDecision {
|
||||
const displayTitle = resolvePaneDisplayTitle(input.normalizedTitle, input.displayOwnerAgentType)
|
||||
const displayTitle = resolvePaneDisplayTitle(
|
||||
input.normalizedTitle,
|
||||
input.displayOwnerAgentType,
|
||||
input.displayOwnerIsLaunch === true
|
||||
)
|
||||
const rendererPolicy = resolvePaneRendererPolicy({
|
||||
rawTitle: input.rawTitle,
|
||||
ownerAgentType: input.rendererOwnerAgentType,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getRepoMapFromState, getWorktreeMapFromState } from '@/store/selectors'
|
||||
import { playDesktopNotificationSound } from '@/lib/desktop-notification-sound'
|
||||
import { showBlockedNotificationFallbackToast } from '@/lib/blocked-notification-fallback'
|
||||
import { buildAgentNotificationId } from '../../../../shared/agent-notification-id'
|
||||
import { resolveCompatibleAgentTypeForOwner } from '../../../../shared/agent-title-owner'
|
||||
import { shareCompatibleTitleIdentityGroup } from '../../../../shared/agent-title-owner'
|
||||
import {
|
||||
isFreshNonDoneAgentStatus,
|
||||
type AgentStatusEntry
|
||||
@@ -40,15 +40,13 @@ function hasFreshActiveHookStatus(
|
||||
snapshot: Pick<AgentStatusEntry, 'state' | 'updatedAt' | 'agentType'> | undefined,
|
||||
explicitTitleAgentType: string | null
|
||||
): boolean {
|
||||
const activeHookAgentForTitle = resolveCompatibleAgentTypeForOwner(
|
||||
snapshot?.agentType,
|
||||
explicitTitleAgentType
|
||||
)
|
||||
// Why: pick-a-winner ownership would treat a Pi idle title as a different
|
||||
// agent than a live OMP hook. Same-group titles are wrapper frames, not reuse.
|
||||
const titleNamesDifferentKnownAgent =
|
||||
explicitTitleAgentType &&
|
||||
snapshot?.agentType &&
|
||||
snapshot.agentType !== 'unknown' &&
|
||||
activeHookAgentForTitle !== explicitTitleAgentType
|
||||
!shareCompatibleTitleIdentityGroup(snapshot.agentType, explicitTitleAgentType)
|
||||
return Boolean(isFreshNonDoneAgentStatus(snapshot) && !titleNamesDifferentKnownAgent)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
resolveSiblingRetainedTabAgent,
|
||||
resolveSiblingTabAgent
|
||||
} from './tab-agent'
|
||||
import { resolveTabAgentFromSignals } from './use-tab-agent'
|
||||
import { resolveTabAgentFromSignals } from './tab-agent-from-signals'
|
||||
|
||||
export type OpenTabOccupantAgentInput = {
|
||||
tabId: string
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { isShellProcess } from '../../../shared/agent-detection'
|
||||
import {
|
||||
isClaudeIdentityFrameTitle,
|
||||
resolveExplicitTerminalTitleAgentType
|
||||
} from '../../../shared/terminal-title-agent-type'
|
||||
import {
|
||||
resolveCompatibleAgentTypeForOwner,
|
||||
shareCompatibleTitleIdentityGroup
|
||||
} from '../../../shared/agent-title-owner'
|
||||
import { isOpenCodeNativeTitle } from '../../../shared/opencode-terminal-title'
|
||||
import { resolvePaneAgentOwnerRecord } from '../../../shared/pane-agent-owner'
|
||||
import type { TuiAgent } from '../../../shared/tui-agent'
|
||||
|
||||
// A shell name or the tab's neutral default title (where inferred-interrupt reset parks it); blank titles are no evidence.
|
||||
function titleShowsNoAgent(title: string, defaultTitle?: string): boolean {
|
||||
const trimmed = title.trim()
|
||||
return trimmed.length > 0 && (isShellProcess(trimmed) || trimmed === defaultTitle?.trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves wrapper-compatible signal identity against the pane owner.
|
||||
*/
|
||||
function resolveSignalAgentForLaunchOwner(
|
||||
signalAgent: TuiAgent | null | undefined,
|
||||
ownerAgent: TuiAgent | null,
|
||||
ownerIsLaunch = false
|
||||
): TuiAgent | null {
|
||||
if (!signalAgent) {
|
||||
return null
|
||||
}
|
||||
return (resolveCompatibleAgentTypeForOwner(signalAgent, ownerAgent, { ownerIsLaunch }) ??
|
||||
signalAgent) as TuiAgent
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe-free evidence a launched agent exited: title shows no agent, no live
|
||||
* hook remains, and either the hook completed or observed activity vanished.
|
||||
* Vanished-activity is local-only — remote rows also drop on transport blips.
|
||||
*/
|
||||
export function resolveLaunchedAgentExitEvidence(args: {
|
||||
title: string
|
||||
defaultTitle?: string
|
||||
isRemote: boolean
|
||||
hasObservedAgentSignal: boolean
|
||||
hookAgent: TuiAgent | null
|
||||
siblingHookAgent?: TuiAgent | null
|
||||
hasCompletedHook: boolean
|
||||
processAgent?: TuiAgent | null
|
||||
processShellForeground?: boolean
|
||||
}): boolean {
|
||||
if (args.hookAgent || args.siblingHookAgent || args.processAgent) {
|
||||
return false
|
||||
}
|
||||
// Why: OSC 133;D (foreground back at shell) is title-independent exit evidence; local-only — remote panes have no shell-foreground producer.
|
||||
if (!args.isRemote && args.processShellForeground && args.hasObservedAgentSignal) {
|
||||
return true
|
||||
}
|
||||
if (!titleShowsNoAgent(args.title, args.defaultTitle)) {
|
||||
return false
|
||||
}
|
||||
return args.hasCompletedHook || (!args.isRemote && args.hasObservedAgentSignal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity-first precedence: live hook > process > title > completed > sleeping
|
||||
* > launch > sibling. Same-group titles (OMP wraps Pi) are not reuse evidence.
|
||||
*/
|
||||
export function resolveTabAgentFromSignals(args: {
|
||||
hasObservedAgentSignal: boolean
|
||||
isRemote: boolean
|
||||
title: string
|
||||
defaultTitle?: string
|
||||
hookAgent: TuiAgent | null
|
||||
siblingHookAgent?: TuiAgent | null
|
||||
focusedCompletedHookAgent?: TuiAgent | null
|
||||
siblingCompletedHookAgent?: TuiAgent | null
|
||||
processAgent?: TuiAgent | null
|
||||
processShellForeground?: boolean
|
||||
sleepingSessionAgent?: TuiAgent | null
|
||||
launchAgent?: TuiAgent
|
||||
}): TuiAgent | null {
|
||||
const launchAgent = args.launchAgent ?? null
|
||||
// Durable focused-pane owner (launch intent → hook → session); focused-pane-scoped so a sibling can't re-own the focused title (would mislabel a Pi pane as OMP).
|
||||
const ownerRecord = resolvePaneAgentOwnerRecord({
|
||||
launchAgent,
|
||||
hookAgent: args.hookAgent,
|
||||
completedHookAgent: args.focusedCompletedHookAgent,
|
||||
sleepingSessionAgent: args.sleepingSessionAgent
|
||||
})
|
||||
const owner = (ownerRecord?.agent ?? null) as TuiAgent | null
|
||||
const ownerIsLaunch = ownerRecord?.ownerIsLaunch === true
|
||||
|
||||
// The live/idle split governs title override; siblings normalize against launch intent only.
|
||||
const liveFocusedIdentity = resolveSignalAgentForLaunchOwner(args.hookAgent, owner, ownerIsLaunch)
|
||||
const liveSiblingIdentity = resolveSignalAgentForLaunchOwner(
|
||||
args.siblingHookAgent,
|
||||
launchAgent,
|
||||
Boolean(launchAgent)
|
||||
)
|
||||
// Why: OSC 133;D proves this local pane returned to shell, so the idle identity is stale; remote titles lag runtime, so keep it there.
|
||||
const processProvesShell = !args.isRemote && args.processShellForeground === true
|
||||
const hasCompletedHook = (args.focusedCompletedHookAgent ?? null) !== null
|
||||
const noAgentTitle = titleShowsNoAgent(args.title, args.defaultTitle)
|
||||
const idleIdentitySuppressed =
|
||||
!args.isRemote && (noAgentTitle || processProvesShell) && hasCompletedHook
|
||||
const idleFocusedIdentity = idleIdentitySuppressed
|
||||
? null
|
||||
: resolveSignalAgentForLaunchOwner(args.focusedCompletedHookAgent, owner, ownerIsLaunch)
|
||||
// Why: idleIdentitySuppressed is the FOCUSED pane's exit evidence, so it must not clear a sibling's idle identity.
|
||||
const idleSiblingIdentity = resolveSignalAgentForLaunchOwner(
|
||||
args.siblingCompletedHookAgent,
|
||||
launchAgent,
|
||||
Boolean(launchAgent)
|
||||
)
|
||||
const sleepingSessionAgent = args.sleepingSessionAgent ?? null
|
||||
|
||||
// Title carries identity only as a reuse override (names a DIFFERENT-group agent) or a legacy standalone id when no hook — same-group titles say nothing (OMP wraps Pi), so the record wins.
|
||||
const rawTitleAgent = resolveExplicitTerminalTitleAgentType(args.title)
|
||||
const explicitTitleAgent = resolveSignalAgentForLaunchOwner(rawTitleAgent, owner, ownerIsLaunch)
|
||||
const priorIdentity = idleFocusedIdentity ?? launchAgent
|
||||
const nativeOpenCodeTitle = explicitTitleAgent === 'opencode' && isOpenCodeNativeTitle(args.title)
|
||||
// Why: a "claude" token in another agent's task text is a mention, not identity, so it must
|
||||
// not take a pane from its known owner — only a title that PRESENTS Claude may (#8940).
|
||||
const titleClaimsIdentity =
|
||||
explicitTitleAgent !== 'claude' || isClaudeIdentityFrameTitle(args.title)
|
||||
// Why: native OpenCode titles can reclaim stale launch intent before any observed hook signal.
|
||||
// Raw title group, not the fallback-rewritten agent: inferred Pi owners would otherwise treat an OMP wrapper title as a different identity.
|
||||
const titleReclaimsReusedPane =
|
||||
priorIdentity !== null &&
|
||||
explicitTitleAgent !== null &&
|
||||
explicitTitleAgent !== priorIdentity &&
|
||||
!shareCompatibleTitleIdentityGroup(rawTitleAgent, priorIdentity) &&
|
||||
titleClaimsIdentity &&
|
||||
(args.hasObservedAgentSignal || hasCompletedHook || nativeOpenCodeTitle)
|
||||
// Why: native OpenCode titles lack a provider generation and cannot displace durable ownership.
|
||||
const titleAgent =
|
||||
processProvesShell ||
|
||||
sleepingSessionAgent ||
|
||||
(nativeOpenCodeTitle && idleFocusedIdentity !== null)
|
||||
? null
|
||||
: titleReclaimsReusedPane
|
||||
? explicitTitleAgent
|
||||
: priorIdentity
|
||||
? null
|
||||
: explicitTitleAgent
|
||||
|
||||
const launchedAgentExited = resolveLaunchedAgentExitEvidence({
|
||||
title: args.title,
|
||||
defaultTitle: args.defaultTitle,
|
||||
isRemote: args.isRemote,
|
||||
hasObservedAgentSignal: args.hasObservedAgentSignal,
|
||||
hookAgent: liveFocusedIdentity,
|
||||
siblingHookAgent: liveSiblingIdentity,
|
||||
hasCompletedHook,
|
||||
processAgent: args.processAgent,
|
||||
processShellForeground: args.processShellForeground
|
||||
})
|
||||
const activeLaunchAgent = launchedAgentExited ? null : launchAgent
|
||||
// Why: re-own the foreground process within its title-identity group so OMP's nested pi (shell → omp → pi) can't flip an OMP-owned tab's icon.
|
||||
const processAgent = resolveSignalAgentForLaunchOwner(args.processAgent, owner, ownerIsLaunch)
|
||||
return (
|
||||
liveFocusedIdentity ??
|
||||
processAgent ??
|
||||
titleAgent ??
|
||||
idleFocusedIdentity ??
|
||||
sleepingSessionAgent ??
|
||||
activeLaunchAgent ??
|
||||
liveSiblingIdentity ??
|
||||
idleSiblingIdentity
|
||||
)
|
||||
}
|
||||
@@ -9,7 +9,8 @@ import { makePaneKey } from '../../../shared/stable-pane-id'
|
||||
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types'
|
||||
import type { TuiAgent } from '../../../shared/tui-agent'
|
||||
import { parseWorkspaceSession } from '../../../shared/workspace-session-schema'
|
||||
import { resolveTabAgentFromSignals, useTabAgent } from './use-tab-agent'
|
||||
import { resolveTabAgentFromSignals } from './tab-agent-from-signals'
|
||||
import { useTabAgent } from './use-tab-agent'
|
||||
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveTabAgentFromSignals } from './use-tab-agent'
|
||||
import { resolveTabAgentFromSignals } from './tab-agent-from-signals'
|
||||
|
||||
// Pi/OMP share a title-identity group: OMP wraps Pi and emits Pi-compatible
|
||||
// wrapper title frames. These tests pin how the tab-icon resolver keeps an
|
||||
|
||||
@@ -10,9 +10,9 @@ import type { TerminalTab } from '../../../shared/terminal-tab-types'
|
||||
import type { TuiAgent } from '../../../shared/tui-agent'
|
||||
import {
|
||||
resolveLaunchedAgentExitEvidence,
|
||||
resolveTabAgentFromSignals,
|
||||
useTabAgent
|
||||
} from './use-tab-agent'
|
||||
resolveTabAgentFromSignals
|
||||
} from './tab-agent-from-signals'
|
||||
import { useTabAgent } from './use-tab-agent'
|
||||
|
||||
const initialAppState = useAppStore.getInitialState()
|
||||
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
@@ -11,7 +11,8 @@ import type {
|
||||
import { makePaneKey } from '../../../shared/stable-pane-id'
|
||||
import type { TerminalTab } from '../../../shared/terminal-tab-types'
|
||||
import type { TuiAgent } from '../../../shared/tui-agent'
|
||||
import { resolveTabAgentFromSignals, useTabAgent } from './use-tab-agent'
|
||||
import { resolveTabAgentFromSignals } from './tab-agent-from-signals'
|
||||
import { useTabAgent } from './use-tab-agent'
|
||||
|
||||
const initialAppState = useAppStore.getInitialState()
|
||||
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
@@ -8,7 +8,8 @@ import type { AgentStatusEntry } from '../../../shared/agent-status-types'
|
||||
import { makePaneKey } from '../../../shared/stable-pane-id'
|
||||
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types'
|
||||
import type { TuiAgent } from '../../../shared/tui-agent'
|
||||
import { resolveTabAgentFromSignals, useTabAgent } from './use-tab-agent'
|
||||
import { resolveTabAgentFromSignals } from './tab-agent-from-signals'
|
||||
import { useTabAgent } from './use-tab-agent'
|
||||
|
||||
const initialAppState = useAppStore.getInitialState()
|
||||
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
@@ -67,7 +67,7 @@ import {
|
||||
normalizeCompatibleAgentStatusEntryForOwner,
|
||||
normalizeCompatibleAgentTitleForOwner
|
||||
} from '../../../shared/agent-title-owner'
|
||||
import { resolvePaneAgentOwner } from '../../../shared/pane-agent-owner'
|
||||
import { resolvePaneAgentOwnerRecord } from '../../../shared/pane-agent-owner'
|
||||
import { resolveTerminalLayoutRoot } from './remote-terminal-layout-resolution'
|
||||
import { toRuntimeWorktreeSelector } from './runtime-worktree-selector'
|
||||
import { readBrowserClientHostId } from './browser-client-host-identity'
|
||||
@@ -1181,7 +1181,7 @@ function buildMirroredTerminalTabs(
|
||||
}
|
||||
const launchAgent =
|
||||
activeSurface.launchAgent ?? surfaces.find((surface) => surface.launchAgent)?.launchAgent
|
||||
const ownerAgent = resolvePaneAgentOwner({
|
||||
const ownerRecord = resolvePaneAgentOwnerRecord({
|
||||
launchAgent,
|
||||
hookAgent: activeSurface.agentStatus?.agentType,
|
||||
siblingHookAgent: surfaces.find((surface) => surface.agentStatus?.agentType)?.agentStatus
|
||||
@@ -1189,7 +1189,8 @@ function buildMirroredTerminalTabs(
|
||||
})
|
||||
const title = normalizeCompatibleAgentTitleForOwner(
|
||||
activeSurface.title.trim() || surfaces[0]?.title.trim() || 'Terminal',
|
||||
ownerAgent
|
||||
ownerRecord?.agent,
|
||||
{ ownerIsLaunch: ownerRecord?.ownerIsLaunch === true }
|
||||
)
|
||||
const existing =
|
||||
existingById.get(localTabId) ??
|
||||
@@ -1262,12 +1263,14 @@ function remapHostAgentStatus(
|
||||
if (!paneKey) {
|
||||
return null
|
||||
}
|
||||
const ownerAgent = resolvePaneAgentOwner({
|
||||
const ownerRecord = resolvePaneAgentOwnerRecord({
|
||||
launchAgent: retainedSurface?.launchAgent ?? surface.launchAgent,
|
||||
hookAgent: surface.agentStatus.agentType
|
||||
})
|
||||
return {
|
||||
...normalizeCompatibleAgentStatusEntryForOwner(surface.agentStatus, ownerAgent),
|
||||
...normalizeCompatibleAgentStatusEntryForOwner(surface.agentStatus, ownerRecord?.agent, {
|
||||
ownerIsLaunch: ownerRecord?.ownerIsLaunch === true
|
||||
}),
|
||||
paneKey,
|
||||
tabId: toWebTerminalSurfaceTabId(surface.parentTabId)
|
||||
}
|
||||
|
||||
@@ -310,7 +310,18 @@ describe('shared agent-hook-listener', () => {
|
||||
expect(tool?.payload.interactivePrompt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('maps OMP ask to blocked without publishing a native prompt', () => {
|
||||
it('maps OMP ask to blocked and publishes its questions payload', () => {
|
||||
const questions = {
|
||||
questions: [
|
||||
{
|
||||
question: 'Choose',
|
||||
options: [
|
||||
{ label: 'x', description: 'First' },
|
||||
{ label: 'y', description: 'Second' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
const tool = normalizeHookPayload(
|
||||
state,
|
||||
'omp',
|
||||
@@ -323,14 +334,7 @@ describe('shared agent-hook-listener', () => {
|
||||
payload: {
|
||||
hook_event_name: 'tool_execution_start',
|
||||
tool_name: 'ask',
|
||||
tool_input: {
|
||||
questions: [
|
||||
{
|
||||
question: 'Choose',
|
||||
options: ['x', 'y']
|
||||
}
|
||||
]
|
||||
}
|
||||
tool_input: questions
|
||||
}
|
||||
},
|
||||
'production'
|
||||
@@ -340,9 +344,60 @@ describe('shared agent-hook-listener', () => {
|
||||
agentType: 'omp',
|
||||
toolName: 'ask'
|
||||
})
|
||||
expect(tool?.payload.interactivePrompt).toBeUndefined()
|
||||
expect(tool?.payload.interactivePrompt).toBe(JSON.stringify(questions))
|
||||
})
|
||||
|
||||
it('blocks an OMP pane on a tool approval request and clears it on resolution', () => {
|
||||
const requested = normalizeHookPayload(
|
||||
state,
|
||||
'omp',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
payload: {
|
||||
hook_event_name: 'tool_approval_requested',
|
||||
tool_name: 'bash',
|
||||
reason: 'tools.approval.bash: prompt',
|
||||
approval_mode: 'prompt'
|
||||
}
|
||||
},
|
||||
'production'
|
||||
)
|
||||
|
||||
expect(requested?.payload).toMatchObject({
|
||||
state: 'blocked',
|
||||
agentType: 'omp',
|
||||
toolName: 'bash',
|
||||
toolInput: 'tools.approval.bash: prompt'
|
||||
})
|
||||
|
||||
const resolved = normalizeHookPayload(
|
||||
state,
|
||||
'omp',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
payload: { hook_event_name: 'tool_approval_resolved', tool_name: 'bash', approved: true }
|
||||
},
|
||||
'production'
|
||||
)
|
||||
|
||||
expect(resolved?.payload).toMatchObject({ state: 'working', toolName: 'bash' })
|
||||
expect(resolved?.payload.toolInput).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each(['tool_approval_requested', 'tool_approval_resolved'])(
|
||||
'ignores %s from Pi-compatible agents that do not emit it',
|
||||
(hookEventName) => {
|
||||
expect(
|
||||
normalizeHookPayload(
|
||||
state,
|
||||
'pi',
|
||||
{ paneKey: PANE_KEY, payload: { hook_event_name: hookEventName, tool_name: 'bash' } },
|
||||
'production'
|
||||
)
|
||||
).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('captures Pi session ids on Pi-compatible status events', () => {
|
||||
const event = normalizeHookPayload(
|
||||
state,
|
||||
|
||||
@@ -28,19 +28,23 @@ export function normalizePiCompatibleEvent(
|
||||
((agentType === 'pi' && isAskUserQuestionTool(toolName)) ||
|
||||
(agentType === 'omp' && toolName === 'ask')) &&
|
||||
(eventName === 'tool_call' || eventName === 'tool_execution_start')
|
||||
const isOmpApprovalRequest = agentType === 'omp' && eventName === 'tool_approval_requested'
|
||||
const isOmpApprovalResolution = agentType === 'omp' && eventName === 'tool_approval_resolved'
|
||||
|
||||
const stateName = isPiCompatibleAsk
|
||||
? 'blocked'
|
||||
: eventName === 'before_agent_start' ||
|
||||
eventName === 'agent_start' ||
|
||||
eventName === 'tool_call' ||
|
||||
eventName === 'tool_execution_start' ||
|
||||
eventName === 'tool_execution_end' ||
|
||||
eventName === 'message_end'
|
||||
? 'working'
|
||||
: eventName === 'agent_end'
|
||||
? 'done'
|
||||
: null
|
||||
const stateName =
|
||||
isPiCompatibleAsk || isOmpApprovalRequest
|
||||
? 'blocked'
|
||||
: isOmpApprovalResolution ||
|
||||
eventName === 'before_agent_start' ||
|
||||
eventName === 'agent_start' ||
|
||||
eventName === 'tool_call' ||
|
||||
eventName === 'tool_execution_start' ||
|
||||
eventName === 'tool_execution_end' ||
|
||||
eventName === 'message_end'
|
||||
? 'working'
|
||||
: eventName === 'agent_end'
|
||||
? 'done'
|
||||
: null
|
||||
|
||||
if (!stateName) {
|
||||
return null
|
||||
|
||||
@@ -1,7 +1,29 @@
|
||||
import type { ToolSnapshot } from '../listener-event'
|
||||
import { isAskUserQuestionTool } from '../../agent-question-answered-intent'
|
||||
import { deriveToolInputPreview, hasOwnField, readString, toolUpdate } from '../tool-input-preview'
|
||||
import { deriveInteractivePrompt } from '../interactive-tool'
|
||||
|
||||
/** OMP's `ask` carries the same questions/options payload as Pi's question tool. */
|
||||
function serializeQuestionPrompt(toolInput: unknown): string | undefined {
|
||||
if (toolInput === undefined || toolInput === null) {
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(toolInput)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function isPiCompatibleAskTool(
|
||||
agentKind: 'pi' | 'omp' | 'prime-agent',
|
||||
toolName: string | undefined
|
||||
): boolean {
|
||||
return agentKind === 'omp'
|
||||
? toolName === 'ask'
|
||||
: agentKind === 'pi' && isAskUserQuestionTool(toolName)
|
||||
}
|
||||
|
||||
export function extractPiToolFields(
|
||||
eventName: unknown,
|
||||
hookPayload: Record<string, unknown>,
|
||||
@@ -15,16 +37,33 @@ export function extractPiToolFields(
|
||||
const toolName = readString(hookPayload, 'tool_name')
|
||||
const rawToolInput = hookPayload.tool_input
|
||||
const toolInput = deriveToolInputPreview(toolName, rawToolInput)
|
||||
// Why: OMP shares this extractor; only derive interactivePrompt for Pi so OMP ask_user_question metadata stays unchanged.
|
||||
// Why: OMP's `ask` uses the same questions/options shape as Pi's question tool.
|
||||
const interactivePrompt =
|
||||
agentKind === 'pi' && (eventName === 'tool_call' || eventName === 'tool_execution_start')
|
||||
? deriveInteractivePrompt(toolName, rawToolInput, eventName)
|
||||
isPiCompatibleAskTool(agentKind, toolName) &&
|
||||
(eventName === 'tool_call' || eventName === 'tool_execution_start')
|
||||
? agentKind === 'omp'
|
||||
? serializeQuestionPrompt(rawToolInput)
|
||||
: deriveInteractivePrompt(toolName, rawToolInput, eventName)
|
||||
: undefined
|
||||
return toolUpdate(
|
||||
{ toolName, toolInput, interactivePrompt },
|
||||
{ hasToolInputField: hasOwnField(hookPayload, 'tool_input') }
|
||||
)
|
||||
}
|
||||
if (
|
||||
agentKind === 'omp' &&
|
||||
(eventName === 'tool_approval_requested' || eventName === 'tool_approval_resolved')
|
||||
) {
|
||||
return toolUpdate(
|
||||
{
|
||||
toolName: readString(hookPayload, 'tool_name'),
|
||||
toolInput:
|
||||
eventName === 'tool_approval_requested' ? readString(hookPayload, 'reason') : undefined,
|
||||
interactivePrompt: undefined
|
||||
},
|
||||
{ hasToolInputField: true }
|
||||
)
|
||||
}
|
||||
if (eventName === 'message_end' && hookPayload.role === 'assistant') {
|
||||
const text = readString(hookPayload, 'text')
|
||||
if (text) {
|
||||
|
||||
@@ -18,6 +18,11 @@ type TitleProfileMatch = {
|
||||
|
||||
type TitleLabelProfileMatch = Pick<TitleProfileMatch, 'profile'>
|
||||
|
||||
export type CompatibleAgentOwnerOptions = {
|
||||
/** Whether the owner comes from explicit launch intent. */
|
||||
ownerIsLaunch?: boolean
|
||||
}
|
||||
|
||||
const COMPATIBLE_IDLE_TITLE_RE = /(?<![\w./\\-])(?:ready|idle|done)(?![\w-])/i
|
||||
|
||||
/**
|
||||
@@ -114,7 +119,8 @@ function hasIdleSuffix(title: string, sourceProfile: SyntheticAgentTitleProfile)
|
||||
*/
|
||||
export function resolveCompatibleAgentTypeForOwner(
|
||||
incomingAgentType: AgentType | null | undefined,
|
||||
ownerAgentType: AgentType | null | undefined
|
||||
ownerAgentType: AgentType | null | undefined,
|
||||
_options?: CompatibleAgentOwnerOptions
|
||||
): AgentType | undefined {
|
||||
if (!incomingAgentType) {
|
||||
return undefined
|
||||
@@ -137,7 +143,8 @@ export function resolveCompatibleAgentTypeForOwner(
|
||||
*/
|
||||
export function normalizeCompatibleAgentTitleForOwner(
|
||||
title: string,
|
||||
ownerAgentType: AgentType | null | undefined
|
||||
ownerAgentType: AgentType | null | undefined,
|
||||
_options?: CompatibleAgentOwnerOptions
|
||||
): string {
|
||||
const ownerProfile = getSyntheticAgentTitleProfile(ownerAgentType)
|
||||
if (!ownerProfile?.titleIdentityGroup) {
|
||||
@@ -188,11 +195,16 @@ export function normalizeCompatibleAgentTitleForOwner(
|
||||
*/
|
||||
export function normalizeCompatibleAgentStatusEntryForOwner(
|
||||
entry: AgentStatusEntry,
|
||||
ownerAgentType: AgentType | null | undefined
|
||||
ownerAgentType: AgentType | null | undefined,
|
||||
options?: CompatibleAgentOwnerOptions
|
||||
): AgentStatusEntry {
|
||||
const agentType = resolveCompatibleAgentTypeForOwner(entry.agentType, ownerAgentType)
|
||||
const agentType = resolveCompatibleAgentTypeForOwner(entry.agentType, ownerAgentType, options)
|
||||
const terminalTitle = entry.terminalTitle
|
||||
? normalizeCompatibleAgentTitleForOwner(entry.terminalTitle, agentType ?? ownerAgentType)
|
||||
? normalizeCompatibleAgentTitleForOwner(
|
||||
entry.terminalTitle,
|
||||
agentType ?? ownerAgentType,
|
||||
options
|
||||
)
|
||||
: entry.terminalTitle
|
||||
if (agentType === entry.agentType && terminalTitle === entry.terminalTitle) {
|
||||
return entry
|
||||
@@ -203,3 +215,19 @@ export function normalizeCompatibleAgentStatusEntryForOwner(
|
||||
...(terminalTitle ? { terminalTitle } : {})
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns whether two agents share the same title identity group. */
|
||||
export function shareCompatibleTitleIdentityGroup(
|
||||
left: AgentType | null | undefined,
|
||||
right: AgentType | null | undefined
|
||||
): boolean {
|
||||
if (!left || !right) {
|
||||
return false
|
||||
}
|
||||
if (left === right) {
|
||||
return true
|
||||
}
|
||||
const leftGroup = getSyntheticAgentTitleProfile(left)?.titleIdentityGroup
|
||||
const rightGroup = getSyntheticAgentTitleProfile(right)?.titleIdentityGroup
|
||||
return Boolean(leftGroup && leftGroup === rightGroup)
|
||||
}
|
||||
|
||||
@@ -153,6 +153,7 @@ const INVENTORY: readonly InventoryGroup[] = [
|
||||
classification: 'parser-implementation',
|
||||
paths: [
|
||||
['src/renderer/src/lib/pane-agent-evidence.ts', 2],
|
||||
['src/renderer/src/lib/tab-agent-from-signals.ts', 2],
|
||||
'src/shared/terminal-title-agent-type.ts'
|
||||
]
|
||||
},
|
||||
@@ -201,11 +202,9 @@ const INVENTORY: readonly InventoryGroup[] = [
|
||||
helper: 'resolvePaneAgentOwner',
|
||||
classification: 'identity-consumer',
|
||||
paths: [
|
||||
['src/main/runtime/orca-runtime.ts', 3],
|
||||
['src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts', 2],
|
||||
['src/renderer/src/components/terminal-pane/pty-connection/shell-command-inference.ts', 2],
|
||||
['src/renderer/src/lib/use-tab-agent.ts', 2],
|
||||
['src/renderer/src/runtime/web-session-tabs-sync.ts', 3]
|
||||
['src/renderer/src/lib/use-tab-agent.ts', 2]
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -218,8 +217,9 @@ const INVENTORY: readonly InventoryGroup[] = [
|
||||
classification: 'identity-consumer',
|
||||
paths: [
|
||||
['src/main/runtime/orca-runtime.ts', 3],
|
||||
['src/renderer/src/components/sidebar/worktree-agent-rows.ts', 2],
|
||||
['src/renderer/src/components/sidebar/worktree-agent-row-type.ts', 2],
|
||||
['src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts', 2],
|
||||
['src/renderer/src/lib/tab-agent-from-signals.ts', 2],
|
||||
['src/renderer/src/lib/use-tab-agent.ts', 2]
|
||||
]
|
||||
},
|
||||
@@ -232,8 +232,7 @@ const INVENTORY: readonly InventoryGroup[] = [
|
||||
'src/renderer/src/components/terminal-pane/pty-connection/command-inferred-pane-agent.ts',
|
||||
3
|
||||
],
|
||||
['src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts', 3],
|
||||
['src/renderer/src/components/terminal-pane/use-notification-dispatch.ts', 2]
|
||||
['src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts', 3]
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -343,7 +342,7 @@ describe('pane agent identity inventory ratchet', () => {
|
||||
const byHelperAndPath = (left: (typeof actual)[number], right: (typeof actual)[number]) =>
|
||||
left.helper.localeCompare(right.helper) || left.path.localeCompare(right.path)
|
||||
expect(actual.sort(byHelperAndPath)).toEqual(expected.sort(byHelperAndPath))
|
||||
})
|
||||
}, 30_000)
|
||||
|
||||
it('pins direct single-source identity and action branches outside named helpers', () => {
|
||||
for (const site of DIRECT_SINGLE_SOURCE_SURFACES) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolvePaneAgentOwner } from './pane-agent-owner'
|
||||
import { resolvePaneAgentOwner, resolvePaneAgentOwnerRecord } from './pane-agent-owner'
|
||||
|
||||
describe('resolvePaneAgentOwner', () => {
|
||||
it('leads with launch intent', () => {
|
||||
@@ -40,5 +40,25 @@ describe('resolvePaneAgentOwner', () => {
|
||||
it('returns null when no owner evidence exists', () => {
|
||||
expect(resolvePaneAgentOwner({})).toBeNull()
|
||||
expect(resolvePaneAgentOwner({ launchAgent: null, hookAgent: undefined })).toBeNull()
|
||||
expect(resolvePaneAgentOwnerRecord({})).toBeNull()
|
||||
})
|
||||
|
||||
it('marks launch-tier evidence as launch ownership and status-tier as inferred', () => {
|
||||
expect(resolvePaneAgentOwnerRecord({ launchAgent: 'pi', hookAgent: 'omp' })).toEqual({
|
||||
agent: 'pi',
|
||||
ownerIsLaunch: true
|
||||
})
|
||||
expect(resolvePaneAgentOwnerRecord({ startupLaunchAgent: 'pi', hookAgent: 'omp' })).toEqual({
|
||||
agent: 'pi',
|
||||
ownerIsLaunch: true
|
||||
})
|
||||
expect(resolvePaneAgentOwnerRecord({ hookAgent: 'omp' })).toEqual({
|
||||
agent: 'omp',
|
||||
ownerIsLaunch: false
|
||||
})
|
||||
expect(resolvePaneAgentOwnerRecord({ completedHookAgent: 'pi' })).toEqual({
|
||||
agent: 'pi',
|
||||
ownerIsLaunch: false
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,6 +26,27 @@ export type PaneAgentOwnerSignals = {
|
||||
sleepingSessionAgent?: AgentType | null
|
||||
}
|
||||
|
||||
export type ResolvedPaneAgentOwner = {
|
||||
agent: AgentType
|
||||
/** User-selected launch/startup/typed-command identity — not a status frame. */
|
||||
ownerIsLaunch: boolean
|
||||
}
|
||||
|
||||
const PANE_OWNER_RANK: readonly {
|
||||
key: keyof PaneAgentOwnerSignals
|
||||
ownerIsLaunch: boolean
|
||||
}[] = [
|
||||
{ key: 'launchAgent', ownerIsLaunch: true },
|
||||
{ key: 'startupLaunchAgent', ownerIsLaunch: true },
|
||||
{ key: 'initialStatusAgent', ownerIsLaunch: true },
|
||||
{ key: 'commandInferredAgent', ownerIsLaunch: true },
|
||||
{ key: 'hookAgent', ownerIsLaunch: false },
|
||||
{ key: 'siblingHookAgent', ownerIsLaunch: false },
|
||||
{ key: 'completedHookAgent', ownerIsLaunch: false },
|
||||
{ key: 'siblingCompletedHookAgent', ownerIsLaunch: false },
|
||||
{ key: 'sleepingSessionAgent', ownerIsLaunch: false }
|
||||
]
|
||||
|
||||
/**
|
||||
* The single authoritative resolver for "which agent owns this pane", shared by
|
||||
* the tab-icon resolver, the terminal-pane display/renderer owner, and the
|
||||
@@ -42,17 +63,18 @@ export type PaneAgentOwnerSignals = {
|
||||
* launch/live-hook above the completed/sleeping records keeps a genuine pane on
|
||||
* its real agent and stops a stale record from hijacking it.
|
||||
*/
|
||||
export function resolvePaneAgentOwnerRecord(
|
||||
signals: PaneAgentOwnerSignals
|
||||
): ResolvedPaneAgentOwner | null {
|
||||
for (const { key, ownerIsLaunch } of PANE_OWNER_RANK) {
|
||||
const agent = signals[key]
|
||||
if (agent) {
|
||||
return { agent, ownerIsLaunch }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function resolvePaneAgentOwner(signals: PaneAgentOwnerSignals): AgentType | null {
|
||||
return (
|
||||
signals.launchAgent ??
|
||||
signals.startupLaunchAgent ??
|
||||
signals.initialStatusAgent ??
|
||||
signals.commandInferredAgent ??
|
||||
signals.hookAgent ??
|
||||
signals.siblingHookAgent ??
|
||||
signals.completedHookAgent ??
|
||||
signals.siblingCompletedHookAgent ??
|
||||
signals.sleepingSessionAgent ??
|
||||
null
|
||||
)
|
||||
return resolvePaneAgentOwnerRecord(signals)?.agent ?? null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user