Files
orca/src/shared/agent-session-wire.ts
T
Merge Sim e2b3abe9e4 Keep earlier turns through a Codex rewind and count a mid-turn attach from the real start
Findings from an independent adversarial review of the typed turn record:

- A Codex rewind adopted the provider's item list as the new epoch, and the
  provider never returns the host's own turn rows, so every duration before
  the rewind point vanished. The host's turn rows are now spliced back beside
  the item each followed, and recovery no longer expects the provider to
  prove rows it never owned.
- The epoch row was stamped with the current schema version, so an older host
  latched read-only at row 1 of every new session, defeating the mixed
  version design. It carries no body and stays at v2; a stored-row test now
  reads SQLite directly, because the reader upcasts every row on read.
- A send Codex folds into a running turn shares the opening prompt's provider
  key, and the alias map credited the duration to the later prompt. The
  earliest submission naming a key now wins.
- The live counter anchored on first sight, so a client attaching mid-turn
  counted from zero. Published frames now carry the host's clock, the reducer
  keeps the last sample with its local receipt time, and both clients anchor
  on how long the host says the turn has run.
2026-09-09 22:09:25 -07:00

382 lines
14 KiB
TypeScript

import type { AgentSessionRewindReason, AgentSessionRewindSupport } from './agent-session-rewind'
import type { AgentSessionConversationCommand } from './agent-session-conversation-command'
// ─── Structured agent-session wire contract ─────────────────────────────────
// The shapes `agentSession.*` accepts and publishes. Phase 2 builds provider
// adapters and clients against exactly these types, so everything here must be
// plain JSON. The whole surface is gated by agent-session.structured.v1, which
// no released baseline advertises; after that capability ships, every new field
// must remain optional to old readers (docs/reference/remote-wire-compatibility.md).
import type {
AgentJournalCursor,
AgentJournalRenderItem,
AgentJournalResetReason,
AgentJournalResolution,
AgentJournalSubmission
} from './agent-session-journal-types'
import type {
AgentSessionHandoffStage,
AgentSessionOwnerRuntimeKind,
AgentSessionRecord
} from './agent-session-record'
import type { AgentProviderSessionMetadata } from './agent-session-resume'
import type { StructuredAgentSessionProjectedStatus } from './structured-agent-session-projection'
export type AgentSessionHandoffDirection = 'to-tui' | 'to-native'
export type AgentSessionHandoffMode = 'now' | 'after-turn' | 'stop-turn'
export type AgentSessionHandoffAction = 'start' | 'cancel-queued' | 'retry' | 'recover'
export type AgentSessionHandoffStatus = {
owner: AgentSessionOwnerRuntimeKind | 'none'
direction: AgentSessionHandoffDirection | null
phase: 'idle' | 'queued' | 'switching' | 'waiting-for-exit' | 'failed'
stage: AgentSessionHandoffStage | null
operationId: string | null
hostLabel?: string
terminal?: {
handle: string
tabId: string
paneKey: string
ptyId?: string
}
error?: {
message: string
details?: string
recoverableOwner: AgentSessionOwnerRuntimeKind | 'none'
canRetryProof?: boolean
}
}
export type AgentSessionHandoffRequest = {
envelope: AgentSessionMutationEnvelope
direction: AgentSessionHandoffDirection
mode: AgentSessionHandoffMode
action?: AgentSessionHandoffAction
}
export type AgentSessionHandoffResult = { status: AgentSessionHandoffStatus }
export type AgentSessionBackgroundTask = {
id: string
kind: 'agent' | 'workflow' | 'command' | 'monitor' | 'unknown'
description?: string
}
export type AgentSessionBackgroundTaskState = {
state: 'monitoring'
/** Optional so mixed-version clients can consume state-only hosts. */
tasks?: AgentSessionBackgroundTask[]
/** Optional so clients only send targeted stops to hosts that accept them. */
supportsTaskStop?: boolean
/** Whether an untargeted "stop everything" is available at all. Absent means
* yes: every host that predates this field accepted one, and a client that
* read absence as "no stop" would hide a working control on those hosts.
* A host whose provider exposes no honest stop sends `false`. */
supportsStopAll?: boolean
}
export type AgentSessionTurnActivity = {
turnId: string
text: string
}
/** Backward paging is the client's normal read; 40 matches the page size the
* mobile list renders without a visible fill-in. */
export const AGENT_SESSION_HISTORY_DEFAULT_LIMIT = 40
export const AGENT_SESSION_HISTORY_MAX_LIMIT = 200
export const AGENT_SESSION_HISTORY_DIRECTIONS = ['tail', 'before', 'after'] as const
/** `tail` is the newest page, `before` pages backward, `after` catches a live
* reader up. Only `after` needs replayable rows; the other two read the
* reduced timeline and so survive compaction. */
export type AgentSessionHistoryDirection = (typeof AGENT_SESSION_HISTORY_DIRECTIONS)[number]
export type AgentSessionHistoryRequest = {
sessionId: string
direction: AgentSessionHistoryDirection
/** Required for `before` and `after`; ignored for `tail`. */
cursor?: AgentJournalCursor
limit?: number
}
export type AgentSessionHistoryPage = {
sessionId: string
epoch: string
/** Optional for mixed-version readers; write-capable clients use the
* checkpoint without forcing a second attach or a redundant snapshot. */
fence?: number
direction: AgentSessionHistoryDirection
items: AgentJournalRenderItem[]
/** Populated by `after` reads so a disconnected client can apply tombstones. */
removedItemIds: string[]
/** Submissions overlapping this page, so an unconfirmed bubble renders with
* its dispatch state instead of as a plain message. */
submissions: AgentJournalSubmission[]
/** Page edges. `nextCursor` is what the client sends back for the same
* direction; it equals the request cursor when the page is empty. */
window: {
oldest: AgentJournalCursor | null
newest: AgentJournalCursor | null
nextCursor: AgentJournalCursor
}
/** Current journal head for switching from a bounded page to live subscribe. */
liveCursor?: AgentJournalCursor
hasOlder: boolean
hasNewer: boolean
/** Present on hosts that expose provider-owned background task lifecycle. */
backgroundTasks?: AgentSessionBackgroundTaskState | null
/** Host wall clock (ms epoch) when the page was read, so a client attaching mid-turn
* can anchor a live counter on the real start. Absent from older hosts. */
hostNow?: number
}
export type AgentSessionHistoryResult =
| { ok: true; page: AgentSessionHistoryPage; providerSession?: AgentProviderSessionMetadata }
/** Every reset carries a byte-bounded tail page so recovery cannot exceed
* remote outbound admission or require another call before resubscribing. */
| {
ok: false
reset: AgentJournalResetReason
page: AgentSessionHistoryPage
fence?: number
providerSession?: AgentProviderSessionMetadata
}
/** Cursor-qualified incremental publication. Items and submissions carry their
* CURRENT reduced state rather than a delta, so applying a batch twice
* converges instead of double-appending. */
export type AgentSessionJournalBatch = {
cursor: AgentJournalCursor
items: AgentJournalRenderItem[]
removedItemIds: string[]
submissions: AgentJournalSubmission[]
}
/** Host wall clock (ms epoch) stamped once per published frame; see `AgentSessionHistoryPage`. */
type AgentSessionHostClockField = { hostNow?: number }
export type AgentSessionSubscribeEvent =
| ({
type: 'snapshot'
sessionId: string
page: AgentSessionHistoryPage
fence: number
handoff?: AgentSessionHandoffStatus
backgroundTasks?: AgentSessionBackgroundTaskState | null
/** Omitted when unchanged; null clears a previous provider catalog. */
commands?: AgentSessionSlashCommand[] | null
/** Latest provider-authored turn activity; optional for mixed-version hosts. */
activity?: AgentSessionTurnActivity | null
} & AgentSessionHostClockField)
| ({
type: 'batch'
sessionId: string
batch: AgentSessionJournalBatch
/** Added with handoff state so mixed-version cursors retain the ownership fence. */
fence?: number
handoff?: AgentSessionHandoffStatus
backgroundTasks?: AgentSessionBackgroundTaskState | null
/** Omitted when unchanged; null clears a previous provider catalog. */
commands?: AgentSessionSlashCommand[] | null
/** Additive ephemeral state; it never creates or advances journal rows. */
activity?: AgentSessionTurnActivity | null
} & AgentSessionHostClockField)
| ({
type: 'reset'
sessionId: string
reset: AgentJournalResetReason
page: AgentSessionHistoryPage
fence: number
handoff?: AgentSessionHandoffStatus
backgroundTasks?: AgentSessionBackgroundTaskState | null
/** Omitted when unchanged; null clears a previous provider catalog. */
commands?: AgentSessionSlashCommand[] | null
activity?: AgentSessionTurnActivity | null
} & AgentSessionHostClockField)
| { type: 'end' }
// ─── Status feed ────────────────────────────────────────────────────────────
/** What a session list needs to know about one session. The host projects it
* from the journal so no client has to replay a transcript to learn whether a
* turn is running. Additive surface: an older host has no such method. */
export type AgentSessionStatusSummary = {
rewindBlockedReason?: AgentSessionRewindReason
sessionId: string
workspaceId: string
agent: AgentSessionRecord['provider']
/** Null until the journal holds a persisted user or assistant message. */
status: StructuredAgentSessionProjectedStatus | null
/** Present only while this host has the provider child executing the session. */
hostExecutionOwned?: true
latestPrompt: string
/** Provider model in force for the next turn; absent until the host has read the options. */
model?: string
/** The tool the running turn is inside. Absent unless `status` is 'working'. */
toolName?: string
toolInput?: string
/** Preview of the newest assistant prose, so a settled row says what the agent said. */
lastAssistantMessage?: string
providerSession?: AgentProviderSessionMetadata
updatedAt: number
}
/** A summary outlives its provider child: an evicted idle session is still idle, so the host
* keeps the last projection and never retracts one. Tabs, not this feed, decide what is listed. */
export type AgentSessionStatusEvent =
| { type: 'snapshot'; sessions: AgentSessionStatusSummary[] }
| { type: 'status'; session: AgentSessionStatusSummary }
| { type: 'end' }
// ─── Mutation envelope ──────────────────────────────────────────────────────
/**
* The four fields every mutating call carries. Same operation id and same
* fingerprint replays the recorded outcome; a different fingerprint under one
* operation id is a conflict, never a second effect.
*/
export type AgentSessionMutationEnvelope = {
sessionId: string
clientOperationId: string
/** Null only on a create for a session that does not exist yet. */
expectedRuntimeFence: number | null
/** Client-declared; the host recomputes it and compares. */
payloadFingerprint: string
}
export const AGENT_SESSION_WIRE_REFUSAL_CODES = [
'structured_agent_session_unsupported',
'agent_session_checkpoint_stale',
'agent_session_conflict',
'agent_session_ownership_unknown',
'agent_session_operation_conflict',
'agent_session_operation_expired',
'agent_session_operation_capacity',
'agent_session_operation_invalid',
'agent_session_operation_unknown',
'agent_session_item_revision_stale',
'agent_session_already_resolved',
'agent_session_identity_required',
'agent_session_journal_unreadable',
'execution_owner_reconciling'
] as const
export type AgentSessionWireRefusalCode = (typeof AGENT_SESSION_WIRE_REFUSAL_CODES)[number]
/** For a host path that raises its refusal as the thrown code. Narrowing through this keeps an
* unrelated fault from being reported to the client as a tidy, wrong refusal. */
export function isAgentSessionWireRefusalCode(
value: unknown
): value is AgentSessionWireRefusalCode {
return (
typeof value === 'string' &&
(AGENT_SESSION_WIRE_REFUSAL_CODES as readonly string[]).includes(value)
)
}
export type AgentSessionWireRefusal = {
rewindReason?: AgentSessionRewindReason
code: AgentSessionWireRefusalCode
message: string
/** On a stale fence, so the client can retry without another round trip. */
currentFence?: number
/** On a lost compare-and-set: the winning answer and who gave it. */
resolution?: AgentJournalResolution
/** On a lost compare-and-set: the revision the host actually holds. */
currentRevision?: number
}
export type AgentSessionMutationResult<TValue> =
| {
ok: true
/** True when the recorded outcome was returned instead of a new effect. */
replayed: boolean
fence: number
cursor: AgentJournalCursor
value: TValue
}
| { ok: false; refusal: AgentSessionWireRefusal }
// ─── Per-method payloads ────────────────────────────────────────────────────
export type AgentSessionAttachResult = {
sessionId: string
fence: number
page: AgentSessionHistoryPage
/** Submissions the crash boundary settled as `unknown` while attaching. */
unconfirmedClientMessageIds: string[]
}
export type AgentSessionSendResult = {
clientMessageId: string
submission: AgentJournalSubmission
}
export type AgentSessionCancelResult = {
/** The turn the client named, echoed so a late reply can be matched. */
turnId: string
cancelled: boolean
}
export type AgentSessionPromptResult = {
itemId: string
revision: number
resolution: AgentJournalResolution
}
export type AgentSessionOptionResult = {
key: string
value: string
/** Full effective next-turn values when the provider reconciled related options. */
options?: Record<string, string>
}
export type AgentSessionOptionChoice = {
value: string
label: string
description?: string
}
export type AgentSessionModelOption = {
id: string
label: string
description?: string
isDefault: boolean
defaultEffort?: string
efforts: AgentSessionOptionChoice[]
}
/** One entry of the `/` menu the running provider reports for itself. `skill`
* marks a name the session loaded as a skill rather than a built-in command;
* commands the provider reserves for a terminal UI are already removed. */
export type AgentSessionSlashCommand = {
name: string
kind: 'command' | 'skill'
/** Membership is authoritative, but this provider report did not classify the name. */
kindUnspecified?: true
}
/** The provider's own command surface, read per session. Additive read-only
* surface: a host that predates it answers `method_not_found`, and the client
* keeps rendering its curated catalog. */
export type AgentSessionCommandsResult = {
commands?: AgentSessionSlashCommand[]
}
/** Provider-reported choices and effective next-turn values. Additive read-only
* surface so older hosts can reject it without changing structured v1 writes. */
export type AgentSessionOptionsResult = {
rewind?: AgentSessionRewindSupport
conversationCommands?: readonly AgentSessionConversationCommand[]
models: AgentSessionModelOption[]
current: {
model: string
effort?: string
/**
* Option ids whose value the provider reported back, not merely accepted.
* Optional: a host that predates it sends nothing and the client keeps
* treating the value as unconfirmed, which is what it was before.
*/
confirmed?: readonly string[]
}
}