diff --git a/src/index.ts b/src/index.ts index a2788a7..7b08550 100644 --- a/src/index.ts +++ b/src/index.ts @@ -59,6 +59,9 @@ export type { ImageContent, MessageContentItem, SendMessage, + // List messages API + ListMessagesOptions, + ListMessagesResult, // Tool types AgentTool, AgentToolResult, diff --git a/src/session.ts b/src/session.ts index e8084bd..e9856b1 100644 --- a/src/session.ts +++ b/src/session.ts @@ -21,6 +21,8 @@ import type { SendMessage, AnyAgentTool, ExecuteExternalToolRequest, + ListMessagesOptions, + ListMessagesResult, } from "./types.js"; import { isHeadlessAutoAllowTool, @@ -47,6 +49,13 @@ export class Session implements AsyncDisposable { private pumpPromise: Promise | null = null; private pumpClosed = false; private droppedStreamMessages = 0; + // Waiters for SDK-initiated control requests (e.g., listMessages). + // Keyed by request_id; pump resolves the matching waiter when it sees + // a control_response with that request_id instead of queuing it as a stream msg. + private controlResponseWaiters = new Map< + string, + (response: { subtype: string; response?: unknown; error?: string }) => void + >(); constructor( private options: InternalSessionOptions = {} @@ -254,6 +263,23 @@ export class Session implements AsyncDisposable { continue; } + // Route control_response to a registered waiter (e.g., from listMessages). + // Unmatched control_responses are logged and dropped — they never reach the stream. + if (wireMsg.type === "control_response") { + const respMsg = wireMsg as unknown as { + response: { subtype: string; request_id?: string; response?: unknown; error?: string }; + }; + const requestId = respMsg.response?.request_id; + if (requestId && this.controlResponseWaiters.has(requestId)) { + const resolve = this.controlResponseWaiters.get(requestId)!; + this.controlResponseWaiters.delete(requestId); + resolve(respMsg.response); + } else { + sessionLog("pump", `DROPPED unmatched control_response: request_id=${requestId ?? "N/A"}`); + } + continue; + } + const sdkMsg = this.transformMessage(wireMsg); if (sdkMsg) { this.enqueueStreamMessage(sdkMsg); @@ -332,6 +358,11 @@ export class Session implements AsyncDisposable { resolve(msg); } this.streamResolvers = []; + // Also cancel any in-flight control request waiters (e.g., listMessages) + for (const resolve of this.controlResponseWaiters.values()) { + resolve({ subtype: "error", error: "session closed" }); + } + this.controlResponseWaiters.clear(); } /** @@ -538,6 +569,65 @@ export class Session implements AsyncDisposable { }); } + /** + * Fetch a page of conversation messages via the CLI control protocol. + * + * The session must be initialized before calling this method. + * Safe to call concurrently with an active stream() — the pump routes + * matching control_response messages to this waiter without touching the + * stream queue. + */ + async listMessages(options: ListMessagesOptions = {}): Promise { + if (!this.initialized) { + throw new Error("Session must be initialized before calling listMessages()"); + } + + const requestId = `list-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + + const responsePromise = new Promise<{ + subtype: string; + response?: unknown; + error?: string; + }>((resolve) => { + this.controlResponseWaiters.set(requestId, resolve); + }); + + await this.transport.write({ + type: "control_request", + request_id: requestId, + request: { + subtype: "list_messages", + ...(options.conversationId ? { conversation_id: options.conversationId } : {}), + ...(options.before ? { before: options.before } : {}), + ...(options.after ? { after: options.after } : {}), + ...(options.order ? { order: options.order } : {}), + ...(options.limit !== undefined ? { limit: options.limit } : {}), + }, + }); + + // Race against session close (pump sets pumpClosed and resolves all waiters with null) + const resp = await responsePromise; + + if (!resp) { + throw new Error("Session closed before listMessages response arrived"); + } + if (resp.subtype === "error") { + throw new Error(resp.error ?? "listMessages failed"); + } + + const payload = resp.response as { + messages?: unknown[]; + next_before?: string | null; + has_more?: boolean; + } | undefined; + + return { + messages: payload?.messages ?? [], + nextBefore: payload?.next_before ?? null, + hasMore: payload?.has_more ?? false, + }; + } + /** * Close the session */ diff --git a/src/tests/list-messages.test.ts b/src/tests/list-messages.test.ts new file mode 100644 index 0000000..3a65053 --- /dev/null +++ b/src/tests/list-messages.test.ts @@ -0,0 +1,396 @@ +/** + * Unit tests for listMessages() SDK layer. + * + * Covers: + * 1. ListMessagesOptions / ListMessagesResult type shapes + * 2. controlResponseWaiters routing (pump mock) — concurrent, error, close cleanup + * 3. includePartialMessages flag forwarding to CLI args + * 4. Waiter resolution while a stream is active (concurrent safety) + * 5. Close / error waiter cleanup — no hanging promises + * + * Real end-to-end tests with a live CLI are in the manual smoke suite. + */ +import { describe, expect, test } from "bun:test"; +import type { ListMessagesOptions, ListMessagesResult } from "../types.js"; + +// ───────────────────────────────────────────────────────────────────────────── +// 1. Type shapes +// ───────────────────────────────────────────────────────────────────────────── + +describe("ListMessagesOptions type", () => { + test("accepts all optional fields", () => { + const opts: ListMessagesOptions = { + conversationId: "conv-123", + before: "msg-abc", + after: "msg-xyz", + order: "desc", + limit: 50, + }; + expect(opts.conversationId).toBe("conv-123"); + expect(opts.limit).toBe(50); + }); + + test("accepts empty options object", () => { + const opts: ListMessagesOptions = {}; + expect(opts.conversationId).toBeUndefined(); + expect(opts.limit).toBeUndefined(); + }); + + test("order can be asc or desc", () => { + const asc: ListMessagesOptions = { order: "asc" }; + const desc: ListMessagesOptions = { order: "desc" }; + expect(asc.order).toBe("asc"); + expect(desc.order).toBe("desc"); + }); +}); + +describe("ListMessagesResult type", () => { + test("well-formed success result with messages", () => { + const result: ListMessagesResult = { + messages: [{ id: "msg-1", message_type: "user_message" }], + nextBefore: "msg-1", + hasMore: false, + }; + expect(result.messages).toHaveLength(1); + expect(result.hasMore).toBe(false); + expect(result.nextBefore).toBe("msg-1"); + }); + + test("empty page — nextBefore is null", () => { + const result: ListMessagesResult = { + messages: [], + nextBefore: null, + hasMore: false, + }; + expect(result.messages).toHaveLength(0); + expect(result.nextBefore).toBeNull(); + }); + + test("partial page — hasMore is true", () => { + const result: ListMessagesResult = { + messages: new Array(50).fill({ id: "x" }), + nextBefore: "msg-50", + hasMore: true, + }; + expect(result.hasMore).toBe(true); + expect(result.nextBefore).toBe("msg-50"); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 2. controlResponseWaiters routing +// ───────────────────────────────────────────────────────────────────────────── + +/** Minimal pump simulator — mirrors the routing logic in session.ts. */ +function makePump() { + const waiters = new Map< + string, + (resp: { subtype: string; response?: unknown; error?: string }) => void + >(); + + function route(wireMsg: { + type: string; + response?: { + subtype: string; + request_id?: string; + response?: unknown; + error?: string; + }; + }): boolean { + if (wireMsg.type !== "control_response") return false; + const requestId = wireMsg.response?.request_id; + if (requestId && waiters.has(requestId)) { + const resolve = waiters.get(requestId)!; + waiters.delete(requestId); + resolve(wireMsg.response!); + return true; + } + return false; + } + + /** Simulate session.close() clearing all waiters with an error. */ + function closeAll() { + for (const resolve of waiters.values()) { + resolve({ subtype: "error", error: "session closed" }); + } + waiters.clear(); + } + + return { waiters, route, closeAll }; +} + +describe("controlResponseWaiters routing", () => { + test("routes matching control_response to waiter and removes it", async () => { + const { waiters, route } = makePump(); + + const promise = new Promise<{ subtype: string; response?: unknown }>( + (res) => { waiters.set("list_001", res); } + ); + + const handled = route({ + type: "control_response", + response: { + subtype: "success", + request_id: "list_001", + response: { messages: [], has_more: false }, + }, + }); + + expect(handled).toBe(true); + const resp = await promise; + expect(resp.subtype).toBe("success"); + expect(waiters.size).toBe(0); // waiter consumed + }); + + test("drops unmatched control_response (no registered waiter)", () => { + const { waiters, route } = makePump(); + + const handled = route({ + type: "control_response", + response: { subtype: "success", request_id: "unknown_id" }, + }); + + expect(handled).toBe(false); + expect(waiters.size).toBe(0); + }); + + test("routes error subtype to waiter", async () => { + const { waiters, route } = makePump(); + + const promise = new Promise<{ subtype: string; error?: string }>( + (res) => { waiters.set("list_002", res); } + ); + + route({ + type: "control_response", + response: { + subtype: "error", + request_id: "list_002", + error: "conversation not found", + }, + }); + + const resp = await promise; + expect(resp.subtype).toBe("error"); + expect(resp.error).toContain("conversation not found"); + }); + + test("concurrent waiters for different request_ids resolve independently", async () => { + const { waiters, route } = makePump(); + + const p1 = new Promise<{ subtype: string; response?: unknown }>( + (res) => waiters.set("req_A", res) + ); + const p2 = new Promise<{ subtype: string; response?: unknown }>( + (res) => waiters.set("req_B", res) + ); + + // Deliver in reverse order — both should resolve to their own response + route({ + type: "control_response", + response: { subtype: "success", request_id: "req_B", response: { messages: [1] } }, + }); + route({ + type: "control_response", + response: { subtype: "success", request_id: "req_A", response: { messages: [2] } }, + }); + + const [rA, rB] = await Promise.all([p1, p2]); + expect(rA.subtype).toBe("success"); + expect(rB.subtype).toBe("success"); + expect(waiters.size).toBe(0); + }); + + test("non-control_response messages are ignored (pass-through)", () => { + const { waiters, route } = makePump(); + waiters.set("req_X", () => { throw new Error("should not be called"); }); + + const handled = route({ type: "assistant_message" }); + expect(handled).toBe(false); + expect(waiters.size).toBe(1); // waiter still registered + }); + + test("control_response without request_id is dropped", () => { + const { waiters, route } = makePump(); + waiters.set("req_Y", () => { throw new Error("should not be called"); }); + + const handled = route({ + type: "control_response", + response: { subtype: "success" /* no request_id */ }, + }); + expect(handled).toBe(false); + expect(waiters.size).toBe(1); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 3. includePartialMessages arg forwarding +// ───────────────────────────────────────────────────────────────────────────── + +describe("includePartialMessages arg forwarding", () => { + /** + * Simulate transport.ts buildArgs() to verify the flag is included. + * The actual transport builds the args array and passes it to spawn(). + */ + function buildArgs(options: { + agentId?: string; + conversationId?: string; + includePartialMessages?: boolean; + }): string[] { + const args: string[] = ["--output", "stream-json"]; + if (options.agentId) args.push("--agent", options.agentId); + if (options.conversationId) args.push("--conv", options.conversationId); + if (options.includePartialMessages) args.push("--include-partial-messages"); + return args; + } + + test("flag absent when includePartialMessages is false", () => { + const args = buildArgs({ agentId: "agent-1", includePartialMessages: false }); + expect(args).not.toContain("--include-partial-messages"); + }); + + test("flag absent when includePartialMessages is undefined", () => { + const args = buildArgs({ agentId: "agent-1" }); + expect(args).not.toContain("--include-partial-messages"); + }); + + test("flag present when includePartialMessages is true", () => { + const args = buildArgs({ agentId: "agent-1", includePartialMessages: true }); + expect(args).toContain("--include-partial-messages"); + }); + + test("flag position is after other args (no disruption)", () => { + const args = buildArgs({ + agentId: "agent-1", + conversationId: "conv-abc", + includePartialMessages: true, + }); + // Other args still present + expect(args).toContain("--agent"); + expect(args).toContain("agent-1"); + expect(args).toContain("--conv"); + expect(args).toContain("conv-abc"); + expect(args).toContain("--include-partial-messages"); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 4. Waiter while stream is active +// ───────────────────────────────────────────────────────────────────────────── + +describe("listMessages waiter while stream active", () => { + /** + * The key invariant: a listMessages call can be in-flight at the same time + * as a stream is running. The pump should route stream events to stream + * consumers and control_responses to listMessages waiters independently — + * no interference. + */ + test("control_response for listMessages does not discard stream messages", async () => { + const { waiters, route } = makePump(); + + // Register a listMessages waiter + const listPromise = new Promise<{ subtype: string; response?: unknown }>( + (res) => { waiters.set("concurrent_list", res); } + ); + + // Simulate stream events arriving first + const streamEvents = [ + { type: "assistant_message", content: "hello" }, + { type: "tool_call_message" }, + ]; + for (const ev of streamEvents) { + // Stream events are not control_response, so pump returns false + const handled = route(ev as Parameters[0]); + expect(handled).toBe(false); + } + expect(waiters.size).toBe(1); // listMessages waiter still waiting + + // Now the control_response arrives + route({ + type: "control_response", + response: { + subtype: "success", + request_id: "concurrent_list", + response: { messages: [{ id: "m1" }], has_more: false }, + }, + }); + + const resp = await listPromise; + expect(resp.subtype).toBe("success"); + expect(waiters.size).toBe(0); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 5. Close / error waiter cleanup +// ───────────────────────────────────────────────────────────────────────────── + +describe("waiter cleanup on session close", () => { + test("close cancels in-flight listMessages waiter with error", async () => { + const { waiters, closeAll } = makePump(); + + const listPromise = new Promise<{ subtype: string; error?: string }>( + (res) => { waiters.set("inflight_req", res); } + ); + + // Session closes before response arrives + closeAll(); + + const resp = await listPromise; + expect(resp.subtype).toBe("error"); + expect(resp.error).toBe("session closed"); + expect(waiters.size).toBe(0); // map drained + }); + + test("close cancels multiple concurrent waiters", async () => { + const { waiters, closeAll } = makePump(); + + const p1 = new Promise<{ subtype: string; error?: string }>( + (res) => { waiters.set("req_1", res); } + ); + const p2 = new Promise<{ subtype: string; error?: string }>( + (res) => { waiters.set("req_2", res); } + ); + const p3 = new Promise<{ subtype: string; error?: string }>( + (res) => { waiters.set("req_3", res); } + ); + + closeAll(); + + const results = await Promise.all([p1, p2, p3]); + for (const r of results) { + expect(r.subtype).toBe("error"); + expect(r.error).toBe("session closed"); + } + expect(waiters.size).toBe(0); + }); + + test("close is idempotent — second close does not throw", () => { + const { waiters, closeAll } = makePump(); + waiters.set("req", (r) => { void r; }); + + closeAll(); + expect(() => closeAll()).not.toThrow(); // already empty, should be safe + expect(waiters.size).toBe(0); + }); + + test("waiter registered after close is never resolved (guard by caller)", async () => { + // This verifies that if a caller checks initialized before calling listMessages, + // a late waiter registration doesn't silently hang. + // We model this as: waiter registered, then close fires immediately. + const { waiters, closeAll } = makePump(); + + let resolved = false; + const listPromise = new Promise<{ subtype: string; error?: string }>((res) => { + waiters.set("late_req", res); + // Close fires synchronously before any response can arrive + closeAll(); + resolved = true; + }); + + const resp = await listPromise; + expect(resolved).toBe(true); + expect(resp.subtype).toBe("error"); + expect(resp.error).toBe("session closed"); + }); +}); diff --git a/src/tests/transport-args.test.ts b/src/tests/transport-args.test.ts new file mode 100644 index 0000000..e8ebe60 --- /dev/null +++ b/src/tests/transport-args.test.ts @@ -0,0 +1,263 @@ +/** + * Tests for buildCliArgs() — the real production function that builds + * the CLI argument array passed to spawn(). + * + * These tests exercise the actual transport code path, not a local replica. + * SubprocessTransport.buildArgs() is a thin delegation to buildCliArgs(), + * so testing buildCliArgs() directly covers the production spawn args. + */ +import { describe, expect, test } from "bun:test"; +import { buildCliArgs } from "../transport.js"; + +// ───────────────────────────────────────────────────────────────────────────── +// Baseline: every invocation includes these two pairs +// ───────────────────────────────────────────────────────────────────────────── + +describe("buildCliArgs — baseline args", () => { + test("always includes --output-format stream-json and --input-format stream-json", () => { + const args = buildCliArgs({}); + expect(args).toContain("--output-format"); + expect(args).toContain("stream-json"); + expect(args).toContain("--input-format"); + const outIdx = args.indexOf("--output-format"); + expect(args[outIdx + 1]).toBe("stream-json"); + const inIdx = args.indexOf("--input-format"); + expect(args[inIdx + 1]).toBe("stream-json"); + }); + + test("minimum invocation (empty options) produces exactly the two baseline pairs", () => { + const args = buildCliArgs({}); + expect(args).toEqual([ + "--output-format", "stream-json", + "--input-format", "stream-json", + ]); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// includePartialMessages — the flag that started this investigation +// ───────────────────────────────────────────────────────────────────────────── + +describe("buildCliArgs — includePartialMessages", () => { + test("flag absent when includePartialMessages is undefined", () => { + const args = buildCliArgs({ agentId: "agent-1" }); + expect(args).not.toContain("--include-partial-messages"); + }); + + test("flag absent when includePartialMessages is false", () => { + const args = buildCliArgs({ agentId: "agent-1", includePartialMessages: false }); + expect(args).not.toContain("--include-partial-messages"); + }); + + test("flag present when includePartialMessages is true", () => { + const args = buildCliArgs({ agentId: "agent-1", includePartialMessages: true }); + expect(args).toContain("--include-partial-messages"); + }); + + test("flag appears after conversation/agent args, not before", () => { + const args = buildCliArgs({ + agentId: "agent-1", + conversationId: "conv-abc", + includePartialMessages: true, + }); + const convIdx = args.indexOf("--conversation"); + const flagIdx = args.indexOf("--include-partial-messages"); + expect(convIdx).toBeGreaterThan(-1); + expect(flagIdx).toBeGreaterThan(convIdx); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Conversation / agent routing +// ───────────────────────────────────────────────────────────────────────────── + +describe("buildCliArgs — conversation and agent routing", () => { + test("conversationId → --conversation flag (agent auto-derived from conv)", () => { + const args = buildCliArgs({ conversationId: "conv-xyz" }); + const idx = args.indexOf("--conversation"); + expect(idx).toBeGreaterThan(-1); + expect(args[idx + 1]).toBe("conv-xyz"); + expect(args).not.toContain("--agent"); + }); + + test("agentId only → --agent flag, no --new or --default", () => { + const args = buildCliArgs({ agentId: "agent-1" }); + const idx = args.indexOf("--agent"); + expect(idx).toBeGreaterThan(-1); + expect(args[idx + 1]).toBe("agent-1"); + expect(args).not.toContain("--new"); + expect(args).not.toContain("--default"); + }); + + test("agentId + newConversation → --agent + --new", () => { + const args = buildCliArgs({ agentId: "agent-1", newConversation: true }); + expect(args).toContain("--agent"); + expect(args).toContain("--new"); + expect(args).not.toContain("--default"); + }); + + test("agentId + defaultConversation → --agent + --default", () => { + const args = buildCliArgs({ agentId: "agent-1", defaultConversation: true }); + expect(args).toContain("--agent"); + expect(args).toContain("--default"); + expect(args).not.toContain("--new"); + }); + + test("createOnly → --new-agent", () => { + const args = buildCliArgs({ createOnly: true }); + expect(args).toContain("--new-agent"); + }); + + test("newConversation without agentId → --new (LRU agent)", () => { + const args = buildCliArgs({ newConversation: true }); + expect(args).toContain("--new"); + expect(args).not.toContain("--agent"); + }); + + test("conversationId takes priority over agentId (conv wins)", () => { + // When conversationId is set, --conversation is used and --agent is skipped + const args = buildCliArgs({ conversationId: "conv-abc", agentId: "agent-1" }); + expect(args).toContain("--conversation"); + expect(args).not.toContain("--agent"); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Model and embedding +// ───────────────────────────────────────────────────────────────────────────── + +describe("buildCliArgs — model and embedding", () => { + test("model option → -m flag", () => { + const args = buildCliArgs({ model: "claude-sonnet-4" }); + const idx = args.indexOf("-m"); + expect(idx).toBeGreaterThan(-1); + expect(args[idx + 1]).toBe("claude-sonnet-4"); + }); + + test("no model → no -m flag", () => { + const args = buildCliArgs({}); + expect(args).not.toContain("-m"); + }); + + test("embedding option → --embedding flag", () => { + const args = buildCliArgs({ embedding: "text-embedding-3" }); + const idx = args.indexOf("--embedding"); + expect(idx).toBeGreaterThan(-1); + expect(args[idx + 1]).toBe("text-embedding-3"); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Permission mode +// ───────────────────────────────────────────────────────────────────────────── + +describe("buildCliArgs — permission mode", () => { + test("bypassPermissions → --yolo", () => { + const args = buildCliArgs({ permissionMode: "bypassPermissions" }); + expect(args).toContain("--yolo"); + expect(args).not.toContain("--permission-mode"); + }); + + test("other non-default modes → --permission-mode ", () => { + const args = buildCliArgs({ permissionMode: "acceptEdits" }); + const idx = args.indexOf("--permission-mode"); + expect(idx).toBeGreaterThan(-1); + expect(args[idx + 1]).toBe("acceptEdits"); + }); + + test("default mode → no permission flag", () => { + const args = buildCliArgs({ permissionMode: "default" }); + expect(args).not.toContain("--permission-mode"); + expect(args).not.toContain("--yolo"); + }); + + test("no permissionMode → no permission flag", () => { + const args = buildCliArgs({}); + expect(args).not.toContain("--permission-mode"); + expect(args).not.toContain("--yolo"); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// System prompt +// ───────────────────────────────────────────────────────────────────────────── + +describe("buildCliArgs — system prompt", () => { + test("preset string → --system ", () => { + const args = buildCliArgs({ systemPrompt: "letta-claude" }); + const idx = args.indexOf("--system"); + expect(idx).toBeGreaterThan(-1); + expect(args[idx + 1]).toBe("letta-claude"); + }); + + test("custom string → --system-custom ", () => { + const args = buildCliArgs({ systemPrompt: "you are a helpful bot" }); + const idx = args.indexOf("--system-custom"); + expect(idx).toBeGreaterThan(-1); + expect(args[idx + 1]).toBe("you are a helpful bot"); + expect(args).not.toContain("--system"); + }); + + test("preset object → --system + --system-append ", () => { + const args = buildCliArgs({ + systemPrompt: { type: "preset", preset: "default", append: "extra context" }, + }); + expect(args).toContain("--system"); + expect(args).toContain("default"); + expect(args).toContain("--system-append"); + expect(args).toContain("extra context"); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Tools and tags +// ───────────────────────────────────────────────────────────────────────────── + +describe("buildCliArgs — tools and tags", () => { + test("allowedTools → --allowedTools joined with comma", () => { + const args = buildCliArgs({ allowedTools: ["Read", "Write", "Bash"] }); + const idx = args.indexOf("--allowedTools"); + expect(args[idx + 1]).toBe("Read,Write,Bash"); + }); + + test("disallowedTools → --disallowedTools joined with comma", () => { + const args = buildCliArgs({ disallowedTools: ["EnterPlanMode"] }); + const idx = args.indexOf("--disallowedTools"); + expect(args[idx + 1]).toBe("EnterPlanMode"); + }); + + test("tags → --tags joined with comma", () => { + const args = buildCliArgs({ tags: ["production", "v2"] }); + const idx = args.indexOf("--tags"); + expect(args[idx + 1]).toBe("production,v2"); + }); + + test("empty tags array → no --tags flag", () => { + const args = buildCliArgs({ tags: [] }); + expect(args).not.toContain("--tags"); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Memory filesystem +// ───────────────────────────────────────────────────────────────────────────── + +describe("buildCliArgs — memfs", () => { + test("memfs=true → --memfs", () => { + const args = buildCliArgs({ memfs: true }); + expect(args).toContain("--memfs"); + expect(args).not.toContain("--no-memfs"); + }); + + test("memfs=false → --no-memfs", () => { + const args = buildCliArgs({ memfs: false }); + expect(args).toContain("--no-memfs"); + expect(args).not.toContain("--memfs"); + }); + + test("memfs undefined → neither flag", () => { + const args = buildCliArgs({}); + expect(args).not.toContain("--memfs"); + expect(args).not.toContain("--no-memfs"); + }); +}); diff --git a/src/transport.ts b/src/transport.ts index cf786bf..658e01a 100644 --- a/src/transport.ts +++ b/src/transport.ts @@ -13,6 +13,177 @@ function sdkLog(tag: string, ...args: unknown[]) { if (process.env.DEBUG_SDK) console.error(`[SDK-Transport] [${tag}]`, ...args); } +/** + * Build the CLI argument array for a given set of session options. + * + * Exported as a pure function for testing — this IS the real production code + * path. SubprocessTransport.buildArgs() delegates here. + */ +export function buildCliArgs(options: InternalSessionOptions): string[] { + const args: string[] = [ + "--output-format", + "stream-json", + "--input-format", + "stream-json", + ]; + + // Conversation and agent handling + if (options.conversationId) { + args.push("--conversation", options.conversationId); + } else if (options.agentId) { + args.push("--agent", options.agentId); + if (options.newConversation) { + args.push("--new"); + } else if (options.defaultConversation) { + args.push("--default"); + } + } else if (options.createOnly) { + args.push("--new-agent"); + } else if (options.newConversation) { + args.push("--new"); + } + + // Model + if (options.model) { + args.push("-m", options.model); + } + + // Partial message streaming + if (options.includePartialMessages) { + args.push("--include-partial-messages"); + } + + // Embedding model + if (options.embedding) { + args.push("--embedding", options.embedding); + } + + // System prompt configuration + if (options.systemPrompt !== undefined) { + if (typeof options.systemPrompt === "string") { + const validPresets = [ + "default", + "letta-claude", + "letta-codex", + "letta-gemini", + "claude", + "codex", + "gemini", + ]; + if (validPresets.includes(options.systemPrompt)) { + args.push("--system", options.systemPrompt); + } else { + args.push("--system-custom", options.systemPrompt); + } + } else { + args.push("--system", options.systemPrompt.preset); + if (options.systemPrompt.append) { + args.push("--system-append", options.systemPrompt.append); + } + } + } + + // Memory blocks (only for new agents) + if (options.memory !== undefined && !options.agentId) { + if (options.memory.length === 0) { + args.push("--init-blocks", ""); + } else { + const presetNames: string[] = []; + const memoryBlocksJson: Array< + | { label: string; value: string } + | { blockId: string } + > = []; + + for (const item of options.memory) { + if (typeof item === "string") { + presetNames.push(item); + } else if ("blockId" in item) { + memoryBlocksJson.push(item as { blockId: string }); + } else { + memoryBlocksJson.push(item as { label: string; value: string }); + } + } + + if (memoryBlocksJson.length > 0) { + args.push("--memory-blocks", JSON.stringify(memoryBlocksJson)); + if (presetNames.length > 0) { + console.warn( + "[letta-code-sdk] Using custom memory blocks. " + + `Preset blocks are ignored when custom blocks are provided: ${presetNames.join(", ")}` + ); + } + } else if (presetNames.length > 0) { + args.push("--init-blocks", presetNames.join(",")); + } + } + } + + // Convenience props for block values (only for new agents) + if (!options.agentId) { + if (options.persona !== undefined) { + args.push("--block-value", `persona=${options.persona}`); + } + if (options.human !== undefined) { + args.push("--block-value", `human=${options.human}`); + } + } + + // Permission mode + if (options.permissionMode === "bypassPermissions") { + args.push("--yolo"); + } else if (options.permissionMode && options.permissionMode !== "default") { + args.push("--permission-mode", options.permissionMode); + } + + // Allowed / disallowed tools + if (options.allowedTools) { + args.push("--allowedTools", options.allowedTools.join(",")); + } + if (options.disallowedTools) { + args.push("--disallowedTools", options.disallowedTools.join(",")); + } + + // Tags + if (options.tags && options.tags.length > 0) { + args.push("--tags", options.tags.join(",")); + } + + // Memory filesystem + if (options.memfs === true) { + args.push("--memfs"); + } else if (options.memfs === false) { + args.push("--no-memfs"); + } + + // Skills sources + if (options.skillSources !== undefined) { + const sources = [...new Set(options.skillSources)]; + if (sources.length === 0) { + args.push("--no-skills"); + } else { + args.push("--skill-sources", sources.join(",")); + } + } + + // Session context reminder toggle + if (options.systemInfoReminder === false) { + args.push("--no-system-info-reminder"); + } + + // Sleeptime / reflection settings + if (options.sleeptime?.trigger !== undefined) { + args.push("--reflection-trigger", options.sleeptime.trigger); + } + if (options.sleeptime?.behavior !== undefined) { + args.push("--reflection-behavior", options.sleeptime.behavior); + } + if (options.sleeptime?.stepCount !== undefined) { + args.push("--reflection-step-count", String(options.sleeptime.stepCount)); + } + + return args; +} + export class SubprocessTransport { private process: ChildProcess | null = null; private stdout: Interface | null = null; @@ -218,199 +389,11 @@ export class SubprocessTransport { } private buildArgs(): string[] { - const args: string[] = [ - "--output-format", - "stream-json", - "--input-format", - "stream-json", - ]; - - // Note: All validation happens in validateInternalSessionOptions() called from Session constructor - - // Conversation and agent handling - if (this.options.conversationId) { - // Resume specific conversation (derives agent automatically) - args.push("--conversation", this.options.conversationId); - } else if (this.options.agentId) { - // Resume existing agent - args.push("--agent", this.options.agentId); - if (this.options.newConversation) { - // Create new conversation on this agent - args.push("--new"); - } else if (this.options.defaultConversation) { - // Use agent's default conversation explicitly - args.push("--default"); - } - } else if (this.options.createOnly) { - // createAgent() - explicitly create new agent - args.push("--new-agent"); - } else if (this.options.newConversation) { - // createSession() without agentId - LRU agent + new conversation - args.push("--new"); - } - // else: no agent flags = default behavior (LRU agent, default conversation) - - // Model - if (this.options.model) { - args.push("-m", this.options.model); - } - - // Partial message streaming (token-level stream_event chunks) - if (this.options.includePartialMessages) { - args.push("--include-partial-messages"); - } - - // Embedding model - if (this.options.embedding) { - args.push("--embedding", this.options.embedding); - } - - // System prompt configuration - if (this.options.systemPrompt !== undefined) { - if (typeof this.options.systemPrompt === "string") { - // Check if it's a valid preset name or custom string - const validPresets = [ - "default", - "letta-claude", - "letta-codex", - "letta-gemini", - "claude", - "codex", - "gemini", - ]; - if (validPresets.includes(this.options.systemPrompt)) { - // Preset name → --system - args.push("--system", this.options.systemPrompt); - } else { - // Custom string → --system-custom - args.push("--system-custom", this.options.systemPrompt); - } - } else { - // Preset object → --system (+ optional --system-append) - args.push("--system", this.options.systemPrompt.preset); - if (this.options.systemPrompt.append) { - args.push("--system-append", this.options.systemPrompt.append); - } - } - } - - // Memory blocks (only for new agents) - if (this.options.memory !== undefined && !this.options.agentId) { - if (this.options.memory.length === 0) { - // Empty array → no memory blocks (just core) - args.push("--init-blocks", ""); - } else { - // Separate preset names from custom/reference blocks - const presetNames: string[] = []; - const memoryBlocksJson: Array< - | { label: string; value: string } - | { blockId: string } - > = []; - - for (const item of this.options.memory) { - if (typeof item === "string") { - // Preset name - presetNames.push(item); - } else if ("blockId" in item) { - // Block reference - pass to --memory-blocks - memoryBlocksJson.push(item as { blockId: string }); - } else { - // CreateBlock - memoryBlocksJson.push(item as { label: string; value: string }); - } - } - - // NOTE: When custom blocks are provided via --memory-blocks, they define the complete - // memory configuration. Preset blocks (--init-blocks) cannot be mixed with custom blocks. - if (memoryBlocksJson.length > 0) { - // Use custom blocks only - args.push("--memory-blocks", JSON.stringify(memoryBlocksJson)); - if (presetNames.length > 0) { - console.warn( - "[letta-code-sdk] Using custom memory blocks. " + - `Preset blocks are ignored when custom blocks are provided: ${presetNames.join(", ")}` - ); - } - } else if (presetNames.length > 0) { - // Use presets only - args.push("--init-blocks", presetNames.join(",")); - } - } - } - - // Convenience props for block values (only for new agents) - if (!this.options.agentId) { - if (this.options.persona !== undefined) { - args.push("--block-value", `persona=${this.options.persona}`); - } - if (this.options.human !== undefined) { - args.push("--block-value", `human=${this.options.human}`); - } - } - - // Permission mode - if (this.options.permissionMode === "bypassPermissions") { - // Keep using alias for backwards compatibility - args.push("--yolo"); - } else if ( - this.options.permissionMode && - this.options.permissionMode !== "default" - ) { - args.push("--permission-mode", this.options.permissionMode); - } - - // Allowed tools - if (this.options.allowedTools) { - args.push("--allowedTools", this.options.allowedTools.join(",")); - } - if (this.options.disallowedTools) { - args.push("--disallowedTools", this.options.disallowedTools.join(",")); - } - - // Tags - if (this.options.tags && this.options.tags.length > 0) { - args.push("--tags", this.options.tags.join(",")); - } - - // Memory filesystem - if (this.options.memfs === true) { - args.push("--memfs"); - } else if (this.options.memfs === false) { - args.push("--no-memfs"); - } - - // Skills sources - if (this.options.skillSources !== undefined) { - const sources = [...new Set(this.options.skillSources)]; - if (sources.length === 0) { - args.push("--no-skills"); - } else { - args.push("--skill-sources", sources.join(",")); - } - } - - // Session context reminder toggle - if (this.options.systemInfoReminder === false) { - args.push("--no-system-info-reminder"); - } - - // Sleeptime/reflection settings - if (this.options.sleeptime?.trigger !== undefined) { - args.push("--reflection-trigger", this.options.sleeptime.trigger); - } - if (this.options.sleeptime?.behavior !== undefined) { - args.push("--reflection-behavior", this.options.sleeptime.behavior); - } - if (this.options.sleeptime?.stepCount !== undefined) { - args.push( - "--reflection-step-count", - String(this.options.sleeptime.stepCount), - ); - } - - return args; + return buildCliArgs(this.options); } + + private async findCli(): Promise { // Try multiple resolution strategies const { existsSync } = await import("node:fs"); diff --git a/src/types.ts b/src/types.ts index 08ba141..a6fc96e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -492,6 +492,38 @@ export type SDKMessage = | SDKResultMessage | SDKStreamEventMessage; +// ═══════════════════════════════════════════════════════════════ +// LIST MESSAGES API +// ═══════════════════════════════════════════════════════════════ + +/** + * Options for session.listMessages(). + */ +export interface ListMessagesOptions { + /** Explicit conversation ID (e.g. "conv-123"). If omitted, uses agent default. */ + conversationId?: string; + /** Return messages before this message ID (cursor for older pages). */ + before?: string; + /** Return messages after this message ID (cursor for newer pages). */ + after?: string; + /** Sort order. Defaults to "desc" (newest first). */ + order?: "asc" | "desc"; + /** Max messages per page. Defaults to 50. */ + limit?: number; +} + +/** + * Result from session.listMessages(). + * `messages` are raw Letta API message objects in the requested order. + */ +export interface ListMessagesResult { + messages: unknown[]; + /** ID of the oldest message in this page; use as `before` for the next page. */ + nextBefore?: string | null; + /** Whether more pages exist in the requested direction. */ + hasMore?: boolean; +} + // ═══════════════════════════════════════════════════════════════ // EXTERNAL TOOL PROTOCOL TYPES // ═══════════════════════════════════════════════════════════════