mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 09:37:33 +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>
922 lines
36 KiB
TypeScript
922 lines
36 KiB
TypeScript
// 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/structured-agent-session-adapter'
|
||
import { StructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-host'
|
||
import { emptyAgentSessionBatch } from '../../../src/main/native-chat/agent-session-wire/agent-session-empty-batch'
|
||
import { startsWhenPublished } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support'
|
||
import { setStructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-registry'
|
||
import type { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store'
|
||
import { openTestAgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store-test-harness'
|
||
import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire'
|
||
import {
|
||
AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY,
|
||
AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY,
|
||
AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY,
|
||
AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY,
|
||
AGENT_SESSION_REWIND_RUNTIME_CAPABILITY,
|
||
AGENT_SESSION_CONVERSATION_OUTLINE_RUNTIME_CAPABILITY,
|
||
AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY,
|
||
STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY,
|
||
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
|
||
} from '../../../src/shared/protocol-version'
|
||
import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-create-capabilities'
|
||
import { resolveBaselineReleaseRef } from './release-checkout'
|
||
import {
|
||
callBuild,
|
||
expectDeclaredSurfaceExecutes,
|
||
expectItemDetailNegotiation,
|
||
runtimeStub
|
||
} from './structured-agent-session-surface-execution'
|
||
import {
|
||
installableHost,
|
||
readyCodexProviderAdapter,
|
||
structuredHostStub,
|
||
turnItemSkew
|
||
} from './structured-agent-session-host-fixture'
|
||
import {
|
||
attachParams,
|
||
createIntentParams,
|
||
NOW,
|
||
paramsFor,
|
||
resetOperationIds,
|
||
REWIND_METHOD,
|
||
CONVERSATION_OUTLINE_METHOD,
|
||
envelope,
|
||
STATUS_FEED_METHOD,
|
||
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 { openTestJournalHostDatabase } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support'
|
||
import { createStructuredAgentSessionLogger } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-logger'
|
||
import { codexProviderHandle } from '../../../src/shared/agent-session-provider-handle-encoding'
|
||
import { NO_STRUCTURED_AGENTS } from '../../../src/main/native-chat/agent-session-wire/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
|
||
})
|
||
|
||
const CLIENT_CAPABILITY_UPDATE_METHOD = 'runtime.clientCapabilities.update'
|
||
|
||
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.'))
|
||
}
|
||
|
||
describe('cross-version structured agent sessions', () => {
|
||
it(
|
||
'skews current code against a real published release',
|
||
() => {
|
||
expect(baselineRef).toMatch(/^v?\d/)
|
||
expect(baseline.revision).toMatch(/^[0-9a-f]{40}$/)
|
||
expect(baseline.revision).not.toBe(current.revision)
|
||
// The anti-vacuous oracle for the source scan: a scan that found nothing
|
||
// would make every "no structured method here" claim below meaningless.
|
||
expect(baseline.methodNames).toContain('terminal.create')
|
||
expect(current.methodNames).toContain('terminal.create')
|
||
},
|
||
SUITE_TIMEOUT_MS
|
||
)
|
||
|
||
describe('a client that never asked for structured sessions', () => {
|
||
let hostCalls: Record<string, ReturnType<typeof vi.fn>>
|
||
|
||
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', () => {
|
||
it('registers the whole surface on the new build', () => {
|
||
expect(current.capabilities).toContain(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)
|
||
expect(current.capabilities).toContain(AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY)
|
||
// The host admits by client capability, so a client may pick each launch mode itself.
|
||
expect(current.capabilities).toContain(STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY)
|
||
expect(current.methodNames.filter((name) => name.startsWith('agentSession.'))).toHaveLength(
|
||
STRUCTURED_CALLS.length
|
||
)
|
||
})
|
||
|
||
it('can detect the absence during negotiation instead of by calling', () => {
|
||
// The invariant that survives a release cut: each build's advertised list and
|
||
// its registered methods agree. "The old build has neither" is only true
|
||
// until a release ships the surface, and pinning it turns this red on the cut
|
||
// rather than on a change.
|
||
expect(baseline.capabilities.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)).toBe(
|
||
baselineStructuredMethods().length > 0
|
||
)
|
||
// The status feed is additive to a surface that already shipped, so it carries its own
|
||
// capability or a client cannot tell "host too old" from "the call failed" — and it
|
||
// would relay-retry a method_not_found forever instead of degrading once.
|
||
for (const build of [current, baseline]) {
|
||
expect(build.capabilities.includes(AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY)).toBe(
|
||
build.methodNames.includes(STATUS_FEED_METHOD)
|
||
)
|
||
expect(build.capabilities.includes(AGENT_SESSION_REWIND_RUNTIME_CAPABILITY)).toBe(
|
||
build.methodNames.includes(REWIND_METHOD)
|
||
)
|
||
// The message rail probes this before asking, so an older host leaves it on loaded
|
||
// messages instead of answering method_not_found.
|
||
expect(
|
||
build.capabilities.includes(AGENT_SESSION_CONVERSATION_OUTLINE_RUNTIME_CAPABILITY)
|
||
).toBe(build.methodNames.includes(CONVERSATION_OUTLINE_METHOD))
|
||
}
|
||
expectItemDetailNegotiation([current, baseline])
|
||
// Additive surface: bumping the protocol number would strand every paired
|
||
// device on this release rather than degrade one feature.
|
||
expect(current.protocolVersion).toBe(baseline.protocolVersion)
|
||
})
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
})
|
||
|
||
it(
|
||
'executes every method a release-shaped checkout registers',
|
||
async () => {
|
||
// Validate the full manifest through an extracted checkout graph as well as the working-tree module graph.
|
||
const releasedCurrent = await loadAgentSessionWireBuild('HEAD')
|
||
expect(releasedCurrent.capabilities).toContain(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)
|
||
expect(
|
||
releasedCurrent.methodNames.filter((name) => name.startsWith('agentSession.'))
|
||
).toHaveLength(STRUCTURED_CALLS.length)
|
||
// Install this graph’s host slot explicitly so unsupported-host fallback cannot masquerade as capability refusal.
|
||
const hostCalls = structuredHostStub(SESSION, WORKSPACE)
|
||
await releasedCurrent.installStructuredHost(installableHost(hostCalls))
|
||
try {
|
||
await expectDeclaredSurfaceExecutes(
|
||
releasedCurrent,
|
||
hostCalls,
|
||
releasedCurrent.capabilities
|
||
)
|
||
} finally {
|
||
await releasedCurrent.installStructuredHost(null)
|
||
}
|
||
},
|
||
SUITE_TIMEOUT_MS
|
||
)
|
||
})
|
||
|
||
describe('post-auth mobile capability negotiation', () => {
|
||
it('is an additive method that lets the current host record mobile capabilities', async () => {
|
||
const updates: string[][] = []
|
||
|
||
const replies = await callBuild(
|
||
current,
|
||
CLIENT_CAPABILITY_UPDATE_METHOD,
|
||
{ clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] },
|
||
{
|
||
clientKind: 'mobile',
|
||
clientCapabilities: [],
|
||
updateClientCapabilities: (capabilities) => updates.push([...capabilities])
|
||
}
|
||
)
|
||
|
||
expect(current.methodNames).toContain(CLIENT_CAPABILITY_UPDATE_METHOD)
|
||
expect(current.protocolVersion).toBe(baseline.protocolVersion)
|
||
expect(replies).toHaveLength(1)
|
||
expect(replies[0]).toMatchObject({
|
||
ok: true,
|
||
result: {
|
||
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
|
||
}
|
||
})
|
||
expect(updates).toEqual([[STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]])
|
||
})
|
||
|
||
it('gets a normal answer from an old host instead of changing the auth shape', async () => {
|
||
const replies = await callBuild(
|
||
baseline,
|
||
CLIENT_CAPABILITY_UPDATE_METHOD,
|
||
{ clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] },
|
||
{ clientKind: 'mobile', clientCapabilities: [] }
|
||
)
|
||
|
||
expect(replies).toHaveLength(1)
|
||
if (!baseline.methodNames.includes(CLIENT_CAPABILITY_UPDATE_METHOD)) {
|
||
expect(replies[0]).toMatchObject({
|
||
ok: false,
|
||
error: { code: 'method_not_found' }
|
||
})
|
||
}
|
||
})
|
||
})
|
||
|
||
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: 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(async () => {
|
||
setStructuredAgentSessionHost(null)
|
||
await rm(root, { recursive: true, force: true })
|
||
})
|
||
|
||
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: 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(async () => {
|
||
setStructuredAgentSessionHost(null)
|
||
await rm(root, { recursive: true, force: true })
|
||
})
|
||
|
||
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.restoreReadableSessions()
|
||
// Restart restores the session for READING. The chat the client still has open takes its
|
||
// hold, and that is what gives the session a 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)
|
||
)
|
||
})
|
||
|
||
// Create replies at spawn, so the first send carries handshake proof; unproven starts must time out as rejected messages.
|
||
it("answers a released client's send once the startup limit ends a start that never proves itself", 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: { submission: { dispatchState: 'rejected' } } }
|
||
})
|
||
})
|
||
})
|
||
})
|