mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
* fix(native-chat): the host owns the send queue; the window keeps no saved outbox The desktop kept each structured chat's unsent messages in localStorage and sent them in order, so one message whose fate was unknown froze every later send, Retry dropped it silently, failed sends could not be discarded, and an offline chat could send hours later. The host already records every message and owns the queue; the window now only sends. - One in-memory sender for composer, launch prompts and messages sent from outside the chat. One send in flight per chat; a transport failure resends the same id for up to 30 s; a refusal that proves nothing was recorded puts the text back in the composer with the reason; a send that went out and was never answered shows an in-doubt line with Send again and holds nothing up. - The host's "unknown" rows get the same in-doubt line and Send again, from the journal, in every window. - A message an older build left in localStorage is never sent: the host's conversation outline decides what goes back to the composer, and the copy is deleted once that is saved. * fix(native-chat): send through the structured chat RPC wrapper, with its timeouts The sender called the runtime RPC directly, skipping the per-method timeouts every other structured chat call gets. Only a remote host's request skips the compatibility check the sender already ran. * fix(native-chat): an unconfirmed send goes back to the composer, with no new row line The common pattern draws nothing extra on a message whose delivery is in doubt once its turn is over, and puts a failed send's text back in the composer with the reason. So a send nobody answered in time, or one a host answers in a way that proves nothing, goes back to the composer worded as unconfirmed, and a host "unknown" row shows nothing extra. Removes the in-doubt phase, Send again, and its strings. A host's made-up row for an id its journal lost now reads as unconfirmed, not as recorded, so that message comes back instead of vanishing. * test(native-chat): pin that nothing resends a send given back as unconfirmed * fix(native-chat): sends survive a tab close, never resend after a Stop, and keep remote images - A normal tab close lets sends on their way settle; what the host never took goes back to the conversation's draft. Only a cancelled launch or a worktree purge drops them. - After any Stop, a send already on its way is never sent again under its id: a doubtful answer, or a resend that was due, hands its text back worded as unconfirmed. - A returned image keeps the SSH connection it lives on; the connection never goes to the host. - An older build's saved message the host recorded and then rejected is left to the host's own row, never handed back. * fix(native-chat): a Stop or a tab close never resends a send already out, and a kept card is the card's A Stop that landed while a same-id resend was being readied (its timer fired, its request not out yet) let that resend go out after the Stop. The sender now tracks whether a request is awaiting its answer: a Stop hands back every send between attempts as unconfirmed, lets one whose request is out settle from its answer, and never issues a request after it. A normal tab close withdraws the same way instead of doing nothing, so nothing more goes out and nothing is dropped. A send the host rejected but kept as a card (keptAsQueuedMessageId) is the card's, from its reply or the journal, and never goes back to the composer. Adds the freeze tests: a send whose fate is unknown holds later sends no longer than its deadline, and one the host can neither confirm nor deny releases the next at once. * fix(native-chat): read a resend's turned-away call or reused id as unproven Ports the send-answer proof contract. A call the host turned away before running it (method_not_found, invalid_argument, unauthorized) proves only that this request wrote nothing, so it reads as never sent on a first attempt only; on a resend an earlier attempt may have landed, and it goes again under the same id. A resent id the host says was already used for other content (messageIdReused) proves nothing either, like an expired or conflicting id. Pins the rest of the contract: any row the host returns is its own, a thrown error is no answer whatever its code, and an older build's refused, held or outlived-Stop copy is handed back once and never sent. * refactor(native-chat): type the structured composer's send with its attachment type Keeps NativeChatStructuredSession.tsx within the file length limit. * refactor(native-chat): drop the kept-card guards the sender never needed A kept send's row is a rejection that was never a Stop's, so the sender already reads it as recorded, from its reply or the journal. The test that pins it stays; the two extra checks only covered a kept row that is also a withdrawal, which the host never writes. * test(native-chat): route launch tests' sends through the client wrapper the sender calls The sender sends through callStructuredAgentSession, but these launch tests replaced that module with a factory that answered nothing (and still named a probe that no longer exists), so every launch prompt resent until its 30 s deadline and the tests timed out. Each factory now hands sends to the runtime RPC mock the tests already answer, and expectations of a local send no longer ask for the remote-only compatibility option. * fix(native-chat): hand a message back without importing the composer's attachment hook The worktree purge reaches the launch prompt, which hands text back, and the attachment hook's imports reach the store. A test that builds the real store behind a mocked one then waited on itself and hung. Handing back now writes images to the draft store directly, as the hook's helper did, and a test pins that each returned image keeps its SSH connection. * fix(native-chat): one send per chat, with no line of sends behind it A chat with a send out took further messages into an in-memory line and sent them one by one. A message typed behind one in doubt then hit its own 30 s deadline and came back as not sent without ever going out. Now, as the common pattern does, a chat takes one send at a time: while it is out, Send is disabled and Enter leaves the text in the box. The sender refuses a second send instead of lining it up, so the 30 s deadline always runs from the send itself. A Stop or a tab close stops the one send: settled from its answer if its request is out, handed back as unconfirmed between attempts, or silently if it never went out. Notes sent from outside the chat while its send is out stay with their sender (not ready); a launch prompt that meets the person's own first message waits in the composer instead of being lost. * fix(native-chat): give a Stop-withdrawn note back to its chat once its notes were cleared Notes sent from outside a chat clear once their message is recorded. A message the host recorded as pending and a Stop then withdrew came back only to its sender, which had already let go of it, so the text was lost. The chat's draft now takes it, as an earlier build's outbox did. * test(native-chat): type the composer-actions probe without a cast * chore(native-chat): drop outbox wording left in comments and an empty locale group * fix(native-chat): pace failed checks, keep the host's reason, and hold the chat for its launch prompt - A send whose checks failed before its request went out (an unreachable or incompatible host, an unreadable history) was tried again at once, about a thousand times a second for 30 s. Attempts are now paced by the attempts made, whether or not their request went out. - A host that refuses every resend by throwing (native chat turned off, a journal that won't open) gave back only "couldn't confirm". The host's reason now comes first, still without claiming not sent. - A launch's prompt now holds the chat's one send from the click, drawn as sending, so a message typed while the chat starts can't overtake it; it goes out once the chat exists, and a cancelled launch frees it. - Notes whose send nobody could confirm say so instead of "did not accept", and notes launched into a new chat let go of their text once that chat's composer holds it. - An open chat keeps drawing a recorded send until its row arrives, so a reply that beats the history no longer makes the message flicker out. * fix(native-chat): hold a chat's sends until it has started, and send a failed chat's message with its restart A message sent to a chat still starting went out at once to a host that had no record of the chat yet, read for a fence it could not get, and came back as not sent. A message sent to a chat whose start failed restarted it but no longer went with the restart. While a chat starts, Send stays off and Enter leaves the text in the box, as with a send already out. A text message sent to a chat whose start failed restarts it and goes as the restart's first message, through the same staged-prompt path a launch prompt takes: it holds the chat's one send slot, drawn as sending, until the chat exists, and comes back to the composer if the restart fails again. Notes wait while a chat starts and ride a failed chat's restart, keeping their text until it is sent. * chore(native-chat): test the queue request, rejection words and card hand-offs; drop an unused clear - Pin which sends ask the host to queue, the moved rejection wording, and that a queue send whose card was handed off and then refused or withdrawn, or whose replay names a withdrawn card, is never handed back. - The send-at-most-once gate now says what the desktop promises after a reload: it never sends the id again, and hands the text back. - The legacy read names when it goes, and drops the notice clear nothing called. * fix(native-chat): keep a sent launch prompt's entry, and give back at once what can't go out - Cleaning up a launch prompt released its send slot even after the prompt had gone out, which deleted the entry the sender keeps once the host records it. A launch prompt recorded as pending and then withdrawn by a Stop was lost from both the chat and the box, and an open chat dropped the new chat's first message before its row arrived. The slot now gives back only a reservation that was never sent. - A send stopped before its request went out by something trying again won't clear (this client and the server can't talk, or the host refused the history read) kept Send off for 30 s and then said Orca couldn't reach the agent. It now comes back at once with its own cause: not sent, since nothing went out, or unconfirmed if an earlier attempt did. Transport errors keep the paced retries. * fix(native-chat): notes keep their own text through a new agent's launch Notes sent to a New agent whose start failed came back to the notes and also sat in the new chat's composer, so sending both delivered the text twice. The notes keep their text until it goes out (they hold it from the click), so the launch now says so and no composer gets a copy, on a failed start or a refused prompt alike. The tests that asserted the copy in the composer pinned the old double ownership and now assert the notes are its only owner. Notes that rode a failed chat's restart and ended unconfirmed now say Orca couldn't confirm them, as notes sent directly do, instead of that the agent did not accept them. * chore(native-chat): the send-once gate says what the desktop keeps across a reload The desktop keeps a send's id in memory only: a send still unsettled at a reload or crash is not resent and not handed back. The coverage notes no longer credit the renderer tests with durable identity across a remount. * fix(native-chat): say once why notes sent to a new agent did not go Since notes keep their own text through a new agent's launch, a prompt the host refused, nobody could confirm, or that found the chat's send taken came back to the notes with nothing said anywhere: the chat shows no notice for text its caller keeps. The notes menu now reports it once, with the toast a send to an existing chat already uses: not accepted, couldn't confirm, or not ready. A start that failed still says so in the new chat instead. * fix(native-chat): retry a history refusal the host says clears, and name it at the deadline A send stopped before its request went out came back at once for any refusal of the history read, including ones the host names as clearing (its journal briefly unavailable, a chat detached while the host quits), so the automatic retry was lost. Only a version mismatch and refusals that won't clear come back at once now; the rest go again on the paced schedule, and if the deadline still finds nothing sent, the words are the host's refusal rather than Orca couldn't reach the agent. * feat(native-chat): a send makes one request, and nothing ever sends it again The desktop resent a message under its own id for up to 30 s when the answer was lost. A send now makes exactly one request, as the common pattern's clients do: - An answer that is lost, dropped or proves nothing hands the text back at once with "couldn't confirm… check the chat". - A failure before the request goes out (the environment check, the history read for the fence, a refused connection) means nothing went out: the text comes back at once with its own reason, or as not sent. - The 30 s cap stays on the one request, so a host that never answers can't hold Send. Nothing ever resends, so nothing can go out after a Stop: a Stop only takes back a send still in its pre-send checks. The resend state goes with it (tries, generation, awaiting, stopped, the resend timer, the send-answers-proof probe, and the first-attempt/resend split in the evidence), and the send-once gate says the desktop never resends. * fix(native-chat): notes keep the chat's line, and a send held behind a rewind says it was not sent - Only a send whose text belongs to the chat's composer clears the chat's line. Notes sent from outside the chat no longer wipe a "couldn't confirm... Check the chat" line that explains text already back in the box. - A send the host turns away behind a rewind it could not confirm went back as "not sent" but said "couldn't confirm what happened. Check the chat". It now gives the rewind's reason and says the message was not sent. - Reliability gate names the single-request test and records a fresh evidence run; the sender test drops its leftover resend mocks and a duplicate Stop test. * fix(native-chat): a send ends even when putting its text back fails If writing the returned text into the chat's draft threw, the send never settled: it stayed "sending", the chat refused every later send until a reload. The send now always ends after a hand-back, the failure is logged, and the chat's line adds "Couldn't save your message." * fix(native-chat): a message typed during /clear stays in the box and follows the chat A /clear moves the chat to a new conversation, and its host refuses any send while it runs. A message sent then went out, came back refused into the old conversation's draft with its line, and vanished when the chat moved on: the box and the line now belong to the new conversation. - A /clear holds the chat's one send slot while it runs: Enter does nothing, Send shows busy and the text stays in the box, as for a send that is out. Notes sent from outside get "busy". - Once it moves the chat, the old conversation keeps taking no send until the view leaves it, and what is left of its draft moves into the new conversation's draft, after anything there: when the composer's /clear settles, and again when the view moves. * fix(native-chat): no "Send message?" or queue clear while the chat's send is out While a send is out (or a /clear runs) the chat takes no message, yet Enter over a held queue still opened "Send message?", and Clear queue deleted every card before its message was refused. Enter now does nothing there and the text stays; Clear queue re-checks and deletes nothing if a send went out after the question opened. * refactor(native-chat): take the /clear draft carry out of this change Moving the old conversation's draft into the one a /clear replaces it with fixes a bug main has too (text left in the box during a /clear stays with the old conversation), so it goes in its own change. Kept here: a /clear holds the chat's send slot while it runs, and the conversation it moved away from takes no send until the view leaves it. * fix(native-chat): a /clear's hold on sends always ends - A view that unmounted while a /clear that moves the chat was out left the old conversation holding its sends until a reload: the late reply kept the hold for a view that was gone. The reply now releases it. - The local call for a conversation command has no deadline of its own, so a /clear that never answers kept Send off for good. The hold now also ends at the command's deadline (195 s, the one the remote call already uses), whichever comes first. * test(native-chat): opening a chat an older build left stuck; hand back its copy in send order Pins, through the real chat hook, sends, legacy recovery and draft store, what opening such a chat does: the queued messages behind a message the host recorded in doubt come back to the composer once, in order, with the "not sent" or "couldn't confirm" wording; the chat is free to send under its read's fence; nothing happens while the chat has no fence here. The legacy reader now hands entries back in the order they were sent (queuedAt), as the older build's reader did, instead of array order. * fix(native-chat): a send this window turned away for a re-paired server comes back as not sent A managed server's update rotates its pairing, and this window's main process then answers the next call itself, before forwarding anything, with runtime_environment_changed. The send read that thrown answer as proving nothing, so the person was told Orca couldn't confirm a message that never left. It is now handed back as not sent, with the reason. Every other thrown answer still reads as unconfirmed once the request may have gone out. * Save a new chat's first message during create * Align opening-message types with the user-message wire * Keep chat publication and cleanup outside store initialization * Complete the create refusal fixture ledger * test(native-chat): prove a created first message waits for the agent's start and is handed over once * refactor(native-chat): keep the host and session hook under the line limit after the main sync * test(native-chat): a chat created with its first message and no pick still teaches its agent's default After the main sync, a created chat's agent starts from the delivery loop rather than the create call. Grok (through the real ACP adapter and model catalog) and Claude (through the real adapter) each still report the model their own config resolved to once the chat reads its options.
876 lines
36 KiB
TypeScript
876 lines
36 KiB
TypeScript
// Cross-version coverage for the structured agent-session surface, paired the same
|
|
// way the terminal wire harness is: current code against a real published release.
|
|
//
|
|
// Three skews matter here, and none can be checked from one build alone — an old
|
|
// client must not receive a journal-backed RPC surface it cannot read, a new client
|
|
// must find an old host's missing surface cleanly, and a client's cursor must survive
|
|
// the host process that minted it.
|
|
//
|
|
// The session-tabs projection may keep a metadata-only row for an incapable mobile client so the
|
|
// chat is not simply absent on the phone. Every `agentSession.*` method and destructive close stays
|
|
// refused, which is what the tests below pin; the row-level behaviour is pinned in
|
|
// src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts.
|
|
|
|
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 { 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,
|
|
runtimeStub
|
|
} from './structured-agent-session-surface-execution'
|
|
import {
|
|
installableHost,
|
|
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'
|
|
|
|
// Why: a cold CI run extracts the baseline checkout before the first pairing.
|
|
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)
|
|
|
|
/**
|
|
* What a client too old to know the structured surface advertises: the baseline's
|
|
* own list, minus the capability. Derived rather than assumed to be the baseline's
|
|
* list as-is — the baseline is the newest release tag, so the day a release ships
|
|
* this capability the list would contain it and the gate below would stop being
|
|
* exercised at all, on a pull request that changed nothing.
|
|
*/
|
|
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))
|
|
}
|
|
// 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 () => {
|
|
// The stand-in for the release that ships this surface: the same source,
|
|
// read the way a release checkout reads it rather than through the test
|
|
// runner's module graph. It is the only place the "registered means
|
|
// usable" claim is executable today, because the baseline registers none
|
|
// of these methods — so it has to carry the whole manifest, not a sample.
|
|
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)
|
|
// Each build owns its own host slot, so the one the suite installed in
|
|
// current source is not this dispatcher's. Installing here is also the
|
|
// anti-vacuous guard: without it every host-backed method answers
|
|
// `structured_agent_session_unsupported`, the same words the capability
|
|
// gate uses, and the run would read as a refusal rather than a miss.
|
|
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 = new StructuredAgentSessionHost({
|
|
agents: NO_STRUCTURED_AGENTS,
|
|
logger: createStructuredAgentSessionLogger(),
|
|
store,
|
|
adapter: {
|
|
acquire: async ({ fence }) => ({
|
|
process: {
|
|
hostId: 'local',
|
|
pid: 4242,
|
|
processStartTimeMs: NOW,
|
|
spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-vault'
|
|
},
|
|
link: {
|
|
linkId: `link-${fence}`,
|
|
handle: codexProviderHandle(THREAD),
|
|
origin: 'created',
|
|
mintedAtFence: fence,
|
|
observedAt: NOW
|
|
}
|
|
}),
|
|
dispatch: async () => ({ state: 'accepted' }),
|
|
cancelTurn: async () => ({ cancelled: true }),
|
|
answerPrompt: async () => undefined,
|
|
setOption: async () => undefined
|
|
},
|
|
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
|
|
)
|
|
)[0]
|
|
expect(oldReply).toMatchObject({ ok: true, result: { sessions: [] } })
|
|
|
|
const capableReply = (
|
|
await callBuild(
|
|
current,
|
|
'aiVault.listSessions',
|
|
{},
|
|
{
|
|
clientKind: 'runtime',
|
|
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
|
|
},
|
|
runtime
|
|
)
|
|
)[0]
|
|
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()
|
|
|
|
// The positive control for the three refusals above: the same client, the
|
|
// same method, a command that is not this thread's resume, and it lands.
|
|
// Without it, a stub whose shape drifted from the runtime would satisfy
|
|
// "was never called" by never being reachable at all.
|
|
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()
|
|
let starts = 0
|
|
|
|
/** Phase 2 owns provider processes; the adapter is the only stub here. */
|
|
function adapter(): StructuredAgentSessionAdapter {
|
|
return {
|
|
// Every real adapter answers this; without it adapterSupportsCreate falls through to
|
|
// `supportsLocation`, which this fake also lacks, so the client-supplied-location gate
|
|
// refused for the fake's silence rather than for the location.
|
|
supportsCreate: () => true,
|
|
acquire: async ({ fence }) => {
|
|
starts += 1
|
|
await startGate
|
|
return {
|
|
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
|
|
}
|
|
}
|
|
|
|
/** Reopens the store from disk and installs a fresh host over the same journal
|
|
* root — what a process restart actually leaves behind. */
|
|
async function bootHost(generation: string): Promise<StructuredAgentSessionHost> {
|
|
store = await openTestAgentSessionRecordStore(root)
|
|
const host = new StructuredAgentSessionHost({
|
|
agents: NO_STRUCTURED_AGENTS,
|
|
logger: createStructuredAgentSessionLogger(),
|
|
store,
|
|
adapter: adapter(),
|
|
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
|
|
})
|
|
setStructuredAgentSessionHost(host)
|
|
return host
|
|
}
|
|
|
|
type HostAnswer = {
|
|
ok: boolean
|
|
fence: number
|
|
cursor: { epoch: string; sequence: number }
|
|
refusal?: { code: string; currentFence?: number }
|
|
}
|
|
|
|
/** Reattaching after a restart: the client's fence died with the previous
|
|
* host, and the refusal that says so is what hands it the live one. */
|
|
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()
|
|
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'])
|
|
const batch = events[0]?.type === 'batch' ? events[0].batch : null
|
|
const rendered = JSON.stringify(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)
|
|
})
|
|
|
|
// 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
|
|
})
|
|
})
|
|
|
|
// A released client answers a send's `pending` as delivered-or-refused; it has no way to show
|
|
// a rejection that arrives after it. So it is answered once the message is handed over, while
|
|
// a client that advertises accepted sends is answered at acceptance, start or no start (W9).
|
|
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)
|
|
)
|
|
})
|
|
})
|
|
})
|