diff --git a/src/agent/listMessagesHandler.ts b/src/agent/listMessagesHandler.ts new file mode 100644 index 0000000..898fe70 --- /dev/null +++ b/src/agent/listMessagesHandler.ts @@ -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; + }; + }; + agents: { + messages: { + list( + agentId: string, + opts: { + limit: number; + order: "asc" | "desc"; + before?: string; + after?: string; + }, + ): Promise; + }; + }; +} + +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 { + 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, + }, + 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(), + }; + } +} diff --git a/src/agent/listMessagesRouting.ts b/src/agent/listMessagesRouting.ts new file mode 100644 index 0000000..124e7cd --- /dev/null +++ b/src/agent/listMessagesRouting.ts @@ -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, + 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 }; +} diff --git a/src/headless.ts b/src/headless.ts index ac44b79..fa40062 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -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", diff --git a/src/tests/headless/list-messages-handler.test.ts b/src/tests/headless/list-messages-handler.test.ts new file mode 100644 index 0000000..c2c9b86 --- /dev/null +++ b/src/tests/headless/list-messages-handler.test.ts @@ -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; + agentListSpy: ReturnType; +} { + 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; + 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"); + } + }); +}); diff --git a/src/tests/headless/list-messages-protocol.test.ts b/src/tests/headless/list-messages-protocol.test.ts new file mode 100644 index 0000000..36c9e53 --- /dev/null +++ b/src/tests/headless/list-messages-protocol.test.ts @@ -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, + }, + }; + 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); + } + }); +}); diff --git a/src/types/protocol.ts b/src/types/protocol.ts index 118b5bd..e1ef358 100644 --- a/src/types/protocol.ts +++ b/src/types/protocol.ts @@ -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)