clients/typescript: v0.2 multi-turn Session API

- Session class with Symbol.asyncDispose for `await using` ergonomics (ES2024)
- forge.session({ agent }) preferred form; forge.createSession() explicit
- forge.listSessions() / forge.getSession()
- TurnResult / TurnEvent / SessionState types + turnText() helper
- Idempotent Session.close() (200 on re-close server-side)
- tests/sessions.test.ts: 13 tests covering disposal/idempotency/throw/list/state/404/regression
- README "Multi-turn / Sessions (v0.2)" section + fallback try/finally docs

tsconfig.json: lib bumped to add ES2023 + ESNext.Disposable so the
Symbol.asyncDispose / AsyncDisposable types resolve under TS 5.9. Target
stays ES2022 — the disposable runtime hooks are TS-erasable, no runtime
polyfill needed; consumers just need Node 20.4+ at runtime to use the
`await using` form (documented in the README; the v0.1 surface and the
explicit createSession + try/finally fallback continue to work on Node 18+).

package.json: bumped to 0.2.0; engines.node stays >=18 since the v0.1
surface is unchanged. v0.1 /run path unchanged (regression test added).

Spec: memory/spec-clawdforge-v0.2.md
Server core: 940861f
This commit is contained in:
Kayos 2026-04-29 06:38:33 -07:00
parent 6a6fc8a67f
commit 3a590d775e
8 changed files with 1084 additions and 3 deletions

View file

@ -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<string, unknown>; // 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<TurnResult>`
```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<string, unknown>;
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<void>`
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

View file

@ -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",

View file

@ -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<Session> {
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<Session> {
const wire: Record<string, unknown> = {};
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<SessionState> {
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<SessionState[]> {
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/<name>` — revoke. Returns true on success, false if not found. */
async revokeToken(
name: string,

View file

@ -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";

View file

@ -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<T>(
method: "GET" | "POST" | "DELETE",
path: string,
opts?: {
jsonBody?: unknown;
signal?: AbortSignal;
timeoutMs?: number;
}
): Promise<T>;
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<TurnResult> {
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<string, unknown> = { 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<void> {
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<void> {
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<string, unknown>;
const type = typeof e["type"] === "string" ? (e["type"] as string) : "";
out.push({ ...(e as TurnEvent), type });
}
}
return out;
}

View file

@ -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<string, unknown>;
/** 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<string, unknown>;
/** 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;
}

View file

@ -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<string, string>;
bodyText: string | null;
}
function makeMockFetch(
handler: (req: CapturedRequest) => Response | Promise<Response>
): { 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<string, string> = {};
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<string, unknown> | 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<string, unknown> | 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);
});

View file

@ -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,