Files
orca/src/shared/agent-session-wire.ts
T
Brennan BensonandMerge Sim 9b83f976f9 feat(native-chat): describe slash commands from the provider's own report (#19928)
* feat(native-chat): describe slash commands from the provider's own report

The Claude session reports a description and argument hint for every
command it can run, but the catalog kept only the name, so the `/` picker
described the handful of commands our curated map covers and left the rest
— `/goal` included — with a blank row.

Carry `description`/`argumentHint` through the catalog and the session wire
(both optional, so mixed-version hosts are unaffected), and let a reported
description win over the curated one, which stays as the fallback for the
name-only report shape. The curated maps are untouched, so structured
dispatch still claims exactly the commands it claimed before.

* feat(native-chat): show the reported argument hint in the slash picker

`argumentHint` was carried to the renderer but nothing read it. Show it
beside the command token — `/goal <objective>` over the description — so a
row says how the command is invoked, not just what it does.

It sits at the row's existing 11px muted tier, subordinate to the
description, and truncates in a min-width-0 flex row; the picker also caps
the hint at 80 characters, so a provider cannot swamp the row.

* fix(native-chat): normalize slash command descriptors consistently

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-10 21:24:22 -07:00

344 lines
13 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'
export * from './agent-session-wire-refusals'
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,
AgentSessionBackgroundTaskRunState,
AgentSessionBackgroundTaskState
} from './agent-session-background-task-wire'
export { agentSessionBackgroundTasksEqual } from './agent-session-background-task-wire'
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
/** Live provider-owned background tasks, so session lists can render
* subagent children without holding a journal reader open. Optional for
* mixed-version hosts. */
backgroundTasks?: AgentSessionBackgroundTask[]
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 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
/** 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[]
}
/** 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[]
}
}