From 4111c546d3043d72e7a57d7cd3f5a348984190d3 Mon Sep 17 00:00:00 2001 From: cthomas Date: Tue, 3 Mar 2026 22:48:49 -0800 Subject: [PATCH] fix: migrate default conversation API usage to SDK 1.7.11 pattern (#1256) Co-authored-by: Letta Code --- bun.lock | 4 +- package.json | 2 +- src/agent/bootstrapHandler.ts | 3 +- src/agent/check-approval.ts | 24 +++------ src/agent/listMessagesHandler.ts | 8 ++- src/agent/listMessagesRouting.ts | 12 ++--- src/agent/message.ts | 34 ++++++------ src/cli/App.tsx | 54 ++++++++++--------- src/cli/components/AgentInfoBar.tsx | 4 +- src/cli/components/ConversationSelector.tsx | 3 +- src/cli/helpers/stream.ts | 2 +- src/cli/subcommands/messages.ts | 3 +- src/headless.ts | 11 +--- src/index.ts | 5 +- src/tests/cli/stream-resume-fallback.test.ts | 4 +- src/tests/headless/bootstrap-handler.test.ts | 12 +++-- .../headless/list-messages-handler.test.ts | 25 ++++++--- .../headless/list-messages-protocol.test.ts | 16 +++--- 18 files changed, 116 insertions(+), 110 deletions(-) diff --git a/bun.lock b/bun.lock index 2c9104d..a5e1f22 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index b71a7b9..fd63d0a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/agent/bootstrapHandler.ts b/src/agent/bootstrapHandler.ts index ccaf487..225653d 100644 --- a/src/agent/bootstrapHandler.ts +++ b/src/agent/bootstrapHandler.ts @@ -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(); diff --git a/src/agent/check-approval.ts b/src/agent/check-approval.ts index bb88586..63286cb 100644 --- a/src/agent/check-approval.ts +++ b/src/agent/check-approval.ts @@ -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) { diff --git a/src/agent/listMessagesHandler.ts b/src/agent/listMessagesHandler.ts index 84edb1e..9ac9b6f 100644 --- a/src/agent/listMessagesHandler.ts +++ b/src/agent/listMessagesHandler.ts @@ -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(); diff --git a/src/agent/listMessagesRouting.ts b/src/agent/listMessagesRouting.ts index 863c418..4efa9c0 100644 --- a/src/agent/listMessagesRouting.ts +++ b/src/agent/listMessagesRouting.ts @@ -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 }; } diff --git a/src/agent/message.ts b/src/agent/message.ts index b9dc2fd..e6e22b9 100644 --- a/src/agent/message.ts +++ b/src/agent/message.ts @@ -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, ); diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 7f3ff82..bd508bb 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -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 diff --git a/src/cli/components/AgentInfoBar.tsx b/src/cli/components/AgentInfoBar.tsx index e689949..7044356 100644 --- a/src/cli/components/AgentInfoBar.tsx +++ b/src/cli/components/AgentInfoBar.tsx @@ -177,9 +177,7 @@ export const AgentInfoBar = memo(function AgentInfoBar({ {/* Phantom alien row + Conversation ID */} {alienLines[3]} - {conversationId && - conversationId !== "default" && - !conversationId.startsWith("agent-") ? ( + {conversationId && conversationId !== "default" ? ( {truncateText(conversationId, rightWidth)} diff --git a/src/cli/components/ConversationSelector.tsx b/src/cli/components/ConversationSelector.tsx index 85a5a5a..1825064 100644 --- a/src/cli/components/ConversationSelector.tsx +++ b/src/cli/components/ConversationSelector.tsx @@ -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", }, diff --git a/src/cli/helpers/stream.ts b/src/cli/helpers/stream.ts index 6bf50fc..e8bcb30 100644 --- a/src/cli/helpers/stream.ts +++ b/src/cli/helpers/stream.ts @@ -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. diff --git a/src/cli/subcommands/messages.ts b/src/cli/subcommands/messages.ts index f2bed2d..c8d268b 100644 --- a/src/cli/subcommands/messages.ts +++ b/src/cli/subcommands/messages.ts @@ -159,7 +159,8 @@ export async function runMessagesSubcommand(argv: string[]): Promise { 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, diff --git a/src/headless.ts b/src/headless.ts index 8e9415f..afe3e5e 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -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", diff --git a/src/index.ts b/src/index.ts index 17ed4a9..931bd1c 100755 --- a/src/index.ts +++ b/src/index.ts @@ -630,10 +630,7 @@ async function main(): Promise { // Validate shared mutual-exclusion rules for startup flags. try { validateFlagConflicts({ - guard: - specifiedConversationId && - specifiedConversationId !== "default" && - !specifiedConversationId.startsWith("agent-"), + guard: specifiedConversationId && specifiedConversationId !== "default", checks: [ { when: specifiedAgentId, diff --git a/src/tests/cli/stream-resume-fallback.test.ts b/src/tests/cli/stream-resume-fallback.test.ts index 20791de..bf45532 100644 --- a/src/tests/cli/stream-resume-fallback.test.ts +++ b/src/tests/cli/stream-resume-fallback.test.ts @@ -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" }, ]); }); diff --git a/src/tests/headless/bootstrap-handler.test.ts b/src/tests/headless/bootstrap-handler.test.ts index d0a7cb2..fcadbc1 100644 --- a/src/tests/headless/bootstrap-handler.test.ts +++ b/src/tests/headless/bootstrap-handler.test.ts @@ -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 () => { diff --git a/src/tests/headless/list-messages-handler.test.ts b/src/tests/headless/list-messages-handler.test.ts index c7153b1..2637ef4 100644 --- a/src/tests/headless/list-messages-handler.test.ts +++ b/src/tests/headless/list-messages-handler.test.ts @@ -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"); }); diff --git a/src/tests/headless/list-messages-protocol.test.ts b/src/tests/headless/list-messages-protocol.test.ts index bde1623..3a78473 100644 --- a/src/tests/headless/list-messages-protocol.test.ts +++ b/src/tests/headless/list-messages-protocol.test.ts @@ -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"); }); /**