diff --git a/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts b/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts index 7981366cadb..11b9fc97afd 100644 --- a/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts +++ b/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts @@ -130,6 +130,23 @@ describe('headless hook agent-status projection (#11761)', () => { ) }) + it('publishes a gated turn end as event metadata, not stored agent status', async () => { + const turnCompletedAt = Date.now() + const runtime = await createRuntimeWithHookRows([ + hookRow({ + state: 'working', + interactivePrompt: undefined, + turnCompletedAt + }) + ]) + + const result = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + const tab = result.tabs[0] + + expect(tab).toMatchObject({ type: 'terminal', turnCompletedAt }) + expect(tab?.type === 'terminal' && tab.agentStatus).not.toHaveProperty('turnCompletedAt') + }) + it('publishes no hook transport identity to clients', async () => { const agentStatus = await projectAgentStatus([ hookRow({ launchToken: 'lt-secret', promptInteractionKey: 'turn-1' }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index d3606666598..bbf5f83186b 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -31391,6 +31391,31 @@ export class OrcaRuntimeService { : livePty ? this.issuePtyHandle(livePty) : null + const projectedAgentStatus = + agentStatus ?? + this.buildPtyMobileAgentStatus( + mobileStatusPty, + tab, + terminalHandle, + retainedAgentStatus, + getHookRowsForPane + ) + const projectedStatusEntry = projectedAgentStatus.agentStatus as + | (AgentStatusEntry & { turnCompletedAt?: number }) + | undefined + const { turnCompletedAt: projectedTurnCompletedAt, ...clientStatusFields } = + projectedStatusEntry ?? {} + const clientAgentStatus = projectedStatusEntry + ? { agentStatus: clientStatusFields as AgentStatusEntry } + : {} + const rawTurnCompletedAt = + hookAgentStatus?.live?.payload.turnCompletedAt ?? + this.getHookAgentRowForPane(getHookRowsForPane(paneKey)).live?.payload.turnCompletedAt ?? + projectedTurnCompletedAt + const turnCompletedAt = + typeof rawTurnCompletedAt === 'number' && Number.isFinite(rawTurnCompletedAt) + ? rawTurnCompletedAt + : undefined tabs.push({ type: 'terminal', id: tab.id, @@ -31400,14 +31425,8 @@ export class OrcaRuntimeService { ...(tab.ptyId ? { ptyId: tab.ptyId } : {}), ...(tab.terminalTheme ? { terminalTheme: tab.terminalTheme } : {}), ...(launchAgent ? { launchAgent } : {}), - ...(agentStatus ?? - this.buildPtyMobileAgentStatus( - mobileStatusPty, - tab, - terminalHandle, - retainedAgentStatus, - getHookRowsForPane - )), + ...clientAgentStatus, + ...(turnCompletedAt !== undefined ? { turnCompletedAt } : {}), ...(tab.parentLayout ? { parentLayout: tab.parentLayout } : {}), ...(tab.startupCwd ? { startupCwd: tab.startupCwd } : {}), ...(tab.color != null ? { color: tab.color } : {}), 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 new file mode 100644 index 00000000000..490ffb222d3 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-agent-completion-notifications.test.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import { makePaneKey } from '../../../shared/stable-pane-id' +import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' + +const mocks = vi.hoisted(() => ({ + observeAgentHookCompletionForNotification: vi.fn() +})) + +vi.mock('@/hooks/agent-hook-completion-notifications', () => ({ + observeAgentHookCompletionForNotification: mocks.observeAgentHookCompletionForNotification +})) + +import { useAppStore } from '@/store' +import { + applyWebSessionTabsSnapshot, + applyWebSessionTabsStorePatch, + resetWebSessionTabsSnapshotFreshnessForTests +} from './web-session-tabs-sync' + +const ENVIRONMENT_ID = 'web-env-1' +const WORKTREE_ID = 'repo::/worktree' +const HOST_TAB_ID = 'host-tab-1' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const NOW = 1_700_000_000_000 +const initialState = useAppStore.getInitialState() + +function makeAgentSnapshot( + snapshotVersion: number, + updatedAt: number, + turnCompletedAt?: number +): RuntimeMobileSessionTabsResult { + return { + worktree: WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion, + activeGroupId: 'host-group-1', + activeTabId: HOST_TAB_ID, + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: `${HOST_TAB_ID}::${LEAF_ID}`, + title: 'Claude working', + parentTabId: HOST_TAB_ID, + leafId: LEAF_ID, + isActive: true, + status: 'ready', + terminal: 'terminal-1', + ...(turnCompletedAt !== undefined ? { turnCompletedAt } : {}), + agentStatus: { + state: 'working', + prompt: 'review the PR', + updatedAt, + stateStartedAt: NOW, + agentType: 'claude', + paneKey: makePaneKey(HOST_TAB_ID, LEAF_ID), + tabId: HOST_TAB_ID, + worktreeId: WORKTREE_ID, + stateHistory: [] + } + } + ] + } +} + +function applySnapshot(snapshot: RuntimeMobileSessionTabsResult, live: boolean): void { + applyWebSessionTabsStorePatch( + (state) => applyWebSessionTabsSnapshot(state, snapshot, ENVIRONMENT_ID, NOW), + live ? snapshot : undefined + ) +} + +describe('paired session-tab agent completion notifications', () => { + beforeEach(() => { + vi.useFakeTimers() + mocks.observeAgentHookCompletionForNotification.mockReset() + resetWebSessionTabsSnapshotFreshnessForTests() + useAppStore.setState(initialState, true) + }) + + afterEach(() => { + useAppStore.setState(initialState, true) + resetWebSessionTabsSnapshotFreshnessForTests() + vi.useRealTimers() + }) + + it('forwards accepted live status and excludes snapshot or reconnect replays', () => { + applySnapshot(makeAgentSnapshot(1, NOW), false) + expect(mocks.observeAgentHookCompletionForNotification).not.toHaveBeenCalled() + + applySnapshot(makeAgentSnapshot(2, NOW + 1_000), true) + expect(mocks.observeAgentHookCompletionForNotification).toHaveBeenCalledTimes(1) + + const turnCompletedAt = NOW + 2_000 + applySnapshot(makeAgentSnapshot(3, NOW + 2_000, turnCompletedAt), true) + expect(mocks.observeAgentHookCompletionForNotification).toHaveBeenLastCalledWith({ + paneKey: makePaneKey(toWebTerminalSurfaceTabId(HOST_TAB_ID), LEAF_ID), + worktreeId: WORKTREE_ID, + payload: expect.objectContaining({ + state: 'working', + stateStartedAt: NOW, + turnCompletedAt + }) + }) + + applySnapshot(makeAgentSnapshot(4, NOW + 3_000, turnCompletedAt + 1_000), false) + expect(mocks.observeAgentHookCompletionForNotification).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 9ee15c8421f..87d488fed5e 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -6,9 +6,11 @@ import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { AGENT_STATUS_STALE_AFTER_MS, + pickParsedAgentStatusPayload, type AgentStatusEntry } from '../../../shared/agent-status-types' import { agentEntryCompletionAt } from '../../../shared/agent-completion-time' +import { normalizeTurnCompletedAtField } from '../../../shared/agent-status-field-normalization' import { agentProviderSessionsEqual } from '../../../shared/agent-session-resume' import type { RuntimeMobileSessionTabsResult, @@ -96,6 +98,7 @@ import { resetWebSessionBrowserPlacementsForTests } from './web-session-browser-placement' import { suppressE2eWebRuntimeBrowserSnapshot } from './web-runtime-browser-creation-e2e-fault' +import { observeAgentHookCompletionForNotification } from '@/hooks/agent-hook-completion-notifications' const WEB_SESSION_GROUP_PREFIX = 'web-session-tabs:' export const WEB_SESSION_TABS_VISIBILITY_RESUME_STAGGER_MS = 100 @@ -3415,18 +3418,60 @@ function applyWebSessionTabsSnapshotOperations( } export function applyWebSessionTabsStorePatch( - buildPatch: (state: AppState) => WebSessionTabsSyncState | Partial + buildPatch: (state: AppState) => WebSessionTabsSyncState | Partial, + liveSnapshot?: RuntimeMobileSessionTabsResult ): void { let mirroredAgentStatusChanged = false + const acceptedLiveStatuses: { + paneKey: string + worktreeId: string + payload: ReturnType & { stateStartedAt: number } + }[] = [] useAppStore.setState((state) => { const patch = buildPatch(state) mirroredAgentStatusChanged = patch !== state && Object.hasOwn(patch, 'agentStatusByPaneKey') + if (mirroredAgentStatusChanged && liveSnapshot) { + const nextAgentStatuses = patch.agentStatusByPaneKey ?? state.agentStatusByPaneKey + for (const surface of liveSnapshot.tabs) { + if (surface.type !== 'terminal') { + continue + } + const remapped = remapHostAgentStatus(surface) + const accepted = remapped ? nextAgentStatuses[remapped.paneKey] : undefined + if ( + !remapped || + !accepted || + accepted === state.agentStatusByPaneKey[remapped.paneKey] || + !agentStatusEntryEqual(accepted, remapped) + ) { + continue + } + const turnCompletedAt = normalizeTurnCompletedAtField( + surface.turnCompletedAt, + accepted.state + ) + acceptedLiveStatuses.push({ + paneKey: accepted.paneKey, + worktreeId: accepted.worktreeId ?? liveSnapshot.worktree, + payload: { + ...pickParsedAgentStatusPayload({ + ...accepted, + ...(turnCompletedAt !== undefined ? { turnCompletedAt } : {}) + }), + stateStartedAt: accepted.stateStartedAt + } + }) + } + } return patch }) // Why: paired-web snapshots bypass setAgentStatus, so arm the stale-boundary timer explicitly like local hook events do. if (mirroredAgentStatusChanged) { useAppStore.getState().scheduleAgentStatusFreshness() } + for (const status of acceptedLiveStatuses) { + observeAgentHookCompletionForNotification(status) + } } function loadInitialWebSessionTabs( @@ -4181,8 +4226,9 @@ export function useWebSessionTabsSync(): void { acceptReplayedWebSessionTabsSnapshot(environmentId, recovered.worktree) } if (shouldApplyWebSessionTabsSnapshot(recovered, environmentId)) { - applyWebSessionTabsStorePatch((state) => - applyWebSessionTabsSnapshot(state, recovered, environmentId) + applyWebSessionTabsStorePatch( + (state) => applyWebSessionTabsSnapshot(state, recovered, environmentId), + event.type === 'updated' && !replayed ? recovered : undefined ) recordVisibilityResumeSnapshot(environmentId, recovered, receivedFrame) } @@ -4304,8 +4350,10 @@ export function useWebSessionTabsSync(): void { skipWakeRespawn: shouldSkipWebRuntimeWakeTerminalRespawn(activeWorktreeId) }) if (fresh) { - applyWebSessionTabsStorePatch((state) => - applyWebSessionTabsSnapshot(state, recovered, environmentId) + const replayed = isRuntimeSubscriptionReplayResponse(response) + applyWebSessionTabsStorePatch( + (state) => applyWebSessionTabsSnapshot(state, recovered, environmentId), + event.type === 'updated' && !replayed ? recovered : undefined ) recordVisibilityResumeSnapshotRef.current(environmentId, recovered, receivedFrame) } diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index d6b257ca85f..69c8c972e36 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -181,6 +181,8 @@ export type RuntimeMobileSessionTerminalTab = { ptyId?: string | null terminalTheme?: RuntimeMobileTerminalTheme agentStatus?: AgentStatusEntry | null + /** Event-only lead-turn end time for paired clients; never persisted in AgentStatusEntry. */ + turnCompletedAt?: number launchAgent?: TuiAgent startupCwd?: string parentLayout?: TerminalLayoutSnapshot