Files
orca/src/shared/agent-session-wire.ts
T
Brennan Benson 0d94c0ba8f Native chat: queue messages sent while the agent works (on by default, Settings toggle, Cmd/Ctrl+Enter to send one now) (#36588)
* Advertise mid-turn queueing as queued-messages.v2

Released clients read the v1 name with an unfinished queue UI; they never
see v2 and keep sending immediately.

(cherry picked from commit f39382e090)

* Let each desktop send choose queue or steer while the agent works

The follow-up choice is now a Queue / Steer setting saved under
nativeChatFollowUpBehavior, Steer by default; the old
nativeChatQueueFollowUps switch, which every install saved as true
while queueing was dark, is retired on load. Every reader goes through
native-chat-follow-up-behavior.

While the agent works, Enter follows the setting and Cmd/Ctrl+Enter
sends the draft the other way; the alternate flips the setting before
the queue's own rules (a waiting command still queues, prompts no one
here can answer still send now) and never applies while Stopping.
Cmd/Ctrl+Shift+Enter steers the head card only and leaves the draft.
An idle agent's card reads Send, and the card menu no longer offers
"Turn off queueing".

(cherry picked from commit 55b48694e7)

* Hold Send on the phone to queue or steer a message mid-turn

While the agent works on a host that queues, holding Send opens a
Queue / Steer sheet; a choice other than the host's setting sends the
alternate, resolved against the chat as it is when it lands, by the
desktop's rule. Release and dismiss send nothing. The phone reads the
host's new follow-up setting (absent reads as Steer), and an idle
agent's card reads Send.

(cherry picked from commit db87cf51ea)

* Read the follow-up default from one place on desktop and phone

(cherry picked from commit 8e1438bfb6)

* Keep phone follow-up choices aligned with live host state

(cherry picked from commit dc9215be3d)

* Update stream and wire fixtures for live follow-up preferences

(cherry picked from commit e4d038cf0b)

* Document minimal runtime test fixture assertions

(cherry picked from commit 254f751ef3)

* Keep mobile preference tests in the mobile compiler project

(cherry picked from commit ae2aa8910e)

* feat(native-chat): queue follow-ups by default with a toggle

(cherry picked from commit 70969d6a43)

* test(mobile): assert shared preference renders on real changes

(cherry picked from commit 3f50de9b2a)

* Remove obsolete follow-up option translations

(cherry picked from commit b87b360cf3)

* Describe Cmd/Ctrl+Enter relative to the Queue follow-ups switch

The Settings description said Cmd/Ctrl+Enter sends into the running
turn, which is wrong while the switch is off. Describe it as doing the
opposite for one message, in every language.

* Show what a mid-turn phone Send does, and offer the other way only when it differs

While the agent works, the phone's Send button now shows Queue or Steer.
Holding it lists that action first with a check and the other second,
each with a one-line description, and appears only when both choices
would actually lead to different sends. A waiting command or prompts
nobody here can answer make a hold behave like a tap.

* Steer the head card once while its shortcut is held

Holding Cmd/Ctrl+Shift+Enter auto-repeats the key, which steered each
card that became the head in turn. Ignore the repeats.

* Show beside Stop what Enter does while the agent works

While a turn runs and the draft has something to send, a send control
now sits beside Stop. Its icon and label say whether Enter queues the
message or steers it into the running turn, and its tooltip names Enter
and, when it would send differently, Cmd/Ctrl+Enter. Clicking it sends
as Enter does. Stop keeps its place and nothing changes while Stopping,
with an empty draft, or before the host says it queues.

The phone and desktop now read what a send does from one shared helper
over the send's own resolver.

* Test the send keys and Queue follow-ups search against real wiring

One desktop test drives Enter and Cmd+Enter through the real composer,
transport and session to the outgoing send and checks its delivery with
queueing on and off. Settings search is checked against the capability
list this build's host advertises.

* Move the composer's send-hold check into its own helper

The pane had grown past the file length limit after the main merge.

