mirror of
https://github.com/stablyai/orca.git
synced 2026-10-28 08:02:54 +00:00
fix(agent-status): notify paired clients at turn end
This commit is contained in:
@@ -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' })
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user