diff --git a/src/renderer/src/hooks/automation-agent-status-entry-change.test.ts b/src/renderer/src/hooks/automation-agent-status-entry-change.test.ts new file mode 100644 index 00000000000..7079f81af8d --- /dev/null +++ b/src/renderer/src/hooks/automation-agent-status-entry-change.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusEntry } from '../../../shared/agent-status-types' +import { + selectAutomationAgentStatusEntryChange, + UNCHANGED_AUTOMATION_AGENT_STATUS_ENTRY +} from './automation-agent-status-entry-change' + +function makeEntry(paneKey: string): AgentStatusEntry { + return { + paneKey, + state: 'working', + prompt: 'turn', + updatedAt: 1, + stateStartedAt: 1, + stateHistory: [] + } +} + +describe('selectAutomationAgentStatusEntryChange', () => { + it('reads only the target and skips an unchanged entry', () => { + const targetPaneKey = 'target-tab:leaf' + const targetEntry = makeEntry(targetPaneKey) + const entries = Object.fromEntries( + Array.from({ length: 499 }, (_, index) => { + const paneKey = `other-tab:${index}` + return [paneKey, makeEntry(paneKey)] + }) + ) + entries[targetPaneKey] = targetEntry + let enumerations = 0 + const measuredEntries = new Proxy(entries, { + ownKeys: (target) => { + enumerations += 1 + return Reflect.ownKeys(target) + } + }) + + expect(selectAutomationAgentStatusEntryChange(measuredEntries, targetPaneKey, undefined)).toBe( + targetEntry + ) + expect( + selectAutomationAgentStatusEntryChange(measuredEntries, targetPaneKey, targetEntry) + ).toBe(UNCHANGED_AUTOMATION_AGENT_STATUS_ENTRY) + expect(enumerations).toBe(0) + }) + + it('reports removal and ignores inherited or non-enumerable entries', () => { + const targetPaneKey = 'target-tab:leaf' + const previousEntry = makeEntry(targetPaneKey) + const inheritedEntries = Object.create({ [targetPaneKey]: previousEntry }) as Record< + string, + AgentStatusEntry + > + const nonEnumerableEntries = Object.defineProperty({}, targetPaneKey, { + value: previousEntry + }) as Record + + expect(selectAutomationAgentStatusEntryChange({}, targetPaneKey, previousEntry)).toBeUndefined() + expect(selectAutomationAgentStatusEntryChange(inheritedEntries, targetPaneKey, undefined)).toBe( + UNCHANGED_AUTOMATION_AGENT_STATUS_ENTRY + ) + expect( + selectAutomationAgentStatusEntryChange(nonEnumerableEntries, targetPaneKey, undefined) + ).toBe(UNCHANGED_AUTOMATION_AGENT_STATUS_ENTRY) + }) +}) diff --git a/src/renderer/src/hooks/automation-agent-status-entry-change.ts b/src/renderer/src/hooks/automation-agent-status-entry-change.ts new file mode 100644 index 00000000000..1c919e6b417 --- /dev/null +++ b/src/renderer/src/hooks/automation-agent-status-entry-change.ts @@ -0,0 +1,15 @@ +import type { AgentStatusEntry } from '../../../shared/agent-status-types' + +export const UNCHANGED_AUTOMATION_AGENT_STATUS_ENTRY = Symbol('unchanged-agent-status-entry') + +export function selectAutomationAgentStatusEntryChange( + entries: Readonly>, + targetPaneKey: string, + previousEntry: AgentStatusEntry | undefined +): AgentStatusEntry | undefined | typeof UNCHANGED_AUTOMATION_AGENT_STATUS_ENTRY { + const entry = Object.prototype.propertyIsEnumerable.call(entries, targetPaneKey) + ? entries[targetPaneKey] + : undefined + // Why: status writes replace entries; unchanged identity proves an unrelated publication. + return entry === previousEntry ? UNCHANGED_AUTOMATION_AGENT_STATUS_ENTRY : entry +} diff --git a/src/renderer/src/hooks/automation-dispatch-observer-test-probe.ts b/src/renderer/src/hooks/automation-dispatch-observer-test-probe.ts new file mode 100644 index 00000000000..302c052782c --- /dev/null +++ b/src/renderer/src/hooks/automation-dispatch-observer-test-probe.ts @@ -0,0 +1,30 @@ +import type { AgentStatusEntry } from '../../../shared/agent-status-types' + +const TARGET_PANE_KEY = 'agent-tab:7c6fb4e5-3bf1-4ff4-8259-03f7ae81c40d' + +export function countUnchangedObserverHistoryReads( + state: { agentStatusByPaneKey: Record }, + subscriber: (() => void) | null +): number { + if (!subscriber) { + throw new Error('agent status observer was not registered') + } + let historyReads = 0 + state.agentStatusByPaneKey = { + [TARGET_PANE_KEY]: { + paneKey: TARGET_PANE_KEY, + state: 'working', + prompt: 'turn', + updatedAt: Date.now() + 1, + stateStartedAt: Date.now() + 1, + get stateHistory() { + historyReads += 1 + return [] + } + } + } + subscriber() + historyReads = 0 + subscriber() + return historyReads +} diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts index 81fc23a5728..b8c1cb49c17 100644 --- a/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts +++ b/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts @@ -1,6 +1,7 @@ import type * as ReactModule from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { AUTOMATIONS_CHANGED_EVENT } from '@/lib/automations-changed-window-event' +import { countUnchangedObserverHistoryReads } from './automation-dispatch-observer-test-probe' const mockDispatchEvent = vi.fn() @@ -620,10 +621,11 @@ describe('useAutomationDispatchEvents setup launch', () => { await vi.waitFor(() => expect(mockFinalizeTerminalOwnership).toHaveBeenCalledOnce()) }) - it('persists assistant output from a batched working→done→working transition', async () => { + it('skips unchanged status and persists batched working→done→working output', async () => { const paneKey = 'agent-tab:7c6fb4e5-3bf1-4ff4-8259-03f7ae81c40d' await registerAndDispatch() + expect(countUnchangedObserverHistoryReads(state, latestStoreSubscriber)).toBe(0) const transitionStartedAt = Date.now() + 1 state.agentStatusByPaneKey = { [paneKey]: { diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.ts index 1e46b315f28..e06eefa6e61 100644 --- a/src/renderer/src/hooks/useAutomationDispatchEvents.ts +++ b/src/renderer/src/hooks/useAutomationDispatchEvents.ts @@ -32,8 +32,12 @@ import { toSshExecutionHostId } from '../../../shared/execution-host' import { parseWorkspaceKey } from '../../../shared/workspace-scope' -import type { AgentStateHistoryEntry } from '../../../shared/agent-status-types' +import type { AgentStateHistoryEntry, AgentStatusEntry } from '../../../shared/agent-status-types' import { resolveFolderWorkspaceHost } from '../../../shared/folder-workspace-execution-host' +import { + selectAutomationAgentStatusEntryChange, + UNCHANGED_AUTOMATION_AGENT_STATUS_ENTRY +} from './automation-agent-status-entry-change' const activeReuseDispatchTabIds = new Set() @@ -450,57 +454,64 @@ export function useAutomationDispatchEvents(): void { ): void => { let sawWorkingAfterStart = false let observedStateHistory: AgentStateHistoryEntry[] = [] + let observedEntry: AgentStatusEntry | undefined const checkCurrentStatus = (): void => { - const { agentStatusByPaneKey } = useAppStore.getState() - for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) { - if (paneKey !== targetPaneKey || entry.updatedAt < startedAfter) { + const entryChange = selectAutomationAgentStatusEntryChange( + useAppStore.getState().agentStatusByPaneKey, + targetPaneKey, + observedEntry + ) + if (entryChange === UNCHANGED_AUTOMATION_AGENT_STATUS_ENTRY) { + return + } + const entry = entryChange + observedEntry = entry + if (!entry || entry.updatedAt < startedAfter) { + return + } + const historyOverlap = getAgentStateHistoryOverlap( + observedStateHistory, + entry.stateHistory + ) + // Why: sawWorkingAfterStart stays monotonic — a recreated entry + // (transport loss, PTY exit, cap eviction) arrives with an empty + // stateHistory, so clearing it here would strand reuseSession runs + // with nothing left to re-derive the working edge from. + for (const historicalState of entry.stateHistory.slice(historyOverlap)) { + if (historicalState.startedAt < startedAfter) { continue } - const historyOverlap = getAgentStateHistoryOverlap( - observedStateHistory, - entry.stateHistory - ) - // Why: sawWorkingAfterStart stays monotonic — a recreated entry - // (transport loss, PTY exit, cap eviction) arrives with an empty - // stateHistory, so clearing it here would strand reuseSession runs - // with nothing left to re-derive the working edge from. - for (const historicalState of entry.stateHistory.slice(historyOverlap)) { - if (historicalState.startedAt < startedAfter) { - continue - } - if (historicalState.state === 'working') { - sawWorkingAfterStart = true - } - if ( - historicalState.state === 'done' && - (!options?.requireWorkingAfterStart || sawWorkingAfterStart) - ) { - // Why: this `done` already rolled out of the live entry, so its output - // survives only in the entry-level completed slot. - latestAssistantMessage = - entry.lastCompletedAssistantMessage?.trim() || latestAssistantMessage - handleAgentDone() - return - } - } - observedStateHistory = [...entry.stateHistory] - if (entry.state === 'working') { + if (historicalState.state === 'working') { sawWorkingAfterStart = true } if ( - entry.state === 'done' && - // Why: a session-boundary done is the agent CONNECTING (Claude SessionStart - // fires at launch, before the argv prompt submits) — completing here would - // close the tab and record an empty run result. - entry.sessionBoundary !== true && + historicalState.state === 'done' && (!options?.requireWorkingAfterStart || sawWorkingAfterStart) ) { + // Why: this `done` already rolled out of the live entry, so its output + // survives only in the entry-level completed slot. latestAssistantMessage = - entry.lastAssistantMessage?.trim() || latestAssistantMessage + entry.lastCompletedAssistantMessage?.trim() || latestAssistantMessage handleAgentDone() return } } + observedStateHistory = [...entry.stateHistory] + if (entry.state === 'working') { + sawWorkingAfterStart = true + } + if ( + entry.state === 'done' && + // Why: a session-boundary done is the agent CONNECTING (Claude SessionStart + // fires at launch, before the argv prompt submits) — completing here would + // close the tab and record an empty run result. + entry.sessionBoundary !== true && + (!options?.requireWorkingAfterStart || sawWorkingAfterStart) + ) { + latestAssistantMessage = + entry.lastAssistantMessage?.trim() || latestAssistantMessage + handleAgentDone() + } } // Why: Codex/Claude completion normally arrives through the global // hook IPC listener, not the hidden PTY OSC fallback.