* Disable the mid-turn Queue/Steer button whenever Send would be disabled

While an image is still loading, another device holds the input, or a send is blocked for a stated reason, the button beside Stop is now disabled and says why, the same way the idle Send button does. Before, it stayed clickable and a click did nothing.

* Hide the mid-turn Queue/Steer button in goal mode

In goal mode Enter sets the goal, so a button promising to queue or steer the message was wrong. It is now hidden there, as it already is while editing a queued message.

* Fix the Russian queue hint to say the message runs after this turn

It read as queueing until the end of this turn.

* Stub window.api without a type assertion in the Queue follow-ups settings search test

* Pass sendHeld to the composer field in tests and drop an unknown setting from the German Queue test

* Let Cmd-click (Ctrl-click elsewhere) on the mid-turn Queue/Steer button send the other way, like Cmd/Ctrl+Enter

The button shows the other action while the modifier is held, and its tooltip names the click. Only when the other way would send differently; otherwise a modified click is a plain send.

* Merge the duplicate composer-types import and give the composer's round buttons a Button size instead of restyling them

The focused code-quality check rejects importing one module twice, and the design-system check rejects rounded-full on a <Button>, which the new Queue/Steer button copied from Send. Both composer buttons now use a round icon size on the Button primitive and render as before.

* Share one window key listener across every chat's Queue/Steer button, and count Cmd/Ctrl as held only from its own key press

Each mounted chat with a mid-turn draft used to add its own window key listeners, so every keystroke did work per chat, hidden tabs included. One shared listener set now serves them all and wakes a button only when the held state really changes.

Other keys can now only clear the held state, so a dictation paste that leaves the Cmd flag on later key events no longer flips the button with Cmd up. Also tests the Ctrl-click on Windows and Linux.

* Remove the desktop Queue/Steer button shown beside Stop while the agent works

While the agent works, the desktop composer goes back to its usual
controls: Stop, with Enter and the send shortcuts doing what they did.
Enter still follows the queue setting, Cmd/Ctrl+Enter still sends one
message the other way, and Cmd/Ctrl+Shift+Enter still steers the first
queued card.

This also removes what only that button used: its Cmd/Ctrl-click
alternate, the shared Cmd/Ctrl key listener, the extra round Button
size, the send-preview value the desktop session passed to the
composer, and the button's text in every language. The phone is
unchanged.

* Put the composer's send-hold checks back inline in the pane

The separate send-hold helper only existed because the removed mid-turn
Queue/Steer button pushed the pane over the file-length limit. Without the
button the pane fits again, so it goes back to main's inline code.
2026-10-31 02:01:32 -07:00

447 lines
20 KiB
TypeScript

