diff --git a/packages/agent/README.md b/packages/agent/README.md index 0fd56f0..b637eb6 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -43,10 +43,12 @@ pure text/JSON steps and the Agent only for the steps that touch tools. ## Install ```bash -pnpm add @coder/ai-sdk-agent ai zod +pnpm add @coder/ai-sdk-agent ai@^6 zod ``` -Requires Node ≥ 20 and `ai` v6. +Requires Node ≥ 20 and `ai` v6 — the constructors throw an actionable error when +another `ai` major is detected (the guard fails open when the installed version +can't be resolved), instead of failing cryptically mid‑generation. ## Quick start @@ -114,6 +116,12 @@ AI SDK tool loop — your `execute` runs in your process. appear in the transcript as `providerExecuted` tool calls/results — you observe them, you don't execute them. +Migration note: since v0.2.1 server‑executed tools stream with `dynamic: true` +(they aren't in your `ToolSet`, and the AI SDK only accepts unknown tool names on +dynamic calls). In UI message streams they therefore surface as `dynamic-tool` +parts rather than `tool-{name}` parts — key off `toolName`, not `part.type`, when +rendering them. + ## Files There are two distinct ways to get a file to the agent, depending on whether the model should @@ -203,13 +211,81 @@ created on the first turn and reused for subsequent `generate()`/`stream()` call conversation with server‑side history). `agent.chatId` is the current chat id. - `agent.resetSession()` — start a fresh chat on the next turn (reuse one instance for sequential turns; you don't need a new agent per turn). -- `agent.interrupt()` — interrupt an in‑flight generation. -- `agent.archive()` — archive the underlying chat (cleanup; see [Cleanup](#cleanup)). +- `agent.interrupt({ signal? })` — interrupt an in‑flight generation. +- `agent.archive({ signal? })` — archive the underlying chat (cleanup; see [Cleanup](#cleanup)). - `agent.listModels()` — list the deployment's model configs, so you don't have to guess the `model` hint. - Resume a prior chat: `new CoderAgent({ …, chatId: "…" })`. +Interrupting is asynchronous on the server: `interrupt()` resolves as soon as the +interrupt is acknowledged, and the run keeps winding down for a few seconds +afterwards. The client‑level `client.interruptChat(chatId, { wait: true })` sends +`?wait=true` to ask the server to hold the response until the run has stopped — +current Coder servers ignore the unknown parameter and still return immediately, +so confirm completion via the event stream (e.g. [`watchChats`](#watching-chats)) +rather than relying on it. + A single instance is **single‑flight** — don't run concurrent generations against it. For concurrency, use one instance per session (and see [Workspaces & quota](#workspaces--quota)). +## Rehydrating chat history + +Chat history lives on the server. To render an existing chat in a UI (e.g. after +a reload), fetch its messages with the `CoderChatClient` (`agent.client`, or one +you construct — see [Auth](#auth)) and convert them with +`chatMessagesToUIMessages` — the mapping mirrors what a live‑streamed transcript +of the same turn looks like: + +```ts +import { chatMessagesToUIMessages } from "@coder/ai-sdk-agent"; + +const { messages } = await client.getMessages(chatId); +const uiMessages = chatMessagesToUIMessages(messages); +// e.g. in React: useChat({ messages: uiMessages }) +``` + +The converter sorts by message id, so the endpoint's newest‑first default page +order (and any pagination order) is safe to pass straight in — `useChat` always +receives a chronological transcript. + +Tool calls become `dynamic-tool` parts with their results folded in, `source` +parts become `source-url` parts, and unknown part kinds are skipped silently, so +history written by newer Coder servers degrades gracefully. One caveat: history +does not record which tool names were client (`ToolSet`) tools, so _every_ tool +call rehydrates as `dynamic-tool` — live, client tools stream as statically +typed `tool-{name}` parts. Render tools by name (ai's +`isToolOrDynamicToolUIPart` and `getToolOrDynamicToolName`) rather than by +exact `part.type` and the difference disappears. Persisted `file` +parts carry only a `file_id` (no bytes, usually no URL), so pass a `fileUrl` +resolver to keep attachments visible — download the bytes with +`client.getChatFile(fileId)` and return a data:/object/proxy URL; parts that end +up without a URL are skipped: + +```ts +chatMessagesToUIMessages(messages, { + fileUrl: (part) => (part.file_id ? `/api/files/${part.file_id}` : undefined), +}); +``` + +## Watching chats + +`client.watchChats({ signal })` yields lifecycle events (status/title changes, +creation, deletion, …) for **every chat visible to the authenticated user** as an +async iterable, backed by the `/api/experimental/chats/watch` WebSocket: + +```ts +for await (const event of client.watchChats({ signal })) { + if (event.kind === "status_change") console.log(event.chat.id, event.chat.status); +} +``` + +Unlike the per‑chat event stream, this is a long‑lived subscription: dropped +connections are redialed automatically with exponential backoff (1s doubling to +a 30s cap, reset once an event arrives). Iteration ends only when the signal +aborts, or with a terminal `CoderApiError` when the server rejects the upgrade +with a 4xx — bad/expired token, or an older Coder server without the endpoint +(404). For custom plumbing (own client, browser sockets), the standalone +`watchChatEvents({ baseUrl, token, signal, webSocketFactory })` export provides +the same stream without a `CoderChatClient`. + ## Timeouts & cancellation Pass an `abortSignal` to `generate()`/`stream()` to cancel a turn. Aborting @@ -242,17 +318,28 @@ truncated result as if the turn had finished. ## Cleanup `archive()` soft‑hides the chat (it stays in listings as `archived: true`; there -is no hard delete yet). To make cleanup ride scope exit instead of a `finally` you -have to remember, the agent is an **async disposable**: +is no hard delete yet). A freshly interrupted chat keeps winding down server‑side +for a few seconds, during which archiving 409s — `archive()` retries those 409s +(~1s apart, up to ~15s overall; tune with `settleDeadlineMs` / +`settleRetryDelayMs`) and rethrows the last one if the chat never settles. Any +other failure, including your own abort, rethrows immediately. + +To make cleanup ride scope exit instead of a `finally` you have to remember, the +agent is an **async disposable**: ```ts await using agent = new CoderAgent({ /* … */ }); const { text } = await agent.generate({ prompt: "…" }); -// agent.archive() runs automatically when the scope exits (best‑effort). +// agent.interrupt() + agent.archive() run automatically when the scope exits. ``` +Disposal interrupts any in‑flight run, then archives. It is **bounded and never +throws** (~15s overall, best‑effort): disposal errors are swallowed so they can't +mask the scope's own error. Call `archive()` directly when you need guaranteed +cleanup. + In a request handler that returns before a fire‑and‑forget `archive()` settles, the archive can be abandoned — `await using` (or an awaited `archive()` in `finally`) avoids accumulating live chats. @@ -284,6 +371,36 @@ try { SDK‑level retry could duplicate a turn. Prefer catching `retryable` errors and retrying the whole step deliberately. +## Usage & cost + +Results carry normalized token usage in `usage`, plus the verbatim snake_case +wire usage in `usage.raw` for fields the normalized shape has no slot for +(`context_limit`, cost, runtime, …). When the server reports them, +`total_cost_micros` (micro‑USD) and `total_runtime_ms` are also mirrored under +`providerMetadata.coder` on finish. Both are **absence‑tolerant mirrors**: +today's Coder servers only expose cost on the aggregate cost endpoints +(`/api/experimental/chats/cost/*`), so expect these fields to be absent — +nothing is emitted when the server sends neither. + +Forward usage to a UI via message metadata: + +```ts +const result = await agent.stream({ prompt: "…" }); +return result.toUIMessageStream({ + messageMetadata: ({ part }) => + part.type === "finish-step" + ? { usage: part.usage, coder: part.providerMetadata?.coder } + : undefined, +}); +``` + +## Sources + +Model configs with web search enabled emit `source` parts. These flow through to +`result.sources` and, in UI message streams, `source-url` parts (pass +`sendSources: true` to `toUIMessageStream` — the AI SDK omits them by default). +Earlier releases dropped them. + ## Structured output Coder Agents has no server‑side `response_format`, so `CoderAgent` cannot @@ -391,6 +508,47 @@ Compose additional client tools through the helper — rather than passing `tools:` to the constructor next to the spread, where the later key silently clobbers the other map. +## Workspace previews + +When the agent is bound to a workspace (the `workspaceId` setting), you can +resolve — and share — the browser URL where a port on that workspace is served, +e.g. the dev server the agent just started: + +```ts +const { url } = await agent.getPreview({ port: 3000 }); +// → https://3000--main--dev--alice.apps.example.com (private to the workspace owner) + +const shared = await agent.sharePreview({ port: 3000, shareLevel: "authenticated" }); +// shared.url is now reachable by any logged-in user; shared.shareLevel is the level in effect +``` + +Both are built on the stable v2 workspace APIs (workspace lookup + the wildcard +apps host; `sharePreview` adds a port‑share upsert), so they work against old +Coder servers — no experimental endpoints. + +- `getPreview({ port, agentName?, protocol?, signal? })` composes the subdomain + URL. The URL honors the port's current share level — private to the workspace + owner unless shared. `agentName` is optional when the workspace has exactly one + agent (with several, the error lists the candidates); `protocol: "https"` means + the app speaks TLS _inside the workspace_ (it adds the `s` label suffix, + `3000s--…`) and does not affect the browser scheme. +- `sharePreview({ port, shareLevel?, … })` additionally upserts the port's share + level (re‑invoking updates it in place) and returns the level in effect. + `shareLevel` is `"authenticated"` (any logged‑in user; the default), + `"organization"` (members of the workspace's organization; requires a newer + Coder server), or `"public"` (no auth at all — mind what the port serves). + Reverting to owner‑only means deleting the share; `"owner"` is not accepted on + upsert. +- Clear failures instead of broken URLs: a deployment without a wildcard access + URL (`--wildcard-access-url`) yields an explanatory error, and a server that + predates port sharing (< Coder v2.9) yields a 404 `CoderApiError` saying so. + Ports below 1000 are rejected up front for the same reason — Coder subdomain + URLs only encode 4–5 digit ports, so `80--agent--…` would be parsed as an app + named "80" and never resolve; serve the preview on a higher port. + +The preview helpers call non‑chat endpoints, so they need `baseUrl` + `token` +credentials — pass them alongside `client` if you construct one yourself. + ## Workspaces & quota A `CoderAgent` is one server‑side chat, and — depending on its configuration and @@ -423,8 +581,18 @@ running many agents at once: | `stopWhen` | AI SDK stop condition(s); default `stepCountIs(64)` | | `maxRetries` | default `0` — SDK retries can duplicate server‑side turns; override with care | | `requestTimeoutMs` | per‑turn time budget (ms); interrupts the run and rejects (`kind: "timeout"`) instead of hanging | +| `settleDeadlineMs` | overall deadline for bounded cleanup (`archive()` 409 retries, disposal); default 15 000 | +| `settleRetryDelayMs` | pause between `archive()` retries while the chat settles; default 1000 | | `chatId` | resume an existing chat | +The `model` hint resolves against the deployment's model configs in order: a +config UUID is used as‑is, then an exact `provider:model` match, an exact model +id, a display‑name substring (case‑insensitive), and finally a model‑id +substring. Partial payloads from older/newer servers are tolerated (entries +match on the fields they carry), and an unresolvable hint falls back to the +server's default model instead of failing. Use `agent.listModels()` to see +what's available. + ## How it works ``` diff --git a/packages/agent/examples/06-structured-output.ts b/packages/agent/examples/06-structured-output.ts index 90a7545..a0c2ab8 100644 --- a/packages/agent/examples/06-structured-output.ts +++ b/packages/agent/examples/06-structured-output.ts @@ -224,12 +224,14 @@ function structuredOutput( } /** Cleanup that tolerates a chat still winding down. A settled (or interrupted) - * turn resumes server-side for a few seconds, and archive() 409s until the chat - * parks — so interrupt and retry under a deadline instead of giving up on the - * first attempt (a bare `archive()` or `await using` would leak the chat here). - * Every attempt carries an AbortSignal so a stalled request is truly cancelled - * (not abandoned mid-flight), and the deadline is checked BEFORE each attempt so - * the documented bound holds. Retries cover only the wind-down outcomes — a 409 + * turn resumes server-side for a few seconds, and archiving 409s until the chat + * parks. `CoderAgent.archive()` / `await using` now retry that wind-down window + * themselves (bounded, 409-only) — but they rethrow/swallow at the end, while + * this example wants warn-and-continue against the raw client. Kept for that, + * and as a reference for driving the retry by hand: every attempt carries an + * AbortSignal so a stalled request is truly cancelled (not abandoned + * mid-flight), and the deadline is checked BEFORE each attempt so the + * documented bound holds. Retries cover only the wind-down outcomes — a 409 * or an aborted attempt; anything else (401/403/404) will not heal: warn, stop. */ async function archiveQuietly(agent: { readonly chatId: string | undefined; diff --git a/packages/agent/src/agent/coder-agent.ts b/packages/agent/src/agent/coder-agent.ts index 19abcf7..80b6012 100644 --- a/packages/agent/src/agent/coder-agent.ts +++ b/packages/agent/src/agent/coder-agent.ts @@ -8,6 +8,7 @@ import { ToolLoopAgent, type ToolSet, } from "ai"; +import { assertSupportedAiVersion } from "../ai-version.js"; import { type ChatFileInput, CoderChatClient, @@ -15,7 +16,13 @@ import { type UploadedChatFile, } from "../coder/client.js"; import type { ChatModelConfig } from "../coder/types.js"; -import { CoderAgentError } from "../errors.js"; +import { + type PreviewShareLevel, + resolveWorkspacePreview, + shareWorkspacePreview, + type WorkspaceApiConnection, +} from "../coder/workspaces.js"; +import { CoderAgentError, CoderApiError } from "../errors.js"; import type { FileContent } from "../files.js"; import { CoderLanguageModel } from "../model/language-model.js"; import { CODER_PROVIDER_OPTIONS } from "../model/prompt.js"; @@ -55,9 +62,89 @@ function makeChatAttachment(file: UploadedChatFile): ChatAttachment { * its own server-side loop at 1200 steps independently. */ const DEFAULT_STOP = stepCountIs(64); +/** + * Bounded-cleanup defaults. An interrupted chat keeps winding down server-side + * for a few seconds, during which archiving 409s — {@link CoderAgent.archive} + * and `[Symbol.asyncDispose]` retry those 409s every ~`SETTLE_RETRY_DELAY_MS` + * for up to ~`SETTLE_DEADLINE_MS` overall before giving up. + */ +const SETTLE_DEADLINE_MS = 15_000; +const SETTLE_RETRY_DELAY_MS = 1_000; + +/** + * Clamps a user-supplied delay to what `AbortSignal.timeout`/`setTimeout` + * accept: a non-negative integer no larger than the 32-bit timer cap. + * Anything else (negative, fractional beyond flooring, `NaN`, `Infinity`) + * falls back to the default rather than throwing at dispose time. + */ +function sanitizeDelayMs(value: number | undefined, fallback: number): number { + const MAX_TIMER_MS = 2 ** 31 - 1; + if (value === undefined || Number.isNaN(value)) return fallback; + if (value === Number.POSITIVE_INFINITY) return MAX_TIMER_MS; + if (!Number.isFinite(value) || value < 0) return fallback; + return Math.min(Math.floor(value), MAX_TIMER_MS); +} + +/** Abort-aware sleep: resolves after `ms`, or rejects with the signal's reason. */ +function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason); + return; + } + const onAbort = () => { + clearTimeout(timer); + reject(signal?.reason); + }; + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +/** + * Archive a chat, retrying while it is still settling. Interrupting a run does + * not stop it instantly — chatd winds it down for a few more seconds and 409s + * archive attempts in that window. Only those 409s are retried (other failures + * and caller aborts rethrow immediately); once `deadlineMs` has elapsed the + * last 409 is rethrown. + */ +async function archiveWhenSettled( + client: CoderChatClient, + chatId: string, + opts: { deadlineMs: number; retryDelayMs: number; signal?: AbortSignal }, +): Promise { + // The deadline decides when to stop retrying; the timeout signal additionally + // unsticks an archive request that hangs (both track the same clock). + const timeout = AbortSignal.timeout(opts.deadlineMs); + const signal = opts.signal ? AbortSignal.any([opts.signal, timeout]) : timeout; + const deadline = Date.now() + opts.deadlineMs; + for (;;) { + try { + await client.archiveChat(chatId, signal); + return; + } catch (err) { + if (!(err instanceof CoderApiError) || err.status !== 409) throw err; + // Stop when there is no budget left for another pause + attempt, surfacing + // the 409 (the actionable error) rather than an opaque timeout. + if (Date.now() + opts.retryDelayMs > deadline) throw err; + // Pause on the caller's signal only — the deadline is enforced above. + await sleep(opts.retryDelayMs, opts.signal); + } + } +} + export interface CoderAgentSettings { // --- connection (provide a client, or baseUrl + token) --- - /** A pre-built {@link CoderChatClient}. Mutually exclusive with baseUrl/token. */ + /** + * A pre-built {@link CoderChatClient}, used instead of constructing one from + * `baseUrl`/`token`. The preview helpers ({@link CoderAgent.getPreview}, + * {@link CoderAgent.sharePreview}) call non-chat endpoints and therefore + * still need credentials — pass `baseUrl` + `token` alongside `client` to + * enable them. + */ client?: CoderChatClient; /** Coder deployment base URL, e.g. `https://dev.coder.com`. */ baseUrl?: string; @@ -113,6 +200,60 @@ export interface CoderAgentSettings { * non-positive means no limit. */ requestTimeoutMs?: number; + /** + * Overall deadline in milliseconds for bounded cleanup: how long + * {@link CoderAgent.archive} keeps retrying while a freshly interrupted chat + * settles server-side, and how long `[Symbol.asyncDispose]` may take in + * total. Default 15 000. Primarily a test/tuning knob. + */ + settleDeadlineMs?: number; + /** + * Pause in milliseconds between {@link CoderAgent.archive} retries while the + * chat settles. Default 1000. Primarily a test/tuning knob. + */ + settleRetryDelayMs?: number; +} + +/** Options for {@link CoderAgent.getPreview}. */ +export interface WorkspacePreviewOptions { + /** Workspace port the app listens on (4–5 digit ports parse most reliably). */ + port: number; + /** + * Agent that serves the port. Optional when the workspace has exactly one + * agent; required when it has several (the error lists the candidates). + */ + agentName?: string; + /** + * Protocol the app speaks *inside the workspace* on that port — `https` adds + * the `s` suffix (`3000s--…`) so the proxy connects over TLS. This is not + * the browser scheme, which follows the deployment's access URL. + * Default `"http"`. + */ + protocol?: "http" | "https"; + signal?: AbortSignal; +} + +/** A resolved workspace preview URL (see {@link CoderAgent.getPreview}). */ +export interface WorkspacePreview { + /** Browser-openable subdomain app URL, e.g. `https://3000--main--dev--alice.apps.example.com`. */ + url: string; +} + +/** Options for {@link CoderAgent.sharePreview}. */ +export interface SharePreviewOptions extends WorkspacePreviewOptions { + /** + * Who may open the preview: `authenticated` (any logged-in user; the + * default), `organization` (members of the workspace's organization; + * requires a newer Coder server), or `public` (no auth at all — mind what + * the port serves). + */ + shareLevel?: PreviewShareLevel; +} + +/** A shared workspace preview (see {@link CoderAgent.sharePreview}). */ +export interface SharedWorkspacePreview extends WorkspacePreview { + /** The share level now in effect for the port. */ + shareLevel: PreviewShareLevel; } /** @@ -135,8 +276,15 @@ export class CoderAgent implements Agent; readonly #organizationId: string; readonly #workspaceFiles: WorkspaceFileStore | undefined; + readonly #workspaceId: string | undefined; + readonly #workspaceApi: WorkspaceApiConnection | undefined; + readonly #settleDeadlineMs: number; + readonly #settleRetryDelayMs: number; constructor(settings: CoderAgentSettings) { + // Fail fast on an incompatible AI SDK major (see peer dependency `ai@^6`). + assertSupportedAiVersion(); + if (settings.client) { this.#client = settings.client; } else if (settings.baseUrl && settings.token) { @@ -154,6 +302,17 @@ export class CoderAgent implements Agent implements Agent { + /** + * Interrupt the in-flight generation, if any. Resolves as soon as the server + * acknowledges the interrupt — the run keeps winding down asynchronously for + * a few seconds afterwards (see {@link CoderAgent.archive}). + */ + async interrupt(opts?: { signal?: AbortSignal }): Promise { const id = this.#model.chatId; - if (id) await this.#client.interruptChat(id); + if (id) await this.#client.interruptChat(id, opts?.signal); } - /** Archive the underlying chat (safe cleanup; hides it from listings). */ - async archive(): Promise { + /** + * Archive the underlying chat (safe cleanup; hides it from listings). + * + * A freshly interrupted/settled chat can keep winding down server-side for a + * few seconds, during which the server rejects archiving with a 409. Those + * 409s are retried (~1s apart, ~15s overall — see `settleDeadlineMs` / + * `settleRetryDelayMs`) and the last one is rethrown if the chat never + * settles; any other failure, including a caller abort, rethrows immediately. + */ + async archive(opts?: { signal?: AbortSignal }): Promise { const id = this.#model.chatId; - if (id) await this.#client.archiveChat(id); + if (!id) return; + await archiveWhenSettled(this.#client, id, { + deadlineMs: this.#settleDeadlineMs, + retryDelayMs: this.#settleRetryDelayMs, + signal: opts?.signal, + }); } /** * Clean up the chat when the agent leaves an `await using` scope, so cleanup * rides scope exit instead of a separate call you have to remember in a - * `finally`. Interrupts any in-flight server run, then archives the chat. - * Best-effort: disposal errors are swallowed so they can't mask the scope's - * own error. + * `finally`. Interrupts any in-flight server run, then archives the chat + * (retrying while the interrupted run settles). Best-effort and bounded + * (~15s overall): disposal errors are swallowed after the bounded attempts + * so they can't mask the scope's own error — call {@link CoderAgent.archive} + * directly when you need guaranteed cleanup. * * @example * ```ts @@ -265,13 +443,15 @@ export class CoderAgent implements Agent implements Agent { + const { conn, workspaceId } = this.#previewContext("getPreview"); + const { url } = await resolveWorkspacePreview( + conn, + { workspaceId, port: opts.port, agentName: opts.agentName, protocol: opts.protocol }, + opts.signal, + ); + return { url }; + } + + /** + * Share a port on the bound workspace and return its preview URL. Upserts + * the port's share level — `authenticated` by default — so teammates (or, + * with `public`, anyone) can open what the agent is serving; re-invoking + * updates the level in place. On Coder servers that predate port sharing + * (< v2.9) this fails with a 404 {@link CoderApiError} saying so. + */ + async sharePreview(opts: SharePreviewOptions): Promise { + const { conn, workspaceId } = this.#previewContext("sharePreview"); + return shareWorkspacePreview( + conn, + { + workspaceId, + port: opts.port, + agentName: opts.agentName, + protocol: opts.protocol, + shareLevel: opts.shareLevel, + }, + opts.signal, + ); + } } diff --git a/packages/agent/src/ai-version.ts b/packages/agent/src/ai-version.ts new file mode 100644 index 0000000..619e084 --- /dev/null +++ b/packages/agent/src/ai-version.ts @@ -0,0 +1,74 @@ +import { CoderAgentError } from "./errors.js"; + +/** The `ai` peer-dependency major this package is built against (`ai@^6`). */ +const SUPPORTED_AI_MAJOR = 6; + +/** + * Decides whether a resolved `ai` package version is supported. Returns the + * error message to raise for a known-incompatible major, or `undefined` to + * proceed. Unparseable strings also return `undefined`: the guard fails open + * rather than inventing a failure on exotic version formats. + */ +export function unsupportedAiVersionMessage(version: string): string | undefined { + const major = Number(/^v?(\d+)(?:[.+-]|$)/.exec(version.trim())?.[1]); + if (!Number.isSafeInteger(major) || major === SUPPORTED_AI_MAJOR) return undefined; + return ( + `@coder/ai-sdk-agent supports the \`ai\` package v${SUPPORTED_AI_MAJOR} only ` + + `(peer dependency \`ai@^${SUPPORTED_AI_MAJOR}\`), but ai@${version} is installed. ` + + `Other majors fail at runtime in confusing ways (brand-check TypeErrors, silently ` + + `empty streams). Install a compatible version, e.g. \`pnpm add ai@^${SUPPORTED_AI_MAJOR}\`.` + ); +} + +/** + * Best-effort resolution of the installed `ai` package's version via + * `createRequire(import.meta.url)("ai/package.json")` (both v6 and v7 export + * `./package.json`). Returns `undefined` whenever resolution isn't possible — + * non-Node runtime, bundle without module resolution, `ai` not installed, … — + * because the guard must never introduce a failure of its own. `node:module` + * is reached through `process.getBuiltinModule` (Node ≥ 20.16) instead of a + * static import, which would crash non-Node runtimes at module-load time. + */ +function resolveInstalledAiVersion(): string | undefined { + try { + const nodeModule = + typeof process !== "undefined" && typeof process.getBuiltinModule === "function" + ? process.getBuiltinModule("node:module") + : undefined; + const requireFromHere = nodeModule?.createRequire(import.meta.url); + const pkg = requireFromHere?.("ai/package.json") as { version?: unknown } | undefined; + return typeof pkg?.version === "string" ? pkg.version : undefined; + } catch { + return undefined; + } +} + +/** + * Memoized verdict: `undefined` = not yet computed, `null` = supported (or + * unresolvable), a string = the message to throw on every construction. + */ +let cachedMessage: string | null | undefined; + +/** + * Asserts that the installed `ai` major matches this package's peer range + * (`ai@^6`). Called from the `CoderAgent`/`CoderLanguageModel` constructors so + * an incompatible AI SDK fails fast with an actionable error instead of the + * cryptic failures it produces mid-generation. Fails open when the installed + * version cannot be resolved. The verdict is memoized across calls. + * + * @param resolveVersion Test seam; production callers use the default. + */ +export function assertSupportedAiVersion( + resolveVersion: () => string | undefined = resolveInstalledAiVersion, +): void { + if (cachedMessage === undefined) { + const version = resolveVersion(); + cachedMessage = version === undefined ? null : (unsupportedAiVersionMessage(version) ?? null); + } + if (cachedMessage !== null) throw new CoderAgentError(cachedMessage); +} + +/** Clears the memoized verdict so resolution runs again (unit-test hook). */ +export function resetAiVersionCheckForTests(): void { + cachedMessage = undefined; +} diff --git a/packages/agent/src/coder/client.ts b/packages/agent/src/coder/client.ts index 1eda252..a2cb792 100644 --- a/packages/agent/src/coder/client.ts +++ b/packages/agent/src/coder/client.ts @@ -6,6 +6,7 @@ import { type ChatMessagesResponse, type ChatModelConfig, type ChatStreamEvent, + type ChatWatchEvent, type CreateChatMessageRequest, type CreateChatMessageResponse, type CreateChatRequest, @@ -14,7 +15,7 @@ import { type UpdateChatRequest, type UploadChatFileResponse, } from "./types.js"; -import { streamChatEvents, type WebSocketFactory } from "./ws.js"; +import { streamChatEvents, watchChatEvents, type WebSocketFactory } from "./ws.js"; /** A file to upload as a chat attachment. */ export interface ChatFileInput { @@ -184,8 +185,31 @@ export class CoderChatClient { return this.#request("POST", `${API_PREFIX}/${chatId}/tool-results`, req, signal); } - interruptChat(chatId: string, signal?: AbortSignal): Promise { - return this.#request("POST", `${API_PREFIX}/${chatId}/interrupt`, undefined, signal); + /** + * Interrupt the chat's in-flight run (`POST /interrupt`). The server flips + * an active run to `interrupting` and responds immediately with the updated + * chat — it does not wait for the run to actually stop. + * + * Pass `{ wait: true }` to send `?wait=true`, asking the server to hold the + * response until the run has stopped. Coder servers without that param + * ignore the unknown query harmlessly and still return immediately, so + * callers should be prepared to confirm completion via the event stream. + * + * The second parameter also accepts a bare `AbortSignal` (the historical + * signature). + */ + interruptChat( + chatId: string, + optsOrSignal?: AbortSignal | { wait?: boolean; signal?: AbortSignal }, + ): Promise { + const opts = optsOrSignal instanceof AbortSignal ? { signal: optsOrSignal } : optsOrSignal; + const query = opts?.wait ? "?wait=true" : ""; + return this.#request( + "POST", + `${API_PREFIX}/${chatId}/interrupt${query}`, + undefined, + opts?.signal, + ); } updateChat(chatId: string, req: UpdateChatRequest, signal?: AbortSignal): Promise { @@ -293,6 +317,23 @@ export class CoderChatClient { }); } + /** + * Watch lifecycle events (status/title changes, creation, deletion, …) for + * every chat visible to the authenticated user via the `/chats/watch` + * WebSocket. Dropped connections are redialed with exponential backoff; the + * iteration ends only when `opts.signal` aborts, or with a terminal + * {@link CoderApiError} when the server rejects the upgrade with a 4xx + * (bad/expired token, or an older Coder server without the endpoint → 404). + */ + watchChats(opts?: { signal?: AbortSignal }): AsyncGenerator { + return watchChatEvents({ + baseUrl: this.baseUrl, + token: this.#token, + signal: opts?.signal, + webSocketFactory: this.#webSocketFactory, + }); + } + // --- Helpers -------------------------------------------------------------- /** @@ -302,12 +343,21 @@ export class CoderChatClient { * (e.g. `anthropic:claude-haiku-4-5-20251001`), a bare model id, or a * display-name substring (case-insensitive). Returns `undefined` if no * match is found, in which case the caller should let chatd pick the default. + * + * Tolerates partial payloads from older/newer servers: a malformed listing + * (empty body, non-array JSON, null entries) resolves as no match, and + * entries missing `provider`, `model`, or `display_name` are matched on the + * fields they do carry — an entry without `provider` still matches by its + * model id, one without `model` only by display name. */ async resolveModelConfigId(hint: string, signal?: AbortSignal): Promise { const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; if (UUID_RE.test(hint)) return hint; - const configs = await this.listModelConfigs(signal); + const raw: unknown = await this.listModelConfigs(signal); + const configs = (Array.isArray(raw) ? raw : []).filter( + (c): c is ChatModelConfig => typeof c === "object" && c !== null, + ); const lower = hint.toLowerCase(); // `provider:model` form. const colon = hint.indexOf(":"); @@ -317,20 +367,26 @@ export class CoderChatClient { const candidates = configs.filter((c) => c.enabled !== false); const pool = candidates.length > 0 ? candidates : configs; + // Guarded accessors: treat a missing/non-string field as absent. + const modelOf = (c: ChatModelConfig) => + typeof c.model === "string" ? c.model.toLowerCase() : undefined; + const providerOf = (c: ChatModelConfig) => + typeof c.provider === "string" ? c.provider.toLowerCase() : undefined; + const displayNameOf = (c: ChatModelConfig) => + typeof c.display_name === "string" ? c.display_name.toLowerCase() : undefined; + const exact = pool.find( - (c) => - c.model.toLowerCase() === model && - (provider === undefined || c.provider.toLowerCase() === provider), + (c) => modelOf(c) === model && (provider === undefined || providerOf(c) === provider), ); if (exact) return exact.id; - const byModel = pool.find((c) => c.model.toLowerCase() === model); + const byModel = pool.find((c) => modelOf(c) === model); if (byModel) return byModel.id; - const byDisplay = pool.find((c) => c.display_name.toLowerCase().includes(lower)); + const byDisplay = pool.find((c) => displayNameOf(c)?.includes(lower)); if (byDisplay) return byDisplay.id; - const byModelSubstring = pool.find((c) => c.model.toLowerCase().includes(model)); + const byModelSubstring = pool.find((c) => modelOf(c)?.includes(model)); return byModelSubstring?.id; } } diff --git a/packages/agent/src/coder/types.ts b/packages/agent/src/coder/types.ts index 95cd75a..3dbfbf5 100644 --- a/packages/agent/src/coder/types.ts +++ b/packages/agent/src/coder/types.ts @@ -201,6 +201,13 @@ export interface ChatMessageUsage { cache_creation_tokens?: number; cache_read_tokens?: number; context_limit?: number; + /** + * Cost in micro-USD, when the server prices usage. Optional: servers that + * don't price usage (or predate cost reporting) omit it. + */ + total_cost_micros?: number; + /** Model runtime in milliseconds, when the server reports it. */ + total_runtime_ms?: number; } export interface ChatMessage { @@ -269,12 +276,18 @@ export interface UploadChatFileResponse { id: string; } +/** + * A configured model (`GET /api/experimental/chats/model-configs`). Depending + * on the server version and how a config was created, `provider`, `model`, and + * `display_name` may be absent on individual entries, so treat them as + * optional and guard reads. + */ export interface ChatModelConfig { id: string; - provider: string; + provider?: string; ai_provider_id?: string; - model: string; - display_name: string; + model?: string; + display_name?: string; enabled?: boolean; is_default?: boolean; context_limit?: number; @@ -348,3 +361,30 @@ export const TERMINAL_STATUSES: ReadonlySet = new Set([ "error", "requires_action", ]); + +// --------------------------------------------------------------------------- +// Watch events (WebSocket `/watch`) +// --------------------------------------------------------------------------- + +/** Kinds of chat lifecycle events emitted on the per-user watch socket. */ +export type ChatWatchEventKind = + | "status_change" + | "summary_change" + | "title_change" + | "created" + | "deleted" + | "diff_status_change" + | "action_required" + | "context_dirty"; + +/** + * An event from `GET /api/experimental/chats/watch`, which covers every chat + * visible to the authenticated user (not a single chat). Mirrors + * `codersdk.ChatWatchEvent`. + */ +export interface ChatWatchEvent { + kind: ChatWatchEventKind; + chat: Chat; + /** Pending client-executed tool calls; sent with `action_required` events. */ + tool_calls?: ChatStreamToolCall[]; +} diff --git a/packages/agent/src/coder/workspaces.ts b/packages/agent/src/coder/workspaces.ts new file mode 100644 index 0000000..8c3373c --- /dev/null +++ b/packages/agent/src/coder/workspaces.ts @@ -0,0 +1,285 @@ +/** + * Minimal helpers for the stable Coder v2 REST endpoints that the workspace + * preview feature composes: workspace lookup, the wildcard app host, and port + * sharing. Deliberately tiny — only the fields this package reads are modeled + * — and mirroring `CoderChatClient`'s conventions (`Coder-Session-Token` + * auth, `CoderApiError` on non-2xx). These are internal building blocks for + * `CoderAgent.getPreview`/`sharePreview`; only their option/result types are + * part of the public API. + */ + +import { CoderAgentError, CoderApiError } from "../errors.js"; + +/** Connection details for the stable v2 API (same credentials as the chat client). */ +export interface WorkspaceApiConnection { + /** Base URL of the Coder deployment, e.g. `https://dev.coder.com`. */ + baseUrl: string; + /** Coder API/session token (sent as `Coder-Session-Token`). */ + token: string; + /** Custom fetch implementation (defaults to global `fetch`). */ + fetch?: typeof globalThis.fetch; +} + +/** + * Subset of `codersdk.Workspace` (GET `/api/v2/workspaces/{workspace}`) that + * the preview helpers read. Resources without agents omit the `agents` key + * entirely, so the whole chain is treated as optional. + */ +export interface WorkspaceSummary { + id: string; + /** Username of the workspace owner (a subdomain URL segment). */ + owner_name: string; + name: string; + latest_build?: { + resources?: { agents?: { name: string }[] }[]; + }; +} + +/** Response of GET `/api/v2/applications/host`; `host` is `""` when subdomain apps are disabled. */ +export interface AppHostResponse { + /** Wildcard app host with a literal `*`, e.g. `*.apps.example.com`, possibly with a port. */ + host: string; +} + +/** Share levels a port can be in; `owner` is the implicit default when no share exists. */ +export type PortShareLevel = "owner" | "authenticated" | "organization" | "public"; + +/** + * Share levels the upsert endpoint accepts. `owner` is rejected server-side — + * reverting to owner-only is done by deleting the share, not setting it. + */ +export type PreviewShareLevel = Exclude; + +/** Protocol the workspace app speaks on the port (drives the `s` suffix in the URL). */ +export type PortShareProtocol = "http" | "https"; + +/** Body of POST `/api/v2/workspaces/{workspace}/port-share` (a true upsert on agent+port). */ +export interface UpsertPortShareRequest { + agent_name: string; + port: number; + share_level: PreviewShareLevel; + protocol: PortShareProtocol; +} + +/** A port-share row, mirroring `codersdk.WorkspaceAgentPortShare`. */ +export interface WorkspaceAgentPortShare { + workspace_id: string; + agent_name: string; + port: number; + share_level: PortShareLevel; + protocol: PortShareProtocol; +} + +/** + * Issue a JSON request against the v2 API. Mirrors `CoderChatClient`: the + * session-token header, and a {@link CoderApiError} carrying the server's + * `message`/`detail` on any non-2xx (body tolerated missing or malformed). + */ +async function request( + conn: WorkspaceApiConnection, + method: string, + path: string, + body?: unknown, + signal?: AbortSignal, +): Promise { + const fetchFn = conn.fetch ?? globalThis.fetch.bind(globalThis); + const headers: Record = { "Coder-Session-Token": conn.token }; + if (body !== undefined) headers["Content-Type"] = "application/json"; + + const res = await fetchFn(`${conn.baseUrl.replace(/\/$/, "")}${path}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + signal, + }); + const text = await res.text().catch(() => ""); + let parsed: unknown; + try { + parsed = text.length > 0 ? JSON.parse(text) : undefined; + } catch { + parsed = undefined; + } + if (!res.ok) { + const errObj = (parsed ?? {}) as { message?: string; detail?: string }; + throw new CoderApiError({ + status: res.status, + method, + path, + message: errObj.message ?? res.statusText ?? "request failed", + detail: errObj.detail, + }); + } + return parsed as T; +} + +/** Fetch a workspace (owner, name, agents) by UUID: GET `/api/v2/workspaces/{workspace}`. */ +export function getWorkspace( + conn: WorkspaceApiConnection, + workspaceId: string, + signal?: AbortSignal, +): Promise { + return request( + conn, + "GET", + `/api/v2/workspaces/${workspaceId}`, + undefined, + signal, + ); +} + +/** + * Fetch the deployment's wildcard app host: GET `/api/v2/applications/host`. + * The endpoint predates port sharing and exists on all supported servers; + * an empty `host` means no wildcard access URL is configured. + */ +export function getAppsHost( + conn: WorkspaceApiConnection, + signal?: AbortSignal, +): Promise { + return request(conn, "GET", "/api/v2/applications/host", undefined, signal); +} + +/** + * Create or update a port share: POST `/api/v2/workspaces/{workspace}/port-share` + * (an upsert keyed on agent+port; re-posting updates level/protocol in place). + * A 404 is rethrown with a hint that the server may predate port sharing — + * the whole route is absent on Coder servers older than ~v2.9. + */ +export async function upsertPortShare( + conn: WorkspaceApiConnection, + workspaceId: string, + req: UpsertPortShareRequest, + signal?: AbortSignal, +): Promise { + const path = `/api/v2/workspaces/${workspaceId}/port-share`; + try { + return await request(conn, "POST", path, req, signal); + } catch (err) { + if (err instanceof CoderApiError && err.status === 404) { + throw new CoderApiError({ + status: 404, + method: "POST", + path, + message: "port sharing is unavailable", + detail: + "the workspace was not found, or this Coder server predates port sharing (added in Coder v2.9)", + }); + } + throw err; + } +} + +/** Inputs for building a preview URL (see `CoderAgent.getPreview`). */ +export interface ResolvePreviewOptions { + workspaceId: string; + /** Workspace port the app listens on. */ + port: number; + /** Agent that serves the port; defaults to the workspace's only agent. */ + agentName?: string; + /** Protocol the app speaks inside the workspace (not the browser scheme). Default `"http"`. */ + protocol?: PortShareProtocol; +} + +/** The workspace's only agent's name, or a clear error naming the candidates. */ +function defaultAgentName(workspace: WorkspaceSummary): string { + const agents = (workspace.latest_build?.resources ?? []).flatMap((r) => r.agents ?? []); + const first = agents[0]; + if (agents.length === 1 && first) return first.name; + if (agents.length === 0) { + throw new CoderAgentError( + `Workspace "${workspace.name}" has no agents in its latest build (is it running?); ` + + `pass agentName explicitly.`, + ); + } + throw new CoderAgentError( + `Workspace "${workspace.name}" has ${agents.length} agents ` + + `(${agents.map((a) => a.name).join(", ")}); pass agentName to pick one.`, + ); +} + +/** + * Ports must be integral, in TCP range, and — stricter than TCP — at least + * 1000: Coder's subdomain app parser (`appurl.PortRegex`, `^\d{4,5}s?$`) only + * treats 4–5 digit labels as ports, so a URL for port 80 would be parsed as an + * app named "80" and never resolve. Reject those up front instead of returning + * a dead URL. + */ +function assertValidPort(port: number): void { + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new CoderAgentError(`Invalid port ${port}: expected an integer between 1 and 65535.`); + } + if (port < 1000) { + throw new CoderAgentError( + `Port ${port} cannot be previewed: Coder subdomain app URLs only encode 4-5 digit ports ` + + `(1000-65535). Serve the preview on a higher port instead.`, + ); + } +} + +/** + * Compose the subdomain preview URL for a workspace port the same way the + * dashboard does: the label `{port}{s?}--{agent}--{workspace}--{owner}` + * replaces the `*` in the deployment's wildcard app host, and the browser + * scheme follows the deployment's own access URL. The trailing `s` (from + * `protocol: "https"`) tells the proxy to speak TLS *to the workspace port*; + * it does not affect the browser scheme. + */ +export async function resolveWorkspacePreview( + conn: WorkspaceApiConnection, + opts: ResolvePreviewOptions, + signal?: AbortSignal, +): Promise<{ url: string; agentName: string }> { + assertValidPort(opts.port); + const [workspace, appHost] = await Promise.all([ + getWorkspace(conn, opts.workspaceId, signal), + getAppsHost(conn, signal), + ]); + if (!appHost.host) { + throw new CoderAgentError( + "This Coder deployment has no wildcard app host configured (--wildcard-access-url), " + + "so subdomain preview URLs are unavailable.", + ); + } + const agentName = opts.agentName ?? defaultAgentName(workspace); + const suffix = opts.protocol === "https" ? "s" : ""; + const label = `${opts.port}${suffix}--${agentName}--${workspace.name}--${workspace.owner_name}`; + // The host keeps a literal `*` (possibly inside the first label, e.g. + // `*--apps.example.com`) and may carry an explicit port on dev deployments — + // substitute the label into the whole host string, dashboard-style. + const scheme = new URL(conn.baseUrl).protocol; + return { url: `${scheme}//${appHost.host.replace(/\*/g, label)}`, agentName }; +} + +/** Inputs for sharing and previewing a workspace port (see `CoderAgent.sharePreview`). */ +export interface SharePreviewRequest extends ResolvePreviewOptions { + /** Who may open the port. Default `"authenticated"`. */ + shareLevel?: PreviewShareLevel; +} + +/** + * Resolve a port's preview URL, then upsert its share level so the URL is + * reachable beyond the workspace owner. Returns the server-confirmed level + * (falling back to the requested one when the server omits it). + */ +export async function shareWorkspacePreview( + conn: WorkspaceApiConnection, + opts: SharePreviewRequest, + signal?: AbortSignal, +): Promise<{ url: string; shareLevel: PreviewShareLevel }> { + const requested = opts.shareLevel ?? "authenticated"; + const { url, agentName } = await resolveWorkspacePreview(conn, opts, signal); + const share = await upsertPortShare( + conn, + opts.workspaceId, + { + agent_name: agentName, + port: opts.port, + share_level: requested, + protocol: opts.protocol ?? "http", + }, + signal, + ); + // Old servers may answer with an empty/partial body; trust it when present. + const level = share?.share_level && share.share_level !== "owner" ? share.share_level : requested; + return { url, shareLevel: level }; +} diff --git a/packages/agent/src/coder/ws.ts b/packages/agent/src/coder/ws.ts index 68a24ea..a9599c4 100644 --- a/packages/agent/src/coder/ws.ts +++ b/packages/agent/src/coder/ws.ts @@ -1,6 +1,6 @@ import NodeWebSocket from "ws"; -import { CoderAgentError } from "../errors.js"; -import type { ChatStreamEvent } from "./types.js"; +import { CoderAgentError, CoderApiError } from "../errors.js"; +import type { ChatStreamEvent, ChatWatchEvent } from "./types.js"; /** * A minimal WebSocket factory abstraction so the stream reader can run on @@ -159,3 +159,240 @@ export async function* streamChatEvents( } } } + +const WATCH_PATH = "/api/experimental/chats/watch"; +const WATCH_BACKOFF_INITIAL_MS = 1_000; +const WATCH_BACKOFF_CAP_MS = 30_000; + +/** Abort-aware sleep; resolves early (without throwing) when the signal aborts. */ +function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal?.aborted) { + resolve(); + return; + } + const onAbort = (): void => { + clearTimeout(timer); + resolve(); + }; + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + // The timer stays ref'd on purpose: an actively consumed watch loop must + // keep the process alive through a reconnect delay (abort clears it). + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +/** + * Extract the HTTP status from a rejected WebSocket upgrade. The `ws` package + * reports handshake failures as an error event whose message is + * `Unexpected server response: `; other factories that follow the + * same convention are recognized too. + */ +function upgradeStatus(message: string): number | undefined { + const match = /unexpected server response: (\d{3})$/i.exec(message.trim()); + return match ? Number(match[1]) : undefined; +} + +export interface WatchChatEventsOptions { + baseUrl: string; + token: string; + signal?: AbortSignal; + webSocketFactory?: WebSocketFactory; +} + +/** + * Opens the chatd `/watch` WebSocket — lifecycle events for every chat visible + * to the authenticated user — and yields decoded {@link ChatWatchEvent}s as an + * async iterable. + * + * Unlike the per-chat `/stream`, this is a long-lived subscription, so dropped + * connections are redialed automatically with exponential backoff (1s doubling + * to a 30s cap, reset once an event arrives). It ends in exactly three ways: + * `signal` aborting, the consumer ending iteration (`break` from `for await`, + * `iterator.return()`) — both tear the socket down promptly even while a read + * is pending — or the server rejecting the upgrade with a 4xx — bad/expired + * token, or an older Coder server without the endpoint (404) — which throws a + * terminal {@link CoderApiError} instead of retrying forever. + */ +export function watchChatEvents( + options: WatchChatEventsOptions, +): AsyncGenerator { + // Cancellation plumbing: async-generator `return()`/`throw()` queue behind a + // pending `next()`, and on this rarely-eventing stream a pending read is the + // steady state — a bare `return()` would otherwise hang (socket open) until + // the next server event. The wrapper aborts an internal controller first, + // which wakes the pending read, then delegates. + const controller = new AbortController(); + const external = options.signal; + const chain = (): void => controller.abort(external?.reason); + if (external) { + if (external.aborted) chain(); + else external.addEventListener("abort", chain, { once: true }); + } + const inner = watchChatEventsLoop({ ...options, signal: controller.signal }); + const detach = (): void => external?.removeEventListener("abort", chain); + const finish = async (): Promise => { + controller.abort(); + detach(); + try { + await inner.return(); + } catch { + /* the loop's own teardown errors are not the caller's problem */ + } + }; + return { + // Detach the signal chain once the loop settles for good (done, or the + // terminal 4xx rejection) — the iteration protocol never calls return() + // on a failed iterator, and the {once} listener would otherwise pile up + // on a long-lived signal across re-created watchers. + async next(): Promise> { + try { + const result = await inner.next(); + if (result.done) detach(); + return result; + } catch (err) { + detach(); + throw err; + } + }, + async return(): Promise> { + await finish(); + return { done: true, value: undefined }; + }, + async throw(err: unknown): Promise> { + await finish(); + throw err; + }, + [Symbol.asyncIterator]() { + return this; + }, + // Native async generators are async-disposable; the wrapper must be too, + // so `await using events = client.watchChats(…)` keeps tearing down. + async [Symbol.asyncDispose](): Promise { + await finish(); + }, + } as AsyncGenerator; +} + +async function* watchChatEventsLoop( + options: WatchChatEventsOptions, +): AsyncGenerator { + const { baseUrl, token, signal } = options; + const factory = options.webSocketFactory ?? defaultFactory; + const url = `${httpToWs(baseUrl)}${WATCH_PATH}`; + + let backoffMs = WATCH_BACKOFF_INITIAL_MS; + while (!signal?.aborted) { + // One connection attempt per iteration; the queue/wake plumbing mirrors + // streamChatEvents above. + const queue: ChatWatchEvent[] = []; + let resolveNext: (() => void) | undefined; + let finished = false; + let failure: Error | undefined; + + const wake = () => { + resolveNext?.(); + resolveNext = undefined; + }; + + const ws = factory(url, { headers: { "Coder-Session-Token": token } }); + + const onAbort = () => { + finished = true; + try { + ws.close(1000); + } catch { + /* ignore */ + } + wake(); + }; + let abortListenerAdded = false; + if (signal) { + if (signal.aborted) onAbort(); + else { + signal.addEventListener("abort", onAbort, { once: true }); + abortListenerAdded = true; + } + } + + const onMessage = (ev: { data: unknown }): void => { + if (finished) return; + let payload: unknown; + try { + const data = typeof ev.data === "string" ? ev.data : String(ev.data); + payload = JSON.parse(data); + } catch (err) { + // A malformed frame means this connection is unusable; redial it. + failure = new CoderAgentError("failed to parse chat watch frame", { cause: err }); + finished = true; + wake(); + return; + } + // chatd sends one event object per frame (wsjson); tolerate batches too. + if (Array.isArray(payload)) { + for (const e of payload) queue.push(e as ChatWatchEvent); + } else if (payload && typeof payload === "object") { + queue.push(payload as ChatWatchEvent); + } + wake(); + }; + const onError = (ev: unknown): void => { + if (finished) return; + const message = + ev && typeof ev === "object" && "message" in ev + ? String((ev as { message: unknown }).message) + : "chat watch socket error"; + const status = upgradeStatus(message); + failure = + status !== undefined && status >= 400 && status < 500 + ? new CoderApiError({ status, method: "GET", path: WATCH_PATH, message }) + : new CoderAgentError(message); + finished = true; + wake(); + }; + const onClose = (): void => { + finished = true; + wake(); + }; + ws.addEventListener("message", onMessage); + ws.addEventListener("error", onError); + ws.addEventListener("close", onClose); + + try { + while (true) { + while (queue.length > 0) { + const next = queue.shift() as ChatWatchEvent; + // Receiving an event proves the connection works — reset the backoff. + backoffMs = WATCH_BACKOFF_INITIAL_MS; + yield next; + } + if (finished) break; + await new Promise((resolve) => { + resolveNext = resolve; + }); + } + } finally { + finished = true; + if (signal && abortListenerAdded) signal.removeEventListener("abort", onAbort); + ws.removeEventListener("message", onMessage); + ws.removeEventListener("error", onError); + ws.removeEventListener("close", onClose); + try { + ws.close(1000); + } catch { + /* ignore */ + } + } + + if (signal?.aborted) return; + // A rejected upgrade (4xx) is terminal: retrying with the same credentials + // against the same server cannot succeed. + if (failure instanceof CoderApiError) throw failure; + await sleep(backoffMs, signal); + if (signal?.aborted) return; + backoffMs = Math.min(backoffMs * 2, WATCH_BACKOFF_CAP_MS); + } +} diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 7a511d2..578c903 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -41,6 +41,7 @@ export { } from "./files.js"; export type { WorkspaceFileStore, WorkspacePlacement } from "./workspace-files.js"; export { TurnTranslator } from "./model/translate.js"; +export { chatMessagesToUIMessages, type ChatMessagesToUIMessagesOptions } from "./model/history.js"; export { classifyTurnAction, CODER_PROVIDER_OPTIONS, @@ -53,8 +54,21 @@ export { type UserContent, userContentToInputParts, } from "./model/prompt.js"; -export { streamChatEvents, type WebSocketFactory, type WebSocketLike } from "./coder/ws.js"; +export { + streamChatEvents, + watchChatEvents, + type WatchChatEventsOptions, + type WebSocketFactory, + type WebSocketLike, +} from "./coder/ws.js"; export { CoderAgentError, CoderApiError, CoderChatError } from "./errors.js"; // Runtime constants (the `export type *` below only re-exports types). export { CHAT_ATTACHMENT_MEDIA_TYPES, MAX_CHAT_FILE_SIZE_BYTES } from "./coder/types.js"; export type * from "./coder/types.js"; +export type { + SharedWorkspacePreview, + SharePreviewOptions, + WorkspacePreview, + WorkspacePreviewOptions, +} from "./agent/coder-agent.js"; +export type { PreviewShareLevel } from "./coder/workspaces.js"; diff --git a/packages/agent/src/model/history.ts b/packages/agent/src/model/history.ts new file mode 100644 index 0000000..994e0da --- /dev/null +++ b/packages/agent/src/model/history.ts @@ -0,0 +1,174 @@ +import type { DynamicToolUIPart, UIMessage } from "ai"; +import type { ChatMessage, ChatMessagePart } from "../coder/types.js"; + +/** Options for {@link chatMessagesToUIMessages}. */ +export interface ChatMessagesToUIMessagesOptions { + /** + * Resolves a persisted `file` part to a URL for its `FileUIPart`. Persisted + * file parts carry only a `file_id` (no bytes, and usually no `url`), so + * without a URL there is nothing for the UI to render and the part is + * skipped. Download the bytes with `CoderChatClient.getChatFile(fileId)` + * and return a data:/object/proxy URL here to keep attachments visible. + * Returning `undefined` falls back to the part's own `url`, if any. + */ + fileUrl?: (part: ChatMessagePart) => string | undefined; +} + +function toolErrorText(result: unknown): string { + if (typeof result === "string") return result; + if (result === undefined || result === null) return ""; + return JSON.stringify(result) ?? ""; +} + +function toDynamicToolPart( + call: ChatMessagePart, + result: ChatMessagePart | undefined, +): DynamicToolUIPart | undefined { + const toolCallId = call.tool_call_id; + const toolName = call.tool_name; + if (!toolCallId || !toolName) return undefined; + const providerExecuted = call.provider_executed ?? result?.provider_executed; + const base = { + type: "dynamic-tool" as const, + toolName, + toolCallId, + ...(providerExecuted !== undefined ? { providerExecuted } : {}), + }; + if (!result) return { ...base, state: "input-available", input: call.args }; + if (result.is_error) { + return { + ...base, + state: "output-error", + input: call.args, + errorText: toolErrorText(result.result), + }; + } + return { ...base, state: "output-available", input: call.args, output: result.result }; +} + +function toUIParts( + content: ChatMessagePart[], + resultsByCallId: ReadonlyMap, + opts: ChatMessagesToUIMessagesOptions | undefined, +): UIMessage["parts"] { + const parts: UIMessage["parts"] = []; + for (const part of content) { + switch (part.type) { + case "text": + parts.push({ type: "text", text: part.text ?? "", state: "done" }); + break; + case "reasoning": + parts.push({ type: "reasoning", text: part.text ?? "", state: "done" }); + break; + case "tool-call": { + const result = part.tool_call_id ? resultsByCallId.get(part.tool_call_id) : undefined; + const ui = toDynamicToolPart(part, result); + if (ui) parts.push(ui); + break; + } + case "source": + // `sourceId` is required by the AI SDK; fall back to the URL when the + // wire part has no `source_id`. Without a URL there is nothing to + // link, so the part is skipped. + if (part.url) { + parts.push({ + type: "source-url", + sourceId: part.source_id || part.url, + url: part.url, + ...(part.title ? { title: part.title } : {}), + }); + } + break; + case "file": { + const url = opts?.fileUrl?.(part) ?? part.url; + if (url) { + parts.push({ + type: "file", + mediaType: part.media_type ?? "application/octet-stream", + ...(part.name ? { filename: part.name } : {}), + url, + }); + } + break; + } + case "tool-result": + // Folded into the originating `dynamic-tool` part above. + break; + default: + // file-reference / context-file / skill / future kinds: no UI + // equivalent; skip so history written by newer Coder servers + // degrades gracefully. + break; + } + } + return parts; +} + +/** + * Converts persisted chat history (`CoderChatClient.getMessages`) into AI SDK + * v6 `UIMessage`s, e.g. to rehydrate `useChat({ messages })` for an existing + * chat. Pure and side-effect free. + * + * The mapping mirrors what a live-streamed transcript of the same turn looks + * like, with one caveat around client-tool typing (below): + * - One `ChatMessage` becomes one `UIMessage` (ids stringified), sorted + * chronologically by id regardless of input order — the messages endpoint + * pages newest-first by default, while `useChat` needs oldest-first. The + * exception is `role: "tool"` messages: their `tool-result` parts are + * folded into the originating assistant `dynamic-tool` part and the message + * itself is dropped. + * - `text`/`reasoning` parts map to their UI counterparts with + * `state: "done"` (history is complete, never streaming). + * - Every `tool-call` part becomes a `dynamic-tool` part, in state + * `"output-available"` once a result exists (`"output-error"` when it is + * marked `is_error`), or `"input-available"` while no result has been + * persisted yet. For server-executed tools this matches the live stream + * (they stream with `dynamic: true`); client (`ToolSet`) tools stream live + * as statically-typed `tool-{name}` parts, but persisted history does not + * record which names were client tools, so they rehydrate as `dynamic-tool` + * too. Render tools by name — ai's `isToolOrDynamicToolUIPart` + + * `getToolOrDynamicToolName` — rather than exact `part.type` matches and + * both shapes look identical. + * - `source` parts become `source-url` parts; skipped when the wire part has + * no `url`. + * - `file` parts become `file` UI parts only when a URL is available (see + * {@link ChatMessagesToUIMessagesOptions.fileUrl}): persisted file parts + * carry only a `file_id`, and `FileUIPart` requires a `url`. Parts without + * one are skipped. + * - `file-reference` / `context-file` / `skill` / unknown part kinds have no + * UI equivalent and are skipped silently (forward compatible). + */ +export function chatMessagesToUIMessages( + messages: ChatMessage[], + opts?: ChatMessagesToUIMessagesOptions, +): UIMessage[] { + // Normalize to chronological order (ids are per-chat sequential). The + // messages endpoint pages newest-first by default (only `after_id` queries + // return ascending), and `useChat` expects oldest-first — accepting any + // input order makes both pagination shapes safe to pass straight in. + const ordered = [...messages].sort((a, b) => a.id - b.id); + + // Pass 1: index tool results by call id. chatd persists results as separate + // role:"tool" messages; scan every message so results inlined on assistant + // snapshots fold identically. + const resultsByCallId = new Map(); + for (const message of ordered) { + for (const part of message.content ?? []) { + if (part.type !== "tool-result" || !part.tool_call_id) continue; + if (!resultsByCallId.has(part.tool_call_id)) resultsByCallId.set(part.tool_call_id, part); + } + } + + // Pass 2: one UIMessage per ChatMessage, chronological. role:"tool" messages + // were folded above and produce none. + const out: UIMessage[] = []; + for (const message of ordered) { + if (message.role === "tool") continue; + out.push({ + id: String(message.id), + role: message.role, + parts: toUIParts(message.content ?? [], resultsByCallId, opts), + }); + } + return out; +} diff --git a/packages/agent/src/model/language-model.ts b/packages/agent/src/model/language-model.ts index 9ea9183..6abb66f 100644 --- a/packages/agent/src/model/language-model.ts +++ b/packages/agent/src/model/language-model.ts @@ -8,6 +8,7 @@ import type { LanguageModelV3Usage, SharedV3Warning, } from "@ai-sdk/provider"; +import { assertSupportedAiVersion } from "../ai-version.js"; import { CoderAgentError, CoderApiError, CoderChatError } from "../errors.js"; import { CoderChatClient } from "../coder/client.js"; import type { ChatInputPart, CreateChatRequest } from "../coder/types.js"; @@ -89,6 +90,8 @@ export class CoderLanguageModel implements LanguageModelV3 { #inFlight = false; constructor(config: CoderLanguageModelConfig) { + // Fail fast on an incompatible AI SDK major (see peer dependency `ai@^6`). + assertSupportedAiVersion(); this.#config = config; this.modelId = config.model ?? "chatd"; this.#chatId = config.chatId; @@ -397,6 +400,7 @@ export class CoderLanguageModel implements LanguageModelV3 { unified: "stop", raw: undefined, }; + let providerMetadata: LanguageModelV3GenerateResult["providerMetadata"]; const warnings: LanguageModelV3GenerateResult["warnings"] = []; for await (const part of this.#runTurn(options)) { @@ -437,6 +441,7 @@ export class CoderLanguageModel implements LanguageModelV3 { case "finish": usage = part.usage; finishReason = part.finishReason; + providerMetadata = part.providerMetadata; break; case "error": throw part.error instanceof Error @@ -447,6 +452,12 @@ export class CoderLanguageModel implements LanguageModelV3 { } } - return { content, finishReason, usage, warnings }; + return { + content, + finishReason, + usage, + warnings, + ...(providerMetadata ? { providerMetadata } : {}), + }; } } diff --git a/packages/agent/src/model/translate.ts b/packages/agent/src/model/translate.ts index 0c663ed..e0893ab 100644 --- a/packages/agent/src/model/translate.ts +++ b/packages/agent/src/model/translate.ts @@ -1,4 +1,5 @@ import type { + JSONObject, LanguageModelV3FinishReason, LanguageModelV3StreamPart, LanguageModelV3Usage, @@ -27,6 +28,9 @@ function mapUsage(u: ChatMessageUsage | undefined): LanguageModelV3Usage { text: undefined, reasoning: u?.reasoning_tokens, }, + // Preserve the verbatim wire usage (snake_case) so callers can reach fields + // the normalized shape has no slot for (context_limit, cost, runtime, …). + ...(u ? { raw: u as unknown as JSONObject } : {}), }; } @@ -40,9 +44,12 @@ function jsonResult(value: unknown): NonNullable { * Translates one chatd turn's {@link ChatStreamEvent} stream into a sequence of * `LanguageModelV3StreamPart`s for the AI SDK. * - * Two text/reasoning modes, decided per turn from the wire behavior: + * Two text/reasoning modes, decided per assistant message from the wire + * behavior: * - **delta mode** — chatd streamed `message_part` deltas; we emit those and - * treat the trailing full `message` snapshot as a no-op for text/reasoning. + * treat the message's trailing full `message` snapshot as a no-op for + * text/reasoning. Deltas carry no message id, so "deltas arrived since the + * last assistant snapshot" is what marks a snapshot as trailing. * - **snapshot mode** — fast turns where only full `message` snapshots arrive; * we diff each snapshot's full text against what we've already emitted. * Both paths track an emitted-length cursor, so neither double-counts. @@ -58,11 +65,16 @@ export class TurnTranslator { #text = { id: undefined as string | undefined, len: 0, sawDelta: false }; #reasoning = { id: undefined as string | undefined, len: 0, sawDelta: false }; #currentAssistantId: number | undefined; + // Whether text/reasoning deltas arrived since the last assistant `message` + // snapshot — i.e. the next assistant snapshot is the trailing snapshot of the + // message those deltas streamed (deltas carry no message id of their own). + #deltasSinceSnapshot = false; #serverToolCalls = new Set(); #serverToolResults = new Set(); #clientToolCalls = new Set(); #clientToolCallSeen = false; + #sources = new Set(); #usage: ChatMessageUsage | undefined; #error: ChatErrorPayload | undefined; @@ -206,6 +218,27 @@ export class TurnTranslator { }); } + /** + * Emits a chatd `source` part as a standalone V3 url source (no text-block + * bracketing needed). Deduped by the emitted id so a part streamed as a + * `message_part` isn't re-emitted by its trailing `message` snapshot, while + * snapshot-only turns still emit theirs. + */ + #emitSource(out: LanguageModelV3StreamPart[], part: ChatMessagePart): void { + const url = part.url; + if (!url) return; // the V3 source part requires both id and url + const id = part.source_id || url; + if (this.#sources.has(id)) return; + this.#sources.add(id); + out.push({ + type: "source", + sourceType: "url", + id, + url, + ...(part.title !== undefined ? { title: part.title } : {}), + }); + } + // --- ingest --------------------------------------------------------------- ingest(ev: ChatStreamEvent): LanguageModelV3StreamPart[] { @@ -258,6 +291,7 @@ export class TurnTranslator { switch (part.type) { case "text": this.#text.sawDelta = true; + this.#deltasSinceSnapshot = true; this.#openText(out); if (part.text) { out.push({ type: "text-delta", id: this.#text.id as string, delta: part.text }); @@ -266,6 +300,7 @@ export class TurnTranslator { break; case "reasoning": this.#reasoning.sawDelta = true; + this.#deltasSinceSnapshot = true; this.#openReasoning(out); if (part.text) { out.push({ type: "reasoning-delta", id: this.#reasoning.id as string, delta: part.text }); @@ -280,6 +315,9 @@ export class TurnTranslator { case "tool-result": if (!this.#isClientTool(part.tool_name)) this.#emitServerToolResult(out, part); break; + case "source": + this.#emitSource(out, part); + break; default: break; } @@ -294,7 +332,19 @@ export class TurnTranslator { if (message.role === "assistant") { // New assistant message boundary: close prior blocks and reset cursors. - if (this.#currentAssistantId !== undefined && this.#currentAssistantId !== message.id) { + // Skipped when deltas arrived since the previous assistant snapshot: + // deltas carry no message id, so they belong to the message THIS snapshot + // finalizes (its id differing from the previous snapshot's), and the + // snapshot must stay a no-op for text/reasoning. The reset is load-bearing + // for snapshot-only messages, which must still diff-and-emit below. Known + // tradeoff: a mid-message snapshot followed by more deltas of the SAME + // message would misclassify the next snapshot — that ordering is outside + // the trailing-snapshot protocol (see class doc). + if ( + this.#currentAssistantId !== undefined && + this.#currentAssistantId !== message.id && + !this.#deltasSinceSnapshot + ) { this.#closeText(out); this.#closeReasoning(out); this.#text.sawDelta = false; @@ -316,16 +366,21 @@ export class TurnTranslator { .join(""); if (full.length > 0) this.#emitReasoningUpTo(out, full); } + // Tool calls/results and sources are id-deduped, so snapshots process them + // unconditionally (even when the snapshot is a text/reasoning no-op). for (const part of content) { if (part.type === "tool-call" && !this.#isClientTool(part.tool_name)) this.#emitServerToolCall(out, part); else if (part.type === "tool-result" && !this.#isClientTool(part.tool_name)) this.#emitServerToolResult(out, part); + else if (part.type === "source") this.#emitSource(out, part); } + this.#deltasSinceSnapshot = false; } else if (message.role === "tool") { for (const part of content) { if (part.type === "tool-result" && !this.#isClientTool(part.tool_name)) this.#emitServerToolResult(out, part); + else if (part.type === "source") this.#emitSource(out, part); } } } @@ -343,10 +398,20 @@ export class TurnTranslator { unified = "tool-calls"; else unified = "stop"; + // Surface server-side cost/runtime (sent by newer servers as extra usage + // fields) verbatim under `providerMetadata.coder`. Omitted entirely when + // the server sent neither, so old servers look unchanged to callers. + const coder: JSONObject = {}; + if (this.#usage?.total_cost_micros !== undefined) + coder.total_cost_micros = this.#usage.total_cost_micros; + if (this.#usage?.total_runtime_ms !== undefined) + coder.total_runtime_ms = this.#usage.total_runtime_ms; + out.push({ type: "finish", usage: mapUsage(this.#usage), finishReason: { unified, raw: this.#terminalStatus }, + ...(Object.keys(coder).length > 0 ? { providerMetadata: { coder } } : {}), }); return out; } diff --git a/packages/agent/test/unit/agent.test.ts b/packages/agent/test/unit/agent.test.ts index 154e288..3cb153e 100644 --- a/packages/agent/test/unit/agent.test.ts +++ b/packages/agent/test/unit/agent.test.ts @@ -7,7 +7,7 @@ import { type UploadedChatFile, } from "../../src/coder/client.js"; import { CoderAgent } from "../../src/agent/coder-agent.js"; -import { CoderAgentError, CoderChatError } from "../../src/errors.js"; +import { CoderAgentError, CoderApiError, CoderChatError } from "../../src/errors.js"; import { CoderLanguageModel } from "../../src/model/language-model.js"; import type { Chat, @@ -69,8 +69,8 @@ class FakeClient { } } - async archiveChat(): Promise {} - async interruptChat(): Promise { + async archiveChat(_chatId: string, _signal?: AbortSignal): Promise {} + async interruptChat(_chatId: string, _signal?: AbortSignal): Promise { throw new Error("not used"); } } @@ -607,3 +607,280 @@ describe("CoderLanguageModel guards", () => { await r1.cancel(); }, 10_000); }); + +/** One complete assistant turn (used to establish a chatId before cleanup tests). */ +function okTurn(): ChatStreamEvent[] { + return [ + status("running"), + msg(2, "assistant", [{ type: "text", text: "hi" }]), + status("waiting"), + ]; +} + +function err409(): CoderApiError { + return new CoderApiError({ + status: 409, + method: "PATCH", + path: "/api/experimental/chats/chat-1", + message: "Chat is not in an archivable state.", + }); +} + +/** A FakeClient with scripted archive behavior and interrupt/archive call recording. */ +class ScriptedArchiveClient extends FakeClient { + archiveCalls = 0; + archiveSignals: (AbortSignal | undefined)[] = []; + interrupted: { chatId: string; signal: AbortSignal | undefined }[] = []; + interruptError: Error | undefined; + readonly #archiveImpl: (attempt: number) => void; + + constructor(turns: ChatStreamEvent[][], archiveImpl: (attempt: number) => void) { + super(turns); + this.#archiveImpl = archiveImpl; + } + + override async archiveChat(_chatId: string, signal?: AbortSignal): Promise { + this.archiveCalls += 1; + this.archiveSignals.push(signal); + this.#archiveImpl(this.archiveCalls); + } + + override async interruptChat(chatId: string, signal?: AbortSignal): Promise { + this.interrupted.push({ chatId, signal }); + if (this.interruptError) throw this.interruptError; + return chatStub(chatId); + } +} + +function settlingAgent( + archiveImpl: (attempt: number) => void, + timings?: { deadlineMs?: number; retryDelayMs?: number }, +) { + const fake = new ScriptedArchiveClient([okTurn()], archiveImpl); + const agent = new CoderAgent({ + client: fake as unknown as CoderChatClient, + organizationId: "org-1", + settleDeadlineMs: timings?.deadlineMs ?? 5_000, + settleRetryDelayMs: timings?.retryDelayMs ?? 10, + }); + return { fake, agent }; +} + +describe("CoderAgent bounded cleanup (interrupt/archive/dispose)", () => { + it("archive() retries a settling 409 and then succeeds", async () => { + const { fake, agent } = settlingAgent((n) => { + if (n <= 2) throw err409(); + }); + await agent.generate({ prompt: "hi" }); + await agent.archive(); + expect(fake.archiveCalls).toBe(3); + }); + + it("archive() is a no-op before any turn (no chat to archive)", async () => { + const { fake, agent } = settlingAgent(() => { + throw err409(); + }); + await agent.archive(); + expect(fake.archiveCalls).toBe(0); + }); + + it("archive() gives up with the last 409 once the deadline passes", async () => { + const { fake, agent } = settlingAgent( + () => { + throw err409(); + }, + { deadlineMs: 80, retryDelayMs: 20 }, + ); + await agent.generate({ prompt: "hi" }); + await expect(agent.archive()).rejects.toMatchObject({ name: "CoderApiError", status: 409 }); + // Bounded: retried at least once, but capped by the deadline/backoff budget. + expect(fake.archiveCalls).toBeGreaterThan(1); + expect(fake.archiveCalls).toBeLessThanOrEqual(5); + }); + + it("archive() rethrows non-409 API errors immediately", async () => { + const { fake, agent } = settlingAgent(() => { + throw new CoderApiError({ status: 500, method: "PATCH", path: "/x", message: "boom" }); + }); + await agent.generate({ prompt: "hi" }); + await expect(agent.archive()).rejects.toMatchObject({ status: 500 }); + expect(fake.archiveCalls).toBe(1); + }); + + it("archive() rethrows non-API errors immediately", async () => { + const { fake, agent } = settlingAgent(() => { + throw new TypeError("fetch failed"); + }); + await agent.generate({ prompt: "hi" }); + await expect(agent.archive()).rejects.toThrow(TypeError); + expect(fake.archiveCalls).toBe(1); + }); + + it("archive() forwards a signal to the client and stops retrying on caller abort", async () => { + const { fake, agent } = settlingAgent( + () => { + throw err409(); + }, + { deadlineMs: 10_000, retryDelayMs: 5_000 }, + ); + await agent.generate({ prompt: "hi" }); + + const ac = new AbortController(); + const outcome = agent.archive({ signal: ac.signal }).then( + () => "resolved", + (e: { name?: string }) => e?.name, + ); + setTimeout(() => ac.abort(), 20); // abort during the first backoff pause + expect(await outcome).toBe("AbortError"); + expect(fake.archiveCalls).toBe(1); + // The client saw a real signal (the caller's, combined with the deadline). + expect(fake.archiveSignals[0]).toBeInstanceOf(AbortSignal); + }); + + it("interrupt() forwards the caller's signal to the client verbatim", async () => { + const { fake, agent } = settlingAgent(() => {}); + await agent.generate({ prompt: "hi" }); + const ac = new AbortController(); + await agent.interrupt({ signal: ac.signal }); + expect(fake.interrupted).toEqual([{ chatId: "chat-1", signal: ac.signal }]); + }); + + it("[Symbol.asyncDispose] interrupts, then archives, under a shared deadline signal", async () => { + const { fake, agent } = settlingAgent(() => {}); + await agent.generate({ prompt: "hi" }); + await agent[Symbol.asyncDispose](); + expect(fake.interrupted).toHaveLength(1); + expect(fake.archiveCalls).toBe(1); + expect(fake.interrupted[0]?.signal).toBeInstanceOf(AbortSignal); + expect(fake.archiveSignals[0]).toBeInstanceOf(AbortSignal); + }); + + it("[Symbol.asyncDispose] never throws: interrupt failure + chat that never settles", async () => { + const { fake, agent } = settlingAgent( + () => { + throw err409(); + }, + { deadlineMs: 60, retryDelayMs: 10 }, + ); + fake.interruptError = new Error("interrupt exploded"); + await agent.generate({ prompt: "hi" }); + await expect(agent[Symbol.asyncDispose]()).resolves.toBeUndefined(); + expect(fake.archiveCalls).toBeGreaterThan(1); // retried before giving up quietly + }); + + it("[Symbol.asyncDispose] swallows immediate archive failures too", async () => { + const { fake, agent } = settlingAgent(() => { + throw new CoderApiError({ status: 500, method: "PATCH", path: "/x", message: "boom" }); + }); + await agent.generate({ prompt: "hi" }); + await expect(agent[Symbol.asyncDispose]()).resolves.toBeUndefined(); + expect(fake.archiveCalls).toBe(1); + }); + + it("hostile settle knobs are sanitized so dispose still never throws", async () => { + // AbortSignal.timeout rejects non-integer, negative, and non-finite delays + // with a RangeError; the knobs must never let that reach dispose. + for (const bad of [Number.POSITIVE_INFINITY, Number.NaN, -5, 1.5]) { + const fake = new ScriptedArchiveClient([okTurn()], () => {}); + const agent = new CoderAgent({ + client: fake as unknown as CoderChatClient, + organizationId: "org-1", + settleDeadlineMs: bad, + settleRetryDelayMs: bad, + }); + await agent.generate({ prompt: "hi" }); + await expect(agent[Symbol.asyncDispose]()).resolves.toBeUndefined(); + expect(fake.archiveCalls).toBe(1); + } + }); +}); + +describe("CoderAgent previews", () => { + /** A fetch fake serving the v2 endpoints the preview helpers compose. */ + function previewFetch() { + const routes: Record Response> = { + "/api/v2/workspaces/ws-1": () => + new Response( + JSON.stringify({ + id: "ws-1", + owner_name: "alice", + name: "dev", + latest_build: { resources: [{ agents: [{ name: "main" }] }] }, + }), + { status: 200 }, + ), + "/api/v2/applications/host": () => + new Response(JSON.stringify({ host: "*.apps.example.com" }), { status: 200 }), + "/api/v2/workspaces/ws-1/port-share": () => + new Response( + JSON.stringify({ + workspace_id: "ws-1", + agent_name: "main", + port: 3000, + share_level: "public", + protocol: "http", + }), + { status: 200 }, + ), + }; + const calls: { url: string; init: RequestInit }[] = []; + const fn = ((url: string, init: RequestInit) => { + calls.push({ url, init }); + const route = routes[new URL(url).pathname]; + return Promise.resolve(route ? route() : new Response("{}", { status: 599 })); + }) as unknown as typeof globalThis.fetch; + return { fn, calls }; + } + + function previewAgent(fetchFn: typeof globalThis.fetch) { + return new CoderAgent({ + baseUrl: "https://coder.example.com", + token: "t", + organizationId: "org-1", + workspaceId: "ws-1", + fetch: fetchFn, + }); + } + + it("getPreview() requires the workspaceId setting", async () => { + const agent = new CoderAgent({ + baseUrl: "https://coder.example.com", + token: "t", + organizationId: "org-1", + }); + await expect(agent.getPreview({ port: 3000 })).rejects.toThrow(/workspaceId/); + }); + + it("getPreview() requires REST credentials when built from a bare client", async () => { + const agent = new CoderAgent({ + client: new FakeClient([]) as unknown as CoderChatClient, + organizationId: "org-1", + workspaceId: "ws-1", + }); + await expect(agent.getPreview({ port: 3000 })).rejects.toThrow(/baseUrl/); + }); + + it("getPreview() composes the subdomain URL from the v2 API", async () => { + const { fn } = previewFetch(); + await expect(previewAgent(fn).getPreview({ port: 3000 })).resolves.toEqual({ + url: "https://3000--main--dev--alice.apps.example.com", + }); + }); + + it("sharePreview() upserts the port share and returns the URL + level", async () => { + const { fn, calls } = previewFetch(); + const result = await previewAgent(fn).sharePreview({ port: 3000, shareLevel: "public" }); + + expect(result).toEqual({ + url: "https://3000--main--dev--alice.apps.example.com", + shareLevel: "public", + }); + const post = calls.find((c) => c.init.method === "POST" && c.url.includes("port-share")); + expect(JSON.parse(String(post?.init.body))).toEqual({ + agent_name: "main", + port: 3000, + share_level: "public", + protocol: "http", + }); + }); +}); diff --git a/packages/agent/test/unit/ai-version.test.ts b/packages/agent/test/unit/ai-version.test.ts new file mode 100644 index 0000000..7be3b97 --- /dev/null +++ b/packages/agent/test/unit/ai-version.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + assertSupportedAiVersion, + resetAiVersionCheckForTests, + unsupportedAiVersionMessage, +} from "../../src/ai-version.js"; +import { CoderAgentError } from "../../src/errors.js"; + +afterEach(() => { + resetAiVersionCheckForTests(); +}); + +describe("unsupportedAiVersionMessage", () => { + it("accepts ai v6 releases", () => { + expect(unsupportedAiVersionMessage("6.0.208")).toBeUndefined(); + expect(unsupportedAiVersionMessage("6.12.3")).toBeUndefined(); + expect(unsupportedAiVersionMessage("6")).toBeUndefined(); + }); + + it("accepts v6 prereleases and build metadata", () => { + expect(unsupportedAiVersionMessage("6.0.0-beta.3")).toBeUndefined(); + expect(unsupportedAiVersionMessage("6.0.0+build.5")).toBeUndefined(); + }); + + it("rejects other majors with an actionable message", () => { + const msg = unsupportedAiVersionMessage("7.0.0"); + expect(msg).toContain("ai@7.0.0"); + expect(msg).toContain("ai@^6"); + expect(unsupportedAiVersionMessage("5.0.51")).toContain("ai@^6"); + // No prefix confusion: 60.x must not pass as 6.x. + expect(unsupportedAiVersionMessage("60.0.0")).toContain("ai@^6"); + }); + + it("rejects prereleases of other majors", () => { + expect(unsupportedAiVersionMessage("7.0.0-canary.12")).toContain("ai@7.0.0-canary.12"); + }); + + it("fails open on unparseable versions", () => { + expect(unsupportedAiVersionMessage("")).toBeUndefined(); + expect(unsupportedAiVersionMessage("garbage")).toBeUndefined(); + expect(unsupportedAiVersionMessage("next")).toBeUndefined(); + expect(unsupportedAiVersionMessage(".1.2")).toBeUndefined(); + expect(unsupportedAiVersionMessage("x7.0.0")).toBeUndefined(); + }); +}); + +describe("assertSupportedAiVersion", () => { + it("passes against the actually installed ai (v6, resolved for real)", () => { + expect(() => assertSupportedAiVersion()).not.toThrow(); + }); + + it("memoizes the resolution (one resolve across many asserts)", () => { + const resolve = vi.fn(() => "6.0.208"); + assertSupportedAiVersion(resolve); + assertSupportedAiVersion(resolve); + expect(resolve).toHaveBeenCalledTimes(1); + }); + + it("throws a CoderAgentError on every call for an unsupported major", () => { + const resolve = vi.fn(() => "7.2.0"); + expect(() => assertSupportedAiVersion(resolve)).toThrow(CoderAgentError); + // The memoized verdict keeps throwing without re-resolving. + expect(() => assertSupportedAiVersion(resolve)).toThrow(/ai@\^6/); + expect(resolve).toHaveBeenCalledTimes(1); + }); + + it("fails open when no version can be resolved", () => { + expect(() => assertSupportedAiVersion(() => undefined)).not.toThrow(); + }); +}); diff --git a/packages/agent/test/unit/client.test.ts b/packages/agent/test/unit/client.test.ts index 24fc6b8..927de48 100644 --- a/packages/agent/test/unit/client.test.ts +++ b/packages/agent/test/unit/client.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { CoderChatClient } from "../../src/coder/client.js"; +import type { WebSocketFactory, WebSocketLike } from "../../src/coder/ws.js"; import { CoderAgentError, CoderApiError } from "../../src/errors.js"; type Init = RequestInit & { headers: Record }; @@ -115,3 +116,413 @@ describe("CoderChatClient.uploadChatFile", () => { ).rejects.toThrow(CoderApiError); }); }); + +describe("CoderChatClient.resolveModelConfigId", () => { + const HAIKU = { + id: "11111111-1111-4111-8111-111111111111", + provider: "anthropic", + model: "claude-haiku-4-5", + display_name: "Claude Haiku 4.5", + enabled: true, + }; + const GPT = { + id: "22222222-2222-4222-8222-222222222222", + provider: "openai", + model: "gpt-5", + display_name: "GPT-5", + enabled: true, + }; + + /** A client whose `/model-configs` endpoint returns `body` (undefined → empty body). */ + function resolver(body: unknown) { + const { fn, calls } = fakeFetch( + () => new Response(body === undefined ? "" : JSON.stringify(body), { status: 200 }), + ); + return { client: client(fn), calls }; + } + + it("returns a UUID hint as-is without fetching configs", async () => { + const { client: c, calls } = resolver([]); + const uuid = "33333333-3333-4333-8333-333333333333"; + await expect(c.resolveModelConfigId(uuid)).resolves.toBe(uuid); + expect(calls).toHaveLength(0); + }); + + it("matches provider:model, bare model, display-name and model substrings", async () => { + const { client: c } = resolver([GPT, HAIKU]); + await expect(c.resolveModelConfigId("anthropic:claude-haiku-4-5")).resolves.toBe(HAIKU.id); + await expect(c.resolveModelConfigId("claude-haiku-4-5")).resolves.toBe(HAIKU.id); + await expect(c.resolveModelConfigId("Haiku")).resolves.toBe(HAIKU.id); + await expect(c.resolveModelConfigId("haiku-4")).resolves.toBe(HAIKU.id); + }); + + it("prefers enabled configs, falls back to disabled-only listings, and returns undefined on no match", async () => { + const { client: c } = resolver([{ ...GPT, enabled: false }, HAIKU]); + // GPT is disabled and an enabled config exists, so it is out of the pool. + await expect(c.resolveModelConfigId("gpt-5")).resolves.toBeUndefined(); + await expect(c.resolveModelConfigId("no-such-model")).resolves.toBeUndefined(); + + const { client: allDisabled } = resolver([{ ...HAIKU, enabled: false }]); + await expect(allDisabled.resolveModelConfigId("claude-haiku-4-5")).resolves.toBe(HAIKU.id); + }); + + it("resolves an entry missing `provider` by its model id on a provider:model hint", async () => { + const { client: c } = resolver([ + { id: HAIKU.id, model: "claude-haiku-4-5", display_name: "Haiku (BYOK)" }, + ]); + await expect(c.resolveModelConfigId("anthropic:claude-haiku-4-5")).resolves.toBe(HAIKU.id); + }); + + it("keeps matching entries with an empty-string provider", async () => { + const { client: c } = resolver([{ ...HAIKU, provider: "" }]); + await expect(c.resolveModelConfigId("anthropic:claude-haiku-4-5")).resolves.toBe(HAIKU.id); + }); + + it("tolerates provider: null", async () => { + const { client: c } = resolver([{ ...HAIKU, provider: null }]); + await expect(c.resolveModelConfigId("anthropic:claude-haiku-4-5")).resolves.toBe(HAIKU.id); + }); + + it("tolerates an entry missing `display_name` when scanning for a display match", async () => { + const { client: c } = resolver([{ id: "no-display", provider: "x", model: "m1" }, GPT]); + await expect(c.resolveModelConfigId("gpt")).resolves.toBe(GPT.id); + }); + + it("never matches an entry missing `model` by model, but still by display name", async () => { + const { client: c } = resolver([ + { id: HAIKU.id, provider: "anthropic", display_name: "Claude Haiku 4.5" }, + ]); + await expect(c.resolveModelConfigId("claude-haiku-4-5")).resolves.toBeUndefined(); + await expect(c.resolveModelConfigId("haiku")).resolves.toBe(HAIKU.id); + }); + + it("skips null entries in the listing", async () => { + const { client: c } = resolver([null, HAIKU]); + await expect(c.resolveModelConfigId("anthropic:claude-haiku-4-5")).resolves.toBe(HAIKU.id); + }); + + it("returns undefined for an empty response body", async () => { + const { client: c } = resolver(undefined); + await expect(c.resolveModelConfigId("claude-haiku-4-5")).resolves.toBeUndefined(); + }); + + it("returns undefined for a non-array JSON body", async () => { + const { client: c } = resolver({}); + await expect(c.resolveModelConfigId("claude-haiku-4-5")).resolves.toBeUndefined(); + }); + + it("resolves a healthy exact match regardless of a malformed neighbor's position", async () => { + const malformed = { id: "bad" }; + const { client: healthyFirst } = resolver([HAIKU, malformed]); + await expect(healthyFirst.resolveModelConfigId("anthropic:claude-haiku-4-5")).resolves.toBe( + HAIKU.id, + ); + const { client: malformedFirst } = resolver([malformed, HAIKU]); + await expect(malformedFirst.resolveModelConfigId("anthropic:claude-haiku-4-5")).resolves.toBe( + HAIKU.id, + ); + }); +}); + +describe("CoderChatClient.interruptChat", () => { + const chatJson = () => JSON.stringify({ id: "c1", status: "interrupting" }); + + it("POSTs without a query by default", async () => { + const { fn, calls } = fakeFetch(() => new Response(chatJson(), { status: 200 })); + await client(fn).interruptChat("c1"); + expect(calls[0]?.url).toBe("https://x/api/experimental/chats/c1/interrupt"); + expect(calls[0]?.init.method).toBe("POST"); + }); + + it("adds ?wait=true when wait is requested", async () => { + const { fn, calls } = fakeFetch(() => new Response(chatJson(), { status: 200 })); + await client(fn).interruptChat("c1", { wait: true }); + expect(calls[0]?.url).toBe("https://x/api/experimental/chats/c1/interrupt?wait=true"); + }); + + it("omits the query for wait: false", async () => { + const { fn, calls } = fakeFetch(() => new Response(chatJson(), { status: 200 })); + await client(fn).interruptChat("c1", { wait: false }); + expect(calls[0]?.url).toBe("https://x/api/experimental/chats/c1/interrupt"); + }); + + it("still accepts a positional AbortSignal (back-compat)", async () => { + const ac = new AbortController(); + const { fn, calls } = fakeFetch(() => new Response(chatJson(), { status: 200 })); + await client(fn).interruptChat("c1", ac.signal); + expect(calls[0]?.url).toBe("https://x/api/experimental/chats/c1/interrupt"); + expect(calls[0]?.init.signal).toBe(ac.signal); + }); + + it("forwards the signal from the options form alongside wait", async () => { + const ac = new AbortController(); + const { fn, calls } = fakeFetch(() => new Response(chatJson(), { status: 200 })); + await client(fn).interruptChat("c1", { wait: true, signal: ac.signal }); + expect(calls[0]?.url).toMatch(/\?wait=true$/); + expect(calls[0]?.init.signal).toBe(ac.signal); + }); +}); + +type WatchListener = (ev: unknown) => void; + +/** A scripted WebSocket: tests emit server events; client-initiated closes are recorded. */ +class FakeWatchSocket { + readonly url: string; + readonly headers: Record; + closedWith: number[] = []; + #listeners = new Map>(); + + constructor(url: string, headers: Record) { + this.url = url; + this.headers = headers; + } + send(_data: string): void {} + close(code?: number): void { + this.closedWith.push(code ?? 0); + } + addEventListener(type: string, cb: WatchListener): void { + let set = this.#listeners.get(type); + if (!set) { + set = new Set(); + this.#listeners.set(type, set); + } + set.add(cb); + } + removeEventListener(type: string, cb: WatchListener): void { + this.#listeners.get(type)?.delete(cb); + } + emit(type: "message" | "error" | "close", ev?: unknown): void { + for (const cb of this.#listeners.get(type) ?? []) cb(ev); + } +} + +describe("CoderChatClient.watchChats", () => { + function watchClient() { + const sockets: FakeWatchSocket[] = []; + const factory: WebSocketFactory = (url, { headers }) => { + const s = new FakeWatchSocket(url, headers); + sockets.push(s); + return s as WebSocketLike; + }; + const c = new CoderChatClient({ baseUrl: "https://x", token: "t", webSocketFactory: factory }); + return { c, sockets }; + } + + const chat = (id: string) => ({ + id, + organization_id: "o", + owner_id: "u", + title: "t", + status: "waiting", + created_at: "", + updated_at: "", + archived: false, + }); + const frame = (kind: string, chatId: string) => ({ + data: JSON.stringify({ kind, chat: chat(chatId) }), + }); + + /** Let the generator run to its next suspension point (real timers). */ + const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + + it("dials /chats/watch with header auth and yields decoded events", async () => { + const { c, sockets } = watchClient(); + const iter = c.watchChats(); + const p1 = iter.next(); + await tick(); + + expect(sockets).toHaveLength(1); + expect(sockets[0]?.url).toBe("wss://x/api/experimental/chats/watch"); + expect(sockets[0]?.headers["Coder-Session-Token"]).toBe("t"); + + sockets[0]?.emit("message", frame("created", "c1")); + expect((await p1).value).toMatchObject({ kind: "created", chat: { id: "c1" } }); + + // Batched frames (defensive) are flattened in order. + sockets[0]?.emit("message", { + data: JSON.stringify([ + { kind: "status_change", chat: chat("c1") }, + { kind: "deleted", chat: chat("c2") }, + ]), + }); + expect((await iter.next()).value).toMatchObject({ kind: "status_change" }); + expect((await iter.next()).value).toMatchObject({ kind: "deleted", chat: { id: "c2" } }); + + await iter.return(undefined); + expect(sockets[0]?.closedWith).toContain(1000); + }); + + it("iterator return() tears down promptly while a read is pending", async () => { + const { c, sockets } = watchClient(); + const iter = c.watchChats(); + const pending = iter.next(); // nothing queued: suspends waiting on the socket + await tick(); + expect(sockets).toHaveLength(1); + + // A bare return() (e.g. `break`, or Readable.from(...).destroy()) must not + // wait for the next server event on this rarely-eventing stream: it has to + // wake the pending read, close the socket, and settle immediately. + const ret = await iter.return(undefined); + expect(ret.done).toBe(true); + expect((await pending).done).toBe(true); + expect(sockets[0]?.closedWith).toContain(1000); + }); + + it("iterator throw() tears down while a read is pending and rethrows", async () => { + const { c, sockets } = watchClient(); + const iter = c.watchChats(); + const pending = iter.next(); + await tick(); + + const boom = new Error("boom"); + await expect(iter.throw(boom)).rejects.toBe(boom); + expect((await pending).done).toBe(true); + expect(sockets[0]?.closedWith).toContain(1000); + }); + + it("reconnects after drops with exponential backoff, reset once an event arrives", async () => { + vi.useFakeTimers(); + try { + const { c, sockets } = watchClient(); + const ac = new AbortController(); + const iter = c.watchChats({ signal: ac.signal }); + + const p1 = iter.next(); + await vi.advanceTimersByTimeAsync(0); + expect(sockets).toHaveLength(1); + sockets[0]?.emit("message", frame("created", "c1")); + expect((await p1).value).toMatchObject({ kind: "created" }); + + const p2 = iter.next(); + await vi.advanceTimersByTimeAsync(0); + sockets[0]?.emit("close", { code: 1006 }); + // First redial after the initial 1s delay… + await vi.advanceTimersByTimeAsync(999); + expect(sockets).toHaveLength(1); + await vi.advanceTimersByTimeAsync(1); + expect(sockets).toHaveLength(2); + + // …and after a drop with no event in between, the delay doubles to 2s. + sockets[1]?.emit("close", { code: 1006 }); + await vi.advanceTimersByTimeAsync(1999); + expect(sockets).toHaveLength(2); + await vi.advanceTimersByTimeAsync(1); + expect(sockets).toHaveLength(3); + + // An event resets the backoff to 1s for the next drop. + sockets[2]?.emit("message", frame("title_change", "c1")); + expect((await p2).value).toMatchObject({ kind: "title_change" }); + const p3 = iter.next(); + await vi.advanceTimersByTimeAsync(0); + sockets[2]?.emit("close", { code: 1006 }); + await vi.advanceTimersByTimeAsync(1000); + expect(sockets).toHaveLength(4); + + ac.abort(); + expect((await p3).done).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it("stops promptly when aborted mid-connection", async () => { + const { c, sockets } = watchClient(); + const ac = new AbortController(); + const iter = c.watchChats({ signal: ac.signal }); + const p = iter.next(); + await tick(); + + ac.abort(); + expect((await p).done).toBe(true); + expect(sockets[0]?.closedWith).toContain(1000); + }); + + it("stops during the reconnect delay without redialing", async () => { + vi.useFakeTimers(); + try { + const { c, sockets } = watchClient(); + const ac = new AbortController(); + const iter = c.watchChats({ signal: ac.signal }); + const p = iter.next(); + await vi.advanceTimersByTimeAsync(0); + sockets[0]?.emit("close", { code: 1006 }); + await vi.advanceTimersByTimeAsync(0); // the generator is now sleeping before a redial + ac.abort(); + // Must resolve without advancing timers — the sleep honors the abort. + expect((await p).done).toBe(true); + expect(sockets).toHaveLength(1); + } finally { + vi.useRealTimers(); + } + }); + + it("does not dial at all on an already-aborted signal", async () => { + const { c, sockets } = watchClient(); + const ac = new AbortController(); + ac.abort(); + expect((await c.watchChats({ signal: ac.signal }).next()).done).toBe(true); + expect(sockets).toHaveLength(0); + }); + + it("throws a terminal CoderApiError on a 401 upgrade rejection instead of retrying", async () => { + const { c, sockets } = watchClient(); + const iter = c.watchChats(); + const p = iter.next(); + await tick(); + + sockets[0]?.emit("error", { message: "Unexpected server response: 401" }); + await expect(p).rejects.toMatchObject({ + name: "CoderApiError", + status: 401, + path: "/api/experimental/chats/watch", + }); + expect(sockets).toHaveLength(1); // no reconnect after a terminal failure + }); + + it("detaches from the caller's signal after a terminal failure", async () => { + // The iteration protocol never calls return() on a failed iterator, so the + // wrapper itself must drop its {once} abort listener — otherwise watchers + // re-created against one long-lived signal accumulate listeners. + const { getEventListeners } = await import("node:events"); + const { c, sockets } = watchClient(); + const ac = new AbortController(); + const iter = c.watchChats({ signal: ac.signal }); + const p = iter.next(); + await tick(); + + sockets[0]?.emit("error", { message: "Unexpected server response: 401" }); + await expect(p).rejects.toMatchObject({ status: 401 }); + expect(getEventListeners(ac.signal, "abort")).toHaveLength(0); + }); + + it("supports `await using` disposal like the native generator it replaced", async () => { + const { c, sockets } = watchClient(); + const iter = c.watchChats(); + const pending = iter.next(); + await tick(); + + const dispose = (iter as AsyncGenerator & AsyncDisposable)[Symbol.asyncDispose]; + expect(typeof dispose).toBe("function"); + await dispose.call(iter); + expect((await pending).done).toBe(true); + expect(sockets[0]?.closedWith).toContain(1000); + }); + + it("treats a 5xx upgrade rejection as transient and redials", async () => { + vi.useFakeTimers(); + try { + const { c, sockets } = watchClient(); + const ac = new AbortController(); + const iter = c.watchChats({ signal: ac.signal }); + const p = iter.next(); + await vi.advanceTimersByTimeAsync(0); + sockets[0]?.emit("error", { message: "Unexpected server response: 502" }); + await vi.advanceTimersByTimeAsync(1000); + expect(sockets).toHaveLength(2); + ac.abort(); + expect((await p).done).toBe(true); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/agent/test/unit/history.test.ts b/packages/agent/test/unit/history.test.ts new file mode 100644 index 0000000..0f7014e --- /dev/null +++ b/packages/agent/test/unit/history.test.ts @@ -0,0 +1,304 @@ +import type { UIMessage } from "ai"; +import { describe, expect, it } from "vitest"; +import type { ChatMessage, ChatMessagePart, ChatMessagePartType } from "../../src/coder/types.js"; +import { chatMessagesToUIMessages } from "../../src/model/history.js"; + +function msg(id: number, role: ChatMessage["role"], content?: ChatMessagePart[]): ChatMessage { + return { id, chat_id: "c", role, created_at: "", content }; +} + +describe("chatMessagesToUIMessages — mixed turn", () => { + it("maps a full turn and folds the role:tool result into the assistant tool part", () => { + // Round-trip sanity: the annotation IS the contract — shapes must satisfy + // the AI SDK's UIMessage types under typecheck. + const messages: UIMessage[] = chatMessagesToUIMessages([ + msg(1, "user", [{ type: "text", text: "What's the weather in Paris?" }]), + msg(2, "assistant", [ + { type: "reasoning", text: "Need the weather tool." }, + { type: "text", text: "Let me check." }, + { + type: "tool-call", + tool_call_id: "tc1", + tool_name: "getWeather", + args: { city: "Paris" }, + }, + ]), + msg(3, "tool", [ + { + type: "tool-result", + tool_call_id: "tc1", + tool_name: "getWeather", + result: { tempC: 21 }, + }, + ]), + msg(4, "assistant", [{ type: "text", text: "21°C and sunny." }]), + ]); + expect(messages).toEqual([ + { + id: "1", + role: "user", + parts: [{ type: "text", text: "What's the weather in Paris?", state: "done" }], + }, + { + id: "2", + role: "assistant", + parts: [ + { type: "reasoning", text: "Need the weather tool.", state: "done" }, + { type: "text", text: "Let me check.", state: "done" }, + { + type: "dynamic-tool", + toolName: "getWeather", + toolCallId: "tc1", + state: "output-available", + input: { city: "Paris" }, + output: { tempC: 21 }, + }, + ], + }, + { + id: "4", + role: "assistant", + parts: [{ type: "text", text: "21°C and sunny.", state: "done" }], + }, + ]); + }); + + it("keeps consecutive assistant messages separate and preserves order", () => { + const messages = chatMessagesToUIMessages([ + msg(5, "assistant", [{ type: "text", text: "one" }]), + msg(6, "assistant", [{ type: "text", text: "two" }]), + ]); + expect(messages.map((m) => m.id)).toEqual(["5", "6"]); + expect(messages.map((m) => m.parts.length)).toEqual([1, 1]); + }); + + it("normalizes the endpoint's newest-first page order to chronological", () => { + // GET /messages pages newest-first by default; passing that response + // straight in must still yield an oldest-first transcript, with the + // role:tool result (also out of order) folded into its assistant call. + const messages = chatMessagesToUIMessages([ + msg(4, "assistant", [{ type: "text", text: "21°C and sunny." }]), + msg(3, "tool", [ + { + type: "tool-result", + tool_call_id: "tc1", + tool_name: "getWeather", + result: { tempC: 21 }, + }, + ]), + msg(2, "assistant", [ + { + type: "tool-call", + tool_call_id: "tc1", + tool_name: "getWeather", + args: { city: "Paris" }, + }, + ]), + msg(1, "user", [{ type: "text", text: "What's the weather in Paris?" }]), + ]); + expect(messages.map((m) => m.id)).toEqual(["1", "2", "4"]); + expect(messages[1]?.parts[0]).toMatchObject({ + type: "dynamic-tool", + state: "output-available", + output: { tempC: 21 }, + }); + }); + + it("maps system messages to role system with text parts", () => { + const messages = chatMessagesToUIMessages([ + msg(1, "system", [{ type: "text", text: "You are helpful." }]), + ]); + expect(messages).toEqual([ + { + id: "1", + role: "system", + parts: [{ type: "text", text: "You are helpful.", state: "done" }], + }, + ]); + }); +}); + +describe("chatMessagesToUIMessages — tool calls", () => { + it("maps a call without a persisted result to state input-available", () => { + const messages = chatMessagesToUIMessages([ + msg(2, "assistant", [ + { + type: "tool-call", + tool_call_id: "tc1", + tool_name: "getWeather", + args: { city: "Paris" }, + }, + ]), + ]); + expect(messages.at(0)!.parts).toEqual([ + { + type: "dynamic-tool", + toolName: "getWeather", + toolCallId: "tc1", + state: "input-available", + input: { city: "Paris" }, + }, + ]); + }); + + it("maps an is_error result to state output-error with errorText", () => { + const messages = chatMessagesToUIMessages([ + msg(2, "assistant", [ + { type: "tool-call", tool_call_id: "tc1", tool_name: "run", args: { cmd: "ls" } }, + ]), + msg(3, "tool", [ + { + type: "tool-result", + tool_call_id: "tc1", + tool_name: "run", + result: "command not found", + is_error: true, + }, + ]), + ]); + expect(messages.at(0)!.parts).toEqual([ + { + type: "dynamic-tool", + toolName: "run", + toolCallId: "tc1", + state: "output-error", + input: { cmd: "ls" }, + errorText: "command not found", + }, + ]); + }); + + it("stringifies non-string error results for errorText", () => { + const messages = chatMessagesToUIMessages([ + msg(2, "assistant", [{ type: "tool-call", tool_call_id: "tc1", tool_name: "run", args: {} }]), + msg(3, "tool", [ + { + type: "tool-result", + tool_call_id: "tc1", + tool_name: "run", + result: { message: "boom" }, + is_error: true, + }, + ]), + ]); + const part = messages.at(0)!.parts.at(0)!; + expect(part).toMatchObject({ state: "output-error", errorText: '{"message":"boom"}' }); + }); + + it("passes provider_executed through and folds results inlined on the assistant snapshot", () => { + const messages = chatMessagesToUIMessages([ + msg(2, "assistant", [ + { + type: "tool-call", + tool_call_id: "s1", + tool_name: "read_file", + args: { path: "/x" }, + provider_executed: true, + }, + { + type: "tool-result", + tool_call_id: "s1", + tool_name: "read_file", + result: { content: "data" }, + }, + ]), + ]); + expect(messages.at(0)!.parts).toEqual([ + { + type: "dynamic-tool", + toolName: "read_file", + toolCallId: "s1", + providerExecuted: true, + state: "output-available", + input: { path: "/x" }, + output: { content: "data" }, + }, + ]); + }); + + it("produces no UIMessage for role:tool messages, even orphaned ones", () => { + const messages = chatMessagesToUIMessages([ + msg(7, "tool", [ + { type: "tool-result", tool_call_id: "gone", tool_name: "web_search", result: { hits: 3 } }, + ]), + ]); + expect(messages).toEqual([]); + }); +}); + +describe("chatMessagesToUIMessages — sources", () => { + it("maps sources to source-url parts, falls back sourceId to url, and skips url-less ones", () => { + const messages = chatMessagesToUIMessages([ + msg(2, "assistant", [ + { type: "source", source_id: "s1", url: "https://a.example", title: "A" }, + { type: "source", url: "https://b.example" }, + { type: "source", source_id: "s3", title: "no url" }, + ]), + ]); + expect(messages.at(0)!.parts).toEqual([ + { type: "source-url", sourceId: "s1", url: "https://a.example", title: "A" }, + { type: "source-url", sourceId: "https://b.example", url: "https://b.example" }, + ]); + }); +}); + +describe("chatMessagesToUIMessages — files", () => { + it("maps file parts through the fileUrl resolver", () => { + const messages = chatMessagesToUIMessages( + [msg(1, "user", [{ type: "file", file_id: "f1", media_type: "image/png", name: "cat.png" }])], + { fileUrl: (part) => `https://coder.example/files/${part.file_id}` }, + ); + expect(messages.at(0)!.parts).toEqual([ + { + type: "file", + mediaType: "image/png", + filename: "cat.png", + url: "https://coder.example/files/f1", + }, + ]); + }); + + it("falls back to the part's own url and defaults the media type", () => { + const messages = chatMessagesToUIMessages([ + msg(1, "user", [{ type: "file", file_id: "f1", url: "https://a.example/f1" }]), + ]); + expect(messages.at(0)!.parts).toEqual([ + { type: "file", mediaType: "application/octet-stream", url: "https://a.example/f1" }, + ]); + }); + + it("skips file parts when no url can be resolved", () => { + const messages = chatMessagesToUIMessages([ + msg(1, "user", [ + { type: "file", file_id: "f1", media_type: "image/png" }, + { type: "text", text: "see attachment" }, + ]), + ]); + expect(messages.at(0)!.parts).toEqual([ + { type: "text", text: "see attachment", state: "done" }, + ]); + }); +}); + +describe("chatMessagesToUIMessages — forward compatibility", () => { + it("skips file-reference/context-file/skill and unknown part kinds silently", () => { + const messages = chatMessagesToUIMessages([ + msg(1, "user", [ + { type: "file-reference", file_id: "f1", file_name: "main.go" }, + { type: "context-file", file_name: "ctx.md", content: "…" }, + { type: "skill", skill_name: "review" }, + { type: "brand-new-kind" as ChatMessagePartType }, + { type: "text", text: "hi" }, + ]), + ]); + expect(messages.at(0)!.parts).toEqual([{ type: "text", text: "hi", state: "done" }]); + }); + + it("keeps the one-ChatMessage-to-one-UIMessage mapping for empty content", () => { + const messages = chatMessagesToUIMessages([msg(1, "user"), msg(2, "assistant", [])]); + expect(messages).toEqual([ + { id: "1", role: "user", parts: [] }, + { id: "2", role: "assistant", parts: [] }, + ]); + }); +}); diff --git a/packages/agent/test/unit/language-model.test.ts b/packages/agent/test/unit/language-model.test.ts new file mode 100644 index 0000000..0c0629c --- /dev/null +++ b/packages/agent/test/unit/language-model.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from "vitest"; +import type { CoderChatClient } from "../../src/coder/client.js"; +import { CoderLanguageModel } from "../../src/model/language-model.js"; +import type { Chat, ChatMessage, ChatMessagePart, ChatStreamEvent } from "../../src/coder/types.js"; + +/** A minimal scripted stand-in for {@link CoderChatClient} (single turn). */ +class FakeClient { + #events: ChatStreamEvent[]; + + constructor(events: ChatStreamEvent[]) { + this.#events = events; + } + + async resolveModelConfigId(): Promise { + return undefined; + } + + async createChat(): Promise { + return { + id: "chat-1", + organization_id: "org-1", + owner_id: "u", + title: "t", + status: "running", + created_at: "", + updated_at: "", + archived: false, + }; + } + + async interruptChat(): Promise { + throw new Error("not used"); + } + + async *streamEvents(): AsyncGenerator { + for (const ev of this.#events) { + await Promise.resolve(); + yield ev; + } + } +} + +function msg( + id: number, + role: ChatMessage["role"], + content: ChatMessagePart[], + usage?: ChatMessage["usage"], +): ChatStreamEvent { + return { + type: "message", + chat_id: "chat-1", + message: { id, chat_id: "chat-1", role, created_at: "", content, usage }, + }; +} +function part(p: ChatMessagePart): ChatStreamEvent { + return { type: "message_part", chat_id: "chat-1", message_part: { role: "assistant", part: p } }; +} +function status(s: string): ChatStreamEvent { + return { type: "status", chat_id: "chat-1", status: { status: s as never } }; +} + +function makeModel(events: ChatStreamEvent[]): CoderLanguageModel { + return new CoderLanguageModel({ + client: new FakeClient(events) as unknown as CoderChatClient, + organizationId: "org-1", + }); +} + +/** + * A server-tool turn whose final text streams via deltas and is then finalized + * by a trailing snapshot — the shape that used to duplicate text — plus a + * source part and cost-bearing usage. + */ +const serverToolTurn: ChatStreamEvent[] = [ + status("running"), + msg(2, "assistant", [ + { type: "tool-call", tool_call_id: "s1", tool_name: "web_search", args: { q: "x" } }, + ]), + msg(3, "tool", [ + { type: "tool-result", tool_call_id: "s1", tool_name: "web_search", result: { hits: 1 } }, + ]), + part({ type: "source", source_id: "src-1", url: "https://example.com/a", title: "A" }), + part({ type: "text", text: "Done" }), + msg( + 4, + "assistant", + [ + { type: "source", source_id: "src-1", url: "https://example.com/a", title: "A" }, + { type: "text", text: "Done" }, + ], + { input_tokens: 10, output_tokens: 3, total_cost_micros: 1234, total_runtime_ms: 5678 }, + ), + status("waiting"), +]; + +describe("CoderLanguageModel.doGenerate aggregation", () => { + it("aggregates a streamed turn once: deduped text, source content, provider metadata", async () => { + const model = makeModel(serverToolTurn); + const result = await model.doGenerate({ + prompt: [{ role: "user", content: [{ type: "text", text: "hi" }] }], + } as never); + + // The trailing snapshot must not duplicate the delta-streamed text. + expect(result.content.filter((c) => c.type === "text")).toEqual([ + { type: "text", text: "Done" }, + ]); + // Source parts land in the generate() content (once). + expect(result.content.filter((c) => c.type === "source")).toEqual([ + { type: "source", sourceType: "url", id: "src-1", url: "https://example.com/a", title: "A" }, + ]); + // The finish part's provider metadata and raw usage flow into the result. + expect(result.providerMetadata).toEqual({ + coder: { total_cost_micros: 1234, total_runtime_ms: 5678 }, + }); + expect(result.usage.raw).toEqual({ + input_tokens: 10, + output_tokens: 3, + total_cost_micros: 1234, + total_runtime_ms: 5678, + }); + }); + + it("omits providerMetadata when the server sends no cost fields (old servers)", async () => { + const model = makeModel([ + status("running"), + msg(2, "assistant", [{ type: "text", text: "Hi" }], { input_tokens: 10, output_tokens: 3 }), + status("waiting"), + ]); + const result = await model.doGenerate({ + prompt: [{ role: "user", content: [{ type: "text", text: "hi" }] }], + } as never); + + expect(result.providerMetadata).toBeUndefined(); + expect(result.content).toEqual([{ type: "text", text: "Hi" }]); + }); + + it("streams source parts via doStream", async () => { + const model = makeModel(serverToolTurn); + const { stream } = await model.doStream({ + prompt: [{ role: "user", content: [{ type: "text", text: "hi" }] }], + } as never); + + const parts: unknown[] = []; + const reader = stream.getReader(); + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + parts.push(value); + } + const sources = parts.filter((p) => (p as { type: string }).type === "source"); + expect(sources).toEqual([ + { type: "source", sourceType: "url", id: "src-1", url: "https://example.com/a", title: "A" }, + ]); + }); +}); diff --git a/packages/agent/test/unit/translate.test.ts b/packages/agent/test/unit/translate.test.ts index 82a22b3..0fb4b67 100644 --- a/packages/agent/test/unit/translate.test.ts +++ b/packages/agent/test/unit/translate.test.ts @@ -32,6 +32,21 @@ function run(events: ChatStreamEvent[], dynamicToolNames = new Set()) { return { parts, t }; } +/** Reassembles the closed text blocks (start→deltas→end), in emission order. */ +function textBlocks(parts: ReturnType): string[] { + const open = new Map(); + const blocks: string[] = []; + for (const p of parts) { + if (p.type === "text-start") open.set(p.id, ""); + else if (p.type === "text-delta") open.set(p.id, (open.get(p.id) ?? "") + p.delta); + else if (p.type === "text-end") { + blocks.push(open.get(p.id) ?? ""); + open.delete(p.id); + } + } + return blocks; +} + describe("TurnTranslator — snapshot (fast) mode", () => { it("emits a single text block from a full message snapshot", () => { const { parts } = run([ @@ -200,3 +215,188 @@ describe("TurnTranslator — errors", () => { expect(finish.type === "finish" && finish.finishReason.unified).toBe("error"); }); }); + +describe("TurnTranslator — trailing snapshots after deltas", () => { + it("emits delta-streamed final text once when its snapshot follows an earlier assistant message", () => { + const { parts } = run([ + msg(2, "assistant", [ + { type: "tool-call", tool_call_id: "s1", tool_name: "web_search", args: { q: "x" } }, + ]), + msg(3, "tool", [ + { type: "tool-result", tool_call_id: "s1", tool_name: "web_search", result: { hits: 1 } }, + ]), + part("assistant", { type: "text", text: "Done" }), + // Trailing snapshot of the SAME message the deltas streamed — must be a + // no-op for text, even though its id differs from the previous snapshot's. + msg(4, "assistant", [{ type: "text", text: "Done" }]), + status("waiting"), + ]); + expect(textBlocks(parts)).toEqual(["Done"]); + }); + + it("keeps per-message text blocks across a multi-round tool turn (no duplicates, no merged blocks)", () => { + const round = (id: number, text: string, callId: string): ChatStreamEvent[] => [ + part("assistant", { type: "text", text }), + part("assistant", { type: "tool-call", tool_call_id: callId, tool_name: "run", args: {} }), + msg(id, "assistant", [ + { type: "text", text }, + { type: "tool-call", tool_call_id: callId, tool_name: "run", args: {} }, + ]), + msg(id + 1, "tool", [ + { type: "tool-result", tool_call_id: callId, tool_name: "run", result: {} }, + ]), + ]; + const { parts } = run([ + ...round(2, "A", "s1"), + ...round(4, "B", "s2"), + part("assistant", { type: "text", text: "C" }), + msg(6, "assistant", [{ type: "text", text: "C" }]), + status("waiting"), + ]); + expect(textBlocks(parts)).toEqual(["A", "B", "C"]); + }); + + it("emits delta-streamed reasoning once when its snapshot follows an earlier assistant message", () => { + const { parts } = run([ + msg(2, "assistant", [{ type: "tool-call", tool_call_id: "s1", tool_name: "run", args: {} }]), + msg(3, "tool", [{ type: "tool-result", tool_call_id: "s1", tool_name: "run", result: {} }]), + part("assistant", { type: "reasoning", text: "Think" }), + part("assistant", { type: "text", text: "Done" }), + msg(4, "assistant", [ + { type: "reasoning", text: "Think" }, + { type: "text", text: "Done" }, + ]), + status("waiting"), + ]); + const reasoning = parts + .filter((p) => p.type === "reasoning-delta") + .map((p) => ("delta" in p ? p.delta : "")) + .join(""); + expect(reasoning).toBe("Think"); + expect(textBlocks(parts)).toEqual(["Done"]); + }); + + it("does not duplicate when an empty snapshot announces the message before its deltas", () => { + const { parts } = run([ + msg(2, "assistant", []), + part("assistant", { type: "text", text: "Hel" }), + part("assistant", { type: "text", text: "lo" }), + msg(2, "assistant", [{ type: "text", text: "Hello" }]), + status("waiting"), + ]); + expect(textBlocks(parts)).toEqual(["Hello"]); + }); + + it("still emits a snapshot-only message that follows a delta-streamed one", () => { + // Mode is decided per message: A streamed via deltas, B arrives snapshot-only. + const { parts } = run([ + part("assistant", { type: "text", text: "A" }), + msg(2, "assistant", [{ type: "text", text: "A" }]), + msg(4, "assistant", [{ type: "text", text: "B" }]), + status("waiting"), + ]); + expect(textBlocks(parts)).toEqual(["A", "B"]); + }); +}); + +describe("TurnTranslator — source parts", () => { + it("emits a url source from a streamed message_part once, deduping the trailing snapshot", () => { + const src: ChatMessagePart = { + type: "source", + source_id: "src-1", + url: "https://example.com/a", + title: "A", + }; + const { parts } = run([ + part("assistant", src), + part("assistant", { type: "text", text: "Cited." }), + msg(2, "assistant", [src, { type: "text", text: "Cited." }]), + status("waiting"), + ]); + expect(parts.filter((p) => p.type === "source")).toEqual([ + { type: "source", sourceType: "url", id: "src-1", url: "https://example.com/a", title: "A" }, + ]); + expect(textBlocks(parts)).toEqual(["Cited."]); + }); + + it("emits sources from a snapshot-only turn, falling back to the url as id", () => { + const { parts } = run([ + msg(2, "assistant", [ + { type: "source", url: "https://example.com/b" }, + { type: "text", text: "See source." }, + ]), + status("waiting"), + ]); + expect(parts.filter((p) => p.type === "source")).toEqual([ + { + type: "source", + sourceType: "url", + id: "https://example.com/b", + url: "https://example.com/b", + }, + ]); + }); + + it("skips source parts without a url (the V3 part requires id + url)", () => { + const { parts } = run([ + msg(2, "assistant", [ + { type: "source", source_id: "src-broken" }, + { type: "text", text: "ok" }, + ]), + status("waiting"), + ]); + expect(parts.some((p) => p.type === "source")).toBe(false); + expect(textBlocks(parts)).toEqual(["ok"]); + }); +}); + +describe("TurnTranslator — usage cost metadata", () => { + it("surfaces wire cost/runtime under providerMetadata.coder and the verbatim usage under usage.raw", () => { + const usage = { + input_tokens: 10, + output_tokens: 3, + total_cost_micros: 1234, + total_runtime_ms: 5678, + }; + const { parts } = run([ + msg(2, "assistant", [{ type: "text", text: "Hi" }], usage), + status("waiting"), + ]); + const finish = parts.at(-1)!; + expect(finish.type).toBe("finish"); + if (finish.type !== "finish") return; + expect(finish.providerMetadata).toEqual({ + coder: { total_cost_micros: 1234, total_runtime_ms: 5678 }, + }); + expect(finish.usage.raw).toEqual(usage); + }); + + it("includes only the cost keys the wire actually sent", () => { + const { parts } = run([ + msg(2, "assistant", [{ type: "text", text: "Hi" }], { total_cost_micros: 42 }), + status("waiting"), + ]); + const finish = parts.at(-1)!; + if (finish.type !== "finish") return; + expect(finish.providerMetadata).toEqual({ coder: { total_cost_micros: 42 } }); + }); + + it("omits providerMetadata when the server sends no cost fields (old servers)", () => { + const { parts } = run([ + msg(2, "assistant", [{ type: "text", text: "Hi" }], { input_tokens: 10, output_tokens: 3 }), + status("waiting"), + ]); + const finish = parts.at(-1)!; + if (finish.type !== "finish") return; + expect(finish.providerMetadata).toBeUndefined(); + expect(finish.usage.raw).toEqual({ input_tokens: 10, output_tokens: 3 }); + }); + + it("leaves usage.raw unset when no usage arrived", () => { + const { parts } = run([msg(2, "assistant", [{ type: "text", text: "Hi" }]), status("waiting")]); + const finish = parts.at(-1)!; + if (finish.type !== "finish") return; + expect(finish.usage.raw).toBeUndefined(); + expect(finish.providerMetadata).toBeUndefined(); + }); +}); diff --git a/packages/agent/test/unit/workspaces.test.ts b/packages/agent/test/unit/workspaces.test.ts new file mode 100644 index 0000000..9a4710b --- /dev/null +++ b/packages/agent/test/unit/workspaces.test.ts @@ -0,0 +1,308 @@ +import { describe, expect, it } from "vitest"; +import { + getAppsHost, + getWorkspace, + resolveWorkspacePreview, + shareWorkspacePreview, + upsertPortShare, + type WorkspaceApiConnection, + type WorkspaceSummary, +} from "../../src/coder/workspaces.js"; +import { CoderAgentError, CoderApiError } from "../../src/errors.js"; + +type Init = RequestInit & { headers: Record }; + +/** A fake `fetch` that records calls and routes scripted responses by path. */ +function fakeFetch(routes: Record Response>) { + const calls: { url: string; init: Init }[] = []; + const fn = ((url: string, init: Init) => { + calls.push({ url, init }); + const route = routes[new URL(url).pathname]; + if (!route) { + return Promise.resolve( + new Response(JSON.stringify({ message: `no fake route for ${url}` }), { status: 599 }), + ); + } + return Promise.resolve(route(init)); + }) as unknown as typeof globalThis.fetch; + return { fn, calls }; +} + +function conn(fetchFn: typeof globalThis.fetch, baseUrl = "https://coder.example.com") { + return { baseUrl, token: "tok", fetch: fetchFn } satisfies WorkspaceApiConnection; +} + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status }); +} + +const WORKSPACE: WorkspaceSummary = { + id: "ws-1", + owner_name: "alice", + name: "dev", + latest_build: { resources: [{ agents: [{ name: "main" }] }] }, +}; + +describe("workspace REST helpers", () => { + it("getWorkspace GETs /api/v2/workspaces/{id} with the session token", async () => { + const { fn, calls } = fakeFetch({ "/api/v2/workspaces/ws-1": () => json(WORKSPACE) }); + const ws = await getWorkspace(conn(fn), "ws-1"); + + expect(ws.owner_name).toBe("alice"); + expect(ws.name).toBe("dev"); + expect(calls[0]?.url).toBe("https://coder.example.com/api/v2/workspaces/ws-1"); + expect(calls[0]?.init.method).toBe("GET"); + expect(calls[0]?.init.headers["Coder-Session-Token"]).toBe("tok"); + }); + + it("getAppsHost GETs /api/v2/applications/host and strips a trailing base-URL slash", async () => { + const { fn, calls } = fakeFetch({ + "/api/v2/applications/host": () => json({ host: "*.apps.example.com" }), + }); + const res = await getAppsHost(conn(fn, "https://coder.example.com/")); + + expect(res.host).toBe("*.apps.example.com"); + expect(calls[0]?.url).toBe("https://coder.example.com/api/v2/applications/host"); + }); + + it("surfaces a non-2xx response as a CoderApiError with the server message", async () => { + const { fn } = fakeFetch({ + "/api/v2/workspaces/ws-1": () => json({ message: "no workspace here" }, 404), + }); + await expect(getWorkspace(conn(fn), "ws-1")).rejects.toMatchObject({ + name: "CoderApiError", + status: 404, + message: expect.stringContaining("no workspace here"), + }); + }); + + it("upsertPortShare POSTs the exact wire shape and returns the row", async () => { + const { fn, calls } = fakeFetch({ + "/api/v2/workspaces/ws-1/port-share": () => + json({ + workspace_id: "ws-1", + agent_name: "main", + port: 3000, + share_level: "public", + protocol: "http", + }), + }); + const share = await upsertPortShare(conn(fn), "ws-1", { + agent_name: "main", + port: 3000, + share_level: "public", + protocol: "http", + }); + + expect(share.share_level).toBe("public"); + expect(calls[0]?.init.method).toBe("POST"); + expect(calls[0]?.init.headers["Content-Type"]).toBe("application/json"); + expect(JSON.parse(String(calls[0]?.init.body))).toEqual({ + agent_name: "main", + port: 3000, + share_level: "public", + protocol: "http", + }); + }); + + it("maps a port-share 404 to a hint that the server may predate port sharing", async () => { + const { fn } = fakeFetch({ + "/api/v2/workspaces/ws-1/port-share": () => json({ message: "not found" }, 404), + }); + const err = await upsertPortShare(conn(fn), "ws-1", { + agent_name: "main", + port: 3000, + share_level: "authenticated", + protocol: "http", + }).then( + () => undefined, + (e: unknown) => e, + ); + + expect(err).toBeInstanceOf(CoderApiError); + expect((err as CoderApiError).status).toBe(404); + expect((err as CoderApiError).message).toMatch(/predates port sharing/); + }); +}); + +describe("resolveWorkspacePreview", () => { + function previewFetch(workspace: unknown = WORKSPACE, host = "*.apps.example.com") { + return fakeFetch({ + "/api/v2/workspaces/ws-1": () => json(workspace), + "/api/v2/applications/host": () => json({ host }), + }); + } + + it("builds {port}--{agent}--{workspace}--{owner} into the wildcard host", async () => { + const { fn } = previewFetch(); + const { url, agentName } = await resolveWorkspacePreview(conn(fn), { + workspaceId: "ws-1", + port: 3000, + }); + + expect(url).toBe("https://3000--main--dev--alice.apps.example.com"); + expect(agentName).toBe("main"); + }); + + it("rejects ports below 1000 that subdomain URLs cannot encode", async () => { + const { fn } = previewFetch(); + // appurl.PortRegex is `^\d{4,5}s?$`: `80--agent--…` would be parsed as an + // app named "80", so the helper must refuse instead of minting a dead URL. + for (const port of [80, 443, 999]) { + await expect( + resolveWorkspacePreview(conn(fn), { workspaceId: "ws-1", port }), + ).rejects.toThrow(/cannot be previewed.*4-5 digit/); + } + // 1000 is the smallest encodable port and must still work. + const { url } = await resolveWorkspacePreview(conn(fn), { workspaceId: "ws-1", port: 1000 }); + expect(url).toBe("https://1000--main--dev--alice.apps.example.com"); + }); + + it("adds the `s` suffix for an in-workspace https port", async () => { + const { fn } = previewFetch(); + const { url } = await resolveWorkspacePreview(conn(fn), { + workspaceId: "ws-1", + port: 8080, + protocol: "https", + }); + expect(url).toBe("https://8080s--main--dev--alice.apps.example.com"); + }); + + it("keeps the app host's explicit port and follows the deployment scheme", async () => { + const { fn } = previewFetch(WORKSPACE, "*.apps.localhost:3000"); + const { url } = await resolveWorkspacePreview(conn(fn, "http://localhost:3000"), { + workspaceId: "ws-1", + port: 8080, + }); + expect(url).toBe("http://8080--main--dev--alice.apps.localhost:3000"); + }); + + it("substitutes into suffixed wildcard hosts (e.g. `*--apps.example.com`)", async () => { + const { fn } = previewFetch(WORKSPACE, "*--apps.coder.example"); + const { url } = await resolveWorkspacePreview(conn(fn), { workspaceId: "ws-1", port: 3000 }); + expect(url).toBe("https://3000--main--dev--alice--apps.coder.example"); + }); + + it("errors clearly when no wildcard app host is configured", async () => { + const { fn } = previewFetch(WORKSPACE, ""); + await expect( + resolveWorkspacePreview(conn(fn), { workspaceId: "ws-1", port: 3000 }), + ).rejects.toThrow(/wildcard app host/); + }); + + it("errors listing the agent names when several agents exist and none is chosen", async () => { + const multi: WorkspaceSummary = { + ...WORKSPACE, + latest_build: { resources: [{ agents: [{ name: "main" }, { name: "gpu" }] }] }, + }; + const { fn } = previewFetch(multi); + await expect( + resolveWorkspacePreview(conn(fn), { workspaceId: "ws-1", port: 3000 }), + ).rejects.toThrow(/main, gpu/); + }); + + it("uses an explicit agentName as-is, even with several agents", async () => { + const multi: WorkspaceSummary = { + ...WORKSPACE, + latest_build: { + resources: [{ agents: [{ name: "main" }] }, { agents: [{ name: "gpu" }] }], + }, + }; + const { fn } = previewFetch(multi); + const { url } = await resolveWorkspacePreview(conn(fn), { + workspaceId: "ws-1", + port: 3000, + agentName: "gpu", + }); + expect(url).toBe("https://3000--gpu--dev--alice.apps.example.com"); + }); + + it("errors when the latest build has no agents (resources without an agents key)", async () => { + const stopped: WorkspaceSummary = { + ...WORKSPACE, + latest_build: { resources: [{}] }, + }; + const { fn } = previewFetch(stopped); + await expect( + resolveWorkspacePreview(conn(fn), { workspaceId: "ws-1", port: 3000 }), + ).rejects.toThrow(/no agents/); + }); + + it("rejects invalid ports before issuing any request", async () => { + const { fn, calls } = previewFetch(); + for (const port of [0, -1, 1.5, 70000, Number.NaN]) { + await expect( + resolveWorkspacePreview(conn(fn), { workspaceId: "ws-1", port }), + ).rejects.toThrow(CoderAgentError); + } + expect(calls).toHaveLength(0); + }); +}); + +describe("shareWorkspacePreview", () => { + it("resolves the URL, upserts the share, and returns the confirmed level", async () => { + const { fn, calls } = fakeFetch({ + "/api/v2/workspaces/ws-1": () => json(WORKSPACE), + "/api/v2/applications/host": () => json({ host: "*.apps.example.com" }), + "/api/v2/workspaces/ws-1/port-share": () => + json({ + workspace_id: "ws-1", + agent_name: "main", + port: 3000, + share_level: "public", + protocol: "http", + }), + }); + const result = await shareWorkspacePreview(conn(fn), { + workspaceId: "ws-1", + port: 3000, + shareLevel: "public", + }); + + expect(result).toEqual({ + url: "https://3000--main--dev--alice.apps.example.com", + shareLevel: "public", + }); + const post = calls.find((c) => c.init.method === "POST"); + expect(JSON.parse(String(post?.init.body))).toEqual({ + agent_name: "main", + port: 3000, + share_level: "public", + protocol: "http", + }); + }); + + it("defaults the share level to authenticated", async () => { + const { fn, calls } = fakeFetch({ + "/api/v2/workspaces/ws-1": () => json(WORKSPACE), + "/api/v2/applications/host": () => json({ host: "*.apps.example.com" }), + "/api/v2/workspaces/ws-1/port-share": () => + json({ + workspace_id: "ws-1", + agent_name: "main", + port: 3000, + share_level: "authenticated", + protocol: "http", + }), + }); + const result = await shareWorkspacePreview(conn(fn), { workspaceId: "ws-1", port: 3000 }); + + expect(result.shareLevel).toBe("authenticated"); + const post = calls.find((c) => c.init.method === "POST"); + expect(JSON.parse(String(post?.init.body)).share_level).toBe("authenticated"); + }); + + it("falls back to the requested level when the server responds with an empty body", async () => { + const { fn } = fakeFetch({ + "/api/v2/workspaces/ws-1": () => json(WORKSPACE), + "/api/v2/applications/host": () => json({ host: "*.apps.example.com" }), + "/api/v2/workspaces/ws-1/port-share": () => new Response("", { status: 200 }), + }); + const result = await shareWorkspacePreview(conn(fn), { + workspaceId: "ws-1", + port: 3000, + shareLevel: "organization", + }); + expect(result.shareLevel).toBe("organization"); + }); +}); diff --git a/packages/sandbox/README.md b/packages/sandbox/README.md index c2071b9..3868c81 100644 --- a/packages/sandbox/README.md +++ b/packages/sandbox/README.md @@ -128,6 +128,44 @@ createCoderWorkspace({ }); ``` +## Provisioning a workspace without a session + +`ensureCoderWorkspace(settings)` runs the same get-or-create → start-if-stopped +→ wait-until-ready pipeline as create mode, but without creating a harness +sandbox session — use it to provision a workspace for **other tools** to bind +to. It takes an explicit `workspace` name (`[owner/]workspace`; there is no +`sessionId` to derive one from), an optional `create` block (same shape as +above; `namePrefix`/`owner` are unused), plus `readyTimeoutMs`, `transport`, and +`abortSignal`. A stopped workspace is always started. Without `create`, the +workspace must already exist. + +It returns an `EnsuredCoderWorkspace`: the workspace's final (ready) status +snapshot plus `created` (whether this call created it) and — when the transport +reports one — `id`, the workspace UUID. That id is the handle other Coder +packages bind to. `@coder/ai-sdk-agent` is intentionally **not** a dependency of +this package; the two compose by a plain string handoff: + +```ts +import { ensureCoderWorkspace } from "@coder/ai-sdk-sandbox"; +import { CoderAgent } from "@coder/ai-sdk-agent"; + +const ws = await ensureCoderWorkspace({ + workspace: "agent-ws", + create: { template: "docker" }, +}); + +const agent = new CoderAgent({ + baseUrl: process.env.CODER_URL!, + token: process.env.CODER_SESSION_TOKEN!, + organizationId: "", + workspaceId: ws.id!, // binds the chat's workspace-scoped tools +}); +``` + +The non-null assertion is safe on `coder` CLIs that emit `id` in +`coder list -o json`; old CLIs omit it, so guard +(`if (ws.id === undefined) throw …`) when you can't pin the CLI version. + ## Terminal UI For an interactive chat in your terminal instead of one-shot `generate()` calls, diff --git a/packages/sandbox/src/cli-transport.ts b/packages/sandbox/src/cli-transport.ts index a1cc7eb..c530d76 100644 --- a/packages/sandbox/src/cli-transport.ts +++ b/packages/sandbox/src/cli-transport.ts @@ -219,6 +219,8 @@ export function parseWorkspaceStatus(workspace: unknown): WorkspaceStatus { } } return { + // The workspace UUID; omitted (not required) so old CLI output stays accepted. + ...(typeof ws.id === "string" && ws.id !== "" ? { id: ws.id } : {}), name: typeof ws.name === "string" ? ws.name : "", buildStatus: typeof build.status === "string" ? build.status : "pending", transition: typeof build.transition === "string" ? build.transition : "start", diff --git a/packages/sandbox/src/coder-workspace-provider.ts b/packages/sandbox/src/coder-workspace-provider.ts index 36d7b65..c6bb044 100644 --- a/packages/sandbox/src/coder-workspace-provider.ts +++ b/packages/sandbox/src/coder-workspace-provider.ts @@ -265,10 +265,88 @@ export function createCoderWorkspace(settings: CoderWorkspaceSettings): HarnessV }; } +/** + * Settings for {@link ensureCoderWorkspace} — the provisioning subset of + * {@link createCoderWorkspace}'s settings. There is no harness session here, so + * the workspace name must be explicit (no `sessionId` to derive one from) and a + * stopped workspace is always started. + */ +export interface EnsureCoderWorkspaceSettings extends Pick< + CoderWorkspaceBaseSettings, + "readyTimeoutMs" | "transport" +> { + /** The workspace to ensure, as `[owner/]workspace`. Required. */ + workspace: string; + /** + * Create the workspace from a template when it doesn't exist; without it an + * existing workspace is required. `create.namePrefix` and `create.owner` are + * unused here (they only shape provider-derived names). + */ + create?: CoderCreateSettings; + /** Aborts pending create/start/status calls and the readiness polling. */ + abortSignal?: AbortSignal; +} + +/** + * Result of {@link ensureCoderWorkspace}: the workspace's final (ready) status + * snapshot plus whether this call created it. + */ +export interface EnsuredCoderWorkspace extends WorkspaceStatus { + /** `true` when this call created the workspace (vs. reusing an existing one). */ + created: boolean; +} + +/** + * Ensure a Coder workspace exists, is started, and has a ready agent — without + * creating a harness sandbox session. Get-or-creates (when `create` is set), + * runs `coder start` if stopped, then polls until an agent is connected and its + * startup script has finished. Shares its internals with + * {@link createCoderWorkspace}'s create mode. + * + * The result includes the workspace UUID (`id`) when the transport reports one + * (a new-enough `coder` CLI; older CLIs may omit it) — the handle that other + * Coder packages bind to. `@coder/ai-sdk-agent` is intentionally *not* a + * dependency of this package; compose the two by passing the id: + * + * @example Provision a workspace, then bind a `CoderAgent` chat to it + * ```ts + * import { ensureCoderWorkspace } from '@coder/ai-sdk-sandbox'; + * import { CoderAgent } from '@coder/ai-sdk-agent'; + * + * const ws = await ensureCoderWorkspace({ + * workspace: 'agent-ws', + * create: { template: 'docker' }, + * }); + * if (ws.id === undefined) { + * throw new Error('this coder CLI does not report workspace ids; upgrade it'); + * } + * const agent = new CoderAgent({ + * baseUrl: 'https://coder.example.com', + * token: process.env.CODER_SESSION_TOKEN!, + * organizationId: '', + * workspaceId: ws.id, // binds the chat's workspace-scoped tools + * }); + * ``` + */ +export async function ensureCoderWorkspace( + settings: EnsureCoderWorkspaceSettings, +): Promise { + const transport: CoderTransport = settings.transport ?? new CoderCliTransport(); + const { created, status } = await ensureWorkspaceReady( + transport, + settings.workspace, + settings.create, + settings.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS, + "ensureCoderWorkspace", + settings.abortSignal, + ); + return { ...status, created }; +} + /** * Get-or-create the workspace and (in create mode) wait for its agent to be * ready. In wrap mode this preserves the original behavior (optionally - * `coder start`). + * `coder start`, no readiness wait). */ async function ensureWorkspace( transport: CoderTransport, @@ -277,27 +355,55 @@ async function ensureWorkspace( readyTimeoutMs: number, abortSignal?: AbortSignal, ): Promise<{ createdByProvider: boolean }> { - const create = settings.create; - if (create === undefined) { + if (settings.create === undefined) { if (settings.ensureStarted) { await transport.start(workspace, { abortSignal }); } return { createdByProvider: false }; } + const { created } = await ensureWorkspaceReady( + transport, + workspace, + settings.create, + readyTimeoutMs, + "createCoderWorkspace", + abortSignal, + ); + return { createdByProvider: created }; +} +/** + * Shared get-or-create + start-if-stopped + wait-until-ready core behind both + * {@link createCoderWorkspace}'s create mode and {@link ensureCoderWorkspace}. + * `caller` names the public entry point in error messages. + */ +async function ensureWorkspaceReady( + transport: CoderTransport, + workspace: string, + create: CoderCreateSettings | undefined, + readyTimeoutMs: number, + caller: string, + abortSignal?: AbortSignal, +): Promise<{ created: boolean; status: WorkspaceStatus }> { const existing = await transport.status(workspace, { abortSignal }); - let createdByProvider = false; + let created = false; if (existing === null) { + if (create === undefined) { + throw new Error( + `${caller}: workspace "${workspace}" does not exist; set \`create\` to ` + + `create it from a template.`, + ); + } if (create.validate ?? true) { - await validatePreset(transport, create, abortSignal); + await validatePreset(transport, create, caller, abortSignal); } await transport.create(toCreateOptions(workspace, create, abortSignal)); - createdByProvider = true; + created = true; } else { - if (create.ifExists === "error") { + if (create?.ifExists === "error") { throw new Error( - `createCoderWorkspace: workspace "${workspace}" already exists (create.ifExists: 'error').`, + `${caller}: workspace "${workspace}" already exists (create.ifExists: 'error').`, ); } if (isStopped(existing)) { @@ -305,8 +411,8 @@ async function ensureWorkspace( } } - await waitForReady(transport, workspace, readyTimeoutMs, abortSignal); - return { createdByProvider }; + const status = await waitForReady(transport, workspace, readyTimeoutMs, caller, abortSignal); + return { created, status }; } function isStopped(status: WorkspaceStatus): boolean { @@ -353,6 +459,7 @@ function stringifyParams( async function validatePreset( transport: CoderTransport, create: CoderCreateSettings, + caller: string, abortSignal?: AbortSignal, ): Promise { if (create.preset === undefined || create.preset.toLowerCase() === "none") return; @@ -374,7 +481,7 @@ async function validatePreset( if (!presets.some((preset) => preset.name === create.preset)) { const available = presets.map((preset) => `"${preset.name}"`).join(", "); throw new Error( - `createCoderWorkspace: preset "${create.preset}" not found for template ` + + `${caller}: preset "${create.preset}" not found for template ` + `"${create.template}". Available presets: ${available || "(none)"}.`, ); } @@ -384,13 +491,15 @@ async function validatePreset( * Poll the workspace until its agent is connected and its startup script has * finished (`lifecycle_state: ready`), failing fast on build or startup errors. * A successful build is *not* sufficient — the agent connects afterwards. + * Resolves with the final (ready) status snapshot. */ async function waitForReady( transport: CoderTransport, workspace: string, timeoutMs: number, + caller: string, abortSignal?: AbortSignal, -): Promise { +): Promise { const deadline = Date.now() + timeoutMs; let last = "unknown"; for (;;) { @@ -402,19 +511,17 @@ async function waitForReady( status.agents.map((a) => `${a.name || "?"}:${a.status}/${a.lifecycleState}`).join(", ") + "]"; if (status.buildStatus === "failed") { - throw new Error(`createCoderWorkspace: workspace "${workspace}" build failed (${last}).`); + throw new Error(`${caller}: workspace "${workspace}" build failed (${last}).`); } if (status.buildStatus === "canceled" || status.buildStatus === "deleted") { - throw new Error( - `createCoderWorkspace: workspace "${workspace}" is ${status.buildStatus} (${last}).`, - ); + throw new Error(`${caller}: workspace "${workspace}" is ${status.buildStatus} (${last}).`); } const errored = status.agents.find( (a) => a.lifecycleState === "start_error" || a.lifecycleState === "start_timeout", ); if (errored) { throw new Error( - `createCoderWorkspace: workspace "${workspace}" agent "${errored.name || "?"}" ` + + `${caller}: workspace "${workspace}" agent "${errored.name || "?"}" ` + `failed to start (lifecycle: ${errored.lifecycleState}).`, ); } @@ -422,12 +529,12 @@ async function waitForReady( status.buildStatus === "running" && status.agents.some((a) => a.status === "connected" && a.lifecycleState === "ready") ) { - return; + return status; } } if (Date.now() >= deadline) { throw new Error( - `createCoderWorkspace: timed out after ${timeoutMs}ms waiting for workspace ` + + `${caller}: timed out after ${timeoutMs}ms waiting for workspace ` + `"${workspace}" to become ready (last status: ${last}).`, ); } diff --git a/packages/sandbox/src/index.ts b/packages/sandbox/src/index.ts index befc5da..981af5c 100644 --- a/packages/sandbox/src/index.ts +++ b/packages/sandbox/src/index.ts @@ -6,6 +6,9 @@ export { type CoderWorkspaceRef, type CoderWorkspaceSettings, createCoderWorkspace, + ensureCoderWorkspace, + type EnsureCoderWorkspaceSettings, + type EnsuredCoderWorkspace, } from "./coder-workspace-provider.js"; export { CoderWorkspaceSession, diff --git a/packages/sandbox/src/transport.ts b/packages/sandbox/src/transport.ts index c2d9ce2..710a8fa 100644 --- a/packages/sandbox/src/transport.ts +++ b/packages/sandbox/src/transport.ts @@ -92,6 +92,11 @@ export interface WorkspaceAgentInfo { } export interface WorkspaceStatus { + /** + * Workspace UUID (top-level `id` in the Coder API). Optional: old `coder` + * CLIs or non-CLI transports may not report it, so never require it. + */ + id?: string; /** Workspace name (without owner/agent qualifiers). */ name: string; /** Workspace-level build status (`latest_build.status`). */ diff --git a/packages/sandbox/test/cli-transport-args.test.ts b/packages/sandbox/test/cli-transport-args.test.ts index 6fa2f3d..f5167aa 100644 --- a/packages/sandbox/test/cli-transport-args.test.ts +++ b/packages/sandbox/test/cli-transport-args.test.ts @@ -221,6 +221,21 @@ describe("parseWorkspaceStatus", () => { expect(status.transition).toBe("start"); expect(status.agents).toEqual([]); }); + + it("surfaces the workspace UUID when the CLI reports one", () => { + const status = parseWorkspaceStatus({ + id: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", + name: "ws", + latest_build: { status: "running", transition: "start", resources: [] }, + }); + expect(status.id).toBe("aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"); + }); + + it("omits id for old CLIs that don't report one (or report a non-string)", () => { + expect("id" in parseWorkspaceStatus({ name: "ws" })).toBe(false); + expect(parseWorkspaceStatus({ id: 42, name: "ws" }).id).toBeUndefined(); + expect(parseWorkspaceStatus({ id: "", name: "ws" }).id).toBeUndefined(); + }); }); describe("parsePresetList", () => { diff --git a/packages/sandbox/test/fake-coder.ts b/packages/sandbox/test/fake-coder.ts index 6746c98..dee6fc7 100644 --- a/packages/sandbox/test/fake-coder.ts +++ b/packages/sandbox/test/fake-coder.ts @@ -2,6 +2,9 @@ import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +/** Fixed workspace UUID the fake `coder list` reports for every workspace. */ +export const FAKE_WORKSPACE_ID = "3f9fc0a2-0000-4000-8000-000000000000"; + /** * A stand-in for the `coder` CLI used by the integration tests. It runs * commands on the local machine instead of a workspace: @@ -60,7 +63,7 @@ case "$sub" in done name="\${search##*name:}"; name="\${name%% *}"; base="\${name##*/}" if [ -n "$base" ] && [ -f "$STATE_DIR/$base" ]; then - printf '[{"name":"%s","latest_build":{"status":"running","transition":"start","resources":[{"agents":[{"name":"main","status":"connected","lifecycle_state":"ready"}]}]}}]' "$base" + printf '[{"id":"${FAKE_WORKSPACE_ID}","name":"%s","latest_build":{"status":"running","transition":"start","resources":[{"agents":[{"name":"main","status":"connected","lifecycle_state":"ready"}]}]}}]' "$base" else printf '[]' fi diff --git a/packages/sandbox/test/integration-local.test.ts b/packages/sandbox/test/integration-local.test.ts index 67c48d7..6b824f8 100644 --- a/packages/sandbox/test/integration-local.test.ts +++ b/packages/sandbox/test/integration-local.test.ts @@ -4,9 +4,9 @@ import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { CoderCliTransport } from "../src/cli-transport.js"; -import { createCoderWorkspace } from "../src/coder-workspace-provider.js"; +import { createCoderWorkspace, ensureCoderWorkspace } from "../src/coder-workspace-provider.js"; import * as fileIo from "../src/file-io.js"; -import { createFakeCoder, createFakeSsh, type FakeCoder } from "./fake-coder.js"; +import { createFakeCoder, createFakeSsh, FAKE_WORKSPACE_ID, type FakeCoder } from "./fake-coder.js"; let fakeCoder: FakeCoder; let fakeSsh: FakeCoder; @@ -153,6 +153,7 @@ describe("CoderCliTransport create/status/presets (via fake coder)", () => { await transport.create({ workspace: name, template: "docker", preset: "Standard" }); const status = await transport.status(name); expect(status).not.toBeNull(); + expect(status!.id).toBe(FAKE_WORKSPACE_ID); expect(status!.name).toBe(name); expect(status!.buildStatus).toBe("running"); expect(status!.agents[0]).toMatchObject({ status: "connected", lifecycleState: "ready" }); @@ -167,6 +168,30 @@ describe("CoderCliTransport create/status/presets (via fake coder)", () => { }); }); +describe("ensureCoderWorkspace (via fake coder)", () => { + it("get-or-creates the workspace and reports its UUID", async () => { + const name = `it-ensure-${Date.now()}`; + const ws = await ensureCoderWorkspace({ + workspace: name, + create: { template: "docker", preset: "Standard" }, + transport, + }); + expect(ws.created).toBe(true); + expect(ws.id).toBe(FAKE_WORKSPACE_ID); + expect(ws.name).toBe(name); + expect(ws.agents.some((a) => a.status === "connected" && a.lifecycleState === "ready")).toBe( + true, + ); + + // A second ensure attaches to the existing workspace instead of creating. + const again = await ensureCoderWorkspace({ workspace: name, transport }); + expect(again.created).toBe(false); + expect(again.id).toBe(FAKE_WORKSPACE_ID); + + await transport.destroy(name); + }); +}); + describe("createCoderWorkspace create mode (via fake coder + ssh)", () => { it("get-or-creates a workspace, runs a command in it, and deletes it on destroy", async () => { const name = `prov-create-${Date.now()}`; diff --git a/packages/sandbox/test/provider.test.ts b/packages/sandbox/test/provider.test.ts index ac3d8de..c649da3 100644 --- a/packages/sandbox/test/provider.test.ts +++ b/packages/sandbox/test/provider.test.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import { describe, expect, it } from "vitest"; -import { createCoderWorkspace } from "../src/coder-workspace-provider.js"; +import { createCoderWorkspace, ensureCoderWorkspace } from "../src/coder-workspace-provider.js"; +import * as sandbox from "../src/index.js"; import type { CoderTransport, CreateWorkspaceOptions, @@ -43,8 +44,9 @@ class MockTransport implements CoderTransport { } } -function readyStatus(name = "ws"): WorkspaceStatus { +function readyStatus(name = "ws", id?: string): WorkspaceStatus { return { + ...(id !== undefined ? { id } : {}), name, buildStatus: "running", transition: "start", @@ -435,3 +437,143 @@ describe("createCoderWorkspace — create mode", () => { expect(attachedHookRan).toBe(false); }); }); + +describe("ensureCoderWorkspace", () => { + const WS_ID = "b0e4c1f8-1234-4abc-9def-000000000001"; + + it("returns the ready status (with UUID) for an existing running workspace", async () => { + const transport = new CreateMockTransport(); + transport.statusScript = [readyStatus("ws", WS_ID)]; + const ws = await ensureCoderWorkspace({ workspace: "ws", transport }); + expect(ws.id).toBe(WS_ID); + expect(ws.name).toBe("ws"); + expect(ws.created).toBe(false); + expect(ws.buildStatus).toBe("running"); + expect(ws.agents[0]).toMatchObject({ status: "connected", lifecycleState: "ready" }); + expect(transport.createCalls).toHaveLength(0); + expect(transport.startCalls).toBe(0); + }); + + it("tolerates an old CLI that reports no workspace id", async () => { + const transport = new CreateMockTransport(); + transport.statusScript = [readyStatus()]; + const ws = await ensureCoderWorkspace({ workspace: "ws", transport }); + expect(ws.id).toBeUndefined(); + expect(ws.name).toBe("ws"); + }); + + it("creates a missing workspace from the template and reports created: true", async () => { + const transport = new CreateMockTransport(); + transport.statusScript = [null, readyStatus("ws", WS_ID)]; + const ws = await ensureCoderWorkspace({ + workspace: "ws", + create: { template: "docker" }, + transport, + }); + expect(transport.createCalls).toHaveLength(1); + expect(transport.createCalls[0]!.workspace).toBe("ws"); + expect(transport.createCalls[0]!.template).toBe("docker"); + expect(ws.created).toBe(true); + expect(ws.id).toBe(WS_ID); + }); + + it("throws when the workspace is missing and no create block is set", async () => { + const transport = new CreateMockTransport(); + transport.statusScript = [null]; + await expect(ensureCoderWorkspace({ workspace: "ws", transport })).rejects.toThrow( + /ensureCoderWorkspace: workspace "ws" does not exist/, + ); + expect(transport.createCalls).toHaveLength(0); + }); + + it("starts a stopped workspace before waiting for readiness", async () => { + const transport = new CreateMockTransport(); + transport.statusScript = [stoppedStatus(), readyStatus("ws", WS_ID)]; + const ws = await ensureCoderWorkspace({ workspace: "ws", transport }); + expect(transport.startCalls).toBe(1); + expect(transport.createCalls).toHaveLength(0); + expect(ws.created).toBe(false); + expect(ws.id).toBe(WS_ID); + }); + + it("keeps polling until the agent becomes ready", async () => { + const transport = new CreateMockTransport(); + const starting: WorkspaceStatus = { + name: "ws", + buildStatus: "running", + transition: "start", + agents: [{ name: "main", status: "connecting", lifecycleState: "starting" }], + }; + transport.statusScript = [starting, starting, readyStatus("ws", WS_ID)]; + const ws = await ensureCoderWorkspace({ workspace: "ws", transport }); + // existence pre-check + a not-ready poll + the ready poll + expect(transport.statusCalls).toBe(3); + expect(ws.id).toBe(WS_ID); + }); + + it("validates a requested preset like createCoderWorkspace does", async () => { + const transport = new CreateMockTransport(); + transport.presets = [{ name: "Standard", default: true }]; + transport.statusScript = [null]; + await expect( + ensureCoderWorkspace({ + workspace: "ws", + create: { template: "docker", preset: "Nope" }, + transport, + }), + ).rejects.toThrow(/ensureCoderWorkspace: preset "Nope" not found.*Standard/s); + expect(transport.createCalls).toHaveLength(0); + }); + + it("honors create.ifExists: 'error'", async () => { + const transport = new CreateMockTransport(); + transport.statusScript = [readyStatus()]; + await expect( + ensureCoderWorkspace({ + workspace: "ws", + create: { template: "docker", ifExists: "error" }, + transport, + }), + ).rejects.toThrow(/ensureCoderWorkspace: workspace "ws" already exists/); + }); + + it("times out with the last observed status in the error", async () => { + const transport = new CreateMockTransport(); + transport.statusScript = [stoppedStatus()]; + await expect( + ensureCoderWorkspace({ workspace: "ws", transport, readyTimeoutMs: 0 }), + ).rejects.toThrow(/ensureCoderWorkspace: timed out after 0ms.*"ws"/s); + }); + + it("rejects immediately for an already-aborted signal", async () => { + const transport = new CreateMockTransport(); + transport.statusScript = [readyStatus()]; + const reason = new Error("caller aborted"); + await expect( + ensureCoderWorkspace({ workspace: "ws", transport, abortSignal: AbortSignal.abort(reason) }), + ).rejects.toBe(reason); + expect(transport.statusCalls).toBe(1); // the existence pre-check only + }); +}); + +describe("package export surface", () => { + it("exports ensureCoderWorkspace from the package index", () => { + expect(sandbox.ensureCoderWorkspace).toBe(ensureCoderWorkspace); + expect(typeof sandbox.createCoderWorkspace).toBe("function"); + }); + + it("exposes the ensure settings and result types", () => { + // Type-level check: these annotations fail typecheck if the exports drift. + const settings: sandbox.EnsureCoderWorkspaceSettings = { workspace: "ws" }; + const result: sandbox.EnsuredCoderWorkspace = { + id: "b0e4c1f8-1234-4abc-9def-000000000001", + name: "ws", + buildStatus: "running", + transition: "start", + agents: [], + created: true, + }; + expect(settings.workspace).toBe("ws"); + expect(result.created).toBe(true); + }); +});