fix: migrate default conversation API usage to SDK 1.7.11 pattern (#1256)
Co-authored-by: Letta Code <noreply@letta.com>
This commit is contained in:
@@ -36,6 +36,7 @@ export interface BootstrapHandlerClient {
|
||||
opts: {
|
||||
limit: number;
|
||||
order: "asc" | "desc";
|
||||
agent_id?: string;
|
||||
before?: string;
|
||||
after?: string;
|
||||
},
|
||||
@@ -99,7 +100,7 @@ export async function handleBootstrapSessionState(
|
||||
const listStart = Date.now();
|
||||
const page = await client.conversations.messages.list(
|
||||
route.conversationId,
|
||||
{ limit, order },
|
||||
{ limit, order, ...(route.agentId ? { agent_id: route.agentId } : {}) },
|
||||
);
|
||||
const items = page.getPaginatedItems();
|
||||
const listEnd = Date.now();
|
||||
|
||||
@@ -6,7 +6,7 @@ import { APIError } from "@letta-ai/letta-client/core/error";
|
||||
import type { AgentState } from "@letta-ai/letta-client/resources/agents/agents";
|
||||
import type { Message } from "@letta-ai/letta-client/resources/agents/messages";
|
||||
import type { ApprovalRequest } from "../cli/helpers/stream";
|
||||
import { debugLog, debugWarn } from "../utils/debug";
|
||||
import { debugWarn } from "../utils/debug";
|
||||
|
||||
// Backfill should feel like "the last turn(s)", not "the last N raw messages".
|
||||
// Tool-heavy turns can generate many tool_call/tool_return messages that would
|
||||
@@ -344,21 +344,10 @@ export async function getResumeData(
|
||||
|
||||
// Use conversations API for explicit conversations,
|
||||
// use agents API for "default" or no conversationId (agent's primary message history)
|
||||
const useConversationsApi =
|
||||
conversationId &&
|
||||
conversationId !== "default" &&
|
||||
!conversationId.startsWith("agent-");
|
||||
|
||||
if (conversationId?.startsWith("agent-")) {
|
||||
debugWarn(
|
||||
"check-approval",
|
||||
`getResumeData called with agent ID as conversationId: ${conversationId}\n${new Error().stack}`,
|
||||
);
|
||||
}
|
||||
const useConversationsApi = conversationId && conversationId !== "default";
|
||||
|
||||
if (useConversationsApi) {
|
||||
// Get conversation to access in_context_message_ids (source of truth)
|
||||
debugLog("conversations", `retrieve(${conversationId}) [getResumeData]`);
|
||||
const conversation = await client.conversations.retrieve(conversationId);
|
||||
inContextMessageIds = conversation.in_context_message_ids;
|
||||
|
||||
@@ -484,15 +473,16 @@ export async function getResumeData(
|
||||
}
|
||||
const retrievedMessages = await client.messages.retrieve(lastInContextId);
|
||||
|
||||
// Fetch message history for backfill using the agent ID as conversation_id
|
||||
// (the server accepts agent-* IDs for default conversation messages)
|
||||
// Fetch message history for backfill through the default conversation route.
|
||||
// For default conversation, pass agent_id as query parameter.
|
||||
// Wrapped in try/catch so backfill failures don't crash the CLI (e.g., older servers
|
||||
// may not support this pattern)
|
||||
if (includeMessageHistory && isBackfillEnabled()) {
|
||||
try {
|
||||
const messagesPage = await client.conversations.messages.list(
|
||||
agent.id,
|
||||
"default",
|
||||
{
|
||||
agent_id: agent.id,
|
||||
limit: BACKFILL_PAGE_LIMIT,
|
||||
order: "desc",
|
||||
},
|
||||
@@ -501,7 +491,7 @@ export async function getResumeData(
|
||||
|
||||
if (process.env.DEBUG) {
|
||||
console.log(
|
||||
`[DEBUG] conversations.messages.list(${agent.id}) returned ${messages.length} messages`,
|
||||
`[DEBUG] conversations.messages.list(default, agent_id=${agent.id}) returned ${messages.length} messages`,
|
||||
);
|
||||
}
|
||||
} catch (backfillError) {
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface ListMessagesHandlerClient {
|
||||
opts: {
|
||||
limit: number;
|
||||
order: "asc" | "desc";
|
||||
agent_id?: string;
|
||||
before?: string;
|
||||
after?: string;
|
||||
},
|
||||
@@ -87,7 +88,12 @@ export async function handleListMessages(
|
||||
|
||||
const page = await client.conversations.messages.list(
|
||||
route.conversationId,
|
||||
{ limit, order, ...cursorOpts },
|
||||
{
|
||||
limit,
|
||||
order,
|
||||
...(route.agentId ? { agent_id: route.agentId } : {}),
|
||||
...cursorOpts,
|
||||
},
|
||||
);
|
||||
const items = page.getPaginatedItems();
|
||||
|
||||
|
||||
@@ -4,9 +4,8 @@
|
||||
* Extracted from headless.ts so it can be tested in isolation without
|
||||
* spinning up a real Letta client.
|
||||
*
|
||||
* 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).
|
||||
* All paths use the conversations endpoint. For the default conversation,
|
||||
* conversation_id stays "default" and agent_id is passed as query param.
|
||||
*/
|
||||
|
||||
import type { ListMessagesControlRequest } from "../types/protocol";
|
||||
@@ -14,6 +13,7 @@ import type { ListMessagesControlRequest } from "../types/protocol";
|
||||
export type ListMessagesRoute = {
|
||||
kind: "conversations";
|
||||
conversationId: string;
|
||||
agentId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -35,8 +35,8 @@ export function resolveListMessagesRoute(
|
||||
return { kind: "conversations", conversationId: targetConvId };
|
||||
}
|
||||
|
||||
// Default conversation: pass the agent ID to the conversations endpoint.
|
||||
// The server accepts agent-* IDs for agent-direct messaging.
|
||||
// Default conversation: keep conversation_id as "default" and
|
||||
// pass the agent ID as a query parameter.
|
||||
const agentId = listReq.agent_id ?? sessionAgentId;
|
||||
return { kind: "conversations", conversationId: agentId };
|
||||
return { kind: "conversations", conversationId: "default", agentId };
|
||||
}
|
||||
|
||||
@@ -49,8 +49,7 @@ export function getStreamRequestContext(
|
||||
*
|
||||
* For the "default" conversation (agent's primary message history without
|
||||
* an explicit conversation object), pass conversationId="default" and
|
||||
* provide agentId in opts. The server accepts agent IDs as the
|
||||
* conversation_id path parameter for agent-direct messaging.
|
||||
* provide agentId in opts. The agent id is sent in the request body.
|
||||
*/
|
||||
export async function sendMessageStream(
|
||||
conversationId: string,
|
||||
@@ -75,33 +74,34 @@ export async function sendMessageStream(
|
||||
await waitForToolsetReady();
|
||||
const { clientTools, contextId } = captureToolExecutionContext();
|
||||
|
||||
// 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) {
|
||||
const isDefaultConversation = conversationId === "default";
|
||||
if (isDefaultConversation && !opts.agentId) {
|
||||
throw new Error(
|
||||
"agentId is required in opts when using default conversation",
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedConversationId = conversationId;
|
||||
|
||||
const requestBody = {
|
||||
messages,
|
||||
streaming: true,
|
||||
stream_tokens: opts.streamTokens ?? true,
|
||||
background: opts.background ?? true,
|
||||
client_tools: clientTools,
|
||||
include_compaction_messages: true,
|
||||
...(isDefaultConversation ? { agent_id: opts.agentId } : {}),
|
||||
};
|
||||
|
||||
if (process.env.DEBUG) {
|
||||
console.log(
|
||||
`[DEBUG] sendMessageStream: conversationId=${conversationId}, resolved=${resolvedConversationId}`,
|
||||
`[DEBUG] sendMessageStream: conversationId=${conversationId}, agentId=${opts.agentId ?? "(none)"}`,
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
requestBody,
|
||||
requestOptions,
|
||||
);
|
||||
|
||||
|
||||
@@ -5819,11 +5819,12 @@ export default function App({
|
||||
// causing CONFLICT on the next user message.
|
||||
getClient()
|
||||
.then((client) => {
|
||||
const cancelId =
|
||||
conversationIdRef.current === "default"
|
||||
? agentIdRef.current
|
||||
: conversationIdRef.current;
|
||||
return client.conversations.cancel(cancelId);
|
||||
if (conversationIdRef.current === "default") {
|
||||
return client.conversations.cancel("default", {
|
||||
agent_id: agentIdRef.current,
|
||||
});
|
||||
}
|
||||
return client.conversations.cancel(conversationIdRef.current);
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently ignore - cancellation already happened client-side
|
||||
@@ -5938,11 +5939,12 @@ export default function App({
|
||||
// Don't wait for it or show errors since user already got feedback
|
||||
getClient()
|
||||
.then((client) => {
|
||||
const cancelId =
|
||||
conversationIdRef.current === "default"
|
||||
? agentIdRef.current
|
||||
: conversationIdRef.current;
|
||||
return client.conversations.cancel(cancelId);
|
||||
if (conversationIdRef.current === "default") {
|
||||
return client.conversations.cancel("default", {
|
||||
agent_id: agentIdRef.current,
|
||||
});
|
||||
}
|
||||
return client.conversations.cancel(conversationIdRef.current);
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently ignore - cancellation already happened client-side
|
||||
@@ -5962,11 +5964,13 @@ export default function App({
|
||||
setInterruptRequested(true);
|
||||
try {
|
||||
const client = await getClient();
|
||||
const cancelId =
|
||||
conversationIdRef.current === "default"
|
||||
? agentIdRef.current
|
||||
: conversationIdRef.current;
|
||||
await client.conversations.cancel(cancelId);
|
||||
if (conversationIdRef.current === "default") {
|
||||
await client.conversations.cancel("default", {
|
||||
agent_id: agentIdRef.current,
|
||||
});
|
||||
} else {
|
||||
await client.conversations.cancel(conversationIdRef.current);
|
||||
}
|
||||
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
@@ -6174,9 +6178,7 @@ export default function App({
|
||||
// Build success message
|
||||
const agentLabel = agent.name || targetAgentId;
|
||||
const isSpecificConv =
|
||||
opts?.conversationId &&
|
||||
opts.conversationId !== "default" &&
|
||||
!opts?.conversationId.startsWith("agent-");
|
||||
opts?.conversationId && opts.conversationId !== "default";
|
||||
const successOutput = isSpecificConv
|
||||
? [
|
||||
`Switched to **${agentLabel}**`,
|
||||
@@ -8047,13 +8049,17 @@ export default function App({
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const compactId =
|
||||
conversationIdRef.current === "default"
|
||||
? agentId
|
||||
: conversationIdRef.current;
|
||||
const compactConversationId = conversationIdRef.current;
|
||||
const compactBody =
|
||||
compactConversationId === "default"
|
||||
? {
|
||||
agent_id: agentId,
|
||||
...(compactParams ?? {}),
|
||||
}
|
||||
: compactParams;
|
||||
const result = await client.conversations.messages.compact(
|
||||
compactId,
|
||||
compactParams,
|
||||
compactConversationId,
|
||||
compactBody,
|
||||
);
|
||||
|
||||
// Format success message with before/after counts and summary
|
||||
|
||||
@@ -177,9 +177,7 @@ export const AgentInfoBar = memo(function AgentInfoBar({
|
||||
{/* Phantom alien row + Conversation ID */}
|
||||
<Box>
|
||||
<Text>{alienLines[3]}</Text>
|
||||
{conversationId &&
|
||||
conversationId !== "default" &&
|
||||
!conversationId.startsWith("agent-") ? (
|
||||
{conversationId && conversationId !== "default" ? (
|
||||
<Box width={rightWidth} flexShrink={1}>
|
||||
<Text dimColor wrap="truncate-end">
|
||||
{truncateText(conversationId, rightWidth)}
|
||||
|
||||
@@ -243,8 +243,9 @@ export function ConversationSelector({
|
||||
if (!afterCursor) {
|
||||
try {
|
||||
const defaultMessages = await client.conversations.messages.list(
|
||||
agentId,
|
||||
"default",
|
||||
{
|
||||
agent_id: agentId,
|
||||
limit: 20,
|
||||
order: "desc",
|
||||
},
|
||||
|
||||
@@ -180,7 +180,7 @@ export async function discoverFallbackRunIdForResume(
|
||||
}> = [];
|
||||
|
||||
if (ctx.conversationId === "default") {
|
||||
// Default conversation routes through resolvedConversationId (typically agent ID).
|
||||
// Default conversation lookup by conversation id first.
|
||||
lookupQueries.push({ conversation_id: ctx.resolvedConversationId });
|
||||
} else {
|
||||
// Named conversation: first use the explicit conversation id.
|
||||
|
||||
@@ -159,7 +159,8 @@ export async function runMessagesSubcommand(argv: string[]): Promise<number> {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const response = await client.conversations.messages.list(agentId, {
|
||||
const response = await client.conversations.messages.list("default", {
|
||||
agent_id: agentId,
|
||||
limit: parseLimit(parsed.values.limit, 20),
|
||||
after: parsed.values.after,
|
||||
before: parsed.values.before,
|
||||
|
||||
@@ -527,10 +527,7 @@ export async function handleHeadlessCommand(
|
||||
// Validate shared mutual-exclusion rules for startup flags.
|
||||
try {
|
||||
validateFlagConflicts({
|
||||
guard:
|
||||
specifiedConversationId &&
|
||||
specifiedConversationId !== "default" &&
|
||||
!specifiedConversationId.startsWith("agent-"),
|
||||
guard: specifiedConversationId && specifiedConversationId !== "default",
|
||||
checks: [
|
||||
{
|
||||
when: specifiedAgentId,
|
||||
@@ -734,11 +731,7 @@ export async function handleHeadlessCommand(
|
||||
// Priority 0: --conversation derives agent from conversation ID.
|
||||
// "default" is a virtual agent-scoped conversation (not a retrievable conv-*).
|
||||
// It requires --agent and should not hit conversations.retrieve().
|
||||
if (
|
||||
specifiedConversationId &&
|
||||
specifiedConversationId !== "default" &&
|
||||
!specifiedConversationId.startsWith("agent-")
|
||||
) {
|
||||
if (specifiedConversationId && specifiedConversationId !== "default") {
|
||||
try {
|
||||
debugLog(
|
||||
"conversations",
|
||||
|
||||
@@ -630,10 +630,7 @@ async function main(): Promise<void> {
|
||||
// Validate shared mutual-exclusion rules for startup flags.
|
||||
try {
|
||||
validateFlagConflicts({
|
||||
guard:
|
||||
specifiedConversationId &&
|
||||
specifiedConversationId !== "default" &&
|
||||
!specifiedConversationId.startsWith("agent-"),
|
||||
guard: specifiedConversationId && specifiedConversationId !== "default",
|
||||
checks: [
|
||||
{
|
||||
when: specifiedAgentId,
|
||||
|
||||
@@ -85,7 +85,7 @@ describe("discoverFallbackRunIdForResume", () => {
|
||||
makeRunsListClient(runsList),
|
||||
{
|
||||
conversationId: "default",
|
||||
resolvedConversationId: "agent-test",
|
||||
resolvedConversationId: "default",
|
||||
agentId: "agent-test",
|
||||
requestStartedAtMs: Date.parse("2026-02-27T11:00:00.000Z"),
|
||||
},
|
||||
@@ -93,7 +93,7 @@ describe("discoverFallbackRunIdForResume", () => {
|
||||
|
||||
expect(candidate).toBe("run-agent-fallback");
|
||||
expect(calls).toEqual([
|
||||
{ conversation_id: "agent-test", agent_id: undefined },
|
||||
{ conversation_id: "default", agent_id: undefined },
|
||||
{ conversation_id: undefined, agent_id: "agent-test" },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* 3. Pagination fields (next_before, has_more)
|
||||
* 4. Timing fields presence
|
||||
* 5. Error path — client throws → error envelope returned
|
||||
* 6. Default conversation passes agent ID to conversations.messages.list
|
||||
* 6. Default conversation passes conversation_id="default" with agent_id query
|
||||
* 7. Explicit conversation uses conversations.messages.list
|
||||
*
|
||||
* No network. No CLI subprocess. No process.stdout.
|
||||
@@ -56,7 +56,7 @@ const BASE_CTX: BootstrapHandlerSessionContext = {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("bootstrap_session_state routing", () => {
|
||||
test("default conversation passes agent ID to conversations.messages.list", async () => {
|
||||
test("default conversation passes default + agent_id to conversations.messages.list", async () => {
|
||||
const { client, convListSpy } = makeClient([
|
||||
{ id: "msg-1", type: "user_message" },
|
||||
]);
|
||||
@@ -70,9 +70,11 @@ describe("bootstrap_session_state routing", () => {
|
||||
|
||||
expect(convListSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Verify agent ID is passed as the conversation_id
|
||||
const callArgs = (convListSpy.mock.calls[0] as unknown[])[0];
|
||||
expect(callArgs).toBe("agent-test-123");
|
||||
const callArgs = convListSpy.mock.calls[0] as unknown[];
|
||||
expect(callArgs[0]).toBe("default");
|
||||
expect((callArgs[1] as { agent_id?: string }).agent_id).toBe(
|
||||
"agent-test-123",
|
||||
);
|
||||
});
|
||||
|
||||
test("named conversation uses conversations.messages.list", async () => {
|
||||
|
||||
@@ -105,7 +105,7 @@ describe("handleListMessages — routing (which API is called)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("omitted conversation_id + session on default → calls conversations.messages.list with agent ID", async () => {
|
||||
test("omitted conversation_id + session on default → calls conversations.messages.list with default + agent_id", async () => {
|
||||
const { client, convListSpy } = makeClient([{ id: "msg-default-1" }]);
|
||||
|
||||
const resp = await handleListMessages({
|
||||
@@ -117,11 +117,13 @@ describe("handleListMessages — routing (which API is called)", () => {
|
||||
});
|
||||
|
||||
expect(convListSpy).toHaveBeenCalledTimes(1);
|
||||
expect(convListSpy.mock.calls[0]?.[0]).toBe("agent-def");
|
||||
expect(convListSpy.mock.calls[0]?.[0]).toBe("default");
|
||||
const opts = convListSpy.mock.calls[0]?.[1] as { agent_id?: string };
|
||||
expect(opts.agent_id).toBe("agent-def");
|
||||
expect(resp.response.subtype).toBe("success");
|
||||
});
|
||||
|
||||
test("explicit agent_id + session default → conversations path uses request agent_id", async () => {
|
||||
test("explicit agent_id + session default → conversations path uses request agent_id query", async () => {
|
||||
const { client, convListSpy } = makeClient([]);
|
||||
|
||||
await handleListMessages({
|
||||
@@ -132,7 +134,9 @@ describe("handleListMessages — routing (which API is called)", () => {
|
||||
client,
|
||||
});
|
||||
|
||||
expect(convListSpy.mock.calls[0]?.[0]).toBe("agent-override");
|
||||
expect(convListSpy.mock.calls[0]?.[0]).toBe("default");
|
||||
const opts = convListSpy.mock.calls[0]?.[1] as { agent_id?: string };
|
||||
expect(opts.agent_id).toBe("agent-override");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -176,12 +180,13 @@ describe("handleListMessages — API call arguments", () => {
|
||||
client,
|
||||
});
|
||||
|
||||
// Default conversation resolves to agent ID
|
||||
expect(convListSpy.mock.calls[0]?.[0]).toBe("agent-1");
|
||||
expect(convListSpy.mock.calls[0]?.[0]).toBe("default");
|
||||
const opts = convListSpy.mock.calls[0]?.[1] as {
|
||||
agent_id?: string;
|
||||
limit: number;
|
||||
order: string;
|
||||
};
|
||||
expect(opts.agent_id).toBe("agent-1");
|
||||
expect(opts.limit).toBe(50);
|
||||
expect(opts.order).toBe("desc");
|
||||
});
|
||||
@@ -216,8 +221,12 @@ describe("handleListMessages — API call arguments", () => {
|
||||
client,
|
||||
});
|
||||
|
||||
expect(convListSpy.mock.calls[0]?.[0]).toBe("agent-1");
|
||||
const opts = convListSpy.mock.calls[0]?.[1] as { before?: string };
|
||||
expect(convListSpy.mock.calls[0]?.[0]).toBe("default");
|
||||
const opts = convListSpy.mock.calls[0]?.[1] as {
|
||||
agent_id?: string;
|
||||
before?: string;
|
||||
};
|
||||
expect(opts.agent_id).toBe("agent-1");
|
||||
expect(opts.before).toBe("msg-cursor-agents");
|
||||
});
|
||||
|
||||
|
||||
@@ -192,28 +192,29 @@ describe("list_messages routing — resolveListMessagesRoute", () => {
|
||||
|
||||
/**
|
||||
* Case C: no conversation_id in request, session is on the default conversation.
|
||||
* Resolves to conversations API with agent ID as the conversation_id
|
||||
* (server accepts agent-* IDs for agent-direct messaging).
|
||||
* Keeps conversation_id="default" and passes agent_id separately.
|
||||
*/
|
||||
test("C — omitted conversation_id + session default → conversations API with session agentId", () => {
|
||||
test("C — omitted conversation_id + session default → conversations API with default + session agentId", () => {
|
||||
const route = resolveListMessagesRoute(
|
||||
{}, // no conversation_id
|
||||
"default", // session is on default conversation
|
||||
SESSION_AGENT,
|
||||
);
|
||||
expect(route.kind).toBe("conversations");
|
||||
expect(route.conversationId).toBe(SESSION_AGENT);
|
||||
expect(route.conversationId).toBe("default");
|
||||
expect(route.agentId).toBe(SESSION_AGENT);
|
||||
});
|
||||
|
||||
test("C — explicit agent_id in request + session default → uses request agentId", () => {
|
||||
test("C — explicit agent_id in request + session default → uses request agentId query", () => {
|
||||
const route = resolveListMessagesRoute(
|
||||
{ agent_id: "agent-override-id" },
|
||||
"default",
|
||||
SESSION_AGENT,
|
||||
);
|
||||
expect(route.kind).toBe("conversations");
|
||||
expect(route.conversationId).toBe("default");
|
||||
// Request's agent_id takes priority over session agent when on default conv
|
||||
expect(route.conversationId).toBe("agent-override-id");
|
||||
expect(route.agentId).toBe("agent-override-id");
|
||||
});
|
||||
|
||||
test("C — no conversation_id, no agent_id, session default → falls back to session agentId", () => {
|
||||
@@ -223,7 +224,8 @@ describe("list_messages routing — resolveListMessagesRoute", () => {
|
||||
"agent-session-fallback",
|
||||
);
|
||||
expect(route.kind).toBe("conversations");
|
||||
expect(route.conversationId).toBe("agent-session-fallback");
|
||||
expect(route.conversationId).toBe("default");
|
||||
expect(route.agentId).toBe("agent-session-fallback");
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user