From fdeef5b6620bcb4b6a106982b4d1d051750eae66 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:37:06 -0700 Subject: [PATCH] Forward paired host completion stamps --- ...abs-agent-completion-notifications.test.ts | 61 +++++++++++++++++++ .../src/runtime/web-session-tabs-sync.ts | 36 +++++++---- 2 files changed, 84 insertions(+), 13 deletions(-) diff --git a/src/renderer/src/runtime/web-session-tabs-agent-completion-notifications.test.ts b/src/renderer/src/runtime/web-session-tabs-agent-completion-notifications.test.ts index 13361240db3..c185e0f7635 100644 --- a/src/renderer/src/runtime/web-session-tabs-agent-completion-notifications.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-agent-completion-notifications.test.ts @@ -13,6 +13,11 @@ vi.mock('@/hooks/agent-hook-completion-notifications', () => ({ import { useAppStore } from '@/store' import { createAgentCompletionCoordinator } from '@/components/terminal-pane/agent-completion-coordinator' +import { + markRendererOwnedAgentStatusWrite, + registerRendererOwnedAgentStatusPane, + resetRendererOwnedAgentStatusPanesForTests +} from '@/components/terminal-pane/renderer-owned-agent-status-registry' import { applyWebSessionTabsSnapshot, applyWebSessionTabsStorePatch, @@ -79,12 +84,14 @@ describe('paired session-tab agent completion notifications', () => { vi.useFakeTimers() mocks.observeAgentHookCompletionForNotification.mockReset() resetWebSessionTabsSnapshotFreshnessForTests() + resetRendererOwnedAgentStatusPanesForTests() useAppStore.setState(initialState, true) }) afterEach(() => { useAppStore.setState(initialState, true) resetWebSessionTabsSnapshotFreshnessForTests() + resetRendererOwnedAgentStatusPanesForTests() vi.useRealTimers() }) @@ -131,6 +138,60 @@ describe('paired session-tab agent completion notifications', () => { expect(dispatchCompletion).toHaveBeenCalledTimes(1) }) + it('announces a host-stamped turn while client OSC status owns the pane', () => { + const paneKey = makePaneKey(toWebTerminalSurfaceTabId(HOST_TAB_ID), LEAF_ID) + registerRendererOwnedAgentStatusPane(paneKey, ENVIRONMENT_ID) + markRendererOwnedAgentStatusWrite(paneKey) + useAppStore.setState({ + agentStatusByPaneKey: { + [paneKey]: { + state: 'working', + prompt: 'review the PR', + updatedAt: NOW, + stateStartedAt: NOW, + agentType: 'claude', + paneKey, + tabId: toWebTerminalSurfaceTabId(HOST_TAB_ID), + worktreeId: WORKTREE_ID, + stateHistory: [] + } + } + }) + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey, + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + mocks.observeAgentHookCompletionForNotification.mockImplementation(({ payload }) => + coordinator.observeHookStatus(payload) + ) + + applySnapshot(makeAgentSnapshot(1, NOW - 2_000), true) + const turnCompletedAt = NOW + 20_000 + applySnapshot(makeAgentSnapshot(2, NOW - 1_000, turnCompletedAt), true) + applySnapshot(makeAgentSnapshot(3, NOW, turnCompletedAt, 'done'), true) + + expect(useAppStore.getState().agentStatusByPaneKey[paneKey]?.state).toBe('working') + expect(mocks.observeAgentHookCompletionForNotification).toHaveBeenCalledTimes(3) + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + expect(dispatchCompletion).toHaveBeenCalledWith( + 'claude', + expect.objectContaining({ + source: 'hook', + quietedHookDone: false, + agentStatus: expect.objectContaining({ + state: 'done', + prompt: 'review the PR', + turnCompletedAt + }) + }) + ) + }) + it('suppresses a stamped reconnect tail and its live all-clear', () => { const dispatchCompletion = vi.fn() const coordinator = createAgentCompletionCoordinator({ diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index bd46ccbf659..76fbb379848 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -3431,7 +3431,7 @@ export function applyWebSessionTabsStorePatch( useAppStore.setState((state) => { const patch = buildPatch(state) mirroredAgentStatusChanged = patch !== state && Object.hasOwn(patch, 'agentStatusByPaneKey') - if (mirroredAgentStatusChanged && agentStatusSnapshots) { + if (agentStatusSnapshots) { const nextAgentStatuses = patch.agentStatusByPaneKey ?? state.agentStatusByPaneKey const snapshots = Array.isArray(agentStatusSnapshots) ? agentStatusSnapshots @@ -3443,30 +3443,40 @@ export function applyWebSessionTabsStorePatch( } const remapped = remapHostAgentStatus(surface) const accepted = remapped ? nextAgentStatuses[remapped.paneKey] : undefined + const turnCompletedAt = remapped + ? normalizeTurnCompletedAtField(surface.turnCompletedAt, remapped.state) + : undefined + // Why: client OSC owns display state, but only the host hook stream carries background-turn stamps. + const clientOwnedNotification = Boolean( + remapped && + isClientAuthoritativeAgentStatusPane(remapped.paneKey) && + (remapped.state === 'working' || turnCompletedAt !== undefined) + ) if ( !remapped || - !accepted || - accepted === state.agentStatusByPaneKey[remapped.paneKey] || - !agentStatusEntryEqual(accepted, remapped) + (!clientOwnedNotification && + (!accepted || + accepted === state.agentStatusByPaneKey[remapped.paneKey] || + !agentStatusEntryEqual(accepted, remapped))) ) { continue } - const turnCompletedAt = normalizeTurnCompletedAtField( - surface.turnCompletedAt, - accepted.state - ) - if (!allowCompletionNotification && accepted.state !== 'working') { + const notificationStatus = clientOwnedNotification ? remapped : accepted + if (!notificationStatus) { + continue + } + if (!allowCompletionNotification && notificationStatus.state !== 'working') { continue } acceptedNotificationStatuses.push({ - paneKey: accepted.paneKey, - worktreeId: accepted.worktreeId ?? snapshot.worktree, + paneKey: notificationStatus.paneKey, + worktreeId: notificationStatus.worktreeId ?? snapshot.worktree, payload: { ...pickParsedAgentStatusPayload({ - ...accepted, + ...notificationStatus, ...(turnCompletedAt !== undefined ? { turnCompletedAt } : {}) }), - stateStartedAt: accepted.stateStartedAt + stateStartedAt: notificationStatus.stateStartedAt } }) }