mirror of
https://github.com/stablyai/orca.git
synced 2026-10-04 16:02:08 +00:00
* refactor(agent-session): keep which conversation each chat tab shows in one host table The host now persists one table in the agent-session store, from chat tab id to the conversation that tab currently shows. It replaces both the visible-session list and the per-record surfaceTabId, so there is one answer to "which chats have a tab, and under what id". - /clear moves the tab's entry from the old conversation to its replacement in the same transaction that commits the clear. No record carries a copied tab id, so a tab id names one conversation by construction, and the lookup from a conversation to its tab returns at most one id. - A create that reserved a tab id claims it in its reservation transaction; uniqueness is a key check on the table. A create that fails releases its claim, and hiding a chat frees its id. - Showing a chat with no entry gives it today's id, structured-agent-session-<sid>, unless a cleared chat's tab kept that id; then it gets a fresh one. - A close that does not land puts the tab back under the id it had. - Stores written by older builds are seeded on read from the visible list and, for chats that have one, the record's surfaceTabId. That field is no longer part of the record type, is never written, and is read only by this seed when the file has no table. The visible list is still written, derived from the table, for older builds. Tab snapshots, status keys and worker pane keys are unchanged. Co-Authored-By: Claude <noreply@anthropic.com> * fix(agent-session): take a reserved chat tab id when its tab is published A create that reserved a tab id put it in the tab table at reservation, and table membership is visibility. A create that stopped before its tab was published (a crash, or a failure after the provider started) left an entry that the next launch either restored as a tab nobody asked to see again or kept forever with no way to close it. Reservation now only refuses an id another chat's tab holds; the id is taken when the tab is published, so there is nothing to release on failure. The create reply now reads the tab id after publishing, so an unreserved create answers with the id its tab was given, as it did before the table, and agrees with a replay of the same create. Co-Authored-By: Claude <noreply@anthropic.com> * fix(agent-session): seed a chat cleared before the upgrade under its first tab id A chat cleared on an older build shows a later conversation of its /clear chain in the tab that was opened for the first one, and clients key that tab, its read state and its status by the first conversation's id. Seeding gave it the latest conversation's derived id instead, so the stored id disagreed with the one clients hold and with what a /clear on this build leaves behind. Seeding now follows the source records' committed clears back to the chain's first conversation and gives the chat showing the chain that conversation's id. Those chats seed first, so a cleared conversation reopened from history takes a fresh id when its own is held. A seeded chat is no longer dropped when every candidate id is taken, and the table keeps the visible list's order. Co-Authored-By: Claude <noreply@anthropic.com> * fix(agent-session): give a reopened cleared chat the same tab id at runtime and at seeding A cleared conversation reopened from history got a random tab id at runtime but a deterministic `-reopened` id when the table is seeded from an older store. One rule now serves both, so a table dropped by an older build and seeded again gives that chat the id it already had. --------- Co-authored-by: Claude <noreply@anthropic.com>
436 lines
18 KiB
TypeScript
436 lines
18 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'
|
|
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,
|
|
AgentJournalTurnOutcome
|
|
} from './agent-session-journal-types'
|
|
import {
|
|
agentSessionScopeKey,
|
|
type AgentSessionExecutionLocation,
|
|
type AgentSessionHandoffStage,
|
|
type AgentSessionRecord
|
|
} from './agent-session-record'
|
|
import type { AgentProviderSessionMetadata } from './agent-session-resume'
|
|
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
|
|
}
|
|
|
|
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
|
|
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
|
|
/** Optional so mixed-version cursors retain the ownership fence. */
|
|
fence?: number
|
|
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
|
|
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
|
|
/** 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 provider's verdict on the newest settled root turn. 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; the
|
|
* agent-status row publishes it as `mainAgent.outcome`. */
|
|
turnOutcome?: AgentJournalTurnOutcome
|
|
/** 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
|
|
/** 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' }
|
|
|
|
// ─── Turn completion feed ───────────────────────────────────────────────────
|
|
|
|
/**
|
|
* One root turn reaching a terminal outcome, derived by the EXECUTION HOST at journal commit.
|
|
*
|
|
* This is the EDGE, with turn identity; `AgentSessionStatusSummary.turnOutcome` is the STATE.
|
|
* The summary carries the verdict only while the session is idle, as a fact about the main agent's
|
|
* last turn that a status reader may act on (attention alerts, the `mainAgent.outcome` row field),
|
|
* and never a turn id: a reader that needs to know WHICH turn finished, or to react exactly once
|
|
* per finish, subscribes here. Re-broadcasting the summary on every status change therefore
|
|
* repeats a state, not a completion.
|
|
*
|
|
* `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[]
|
|
/** The host-owned id of the tab showing this chat, when it has one. Absent from older hosts. */
|
|
tabId?: 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
|
|
}
|
|
|
|
/**
|
|
* 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' }
|
|
| {
|
|
/** 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[]
|
|
}
|
|
}
|