feat: headless list messages (#1095)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
156
src/agent/listMessagesHandler.ts
Normal file
156
src/agent/listMessagesHandler.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
41
src/agent/listMessagesRouting.ts
Normal file
41
src/agent/listMessagesRouting.ts
Normal 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 };
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
433
src/tests/headless/list-messages-handler.test.ts
Normal file
433
src/tests/headless/list-messages-handler.test.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
320
src/tests/headless/list-messages-protocol.test.ts
Normal file
320
src/tests/headless/list-messages-protocol.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user