Files
orca/src/shared/agent-session-wire.ts
T
Brennan Benson 8757e40063 fix(native-chat): keep a structured agent's tool line between tool calls (#22349)
* fix(native-chat): keep a structured agent's tool line between tool calls

A structured session's status named a tool only while the call was still
running, so the sidebar's tool line went blank whenever the agent was
thinking or writing between calls. Terminal agents keep naming the finished
tool until the next one starts, and clear it after a failure. The structured
status projection now does the same: a running call wins, otherwise the
turn's newest root call if it completed.

* fix(native-chat): bound the structured tool line by the running turn, not the user row

A send made while a turn is running writes its user row into the journal
straight away, and the turn keeps going. Stopping the scan at that row
blanked the tool line while a tool was still running. The scan now runs to
the turn record and names a call only when that record is still running, so
a turn that already ended never lends its last tool to a pending follow-up.

This lookup was the running-only lookup's only production caller, so it
replaces that lookup instead of sitting beside it.

* fix(native-chat): keep naming a structured agent's failed tool until the next one

Clearing the tool line after a failed call brought the blank gap back for
much of a turn: Codex marks any nonzero exit as failed, so a search with no
match or a red test run is enough. The failure already shows on the tool's
own row in the transcript. The running turn's newest running call still
wins; otherwise its newest root call is named whatever it settled to.

* fix(native-chat): name a structured Codex edit on the tool line as the chat draws it

Once a Codex edit's changes exist, its apply_patch call becomes a diff row,
which the status lookup skipped, so the row named the command before the edit.
The chat's tool-call block for a journal row now comes from one shared builder,
and the status lookup reads the same definition: a diff is named as Diff with
its path, and counts as settled since it carries no lifecycle.

* docs(native-chat): describe the structured tool field as running-or-latest

The status summary's toolName/toolInput now name the running turn's
latest tool between calls, not only a running one. Update the wire type
and status bridge comments that still said "the running tool".
2026-09-22 22:42:31 -07:00

405 lines
16 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,
AgentJournalTurnOutcome
} from './agent-session-journal-types'
import {
agentSessionScopeKey,
type AgentSessionExecutionLocation,
type AgentSessionHandoffStage,
type AgentSessionOwnerRuntimeKind,
type 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
}
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
}
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, 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
/** 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' }
// ─── Turn completion feed ───────────────────────────────────────────────────
/**
* One root turn reaching a terminal outcome, derived by the EXECUTION HOST at journal commit.
*
* Deliberately not a field on `AgentSessionStatusSummary`: that summary carries no turn identity
* and no outcome, it is re-broadcast on every status change, and adding an outcome would make
* every status consumer a completion consumer. A completion is a rare edge, not a state.
*
* `outcome` is A0's provider verdict and is never inferred — a turn the host only observed ending
* carries no outcome and produces no event at all, because absent means UNKNOWN, not success.
*/
export type AgentSessionTurnCompletion = {
/** Host-and-workspace scope; a bare provider turn id is not globally unique. */
scope: AgentSessionExecutionLocation
sessionId: string
/** Root turn identity from the journal turn record; no second identity is minted. */
turnId: string
outcome: AgentJournalTurnOutcome
/** Execution host's clock at journal commit. */
completedAt: number
}
/**
* LIVE-ONLY: there is no snapshot arm and no replay arm, by decision. A subscriber is told what
* completes while it is subscribed and nothing else; completions that land while it is away are
* dropped rather than queued, so nothing durable can strand. On reconnect the client baselines.
*/
export type AgentSessionTurnCompletionEvent =
| { type: 'completion'; completion: AgentSessionTurnCompletion }
| { type: 'end' }
/** Delivery dedupe address. Unread is idempotent and does not need it; mobile fanout does. */
export function agentSessionTurnCompletionKey(completion: AgentSessionTurnCompletion): string {
return [agentSessionScopeKey(completion.scope), completion.sessionId, completion.turnId].join(
'\u0000'
)
}
// ─── 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[]
/** 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
}
/** 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[]
/** 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[]
}
}