From 33eb658f8be6e50d30d6bc4721be3ed96065e215 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Wed, 6 May 2026 14:52:52 -0700 Subject: [PATCH] feat(agent-sdk): add SessionHandle with asyncDispose --- sdks/typescript-agent-sdk/src/session.ts | 133 +++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 sdks/typescript-agent-sdk/src/session.ts diff --git a/sdks/typescript-agent-sdk/src/session.ts b/sdks/typescript-agent-sdk/src/session.ts new file mode 100644 index 00000000000..d15c675011e --- /dev/null +++ b/sdks/typescript-agent-sdk/src/session.ts @@ -0,0 +1,133 @@ +/** + * 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: `/v1/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 { + await requestJson(this._client, { + method: "POST", + path: `/v1/sessions/${encodeURIComponent(this.id)}/followup`, + body: { message }, + }); + } + + /** Fetch a single run by ID. */ + async getRun(runId: string): Promise { + const info = await requestJson(this._client, { + method: "GET", + path: `/v1/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: `/v1/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: `/v1/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: `/v1/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 ?? [] }; +}