feat: headless list messages (#1095)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-02-22 17:38:29 -08:00
committed by GitHub
parent 35812d5b39
commit 7c9e1b1623
6 changed files with 995 additions and 1 deletions

View File

@@ -0,0 +1,156 @@
/**
* Extracted handler for the list_messages control request.
*
* Returns a ControlResponse object (caller does console.log + JSON.stringify).
* Accepting a minimal client interface makes the handler fully testable with
* mock objects — no real network or process required.
*/
import { randomUUID } from "node:crypto";
import type {
ControlResponse,
ListMessagesControlRequest,
ListMessagesResponsePayload,
} from "../types/protocol";
import { resolveListMessagesRoute } from "./listMessagesRouting";
// ─────────────────────────────────────────────────────────────────────────────
// Minimal client interface — only what the handler needs
// ─────────────────────────────────────────────────────────────────────────────
export interface ConversationsMessagesPage {
getPaginatedItems(): unknown[];
}
export interface AgentsMessagesPage {
items: unknown[];
}
export interface ListMessagesHandlerClient {
conversations: {
messages: {
list(
conversationId: string,
opts: {
limit: number;
order: "asc" | "desc";
before?: string;
after?: string;
},
): Promise<ConversationsMessagesPage>;
};
};
agents: {
messages: {
list(
agentId: string,
opts: {
limit: number;
order: "asc" | "desc";
before?: string;
after?: string;
},
): Promise<AgentsMessagesPage>;
};
};
}
export interface HandleListMessagesParams {
listReq: ListMessagesControlRequest;
/** Session's current resolved conversationId ("default" or a real conv id). */
sessionConversationId: string;
/** Session's agentId — used as fallback for the agents path. */
sessionAgentId: string;
sessionId: string;
requestId: string;
client: ListMessagesHandlerClient;
}
// ─────────────────────────────────────────────────────────────────────────────
// Handler
// ─────────────────────────────────────────────────────────────────────────────
/**
* Execute a list_messages control request and return the ControlResponse.
*
* Caller is responsible for serialising + writing to stdout:
* console.log(JSON.stringify(await handleListMessages(params)));
*/
export async function handleListMessages(
params: HandleListMessagesParams,
): Promise<ControlResponse> {
const {
listReq,
sessionConversationId,
sessionAgentId,
sessionId,
requestId,
client,
} = params;
const limit = listReq.limit ?? 50;
const order = listReq.order ?? "desc";
const cursorOpts = {
...(listReq.before ? { before: listReq.before } : {}),
...(listReq.after ? { after: listReq.after } : {}),
};
try {
let items: unknown[];
const route = resolveListMessagesRoute(
listReq,
sessionConversationId,
sessionAgentId,
);
if (route.kind === "conversations") {
const page = await client.conversations.messages.list(
route.conversationId,
{ limit, order, ...cursorOpts },
);
items = page.getPaginatedItems();
} else {
const page = await client.agents.messages.list(route.agentId, {
limit,
order,
...cursorOpts,
});
items = page.items;
}
const hasMore = items.length >= limit;
const oldestId =
items.length > 0
? (items[items.length - 1] as { id?: string })?.id
: undefined;
const payload: ListMessagesResponsePayload = {
messages: items,
next_before: oldestId ?? null,
has_more: hasMore,
};
return {
type: "control_response",
response: {
subtype: "success",
request_id: requestId,
response: payload as unknown as Record<string, unknown>,
},
session_id: sessionId,
uuid: randomUUID(),
};
} catch (err) {
return {
type: "control_response",
response: {
subtype: "error",
request_id: requestId,
error: err instanceof Error ? err.message : "list_messages failed",
},
session_id: sessionId,
uuid: randomUUID(),
};
}
}

View File

@@ -0,0 +1,41 @@
/**
* Pure routing function for the list_messages control request.
*
* Extracted from headless.ts so it can be tested in isolation without
* spinning up a real Letta client.
*
* Routing rules (in priority order):
* 1. Explicit `conversation_id` in the request → conversations.messages.list
* 2. Session is on a named conversation (not "default") → conversations.messages.list
* 3. Session is on the default conversation → agents.messages.list
*/
import type { ListMessagesControlRequest } from "../types/protocol";
export type ListMessagesRoute =
| { kind: "conversations"; conversationId: string }
| { kind: "agents"; agentId: string };
/**
* Resolve which Letta API endpoint to call for a list_messages request.
*
* @param listReq The inbound control request (partial — only conv/agent id used)
* @param sessionConvId The session's current conversationId (already resolved
* at session init, either "default" or a real conv id)
* @param sessionAgentId The session's agentId (fallback when using agents path)
*/
export function resolveListMessagesRoute(
listReq: Pick<ListMessagesControlRequest, "conversation_id" | "agent_id">,
sessionConvId: string,
sessionAgentId: string,
): ListMessagesRoute {
const targetConvId = listReq.conversation_id ?? sessionConvId;
if (targetConvId !== "default") {
return { kind: "conversations", conversationId: targetConvId };
}
// Session is on the agent's default conversation —
// use request's agent_id if supplied (e.g. explicit override), else session's
return { kind: "agents", agentId: listReq.agent_id ?? sessionAgentId };
}

View File

@@ -21,6 +21,7 @@ import {
import { getClient } from "./agent/client";
import { setAgentContext, setConversationId } from "./agent/context";
import { createAgent } from "./agent/create";
import { handleListMessages } from "./agent/listMessagesHandler";
import { ISOLATED_BLOCK_LABELS } from "./agent/memory";
import { getStreamToolContextId, sendMessageStream } from "./agent/message";
import { getModelUpdateArgs } from "./agent/model";
@@ -81,6 +82,7 @@ import type {
ControlRequest,
ControlResponse,
ErrorMessage,
ListMessagesControlRequest,
MessageWire,
RecoveryMessage,
ResultMessage,
@@ -2707,6 +2709,17 @@ async function runBidirectionalMode(
uuid: randomUUID(),
};
console.log(JSON.stringify(registerResponse));
} else if (subtype === "list_messages") {
const listReq = message.request as ListMessagesControlRequest;
const listResp = await handleListMessages({
listReq,
sessionConversationId: conversationId,
sessionAgentId: agent.id,
sessionId,
requestId: requestId ?? "",
client,
});
console.log(JSON.stringify(listResp));
} else {
const errorResponse: ControlResponse = {
type: "control_response",

View File

@@ -0,0 +1,433 @@
/**
* Handler-level tests for list_messages using mock Letta clients.
*
* These tests call handleListMessages() directly with mock implementations
* of conversations.messages.list and agents.messages.list. They verify:
*
* 1. Which client method is called for each routing case (explicit conv,
* omitted+named session conv, omitted+default session conv)
* 2. The arguments passed to each client method
* 3. The shape and content of the returned ControlResponse
* 4. Error path — client throws, handler returns error envelope
*
* No network. No CLI subprocess. No process.stdout.
*/
import { describe, expect, mock, test } from "bun:test";
import type { ListMessagesHandlerClient } from "../../agent/listMessagesHandler";
import { handleListMessages } from "../../agent/listMessagesHandler";
// ─────────────────────────────────────────────────────────────────────────────
// Mock factory
// ─────────────────────────────────────────────────────────────────────────────
function makeClient(
convMessages: unknown[] = [],
agentMessages: unknown[] = [],
): {
client: ListMessagesHandlerClient;
convListSpy: ReturnType<typeof mock>;
agentListSpy: ReturnType<typeof mock>;
} {
const convListSpy = mock(async () => ({
getPaginatedItems: () => convMessages,
}));
const agentListSpy = mock(async () => ({
items: agentMessages,
}));
const client: ListMessagesHandlerClient = {
conversations: {
messages: {
list: convListSpy as unknown as ListMessagesHandlerClient["conversations"]["messages"]["list"],
},
},
agents: {
messages: {
list: agentListSpy as unknown as ListMessagesHandlerClient["agents"]["messages"]["list"],
},
},
};
return { client, convListSpy, agentListSpy };
}
const BASE = {
sessionId: "sess-test",
requestId: "req-test-001",
} as const;
// ─────────────────────────────────────────────────────────────────────────────
// Routing: which client method is called
// ─────────────────────────────────────────────────────────────────────────────
describe("handleListMessages — routing (which API is called)", () => {
test("explicit conversation_id → calls conversations.messages.list with that id", async () => {
const { client, convListSpy, agentListSpy } = makeClient([{ id: "m1" }]);
const resp = await handleListMessages({
...BASE,
listReq: { subtype: "list_messages", conversation_id: "conv-explicit" },
sessionConversationId: "default",
sessionAgentId: "agent-session",
client,
});
expect(convListSpy).toHaveBeenCalledTimes(1);
expect(agentListSpy).toHaveBeenCalledTimes(0);
expect(convListSpy.mock.calls[0]?.[0]).toBe("conv-explicit");
expect(resp.response.subtype).toBe("success");
});
test("explicit conversation_id overrides a non-default session conv", async () => {
const { client, convListSpy } = makeClient([]);
await handleListMessages({
...BASE,
listReq: { subtype: "list_messages", conversation_id: "conv-override" },
sessionConversationId: "conv-session-other",
sessionAgentId: "agent-session",
client,
});
expect(convListSpy.mock.calls[0]?.[0]).toBe("conv-override");
});
test("omitted conversation_id + named session conv → calls conversations.messages.list with session conv", async () => {
const { client, convListSpy, agentListSpy } = makeClient([
{ id: "msg-A" },
{ id: "msg-B" },
]);
const resp = await handleListMessages({
...BASE,
listReq: { subtype: "list_messages" }, // no conversation_id
sessionConversationId: "conv-session-xyz",
sessionAgentId: "agent-session",
client,
});
expect(convListSpy).toHaveBeenCalledTimes(1);
expect(agentListSpy).toHaveBeenCalledTimes(0);
expect(convListSpy.mock.calls[0]?.[0]).toBe("conv-session-xyz");
expect(resp.response.subtype).toBe("success");
if (resp.response.subtype === "success") {
const payload = resp.response.response as {
messages: unknown[];
has_more: boolean;
};
expect(payload.messages).toHaveLength(2);
}
});
test("omitted conversation_id + session on default → calls agents.messages.list", async () => {
const { client, convListSpy, agentListSpy } = makeClient(
[],
[{ id: "msg-default-1" }],
);
const resp = await handleListMessages({
...BASE,
listReq: { subtype: "list_messages" }, // no conversation_id
sessionConversationId: "default",
sessionAgentId: "agent-def",
client,
});
expect(agentListSpy).toHaveBeenCalledTimes(1);
expect(convListSpy).toHaveBeenCalledTimes(0);
expect(agentListSpy.mock.calls[0]?.[0]).toBe("agent-def");
expect(resp.response.subtype).toBe("success");
});
test("explicit agent_id + session default → agents path uses request agent_id", async () => {
const { client, agentListSpy } = makeClient([], []);
await handleListMessages({
...BASE,
listReq: { subtype: "list_messages", agent_id: "agent-override" },
sessionConversationId: "default",
sessionAgentId: "agent-session",
client,
});
expect(agentListSpy.mock.calls[0]?.[0]).toBe("agent-override");
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Args forwarding: limit, order, cursor options
// ─────────────────────────────────────────────────────────────────────────────
describe("handleListMessages — API call arguments", () => {
test("passes limit and order to conversations path", async () => {
const { client, convListSpy } = makeClient([]);
await handleListMessages({
...BASE,
listReq: {
subtype: "list_messages",
conversation_id: "conv-1",
limit: 25,
order: "asc",
},
sessionConversationId: "default",
sessionAgentId: "agent-1",
client,
});
const opts = convListSpy.mock.calls[0]?.[1] as {
limit: number;
order: string;
};
expect(opts.limit).toBe(25);
expect(opts.order).toBe("asc");
});
test("defaults to limit=50 and order=desc when not specified", async () => {
const { client, agentListSpy } = makeClient([], []);
await handleListMessages({
...BASE,
listReq: { subtype: "list_messages" },
sessionConversationId: "default",
sessionAgentId: "agent-1",
client,
});
const opts = agentListSpy.mock.calls[0]?.[1] as {
limit: number;
order: string;
};
expect(opts.limit).toBe(50);
expect(opts.order).toBe("desc");
});
test("forwards before cursor to conversations path", async () => {
const { client, convListSpy } = makeClient([]);
await handleListMessages({
...BASE,
listReq: {
subtype: "list_messages",
conversation_id: "conv-1",
before: "msg-cursor",
},
sessionConversationId: "default",
sessionAgentId: "agent-1",
client,
});
const opts = convListSpy.mock.calls[0]?.[1] as { before?: string };
expect(opts.before).toBe("msg-cursor");
});
test("forwards before cursor to agents path", async () => {
const { client, agentListSpy } = makeClient([], []);
await handleListMessages({
...BASE,
listReq: { subtype: "list_messages", before: "msg-cursor-agents" },
sessionConversationId: "default",
sessionAgentId: "agent-1",
client,
});
const opts = agentListSpy.mock.calls[0]?.[1] as { before?: string };
expect(opts.before).toBe("msg-cursor-agents");
});
test("does not include before/after when absent", async () => {
const { client, convListSpy } = makeClient([]);
await handleListMessages({
...BASE,
listReq: { subtype: "list_messages", conversation_id: "conv-1" },
sessionConversationId: "default",
sessionAgentId: "agent-1",
client,
});
const opts = convListSpy.mock.calls[0]?.[1] as Record<string, unknown>;
expect(opts.before).toBeUndefined();
expect(opts.after).toBeUndefined();
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Response shape
// ─────────────────────────────────────────────────────────────────────────────
describe("handleListMessages — response shape", () => {
test("success response has correct envelope", async () => {
const msgs = [{ id: "m1" }, { id: "m2" }];
const { client } = makeClient(msgs);
const resp = await handleListMessages({
...BASE,
listReq: { subtype: "list_messages", conversation_id: "conv-1" },
sessionConversationId: "default",
sessionAgentId: "agent-1",
client,
});
expect(resp.type).toBe("control_response");
expect(resp.session_id).toBe(BASE.sessionId);
expect(resp.response.subtype).toBe("success");
expect(resp.response.request_id).toBe(BASE.requestId);
if (resp.response.subtype === "success") {
const payload = resp.response.response as {
messages: unknown[];
has_more: boolean;
next_before: string | null;
};
expect(payload.messages).toHaveLength(2);
expect(payload.has_more).toBe(false); // 2 < 50
expect(payload.next_before).toBe("m2"); // last item id
}
});
test("has_more is true when result length equals limit", async () => {
const msgs = Array.from({ length: 50 }, (_, i) => ({ id: `m${i}` }));
const { client } = makeClient(msgs);
const resp = await handleListMessages({
...BASE,
listReq: {
subtype: "list_messages",
conversation_id: "conv-1",
limit: 50,
},
sessionConversationId: "default",
sessionAgentId: "agent-1",
client,
});
if (resp.response.subtype === "success") {
const payload = resp.response.response as { has_more: boolean };
expect(payload.has_more).toBe(true);
}
});
test("next_before is null when result is empty", async () => {
const { client } = makeClient([]);
const resp = await handleListMessages({
...BASE,
listReq: { subtype: "list_messages", conversation_id: "conv-1" },
sessionConversationId: "default",
sessionAgentId: "agent-1",
client,
});
if (resp.response.subtype === "success") {
const payload = resp.response.response as { next_before: null };
expect(payload.next_before).toBeNull();
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Error path
// ─────────────────────────────────────────────────────────────────────────────
describe("handleListMessages — error path", () => {
test("client Error → error control_response with message", async () => {
const convListSpy = mock(async () => {
throw new Error("404 conversation not found");
});
const agentListSpy = mock(async () => ({ items: [] }));
const client: ListMessagesHandlerClient = {
conversations: {
messages: {
list: convListSpy as unknown as ListMessagesHandlerClient["conversations"]["messages"]["list"],
},
},
agents: {
messages: {
list: agentListSpy as unknown as ListMessagesHandlerClient["agents"]["messages"]["list"],
},
},
};
const resp = await handleListMessages({
...BASE,
listReq: { subtype: "list_messages", conversation_id: "conv-bad" },
sessionConversationId: "default",
sessionAgentId: "agent-1",
client,
});
expect(resp.response.subtype).toBe("error");
if (resp.response.subtype === "error") {
expect(resp.response.error).toContain("404 conversation not found");
expect(resp.response.request_id).toBe(BASE.requestId);
}
});
test("non-Error throw → generic fallback message", async () => {
const convListSpy = mock(async () => {
throw "string error";
});
const client: ListMessagesHandlerClient = {
conversations: {
messages: {
list: convListSpy as unknown as ListMessagesHandlerClient["conversations"]["messages"]["list"],
},
},
agents: {
messages: {
list: mock(async () => ({
items: [],
})) as unknown as ListMessagesHandlerClient["agents"]["messages"]["list"],
},
},
};
const resp = await handleListMessages({
...BASE,
listReq: { subtype: "list_messages", conversation_id: "conv-1" },
sessionConversationId: "default",
sessionAgentId: "agent-1",
client,
});
if (resp.response.subtype === "error") {
expect(resp.response.error).toBe("list_messages failed");
}
});
test("agents path error → error envelope with correct session_id", async () => {
const agentListSpy = mock(async () => {
throw new Error("agent unavailable");
});
const client: ListMessagesHandlerClient = {
conversations: {
messages: {
list: mock(async () => ({
getPaginatedItems: () => [],
})) as unknown as ListMessagesHandlerClient["conversations"]["messages"]["list"],
},
},
agents: {
messages: {
list: agentListSpy as unknown as ListMessagesHandlerClient["agents"]["messages"]["list"],
},
},
};
const resp = await handleListMessages({
sessionId: "my-session",
requestId: "req-err",
listReq: { subtype: "list_messages" },
sessionConversationId: "default",
sessionAgentId: "agent-1",
client,
});
expect(resp.session_id).toBe("my-session");
expect(resp.response.subtype).toBe("error");
if (resp.response.subtype === "error") {
expect(resp.response.error).toBe("agent unavailable");
}
});
});

View File

@@ -0,0 +1,320 @@
/**
* Tests for list_messages protocol — types, routing semantics, and
* error-response construction.
*
* Organised in three suites:
*
* 1. Type / wire-shape tests — pure TypeScript structural checks.
* 2. Routing semantics — tests against resolveListMessagesRoute() covering
* all four meaningful input combinations.
* 3. Error propagation — verify error control_response envelope shape and
* message extraction.
*
* No live Letta API calls are made here; those are in the manual smoke suite.
*/
import { describe, expect, test } from "bun:test";
import { resolveListMessagesRoute } from "../../agent/listMessagesRouting";
import type {
ControlRequest,
ControlResponse,
ListMessagesControlRequest,
ListMessagesResponsePayload,
} from "../../types/protocol";
// ─────────────────────────────────────────────────────────────────────────────
// 1. Wire-shape / type tests
// ─────────────────────────────────────────────────────────────────────────────
describe("list_messages protocol — wire shapes", () => {
test("ListMessagesControlRequest accepts all optional fields", () => {
const req: ListMessagesControlRequest = {
subtype: "list_messages",
conversation_id: "conv-123",
before: "msg-abc",
order: "desc",
limit: 50,
};
expect(req.subtype).toBe("list_messages");
expect(req.conversation_id).toBe("conv-123");
expect(req.limit).toBe(50);
});
test("ListMessagesControlRequest works with only agent_id (empty request body)", () => {
const req: ListMessagesControlRequest = {
subtype: "list_messages",
agent_id: "agent-xyz",
limit: 20,
};
expect(req.agent_id).toBe("agent-xyz");
expect(req.conversation_id).toBeUndefined();
});
test("minimal ListMessagesControlRequest (no fields) is valid", () => {
const req: ListMessagesControlRequest = { subtype: "list_messages" };
expect(req.subtype).toBe("list_messages");
expect(req.conversation_id).toBeUndefined();
expect(req.agent_id).toBeUndefined();
});
test("ListMessagesControlRequest assembles into ControlRequest envelope", () => {
const body: ListMessagesControlRequest = { subtype: "list_messages" };
const req: ControlRequest = {
type: "control_request",
request_id: "list_1739999999999",
request: body,
};
expect(req.type).toBe("control_request");
expect(req.request_id).toBe("list_1739999999999");
});
test("ListMessagesResponsePayload success shape", () => {
const payload: ListMessagesResponsePayload = {
messages: [{ id: "msg-1", message_type: "user_message" }],
next_before: "msg-1",
has_more: false,
};
expect(payload.messages).toHaveLength(1);
expect(payload.has_more).toBe(false);
expect(payload.next_before).toBe("msg-1");
});
test("ListMessagesResponsePayload empty page", () => {
const payload: ListMessagesResponsePayload = {
messages: [],
next_before: null,
has_more: false,
};
expect(payload.messages).toHaveLength(0);
expect(payload.next_before).toBeNull();
});
test("success control_response wraps list payload correctly", () => {
const payload: ListMessagesResponsePayload = {
messages: [],
next_before: null,
has_more: false,
};
const resp: ControlResponse = {
type: "control_response",
session_id: "session-1",
uuid: "uuid-1",
response: {
subtype: "success",
request_id: "list_1739999999999",
response: payload as unknown as Record<string, unknown>,
},
};
expect(resp.type).toBe("control_response");
expect(resp.response.subtype).toBe("success");
expect(resp.response.request_id).toBe("list_1739999999999");
});
test("error control_response carries message string", () => {
const resp: ControlResponse = {
type: "control_response",
session_id: "session-1",
uuid: "uuid-2",
response: {
subtype: "error",
request_id: "list_1739999999999",
error: "conversation not found",
},
};
expect(resp.response.subtype).toBe("error");
if (resp.response.subtype === "error") {
expect(resp.response.error).toBe("conversation not found");
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 2. Routing semantics (resolveListMessagesRoute)
// ─────────────────────────────────────────────────────────────────────────────
describe("list_messages routing — resolveListMessagesRoute", () => {
const SESSION_AGENT = "agent-session-default";
const SESSION_CONV = "conv-session-abc";
/**
* Case A: explicit conversation_id in request.
* Must use conversations.messages.list with the explicit id,
* regardless of what the session's conversation is.
*/
test("A — explicit conversation_id → conversations API with that id", () => {
const route = resolveListMessagesRoute(
{ conversation_id: "conv-explicit-xyz" },
"default", // session is on default — must be ignored
SESSION_AGENT,
);
expect(route.kind).toBe("conversations");
if (route.kind === "conversations") {
expect(route.conversationId).toBe("conv-explicit-xyz");
}
});
test("A — explicit conversation_id overrides non-default session conv", () => {
const route = resolveListMessagesRoute(
{ conversation_id: "conv-override" },
"conv-session-other", // different from explicit — must be ignored
SESSION_AGENT,
);
expect(route.kind).toBe("conversations");
if (route.kind === "conversations") {
expect(route.conversationId).toBe("conv-override");
}
});
/**
* Case B: no conversation_id in request, session is on a named conversation.
* Must use conversations.messages.list with the session's conversation id.
* This is the backfill case for non-default conversations created mid-session.
*/
test("B — omitted conversation_id + non-default session → conversations API with session convId", () => {
const route = resolveListMessagesRoute(
{}, // no conversation_id
SESSION_CONV, // session has a real conversation
SESSION_AGENT,
);
expect(route.kind).toBe("conversations");
if (route.kind === "conversations") {
expect(route.conversationId).toBe(SESSION_CONV);
}
});
test("B — omitted conversation_id + session conv starts with conv- prefix", () => {
const realConvId = "conv-0123456789abcdef";
const route = resolveListMessagesRoute({}, realConvId, SESSION_AGENT);
expect(route.kind).toBe("conversations");
if (route.kind === "conversations") {
expect(route.conversationId).toBe(realConvId);
}
});
/**
* Case C: no conversation_id in request, session is on the default conversation.
* Must use agents.messages.list (implicit default conv via agent route).
*/
test("C — omitted conversation_id + session default → agents API with session agentId", () => {
const route = resolveListMessagesRoute(
{}, // no conversation_id
"default", // session is on default conversation
SESSION_AGENT,
);
expect(route.kind).toBe("agents");
if (route.kind === "agents") {
expect(route.agentId).toBe(SESSION_AGENT);
}
});
test("C — explicit agent_id in request + session default → uses request agentId", () => {
const route = resolveListMessagesRoute(
{ agent_id: "agent-override-id" },
"default",
SESSION_AGENT,
);
expect(route.kind).toBe("agents");
if (route.kind === "agents") {
// Request's agent_id takes priority over session agent when on default conv
expect(route.agentId).toBe("agent-override-id");
}
});
test("C — no conversation_id, no agent_id, session default → falls back to session agentId", () => {
const route = resolveListMessagesRoute(
{},
"default",
"agent-session-fallback",
);
expect(route.kind).toBe("agents");
if (route.kind === "agents") {
expect(route.agentId).toBe("agent-session-fallback");
}
});
/**
* Invariant: "default" is the only string that triggers the agents path.
* Any other string (even empty, or a UUID-like string) uses conversations.
*/
test("conversations path for any non-default conversation string", () => {
const convIds = [
"conv-00000000-0000-0000-0000-000000000000",
"some-arbitrary-id",
" ", // whitespace — unusual but should still use conversations path
];
for (const id of convIds) {
const route = resolveListMessagesRoute({}, id, SESSION_AGENT);
expect(route.kind).toBe("conversations");
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 3. Error propagation — envelope construction
// ─────────────────────────────────────────────────────────────────────────────
describe("list_messages — error control_response construction", () => {
/** Simulates what the headless handler does in the catch block. */
function buildErrorResponse(
err: unknown,
requestId: string,
sessionId: string,
): ControlResponse {
return {
type: "control_response",
session_id: sessionId,
uuid: "test-uuid",
response: {
subtype: "error",
request_id: requestId,
error: err instanceof Error ? err.message : "list_messages failed",
},
};
}
test("Error instance message is extracted", () => {
const resp = buildErrorResponse(
new Error("conversation not found"),
"req-1",
"sess-1",
);
expect(resp.response.subtype).toBe("error");
if (resp.response.subtype === "error") {
expect(resp.response.error).toBe("conversation not found");
expect(resp.response.request_id).toBe("req-1");
}
});
test("Non-Error throw falls back to generic message", () => {
const resp = buildErrorResponse("something went wrong", "req-2", "sess-1");
if (resp.response.subtype === "error") {
expect(resp.response.error).toBe("list_messages failed");
}
});
test("APIError (subclass of Error) uses message", () => {
class APIError extends Error {}
const resp = buildErrorResponse(
new APIError("403 Forbidden"),
"req-3",
"sess-1",
);
if (resp.response.subtype === "error") {
expect(resp.response.error).toBe("403 Forbidden");
}
});
test("null throw falls back to generic message", () => {
const resp = buildErrorResponse(null, "req-4", "sess-1");
if (resp.response.subtype === "error") {
expect(resp.response.error).toBe("list_messages failed");
}
});
test("request_id is echoed from the original request", () => {
const requestId = `list-${Date.now()}`;
const resp = buildErrorResponse(new Error("timeout"), requestId, "sess-1");
if (resp.response.subtype === "error") {
expect(resp.response.request_id).toBe(requestId);
}
});
});

View File

@@ -270,7 +270,38 @@ export interface ControlRequest {
export type SdkToCliControlRequest =
| { subtype: "initialize" }
| { subtype: "interrupt" }
| RegisterExternalToolsRequest;
| RegisterExternalToolsRequest
| ListMessagesControlRequest;
/**
* Request to list conversation messages (SDK → CLI).
* Returns paginated messages from a specific conversation.
*/
export interface ListMessagesControlRequest {
subtype: "list_messages";
/** Explicit conversation ID (e.g. "conv-123"). */
conversation_id?: string;
/** Use the agent's default conversation. */
agent_id?: string;
/** Cursor: return messages before this message ID. */
before?: string;
/** Cursor: return messages after this message ID. */
after?: string;
/** Sort order. Defaults to "desc" (newest first). */
order?: "asc" | "desc";
/** Max messages to return. Defaults to 50. */
limit?: number;
}
/**
* Successful list_messages response payload.
*/
export interface ListMessagesResponsePayload {
messages: unknown[]; // Raw API Message objects
next_before?: string | null;
next_after?: string | null;
has_more?: boolean;
}
/**
* Request to register external tools (SDK → CLI)