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 <sim@local>
This commit is contained in:
Brennan Benson
2026-09-06 17:41:56 -07:00
committed by GitHub
co-authored by Merge Sim
parent b7b6ea3942
commit c49345d358
9 changed files with 230 additions and 56 deletions
@@ -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, {
@@ -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[] =>
@@ -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 }]]), {
@@ -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()
}
}
@@ -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
}
]
}
@@ -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<string, unknown>
setState: (state: Record<string, unknown>) => void
getState: () => AppState
setState: (state: Partial<AppState> & { 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<string, Record<string, unknown>>
removeAgentStatus: (paneKey: string) => void
setAgentStatus: (...args: unknown[]) => void
testRuntimeOwner: string | null
unifiedTabsByWorktree: Record<string, Tab[]>
}>((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>,
string,
unknown,
Record<string, unknown>,
Record<string, unknown>
]
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<AgentSessionStatusSummary> = {}): AgentSessi
}
}
function statuses(): Record<string, unknown>[] {
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(<StructuredAgentSessionStatusBridge />)
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(<StructuredAgentSessionStatusBridge />)
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(<StructuredAgentSessionStatusBridge />)
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(<StructuredAgentSessionStatusBridge />)
await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce())
@@ -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 } : {}),
@@ -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
@@ -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