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:
cthomas
2026-03-03 22:48:49 -08:00
committed by GitHub
parent a44c16edc7
commit 4111c546d3
18 changed files with 116 additions and 110 deletions

View File

@@ -5,7 +5,7 @@
"": {
"name": "@letta-ai/letta-code",
"dependencies": {
"@letta-ai/letta-client": "^1.7.9",
"@letta-ai/letta-client": "^1.7.11",
"glob": "^13.0.0",
"ink-link": "^5.0.0",
"open": "^10.2.0",
@@ -93,7 +93,7 @@
"@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.0", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA=="],
"@letta-ai/letta-client": ["@letta-ai/letta-client@1.7.9", "", {}, "sha512-ZoUH71/c5t7/7H5DF52lduAKGCet/UoAe2PZTwKCt7CpENdyVlAsM1gV3q8xABmu4RZ1zmwiOjbQT8yWty6w3g=="],
"@letta-ai/letta-client": ["@letta-ai/letta-client@1.7.11", "", {}, "sha512-8tB+v/p7xb8/ato/MUhJx2MtTCIZToaTGGHOCFqPql20aN1aJmp2b67ro8cK8jbOygYvHtBYnsogHkY4hvMO1Q=="],
"@types/bun": ["@types/bun@1.3.7", "", { "dependencies": { "bun-types": "1.3.7" } }, "sha512-lmNuMda+Z9b7tmhA0tohwy8ZWFSnmQm1UDWXtH5r9F7wZCfkeO3Jx7wKQ1EOiKq43yHts7ky6r8SDJQWRNupkA=="],

View File

@@ -33,7 +33,7 @@
"access": "public"
},
"dependencies": {
"@letta-ai/letta-client": "^1.7.9",
"@letta-ai/letta-client": "^1.7.11",
"glob": "^13.0.0",
"ink-link": "^5.0.0",
"open": "^10.2.0",

View File

@@ -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();

View File

@@ -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) {

View File

@@ -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();

View File

@@ -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 };
}

View File

@@ -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,
);

View File

@@ -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

View File

@@ -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)}

View File

@@ -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",
},

View File

@@ -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.

View File

@@ -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,

View File

@@ -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",

View File

@@ -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,

View File

@@ -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" },
]);
});

View File

@@ -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 () => {

View File

@@ -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");
});

View File

@@ -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");
});
/**