fix terminal agent icon ownership

This commit is contained in:
Brennan Benson
2026-08-04 14:15:52 -07:00
parent 2073f7eeb2
commit 6cb25527c7
8 changed files with 225 additions and 2 deletions
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { canCommandCodeOutputOwnPane } from './command-code-output-ownership'
describe('canCommandCodeOutputOwnPane', () => {
it('allows the banner fallback when no stronger identity exists', () => {
expect(canCommandCodeOutputOwnPane({})).toBe(true)
expect(canCommandCodeOutputOwnPane({ paneOwnerAgent: 'unknown' })).toBe(true)
})
it('allows Command Code ownership evidence', () => {
expect(canCommandCodeOutputOwnPane({ paneOwnerAgent: 'command-code' })).toBe(true)
expect(canCommandCodeOutputOwnPane({ foregroundAgent: 'command-code' })).toBe(true)
})
it('rejects another agent owner or foreground process', () => {
expect(canCommandCodeOutputOwnPane({ paneOwnerAgent: 'claude' })).toBe(false)
expect(canCommandCodeOutputOwnPane({ foregroundAgent: 'claude' })).toBe(false)
})
it('prefers the current foreground process over stale pane ownership', () => {
expect(
canCommandCodeOutputOwnPane({
foregroundAgent: 'command-code',
paneOwnerAgent: 'claude'
})
).toBe(true)
expect(
canCommandCodeOutputOwnPane({
foregroundAgent: 'claude',
paneOwnerAgent: 'command-code'
})
).toBe(false)
})
it('rejects output observed at a confirmed shell prompt', () => {
expect(canCommandCodeOutputOwnPane({ shellForeground: true })).toBe(false)
})
})
@@ -0,0 +1,20 @@
import type { AgentType } from '../../../../shared/agent-status-types'
import type { TuiAgent } from '../../../../shared/types'
export function canCommandCodeOutputOwnPane(args: {
foregroundAgent?: TuiAgent | null
shellForeground?: boolean
paneOwnerAgent?: AgentType | null
}): boolean {
if (args.foregroundAgent) {
return args.foregroundAgent === 'command-code'
}
if (args.shellForeground) {
return false
}
return (
!args.paneOwnerAgent ||
args.paneOwnerAgent === 'unknown' ||
args.paneOwnerAgent === 'command-code'
)
}
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types'
import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent'
const PTY_ID_LOCAL = 'pty-1'
const PTY_ID_SSH = 'ssh:target-1@@pty-9'
@@ -13,7 +14,10 @@ const DONE_SETTLE_MS = 1500
const ROUTING = { connectionId: null }
type MockStoreState = {
tabsByWorktree: Record<string, { id: string; launchAgent?: AgentType }[]>
agentStatusByPaneKey: Record<string, AgentStatusEntry | undefined>
paneForegroundAgentByPaneKey: Record<string, PaneForegroundAgentEntry>
agentLaunchConfigByPaneKey: Record<string, { identity: { agentType?: AgentType } } | undefined>
runtimePaneTitlesByTabId: Record<string, Record<number, string | undefined>>
setAgentStatus: ReturnType<typeof vi.fn>
dropAgentStatus: ReturnType<typeof vi.fn>
@@ -40,7 +44,10 @@ vi.mock('@/lib/connection-owner-resolution', () => ({
function makeMockStoreState(): MockStoreState {
return {
tabsByWorktree: { [WORKTREE_ID]: [{ id: TAB_ID }] },
agentStatusByPaneKey: {},
paneForegroundAgentByPaneKey: {},
agentLaunchConfigByPaneKey: {},
runtimePaneTitlesByTabId: { [TAB_ID]: { [PANE_ID]: '✳ Build feature' } },
setAgentStatus: vi.fn(),
dropAgentStatus: vi.fn(),
@@ -110,6 +117,66 @@ describe('createParkedTerminalCommandStatusPolicy', () => {
policy.dispose()
})
it('rejects scrape status when a Claude hook owns the pane', async () => {
mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({ state: 'done' })
const policy = await createPolicy(PTY_ID_LOCAL)
policy.onCommandCodeWorking('False prompt')
policy.onCommandCodeDone('False prompt')
vi.advanceTimersByTime(DONE_SETTLE_MS)
expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled()
policy.dispose()
})
it('rejects scrape status when Claude launch metadata owns the pane', async () => {
mockStoreState.tabsByWorktree[WORKTREE_ID] = [{ id: TAB_ID, launchAgent: 'claude' }]
const policy = await createPolicy(PTY_ID_LOCAL)
policy.onCommandCodeWorking('False prompt')
expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled()
policy.dispose()
})
it('rejects scrape status when Claude is the foreground process', async () => {
mockStoreState.paneForegroundAgentByPaneKey[PANE_KEY] = {
agent: 'claude',
shellForeground: false
}
const policy = await createPolicy(PTY_ID_LOCAL)
policy.onCommandCodeWorking('False prompt')
expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled()
policy.dispose()
})
it('lets a current Command Code process reclaim stale Claude ownership', async () => {
mockStoreState.tabsByWorktree[WORKTREE_ID] = [{ id: TAB_ID, launchAgent: 'claude' }]
mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({ state: 'done' })
mockStoreState.paneForegroundAgentByPaneKey[PANE_KEY] = {
agent: 'command-code',
shellForeground: false
}
const policy = await createPolicy(PTY_ID_LOCAL)
policy.onCommandCodeWorking('New Command Code prompt')
expect(mockStoreState.setAgentStatus).toHaveBeenCalledWith(
PANE_KEY,
{
state: 'working',
prompt: 'New Command Code prompt',
agentType: 'command-code'
},
'✳ Build feature',
undefined,
ROUTING
)
policy.dispose()
})
it('leaves a settled done row alone when working repeats the same prompt', async () => {
mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({
state: 'done',
@@ -5,6 +5,7 @@
* (foreground process-confirm ladder, key-intent interrupt inference) stay with the mounted pane.
*/
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import { resolvePaneAgentOwner } from '../../../../shared/pane-agent-owner'
import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id'
import { dispatchTerminalCommandFinishedEvent } from '@/hooks/terminal-command-finished-event'
import { resolveLiveAgentStatusConnectionRouting } from '@/lib/agent-status-connection-ownership'
@@ -15,6 +16,7 @@ import {
openCommandCodeDoneSettle,
setCommandCodeDoneSettleExecutor
} from './command-code-done-settle'
import { canCommandCodeOutputOwnPane } from './command-code-output-ownership'
export type ParkedTerminalCommandStatusPolicy = {
onCommandFinished: (bestEffortExitCode: number | null) => void
@@ -60,6 +62,22 @@ export function createParkedTerminalCommandStatusPolicy(options: {
})
}
const canApplyCommandCodeOutputStatus = (): boolean => {
const state = useAppStore.getState()
const tab = (state.tabsByWorktree[worktreeId] ?? []).find((entry) => entry.id === tabId)
const foreground = state.paneForegroundAgentByPaneKey[paneKey]
const paneOwnerAgent = resolvePaneAgentOwner({
launchAgent: tab?.launchAgent,
startupLaunchAgent: state.agentLaunchConfigByPaneKey[paneKey]?.identity.agentType,
hookAgent: state.agentStatusByPaneKey[paneKey]?.agentType
})
return canCommandCodeOutputOwnPane({
foregroundAgent: foreground?.agent,
shellForeground: foreground?.shellForeground,
paneOwnerAgent
})
}
// Port of pty-connection's dropCommandFinishedStatusIfSameTurn, minus the interrupt-inference
// option: parked panes receive no key events, so inference never has evidence here.
const dropCommandFinishedStatusIfSameTurn = (entry: AgentStatusEntry | undefined): void => {
@@ -145,6 +163,9 @@ export function createParkedTerminalCommandStatusPolicy(options: {
// Port of pty-connection's seedCommandCodeOutputWorkingStatus (store-level only).
onCommandCodeWorking: (prompt: string): void => {
if (!canApplyCommandCodeOutputStatus()) {
return
}
clearCommandCodeOutputDoneTimer()
const routing = resolveRouting()
if (!routing) {
@@ -178,6 +199,9 @@ export function createParkedTerminalCommandStatusPolicy(options: {
// Port of pty-connection's scheduleCommandCodeOutputDoneStatus: Command Code keeps rendering
// the composer while tools run, so only complete the row if no active repaint arrives.
onCommandCodeDone: (prompt: string): void => {
if (!canApplyCommandCodeOutputStatus()) {
return
}
const normalizedPrompt = prompt.trim()
if (!normalizedPrompt) {
cancelCommandCodeDoneSettle(paneKey)
@@ -18999,6 +18999,51 @@ describe('connectPanePty', () => {
})
})
it('rejects Command Code facts when Claude owns the pane', async () => {
enableMainAuthority()
const { connectPanePty } = await import('./pty-connection')
const handler = await import('./terminal-side-effect-facts-handler')
const transport = createMockTransport()
transportFactoryQueue.push(transport)
vi.useFakeTimers()
const paneKey = makePaneKey('tab-1', LEAF_1)
const claudeStatus = {
paneKey,
state: 'done' as const,
prompt: 'Previous Claude turn',
updatedAt: Date.now(),
stateStartedAt: Date.now(),
agentType: 'claude' as const,
stateHistory: []
}
mockStoreState.tabsByWorktree = {
'wt-1': [{ id: 'tab-1', ptyId: null, launchAgent: 'claude' }]
}
mockStoreState.agentStatusByPaneKey[paneKey] = claudeStatus
mockStoreState.paneForegroundAgentByPaneKey[paneKey] = {
agent: 'claude',
shellForeground: false
}
connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never)
const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void
onPtySpawn('pty-fact-false-cc')
mockStoreState.setAgentStatus.mockClear()
handler._dispatchTerminalSideEffectBatchForTest({
ptyId: 'pty-fact-false-cc',
seq: 1,
facts: [
{ kind: 'command-code-working', prompt: 'False prompt' },
{ kind: 'command-code-done', prompt: 'False prompt' }
]
})
vi.advanceTimersByTime(2000)
expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled()
expect(mockStoreState.agentStatusByPaneKey[paneKey]).toBe(claudeStatus)
})
it('keeps Command Code working when a working fact lands before the done settles', async () => {
enableMainAuthority()
const { connectPanePty } = await import('./pty-connection')
@@ -267,6 +267,7 @@ import {
openCommandCodeDoneSettle,
setCommandCodeDoneSettleExecutor
} from './command-code-done-settle'
import { canCommandCodeOutputOwnPane } from './command-code-output-ownership'
import { isTerminalTabParked } from './terminal-parked-watcher-registry'
import {
getExecutionHostIdForWorktree,
@@ -2818,7 +2819,19 @@ export function connectPanePty(
.setAgentStatus(cacheKey, statusPayload, terminalTitle, undefined, routing)
}
const canApplyCommandCodeOutputStatus = (): boolean => {
const foreground = useAppStore.getState().paneForegroundAgentByPaneKey[cacheKey]
return canCommandCodeOutputOwnPane({
foregroundAgent: foreground?.agent,
shellForeground: foreground?.shellForeground,
paneOwnerAgent: getAuthoritativePaneAgent()
})
}
const seedCommandCodeOutputWorkingStatus = (prompt: string): void => {
if (!canApplyCommandCodeOutputStatus()) {
return
}
clearCommandCodeOutputDoneTimer()
const routing = resolveCurrentAgentStatusRouting()
if (!routing) {
@@ -2885,6 +2898,9 @@ export function connectPanePty(
)
const clearCommandCodeOutputDoneTimer = (): void => cancelCommandCodeDoneSettle(cacheKey)
const scheduleCommandCodeOutputDoneStatus = (prompt: string): void => {
if (!canApplyCommandCodeOutputStatus()) {
return
}
const normalizedPrompt = prompt.trim()
if (!normalizedPrompt) {
cancelCommandCodeDoneSettle(cacheKey)
@@ -74,6 +74,19 @@ describe('createCommandCodeOutputStatusDetector', () => {
expect(onWorking).not.toHaveBeenCalled()
})
it('does not arm when another agent merely discusses Command Code', () => {
const onWorking = vi.fn()
const detector = createCommandCodeOutputStatusDetector({
startupCommand: null,
onWorking
})
expect(detector.observe('// Why: Command Code exposes transcript prompts')).toBe(false)
expect(detector.observe('\r\n✻ Thinking...')).toBe(false)
expect(onWorking).not.toHaveBeenCalled()
})
it.each([
'Pondering',
'Contemplating',
+1 -1
View File
@@ -112,7 +112,7 @@ const ACTIVE_EXECUTION_STATUS_RE = new RegExp(
`(?:^|[\\r\\n])\\s*(?:${COMMAND_CODE_STATUS_GLYPH_RE_SOURCE}\\s*)?(?:Executing:\\s+\\S|Running\\s*\\()`
)
const IDLE_PROMPT_RE = /(?:^|[\r\n])\s*[❯>]\s+Ask your question\.\.\./
const COMMAND_CODE_BANNER_RE = /\bCommand Code\b/
const COMMAND_CODE_BANNER_RE = /(?:^|[\r\n])\s*#\s*Command Code\s+v\d+(?:\.\d+){1,3}\b/
function cleanPromptCandidate(value: string): string {
return cleanCommandCodePromptCandidate(stripTerminalControl(value))