diff --git a/clients/typescript/README.md b/clients/typescript/README.md index 23fee88..230ca71 100644 --- a/clients/typescript/README.md +++ b/clients/typescript/README.md @@ -185,6 +185,129 @@ for (const t of await admin.listTokens()) { await admin.revokeToken("my-app"); // returns true; false if not found ``` +## Multi-turn / Sessions (v0.2) + +For workflows that need context across turns ("read this file… now look at +the auth flow… now write a fix"), the SDK exposes a `Session` handle backed +by the server's `/sessions/*` endpoints (themselves backed by [ACPX](https://github.com/openclaw/acpx)). + +The preferred form uses [`await using`](https://github.com/tc39/proposal-explicit-resource-management) +(ES2024 Explicit Resource Management — Node 20.4+ / TypeScript 5.2+). The +session is closed automatically at scope exit, including on throw: + +```ts +import { Forge, turnText } from "clawdforge"; + +const forge = new Forge({ baseUrl, token: process.env.CLAWDFORGE_TOKEN! }); + +{ + await using s = await forge.session({ agent: "claude" }); + + const r1 = await s.turn("Read README.md and summarize"); + console.log(turnText(r1)); + + const r2 = await s.turn("Now look at the auth flow"); + console.log(turnText(r2)); +} // session.close() runs here automatically (success OR throw) +``` + +If your runtime doesn't support `await using` yet, use the explicit form +with try/finally — same end result, just verbose: + +```ts +const s = await forge.createSession({ agent: "claude" }); +try { + const r = await s.turn("hello"); +} finally { + await s.close(); // idempotent — safe to call any number of times +} +``` + +### `forge.session(opts?)` / `forge.createSession(opts?)` + +```ts +interface CreateSessionOptions { + agent?: string; // default "claude"; ^[a-zA-Z0-9_-]{1,64}$ + meta?: Record; // free-form, round-tripped via SessionState.meta (server-side) + signal?: AbortSignal; +} +``` + +`session()` is sugar over `createSession()` — same return type. Use +`session()` when you intend to `await using` it; use `createSession()` when +you'll manage the lifetime yourself. + +### `session.turn(prompt, opts?): Promise` + +```ts +interface TurnOptions { + files?: string[]; // file_tokens from forge.uploadFile() + timeoutSecs?: number; // server-side per-turn timeout, 5..1800 + signal?: AbortSignal; +} + +interface TurnResult { + ok: boolean; + sessionId: string; + turnIndex: number; // monotonic 1-based + events: TurnEvent[]; // structured event log + stopReason: string; // typically "end_turn" + durationMs: number; +} + +interface TurnEvent { + type: "text" | "tool_call" | "thinking" | string; // open enum + content?: string; + name?: string; + args?: Record; + result?: unknown; + [extra: string]: unknown; // pass-through for server additions +} +``` + +The `type` field is intentionally an **open enum** — the server may grow +new event kinds (`session_update`, `agent_message_chunk`, etc.) and the +SDK will pass them through unchanged. Match on the `type`s you care about +and tolerate the rest. + +### `turnText(result): string` + +Convenience helper that concatenates the `content` of every `"text"` event +in a turn, ignoring tool calls and thinking traces: + +```ts +const r = await s.turn("Reply with one sentence."); +console.log(turnText(r)); // just the assistant's prose +``` + +### `session.close(opts?): Promise` + +Closes the session. Idempotent — calling twice is a no-op locally, and the +server itself returns `{ok:true, already_closed:true}` on a second DELETE. +Safe in `finally` blocks. + +### `forge.listSessions(opts?)` / `forge.getSession(id, opts?)` + +```ts +interface SessionState { + sessionId: string; + agent: string; + appName: string; // populated from listSessions(); "" from getSession() + createdAt: number; // unix epoch seconds + lastTurnAt: number | null; + turnCount: number; + closedAt: number | null; +} + +const list = await forge.listSessions(); // your token's sessions +const open = await forge.listSessions({ includeClosed: false }); +const s = await forge.getSession("sess_abc..."); +``` + +Cross-token access (a session id you don't own) returns 404, surfaced as +`ForgeAPIError(404)` — not 403, deliberately, so existence isn't leaked +across apps. + ## Errors All SDK errors extend `ForgeError`. Catch the leaf you care about, or the diff --git a/clients/typescript/package.json b/clients/typescript/package.json index ca95155..ef269a1 100644 --- a/clients/typescript/package.json +++ b/clients/typescript/package.json @@ -1,7 +1,7 @@ { "name": "clawdforge", - "version": "0.1.0", - "description": "TypeScript SDK for the clawdforge HTTP service — a token-gated wrapper around `claude -p`.", + "version": "0.2.0", + "description": "TypeScript SDK for the clawdforge HTTP service — single-turn /run plus multi-turn /sessions over `claude -p` / ACPX.", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/clients/typescript/src/client.ts b/clients/typescript/src/client.ts index 12ac444..6513c82 100644 --- a/clients/typescript/src/client.ts +++ b/clients/typescript/src/client.ts @@ -9,9 +9,11 @@ import { ForgeError, ForgeTransportError, } from "./errors.js"; +import { Session, type SessionTransport } from "./session.js"; import type { AppToken, AppTokenCreated, + CreateSessionOptions, CreateTokenOptions, FileToken, ForgeOptions, @@ -19,6 +21,7 @@ import type { RunErrorBody, RunRequest, RunResult, + SessionState, UploadFileOptions, } from "./types.js"; @@ -337,6 +340,156 @@ export class Forge { })); } + // ---------------------------------------------------------- /sessions (v0.2) + + /** + * `POST /sessions` — create a multi-turn session and return a {@link Session} + * handle. The returned handle has a `Symbol.asyncDispose` hook so callers + * can use `await using` for automatic close-on-scope-exit: + * + * ```ts + * await using s = await forge.session({ agent: "claude" }); + * await s.turn("hello"); + * // close() runs here, including if the block throws + * ``` + * + * Requires Node 20.4+ / TypeScript 5.2+ for `await using`. On older + * runtimes use {@link createSession} + try/finally instead. + */ + async session(opts: CreateSessionOptions = {}): Promise { + return this.createSession(opts); + } + + /** + * `POST /sessions` — create a multi-turn session, lower-level explicit form + * with **no** auto-disposal. The caller is responsible for invoking + * `session.close()` (typically inside a `finally`). + * + * Prefer {@link session} if your runtime supports `await using`. + */ + async createSession(opts: CreateSessionOptions = {}): Promise { + const wire: Record = {}; + if (opts.agent !== undefined) wire["agent"] = opts.agent; + if (opts.meta !== undefined) wire["meta"] = opts.meta; + + const requestOpts: { jsonBody: unknown; signal?: AbortSignal } = { + jsonBody: wire, + }; + if (opts.signal !== undefined) requestOpts.signal = opts.signal; + + const raw = await this.request<{ + ok: boolean; + session_id: string; + agent: string; + created_at: number; + }>("POST", "/sessions", requestOpts); + + return new Session(this.sessionTransport(), { + sessionId: raw.session_id, + agent: raw.agent, + createdAt: raw.created_at, + }); + } + + /** + * `GET /sessions/{id}` — fetch the current state of a session. + * + * Cross-token access (the calling token doesn't own the session id) + * surfaces as {@link ForgeAPIError} with `status === 404` — same shape as + * a genuinely missing session, deliberately, so existence isn't leaked + * across apps. + */ + async getSession( + sessionId: string, + opts: { signal?: AbortSignal } = {} + ): Promise { + if (!sessionId) { + throw new ForgeError("getSession: sessionId is required"); + } + const requestOpts: { signal?: AbortSignal } = {}; + if (opts.signal !== undefined) requestOpts.signal = opts.signal; + + const raw = await this.request<{ + ok: boolean; + session_id: string; + agent: string; + app_name?: string; + created_at: number; + last_turn_at: number | null; + turn_count: number; + closed_at: number | null; + }>( + "GET", + `/sessions/${encodeURIComponent(sessionId)}`, + requestOpts + ); + + return { + sessionId: raw.session_id, + agent: raw.agent, + // The server omits app_name from the per-session GET (callers only + // see their own anyway). Keep the field as-typed string for parity + // with listSessions() rows; empty string is the documented sentinel. + appName: typeof raw.app_name === "string" ? raw.app_name : "", + createdAt: raw.created_at, + lastTurnAt: raw.last_turn_at, + turnCount: raw.turn_count, + closedAt: raw.closed_at, + }; + } + + /** + * `GET /sessions` — list sessions owned by the calling token. The server + * filters server-side by `app_name`; this helper just unwraps and + * camel-cases the result. + */ + async listSessions( + opts: { signal?: AbortSignal; includeClosed?: boolean } = {} + ): Promise { + const qs = + opts.includeClosed === false ? "?include_closed=false" : ""; + const requestOpts: { signal?: AbortSignal } = {}; + if (opts.signal !== undefined) requestOpts.signal = opts.signal; + + const raw = await this.request<{ + ok: boolean; + sessions: Array<{ + session_id: string; + agent: string; + app_name: string; + created_at: number; + last_turn_at: number | null; + turn_count: number; + closed_at: number | null; + }>; + count: number; + }>("GET", `/sessions${qs}`, requestOpts); + + return raw.sessions.map((s) => ({ + sessionId: s.session_id, + agent: s.agent, + appName: s.app_name ?? "", + createdAt: s.created_at, + lastTurnAt: s.last_turn_at, + turnCount: s.turn_count, + closedAt: s.closed_at, + })); + } + + /** + * Build the transport adapter Session uses to call back into us. Kept as + * a method (rather than an eager field) so we don't leak `this` into the + * Session constructor before the Forge instance is fully initialized. + */ + private sessionTransport(): SessionTransport { + return { + request: this.request.bind(this), + defaultTimeoutMs: this.defaultTimeoutMs, + }; + } + + // ------------------------------------------------------------ /admin/tokens + /** `DELETE /admin/tokens/` — revoke. Returns true on success, false if not found. */ async revokeToken( name: string, diff --git a/clients/typescript/src/index.ts b/clients/typescript/src/index.ts index a56245d..0f2e955 100644 --- a/clients/typescript/src/index.ts +++ b/clients/typescript/src/index.ts @@ -22,6 +22,7 @@ * ``` */ export { Forge } from "./client.js"; +export { Session, turnText } from "./session.js"; export { ForgeError, ForgeAuthError, @@ -31,6 +32,7 @@ export { export type { AppToken, AppTokenCreated, + CreateSessionOptions, CreateTokenOptions, FileToken, ForgeOptions, @@ -38,5 +40,9 @@ export type { RunErrorBody, RunRequest, RunResult, + SessionState, + TurnEvent, + TurnOptions, + TurnResult, UploadFileOptions, } from "./types.js"; diff --git a/clients/typescript/src/session.ts b/clients/typescript/src/session.ts new file mode 100644 index 0000000..522c1fa --- /dev/null +++ b/clients/typescript/src/session.ts @@ -0,0 +1,209 @@ +/** + * Multi-turn session handle (clawdforge v0.2). + * + * A `Session` wraps the server-side `/sessions/{id}/*` lifecycle so callers + * can string multiple turns together with shared agent context. Created via + * {@link Forge.session} (preferred — auto-disposes via `await using`) or + * {@link Forge.createSession} (explicit — caller `close()`s manually). + * + * @example `await using` form (Node 20.4+, TS 5.2+) + * ```ts + * await using s = await forge.session({ agent: "claude" }); + * const r1 = await s.turn("Read README.md and summarize"); + * const r2 = await s.turn("Now look at the auth flow"); + * // s.close() called automatically at scope exit (even on throw). + * ``` + * + * @example explicit / fallback for older runtimes + * ```ts + * const s = await forge.createSession({ agent: "claude" }); + * try { + * const r = await s.turn("hello"); + * } finally { + * await s.close(); + * } + * ``` + */ +import { ForgeError } from "./errors.js"; +import type { TurnEvent, TurnOptions, TurnResult } from "./types.js"; + +/** + * Minimal transport interface the Session needs from its parent {@link Forge}. + * Lets us keep the Session class file isolated without exposing a private + * fetch helper publicly. + */ +export interface SessionTransport { + request( + method: "GET" | "POST" | "DELETE", + path: string, + opts?: { + jsonBody?: unknown; + signal?: AbortSignal; + timeoutMs?: number; + } + ): Promise; + defaultTimeoutMs: number; +} + +export class Session { + /** Server-issued opaque session id (ACPX's id, also the ledger PK). */ + public readonly sessionId: string; + /** Agent name the session was created with. */ + public readonly agent: string; + /** Unix epoch seconds when the server created the session. */ + public readonly createdAt: number; + + private readonly transport: SessionTransport; + private _closed = false; + + /** + * @internal — instances are produced by {@link Forge.createSession} / + * {@link Forge.session}; consumers should not call the constructor directly. + */ + constructor( + transport: SessionTransport, + init: { sessionId: string; agent: string; createdAt: number } + ) { + this.transport = transport; + this.sessionId = init.sessionId; + this.agent = init.agent; + this.createdAt = init.createdAt; + } + + /** True after {@link close} has been called (locally or by `await using`). */ + get closed(): boolean { + return this._closed; + } + + /** + * Send one turn to the session. + * + * Mirrors the v0.1 `run` shape: pass `files: [...]` to attach previously + * uploaded file tokens, `timeoutSecs` to bound the server-side subprocess, + * `signal` to cancel from the caller. + * + * Throws {@link ForgeError} if the session has already been closed locally. + * Server-side 410 (closed by sweeper / explicit DELETE before this call) + * surfaces as `ForgeAPIError(410)` from the underlying request layer. + */ + async turn(prompt: string, opts: TurnOptions = {}): Promise { + if (typeof prompt !== "string" || prompt.length === 0) { + throw new ForgeError("Session.turn: prompt is required and must be non-empty"); + } + if (this._closed) { + throw new ForgeError("Session.turn: session has been closed locally"); + } + + const wire: Record = { prompt }; + if (opts.files !== undefined) wire["files"] = opts.files; + if (opts.timeoutSecs !== undefined) wire["timeout_secs"] = opts.timeoutSecs; + + // Network timeout = server timeout + 30s headroom, mirroring `run()`. + const networkTimeoutMs = + opts.timeoutSecs !== undefined + ? opts.timeoutSecs * 1000 + 30_000 + : this.transport.defaultTimeoutMs; + + const requestOpts: { + jsonBody: unknown; + timeoutMs: number; + signal?: AbortSignal; + } = { + jsonBody: wire, + timeoutMs: networkTimeoutMs, + }; + if (opts.signal !== undefined) requestOpts.signal = opts.signal; + + const raw = await this.transport.request<{ + ok: boolean; + session_id: string; + turn_index: number; + events: unknown; + stop_reason: string | null; + duration_ms: number; + }>( + "POST", + `/sessions/${encodeURIComponent(this.sessionId)}/turn`, + requestOpts + ); + + return { + ok: raw.ok === true, + sessionId: raw.session_id, + turnIndex: raw.turn_index, + events: normalizeEvents(raw.events), + stopReason: raw.stop_reason ?? "", + durationMs: raw.duration_ms, + }; + } + + /** + * Close the session. Idempotent: subsequent calls short-circuit locally + * without hitting the network. The server itself also treats a re-close as + * a 200 success (`{ok:true, already_closed:true}`). + */ + async close(opts: { signal?: AbortSignal } = {}): Promise { + if (this._closed) return; + this._closed = true; + + const requestOpts: { signal?: AbortSignal } = {}; + if (opts.signal !== undefined) requestOpts.signal = opts.signal; + + await this.transport.request<{ ok: boolean; already_closed?: boolean }>( + "DELETE", + `/sessions/${encodeURIComponent(this.sessionId)}`, + requestOpts + ); + } + + /** + * `Symbol.asyncDispose` hook for `await using` (ES2024 Explicit Resource + * Management). Lets callers write: + * + * ```ts + * await using s = await forge.session(); + * await s.turn("..."); + * // close() invoked at scope exit, including on throw + * ``` + * + * Requires Node 20.4+ and TypeScript 5.2+. On older runtimes, prefer the + * explicit {@link Forge.createSession} + try/finally pattern. + */ + async [Symbol.asyncDispose](): Promise { + await this.close(); + } +} + +/** + * Concatenate the `content` of every `"text"` event in a turn result. Useful + * when you only care about the assistant's final text and want to ignore + * tool calls / thinking traces. + */ +export function turnText(r: TurnResult): string { + let out = ""; + for (const e of r.events) { + if (e.type === "text" && typeof e.content === "string") { + out += e.content; + } + } + return out; +} + +/** + * Coerce the raw `events` array off the wire into a {@link TurnEvent}[]. The + * server can ship arbitrary JSON-RPC envelopes (the open-enum design), so we + * only enforce that each entry is a non-null object and that `type` (when + * present) is a string. + */ +function normalizeEvents(raw: unknown): TurnEvent[] { + if (!Array.isArray(raw)) return []; + const out: TurnEvent[] = []; + for (const item of raw) { + if (item && typeof item === "object") { + const e = item as Record; + const type = typeof e["type"] === "string" ? (e["type"] as string) : ""; + out.push({ ...(e as TurnEvent), type }); + } + } + return out; +} diff --git a/clients/typescript/src/types.ts b/clients/typescript/src/types.ts index 0433797..f5d0b46 100644 --- a/clients/typescript/src/types.ts +++ b/clients/typescript/src/types.ts @@ -156,3 +156,98 @@ export interface AppToken { /** 1 = active, 0 = revoked. */ enabled: number; } + +// ============================================================================ +// v0.2 — multi-turn Session API +// ============================================================================ + +/** Options for {@link Forge.session} / {@link Forge.createSession}. */ +export interface CreateSessionOptions { + /** + * Agent name. Server validates against `^[a-zA-Z0-9_-]{1,64}$`. + * Defaults to `"claude"` server-side when omitted. + */ + agent?: string; + /** + * Free-form metadata stored alongside the session ledger row. Round-tripped + * verbatim back through {@link SessionState.meta}. + */ + meta?: Record; + /** Optional `AbortSignal` to cancel the create call. */ + signal?: AbortSignal; +} + +/** Options for {@link Session.turn}. */ +export interface TurnOptions { + /** + * File tokens previously returned by {@link Forge.uploadFile}. Same scoping + * rules as {@link RunRequest.files} — token A cannot reference token B's + * files. + */ + files?: string[]; + /** + * Server-side per-turn timeout in seconds. 5..1800. The SDK sets the + * network timeout to `timeoutSecs * 1000 + 30_000` so the HTTP socket + * outlives the subprocess timeout (same convention as `/run`). + */ + timeoutSecs?: number; + /** Optional `AbortSignal` to cancel this turn. */ + signal?: AbortSignal; +} + +/** + * One event emitted during a turn. The `type` field is intentionally an + * **open enum** — the server may add new event kinds (e.g. `"session_update"`, + * `"agent_message_chunk"`) and clients must tolerate unknown values rather + * than throwing. + */ +export interface TurnEvent { + /** Open enum: `"text" | "tool_call" | "thinking" | string`. */ + type: "text" | "tool_call" | "thinking" | (string & {}); + /** Text payload for `"text"` / `"thinking"` events. */ + content?: string; + /** Tool name for `"tool_call"` events. */ + name?: string; + /** Tool args for `"tool_call"` events. */ + args?: Record; + /** Tool result payload for `"tool_call"` events. */ + result?: unknown; + /** Pass-through for any extra fields the server emits. */ + [extra: string]: unknown; +} + +/** Successful response from `POST /sessions/{id}/turn`. */ +export interface TurnResult { + ok: boolean; + sessionId: string; + /** Monotonic 1-based turn counter for this session. */ + turnIndex: number; + /** Structured event log emitted during the turn. */ + events: TurnEvent[]; + /** Typically `"end_turn"` on success. */ + stopReason: string; + /** Wall-clock duration of the turn, in milliseconds. */ + durationMs: number; +} + +/** State row for a session, returned from {@link Forge.getSession} / {@link Forge.listSessions}. */ +export interface SessionState { + sessionId: string; + /** Agent name the session was created with. */ + agent: string; + /** + * App slug (token name) that owns the session. Populated from + * `GET /sessions` rows; the per-session `GET /sessions/{id}` endpoint does + * not echo it (each token only sees its own anyway), so it may be `""` + * when read via {@link Forge.getSession}. + */ + appName: string; + /** Unix epoch seconds when the session was created. */ + createdAt: number; + /** Unix epoch seconds of the last successful turn, or null. */ + lastTurnAt: number | null; + /** Number of successful turns so far. */ + turnCount: number; + /** Unix epoch seconds when the session was closed, or null if still open. */ + closedAt: number | null; +} diff --git a/clients/typescript/tests/sessions.test.ts b/clients/typescript/tests/sessions.test.ts new file mode 100644 index 0000000..42bca5c --- /dev/null +++ b/clients/typescript/tests/sessions.test.ts @@ -0,0 +1,495 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + Forge, + ForgeAPIError, + ForgeError, + Session, + turnText, +} from "../src/index.js"; +import type { TurnResult } from "../src/index.js"; + +// -------------------------------------------------------------- mock plumbing + +interface CapturedRequest { + url: string; + method: string; + headers: Record; + bodyText: string | null; +} + +function makeMockFetch( + handler: (req: CapturedRequest) => Response | Promise +): { fetch: typeof fetch; calls: CapturedRequest[] } { + const calls: CapturedRequest[] = []; + const fn: typeof fetch = async (input, init) => { + const url = typeof input === "string" ? input : (input as URL).toString(); + const method = (init?.method ?? "GET").toUpperCase(); + const headers: Record = {}; + const rawHeaders = init?.headers; + if (rawHeaders) { + if (rawHeaders instanceof Headers) { + rawHeaders.forEach((v, k) => (headers[k.toLowerCase()] = v)); + } else if (Array.isArray(rawHeaders)) { + for (const [k, v] of rawHeaders) headers[k.toLowerCase()] = v; + } else { + for (const [k, v] of Object.entries(rawHeaders)) { + headers[k.toLowerCase()] = v as string; + } + } + } + let bodyText: string | null = null; + if (init?.body !== undefined && init.body !== null) { + if (typeof init.body === "string") bodyText = init.body; + else bodyText = String(init.body); + } + const captured: CapturedRequest = { url, method, headers, bodyText }; + calls.push(captured); + return await handler(captured); + }; + return { fetch: fn, calls }; +} + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +const BASE = { baseUrl: "http://forge.test:8800", token: "cf_unit_test" }; + +/** + * Build a tiny in-memory mock server: tracks created sessions, supports + * create / turn / state / list / close, mirrors the real server's idempotent + * close + 404-on-missing semantics. + */ +function makeMockServer() { + const sessions = new Map< + string, + { + session_id: string; + agent: string; + app_name: string; + created_at: number; + last_turn_at: number | null; + turn_count: number; + closed_at: number | null; + meta: Record | null; + } + >(); + let nextId = 1; + const t0 = 1_700_000_000; + + const handler = (req: CapturedRequest): Response => { + const u = new URL(req.url); + const path = u.pathname; + + // POST /sessions + if (req.method === "POST" && path === "/sessions") { + const body = req.bodyText ? JSON.parse(req.bodyText) : {}; + const sid = `sess_${nextId++}`; + const agent = (body.agent as string | undefined) ?? "claude"; + const meta = (body.meta as Record | undefined) ?? null; + const row = { + session_id: sid, + agent, + app_name: "unit-test-app", + created_at: t0 + nextId, + last_turn_at: null, + turn_count: 0, + closed_at: null, + meta, + }; + sessions.set(sid, row); + return jsonResponse(200, { + ok: true, + session_id: sid, + agent, + created_at: row.created_at, + }); + } + + // POST /sessions/{id}/turn + const turnMatch = /^\/sessions\/([^/]+)\/turn$/.exec(path); + if (req.method === "POST" && turnMatch) { + const sid = decodeURIComponent(turnMatch[1]!); + const row = sessions.get(sid); + if (!row) return jsonResponse(404, { detail: "session not found" }); + if (row.closed_at !== null) + return jsonResponse(410, { detail: "session is closed" }); + row.turn_count += 1; + row.last_turn_at = t0 + 1000 + row.turn_count; + const body = req.bodyText ? JSON.parse(req.bodyText) : {}; + const promptStr = String(body.prompt ?? ""); + return jsonResponse(200, { + ok: true, + session_id: sid, + turn_index: row.turn_count, + events: [ + { type: "thinking", content: "..." }, + { + type: "tool_call", + name: "Read", + args: { file: "README.md" }, + result: { ok: true }, + }, + { type: "text", content: `echo: ${promptStr}` }, + { type: "text", content: " (more)" }, + // unknown event kind — open enum tolerance + { type: "session_update", custom: 42 }, + ], + stop_reason: "end_turn", + duration_ms: 1234, + }); + } + + // GET /sessions/{id} + const stateMatch = /^\/sessions\/([^/]+)$/.exec(path); + if (req.method === "GET" && stateMatch) { + const sid = decodeURIComponent(stateMatch[1]!); + const row = sessions.get(sid); + if (!row) return jsonResponse(404, { detail: "session not found" }); + // Per server.py: state endpoint omits app_name. + return jsonResponse(200, { + ok: true, + session_id: row.session_id, + agent: row.agent, + cwd: "/tmp/mock", + created_at: row.created_at, + last_turn_at: row.last_turn_at, + turn_count: row.turn_count, + closed_at: row.closed_at, + live: row.closed_at === null, + meta: row.meta, + }); + } + + // DELETE /sessions/{id} + if (req.method === "DELETE" && stateMatch) { + const sid = decodeURIComponent(stateMatch[1]!); + const row = sessions.get(sid); + if (!row) return jsonResponse(404, { detail: "session not found" }); + if (row.closed_at !== null) + return jsonResponse(200, { ok: true, already_closed: true }); + row.closed_at = t0 + 9999; + return jsonResponse(200, { ok: true }); + } + + // GET /sessions + if (req.method === "GET" && path === "/sessions") { + const rows = Array.from(sessions.values()); + return jsonResponse(200, { + ok: true, + sessions: rows.map((r) => ({ + session_id: r.session_id, + agent: r.agent, + app_name: r.app_name, + cwd: "/tmp/mock", + created_at: r.created_at, + last_turn_at: r.last_turn_at, + turn_count: r.turn_count, + closed_at: r.closed_at, + meta: r.meta, + })), + count: rows.length, + }); + } + + return jsonResponse(500, { detail: `unhandled ${req.method} ${path}` }); + }; + + return { handler, sessions }; +} + +// ---------------------------------------------------------------------- tests + +test("session: createSession + turn + manual close round-trip", async () => { + const { handler } = makeMockServer(); + const { fetch, calls } = makeMockFetch(handler); + const forge = new Forge({ ...BASE, fetch }); + + const s = await forge.createSession({ agent: "claude" }); + try { + assert.ok(s instanceof Session); + assert.equal(s.agent, "claude"); + assert.match(s.sessionId, /^sess_/); + assert.equal(typeof s.createdAt, "number"); + + const r = await s.turn("hello"); + assert.equal(r.ok, true); + assert.equal(r.sessionId, s.sessionId); + assert.equal(r.turnIndex, 1); + assert.equal(r.stopReason, "end_turn"); + assert.equal(r.durationMs, 1234); + assert.ok(Array.isArray(r.events)); + } finally { + await s.close(); + } + + assert.equal(s.closed, true); + + // Verify the request shapes hitting the server + assert.ok(calls.some((c) => c.method === "POST" && c.url.endsWith("/sessions"))); + assert.ok( + calls.some( + (c) => + c.method === "POST" && + new RegExp(`/sessions/${s.sessionId}/turn$`).test(c.url) + ) + ); + assert.ok( + calls.some( + (c) => + c.method === "DELETE" && + new RegExp(`/sessions/${s.sessionId}$`).test(c.url) + ) + ); + + // Bearer header was forwarded + const createCall = calls.find((c) => c.url.endsWith("/sessions"))!; + assert.equal(createCall.headers["authorization"], "Bearer cf_unit_test"); + + // Body: agent forwarded as-is + const body = JSON.parse(createCall.bodyText!); + assert.equal(body.agent, "claude"); +}); + +test("session: `await using` closes at scope exit (success path)", async () => { + const { handler } = makeMockServer(); + const { fetch, calls } = makeMockFetch(handler); + const forge = new Forge({ ...BASE, fetch }); + + let sid: string | null = null; + { + await using s = await forge.session({ agent: "claude" }); + sid = s.sessionId; + await s.turn("hello"); + } // scope exit triggers Symbol.asyncDispose + + assert.ok(sid); + const closeHits = calls.filter( + (c) => c.method === "DELETE" && c.url.endsWith(`/sessions/${sid}`) + ); + assert.equal(closeHits.length, 1, "scope exit must call DELETE exactly once"); +}); + +test("session: `await using` closes even when block throws", async () => { + const { handler } = makeMockServer(); + const { fetch, calls } = makeMockFetch(handler); + const forge = new Forge({ ...BASE, fetch }); + + let sid: string | null = null; + await assert.rejects( + (async () => { + await using s = await forge.session(); + sid = s.sessionId; + throw new Error("boom inside block"); + })(), + /boom inside block/ + ); + + assert.ok(sid); + const closeHits = calls.filter( + (c) => c.method === "DELETE" && c.url.endsWith(`/sessions/${sid}`) + ); + assert.equal(closeHits.length, 1, "throw must still trigger close"); +}); + +test("session: close() is idempotent — second call short-circuits, no extra DELETE", async () => { + const { handler } = makeMockServer(); + const { fetch, calls } = makeMockFetch(handler); + const forge = new Forge({ ...BASE, fetch }); + + const s = await forge.createSession(); + await s.close(); + await s.close(); // no-op + await s.close(); // still no-op + + const closeHits = calls.filter( + (c) => c.method === "DELETE" && c.url.endsWith(`/sessions/${s.sessionId}`) + ); + assert.equal(closeHits.length, 1, "only one network DELETE for repeated close"); + assert.equal(s.closed, true); +}); + +test("session: turn() after local close throws ForgeError without hitting network", async () => { + const { handler } = makeMockServer(); + const { fetch, calls } = makeMockFetch(handler); + const forge = new Forge({ ...BASE, fetch }); + + const s = await forge.createSession(); + await s.close(); + const beforeCount = calls.length; + await assert.rejects(s.turn("noop"), ForgeError); + // No turn request should have been made after close. + const after = calls.slice(beforeCount); + assert.equal( + after.filter((c) => c.url.includes("/turn")).length, + 0, + "no turn-call after local close" + ); +}); + +test("session: empty prompt rejected client-side", async () => { + const { handler } = makeMockServer(); + const { fetch, calls } = makeMockFetch(handler); + const forge = new Forge({ ...BASE, fetch }); + + const s = await forge.createSession(); + const before = calls.length; + await assert.rejects(s.turn(""), ForgeError); + const turnCalls = calls + .slice(before) + .filter((c) => c.url.includes("/turn")); + assert.equal(turnCalls.length, 0); + await s.close(); +}); + +test("listSessions: GET /sessions, camelCase + per-token filter (server-side)", async () => { + const { handler } = makeMockServer(); + const { fetch, calls } = makeMockFetch(handler); + const forge = new Forge({ ...BASE, fetch }); + + // Create two — the mock server attaches the same app_name to both. + const a = await forge.createSession({ agent: "claude" }); + const b = await forge.createSession({ agent: "claude" }); + + const list = await forge.listSessions(); + assert.equal(list.length, 2); + assert.ok(list.find((s) => s.sessionId === a.sessionId)); + assert.ok(list.find((s) => s.sessionId === b.sessionId)); + assert.equal(list[0]!.appName, "unit-test-app"); + assert.equal(typeof list[0]!.createdAt, "number"); + assert.equal(list[0]!.turnCount, 0); + assert.equal(list[0]!.closedAt, null); + + const listCall = calls.findLast((c) => c.method === "GET" && c.url.endsWith("/sessions"))!; + assert.equal(listCall.headers["authorization"], "Bearer cf_unit_test"); + + // includeClosed=false should add the query param + await forge.listSessions({ includeClosed: false }); + const lastList = calls.findLast( + (c) => c.method === "GET" && c.url.includes("/sessions?") + )!; + assert.match(lastList.url, /\?include_closed=false$/); + + await a.close(); + await b.close(); +}); + +test("getSession: returns SessionState shape", async () => { + const { handler } = makeMockServer(); + const { fetch } = makeMockFetch(handler); + const forge = new Forge({ ...BASE, fetch }); + + const s = await forge.createSession({ agent: "claude" }); + await s.turn("hello"); + const state = await forge.getSession(s.sessionId); + assert.equal(state.sessionId, s.sessionId); + assert.equal(state.agent, "claude"); + assert.equal(state.turnCount, 1); + assert.equal(typeof state.createdAt, "number"); + assert.notEqual(state.lastTurnAt, null); + assert.equal(state.closedAt, null); + // appName is empty string because the per-session GET endpoint omits it + // (documented behavior, mirrors server response shape). + assert.equal(state.appName, ""); + await s.close(); +}); + +test("getSession: cross-token (or unknown id) → ForgeAPIError(404)", async () => { + const { handler } = makeMockServer(); + const { fetch } = makeMockFetch(handler); + const forge = new Forge({ ...BASE, fetch }); + + await assert.rejects(forge.getSession("sess_does_not_exist"), (e: unknown) => { + assert.ok(e instanceof ForgeAPIError); + assert.equal((e as ForgeAPIError).status, 404); + return true; + }); +}); + +test("turnText() concatenates only text events", async () => { + const result: TurnResult = { + ok: true, + sessionId: "sess_x", + turnIndex: 1, + events: [ + { type: "thinking", content: "ignored" }, + { type: "text", content: "hello, " }, + { type: "tool_call", name: "Read", args: {}, result: null }, + { type: "text", content: "world" }, + { type: "text" }, // missing content shouldn't blow up + { type: "session_update", content: "should-not-leak" }, + ], + stopReason: "end_turn", + durationMs: 1, + }; + assert.equal(turnText(result), "hello, world"); +}); + +test("turn(): forwards files and timeoutSecs as snake_case wire keys", async () => { + const { handler } = makeMockServer(); + const { fetch, calls } = makeMockFetch(handler); + const forge = new Forge({ ...BASE, fetch }); + + const s = await forge.createSession(); + await s.turn("with files", { + files: ["ff_one", "ff_two"], + timeoutSecs: 60, + }); + await s.close(); + + const turnCall = calls.find((c) => c.url.includes(`/turn`))!; + const sent = JSON.parse(turnCall.bodyText!); + assert.equal(sent.prompt, "with files"); + assert.deepEqual(sent.files, ["ff_one", "ff_two"]); + assert.equal(sent.timeout_secs, 60); +}); + +test("turn(): events array preserves unknown types via open enum", async () => { + const { handler } = makeMockServer(); + const { fetch } = makeMockFetch(handler); + const forge = new Forge({ ...BASE, fetch }); + + const s = await forge.createSession(); + const r = await s.turn("anything"); + await s.close(); + + const types = r.events.map((e) => e.type); + assert.ok(types.includes("text")); + assert.ok(types.includes("tool_call")); + assert.ok(types.includes("thinking")); + assert.ok(types.includes("session_update")); + + // The unknown-typed event still round-trips its extra fields untouched. + const unknown = r.events.find((e) => e.type === "session_update")!; + assert.equal(unknown["custom"], 42); +}); + +// --------------------------------------------------- v0.1 regression guards + +test("v0.1 regression: forge.run() shape unchanged", async () => { + // Mock fetch that responds to /run only — sanity-checks that the + // legacy single-turn path didn't get refactored under us. + const { fetch } = makeMockFetch(() => + jsonResponse(200, { + ok: true, + result: { hello: "world" }, + duration_ms: 1234, + stop_reason: "end_turn", + }) + ); + const forge = new Forge({ ...BASE, fetch }); + const r = await forge.run({ prompt: "x" }); + assert.equal(r.ok, true); + assert.deepEqual(r.result, { hello: "world" }); + assert.equal(r.durationMs, 1234); + assert.equal(r.stopReason, "end_turn"); + // RunResult must NOT have leaked v0.2 fields: + assert.equal(("sessionId" in r) as boolean, false); + assert.equal(("turnIndex" in r) as boolean, false); + assert.equal(("events" in r) as boolean, false); +}); diff --git a/clients/typescript/tsconfig.json b/clients/typescript/tsconfig.json index 82d41ae..3880368 100644 --- a/clients/typescript/tsconfig.json +++ b/clients/typescript/tsconfig.json @@ -3,7 +3,7 @@ "target": "ES2022", "module": "Node16", "moduleResolution": "Node16", - "lib": ["ES2022", "DOM"], + "lib": ["ES2022", "ES2023", "ESNext.Disposable", "DOM"], "strict": true, "noImplicitAny": true, "noImplicitOverride": true,