From c49345d35824be0fa7cff2a0d8d51915dbf2525b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 6 Sep 2026 17:41:56 -0700 Subject: [PATCH] Fix native chat completion sorting and restored activity timestamps (#19144) * Fix structured native chat completion sorting and timestamps * Preserve native chat activity across settled updates and host upgrades --------- Co-authored-by: Merge Sim --- .../agent-session-journal/journal-reducer.ts | 3 + .../agent-session-journal/journal-store.ts | 3 + ...ructured-agent-session-status-feed.test.ts | 105 +++++++++++- .../structured-agent-session-status-feed.ts | 4 +- .../methods/structured-agent-session.test.ts | 4 +- ...tructuredAgentSessionStatusBridge.test.tsx | 149 ++++++++++++------ .../StructuredAgentSessionStatusBridge.tsx | 14 +- .../src/store/slices/agent-status-contract.ts | 2 + .../slices/agent-status-live-entry-builder.ts | 2 +- 9 files changed, 230 insertions(+), 56 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.ts b/src/main/native-chat/agent-session-journal/journal-reducer.ts index b6988ec0e6f..41625792aa0 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -26,6 +26,7 @@ export type JournalReducerState = { sessionId: string epoch: string lastSequence: number + lastActivityAt: number /** Lowest sequence still individually replayable; rows below it were compacted. */ oldestSequence: number highestFence: number @@ -45,6 +46,7 @@ export function createJournalReducerState(sessionId: string, epoch: string): Jou sessionId, epoch, lastSequence: 0, + lastActivityAt: 0, oldestSequence: 1, highestFence: 0, items: new Map(), @@ -62,6 +64,7 @@ export function applyJournalRow(state: JournalReducerState, row: JournalRow): vo if (row.kind === 'epoch') { return } + state.lastActivityAt = Math.max(state.lastActivityAt, row.ts) if (row.kind === 'item') { const itemId = resolveJournalItemId(state, row.itemId, row.body) upsertItem(state, itemId, row.revision, { diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index ab2715d0d86..e2936b2553d 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -162,6 +162,9 @@ export class AgentSessionJournal { snapshot = (): AgentJournalSnapshot => renderJournalState(this.state) + /** Includes revisions and completion tombstones, whose timestamps disappear from render items. */ + lastActivityAt = (): number => this.state.lastActivityAt + submissions = (): AgentJournalSubmission[] => [...this.state.submissions.values()] pendingSubmissions = (): AgentJournalSubmission[] => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts index 7e60f77d979..c1b52f879d4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts @@ -39,7 +39,7 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) -async function openJournal(sessionId = SESSION) { +async function openJournal(sessionId = SESSION, now?: () => number) { return journals.open({ identity: { sessionId, @@ -48,6 +48,7 @@ async function openJournal(sessionId = SESSION) { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, + now, journalDir: join(root, sessionId) }) } @@ -144,6 +145,108 @@ describe('StructuredAgentSessionStatusFeed', () => { expect(events).toHaveLength(3) }) + it('preserves the completion tombstone time when the journal and host reopen', async () => { + let now = 100 + const journal = await openJournal(SESSION, () => now) + await journal.appendItem( + USER_IDENTITY, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + { fence: 1 } + ) + await journal.appendItem( + TURN_IDENTITY, + { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, + { fence: 1 } + ) + const { feed, events } = feedFor(new Map([[SESSION, { journal }]])) + now = 200 + await journal.appendTombstone(TURN_IDENTITY, { fence: 1 }) + feed.publish(SESSION) + expect(events.at(-1)).toMatchObject({ + type: 'status', + session: { status: 'idle', updatedAt: 200 } + }) + await journal.close() + now = 900 + const reopened = await openJournal(SESSION, () => now) + const restored = feedFor(new Map([[SESSION, { journal: reopened }]])) + expect(restored.events[0]).toMatchObject({ + type: 'snapshot', + sessions: [{ status: 'idle', updatedAt: 200 }] + }) + }) + + it('publishes settled activity revisions and restores the same age after reopening', async () => { + let now = 100 + const journal = await openJournal(SESSION, () => now) + await journal.appendItem( + USER_IDENTITY, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + { fence: 1 } + ) + const assistant = { ...USER_IDENTITY, ordinal: 2 } + await journal.appendItem( + assistant, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'first' }] }, + { fence: 1 } + ) + const { feed, events } = feedFor(new Map([[SESSION, { journal }]])) + now = 200 + await journal.appendItem( + assistant, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'finished' }] }, + { fence: 1 } + ) + feed.publish(SESSION) + expect(events.at(-1)).toMatchObject({ + type: 'status', + session: { status: 'idle', updatedAt: 200 } + }) + feed.publish(SESSION) + expect(events).toHaveLength(2) + await journal.close() + const reopened = await openJournal(SESSION, () => 900) + const restored = feedFor(new Map([[SESSION, { journal: reopened }]])) + expect(restored.events[0]).toMatchObject({ + type: 'snapshot', + sessions: [{ status: 'idle', updatedAt: 200 }] + }) + }) + + it('does not publish timestamp-only revisions while a turn is working', async () => { + let now = 100 + const journal = await openJournal(SESSION, () => now) + await journal.appendItem( + USER_IDENTITY, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + { fence: 1 } + ) + await journal.appendItem( + TURN_IDENTITY, + { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, + { fence: 1 } + ) + const { feed, events } = feedFor(new Map([[SESSION, { journal }]])) + for (let revision = 1; revision <= 20; revision += 1) { + now += 1 + await journal.appendItem( + TURN_IDENTITY, + { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, + { fence: 1 } + ) + feed.publish(SESSION) + } + expect(events).toHaveLength(1) + now = 200 + await journal.appendTombstone(TURN_IDENTITY, { fence: 1 }) + feed.publish(SESSION) + expect(events).toHaveLength(2) + expect(events.at(-1)).toMatchObject({ + type: 'status', + session: { status: 'idle', updatedAt: 200 } + }) + }) + it('carries the record model and the running tool line the sidebar row shows', async () => { const journal = await openJournal() const { feed, events } = feedFor(new Map([[SESSION, { journal }]]), { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index e95a1f35e63..348b5aebc21 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -46,6 +46,8 @@ function summariesEqual(a: AgentSessionStatusSummary, b: AgentSessionStatusSumma a.workspaceId === b.workspaceId && a.agent === b.agent && a.status === b.status && + // Settled activity changes ranking; streaming active turns must stay quiet. + (a.status !== 'idle' || a.updatedAt === b.updatedAt) && a.latestPrompt === b.latestPrompt && a.model === b.model && a.toolName === b.toolName && @@ -126,7 +128,7 @@ export class StructuredAgentSessionStatusFeed { ...projectStructuredAgentSessionStatusSummary(items), ...(model ? { model } : {}), ...(providerSession ? { providerSession } : {}), - updatedAt: this.deps.now() + updatedAt: journal.lastActivityAt() || this.deps.now() } } diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index 8defafb4433..f6c9d274142 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -98,6 +98,7 @@ function statusFeed(): StructuredAgentSessionStatusFeed { { journal: { isReadOnly: false, + lastActivityAt: () => 2, snapshot: () => ({ items: STATUS_ITEMS }) } as unknown as AgentSessionJournal, params: { location: { workspaceId: 'workspace-1' }, provider: 'codex' as const } @@ -815,7 +816,8 @@ describe('agentSession.subscribeStatus', () => { workspaceId: 'workspace-1', agent: 'codex', status: 'working', - latestPrompt: 'write a poem' + latestPrompt: 'write a poem', + updatedAt: 2 } ] } diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx index bfa522e4b83..c4376dbf66e 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx @@ -6,15 +6,18 @@ import type { AgentSessionStatusEvent, AgentSessionStatusSummary } from '../../../../shared/agent-session-wire' +import { resolveAttention } from '../sidebar/smart-attention' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import type { Tab } from '../../../../shared/tab-types' +import type { AppState } from '@/store/types' import type * as RuntimeRpcClientModule from '@/runtime/runtime-rpc-client' const mocks = vi.hoisted(() => ({ removeAgentStatus: vi.fn(), setAgentStatus: vi.fn(), store: null as null | { - getState: () => Record - setState: (state: Record) => void + getState: () => AppState + setState: (state: Partial & { testRuntimeOwner?: string | null }) => void }, subscribeStatus: vi.fn(), subscribeTranscript: vi.fn(), @@ -23,53 +26,19 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('@/store', async () => { - const { create } = await import('zustand') - const useAppStore = create<{ - agentStatusByPaneKey: Record> - removeAgentStatus: (paneKey: string) => void - setAgentStatus: (...args: unknown[]) => void - testRuntimeOwner: string | null - unifiedTabsByWorktree: Record - }>((set, get) => ({ - agentStatusByPaneKey: {}, - removeAgentStatus: (paneKey) => { - mocks.removeAgentStatus(paneKey) - if (!get().agentStatusByPaneKey[paneKey]) { - return - } - const next = { ...get().agentStatusByPaneKey } - delete next[paneKey] - set({ agentStatusByPaneKey: next }) - }, + const { createTestStore } = await import('@/store/slices/store-test-helpers') + const useAppStore = createTestStore() + const { setAgentStatus, removeAgentStatus } = useAppStore.getState() + useAppStore.setState({ setAgentStatus: (...args) => { mocks.setAgentStatus(...args) - const [paneKey, payload, terminalTitle, , routing, metadata] = args as [ - string, - Record, - string, - unknown, - Record, - Record - ] - set((state) => ({ - agentStatusByPaneKey: { - ...state.agentStatusByPaneKey, - [paneKey]: { - ...payload, - ...routing, - ...metadata, - paneKey, - terminalTitle, - updatedAt: Date.now(), - stateStartedAt: Date.now(), - stateHistory: [] - } - } - })) + setAgentStatus(...args) }, - testRuntimeOwner: null, - unifiedTabsByWorktree: {} - })) + removeAgentStatus: (paneKey) => { + mocks.removeAgentStatus(paneKey) + removeAgentStatus(paneKey) + } + }) mocks.store = useAppStore return { useAppStore } }) @@ -126,7 +95,7 @@ function summary(overrides: Partial = {}): AgentSessi } } -function statuses(): Record[] { +function statuses(): AgentStatusEntry[] { return Object.values(mocks.store?.getState().agentStatusByPaneKey ?? {}) } @@ -218,7 +187,9 @@ describe('StructuredAgentSessionStatusBridge', () => { expect(statuses()).toEqual([expect.objectContaining({ state: 'working' })]) act(() => feed().emit({ type: 'status', session: summary({ status: 'idle', updatedAt: 2 }) })) - expect(statuses()).toEqual([expect.objectContaining({ state: 'done', sessionBoundary: true })]) + expect(statuses()).toEqual([ + expect.objectContaining({ state: 'done', sessionBoundary: false, stateStartedAt: 2 }) + ]) act(() => feed().emit({ type: 'status', session: summary({ status: 'attention', updatedAt: 3 }) }) @@ -309,8 +280,8 @@ describe('StructuredAgentSessionStatusBridge', () => { const before = mocks.store?.getState().agentStatusByPaneKey act(() => { - for (let updatedAt = 2; updatedAt <= 12; updatedAt += 1) { - feed().emit({ type: 'status', session: summary({ updatedAt }) }) + for (let repeat = 0; repeat < 10; repeat += 1) { + feed().emit({ type: 'status', session: summary() }) } }) @@ -318,6 +289,84 @@ describe('StructuredAgentSessionStatusBridge', () => { expect(mocks.store?.getState().agentStatusByPaneKey).toBe(before) }) + it.each(['claude', 'codex'] as const)( + 'sorts restored %s completions by host time and advances identical turns', + async (agent) => { + const now = Date.now() + mocks.store?.setState({ + unifiedTabsByWorktree: { 'wt-1': [{ ...structuredTab, agentSessionAgent: agent }] } + }) + render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + act(() => + feed().emit({ + type: 'snapshot', + sessions: [summary({ status: 'idle', updatedAt: now - 100 })] + }) + ) + expect(statuses()).toEqual([ + expect.objectContaining({ + state: 'done', + sessionBoundary: false, + stateStartedAt: now - 100, + updatedAt: now - 100 + }) + ]) + act(() => + feed().emit({ type: 'status', session: summary({ status: 'idle', updatedAt: now - 50 }) }) + ) + expect(statuses()).toEqual([ + expect.objectContaining({ stateStartedAt: now - 50, updatedAt: now - 50 }) + ]) + expect( + resolveAttention([{ kind: 'hook', entry: statuses()[0], hasLivePty: false }], now) + ).toEqual({ cls: 2, attentionTimestamp: now - 50 }) + } + ) + + it('preserves the working age when host metadata advances during the same turn', async () => { + render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + act(() => feed().emit({ type: 'status', session: summary({ updatedAt: 100 }) })) + act(() => + feed().emit({ + type: 'status', + session: summary({ updatedAt: 200, providerSession: { ...providerSession, id: 'new-id' } }) + }) + ) + expect(statuses()).toEqual([ + expect.objectContaining({ state: 'working', updatedAt: 200, stateStartedAt: 100 }) + ]) + }) + + it('accepts an authoritative older journal age after a host upgrade reconnect', async () => { + render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + act(() => feed().emit({ type: 'status', session: summary({ updatedAt: 800 }) })) + act(() => + feed().emit({ type: 'snapshot', sessions: [summary({ status: 'idle', updatedAt: 900 })] }) + ) + const paneKey = statuses()[0].paneKey + const history = statuses()[0].stateHistory + const acknowledged = { [paneKey]: 950 } + mocks.store?.setState({ acknowledgedAgentsByPaneKey: acknowledged }) + act(() => + feed().emit({ type: 'snapshot', sessions: [summary({ status: 'idle', updatedAt: 200 })] }) + ) + expect(statuses()).toEqual([ + expect.objectContaining({ state: 'done', updatedAt: 200, stateStartedAt: 200 }) + ]) + const before = mocks.store?.getState().agentStatusByPaneKey + const calls = mocks.setAgentStatus.mock.calls.length + expect(statuses()[0].stateHistory).toBe(history) + expect(mocks.store?.getState().acknowledgedAgentsByPaneKey).toBe(acknowledged) + act(() => + feed().emit({ type: 'snapshot', sessions: [summary({ status: 'idle', updatedAt: 200 })] }) + ) + expect(mocks.store?.getState().agentStatusByPaneKey).toBe(before) + expect(mocks.setAgentStatus).toHaveBeenCalledTimes(calls) + }) + it('drops the status and the feed when the last structured tab closes', async () => { render() await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index 601592a11a7..a72f28d7c08 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -81,7 +81,7 @@ function projectStatus(tab: StructuredTab, summary: AgentSessionStatusSummary | ...(summary.toolName ? { toolName: summary.toolName } : {}), ...(summary.toolInput ? { toolInput: summary.toolInput } : {}), ...(summary.lastAssistantMessage ? { lastAssistantMessage: summary.lastAssistantMessage } : {}), - sessionBoundary: summary.status === 'idle' + sessionBoundary: false } as const const current = store.agentStatusByPaneKey?.[paneKey] if ( @@ -94,6 +94,7 @@ function projectStatus(tab: StructuredTab, summary: AgentSessionStatusSummary | current.toolInput === summary.toolInput && current.lastAssistantMessage === summary.lastAssistantMessage && current.sessionBoundary === desired.sessionBoundary && + current.updatedAt === summary.updatedAt && current.terminalTitle === tab.label && current.tabId === tab.id && current.worktreeId === tab.worktreeId && @@ -110,7 +111,16 @@ function projectStatus(tab: StructuredTab, summary: AgentSessionStatusSummary | paneKey, desired, tab.label, - undefined, + { + updatedAt: summary.updatedAt, + // This ordered host feed can correct a legacy publication clock after upgrade. + allowOlderTimestamp: true, + stateStartedAt: + desired.state !== 'done' && current?.state === desired.state + ? current.stateStartedAt + : summary.updatedAt, + evidenceObservedAt: Date.now() + }, { tabId: tab.id, worktreeId: tab.worktreeId }, { ...(summary.providerSession ? { providerSession: summary.providerSession } : {}), diff --git a/src/renderer/src/store/slices/agent-status-contract.ts b/src/renderer/src/store/slices/agent-status-contract.ts index 64dcb7f6919..39bbde535d1 100644 --- a/src/renderer/src/store/slices/agent-status-contract.ts +++ b/src/renderer/src/store/slices/agent-status-contract.ts @@ -92,6 +92,8 @@ export type AgentStatusPayload = ParsedAgentStatusPayload & { } export type AgentStatusTiming = { + /** Ordered authoritative sources may correct a prior publication clock. */ + allowOlderTimestamp?: boolean updatedAt?: number /** Observation clock for staleness; see `AgentStatusEntry.evidenceObservedAt`. */ evidenceObservedAt?: number diff --git a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts index 12812b380c7..5c3ef89bdaf 100644 --- a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts +++ b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts @@ -74,7 +74,7 @@ export function buildAgentStatusLiveEntry( ): AgentStatusLiveEntryBuild | AgentStatusLiveEntryRejection { const { state, paneKey, payload, terminalTitle, timing, routing, metadata, updatedAt } = args const existing = state.agentStatusByPaneKey[paneKey] - if (existing && updatedAt < existing.updatedAt) { + if (existing && updatedAt < existing.updatedAt && !timing?.allowOlderTimestamp) { return { entry: null, reason: 'stale' } } const effectiveTitle = terminalTitle ?? existing?.terminalTitle