mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 17:47:40 +00:00
* [comment audit] Align legacy failure-read counts with cached display * [comment audit] Condense shared provider lifecycle and browser recovery comments [skip ci] * [comment audit] Condense shared generation, path and preview comments [skip ci] * [comment audit] Condense host, file and Git contract rationale [skip ci] * [comment audit] Tighten Git lifecycle and provider contract comments [skip ci] * [comment audit] Clarify shared host authority and launch isolation [skip ci] * [comment audit] simplify local access mobile and native chat contracts [skip ci] * [comment audit] simplify native chat presentation and state contracts [skip ci] * [comment audit] simplify native runtime and provider contracts [skip ci] * [comment audit] simplify migration and orchestration contracts [skip ci] * [comment audit] simplify plugin trust and process capture rationale [skip ci] * [comment audit] simplify terminal delivery and remote compatibility rationale [skip ci] * [comment audit] clarify remote recovery and launch wire rationale [skip ci] * [comment audit] shorten shared RPC compatibility and runtime evidence comments [skip ci] * [comment audit] simplify secure storage shell startup and sidebar rationale [skip ci] * [comment audit] clarify sidebar, skill and SSH invariants [skip ci] * [comment audit] condense structured chat delivery and timing rationale [skip ci] * [comment audit] condense terminal replay and telemetry invariants [skip ci] * [comment audit] clarify launch permission and terminal delivery rationale [skip ci] * [comment audit] clarify workspace ownership and layout contracts [skip ci] * [comment audit] wait for fitted and painted SSH terminal links * [comment audit] clarify layout persistence and session ownership [skip ci] * [comment audit] clarify host routing and workspace lifecycle contracts [skip ci] * [comment audit] clarify startup persistence and app lifetime comments [skip ci] * [comment audit] clarify recovery and activity boundaries [skip ci] * [comment audit] condense activity and artifact invariants [skip ci] * [comment audit] condense automation authority contracts [skip ci] * [comment audit] simplify automation scheduling and history contracts [skip ci] * [comment audit] clarify browser chrome and annotation lifecycles [skip ci] * [comment audit] clarify browser routing and retained guest ownership [skip ci] * [comment audit] clarify browser recovery and document ownership comments [skip ci] * [comment audit] restore hosted runners required by native IME fixtures * [comment audit] shorten preview, palette and dashboard invariants [skip ci] * [comment audit] clarify terminal preview, dashboard and retention comments [skip ci] * [comment audit] clarify diff-note recovery and editor lifecycle comments [skip ci] * [comment audit] shorten rich-text and combined-diff ownership comments [skip ci] * [comment audit] shorten autosave, draft recovery and model ownership comments [skip ci] * [comment audit] shorten save-queue, notebook and Markdown constraints [skip ci] * [comment audit] shorten Monaco, PDF and rich Markdown safeguards [skip ci] * [comment audit] clarify rich Markdown source and selection safeguards [skip ci] * [comment audit] clarify editor loading and emulator stream lifetimes [skip ci] * [comment audit] trim emulator and feature tour narration [skip ci] * [comment audit] clarify floating workspace and GitHub cache ownership [skip ci] * [comment audit] trim provider controls and Linear filter explanations [skip ci] * [comment audit] clarify pairing and chat resume lifetimes [skip ci] * [comment audit] clarify chat delivery and transcript lifetimes [skip ci] * [comment audit] clarify session options and host-owned status [skip ci] * [comment audit] clarify draft durability and live-status precedence [skip ci] * [comment audit] Trim chat send and model ownership explanations [skip ci] * [comment audit] Shorten transcript ownership and send recovery comments [skip ci] * [comment audit] Simplify native chat composer and transcript hook comments [skip ci] * [comment audit] Shorten native chat scroll and structured session explanations [skip ci] * [comment audit] Trim workspace picker and onboarding commentary [skip ci] * [comment audit] Simplify pet rendering, review cache and quick open comments [skip ci] * [comment audit] Trim sidebar and session history explanations [skip ci] * [comment audit] Condense session resume and review checks comments [skip ci] * [comment audit] Shorten checks state and file explorer refresh comments [skip ci] * [comment audit] Trim plugin bridge and source-control action rationale [skip ci] * [comment audit] Trim source-control listing and review rationale [skip ci] * [comment audit] Shorten sidebar lifecycle and explorer safeguards [skip ci] * [comment audit] Condense Settings account and appearance rationale [skip ci] * [comment audit] Trim pairing and repository Settings explanations [skip ci] * [comment audit] Condense Settings search SSH and terminal safeguards [skip ci] * [comment audit] Shorten Settings voice and provider ownership comments [skip ci] * [comment audit] Condense Settings draft persistence and host-state rationale [skip ci] * [comment audit] Trim Settings hooks and sidebar setup rationale [skip ci] * [comment audit] Condense sidebar deletion and board-filter safeguards [skip ci] * [comment audit] Condense worktree cards, host ownership and Markdown contracts [skip ci] * [comment audit] Condense sidebar drag, folder and filtering contracts [skip ci] * [comment audit] Condense sidebar parity, sleep and host ownership contracts [skip ci] * [comment audit] Prune focus-read baseline after comment cleanup * [comment audit] Clarify sidebar visibility, board drops and agent evidence [skip ci] * [comment audit] Tighten sidebar listing, drag and reveal contracts [skip ci] * [comment audit] Clarify viewport geometry and skill update safeguards [skip ci] * [comment audit] Tighten skill inventory, usage and host status comments [skip ci] * [comment audit] Clarify usage ownership and resource kill safeguards [skip ci] * [comment audit] Tighten tab selection, rename and search contracts [skip ci] * [comment audit] Tighten tab-strip geometry and drag ownership comments [skip ci] * [comment audit] Tighten Tasks recovery, pagination and mutation comments [skip ci] * [comment audit] Tighten provider loading and terminal recovery comments [skip ci] * [comment audit] Tighten completion, cadence and PTY ownership comments [skip ci] * [comment audit] Clarify terminal restore and parked-pane boundaries [skip ci] * [comment audit] Tighten PTY startup and replay explanations [skip ci] * [comment audit] Clarify PTY identity and delivery ownership [skip ci] * [comment audit] Shorten remote reconnect and input replay rationale [skip ci] * [comment audit] Condense terminal parking clipboard and file link comments [skip ci] * [comment audit] Clarify IME ownership and trim trace history [skip ci] * [comment audit] Shorten pane recovery and parked watcher explanations [skip ci] * [comment audit] Condense snapshot replay and visibility recovery rationale [skip ci] * [comment audit] Shorten terminal parking and keyboard ownership rationale [skip ci] * [comment audit] Trim renderer lifecycle and menu rationale [skip ci] * [comment audit] Condense workspace cleanup ownership and settlement notes [skip ci] * [comment audit] Clarify renderer hook evidence and source ownership [skip ci] * [comment audit] Tighten IPC ownership and reconnect explanations [skip ci] * [comment audit] Trim renderer focus, paste and attention comments [skip ci] * [comment audit] Shorten scroll, locale and agent lifecycle comments [skip ci] * [comment audit] Trim agent launch, resume and ownership commentary [skip ci] * [comment audit] Shorten account, browser and editor comments [skip ci] * [comment audit] Clarify host-mirror waits, previews and input ownership [skip ci] * [comment audit] Condense launch recovery and editor tokenization comments [skip ci] * [comment audit] Condense workspace selection and terminal pane rationale [skip ci] * [comment audit] Condense terminal fitting and rendering recovery comments [skip ci] * [comment audit] Condense terminal input and host routing explanations [skip ci] * [comment audit] Condense ownership and sleeping-session recovery rationale [skip ci] * [comment audit] Condense structured launch and terminal link explanations [skip ci] * [comment audit] Condense terminal ownership and workspace file routing rationale [skip ci] * [comment audit] Condense session persistence and workspace activation rationale [skip ci] * [comment audit] Condense workspace routing and browser ownership rationale [skip ci] * [comment audit] Condense mirror recovery and layout authority rationale [skip ci] * [comment audit] Condense runtime transport and file safety rationale [skip ci] * [comment audit] Condense runtime graph and structured session rationale [skip ci] * [comment audit] Condense browser creation and remote tab recovery rationale [skip ci] * [comment audit] Condense snapshot authority and publisher recovery rationale [skip ci] * [comment audit] Condense frame, startup and provider cache rationale [skip ci] * [comment audit] Condense host twin and agent lifecycle rationale [skip ci] * [comment audit] Condense browser authority and editor cleanup rationale [skip ci] * [comment audit] Condense editor buffer identity and review cache rationale [skip ci] * [comment audit] Condense provider lifetime and repository ownership rationale [skip ci] * [comment audit] Condense reconnect, hydration and terminal retirement rationale [skip ci] * [comment audit] Condense recovery, reconciliation and persistence rationale [skip ci] * [comment audit] Condense cleanup ownership and listing admission rationale [skip ci] * [comment audit] Condense teardown ordering and terminal ownership rationale [skip ci] * [comment audit] Condense recovery ledger and web host-boundary rationale [skip ci] * [comment audit] Condense web reconnect and credential lifecycle rationale [skip ci] * [comment audit] Condense cloud rollout and assignment safety rationale [skip ci] * [comment audit] Condense relay session and database recovery rationale [skip ci] * [comment audit] Condense relay placement and protocol compatibility rationale [skip ci] * [comment audit] Condense build and packaging constraints [skip ci] * [comment audit] Condense CI gates and validation isolation rationale [skip ci] * [comment audit] Condense catalog identity and browser validation rationale [skip ci] * [comment audit] Condense mobile browser and frame-budget rationale [skip ci] * [comment audit] Shorten mobile route and browser test explanations [skip ci] * [comment audit] Condense editor and session seam test rationale [skip ci] * [comment audit] Trim session and terminal closure history [skip ci] * [comment audit] Shorten terminal browser and census explanations [skip ci] * [comment audit] Preserve root-only E2E diagnostic baseline * [comment audit] Condense mobile seam and HTML preview test rationale [skip ci] * [comment audit] Condense preview instrumentation and render harness rationale [skip ci] * [comment audit] Condense bundle contracts and native-build constraints [skip ci] * [comment audit] Shorten native packaging and CI routing rationale [skip ci] * [comment audit] Condense release tooling and benchmark constraints [skip ci] * [comment audit] Condense packaging and platform gate rationale [skip ci] * [comment audit] Shorten mobile route and history rationale [skip ci] * [comment audit] Condense mobile browser and component rationale [skip ci] * [comment audit] Shorten drawer and workspace-form rationale [skip ci] * [comment audit] Trim mobile review and editor-document explanations [skip ci] * [comment audit] Condense editor lifecycle and Markdown explanations [skip ci] * [comment audit] Trim mobile file, diagnostics and dictation commentary [skip ci] * [comment audit] Simplify mobile host-list and workspace-view explanations [skip ci] * [comment audit] Clarify mobile shell lifetime and bridge delivery constraints [skip ci] * [comment audit] Condense bridge stream, native request and audio rationale [skip ci] * [comment audit] Reduce bridge schema and page-client commentary [skip ci] * [comment audit] Tighten mobile page bridge contracts [skip ci] * [comment audit] Clarify mobile cache and session constraints [skip ci] * [comment audit] Condense mobile route and lifecycle rationale [skip ci] * [comment audit] Tighten mobile shell hook timing rationale [skip ci] * [comment audit] Clarify route handoff and notification compatibility [skip ci] * [comment audit] Clarify push registration and dictation cleanup constraints [skip ci] * [comment audit] Condense keyboard geometry and native media ownership rationale [skip ci] * [comment audit] Tighten mobile chat scroll and composer concurrency rationale [skip ci] * [comment audit] Condense mobile reply compatibility and upload ownership rationale [skip ci] * [comment audit] Condense optimistic echo and scroll ownership rationale [skip ci] * [comment audit] Tighten stream replay and queued session ownership rationale [skip ci] * [comment audit] Condense mobile composer and reply-schema constraints [skip ci] * [comment audit] Clarify mobile transcript recovery and session ownership [skip ci] * [comment audit] Clarify mobile queue and source-control compatibility [skip ci] * [comment audit] Condense storage and task launch constraints [skip ci] * [comment audit] Clarify task schemas and terminal document ownership [skip ci] * [comment audit] Tighten terminal input and viewport rationale [skip ci] * [comment audit] Shorten terminal and RPC recorder contracts [skip ci] * [comment audit] Condense recorder engine and connection contracts [skip ci] * [comment audit] Shorten host credential and relay recovery comments [skip ci] * [comment audit] Tighten pairing recovery and bundle transport rationale [skip ci] * [comment audit] Clarify RPC delivery and workspace catalog boundaries [skip ci] * [comment audit] Condense catalog and relay entry contracts [skip ci] * [comment audit] Shorten shutdown and SSH relay delivery contracts [skip ci] * [comment audit] Condense WSL relay and E2E regression rationale [skip ci] * [comment audit] Shorten cross-version harness and renderer fixture explanations [skip ci] * [comment audit] Condense paired-host and E2E lifecycle rationale [skip ci] * [comment audit] Shorten layout oracle and regression explanations [skip ci] * [comment audit] Condense paired recovery and ownership regression contracts [skip ci] * [comment audit] Shorten restart, sidebar and SSH lifecycle contracts [skip ci] * [comment audit] Condense terminal rendering and IME regression rationale [skip ci] * [comment audit] Shorten terminal recovery and benchmark contracts [skip ci] * [comment audit] Condense tooling and Windows lifecycle proof rationale [skip ci] * [comment audit] Condense workflow routing and release rationale [skip ci] * [comment audit] Condense mobile security and installer contracts [skip ci] * [comment audit] Shorten native rationale and stylesheet workarounds [skip ci] * [comment audit] Shorten Windows visibility probe rationale [skip ci] * [comment audit] Condense embedded release and signing rationale [skip ci] * [comment audit] Retain current main test behavior in final reconciliation [skip ci] * [comment audit] Complete final comment review across current source inventory * [comment audit] Handle consolidated audit reports before final CI * [comment audit] Preserve credential storage census detail [skip ci] * [comment audit] Scale broad changed E2E selections with verified timing shards * [comment audit] Align E2E assertions with browser close and settled session metadata * [comment audit] Match existing E2Es to current resource and windowing contracts * [comment audit] Observe the shared server outage notice in reconnect coverage * [comment audit] Make existing renderer probes measure settled tab and PTY work * [comment audit] Align palette E2E selector with task search * [comment audit] Refresh remaining palette search selectors * [comment audit] Keep selector correction within selected audit specs * [comment audit] Await emoji fixture settings inside renderer evaluation * [comment audit] Cancel fixture fit retries between terminal tests --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
781 lines
25 KiB
TypeScript
781 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 lost Stop button changes behavior and must invalidate equality.
|
||
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()
|
||
})
|
||
|
||
// Old hosts omit empty rosters; a cursor resume must clear the detached client’s stale roster (#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)
|
||
})
|