mirror of
https://github.com/stablyai/orca.git
synced 2026-09-25 00:02:35 +00:00
* feat(native-chat): report Codex background tasks in the chat strip The background-tasks strip works for Claude only; a structured Codex session shows nothing in it. Feed it from the Codex app-server stream. The strip stands for work that OUTLIVED a turn, which is what the monitoring header, Claude's foreground suppression, and the conversation command gate all already assume. Codex has no `is_backgrounded` flag, so that fact is derived from the turn boundary: a `subAgentActivity` child or a primary-thread `commandExecution` becomes visible once the turn it belongs to completes and it is still unsettled. `turn/completed` only reveals a task here, never settles one — measured on `codex app-server` 0.153.4, a spawn_agent child reported `completed` 95.8s after its parent turn ended. Only a child's own activity kind settles it. Codex exposes no honest stop: `turn/interrupt` on a child ends its turn without emitting a terminal activity item and leaves its shell running. So the state carries a new optional `supportsStopAll: false`, the strip hides a control that could not act, and the blocked-command message asks the user to wait rather than to press a button that does not exist. * refactor(codex): move session teardown out of the structured adapter Merging main crossed the 300-line cap on `codex-structured-session-adapter.ts`: the rewind backend (#19235) and this branch's close-time strip clear both landed in it. The four close paths move verbatim into `codex-structured-session-teardown.ts`, where they funnel through one `settled` helper instead of repeating the notification-retry and background-task cleanup at each call site. No ratchet bump. Also normalize a background task's description once at receipt rather than on every projection; the roster is re-projected on each observed frame. * fix(codex): drop the shell row the journal already settles A `commandExecution` still `inProgress` when its turn ends was reported as a `command` task. But `settleCodexJournalTurn` writes exactly those items to the journal as `state: 'failed'` on `turn/completed` and forgets them, so the strip row would have claimed a shell was still running at the same instant Orca recorded that it was not — two surfaces contradicting each other about the same process. A subagent is the opposite case and stays: the roster pointedly does not sweep at a turn boundary, because children measurably outlive it. That leaves the producer making exactly one claim — these spawn_agent children are still live after their turn — which the durable roster row corroborates. * fix(native-chat): track Codex background execution lifetimes * fix(native-chat): keep running tool groups from claiming completion * Fix runtime catalog and capability expectation * fix(codex): keep a child's name on the command row that outlives it A child agent's commands stay hidden behind its agent row while the child works. Once the child's turn settles with a command still running, that command surfaces as its own row labelled from the raw command string, so 'long_probe' became "/bin/zsh -lc 'ping -c 300 127.0.0.1 > /dev/null'" at the moment that row was the only remaining signal for the work. Qualify a child's command row with the child's label. Resolved on read, so a label registered after the command still lands, and bounded by the existing description cap so admission accounting stays valid. Primary- thread commands are left unqualified: they have no child to name. --------- Co-authored-by: Merge Sim <sim@local>
376 lines
14 KiB
TypeScript
376 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
|
|
}
|
|
|
|
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[]
|
|
}
|
|
|
|
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
|
|
}
|
|
| {
|
|
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
|
|
}
|
|
| {
|
|
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
|
|
}
|
|
| { 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[]
|
|
}
|
|
}
|