diff --git a/.changeset/deadline-minutes-run-specs.md b/.changeset/deadline-minutes-run-specs.md new file mode 100644 index 000000000..da3406c72 --- /dev/null +++ b/.changeset/deadline-minutes-run-specs.md @@ -0,0 +1,9 @@ +--- +"@sapiom/tools": minor +--- + +Coding runs and model runs take an optional `deadlineMinutes` — how long you're willing to wait — sent on the wire as `deadline_minutes`. It is the deadline half of the label + deadline vocabulary: you state the kind of call (`model`) and how long you can wait, and the platform derives the billing lane from that rather than you naming one. **In this release the field is accepted and sent on the wire only; lane derivation lands in a later platform release, so a deadline does not yet change how a run is dispatched or priced.** Omitting it is unchanged behavior — the key never reaches the wire and the run dispatches immediately. + +`RunStatus` and `ModelRunStatus` gain `awaiting_capacity`, the non-terminal state a deferred run reports while it waits for a lane. `run()` and a `launch()` handle keep polling through it rather than resolving with no result, and `wait()`'s default poll budget now widens to cover the deadline you asked for (an explicit `timeoutMs` still wins). While a run is parked, polling backs off — doubling up to a minute between checks — and returns to the caller's interval as soon as the run is moving, so a long deadline costs a few hundred requests rather than thousands. + +Note for consumers who `switch` exhaustively over `RunStatus` or `ModelRunStatus`: a new union member is a compile error against a `default: assertNever(status)` arm. Handle `awaiting_capacity` as non-terminal — the run is still in flight. diff --git a/packages/tools/src/models/README.md b/packages/tools/src/models/README.md index d1eac2414..97d22bbfa 100644 --- a/packages/tools/src/models/README.md +++ b/packages/tools/src/models/README.md @@ -29,6 +29,10 @@ if (run.result?.success) await repo.pushFromSandbox(run.sandbox, { message: "fea - **Each run is billed.** Runs that fail or are aborted still cost. Check `run.result?.success` and `run.error` before relying on a run's output. +- **`deadlineMinutes` says how long you can wait.** Available on both `agent.coding.run`/`launch` and `agent.run`/`launch`. You say the kind of call (`model`) and how long you're willing to wait; the platform derives the billing lane (`run_now` / `priority` / `standard` / `flex`) from that — you never name a lane. **The platform does not honor the deadline yet**: today the field is accepted and sent, and every run still dispatches immediately, so setting it does not yet change dispatch or price. Leaving it unset stays the immediate-dispatch default either way. + +- **A deferred run reports `awaiting_capacity`, which is not terminal.** Once deadlines are honored, a run waiting for a cheaper lane sits in this status. `run` keeps polling through it and a `launch` handle keeps waiting, so a deferred run is never resolved with a null result. `wait()`'s default timeout widens to cover the deadline you asked for — a 60-minute deadline doesn't blow the 20-minute default — and an explicit `wait({ timeoutMs })` still overrides it. Polling backs off while a run is parked (doubling, up to a minute between checks) so a long deadline doesn't spend thousands of requests waiting; it returns to the normal interval as soon as the run is moving. + - **Coding HTTP failures are structured.** `run`, `launch`, `handle.status()`, and `handle.wait()` throw `CodingRunHttpError`. Inspect `status`, `code`, `requestId`, and `body`; workflow steps can return `fail(error.message)` for `repository_not_found` and rethrow other errors. ## Reference diff --git a/packages/tools/src/models/index.ts b/packages/tools/src/models/index.ts index bc4104268..9ee22f58f 100644 --- a/packages/tools/src/models/index.ts +++ b/packages/tools/src/models/index.ts @@ -42,13 +42,26 @@ const DEFAULT_BASE_URL = */ export const CODING_RESULT_SIGNAL = "models.coding.result"; -/** Run lifecycle, mirrored from the gateway's `ModelsRunStatus`. */ +/** + * Run lifecycle, mirrored from the gateway's `ModelsRunStatus`. + * + * `awaiting_capacity` is the deferred state a run sits in when it carried a + * {@link CodingRunSpec.deadlineMinutes} and the platform parked it for a + * cheaper lane instead of dispatching immediately. It is NOT terminal: `run()` + * and a `launch()` handle keep polling through it. + */ export type RunStatus = | "pending" | "queued" + | "awaiting_capacity" | "running" | "completed" | "failed"; +/** + * The states `run()`'s poll loop and `handle.wait()` stop on. Deliberately just + * the two end states: every other status — `awaiting_capacity` included — keeps + * the loop polling, so a deferred run is never resolved with a null result. + */ const TERMINAL = new Set(["completed", "failed"]); export interface CodingRunSpec { @@ -70,6 +83,20 @@ export interface CodingRunSpec { * pass `"small"`/`"medium"`/`"large"` only to pick a billing class deliberately. */ model?: ModelLabel; + /** + * How long you're willing to wait for this run, in minutes — the deadline + * half of the label + deadline vocabulary. You say the kind of call + * (`model`) and how long you can wait; the platform derives the billing lane + * (`run_now` / `priority` / `standard` / `flex`) from it — you never name a + * lane yourself. + * + * Omit it (the default) for `run_now`: the request is byte-identical to one + * sent before this field existed, and the run dispatches immediately. Give it + * a value and the platform may park the run in `awaiting_capacity` until a + * cheaper lane is free, dispatching in time to finish within the deadline. + * `wait()`'s default poll budget widens to cover the deadline you asked for. + */ + deadlineMinutes?: number; } /** @@ -193,6 +220,7 @@ export class CodingResultSchemaError extends Error {} const RUN_STATUSES: readonly RunStatus[] = [ "pending", "queued", + "awaiting_capacity", "running", "completed", "failed", @@ -291,6 +319,52 @@ function workflowResumeHeaders( return token ? { "x-sapiom-workflow-token": token } : {}; } +/** + * Default poll budget for a handle's `wait()`, widened to cover a deadline the + * caller asked for. A deferred run sits in `awaiting_capacity` for up to its + * deadline, so a fixed default would throw on exactly the runs the deadline + * exists for — `run({ deadlineMinutes: 60 })` against a 20-minute default. + * + * The deadline bounds when the run FINISHES, so the surface default is added on + * top as slack rather than replaced: it covers dispatch latency at the far end + * of the window plus poll granularity. Never shrinks the default (a deadline + * under it, or a non-positive one, leaves the default alone), and an explicit + * `wait({ timeoutMs })` still wins over all of this. + */ +function defaultWaitMs( + deadlineMinutes: number | undefined, + surfaceDefaultMs: number, +): number { + if (typeof deadlineMinutes !== "number" || !(deadlineMinutes > 0)) + return surfaceDefaultMs; + return deadlineMinutes * 60_000 + surfaceDefaultMs; +} + +/** Ceiling for the deferred-run backoff below. */ +const DEFERRED_POLL_CAP_MS = 60_000; + +/** + * Poll interval for the next tick of a `wait()` loop. + * + * A run parked in `awaiting_capacity` has nothing new to report until the + * platform dispatches it, and a long deadline widens the poll budget to match — + * so at a flat interval an 8-hour deadline would spend ~14,000 GETs of the + * caller's rate limit doing nothing. While deferred, the interval doubles up to + * {@link DEFERRED_POLL_CAP_MS}, which brings that back to a few hundred. + * + * Every other status — including `running` — snaps straight back to the + * caller's `pollMs`, so a run that is actually moving is still observed at full + * cadence and a terminal transition is caught promptly. + */ +function nextPollMs( + status: string, + currentMs: number, + callerPollMs: number, +): number { + if (status !== "awaiting_capacity") return callerPollMs; + return Math.min(currentMs * 2, DEFERRED_POLL_CAP_MS); +} + // --- wire shapes (snake_case, as served by the gateway serializer) --- interface WireResult { @@ -352,6 +426,9 @@ function buildBody(spec: CodingRunSpec): Record { working_directory: spec.workingDirectory, keep_sandbox: spec.keepSandbox ?? true, model: spec.model, + // Left `undefined` when unset, so `JSON.stringify` drops the key entirely — + // the server must be able to tell "no deadline" from a `0` or a `null`. + deadline_minutes: spec.deadlineMinutes, }; } @@ -417,18 +494,24 @@ export async function codingLaunch( async status() { return (await fetchDoc()).data.attributes.status; }, - async wait({ timeoutMs = 20 * 60_000, pollMs = 3_000 } = {}) { + async wait({ + timeoutMs = defaultWaitMs(spec.deadlineMinutes, 20 * 60_000), + pollMs = 3_000, + } = {}) { const deadline = Date.now() + timeoutMs; + let intervalMs = pollMs; // eslint-disable-next-line no-constant-condition while (true) { const d = await fetchDoc(); - if (TERMINAL.has(d.data.attributes.status)) return toResult(d); + const status = d.data.attributes.status; + if (TERMINAL.has(status)) return toResult(d); if (Date.now() > deadline) { throw new Error( - `coding run ${runId} timed out after ${timeoutMs}ms (last status: ${d.data.attributes.status})`, + `coding run ${runId} timed out after ${timeoutMs}ms (last status: ${status})`, ); } - await new Promise((r) => setTimeout(r, pollMs)); + intervalMs = nextPollMs(status, intervalMs, pollMs); + await new Promise((r) => setTimeout(r, intervalMs)); } }, }; @@ -464,8 +547,26 @@ export const coding = { run: codingRun, launch: codingLaunch }; */ export const MODEL_RUN_RESULT_SIGNAL = "models.run.result"; -/** Run lifecycle, mirrored from the gateway's `ModelRunStatus` (no `queued`). */ -export type ModelRunStatus = "pending" | "running" | "completed" | "failed"; +/** + * Run lifecycle, mirrored from the gateway's `ModelRunStatus` (no `queued`), + * plus `awaiting_capacity`. + * + * `awaiting_capacity` is RESERVED rather than mirrored: the gateway does not + * emit it on this surface today. It is declared because this surface accepts a + * {@link ModelRunSpec.deadlineMinutes}, and the only reason to send a deadline + * is for the platform to defer — so the day it does, a union too narrow to hold + * the value would mis-type `handle.status()` and make + * `modelRunResultSchema.parse` reject a real payload. Reserving it costs a + * consumer one branch that is currently unreachable; omitting it would cost a + * silent type lie. It carries the same meaning as on {@link RunStatus} and is + * likewise NOT terminal. + */ +export type ModelRunStatus = + | "pending" + | "awaiting_capacity" + | "running" + | "completed" + | "failed"; const MODEL_TERMINAL = new Set(["completed", "failed"]); /** A remote MCP server (Streamable HTTP) the agent may call tools on. */ @@ -489,6 +590,20 @@ export interface ModelRunSpec { * pass `"small"`/`"medium"`/`"large"` only to pick a billing class deliberately. */ model?: ModelLabel; + /** + * How long you're willing to wait for this run, in minutes — the deadline + * half of the label + deadline vocabulary. You say the kind of call + * (`model`) and how long you can wait; the platform derives the billing lane + * (`run_now` / `priority` / `standard` / `flex`) from it — you never name a + * lane yourself. + * + * Omit it (the default) for `run_now`: the request is byte-identical to one + * sent before this field existed, and the run dispatches immediately. Give it + * a value and the platform may park the run in `awaiting_capacity` until a + * cheaper lane is free, dispatching in time to finish within the deadline. + * `wait()`'s default poll budget widens to cover the deadline you asked for. + */ + deadlineMinutes?: number; /** Max output tokens per turn. */ maxTokens?: number; /** Remote MCP servers the agent may call tools on (network round-trip per call). */ @@ -589,7 +704,7 @@ export const modelRunResultSchema = { if (!value || typeof value !== "object") fail("not an object"); const v = value as Record; if (typeof v.runId !== "string") fail("runId must be a string"); - if (!(["pending", "running", "completed", "failed"] as ModelRunStatus[]).includes(v.status as ModelRunStatus)) + if (!(["pending", "awaiting_capacity", "running", "completed", "failed"] as ModelRunStatus[]).includes(v.status as ModelRunStatus)) fail("status must be a valid ModelRunStatus"); if (v.output !== null && typeof v.output !== "string") fail("output must be a string or null"); if (v.result !== null && (typeof v.result !== "object" || !v.result)) fail("result must be an object or null"); @@ -686,6 +801,9 @@ function buildModelBody(spec: ModelRunSpec): Record { prompt: spec.prompt, system: spec.system, model: spec.model, + // Same encoding as the coding body: unset ⇒ `undefined` ⇒ the key is + // absent on the wire, never `0` or `null`. + deadline_minutes: spec.deadlineMinutes, max_tokens: spec.maxTokens, mcps: spec.mcps, }; @@ -721,18 +839,24 @@ export async function launch( async status() { return (await fetchDoc()).data.attributes.status; }, - async wait({ timeoutMs = 10 * 60_000, pollMs = 2_000 } = {}) { + async wait({ + timeoutMs = defaultWaitMs(spec.deadlineMinutes, 10 * 60_000), + pollMs = 2_000, + } = {}) { const deadline = Date.now() + timeoutMs; + let intervalMs = pollMs; // eslint-disable-next-line no-constant-condition while (true) { const d = await fetchDoc(); - if (MODEL_TERMINAL.has(d.data.attributes.status)) return toResult(d); + const status = d.data.attributes.status; + if (MODEL_TERMINAL.has(status)) return toResult(d); if (Date.now() > deadline) { throw new Error( - `agent run ${runId} timed out after ${timeoutMs}ms (last status: ${d.data.attributes.status})`, + `agent run ${runId} timed out after ${timeoutMs}ms (last status: ${status})`, ); } - await new Promise((r) => setTimeout(r, pollMs)); + intervalMs = nextPollMs(status, intervalMs, pollMs); + await new Promise((r) => setTimeout(r, intervalMs)); } }, }; diff --git a/packages/tools/src/models/launch.spec.ts b/packages/tools/src/models/launch.spec.ts index fe45df7ce..993f9ca89 100644 --- a/packages/tools/src/models/launch.spec.ts +++ b/packages/tools/src/models/launch.spec.ts @@ -79,6 +79,246 @@ describe("agent.coding.launch — dispatch handle", () => { }); }); +describe("agent.coding.launch — deadlineMinutes", () => { + it("sends the deadline as snake_case deadline_minutes", async () => { + const capture: Capture = {}; + const sapiom = createClient({ + apiKey: "k", + fetch: fakeLaunchFetch(capture), + }); + + await sapiom.models.coding.launch({ task: "do a thing", deadlineMinutes: 30 }); + + expect(capture.body).toMatchObject({ deadline_minutes: 30 }); + expect(capture.body).not.toHaveProperty("deadlineMinutes"); + }); + + it("omits the key entirely when no deadline is given — not null, not 0", async () => { + // The server has to tell "no deadline" (dispatch now) from "zero minutes", + // so an unset deadline must not reach the wire at all. + const capture: Capture = {}; + const sapiom = createClient({ + apiKey: "k", + fetch: fakeLaunchFetch(capture), + }); + + await sapiom.models.coding.launch({ task: "do a thing" }); + + expect(capture.body).not.toHaveProperty("deadline_minutes"); + }); +}); + +describe("agent.coding — awaiting_capacity is not terminal", () => { + it("keeps polling a deferred run instead of resolving it", async () => { + // A run parked on a deadline reports awaiting_capacity for as long as it + // waits for a lane. Resolving there would hand the caller a null result. + const statuses = ["awaiting_capacity", "awaiting_capacity", "completed"]; + let polls = 0; + const fetch = (async (_url: string, init: RequestInit = {}) => { + const isPost = (init.method ?? "GET") === "POST"; + const status = isPost ? "pending" : (statuses[polls++] ?? "completed"); + return { + ok: true, + status: isPost ? 202 : 200, + json: async () => ({ + data: { + id: "run-deferred", + attributes: { + status, + summary: status === "completed" ? "done" : null, + result: null, + error: null, + }, + relationships: { execution_environment: { data: { id: "env-1" } } }, + }, + }), + text: async () => "", + } as unknown as Response; + }) as unknown as typeof globalThis.fetch; + const sapiom = createClient({ apiKey: "k", fetch }); + + const handle = await sapiom.models.coding.launch({ + task: "do a thing", + deadlineMinutes: 30, + }); + expect(await handle.status()).toBe("awaiting_capacity"); + + const result = await handle.wait({ pollMs: 1 }); + + expect(result.status).toBe("completed"); + expect(polls).toBe(statuses.length); + }); +}); + +describe("agent.coding — the deadline widens wait()'s poll budget", () => { + /** + * Holds the run in awaiting_capacity for two polls, jumping the clock past + * the 20-minute surface default between them, then completes it. Time is + * moved with a Date.now stub rather than fake timers so the loop's real + * `setTimeout(pollMs)` still runs. + */ + function deferredPastDefault(): typeof globalThis.fetch { + const realNow = Date.now(); + let offsetMs = 0; + let polls = 0; + jest.spyOn(Date, "now").mockImplementation(() => realNow + offsetMs); + return (async (_url: string, init: RequestInit = {}) => { + const isPost = (init.method ?? "GET") === "POST"; + const deferred = !isPost && polls++ === 0; + // The first poll finds the run parked; by the time the loop re-checks its + // budget, 25 minutes have passed — past the 20-minute surface default. + if (deferred) offsetMs = 25 * 60_000; + const status = isPost + ? "pending" + : deferred + ? "awaiting_capacity" + : "completed"; + return { + ok: true, + status: isPost ? 202 : 200, + json: async () => ({ + data: { + id: "run-slow", + attributes: { status, summary: null, result: null, error: null }, + relationships: { execution_environment: { data: { id: "env-1" } } }, + }, + }), + text: async () => "", + } as unknown as Response; + }) as unknown as typeof globalThis.fetch; + } + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("run() survives a deadline longer than the surface default", async () => { + // `coding.run` takes no timeoutMs, so a fixed 20-minute default would throw + // on exactly the runs a 60-minute deadline exists for. + const sapiom = createClient({ apiKey: "k", fetch: deferredPastDefault() }); + + const result = await sapiom.models.coding.run({ + task: "do a thing", + deadlineMinutes: 60, + }); + + expect(result.status).toBe("completed"); + }, 10_000); // one real 3s poll sleep — run() takes no pollMs + + it("still times out at the surface default when no deadline was asked for", async () => { + // The widening is opt-in: an ordinary run keeps the 20-minute budget. + const sapiom = createClient({ apiKey: "k", fetch: deferredPastDefault() }); + + await expect( + sapiom.models.coding.run({ task: "do a thing" }), + ).rejects.toThrow(/timed out after 1200000ms \(last status: awaiting_capacity\)/); + }); + + it("an explicit timeoutMs still wins over the deadline", async () => { + const sapiom = createClient({ apiKey: "k", fetch: deferredPastDefault() }); + const handle = await sapiom.models.coding.launch({ + task: "do a thing", + deadlineMinutes: 60, + }); + + await expect(handle.wait({ timeoutMs: 1_000 })).rejects.toThrow( + /timed out after 1000ms/, + ); + }); +}); + +describe("agent.coding — polling backs off while a run is deferred", () => { + /** + * Records the delay each poll sleep ASKS for while firing it immediately, so + * the backoff schedule is observable without the test taking that long. + */ + function recordPollDelays(): number[] { + const delays: number[] = []; + const realSetTimeout = globalThis.setTimeout; + jest.spyOn(globalThis, "setTimeout").mockImplementation((( + fn: () => void, + ms?: number, + ) => { + delays.push(ms ?? 0); + return realSetTimeout(fn, 0); + }) as unknown as typeof globalThis.setTimeout); + return delays; + } + + function fetchStatuses(statuses: string[]): typeof globalThis.fetch { + let polls = 0; + return (async (_url: string, init: RequestInit = {}) => { + const isPost = (init.method ?? "GET") === "POST"; + const status = isPost + ? "pending" + : (statuses[polls++] ?? "completed"); + return { + ok: true, + status: isPost ? 202 : 200, + json: async () => ({ + data: { + id: "run-parked", + attributes: { status, summary: null, result: null, error: null }, + relationships: { execution_environment: { data: { id: "env-1" } } }, + }, + }), + text: async () => "", + } as unknown as Response; + }) as unknown as typeof globalThis.fetch; + } + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("doubles the interval while deferred and snaps back once the run moves", async () => { + // A parked run has nothing new to report, so polling it at full cadence for + // the whole widened budget would burn the caller's rate limit. A run that is + // actually `running` goes straight back to the caller's interval. + const delays = recordPollDelays(); + const sapiom = createClient({ + apiKey: "k", + fetch: fetchStatuses([ + "awaiting_capacity", + "awaiting_capacity", + "awaiting_capacity", + "running", + "running", + "completed", + ]), + }); + const handle = await sapiom.models.coding.launch({ + task: "do a thing", + deadlineMinutes: 60, + }); + + await handle.wait({ pollMs: 1 }); + + expect(delays).toEqual([2, 4, 8, 1, 1]); + }); + + it("caps the backoff so a long deadline still polls periodically", async () => { + const delays = recordPollDelays(); + const sapiom = createClient({ + apiKey: "k", + fetch: fetchStatuses([ + "awaiting_capacity", + "awaiting_capacity", + "completed", + ]), + }); + const handle = await sapiom.models.coding.launch({ + task: "do a thing", + deadlineMinutes: 480, + }); + + await handle.wait({ pollMs: 40_000 }); + + // 40s doubles to 80s, clamped to the 60s ceiling and held there. + expect(delays).toEqual([60_000, 60_000]); + }); +}); + describe("agent.coding — typed HTTP failures", () => { const repositoryMessage = "The requested git_repository is not an active Sapiom repository available to this tenant. " + diff --git a/packages/tools/src/models/run-launch.spec.ts b/packages/tools/src/models/run-launch.spec.ts index dc1c78d2b..69cebf9b8 100644 --- a/packages/tools/src/models/run-launch.spec.ts +++ b/packages/tools/src/models/run-launch.spec.ts @@ -7,7 +7,11 @@ import { createClient } from "../index.js"; import { MODEL_RUN_RESULT_SIGNAL, modelRunResultSchema } from "./index.js"; function fakeFetch(opts: { - capture?: { headers?: Record; url?: string }; + capture?: { + headers?: Record; + url?: string; + body?: Record; + }; terminal?: boolean; wireResult?: Record; }): typeof globalThis.fetch { @@ -15,6 +19,11 @@ function fakeFetch(opts: { if (opts.capture) { opts.capture.headers = init.headers as Record; opts.capture.url = url; + if (init.body) + opts.capture.body = JSON.parse(init.body as string) as Record< + string, + unknown + >; } const isPost = (init.method ?? "GET") === "POST"; const attributes = isPost @@ -65,6 +74,96 @@ describe("agent.launch — dispatch handle", () => { }); }); +describe("agent.launch — deadlineMinutes", () => { + it("sends the deadline as snake_case deadline_minutes", async () => { + const capture: { body?: Record } = {}; + const sapiom = createClient({ apiKey: "k", fetch: fakeFetch({ capture }) }); + + await sapiom.models.launch({ prompt: "say OK", deadlineMinutes: 30 }); + + expect(capture.body).toMatchObject({ deadline_minutes: 30 }); + expect(capture.body).not.toHaveProperty("deadlineMinutes"); + }); + + it("omits the key entirely when no deadline is given — not null, not 0", async () => { + // Same contract as the coding surface: the server has to tell "no + // deadline" from "zero minutes", so an unset deadline never hits the wire. + const capture: { body?: Record } = {}; + const sapiom = createClient({ apiKey: "k", fetch: fakeFetch({ capture }) }); + + await sapiom.models.launch({ prompt: "say OK" }); + + expect(capture.body).not.toHaveProperty("deadline_minutes"); + }); +}); + +describe("agent.run — awaiting_capacity is not terminal here either", () => { + /** Parks the run for one poll, jumping the clock past the 10-minute default. */ + function deferredPastDefault(): typeof globalThis.fetch { + const realNow = Date.now(); + let offsetMs = 0; + let polls = 0; + jest.spyOn(Date, "now").mockImplementation(() => realNow + offsetMs); + return (async (_url: string, init: RequestInit = {}) => { + const isPost = (init.method ?? "GET") === "POST"; + const deferred = !isPost && polls++ === 0; + if (deferred) offsetMs = 15 * 60_000; + const attributes = isPost + ? { status: "pending" } + : deferred + ? { status: "awaiting_capacity", output: null, result: null, error: null } + : { status: "completed", output: "OK", result: null, error: null }; + return { + ok: true, + status: isPost ? 202 : 200, + json: async () => ({ data: { id: "run-slow", attributes } }), + text: async () => "", + } as unknown as Response; + }) as unknown as typeof globalThis.fetch; + } + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("keeps polling a deferred run, within a budget widened by the deadline", async () => { + // This surface accepts deadlineMinutes, so it has to survive being + // deferred: awaiting_capacity is out of MODEL_TERMINAL and a 30-minute + // deadline lifts wait()'s default past the 10-minute one. + const sapiom = createClient({ apiKey: "k", fetch: deferredPastDefault() }); + + const result = await sapiom.models.run({ + prompt: "say OK", + deadlineMinutes: 30, + }); + + expect(result.status).toBe("completed"); + expect(result.output).toBe("OK"); + }, 10_000); // one real 2s poll sleep — run() takes no pollMs + + it("still times out at the surface default when no deadline was asked for", async () => { + const sapiom = createClient({ apiKey: "k", fetch: deferredPastDefault() }); + + await expect(sapiom.models.run({ prompt: "say OK" })).rejects.toThrow( + /timed out after 600000ms \(last status: awaiting_capacity\)/, + ); + }); + + it("modelRunResultSchema accepts the deferred status", () => { + // A union too narrow to hold a status the server can send would make the + // resumed-step validator reject a real payload. + expect( + modelRunResultSchema.parse({ + runId: "run-abc", + status: "awaiting_capacity", + output: null, + result: null, + error: null, + }).status, + ).toBe("awaiting_capacity"); + }); +}); + describe("agent.run — terminal result mapping", () => { it("maps the wire result (snake_case) to the SDK shape", async () => { const sapiom = createClient({ apiKey: "k", fetch: fakeFetch({}) });