mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 00:02:56 +00:00
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.
382 lines
14 KiB
TypeScript
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[]
|
|
}
|
|
}
|