feat: add session listMessages + transport args hardening (#48)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-02-22 17:49:26 -08:00
committed by GitHub
parent 3c78e116d7
commit 39704aa8ea
6 changed files with 958 additions and 191 deletions

View File

@@ -59,6 +59,9 @@ export type {
ImageContent,
MessageContentItem,
SendMessage,
// List messages API
ListMessagesOptions,
ListMessagesResult,
// Tool types
AgentTool,
AgentToolResult,

View File

@@ -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<void> | 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<ListMessagesResult> {
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
*/

View File

@@ -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<typeof route>[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");
});
});

View File

@@ -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 <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 <name>", () => {
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 <text>", () => {
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 <preset> + --system-append <text>", () => {
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");
});
});

View File

@@ -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<string> {
// Try multiple resolution strategies
const { existsSync } = await import("node:fs");

View File

@@ -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
// ═══════════════════════════════════════════════════════════════