Files
orca/src/shared/agent-session-wire.ts
T
Brennan Benson 0c96550ee9 fix(native-chat): queued messages carry on in order after any turn, and nothing sends by itself after a restart (#24586)
* fix(native-chat): drop the queue-paused header and Resume button

A Stop, a restart or /clear holds the queued cards. The hold stays; only the
header row naming why, and its Resume button, go. A held card shows no
caption, and its own Steer, or any new message, releases the queue.

* test(native-chat): type the unknown hold reason a newer host may publish

* fix(native-chat): a held card offers Send, not Steer, when no turn runs

Steer vs Send now follows whether a turn is running, not the card's hold,
so a card held after a Stop, a restart or /clear reads Send.

* fix(native-chat): the queue sends past held cards instead of stalling behind them

A card queued after a Stop (or written after a restart or /clear) sent only
once the cards held before it were released; with no header to explain or
release the hold, it sat silently. The next sendable card now skips held
cards; a returned card still blocks what is behind it.

* fix(native-chat): the queue's send of a card is the person's turn, so held cards follow it

After a Stop, a card queued later sent past the held cards, but the queue
recorded that send as Orca's own turn. It never ended the Stop's pause, so
the held cards then waited forever with nothing on the card saying why.

A queued card is always something the person wrote: only the client send
RPC may now create one. The queue's send of it is therefore recorded as the
person's turn, which ends the Stop's pause once the agent takes it, and the
held cards then drain in order.

* fix(native-chat): a queued card carries its author, so the queue's send of it is that author's turn

Main now lets Orca's own sends ask to queue (sendAgentTurn's 'queue' delivery),
so "every card is a person's" no longer holds by refusing host sends. Each card
records who wrote it (the submission's client/host vocabulary) in a new nullable
column; the drain records that origin, so a person's card ends a Stop's pause
and Orca's does not. /clear carries the author. Rows from before the column
read as a person's. The userSend-only admission gate is removed.

* docs(native-chat): state why an unrecorded card author reads as a person's

* fix(native-chat): a restart holds only cards written before it, and an idle held queue offers Resume

A restart's pause held every waiting card, including one a person typed after the restart while
Orca's own continuation ran, and nothing released it except a per-card Send. It now holds only
cards another host process wrote, the same way a Stop holds only cards queued before it.

The composer's primary button becomes Resume (Play) while nothing is typed, no turn runs and the
host holds a card Resume would send, whatever held it (Stop, restart or /clear). It calls the
existing agentSession.queuedMessagesResume, guarded against a second press in flight.

A card nothing holds keeps the run going between a turn's end and the queue's send of it, so its
Steer no longer flips to Send for the frame in between.

* fix(native-chat): the host publishes which pause holds each queued card

The host published one pause for the whole queue, so a client held every waiting card while it was
set. Between a turn's end and the queue's send of a card queued after a Stop or restart, the
composer could flash Resume and the cards Send, and a card queued after a Stop lost its
"Waiting for your answer" caption.

Each published card now carries an optional `heldBy`: the pause holding it, or null, derived from
the same rule the drain reads. A client holds only those cards; against a host without the field
it falls back to the queue-level pause.

* test(native-chat): Resume needs the queue capability and is disabled whenever Send is

* docs(native-chat): describe per-card holds in the queue contract and table comments

* fix(native-chat): the composer goes from Resume straight to Stop, and Resume returns focus

After Resume, the host lifts the hold in one update and sends the first card in a later one. In
between nothing was running, so the composer's button flashed a disabled Send. A card nothing
holds now keeps the queue's run going for the button too: an empty composer shows Stop, disabled
until the turn starts. Not when the host refuses every send (a rewind whose outcome is unknown,
read from its status), where nothing is coming. The same fix removes the Stop, Send, Stop flip
between queued turns.

Resume disables the button, which dropped keyboard focus; focus now returns to the composer.

* fix(native-chat): the host names the card its queue sends next, so the chat stays working across the gap

A turn's end, or a Resume, and the queue's send of the next card commit as two host updates. In
between nothing was running, so the working status, timer, pickers and composer button flipped
for one update. The client guessed the drain from its own copy of the host's gates, which missed a
/clear-replaced source and covered only the button.

The queue publication now carries `nextQueuedMessageId`: the drain's own next card through the
drain's own gate (`nextStructuredQueuedMessage`, which the drain step now calls), null whenever the
host would refuse the send. The client derives one fact, the queue is about to send, and every
working reader follows it; Stop stays disabled until a turn can be stopped. The client-side copy of
the gates and the status-feed rewind read are removed.

* test(native-chat): the queue's next card survives the coalescer, the reducer and a history page

* test(native-chat): build the snapshot that names the next card through its helper

* feat(native-chat): a held queue keeps its header row, and a new message asks before passing it

The queue's header row ("Queue paused because you interrupted", or Orca
restarted, or you cleared the conversation) comes back above the cards it
holds, with Resume; it names the oldest held card's pause, as the host
publishes it per card, and hides over cards held only on their own or
returned. The header's Resume and the composer's share one in-flight guard.

