diff options
Diffstat (limited to 'packages/wire/src')
| -rw-r--r-- | packages/wire/src/index.test.ts | 126 | ||||
| -rw-r--r-- | packages/wire/src/index.ts | 652 |
2 files changed, 433 insertions, 345 deletions
diff --git a/packages/wire/src/index.test.ts b/packages/wire/src/index.test.ts index cd297b7..2886ccb 100644 --- a/packages/wire/src/index.test.ts +++ b/packages/wire/src/index.test.ts @@ -8,52 +8,86 @@ */ import { describe, expect, it } from "vitest"; -import type { Computer, ComputerEntry, Workspace } from "./index.js"; +import type { Chunk, Computer, ComputerEntry, ImageChunk, ImageInput, Workspace } from "./index.js"; describe("@dispatch/wire — Computer / Workspace shapes", () => { - it("a Computer literal satisfies the Computer type", () => { - const c: Computer = { - alias: "myserver", - hostName: "myserver.example.com", - port: 22, - user: "deploy", - identityFile: null, - knownHost: true, - }; - expect(c.alias).toBe("myserver"); - expect(c.port).toBe(22); - expect(c.identityFile).toBeNull(); - expect(c.knownHost).toBe(true); - }); - - it("ComputerEntry extends Computer and carries usageCount", () => { - const entry: ComputerEntry = { - alias: "buildbox", - hostName: "buildbox", - port: 2222, - user: "root", - identityFile: "/home/u/.ssh/id_ed25519", - knownHost: false, - usageCount: 3, - }; - // Compile-time proof that ComputerEntry is assignable to Computer. - const asComputer: Computer = entry; - expect(asComputer.alias).toBe("buildbox"); - expect(entry.usageCount).toBe(3); - }); - - it("a Workspace carries defaultComputerId (null = local)", () => { - const remote: Workspace = { - id: "default", - title: "Default", - defaultCwd: null, - defaultComputerId: "myserver", - createdAt: 0, - lastActivityAt: 0, - }; - expect(remote.defaultComputerId).toBe("myserver"); - - const local: Workspace = { ...remote, defaultComputerId: null }; - expect(local.defaultComputerId).toBeNull(); - }); + it("a Computer literal satisfies the Computer type", () => { + const c: Computer = { + alias: "myserver", + hostName: "myserver.example.com", + port: 22, + user: "deploy", + identityFile: null, + knownHost: true, + }; + expect(c.alias).toBe("myserver"); + expect(c.port).toBe(22); + expect(c.identityFile).toBeNull(); + expect(c.knownHost).toBe(true); + }); + + it("ComputerEntry extends Computer and carries usageCount", () => { + const entry: ComputerEntry = { + alias: "buildbox", + hostName: "buildbox", + port: 2222, + user: "root", + identityFile: "/home/u/.ssh/id_ed25519", + knownHost: false, + usageCount: 3, + }; + // Compile-time proof that ComputerEntry is assignable to Computer. + const asComputer: Computer = entry; + expect(asComputer.alias).toBe("buildbox"); + expect(entry.usageCount).toBe(3); + }); + + it("a Workspace carries defaultComputerId (null = local) and starred", () => { + const remote: Workspace = { + id: "default", + title: "Default", + defaultCwd: null, + defaultComputerId: "myserver", + starred: false, + createdAt: 0, + lastActivityAt: 0, + }; + expect(remote.defaultComputerId).toBe("myserver"); + expect(remote.starred).toBe(false); + + const local: Workspace = { ...remote, defaultComputerId: null }; + expect(local.defaultComputerId).toBeNull(); + + const starred: Workspace = { ...remote, starred: true }; + expect(starred.starred).toBe(true); + }); +}); + +describe("@dispatch/wire — ImageChunk / ImageInput shapes", () => { + it("an ImageChunk carries a data URL and optional mimeType", () => { + const c: ImageChunk = { + type: "image", + url: "data:image/png;base64,iVBORw0KGgo=", + mimeType: "image/png", + }; + expect(c.type).toBe("image"); + expect(c.url).toContain("base64"); + expect(c.mimeType).toBe("image/png"); + }); + + it("an ImageChunk with only a url is valid (mimeType optional)", () => { + const c: ImageChunk = { type: "image", url: "https://example.com/cat.png" }; + expect(c.mimeType).toBeUndefined(); + }); + + it("ImageInput mirrors ImageChunk's url semantics", () => { + const input: ImageInput = { url: "data:image/jpeg;base64,/9j/4AAQ" }; + expect(input.url).toContain("jpeg"); + }); + + it("ImageChunk is a member of the Chunk union (assignable)", () => { + const chunk: Chunk = { type: "image", url: "data:image/png;base64,x" }; + // Compile-time proof: an ImageChunk satisfies the Chunk union. + expect(chunk.type).toBe("image"); + }); }); diff --git a/packages/wire/src/index.ts b/packages/wire/src/index.ts index 8dc3a72..aa5b6a8 100644 --- a/packages/wire/src/index.ts +++ b/packages/wire/src/index.ts @@ -31,23 +31,24 @@ export type StepId = string & { readonly __brand: "StepId" }; * append-only conversation log. Discriminated by `type`. */ export type Chunk = - | TextChunk - | ThinkingChunk - | ToolCallChunk - | ToolResultChunk - | ErrorChunk - | SystemChunk; + | TextChunk + | ThinkingChunk + | ToolCallChunk + | ToolResultChunk + | ErrorChunk + | SystemChunk + | ImageChunk; /** A piece of plain text content from the assistant or user. */ export interface TextChunk { - readonly type: "text"; - readonly text: string; + readonly type: "text"; + readonly text: string; } /** A piece of model reasoning / thinking content (e.g. extended thinking). */ export interface ThinkingChunk { - readonly type: "thinking"; - readonly text: string; + readonly type: "thinking"; + readonly text: string; } /** @@ -56,22 +57,22 @@ export interface ThinkingChunk { * `ToolContract.execute`. */ export interface ToolCallChunk { - readonly type: "tool-call"; - readonly toolCallId: string; - readonly toolName: string; - readonly input: unknown; - /** - * The step that produced this call — generation provenance stamped by the - * runtime when the model emits the call (NOT storage metadata like `seq`, - * which is why it lives on the chunk and travels with it through persistence - * and replay). Tool calls a model batches together in one step share the same - * `stepId`: the grouping key for rendering a parallel batch as one unit, and - * equal to the `stepId` on the matching `tool-call` AgentEvent. Optional: - * absent on chunks reconstructed outside a turn and on rows persisted before - * this field existed, so a consumer must tolerate its absence (render - * ungrouped). - */ - readonly stepId?: StepId; + readonly type: "tool-call"; + readonly toolCallId: string; + readonly toolName: string; + readonly input: unknown; + /** + * The step that produced this call — generation provenance stamped by the + * runtime when the model emits the call (NOT storage metadata like `seq`, + * which is why it lives on the chunk and travels with it through persistence + * and replay). Tool calls a model batches together in one step share the same + * `stepId`: the grouping key for rendering a parallel batch as one unit, and + * equal to the `stepId` on the matching `tool-call` AgentEvent. Optional: + * absent on chunks reconstructed outside a turn and on rows persisted before + * this field existed, so a consumer must tolerate its absence (render + * ungrouped). + */ + readonly stepId?: StepId; } /** @@ -80,27 +81,27 @@ export interface ToolCallChunk { * (synthesized if interrupted — see reconcile). */ export interface ToolResultChunk { - readonly type: "tool-result"; - readonly toolCallId: string; - readonly toolName: string; - readonly content: string; - readonly isError: boolean; - /** - * The step that produced the originating call — equal to the `stepId` on the - * matching `tool-call` chunk (same `toolCallId`) and on the `tool-result` - * AgentEvent, so a consumer groups a step's calls with their results. - * Generation provenance, not storage metadata (see `ToolCallChunk.stepId`). - * Optional for the same reasons; `reconcile` copies it from the originating - * call onto a synthesized (interrupted) result. - */ - readonly stepId?: StepId; + readonly type: "tool-result"; + readonly toolCallId: string; + readonly toolName: string; + readonly content: string; + readonly isError: boolean; + /** + * The step that produced the originating call — equal to the `stepId` on the + * matching `tool-call` chunk (same `toolCallId`) and on the `tool-result` + * AgentEvent, so a consumer groups a step's calls with their results. + * Generation provenance, not storage metadata (see `ToolCallChunk.stepId`). + * Optional for the same reasons; `reconcile` copies it from the originating + * call onto a synthesized (interrupted) result. + */ + readonly stepId?: StepId; } /** An error that occurred during generation or tool dispatch. */ export interface ErrorChunk { - readonly type: "error"; - readonly message: string; - readonly code?: string; + readonly type: "error"; + readonly message: string; + readonly code?: string; } /** @@ -108,8 +109,48 @@ export interface ErrorChunk { * Kept distinct from text so the log records provenance. */ export interface SystemChunk { - readonly type: "system"; - readonly text: string; + readonly type: "system"; + readonly text: string; +} + +/** + * An image attached to a message (e.g. a user-pasted screenshot or pasted + * photo). Carries a `url` that is EITHER a base64 data URL + * (`data:image/png;base64,…`) OR an `http(s)://` URL. Vision-capable models + * receive it natively (the provider serializes it to its image-content + * format); non-vision models never see it directly — the orchestrator's + * **vision handoff** transcribes it to a text description (via a + * vision-capable model) and feeds that text instead, so a text-only model can + * still reason about the image's contents. + * + * When a transcription was performed, it is persisted as a separate `text` + * chunk alongside the `image` chunk in the SAME user message, so the + * description is reused on every later turn (no re-transcription) and a + * client renders both the original image and its textual analysis. + */ +export interface ImageChunk { + readonly type: "image"; + /** Image source: a base64 data URL (`data:image/…;base64,…`) or an `http(s)://` URL. */ + readonly url: string; + /** + * Optional MIME type of the image (e.g. `"image/png"`). Inferred from the + * data URL when absent; present so a client can render an icon/label without + * parsing the URL. Optional — callers that only have a URL omit it. + */ + readonly mimeType?: string; +} + +/** + * An image a client attaches to a chat message (`ChatRequest.images`). The + * transport-facing input shape; the orchestrator converts each `ImageInput` + * into an `ImageChunk` on the persisted user message. Carries the same `url` + * semantics as `ImageChunk.url`. + */ +export interface ImageInput { + /** Image source: a base64 data URL (`data:image/…;base64,…`) or an `http(s)://` URL. */ + readonly url: string; + /** Optional MIME type (e.g. `"image/png"`). Optional — inferred from the data URL when absent. */ + readonly mimeType?: string; } /** @@ -118,8 +159,8 @@ export interface SystemChunk { * rendered. */ export interface ChatMessage { - readonly role: Role; - readonly chunks: readonly Chunk[]; + readonly role: Role; + readonly chunks: readonly Chunk[]; } /** @@ -141,9 +182,9 @@ export interface ChatMessage { * `stepId`), which is intrinsic to the content and so travels with it. */ export interface StoredChunk { - readonly seq: number; - readonly role: Role; - readonly chunk: Chunk; + readonly seq: number; + readonly role: Role; + readonly chunk: Chunk; } // ─── Reasoning effort ─────────────────────────────────────────────────────── @@ -167,10 +208,10 @@ export type ReasoningEffort = "low" | "medium" | "high" | "xhigh" | "max"; * Cache fields are optional because not all providers expose cache metrics. */ export interface Usage { - readonly inputTokens: number; - readonly outputTokens: number; - readonly cacheReadTokens?: number; - readonly cacheWriteTokens?: number; + readonly inputTokens: number; + readonly outputTokens: number; + readonly cacheReadTokens?: number; + readonly cacheWriteTokens?: number; } // ─── Persisted metrics ─────────────────────────────────────────────────────── @@ -184,15 +225,15 @@ export interface Usage { * served by `GET /conversations/:id/metrics`. */ export interface StepMetrics { - readonly stepId: StepId; - /** The step's token usage (all four counters; cache fields optional per `Usage`). */ - readonly usage: Usage; - /** Time to first token (stream start → first text/reasoning delta). Optional — see `TurnStepCompleteEvent.ttftMs`. */ - readonly ttftMs?: number; - /** Decode time (first token → stream end). Optional — see `TurnStepCompleteEvent.decodeMs`. */ - readonly decodeMs?: number; - /** Total generation time for the step (stream start → stream end). Optional: present only when a clock was available. */ - readonly genTotalMs?: number; + readonly stepId: StepId; + /** The step's token usage (all four counters; cache fields optional per `Usage`). */ + readonly usage: Usage; + /** Time to first token (stream start → first text/reasoning delta). Optional — see `TurnStepCompleteEvent.ttftMs`. */ + readonly ttftMs?: number; + /** Decode time (first token → stream end). Optional — see `TurnStepCompleteEvent.decodeMs`. */ + readonly decodeMs?: number; + /** Total generation time for the step (stream start → stream end). Optional: present only when a clock was available. */ + readonly genTotalMs?: number; } /** @@ -205,23 +246,23 @@ export interface StepMetrics { * on every `AgentEvent`, the join key to the live stream.) */ export interface TurnMetrics { - readonly turnId: string; - /** Aggregate token usage across all steps in the turn. */ - readonly usage: Usage; - /** Total wall-clock duration of the turn (turn start → turn end). Optional: present only when a clock was available. */ - readonly durationMs?: number; - /** Per-step metrics in step order. */ - readonly steps: readonly StepMetrics[]; - /** - * **Context size** — tokens the conversation occupies as of this turn: the - * turn's FINAL step `inputTokens + outputTokens` (the last entry of `steps`), - * NOT the aggregate `usage` (which sums per-step prompts and overcounts a - * multi-step turn). The persisted, replayable counterpart of - * `TurnDoneEvent.contextSize` and equal to it for the same turn. A client - * reopening a past conversation reads the LAST turn's `contextSize` as the - * current context usage. Optional: absent when no per-step usage was available. - */ - readonly contextSize?: number; + readonly turnId: string; + /** Aggregate token usage across all steps in the turn. */ + readonly usage: Usage; + /** Total wall-clock duration of the turn (turn start → turn end). Optional: present only when a clock was available. */ + readonly durationMs?: number; + /** Per-step metrics in step order. */ + readonly steps: readonly StepMetrics[]; + /** + * **Context size** — tokens the conversation occupies as of this turn: the + * turn's FINAL step `inputTokens + outputTokens` (the last entry of `steps`), + * NOT the aggregate `usage` (which sums per-step prompts and overcounts a + * multi-step turn). The persisted, replayable counterpart of + * `TurnDoneEvent.contextSize` and equal to it for the same turn. A client + * reopening a past conversation reads the LAST turn's `contextSize` as the + * current context usage. Optional: absent when no per-step usage was available. + */ + readonly contextSize?: number; } // ─── Message queue + steering ─────────────────────────────────────────────── @@ -234,12 +275,12 @@ export interface TurnMetrics { * use (so a separate frontend repo can depend on the wire alone to render it). */ export interface QueuedMessage { - /** Stable id (client-visible) for UI keying + dedup. */ - readonly id: string; - /** The message text the client enqueued. */ - readonly text: string; - /** When the message was enqueued (epoch-ms). */ - readonly queuedAt: number; + /** Stable id (client-visible) for UI keying + dedup. */ + readonly id: string; + /** The message text the client enqueued. */ + readonly text: string; + /** When the message was enqueued (epoch-ms). */ + readonly queuedAt: number; } /** @@ -252,7 +293,7 @@ export interface QueuedMessage { * surface clears) and/or when the matching `TurnSteeringEvent` arrives. */ export interface QueuePayload { - readonly messages: readonly QueuedMessage[]; + readonly messages: readonly QueuedMessage[]; } // ─── Outward events ───────────────────────────────────────────────────────── @@ -262,34 +303,34 @@ export interface QueuePayload { * Consumers (transport, persistence, notifications) pattern-match on `type`. */ export type AgentEvent = - | StatusEvent - | TurnStartEvent - | TurnInputEvent - | TurnTextDeltaEvent - | TurnReasoningDeltaEvent - | TurnToolCallEvent - | TurnToolResultEvent - | TurnToolOutputEvent - | TurnUsageEvent - | TurnStepCompleteEvent - | TurnErrorEvent - | TurnProviderRetryEvent - | TurnDoneEvent - | TurnSealedEvent - | TurnSteeringEvent; + | StatusEvent + | TurnStartEvent + | TurnInputEvent + | TurnTextDeltaEvent + | TurnReasoningDeltaEvent + | TurnToolCallEvent + | TurnToolResultEvent + | TurnToolOutputEvent + | TurnUsageEvent + | TurnStepCompleteEvent + | TurnErrorEvent + | TurnProviderRetryEvent + | TurnDoneEvent + | TurnSealedEvent + | TurnSteeringEvent; /** Status change for a conversation (e.g. idle → running). */ export interface StatusEvent { - readonly type: "status"; - readonly conversationId: string; - readonly status: string; + readonly type: "status"; + readonly conversationId: string; + readonly status: string; } /** A turn has begun. */ export interface TurnStartEvent { - readonly type: "turn-start"; - readonly conversationId: string; - readonly turnId: string; + readonly type: "turn-start"; + readonly conversationId: string; + readonly turnId: string; } /** @@ -305,93 +346,93 @@ export interface TurnStartEvent { * directly. Carries the raw prompt `text` (the same text passed to the provider). */ export interface TurnInputEvent { - readonly type: "user-message"; - readonly conversationId: string; - readonly turnId: string; - readonly text: string; + readonly type: "user-message"; + readonly conversationId: string; + readonly turnId: string; + readonly text: string; } /** Incremental text content from the model during a turn. */ export interface TurnTextDeltaEvent { - readonly type: "text-delta"; - readonly conversationId: string; - readonly turnId: string; - readonly delta: string; + readonly type: "text-delta"; + readonly conversationId: string; + readonly turnId: string; + readonly delta: string; } /** Incremental reasoning / thinking content during a turn. */ export interface TurnReasoningDeltaEvent { - readonly type: "reasoning-delta"; - readonly conversationId: string; - readonly turnId: string; - readonly delta: string; + readonly type: "reasoning-delta"; + readonly conversationId: string; + readonly turnId: string; + readonly delta: string; } /** The model has requested a tool to be run. */ export interface TurnToolCallEvent { - readonly type: "tool-call"; - readonly conversationId: string; - readonly turnId: string; - /** - * The step that produced this call. Tool calls a model batches together in - * one step share the same `stepId` — the grouping key for rendering a - * parallel batch as one unit. Matches the `stepId` on the matching - * `tool-result` event and on the persisted tool chunk - * (`StoredChunk.chunk.stepId`). - */ - readonly stepId: StepId; - readonly toolCallId: string; - readonly toolName: string; - readonly input: unknown; + readonly type: "tool-call"; + readonly conversationId: string; + readonly turnId: string; + /** + * The step that produced this call. Tool calls a model batches together in + * one step share the same `stepId` — the grouping key for rendering a + * parallel batch as one unit. Matches the `stepId` on the matching + * `tool-result` event and on the persisted tool chunk + * (`StoredChunk.chunk.stepId`). + */ + readonly stepId: StepId; + readonly toolCallId: string; + readonly toolName: string; + readonly input: unknown; } /** A tool has completed execution. */ export interface TurnToolResultEvent { - readonly type: "tool-result"; - readonly conversationId: string; - readonly turnId: string; - /** - * The step that produced the originating call. Equal to the `stepId` on the - * matching `tool-call` event (same `toolCallId`) and on the persisted tool - * chunk (`StoredChunk.chunk.stepId`), so a client groups a step's calls with - * their results. - */ - readonly stepId: StepId; - readonly toolCallId: string; - readonly toolName: string; - readonly content: string; - readonly isError: boolean; - /** - * How long the tool took to execute (dispatch → result), in milliseconds — - * the backend's authoritative execution time, distinct from any client-side - * wall-clock. Optional: present only when the runtime was given a clock. - */ - readonly durationMs?: number; + readonly type: "tool-result"; + readonly conversationId: string; + readonly turnId: string; + /** + * The step that produced the originating call. Equal to the `stepId` on the + * matching `tool-call` event (same `toolCallId`) and on the persisted tool + * chunk (`StoredChunk.chunk.stepId`), so a client groups a step's calls with + * their results. + */ + readonly stepId: StepId; + readonly toolCallId: string; + readonly toolName: string; + readonly content: string; + readonly isError: boolean; + /** + * How long the tool took to execute (dispatch → result), in milliseconds — + * the backend's authoritative execution time, distinct from any client-side + * wall-clock. Optional: present only when the runtime was given a clock. + */ + readonly durationMs?: number; } /** Streaming output from a tool execution (e.g. shell stdout/stderr). */ export interface TurnToolOutputEvent { - readonly type: "tool-output"; - readonly conversationId: string; - readonly turnId: string; - readonly toolCallId: string; - readonly data: string; - readonly stream: "stdout" | "stderr"; + readonly type: "tool-output"; + readonly conversationId: string; + readonly turnId: string; + readonly toolCallId: string; + readonly data: string; + readonly stream: "stdout" | "stderr"; } /** Token usage for the current step or turn. */ export interface TurnUsageEvent { - readonly type: "usage"; - readonly conversationId: string; - readonly turnId: string; - /** - * The step this usage report belongs to, so a consumer can attribute tokens - * per step (and join with the matching `step-complete` timing by `stepId`). - * Optional: absent when the runtime had no step context, and on usage emitted - * before this field existed. - */ - readonly stepId?: StepId; - readonly usage: Usage; + readonly type: "usage"; + readonly conversationId: string; + readonly turnId: string; + /** + * The step this usage report belongs to, so a consumer can attribute tokens + * per step (and join with the matching `step-complete` timing by `stepId`). + * Optional: absent when the runtime had no step context, and on usage emitted + * before this field existed. + */ + readonly stepId?: StepId; + readonly usage: Usage; } /** @@ -404,30 +445,30 @@ export interface TurnUsageEvent { * content token (text or reasoning) was observed this step. */ export interface TurnStepCompleteEvent { - readonly type: "step-complete"; - readonly conversationId: string; - readonly turnId: string; - readonly stepId: StepId; - /** Time to first token: stream start → first text/reasoning delta. */ - readonly ttftMs?: number; - /** Decode time: first token → stream end (generation total − TTFT). */ - readonly decodeMs?: number; - /** - * Total generation time for the step: stream start → stream end. Present - * whenever a clock was available, even if no first token was seen (in which - * case `ttftMs`/`decodeMs` are absent). When a first token was seen, - * `genTotalMs === ttftMs + decodeMs`. - */ - readonly genTotalMs?: number; + readonly type: "step-complete"; + readonly conversationId: string; + readonly turnId: string; + readonly stepId: StepId; + /** Time to first token: stream start → first text/reasoning delta. */ + readonly ttftMs?: number; + /** Decode time: first token → stream end (generation total − TTFT). */ + readonly decodeMs?: number; + /** + * Total generation time for the step: stream start → stream end. Present + * whenever a clock was available, even if no first token was seen (in which + * case `ttftMs`/`decodeMs` are absent). When a first token was seen, + * `genTotalMs === ttftMs + decodeMs`. + */ + readonly genTotalMs?: number; } /** An error occurred during the turn. */ export interface TurnErrorEvent { - readonly type: "error"; - readonly conversationId: string; - readonly turnId: string; - readonly message: string; - readonly code?: string; + readonly type: "error"; + readonly conversationId: string; + readonly turnId: string; + readonly message: string; + readonly code?: string; } /** @@ -442,51 +483,51 @@ export interface TurnErrorEvent { * before that retry fires. */ export interface TurnProviderRetryEvent { - readonly type: "provider-retry"; - readonly conversationId: string; - readonly turnId: string; - /** 0-based: this is the Nth retry about to happen. */ - readonly attempt: number; - /** ms the client should expect to wait before the retry fires. */ - readonly delayMs: number; - /** The endpoint's error verbatim (e.g. "HTTP 429: {…overloaded_error…}"). */ - readonly message: string; - /** The HTTP code when known (e.g. "429"). */ - readonly code?: string; + readonly type: "provider-retry"; + readonly conversationId: string; + readonly turnId: string; + /** 0-based: this is the Nth retry about to happen. */ + readonly attempt: number; + /** ms the client should expect to wait before the retry fires. */ + readonly delayMs: number; + /** The endpoint's error verbatim (e.g. "HTTP 429: {…overloaded_error…}"). */ + readonly message: string; + /** The HTTP code when known (e.g. "429"). */ + readonly code?: string; } /** The turn has completed (model finished generating). */ export interface TurnDoneEvent { - readonly type: "done"; - readonly conversationId: string; - readonly turnId: string; - readonly reason: string; - /** - * Total wall-clock duration of the turn (turn start → turn end), in - * milliseconds. Optional: present only when the runtime was given a clock. - */ - readonly durationMs?: number; - /** - * Aggregate token usage across all steps in the turn — a convenience total so - * a consumer need not sum the per-step `usage` events. Optional (absent if the - * provider reported no usage). - */ - readonly usage?: Usage; - /** - * **Context size** — the number of tokens the conversation now occupies: this - * (the most recent) turn's FINAL step `inputTokens + outputTokens` (the full - * prompt sent into the last LLM round-trip plus that round-trip's output). This - * is the "tokens in context" figure a client renders as the chat's current - * context usage, and a client treats the LATEST turn's value as the live total. - * - * Deliberately NOT the aggregate `usage` above: `usage` SUMS each step's - * `inputTokens`, which overcounts a multi-step / tool-calling turn because every - * step re-prefills the growing prompt — the final step's input already includes - * all prior context, so its input+output is the true occupancy. Optional: absent - * when no per-step usage was observed this turn (mirrors `usage`). A later field - * will carry the model's max context-window LIMIT; this is only the current size. - */ - readonly contextSize?: number; + readonly type: "done"; + readonly conversationId: string; + readonly turnId: string; + readonly reason: string; + /** + * Total wall-clock duration of the turn (turn start → turn end), in + * milliseconds. Optional: present only when the runtime was given a clock. + */ + readonly durationMs?: number; + /** + * Aggregate token usage across all steps in the turn — a convenience total so + * a consumer need not sum the per-step `usage` events. Optional (absent if the + * provider reported no usage). + */ + readonly usage?: Usage; + /** + * **Context size** — the number of tokens the conversation now occupies: this + * (the most recent) turn's FINAL step `inputTokens + outputTokens` (the full + * prompt sent into the last LLM round-trip plus that round-trip's output). This + * is the "tokens in context" figure a client renders as the chat's current + * context usage, and a client treats the LATEST turn's value as the live total. + * + * Deliberately NOT the aggregate `usage` above: `usage` SUMS each step's + * `inputTokens`, which overcounts a multi-step / tool-calling turn because every + * step re-prefills the growing prompt — the final step's input already includes + * all prior context, so its input+output is the true occupancy. Optional: absent + * when no per-step usage was observed this turn (mirrors `usage`). A later field + * will carry the model's max context-window LIMIT; this is only the current size. + */ + readonly contextSize?: number; } /** @@ -494,9 +535,9 @@ export interface TurnDoneEvent { * This is the hook point for post-turn extensions (compaction, cache-warm). */ export interface TurnSealedEvent { - readonly type: "turn-sealed"; - readonly conversationId: string; - readonly turnId: string; + readonly type: "turn-sealed"; + readonly conversationId: string; + readonly turnId: string; } /** @@ -517,22 +558,28 @@ export interface TurnSealedEvent { * event per drain; the combined text of all drained messages. */ export interface TurnSteeringEvent { - readonly type: "steering"; - readonly conversationId: string; - readonly turnId: string; - readonly text: string; + readonly type: "steering"; + readonly conversationId: string; + readonly turnId: string; + readonly text: string; } // ─── Conversation metadata ─────────────────────────────────────────────────── /** * The lifecycle status of a conversation, used for tab persistence across - * devices. `active` = an agent is currently generating; `idle` = exists but not + * devices. `active` = an agent is currently generating; `queued` = the + * request is waiting in the per-provider concurrency queue for a slot (not yet + * generating — the FE shows a loading ring, not dots); `idle` = exists but not * generating; `closed` = user dismissed the tab (hidden from the tab bar, not * deleted). New conversations start as `idle`; transitions to `active` on * turn-start, back to `idle` on turn done/error, and to `closed` on user close. + * When the concurrency extension is loaded and a request blocks on + * `acquire()`, `queued` is broadcast (persisted status stays `active`); when + * the slot is granted, `active` is re-broadcast. A request that gets a slot + * immediately never emits `queued`. */ -export type ConversationStatus = "active" | "idle" | "closed"; +export type ConversationStatus = "active" | "queued" | "idle" | "closed"; /** * Metadata for a conversation, returned by `GET /conversations` (the list @@ -542,23 +589,23 @@ export type ConversationStatus = "active" | "idle" | "closed"; * for cross-device persistence. */ export interface ConversationMeta { - readonly id: string; - readonly createdAt: number; - readonly lastActivityAt: number; - readonly title: string; - readonly status: ConversationStatus; - /** - * The workspace this conversation belongs to. Always present; reads as - * `"default"` for legacy conversations that were never explicitly assigned. - * Conversations created with no `workspaceId` default to `"default"`. - */ - readonly workspaceId: string; - /** - * Set on a compacted conversation: points to the archive conversation ID - * that holds the full pre-compaction history. Absent on conversations - * that have never been compacted. - */ - readonly compactedFrom?: string; + readonly id: string; + readonly createdAt: number; + readonly lastActivityAt: number; + readonly title: string; + readonly status: ConversationStatus; + /** + * The workspace this conversation belongs to. Always present; reads as + * `"default"` for legacy conversations that were never explicitly assigned. + * Conversations created with no `workspaceId` default to `"default"`. + */ + readonly workspaceId: string; + /** + * Set on a compacted conversation: points to the archive conversation ID + * that holds the full pre-compaction history. Absent on conversations + * that have never been compacted. + */ + readonly compactedFrom?: string; } // ─── Compaction ────────────────────────────────────────────────────────────── @@ -571,10 +618,10 @@ export interface ConversationMeta { * pre-compaction history (non-destructive — the original history is preserved). */ export interface CompactionResult { - readonly summary: string; - readonly newConversationId: string; - readonly messagesSummarized: number; - readonly messagesKept: number; + readonly summary: string; + readonly newConversationId: string; + readonly messagesSummarized: number; + readonly messagesKept: number; } // ─── Workspaces ────────────────────────────────────────────────────────────── @@ -590,24 +637,31 @@ export interface CompactionResult { * created with no `workspaceId` are assigned to `"default"`. */ export interface Workspace { - /** The URL slug (immutable). Lowercase `[a-z0-9-]`, 1–40 chars. */ - readonly id: string; - /** Display title (editable). Defaults to `id` on creation. */ - readonly title: string; - /** The workspace's default cwd, or `null` (fall through to server default). */ - readonly defaultCwd: string | null; - /** - * The workspace's default computer — an SSH config `Host` alias that - * conversations in this workspace inherit when they set no `computerId` of - * their own. `null` means local (no SSH; today's behavior). The computer - * analog of `defaultCwd`. Resolved per-conversation by `getEffectiveComputer` - * (per-conv `computerId` → this → `null`/local). - */ - readonly defaultComputerId: string | null; - /** Epoch-ms when the workspace was first created. */ - readonly createdAt: number; - /** Epoch-ms of the most recent conversation activity in this workspace. */ - readonly lastActivityAt: number; + /** The URL slug (immutable). Lowercase `[a-z0-9-]`, 1–40 chars. */ + readonly id: string; + /** Display title (editable). Defaults to `id` on creation. */ + readonly title: string; + /** The workspace's default cwd, or `null` (fall through to server default). */ + readonly defaultCwd: string | null; + /** + * The workspace's default computer — an SSH config `Host` alias that + * conversations in this workspace inherit when they set no `computerId` of + * their own. `null` means local (no SSH; today's behavior). The computer + * analog of `defaultCwd`. Resolved per-conversation by `getEffectiveComputer` + * (per-conv `computerId` → this → `null`/local). + */ + readonly defaultComputerId: string | null; + /** + * Whether the workspace is starred by the user. Starred workspaces receive + * PRIORITY in the concurrency limiter queue — their agents jump ahead of + * agents from non-starred workspaces (oldest-agent-first within each group). + * Defaults to `false` on creation. + */ + readonly starred: boolean; + /** Epoch-ms when the workspace was first created. */ + readonly createdAt: number; + /** Epoch-ms of the most recent conversation activity in this workspace. */ + readonly lastActivityAt: number; } /** @@ -615,8 +669,8 @@ export interface Workspace { * plus a conversation count. */ export interface WorkspaceEntry extends Workspace { - /** Number of conversations assigned to this workspace. */ - readonly conversationCount: number; + /** Number of conversations assigned to this workspace. */ + readonly conversationCount: number; } // ─── Computers ─────────────────────────────────────────────────────────────── @@ -634,21 +688,21 @@ export interface WorkspaceEntry extends Workspace { * drives the frontend "known/new" indicator and is read-only. */ export interface Computer { - /** The SSH config `Host` alias — also the `computerId` users select. */ - readonly alias: string; - /** Resolved `HostName`/IP from the config (falls back to the alias itself). */ - readonly hostName: string; - /** Resolved port (config `Port`, default 22). */ - readonly port: number; - /** Resolved user (config `User`, default the current user). */ - readonly user: string; - /** Resolved `IdentityFile` path (from the config, or `null` = default `~/.ssh/id_*`). */ - readonly identityFile: string | null; - /** - * Whether the host's key is already in `~/.ssh/known_hosts` (i.e. previously - * connected). Drives the frontend "known/new" indicator. Read-only. - */ - readonly knownHost: boolean; + /** The SSH config `Host` alias — also the `computerId` users select. */ + readonly alias: string; + /** Resolved `HostName`/IP from the config (falls back to the alias itself). */ + readonly hostName: string; + /** Resolved port (config `Port`, default 22). */ + readonly port: number; + /** Resolved user (config `User`, default the current user). */ + readonly user: string; + /** Resolved `IdentityFile` path (from the config, or `null` = default `~/.ssh/id_*`). */ + readonly identityFile: string | null; + /** + * Whether the host's key is already in `~/.ssh/known_hosts` (i.e. previously + * connected). Drives the frontend "known/new" indicator. Read-only. + */ + readonly knownHost: boolean; } /** @@ -656,6 +710,6 @@ export interface Computer { * a usage count. Parallel to `WorkspaceEntry`. */ export interface ComputerEntry extends Computer { - /** Number of conversations/workspaces whose `computerId` resolves to this alias. */ - readonly usageCount: number; + /** Number of conversations/workspaces whose `computerId` resolves to this alias. */ + readonly usageCount: number; } |
