Files
orca/tests/e2e/structured-agent-session-read-owner.unit.test.ts
Brennan Benson a4c11f1889 fix(native-chat): stop a bounded tail read from moving the chat cursor past unapplied rows (#20581)
* fix(native-chat): stop a bounded tail read from moving the chat cursor past unapplied rows

A structured chat pane could latch "Working for N" forever after the agent had
finished, showing the send arrow rather than Stop, while the sidebar and
`worktree ps` correctly read idle.

The client replica has one position (`state.cursor`) and one body. Two
operations keep those consistent: replace (both from one host snapshot) and
append (rows contiguous with the cursor). The `tail-page` branch was a third
thing: it took the cursor from the journal head, the items from a bounded page
(200 items, byte-capped), then merged retained client submissions over the
page's. Under continuous journal writes the client is always slightly behind,
so the branch ran on every window focus and on every pane re-activation. When
more than a page of rows had landed since a send, that send's user item fell
off the page, its submission was not carried, the retained `pending` survived,
and the cursor jumped past the dispatch-acceptance row. Nothing re-sends it: a
batch carries only touched items and that submission is never touched again.

Delete the third operation rather than guard it. A live subscription is now the
only thing that moves the cursor, and `subscribe({ cursor })` already replays
exactly the missed rows.

- remove the window `focus` listener and the owner/transport `refresh` contract
- skip warm hydration: a retained owner subscribes at its applied cursor
- cold hydration keeps its history read, applied as the existing `snapshot`
  (replace) event rather than `tail-page`
- delete the `tail-page` action and its reducer branch
- delete `resumeCursor` and `shouldAdvanceStructuredResumeCursor`; two cursors
  with two advancement rules were how position and body drifted apart

`older-page`/`loadOlder`, the unattached-refusal grace, generation guards and
the coalescer are unchanged. No host, wire or schema change.

Also fixes a second cost of the same branch: focus during a busy turn discarded
paged-in older items, shrinking the transcript to one bounded page mid-turn.

* fix(native-chat): preserve unavailable mixed-version session fences
2026-09-14 10:28:16 -07:00

252 lines
9.8 KiB
TypeScript

import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type {
AgentSessionHistoryRequest,
AgentSessionHistoryResult,
AgentSessionStatusSummary,
AgentSessionSubscribeEvent
} from '../../src/shared/agent-session-wire'
import type { AgentJournalCursor } from '../../src/shared/agent-session-journal-types'
import {
EMPTY_STRUCTURED_AGENT_SESSION,
reduceStructuredAgentSession
} from '../../src/shared/structured-agent-session-reducer'
import {
hasUnansweredStructuredAgentSessionDispatch,
projectStructuredAgentSessionStatus
} from '../../src/shared/structured-agent-session-projection'
import { createTrackedJournalOpener } from '../../src/main/native-chat/agent-session-journal/journal-store-test-open'
import { readAgentSessionHistory } from '../../src/main/native-chat/agent-session-wire/agent-session-history-page'
import { AgentSessionSubscribers } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-subscribers'
import { StructuredAgentSessionStatusFeed } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-status-feed'
const mocks = vi.hoisted(() => ({ call: vi.fn(), subscribe: vi.fn() }))
vi.mock('@/runtime/structured-agent-session-client', () => ({
callStructuredAgentSession: mocks.call,
subscribeStructuredAgentSession: mocks.subscribe
}))
import {
getStructuredAgentSessionReadOwner,
resetStructuredAgentSessionReadOwnersForTests
} from '../../src/renderer/src/components/native-chat/structured-agent-session-read-owner'
const SESSION = 'cursor-body-regression'
const target = { kind: 'local' } as const
const journals = createTrackedJournalOpener()
let root: string
beforeEach(async () => {
vi.resetAllMocks()
root = await mkdtemp(join(tmpdir(), 'orca-cursor-body-'))
})
afterEach(async () => {
resetStructuredAgentSessionReadOwnersForTests()
await journals.closeAll()
await rm(root, { recursive: true, force: true })
})
async function fixture() {
const journal = await journals.open({
identity: {
sessionId: SESSION,
workspaceId: 'folder-workspace',
hostId: 'local',
agent: 'codex',
providerHandle: { kind: 'codex', threadId: 'thread-1' }
},
journalDir: join(root, 'journal')
})
async function appendOutput(index: number) {
await journal.appendItem(
{ provider: 'orca', clientMessageId: `output-${index}` },
{ kind: 'status', text: `Tool output ${index}` },
{ fence: 1 }
)
}
for (let index = 1; index < 99; index += 1) {
await appendOutput(index)
}
await journal.appendSubmission({
clientMessageId: 'pending-send',
payloadFingerprint: 'prompt',
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Run tools' }] },
fence: 1
})
expect(journal.cursor().sequence).toBe(100)
const initial = structuredClone(
readAgentSessionHistory(journal, { sessionId: SESSION, direction: 'tail' })
)
const accept = () =>
journal.resolveDispatch({
clientMessageId: 'pending-send',
fence: 1,
state: 'accepted',
providerIdentity: { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }
})
return { journal, initial, appendOutput, accept }
}
describe('structured session cursor/body regression', () => {
it('replaces retained pending submissions together with a real bounded snapshot at 140', async () => {
const { journal, initial, appendOutput, accept } = await fixture()
const retained = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
type: 'event',
event: { type: 'snapshot', sessionId: SESSION, page: initial.page, fence: 1 }
})
expect(retained.submissions[0]?.dispatchState).toBe('pending')
await accept()
for (let index = 102; index <= 140; index += 1) {
await appendOutput(index)
}
const bounded = readAgentSessionHistory(journal, {
sessionId: SESSION,
direction: 'tail',
limit: 1
}).page
expect(bounded.liveCursor?.sequence).toBe(140)
expect(bounded.items).not.toContainEqual(retained.items.at(-1))
expect(bounded.submissions).toEqual([])
const replaced = reduceStructuredAgentSession(retained, {
type: 'event',
event: { type: 'snapshot', sessionId: SESSION, page: bounded, fence: 1 }
})
expect(replaced.cursor).toEqual(bounded.liveCursor)
expect(replaced.submissions).toEqual(bounded.submissions)
expect(hasUnansweredStructuredAgentSessionDispatch(replaced.submissions, 1)).toBe(false)
})
it.each([40, 401])(
'replays an off-page dispatch after %i missed rows without stranding pending state',
async (missedRows) => {
const { journal, appendOutput, accept } = await fixture()
let hostSummary: AgentSessionStatusSummary | undefined
const feed = new StructuredAgentSessionStatusFeed({
sessions: new Map([
[
SESSION,
{
journal,
fence: 1,
params: { location: { workspaceId: 'folder-workspace' }, provider: 'codex' }
}
]
]),
getRecord: () => null,
now: () => 1_000,
onStatusChanged: (summary) => {
hostSummary = summary
}
})
const subscribers = new AgentSessionSubscribers({
onJournalPublished: (sessionId, published) => feed.publish(sessionId, published)
})
const delayedOlder = Promise.withResolvers<AgentSessionHistoryResult>()
let warm = false
mocks.call.mockImplementation((_target, _method, request: AgentSessionHistoryRequest) => {
// Hold the measured bounded page before its asynchronous older-page fill can mask it.
if (warm && missedRows === 40 && request.direction === 'before') {
return delayedOlder.promise
}
const result = readAgentSessionHistory(journal, {
...request,
...(warm && missedRows === 40 ? { limit: 1 } : {})
})
return Promise.resolve(
structuredClone({
...result,
page: { ...result.page, fence: 1, hostNow: 1234 },
providerSession: { key: 'session_id', id: 'provider-1' }
})
)
})
mocks.subscribe.mockImplementation(
(
_target,
request: { cursor?: AgentJournalCursor },
onEvent: (event: AgentSessionSubscribeEvent) => void
) =>
Promise.resolve({
unsubscribe: subscribers.open({
id: 'pane',
sessionId: SESSION,
journal,
fence: 1,
cursor: request.cursor,
emit: (event) => onEvent(structuredClone(event))
})
})
)
const owner = getStructuredAgentSessionReadOwner(SESSION, target)
const unlisten = owner.subscribe(() => {})
const deactivate = owner.activate()
await vi.waitFor(() => expect(mocks.subscribe).toHaveBeenCalledTimes(1))
expect(owner.getSnapshot().state.cursor?.sequence).toBe(100)
expect(owner.getSnapshot().state.items.at(-1)?.body).toMatchObject({ role: 'user' })
expect(owner.getSnapshot().state.submissions[0]?.dispatchState).toBe('pending')
expect(owner.getSnapshot().providerSession).toEqual({ key: 'session_id', id: 'provider-1' })
expect(owner.getSnapshot().state.hostClock?.hostNow).toBe(1234)
expect(mocks.call).toHaveBeenCalledTimes(1)
deactivate()
await accept()
for (let index = 102; index <= 100 + missedRows; index += 1) {
await appendOutput(index)
}
const tail = readAgentSessionHistory(journal, {
sessionId: SESSION,
direction: 'tail',
limit: missedRows === 40 ? 1 : 200
}).page
expect(tail.liveCursor?.sequence).toBe(100 + missedRows)
expect(tail.submissions).toEqual([])
feed.publish(SESSION, journal)
// IPC/RPC copies values; the journal mutates its own submission records in place.
expect(owner.getSnapshot().state.submissions[0]?.dispatchState).toBe('pending')
warm = true
const stop = owner.activate()
await vi.waitFor(() => expect(owner.getSnapshot().state.cursor).toEqual(journal.cursor()))
const caughtUp = owner.getSnapshot().state
if (missedRows === 40) {
expect({
cursor: caughtUp.cursor?.sequence,
dispatch: caughtUp.submissions[0]?.dispatchState,
unansweredDispatch: hasUnansweredStructuredAgentSessionDispatch(caughtUp.submissions, 1)
}).toEqual({ cursor: 140, dispatch: 'accepted', unansweredDispatch: false })
}
await journal.appendItem(
{ provider: 'orca', clientMessageId: 'completed-turn' },
{ kind: 'turn', turnId: 'turn-1', state: 'completed' },
{ fence: 1 }
)
subscribers.publish(SESSION, journal)
await vi.waitFor(() => expect(owner.getSnapshot().state.cursor).toEqual(journal.cursor()))
const settled = owner.getSnapshot().state
expect(hostSummary?.status).toBe('idle')
expect(
projectStructuredAgentSessionStatus(settled.items, settled.submissions, settled.fence)
).toBe(hostSummary?.status)
expect(settled.submissions).toEqual(journal.snapshot().submissions)
expect({
cursor: caughtUp.cursor?.sequence,
dispatch: caughtUp.submissions[0]?.dispatchState,
unansweredDispatch: hasUnansweredStructuredAgentSessionDispatch(caughtUp.submissions, 1)
}).toEqual({ cursor: 100 + missedRows, dispatch: 'accepted', unansweredDispatch: false })
expect(mocks.call).toHaveBeenCalledTimes(1)
expect(mocks.subscribe).toHaveBeenCalledTimes(2)
expect(mocks.subscribe.mock.calls[1]?.[1]).toEqual({
sessionId: SESSION,
cursor: { epoch: journal.cursor().epoch, sequence: 100 }
})
stop()
unlisten()
}
)
})