diff options
Diffstat (limited to 'packages/transport-contract')
| -rw-r--r-- | packages/transport-contract/package.json | 20 | ||||
| -rw-r--r-- | packages/transport-contract/src/contract.types.test.ts | 477 | ||||
| -rw-r--r-- | packages/transport-contract/src/index.ts | 885 | ||||
| -rw-r--r-- | packages/transport-contract/tsconfig.json | 8 |
4 files changed, 868 insertions, 522 deletions
diff --git a/packages/transport-contract/package.json b/packages/transport-contract/package.json index 842a28b..0f65b7b 100644 --- a/packages/transport-contract/package.json +++ b/packages/transport-contract/package.json @@ -1,12 +1,12 @@ { - "name": "@dispatch/transport-contract", - "version": "0.22.0", - "type": "module", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "dependencies": { - "@dispatch/ui-contract": "workspace:*", - "@dispatch/wire": "workspace:*" - } + "name": "@dispatch/transport-contract", + "version": "0.24.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/ui-contract": "workspace:*", + "@dispatch/wire": "workspace:*" + } } diff --git a/packages/transport-contract/src/contract.types.test.ts b/packages/transport-contract/src/contract.types.test.ts index 0aad643..3cc1b1e 100644 --- a/packages/transport-contract/src/contract.types.test.ts +++ b/packages/transport-contract/src/contract.types.test.ts @@ -8,71 +8,95 @@ import { describe, expect, it } from "vitest"; import type { - ChatRequest, - Computer, - ComputerEntry, - ComputerListResponse, - ComputerResponse, - ComputerStatusResponse, - ConversationComputerResponse, - CwdResponse, - LspServerInfo, - LspServerState, - LspStatusResponse, - McpStatusResponse, - SetConversationComputerRequest, - SetCwdRequest, - SetWorkspaceDefaultComputerRequest, - TestComputerResponse, + ChatRequest, + Computer, + ComputerEntry, + ComputerListResponse, + ComputerResponse, + ComputerStatusResponse, + ConversationComputerResponse, + CwdResponse, + LspServerInfo, + LspServerState, + LspStatusResponse, + McpStatusResponse, + ModelsResponse, + SetConversationComputerRequest, + SetCwdRequest, + SetWorkspaceDefaultComputerRequest, + TestComputerResponse, } from "./index.js"; // ─── CwdResponse ───────────────────────────────────────────────────────────── const _cwdNull: CwdResponse = { - conversationId: "conv-1", - cwd: null, + conversationId: "conv-1", + cwd: null, }; const _cwdSet: CwdResponse = { - conversationId: "conv-2", - cwd: "/home/user/project", + conversationId: "conv-2", + cwd: "/home/user/project", }; // ─── SetCwdRequest ─────────────────────────────────────────────────────────── const _setCwd: SetCwdRequest = { - cwd: "/tmp/workspace", + cwd: "/tmp/workspace", }; // ─── ChatRequest.computerId (additive optional) ────────────────────────────── const _chatWithComputer: ChatRequest = { - message: "run the test suite", - computerId: "prod-box", + message: "run the test suite", + computerId: "prod-box", }; const _chatWithoutComputer: ChatRequest = { - message: "hello", + message: "hello", +}; + +// ─── ChatRequest.images (additive optional) ────────────────────────────────── + +const _chatWithImages: ChatRequest = { + message: "What's in this screenshot?", + images: [{ url: "data:image/png;base64,iVBORw0KGgo=", mimeType: "image/png" }], +}; + +const _chatWithHttpImage: ChatRequest = { + message: "analyze this", + images: [{ url: "https://example.com/diagram.png" }], +}; + +// ─── ChatRequest.title (additive optional) ─────────────────────────────────── + +const _chatWithTitle: ChatRequest = { + message: "implement the feature", + title: "Summon: add --title flag", +}; + +const _chatWithoutTitle: ChatRequest = { + message: "hello", }; // ─── Computer list / single response ───────────────────────────────────────── const _computer: Computer = { - alias: "prod-box", - hostName: "10.0.0.5", - port: 22, - user: "deploy", - identityFile: "/home/user/.ssh/id_ed25519", - knownHost: true, + alias: "prod-box", + hostName: "10.0.0.5", + port: 22, + user: "deploy", + identityFile: "/home/user/.ssh/id_ed25519", + knownHost: true, }; const _computerEntry: ComputerEntry = { - ..._computer, - usageCount: 3, + ..._computer, + usageCount: 3, }; const _computerList: ComputerListResponse = { - computers: [_computerEntry], + computers: [_computerEntry], }; const _computerResponse: ComputerResponse = _computer; @@ -80,51 +104,51 @@ const _computerResponse: ComputerResponse = _computer; // ─── Computer status / test probe ──────────────────────────────────────────── const _statusConnected: ComputerStatusResponse = { - alias: "prod-box", - state: "connected", - knownHost: true, + alias: "prod-box", + state: "connected", + knownHost: true, }; const _statusError: ComputerStatusResponse = { - alias: "prod-box", - state: "error", - error: "connection refused", - knownHost: false, + alias: "prod-box", + state: "error", + error: "connection refused", + knownHost: false, }; const _testOk: TestComputerResponse = { - alias: "prod-box", - ok: true, + alias: "prod-box", + ok: true, }; const _testFail: TestComputerResponse = { - alias: "prod-box", - ok: false, - error: "auth failed", + alias: "prod-box", + ok: false, + error: "auth failed", }; // ─── Per-conversation + workspace computer ─────────────────────────────────── const _setConvComputer: SetConversationComputerRequest = { - computerId: "prod-box", + computerId: "prod-box", }; const _clearConvComputer: SetConversationComputerRequest = { - computerId: null, + computerId: null, }; const _convComputer: ConversationComputerResponse = { - conversationId: "conv-1", - computerId: "prod-box", + conversationId: "conv-1", + computerId: "prod-box", }; const _convComputerNull: ConversationComputerResponse = { - conversationId: "conv-2", - computerId: null, + conversationId: "conv-2", + computerId: null, }; const _setDefaultComputer: SetWorkspaceDefaultComputerRequest = { - computerId: null, + computerId: null, }; // ─── LspServerState ────────────────────────────────────────────────────────── @@ -137,178 +161,217 @@ const _stateNotStarted: LspServerState = "not-started"; // ─── LspServerInfo ─────────────────────────────────────────────────────────── const _serverOk: LspServerInfo = { - id: "typescript", - name: "TypeScript Language Server", - root: "/home/user/project", - extensions: [".ts", ".tsx"], - state: "connected", + id: "typescript", + name: "TypeScript Language Server", + root: "/home/user/project", + extensions: [".ts", ".tsx"], + state: "connected", }; const _serverErr: LspServerInfo = { - id: "luau-lsp", - name: "Luau LSP", - root: "/home/user/game", - extensions: [".luau"], - state: "error", - error: "Failed to start: binary not found", + id: "luau-lsp", + name: "Luau LSP", + root: "/home/user/game", + extensions: [".luau"], + state: "error", + error: "Failed to start: binary not found", }; const _serverWithSource: LspServerInfo = { - id: "ruby-lsp", - name: "Ruby LSP", - root: "/home/user/raylib", - extensions: [".rb"], - state: "connected", - configSource: ".dispatch/lsp.json", + id: "ruby-lsp", + name: "Ruby LSP", + root: "/home/user/raylib", + extensions: [".rb"], + state: "connected", + configSource: ".dispatch/lsp.json", }; // ─── LspStatusResponse ─────────────────────────────────────────────────────── const _lspNoCwd: LspStatusResponse = { - conversationId: "conv-3", - cwd: null, - servers: [], + conversationId: "conv-3", + cwd: null, + servers: [], }; const _lspWithServers: LspStatusResponse = { - conversationId: "conv-4", - cwd: "/home/user/project", - servers: [_serverOk, _serverErr], + conversationId: "conv-4", + cwd: "/home/user/project", + servers: [_serverOk, _serverErr], }; // ─── Runtime smoke (vitest needs a suite) ──────────────────────────────────── describe("transport-contract types compile and are exported", () => { - it("CwdResponse: null cwd round-trips", () => { - expect(_cwdNull).toEqual({ conversationId: "conv-1", cwd: null }); - }); - - it("CwdResponse: set cwd round-trips", () => { - expect(_cwdSet.cwd).toBe("/home/user/project"); - }); - - it("SetCwdRequest: carries cwd", () => { - expect(_setCwd.cwd).toBe("/tmp/workspace"); - }); - - it("LspServerState: all four variants are valid", () => { - const states: LspServerState[] = [ - _stateConnected, - _stateStarting, - _stateError, - _stateNotStarted, - ]; - expect(states).toHaveLength(4); - }); - - it("LspServerInfo: ok server has no error field", () => { - expect(_serverOk.state).toBe("connected"); - expect(_serverOk.error).toBeUndefined(); - }); - - it("LspServerInfo: error server carries error message", () => { - expect(_serverErr.state).toBe("error"); - expect(_serverErr.error).toBe("Failed to start: binary not found"); - }); - - it("LspServerInfo: carries optional configSource", () => { - expect(_serverWithSource.configSource).toBe(".dispatch/lsp.json"); - expect(_serverOk.configSource).toBeUndefined(); - }); - - it("LspStatusResponse: empty servers when cwd is null", () => { - expect(_lspNoCwd.servers).toEqual([]); - }); - - it("LspStatusResponse: populated servers when cwd is set", () => { - expect(_lspWithServers.servers).toHaveLength(2); - }); - - // ─── MCP status ───────────────────────────────────────────────────────────── - - it("McpStatusResponse: empty servers when cwd is null", () => { - const _noCwd: McpStatusResponse = { conversationId: "c1", cwd: null, servers: [] }; - expect(_noCwd.servers).toEqual([]); - }); - - it("McpStatusResponse: populated servers when cwd is set", () => { - const _withServers: McpStatusResponse = { - conversationId: "c2", - cwd: "/home/user/project", - servers: [ - { id: "freecad", state: "connected", toolCount: 12, configSource: ".dispatch/mcp.json" }, - { id: "chrome", state: "error", error: "spawn failed", toolCount: 0 }, - ], - }; - expect(_withServers.servers).toHaveLength(2); - expect(_withServers.servers[0]?.toolCount).toBe(12); - expect(_withServers.servers[1]?.error).toBe("spawn failed"); - }); - - // ─── ChatRequest.computerId ────────────────────────────────────────────── - - it("ChatRequest: computerId is additive optional (omittable)", () => { - expect(_chatWithoutComputer.computerId).toBeUndefined(); - }); - - it("ChatRequest: carries computerId when set", () => { - expect(_chatWithComputer.computerId).toBe("prod-box"); - }); - - // ─── Computers ─────────────────────────────────────────────────────────── - - it("ComputerListResponse: carries entries with usage counts", () => { - expect(_computerList.computers).toHaveLength(1); - expect(_computerList.computers[0]?.usageCount).toBe(3); - expect(_computerList.computers[0]?.alias).toBe("prod-box"); - }); - - it("ComputerResponse: is a single Computer", () => { - expect(_computerResponse.alias).toBe("prod-box"); - expect(_computerResponse.port).toBe(22); - }); - - it("ComputerStatusResponse: all four states are valid", () => { - const states: ComputerStatusResponse["state"][] = [ - "disconnected", - "connecting", - "connected", - "error", - ]; - expect(states).toHaveLength(4); - }); - - it("ComputerStatusResponse: connected has no error field", () => { - expect(_statusConnected.state).toBe("connected"); - expect(_statusConnected.error).toBeUndefined(); - }); - - it("ComputerStatusResponse: error carries message", () => { - expect(_statusError.state).toBe("error"); - expect(_statusError.error).toBe("connection refused"); - }); - - it("TestComputerResponse: ok has no error field", () => { - expect(_testOk.ok).toBe(true); - expect(_testOk.error).toBeUndefined(); - }); - - it("TestComputerResponse: failure carries error", () => { - expect(_testFail.ok).toBe(false); - expect(_testFail.error).toBe("auth failed"); - }); - - it("SetConversationComputerRequest: null clears to inherit/local", () => { - expect(_setConvComputer.computerId).toBe("prod-box"); - expect(_clearConvComputer.computerId).toBeNull(); - }); - - it("ConversationComputerResponse: null computerId round-trips", () => { - expect(_convComputer.computerId).toBe("prod-box"); - expect(_convComputerNull.computerId).toBeNull(); - }); - - it("SetWorkspaceDefaultComputerRequest: null clears to local", () => { - expect(_setDefaultComputer.computerId).toBeNull(); - }); + it("CwdResponse: null cwd round-trips", () => { + expect(_cwdNull).toEqual({ conversationId: "conv-1", cwd: null }); + }); + + it("CwdResponse: set cwd round-trips", () => { + expect(_cwdSet.cwd).toBe("/home/user/project"); + }); + + it("SetCwdRequest: carries cwd", () => { + expect(_setCwd.cwd).toBe("/tmp/workspace"); + }); + + it("LspServerState: all four variants are valid", () => { + const states: LspServerState[] = [ + _stateConnected, + _stateStarting, + _stateError, + _stateNotStarted, + ]; + expect(states).toHaveLength(4); + }); + + it("LspServerInfo: ok server has no error field", () => { + expect(_serverOk.state).toBe("connected"); + expect(_serverOk.error).toBeUndefined(); + }); + + it("LspServerInfo: error server carries error message", () => { + expect(_serverErr.state).toBe("error"); + expect(_serverErr.error).toBe("Failed to start: binary not found"); + }); + + it("LspServerInfo: carries optional configSource", () => { + expect(_serverWithSource.configSource).toBe(".dispatch/lsp.json"); + expect(_serverOk.configSource).toBeUndefined(); + }); + + it("LspStatusResponse: empty servers when cwd is null", () => { + expect(_lspNoCwd.servers).toEqual([]); + }); + + it("LspStatusResponse: populated servers when cwd is set", () => { + expect(_lspWithServers.servers).toHaveLength(2); + }); + + // ─── MCP status ───────────────────────────────────────────────────────────── + + it("McpStatusResponse: empty servers when cwd is null", () => { + const _noCwd: McpStatusResponse = { conversationId: "c1", cwd: null, servers: [] }; + expect(_noCwd.servers).toEqual([]); + }); + + it("McpStatusResponse: populated servers when cwd is set", () => { + const _withServers: McpStatusResponse = { + conversationId: "c2", + cwd: "/home/user/project", + servers: [ + { id: "freecad", state: "connected", toolCount: 12, configSource: ".dispatch/mcp.json" }, + { id: "chrome", state: "error", error: "spawn failed", toolCount: 0 }, + ], + }; + expect(_withServers.servers).toHaveLength(2); + expect(_withServers.servers[0]?.toolCount).toBe(12); + expect(_withServers.servers[1]?.error).toBe("spawn failed"); + }); + + // ─── ChatRequest.computerId ────────────────────────────────────────────── + + it("ChatRequest: computerId is additive optional (omittable)", () => { + expect(_chatWithoutComputer.computerId).toBeUndefined(); + }); + + it("ChatRequest: carries computerId when set", () => { + expect(_chatWithComputer.computerId).toBe("prod-box"); + }); + + // ─── ChatRequest.images (additive optional) ────────────────────────────── + + it("ChatRequest: images is additive optional (omittable)", () => { + expect(_chatWithoutComputer.images).toBeUndefined(); + }); + + it("ChatRequest: carries images (data URL) when set", () => { + expect(_chatWithImages.images).toHaveLength(1); + expect(_chatWithImages.images?.[0]?.url).toContain("base64"); + expect(_chatWithImages.images?.[0]?.mimeType).toBe("image/png"); + }); + + it("ChatRequest: carries images (http URL, mimeType optional)", () => { + expect(_chatWithHttpImage.images?.[0]?.url).toBe("https://example.com/diagram.png"); + expect(_chatWithHttpImage.images?.[0]?.mimeType).toBeUndefined(); + }); + + // ─── ChatRequest.title (additive optional) ──────────────────────────────── + + it("ChatRequest: title is additive optional (omittable)", () => { + expect(_chatWithoutTitle.title).toBeUndefined(); + }); + + it("ChatRequest: carries title when set", () => { + expect(_chatWithTitle.title).toBe("Summon: add --title flag"); + }); + + it("ModelsResponse: ModelMetadata carries optional vision flag", () => { + const resp: ModelsResponse = { + models: ["umans/kimi-k2.7", "umans/glm-5.2"], + modelInfo: { + "umans/kimi-k2.7": { contextWindow: 200000, vision: true }, + "umans/glm-5.2": { contextWindow: 128000 }, + }, + }; + expect(resp.modelInfo?.["umans/kimi-k2.7"]?.vision).toBe(true); + expect(resp.modelInfo?.["umans/glm-5.2"]?.vision).toBeUndefined(); + }); + + // ─── Computers ─────────────────────────────────────────────────────────── + + it("ComputerListResponse: carries entries with usage counts", () => { + expect(_computerList.computers).toHaveLength(1); + expect(_computerList.computers[0]?.usageCount).toBe(3); + expect(_computerList.computers[0]?.alias).toBe("prod-box"); + }); + + it("ComputerResponse: is a single Computer", () => { + expect(_computerResponse.alias).toBe("prod-box"); + expect(_computerResponse.port).toBe(22); + }); + + it("ComputerStatusResponse: all four states are valid", () => { + const states: ComputerStatusResponse["state"][] = [ + "disconnected", + "connecting", + "connected", + "error", + ]; + expect(states).toHaveLength(4); + }); + + it("ComputerStatusResponse: connected has no error field", () => { + expect(_statusConnected.state).toBe("connected"); + expect(_statusConnected.error).toBeUndefined(); + }); + + it("ComputerStatusResponse: error carries message", () => { + expect(_statusError.state).toBe("error"); + expect(_statusError.error).toBe("connection refused"); + }); + + it("TestComputerResponse: ok has no error field", () => { + expect(_testOk.ok).toBe(true); + expect(_testOk.error).toBeUndefined(); + }); + + it("TestComputerResponse: failure carries error", () => { + expect(_testFail.ok).toBe(false); + expect(_testFail.error).toBe("auth failed"); + }); + + it("SetConversationComputerRequest: null clears to inherit/local", () => { + expect(_setConvComputer.computerId).toBe("prod-box"); + expect(_clearConvComputer.computerId).toBeNull(); + }); + + it("ConversationComputerResponse: null computerId round-trips", () => { + expect(_convComputer.computerId).toBe("prod-box"); + expect(_convComputerNull.computerId).toBeNull(); + }); + + it("SetWorkspaceDefaultComputerRequest: null clears to local", () => { + expect(_setDefaultComputer.computerId).toBeNull(); + }); }); diff --git a/packages/transport-contract/src/index.ts b/packages/transport-contract/src/index.ts index b32c8a0..32b03d3 100644 --- a/packages/transport-contract/src/index.ts +++ b/packages/transport-contract/src/index.ts @@ -21,33 +21,36 @@ import type { SurfaceClientMessage, SurfaceServerMessage } from "@dispatch/ui-contract"; import type { - AgentEvent, - Computer, - ComputerEntry, - ConversationMeta, - ConversationStatus, - QueuedMessage, - ReasoningEffort, - StoredChunk, - TurnMetrics, - Workspace, - WorkspaceEntry, + AgentEvent, + Computer, + ComputerEntry, + ConversationMeta, + ConversationStatus, + ImageInput, + QueuedMessage, + ReasoningEffort, + StoredChunk, + TurnMetrics, + Workspace, + WorkspaceEntry, } from "@dispatch/wire"; export type { - AgentEvent, - CompactionResult, - Computer, - ComputerEntry, - ConversationMeta, - ConversationStatus, - QueuedMessage, - ReasoningEffort, - StepMetrics, - StoredChunk, - TurnMetrics, - Workspace, - WorkspaceEntry, + AgentEvent, + CompactionResult, + Computer, + ComputerEntry, + ConversationMeta, + ConversationStatus, + ImageChunk, + ImageInput, + QueuedMessage, + ReasoningEffort, + StepMetrics, + StoredChunk, + TurnMetrics, + Workspace, + WorkspaceEntry, } from "@dispatch/wire"; /** @@ -58,53 +61,83 @@ export type { * response header (useful when `conversationId` was omitted). */ export interface ChatRequest { - /** - * The conversation to continue. Omit to start a fresh conversation — the - * server mints an id and returns it via the `X-Conversation-Id` header. - */ - readonly conversationId?: string; - - /** The user's message text for this turn. */ - readonly message: string; - - /** - * The model to use, as a model name in `<credentialName>/<model>` form — one - * of the exact strings returned by `GET /models`. Omit to use the server's - * default credential + model. - */ - readonly model?: string; - - /** - * Working directory for this turn's tool execution. Defaults server-side when - * omitted. Forwarded to tools for path resolution; never part of the model - * prompt (so it does not affect prompt caching). - */ - readonly cwd?: string; - - /** - * The computer to run this turn's tools on — an SSH config `Host` alias - * (one of the `alias` values returned by `GET /computers`). Omit to inherit - * the resolved chain: per-conversation `computerId` → the workspace's - * `defaultComputerId` → `null`/local (today's behavior). Like `cwd`, this is - * a per-turn tool-execution target forwarded to tools and never part of the - * model prompt (so it does not affect prompt caching). Mirrors `cwd`. - */ - readonly computerId?: string; - - /** - * Reasoning-effort override for THIS turn only (does not persist). When - * omitted, the server resolves the conversation's persisted value, falling - * back to `"high"`. Must be one of the `ReasoningEffort` levels; an - * unrecognized value → HTTP 400 `{ error }`. - */ - readonly reasoningEffort?: ReasoningEffort; - - /** - * The workspace to assign this conversation to. Omit for `"default"`. - * If the workspace doesn't exist yet, it is auto-created (title = id, - * defaultCwd = null). - */ - readonly workspaceId?: string; + /** + * The conversation to continue. Omit to start a fresh conversation — the + * server mints an id and returns it via the `X-Conversation-Id` header. + */ + readonly conversationId?: string; + + /** The user's message text for this turn. */ + readonly message: string; + + /** + * Images attached to this turn (e.g. a user-pasted screenshot). Each entry's + * `url` is a base64 data URL (`data:image/…;base64,…`) or an `http(s)://` + * URL. The server converts these to `image` chunks on the persisted user + * message. For a VISION-capable model (e.g. kimi), the images are passed + * through to the provider natively. For a NON-vision model (e.g. glm-5.2), + * the server's vision handoff transcribes each image 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. Optional — omit for a + * text-only turn (backward compatible). + */ + readonly images?: readonly ImageInput[]; + + /** + * The model to use, as a model name in `<credentialName>/<model>` form — one + * of the exact strings returned by `GET /models`. Omit to use the server's + * default credential + model. + */ + readonly model?: string; + + /** + * Working directory for this turn's tool execution. Defaults server-side when + * omitted. Forwarded to tools for path resolution; never part of the model + * prompt (so it does not affect prompt caching). + */ + readonly cwd?: string; + + /** + * The computer to run this turn's tools on — an SSH config `Host` alias + * (one of the `alias` values returned by `GET /computers`). Omit to inherit + * the resolved chain: per-conversation `computerId` → the workspace's + * `defaultComputerId` → `null`/local (today's behavior). Like `cwd`, this is + * a per-turn tool-execution target forwarded to tools and never part of the + * model prompt (so it does not affect prompt caching). Mirrors `cwd`. + */ + readonly computerId?: string; + + /** + * Reasoning-effort override for THIS turn only (does not persist). When + * omitted, the server resolves the conversation's persisted value, falling + * back to `"high"`. Must be one of the `ReasoningEffort` levels; an + * unrecognized value → HTTP 400 `{ error }`. + */ + readonly reasoningEffort?: ReasoningEffort; + + /** + * The workspace to assign this conversation to. Omit for `"default"`. + * If the workspace doesn't exist yet, it is auto-created (title = id, + * defaultCwd = null). + */ + readonly workspaceId?: string; + + /** + * A human-readable title for the conversation tab — persisted at creation + * time, after the new-conversation workspace setup resolves (so workspace + * assignment and first-turn system-prompt construction are not skipped) and + * before the first message append (so the append's auto-derived title does + * not overwrite it). The tab shows it instead of the default derived from + * the first message (`"Untitled"` until the first append). Omit to keep the + * auto-derived title. When present, the value is trimmed server-side; a + * whitespace-only value is treated as absent (auto-derive). A non-string + * value → HTTP 400 `{ error }`. + * + * Backward compatible — clients that omit it are unaffected. Mirrors the + * dedicated `PUT /conversations/:id/title` endpoint but is atomic with the + * turn (no second round-trip from the client). + */ + readonly title?: string; } /** @@ -117,13 +150,21 @@ export interface ChatRequest { * read `models` are unaffected. */ export interface ModelsResponse { - readonly models: readonly string[]; - readonly modelInfo?: Readonly<Record<string, ModelMetadata>>; + readonly models: readonly string[]; + readonly modelInfo?: Readonly<Record<string, ModelMetadata>>; } /** Per-model metadata returned alongside the model catalog. */ export interface ModelMetadata { - readonly contextWindow?: number; + readonly contextWindow?: number; + /** + * Whether this model can natively accept image input (vision/multimodal). + * When `true`, image chunks in a user message are passed through to the + * provider. When `false`/absent, the server's vision handoff transcribes + * images to text before the model sees them. A client may use this to show a + * vision badge in the model picker. Optional — absent when unknown. + */ + readonly vision?: boolean; } /** @@ -172,8 +213,8 @@ export interface ModelMetadata { * the store contract.) */ export interface ConversationHistoryResponse { - readonly chunks: readonly StoredChunk[]; - readonly latestSeq: number; + readonly chunks: readonly StoredChunk[]; + readonly latestSeq: number; } /** @@ -192,15 +233,15 @@ export interface ConversationHistoryResponse { * absent until then. */ export interface ConversationMetricsResponse { - readonly turns: readonly TurnMetrics[]; + readonly turns: readonly TurnMetrics[]; } export interface ConversationStatusResponse { - readonly conversationId: string; - /** True if the orchestrator has an in-memory active turn for this conversation. */ - readonly isActive: boolean; - /** The persisted lifecycle status from the conversation store. */ - readonly status: ConversationStatus; + readonly conversationId: string; + /** True if the orchestrator has an in-memory active turn for this conversation. */ + readonly isActive: boolean; + /** The persisted lifecycle status from the conversation store. */ + readonly status: ConversationStatus; } /** The aggregation window for `GET /metrics/throughput`. */ @@ -214,16 +255,16 @@ export type ThroughputPeriod = "day" | "week" | "month"; * waits). */ export interface ThroughputModelStat { - /** The model name in `<credentialName>/<model>` form (as selected). */ - readonly model: string; - /** Token-weighted average tokens/second over the period. */ - readonly tokensPerSecond: number; - /** Total output tokens generated across the period's turns. */ - readonly totalOutputTokens: number; - /** Total pure generation time across the period's turns, in milliseconds. */ - readonly totalGenMs: number; - /** Number of turns that contributed. */ - readonly turns: number; + /** The model name in `<credentialName>/<model>` form (as selected). */ + readonly model: string; + /** Token-weighted average tokens/second over the period. */ + readonly tokensPerSecond: number; + /** Total output tokens generated across the period's turns. */ + readonly totalOutputTokens: number; + /** Total pure generation time across the period's turns, in milliseconds. */ + readonly totalGenMs: number; + /** Number of turns that contributed. */ + readonly turns: number; } /** @@ -237,21 +278,21 @@ export interface ThroughputModelStat { * `tokensPerSecond` descending. */ export interface ThroughputResponse { - readonly period: ThroughputPeriod; - readonly date: string; - /** Inclusive start of the window, epoch-ms. */ - readonly start: number; - /** Exclusive end of the window, epoch-ms. */ - readonly end: number; - readonly models: readonly ThroughputModelStat[]; + readonly period: ThroughputPeriod; + readonly date: string; + /** Inclusive start of the window, epoch-ms. */ + readonly start: number; + /** Exclusive end of the window, epoch-ms. */ + readonly end: number; + readonly models: readonly ThroughputModelStat[]; } // ─── Per-conversation working directory (cwd) ───────────────────────────────── /** Response of `GET /conversations/:id/cwd`. `cwd` is null when never set. */ export interface CwdResponse { - readonly conversationId: string; - readonly cwd: string | null; + readonly conversationId: string; + readonly cwd: string | null; } /** @@ -265,8 +306,8 @@ export interface CwdResponse { * `"default"` if none). */ export interface SetCwdRequest { - readonly cwd: string; - readonly workspaceId?: string; + readonly cwd: string; + readonly workspaceId?: string; } // ─── Per-conversation reasoning effort ──────────────────────────────────────── @@ -277,8 +318,8 @@ export interface SetCwdRequest { * `"high"`). */ export interface ReasoningEffortResponse { - readonly conversationId: string; - readonly reasoningEffort: ReasoningEffort | null; + readonly conversationId: string; + readonly reasoningEffort: ReasoningEffort | null; } /** @@ -288,7 +329,7 @@ export interface ReasoningEffortResponse { * unrecognized level → HTTP 400 `{ error }`. */ export interface SetReasoningEffortRequest { - readonly reasoningEffort: ReasoningEffort; + readonly reasoningEffort: ReasoningEffort; } // ─── Per-conversation model ────────────────────────────────────────────────── @@ -299,8 +340,8 @@ export interface SetReasoningEffortRequest { * then resolves turns using the default provider + model). */ export interface ModelResponse { - readonly conversationId: string; - readonly model: string | null; + readonly conversationId: string; + readonly model: string | null; } /** @@ -311,7 +352,7 @@ export interface ModelResponse { * at turn time; an unknown model → turn error, not a 400). */ export interface SetModelRequest { - readonly model: string | null; + readonly model: string | null; } // ─── Conversation close (explicit tab close) ────────────────────────────────── @@ -331,9 +372,9 @@ export interface SetModelRequest { * `abortedTurn: false`. */ export interface CloseConversationResponse { - readonly conversationId: string; - /** True when an in-flight turn existed and was aborted by this close. */ - readonly abortedTurn: boolean; + readonly conversationId: string; + /** True when an in-flight turn existed and was aborted by this close. */ + readonly abortedTurn: boolean; } // ─── System prompt template ─────────────────────────────────────────────────── @@ -348,8 +389,8 @@ export interface CloseConversationResponse { * and reused on all subsequent turns (cache-safe — no per-turn reconstruction). */ export interface SystemPromptTemplateResponse { - /** The template text (may be empty — then no system prompt is sent). */ - readonly template: string; + /** The template text (may be empty — then no system prompt is sent). */ + readonly template: string; } /** @@ -360,7 +401,7 @@ export interface SystemPromptTemplateResponse { * conversations use the new template on their first turn. */ export interface SetSystemPromptTemplateRequest { - readonly template: string; + readonly template: string; } /** @@ -369,22 +410,39 @@ export interface SetSystemPromptTemplateRequest { * selector buttons. */ export interface SystemPromptVariable { - /** The variable type/source: `"system"`, `"file"`, `"prompt"`, `"git"`. */ - readonly type: string; - /** The variable name (e.g. `"time"`, `"date"`, `"os"`). For dynamic types, a description. */ - readonly name: string; - /** Human-readable description of what the variable resolves to. */ - readonly description: string; - /** - * When `true`, any name is valid for this type (e.g. `file:<path>` accepts - * any file path). The frontend should allow free-text input for the name. - */ - readonly dynamic?: boolean; + /** The variable type/source: `"system"`, `"file"`, `"prompt"`, `"git"`. */ + readonly type: string; + /** The variable name (e.g. `"time"`, `"date"`, `"os"`). For dynamic types, a description. */ + readonly name: string; + /** Human-readable description of what the variable resolves to. */ + readonly description: string; + /** + * When `true`, any name is valid for this type (e.g. `file:<path>` accepts + * any file path). The frontend should allow free-text input for the name. + */ + readonly dynamic?: boolean; } /** Response of `GET /system-prompt/variables`. */ export interface SystemPromptVariablesResponse { - readonly variables: readonly SystemPromptVariable[]; + readonly variables: readonly SystemPromptVariable[]; +} + +// ─── Vision settings (global) ────────────────────────────────────────────────── + +/** + * Response of `GET /settings/vision` — the global vision configuration shared + * across all conversations and vision models. + */ +export interface VisionSettingsResponse { + readonly imageLimit: number; + readonly compactionModel: string | null; +} + +/** Body of `PUT /settings/vision` — a partial update. */ +export interface SetVisionSettingsRequest { + readonly imageLimit?: number; + readonly compactionModel?: string | null; } // ─── Message queue (steering) ───────────────────────────────────────────────── @@ -405,12 +463,12 @@ export interface SystemPromptVariablesResponse { * `text` must be non-empty (after trim) → HTTP 400 `{ error }` otherwise. */ export interface QueueRequest { - readonly text: string; - /** - * The workspace to assign the conversation to (if a new conversation is - * started). Omit for `"default"`. Auto-creates if missing. - */ - readonly workspaceId?: string; + readonly text: string; + /** + * The workspace to assign the conversation to (if a new conversation is + * started). Omit for `"default"`. Auto-creates if missing. + */ + readonly workspaceId?: string; } /** @@ -422,9 +480,30 @@ export interface QueueRequest { * the chat channel as usual. */ export interface QueueResponse { - readonly conversationId: string; - readonly startedTurn: boolean; - readonly queue: readonly QueuedMessage[]; + readonly conversationId: string; + readonly startedTurn: boolean; + readonly queue: readonly QueuedMessage[]; +} + +/** + * Response body for + * `DELETE /conversations/:id/queue/:messageId` — cancel (remove) a single + * queued steering message by id so it never runs. + * + * `cancelled` is `true` when a message with the given id was found in the + * conversation's queue and removed (it will never be delivered as steering nor + * carried into a new turn). `cancelled` is `false` when the message was not in + * the queue (already drained/delivered, never existed, unknown conversation) + * OR when the message-queue extension isn't loaded (degraded — feature off). + * `queue` is the post-cancel snapshot (empty when no queue extension is + * loaded). Idempotent — cancelling a message that is no longer queued returns + * `cancelled: false` with HTTP 200 (not an error), so a client may optimistically + * fire-and-forget a cancel and reconcile from the surface. + */ +export interface QueueCancelResponse { + readonly conversationId: string; + readonly cancelled: boolean; + readonly queue: readonly QueuedMessage[]; } // ─── Per-conversation LSP status ────────────────────────────────────────────── @@ -434,39 +513,39 @@ export type LspServerState = "connected" | "starting" | "error" | "not-started"; /** One language server's status as reported to the frontend. */ export interface LspServerInfo { - /** Stable server id, e.g. "typescript", "luau-lsp". */ - readonly id: string; - /** Human-readable display name. */ - readonly name: string; - /** The resolved workspace root the server is (or would be) rooted at (absolute). */ - readonly root: string; - /** File extensions this server handles, e.g. [".ts", ".tsx"] or [".luau"]. */ - readonly extensions: readonly string[]; - /** Current connection state. */ - readonly state: LspServerState; - /** Present only when `state === "error"`: a short human-readable reason. */ - readonly error?: string; - /** - * Which config source this server was resolved from: `".dispatch/lsp.json"`, - * `"opencode.json"`, or `"built-in"` (the built-in TypeScript default). Omitted - * when not yet resolved. Surfaces config-shadow debugging to the status caller - * (a broken `.dispatch/lsp.json` silently shadowing `opencode.json`). - */ - readonly configSource?: string; + /** Stable server id, e.g. "typescript", "luau-lsp". */ + readonly id: string; + /** Human-readable display name. */ + readonly name: string; + /** The resolved workspace root the server is (or would be) rooted at (absolute). */ + readonly root: string; + /** File extensions this server handles, e.g. [".ts", ".tsx"] or [".luau"]. */ + readonly extensions: readonly string[]; + /** Current connection state. */ + readonly state: LspServerState; + /** Present only when `state === "error"`: a short human-readable reason. */ + readonly error?: string; + /** + * Which config source this server was resolved from: `".dispatch/lsp.json"`, + * `"opencode.json"`, or `"built-in"` (the built-in TypeScript default). Omitted + * when not yet resolved. Surfaces config-shadow debugging to the status caller + * (a broken `.dispatch/lsp.json` silently shadowing `opencode.json`). + */ + readonly configSource?: string; } /** Response of `GET /conversations/:id/lsp`. */ export interface LspStatusResponse { - readonly conversationId: string; - /** - * The resolved working directory the LSP connects on, or `null` when no - * cwd has been set for the conversation (then `servers` is empty). When - * non-null, this is the effective cwd — a relative persisted cwd resolved - * against the conversation's workspace `defaultCwd`. - */ - readonly cwd: string | null; - /** The language servers configured for `cwd` and their live state. */ - readonly servers: readonly LspServerInfo[]; + readonly conversationId: string; + /** + * The resolved working directory the LSP connects on, or `null` when no + * cwd has been set for the conversation (then `servers` is empty). When + * non-null, this is the effective cwd — a relative persisted cwd resolved + * against the conversation's workspace `defaultCwd`. + */ + readonly cwd: string | null; + /** The language servers configured for `cwd` and their live state. */ + readonly servers: readonly LspServerInfo[]; } // ─── MCP status ────────────────────────────────────────────────────── @@ -475,29 +554,29 @@ export type McpServerState = "connecting" | "connected" | "error" | "disconnecte /** One MCP server's status as reported to the frontend. */ export interface McpServerInfo { - /** Stable server id (the config key from `.dispatch/mcp.json`), e.g. "freecad". */ - readonly id: string; - /** Current connection state. */ - readonly state: McpServerState; - /** Present only when `state === "error"`: a short human-readable reason. */ - readonly error?: string; - /** Number of tools discovered from this server. */ - readonly toolCount: number; - /** Which config source this server was resolved from. */ - readonly configSource?: string; + /** Stable server id (the config key from `.dispatch/mcp.json`), e.g. "freecad". */ + readonly id: string; + /** Current connection state. */ + readonly state: McpServerState; + /** Present only when `state === "error"`: a short human-readable reason. */ + readonly error?: string; + /** Number of tools discovered from this server. */ + readonly toolCount: number; + /** Which config source this server was resolved from. */ + readonly configSource?: string; } /** Response of `GET /conversations/:id/mcp`. */ export interface McpStatusResponse { - readonly conversationId: string; - /** - * The resolved working directory the MCP servers are configured for, or - * `null` when no cwd has been set for the conversation (then `servers` is - * empty). Mirrors the LSP status endpoint behavior. - */ - readonly cwd: string | null; - /** The MCP servers configured for `cwd` and their live state. */ - readonly servers: readonly McpServerInfo[]; + readonly conversationId: string; + /** + * The resolved working directory the MCP servers are configured for, or + * `null` when no cwd has been set for the conversation (then `servers` is + * empty). Mirrors the LSP status endpoint behavior. + */ + readonly cwd: string | null; + /** The MCP servers configured for `cwd` and their live state. */ + readonly servers: readonly McpServerInfo[]; } /** @@ -511,17 +590,17 @@ export interface McpStatusResponse { * prefix is byte-identical to a real turn (which is what makes the cache hit). */ export interface WarmRequest { - /** The conversation whose prompt cache to warm. */ - readonly conversationId: string; + /** The conversation whose prompt cache to warm. */ + readonly conversationId: string; - /** - * The model name in `<credentialName>/<model>` form the conversation uses, so - * the warm resolves the same provider + prefix. Omit to use the server default. - */ - readonly model?: string; + /** + * The model name in `<credentialName>/<model>` form the conversation uses, so + * the warm resolves the same provider + prefix. Omit to use the server default. + */ + readonly model?: string; - /** Working directory matching the conversation's turns (for cwd-aware tool assembly). */ - readonly cwd?: string; + /** Working directory matching the conversation's turns (for cwd-aware tool assembly). */ + readonly cwd?: string; } /** @@ -533,26 +612,26 @@ export interface WarmRequest { * server responds `409` with `{ error }` instead of this body. */ export interface WarmResponse { - readonly inputTokens: number; - readonly outputTokens: number; - readonly cacheReadTokens: number; - readonly cacheWriteTokens: number; - /** - * **Cache rate** — what fraction of THIS request's prompt was served from cache: - * `round(cacheReadTokens / inputTokens * 100)` (0 when `inputTokens <= 0`). - * (`inputTokens` is the TOTAL prompt incl. cached, so this is in [0,100].) - */ - readonly cachePct: number; - /** - * **Expected cache (retention)** — of the cacheable prefix this warm touched, how - * much was still warm and read back vs. had to be (re)written: - * `round(cacheReadTokens / (cacheReadTokens + cacheWriteTokens) * 100)` (0 when the - * sum is 0). For a healthy warm this is ~**100%** (the whole prefix was still - * cached); it drops toward 0 as the cache expires/busts and the warm has to rewrite - * it. This is the warming HEALTH signal — distinct from `cachePct` (which a warm's - * tiny fresh probe makes ~equal, but which on a real turn reflects new content). - */ - readonly expectedCacheRate: number; + readonly inputTokens: number; + readonly outputTokens: number; + readonly cacheReadTokens: number; + readonly cacheWriteTokens: number; + /** + * **Cache rate** — what fraction of THIS request's prompt was served from cache: + * `round(cacheReadTokens / inputTokens * 100)` (0 when `inputTokens <= 0`). + * (`inputTokens` is the TOTAL prompt incl. cached, so this is in [0,100].) + */ + readonly cachePct: number; + /** + * **Expected cache (retention)** — of the cacheable prefix this warm touched, how + * much was still warm and read back vs. had to be (re)written: + * `round(cacheReadTokens / (cacheReadTokens + cacheWriteTokens) * 100)` (0 when the + * sum is 0). For a healthy warm this is ~**100%** (the whole prefix was still + * cached); it drops toward 0 as the cache expires/busts and the warm has to rewrite + * it. This is the warming HEALTH signal — distinct from `cachePct` (which a warm's + * tiny fresh probe makes ~equal, but which on a real turn reflects new content). + */ + readonly expectedCacheRate: number; } // ─── WebSocket chat ops ─────────────────────────────────────────────────────── @@ -567,7 +646,7 @@ export interface WarmResponse { * `AgentEvent`s (each carries `conversationId`). */ export interface ChatSendMessage extends ChatRequest { - readonly type: "chat.send"; + readonly type: "chat.send"; } /** @@ -577,8 +656,8 @@ export interface ChatSendMessage extends ChatRequest { * carrier. */ export interface ChatDeltaMessage { - readonly type: "chat.delta"; - readonly event: AgentEvent; + readonly type: "chat.delta"; + readonly event: AgentEvent; } /** @@ -587,9 +666,9 @@ export interface ChatDeltaMessage { * `TurnErrorEvent` inside a `chat.delta`.) */ export interface ChatErrorMessage { - readonly type: "chat.error"; - readonly conversationId?: string; - readonly message: string; + readonly type: "chat.error"; + readonly conversationId?: string; + readonly message: string; } /** @@ -609,8 +688,8 @@ export interface ChatErrorMessage { * `chat.subscribe` for conversations it is viewing but did not send to. */ export interface ChatSubscribeMessage { - readonly type: "chat.subscribe"; - readonly conversationId: string; + readonly type: "chat.subscribe"; + readonly conversationId: string; } /** @@ -620,8 +699,8 @@ export interface ChatSubscribeMessage { * the socket closes — again WITHOUT aborting any in-flight turn. */ export interface ChatUnsubscribeMessage { - readonly type: "chat.unsubscribe"; - readonly conversationId: string; + readonly type: "chat.unsubscribe"; + readonly conversationId: string; } /** @@ -636,14 +715,32 @@ export interface ChatUnsubscribeMessage { * latter being equivalent to `chat.send`). */ export interface ChatQueueMessage { - readonly type: "chat.queue"; - readonly conversationId: string; - readonly text: string; - /** - * The workspace to assign the conversation to (if a new conversation is - * started). Omit for `"default"`. Auto-creates if missing. - */ - readonly workspaceId?: string; + readonly type: "chat.queue"; + readonly conversationId: string; + readonly text: string; + /** + * The workspace to assign the conversation to (if a new conversation is + * started). Omit for `"default"`. Auto-creates if missing. + */ + readonly workspaceId?: string; +} + +/** + * Client → server: cancel (remove) a SINGLE queued steering message by id so + * it never runs. The WebSocket counterpart of the HTTP + * `DELETE /conversations/:id/queue/:messageId` (`QueueCancelResponse`). + * Fire-and-forget: success is confirmed by the message-queue SURFACE updating + * (the cancelled message leaves the snapshot); a failure (missing/empty + * `conversationId` or `messageId`) arrives as a `chat.error`. Idempotent — + * cancelling a message that is no longer queued (already drained/delivered) is + * a silent no-op (no surface update, no error). `messageId` is the stable + * client-visible `QueuedMessage.id` (obtained from the queue surface snapshot + * or the enqueue response). + */ +export interface ChatQueueCancelMessage { + readonly type: "chat.queue.cancel"; + readonly conversationId: string; + readonly messageId: string; } /** @@ -651,23 +748,24 @@ export interface ChatQueueMessage { * ops. A server discriminates on `type`. */ export type WsClientMessage = - | SurfaceClientMessage - | ChatSendMessage - | ChatSubscribeMessage - | ChatUnsubscribeMessage - | ChatQueueMessage; + | SurfaceClientMessage + | ChatSendMessage + | ChatSubscribeMessage + | ChatUnsubscribeMessage + | ChatQueueMessage + | ChatQueueCancelMessage; /** * Every server → client WS message: surface ops (`@dispatch/ui-contract`) + chat * ops. A client discriminates on `type`. */ export type WsServerMessage = - | SurfaceServerMessage - | ChatDeltaMessage - | ChatErrorMessage - | ConversationOpenMessage - | ConversationStatusChangedMessage - | ConversationCompactedMessage; + | SurfaceServerMessage + | ChatDeltaMessage + | ChatErrorMessage + | ConversationOpenMessage + | ConversationStatusChangedMessage + | ConversationCompactedMessage; // ─── Conversation list + metadata ──────────────────────────────────────────── @@ -677,14 +775,14 @@ export type WsServerMessage = * — the backend just signals. Additive to `WsServerMessage`. */ export interface ConversationOpenMessage { - readonly type: "conversation.open"; - readonly conversationId: string; - /** - * The conversation's actual workspace id, so a frontend can open/focus it - * in the correct workspace instead of stamping it with the viewer's current - * workspace. - */ - readonly workspaceId: string; + readonly type: "conversation.open"; + readonly conversationId: string; + /** + * The conversation's actual workspace id, so a frontend can open/focus it + * in the correct workspace instead of stamping it with the viewer's current + * workspace. + */ + readonly workspaceId: string; } /** @@ -693,15 +791,15 @@ export interface ConversationOpenMessage { * devices in real time. */ export interface ConversationStatusChangedMessage { - readonly type: "conversation.statusChanged"; - readonly conversationId: string; - readonly status: ConversationStatus; - /** - * The conversation's actual workspace id, so a frontend can open/focus it - * in the correct workspace instead of stamping it with the viewer's current - * workspace. - */ - readonly workspaceId: string; + readonly type: "conversation.statusChanged"; + readonly conversationId: string; + readonly status: ConversationStatus; + /** + * The conversation's actual workspace id, so a frontend can open/focus it + * in the correct workspace instead of stamping it with the viewer's current + * workspace. + */ + readonly workspaceId: string; } /** @@ -710,11 +808,11 @@ export interface ConversationStatusChangedMessage { * via `GET /conversations/:id` to reflect the compacted state. */ export interface ConversationCompactedMessage { - readonly type: "conversation.compacted"; - readonly conversationId: string; - readonly newConversationId: string; - readonly messagesSummarized: number; - readonly messagesKept: number; + readonly type: "conversation.compacted"; + readonly conversationId: string; + readonly newConversationId: string; + readonly messagesSummarized: number; + readonly messagesKept: number; } /** @@ -724,7 +822,7 @@ export interface ConversationCompactedMessage { * Optional `?q=` query param filters by id prefix (short-id resolution). */ export interface ConversationListResponse { - readonly conversations: readonly ConversationMeta[]; + readonly conversations: readonly ConversationMeta[]; } /** @@ -734,9 +832,9 @@ export interface ConversationListResponse { * `turnId` is the turn that produced the message (absent if no turn ran). */ export interface LastMessageResponse { - readonly conversationId: string; - readonly content: string; - readonly turnId?: string; + readonly conversationId: string; + readonly content: string; + readonly turnId?: string; } /** @@ -744,22 +842,22 @@ export interface LastMessageResponse { * signal was broadcast to connected WS clients. */ export interface OpenConversationResponse { - readonly conversationId: string; + readonly conversationId: string; } /** * Request body for `PUT /conversations/:id/title` — set a human-readable title. */ export interface SetTitleRequest { - readonly title: string; + readonly title: string; } /** * Response for `GET/PUT /conversations/:id/title` — the current title. */ export interface TitleResponse { - readonly conversationId: string; - readonly title: string; + readonly conversationId: string; + readonly title: string; } /** @@ -767,10 +865,10 @@ export interface TitleResponse { * history was compacted (old messages summarized, recent messages retained). */ export interface CompactResponse { - readonly conversationId: string; - readonly newConversationId: string; - readonly messagesSummarized: number; - readonly messagesKept: number; + readonly conversationId: string; + readonly newConversationId: string; + readonly messagesSummarized: number; + readonly messagesKept: number; } /** @@ -778,15 +876,15 @@ export interface CompactResponse { * at which automatic compaction triggers (0 = manual only). */ export interface CompactPercentResponse { - readonly conversationId: string; - readonly threshold: number; + readonly conversationId: string; + readonly threshold: number; } /** * Request body for `PUT /conversations/:id/compact-percent`. */ export interface SetCompactPercentRequest { - readonly threshold: number; + readonly threshold: number; } // ─── Workspaces ─────────────────────────────────────────────────────────────── @@ -797,10 +895,10 @@ export interface SetCompactPercentRequest { * an existing workspace is returned as-is. */ export interface EnsureWorkspaceRequest { - /** Display title. Default: the workspace id. Only used on create. */ - readonly title?: string; - /** Default cwd. Default: null (inherit server default). Only used on create. */ - readonly defaultCwd?: string | null; + /** Display title. Default: the workspace id. Only used on create. */ + readonly title?: string; + /** Default cwd. Default: null (inherit server default). Only used on create. */ + readonly defaultCwd?: string | null; } /** Response of `GET`/`PUT /workspaces/:id` — the workspace itself. */ @@ -808,17 +906,17 @@ export interface WorkspaceResponse extends Workspace {} /** Response of `GET /workspaces` — all workspaces sorted by `lastActivityAt` desc. */ export interface WorkspaceListResponse { - readonly workspaces: readonly WorkspaceEntry[]; + readonly workspaces: readonly WorkspaceEntry[]; } /** Body of `PUT /workspaces/:id/title` — rename (display only; id unchanged). */ export interface SetWorkspaceTitleRequest { - readonly title: string; + readonly title: string; } /** Body of `PUT /workspaces/:id/default-cwd` — set or clear the default cwd. */ export interface SetWorkspaceDefaultCwdRequest { - readonly defaultCwd: string | null; + readonly defaultCwd: string | null; } /** @@ -827,9 +925,9 @@ export interface SetWorkspaceDefaultCwdRequest { * workspace entity is deleted. `"default"` is non-deletable (HTTP 409). */ export interface DeleteWorkspaceResponse { - readonly workspaceId: string; - /** Conversations that were closed (status → "closed") by this delete. */ - readonly closedCount: number; + readonly workspaceId: string; + /** Conversations that were closed (status → "closed") by this delete. */ + readonly closedCount: number; } // ─── Computers ─────────────────────────────────────────────────────────────── @@ -842,7 +940,7 @@ export interface DeleteWorkspaceResponse { * block to `~/.ssh/config` and Dispatch discovers it on the next read. */ export interface ComputerListResponse { - readonly computers: readonly ComputerEntry[]; + readonly computers: readonly ComputerEntry[]; } /** @@ -859,10 +957,10 @@ export interface ComputerResponse extends Computer {} * `state === "error"`; `knownHost` mirrors the read-only `Computer` field. */ export interface ComputerStatusResponse { - readonly alias: string; - readonly state: "disconnected" | "connecting" | "connected" | "error"; - readonly error?: string; - readonly knownHost: boolean; + readonly alias: string; + readonly state: "disconnected" | "connecting" | "connected" | "error"; + readonly error?: string; + readonly knownHost: boolean; } /** @@ -874,7 +972,7 @@ export interface ComputerStatusResponse { * a 400). Mirrors the cwd/model PUT clear semantics. */ export interface SetConversationComputerRequest { - readonly computerId: string | null; + readonly computerId: string | null; } /** @@ -883,8 +981,8 @@ export interface SetConversationComputerRequest { * the workspace default → local). Parallel to `CwdResponse`. */ export interface ConversationComputerResponse { - readonly conversationId: string; - readonly computerId: string | null; + readonly conversationId: string; + readonly computerId: string | null; } /** @@ -894,7 +992,7 @@ export interface ConversationComputerResponse { * `computerId` of their own inherit this. */ export interface SetWorkspaceDefaultComputerRequest { - readonly computerId: string | null; + readonly computerId: string | null; } /** @@ -904,7 +1002,192 @@ export interface SetWorkspaceDefaultComputerRequest { * failure reason (e.g. auth refused, host unreachable) when `ok` is false. */ export interface TestComputerResponse { - readonly alias: string; - readonly ok: boolean; - readonly error?: string; + readonly alias: string; + readonly ok: boolean; + readonly error?: string; +} + +// ─── Heartbeat ─────────────────────────────────────────────────────────────── + +/** + * The per-workspace Heartbeat config — a single record stored per workspace. + * + * A heartbeat is an AI that runs on a configurable loop per workspace: when + * `enabled`, the backend summons a NEW conversation every `intervalMinutes` + * minutes, gives the heartbeat AI `systemPrompt` + sends `taskPrompt` as the + * opening user message, and inherits the workspace's computer (SSH) + cwd + + * standard tool kit. The main purpose is monitoring (e.g. checking if chats + * are stuck). + * + * `model` is a model name in `<credentialName>/<model>` form (one of the strings + * from `GET /models`), or the empty string to use the server default. + * `reasoningEffort` is `null` to inherit the workspace default, or an explicit + * level. The scheduler resets its timer after each run completes (not a fixed + * wall-clock schedule); on backend restart it resumes scheduling for enabled + * heartbeats. + * + * `inactiveOnly` (default `true`) gates each fire on the configured workspace + * having NO active agents (no conversation with status `"active"` or `"queued"`): + * the heartbeat stays quiet while the user is actively working, and only fires + * when the workspace is idle. Set `false` to fire unconditionally. + */ +export interface HeartbeatConfig { + /** Whether the heartbeat loop is active for this workspace. */ + readonly enabled: boolean; + /** + * When `true` (the default), the heartbeat SKIPS a fire when the configured + * workspace has any active agents — conversations whose persisted status is + * `"active"` (driving a turn) or `"queued"` (waiting on the message queue). + * The fire is silently skipped (no run is recorded); the scheduler re-arms + * and tries again at the next interval. When `false`, the heartbeat fires + * unconditionally regardless of workspace activity. The spawned heartbeat + * conversation lives in the DEDICATED heartbeat workspace, so it never + * counts as an "active agent" of the configured workspace (no self-block). + */ + readonly inactiveOnly: boolean; + /** Custom system prompt for the heartbeat AI (empty = no system prompt). */ + readonly systemPrompt: string; + /** Task prompt sent as the first user message when the heartbeat fires. */ + readonly taskPrompt: string; + /** How often to fire, in minutes (default 30). */ + readonly intervalMinutes: number; + /** Model name (`<credentialName>/<model>`), or empty string = server default. */ + readonly model: string; + /** Reasoning-effort level, or `null` = inherit the workspace default. */ + readonly reasoningEffort: ReasoningEffort | null; +} + +/** + * Body of `PUT /workspaces/:id/heartbeat` — a partial update. All fields are + * optional; only the provided fields are applied. `reasoningEffort` accepts + * `null` to clear to the workspace default. `intervalMinutes` must be a positive + * integer (clamped server-side to a minimum of 1). An unrecognized + * `reasoningEffort` → HTTP 400. + */ +export interface UpdateHeartbeatRequest { + readonly enabled?: boolean; + readonly inactiveOnly?: boolean; + readonly systemPrompt?: string; + readonly taskPrompt?: string; + readonly intervalMinutes?: number; + readonly model?: string; + readonly reasoningEffort?: ReasoningEffort | null; +} + +/** The status of a single heartbeat run. */ +export type HeartbeatRunStatus = "running" | "completed" | "stopped"; + +/** + * One heartbeat run — created each time the heartbeat fires. A run tracks the + * conversation it spawned and its lifecycle. `triggeredAt` is an ISO-8601 + * timestamp. `status` is `"running"` while the turn is in flight, + * `"completed"` when the turn sealed normally, or `"stopped"` when the user + * stopped it via the stop endpoint (the turn is aborted and seals `"stopped"`). + */ +export interface HeartbeatRun { + readonly id: string; + readonly conversationId: string; + readonly triggeredAt: string; + readonly status: HeartbeatRunStatus; +} + +/** Response of `GET /workspaces/:id/heartbeat/runs` — runs, most-recent first. */ +export interface HeartbeatRunsResponse { + readonly runs: readonly HeartbeatRun[]; +} + +/** Response of `POST /workspaces/:id/heartbeat/runs/:runId/stop`. */ +export interface StopHeartbeatRunResponse { + readonly ok: true; +} + +// ─── Provider concurrency limits ────────────────────────────────────────────── + +/** + * Response of `GET /concurrency/limits` — all providers with configured + * concurrency limits. Each entry pairs a provider id (e.g. "umans", + * "openai-compat") with its maximum concurrent in-flight requests. Providers + * not listed here have no limit (unlimited). + */ +export interface ConcurrencyLimitsResponse { + readonly limits: readonly { readonly providerId: string; readonly limit: number }[]; +} + +/** + * Body of `PUT /concurrency/limits/:providerId` — set or update the concurrency + * limit for a provider. `limit` must be a positive integer. When a limit is + * set, requests beyond the limit queue (oldest-agent-first) rather than being + * sent immediately. + */ +export interface SetConcurrencyLimitRequest { + readonly limit: number; +} + +/** Response of `GET/PUT /concurrency/limits/:providerId` — the configured limit. */ +export interface ConcurrencyLimitResponse { + readonly providerId: string; + readonly limit: number; +} + +/** + * One provider's live concurrency status. + * + * - `inFlight`: how many slots are currently held (tokens being generated). + * - `queued`: how many agents are waiting for a slot. + * - `paused`: whether the queue is paused due to a 429 backoff. + * - `pausedUntil`: when the pause expires (epoch-ms), present only when paused. + * - `cooldownMs`: the per-slot release cooldown (ms). A recycled slot is held + * this long before the next waiter is admitted — covers the upstream + * provider's accounting lag. Configurable + persisted per provider. + * - `autoReduced`: whether the limit was auto-reduced by 1 after a 429 + * (adaptive headroom, one-way, persisted). The user restores the limit + * manually via `PUT /concurrency/limits/:providerId`, which clears the flag. + * When `true`, the frontend renders a visible notice/banner. + * - `autoReducedFrom`: the original limit before auto-reduction (present only + * when `autoReduced` is true). + * - `notice`: a human-readable notice string for the frontend to render as a + * banner when the limit was auto-reduced (present only when `autoReduced`). + */ +export interface ConcurrencyStatusEntry { + readonly providerId: string; + readonly limit: number; + readonly inFlight: number; + readonly queued: number; + readonly paused: boolean; + readonly pausedUntil?: number; + readonly cooldownMs: number; + readonly autoReduced: boolean; + readonly autoReducedFrom?: number; + readonly notice?: string; +} + +/** + * Response of `GET /concurrency/status` — live status for every provider with a + * configured limit. Providers without a limit are absent (they are unlimited). + */ +export interface ConcurrencyStatusResponse { + readonly providers: readonly ConcurrencyStatusEntry[]; +} + +// ─── Provider concurrency cooldown ──────────────────────────────────────────── + +/** + * Response of `GET /concurrency/cooldown/:providerId` — the per-slot release + * cooldown (ms) for a provider. A recycled slot is held this long before the + * next waiter is admitted, covering the upstream provider's accounting lag. + * When no cooldown was explicitly set, the server default (350ms) is returned. + */ +export interface ConcurrencyCooldownResponse { + readonly providerId: string; + readonly cooldownMs: number; +} + +/** + * Body of `PUT /concurrency/cooldown/:providerId` — set the release cooldown + * (ms) for a provider. `cooldownMs` must be a non-negative integer (0 = no + * cooldown, instant re-admission). The value is persisted and applied to + * subsequently recycled slots. + */ +export interface SetConcurrencyCooldownRequest { + readonly cooldownMs: number; } diff --git a/packages/transport-contract/tsconfig.json b/packages/transport-contract/tsconfig.json index 5a5de0f..c5e5978 100644 --- a/packages/transport-contract/tsconfig.json +++ b/packages/transport-contract/tsconfig.json @@ -1,6 +1,6 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, - "include": ["src/**/*.ts"], - "references": [{ "path": "../ui-contract" }, { "path": "../wire" }] + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../ui-contract" }, { "path": "../wire" }] } |