A held card reads Steer again whether or not a turn runs; a card held on its
own or returned keeps Send.

Sending a message while the header shows (Enter or the button) first asks
"Send message?": Clear queue deletes every card and then sends (a failed
delete sends nothing), Send message sends and keeps the cards, which follow
the new turn, and dismissing sends nothing and keeps the draft. Host
commands send as they are.

* fix(native-chat): the paused row goes while your own message is on its way to lift it

After "Send message" over a held queue, the row kept saying "Queue paused…"
until the agent accepted the new turn. The chat now reads that gap from the
outbox: while this composer's direct send is recorded by the host and not yet
accepted, the controller shows no paused row (and so no Resume or
confirmation). A refusal settles the entry and the row comes back, since the
hold did not lift. Orca's own sends never enter this outbox, and the queue's
send of a card goes under a fresh id, so neither hides it. Nothing is stored.

* fix(native-chat): a "Send message?" choice is taken once, and a failed Clear queue is one toast

The closing dialog stays mounted and clickable through its exit animation,
and a double-click or a held Enter lands twice before any re-render, so
Send message (or Clear queue) could send the captured message twice. The
pending send now lives in a ref that the first choice takes; a second one
finds nothing.

Clear queue deletes one card at a time and stops at the first failure, so a
failed press shows one toast instead of one per card.

The dialog keeps its compact width at desktop sizes and the primitive's
narrow-window gutter (`max-w-sm sm:max-w-sm`, as the other compact
confirmations).

* fix(native-chat): Clear queue's message goes out once, and keeps text typed while it waits

After Clear queue, the message waited in the composer while the cards were
deleted one by one. A second Enter in that window sent it again, and text
typed meanwhile was wiped when the chained send was accepted.