import type { AgentSessionUnavailable } from './agent-session-availability'
import type { AgentSessionFrameFields } from './agent-session-frame-fields'
import type { AgentSessionCurrentWorkFields } from './agent-session-current-work-wire'
import type {
AgentSessionBackgroundTask,
AgentSessionBackgroundTaskState
} from './agent-session-background-task-wire'
import type { AgentSessionRewindReason } 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
} from './agent-session-queued-message-wire'
export type { AgentSessionOptionsResult } from './agent-session-options-result'
export * from './agent-session-wire-refusals'
export * from './agent-session-queued-message-wire'
export * from './agent-session-turn-completion-wire'
// Use plain JSON and optional additions after structured.v1; hosts/clients update independently (docs/reference/remote-wire-compatibility.md).
import type {
AgentJournalCursor,
AgentJournalRenderItem,
AgentJournalResetReason,
AgentJournalResolution,
AgentJournalSubmission,
AgentJournalTurnLifecycle
} from './agent-session-journal-types'
import type { AgentTurnOutcome } from './agent-turn-outcome'
import type { 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'
export type { AgentSessionHandoffStatus } from './agent-session-handoff-status'
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
}
/** Newest turn across the whole journal, including off-page long-running turns. null means no turn; older hosts omit it and clients derive from 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
/** Use 40 rows to match mobile's rendered page without 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/before read reduced history and survive compaction; after catches up from replayable rows. */
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[]
/** Include overlapping submissions so uncertain bubbles retain their dispatch state. */
submissions: AgentJournalSubmission[]
/** Return the next same-direction request cursor; keep the request cursor on empty pages. */
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
/** Publish pause with queuedMessages: null means automatic sending. */
queuePause?: AgentSessionQueuePause | null
/** With queuedMessages, name the next unblocked send; null while held or absent on older hosts. */
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
} & AgentSessionCurrentWorkFields
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
}
/** Publish cursor-qualified current reduced states, not deltas, so duplicate batches converge. */
export type AgentSessionJournalBatch = {
cursor: AgentJournalCursor
items: AgentJournalRenderItem[]
removedItemIds: string[]
submissions: AgentJournalSubmission[]
}
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
/** Publish whole card lists only on the final catch-up page so cards do not vanish before their bubbles arrive. */
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
/** On a catch-up page with more behind it; absent on the last, live, and from older hosts. */
hasNewer?: true
} & AgentSessionCurrentWorkFields &
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 ────────────────────────────────────────────────────────────
/** Host-projected list status avoids client transcript replay; older hosts lack this additive 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'
/** This restart action's progress, derived by the live host and never persisted.
* Cleared when the action returns; absent on older hosts. */
restartResume?: {
phase: 'queued' | 'starting' | 'continued' | 'refused' | 'unconfirmed' | 'skipped'
}
latestPrompt: string
/** Provider model in force for the next turn; absent until the host has read the options. */
model?: string
/** The chat's saved permission mode; a client re-reads options when it or `model` moves. */
permissionMode?: string
runtimePermission?: { fence: number; mode: string | null } | null
/** The chat's saved effort, Fast and speed picks, encoded; another device's pick moves them so a
* client re-reads options. Absent from older hosts. */
effort?: string
fastMode?: string
serviceTier?: 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
/** Latest request verdict only while idle; never carry it on running/blocked states. Absent or unknown arms mean unknown, never success. Published as mainAgent.outcome in agent status. */
turnOutcome?: AgentTurnOutcome
/** Host-derived, unpersisted Stop progress while working until its turn ends; clear a settled no-op Stop. Older hosts omit it and older clients ignore it. */
stopping?: true
/** Optional live provider tasks let lists show children without journal subscriptions; derive from children when published. */
backgroundTasks?: AgentSessionBackgroundTask[]
/** Running/monitoring child views (decodeAgentChildWorkViews); finished children use background tasks only. Omit usage and per-tick freshness here; the background-task channel carries those ticks. Older hosts omit this. */
children?: AgentChildWorkView[]
providerSession?: AgentProviderSessionMetadata
/** Retain the saved conversation name beyond tab closure; absent while unnamed or on older hosts. */
conversationName?: string
/** Why the host could not name this chat, kept in memory only while the chat still has the
* name the failed run would have replaced. Absent from older hosts; older clients ignore it. */
namingError?: AgentSessionNamingError
/** The record's Sleep mark: the user's Sleep stopped this chat and nothing has been sent since.
* Absent from older hosts. */
sleptAt?: number
/** 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
/** Date the lead status by its own lifecycle edge, never updatedAt (child rows change it). Missing/older-host dates leave readers tracking time themselves. */
statusStartedAt?: number
}
/** `failedAt` dates the run, so a repeat failure with the same message still republishes. */
export type AgentSessionNamingError = {
message: string
failedAt: number
/** Names Orca-authored copy; `message` stays English for older clients. */
messageCode?: string
}
export type AgentSessionConversationName = {
sessionId: string
conversationName: string | null
namingError?: AgentSessionNamingError
}
/** Retain projections after child eviction; idle remains idle. Tabs decide list membership, never this feed. */
export type AgentSessionStatusEvent =
| {
type: 'snapshot'
sessions: AgentSessionStatusSummary[]
namingActiveSessionIds?: string[]
conversationNames?: AgentSessionConversationName[]
}
| { type: 'status'; session: AgentSessionStatusSummary }
| { type: 'end' }
// ─── Mutation envelope ──────────────────────────────────────────────────────
/** Same operation ID/fingerprint replays; changed fingerprints conflict instead of repeating effects. */
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 = {
/** Create's committed opening message, even when its row is outside the returned history page. */
firstMessage?: AgentJournalSubmission
sessionId: string
fence: number
page: AgentSessionHistoryPage
/** Submissions a crash boundary left `unknown`. No client reads it; kept for older peers. */
unconfirmedClientMessageIds: string[]
/** The host-owned id of the tab showing this chat, when it has one. Absent from older hosts. */
tabId?: string
}
/** Only queue-if-active callers with queued-messages.v2 receive this draft arm; non-waiting states occur only on settled-draft replays. */
export type AgentSessionQueuedSendReceipt = {
messageId: string
position: number
state: 'waiting' | 'dispatched' | 'returned' | 'withdrawn'
}
export type AgentSessionSendResult =
| { clientMessageId: string; submission: AgentJournalSubmission }
| { clientMessageId: string; queued: AgentSessionQueuedSendReceipt }
/** Undefined for queued replies; callers never sending delivery cannot receive that arm and read undefined as delivery-unknown. */
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 = {
resolvedModel?: string | null
effortsKnown?: false
id: string
label: string
description?: string
isDefault: boolean
defaultEffort?: string
/** Transient display declaration; null is observed unknown, absence supplies no evidence. */
declaredDefaultEffort?: string | null
efforts: AgentSessionOptionChoice[]
/** Provider catalog fact. Absent means the host could not determine support. */
supportsFastMode?: boolean
/** Whether the model runs Approve for me; absent is unknown, which neither offers nor launches it. */
supportsAutoMode?: boolean
/** Provider tier IDs beyond standard; capable clients replace Fast with one speed choice including default. Absence means unknown. */
serviceTiers?: AgentSessionOptionChoice[]
}
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
}
/** Read the host catalog store outside session queues. unknown keeps the static seed; older hosts lack this additive read method. */
export type AgentSessionModelCatalogResult = {
/** The host is running the listing this answer is waiting on (its first for the account, or
* the probe re-checking `unavailable`); a `waitForListing` read answers when it lands. Absent
* from a host that predates it; such a host sends it only with `unknown`. */
listingInProgress?: true
/** Why no chat can start under the account, as the host's probe last found it. Absent is
* unknown, which shows nothing; an older host never sends it. */
unavailable?: AgentSessionUnavailable
} & (
| { origin: 'unknown' }
| {
/** What produced the listing; any age is served, `fetchedAt` carries it. */
origin: 'live-session' | 'probe'
models: AgentSessionModelOption[]
fastModeSupport?: AgentSessionFastModeSupport
fetchedAt: number
/** The listed default is the model a new chat here launches with: the agent's listing names
* its configured model and no workspace config can replace it. Absent from an older host. */
listingNamesConfiguredModel?: boolean
/** The named default holds in every workspace: the agent reads no project config for its
* model, so an answer naming no workspace serves any new chat. Absent from an older host. */
defaultHoldsInEveryWorkspace?: true
/** The models are this workspace's: its own config adds or removes some, so the answer serves
* no other workspace. Absent from an older host. */
modelsDifferByWorkspace?: true
}
)
/** Provider slash menu excludes terminal-only commands; skill names came from loaded skills rather than built-ins. */
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 skill file the provider selects this skill by; two skills may share a name. */
path?: string
}
/** Per-session provider commands; older hosts return method_not_found and clients retain their curated catalog. */
export type AgentSessionCommandsResult = {
commands?: AgentSessionSlashCommand[]
commandsPending?: boolean
}
export type AgentSessionContinuationContext =
| { status: 'current'; capturedText?: string; transcriptPath?: string }
| { status: 'superseded' }