feat: use conversations endpoint for default conversation (#1206)
This commit is contained in:
@@ -28,10 +28,6 @@ export interface BootstrapMessagesPage {
|
||||
getPaginatedItems(): unknown[];
|
||||
}
|
||||
|
||||
export interface BootstrapAgentsPage {
|
||||
items: unknown[];
|
||||
}
|
||||
|
||||
export interface BootstrapHandlerClient {
|
||||
conversations: {
|
||||
messages: {
|
||||
@@ -46,20 +42,6 @@ export interface BootstrapHandlerClient {
|
||||
): Promise<BootstrapMessagesPage>;
|
||||
};
|
||||
};
|
||||
agents: {
|
||||
messages: {
|
||||
list(
|
||||
agentId: string,
|
||||
opts: {
|
||||
limit: number;
|
||||
order: "asc" | "desc";
|
||||
before?: string;
|
||||
after?: string;
|
||||
conversation_id?: "default";
|
||||
},
|
||||
): Promise<BootstrapAgentsPage>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface BootstrapHandlerSessionContext {
|
||||
@@ -115,22 +97,11 @@ export async function handleBootstrapSessionState(
|
||||
);
|
||||
|
||||
const listStart = Date.now();
|
||||
let items: unknown[];
|
||||
|
||||
if (route.kind === "conversations") {
|
||||
const page = await client.conversations.messages.list(
|
||||
route.conversationId,
|
||||
{ limit, order },
|
||||
);
|
||||
items = page.getPaginatedItems();
|
||||
} else {
|
||||
const page = await client.agents.messages.list(route.agentId, {
|
||||
limit,
|
||||
order,
|
||||
conversation_id: "default",
|
||||
});
|
||||
items = page.items;
|
||||
}
|
||||
const page = await client.conversations.messages.list(
|
||||
route.conversationId,
|
||||
{ limit, order },
|
||||
);
|
||||
const items = page.getPaginatedItems();
|
||||
const listEnd = Date.now();
|
||||
|
||||
const hasMore = items.length >= limit;
|
||||
|
||||
@@ -479,22 +479,24 @@ export async function getResumeData(
|
||||
}
|
||||
const retrievedMessages = await client.messages.retrieve(lastInContextId);
|
||||
|
||||
// Fetch message history for backfill using conversation_id=default
|
||||
// This filters to only the default conversation's messages (like the ADE does)
|
||||
// Fetch message history for backfill using the agent ID as conversation_id
|
||||
// (the server accepts agent-* IDs for default conversation messages)
|
||||
// Wrapped in try/catch so backfill failures don't crash the CLI (e.g., older servers
|
||||
// may not support conversation_id filter)
|
||||
// may not support this pattern)
|
||||
if (includeMessageHistory && isBackfillEnabled()) {
|
||||
try {
|
||||
const messagesPage = await client.agents.messages.list(agent.id, {
|
||||
limit: BACKFILL_PAGE_LIMIT,
|
||||
order: "desc",
|
||||
conversation_id: "default", // Key: filter to default conversation only
|
||||
});
|
||||
messages = sortChronological(messagesPage.items);
|
||||
const messagesPage = await client.conversations.messages.list(
|
||||
agent.id,
|
||||
{
|
||||
limit: BACKFILL_PAGE_LIMIT,
|
||||
order: "desc",
|
||||
},
|
||||
);
|
||||
messages = sortChronological(messagesPage.getPaginatedItems());
|
||||
|
||||
if (process.env.DEBUG) {
|
||||
console.log(
|
||||
`[DEBUG] agents.messages.list(conversation_id=default) returned ${messages.length} messages`,
|
||||
`[DEBUG] conversations.messages.list(${agent.id}) returned ${messages.length} messages`,
|
||||
);
|
||||
}
|
||||
} catch (backfillError) {
|
||||
|
||||
@@ -22,10 +22,6 @@ export interface ConversationsMessagesPage {
|
||||
getPaginatedItems(): unknown[];
|
||||
}
|
||||
|
||||
export interface AgentsMessagesPage {
|
||||
items: unknown[];
|
||||
}
|
||||
|
||||
export interface ListMessagesHandlerClient {
|
||||
conversations: {
|
||||
messages: {
|
||||
@@ -40,20 +36,6 @@ export interface ListMessagesHandlerClient {
|
||||
): Promise<ConversationsMessagesPage>;
|
||||
};
|
||||
};
|
||||
agents: {
|
||||
messages: {
|
||||
list(
|
||||
agentId: string,
|
||||
opts: {
|
||||
limit: number;
|
||||
order: "asc" | "desc";
|
||||
before?: string;
|
||||
after?: string;
|
||||
conversation_id?: "default";
|
||||
},
|
||||
): Promise<AgentsMessagesPage>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface HandleListMessagesParams {
|
||||
@@ -97,29 +79,17 @@ export async function handleListMessages(
|
||||
};
|
||||
|
||||
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,
|
||||
conversation_id: "default",
|
||||
...cursorOpts,
|
||||
});
|
||||
items = page.items;
|
||||
}
|
||||
const page = await client.conversations.messages.list(
|
||||
route.conversationId,
|
||||
{ limit, order, ...cursorOpts },
|
||||
);
|
||||
const items = page.getPaginatedItems();
|
||||
|
||||
const hasMore = items.length >= limit;
|
||||
const oldestId =
|
||||
|
||||
@@ -4,17 +4,17 @@
|
||||
* 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
|
||||
* All paths now use the conversations endpoint. For the default conversation,
|
||||
* the agent ID is passed as the conversation_id (the server accepts agent-*
|
||||
* IDs for agent-direct messaging).
|
||||
*/
|
||||
|
||||
import type { ListMessagesControlRequest } from "../types/protocol";
|
||||
|
||||
export type ListMessagesRoute =
|
||||
| { kind: "conversations"; conversationId: string }
|
||||
| { kind: "agents"; agentId: string };
|
||||
export type ListMessagesRoute = {
|
||||
kind: "conversations";
|
||||
conversationId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve which Letta API endpoint to call for a list_messages request.
|
||||
@@ -35,7 +35,8 @@ export function resolveListMessagesRoute(
|
||||
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 };
|
||||
// Default conversation: pass the agent ID to the conversations endpoint.
|
||||
// The server accepts agent-* IDs for agent-direct messaging.
|
||||
const agentId = listReq.agent_id ?? sessionAgentId;
|
||||
return { kind: "conversations", conversationId: agentId };
|
||||
}
|
||||
|
||||
@@ -32,11 +32,12 @@ export function getStreamToolContextId(
|
||||
|
||||
/**
|
||||
* Send a message to a conversation and return a streaming response.
|
||||
* Uses the conversations API for proper message isolation per session.
|
||||
* Uses the conversations API for all conversations.
|
||||
*
|
||||
* For the "default" conversation (agent's primary message history without
|
||||
* an explicit conversation object), pass conversationId="default" and
|
||||
* provide agentId in opts. This uses the agents messages API instead.
|
||||
* provide agentId in opts. The server accepts agent IDs as the
|
||||
* conversation_id path parameter for agent-direct messaging.
|
||||
*/
|
||||
export async function sendMessageStream(
|
||||
conversationId: string,
|
||||
@@ -58,48 +59,35 @@ export async function sendMessageStream(
|
||||
await waitForToolsetReady();
|
||||
const { clientTools, contextId } = captureToolExecutionContext();
|
||||
|
||||
let stream: Stream<LettaStreamingResponse>;
|
||||
// For "default" conversation, pass the agent ID to the conversations endpoint.
|
||||
// The server accepts agent-* IDs for agent-direct messaging.
|
||||
const resolvedConversationId =
|
||||
conversationId === "default" ? opts.agentId : conversationId;
|
||||
|
||||
if (!resolvedConversationId) {
|
||||
throw new Error(
|
||||
"agentId is required in opts when using default conversation",
|
||||
);
|
||||
}
|
||||
|
||||
if (process.env.DEBUG) {
|
||||
console.log(
|
||||
`[DEBUG] sendMessageStream: conversationId=${conversationId}, useAgentsRoute=${conversationId === "default"}`,
|
||||
`[DEBUG] sendMessageStream: conversationId=${conversationId}, resolved=${resolvedConversationId}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (conversationId === "default") {
|
||||
// Use agents route for default conversation (agent's primary message history)
|
||||
if (!opts.agentId) {
|
||||
throw new Error(
|
||||
"agentId is required in opts when using default conversation",
|
||||
);
|
||||
}
|
||||
stream = await client.agents.messages.create(
|
||||
opts.agentId,
|
||||
{
|
||||
messages: messages,
|
||||
streaming: true,
|
||||
stream_tokens: opts.streamTokens ?? true,
|
||||
background: opts.background ?? true,
|
||||
client_tools: clientTools,
|
||||
include_compaction_messages: true,
|
||||
},
|
||||
requestOptions,
|
||||
);
|
||||
} else {
|
||||
// Use conversations route for explicit conversations
|
||||
stream = await client.conversations.messages.create(
|
||||
conversationId,
|
||||
{
|
||||
messages: messages,
|
||||
streaming: true,
|
||||
stream_tokens: opts.streamTokens ?? true,
|
||||
background: opts.background ?? true,
|
||||
client_tools: clientTools,
|
||||
include_compaction_messages: true,
|
||||
},
|
||||
requestOptions,
|
||||
);
|
||||
}
|
||||
const stream = await client.conversations.messages.create(
|
||||
resolvedConversationId,
|
||||
{
|
||||
messages: messages,
|
||||
streaming: true,
|
||||
stream_tokens: opts.streamTokens ?? true,
|
||||
background: opts.background ?? true,
|
||||
client_tools: clientTools,
|
||||
include_compaction_messages: true,
|
||||
},
|
||||
requestOptions,
|
||||
);
|
||||
|
||||
if (requestStartTime !== undefined) {
|
||||
streamRequestStartTimes.set(stream as object, requestStartTime);
|
||||
|
||||
@@ -5690,10 +5690,11 @@ export default function App({
|
||||
// causing CONFLICT on the next user message.
|
||||
getClient()
|
||||
.then((client) => {
|
||||
if (conversationIdRef.current === "default") {
|
||||
return client.agents.messages.cancel(agentIdRef.current);
|
||||
}
|
||||
return client.conversations.cancel(conversationIdRef.current);
|
||||
const cancelId =
|
||||
conversationIdRef.current === "default"
|
||||
? agentIdRef.current
|
||||
: conversationIdRef.current;
|
||||
return client.conversations.cancel(cancelId);
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently ignore - cancellation already happened client-side
|
||||
@@ -5808,11 +5809,11 @@ export default function App({
|
||||
// Don't wait for it or show errors since user already got feedback
|
||||
getClient()
|
||||
.then((client) => {
|
||||
// Use agents API for "default" conversation (primary message history)
|
||||
if (conversationIdRef.current === "default") {
|
||||
return client.agents.messages.cancel(agentIdRef.current);
|
||||
}
|
||||
return client.conversations.cancel(conversationIdRef.current);
|
||||
const cancelId =
|
||||
conversationIdRef.current === "default"
|
||||
? agentIdRef.current
|
||||
: conversationIdRef.current;
|
||||
return client.conversations.cancel(cancelId);
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently ignore - cancellation already happened client-side
|
||||
@@ -5832,12 +5833,11 @@ export default function App({
|
||||
setInterruptRequested(true);
|
||||
try {
|
||||
const client = await getClient();
|
||||
// Use agents API for "default" conversation (primary message history)
|
||||
if (conversationIdRef.current === "default") {
|
||||
await client.agents.messages.cancel(agentIdRef.current);
|
||||
} else {
|
||||
await client.conversations.cancel(conversationIdRef.current);
|
||||
}
|
||||
const cancelId =
|
||||
conversationIdRef.current === "default"
|
||||
? agentIdRef.current
|
||||
: conversationIdRef.current;
|
||||
await client.conversations.cancel(cancelId);
|
||||
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
@@ -7886,15 +7886,14 @@ export default function App({
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Use agent-level compact API for "default" conversation,
|
||||
// otherwise use conversation-level API
|
||||
const result =
|
||||
const compactId =
|
||||
conversationIdRef.current === "default"
|
||||
? await client.agents.messages.compact(agentId, compactParams)
|
||||
: await client.conversations.messages.compact(
|
||||
conversationIdRef.current,
|
||||
compactParams,
|
||||
);
|
||||
? agentId
|
||||
: conversationIdRef.current;
|
||||
const result = await client.conversations.messages.compact(
|
||||
compactId,
|
||||
compactParams,
|
||||
);
|
||||
|
||||
// Format success message with before/after counts and summary
|
||||
const outputLines = [
|
||||
|
||||
@@ -242,12 +242,14 @@ export function ConversationSelector({
|
||||
let defaultConversation: EnrichedConversation | null = null;
|
||||
if (!afterCursor) {
|
||||
try {
|
||||
const defaultMessages = await client.agents.messages.list(agentId, {
|
||||
limit: 20,
|
||||
order: "desc",
|
||||
conversation_id: "default", // Filter to default conversation only
|
||||
});
|
||||
const defaultMsgItems = defaultMessages.items;
|
||||
const defaultMessages = await client.conversations.messages.list(
|
||||
agentId,
|
||||
{
|
||||
limit: 20,
|
||||
order: "desc",
|
||||
},
|
||||
);
|
||||
const defaultMsgItems = defaultMessages.getPaginatedItems();
|
||||
if (defaultMsgItems.length > 0) {
|
||||
const defaultStats = getMessageStats(
|
||||
[...defaultMsgItems].reverse(),
|
||||
|
||||
@@ -159,14 +159,14 @@ export async function runMessagesSubcommand(argv: string[]): Promise<number> {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const response = await client.agents.messages.list(agentId, {
|
||||
const response = await client.conversations.messages.list(agentId, {
|
||||
limit: parseLimit(parsed.values.limit, 20),
|
||||
after: parsed.values.after,
|
||||
before: parsed.values.before,
|
||||
order,
|
||||
});
|
||||
|
||||
const messages = response.items ?? [];
|
||||
const messages = response.getPaginatedItems() ?? [];
|
||||
const startDate = parsed.values["start-date"];
|
||||
const endDate = parsed.values["end-date"];
|
||||
|
||||
|
||||
@@ -104,10 +104,10 @@ describe("getResumeData", () => {
|
||||
in_context_message_ids: ["msg-last"],
|
||||
}));
|
||||
const conversationsList = mock(async () => ({
|
||||
getPaginatedItems: () => [],
|
||||
}));
|
||||
const agentsList = mock(async () => ({
|
||||
items: [makeUserMessage("msg-a"), makeUserMessage("msg-b")],
|
||||
getPaginatedItems: () => [
|
||||
makeUserMessage("msg-a"),
|
||||
makeUserMessage("msg-b"),
|
||||
],
|
||||
}));
|
||||
const messagesRetrieve = mock(async () => [makeUserMessage()]);
|
||||
|
||||
@@ -116,14 +116,13 @@ describe("getResumeData", () => {
|
||||
retrieve: conversationsRetrieve,
|
||||
messages: { list: conversationsList },
|
||||
},
|
||||
agents: { messages: { list: agentsList } },
|
||||
messages: { retrieve: messagesRetrieve },
|
||||
} as unknown as Letta;
|
||||
|
||||
const resume = await getResumeData(client, makeAgent(), "default");
|
||||
|
||||
expect(messagesRetrieve).toHaveBeenCalledTimes(1);
|
||||
expect(agentsList).toHaveBeenCalledTimes(1);
|
||||
expect(conversationsList).toHaveBeenCalledTimes(1);
|
||||
expect(resume.pendingApprovals).toHaveLength(0);
|
||||
expect(resume.messageHistory.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
@@ -59,7 +59,6 @@ describe("approval recovery wiring", () => {
|
||||
const segment = source.slice(start, end);
|
||||
|
||||
expect(segment).toContain("getClient()");
|
||||
expect(segment).toContain("client.agents.messages.cancel");
|
||||
expect(segment).toContain("client.conversations.cancel");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
* Handler-level tests for bootstrap_session_state using mock Letta clients.
|
||||
*
|
||||
* Verifies:
|
||||
* 1. Correct routing (conversations vs agents path based on session conversationId)
|
||||
* 1. Correct routing (all paths use conversations.messages.list)
|
||||
* 2. Response payload shape (agent_id, conversation_id, model, tools, messages, etc.)
|
||||
* 3. Pagination fields (next_before, has_more)
|
||||
* 4. Timing fields presence
|
||||
* 5. Error path — client throws → error envelope returned
|
||||
* 6. Default conversation uses agents.messages.list with conversation_id: "default"
|
||||
* 6. Default conversation passes agent ID to conversations.messages.list
|
||||
* 7. Explicit conversation uses conversations.messages.list
|
||||
*
|
||||
* No network. No CLI subprocess. No process.stdout.
|
||||
@@ -23,20 +23,13 @@ import { handleBootstrapSessionState } from "../../agent/bootstrapHandler";
|
||||
// Mock factory
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeClient(
|
||||
convMessages: unknown[] = [],
|
||||
agentMessages: unknown[] = [],
|
||||
): {
|
||||
function makeClient(convMessages: unknown[] = []): {
|
||||
client: BootstrapHandlerClient;
|
||||
convListSpy: ReturnType<typeof mock>;
|
||||
agentListSpy: ReturnType<typeof mock>;
|
||||
} {
|
||||
const convListSpy = mock(async () => ({
|
||||
getPaginatedItems: () => convMessages,
|
||||
}));
|
||||
const agentListSpy = mock(async () => ({
|
||||
items: agentMessages,
|
||||
}));
|
||||
|
||||
const client: BootstrapHandlerClient = {
|
||||
conversations: {
|
||||
@@ -44,14 +37,9 @@ function makeClient(
|
||||
list: convListSpy as unknown as BootstrapHandlerClient["conversations"]["messages"]["list"],
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
messages: {
|
||||
list: agentListSpy as unknown as BootstrapHandlerClient["agents"]["messages"]["list"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return { client, convListSpy, agentListSpy };
|
||||
return { client, convListSpy };
|
||||
}
|
||||
|
||||
const BASE_CTX: BootstrapHandlerSessionContext = {
|
||||
@@ -68,11 +56,10 @@ const BASE_CTX: BootstrapHandlerSessionContext = {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("bootstrap_session_state routing", () => {
|
||||
test("default conversation uses agents.messages.list", async () => {
|
||||
const { client, agentListSpy, convListSpy } = makeClient(
|
||||
[],
|
||||
[{ id: "msg-1", type: "user_message" }],
|
||||
);
|
||||
test("default conversation passes agent ID to conversations.messages.list", async () => {
|
||||
const { client, convListSpy } = makeClient([
|
||||
{ id: "msg-1", type: "user_message" },
|
||||
]);
|
||||
|
||||
await handleBootstrapSessionState({
|
||||
bootstrapReq: { subtype: "bootstrap_session_state" },
|
||||
@@ -81,19 +68,15 @@ describe("bootstrap_session_state routing", () => {
|
||||
client,
|
||||
});
|
||||
|
||||
expect(agentListSpy).toHaveBeenCalledTimes(1);
|
||||
expect(convListSpy).toHaveBeenCalledTimes(0);
|
||||
expect(convListSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Verify conversation_id: "default" param is passed
|
||||
const callArgs = (agentListSpy.mock.calls[0] as unknown[])[1] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(callArgs.conversation_id).toBe("default");
|
||||
// Verify agent ID is passed as the conversation_id
|
||||
const callArgs = (convListSpy.mock.calls[0] as unknown[])[0];
|
||||
expect(callArgs).toBe("agent-test-123");
|
||||
});
|
||||
|
||||
test("named conversation uses conversations.messages.list", async () => {
|
||||
const { client, convListSpy, agentListSpy } = makeClient([
|
||||
const { client, convListSpy } = makeClient([
|
||||
{ id: "msg-1", type: "user_message" },
|
||||
]);
|
||||
|
||||
@@ -105,7 +88,6 @@ describe("bootstrap_session_state routing", () => {
|
||||
});
|
||||
|
||||
expect(convListSpy).toHaveBeenCalledTimes(1);
|
||||
expect(agentListSpy).toHaveBeenCalledTimes(0);
|
||||
|
||||
const callArgs = (convListSpy.mock.calls[0] as unknown[])[0];
|
||||
expect(callArgs).toBe("conv-abc-123");
|
||||
@@ -123,7 +105,7 @@ describe("bootstrap_session_state response shape", () => {
|
||||
{ id: "msg-2", type: "user_message" },
|
||||
{ id: "msg-1", type: "user_message" },
|
||||
];
|
||||
const { client } = makeClient([], messages);
|
||||
const { client } = makeClient(messages);
|
||||
|
||||
const resp = await handleBootstrapSessionState({
|
||||
bootstrapReq: { subtype: "bootstrap_session_state" },
|
||||
@@ -210,7 +192,7 @@ describe("bootstrap_session_state pagination", () => {
|
||||
id: `msg-${i}`,
|
||||
type: "user_message",
|
||||
}));
|
||||
const { client } = makeClient([], messages);
|
||||
const { client } = makeClient(messages);
|
||||
|
||||
const resp = await handleBootstrapSessionState({
|
||||
bootstrapReq: { subtype: "bootstrap_session_state", limit: 50 },
|
||||
@@ -228,7 +210,7 @@ describe("bootstrap_session_state pagination", () => {
|
||||
const messages = Array.from({ length: limit }, (_, i) => ({
|
||||
id: `msg-${i}`,
|
||||
}));
|
||||
const { client } = makeClient([], messages);
|
||||
const { client } = makeClient(messages);
|
||||
|
||||
const resp = await handleBootstrapSessionState({
|
||||
bootstrapReq: { subtype: "bootstrap_session_state", limit },
|
||||
@@ -247,7 +229,7 @@ describe("bootstrap_session_state pagination", () => {
|
||||
{ id: "msg-middle" },
|
||||
{ id: "msg-oldest" },
|
||||
];
|
||||
const { client } = makeClient([], messages);
|
||||
const { client } = makeClient(messages);
|
||||
|
||||
const resp = await handleBootstrapSessionState({
|
||||
bootstrapReq: { subtype: "bootstrap_session_state" },
|
||||
@@ -262,7 +244,7 @@ describe("bootstrap_session_state pagination", () => {
|
||||
});
|
||||
|
||||
test("next_before is null when no messages", async () => {
|
||||
const { client } = makeClient([], []);
|
||||
const { client } = makeClient([]);
|
||||
const resp = await handleBootstrapSessionState({
|
||||
bootstrapReq: { subtype: "bootstrap_session_state" },
|
||||
sessionContext: BASE_CTX,
|
||||
@@ -289,13 +271,6 @@ describe("bootstrap_session_state error handling", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
messages: {
|
||||
list: async () => {
|
||||
throw new Error("Network timeout");
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const resp = await handleBootstrapSessionState({
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 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:
|
||||
* of conversations.messages.list. They verify:
|
||||
*
|
||||
* 1. Which client method is called for each routing case (explicit conv,
|
||||
* omitted+named session conv, omitted+default session conv)
|
||||
@@ -20,20 +20,13 @@ import { handleListMessages } from "../../agent/listMessagesHandler";
|
||||
// Mock factory
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeClient(
|
||||
convMessages: unknown[] = [],
|
||||
agentMessages: unknown[] = [],
|
||||
): {
|
||||
function makeClient(convMessages: 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: {
|
||||
@@ -41,14 +34,9 @@ function makeClient(
|
||||
list: convListSpy as unknown as ListMessagesHandlerClient["conversations"]["messages"]["list"],
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
messages: {
|
||||
list: agentListSpy as unknown as ListMessagesHandlerClient["agents"]["messages"]["list"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return { client, convListSpy, agentListSpy };
|
||||
return { client, convListSpy };
|
||||
}
|
||||
|
||||
const BASE = {
|
||||
@@ -62,7 +50,7 @@ const BASE = {
|
||||
|
||||
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 { client, convListSpy } = makeClient([{ id: "m1" }]);
|
||||
|
||||
const resp = await handleListMessages({
|
||||
...BASE,
|
||||
@@ -73,7 +61,6 @@ describe("handleListMessages — routing (which API is called)", () => {
|
||||
});
|
||||
|
||||
expect(convListSpy).toHaveBeenCalledTimes(1);
|
||||
expect(agentListSpy).toHaveBeenCalledTimes(0);
|
||||
expect(convListSpy.mock.calls[0]?.[0]).toBe("conv-explicit");
|
||||
expect(resp.response.subtype).toBe("success");
|
||||
});
|
||||
@@ -93,7 +80,7 @@ describe("handleListMessages — routing (which API is called)", () => {
|
||||
});
|
||||
|
||||
test("omitted conversation_id + named session conv → calls conversations.messages.list with session conv", async () => {
|
||||
const { client, convListSpy, agentListSpy } = makeClient([
|
||||
const { client, convListSpy } = makeClient([
|
||||
{ id: "msg-A" },
|
||||
{ id: "msg-B" },
|
||||
]);
|
||||
@@ -107,7 +94,6 @@ describe("handleListMessages — routing (which API is called)", () => {
|
||||
});
|
||||
|
||||
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") {
|
||||
@@ -119,11 +105,8 @@ describe("handleListMessages — routing (which API is called)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("omitted conversation_id + session on default → calls agents.messages.list", async () => {
|
||||
const { client, convListSpy, agentListSpy } = makeClient(
|
||||
[],
|
||||
[{ id: "msg-default-1" }],
|
||||
);
|
||||
test("omitted conversation_id + session on default → calls conversations.messages.list with agent ID", async () => {
|
||||
const { client, convListSpy } = makeClient([{ id: "msg-default-1" }]);
|
||||
|
||||
const resp = await handleListMessages({
|
||||
...BASE,
|
||||
@@ -133,14 +116,13 @@ describe("handleListMessages — routing (which API is called)", () => {
|
||||
client,
|
||||
});
|
||||
|
||||
expect(agentListSpy).toHaveBeenCalledTimes(1);
|
||||
expect(convListSpy).toHaveBeenCalledTimes(0);
|
||||
expect(agentListSpy.mock.calls[0]?.[0]).toBe("agent-def");
|
||||
expect(convListSpy).toHaveBeenCalledTimes(1);
|
||||
expect(convListSpy.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([], []);
|
||||
test("explicit agent_id + session default → conversations path uses request agent_id", async () => {
|
||||
const { client, convListSpy } = makeClient([]);
|
||||
|
||||
await handleListMessages({
|
||||
...BASE,
|
||||
@@ -150,7 +132,7 @@ describe("handleListMessages — routing (which API is called)", () => {
|
||||
client,
|
||||
});
|
||||
|
||||
expect(agentListSpy.mock.calls[0]?.[0]).toBe("agent-override");
|
||||
expect(convListSpy.mock.calls[0]?.[0]).toBe("agent-override");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -184,7 +166,7 @@ describe("handleListMessages — API call arguments", () => {
|
||||
});
|
||||
|
||||
test("defaults to limit=50 and order=desc when not specified", async () => {
|
||||
const { client, agentListSpy } = makeClient([], []);
|
||||
const { client, convListSpy } = makeClient([]);
|
||||
|
||||
await handleListMessages({
|
||||
...BASE,
|
||||
@@ -194,14 +176,14 @@ describe("handleListMessages — API call arguments", () => {
|
||||
client,
|
||||
});
|
||||
|
||||
const opts = agentListSpy.mock.calls[0]?.[1] as {
|
||||
// Default conversation resolves to agent ID
|
||||
expect(convListSpy.mock.calls[0]?.[0]).toBe("agent-1");
|
||||
const opts = convListSpy.mock.calls[0]?.[1] as {
|
||||
limit: number;
|
||||
order: string;
|
||||
conversation_id?: string;
|
||||
};
|
||||
expect(opts.limit).toBe(50);
|
||||
expect(opts.order).toBe("desc");
|
||||
expect(opts.conversation_id).toBe("default");
|
||||
});
|
||||
|
||||
test("forwards before cursor to conversations path", async () => {
|
||||
@@ -223,8 +205,8 @@ describe("handleListMessages — API call arguments", () => {
|
||||
expect(opts.before).toBe("msg-cursor");
|
||||
});
|
||||
|
||||
test("forwards before cursor to agents path", async () => {
|
||||
const { client, agentListSpy } = makeClient([], []);
|
||||
test("forwards before cursor to default conversation path", async () => {
|
||||
const { client, convListSpy } = makeClient([]);
|
||||
|
||||
await handleListMessages({
|
||||
...BASE,
|
||||
@@ -234,12 +216,9 @@ describe("handleListMessages — API call arguments", () => {
|
||||
client,
|
||||
});
|
||||
|
||||
const opts = agentListSpy.mock.calls[0]?.[1] as {
|
||||
before?: string;
|
||||
conversation_id?: string;
|
||||
};
|
||||
expect(convListSpy.mock.calls[0]?.[0]).toBe("agent-1");
|
||||
const opts = convListSpy.mock.calls[0]?.[1] as { before?: string };
|
||||
expect(opts.before).toBe("msg-cursor-agents");
|
||||
expect(opts.conversation_id).toBe("default");
|
||||
});
|
||||
|
||||
test("does not include before/after when absent", async () => {
|
||||
@@ -341,18 +320,12 @@ describe("handleListMessages — error path", () => {
|
||||
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({
|
||||
@@ -380,13 +353,6 @@ describe("handleListMessages — error path", () => {
|
||||
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({
|
||||
@@ -402,21 +368,14 @@ describe("handleListMessages — error path", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("agents path error → error envelope with correct session_id", async () => {
|
||||
const agentListSpy = mock(async () => {
|
||||
test("default conversation error → error envelope with correct session_id", async () => {
|
||||
const convListSpy = 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"],
|
||||
list: convListSpy as unknown as ListMessagesHandlerClient["conversations"]["messages"]["list"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -192,18 +192,17 @@ describe("list_messages routing — resolveListMessagesRoute", () => {
|
||||
|
||||
/**
|
||||
* Case C: no conversation_id in request, session is on the default conversation.
|
||||
* Must use agents.messages.list (implicit default conv via agent route).
|
||||
* Resolves to conversations API with agent ID as the conversation_id
|
||||
* (server accepts agent-* IDs for agent-direct messaging).
|
||||
*/
|
||||
test("C — omitted conversation_id + session default → agents API with session agentId", () => {
|
||||
test("C — omitted conversation_id + session default → conversations 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);
|
||||
}
|
||||
expect(route.kind).toBe("conversations");
|
||||
expect(route.conversationId).toBe(SESSION_AGENT);
|
||||
});
|
||||
|
||||
test("C — explicit agent_id in request + session default → uses request agentId", () => {
|
||||
@@ -212,11 +211,9 @@ describe("list_messages routing — resolveListMessagesRoute", () => {
|
||||
"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");
|
||||
}
|
||||
expect(route.kind).toBe("conversations");
|
||||
// Request's agent_id takes priority over session agent when on default conv
|
||||
expect(route.conversationId).toBe("agent-override-id");
|
||||
});
|
||||
|
||||
test("C — no conversation_id, no agent_id, session default → falls back to session agentId", () => {
|
||||
@@ -225,15 +222,12 @@ describe("list_messages routing — resolveListMessagesRoute", () => {
|
||||
"default",
|
||||
"agent-session-fallback",
|
||||
);
|
||||
expect(route.kind).toBe("agents");
|
||||
if (route.kind === "agents") {
|
||||
expect(route.agentId).toBe("agent-session-fallback");
|
||||
}
|
||||
expect(route.kind).toBe("conversations");
|
||||
expect(route.conversationId).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.
|
||||
* All paths use the conversations API.
|
||||
*/
|
||||
test("conversations path for any non-default conversation string", () => {
|
||||
const convIds = [
|
||||
|
||||
Reference in New Issue
Block a user