mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 00:03:08 +00:00
Harden probe-free launch clearing against pane, remote, and title races
Review fixes: gate the vanished-activity clear to local panes (remote rows drop on transport blips), require no live hook row anywhere in the tab, make the observed-signal generation reset race-safe and self-re-seeding, accept the neutral default title as exit evidence (inferred-interrupt reset target), count a title naming the launched agent as its activity so hookless launched agents can clear, scope layout-less completed rows to single-pane tabs, share one exit-evidence predicate between the resolver and the clear effect, and drop the dead tab-agent wrappers. Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -1,15 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
hasCompletedTabAgent,
|
||||
resolveCompletedTabAgent,
|
||||
resolveFocusedCompletedTabAgent,
|
||||
resolveFocusedTabAgent,
|
||||
resolveSiblingCompletedTabAgent,
|
||||
resolveSiblingTabAgent,
|
||||
resolveTabAgent
|
||||
resolveSiblingTabAgent
|
||||
} from './tab-agent'
|
||||
import type { AgentStatusEntry, AgentType } from '../../../shared/agent-status-types'
|
||||
import type { TerminalLayoutSnapshot } from '../../../shared/types'
|
||||
import type { TerminalLayoutSnapshot, TuiAgent } from '../../../shared/types'
|
||||
|
||||
// Composed exactly the way useTabAgent layers the resolvers: focused pane
|
||||
// first, then any sibling agent pane in the tab.
|
||||
function resolveTabAgent(
|
||||
map: Record<string, AgentStatusEntry>,
|
||||
layout: TerminalLayoutSnapshot | undefined,
|
||||
tabId: string
|
||||
): TuiAgent | null {
|
||||
return resolveFocusedTabAgent(map, layout, tabId) ?? resolveSiblingTabAgent(map, layout, tabId)
|
||||
}
|
||||
|
||||
const LEAF_A = '11111111-1111-4111-8111-111111111111'
|
||||
const LEAF_B = '22222222-2222-4222-8222-222222222222'
|
||||
@@ -97,8 +104,8 @@ describe('resolveTabAgent', () => {
|
||||
state: 'done' as const
|
||||
}
|
||||
}
|
||||
expect(hasCompletedTabAgent(map, 'tab-1')).toBe(true)
|
||||
expect(resolveCompletedTabAgent(map, 'tab-1')).toBe('openclaude')
|
||||
expect(resolveFocusedCompletedTabAgent(map, undefined, 'tab-1')).toBe('openclaude')
|
||||
expect(resolveSiblingCompletedTabAgent(map, undefined, 'tab-1')).toBeNull()
|
||||
})
|
||||
|
||||
it('exposes focused and sibling completed hook identity separately', () => {
|
||||
|
||||
@@ -4,26 +4,13 @@ import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../shared/sta
|
||||
import { agentTypeToIconAgent } from './agent-status'
|
||||
|
||||
/**
|
||||
* Resolve a terminal tab's agent from hook-reported status. This is the
|
||||
* PRIMARY identity signal for the tab-bar icon (see useTabAgent) — the same
|
||||
* Resolve a terminal tab's agent from hook-reported status — the PRIMARY
|
||||
* identity signal for the tab-bar icon (composed by useTabAgent): the same
|
||||
* already-computed state that drives the sidebar agent rows, kept live by the
|
||||
* OSC 133 command-finished machinery that drops entries when a process exits.
|
||||
*
|
||||
* Prefers the focused pane's agent so a split tab's icon tracks the pane in
|
||||
* view; falls back to any agent pane in the tab. Returns null when no pane
|
||||
* reports an iconable agent.
|
||||
* Focused-pane resolvers track the pane in view; sibling resolvers cover the
|
||||
* rest of a split tab.
|
||||
*/
|
||||
export function resolveTabAgent(
|
||||
agentStatusByPaneKey: Record<string, AgentStatusEntry>,
|
||||
layout: TerminalLayoutSnapshot | undefined,
|
||||
tabId: string
|
||||
): TuiAgent | null {
|
||||
return (
|
||||
resolveFocusedTabAgent(agentStatusByPaneKey, layout, tabId) ??
|
||||
resolveSiblingTabAgent(agentStatusByPaneKey, layout, tabId)
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveFocusedTabAgent(
|
||||
agentStatusByPaneKey: Record<string, AgentStatusEntry>,
|
||||
layout: TerminalLayoutSnapshot | undefined,
|
||||
@@ -75,24 +62,6 @@ function agentFromStatusEntry(entry: AgentStatusEntry | undefined): TuiAgent | n
|
||||
return agentTypeToIconAgent(entry.agentType)
|
||||
}
|
||||
|
||||
export function hasCompletedTabAgent(
|
||||
agentStatusByPaneKey: Record<string, AgentStatusEntry>,
|
||||
tabId: string
|
||||
): boolean {
|
||||
return resolveCompletedTabAgent(agentStatusByPaneKey, tabId) !== null
|
||||
}
|
||||
|
||||
export function resolveCompletedTabAgent(
|
||||
agentStatusByPaneKey: Record<string, AgentStatusEntry>,
|
||||
tabId: string,
|
||||
layout?: TerminalLayoutSnapshot
|
||||
): TuiAgent | null {
|
||||
return (
|
||||
resolveFocusedCompletedTabAgent(agentStatusByPaneKey, layout, tabId) ??
|
||||
resolveSiblingCompletedTabAgent(agentStatusByPaneKey, layout, tabId)
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveFocusedCompletedTabAgent(
|
||||
agentStatusByPaneKey: Record<string, AgentStatusEntry>,
|
||||
layout: TerminalLayoutSnapshot | undefined,
|
||||
|
||||
@@ -86,7 +86,6 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
isRemote: false,
|
||||
title: 'Terminal 1',
|
||||
hookAgent: null,
|
||||
hasCompletedHook: false,
|
||||
launchAgent: 'claude'
|
||||
})
|
||||
).toBe('claude')
|
||||
@@ -99,7 +98,6 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
isRemote: false,
|
||||
title: 'zsh',
|
||||
hookAgent: 'claude',
|
||||
hasCompletedHook: false,
|
||||
launchAgent: 'claude'
|
||||
})
|
||||
).toBe('claude')
|
||||
@@ -112,7 +110,6 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
isRemote: false,
|
||||
title: '⠋ OpenClaude',
|
||||
hookAgent: null,
|
||||
hasCompletedHook: false,
|
||||
launchAgent: undefined
|
||||
})
|
||||
).toBe('openclaude')
|
||||
@@ -125,7 +122,6 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
isRemote: false,
|
||||
title: '✦ Gemini CLI',
|
||||
hookAgent: null,
|
||||
hasCompletedHook: false,
|
||||
launchAgent: undefined
|
||||
})
|
||||
).toBe('gemini')
|
||||
@@ -136,7 +132,6 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
isRemote: false,
|
||||
title: 'MiMo Code',
|
||||
hookAgent: null,
|
||||
hasCompletedHook: false,
|
||||
launchAgent: undefined
|
||||
})
|
||||
).toBe('mimo-code')
|
||||
@@ -147,7 +142,6 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
isRemote: false,
|
||||
title: 'π - my-project',
|
||||
hookAgent: null,
|
||||
hasCompletedHook: false,
|
||||
launchAgent: undefined
|
||||
})
|
||||
).toBe('pi')
|
||||
@@ -160,8 +154,7 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
isRemote: false,
|
||||
title: '✳ Say hi',
|
||||
hookAgent: null,
|
||||
hasCompletedHook: true,
|
||||
completedHookAgent: 'openclaude',
|
||||
focusedCompletedHookAgent: 'openclaude',
|
||||
launchAgent: 'openclaude'
|
||||
})
|
||||
).toBe('openclaude')
|
||||
@@ -174,7 +167,6 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
isRemote: false,
|
||||
title: '✳ Say hi',
|
||||
hookAgent: null,
|
||||
hasCompletedHook: false,
|
||||
launchAgent: 'openclaude'
|
||||
})
|
||||
).toBe('openclaude')
|
||||
@@ -187,7 +179,6 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
isRemote: false,
|
||||
title: '⠸ codex-quarter-flash-202606191419',
|
||||
hookAgent: null,
|
||||
hasCompletedHook: false,
|
||||
launchAgent: 'codex'
|
||||
})
|
||||
).toBe('codex')
|
||||
@@ -200,7 +191,6 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
isRemote: false,
|
||||
title: '⠸ investigating startup',
|
||||
hookAgent: null,
|
||||
hasCompletedHook: false,
|
||||
launchAgent: undefined
|
||||
})
|
||||
).toBeNull()
|
||||
@@ -214,7 +204,6 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
isRemote: false,
|
||||
title,
|
||||
hookAgent: null,
|
||||
hasCompletedHook: false,
|
||||
launchAgent: undefined
|
||||
})
|
||||
).toBeNull()
|
||||
@@ -228,7 +217,6 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
isRemote: false,
|
||||
title: '⠸ Claude Code',
|
||||
hookAgent: null,
|
||||
hasCompletedHook: false,
|
||||
launchAgent: 'codex'
|
||||
})
|
||||
).toBe('codex')
|
||||
@@ -241,7 +229,6 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
isRemote: false,
|
||||
title: '✳ improve-pr-actions-customization',
|
||||
hookAgent: 'codex',
|
||||
hasCompletedHook: false,
|
||||
launchAgent: 'codex'
|
||||
})
|
||||
).toBe('codex')
|
||||
@@ -254,7 +241,6 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
isRemote: false,
|
||||
title: '✳ Claude Code',
|
||||
hookAgent: null,
|
||||
hasCompletedHook: false,
|
||||
launchAgent: 'openclaude'
|
||||
})
|
||||
).toBe('openclaude')
|
||||
@@ -267,7 +253,6 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
isRemote: false,
|
||||
title: '✳ Claude Code',
|
||||
hookAgent: null,
|
||||
hasCompletedHook: false,
|
||||
launchAgent: 'codex'
|
||||
})
|
||||
).toBe('claude')
|
||||
@@ -280,7 +265,6 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
isRemote: false,
|
||||
title: '✳ Claude Code',
|
||||
hookAgent: null,
|
||||
hasCompletedHook: false,
|
||||
launchAgent: 'codex'
|
||||
})
|
||||
).toBe('codex')
|
||||
@@ -293,7 +277,6 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
isRemote: false,
|
||||
title: '✳ Gemini CLI',
|
||||
hookAgent: 'claude',
|
||||
hasCompletedHook: false,
|
||||
launchAgent: 'claude'
|
||||
})
|
||||
).toBe('claude')
|
||||
@@ -306,7 +289,6 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
isRemote: false,
|
||||
title: '✦ Gemini CLI',
|
||||
hookAgent: 'claude',
|
||||
hasCompletedHook: false,
|
||||
launchAgent: 'gemini'
|
||||
})
|
||||
).toBe('claude')
|
||||
@@ -320,7 +302,6 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
title: 'Terminal 1',
|
||||
hookAgent: 'claude',
|
||||
siblingHookAgent: 'gemini',
|
||||
hasCompletedHook: false,
|
||||
launchAgent: 'codex'
|
||||
})
|
||||
).toBe('claude')
|
||||
@@ -334,7 +315,6 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
title: 'Terminal 1',
|
||||
hookAgent: null,
|
||||
siblingHookAgent: 'claude',
|
||||
hasCompletedHook: false,
|
||||
launchAgent: 'codex'
|
||||
})
|
||||
).toBe('codex')
|
||||
@@ -348,7 +328,6 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
title: 'Terminal 1',
|
||||
hookAgent: null,
|
||||
siblingHookAgent: 'claude',
|
||||
hasCompletedHook: false,
|
||||
launchAgent: undefined
|
||||
})
|
||||
).toBe('claude')
|
||||
@@ -361,7 +340,6 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
isRemote: false,
|
||||
title: '✳ Gemini CLI',
|
||||
hookAgent: null,
|
||||
hasCompletedHook: false,
|
||||
launchAgent: 'gemini'
|
||||
})
|
||||
).toBe('gemini')
|
||||
@@ -374,7 +352,6 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
isRemote: false,
|
||||
title: '. Compare Opencode Vs Orca',
|
||||
hookAgent: null,
|
||||
hasCompletedHook: false,
|
||||
launchAgent: 'opencode'
|
||||
})
|
||||
).toBe('opencode')
|
||||
@@ -385,7 +362,6 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
isRemote: false,
|
||||
title: '* Review Codex behavior',
|
||||
hookAgent: null,
|
||||
hasCompletedHook: false,
|
||||
launchAgent: 'codex'
|
||||
})
|
||||
).toBe('codex')
|
||||
@@ -398,7 +374,6 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
isRemote: false,
|
||||
title: '✳ Claude Code',
|
||||
hookAgent: null,
|
||||
hasCompletedHook: false,
|
||||
launchAgent: undefined
|
||||
})
|
||||
).toBe('claude')
|
||||
@@ -409,23 +384,64 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
isRemote: false,
|
||||
title: '. Claude Code compare Opencode',
|
||||
hookAgent: null,
|
||||
hasCompletedHook: false,
|
||||
launchAgent: undefined
|
||||
})
|
||||
).toBe('claude')
|
||||
})
|
||||
|
||||
it('keeps launch identity at a shell title until hook evidence proves exit', () => {
|
||||
it('clears local launch identity once observed activity vanishes at a shell title', () => {
|
||||
// Why: matches the clear effect — the dropped hook row plus a shell title
|
||||
// is the crash/kill exit evidence, so the resolver must not lag it.
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
hasObservedAgentSignal: true,
|
||||
isRemote: false,
|
||||
title: 'zsh',
|
||||
hookAgent: null,
|
||||
hasCompletedHook: false,
|
||||
launchAgent: 'codex'
|
||||
})
|
||||
).toBe('codex')
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps launch identity at a shell title while a sibling hook row is live', () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
hasObservedAgentSignal: true,
|
||||
isRemote: false,
|
||||
title: 'zsh',
|
||||
hookAgent: null,
|
||||
siblingHookAgent: 'gemini',
|
||||
launchAgent: 'claude'
|
||||
})
|
||||
).toBe('claude')
|
||||
})
|
||||
|
||||
it('keeps remote launch identity at a shell title without completed-hook evidence', () => {
|
||||
// Why: remote hook rows also drop on transport blips, so vanished activity
|
||||
// alone must not count as exit evidence for remote panes.
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
hasObservedAgentSignal: true,
|
||||
isRemote: true,
|
||||
title: 'zsh',
|
||||
hookAgent: null,
|
||||
launchAgent: 'claude'
|
||||
})
|
||||
).toBe('claude')
|
||||
})
|
||||
|
||||
it('treats the neutral default title as exit evidence alongside shell titles', () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
hasObservedAgentSignal: true,
|
||||
isRemote: false,
|
||||
title: 'Terminal 2',
|
||||
defaultTitle: 'Terminal 2',
|
||||
hookAgent: null,
|
||||
focusedCompletedHookAgent: 'claude',
|
||||
launchAgent: 'claude'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps hook identity for remote panes', () => {
|
||||
@@ -435,7 +451,6 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
isRemote: true,
|
||||
title: 'Terminal 1',
|
||||
hookAgent: 'codex',
|
||||
hasCompletedHook: false,
|
||||
launchAgent: 'claude'
|
||||
})
|
||||
).toBe('codex')
|
||||
@@ -448,8 +463,7 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
isRemote: true,
|
||||
title: 'zsh',
|
||||
hookAgent: null,
|
||||
hasCompletedHook: true,
|
||||
completedHookAgent: 'codex',
|
||||
focusedCompletedHookAgent: 'codex',
|
||||
launchAgent: 'codex'
|
||||
})
|
||||
).toBe('codex')
|
||||
@@ -465,8 +479,7 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
isRemote: false,
|
||||
title: 'zsh',
|
||||
hookAgent: null,
|
||||
hasCompletedHook: true,
|
||||
completedHookAgent: 'claude',
|
||||
focusedCompletedHookAgent: 'claude',
|
||||
launchAgent: 'claude'
|
||||
})
|
||||
).toBeNull()
|
||||
@@ -585,9 +598,7 @@ describe('useTabAgent', () => {
|
||||
expect(latestHookAgent).toBe('codex')
|
||||
expect(clearTabLaunchAgent).not.toHaveBeenCalled()
|
||||
|
||||
// Why: crash/kill exits never fire a completed hook — the OSC 133
|
||||
// command-finished machinery drops the live entry and the title returns
|
||||
// to a shell. That already-computed evidence must clear launch intent.
|
||||
// Why: crash/kill exits drop the live row without a completed hook.
|
||||
await act(async () => {
|
||||
useAppStore.setState({ agentStatusByPaneKey: {} })
|
||||
})
|
||||
@@ -672,4 +683,142 @@ describe('useTabAgent', () => {
|
||||
expect(clearTabLaunchAgent).not.toHaveBeenCalled()
|
||||
expect(getForegroundProcess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not clear remote launch identity when the hook row drops at a shell title', async () => {
|
||||
const remotePtyId = 'remote:web-env-1@@terminal-1'
|
||||
const paneKey = makePaneKey('tab-1', LEAF_ID)
|
||||
useAppStore.setState({
|
||||
ptyIdsByTabId: { 'tab-1': [remotePtyId] },
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': {
|
||||
root: { type: 'leaf', leafId: LEAF_ID },
|
||||
activeLeafId: LEAF_ID,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_ID]: remotePtyId }
|
||||
}
|
||||
},
|
||||
agentStatusByPaneKey: {
|
||||
[paneKey]: workingAgentStatus(paneKey)
|
||||
}
|
||||
})
|
||||
const remoteTab = { ...baseTab, ptyId: remotePtyId, launchAgent: 'claude' as const }
|
||||
|
||||
const root = await renderHookProbe(remoteTab)
|
||||
|
||||
// Why: remote rows also drop on transport blips (reconnect, snapshot gaps)
|
||||
// that say nothing about the process — only a completed hook may clear.
|
||||
await act(async () => {
|
||||
useAppStore.setState({ agentStatusByPaneKey: {} })
|
||||
})
|
||||
await rerenderHookProbe(root, { ...remoteTab, title: 'zsh' })
|
||||
|
||||
expect(clearTabLaunchAgent).not.toHaveBeenCalled()
|
||||
expect(latestHookAgent).toBe('claude')
|
||||
})
|
||||
|
||||
it('does not clear launch identity while a sibling hook row is still live', async () => {
|
||||
const focusedPaneKey = makePaneKey('tab-1', LEAF_ID)
|
||||
const siblingPaneKey = makePaneKey('tab-1', SECOND_LEAF_ID)
|
||||
useAppStore.setState({
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-focus', 'pty-sibling'] },
|
||||
terminalLayoutsByTabId: { 'tab-1': twoPaneLayout() },
|
||||
agentStatusByPaneKey: {
|
||||
[focusedPaneKey]: workingAgentStatus(focusedPaneKey),
|
||||
[siblingPaneKey]: workingAgentStatus(siblingPaneKey)
|
||||
}
|
||||
})
|
||||
const splitTab = { ...baseTab, ptyId: 'pty-focus', launchAgent: 'claude' as const }
|
||||
|
||||
const root = await renderHookProbe(splitTab)
|
||||
|
||||
// Focused row drops but the sibling agent still runs: no exit evidence yet.
|
||||
await act(async () => {
|
||||
useAppStore.setState({
|
||||
agentStatusByPaneKey: { [siblingPaneKey]: workingAgentStatus(siblingPaneKey) }
|
||||
})
|
||||
})
|
||||
await rerenderHookProbe(root, { ...splitTab, title: 'zsh' })
|
||||
expect(clearTabLaunchAgent).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
useAppStore.setState({ agentStatusByPaneKey: {} })
|
||||
})
|
||||
await rerenderHookProbe(root, { ...splitTab, title: 'zsh' })
|
||||
expect(clearTabLaunchAgent).toHaveBeenCalledWith('tab-1')
|
||||
})
|
||||
|
||||
it('clears launch identity at the neutral default title after a completed hook', async () => {
|
||||
const paneKey = makePaneKey('tab-1', LEAF_ID)
|
||||
useAppStore.setState({
|
||||
agentStatusByPaneKey: {
|
||||
[paneKey]: completedAgentStatus(paneKey)
|
||||
}
|
||||
})
|
||||
|
||||
// Why: the inferred-interrupt flow resets titles to the tab's default
|
||||
// ("Terminal N"), not a shell name — that must still count as exit.
|
||||
await renderHookProbe({ ...baseTab, title: 'Terminal 3', defaultTitle: 'Terminal 3' })
|
||||
|
||||
expect(clearTabLaunchAgent).toHaveBeenCalledWith('tab-1')
|
||||
})
|
||||
|
||||
it('clears hookless launch identity once its own title evidence ends at a shell', async () => {
|
||||
const geminiTab = { ...baseTab, launchAgent: 'gemini' as const, title: '✦ Gemini CLI' }
|
||||
|
||||
// Why: agents without hook integration prove activity via a title naming
|
||||
// the launched agent; the later shell title is then exit evidence.
|
||||
const root = await renderHookProbe(geminiTab)
|
||||
expect(clearTabLaunchAgent).not.toHaveBeenCalled()
|
||||
|
||||
await rerenderHookProbe(root, { ...geminiTab, title: 'zsh' })
|
||||
|
||||
expect(clearTabLaunchAgent).toHaveBeenCalledWith('tab-1')
|
||||
})
|
||||
|
||||
it('does not treat a layout-less multi-pane completed row as focused exit evidence', async () => {
|
||||
const siblingPaneKey = makePaneKey('tab-1', SECOND_LEAF_ID)
|
||||
useAppStore.setState({
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-focus', 'pty-sibling'] },
|
||||
terminalLayoutsByTabId: {},
|
||||
agentStatusByPaneKey: {
|
||||
[siblingPaneKey]: completedAgentStatus(siblingPaneKey)
|
||||
}
|
||||
})
|
||||
|
||||
await renderHookProbe({ ...baseTab, ptyId: 'pty-focus', title: 'zsh', launchAgent: 'claude' })
|
||||
|
||||
expect(clearTabLaunchAgent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not clear launch identity on the commit that switches pane generations', async () => {
|
||||
const paneKey = makePaneKey('tab-1', LEAF_ID)
|
||||
const boundLayout = {
|
||||
root: { type: 'leaf', leafId: LEAF_ID } as const,
|
||||
activeLeafId: LEAF_ID,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_ID]: 'pty-1' }
|
||||
}
|
||||
useAppStore.setState({
|
||||
terminalLayoutsByTabId: { 'tab-1': boundLayout },
|
||||
agentStatusByPaneKey: { [paneKey]: workingAgentStatus(paneKey) }
|
||||
})
|
||||
|
||||
const root = await renderHookProbe(baseTab)
|
||||
expect(latestHookAgent).toBe('codex')
|
||||
|
||||
// Why: a respawn/focus switch can land the new ptyId, the dropped row, and
|
||||
// a shell title in one commit — the previous generation's observed signal
|
||||
// must not clear the new generation's launch identity.
|
||||
await act(async () => {
|
||||
useAppStore.setState({
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': { ...boundLayout, ptyIdsByLeafId: { [LEAF_ID]: 'pty-2' } }
|
||||
},
|
||||
agentStatusByPaneKey: {}
|
||||
})
|
||||
})
|
||||
await rerenderHookProbe(root, { ...baseTab, title: 'zsh' })
|
||||
|
||||
expect(clearTabLaunchAgent).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useAppStore } from '@/store'
|
||||
import { isShellProcess } from '../../../shared/agent-detection'
|
||||
import { worktreeUsesRemoteConnection } from '@/store/slices/terminals'
|
||||
import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream'
|
||||
import { isTerminalLeafId } from '../../../shared/stable-pane-id'
|
||||
import {
|
||||
resolveFocusedCompletedTabAgent,
|
||||
resolveFocusedTabAgent,
|
||||
@@ -14,14 +15,48 @@ import type { TerminalTab, TuiAgent } from '../../../shared/types'
|
||||
|
||||
export { resolveExplicitTerminalTitleAgentType as resolveTabAgentFromTitle } from '../../../shared/terminal-title-agent-type'
|
||||
|
||||
// A shell name, or the tab's neutral default title — where Orca's
|
||||
// inferred-interrupt reset parks it. Blank titles are no evidence either way.
|
||||
function titleShowsNoAgent(title: string, defaultTitle?: string): boolean {
|
||||
const trimmed = title.trim()
|
||||
return trimmed.length > 0 && (isShellProcess(trimmed) || trimmed === defaultTitle?.trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe-free evidence that a launched agent exited: the title shows no agent,
|
||||
* no live hook row remains in the tab, and either the hook completed or
|
||||
* previously observed activity vanished. The vanished-activity disjunct is
|
||||
* local-only: remote rows also drop on transport blips that say nothing about
|
||||
* the process.
|
||||
*/
|
||||
export function resolveLaunchedAgentExitEvidence(args: {
|
||||
title: string
|
||||
defaultTitle?: string
|
||||
isRemote: boolean
|
||||
hasObservedAgentSignal: boolean
|
||||
hookAgent: TuiAgent | null
|
||||
siblingHookAgent?: TuiAgent | null
|
||||
hasCompletedHook: boolean
|
||||
}): boolean {
|
||||
if (
|
||||
!titleShowsNoAgent(args.title, args.defaultTitle) ||
|
||||
args.hookAgent ||
|
||||
args.siblingHookAgent
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return args.hasCompletedHook || (!args.isRemote && args.hasObservedAgentSignal)
|
||||
}
|
||||
|
||||
export function resolveTabAgentFromSignals(args: {
|
||||
hasObservedAgentSignal: boolean
|
||||
isRemote: boolean
|
||||
title: string
|
||||
defaultTitle?: string
|
||||
hookAgent: TuiAgent | null
|
||||
siblingHookAgent?: TuiAgent | null
|
||||
hasCompletedHook: boolean
|
||||
completedHookAgent?: TuiAgent | null
|
||||
focusedCompletedHookAgent?: TuiAgent | null
|
||||
siblingCompletedHookAgent?: TuiAgent | null
|
||||
launchAgent?: TuiAgent
|
||||
}): TuiAgent | null {
|
||||
const launchAgent = args.launchAgent ?? null
|
||||
@@ -40,17 +75,26 @@ export function resolveTabAgentFromSignals(args: {
|
||||
: launchAgent
|
||||
? null
|
||||
: explicitTitleAgent
|
||||
const titleLooksShell = isShellProcess(args.title)
|
||||
const hasCompletedHook = (args.focusedCompletedHookAgent ?? null) !== null
|
||||
const noAgentTitle = titleShowsNoAgent(args.title, args.defaultTitle)
|
||||
// Why: remote pane titles can lag their runtime, so keep the last completed
|
||||
// hook identity instead of flashing unknown when the title reads as a shell.
|
||||
const completedHookAgent =
|
||||
!args.isRemote && titleLooksShell && args.hasCompletedHook ? null : args.completedHookAgent
|
||||
!args.isRemote && noAgentTitle && hasCompletedHook
|
||||
? null
|
||||
: (args.focusedCompletedHookAgent ?? args.siblingCompletedHookAgent ?? null)
|
||||
const focusedHookAgent = args.hookAgent ?? null
|
||||
const fallbackHookAgent = args.siblingHookAgent ?? completedHookAgent ?? null
|
||||
// Why: a completed hook with the title back at a shell is this pipeline's
|
||||
// process-gone evidence — the same signals that clear the sidebar row.
|
||||
const completedHookAtShellTitle = titleLooksShell && args.hasCompletedHook
|
||||
const activeLaunchAgent = completedHookAtShellTitle ? null : launchAgent
|
||||
const launchedAgentExited = resolveLaunchedAgentExitEvidence({
|
||||
title: args.title,
|
||||
defaultTitle: args.defaultTitle,
|
||||
isRemote: args.isRemote,
|
||||
hasObservedAgentSignal: args.hasObservedAgentSignal,
|
||||
hookAgent: focusedHookAgent,
|
||||
siblingHookAgent: args.siblingHookAgent,
|
||||
hasCompletedHook
|
||||
})
|
||||
const activeLaunchAgent = launchedAgentExited ? null : launchAgent
|
||||
// Why: titleAgent ranks ahead of launch/fallback hooks because, once the
|
||||
// pane has shown activity, a live explicit title is the freshest identity
|
||||
// signal — it beats a launchAgent gone stale through pane reuse. Before any
|
||||
@@ -97,7 +141,6 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null {
|
||||
tab.id
|
||||
)
|
||||
)
|
||||
const completedHookAgent = focusedCompletedHookAgent ?? siblingCompletedHookAgent
|
||||
const hasCompletedHook = focusedCompletedHookAgent !== null
|
||||
const clearTabLaunchAgent = useAppStore((s) => s.clearTabLaunchAgent)
|
||||
|
||||
@@ -113,6 +156,16 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null {
|
||||
const ptyIds = s.ptyIdsByTabId[tab.id] ?? []
|
||||
return ptyIds.length === 1 ? ptyIds[0]! : null
|
||||
})
|
||||
// Why: with no layout to prove which pane a completed row belongs to, only a
|
||||
// single-pane tab may treat it as focused-pane exit evidence — a sibling's
|
||||
// done row must not clear another pane's launch identity.
|
||||
const completedHookScopeKnown = useAppStore((s) => {
|
||||
const layout = s.terminalLayoutsByTabId[tab.id]
|
||||
if (layout?.activeLeafId && isTerminalLeafId(layout.activeLeafId)) {
|
||||
return true
|
||||
}
|
||||
return (s.ptyIdsByTabId[tab.id] ?? []).length <= 1
|
||||
})
|
||||
const hasRemoteRuntimePty = useAppStore((s) => {
|
||||
const layout = s.terminalLayoutsByTabId[tab.id]
|
||||
const ptyIds = new Set(s.ptyIdsByTabId[tab.id] ?? [])
|
||||
@@ -126,41 +179,67 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null {
|
||||
|
||||
const [hasObservedAgentSignal, setHasObservedAgentSignal] = useState(false)
|
||||
const hasObservedAgentSignalRef = useRef(false)
|
||||
const signalGenerationRef = useRef<string | null>(null)
|
||||
const completedHookEvidence = hasCompletedHook && completedHookScopeKnown
|
||||
|
||||
useEffect(() => {
|
||||
setHasObservedAgentSignal(false)
|
||||
hasObservedAgentSignalRef.current = false
|
||||
}, [ptyId, isRemoteLike])
|
||||
|
||||
useEffect(() => {
|
||||
const fallbackAgentSignal =
|
||||
!tab.launchAgent && (resolveExplicitTerminalTitleAgentType(tab.title) || siblingHookAgent)
|
||||
if (focusedHookAgent || hasCompletedHook || fallbackAgentSignal) {
|
||||
// Why: reset and re-seed in one effect so a pane respawn both invalidates
|
||||
// the previous generation's signal and immediately re-observes a still-live
|
||||
// hook row instead of leaving the signal stuck false.
|
||||
const generation = `${ptyId ?? ''}|${String(isRemoteLike)}`
|
||||
if (signalGenerationRef.current !== generation) {
|
||||
signalGenerationRef.current = generation
|
||||
hasObservedAgentSignalRef.current = false
|
||||
setHasObservedAgentSignal(false)
|
||||
}
|
||||
const explicitTitleAgent = resolveExplicitTerminalTitleAgentType(tab.title)
|
||||
// Why: for launched panes, only a title naming the launched agent counts as
|
||||
// its activity — other-agent or sibling evidence must not arm exit clearing
|
||||
// for an agent that never produced evidence of its own.
|
||||
const fallbackAgentSignal = tab.launchAgent
|
||||
? explicitTitleAgent === tab.launchAgent
|
||||
: Boolean(explicitTitleAgent || siblingHookAgent)
|
||||
if (focusedHookAgent || completedHookEvidence || fallbackAgentSignal) {
|
||||
hasObservedAgentSignalRef.current = true
|
||||
setHasObservedAgentSignal(true)
|
||||
}
|
||||
}, [focusedHookAgent, hasCompletedHook, siblingHookAgent, tab.launchAgent, tab.title])
|
||||
}, [
|
||||
ptyId,
|
||||
isRemoteLike,
|
||||
focusedHookAgent,
|
||||
completedHookEvidence,
|
||||
siblingHookAgent,
|
||||
tab.launchAgent,
|
||||
tab.title
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!tab.launchAgent) {
|
||||
return
|
||||
}
|
||||
const titleLooksShell = isShellProcess(tab.title)
|
||||
// Why: launched-agent exit evidence without probing — either the hook
|
||||
// completed, or the pane's live hook row was dropped by command-finished
|
||||
// after the pane had shown agent activity, while the title reads as a
|
||||
// shell again. Crash/kill paths land in the second disjunct because the
|
||||
// OSC 133 drop removes the live entry without a completed hook.
|
||||
const launchedAgentExited =
|
||||
titleLooksShell && (hasCompletedHook || (hasObservedAgentSignal && !focusedHookAgent))
|
||||
// Why: AND the state with the ref — the ref is generation-safe within this
|
||||
// commit (the observe effect above already reset it), while the state can
|
||||
// lag one render behind a pane focus/respawn switch.
|
||||
const launchedAgentExited = resolveLaunchedAgentExitEvidence({
|
||||
title: tab.title,
|
||||
defaultTitle: tab.defaultTitle,
|
||||
isRemote: isRemoteLike,
|
||||
hasObservedAgentSignal: hasObservedAgentSignal && hasObservedAgentSignalRef.current,
|
||||
hookAgent: focusedHookAgent,
|
||||
siblingHookAgent,
|
||||
hasCompletedHook: completedHookEvidence
|
||||
})
|
||||
if (launchedAgentExited) {
|
||||
clearTabLaunchAgent(tab.id)
|
||||
}
|
||||
}, [
|
||||
clearTabLaunchAgent,
|
||||
completedHookEvidence,
|
||||
focusedHookAgent,
|
||||
hasCompletedHook,
|
||||
siblingHookAgent,
|
||||
hasObservedAgentSignal,
|
||||
isRemoteLike,
|
||||
tab.defaultTitle,
|
||||
tab.id,
|
||||
tab.launchAgent,
|
||||
tab.title
|
||||
@@ -170,10 +249,11 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null {
|
||||
hasObservedAgentSignal,
|
||||
isRemote: isRemoteLike,
|
||||
title: tab.title,
|
||||
defaultTitle: tab.defaultTitle,
|
||||
hookAgent: focusedHookAgent,
|
||||
siblingHookAgent,
|
||||
hasCompletedHook,
|
||||
completedHookAgent,
|
||||
focusedCompletedHookAgent,
|
||||
siblingCompletedHookAgent,
|
||||
launchAgent: tab.launchAgent
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user