From the Clear queue choice until its message has gone out, the composer's
structured send does nothing. The chained send (and Send message's) now
carries the composition it was taken from, and the composer is cleared on
acceptance only if it still holds exactly that, as host commands already do.

Also: the v1 contract comment names `nextQueuedMessageId` and its absent-
means-null fallback, and the own-send check returns at once on an empty
outbox.

* fix(native-chat): the queue carries on after any turn, in order, and a restart sends nothing by itself

- Any accepted turn ends a Stop's or a /clear's pause, whoever sent it (a person,
  Orca's own messages, or the queue), and so does Resume. The card and submission
  author fields that only fed the old person-only rule are gone.
- The queue sends strictly in order: a card never overtakes a held one.
- After a restart nothing sends by itself and no paused row shows: the chat's next
  turn (the carry-on, or the person's own message) runs first, then the cards.
- Resume and "Send message?" are offered only while nothing runs and no prompt waits.

* fix(native-chat): after a restart no queue pause shows, and a card written before the next turn waits for it too

* fix(native-chat): a quit hands no queued card off, and the paused row goes while any turn that will lift it is on its way

- The queue stops handing cards off when the host tears down. A card sent during
  a quit was refused at close, and that refused send withdrew the chat's restart
  offer, so resuming after the relaunch sent nothing.
- The host publishes no pause while a turn sent after it (your message, Steer, or
  Orca's own) waits for the agent; a refusal shows it again. This replaces the
  client's own-send check.
- A card written after a restart is an ordinary card again: it waits while any
  card from before the restart still waits.

* refactor(native-chat): the host's paused-row-while-a-turn-is-on-its-way check in one expression

* refactor(native-chat): the composer's queue Resume rides the structured transport beside the held queue

* fix(native-chat): the "Send message?" choice ends with the pause it asked about; tests follow main's draft props

- The open dialog closes when the queue's pause lifts under it (Orca's mail, another client's
  Resume, any accepted turn): nothing is sent, the draft stays, and the next Enter sends as
  usual. The pending choice records the hold it was asked under; nothing new is stored.
- The composer-field Resume test passes main's dropScopeKey/draftScopeKey.
- The dialog test expects main's rule: only the sent text leaves the composer.

* test(mobile): a host-kept card's test stands in a Stop's pause, as this host publishes no restart pause

Main's #24660 test published queuePause 'restarted', which this branch's wire
type no longer lists, so the mobile tests typecheck ratchet failed.
2026-10-06 18:11:54 -07:00

487 lines
22 KiB
TypeScript

import type {
AgentSessionBackgroundTask,
AgentSessionBackgroundTaskState
} from './agent-session-background-task-wire'
import type { AgentSessionRewindReason, AgentSessionRewindSupport } from './agent-session-rewind'
import type { AgentSessionWireRefusal } from './agent-session-wire-refusals'
import type { AgentChildWorkView } from './agent-status-child-work-view'
import type {
AgentSessionQueuedMessage,
AgentSessionQueuePause,
AgentSessionQueuePublicationFields
} from './agent-session-queued-message-wire'
export * from './agent-session-wire-refusals'
export * from './agent-session-queued-message-wire'
export * from './agent-session-turn-completion-wire'
import type { AgentSessionConversationCommand } from './agent-session-conversation-command'
import type { AgentSessionContextUsage } from './agent-session-context-usage'
// ─── 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,
AgentJournalThreadGoal,
AgentJournalTurnLifecycle
} from './agent-session-journal-types'
import type { AgentTurnOutcome } from './agent-turn-outcome'
import type { AgentSessionHandoffStage, AgentSessionRecord } from './agent-session-record'
import type { AgentProviderSessionMetadata } from './agent-session-resume'
import type { NativeChatSubagentEntry } from './native-chat-types'
import type { StructuredAgentSessionProjectedStatus } from './structured-agent-session-projection'
/** `agentSession.handoffStatus`. Named for the removed terminal handoff; released desktop clients
* still read `owner`. Clients parse the reply as unknown, since older hosts sent more fields. */
export type AgentSessionHandoffStatus = {
owner: 'native' | 'none'
direction: 'to-native' | null
phase: 'idle' | 'switching' | 'failed'
stage: AgentSessionHandoffStage | null
operationId: string | null
error?: { message: string; recoverableOwner: 'none' }
}
export type {
AgentSessionBackgroundTask,
AgentSessionBackgroundTaskRunState,
AgentSessionBackgroundTaskState
} from './agent-session-background-task-wire'
export { agentSessionBackgroundTasksEqual } from './agent-session-background-task-wire'
export type AgentSessionTurnActivity = {
turnId: string
text: string
}
/** The session's newest turn record over the WHOLE journal. A page windows the timeline and a
* turn's record keeps the place it opened at, so a long turn's record falls off the page; this is
* what tells a client a turn is running. Present null: the journal records no turn. Absent: an
* older host, whose clients still read the loaded rows. */
export type AgentSessionLatestTurn = {
/** The record's journal key, which rows of the turn name as their scope. */
itemId: string
/** Host clock at the record's creation, as on its own row; a revision does not move it. */
observedAt: number
turn: AgentJournalTurnLifecycle
}
export const AGENT_SESSION_ID_MAX_LENGTH = 512
/** 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
}
/** A subagent named by a roster row the page does not carry, while its own rows are on it. */
export type AgentSessionSubagentRosterEntry = {
/** The roster row naming it: the first that does. */
itemId: string
sequence: number
sequenceIndex?: number
revision: number
entry: NativeChatSubagentEntry
}
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
/** The host's queued drafts. Absent = no claim (older host); `[]`/null = empty.
* Live subscription state stays authoritative over a stale history answer. */
queuedMessages?: AgentSessionQueuedMessage[] | null
/** The queue's pause, published with the list: present whenever `queuedMessages` is, null
* when the queue sends on its own. */
queuePause?: AgentSessionQueuePause | null
/** Rides with `queuedMessages`: the card the queue sends next as soon as nothing runs, null
* while anything holds the queue. Absent from an older host, read as null. */
nextQueuedMessageId?: string | 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
/** Names the subagents with rows on the page whose roster row is older than it; bounded.
* Absent from older hosts, and when every such roster row is on the page. */
subagentRoster?: AgentSessionSubagentRosterEntry[]
/** As of the page's read; a client applies it only from a page that replaces its state. */
latestTurn?: AgentSessionLatestTurn | null
}
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[]
}
/** Every published frame: the host wall clock (ms epoch, see `AgentSessionHistoryPage`), and what
* rides beside its `queuedMessages`. */
type AgentSessionFrameFields = { hostNow?: number } & AgentSessionQueuePublicationFields
export type AgentSessionSubscribeEvent =
| ({
type: 'snapshot'
sessionId: string
page: AgentSessionHistoryPage
fence: number
backgroundTasks?: AgentSessionBackgroundTaskState | null
/** Whole-list draft publication; omitted when unchanged since the last frame sent. */
queuedMessages?: AgentSessionQueuedMessage[] | 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
} & AgentSessionFrameFields)
| ({
type: 'batch'
sessionId: string
batch: AgentSessionJournalBatch
/** Optional so mixed-version cursors retain the ownership fence. */
fence?: number
backgroundTasks?: AgentSessionBackgroundTaskState | null
/** Whole-list draft publication. On a multi-page catch-up it rides only the
* final page, so a consumed card never vanishes before its bubble arrives. */
queuedMessages?: AgentSessionQueuedMessage[] | 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
/** Rides every batch that carries rows, removals or submissions, so absent there means an
* older host; absent on one that carries none, which changes no turn. */
latestTurn?: AgentSessionLatestTurn | null
} & AgentSessionFrameFields)
| ({
type: 'reset'
sessionId: string
reset: AgentJournalResetReason
page: AgentSessionHistoryPage
fence: number
backgroundTasks?: AgentSessionBackgroundTaskState | null
/** Whole-list draft publication; a reset re-hydrates it with the page. */
queuedMessages?: AgentSessionQueuedMessage[] | null
/** Omitted when unchanged; null clears a previous provider catalog. */
commands?: AgentSessionSlashCommand[] | null
activity?: AgentSessionTurnActivity | null
} & AgentSessionFrameFields)
| { 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
/** With `hostExecutionOwned`: whether that child has proven its start. `starting` is a
* published session whose provider has not yet answered startup; absent on older hosts. */
hostExecutionPhase?: 'starting' | 'ready'
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, else the last one it used. 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
/** The verdict on the latest request: the provider's, or, when it gave none, what the host
* observed of the turn's end. Present only while `status` is `idle`: a running or
* attention-blocked turn has no verdict yet, and a stale one must not ride along. Absent means
* UNKNOWN, never success. Optional for mixed-version hosts; an older client reads an arm it
* does not know as no verdict. The agent-status row publishes it as `mainAgent.outcome`. */
turnOutcome?: AgentTurnOutcome
/** Present only while `status` is 'working' and a person's Stop is still ending that work: while
* the Stop settles, then until the turn it stopped or failed to stop ends. A Stop that settles
* having stopped nothing clears it. Derived by the host, never stored. Absent from older hosts;
* an older client ignores it. */
stopping?: true
/** Live provider-owned background tasks, so session lists can render
* subagent children without holding a journal reader open. Optional for
* mixed-version hosts. Derived from `children` on hosts that publish it. */
backgroundTasks?: AgentSessionBackgroundTask[]
/** The host's running child records for this session, as views: live ones, and a finished one
* whose own work still runs (it reads monitoring); finished children ride the background-task
* channel only. Absent from older hosts; decode with `decodeAgentChildWorkViews`. Usage is
* omitted, and an evidence clock that only ticked does not republish: per-tick freshness rides
* the background-task channel. */
children?: AgentChildWorkView[]
providerSession?: AgentProviderSessionMetadata
/** Host-path directory the session is held to regardless of its workspace's current directory
* (a floating chat's pinned folder). Absent means resolve the workspace id; older hosts omit it. */
launchDirectory?: string
updatedAt: number
/** When the session's own agent entered `status`, dated by its own lifecycle edges and never by
* row activity: `updatedAt` also moves for a subagent's rows. Absent from older hosts, and when
* the journal records no such edge; readers then keep dating the state themselves. */
statusStartedAt?: 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 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 a crash boundary left `unknown` that provider history could not decide. */
unconfirmedClientMessageIds: string[]
/** The host-owned id of the tab showing this chat, when it has one. Absent from older hosts. */
tabId?: string
}
/** The host queued the send as a draft instead of submitting it. Only clients
* that sent `delivery: 'queue-if-active'` — gated on
* `agent-session.queued-messages.v1` — ever receive this arm; `state` other
* than `waiting` appears only on replays of an already-settled draft. */
export type AgentSessionQueuedSendReceipt = {
messageId: string
position: number
state: 'waiting' | 'dispatched' | 'returned' | 'withdrawn'
}
export type AgentSessionSendResult =
| {
clientMessageId: string
submission: AgentJournalSubmission
}
| { clientMessageId: string; queued: AgentSessionQueuedSendReceipt }
/** The submission arm's payload; undefined for a queued answer. For callers that
* never send `delivery` the queued arm cannot arrive, and `undefined` reads as
* delivery-unknown rather than as an error. */
export function agentSessionSendSubmission(
result: AgentSessionSendResult | undefined
): AgentJournalSubmission | undefined {
return result !== undefined && 'submission' in result ? result.submission : undefined
}
export type AgentSessionCancelResult = {
/** The turn the client named, echoed so a late reply can be matched; absent when it named none. */
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[]
/** Provider catalog fact. Absent means the host could not determine support. */
supportsFastMode?: boolean
}
export type AgentSessionFastModeState = 'off' | 'cooldown' | 'on'
export type AgentSessionFastModeSupport = {
supported: boolean
/** Provider-authored or host-normalized reason code; presentation may ignore unknown values. */
reason?: string
}
/**
* The host's model catalog for an agent, answered from its own store and
* never through a session's queue. `unknown` means this host has no listing
* for the key yet — the client keeps its static seed. Additive read-only
* surface: an older host simply lacks the method.
*/
export type AgentSessionModelCatalogResult =
| {
origin: 'unknown'
/** The host is running its first listing for this account; a `waitForListing` read answers
* when it lands. Absent from a host that predates it. */
listingInProgress?: true
}
| {
/** What produced the listing; any age is served, `fetchedAt` carries it. */
origin: 'live-session' | 'probe'
models: AgentSessionModelOption[]
fastModeSupport?: AgentSessionFastModeSupport
fetchedAt: number
}
/** 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
/** Provider-authored row text; absent when the report carried names only. */
description?: string
/** Provider-authored argument sketch, e.g. `<issue-url>`. */
argumentHint?: string
}
/** 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[]
}
/** Longest objective a client may send; matches the provider's own limit. */
export const AGENT_SESSION_THREAD_GOAL_OBJECTIVE_MAX_LENGTH = 4000
/** A client's change to the thread goal. `set` replaces the objective and makes
* it active, which the provider pursues without a separate turn. */
export type AgentSessionThreadGoalChange =
| { kind: 'set'; objective: string }
| { kind: 'status'; status: 'active' | 'paused' }
| { kind: 'clear' }
export type AgentSessionThreadGoalResult = {
change: AgentSessionThreadGoalChange['kind']
}
/** 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[]
/** Present only where this session can change its goal, so a host without
* `agentSession.threadGoal` never offers the controls. `current` is the
* latest goal the whole journal records, for a client whose loaded page
* starts after it. */
threadGoal?: { current: AgentJournalThreadGoal | null }
/** Present only where this session writes context facts to its turn rows.
* `current` is the newest of each part the whole journal records, for a
* client whose loaded page starts after the row that carries it. */
contextUsage?: { current: AgentSessionContextUsage }
models: AgentSessionModelOption[]
/** Session/account/transport support. Absent means unknown, never unsupported. */
fastModeSupport?: AgentSessionFastModeSupport
current: {
model: string
effort?: string
/** Canonical preference for the next turn. Explicit false is meaningful. */
fastMode?: boolean
/** Provider-reported effective routing, distinct from the next-turn preference. */
fastModeState?: AgentSessionFastModeState
/**
* 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[]
}
}