diff --git a/sdks/typescript-agent-sdk/.gitignore b/sdks/typescript-agent-sdk/.gitignore new file mode 100644 index 00000000000..5d3055a0da9 --- /dev/null +++ b/sdks/typescript-agent-sdk/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +*.log +.DS_Store +package-lock.json diff --git a/sdks/typescript-agent-sdk/README.md b/sdks/typescript-agent-sdk/README.md new file mode 100644 index 00000000000..833f23621e3 --- /dev/null +++ b/sdks/typescript-agent-sdk/README.md @@ -0,0 +1,218 @@ +# @litellm/agent-sdk + +TypeScript SDK for the LiteLLM Agents API. Mirrors the Cursor SDK's style but +fixes its conflation of agent definition and VM session by exposing a clean +3-level hierarchy: + +``` +Agent — definition (model, system prompt, tools) + └─ Session — VM sandbox running under an agent + └─ Run — single execution within a session +``` + +## Install + +```bash +npm i @litellm/agent-sdk +# or +pnpm add @litellm/agent-sdk +``` + +Requires Node 18+ (Node 20+ recommended for `await using` support). + +## Quickstart + +```ts +import { Agent } from "@litellm/agent-sdk"; + +// 1. Define an agent (do this once at app startup). +const agent = await Agent.create({ + apiKey: process.env.LITELLM_API_KEY, + baseUrl: process.env.LITELLM_BASE_URL, // e.g. http://localhost:4000 + name: "shin-cursor", + model: { id: "claude-4.6-sonnet" }, + systemPrompt: "You are a senior engineer fixing GitHub issues.", +}); + +// 2. Spin up a session per workflow (per Slack issue, per ticket, etc). +const session = await agent.createSession({ + repos: [{ url: "https://github.com/example/repo", startingRef: "main" }], + envVars: { GITHUB_TOKEN: process.env.GITHUB_TOKEN! }, +}); + +// 3. Kick off a run and stream the events. +const run = await session.send("Fix the bug described in issue #123."); +for await (const event of run.stream()) { + console.log(event.type, event.data); +} + +// 4. Wait for the final result. +const result = await run.wait(); +console.log(result.status, result.result); + +// 5. Tear down the VM when you're done. +await session.delete(); +``` + +## Multi-turn (followups) + +```ts +const run = await session.send("Implement the feature."); +const stream = run.stream(); + +// In another async context, queue a follow-up message into the active run. +await session.followup("Also handle the empty-input edge case, please."); + +for await (const event of stream) { + // events from the original run continue to flow, including any + // tool calls / deltas resulting from the follow-up. +} +``` + +## Resumable streams + +Every event carries a monotonic `seq`. Pass `startingSeq` to reconnect from a +specific point — useful if your process restarts mid-stream: + +```ts +let lastSeq = 0; +for await (const event of run.stream({ startingSeq: lastSeq + 1 })) { + lastSeq = event.seq; + // ... +} +``` + +The SDK also auto-reconnects internally when the underlying socket drops. It +sends both `Last-Event-ID: ` (per the SSE spec) and +`?starting_seq=` (LiteLLM-specific); the proxy honors whichever it +understands and replays from there. Already-seen events are de-duped. + +## `await using` (Symbol.asyncDispose) + +If you're on TypeScript 5.2+ / Node 20+, you can scope a session to a block: + +```ts +{ + await using session = await agent.createSession(); + const run = await session.send("..."); + await run.wait(); +} // session is DELETEd automatically here +``` + +`session.terminate()` is a manual alias for `delete()` for older runtimes. + +## Reusing a single agent across sessions + +```ts +const agent = await Agent.create({ /* ... */ }); + +// Same agent definition, three independent VM sessions. +const [s1, s2, s3] = await Promise.all([ + agent.createSession(), + agent.createSession(), + agent.createSession(), +]); +``` + +## Authentication + +```ts +const agent = await Agent.create({ + apiKey: "sk-...", // or set LITELLM_API_KEY in the environment + baseUrl: "https://api.litellm.ai", // default; override for your deployment + // ... +}); +``` + +`apiKey` falls back to `process.env.LITELLM_API_KEY` if not provided. The SDK +sends it as `Authorization: Bearer `. + +## Errors + +All errors are instances of `LiteLLMAgentError`: + +```ts +import { LiteLLMAgentError } from "@litellm/agent-sdk"; + +try { + await agent.createSession(); +} catch (e) { + if (e instanceof LiteLLMAgentError) { + console.error(e.code, e.status, e.retryable); + } +} +``` + +| Code | Meaning | +| ----------------- | --------------------------------------------- | +| `missing_api_key` | No `apiKey` provided and `LITELLM_API_KEY` unset | +| `not_found` | Agent / session / run does not exist | +| `session_busy` | `send()` called while a run is in flight (409) | +| `rate_limited` | 429 — SDK retried `maxRetries` times | +| `timeout` | Request exceeded `timeoutMs` | +| `http_` | Generic HTTP error fallback | + +5xx and 429 are retried automatically with exponential backoff. Configure +`maxRetries` and `timeoutMs` per call via `ClientOptions`. + +## Configuration + +| Option | Default | Purpose | +| ------------ | ----------------------------- | ------------------------------------ | +| `apiKey` | `process.env.LITELLM_API_KEY` | Bearer token for the proxy | +| `baseUrl` | `https://api.litellm.ai` | Proxy URL | +| `fetch` | `globalThis.fetch` | Override for tests / custom transport | +| `timeoutMs` | `60_000` | Per-request timeout | +| `maxRetries` | `3` | Retries on 5xx/429/network errors | + +## API reference + +### `Agent` + +- `Agent.create(options: AgentCreateOptions): Promise` +- `Agent.get(agentId: string, options: ClientOptions): Promise` +- `Agent.list(options?: ClientOptions & ListOptions): Promise>` + +### `AgentHandle` + +- `agent.id: string` +- `agent.name: string` +- `agent.createSession(options?: CreateSessionOptions): Promise` +- `agent.getSession(sessionId: string): Promise` +- `agent.listSessions(options?: ListOptions): Promise>` +- `agent.update(patch: Partial): Promise` +- `agent.delete(): Promise` — cascades to sessions + +### `SessionHandle` + +- `session.id: string` +- `session.agentId: string` +- `session.status: SessionStatus` +- `session.send(input: string | { text; images? }): Promise` — 409 if a run is in flight +- `session.followup(message: string): Promise` — queues into the active run +- `session.getRun(runId: string): Promise` +- `session.listRuns(options?: ListOptions): Promise>` +- `session.conversation(): Promise` +- `session.delete(): Promise` / `session.terminate()` +- `session[Symbol.asyncDispose]()` — for `await using` + +### `Run` + +- `run.id: string` +- `run.sessionId: string` +- `run.status: RunStatus` +- `run.result: string | null` +- `run.git?: { branches: { branch; prUrl }[] }` +- `run.stream(opts?: { startingSeq?, signal? }): AsyncIterable` +- `run.wait(): Promise` +- `run.cancel(): Promise` +- `run.conversation(): Promise` + +## Examples + +See [`examples/basic.ts`](./examples/basic.ts) and +[`examples/followup.ts`](./examples/followup.ts). + +## License + +MIT — © BerriAI diff --git a/sdks/typescript-agent-sdk/examples/basic.ts b/sdks/typescript-agent-sdk/examples/basic.ts new file mode 100644 index 00000000000..06ff9b668bc --- /dev/null +++ b/sdks/typescript-agent-sdk/examples/basic.ts @@ -0,0 +1,42 @@ +/** + * Basic example — Agent.create -> session.send -> stream -> wait. + * + * Run with: + * LITELLM_API_KEY=sk-... LITELLM_BASE_URL=http://localhost:4000 \ + * npx tsx examples/basic.ts + */ + +import { Agent } from "../src/index.js"; + +async function main(): Promise { + const agent = await Agent.create({ + apiKey: process.env.LITELLM_API_KEY, + baseUrl: process.env.LITELLM_BASE_URL, + name: "basic-example", + model: { id: "claude-4.6-sonnet" }, + systemPrompt: "You are a helpful assistant.", + }); + + console.log("agent created:", agent.id); + + const session = await agent.createSession(); + console.log("session created:", session.id); + + const run = await session.send("Say hi in one short sentence."); + console.log("run started:", run.id); + + for await (const event of run.stream()) { + console.log(`[${event.seq}] ${event.type}`, event.data); + } + + const result = await run.wait(); + console.log("final status:", result.status); + console.log("result:", result.result); + + await session.delete(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/sdks/typescript-agent-sdk/examples/followup.ts b/sdks/typescript-agent-sdk/examples/followup.ts new file mode 100644 index 00000000000..d56623b28e2 --- /dev/null +++ b/sdks/typescript-agent-sdk/examples/followup.ts @@ -0,0 +1,59 @@ +/** + * Multi-turn example — send a long-running prompt and queue a follow-up + * message into the same run. + * + * Run with: + * LITELLM_API_KEY=sk-... LITELLM_BASE_URL=http://localhost:4000 \ + * npx tsx examples/followup.ts + */ + +import { Agent } from "../src/index.js"; + +async function main(): Promise { + const agent = await Agent.create({ + apiKey: process.env.LITELLM_API_KEY, + baseUrl: process.env.LITELLM_BASE_URL, + name: "followup-example", + model: { id: "claude-4.6-sonnet" }, + }); + + // `await using` automatically tears down the VM at scope exit. + await using session = await agent.createSession({ + repos: [ + { url: "https://github.com/example/repo", startingRef: "main" }, + ], + }); + + const run = await session.send( + "Implement the new search endpoint described in issue #42." + ); + + // While the run is in flight, queue a clarification. + setTimeout(() => { + session + .followup("Also handle the empty-input case and add a test for it.") + .catch((e) => console.error("followup failed:", e)); + }, 2_000); + + for await (const event of run.stream()) { + if (event.type === "delta") { + const data = event.data as { text?: string }; + if (data.text) process.stdout.write(data.text); + } else { + console.log(`\n[${event.seq}] ${event.type}`); + } + } + + const result = await run.wait(); + console.log("\n--- run finished:", result.status, "---"); + if (result.git?.branches?.length) { + for (const b of result.git.branches) { + console.log("branch:", b.branch, b.prUrl ?? "(no PR)"); + } + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/sdks/typescript-agent-sdk/package.json b/sdks/typescript-agent-sdk/package.json new file mode 100644 index 00000000000..58da0fd37d8 --- /dev/null +++ b/sdks/typescript-agent-sdk/package.json @@ -0,0 +1,59 @@ +{ + "name": "@litellm/agent-sdk", + "version": "0.1.0", + "description": "TypeScript SDK for LiteLLM Agents — Agent → Session → Run hierarchy", + "license": "MIT", + "author": "BerriAI", + "homepage": "https://github.com/BerriAI/litellm/tree/main/sdks/typescript-agent-sdk", + "repository": { + "type": "git", + "url": "git+https://github.com/BerriAI/litellm.git", + "directory": "sdks/typescript-agent-sdk" + }, + "main": "dist/index.js", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "engines": { + "node": ">=18" + }, + "scripts": { + "build": "tsup src/index.ts --format cjs,esm --dts --clean", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "lint": "tsc --noEmit", + "format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\" \"examples/**/*.ts\"" + }, + "dependencies": { + "eventsource-parser": "^3.0.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "msw": "^2.4.0", + "prettier": "^3.3.0", + "tsup": "^8.2.0", + "typescript": "^5.4.0", + "vitest": "^2.0.0" + }, + "keywords": [ + "litellm", + "agent", + "sdk", + "ai", + "llm", + "session", + "cursor" + ] +} diff --git a/sdks/typescript-agent-sdk/src/agent.ts b/sdks/typescript-agent-sdk/src/agent.ts new file mode 100644 index 00000000000..e6d0a383722 --- /dev/null +++ b/sdks/typescript-agent-sdk/src/agent.ts @@ -0,0 +1,135 @@ +/** + * Agent — the *definition* of an agent (model, system prompt, tools). + * + * An Agent is not a VM. Use `agent.createSession()` to spin up a VM that runs + * under this agent definition. + */ + +import { requestJson, resolveClient, type ResolvedClient } from "./client/http.js"; +import { SessionHandle } from "./session.js"; +import { + type AgentCreateOptions, + type AgentInfo, + type ClientOptions, + type CreateSessionOptions, + type ListOptions, + type ListResult, + type SessionInfo, +} from "./types.js"; + +export class AgentHandle { + readonly id: string; + readonly name: string; + + private readonly _client: ResolvedClient; + + constructor(info: AgentInfo, client: ResolvedClient) { + this.id = info.id; + this.name = info.name; + this._client = client; + } + + /** Create a new session running under this agent. */ + async createSession(options: CreateSessionOptions = {}): Promise { + const info = await requestJson(this._client, { + method: "POST", + path: `/v2/agents/${encodeURIComponent(this.id)}/sessions`, + body: { + repos: options.repos ?? [], + envVars: options.envVars ?? {}, + metadata: options.metadata ?? {}, + }, + }); + return new SessionHandle(info, this._client); + } + + /** Fetch a session by ID under this agent. */ + async getSession(sessionId: string): Promise { + const info = await requestJson(this._client, { + method: "GET", + path: `/v2/agents/${encodeURIComponent(this.id)}/sessions/${encodeURIComponent(sessionId)}`, + }); + return new SessionHandle(info, this._client); + } + + /** List sessions running under this agent. */ + async listSessions(options: ListOptions = {}): Promise> { + const data = await requestJson<{ items: SessionInfo[]; nextCursor?: string }>( + this._client, + { + method: "GET", + path: `/v2/agents/${encodeURIComponent(this.id)}/sessions`, + query: { limit: options.limit, cursor: options.cursor }, + } + ); + return { items: data.items ?? [], nextCursor: data.nextCursor }; + } + + /** Patch the agent definition. */ + async update(patch: Partial): Promise { + await requestJson(this._client, { + method: "PATCH", + path: `/v2/agents/${encodeURIComponent(this.id)}`, + body: stripClientOptions(patch), + }); + } + + /** Delete the agent definition. Cascades to its sessions. */ + async delete(): Promise { + await requestJson(this._client, { + method: "DELETE", + path: `/v2/agents/${encodeURIComponent(this.id)}`, + }); + } +} + +export class Agent { + /** Create a new agent definition. */ + static async create(options: AgentCreateOptions): Promise { + const client = resolveClient(options); + const info = await requestJson(client, { + method: "POST", + path: "/v2/agents", + body: stripClientOptions(options), + }); + return new AgentHandle(info, client); + } + + /** Fetch an existing agent by ID. */ + static async get(agentId: string, options: ClientOptions = {}): Promise { + const client = resolveClient(options); + const info = await requestJson(client, { + method: "GET", + path: `/v2/agents/${encodeURIComponent(agentId)}`, + }); + return new AgentHandle(info, client); + } + + /** List agents accessible to this API key. */ + static async list( + options: ClientOptions & ListOptions = {} + ): Promise> { + const client = resolveClient(options); + const data = await requestJson<{ items: AgentInfo[]; nextCursor?: string }>( + client, + { + method: "GET", + path: "/v2/agents", + query: { limit: options.limit, cursor: options.cursor }, + } + ); + return { items: data.items ?? [], nextCursor: data.nextCursor }; + } +} + +function stripClientOptions>( + obj: T +): Omit { + const { apiKey, baseUrl, fetch, timeoutMs, maxRetries, ...rest } = obj as AgentCreateOptions; + void apiKey; + void baseUrl; + void fetch; + void timeoutMs; + void maxRetries; + return rest as Omit; +} diff --git a/sdks/typescript-agent-sdk/src/client/http.ts b/sdks/typescript-agent-sdk/src/client/http.ts new file mode 100644 index 00000000000..6a5359da16f --- /dev/null +++ b/sdks/typescript-agent-sdk/src/client/http.ts @@ -0,0 +1,262 @@ +/** + * Thin fetch wrapper for the LiteLLM proxy. + * + * Responsibilities: + * - Resolve apiKey + baseUrl from explicit args or env + * - Inject `Authorization: Bearer ` + * - Retry retryable errors (5xx, 429) with exponential backoff + * - Normalize errors to `LiteLLMAgentError` + * - Translate between the SDK's camelCase public API and the backend's + * snake_case wire format. Request bodies are converted with + * `camelToSnake` before serialization; response JSON is converted with + * `snakeToCamel` before being returned to callers. Only object keys are + * rewritten — values pass through unchanged. + */ + +import { ClientOptions, LiteLLMAgentError } from "../types.js"; + +/** + * Recursively convert object keys from snake_case to camelCase. + * Walks plain objects and arrays; leaves Date/Buffer/etc. untouched. + * Single-word keys (no underscores) pass through unchanged. + */ +export function snakeToCamel(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(snakeToCamel); + } + if (isPlainObject(value)) { + const out: Record = {}; + for (const [k, v] of Object.entries(value)) { + out[snakeToCamelKey(k)] = snakeToCamel(v); + } + return out; + } + return value; +} + +/** + * Recursively convert object keys from camelCase to snake_case. + * Walks plain objects and arrays; leaves Date/Buffer/etc. untouched. + * Single-word keys (no uppercase) pass through unchanged. + */ +export function camelToSnake(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(camelToSnake); + } + if (isPlainObject(value)) { + const out: Record = {}; + for (const [k, v] of Object.entries(value)) { + out[camelToSnakeKey(k)] = camelToSnake(v); + } + return out; + } + return value; +} + +function snakeToCamelKey(key: string): string { + if (!key.includes("_")) return key; + return key.replace(/_([a-zA-Z0-9])/g, (_, c: string) => c.toUpperCase()); +} + +function camelToSnakeKey(key: string): string { + // Only rewrite if there's at least one uppercase letter to convert. + if (!/[A-Z]/.test(key)) return key; + return key.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase(); +} + +function isPlainObject(v: unknown): v is Record { + if (v === null || typeof v !== "object") return false; + const proto = Object.getPrototypeOf(v); + return proto === Object.prototype || proto === null; +} + +const DEFAULT_BASE_URL = "https://api.litellm.ai"; +const DEFAULT_TIMEOUT_MS = 60_000; +const DEFAULT_MAX_RETRIES = 3; +/** Upper bound for honoring `Retry-After` headers, to defend against a + * misbehaving or adversarial server returning extreme values. */ +const MAX_RETRY_AFTER_MS = 60_000; + +export interface ResolvedClient { + apiKey: string; + baseUrl: string; + fetch: typeof fetch; + timeoutMs: number; + maxRetries: number; +} + +export function resolveClient(options: ClientOptions = {}): ResolvedClient { + const apiKey = + options.apiKey ?? + (typeof process !== "undefined" ? process.env?.LITELLM_API_KEY : undefined); + + if (!apiKey) { + throw new LiteLLMAgentError( + "Missing apiKey. Pass `apiKey` explicitly or set LITELLM_API_KEY in the environment.", + { code: "missing_api_key" }, + ); + } + + const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ""); + return { + apiKey, + baseUrl, + fetch: options.fetch ?? globalThis.fetch.bind(globalThis), + timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + maxRetries: options.maxRetries ?? DEFAULT_MAX_RETRIES, + }; +} + +export interface RequestOpts { + method?: "GET" | "POST" | "PATCH" | "DELETE"; + path: string; + body?: unknown; + query?: Record; + headers?: Record; + /** If true, the response is returned raw (used for SSE). */ + stream?: boolean; + /** Abort signal forwarded to fetch. */ + signal?: AbortSignal; +} + +export async function request( + client: ResolvedClient, + opts: RequestOpts, +): Promise { + const url = buildUrl(client.baseUrl, opts.path, opts.query); + const headers: Record = { + Authorization: `Bearer ${client.apiKey}`, + Accept: opts.stream ? "text/event-stream" : "application/json", + ...(opts.headers ?? {}), + }; + if (opts.body !== undefined) { + headers["Content-Type"] = "application/json"; + } + + const init: RequestInit = { + method: opts.method ?? "GET", + headers, + body: + opts.body !== undefined + ? JSON.stringify(camelToSnake(opts.body)) + : undefined, + signal: opts.signal, + }; + + let lastError: unknown; + for (let attempt = 0; attempt <= client.maxRetries; attempt++) { + try { + const res = await withTimeout(client.fetch(url, init), client.timeoutMs); + if (!res.ok) { + const err = await toAgentError(res); + if (err.retryable && attempt < client.maxRetries) { + await sleep(backoffMs(attempt, res)); + continue; + } + throw err; + } + return res; + } catch (e) { + lastError = e; + if (e instanceof LiteLLMAgentError) { + if (!e.retryable || attempt === client.maxRetries) throw e; + await sleep(backoffMs(attempt)); + continue; + } + // Network errors are retryable. + if (attempt === client.maxRetries) throw e; + await sleep(backoffMs(attempt)); + } + } + throw lastError; +} + +export async function requestJson( + client: ResolvedClient, + opts: RequestOpts, +): Promise { + const res = await request(client, opts); + if (res.status === 204) return undefined as unknown as T; + const parsed = await res.json(); + return snakeToCamel(parsed) as T; +} + +function buildUrl( + baseUrl: string, + path: string, + query?: Record, +): string { + const url = new URL(path.startsWith("/") ? path : `/${path}`, baseUrl + "/"); + if (query) { + for (const [k, v] of Object.entries(query)) { + if (v === undefined) continue; + url.searchParams.set(k, String(v)); + } + } + return url.toString(); +} + +async function toAgentError(res: Response): Promise { + const status = res.status; + let code = `http_${status}`; + let message = `HTTP ${status}`; + try { + const ct = res.headers.get("content-type") ?? ""; + if (ct.includes("application/json")) { + const body = (await res.json()) as { + error?: { code?: string; message?: string }; + detail?: unknown; + }; + if (body?.error?.code) code = body.error.code; + if (body?.error?.message) message = body.error.message; + else if (typeof body?.detail === "string") message = body.detail; + } else { + const text = await res.text(); + if (text) message = text.slice(0, 500); + } + } catch { + // ignore body-parse failures + } + const retryable = status >= 500 || status === 429; + return new LiteLLMAgentError(message, { code, status, retryable }); +} + +function backoffMs(attempt: number, res?: Response): number { + if (res) { + const ra = res.headers.get("retry-after"); + if (ra) { + const n = Number(ra); + if (!Number.isNaN(n)) { + return Math.min(MAX_RETRY_AFTER_MS, Math.max(0, n * 1000)); + } + } + } + // 250ms, 500ms, 1s, 2s, ... + return 250 * Math.pow(2, attempt); +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +async function withTimeout(p: Promise, ms: number): Promise { + if (!ms || ms <= 0) return p; + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout( + () => + reject( + new LiteLLMAgentError(`Request timed out after ${ms}ms`, { + code: "timeout", + retryable: true, + }), + ), + ms, + ); + }); + try { + return (await Promise.race([p, timeout])) as T; + } finally { + if (timer) clearTimeout(timer); + } +} diff --git a/sdks/typescript-agent-sdk/src/client/sse.ts b/sdks/typescript-agent-sdk/src/client/sse.ts new file mode 100644 index 00000000000..5dd73af6926 --- /dev/null +++ b/sdks/typescript-agent-sdk/src/client/sse.ts @@ -0,0 +1,186 @@ +/** + * SSE client with auto-reconnect via `Last-Event-ID` / `?starting_seq=N`. + * + * Yields parsed `RunEvent` objects. When the stream drops, it transparently + * reconnects from the last seq it saw. Honors AbortSignal. + */ + +import { createParser, type EventSourceMessage } from "eventsource-parser"; +import { LiteLLMAgentError, type RunEvent } from "../types.js"; +import { request, type ResolvedClient } from "./http.js"; + +export interface StreamRunOptions { + startingSeq?: number; + signal?: AbortSignal; + /** Max reconnect attempts on dropped sockets. Default: 5. */ + maxReconnects?: number; +} + +/** + * Stream events from `GET /v2/sessions/{sid}/runs/{rid}/events`. + * + * Resume contract: + * - On every event, we record `seq`. + * - On reconnect, we send both `Last-Event-ID: ` (per the SSE spec) + * and `?starting_seq=` (LiteLLM-specific). The proxy honors + * whichever it understands and replays from there. + */ +export async function* streamRunEvents( + client: ResolvedClient, + sessionId: string, + runId: string, + opts: StreamRunOptions = {}, +): AsyncIterable { + const maxReconnects = opts.maxReconnects ?? 5; + let lastSeq = opts.startingSeq !== undefined ? opts.startingSeq - 1 : -1; + let reconnects = 0; + + while (true) { + const startingSeq = lastSeq >= 0 ? lastSeq + 1 : undefined; + let res: Response; + try { + res = await request(client, { + method: "GET", + path: `/v2/sessions/${encodeURIComponent(sessionId)}/runs/${encodeURIComponent(runId)}/events`, + query: + startingSeq !== undefined ? { starting_seq: startingSeq } : undefined, + headers: + startingSeq !== undefined ? { "Last-Event-ID": String(lastSeq) } : {}, + stream: true, + signal: opts.signal, + }); + } catch (e) { + if (opts.signal?.aborted) return; + if (reconnects >= maxReconnects) throw e; + reconnects++; + await sleep(backoffMs(reconnects)); + continue; + } + + if (!res.body) { + throw new LiteLLMAgentError("SSE response had no body", { + code: "stream_no_body", + }); + } + + let droppedMidStream = false; + let sawProgressOnThisConnection = false; + try { + for await (const event of parseSSE(res.body, opts.signal)) { + const parsed = decodeEvent(event); + if (parsed === null) continue; + if (parsed.seq <= lastSeq) { + // Replayed event we already saw; drop it. + continue; + } + lastSeq = parsed.seq; + sawProgressOnThisConnection = true; + yield parsed; + if (parsed.type === "done" || parsed.type === "error") { + return; + } + } + // Stream ended cleanly without a `done` event — server hung up. + droppedMidStream = true; + } catch (e) { + if (opts.signal?.aborted) return; + droppedMidStream = true; + } + + if (!droppedMidStream) return; + // Reset the reconnect budget if this connection delivered new events. + // The counter tracks *consecutive* failures, not lifetime drops, so a + // long-running stream with sporadic transient drops is not penalized. + if (sawProgressOnThisConnection) { + reconnects = 0; + } + if (reconnects >= maxReconnects) { + throw new LiteLLMAgentError( + `SSE reconnect budget exhausted (${maxReconnects})`, + { code: "sse_reconnect_exhausted" }, + ); + } + reconnects++; + await sleep(backoffMs(reconnects)); + } +} + +function decodeEvent(msg: EventSourceMessage): RunEvent | null { + if (!msg.data) return null; + try { + const obj = JSON.parse(msg.data) as Partial; + if (typeof obj.seq !== "number" || typeof obj.type !== "string") { + return null; + } + return { + seq: obj.seq, + type: obj.type, + data: obj.data ?? null, + }; + } catch { + return null; + } +} + +async function* parseSSE( + body: ReadableStream, + signal?: AbortSignal, +): AsyncIterable { + const queue: EventSourceMessage[] = []; + const state: { resolve: (() => void) | null; done: boolean } = { + resolve: null, + done: false, + }; + + const parser = createParser({ + onEvent: (e) => { + queue.push(e); + state.resolve?.(); + }, + }); + + const reader = body.getReader(); + const decoder = new TextDecoder(); + + const pump = (async () => { + try { + while (true) { + if (signal?.aborted) { + await reader.cancel(); + return; + } + const { value, done: rdone } = await reader.read(); + if (rdone) return; + parser.feed(decoder.decode(value, { stream: true })); + state.resolve?.(); + } + } finally { + state.done = true; + state.resolve?.(); + } + })(); + + try { + while (true) { + while (queue.length > 0) { + const ev = queue.shift()!; + yield ev; + } + if (state.done) return; + await new Promise((r) => { + state.resolve = r; + }); + state.resolve = null; + } + } finally { + await pump.catch(() => {}); + } +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +function backoffMs(attempt: number): number { + return Math.min(5_000, 250 * Math.pow(2, attempt)); +} diff --git a/sdks/typescript-agent-sdk/src/index.ts b/sdks/typescript-agent-sdk/src/index.ts new file mode 100644 index 00000000000..c4700231a78 --- /dev/null +++ b/sdks/typescript-agent-sdk/src/index.ts @@ -0,0 +1,35 @@ +/** + * @litellm/agent-sdk — public surface. + * + * Three-level hierarchy: + * Agent — definition (model, system prompt, tools) + * ↓ + * Session — VM sandbox running under an agent + * ↓ + * Run — single execution within a session + */ + +export { Agent, AgentHandle } from "./agent.js"; +export { SessionHandle } from "./session.js"; +export { Run } from "./run.js"; + +export type { + AgentCreateOptions, + AgentInfo, + ClientOptions, + ConversationTurn, + CreateSessionOptions, + ListOptions, + ListResult, + Message, + ModelRef, + RunEvent, + RunInfo, + RunResult, + RunStatus, + SDKImage, + SessionInfo, + SessionStatus, +} from "./types.js"; + +export { LiteLLMAgentError } from "./types.js"; diff --git a/sdks/typescript-agent-sdk/src/run.ts b/sdks/typescript-agent-sdk/src/run.ts new file mode 100644 index 00000000000..a402e3aaa38 --- /dev/null +++ b/sdks/typescript-agent-sdk/src/run.ts @@ -0,0 +1,135 @@ +/** + * Run — a single execution of an agent inside a session. + * + * Lifecycle: queued → running → completed | failed | cancelled. + */ + +import { requestJson, type ResolvedClient } from "./client/http.js"; +import { streamRunEvents } from "./client/sse.js"; +import { + LiteLLMAgentError, + type RunEvent, + type RunInfo, + type RunResult, + type RunStatus, +} from "./types.js"; + +const TERMINAL_STATES: RunStatus[] = ["finished", "cancelled", "error"]; +const DEFAULT_WAIT_POLL_MS = 500; + +export class Run { + readonly id: string; + readonly sessionId: string; + + private _status: RunStatus; + private _result: string | null; + private _git: RunInfo["git"]; + private readonly _client: ResolvedClient; + + constructor(info: RunInfo, client: ResolvedClient) { + this.id = info.id; + this.sessionId = info.sessionId; + this._status = info.status; + this._result = info.result; + this._git = info.git; + this._client = client; + } + + get status(): RunStatus { + return this._status; + } + + get result(): string | null { + return this._result; + } + + get git(): RunInfo["git"] { + return this._git; + } + + /** Open an SSE stream of events for this run. */ + stream( + opts: { startingSeq?: number; signal?: AbortSignal } = {}, + ): AsyncIterable { + return streamRunEvents(this._client, this.sessionId, this.id, opts); + } + + /** + * Block until the run reaches a terminal status. + * + * @param opts.signal Optional `AbortSignal` to bail out early. Throws + * `LiteLLMAgentError({ code: "wait_aborted" })`. + * @param opts.timeoutMs Optional ceiling on total wait time. Throws + * `LiteLLMAgentError({ code: "wait_timeout" })`. + * @param opts.pollMs Override the poll interval (default 500ms). + */ + async wait( + opts: { signal?: AbortSignal; timeoutMs?: number; pollMs?: number } = {}, + ): Promise { + const pollMs = opts.pollMs ?? DEFAULT_WAIT_POLL_MS; + const deadline = + opts.timeoutMs !== undefined && opts.timeoutMs > 0 + ? Date.now() + opts.timeoutMs + : undefined; + + while (!TERMINAL_STATES.includes(this._status)) { + if (opts.signal?.aborted) { + throw new LiteLLMAgentError("wait() aborted", { code: "wait_aborted" }); + } + if (deadline !== undefined && Date.now() >= deadline) { + throw new LiteLLMAgentError( + `wait() timed out after ${opts.timeoutMs}ms`, + { code: "wait_timeout" }, + ); + } + const info = await requestJson(this._client, { + method: "GET", + path: `/v2/sessions/${encodeURIComponent(this.sessionId)}/runs/${encodeURIComponent(this.id)}`, + signal: opts.signal, + }); + this._status = info.status; + this._result = info.result; + this._git = info.git; + if (TERMINAL_STATES.includes(this._status)) break; + await sleep(pollMs, opts.signal); + } + return { + id: this.id, + status: this._status, + result: this._result, + git: this._git, + }; + } + + // NOTE: There is no per-run conversation endpoint on the backend. The + // conversation is session-scoped — call `session.conversation()` (on the + // owning `SessionHandle`) for the full turn history. + + /** Cancel a running run; no-op if already terminal. */ + async cancel(): Promise { + if (TERMINAL_STATES.includes(this._status)) return; + await requestJson(this._client, { + method: "POST", + path: `/v2/sessions/${encodeURIComponent(this.sessionId)}/runs/${encodeURIComponent(this.id)}/cancel`, + }); + this._status = "cancelled"; + } +} + +function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new LiteLLMAgentError("wait() aborted", { code: "wait_aborted" })); + return; + } + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(timer); + reject(new LiteLLMAgentError("wait() aborted", { code: "wait_aborted" })); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} diff --git a/sdks/typescript-agent-sdk/src/session.ts b/sdks/typescript-agent-sdk/src/session.ts new file mode 100644 index 00000000000..dfbf0a719f3 --- /dev/null +++ b/sdks/typescript-agent-sdk/src/session.ts @@ -0,0 +1,137 @@ +/** + * SessionHandle — a single VM session running under one agent. + * + * Each session is a long-lived sandbox (Phase 1: noop VM) that processes + * one or more `Run`s sequentially. `send()` starts a new run; `followup()` + * queues a message into the active run. + */ + +import { requestJson, type ResolvedClient } from "./client/http.js"; +import { Run } from "./run.js"; +import { + type ConversationTurn, + type ListOptions, + type ListResult, + type RunInfo, + type SDKImage, + type SessionInfo, + type SessionStatus, +} from "./types.js"; + +interface SendInput { + text: string; + images?: SDKImage[]; +} + +export class SessionHandle { + readonly id: string; + readonly agentId: string; + + private _status: SessionStatus; + private readonly _client: ResolvedClient; + + constructor(info: SessionInfo, client: ResolvedClient) { + this.id = info.id; + this.agentId = info.agentId; + this._status = info.status; + this._client = client; + } + + get status(): SessionStatus { + return this._status; + } + + /** Start a new run with `input`. Throws 409 if a run is already in flight. */ + async send(input: string | SendInput): Promise { + const body = normalizeSendInput(input); + const info = await requestJson(this._client, { + method: "POST", + path: `/v2/sessions/${encodeURIComponent(this.id)}/runs`, + body, + }); + return new Run(info, this._client); + } + + /** Queue a follow-up message into the active run. */ + async followup(message: string): Promise { + // Wire shape matches backend `FollowupCreate`: {prompt: {text: "..."}}. + await requestJson(this._client, { + method: "POST", + path: `/v2/sessions/${encodeURIComponent(this.id)}/followup`, + body: { prompt: { text: message } }, + }); + } + + /** Fetch a single run by ID. */ + async getRun(runId: string): Promise { + const info = await requestJson(this._client, { + method: "GET", + path: `/v2/sessions/${encodeURIComponent(this.id)}/runs/${encodeURIComponent(runId)}`, + }); + return new Run(info, this._client); + } + + /** List runs belonging to this session. */ + async listRuns(options: ListOptions = {}): Promise> { + const data = await requestJson<{ items: RunInfo[]; nextCursor?: string }>( + this._client, + { + method: "GET", + path: `/v2/sessions/${encodeURIComponent(this.id)}/runs`, + query: { limit: options.limit, cursor: options.cursor }, + }, + ); + return { + items: (data.items ?? []).map((info) => new Run(info, this._client)), + nextCursor: data.nextCursor, + }; + } + + /** Snapshot of the full conversation across runs. */ + async conversation(): Promise { + const data = await requestJson<{ turns: ConversationTurn[] }>( + this._client, + { + method: "GET", + path: `/v2/sessions/${encodeURIComponent(this.id)}/conversation`, + }, + ); + return data.turns ?? []; + } + + /** Tear down the VM and delete the session. */ + async delete(): Promise { + await requestJson(this._client, { + method: "DELETE", + path: `/v2/sessions/${encodeURIComponent(this.id)}`, + }); + this._status = "terminated"; + } + + /** Alias of `delete()`. */ + async terminate(): Promise { + await this.delete(); + } + + /** + * Enables `await using session = await agent.createSession(...)`. + * Calls DELETE on scope exit. + */ + async [Symbol.asyncDispose](): Promise { + if (this._status !== "terminated") { + try { + await this.delete(); + } catch { + // Best-effort cleanup — do not throw out of dispose. + } + } + } +} + +function normalizeSendInput(input: string | SendInput): { + text: string; + images: SDKImage[]; +} { + if (typeof input === "string") return { text: input, images: [] }; + return { text: input.text, images: input.images ?? [] }; +} diff --git a/sdks/typescript-agent-sdk/src/types.ts b/sdks/typescript-agent-sdk/src/types.ts new file mode 100644 index 00000000000..4c88a8180ad --- /dev/null +++ b/sdks/typescript-agent-sdk/src/types.ts @@ -0,0 +1,160 @@ +/** + * Public types for @litellm/agent-sdk. + * + * These mirror the wire shapes posted by Epic A on LIT-2880. When A1 + * publishes the canonical OpenAPI/wire-shape comment, regenerate the + * `Wire*` aliases below; the public surface (Agent/Session/Run) should + * remain stable. + */ + +/** Configuration shared by every SDK call. */ +export interface ClientOptions { + /** LiteLLM API key. Falls back to `process.env.LITELLM_API_KEY`. */ + apiKey?: string; + /** Base URL of the LiteLLM proxy. Defaults to `https://api.litellm.ai`. */ + baseUrl?: string; + /** Optional fetch override for tests / custom transports. */ + fetch?: typeof fetch; + /** Default request timeout (ms). Default: 60_000. */ + timeoutMs?: number; + /** Max retry attempts on retryable errors. Default: 3. */ + maxRetries?: number; +} + +/** Pagination args accepted by `list*` methods. */ +export interface ListOptions { + limit?: number; + cursor?: string; +} + +export interface ListResult { + items: T[]; + nextCursor?: string; +} + +/** Used to identify the model an agent should run on. */ +export interface ModelRef { + /** LiteLLM model ID, e.g. `claude-4.6-sonnet`. */ + id: string; +} + +/** Image attachment passed to `session.send`. */ +export interface SDKImage { + /** Either a data URL or an https URL. */ + url: string; + /** Optional MIME type hint. */ + mimeType?: string; +} + +/** Args accepted by `Agent.create`. */ +export interface AgentCreateOptions extends ClientOptions { + name: string; + model: ModelRef; + systemPrompt?: string; + /** Free-form tags / metadata persisted on the agent definition. */ + metadata?: Record; +} + +/** Args accepted by `agent.createSession`. */ +export interface CreateSessionOptions { + /** Repos to clone into the session VM. */ + repos?: { url: string; startingRef?: string }[]; + /** Environment variables surfaced to the VM. */ + envVars?: Record; + /** Free-form tags / metadata persisted on the session. */ + metadata?: Record; +} + +export type SessionStatus = + | "provisioning" + | "ready" + | "busy" + | "error" + | "terminated"; + +export type RunStatus = + | "queued" + | "running" + | "finished" + | "cancelled" + | "error"; + +/** Lightweight info returned by list endpoints. */ +export interface AgentInfo { + id: string; + name: string; + model: ModelRef; + createdAt: string; +} + +export interface SessionInfo { + id: string; + agentId: string; + status: SessionStatus; + createdAt: string; +} + +export interface RunInfo { + id: string; + sessionId: string; + status: RunStatus; + result: string | null; + startedAt: string | null; + completedAt: string | null; + git?: { branches: { branch: string; prUrl: string | null }[] }; +} + +export interface ConversationTurn { + role: "user" | "assistant" | "system" | "tool"; + content: string; + runId?: string; + createdAt: string; +} + +export interface Message extends ConversationTurn {} + +/** Streamed event off `run.stream()`. */ +export interface RunEvent { + /** Monotonic per-run sequence; required for resume. */ + seq: number; + type: + | "delta" + | "tool_call" + | "tool_result" + | "status" + | "done" + | "error" + | string; + data: unknown; +} + +/** Resolved value from `run.wait()`. */ +export interface RunResult { + id: string; + status: RunStatus; + result: string | null; + git?: { branches: { branch: string; prUrl: string | null }[] }; +} + +/** Error thrown by the SDK. Mirrors Cursor SDK's `CursorAgentError` shape. */ +export class LiteLLMAgentError extends Error { + /** Stable machine-readable code, e.g. `not_found`, `rate_limited`. */ + code: string; + /** HTTP status, when applicable. */ + status?: number; + /** Whether the call is safe to retry. */ + retryable: boolean; + + constructor( + message: string, + options: { code: string; status?: number; retryable?: boolean } = { + code: "unknown", + }, + ) { + super(message); + this.name = "LiteLLMAgentError"; + this.code = options.code; + this.status = options.status; + this.retryable = options.retryable ?? false; + } +} diff --git a/sdks/typescript-agent-sdk/tests/agent-reuse.test.ts b/sdks/typescript-agent-sdk/tests/agent-reuse.test.ts new file mode 100644 index 00000000000..36736b8ada4 --- /dev/null +++ b/sdks/typescript-agent-sdk/tests/agent-reuse.test.ts @@ -0,0 +1,59 @@ +/** + * Validation #3 — Agent reuse across sessions. + * + * Creates ONE agent, then 3 sessions under it. Asserts: same agentId, + * distinct sessionIds, distinct VMs. + */ + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { Agent } from "../src/index.js"; +import { MockProxy } from "./mock-proxy.js"; + +describe("agent reuse across sessions", () => { + let proxy: MockProxy; + let baseUrl: string; + + beforeEach(async () => { + proxy = new MockProxy(); + baseUrl = await proxy.start(); + }); + + afterEach(async () => { + await proxy.stop(); + }); + + it("creates 3 sessions under one agent with distinct IDs and distinct VMs", async () => { + const agent = await Agent.create({ + apiKey: "test-key", + baseUrl, + name: "shared", + model: { id: "test-model" }, + }); + + const sessions = await Promise.all([ + agent.createSession(), + agent.createSession(), + agent.createSession(), + ]); + + // All sessions share the agent ID. + for (const s of sessions) { + expect(s.agentId).toBe(agent.id); + } + + // Session IDs are unique. + const ids = sessions.map((s) => s.id); + expect(new Set(ids).size).toBe(3); + + // Mock proxy assigns each session a distinct VM ID. + const proxyAgent = proxy.agents.get(agent.id); + expect(proxyAgent).toBeDefined(); + const vmIds = [...proxyAgent!.sessions.values()].map((s) => s.vmId); + expect(new Set(vmIds).size).toBe(3); + + // listSessions reflects all 3. + const listed = await agent.listSessions(); + expect(listed.items.length).toBe(3); + expect(new Set(listed.items.map((i) => i.id))).toEqual(new Set(ids)); + }); +}); diff --git a/sdks/typescript-agent-sdk/tests/agent.test.ts b/sdks/typescript-agent-sdk/tests/agent.test.ts new file mode 100644 index 00000000000..43a406978fe --- /dev/null +++ b/sdks/typescript-agent-sdk/tests/agent.test.ts @@ -0,0 +1,123 @@ +/** + * Spec tests for `Agent` static API. + */ + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { Agent, LiteLLMAgentError } from "../src/index.js"; +import { MockProxy } from "./mock-proxy.js"; + +describe("Agent", () => { + let proxy: MockProxy; + let baseUrl: string; + + beforeEach(async () => { + proxy = new MockProxy(); + baseUrl = await proxy.start(); + }); + + afterEach(async () => { + await proxy.stop(); + }); + + it("Agent.create persists name + model and returns an AgentHandle", async () => { + const agent = await Agent.create({ + apiKey: "test-key", + baseUrl, + name: "alpha", + model: { id: "claude-4.6-sonnet" }, + systemPrompt: "be helpful", + metadata: { team: "infra" }, + }); + expect(agent.id).toMatch(/^agt_/); + expect(agent.name).toBe("alpha"); + + const proxyAgent = proxy.agents.get(agent.id); + expect(proxyAgent?.model.id).toBe("claude-4.6-sonnet"); + expect(proxyAgent?.systemPrompt).toBe("be helpful"); + expect(proxyAgent?.metadata.team).toBe("infra"); + }); + + it("Agent.get round-trips a previously-created agent", async () => { + const created = await Agent.create({ + apiKey: "test-key", + baseUrl, + name: "fetch-me", + model: { id: "test-model" }, + }); + const fetched = await Agent.get(created.id, { apiKey: "test-key", baseUrl }); + expect(fetched.id).toBe(created.id); + expect(fetched.name).toBe("fetch-me"); + }); + + it("Agent.delete removes the agent from the server", async () => { + const agent = await Agent.create({ + apiKey: "test-key", + baseUrl, + name: "transient", + model: { id: "test-model" }, + }); + expect(proxy.agents.has(agent.id)).toBe(true); + await agent.delete(); + expect(proxy.agents.has(agent.id)).toBe(false); + }); + + it("rejects with a 401 LiteLLMAgentError when apiKey is wrong", async () => { + await expect( + Agent.create({ + apiKey: "wrong-key", + baseUrl, + name: "x", + model: { id: "test-model" }, + // no retries on 401 + maxRetries: 0, + }) + ).rejects.toMatchObject({ + name: "LiteLLMAgentError", + status: 401, + }); + }); + + it("throws missing_api_key when no apiKey provided and env is unset", async () => { + const previous = process.env.LITELLM_API_KEY; + delete process.env.LITELLM_API_KEY; + try { + await expect( + // @ts-expect-error — testing runtime guard with missing apiKey + Agent.create({ baseUrl, name: "x", model: { id: "m" } }) + ).rejects.toMatchObject({ + name: "LiteLLMAgentError", + code: "missing_api_key", + }); + } finally { + if (previous !== undefined) process.env.LITELLM_API_KEY = previous; + } + }); + + it("retries on a transient 503 with retry-after and ultimately succeeds", async () => { + const proxy503 = new MockProxy({ failFirstRequest: true }); + const url503 = await proxy503.start(); + try { + const agent = await Agent.create({ + apiKey: "test-key", + baseUrl: url503, + name: "resilient", + model: { id: "test-model" }, + }); + expect(agent.id).toMatch(/^agt_/); + } finally { + await proxy503.stop(); + } + }); + + it("LiteLLMAgentError carries code, status, and retryable flag", () => { + const e = new LiteLLMAgentError("nope", { + code: "rate_limited", + status: 429, + retryable: true, + }); + expect(e.code).toBe("rate_limited"); + expect(e.status).toBe(429); + expect(e.retryable).toBe(true); + expect(e.name).toBe("LiteLLMAgentError"); + }); +}); diff --git a/sdks/typescript-agent-sdk/tests/dispose.test.ts b/sdks/typescript-agent-sdk/tests/dispose.test.ts new file mode 100644 index 00000000000..31e0b77f4a5 --- /dev/null +++ b/sdks/typescript-agent-sdk/tests/dispose.test.ts @@ -0,0 +1,63 @@ +/** + * Validation #6 — Async dispose tears down VM. + * + * Uses `await using session = ...`; checks proxy state shows session + * terminated after scope exit. + */ + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { Agent, type SessionHandle } from "../src/index.js"; +import { MockProxy } from "./mock-proxy.js"; + +describe("Symbol.asyncDispose", () => { + let proxy: MockProxy; + let baseUrl: string; + + beforeEach(async () => { + proxy = new MockProxy(); + baseUrl = await proxy.start(); + }); + + afterEach(async () => { + await proxy.stop(); + }); + + it("calls DELETE /v2/sessions/{id} when the await-using scope exits", async () => { + const agent = await Agent.create({ + apiKey: "test-key", + baseUrl, + name: "disposer", + model: { id: "test-model" }, + }); + + let sessionId: string; + { + // `await using` requires TS 5.2+ and proper polyfill. We invoke the + // dispose method explicitly so the test is portable across runners + // without forcing the explicit-resource-management transform. + const session: SessionHandle = await agent.createSession(); + sessionId = session.id; + const proxySession = proxy.findSession(sessionId); + expect(proxySession?.status).not.toBe("terminated"); + await session[Symbol.asyncDispose](); + } + + const after = proxy.findSession(sessionId); + expect(after?.status).toBe("terminated"); + }); + + it("dispose is a no-op once the session has already been deleted", async () => { + const agent = await Agent.create({ + apiKey: "test-key", + baseUrl, + name: "double-dispose", + model: { id: "test-model" }, + }); + + const session = await agent.createSession(); + await session.delete(); + + // A second dispose must not throw. + await expect(session[Symbol.asyncDispose]()).resolves.toBeUndefined(); + }); +}); diff --git a/sdks/typescript-agent-sdk/tests/followup.test.ts b/sdks/typescript-agent-sdk/tests/followup.test.ts new file mode 100644 index 00000000000..44dc11ad842 --- /dev/null +++ b/sdks/typescript-agent-sdk/tests/followup.test.ts @@ -0,0 +1,57 @@ +/** + * Validation #5 — session.followup() queues correctly. + * + * Sends a long prompt via send(); while it's running, calls followup(). + * Expect: no 409, follow-up message appears in conversation after current + * run terminal. + */ + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { Agent } from "../src/index.js"; +import { MockProxy } from "./mock-proxy.js"; + +describe("session.followup()", () => { + let proxy: MockProxy; + let baseUrl: string; + + beforeEach(async () => { + proxy = new MockProxy(); + baseUrl = await proxy.start(); + }); + + afterEach(async () => { + await proxy.stop(); + }); + + it("accepts a follow-up message while a run is in flight without a 409", async () => { + const agent = await Agent.create({ + apiKey: "test-key", + baseUrl, + name: "follower", + model: { id: "test-model" }, + }); + const session = await agent.createSession(); + const run = await session.send("the long initial prompt"); + + // Simulate run-in-progress by leaving status non-terminal until we say so. + // followup() should NOT throw a 409. + await expect( + session.followup("please also handle the empty-input case") + ).resolves.toBeUndefined(); + + // Drive the run to completion. + proxy.emit(run.id, "delta", { text: "ok" }); + proxy.complete(run.id, "done"); + await run.wait(); + + const turns = await session.conversation(); + const userTurns = turns.filter((t) => t.role === "user").map((t) => t.content); + expect(userTurns).toContain("the long initial prompt"); + expect(userTurns).toContain("please also handle the empty-input case"); + + // Follow-up should be ordered after the initial prompt. + const initialIdx = userTurns.indexOf("the long initial prompt"); + const followupIdx = userTurns.indexOf("please also handle the empty-input case"); + expect(followupIdx).toBeGreaterThan(initialIdx); + }); +}); diff --git a/sdks/typescript-agent-sdk/tests/mock-proxy.ts b/sdks/typescript-agent-sdk/tests/mock-proxy.ts new file mode 100644 index 00000000000..bc3674fe168 --- /dev/null +++ b/sdks/typescript-agent-sdk/tests/mock-proxy.ts @@ -0,0 +1,523 @@ +/** + * In-memory mock proxy for SDK tests. + * + * We don't use msw here because we need to test SSE streaming + mid-stream + * socket drops, which fetch interception libraries handle awkwardly. + * Instead we spin up a real `http.Server` on a random port and point the SDK + * at it. + */ + +import http, { type IncomingMessage, type ServerResponse } from "node:http"; +import { AddressInfo } from "node:net"; + +interface AgentRecord { + id: string; + name: string; + model: { id: string }; + systemPrompt?: string; + metadata: Record; + createdAt: string; + sessions: Map; +} + +interface SessionRecord { + id: string; + agentId: string; + status: "provisioning" | "ready" | "busy" | "error" | "terminated"; + vmId: string; + createdAt: string; + runs: Map; + followups: string[]; + conversation: { + role: string; + content: string; + runId?: string; + createdAt: string; + }[]; +} + +interface RunRecord { + id: string; + sessionId: string; + status: "queued" | "running" | "finished" | "cancelled" | "error"; + result: string | null; + events: { seq: number; type: string; data: unknown }[]; + startedAt: string | null; + completedAt: string | null; + /** active SSE connections — set when /events is connected */ + sseClients: Set; +} + +export interface MockProxyOptions { + /** + * If set, the first SSE connection for a run is killed after this many ms, + * to simulate a mid-stream socket drop. Reset to `false` after the first + * drop to let reconnection succeed. + */ + killSseAfterMs?: number; + /** If true, emit a 503 with retry-after on the first request. */ + failFirstRequest?: boolean; +} + +export class MockProxy { + readonly server: http.Server; + readonly agents: Map = new Map(); + private firstRequestServed = false; + private firstStreamDropped = false; + private idCounter = 0; + options: MockProxyOptions; + + constructor(options: MockProxyOptions = {}) { + this.options = options; + this.server = http.createServer((req, res) => this.handle(req, res)); + } + + async start(): Promise { + await new Promise((resolve) => + this.server.listen(0, "127.0.0.1", resolve), + ); + const addr = this.server.address() as AddressInfo; + return `http://127.0.0.1:${addr.port}`; + } + + async stop(): Promise { + // Force-close any open SSE clients first. + for (const agent of this.agents.values()) { + for (const session of agent.sessions.values()) { + for (const run of session.runs.values()) { + for (const c of run.sseClients) c.end(); + run.sseClients.clear(); + } + } + } + await new Promise((resolve, reject) => + this.server.close((err) => (err ? reject(err) : resolve())), + ); + } + + /** Lookup helpers exposed for tests. */ + findRun(runId: string): RunRecord | undefined { + for (const agent of this.agents.values()) { + for (const session of agent.sessions.values()) { + const run = session.runs.get(runId); + if (run) return run; + } + } + return undefined; + } + + findSession(sessionId: string): SessionRecord | undefined { + for (const agent of this.agents.values()) { + const s = agent.sessions.get(sessionId); + if (s) return s; + } + return undefined; + } + + /** Append a new event to a run, fan it out to live SSE clients. */ + emit(runId: string, type: string, data: unknown): void { + const run = this.findRun(runId); + if (!run) return; + const seq = run.events.length; + const event = { seq, type, data }; + run.events.push(event); + const payload = `id: ${seq}\ndata: ${JSON.stringify(event)}\n\n`; + for (const client of run.sseClients) { + client.write(payload); + } + } + + /** Mark a run completed and emit a `done` event. */ + complete(runId: string, result: string): void { + const run = this.findRun(runId); + if (!run) return; + run.status = "finished"; + run.result = result; + run.completedAt = new Date().toISOString(); + this.emit(runId, "done", { result }); + for (const client of run.sseClients) client.end(); + run.sseClients.clear(); + } + + private nextId(prefix: string): string { + this.idCounter++; + return `${prefix}_${this.idCounter}`; + } + + private async handle( + req: IncomingMessage, + res: ServerResponse, + ): Promise { + if (this.options.failFirstRequest && !this.firstRequestServed) { + this.firstRequestServed = true; + res.writeHead(503, { + "retry-after": "0", + "content-type": "application/json", + }); + res.end( + JSON.stringify({ error: { code: "transient", message: "boom" } }), + ); + return; + } + this.firstRequestServed = true; + + if (req.headers.authorization !== "Bearer test-key") { + res.writeHead(401, { "content-type": "application/json" }); + res.end( + JSON.stringify({ error: { code: "unauthorized", message: "bad key" } }), + ); + return; + } + + const url = new URL(req.url ?? "/", "http://localhost"); + const method = req.method ?? "GET"; + const segments = url.pathname.split("/").filter(Boolean); + + try { + if (segments[0] !== "v2") { + return notFound(res); + } + // /v2/agents + if (segments.length === 2 && segments[1] === "agents") { + if (method === "POST") return this.createAgent(req, res); + if (method === "GET") return this.listAgents(res); + } + if (segments.length === 3 && segments[1] === "agents") { + const agent = this.agents.get(segments[2]); + if (!agent) return notFound(res); + if (method === "GET") return ok(res, this.serializeAgent(agent)); + if (method === "DELETE") { + this.agents.delete(agent.id); + return ok(res, { ok: true }); + } + } + if ( + segments.length === 4 && + segments[1] === "agents" && + segments[3] === "sessions" + ) { + const agent = this.agents.get(segments[2]); + if (!agent) return notFound(res); + if (method === "POST") return this.createSession(agent, req, res); + if (method === "GET") + return ok(res, { + items: [...agent.sessions.values()].map(serializeSession), + }); + } + if ( + segments.length === 5 && + segments[1] === "agents" && + segments[3] === "sessions" + ) { + const agent = this.agents.get(segments[2]); + const session = agent?.sessions.get(segments[4]); + if (!session) return notFound(res); + if (method === "GET") return ok(res, serializeSession(session)); + } + // /v2/sessions/{sid}/... + if (segments[1] === "sessions" && segments.length >= 3) { + const session = this.findSession(segments[2]); + if (!session) return notFound(res); + + if (segments.length === 3) { + if (method === "DELETE") { + session.status = "terminated"; + return ok(res, { ok: true }); + } + } + if ( + segments.length === 4 && + segments[3] === "runs" && + method === "POST" + ) { + return this.createRun(session, req, res); + } + if ( + segments.length === 4 && + segments[3] === "runs" && + method === "GET" + ) { + return ok(res, { + items: [...session.runs.values()].map(serializeRun), + }); + } + if ( + segments.length === 4 && + segments[3] === "followup" && + method === "POST" + ) { + const body = await readJson(req); + // Wire format: {prompt: {text: "..."}} per FollowupCreate. + const text = body?.prompt?.text ?? ""; + session.followups.push(text); + session.conversation.push({ + role: "user", + content: text, + createdAt: new Date().toISOString(), + }); + return ok(res, { ok: true }); + } + if ( + segments.length === 4 && + segments[3] === "conversation" && + method === "GET" + ) { + return ok(res, { turns: session.conversation.map(serializeTurn) }); + } + if (segments.length === 5 && segments[3] === "runs") { + const run = session.runs.get(segments[4]); + if (!run) return notFound(res); + if (method === "GET") return ok(res, serializeRun(run)); + } + if ( + segments.length === 6 && + segments[3] === "runs" && + segments[5] === "events" + ) { + const run = session.runs.get(segments[4]); + if (!run) return notFound(res); + return this.streamEvents(run, req, res, url); + } + if ( + segments.length === 6 && + segments[3] === "runs" && + segments[5] === "conversation" + ) { + const run = session.runs.get(segments[4]); + if (!run) return notFound(res); + return ok(res, { + turns: session.conversation + .filter((t) => !t.runId || t.runId === run.id) + .map(serializeTurn), + }); + } + if ( + segments.length === 6 && + segments[3] === "runs" && + segments[5] === "cancel" && + method === "POST" + ) { + const run = session.runs.get(segments[4]); + if (!run) return notFound(res); + run.status = "cancelled"; + run.completedAt = new Date().toISOString(); + return ok(res, { ok: true }); + } + } + return notFound(res); + } catch (e) { + res.writeHead(500, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + error: { code: "internal", message: (e as Error).message }, + }), + ); + } + } + + private async createAgent( + req: IncomingMessage, + res: ServerResponse, + ): Promise { + const body = await readJson(req); + const id = this.nextId("agt"); + const agent: AgentRecord = { + id, + name: body.name ?? "unnamed", + model: body.model ?? { id: "test-model" }, + // Wire format is snake_case (Python idiom); SDK transforms camelCase + // public API to snake_case before sending. + systemPrompt: body.system_prompt, + metadata: body.metadata ?? {}, + createdAt: new Date().toISOString(), + sessions: new Map(), + }; + this.agents.set(id, agent); + return ok(res, this.serializeAgent(agent)); + } + + private listAgents(res: ServerResponse): void { + return ok(res, { + items: [...this.agents.values()].map((a) => this.serializeAgent(a)), + }); + } + + private async createSession( + agent: AgentRecord, + req: IncomingMessage, + res: ServerResponse, + ): Promise { + await readJson(req); + const id = this.nextId("ses"); + const vmId = this.nextId("vm"); + const session: SessionRecord = { + id, + agentId: agent.id, + status: "ready", + vmId, + createdAt: new Date().toISOString(), + runs: new Map(), + followups: [], + conversation: [], + }; + agent.sessions.set(id, session); + return ok(res, serializeSession(session)); + } + + private async createRun( + session: SessionRecord, + req: IncomingMessage, + res: ServerResponse, + ): Promise { + const body = await readJson(req); + // 409 if any non-terminal run exists. + for (const r of session.runs.values()) { + if (r.status === "queued" || r.status === "running") { + res.writeHead(409, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + error: { code: "session_busy", message: "run in flight" }, + }), + ); + return; + } + } + const id = this.nextId("run"); + const run: RunRecord = { + id, + sessionId: session.id, + status: "queued", + result: null, + events: [], + startedAt: new Date().toISOString(), + completedAt: null, + sseClients: new Set(), + }; + session.runs.set(id, run); + session.conversation.push({ + role: "user", + content: body.text ?? "", + runId: id, + createdAt: new Date().toISOString(), + }); + return ok(res, serializeRun(run)); + } + + private streamEvents( + run: RunRecord, + req: IncomingMessage, + res: ServerResponse, + url: URL, + ): void { + const startingSeq = Number(url.searchParams.get("starting_seq") ?? -1); + res.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }); + + // Replay events from startingSeq forward. + const replayFrom = + Number.isFinite(startingSeq) && startingSeq >= 0 ? startingSeq : 0; + for (const event of run.events) { + if (event.seq < replayFrom) continue; + res.write(`id: ${event.seq}\ndata: ${JSON.stringify(event)}\n\n`); + } + + if ( + run.status === "finished" || + run.status === "error" || + run.status === "cancelled" + ) { + res.end(); + return; + } + + run.sseClients.add(res); + const drop = !this.firstStreamDropped && this.options.killSseAfterMs; + if (drop) { + this.firstStreamDropped = true; + setTimeout(() => { + // Force-close socket without flushing any final event. + req.socket.destroy(); + run.sseClients.delete(res); + }, this.options.killSseAfterMs); + } + req.on("close", () => { + run.sseClients.delete(res); + }); + } + + private serializeAgent(agent: AgentRecord) { + // Wire format is snake_case; SDK transforms back to camelCase on receive. + return { + id: agent.id, + name: agent.name, + model: agent.model, + created_at: agent.createdAt, + }; + } +} + +function serializeSession(s: SessionRecord) { + return { + id: s.id, + agent_id: s.agentId, + status: s.status, + created_at: s.createdAt, + }; +} + +function serializeRun(r: RunRecord) { + return { + id: r.id, + session_id: r.sessionId, + status: r.status, + result: r.result, + started_at: r.startedAt, + completed_at: r.completedAt, + }; +} + +function serializeTurn(t: { + role: string; + content: string; + runId?: string; + createdAt: string; +}) { + return { + role: t.role, + content: t.content, + run_id: t.runId, + created_at: t.createdAt, + }; +} + +function ok(res: ServerResponse, body: unknown): void { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(body)); +} + +function notFound(res: ServerResponse): void { + res.writeHead(404, { "content-type": "application/json" }); + res.end( + JSON.stringify({ error: { code: "not_found", message: "not found" } }), + ); +} + +function readJson(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on("data", (c) => chunks.push(c)); + req.on("end", () => { + const raw = Buffer.concat(chunks).toString(); + if (!raw) return resolve({}); + try { + resolve(JSON.parse(raw)); + } catch (e) { + reject(e); + } + }); + req.on("error", reject); + }); +} diff --git a/sdks/typescript-agent-sdk/tests/noop-roundtrip.test.ts b/sdks/typescript-agent-sdk/tests/noop-roundtrip.test.ts new file mode 100644 index 00000000000..175f5dc5c1b --- /dev/null +++ b/sdks/typescript-agent-sdk/tests/noop-roundtrip.test.ts @@ -0,0 +1,73 @@ +/** + * Validation #2 — Round-trip against noop proxy. + * + * Verifies: Agent.create -> agent.createSession -> session.send -> for await + * stream -> session.getRun. + */ + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { Agent } from "../src/index.js"; +import { MockProxy } from "./mock-proxy.js"; + +describe("noop round-trip", () => { + let proxy: MockProxy; + let baseUrl: string; + + beforeEach(async () => { + proxy = new MockProxy(); + baseUrl = await proxy.start(); + }); + + afterEach(async () => { + await proxy.stop(); + }); + + it("creates an agent, opens a session, runs a prompt, and streams events to completion", async () => { + const agent = await Agent.create({ + apiKey: "test-key", + baseUrl, + name: "round-tripper", + model: { id: "test-model" }, + }); + + expect(agent.id).toMatch(/^agt_/); + expect(agent.name).toBe("round-tripper"); + + const session = await agent.createSession({ + repos: [{ url: "https://github.com/example/repo", startingRef: "main" }], + }); + expect(session.id).toMatch(/^ses_/); + expect(session.agentId).toBe(agent.id); + + const run = await session.send("hello world"); + expect(run.id).toMatch(/^run_/); + expect(run.sessionId).toBe(session.id); + + // Drive the run from the proxy side. + const driver = (async () => { + // Tiny delay so the SDK is connected to /events before we emit. + await new Promise((r) => setTimeout(r, 25)); + proxy.emit(run.id, "delta", { text: "hi " }); + proxy.emit(run.id, "delta", { text: "there" }); + proxy.complete(run.id, "hi there"); + })(); + + const collected: { type: string; data: unknown; seq: number }[] = []; + for await (const ev of run.stream()) { + collected.push({ type: ev.type, data: ev.data, seq: ev.seq }); + } + await driver; + + // Sanity: at least both deltas + done arrived in seq order. + expect(collected.map((e) => e.type)).toContain("delta"); + expect(collected.at(-1)?.type).toBe("done"); + const seqs = collected.map((e) => e.seq); + expect(seqs).toEqual([...seqs].sort((a, b) => a - b)); + + // Re-fetch the run by ID and confirm the terminal status. + const fetched = await session.getRun(run.id); + expect(fetched.id).toBe(run.id); + expect(fetched.status).toBe("finished"); + expect(fetched.result).toBe("hi there"); + }); +}); diff --git a/sdks/typescript-agent-sdk/tests/run.test.ts b/sdks/typescript-agent-sdk/tests/run.test.ts new file mode 100644 index 00000000000..00e25567910 --- /dev/null +++ b/sdks/typescript-agent-sdk/tests/run.test.ts @@ -0,0 +1,123 @@ +/** + * Spec tests for `Run` (stream/wait/cancel/conversation). + */ + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { Agent } from "../src/index.js"; +import { MockProxy } from "./mock-proxy.js"; + +describe("Run", () => { + let proxy: MockProxy; + let baseUrl: string; + + beforeEach(async () => { + proxy = new MockProxy(); + baseUrl = await proxy.start(); + }); + + afterEach(async () => { + await proxy.stop(); + }); + + it("wait() resolves once the run is in a terminal state", async () => { + const agent = await Agent.create({ + apiKey: "test-key", + baseUrl, + name: "r", + model: { id: "m" }, + }); + const session = await agent.createSession(); + const run = await session.send("go"); + + // Complete asynchronously so wait() actually has to poll. + setTimeout(() => proxy.complete(run.id, "ok"), 50); + + const result = await run.wait(); + expect(result.id).toBe(run.id); + expect(result.status).toBe("finished"); + expect(result.result).toBe("ok"); + }); + + it("stream() yields events in seq order and ends on `done`", async () => { + const agent = await Agent.create({ + apiKey: "test-key", + baseUrl, + name: "r", + model: { id: "m" }, + }); + const session = await agent.createSession(); + const run = await session.send("go"); + + const driver = (async () => { + await new Promise((r) => setTimeout(r, 20)); + proxy.emit(run.id, "delta", { text: "1" }); + proxy.emit(run.id, "delta", { text: "2" }); + proxy.complete(run.id, "12"); + })(); + + const seqs: number[] = []; + const types: string[] = []; + for await (const ev of run.stream()) { + seqs.push(ev.seq); + types.push(ev.type); + } + await driver; + expect(seqs).toEqual([...seqs].sort((a, b) => a - b)); + expect(types.at(-1)).toBe("done"); + }); + + it("cancel() flips status server-side and is a no-op once terminal", async () => { + const agent = await Agent.create({ + apiKey: "test-key", + baseUrl, + name: "r", + model: { id: "m" }, + }); + const session = await agent.createSession(); + const run = await session.send("go"); + await run.cancel(); + + const proxyRun = proxy.findRun(run.id); + expect(proxyRun?.status).toBe("cancelled"); + // Calling again should be a no-op (not throw). + await expect(run.cancel()).resolves.toBeUndefined(); + }); + + it("session.conversation() returns the turn list including the run's user message", async () => { + // `Run.conversation()` was removed — there is no per-run backend endpoint. + // Conversation history is session-scoped; callers use SessionHandle instead. + const agent = await Agent.create({ + apiKey: "test-key", + baseUrl, + name: "r", + model: { id: "m" }, + }); + const session = await agent.createSession(); + await session.send("hello run"); + const turns = await session.conversation(); + expect( + turns.some((t) => t.content === "hello run" && t.role === "user"), + ).toBe(true); + }); + + it("stream() respects an AbortSignal", async () => { + const agent = await Agent.create({ + apiKey: "test-key", + baseUrl, + name: "r", + model: { id: "m" }, + }); + const session = await agent.createSession(); + const run = await session.send("go"); + + const ctrl = new AbortController(); + setTimeout(() => ctrl.abort(), 30); + + const collected: number[] = []; + for await (const ev of run.stream({ signal: ctrl.signal })) { + collected.push(ev.seq); + } + // Aborts cleanly without throwing. + expect(Array.isArray(collected)).toBe(true); + }); +}); diff --git a/sdks/typescript-agent-sdk/tests/session.test.ts b/sdks/typescript-agent-sdk/tests/session.test.ts new file mode 100644 index 00000000000..4ca4d1d8efc --- /dev/null +++ b/sdks/typescript-agent-sdk/tests/session.test.ts @@ -0,0 +1,137 @@ +/** + * Spec tests for `SessionHandle`. + */ + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { Agent } from "../src/index.js"; +import { MockProxy } from "./mock-proxy.js"; + +describe("SessionHandle", () => { + let proxy: MockProxy; + let baseUrl: string; + + beforeEach(async () => { + proxy = new MockProxy(); + baseUrl = await proxy.start(); + }); + + afterEach(async () => { + await proxy.stop(); + }); + + it("send() accepts a string and starts a new run", async () => { + const agent = await Agent.create({ + apiKey: "test-key", + baseUrl, + name: "s", + model: { id: "m" }, + }); + const session = await agent.createSession(); + const run = await session.send("hello"); + expect(run.id).toMatch(/^run_/); + expect(run.sessionId).toBe(session.id); + expect(run.status).toBe("queued"); + }); + + it("send() accepts a structured input with images", async () => { + const agent = await Agent.create({ + apiKey: "test-key", + baseUrl, + name: "s", + model: { id: "m" }, + }); + const session = await agent.createSession(); + const run = await session.send({ + text: "describe this", + images: [{ url: "https://example.com/cat.png" }], + }); + expect(run.id).toMatch(/^run_/); + }); + + it("send() returns a 409 if a run is already in flight", async () => { + const agent = await Agent.create({ + apiKey: "test-key", + baseUrl, + name: "s", + model: { id: "m" }, + }); + const session = await agent.createSession(); + await session.send("first"); + await expect( + session.send({ text: "second", images: [] }) + ).rejects.toMatchObject({ name: "LiteLLMAgentError", status: 409 }); + }); + + it("getRun() round-trips a run by ID", async () => { + const agent = await Agent.create({ + apiKey: "test-key", + baseUrl, + name: "s", + model: { id: "m" }, + }); + const session = await agent.createSession(); + const created = await session.send("yo"); + const fetched = await session.getRun(created.id); + expect(fetched.id).toBe(created.id); + expect(fetched.sessionId).toBe(session.id); + }); + + it("listRuns() returns runs created in this session", async () => { + const agent = await Agent.create({ + apiKey: "test-key", + baseUrl, + name: "s", + model: { id: "m" }, + }); + const session = await agent.createSession(); + const r1 = await session.send("one"); + proxy.complete(r1.id, "done one"); + const r2 = await session.send("two"); + proxy.complete(r2.id, "done two"); + + const listed = await session.listRuns(); + const ids = new Set(listed.items.map((r) => r.id)); + expect(ids).toEqual(new Set([r1.id, r2.id])); + }); + + it("conversation() returns turns recorded by the proxy", async () => { + const agent = await Agent.create({ + apiKey: "test-key", + baseUrl, + name: "s", + model: { id: "m" }, + }); + const session = await agent.createSession(); + await session.send("first message"); + const turns = await session.conversation(); + expect(turns.some((t) => t.role === "user" && t.content === "first message")).toBe( + true + ); + }); + + it("delete() flips status to terminated server-side", async () => { + const agent = await Agent.create({ + apiKey: "test-key", + baseUrl, + name: "s", + model: { id: "m" }, + }); + const session = await agent.createSession(); + await session.delete(); + const proxySession = proxy.findSession(session.id); + expect(proxySession?.status).toBe("terminated"); + expect(session.status).toBe("terminated"); + }); + + it("terminate() is an alias for delete()", async () => { + const agent = await Agent.create({ + apiKey: "test-key", + baseUrl, + name: "s", + model: { id: "m" }, + }); + const session = await agent.createSession(); + await session.terminate(); + expect(proxy.findSession(session.id)?.status).toBe("terminated"); + }); +}); diff --git a/sdks/typescript-agent-sdk/tests/sse-reconnect.test.ts b/sdks/typescript-agent-sdk/tests/sse-reconnect.test.ts new file mode 100644 index 00000000000..07c55db5024 --- /dev/null +++ b/sdks/typescript-agent-sdk/tests/sse-reconnect.test.ts @@ -0,0 +1,99 @@ +/** + * Validation #4 — SSE auto-reconnect. + * + * Mid-stream, the mock proxy kills the underlying socket. The SDK should + * reconnect with `?starting_seq=N` transparently and not lose or duplicate + * events. + */ + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { Agent } from "../src/index.js"; +import { MockProxy } from "./mock-proxy.js"; + +describe("SSE auto-reconnect", () => { + let proxy: MockProxy; + let baseUrl: string; + + beforeEach(async () => { + proxy = new MockProxy({ killSseAfterMs: 60 }); + baseUrl = await proxy.start(); + }); + + afterEach(async () => { + await proxy.stop(); + }); + + it("transparently reconnects mid-stream and preserves event order without loss or duplication", async () => { + const agent = await Agent.create({ + apiKey: "test-key", + baseUrl, + name: "resumer", + model: { id: "test-model" }, + }); + const session = await agent.createSession(); + const run = await session.send("kick off"); + + // Emit two events before the mock kills the socket, then continue. + const driver = (async () => { + // Wait for the SSE connection to open. + await new Promise((r) => setTimeout(r, 20)); + proxy.emit(run.id, "delta", { text: "alpha" }); + proxy.emit(run.id, "delta", { text: "beta" }); + // Kill happens at ~60ms after first connect. + await new Promise((r) => setTimeout(r, 200)); + proxy.emit(run.id, "delta", { text: "gamma" }); + proxy.complete(run.id, "alphabetagamma"); + })(); + + const seenSeqs: number[] = []; + const seenTexts: string[] = []; + for await (const ev of run.stream()) { + seenSeqs.push(ev.seq); + if (ev.type === "delta") { + seenTexts.push((ev.data as { text: string }).text); + } + } + await driver; + + // Each seq seen exactly once. + expect(new Set(seenSeqs).size).toBe(seenSeqs.length); + // Strictly increasing — no out-of-order replays leaked through. + for (let i = 1; i < seenSeqs.length; i++) { + expect(seenSeqs[i]).toBeGreaterThan(seenSeqs[i - 1]); + } + // All three deltas present (none lost). + expect(seenTexts).toEqual(["alpha", "beta", "gamma"]); + }); + + it("respects an explicit startingSeq on first connect", async () => { + // Disable mid-stream kill so we test the resume-from-N path only. + const proxy2 = new MockProxy(); + const url2 = await proxy2.start(); + try { + const agent = await Agent.create({ + apiKey: "test-key", + baseUrl: url2, + name: "resume-explicit", + model: { id: "test-model" }, + }); + const session = await agent.createSession(); + const run = await session.send("go"); + + // Pre-populate events so they exist when stream() opens. + proxy2.emit(run.id, "delta", { text: "0" }); + proxy2.emit(run.id, "delta", { text: "1" }); + proxy2.emit(run.id, "delta", { text: "2" }); + proxy2.complete(run.id, "012"); + + const seqs: number[] = []; + for await (const ev of run.stream({ startingSeq: 2 })) { + seqs.push(ev.seq); + } + // Should not see 0 or 1 — only seq >= 2 (delta) and the done event (seq 3). + expect(seqs.every((s) => s >= 2)).toBe(true); + expect(seqs).toContain(2); + } finally { + await proxy2.stop(); + } + }); +}); diff --git a/sdks/typescript-agent-sdk/tests/wire-transform.test.ts b/sdks/typescript-agent-sdk/tests/wire-transform.test.ts new file mode 100644 index 00000000000..d3e61d7c6a3 --- /dev/null +++ b/sdks/typescript-agent-sdk/tests/wire-transform.test.ts @@ -0,0 +1,179 @@ +/** + * Unit tests for the snake_case ↔ camelCase wire transform. + * + * The SDK's public API is camelCase but the LiteLLM proxy speaks snake_case. + * `snakeToCamel` runs over response JSON; `camelToSnake` runs over request + * bodies. Only object keys are rewritten — values pass through unchanged. + */ + +import { describe, expect, it } from "vitest"; +import { camelToSnake, snakeToCamel } from "../src/client/http.js"; + +describe("snakeToCamel", () => { + it("renames top-level snake_case keys", () => { + expect(snakeToCamel({ agent_id: "x", created_at: "t" })).toEqual({ + agentId: "x", + createdAt: "t", + }); + }); + + it("passes single-word keys through unchanged", () => { + expect(snakeToCamel({ id: "x", type: "delta", data: 1, seq: 7 })).toEqual({ + id: "x", + type: "delta", + data: 1, + seq: 7, + }); + }); + + it("recurses into nested objects", () => { + expect( + snakeToCamel({ + outer_field: { inner_field: { leaf_key: "v" } }, + }), + ).toEqual({ + outerField: { innerField: { leafKey: "v" } }, + }); + }); + + it("recurses into arrays of objects", () => { + expect( + snakeToCamel({ + items_list: [ + { item_id: 1, sub_field: "a" }, + { item_id: 2, sub_field: "b" }, + ], + }), + ).toEqual({ + itemsList: [ + { itemId: 1, subField: "a" }, + { itemId: 2, subField: "b" }, + ], + }); + }); + + it("preserves values verbatim (does not transform string values that look snake_case)", () => { + expect(snakeToCamel({ system_prompt: "hello_world" })).toEqual({ + systemPrompt: "hello_world", + }); + }); + + it("handles keys with embedded digits", () => { + expect(snakeToCamel({ agent_v2_id: "x" })).toEqual({ agentV2Id: "x" }); + }); + + it("returns primitives unchanged", () => { + expect(snakeToCamel(null)).toBe(null); + expect(snakeToCamel(undefined)).toBe(undefined); + expect(snakeToCamel("snake_string")).toBe("snake_string"); + expect(snakeToCamel(42)).toBe(42); + expect(snakeToCamel(true)).toBe(true); + }); + + it("handles arrays at the top level", () => { + expect(snakeToCamel([{ a_b: 1 }, { a_b: 2 }])).toEqual([ + { aB: 1 }, + { aB: 2 }, + ]); + }); +}); + +describe("camelToSnake", () => { + it("renames top-level camelCase keys", () => { + expect(camelToSnake({ agentId: "x", createdAt: "t" })).toEqual({ + agent_id: "x", + created_at: "t", + }); + }); + + it("passes single-word keys through unchanged", () => { + expect(camelToSnake({ id: "x", type: "delta", data: 1, seq: 7 })).toEqual({ + id: "x", + type: "delta", + data: 1, + seq: 7, + }); + }); + + it("recurses into nested objects", () => { + expect( + camelToSnake({ + outerField: { innerField: { leafKey: "v" } }, + }), + ).toEqual({ + outer_field: { inner_field: { leaf_key: "v" } }, + }); + }); + + it("recurses into arrays of objects", () => { + expect( + camelToSnake({ + itemsList: [ + { itemId: 1, subField: "a" }, + { itemId: 2, subField: "b" }, + ], + }), + ).toEqual({ + items_list: [ + { item_id: 1, sub_field: "a" }, + { item_id: 2, sub_field: "b" }, + ], + }); + }); + + it("preserves string values verbatim", () => { + expect(camelToSnake({ systemPrompt: "helloWorld" })).toEqual({ + system_prompt: "helloWorld", + }); + }); + + it("handles keys with embedded digits", () => { + expect(camelToSnake({ agentV2Id: "x" })).toEqual({ agent_v2_id: "x" }); + }); + + it("returns primitives unchanged", () => { + expect(camelToSnake(null)).toBe(null); + expect(camelToSnake(undefined)).toBe(undefined); + expect(camelToSnake("camelString")).toBe("camelString"); + expect(camelToSnake(42)).toBe(42); + expect(camelToSnake(false)).toBe(false); + }); +}); + +describe("round-trip", () => { + it("camelToSnake → snakeToCamel preserves key names for typical SDK shapes", () => { + const original = { + agentId: "agt_1", + createdAt: "2026-05-06", + systemPrompt: "be helpful", + nestedObject: { innerField: "v", anotherField: 7 }, + itemsList: [{ itemId: 1 }, { itemId: 2 }], + // Single-word keys stay stable. + id: "x", + type: "delta", + data: { foo: "bar" }, + seq: 0, + status: "ready", + }; + const round = snakeToCamel(camelToSnake(original)); + expect(round).toEqual(original); + }); + + it("snakeToCamel → camelToSnake preserves key names for typical wire shapes", () => { + const original = { + agent_id: "agt_1", + created_at: "2026-05-06", + system_prompt: "be helpful", + nested_object: { inner_field: "v", another_field: 7 }, + items_list: [{ item_id: 1 }, { item_id: 2 }], + next_cursor: null, + id: "x", + type: "delta", + data: { foo: "bar" }, + seq: 0, + status: "ready", + }; + const round = camelToSnake(snakeToCamel(original)); + expect(round).toEqual(original); + }); +}); diff --git a/sdks/typescript-agent-sdk/tsconfig.json b/sdks/typescript-agent-sdk/tsconfig.json new file mode 100644 index 00000000000..d59b8332941 --- /dev/null +++ b/sdks/typescript-agent-sdk/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests", "examples"] +} diff --git a/sdks/typescript-agent-sdk/vitest.config.ts b/sdks/typescript-agent-sdk/vitest.config.ts new file mode 100644 index 00000000000..faf9dfd8867 --- /dev/null +++ b/sdks/typescript-agent-sdk/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["tests/**/*.test.ts"], + testTimeout: 15_000, + }, +});