fix(agent-status): notify paired clients at turn end

This commit is contained in:
Brennan Benson
2026-08-14 13:48:51 -07:00
parent f3f1b9ccd1
commit d59043b0eb
5 changed files with 209 additions and 13 deletions
@@ -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' })
+27 -8
View File
@@ -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 } : {}),
@@ -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)
})
})
@@ -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<WebSessionTabsSyncState>
buildPatch: (state: AppState) => WebSessionTabsSyncState | Partial<WebSessionTabsSyncState>,
liveSnapshot?: RuntimeMobileSessionTabsResult
): void {
let mirroredAgentStatusChanged = false
const acceptedLiveStatuses: {
paneKey: string
worktreeId: string
payload: ReturnType<typeof pickParsedAgentStatusPayload> & { 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)
}
+2
View File
@@ -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