mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 01:27:35 +00:00
783 lines
25 KiB
TypeScript
783 lines
25 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types'
|
|
import type { AgentSessionHistoryPage, AgentSessionLatestTurn } from './agent-session-wire'
|
|
import { runningStructuredAgentSessionTurnId } from './structured-agent-session-live-turn'
|
|
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("orders one journal write's items by their place in it, whatever order they arrive in", () => {
|
|
const at = (id: string, sequence: number, sequenceIndex: number): AgentJournalRenderItem => ({
|
|
...item(id, sequence),
|
|
...(sequenceIndex > 0 ? { sequenceIndex } : {})
|
|
})
|
|
const paged = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
|
|
type: 'history-page',
|
|
page: hydrationPage([
|
|
item('before', 4),
|
|
at('third', 5, 2),
|
|
at('first', 5, 0),
|
|
at('second', 5, 1)
|
|
])
|
|
})
|
|
expect(paged.items.map(({ itemId }) => itemId)).toEqual(['before', 'first', 'second', 'third'])
|
|
const live = reduceStructuredAgentSession(paged, {
|
|
type: 'event',
|
|
event: {
|
|
type: 'batch',
|
|
sessionId: 'session-a',
|
|
batch: {
|
|
cursor: { epoch: 'epoch-a', sequence: 6 },
|
|
items: [at('next-b', 6, 1), at('next-a', 6, 0)],
|
|
removedItemIds: [],
|
|
submissions: []
|
|
}
|
|
}
|
|
})
|
|
expect(live.items.map(({ itemId }) => itemId).slice(-2)).toEqual(['next-a', 'next-b'])
|
|
})
|
|
|
|
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('decodes child rows once as they arrive: a malformed row is dropped, and a repeat keeps identity', () => {
|
|
const view = {
|
|
id: 'child-1',
|
|
providerId: 'task-1',
|
|
kind: 'agent',
|
|
state: 'working',
|
|
membership: 'live',
|
|
firstObservedAt: 1,
|
|
observedAt: 2,
|
|
stoppable: true,
|
|
invocation: { invocationId: 'spawn-1', generation: 1 }
|
|
}
|
|
// A row a newer host shapes differently: no invocation, which equality would dereference.
|
|
const { invocation: _invocation, ...malformed } = { ...view, id: 'child-2' }
|
|
const roster = { state: 'monitoring', tasks: [], children: [view, malformed] }
|
|
const batch = (backgroundTasks: unknown, sequence: number) =>
|
|
reduceStructuredAgentSession(state, {
|
|
type: 'event',
|
|
event: {
|
|
type: 'batch',
|
|
sessionId: 'session-a',
|
|
batch: {
|
|
cursor: { epoch: 'epoch-a', sequence },
|
|
items: sequence > 0 ? [item(`item-${sequence}`, sequence)] : [],
|
|
removedItemIds: [],
|
|
submissions: []
|
|
},
|
|
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a wire frame as it arrives, before the client decodes it.
|
|
backgroundTasks: structuredClone(backgroundTasks) as never
|
|
}
|
|
})
|
|
let state = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
|
|
type: 'event',
|
|
event: { type: 'snapshot', sessionId: 'session-a', fence: 1, page: hydrationPage([]) }
|
|
})
|
|
state = batch(roster, 0)
|
|
expect(state.backgroundTasks?.children).toEqual([view])
|
|
const kept = state.backgroundTasks
|
|
// The same roster again, beside a journal change: the roster keeps its identity.
|
|
state = batch(roster, 1)
|
|
expect(state.items.map(({ itemId }) => itemId)).toEqual(['item-1'])
|
|
expect(state.backgroundTasks).toBe(kept)
|
|
})
|
|
|
|
it("keeps a failed read's refusal beside its text until the read recovers", () => {
|
|
const refusal = {
|
|
code: 'agent_session_journal_unreadable',
|
|
details: { reason: 'journalCorrupt' }
|
|
} as const
|
|
const failed = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
|
|
type: 'error',
|
|
message: 'agent_session_journal_unreadable',
|
|
refusal
|
|
})
|
|
expect(failed).toMatchObject({ status: 'error', readRefusal: refusal })
|
|
|
|
expect(reduceStructuredAgentSession(failed, { type: 'loading' }).readRefusal).toBeUndefined()
|
|
const live = reduceStructuredAgentSession(
|
|
reduceStructuredAgentSession(failed, {
|
|
type: 'history-page',
|
|
page: hydrationPage([item('a', 1)])
|
|
}),
|
|
{ type: 'error', message: 'transport died' }
|
|
)
|
|
expect(live.readRefusal).toBeUndefined()
|
|
const recovered = reduceStructuredAgentSession(
|
|
{ ...live, readRefusal: refusal },
|
|
{
|
|
type: 'event',
|
|
event: {
|
|
type: 'batch',
|
|
sessionId: 'session-a',
|
|
batch: {
|
|
cursor: { epoch: 'epoch-a', sequence: 2 },
|
|
items: [item('b', 2)],
|
|
removedItemIds: [],
|
|
submissions: []
|
|
}
|
|
}
|
|
}
|
|
)
|
|
expect(recovered).toMatchObject({ status: 'ready', error: undefined, readRefusal: undefined })
|
|
})
|
|
|
|
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('stops offering older history once a /clear divider is loaded, live or paged in', () => {
|
|
const clearRow = (sequence: number): AgentJournalRenderItem => ({
|
|
...item(`clear-${sequence}`, sequence),
|
|
body: {
|
|
kind: 'status',
|
|
text: 'Context cleared',
|
|
presentation: 'context-cleared',
|
|
contextClear: { operationId: `clear-${sequence}`, afterFence: 1, clearedAt: sequence }
|
|
}
|
|
})
|
|
const restored = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
|
|
type: 'history-page',
|
|
page: { ...hydrationPage([item('old', 10)]), hasOlder: true }
|
|
})
|
|
expect(restored.hasOlder).toBe(true)
|
|
const cleared = reduceStructuredAgentSession(restored, {
|
|
type: 'event',
|
|
event: {
|
|
type: 'batch',
|
|
sessionId: 'session-a',
|
|
batch: {
|
|
cursor: { epoch: 'epoch-a', sequence: 11 },
|
|
items: [clearRow(11)],
|
|
removedItemIds: [],
|
|
submissions: []
|
|
}
|
|
}
|
|
})
|
|
expect(cleared.hasOlder).toBe(false)
|
|
|
|
const afterLongChat = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
|
|
type: 'history-page',
|
|
page: { ...hydrationPage([item('new', 40)]), hasOlder: true }
|
|
})
|
|
const pagedBack = reduceStructuredAgentSession(afterLongChat, {
|
|
type: 'older-page',
|
|
requestedCursor: { epoch: 'epoch-a', sequence: 40 },
|
|
page: { ...hydrationPage([item('old', 10), clearRow(11)]), hasOlder: true }
|
|
})
|
|
expect(pagedBack.items.map(({ itemId }) => itemId)).toEqual(['old', 'clear-11', 'new'])
|
|
expect(pagedBack.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()
|
|
})
|
|
|
|
// An older host omits the roster only when its records hold no running child; a pane resuming
|
|
// from its cursor must not keep the one it held while away (#24227).
|
|
it("drops the held roster on a resumed subscription's first batch that omits it", () => {
|
|
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 batch = {
|
|
type: 'batch' as const,
|
|
sessionId: 'session-a',
|
|
batch: { cursor: monitoring.cursor!, items: [], removedItemIds: [], submissions: [] },
|
|
fence: 1
|
|
}
|
|
|
|
expect(reduceStructuredAgentSession(monitoring, { type: 'event', event: batch })).toBe(
|
|
monitoring
|
|
)
|
|
const resumed = reduceStructuredAgentSession(monitoring, {
|
|
type: 'event',
|
|
event: batch,
|
|
opensSubscription: true
|
|
})
|
|
expect(resumed.backgroundTasks).toBeUndefined()
|
|
expect(resumed.items).toBe(monitoring.items)
|
|
})
|
|
|
|
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)
|
|
})
|
|
|
|
describe("the host's newest turn record", () => {
|
|
const turnRow = (state: 'running' | 'completed'): AgentJournalRenderItem => ({
|
|
itemId: 'turn-record',
|
|
revision: state === 'running' ? 1 : 2,
|
|
sequence: 1,
|
|
observedAt: 1,
|
|
body: { kind: 'turn', turnId: 'turn-1', state }
|
|
})
|
|
const hostTurn = (state: 'running' | 'completed'): AgentSessionLatestTurn => ({
|
|
itemId: 'turn-record',
|
|
observedAt: 1,
|
|
turn: { turnId: 'turn-1', state }
|
|
})
|
|
const opened = () =>
|
|
reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
|
|
type: 'event',
|
|
event: {
|
|
type: 'snapshot',
|
|
sessionId: 'session-a',
|
|
fence: 1,
|
|
page: { ...hydrationPage([turnRow('running')]), latestTurn: hostTurn('running') }
|
|
}
|
|
})
|
|
const batch = (
|
|
state: ReturnType<typeof opened>,
|
|
items: AgentJournalRenderItem[],
|
|
latestTurn?: AgentSessionLatestTurn | null
|
|
) =>
|
|
reduceStructuredAgentSession(state, {
|
|
type: 'event',
|
|
event: {
|
|
type: 'batch',
|
|
sessionId: 'session-a',
|
|
batch: {
|
|
cursor: { epoch: 'epoch-a', sequence: state.cursor!.sequence + items.length },
|
|
items,
|
|
removedItemIds: [],
|
|
submissions: []
|
|
},
|
|
activity: { turnId: 'turn-1', text: 'Reading' },
|
|
...(latestTurn !== undefined ? { latestTurn } : {})
|
|
}
|
|
})
|
|
|
|
it('keeps it across a frame that carries no rows, and takes the next one rows carry', () => {
|
|
const quiet = batch(opened(), [])
|
|
expect(quiet.latestTurn).toEqual(hostTurn('running'))
|
|
const ended = batch(quiet, [turnRow('completed')], hostTurn('completed'))
|
|
expect(ended.latestTurn).toEqual(hostTurn('completed'))
|
|
expect(runningStructuredAgentSessionTurnId(ended)).toBeNull()
|
|
})
|
|
|
|
it('falls back to the loaded rows when rows arrive without it, as from an older host', () => {
|
|
// A cursor resume against a downgraded host: its rows end the turn but restate nothing.
|
|
const downgraded = batch(opened(), [turnRow('completed')])
|
|
expect(downgraded.latestTurn).toBeUndefined()
|
|
expect(runningStructuredAgentSessionTurnId(downgraded)).toBeNull()
|
|
})
|
|
})
|
|
|
|
it("keeps the host's working answer and prompts across a quiet frame, takes a row-less restatement, and drops them for rows that name none", () => {
|
|
const pending: AgentJournalRenderItem = {
|
|
itemId: 'prompt',
|
|
revision: 1,
|
|
sequence: 1,
|
|
observedAt: 1,
|
|
body: {
|
|
kind: 'approval',
|
|
title: 'Run command?',
|
|
detail: null,
|
|
options: [{ id: 'yes', label: 'Allow' }],
|
|
resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
|
|
}
|
|
}
|
|
const frame = (
|
|
items: AgentJournalRenderItem[],
|
|
host: { working?: boolean; actionablePromptIds?: string[] } = {}
|
|
) => ({
|
|
type: 'event' as const,
|
|
event: {
|
|
type: 'batch' as const,
|
|
sessionId: 'session-a',
|
|
batch: {
|
|
cursor: { epoch: 'epoch-a', sequence: 1 },
|
|
items,
|
|
removedItemIds: [],
|
|
submissions: []
|
|
},
|
|
...host
|
|
}
|
|
})
|
|
const opened = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
|
|
type: 'event',
|
|
event: {
|
|
type: 'snapshot',
|
|
sessionId: 'session-a',
|
|
fence: 1,
|
|
page: { ...hydrationPage([pending]), working: true, actionablePromptIds: ['prompt'] }
|
|
}
|
|
})
|
|
const quiet = reduceStructuredAgentSession(opened, frame([]))
|
|
expect(quiet).toMatchObject({ working: true, actionablePromptIds: ['prompt'] })
|
|
// The generation ended with no row written: the host restates both.
|
|
const ended = reduceStructuredAgentSession(
|
|
quiet,
|
|
frame([], { working: false, actionablePromptIds: [] })
|
|
)
|
|
expect(ended).toMatchObject({ working: false, actionablePromptIds: [] })
|
|
// Rows from a downgraded host name neither: back to deriving from rows.
|
|
const downgraded = reduceStructuredAgentSession(ended, frame([{ ...pending, revision: 2 }]))
|
|
expect(downgraded.working).toBeUndefined()
|
|
expect(downgraded.actionablePromptIds).toBeUndefined()
|
|
})
|
|
|
|
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)
|
|
})
|