feat: add session listMessages + transport args hardening (#48)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -59,6 +59,9 @@ export type {
|
||||
ImageContent,
|
||||
MessageContentItem,
|
||||
SendMessage,
|
||||
// List messages API
|
||||
ListMessagesOptions,
|
||||
ListMessagesResult,
|
||||
// Tool types
|
||||
AgentTool,
|
||||
AgentToolResult,
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
396
src/tests/list-messages.test.ts
Normal file
396
src/tests/list-messages.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
263
src/tests/transport-args.test.ts
Normal file
263
src/tests/transport-args.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
365
src/transport.ts
365
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<string> {
|
||||
// Try multiple resolution strategies
|
||||
const { existsSync } = await import("node:fs");
|
||||
|
||||
32
src/types.ts
32
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
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user