From e9ed75a5de18b2f194d65fce6ac63c5cfe4c4fef Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:50:18 -0700 Subject: [PATCH] fix(native-chat): mark a chat whose start failed on its tab and workspace row (#24904) * fix(native-chat): mark a chat whose start failed on its tab and workspace row A chat whose start the host refused said so only inside its own tab. Its row now carries the failed verdict the host publishes for a refused start, so the tab and the workspace row show it from elsewhere until a retry starts it or the tab closes. The + menu no longer shows a failed start as still starting, so launching the agent again retries it. * fix(native-chat): date the failed-start mark by the failure; keep the + menu change for a separate PR The failed-start mark on a chat's tab and workspace row is now dated by when the start failed, the way a failure the host reports is dated: it ages out on the same schedule, a restart does not bring an old mark back, and a failure the user already saw stays read. The failure time is saved with the launch record so a restart keeps it; records saved without it fall back to the tab's creation time. The + menu change (letting a failed agent be picked again) moves to its own PR: its correct form makes a new chat instead of retrying the failed one, which changes how launches join. * test: name the local host on the saved failed-launch record * fix(native-chat): re-check an unconfirmed chat start when its host is reachable again A chat create whose reply was lost, or that a reload interrupted, stayed unknown until the user pressed Retry in its tab. Each host subscription opens with one full inventory; on it, every launch that host owns whose outcome is unknown (including ones restored from the saved launch record) is re-checked through the existing Retry path, so it settles to started or failed. One still unknown waits for the host's next return; cancelled launches are skipped. --- docs/reference/agent-status-store.md | 1 + .../StructuredAgentSessionStatusBridge.tsx | 61 ++- ...ession-status-bridge-failed-start.test.tsx | 414 ++++++++++++++++++ ...n-status-bridge-unconfirmed-start.test.tsx | 383 ++++++++++++++++ ...structured-agent-session-launch-callers.ts | 5 + ...ructured-agent-session-launch-failed-at.ts | 23 + ...d-agent-session-launch-persistence.test.ts | 32 ++ ...ctured-agent-session-launch-persistence.ts | 4 + ...tructured-agent-session-launch-registry.ts | 5 +- .../structured-agent-session-launch-reload.ts | 1 + ...gent-session-launch-unconfirmed-recheck.ts | 43 ++ ...local-structured-session-tabs-sync.test.ts | 54 +++ .../subscription.ts | 4 + ...n-tabs-sync-reconnect-resubscribe.test.tsx | 36 +- .../global-session-inventory-event.ts | 5 + 15 files changed, 1065 insertions(+), 6 deletions(-) create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-status-bridge-failed-start.test.tsx create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-status-bridge-unconfirmed-start.test.tsx create mode 100644 src/renderer/src/lib/structured-agent-session-launch-failed-at.ts create mode 100644 src/renderer/src/lib/structured-agent-session-launch-unconfirmed-recheck.ts diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index aa514bc6784..dcd9c308f2a 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -432,6 +432,7 @@ writers: | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | | Command Code output seeds, parked-pane seeds, pty-exit removal | delete; main already emits the same facts | | structured bridge status writes | delete; main now publishes the row | +| structured bridge failed-start row (the host refused the create) | keep; a refused create leaves the host no session, so the bridge writes it from the launch record | | launch placeholder seeds (a user launched an agent with a prompt) | keep for now; main holds the launch config and can seed later | | dismissal, acknowledgement, unmount | keep; user facts and component lifecycle | | remote-runtime OSC parse (bytes never transit local main) | keep, fenced behind the host's published row once the host is new enough; rule 3 of the wire doc applies | diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index c460ecc4805..45b987c9585 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -28,6 +28,9 @@ import { structuredAgentSessionDatedMainAgent, structuredAgentSessionRowStateStartedAt } from '../../../../shared/structured-agent-session-status-started-at' +import { agentMainAgentVerdict } from '../../../../shared/agent-main-agent-verdict' +import { useStructuredAgentSessionLaunchLifecycle } from '@/lib/structured-agent-session-launch-registry' +import { useStructuredAgentSessionLaunchFailedAt } from '@/lib/structured-agent-session-launch-failed-at' import { useAppStore } from '@/store' import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { @@ -93,16 +96,60 @@ function childWorkFor(summary: AgentSessionStatusSummary): { return subagents ? { subagents } : {} } +/** A start the host refused leaves it no session to publish, so the launch's own failure is the + * row: the same failed verdict the host publishes for a send the agent's start refused. */ +function projectFailedStart(tab: StructuredTab, paneKey: string, failedAt: number): void { + const store = useAppStore.getState() + const current = store.agentStatusByPaneKey?.[paneKey] + if ( + current?.state === 'done' && + agentMainAgentVerdict(current) === 'failure' && + current.updatedAt === failedAt && + current.stateStartedAt === failedAt && + current.agentType === tab.agentSessionAgent && + current.terminalTitle === tab.label && + current.tabId === tab.id && + current.worktreeId === tab.worktreeId + ) { + return + } + const { state, mainAgent } = structuredAgentSessionAgentStatus({ + status: 'idle', + turnOutcome: 'failure' + }) + store.setAgentStatus( + paneKey, + { + state, + mainAgent: { ...mainAgent, stateStartedAt: failedAt }, + interrupted: false, + prompt: '', + agentType: tab.agentSessionAgent, + sessionBoundary: false + }, + tab.label, + // Dated by the failure, as a host row is by its journal: it ages the same, a restart does not + // refresh it, and it replaces whatever newer-dated row the pane key held. + { updatedAt: failedAt, allowOlderTimestamp: true, stateStartedAt: failedAt }, + { tabId: tab.id, worktreeId: tab.worktreeId }, + { terminalResumeEligible: false } + ) +} + function projectStatus( tab: StructuredTab, summary: AgentSessionStatusSummary | null, - observation: 'live' | 'unverifiable' + observation: 'live' | 'unverifiable', + /** When the launch failed; null while it has not. */ + launchFailedAt: number | null ): void { const paneKey = structuredAgentSessionPaneKey(tab.id, tab.entityId) const store = useAppStore.getState() // No persisted turn yet (or nothing known): the row shows no agent status at all. if (!summary?.status) { - if (store.agentStatusByPaneKey?.[paneKey]) { + if (launchFailedAt !== null) { + projectFailedStart(tab, paneKey, launchFailedAt) + } else if (store.agentStatusByPaneKey?.[paneKey]) { store.removeAgentStatus(paneKey) } return @@ -219,9 +266,15 @@ function StructuredAgentSessionOwnedStatusProjection({ target: RuntimeClientTarget }): null { const { summary, observation } = useStructuredAgentSessionStatusSummary(tab.entityId, target) + const launchFailed = + useStructuredAgentSessionLaunchLifecycle(tab.worktreeId, tab.entityId) === 'failed' + const failedAt = useStructuredAgentSessionLaunchFailedAt(tab.entityId) + // Only records saved by older builds lack the time; the tab's creation precedes any + // acknowledgement of it, so a failure seen before then stays read. + const launchFailedAt = launchFailed ? (failedAt ?? tab.createdAt) : null useEffect(() => { - projectStatus(tab, summary, observation) - }, [summary, observation, tab]) + projectStatus(tab, summary, observation, launchFailedAt) + }, [summary, observation, tab, launchFailedAt]) useEffect( () => () => useAppStore.getState().removeAgentStatus(structuredAgentSessionPaneKey(tab.id, tab.entityId)), diff --git a/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-failed-start.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-failed-start.test.tsx new file mode 100644 index 00000000000..f647ed24415 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-failed-start.test.tsx @@ -0,0 +1,414 @@ +// @vitest-environment happy-dom + +// A chat whose start the host refused has no host session, so nothing publishes its row. The +// bridge marks it failed from the launch's own outcome, so the tab and the workspace row show it +// to a user looking elsewhere, until a retry starts it or the tab closes. + +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import type { AgentSessionStatusEvent } from '../../../../shared/agent-session-wire' +import { agentVerdictDisplayMark } from '../../../../shared/agent-main-agent-verdict' +import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-freshness' +import { structuredAgentSessionAgentStatus } from '../../../../shared/structured-agent-session-agent-status' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' +import type { Tab } from '../../../../shared/tab-types' +import type { StructuredAgentSessionLaunchIntent } from '@/lib/launch-structured-agent-session' +import type { AppState } from '@/store/types' + +type TestStore = { + getState: () => AppState + setState: (state: Partial & { testRuntimeOwner?: string | null }) => void +} + +const mocks = vi.hoisted(() => { + const hoisted: { + store: TestStore | null + subscribeStatus: Mock + unsubscribe: Mock + createIntent: Mock + retryIntent: Mock + restoreIntent: Mock + launch: Mock + } = { + store: null, + subscribeStatus: vi.fn(), + unsubscribe: vi.fn(), + createIntent: vi.fn(), + retryIntent: vi.fn(), + restoreIntent: vi.fn(), + launch: vi.fn() + } + return hoisted +}) + +vi.mock('@/store', async () => { + const { createTestStore } = await import('@/store/slices/store-test-helpers') + const useAppStore = createTestStore() + mocks.store = useAppStore + return { useAppStore } +}) + +vi.mock('sonner', () => ({ toast: { error: vi.fn(), message: vi.fn() } })) + +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getRuntimeEnvironmentIdForWorktree: () => null, + getExecutionHostIdForWorktree: () => 'local' +})) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: vi.fn(), + subscribeStructuredAgentSession: vi.fn(), + subscribeStructuredAgentSessionStatus: mocks.subscribeStatus +})) + +vi.mock('@/runtime/local-structured-session-tabs-sync', () => ({ + refreshLocalStructuredSessionTabs: vi.fn(async () => []) +})) + +vi.mock('@/lib/launch-structured-agent-session', () => { + class StructuredAgentSessionCreateRefusalError extends Error {} + return { + createStructuredAgentSessionLaunchIntent: mocks.createIntent, + retryStructuredAgentSessionLaunchIntent: mocks.retryIntent, + restoreStructuredAgentSessionLaunchIntent: mocks.restoreIntent, + abandonStructuredAgentSessionLaunchIntent: vi.fn(), + launchStructuredAgentSession: mocks.launch, + StructuredAgentSessionCreateRefusalError + } +}) + +import { StructuredAgentSessionStatusBridge } from './StructuredAgentSessionStatusBridge' +import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session' +import { + retryStructuredAgentSessionLaunch, + startStructuredAgentLaunch +} from '@/lib/structured-agent-session-launch' +import { resetStructuredAgentLaunchRegistryForTests } from '@/lib/structured-agent-session-launch-registry' +import { resetStructuredAgentLaunchPersistenceForTests } from '@/lib/structured-agent-session-launch-persistence' +import { resetStructuredAgentSessionStatusFeedsForTests } from '@/runtime/structured-agent-session-status-feed' +import { + resetTerminalTabActivityFlagsCacheForTest, + resolveTerminalTabActivityStatus +} from '../tab-bar/terminal-tab-activity-status' +import { selectWorktreeAgentActivitySummary } from '../sidebar/worktree-agent-activity-summary' +import { countActivityUnread } from '../activity/useActivityUnreadCount' + +const WORKTREE_ID = 'wt-1' +const SESSION_ID = 'session-1' +const TAB_CREATED_AT = 1_000 +const FAILED_AT = 10 * 60 * 60_000 +const PANE_KEY = structuredAgentSessionPaneKey(`structured-agent-session-${SESSION_ID}`, SESSION_ID) + +const structuredTab = { + id: `structured-agent-session-${SESSION_ID}`, + worktreeId: WORKTREE_ID, + groupId: 'group-1', + contentType: 'agent-session', + entityId: SESSION_ID, + label: 'Claude Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: TAB_CREATED_AT, + isPinned: false, + agentSessionAgent: 'claude' +} satisfies Tab + +const intent: StructuredAgentSessionLaunchIntent = { + worktreeId: WORKTREE_ID, + sessionId: SESSION_ID, + executionHostId: 'local', + target: { kind: 'local' }, + agent: 'claude', + params: { + envelope: { + sessionId: SESSION_ID, + clientOperationId: 'operation-1', + expectedRuntimeFence: null, + payloadFingerprint: 'fingerprint-1' + }, + worktree: `id:${WORKTREE_ID}`, + agent: 'claude' + } +} + +function store(): TestStore { + if (!mocks.store) { + throw new Error('store missing') + } + return mocks.store +} + +function rows(): AgentStatusEntry[] { + return Object.values(store().getState().agentStatusByPaneKey) +} + +/** What the chat's own tab shows, resolved as the tab bar resolves a structured tab. */ +function tabStatus(): string { + const state = store().getState() + return resolveTerminalTabActivityStatus({ + tab: { id: structuredTab.id, title: structuredTab.label, launchAgent: 'claude' }, + agentStatusByPaneKey: state.agentStatusByPaneKey, + agentStatusEpoch: state.agentStatusEpoch + }) +} + +async function flush(): Promise { + for (let i = 0; i < 20; i += 1) { + await act(async () => { + await Promise.resolve() + }) + } +} + +async function connect(): Promise<(event: AgentSessionStatusEvent) => void> { + render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + const emit: (event: AgentSessionStatusEvent) => void = mocks.subscribeStatus.mock.calls[0]?.[1] + // The host never created the session, so it publishes nothing for it. + act(() => emit({ type: 'snapshot', sessions: [] })) + return (event) => act(() => emit(event)) +} + +/** A restart keeps the saved launch and the acknowledgements, but no rows and no launch in memory. */ +async function restart(): Promise { + const { acknowledgedAgentsByPaneKey } = store().getState() + cleanup() + store().setState({ agentStatusByPaneKey: {}, acknowledgedAgentsByPaneKey }) + resetStructuredAgentLaunchRegistryForTests() + resetStructuredAgentLaunchPersistenceForTests() + resetStructuredAgentSessionStatusFeedsForTests() + resetTerminalTabActivityFlagsCacheForTest() + mocks.subscribeStatus.mockClear() + await connect() +} + +function setClock(now: number): void { + vi.spyOn(Date, 'now').mockReturnValue(now) +} + +function acknowledge(at: number): void { + store().setState({ acknowledgedAgentsByPaneKey: { [PANE_KEY]: at } }) +} + +function unread(): number { + return countActivityUnread(store().getState()) +} + +let failedStarts = 0 + +async function failStart(): Promise { + mocks.createIntent.mockReturnValueOnce(intent) + mocks.launch.mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('refused')) + failedStarts += 1 + // Each failed start stands for its own user action. + const launch = startStructuredAgentLaunch(WORKTREE_ID, 'claude', { + requestId: `request-${failedStarts}` + }) + await expect(launch.launchResult).rejects.toBeInstanceOf(StructuredAgentSessionCreateRefusalError) + await flush() +} + +describe('a chat whose start failed', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + resetStructuredAgentLaunchRegistryForTests() + resetStructuredAgentLaunchPersistenceForTests() + resetStructuredAgentSessionStatusFeedsForTests() + resetTerminalTabActivityFlagsCacheForTest() + mocks.subscribeStatus.mockResolvedValue({ unsubscribe: mocks.unsubscribe }) + mocks.retryIntent.mockImplementation((prior: StructuredAgentSessionLaunchIntent) => prior) + mocks.restoreIntent.mockReturnValue(intent) + store().setState({ + agentStatusByPaneKey: {}, + acknowledgedAgentsByPaneKey: {}, + retainedAgentsByPaneKey: {}, + unifiedTabsByWorktree: { [WORKTREE_ID]: [structuredTab] } + }) + }) + + afterEach(() => { + cleanup() + resetStructuredAgentSessionStatusFeedsForTests() + vi.restoreAllMocks() + }) + + it('marks its tab and its workspace row failed', async () => { + await connect() + expect(rows()).toEqual([]) + + await failStart() + + const [row] = rows() + expect(row).toMatchObject({ + state: 'done', + mainAgent: { state: 'done', outcome: 'failure' }, + tabId: structuredTab.id, + worktreeId: WORKTREE_ID, + agentType: 'claude' + }) + expect(row && agentVerdictDisplayMark(row)).toBe('failed') + expect(tabStatus()).toBe('failed') + expect(selectWorktreeAgentActivitySummary(store().getState(), WORKTREE_ID).hasFailed).toBe(true) + }) + + it('drops the mark while a retry starts it, and leaves the started chat to the host', async () => { + const emit = await connect() + await failStart() + expect(rows()).toHaveLength(1) + + let publish: (receipt: { sessionId: string; fence: number }) => void = () => {} + mocks.launch.mockReturnValueOnce(new Promise((resolve) => (publish = resolve))) + act(() => { + retryStructuredAgentSessionLaunch(WORKTREE_ID, SESSION_ID) + }) + await flush() + expect(rows()).toEqual([]) + + publish({ sessionId: SESSION_ID, fence: 1 }) + await flush() + expect(rows()).toEqual([]) + emit({ + type: 'status', + session: { + sessionId: SESSION_ID, + workspaceId: WORKTREE_ID, + agent: 'claude', + status: 'working', + latestPrompt: 'hello', + updatedAt: 50_000 + } + }) + expect(rows()).toEqual([expect.objectContaining({ state: 'working' })]) + }) + + it('drops the mark when its tab closes', async () => { + await connect() + await failStart() + expect(rows()).toHaveLength(1) + + act(() => store().setState({ unifiedTabsByWorktree: { [WORKTREE_ID]: [] } })) + await flush() + + expect(rows()).toEqual([]) + }) + + it('stays read across a restart once seen, and a later failure is news again', async () => { + setClock(FAILED_AT) + await connect() + await failStart() + expect(rows()).toEqual([ + expect.objectContaining({ updatedAt: FAILED_AT, stateStartedAt: FAILED_AT }) + ]) + expect(unread()).toBe(1) + setClock(FAILED_AT + 60_000) + acknowledge(FAILED_AT + 60_000) + expect(unread()).toBe(0) + + await restart() + expect(rows()).toEqual([ + expect.objectContaining({ state: 'done', updatedAt: FAILED_AT, stateStartedAt: FAILED_AT }) + ]) + expect(unread()).toBe(0) + expect(tabStatus()).toBe('failed') + + setClock(FAILED_AT + 120_000) + mocks.launch.mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('refused')) + act(() => { + retryStructuredAgentSessionLaunch(WORKTREE_ID, SESSION_ID) + }) + await flush() + expect(rows()).toEqual([ + expect.objectContaining({ + stateStartedAt: FAILED_AT + 120_000, + mainAgent: expect.objectContaining({ outcome: 'failure' }) + }) + ]) + expect(unread()).toBe(1) + }) + + it('is unread after a restart when it failed after the chat was last viewed', async () => { + setClock(FAILED_AT - 60_000) + await connect() + // Viewed while it was starting; it failed after the user looked away. + acknowledge(FAILED_AT - 60_000) + setClock(FAILED_AT) + await failStart() + expect(unread()).toBe(1) + + setClock(FAILED_AT + 60_000) + await restart() + expect(rows()).toEqual([expect.objectContaining({ stateStartedAt: FAILED_AT })]) + expect(unread()).toBe(1) + }) + + it('ages like a host-reported failure: a restart does not bring back an old mark', async () => { + setClock(FAILED_AT) + await connect() + await failStart() + expect(tabStatus()).toBe('failed') + + setClock(FAILED_AT + AGENT_STATUS_STALE_AFTER_MS + 1) + await restart() + expect(rows()).toEqual([expect.objectContaining({ updatedAt: FAILED_AT })]) + expect(tabStatus()).not.toBe('failed') + expect(selectWorktreeAgentActivitySummary(store().getState(), WORKTREE_ID).hasFailed).toBe( + false + ) + }) + + it('dates a retry that fails again by the new failure', async () => { + setClock(FAILED_AT) + await connect() + await failStart() + acknowledge(FAILED_AT + 1) + expect(unread()).toBe(0) + + setClock(FAILED_AT + 60_000) + mocks.launch.mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('refused')) + act(() => { + retryStructuredAgentSessionLaunch(WORKTREE_ID, SESSION_ID) + }) + await flush() + expect(rows()).toEqual([ + expect.objectContaining({ updatedAt: FAILED_AT + 60_000, stateStartedAt: FAILED_AT + 60_000 }) + ]) + expect(unread()).toBe(1) + }) + + it('re-dates a failed row the pane key already held by its own failure', async () => { + setClock(FAILED_AT - 60_000) + await connect() + const { state, mainAgent } = structuredAgentSessionAgentStatus({ + status: 'idle', + turnOutcome: 'failure' + }) + act(() => + store() + .getState() + .setAgentStatus( + PANE_KEY, + { + state, + mainAgent: { ...mainAgent, stateStartedAt: FAILED_AT - 60_000 }, + interrupted: false, + prompt: '', + agentType: 'claude', + sessionBoundary: false + }, + structuredTab.label, + undefined, + { tabId: structuredTab.id, worktreeId: WORKTREE_ID } + ) + ) + expect(rows()).toEqual([expect.objectContaining({ updatedAt: FAILED_AT - 60_000 })]) + + setClock(FAILED_AT) + await failStart() + expect(rows()).toEqual([ + expect.objectContaining({ updatedAt: FAILED_AT, stateStartedAt: FAILED_AT }) + ]) + }) +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-unconfirmed-start.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-unconfirmed-start.test.tsx new file mode 100644 index 00000000000..ff0775ee511 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-unconfirmed-start.test.tsx @@ -0,0 +1,383 @@ +// @vitest-environment happy-dom + +// A chat whose create got no answer (a lost reply, or a reload mid-start) may or may not exist on +// its host. It shows no mark while that is unknown; when the host is reachable again the launch is +// re-checked without the user, and settles to started (no mark) or failed (marked). + +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { Tab } from '../../../../shared/tab-types' +import type { StructuredAgentSessionLaunchIntent } from '@/lib/launch-structured-agent-session' +import type { AppState } from '@/store/types' + +type TestStore = { + getState: () => AppState + setState: (state: Partial) => void +} + +const mocks = vi.hoisted(() => { + const hoisted: { + store: TestStore | null + subscribeStatus: Mock + createIntent: Mock + restoreIntent: Mock + launch: Mock< + ( + intent: StructuredAgentSessionLaunchIntent, + onHostSeed?: unknown + ) => Promise<{ sessionId: string; fence: number }> + > + refresh: Mock + history: Mock + } = { + store: null, + subscribeStatus: vi.fn(), + createIntent: vi.fn(), + restoreIntent: vi.fn(), + launch: vi.fn(), + refresh: vi.fn(), + history: vi.fn() + } + return hoisted +}) + +vi.mock('@/store', async () => { + const { createTestStore } = await import('@/store/slices/store-test-helpers') + const useAppStore = createTestStore() + mocks.store = useAppStore + return { useAppStore } +}) + +vi.mock('sonner', () => ({ toast: { error: vi.fn(), message: vi.fn() } })) + +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getRuntimeEnvironmentIdForWorktree: () => null, + getExecutionHostIdForWorktree: () => 'local' +})) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.history, + subscribeStructuredAgentSession: vi.fn(), + subscribeStructuredAgentSessionStatus: mocks.subscribeStatus +})) + +vi.mock('@/runtime/local-structured-session-tabs-sync', () => ({ + refreshLocalStructuredSessionTabs: mocks.refresh +})) + +vi.mock('@/lib/launch-structured-agent-session', () => { + class StructuredAgentSessionCreateRefusalError extends Error {} + return { + createStructuredAgentSessionLaunchIntent: mocks.createIntent, + retryStructuredAgentSessionLaunchIntent: vi.fn((prior: unknown) => prior), + restoreStructuredAgentSessionLaunchIntent: mocks.restoreIntent, + abandonStructuredAgentSessionLaunchIntent: vi.fn(), + launchStructuredAgentSession: mocks.launch, + StructuredAgentSessionCreateRefusalError + } +}) + +import { StructuredAgentSessionStatusBridge } from './StructuredAgentSessionStatusBridge' +import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session' +import { startStructuredAgentLaunch } from '@/lib/structured-agent-session-launch' +import { + getStructuredAgentSessionLaunchLifecycle, + markStructuredAgentSessionLaunchCancelled, + resetStructuredAgentLaunchRegistryForTests +} from '@/lib/structured-agent-session-launch-registry' +import { resetStructuredAgentLaunchPersistenceForTests } from '@/lib/structured-agent-session-launch-persistence' +import { recheckUnconfirmedStructuredAgentLaunches } from '@/lib/structured-agent-session-launch-unconfirmed-recheck' +import { resetStructuredAgentSessionStatusFeedsForTests } from '@/runtime/structured-agent-session-status-feed' +import { + resetTerminalTabActivityFlagsCacheForTest, + resolveTerminalTabActivityStatus +} from '../tab-bar/terminal-tab-activity-status' + +const WORKTREE_ID = 'wt-1' +const CLAUDE_SESSION = 'session-claude' +const CODEX_SESSION = 'session-codex' + +function chatTab(sessionId: string, agent: 'claude' | 'codex'): Tab { + return { + id: `structured-agent-session-${sessionId}`, + worktreeId: WORKTREE_ID, + groupId: 'group-1', + contentType: 'agent-session', + entityId: sessionId, + label: 'Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1_000, + isPinned: false, + agentSessionAgent: agent + } +} + +function launchIntent( + sessionId: string, + agent: 'claude' | 'codex' = 'claude' +): StructuredAgentSessionLaunchIntent { + return { + worktreeId: WORKTREE_ID, + sessionId, + executionHostId: 'local', + target: { kind: 'local' }, + agent, + params: { + envelope: { + sessionId, + clientOperationId: `operation-${sessionId}`, + expectedRuntimeFence: null, + payloadFingerprint: `fingerprint-${sessionId}` + }, + worktree: `id:${WORKTREE_ID}`, + agent + } + } +} + +function listing(...sessionIds: string[]): RuntimeMobileSessionTabsResult[] { + return [ + { + worktree: WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: sessionIds.map((sessionId) => ({ + type: 'agent-session' as const, + id: `agent-session:${sessionId}`, + title: 'Chat', + sessionId, + agent: 'claude' as const, + isActive: false + })) + } + ] +} + +function store(): TestStore { + if (!mocks.store) { + throw new Error('store missing') + } + return mocks.store +} + +function rows(): AgentStatusEntry[] { + return Object.values(store().getState().agentStatusByPaneKey) +} + +function tabStatus(sessionId = CLAUDE_SESSION): string { + const state = store().getState() + return resolveTerminalTabActivityStatus({ + tab: { id: `structured-agent-session-${sessionId}`, title: 'Chat', launchAgent: 'claude' }, + agentStatusByPaneKey: state.agentStatusByPaneKey, + agentStatusEpoch: state.agentStatusEpoch + }) +} + +function lifecycle(sessionId = CLAUDE_SESSION): string | null { + return getStructuredAgentSessionLaunchLifecycle(WORKTREE_ID, sessionId) +} + +function createCalls(): number { + return mocks.launch.mock.calls.length +} + +async function flush(): Promise { + for (let i = 0; i < 30; i += 1) { + await act(async () => { + await Promise.resolve() + }) + } +} + +async function connect(): Promise { + render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalled()) + for (const [, emit] of mocks.subscribeStatus.mock.calls) { + act(() => emit({ type: 'snapshot', sessions: [] })) + } +} + +/** A reload keeps the saved launch record and the tab, but nothing in memory. */ +async function reload(): Promise { + cleanup() + store().setState({ agentStatusByPaneKey: {} }) + resetStructuredAgentLaunchRegistryForTests() + resetStructuredAgentLaunchPersistenceForTests() + resetStructuredAgentSessionStatusFeedsForTests() + resetTerminalTabActivityFlagsCacheForTest() + mocks.subscribeStatus.mockClear() + await connect() +} + +/** The create's reply never arrives and the host lists nothing, so the outcome stays unknown. */ +async function loseStart( + sessionId = CLAUDE_SESSION, + agent: 'claude' | 'codex' = 'claude' +): Promise { + mocks.createIntent.mockReturnValueOnce(launchIntent(sessionId, agent)) + const launch = startStructuredAgentLaunch(WORKTREE_ID, agent, { + requestId: `request-${sessionId}` + }) + await expect(launch.launchResult).rejects.toThrow('reply lost') + await flush() + expect(lifecycle(sessionId)).toBe('visibility-unknown') +} + +function hostIsReachableAgain(executionHostId: 'local' | `runtime:${string}` = 'local'): void { + act(() => recheckUnconfirmedStructuredAgentLaunches(executionHostId)) +} + +describe('a chat whose start was never confirmed', () => { + beforeEach(() => { + vi.clearAllMocks() + // Queued one-shot answers an earlier case left unconsumed must not leak into the next. + for (const mock of [mocks.createIntent, mocks.launch, mocks.refresh, mocks.restoreIntent]) { + mock.mockReset() + } + localStorage.clear() + resetStructuredAgentLaunchRegistryForTests() + resetStructuredAgentLaunchPersistenceForTests() + resetStructuredAgentSessionStatusFeedsForTests() + resetTerminalTabActivityFlagsCacheForTest() + mocks.subscribeStatus.mockResolvedValue({ unsubscribe: vi.fn() }) + mocks.launch.mockRejectedValue(new Error('reply lost')) + mocks.refresh.mockResolvedValue([]) + mocks.history.mockResolvedValue({ ok: true, page: { fence: 1 } }) + mocks.restoreIntent.mockImplementation((args: { sessionId: string }) => + launchIntent(args.sessionId) + ) + store().setState({ + agentStatusByPaneKey: {}, + acknowledgedAgentsByPaneKey: {}, + retainedAgentsByPaneKey: {}, + unifiedTabsByWorktree: { + [WORKTREE_ID]: [chatTab(CLAUDE_SESSION, 'claude'), chatTab(CODEX_SESSION, 'codex')] + } + }) + }) + + afterEach(() => { + cleanup() + resetStructuredAgentSessionStatusFeedsForTests() + }) + + it('shows no mark while unconfirmed, and none once the host shows the create had landed', async () => { + await connect() + await loseStart() + expect(rows()).toEqual([]) + expect(tabStatus()).not.toBe('failed') + const creates = createCalls() + + mocks.refresh.mockResolvedValueOnce(listing(CLAUDE_SESSION)) + hostIsReachableAgain() + await flush() + + expect(lifecycle()).toBeNull() + // The host already held the chat, so nothing is created twice. + expect(createCalls()).toBe(creates) + expect(rows()).toEqual([]) + }) + + it('marks it failed when the host, asked again, refuses the create', async () => { + await connect() + await loseStart() + expect(rows()).toEqual([]) + + mocks.launch.mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('refused')) + hostIsReachableAgain() + await flush() + + expect(lifecycle()).toBe('failed') + expect(tabStatus()).toBe('failed') + + // A refusal is an answer: only the user's Retry asks again. + const creates = createCalls() + hostIsReachableAgain() + await flush() + expect(createCalls()).toBe(creates) + }) + + it('re-checks a start a reload interrupted, without the user pressing Retry', async () => { + await connect() + mocks.createIntent.mockReturnValueOnce(launchIntent(CLAUDE_SESSION)) + mocks.launch.mockReturnValueOnce(new Promise(() => {})) + startStructuredAgentLaunch(WORKTREE_ID, 'claude', { requestId: `request-${CLAUDE_SESSION}` }) + await flush() + + await reload() + expect(lifecycle()).toBe('visibility-unknown') + expect(rows()).toEqual([]) + expect(tabStatus()).not.toBe('failed') + + mocks.launch.mockResolvedValueOnce({ sessionId: CLAUDE_SESSION, fence: 1 }) + mocks.refresh.mockResolvedValueOnce([]).mockResolvedValueOnce(listing(CLAUDE_SESSION)) + hostIsReachableAgain() + await flush() + + expect(mocks.launch).toHaveBeenLastCalledWith( + expect.objectContaining({ + sessionId: CLAUDE_SESSION, + params: expect.objectContaining({ + envelope: expect.objectContaining({ clientOperationId: `operation-${CLAUDE_SESSION}` }) + }) + }), + expect.any(Function) + ) + expect(lifecycle()).toBeNull() + expect(rows()).toEqual([]) + }) + + it('never re-creates a chat the user closed, before or after a reload', async () => { + await connect() + await loseStart(CLAUDE_SESSION, 'claude') + await loseStart(CODEX_SESSION, 'codex') + act(() => { + markStructuredAgentSessionLaunchCancelled(WORKTREE_ID, CLAUDE_SESSION, 'local') + }) + const creates = createCalls() + + hostIsReachableAgain() + await flush() + const recreated = (sessionId: string): number => + mocks.launch.mock.calls.slice(creates).filter(([intent]) => intent.sessionId === sessionId) + .length + expect(recreated(CODEX_SESSION)).toBe(1) + expect(recreated(CLAUDE_SESSION)).toBe(0) + + await reload() + hostIsReachableAgain() + await flush() + expect(recreated(CLAUDE_SESSION)).toBe(0) + expect(lifecycle(CLAUDE_SESSION)).toBe('cancelled') + }) + + it('re-checks once per return of its own host: one still unknown waits for the next', async () => { + await connect() + await loseStart() + const creates = createCalls() + + hostIsReachableAgain('runtime:other-host') + await flush() + expect(createCalls()).toBe(creates) + + hostIsReachableAgain() + await flush() + expect(createCalls()).toBe(creates + 1) + expect(lifecycle()).toBe('visibility-unknown') + expect(rows()).toEqual([]) + + await flush() + expect(createCalls()).toBe(creates + 1) + + hostIsReachableAgain() + await flush() + expect(createCalls()).toBe(creates + 2) + }) +}) diff --git a/src/renderer/src/lib/structured-agent-session-launch-callers.ts b/src/renderer/src/lib/structured-agent-session-launch-callers.ts index b131ede7391..c2c3cc5ce6c 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-callers.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-callers.ts @@ -31,6 +31,8 @@ export type StructuredLaunchCaller = { export type StructuredLaunchCallerGroup = { outcome: 'pending' | 'published' | 'failed' | 'unknown' | 'cancelled' attempt: StructuredLaunchAttempt + /** When this attempt failed; a retry starts a new group, so it never outlives the failure. */ + failedAt?: number entries: Set promptDeliveryResults: Set> onSettled: () => void @@ -90,6 +92,9 @@ export function settleStructuredLaunchCallers( outcome: 'published' | 'failed' | 'cancelled' ): void { group.outcome = outcome + if (outcome === 'failed') { + group.failedAt = Date.now() + } group.onSettled() } diff --git a/src/renderer/src/lib/structured-agent-session-launch-failed-at.ts b/src/renderer/src/lib/structured-agent-session-launch-failed-at.ts new file mode 100644 index 00000000000..80cb2b0ad4f --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-failed-at.ts @@ -0,0 +1,23 @@ +import { useSyncExternalStore } from 'react' +import { + getPersistedStructuredAgentLaunchRecord, + getStructuredLaunchStateBySessionId, + subscribeStructuredAgentLaunchStatus +} from './structured-agent-session-launch-registry' + +/** When the launch's last attempt failed: in memory, else as saved before a reload. Undefined + * while it has not failed, and for records saved by builds that did not keep the time. */ +export function getStructuredAgentSessionLaunchFailedAt(sessionId: string): number | undefined { + return ( + getStructuredLaunchStateBySessionId(sessionId)?.callers.failedAt ?? + getPersistedStructuredAgentLaunchRecord(sessionId)?.failedAt + ) +} + +export function useStructuredAgentSessionLaunchFailedAt(sessionId: string): number | undefined { + return useSyncExternalStore( + subscribeStructuredAgentLaunchStatus, + () => getStructuredAgentSessionLaunchFailedAt(sessionId), + () => undefined + ) +} diff --git a/src/renderer/src/lib/structured-agent-session-launch-persistence.test.ts b/src/renderer/src/lib/structured-agent-session-launch-persistence.test.ts index bd41ed9733c..bde1adbf8c7 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-persistence.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-persistence.test.ts @@ -149,6 +149,38 @@ describe('structured agent launch persistence', () => { expect(readStructuredAgentLaunchRecord('claude_session')?.clientOperationId).toBe('operation-2') }) + it('keeps when a failed launch failed across a reload, and still loads records without it', () => { + writeStructuredAgentLaunchRecord({ + sessionId: 'claude_session', + executionHostId: 'local', + agent: 'claude', + lifecycle: 'failed', + clientOperationId: 'operation-3', + payloadFingerprint: 'fingerprint-3', + expectedRuntimeFence: null, + failedAt: 42_000 + }) + const stored = JSON.parse(localStorage.getItem('orca:structuredAgentLaunches:v1') ?? '[]') + localStorage.setItem( + 'orca:structuredAgentLaunches:v1', + JSON.stringify([ + ...stored, + // Written by a build that did not save the failure time. + { ...stored[0], sessionId: 'older_session', failedAt: undefined }, + { ...stored[0], sessionId: 'corrupt_session', failedAt: 'yesterday' } + ]) + ) + resetStructuredAgentLaunchPersistenceForTests() + + expect(readStructuredAgentLaunchRecord('claude_session')).toMatchObject({ + lifecycle: 'failed', + failedAt: 42_000 + }) + expect(readStructuredAgentLaunchRecord('older_session')).toMatchObject({ lifecycle: 'failed' }) + expect(readStructuredAgentLaunchRecord('older_session')?.failedAt).toBeUndefined() + expect(readStructuredAgentLaunchRecord('corrupt_session')).toBeUndefined() + }) + it('persists cancellation tombstones by session id and retires them', () => { markStructuredAgentLaunchCancelledPersisted('codex_session', 'local') expect(hasStructuredAgentLaunchCancellationTombstonePersisted('codex_session')).toBe(true) diff --git a/src/renderer/src/lib/structured-agent-session-launch-persistence.ts b/src/renderer/src/lib/structured-agent-session-launch-persistence.ts index 01ed8b303ed..c6efb318f20 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-persistence.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-persistence.ts @@ -23,6 +23,8 @@ export type StructuredAgentLaunchPersistedRecord = { resumeFrom?: StructuredAgentSessionResumeSource /** A paired server's reported seed, which this machine cannot re-derive after a reload. */ seedOptions?: Readonly> + /** When a failed launch failed; records written by older builds lack it. */ + failedAt?: number } /** What survives a reload of an unpublished launch. */ @@ -85,6 +87,7 @@ function validRecord(value: unknown): value is Omit< } = value const resumeFrom = 'resumeFrom' in value ? value.resumeFrom : undefined const executionHostId = 'executionHostId' in value ? value.executionHostId : undefined + const failedAt = 'failedAt' in value ? value.failedAt : undefined return ( (executionHostId === undefined || (typeof executionHostId === 'string' && parseExecutionHostId(executionHostId) !== null)) && @@ -95,6 +98,7 @@ function validRecord(value: unknown): value is Omit< typeof clientOperationId === 'string' && typeof payloadFingerprint === 'string' && (expectedRuntimeFence === null || typeof expectedRuntimeFence === 'number') && + (failedAt === undefined || Number.isFinite(failedAt)) && (resumeFrom === undefined || (typeof resumeFrom === 'object' && resumeFrom !== null && diff --git a/src/renderer/src/lib/structured-agent-session-launch-registry.ts b/src/renderer/src/lib/structured-agent-session-launch-registry.ts index 9d21852b2fb..07586be1f65 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-registry.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-registry.ts @@ -104,7 +104,10 @@ function persistStructuredLaunchState(state: StructuredLaunchState): void { deleteStructuredAgentLaunchRecord(state.intent.sessionId) return } - writeStructuredAgentLaunchRecord(structuredAgentLaunchRecordFor(state.intent, lifecycle)) + writeStructuredAgentLaunchRecord({ + ...structuredAgentLaunchRecordFor(state.intent, lifecycle), + ...(lifecycle === 'failed' ? { failedAt: state.callers.failedAt } : {}) + }) } export function getPersistedStructuredAgentLaunchRecord( diff --git a/src/renderer/src/lib/structured-agent-session-launch-reload.ts b/src/renderer/src/lib/structured-agent-session-launch-reload.ts index 35379324faa..5bf6859ef05 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-reload.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-reload.ts @@ -56,6 +56,7 @@ export function restorePersistedStructuredLaunchState( selection: { seed: intent.seedOptions, held: {} } } callers.outcome = record.lifecycle === 'failed' ? 'failed' : 'unknown' + callers.failedAt = record.failedAt setStructuredLaunchState(state) return state } diff --git a/src/renderer/src/lib/structured-agent-session-launch-unconfirmed-recheck.ts b/src/renderer/src/lib/structured-agent-session-launch-unconfirmed-recheck.ts new file mode 100644 index 00000000000..e6c256767e8 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-unconfirmed-recheck.ts @@ -0,0 +1,43 @@ +import type { ExecutionHostId } from '../../../shared/execution-host' +import { useAppStore } from '@/store' +import { retryStructuredAgentSessionLaunch } from './structured-agent-session-launch' +import { + getStructuredAgentSessionLaunchLifecycle, + getStructuredAgentSessionLaunchOwner, + structuredLaunchStates +} from './structured-agent-session-launch-registry' + +/** + * `executionHostId` is reachable again: its full inventory just arrived on a new subscription. + * Re-checks each launch it owns whose create was never confirmed, as Retry would, so it settles to + * published or failed without the user. One still unconfirmed waits for the host's next return; + * a cancelled launch reads as cancelled and is skipped. + */ +export function recheckUnconfirmedStructuredAgentLaunches(executionHostId: ExecutionHostId): void { + const launches = [...structuredLaunchStates()].map(({ intent }) => ({ + worktreeId: intent.worktreeId, + sessionId: intent.sessionId + })) + // A launch from before a reload is known only by its saved record and the tab still showing it. + for (const [worktreeId, tabs] of Object.entries(useAppStore.getState().unifiedTabsByWorktree)) { + for (const tab of tabs) { + if (tab.contentType === 'agent-session') { + launches.push({ worktreeId, sessionId: tab.entityId }) + } + } + } + for (const { worktreeId, sessionId } of launches) { + if ( + getStructuredAgentSessionLaunchLifecycle(worktreeId, sessionId) !== 'visibility-unknown' || + getStructuredAgentSessionLaunchOwner(sessionId) !== executionHostId + ) { + continue + } + try { + retryStructuredAgentSessionLaunch(worktreeId, sessionId) + } catch (error) { + // Why: recovery bookkeeping must not break the inventory that reported the host back. + console.warn('[structured-agent-launch] unconfirmed launch re-check failed', error) + } + } +} diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts index 45a539cdc6e..7c1c0cf7ae1 100644 --- a/src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts @@ -24,6 +24,12 @@ import { resetWebSessionFocusIntentForTests } from './web-session-focus-intent' +const mocks = vi.hoisted(() => ({ recheckUnconfirmedLaunches: vi.fn() })) + +vi.mock('../lib/structured-agent-session-launch-unconfirmed-recheck', () => ({ + recheckUnconfirmedStructuredAgentLaunches: mocks.recheckUnconfirmedLaunches +})) + const WORKTREE_ID = 'repo-1::worktree-1' const TERMINAL_ID = 'terminal-1' const STRUCTURED_ID = 'structured-agent-session-codex-1' @@ -244,6 +250,54 @@ describe('local structured session tab projection', () => { } }) + it('re-checks unconfirmed chat starts each time the host stream reopens, not on each frame', async () => { + vi.useFakeTimers() + mocks.recheckUnconfirmedLaunches.mockClear() + const priorApi = window.api + const callbacks: ((response: unknown) => void)[] = [] + const subscribe = vi.fn(async (_args: unknown, callback: (response: unknown) => void) => { + callbacks.push(callback) + return { unsubscribe: vi.fn(), sendBinary: vi.fn() } + }) + Object.defineProperty(window, 'api', { + configurable: true, + value: { + runtime: { + getStatus: vi.fn().mockResolvedValue({ + capabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + }), + call: vi.fn().mockResolvedValue({ ok: true, result: { snapshots: [] } }), + subscribe + } + } + }) + const census = { ok: true, result: { type: 'snapshots', snapshots: [], authoritative: true } } + try { + await startLocalStructuredSessionTabsSync({ + isDisposed: () => false, + setUnsubscribe: () => undefined + }) + // The startup inventory alone is not the stream reopening. + expect(mocks.recheckUnconfirmedLaunches).not.toHaveBeenCalled() + + callbacks[0]?.(census) + expect(mocks.recheckUnconfirmedLaunches).toHaveBeenCalledOnce() + expect(mocks.recheckUnconfirmedLaunches).toHaveBeenCalledWith('local') + callbacks[0]?.({ ok: true, result: { type: 'updated', ...structuredInventory('e', 3, 'c') } }) + expect(mocks.recheckUnconfirmedLaunches).toHaveBeenCalledOnce() + + // The runtime restarted: the stream ends and the resubscribe opens with a new census. + callbacks[0]?.({ ok: true, result: { type: 'end' } }) + await vi.advanceTimersByTimeAsync(250) + await Promise.resolve() + expect(subscribe).toHaveBeenCalledTimes(2) + callbacks[1]?.(census) + expect(mocks.recheckUnconfirmedLaunches).toHaveBeenCalledTimes(2) + } finally { + Object.defineProperty(window, 'api', { configurable: true, value: priorApi }) + } + }) + it('starts the session-tabs inventory without waiting for the capability refresh', async () => { const priorApi = window.api let releaseStatus = (): void => undefined diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync/subscription.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync/subscription.ts index 29fc6230629..31c84e0cf79 100644 --- a/src/renderer/src/runtime/local-structured-session-tabs-sync/subscription.ts +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync/subscription.ts @@ -1,3 +1,4 @@ +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' import { refreshLocalRuntimeCapabilities } from '../local-runtime-capabilities' @@ -9,6 +10,7 @@ import { refreshLocalStructuredSessionTabs, restoreLocalStructuredSessionTabsOnce } from './inventory-refresh' +import { recheckUnconfirmedStructuredAgentLaunches } from '../../lib/structured-agent-session-launch-unconfirmed-recheck' import { scheduleRetiredEpochRepair } from './retired-epoch-repair' import { applyStructuredSessionTabSnapshots, @@ -101,6 +103,8 @@ export async function startLocalStructuredSessionTabsSync(args: { ...REPAIR_DROPPED_EPOCHS, authoritative: event.authoritative === true }) + // Each subscription opens with one census: the host is reachable again. + recheckUnconfirmedStructuredAgentLaunches(LOCAL_EXECUTION_HOST_ID) } else if (event.type === 'snapshot' || event.type === 'updated') { applyStructuredSessionTabSnapshots([event], undefined, REPAIR_DROPPED_EPOCHS) } else if (event.type === 'end' && generation === subscriptionGeneration) { diff --git a/src/renderer/src/runtime/web-session-tabs-sync-reconnect-resubscribe.test.tsx b/src/renderer/src/runtime/web-session-tabs-sync-reconnect-resubscribe.test.tsx index ab881db7d81..fd13e945bf3 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-reconnect-resubscribe.test.tsx +++ b/src/renderer/src/runtime/web-session-tabs-sync-reconnect-resubscribe.test.tsx @@ -10,7 +10,14 @@ import type * as WorktreeRuntimeOwnerModule from '@/lib/worktree-runtime-owner' vi.mock('sonner', () => ({ toast: { warning: vi.fn(), dismiss: vi.fn() } })) -const mocks = vi.hoisted(() => ({ getExplicitRuntimeEnvironmentIdForWorktree: vi.fn() })) +const mocks = vi.hoisted(() => ({ + getExplicitRuntimeEnvironmentIdForWorktree: vi.fn(), + recheckUnconfirmedLaunches: vi.fn() +})) + +vi.mock('@/lib/structured-agent-session-launch-unconfirmed-recheck', () => ({ + recheckUnconfirmedStructuredAgentLaunches: mocks.recheckUnconfirmedLaunches +})) vi.mock('@/lib/worktree-runtime-owner', async (importOriginal) => { const actual = await importOriginal() @@ -144,6 +151,7 @@ describe('session-tabs mirror across an outage and its recovery', () => { runtimeCall.mockClear() runtimeSubscribe.mockClear() mocks.getExplicitRuntimeEnvironmentIdForWorktree.mockReset().mockReturnValue(ENV_A) + mocks.recheckUnconfirmedLaunches.mockClear() Object.defineProperty(window, 'api', { configurable: true, value: { runtimeEnvironments: { call: runtimeCall, subscribe: runtimeSubscribe } } @@ -195,6 +203,32 @@ describe('session-tabs mirror across an outage and its recovery', () => { expect(mirroredSubscriptions('session.tabs.subscribe')).toHaveLength(stranded.active + 1) }) + // A create whose reply was lost may have landed: the host's census on the new stream is when + // this client can ask it again, once per return. + it('re-checks unconfirmed chat starts on that host when its reinstalled stream opens', async () => { + await connectThenLoseContact() + const census = async (): Promise => { + const [reopened] = mirroredSubscriptions('session.tabs.subscribeAll').slice(-1) + await act(async () => { + reopened?.callbacks.onResponse({ + id: 'census', + ok: true, + result: { type: 'snapshots', snapshots: [], authoritative: true }, + _meta: { runtimeId: 'runtime-a' } + }) + await settle() + }) + } + mocks.recheckUnconfirmedLaunches.mockClear() + + await regainContact() + expect(mocks.recheckUnconfirmedLaunches).not.toHaveBeenCalled() + await census() + + expect(mocks.recheckUnconfirmedLaunches).toHaveBeenCalledOnce() + expect(mocks.recheckUnconfirmedLaunches).toHaveBeenCalledWith(`runtime:${ENV_A}`) + }) + // Direction 2: the mirror's cache key. #19647 -- recovery is not a second connection, so every // retained-state stamp cut from this key stays valid and the mirror is never rebuilt. it('holds the mirror environment key across the outage and the recovery', async () => { diff --git a/src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts b/src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts index 080a7c99a89..f8692972c69 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts @@ -1,4 +1,6 @@ +import { toRuntimeExecutionHostId } from '../../../../shared/execution-host' import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { recheckUnconfirmedStructuredAgentLaunches } from '../../lib/structured-agent-session-launch-unconfirmed-recheck' import { useAppStore } from '../../store' import { recoverWebSessionTerminalOrphansBeforeApply } from '../web-session-terminal-orphan-recovery' import { queueAcceptedWebSessionTerminalSnapshot } from '../web-session-terminal-handle-events' @@ -173,4 +175,7 @@ export function handleGlobalSessionInventoryEvent({ settleHydration?.() } }) + // Each subscription opens with one census: the host is reachable again. Chats it lists were + // already settled as published above, before any recovery await. + recheckUnconfirmedStructuredAgentLaunches(toRuntimeExecutionHostId(environmentId)) }