Files
orca/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts
T
Brennan Benson 133bc99e5a Keep waiting chat input in one ordered queue (#39271)
* Prepare queue rows to preserve accepted delivery intent

* Keep waiting native chat input in one ordered queue

* Keep a typed view of admitted queue records

* Keep the removed queue caption in the runtime catalog

* Remove the unused queue barrier caption

* Keep queue writes local and preserve accepted send provenance

* Read the sender from the queued admission request

* Keep continuation fixture clock within its operation window

* Remove deleted queue-barrier test from ratchet baseline

* Keep ratchet baseline change scoped to the deleted test

* Filter historical acceptance reads before loading payloads

* Instrument cached acceptance cursor once per fixture

* Restore original personal provenance after legacy card cleanup

* Complete legacy sender fixture metadata

* Narrow admitted history record types

* Reconstruct accepted personal proof after restoring original admissions

* Use the fixed clock for historical queue fixtures

* Consolidate queue source and type imports

* Preserve admission refusals and failed startup ownership

* Keep manual queue holds independent of later turn endings

* Remove obsolete mobile queue barrier expectations

* Keep journal format initialization independent of its decoder

* Assert returned-row recovery for released send clients

* Preserve Stop assertion across handoff-only publication
2026-11-07 11:35:38 -08:00

793 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Pair real builds: old clients must refuse unreadable structured RPCs, new clients must detect missing methods, and cursors must survive host restart.
// Incapable mobile clients may retain metadata-only tabs; their destructive/agentSession calls stay refused.
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import type { StructuredAgentSessionAdapter } from '../../../src/main/native-chat/agent-session-wire/providers/structured-agent-session-adapter'
import { StructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/host/structured-agent-session-host'
import { emptyAgentSessionBatch } from '../../../src/main/native-chat/agent-session-wire/publication/subscribers/agent-session-empty-batch'
import { startsWhenPublished } from '../../../src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-instant-start.test-support'
import { setStructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/host/structured-agent-session-registry'
import type { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session/records/agent-session-record-store'
import { openTestAgentSessionRecordStore } from '../../../src/main/runtime/agent-session/records/agent-session-record-store-test-harness'
import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session/agent-session-wire'
import {
AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY,
AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY,
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
} from '../../../src/shared/agent-session/agent-session-runtime-capabilities'
import { AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session/lifecycle/agent-session-stop-capabilities'
import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session/agent-session-create-capabilities'
import { resolveBaselineReleaseRef } from './release-checkout'
import {
callBuild,
expectDeclaredSurfaceExecutes,
runtimeStub
} from './structured-agent-session-surface-execution'
import {
installableHost,
readyCodexProviderAdapter,
structuredHostStub,
turnItemSkew
} from './structured-agent-session-host-fixture'
import {
attachParams,
createIntentParams,
NOW,
paramsFor,
resetOperationIds,
envelope,
sendParams,
SESSION,
STRUCTURED_CALLS,
THREAD,
WORKSPACE
} from './structured-agent-session-surface-manifest'
import {
loadAgentSessionWireBuild,
WORKING_TREE,
type AgentSessionWireBuild,
type RpcClientIdentity,
type RpcReply
} from './versioned-agent-session-wire'
import * as journalHostDatabase from '../../../src/main/native-chat/agent-session-journal/store-lifecycle/journal-host-database-test-support'
import { createStructuredAgentSessionLogger } from '../../../src/main/native-chat/agent-session-wire/host/structured-agent-session-logger'
import { codexProviderHandle } from '../../../src/shared/agent-session/lifecycle/agent-session-provider-handle-encoding'
import { NO_STRUCTURED_AGENTS } from '../../../src/main/native-chat/agent-session-wire/providers/testing/structured-agent-session-adapter-router-test-support'
import { describeReleasedStopNoteProjection } from './cross-version-stop-note-scenarios'
const SUITE_TIMEOUT_MS = 180_000
describeReleasedStopNoteProjection({
build: () => current,
callBuild,
runtimeStub
})
let baselineRef: string
let current: AgentSessionWireBuild
let baseline: AgentSessionWireBuild
beforeAll(async () => {
baselineRef = resolveBaselineReleaseRef()
current = await loadAgentSessionWireBuild(WORKING_TREE)
baseline = await loadAgentSessionWireBuild(baselineRef)
}, SUITE_TIMEOUT_MS)
// Remove the capability from the baseline’s own list so the old-client gate remains exercised after releases gain it.
function legacyClientCapabilities(): string[] {
return baseline.capabilities.filter(
(capability) =>
capability !== STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY &&
capability !== AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY
)
}
/** The structured methods the baseline release actually registers, read from it. */
function baselineStructuredMethods(): string[] {
return baseline.methodNames.filter((name) => name.startsWith('agentSession.'))
}
async function releaseHostRoot(root: string): Promise<void> {
setStructuredAgentSessionHost(null)
await journalHostDatabase.closeTestJournalHostDatabase(root)
await rm(root, { recursive: true, force: true })
}
describe('cross-version structured agent sessions', () => {
describe('a client that never asked for structured sessions', () => {
let hostCalls: ReturnType<typeof structuredHostStub>
beforeEach(() => {
resetOperationIds()
hostCalls = structuredHostStub(SESSION, WORKSPACE)
setStructuredAgentSessionHost(installableHost(hostCalls))
})
afterEach(() => {
setStructuredAgentSessionHost(null)
})
it('is told the whole surface does not exist, and reaches no host method', async () => {
// Anti-vacuous: the old client still advertises a real list, so the refusal
// below is the capability gate answering, not an empty negotiation.
expect(legacyClientCapabilities().length).toBeGreaterThan(0)
for (const { method } of STRUCTURED_CALLS) {
const replies = await callBuild(current, method, paramsFor(method), {
clientKind: 'runtime',
clientCapabilities: legacyClientCapabilities()
})
expect(replies, `${method} must answer exactly once`).toHaveLength(1)
expect(replies[0]).toMatchObject({
ok: false,
error: {
message: expect.stringContaining('structured_agent_session_unsupported')
}
})
}
for (const [name, spy] of Object.entries(hostCalls)) {
expect(spy, `${name} ran for a client without the capability`).not.toHaveBeenCalled()
}
})
it('is served the same calls once it advertises the capability', async () => {
await expectDeclaredSurfaceExecutes(current, hostCalls, [
...legacyClientCapabilities(),
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
])
})
})
// Released phones ask createSupport whether a launch should be a chat at all, and the host's
// setting answered; a client that picks the mode itself advertises that it does.
describe('a client that leaves the launch mode to the host', () => {
const SEED = { model: 'seeded-model' }
const settingOff = (): unknown =>
runtimeStub({
getClientSettings: () => ({ experimentalStructuredNativeChat: false }),
structuredAgentSessionLaunchSeedOptions: () => SEED
})
// The release's own list, so the day a release ships the launch-mode capability this still
// describes a client without it.
const released = (...extra: string[]): RpcClientIdentity => ({
clientKind: 'mobile',
clientCapabilities: [
...baseline.capabilities.filter(
(capability) => capability !== STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY
),
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
...extra
]
})
const createSupport = (build: AgentSessionWireBuild, client: RpcClientIdentity) =>
callBuild(
build,
'agentSession.createSupport',
paramsFor('agentSession.createSupport'),
client,
settingOff()
)
beforeEach(async () => {
for (const build of [current, baseline]) {
await build.installStructuredHost(installableHost(structuredHostStub(SESSION, WORKSPACE)))
}
})
afterEach(async () => {
for (const build of [current, baseline]) {
await build.installStructuredHost(null)
}
})
it('is refused by a host whose setting is off, exactly as the release refused it', async () => {
const replies = await createSupport(current, released())
expect(replies).toHaveLength(1)
expect(replies[0]).toMatchObject({
ok: false,
error: {
message: expect.stringContaining('structured_agent_session_unsupported')
}
})
if (baseline.methodNames.includes('agentSession.createSupport')) {
// Older clients read the existing refusal fields and ignore additive error metadata.
expect(replies[0]).toMatchObject({
error: (await createSupport(baseline, released()))[0]?.error
})
}
})
it('is supported once it picks the mode itself, with the host seed as an extra field', async () => {
const replies = await createSupport(
current,
released(STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY)
)
expect(replies).toHaveLength(1)
// `supported` is all an older desktop or phone reads; the seed rides beside it.
expect(replies[0]).toMatchObject({
ok: true,
result: { supported: true, seedOptions: SEED }
})
})
})
describe('a client that predates the turn item', () => {
beforeEach(() => turnItemSkew.install(SESSION, WORKSPACE))
afterEach(() => setStructuredAgentSessionHost(null))
it('is published the status carrier where a capable client gets the turn item', async () => {
const params = paramsFor('agentSession.history')
for (const [clientCapabilities, item] of turnItemSkew.clients(baseline, current)) {
const client = { clientKind: 'runtime' as const, clientCapabilities }
const replies = await callBuild(current, 'agentSession.history', params, client)
expect(replies[0]).toMatchObject({
ok: true,
result: { page: { items: [item] } }
})
}
})
})
describe('a new client against an old host', () => {
// The client sends a Stop naming no turn only to a host advertising this, because older cancel
// params are strict and require the turn. The invariant survives a release cut: each build
// advertises the capability exactly when its dispatcher accepts that cancel.
it('advertises conversation stop exactly where a cancel naming no turn is accepted', async () => {
const named = paramsFor('agentSession.cancel')
if (typeof named !== 'object' || named === null) {
throw new Error('the manifest has no cancel params')
}
const unnamed = Object.fromEntries(Object.entries(named).filter(([key]) => key !== 'turnId'))
for (const build of [current, baseline]) {
if (!build.methodNames.includes('agentSession.cancel')) {
continue
}
// Without a host every call answers `structured_agent_session_unsupported`, which would
// read as the params refusal this looks for.
const hostCalls = structuredHostStub(SESSION, WORKSPACE)
await build.installStructuredHost(installableHost(hostCalls))
try {
const replies = await callBuild(build, 'agentSession.cancel', unnamed, {
clientKind: 'runtime',
clientCapabilities: current.capabilities
})
expect(replies, `${build.label}: a cancel naming no turn`).toHaveLength(1)
expect(replies[0]?.ok, `${build.label}: a cancel naming no turn`).toBe(
build.capabilities.includes(AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY)
)
expect(hostCalls.cancel).toHaveBeenCalledTimes(replies[0]?.ok ? 1 : 0)
} finally {
await build.installStructuredHost(null)
}
}
expect(current.capabilities).toContain(AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY)
})
it('gets a clean answer from the old dispatcher rather than silence', async () => {
const registered = new Set(baselineStructuredMethods())
for (const { method } of STRUCTURED_CALLS) {
const replies = await callBuild(baseline, method, paramsFor(method), {
clientKind: 'runtime',
clientCapabilities: current.capabilities
})
// Silence is the failure mode a new client cannot recover from, whatever
// the old build knows; the refusal code is only asserted for the methods
// that release genuinely does not have.
expect(replies, `${method} must answer exactly once`).toHaveLength(1)
if (!registered.has(method)) {
expect(replies[0], `${method} on the old host`).toMatchObject({
ok: false,
error: { code: 'method_not_found' }
})
} else {
expect(replies[0], `${method} is registered on the old host`).not.toMatchObject({
ok: false,
error: { code: 'method_not_found' }
})
}
}
})
it('takes structured question answers exactly where the host advertises them', async () => {
// A client sends `answers` only on this capability, so the two must never disagree:
// a strict older schema refuses the field and the answer is lost rather than degraded.
const method = 'agentSession.respondToQuestion'
const fields = {
itemId: 'item-1',
expectedRevision: 1,
answers: [
{
questionId: 'q1',
optionIds: [],
other: 'Wait for the capture. '.repeat(80)
}
]
}
const params = {
envelope: envelope({ method, fields, fence: 1 }),
...fields
}
expect(current.capabilities).toContain(AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY)
for (const build of [current, baseline]) {
const advertised = build.capabilities.includes(
AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY
)
if (!build.methodNames.includes(method)) {
expect(advertised, `${build.label} advertises answers without the method`).toBe(false)
continue
}
const hostCalls = structuredHostStub(SESSION, WORKSPACE)
await build.installStructuredHost(installableHost(hostCalls))
try {
const replies = await callBuild(build, method, params, {
clientKind: 'runtime',
clientCapabilities: current.capabilities
})
expect(replies, `${build.label}: ${method} must answer exactly once`).toHaveLength(1)
expect(replies[0]?.ok, `${build.label}: ${JSON.stringify(replies[0])}`).toBe(advertised)
expect(hostCalls.respondToPrompt).toHaveBeenCalledTimes(advertised ? 1 : 0)
} finally {
await build.installStructuredHost(null)
}
}
})
})
describe('an old client against a structured-owned AI Vault row', () => {
let root: string
let store: AgentSessionRecordStore
let runtime: Record<string, unknown>
let createMobileSessionTerminal: ReturnType<typeof vi.fn>
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-cross-version-ai-vault-'))
store = await openTestAgentSessionRecordStore(root)
const host: StructuredAgentSessionHost = new StructuredAgentSessionHost({
agents: NO_STRUCTURED_AGENTS,
logger: createStructuredAgentSessionLogger(),
store,
adapter: readyCodexProviderAdapter({
store,
sessionId: SESSION,
thread: THREAD,
now: NOW,
spawnToken: 'spawn-vault',
host: () => host
}),
journalDatabase: journalHostDatabase.openTestJournalHostDatabase(root),
claimKeyId: 'key-1',
mintSpawnToken: () => 'spawn-vault',
now: () => NOW
})
setStructuredAgentSessionHost(host)
const attached = await host.attach({ callerKey: 'test' }, attachParams(null) as never)
expect(attached.ok).toBe(true)
createMobileSessionTerminal = vi.fn()
runtime = {
...(runtimeStub() as Record<string, unknown>),
listAiVaultSessions: vi.fn(async () => ({
sessions: [
{
id: `local:codex:${THREAD}:/home/dev/.codex/sessions/rollout-${THREAD}.jsonl`,
executionHostId: 'local',
agent: 'codex',
sessionId: THREAD,
title: 'Owned thread',
cwd: '/repo',
branch: null,
model: null,
filePath: `/home/dev/.codex/sessions/rollout-${THREAD}.jsonl`,
codexHome: '/home/dev/.codex',
createdAt: null,
updatedAt: null,
modifiedAt: '2026-08-11T00:00:00.000Z',
messageCount: 1,
totalTokens: 0,
previewMessages: [],
queuedMessageCount: 0,
subagentTranscriptCount: 0,
resumeCommand: `codex resume '${THREAD}'`,
subagent: null
}
],
issues: [],
scannedAt: '2026-08-11T00:00:00.000Z'
})),
prepareAiVaultSessionResume: vi.fn(),
createMobileSessionTerminal
}
})
afterEach(() => releaseHostRoot(root))
it('hides the row from the old client and annotates it for a capable client', async () => {
const [oldReply] = await callBuild(
current,
'aiVault.listSessions',
{},
{
clientKind: 'runtime',
clientCapabilities: legacyClientCapabilities()
},
runtime
)
expect(oldReply).toMatchObject({ ok: true, result: { sessions: [] } })
const [capableReply] = await callBuild(
current,
'aiVault.listSessions',
{},
{
clientKind: 'runtime',
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
},
runtime
)
expect(capableReply).toMatchObject({
ok: true,
result: {
sessions: [
{
structuredSession: { sessionId: SESSION, workspaceId: WORKSPACE }
}
]
}
})
})
it('refuses cached prepare and both legacy launch deliveries before a second writer starts', async () => {
const params = {
agent: 'codex',
filePath: `/home/dev/.codex/sessions/rollout-${THREAD}.jsonl`,
codexHome: '/home/dev/.codex'
}
expect(
(
await callBuild(
current,
'aiVault.prepareSessionResume',
params,
{
clientKind: 'runtime',
clientCapabilities: legacyClientCapabilities()
},
runtime
)
)[0]
).toMatchObject({ ok: false, error: { code: 'agent_session_conflict' } })
expect(
(
await callBuild(
current,
'session.tabs.createTerminal',
{
worktree: `id:${WORKSPACE}`,
command: `codex resume '${THREAD}'`
},
{
clientKind: 'runtime',
clientCapabilities: legacyClientCapabilities()
},
runtime
)
)[0]
).toMatchObject({ ok: false, error: { code: 'agent_session_conflict' } })
expect(
(
await callBuild(
current,
'terminal.send',
{
terminal: 'terminal-1',
text: `codex resume '${THREAD}'`,
enter: true
},
{
clientKind: 'runtime',
clientCapabilities: legacyClientCapabilities()
},
runtime
)
)[0]
).toMatchObject({ ok: false, error: { code: 'agent_session_conflict' } })
expect(createMobileSessionTerminal).not.toHaveBeenCalled()
// Send a non-resume command as positive control so an unreachable stub cannot satisfy every refusal assertion.
expect(
(
await callBuild(
current,
'session.tabs.createTerminal',
{ worktree: `id:${WORKSPACE}`, command: 'echo unrelated' },
{
clientKind: 'runtime',
clientCapabilities: legacyClientCapabilities()
},
runtime
)
)[0]
).toMatchObject({ ok: true })
expect(createMobileSessionTerminal).toHaveBeenCalledTimes(1)
})
})
describe('a cursor across a host restart', () => {
let root: string
let store: AgentSessionRecordStore
let runtime: unknown
/** Holds a provider start open, so a reply's timing can be read against it. */
let startGate: Promise<void> = Promise.resolve()
/** Holds a published child's handshake: it proves its start only once this settles. */
let handshake: Promise<void> = Promise.resolve()
let starts = 0
// Stub only provider acquisition; published adapters prove start after their handshake.
function adapter(host: () => StructuredAgentSessionHost): StructuredAgentSessionAdapter {
return startsWhenPublished(
{
// Supply supportsCreate like real adapters, or missing fake capabilities would refuse location before the tested gate.
supportsCreate: () => true,
open: async () => {
await handshake
return { reportedOptions: { model: '' } }
},
acquire: async ({ fence }) => {
starts += 1
await startGate
return {
acquisitionGeneration: `cursor-${fence}`,
process: {
hostId: 'local',
pid: 4242,
processStartTimeMs: 1_700_000_000_000,
spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a'
},
link: {
linkId: `link-${fence}`,
handle: codexProviderHandle(THREAD),
// A restarted host re-proves the thread it inherited; only the first
// owner of a session may claim to have created it.
origin: store.getRecord(SESSION)?.providerHandleChain.length
? 'resumed'
: 'created',
mintedAtFence: fence,
observedAt: NOW
}
}
},
dispatch: async () => ({
state: 'accepted',
providerIdentity: {
provider: 'codex',
threadId: THREAD,
turnId: 'turn-1',
ordinal: 1
}
}),
cancelTurn: async () => ({ cancelled: true }),
answerPrompt: async () => undefined,
setOption: async () => undefined
},
host
)
}
// Reopen disk state under a fresh host, preserving the journal root as a real process restart does.
async function bootHost(generation: string, startupLimits?: { ceilingMs: number }) {
store = await openTestAgentSessionRecordStore(root)
const host: StructuredAgentSessionHost = new StructuredAgentSessionHost({
agents: NO_STRUCTURED_AGENTS,
logger: createStructuredAgentSessionLogger(),
store,
adapter: adapter(() => host),
journalDatabase: journalHostDatabase.openTestJournalHostDatabase(root),
claimKeyId: 'key-1',
mintSpawnToken: () => `spawn-${generation}`,
// The provider died with the host that spawned it, which is what makes
// the restarted host the legitimate next writer.
probeOwner: async () => ({ outcome: 'pid-absent' }),
now: () => NOW,
...(startupLimits ? { startupLimits } : {})
})
setStructuredAgentSessionHost(host)
return host
}
type HostAnswer = {
ok: boolean
fence: number
cursor: { epoch: string; sequence: number }
refusal?: { code: string; currentFence?: number }
}
// A restarted host’s refusal replaces the dead client fence with its live authority.
async function reattach(staleFence: number): Promise<HostAnswer> {
const refused = await answer('agentSession.ensure', attachParams(staleFence))
expect(refused).toMatchObject({
ok: false,
refusal: { code: 'agent_session_checkpoint_stale' }
})
const currentFence = refused.refusal?.currentFence
expect(currentFence).toBeGreaterThan(staleFence)
const reattached = await answer('agentSession.ensure', attachParams(currentFence ?? 0))
expect(reattached).toMatchObject({ ok: true })
return reattached
}
async function call(
method: string,
params: unknown,
clientCapabilities: readonly string[] = [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
): Promise<RpcReply[]> {
return callBuild(
current,
method,
params,
{
clientKind: 'runtime',
clientCapabilities,
clientId: 'paired-device-1',
connectionId: 'connection-1'
},
runtime
)
}
/** The host's own answer, which carries its refusals inside a successful RPC. */
async function answer(method: string, params: unknown): Promise<HostAnswer> {
const reply = (await call(method, params))[0]
if (!reply?.ok) {
throw new Error(`${method} failed at the wire: ${JSON.stringify(reply?.error ?? reply)}`)
}
return reply.result as HostAnswer
}
beforeEach(async () => {
resetOperationIds()
startGate = Promise.resolve()
handshake = Promise.resolve()
root = await mkdtemp(join(tmpdir(), 'orca-cross-version-agent-session-'))
runtime = runtimeStub()
await bootHost('a')
})
afterEach(() => releaseHostRoot(root))
it('resumes from the cursor the client held, with no snapshot and no replay', async () => {
const created = await answer('agentSession.create', createIntentParams())
expect(created.ok).toBe(true)
const first = await answer('agentSession.send', sendParams('before restart', created.fence))
expect(first.ok).toBe(true)
const held = first.cursor
const restarted = await bootHost('b')
await restarted.seedSessionTabs([SESSION])
await restarted.revealSession(SESSION)
// Demand-open restores reading; the retained chat's hold starts its provider child again.
await answer('agentSession.hold', {
sessionId: SESSION,
holderId: 'surface-1'
})
const resumedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0
expect(resumedFence).toBeGreaterThan(created.fence)
const second = await answer('agentSession.send', sendParams('after restart', resumedFence))
expect(second.ok).toBe(true)
const events = (
await call('agentSession.subscribe', {
sessionId: SESSION,
cursor: held
})
).map((reply) => reply.result as AgentSessionSubscribeEvent)
expect(events.map((event) => event.type)).toEqual(['batch', 'batch'])
const batches = events.filter((event) => event.type === 'batch')
const batch = batches[0]?.batch
const rendered = JSON.stringify(batches.flatMap((event) => event.batch.items))
expect(rendered).toContain('after restart')
// Everything the client already had stays out of the resume.
expect(rendered).not.toContain('before restart')
expect(batch?.cursor.epoch).toBe(held.epoch)
expect(batch?.cursor.sequence).toBeGreaterThan(held.sequence)
expect(batches[1]?.batch).toEqual(emptyAgentSessionBatch(batch?.cursor ?? held))
})
// Every released client still sends the fence it last saw; this host names a write by its
// target and ignores that fence. Only the attach keeps comparing one, which `reattach` pins.
it('delivers a write still fenced to the host generation that died', async () => {
const created = await answer('agentSession.create', createIntentParams())
await bootHost('b')
const reattached = await reattach(created.fence)
expect(reattached.fence).toBeGreaterThan(created.fence)
expect(await answer('agentSession.send', sendParams('stale', created.fence))).toMatchObject({
ok: true,
fence: reattached.fence
})
})
// Legacy clients cannot show rejection after pending; answer after handoff, while accepted-send clients may answer before provider start.
it('holds the send reply of a released client until the handover, and answers a current one at once', async () => {
// Why: the baseline is the newest release, which will itself carry accepted sends.
const released = baseline.capabilities.filter(
(capability) => capability !== AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY
)
const created = await answer('agentSession.create', createIntentParams())
await bootHost('b')
let open = (): void => undefined
startGate = new Promise((resolve) => (open = resolve))
let answered = false
const releasedReply = call(
'agentSession.send',
sendParams('released', created.fence),
released
).finally(() => (answered = true))
// The start that delivers it is under way, and the reply still waits for it.
const before = starts
await vi.waitFor(() => expect(starts).toBeGreaterThan(before))
await new Promise((resolve) => setTimeout(resolve, 20))
expect(answered).toBe(false)
open()
const [reply] = await releasedReply
// Handed over, whatever the provider has said since.
expect(reply).toMatchObject({
ok: true,
result: { value: { submission: { handedOverAt: expect.any(Number) } } }
})
const restarted = await bootHost('c')
startGate = new Promise((resolve) => (open = resolve))
const currentReply = await call('agentSession.send', sendParams('current', created.fence), [
...released,
AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY
])
expect(currentReply[0]).toMatchObject({
ok: true,
result: {
value: {
submission: { dispatchState: 'pending', handoverRecorded: true }
}
}
})
open()
await vi.waitFor(async () =>
expect(
(await restarted.journalSnapshot(SESSION)).submissions.every(
(row) => row.dispatchState !== 'pending' || row.handedOverAt
)
).toBe(true)
)
})
// Unproven starts return the person's message to the queue instead of leaving a legacy RPC waiting.
it("answers a released client's send with its returned row once the startup limit ends", async () => {
const released = baseline.capabilities.filter(
(capability) => capability !== AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY
)
const created = await answer('agentSession.create', createIntentParams())
// The ceiling counts from the start's registration, so a short one ends a start whose
// handshake never answers without waiting out the real ten minutes.
await bootHost('b', { ceilingMs: 200 })
handshake = new Promise(() => undefined)
const params = sendParams('never started', created.fence)
const [reply] = await call('agentSession.send', params, released)
expect(reply).toMatchObject({
ok: true,
result: { value: { queued: { state: 'returned' } } }
})
const [replayed] = await call('agentSession.send', params, released)
expect(replayed).toMatchObject({
ok: true,
result: { replayed: true, value: { queued: { state: 'returned' } } }
})
})
})
})