Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -792,6 +792,12 @@ export default function App() {
patchSessionRunningStateFromTerminalMeta,
queueActiveSessionWindowRefresh: () => queueActiveSessionWindowRefresh() as any,
fetchTerminalDirectoryWindow: (payload) => fetchTerminalDirectoryWindow(payload) as any,
onRefreshError: (error, source) => log.debug(
source === 'session-window'
? 'active session window background refresh failed'
: 'terminal directory background refresh failed',
error,
),
})

const unsubscribe = ws.onMessage((msg) => {
Expand Down Expand Up @@ -839,7 +845,9 @@ export default function App() {
const rev = typeof msg.revision === 'number' ? msg.revision : -1
if (rev > lastSessionsRevision) {
lastSessionsRevision = rev
void appStore.dispatch(queueActiveSessionWindowRefresh() as any)
// Fire-and-forget refresh: the thunk re-throws on failure, so contain the
// rejection rather than leak an unhandled rejection (matches the inventory site).
void appStore.dispatch(queueActiveSessionWindowRefresh() as any).catch((error: unknown) => log.debug('active session window refresh failed', error))
}
}
if (msg.type === 'settings.updated') {
Expand Down Expand Up @@ -911,11 +919,13 @@ export default function App() {
upsert: terminalMeta,
remove: removedTerminalMetaIds,
}))
// Fire-and-forget refreshes: the thunks re-throw on failure, so
// contain the rejection rather than leak an unhandled rejection.
void appStore.dispatch(fetchTerminalDirectoryWindow({
surface: 'sidebar',
priority: 'visible',
}) as any)
void appStore.dispatch(queueActiveSessionWindowRefresh() as any)
}) as any).catch((error: unknown) => log.debug('terminal directory background refresh failed', error))
void appStore.dispatch(queueActiveSessionWindowRefresh() as any).catch((error: unknown) => log.debug('active session window refresh failed', error))
}
if (msg.type === 'codex.activity.list.response') {
const requestId = typeof msg.requestId === 'string' ? msg.requestId : ''
Expand Down
10 changes: 7 additions & 3 deletions src/components/fresh-agent/FreshAgentView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -693,14 +693,18 @@ export function FreshAgentView({
setLoadError(error instanceof Error ? error.message : 'Failed to load session')
})
return () => controller.abort()
// Depend only on what identifies *which* snapshot to load. This effect
// dispatches updatePaneContent to persist its own resolved resumeSessionId/
// status; listing the whole paneContent object (or those output fields) made
// that self-update retrigger the effect, firing a redundant second fetch for
// the same session. Current values for non-identity fields are read live via
// paneContentRef.current inside the effect.
}, [
claudeSession?.lost,
dispatch,
paneContent,
paneContent.provider,
paneContent.resumeSessionId,
paneContent.createRequestId,
paneContent.sessionId,
paneContent.status,
paneContent.sessionType,
paneId,
autoTitleIdentity,
Expand Down
30 changes: 27 additions & 3 deletions src/lib/terminal-invalidation-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ type DispatchLike = (action: unknown) => unknown

type RefreshThunk = unknown

/** Identifies which fire-and-forget background refresh produced a rejection. */
export type RefreshSource = 'terminal-directory' | 'session-window'

type TerminalInvalidationDeps = {
dispatch: DispatchLike
upsertTerminalMeta: (payload: TerminalMetaRecord[]) => PayloadAction<TerminalMetaRecord[]>
Expand All @@ -21,6 +24,14 @@ type TerminalInvalidationDeps = {
setTimeout?: typeof setTimeout
clearTimeout?: typeof clearTimeout
refreshDelayMs?: number
/**
* Invoked when a fire-and-forget background refresh rejects. The refresh
* thunks re-throw on failure (callers that await them rely on this), but the
* handler dispatches them from a timer without awaiting, so it contains the
* rejection here instead of letting it surface as an unhandled rejection.
* `source` identifies which refresh failed so the callback can log accurately.
*/
onRefreshError?: (error: unknown, source: RefreshSource) => void
}

function isTerminalMetaRecord(value: unknown): value is TerminalMetaRecord {
Expand All @@ -36,13 +47,26 @@ export function createTerminalInvalidationHandler(deps: TerminalInvalidationDeps
const delayMs = deps.refreshDelayMs ?? 50
let refreshTimer: ReturnType<typeof setTimeout> | undefined

const dispatchBackgroundRefresh = (thunk: RefreshThunk, source: RefreshSource) => {
const result = deps.dispatch(thunk) as unknown
// Contain rejections from the fire-and-forget refresh dispatch: the thunks
// re-throw on failure, and a failed background refresh must never become an
// unhandled promise rejection (which crashes test runs and is silently
// uncaught in production).
if (result && typeof (result as { catch?: unknown }).catch === 'function') {
void (result as Promise<unknown>).catch((error: unknown) => {
deps.onRefreshError?.(error, source)
})
}
}

const runRefresh = () => {
refreshTimer = undefined
deps.dispatch(deps.fetchTerminalDirectoryWindow({
dispatchBackgroundRefresh(deps.fetchTerminalDirectoryWindow({
surface: 'sidebar',
priority: 'visible',
}))
deps.dispatch(deps.queueActiveSessionWindowRefresh())
}), 'terminal-directory')
dispatchBackgroundRefresh(deps.queueActiveSessionWindowRefresh(), 'session-window')
}

const scheduleRefresh = () => {
Expand Down
51 changes: 51 additions & 0 deletions test/unit/client/components/App.ws-bootstrap.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1556,6 +1556,57 @@ describe('App WS bootstrap recovery', () => {
})
})

it('contains a failing queued session-window refresh from a sessions.changed broadcast instead of leaking an unhandled rejection', async () => {
// Regression: the sessions.changed handler dispatched queueActiveSessionWindowRefresh()
// fire-and-forget with no .catch(). That thunk re-throws when it falls through to
// fetchSessionWindow() without committed window data (e.g. a sessions.changed before the
// sidebar window commits, or after a failed direct fetch retry), so a transient refresh
// failure leaked an unhandled rejection that fails the whole test run even though every
// test "passed" — the same failure class the terminal.inventory site already contains.
const store = createStore()

// Reject every snapshot fetch. The bootstrap sidebar load fails but is contained by
// ensureSidebarSessionsWindow (so no window ever commits -> hasCommittedWindow stays
// false), and the queued refresh then takes the re-throwing fetchSessionWindow branch.
fetchSidebarSessionsSnapshot.mockRejectedValue(new Error('window snapshot unavailable'))

const unhandled: unknown[] = []
const onUnhandled = (reason: unknown) => { unhandled.push(reason) }
process.on('unhandledRejection', onUnhandled)
try {
render(
<Provider store={store}>
<App />
</Provider>
)

await waitFor(() => {
expect(messageHandler).toBeTypeOf('function')
})

act(() => {
messageHandler?.({ type: 'sessions.changed', revision: 1 })
})

// The queued refresh actually ran and failed (proves the path is exercised, not vacuous).
await waitFor(() => {
expect(store.getState().sessions.windows.sidebar?.error).toBe('window snapshot unavailable')
})

// Give Node a chance to surface any unhandled rejection from the fire-and-forget dispatch.
await new Promise((resolve) => setTimeout(resolve, 0))
await new Promise((resolve) => setTimeout(resolve, 0))

expect(
unhandled.filter(
(reason) => reason instanceof Error && reason.message.includes('window snapshot unavailable'),
),
).toHaveLength(0)
} finally {
process.off('unhandledRejection', onUnhandled)
}
})

it('ignores legacy sessions.patch messages when bootstrapping against an already-ready socket', async () => {
const baselineProjects = [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,22 @@ vi.mock('@/lib/api', async () => {
}
})

// Render markdown bodies synchronously. The real LazyMarkdown wraps MarkdownRenderer
// in React.lazy + Suspense, so the first assistant-message render shows a fallback
// <p> that the lazy chunk later replaces with the real node. Tests that capture the
// fallback node via `await findByText(...)` and then assert `toBeInTheDocument()` race
// that swap (the captured node detaches), producing an order-dependent flake. Mocking
// LazyMarkdown to render MarkdownRenderer directly removes the swap. Matches the mock
// used by the other agent-chat tests (MessageBubble, tool-coalesce, agent-chat-polish-flow).
vi.mock('@/components/markdown/LazyMarkdown', async () => {
const { MarkdownRenderer } = await import('@/components/markdown/MarkdownRenderer')
return {
LazyMarkdown: ({ content }: { content: string }) => (
<MarkdownRenderer content={content} />
),
}
})

function makeStore() {
return configureStore({
reducer: {
Expand Down Expand Up @@ -1891,6 +1907,19 @@ function getPaneContent(store: ReturnType<typeof makeStore>, tabId: string, pane
}

describe('AgentChatView server-restart recovery', () => {
// Reset the timeline API mocks before each test. Without this, a test's
// mockResolvedValueOnce queue leaks into the next test under shuffled order
// (e.g. one test consuming another's "Revision N body" page), producing an
// order-dependent flake. Mirrors the reset in the first describe's beforeEach.
beforeEach(() => {
localStorage.clear()
getAgentTimelinePage.mockReset()
getAgentTurnBody.mockReset()
getAgentChatCapabilities.mockReset()
setSessionMetadata.mockReset()
setSessionMetadata.mockResolvedValue(undefined)
})

afterEach(() => {
cleanup()
wsSend.mockClear()
Expand Down
53 changes: 53 additions & 0 deletions test/unit/client/components/fresh-agent/FreshAgentView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1153,6 +1153,59 @@ describe('FreshAgentView', () => {
]))
})

it('fetches the initial snapshot once and does not refetch from its own pane update', async () => {
const store = createStore()
// First fetch returns a distinct snapshot; the default mockResolvedValue
// ("Codex turn") would answer any *second* fetch. The snapshot-load effect
// persists resumeSessionId via updatePaneContent, and if that self-update
// retriggers the effect, the redundant second fetch overwrites the loaded
// content with the default — a wasteful double network request in production
// and an order-dependent flake in tests.
apiMock.getFreshAgentThreadSnapshot.mockResolvedValueOnce({
status: 'idle',
summary: 'Codex summary',
capabilities: { send: true, interrupt: true, fork: true },
turns: [
{ id: 'turn-user-1', role: 'user', items: [{ id: 'item-user-1', kind: 'text', text: 'Loaded user turn' }] },
{ id: 'turn-assistant-1', role: 'assistant', items: [{ id: 'item-assistant-1', kind: 'text', text: 'Loaded assistant turn' }] },
],
})
store.dispatch(initLayout({
tabId: 'tab-1',
paneId: 'pane-1',
content: {
kind: 'fresh-agent',
sessionType: 'freshcodex',
provider: 'codex',
createRequestId: 'req-single-fetch',
sessionId: 'thread-single-fetch',
status: 'idle',
},
}))

render(
<Provider store={store}>
<StoreBackedFreshAgentView tabId="tab-1" paneId="pane-1" />
</Provider>,
)

// Wait until the effect has persisted resumeSessionId back into pane content
// (the self-update that previously retriggered the effect).
await waitFor(() => {
const layout = store.getState().panes.layouts['tab-1']
const resumeSessionId = layout?.type === 'leaf' && layout.content.kind === 'fresh-agent'
? layout.content.resumeSessionId
: undefined
expect(resumeSessionId).toBe('thread-single-fetch')
})
// Let any spurious self-triggered refetch run before asserting.
await act(async () => { await Promise.resolve() })

expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(1)
// The loaded snapshot stays rendered (not overwritten by a second fetch).
expect(screen.getByText('Loaded assistant turn')).toBeInTheDocument()
})

it('resets auto-title for a new conversation even if the stale prior snapshot had user turns', async () => {
const store = createStore()
apiMock.getFreshAgentThreadSnapshot.mockResolvedValueOnce({
Expand Down
44 changes: 44 additions & 0 deletions test/unit/client/lib/terminal-invalidation-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,4 +178,48 @@ describe('createTerminalInvalidationHandler', () => {
await vi.advanceTimersByTimeAsync(50)
expect(refresh.fetchTerminalDirectoryWindow).toHaveBeenCalledTimes(1)
})

it('contains background-refresh dispatch failures instead of leaking an unhandled rejection', async () => {
vi.useFakeTimers()
const refresh = createRefreshDoubles()
const refreshError = new Error('refresh failed')
// Mirror the real store: dispatching the refresh thunks returns a promise
// that rejects, because fetchTerminalDirectoryWindow / the active-window
// refresh re-throw on API failure. The handler dispatches them
// fire-and-forget from a timer, so it must contain the rejection — otherwise
// a transient background-refresh failure becomes an unhandled rejection that
// crashes the whole vitest run (and is silently uncaught in production).
const dispatch = vi.fn((action: unknown) => {
if (action === refresh.terminalDirectoryRefreshThunk || action === refresh.activeSessionWindowRefreshThunk) {
return Promise.reject(refreshError)
}
return undefined
})
const onRefreshError = vi.fn()
const handler = createTerminalInvalidationHandler({
dispatch,
upsertTerminalMeta,
removeTerminalMeta,
patchSessionRunningStateFromTerminalMeta,
queueActiveSessionWindowRefresh: refresh.queueActiveSessionWindowRefresh,
fetchTerminalDirectoryWindow: refresh.fetchTerminalDirectoryWindow,
setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
refreshDelayMs: 50,
onRefreshError,
})

handler.handle({ type: 'terminals.changed', revision: 12 })
await vi.advanceTimersByTimeAsync(50)
// Let the contained .catch() handlers settle.
await Promise.resolve()
await Promise.resolve()

expect(onRefreshError).toHaveBeenCalledTimes(2)
// Each rejection is tagged with which refresh failed so a shared
// onRefreshError can log an accurate label instead of mislabeling a
// session-window failure as a terminal-directory one.
expect(onRefreshError).toHaveBeenCalledWith(refreshError, 'terminal-directory')
expect(onRefreshError).toHaveBeenCalledWith(refreshError, 'session-window')
})
})
Loading