mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +00:00
* fix(native-chat): alert when a structured chat asks for approval or input mid-turn A structured Claude or Codex chat that stopped mid-turn to ask for approval or a question raised no OS notification, no phone push and no unread dot: the host's attention feed only fired when a turn settled. The host now announces each newly pending prompt once, from the same feed, and owns the phone push for its own structured sessions; desktops keep presentation only. - host feed: a `prompt` edge per pending approval/question, opt-in on the existing stream; a clean settle that only restates an announced prompt is not re-announced to prompt-aware clients; failures always are - host push: an in-process subscriber pushes the host's paired phones with labels from in-memory metadata, and withdraws a prompt's alert once answered - one attention identity for desktop banner, phone push, dedupe and retirement; delivery dedupes on it instead of the per-workspace burst window - agentSession.acknowledgeAttention routes reading a chat to its owning host, which retires what it pushed for that session via its dismissal record * fix(native-chat): keep prompt alerts under their retirement identity * Fix structured attention read and delivery lifecycle * Allow later reads to retry failed attention retirement * Prevent delayed prompt alerts after accepted reads * Keep attention integration tests across process boundaries * Check structured capability before cursorless attention reads * Keep host retirement fixture in the Node typecheck boundary * Keep attention test seams in the mock boundary * Keep host notification formatting usable without Electron * Declare agents in restored-host attention fixture * Group session attention capability declarations * Retire structured alerts on every read and when a remote prompt ends - Mark read and Mark all read on a chat that was never opened, or is hidden, now cover what lit its row: the read boundary is the later of the transcript cursor and the newest edge the tab was handed, captured at the click. The one boundary closes the banners, retires relayed phone alerts and drives the host acknowledgement; an edge after the click stays live. - An alert from a host older than journal cursors has no position, so a pane read retires it again, banner and relayed phone push, as before this change. - A desktop-relayed prompt alert for a remote chat is withdrawn once that host's live status stops reporting attention (answered elsewhere, cancelled, host restart); cached status after loss of contact never settles it. - The attention acknowledgement is agent-session.attention-ack.v1 with a required journal cursor and strict params; the cursorless no-op is gone. - The dismissal store keeps a record whose origin a newer build wrote, dropping only that origin. - Host reconciliation runs on restore and when a prompt leaves the pending set, not on every journal commit. - Remove unused session-wide retirement (retire, retireMatching, retireMobileNotificationsMatching) and test-only store lookups. * Settle relayed prompts only from host-dated status; reads default to view - A remote chat's status and its prompt edges arrive on separate sockets, so a status row queued before the prompt could land after it and withdraw a relayed alert that was still pending. Settlement now needs a live, non-attention row whose host updatedAt (the journal's latest row time, never decreasing) is later than the prompt's host raisedAt. The owed prompt is cleared only once the settle call succeeds, so a failed call retries on the next qualifying row. - An acknowledgement without captured reads now reads as a view. Mark read, Mark all read, the hover-card jump and both dashboard acks pass 'explicit'; Activity's automatic per-turn read stays a view. * Re-judge relayed prompt settlement after each call; dashboard watching is a view - Settlement is one check of the current mirror, run on each status row, after each settle call returns, and when a prompt edge arrives. A row that lands while a call is out is no longer dropped, and an edge that arrives after its own resolution settles at once. A rejected call is retried only once the mirror holds a different row, so a failing call cannot loop. - The Agent Dashboard's card click acknowledges as explicit; watching the open dialog acknowledges as a view, through both the in-window drawer and the pop-out relay, and no longer re-acks the state the click just read. * Keep a reused read owner findable after its holders release it A chat pane memoizes its read owner. When every holder let go in one commit and the same owner was picked up again (React StrictMode's effect re-run in development, or a pane remounted in one commit, as on a tab move), the release dropped it from the registry and nothing put it back. The attention bridge then found no owner, so a view read carried no cursor: the unread dot cleared but the banner and phone alerts stayed. An owner now names its key again whenever it gains an activation or a subscriber and the key is free, never over a different live owner. * Type the ack test's execution host ids * Keep the turn-completion types in main's turn-completion wire module Main moved these types into agent-session-turn-completion-wire.ts while this branch moved them into agent-session-attention-wire.ts. They now live at main's path with this branch's additions (journal cursor, prompt attention, prompt arm); the unused completion key is dropped.
873 lines
36 KiB
TypeScript
873 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 { 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
|
|
)
|
|
}
|
|
|
|
/** 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)
|
|
)
|
|
})
|
|
})
|
|
})
|