Files
orca/src/shared/structured-agent-session-reducer.test.ts
T
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

478 lines
14 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types'
import type { AgentSessionHistoryPage } from './agent-session-wire'
import {
EMPTY_STRUCTURED_AGENT_SESSION,
reduceStructuredAgentSession
} from './structured-agent-session-reducer'
function item(id: string, sequence: number): AgentJournalRenderItem {
return {
itemId: id,
revision: 1,
sequence,
observedAt: sequence,
body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: id }] }
}
}
function submission(index: number) {
return {
clientMessageId: `client-${index}`,
fence: 1,
payloadFingerprint: `fingerprint-${index}`,
dispatchState: 'accepted' as const,
providerItemId: `provider-${index}`,
reason: null,
submittedAt: index,
resolvedAt: index
}
}
function hydrationPage(
items: AgentJournalRenderItem[],
submissions: AgentJournalSubmission[] = []
): AgentSessionHistoryPage {
const oldest = items[0]?.sequence ?? 0
const newest = items.at(-1)?.sequence ?? 0
return {
sessionId: 'session-a',
epoch: 'epoch-a',
direction: 'tail',
items,
removedItemIds: [],
submissions,
window: {
oldest: items[0] ? { epoch: 'epoch-a', sequence: oldest } : null,
newest: items.at(-1) ? { epoch: 'epoch-a', sequence: newest } : null,
nextCursor: { epoch: 'epoch-a', sequence: oldest }
},
liveCursor: { epoch: 'epoch-a', sequence: newest },
hasOlder: false,
hasNewer: false
}
}
describe('structured agent session reducer', () => {
it('applies an additive targeted-stop capability update without journal churn', () => {
const backgroundTasks = {
state: 'monitoring' as const,
tasks: [{ id: 'task-1', kind: 'agent' as const }]
}
const initial = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
type: 'event',
event: {
type: 'snapshot',
sessionId: 'session-a',
fence: 1,
page: { ...hydrationPage([]), backgroundTasks }
}
})
const updated = reduceStructuredAgentSession(initial, {
type: 'event',
event: {
type: 'batch',
sessionId: 'session-a',
batch: {
cursor: { epoch: 'epoch-a', sequence: 0 },
items: [],
removedItemIds: [],
submissions: []
},
backgroundTasks: { ...backgroundTasks, supportsTaskStop: true }
}
})
expect(updated.backgroundTasks).toEqual({ ...backgroundTasks, supportsTaskStop: true })
expect(updated.items).toBe(initial.items)
})
it("republishes when only a row's stoppability changes", () => {
// A row losing its stop is the whole difference between an honest control
// and a dead one, so it must not be dropped as an equal state.
const backgroundTasks = {
state: 'monitoring' as const,
supportsTaskStop: true,
tasks: [{ id: 'task-1', kind: 'agent' as const }]
}
const initial = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
type: 'event',
event: {
type: 'snapshot',
sessionId: 'session-a',
fence: 1,
page: { ...hydrationPage([]), backgroundTasks }
}
})
const updated = reduceStructuredAgentSession(initial, {
type: 'event',
event: {
type: 'batch',
sessionId: 'session-a',
batch: {
cursor: { epoch: 'epoch-a', sequence: 0 },
items: [],
removedItemIds: [],
submissions: []
},
backgroundTasks: {
...backgroundTasks,
tasks: [{ id: 'task-1', kind: 'agent' as const, stoppable: false }]
}
}
})
expect(updated.backgroundTasks?.tasks).toEqual([
{ id: 'task-1', kind: 'agent', stoppable: false }
])
})
it('uses the bounded hydration page pagination boundary', () => {
const restored = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
type: 'event',
event: {
type: 'snapshot',
sessionId: 'session-a',
fence: 1,
page: hydrationPage(
Array.from({ length: 84 }, (_, index) => item(`item-${index}`, index + 1))
)
}
})
expect(restored.items).toHaveLength(84)
expect(restored.hasOlder).toBe(false)
})
it('projects additive background task state without changing transcript identity', () => {
const initial = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
type: 'event',
event: {
type: 'snapshot',
sessionId: 'session-a',
fence: 1,
page: hydrationPage([item('message', 1)]),
backgroundTasks: null
}
})
const monitoring = reduceStructuredAgentSession(initial, {
type: 'event',
event: {
type: 'batch',
sessionId: 'session-a',
batch: {
cursor: initial.cursor!,
items: [],
removedItemIds: [],
submissions: []
},
fence: 1,
backgroundTasks: { state: 'monitoring' }
}
})
expect(monitoring.backgroundTasks).toEqual({ state: 'monitoring' })
expect(monitoring.items).toBe(initial.items)
})
it('returns the same state for duplicate background task publications', () => {
const monitoring = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
type: 'event',
event: {
type: 'snapshot',
sessionId: 'session-a',
fence: 1,
page: hydrationPage([item('message', 1)]),
backgroundTasks: { state: 'monitoring' }
}
})
const duplicate = reduceStructuredAgentSession(monitoring, {
type: 'event',
event: {
type: 'batch',
sessionId: 'session-a',
batch: {
cursor: monitoring.cursor!,
items: [],
removedItemIds: [],
submissions: []
},
fence: 1,
backgroundTasks: { state: 'monitoring' }
}
})
expect(duplicate).toBe(monitoring)
})
it('applies background task roster changes without a journal update', () => {
const monitoring = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
type: 'event',
event: {
type: 'snapshot',
sessionId: 'session-a',
fence: 1,
page: hydrationPage([item('message', 1)]),
backgroundTasks: {
state: 'monitoring',
tasks: [{ id: 'task-1', kind: 'command', description: 'first command' }]
}
}
})
const changed = reduceStructuredAgentSession(monitoring, {
type: 'event',
event: {
type: 'batch',
sessionId: 'session-a',
batch: {
cursor: monitoring.cursor!,
items: [],
removedItemIds: [],
submissions: []
},
fence: 1,
backgroundTasks: {
state: 'monitoring',
tasks: [{ id: 'task-1', kind: 'agent', description: 'review the change' }]
}
}
})
expect(changed).not.toBe(monitoring)
expect(changed.backgroundTasks?.tasks).toEqual([
{ id: 'task-1', kind: 'agent', description: 'review the change' }
])
expect(changed.items).toBe(monitoring.items)
})
it('applies a publication whose only change is one task state or settled roster', () => {
const monitoring = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
type: 'event',
event: {
type: 'snapshot',
sessionId: 'session-a',
fence: 1,
page: hydrationPage([item('message', 1)]),
backgroundTasks: {
state: 'monitoring',
tasks: [
{ id: 'task-1', kind: 'agent', name: 'deep_review', state: 'working', startedAt: 100 }
]
}
}
})
const batch = (backgroundTasks: NonNullable<typeof monitoring.backgroundTasks>) =>
reduceStructuredAgentSession(monitoring, {
type: 'event',
event: {
type: 'batch',
sessionId: 'session-a',
batch: { cursor: monitoring.cursor!, items: [], removedItemIds: [], submissions: [] },
fence: 1,
backgroundTasks
}
})
const stateOnly = batch({
state: 'monitoring',
tasks: [
{ id: 'task-1', kind: 'agent', name: 'deep_review', state: 'waiting', startedAt: 100 }
]
})
expect(stateOnly).not.toBe(monitoring)
expect(stateOnly.backgroundTasks?.tasks?.[0]?.state).toBe('waiting')
const settledOnly = batch({
state: 'monitoring',
tasks: [
{ id: 'task-1', kind: 'agent', name: 'deep_review', state: 'working', startedAt: 100 }
],
settledTasks: [{ id: 'task-2', kind: 'agent', state: 'done', startedAt: 50 }]
})
expect(settledOnly).not.toBe(monitoring)
expect(settledOnly.backgroundTasks?.settledTasks).toHaveLength(1)
const tokensOnly = batch({
state: 'monitoring',
tasks: [
{
id: 'task-1',
kind: 'agent',
name: 'deep_review',
state: 'working',
startedAt: 100,
totalTokens: 18_130
}
]
})
expect(tokensOnly.backgroundTasks?.tasks?.[0]?.totalTokens).toBe(18_130)
const unchanged = batch({
state: 'monitoring',
tasks: [
{ id: 'task-1', kind: 'agent', name: 'deep_review', state: 'working', startedAt: 100 }
]
})
expect(unchanged).toBe(monitoring)
})
it('clears additive background state when a replacement snapshot omits the field', () => {
const monitoring = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
type: 'event',
event: {
type: 'snapshot',
sessionId: 'session-a',
fence: 1,
page: hydrationPage([item('message', 1)]),
backgroundTasks: { state: 'monitoring' }
}
})
const withoutCapability = reduceStructuredAgentSession(monitoring, {
type: 'event',
event: {
type: 'snapshot',
sessionId: 'session-a',
fence: 2,
page: hydrationPage([item('message', 1)])
}
})
expect(withoutCapability.backgroundTasks).toBeUndefined()
})
it('projects ephemeral activity without changing transcript identity and clears it', () => {
const initial = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
type: 'event',
event: {
type: 'snapshot',
sessionId: 'session-a',
fence: 1,
page: hydrationPage([item('message', 1)])
}
})
const active = reduceStructuredAgentSession(initial, {
type: 'event',
event: {
type: 'batch',
sessionId: 'session-a',
batch: {
cursor: initial.cursor!,
items: [],
removedItemIds: [],
submissions: []
},
activity: { turnId: 'turn-1', text: 'Checking the renderer' }
}
})
expect(active.activity).toEqual({ turnId: 'turn-1', text: 'Checking the renderer' })
expect(active.items).toBe(initial.items)
const cleared = reduceStructuredAgentSession(active, {
type: 'event',
event: {
type: 'batch',
sessionId: 'session-a',
batch: {
cursor: active.cursor!,
items: [],
removedItemIds: [],
submissions: []
},
activity: null
}
})
expect(cleared.activity).toBeNull()
expect(cleared.items).toBe(active.items)
})
it('records the host clock from frames that carry it and keeps it otherwise', () => {
const snapshot = reduceStructuredAgentSession(
EMPTY_STRUCTURED_AGENT_SESSION,
{
type: 'event',
event: {
type: 'snapshot',
sessionId: 'session-a',
fence: 1,
page: hydrationPage([item('first', 1)]),
hostNow: 5_000
}
},
9_000
)
expect(snapshot.hostClock).toEqual({ hostNow: 5_000, receivedAt: 9_000 })
const batch = reduceStructuredAgentSession(
snapshot,
{
type: 'event',
event: {
type: 'batch',
sessionId: 'session-a',
fence: 1,
hostNow: 5_400,
batch: {
cursor: { epoch: 'epoch-a', sequence: 2 },
items: [item('second', 2)],
removedItemIds: [],
submissions: []
}
}
},
9_400
)
expect(batch.hostClock).toEqual({ hostNow: 5_400, receivedAt: 9_400 })
// An older host stamps nothing; the last sample stays usable.
const unstamped = reduceStructuredAgentSession(
batch,
{
type: 'event',
event: {
type: 'batch',
sessionId: 'session-a',
fence: 1,
batch: {
cursor: { epoch: 'epoch-a', sequence: 3 },
items: [item('third', 3)],
removedItemIds: [],
submissions: []
}
}
},
9_800
)
expect(unstamped.hostClock).toEqual({ hostNow: 5_400, receivedAt: 9_400 })
})
})
it('applies catalog-only checkpoints without replacing transcript or submission state', () => {
const state = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
type: 'event',
event: {
type: 'snapshot',
sessionId: 'session-a',
fence: 1,
page: hydrationPage([item('one', 1)], [submission(1)]),
commands: []
}
})
const event = {
type: 'batch' as const,
sessionId: 'session-a',
fence: 1,
commands: [{ name: 'loaded', kind: 'skill' as const }],
batch: { cursor: state.cursor!, items: [], removedItemIds: [], submissions: [] }
}
const updated = reduceStructuredAgentSession(state, { type: 'event', event })
expect(updated.commands).toEqual(event.commands)
expect(updated.items).toBe(state.items)
expect(updated.submissions).toBe(state.submissions)
expect(updated.cursor).toBe(state.cursor)
expect(reduceStructuredAgentSession(updated, { type: 'event', event })).toBe(updated)
const { commands: _commands, ...oldEvent } = event
expect(reduceStructuredAgentSession(updated, { type: 'event', event: oldEvent })).toBe(updated)
})