diff --git a/src/client.ts b/src/client.ts index 30aa45d..4454d63 100644 --- a/src/client.ts +++ b/src/client.ts @@ -98,6 +98,44 @@ export type ServiceAccountWriteOptions = RequestOptions & { idempotencyKey?: string; }; +export type CreateSessionOptions = RequestOptions & { + type?: string; + projectId?: string; + parentSessionId?: string; + dependsOn?: string[]; + metadata?: Record; +}; + +export type ListSessionsOptions = RequestOptions & { + status?: string; + parentSessionId?: string; + limit?: number; +}; + +export type PostSessionMessageOptions = RequestOptions & { + contentType?: string; +}; + +export type ListSessionMessagesOptions = RequestOptions & { + since?: number; + limit?: number; +}; + +export type GetSessionFeedOptions = RequestOptions & { + limit?: number; +}; + +export type ListenSessionOptions = RequestOptions & { + since?: number; + waitSeconds?: number; + pollIntervalMs?: number; + timeoutMs?: number; +}; + +export type CompleteSessionOptions = RequestOptions & { + result?: Record; +}; + export type ServiceAccountsListOptions = RequestOptions & { orgId: string; workspaceId?: string; @@ -1492,6 +1530,186 @@ export class AxmeClient { }); } + // --- Session API --- + + async createSession(options: CreateSessionOptions = {}): Promise> { + const payload: Record = {}; + if (options.type) payload.type = options.type; + if (options.projectId) payload.project_id = options.projectId; + if (options.parentSessionId) payload.parent_session_id = options.parentSessionId; + if (options.dependsOn) payload.depends_on = options.dependsOn; + if (options.metadata) payload.metadata = options.metadata; + return this.requestJson("/v1/sessions", { + method: "POST", + body: JSON.stringify(payload), + traceId: options.traceId, + retryable: false, + }); + } + + async getSession(sessionId: string, options: RequestOptions = {}): Promise> { + return this.requestJson(`/v1/sessions/${sessionId}`, { + method: "GET", + retryable: true, + traceId: options.traceId, + }); + } + + async listSessions(options: ListSessionsOptions = {}): Promise> { + const url = new URL(`${this.baseUrl}/v1/sessions`); + if (options.status) url.searchParams.set("status", options.status); + if (options.parentSessionId) url.searchParams.set("parent_session_id", options.parentSessionId); + if (typeof options.limit === "number") url.searchParams.set("limit", String(options.limit)); + return this.requestJson(url.toString(), { + method: "GET", + retryable: true, + traceId: options.traceId, + }); + } + + async postSessionMessage( + sessionId: string, + role: string, + content: unknown, + options: PostSessionMessageOptions = {}, + ): Promise> { + const payload: Record = { role, content }; + if (options.contentType) payload.content_type = options.contentType; + return this.requestJson(`/v1/sessions/${sessionId}/messages`, { + method: "POST", + body: JSON.stringify(payload), + traceId: options.traceId, + retryable: false, + }); + } + + async listSessionMessages( + sessionId: string, + options: ListSessionMessagesOptions = {}, + ): Promise> { + const url = new URL(`${this.baseUrl}/v1/sessions/${sessionId}/messages`); + if (typeof options.since === "number") url.searchParams.set("since", String(options.since)); + if (typeof options.limit === "number") url.searchParams.set("limit", String(options.limit)); + return this.requestJson(url.toString(), { + method: "GET", + retryable: true, + traceId: options.traceId, + }); + } + + async getSessionFeed( + sessionId: string, + options: GetSessionFeedOptions = {}, + ): Promise> { + const url = new URL(`${this.baseUrl}/v1/sessions/${sessionId}/feed`); + if (typeof options.limit === "number") url.searchParams.set("limit", String(options.limit)); + return this.requestJson(url.toString(), { + method: "GET", + retryable: true, + traceId: options.traceId, + }); + } + + async *listenSession( + sessionId: string, + options: ListenSessionOptions = {}, + ): AsyncGenerator, void, void> { + const since = options.since ?? 0; + const waitSeconds = options.waitSeconds ?? 30; + const pollIntervalMs = options.pollIntervalMs ?? 1000; + const deadline = typeof options.timeoutMs === "number" ? Date.now() + options.timeoutMs : undefined; + let nextSince = since; + + while (true) { + if (typeof deadline === "number" && Date.now() >= deadline) { + return; + } + + let streamWaitSeconds = waitSeconds; + if (typeof deadline === "number") { + const msLeft = Math.max(0, deadline - Date.now()); + if (msLeft <= 0) return; + streamWaitSeconds = Math.max(1, Math.min(waitSeconds, Math.floor(msLeft / 1000))); + } + + try { + const streamedEvents = await this.fetchSessionFeedStream(sessionId, { + since: nextSince, + waitSeconds: streamWaitSeconds, + traceId: options.traceId, + }); + for (const event of streamedEvents) { + const seq = typeof event.seq === "number" ? event.seq : 0; + if (seq > nextSince) nextSince = seq; + yield event; + if (event.type === "session.completed" || (typeof event.event === "string" && event.event === "session.completed")) { + return; + } + } + } catch (error) { + if (!(error instanceof AxmeHttpError) || ![404, 405, 501].includes(error.statusCode)) { + throw error; + } + } + + // Fallback to polling if SSE not supported + const polled = await this.listSessionMessages(sessionId, { + since: nextSince > 0 ? nextSince : undefined, + traceId: options.traceId, + }); + const messages = polled.messages; + if (!Array.isArray(messages) || messages.length === 0) { + if (typeof deadline === "number" && Date.now() >= deadline) return; + await delay(pollIntervalMs); + continue; + } + for (const msg of messages) { + if (!msg || typeof msg !== "object") continue; + const message = msg as Record; + const seq = typeof message.seq === "number" ? message.seq : 0; + if (seq > nextSince) nextSince = seq; + yield { type: "session.message", ...message }; + } + } + } + + async completeSession( + sessionId: string, + options: CompleteSessionOptions = {}, + ): Promise> { + const payload: Record = {}; + if (options.result) payload.result = options.result; + return this.requestJson(`/v1/sessions/${sessionId}/complete`, { + method: "POST", + body: JSON.stringify(payload), + traceId: options.traceId, + retryable: false, + }); + } + + private async fetchSessionFeedStream( + sessionId: string, + options: { + since: number; + waitSeconds: number; + traceId?: string; + }, + ): Promise>> { + const streamUrl = new URL(`${this.baseUrl}/v1/sessions/${sessionId}/feed/stream`); + streamUrl.searchParams.set("since", String(options.since)); + streamUrl.searchParams.set("wait_seconds", String(options.waitSeconds)); + + const response = await this.fetchImpl(streamUrl.toString(), { + method: "GET", + headers: this.buildHeaders(undefined, options.traceId), + }); + if (!response.ok) { + throw await buildHttpError(response); + } + const body = await response.text(); + return parseSessionSseEvents(body); + } + private async fetchIntentEventStream( intentId: string, options: { @@ -1872,6 +2090,10 @@ function parseAgentSseEvents(body: string): Array> { return parseSseEvents(body, (eventType) => eventType.startsWith("intent.")); } +function parseSessionSseEvents(body: string): Array> { + return parseSseEvents(body, (eventType) => eventType.startsWith("session.") || eventType.startsWith("stream.")); +} + function parseSseEvents( body: string, includeEvent: (eventType: string) => boolean, diff --git a/src/index.ts b/src/index.ts index 59bb930..489f1e9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,13 @@ export { type ServiceAccountsListOptions, type ServiceAccountWriteOptions, type UserWriteOptions, + type CreateSessionOptions, + type ListSessionsOptions, + type PostSessionMessageOptions, + type ListSessionMessagesOptions, + type GetSessionFeedOptions, + type ListenSessionOptions, + type CompleteSessionOptions, type IdempotentOwnerScopedOptions, type InboxChangesOptions, type McpCallToolOptions, diff --git a/test/client.test.ts b/test/client.test.ts index 214f535..a4e1002 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -2134,6 +2134,176 @@ test("listen ignores stream.timeout keepalive events", async () => { assert.equal(results[0].intent_id, "real-1"); }); +// ── Session API tests ───────────────────────────────────────────────────────── + +const SESSION_RESPONSE = { + ok: true, + session_id: "sess-1111", + status: "ACTIVE", + type: "task", + created_at: "2026-03-26T00:00:00Z", +}; + +test("createSession sends POST /v1/sessions with payload", async () => { + const client = new AxmeClient( + { baseUrl: "https://api.axme.test", apiKey: "token" }, + async (input, init) => { + assert.equal(input.toString(), "https://api.axme.test/v1/sessions"); + assert.equal(init?.method, "POST"); + const body = JSON.parse(init?.body as string); + assert.equal(body.type, "task"); + assert.equal(body.project_id, "/home/user/project"); + assert.deepEqual(body.metadata, { agent: "claude-code" }); + return new Response(JSON.stringify(SESSION_RESPONSE), { status: 200 }); + }, + ); + const result = await client.createSession({ + type: "task", + projectId: "/home/user/project", + metadata: { agent: "claude-code" }, + }); + assert.equal(result.session_id, "sess-1111"); +}); + +test("getSession sends GET /v1/sessions/{id}", async () => { + const client = new AxmeClient( + { baseUrl: "https://api.axme.test", apiKey: "token" }, + async (input, init) => { + assert.equal(input.toString(), "https://api.axme.test/v1/sessions/sess-1111"); + assert.equal(init?.method, "GET"); + return new Response(JSON.stringify({ ok: true, session: { session_id: "sess-1111", status: "ACTIVE" } }), { status: 200 }); + }, + ); + const result = await client.getSession("sess-1111"); + assert.equal((result.session as Record).session_id, "sess-1111"); +}); + +test("listSessions sends GET /v1/sessions with query params", async () => { + const client = new AxmeClient( + { baseUrl: "https://api.axme.test", apiKey: "token" }, + async (input, init) => { + const url = new URL(input.toString()); + assert.equal(url.pathname, "/v1/sessions"); + assert.equal(url.searchParams.get("status"), "ACTIVE"); + assert.equal(url.searchParams.get("limit"), "10"); + assert.equal(init?.method, "GET"); + return new Response(JSON.stringify({ ok: true, sessions: [] }), { status: 200 }); + }, + ); + await client.listSessions({ status: "ACTIVE", limit: 10 }); +}); + +test("postSessionMessage sends POST with role and content", async () => { + const client = new AxmeClient( + { baseUrl: "https://api.axme.test", apiKey: "token" }, + async (input, init) => { + assert.equal(input.toString(), "https://api.axme.test/v1/sessions/sess-1111/messages"); + assert.equal(init?.method, "POST"); + const body = JSON.parse(init?.body as string); + assert.equal(body.role, "user"); + assert.equal(body.content, "hello"); + assert.equal(body.content_type, "text"); + return new Response(JSON.stringify({ ok: true, message_id: "msg-1", seq: 1 }), { status: 200 }); + }, + ); + const result = await client.postSessionMessage("sess-1111", "user", "hello", { contentType: "text" }); + assert.equal(result.seq, 1); +}); + +test("listSessionMessages sends GET with since and limit", async () => { + const client = new AxmeClient( + { baseUrl: "https://api.axme.test", apiKey: "token" }, + async (input, init) => { + const url = new URL(input.toString()); + assert.equal(url.pathname, "/v1/sessions/sess-1111/messages"); + assert.equal(url.searchParams.get("since"), "5"); + assert.equal(url.searchParams.get("limit"), "20"); + return new Response(JSON.stringify({ ok: true, messages: [{ seq: 6, role: "agent", content: "hi" }] }), { status: 200 }); + }, + ); + const result = await client.listSessionMessages("sess-1111", { since: 5, limit: 20 }); + assert.equal((result.messages as Array>).length, 1); +}); + +test("getSessionFeed sends GET /v1/sessions/{id}/feed", async () => { + const client = new AxmeClient( + { baseUrl: "https://api.axme.test", apiKey: "token" }, + async (input, init) => { + const url = new URL(input.toString()); + assert.equal(url.pathname, "/v1/sessions/sess-1111/feed"); + assert.equal(url.searchParams.get("limit"), "50"); + return new Response(JSON.stringify({ ok: true, feed: [] }), { status: 200 }); + }, + ); + await client.getSessionFeed("sess-1111", { limit: 50 }); +}); + +test("completeSession sends POST /v1/sessions/{id}/complete", async () => { + const client = new AxmeClient( + { baseUrl: "https://api.axme.test", apiKey: "token" }, + async (input, init) => { + assert.equal(input.toString(), "https://api.axme.test/v1/sessions/sess-1111/complete"); + assert.equal(init?.method, "POST"); + const body = JSON.parse(init?.body as string); + assert.deepEqual(body.result, { summary: "done" }); + return new Response(JSON.stringify({ ok: true, session_id: "sess-1111", status: "COMPLETED" }), { status: 200 }); + }, + ); + const result = await client.completeSession("sess-1111", { result: { summary: "done" } }); + assert.equal(result.status, "COMPLETED"); +}); + +test("listenSession yields events from SSE stream", async () => { + const sseBody = [ + "event: session.message", + 'data: {"type":"message","seq":1,"role":"agent","content":"hello"}', + "", + "event: session.completed", + 'data: {"type":"session.completed","seq":2}', + "", + ].join("\n"); + + const client = new AxmeClient( + { baseUrl: "https://api.axme.test", apiKey: "token" }, + async (input) => { + const url = new URL(input.toString()); + if (url.pathname.endsWith("/feed/stream")) { + return new Response(sseBody, { status: 200 }); + } + return new Response(JSON.stringify({ ok: true, messages: [] }), { status: 200 }); + }, + ); + const events = await collect(client.listenSession("sess-1111")); + assert.equal(events.length, 2); + assert.equal(events[0].role, "agent"); + assert.equal(events[1].type, "session.completed"); +}); + +test("listenSession falls back to polling when SSE returns 404", async () => { + let pollCount = 0; + const client = new AxmeClient( + { baseUrl: "https://api.axme.test", apiKey: "token" }, + async (input) => { + const url = new URL(input.toString()); + if (url.pathname.endsWith("/feed/stream")) { + return new Response("Not Found", { status: 404 }); + } + pollCount++; + if (pollCount === 1) { + return new Response(JSON.stringify({ ok: true, messages: [{ seq: 1, role: "agent", content: "polled" }] }), { status: 200 }); + } + return new Response(JSON.stringify({ ok: true, messages: [] }), { status: 200 }); + }, + ); + const events: Record[] = []; + for await (const ev of client.listenSession("sess-1111", { timeoutMs: 100, pollIntervalMs: 10 })) { + events.push(ev); + if (events.length >= 1) break; + } + assert.equal(events.length, 1); + assert.equal(events[0].content, "polled"); +}); + // ── helpers (local) ─────────────────────────────────────────────────────────── async function collect(gen: AsyncGenerator): Promise {