diff --git a/src/agent/bootstrapHandler.ts b/src/agent/bootstrapHandler.ts new file mode 100644 index 0000000..660c4fb --- /dev/null +++ b/src/agent/bootstrapHandler.ts @@ -0,0 +1,185 @@ +/** + * Extracted handler for the bootstrap_session_state control request. + * + * Returns a single ControlResponse containing: + * - resolved session metadata (agent_id, conversation_id, model, tools, memfs_enabled) + * - initial history page (messages, next_before, has_more) + * - pending approval flag + * - optional wall-clock timings + * + * Accepting minimal client/context interfaces keeps the handler fully testable + * without a real network or subprocess. + */ + +import { randomUUID } from "node:crypto"; +import type { + BootstrapSessionStatePayload, + BootstrapSessionStateRequest, + ControlResponse, +} from "../types/protocol"; +import { resolveListMessagesRoute } from "./listMessagesRouting"; + +// ───────────────────────────────────────────────────────────────────────────── +// Minimal interfaces — only what the handler needs +// ───────────────────────────────────────────────────────────────────────────── + +export interface BootstrapMessagesPage { + /** conversations.messages.list() returns a paginated resource */ + getPaginatedItems(): unknown[]; +} + +export interface BootstrapAgentsPage { + items: unknown[]; +} + +export interface BootstrapHandlerClient { + conversations: { + messages: { + list( + conversationId: string, + opts: { + limit: number; + order: "asc" | "desc"; + before?: string; + after?: string; + }, + ): Promise; + }; + }; + agents: { + messages: { + list( + agentId: string, + opts: { + limit: number; + order: "asc" | "desc"; + before?: string; + after?: string; + conversation_id?: "default"; + }, + ): Promise; + }; + }; +} + +export interface BootstrapHandlerSessionContext { + agentId: string; + conversationId: string; + model: string | undefined; + tools: string[]; + memfsEnabled: boolean; + sessionId: string; +} + +export interface HandleBootstrapParams { + bootstrapReq: BootstrapSessionStateRequest; + sessionContext: BootstrapHandlerSessionContext; + requestId: string; + client: BootstrapHandlerClient; + /** Optional: flag indicating a pending approval is waiting. */ + hasPendingApproval?: boolean; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Handler +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Execute a bootstrap_session_state control request and return the ControlResponse. + * + * Caller is responsible for serialising + writing to stdout: + * console.log(JSON.stringify(await handleBootstrapSessionState(params))); + */ +export async function handleBootstrapSessionState( + params: HandleBootstrapParams, +): Promise { + const { + bootstrapReq, + sessionContext, + requestId, + client, + hasPendingApproval, + } = params; + + const bootstrapStart = Date.now(); + + const limit = bootstrapReq.limit ?? 50; + const order = bootstrapReq.order ?? "desc"; + + try { + // Reuse the same routing logic as list_messages for consistency + const route = resolveListMessagesRoute( + { conversation_id: undefined, agent_id: sessionContext.agentId }, + sessionContext.conversationId, + sessionContext.agentId, + ); + + 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 listEnd = Date.now(); + + const hasMore = items.length >= limit; + // When order=desc, newest first; oldest item is at the end of the array. + const oldestId = + items.length > 0 + ? (items[items.length - 1] as { id?: string })?.id + : undefined; + + const bootstrapEnd = Date.now(); + + const payload: BootstrapSessionStatePayload = { + agent_id: sessionContext.agentId, + conversation_id: sessionContext.conversationId, + model: sessionContext.model, + tools: sessionContext.tools, + memfs_enabled: sessionContext.memfsEnabled, + messages: items, + next_before: oldestId ?? null, + has_more: hasMore, + has_pending_approval: hasPendingApproval ?? false, + timings: { + resolve_ms: listStart - bootstrapStart, + list_messages_ms: listEnd - listStart, + total_ms: bootstrapEnd - bootstrapStart, + }, + }; + + return { + type: "control_response", + response: { + subtype: "success", + request_id: requestId, + response: payload as unknown as Record, + }, + session_id: sessionContext.sessionId, + uuid: randomUUID(), + }; + } catch (err) { + return { + type: "control_response", + response: { + subtype: "error", + request_id: requestId, + error: + err instanceof Error ? err.message : "bootstrap_session_state failed", + }, + session_id: sessionContext.sessionId, + uuid: randomUUID(), + }; + } +} diff --git a/src/headless.ts b/src/headless.ts index 4313d6b..007b9e9 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -18,6 +18,7 @@ import { parseRetryAfterHeaderMs, shouldRetryRunMetadataError, } from "./agent/approval-recovery"; +import { handleBootstrapSessionState } from "./agent/bootstrapHandler"; import { getClient } from "./agent/client"; import { setAgentContext, setConversationId } from "./agent/context"; import { createAgent } from "./agent/create"; @@ -77,6 +78,7 @@ import { } from "./tools/manager"; import type { AutoApprovalMessage, + BootstrapSessionStateRequest, CanUseToolControlRequest, CanUseToolResponse, ControlRequest, @@ -286,6 +288,7 @@ export async function handleHeadlessCommand( memfs: { type: "boolean" }, "no-memfs": { type: "boolean" }, + "memfs-startup": { type: "string" }, // "blocking" | "background" | "skip" "no-skills": { type: "boolean" }, "no-bundled-skills": { type: "boolean" }, "no-system-info-reminder": { type: "boolean" }, @@ -436,6 +439,15 @@ export async function handleHeadlessCommand( const skillSourcesRaw = values["skill-sources"] as string | undefined; const memfsFlag = values.memfs as boolean | undefined; const noMemfsFlag = values["no-memfs"] as boolean | undefined; + // Startup policy for the git-backed memory pull on session init. + // "blocking" (default): await the pull before proceeding. + // "background": fire the pull async, emit init without waiting. + // "skip": skip the pull entirely this session. + const memfsStartupRaw = values["memfs-startup"] as string | undefined; + const memfsStartupPolicy: "blocking" | "background" | "skip" = + memfsStartupRaw === "background" || memfsStartupRaw === "skip" + ? memfsStartupRaw + : "blocking"; const requestedMemoryPromptMode: "memfs" | "standard" | undefined = memfsFlag ? "memfs" : noMemfsFlag @@ -955,25 +967,58 @@ export async function handleHeadlessCommand( const isSubagent = process.env.LETTA_CODE_AGENT_ROLE === "subagent"; // Apply memfs flags and auto-enable from server tag when local settings are missing. - try { - const { applyMemfsFlags } = await import("./agent/memoryFilesystem"); - const memfsResult = await applyMemfsFlags( - agent.id, - memfsFlag, - noMemfsFlag, - { pullOnExistingRepo: true, agentTags: agent.tags }, - ); - if (memfsResult.pullSummary?.includes("CONFLICT")) { + // Respects memfsStartupPolicy: + // "blocking" (default) – await the pull; exit on conflict. + // "background" – fire pull async; session init proceeds immediately. + // "skip" – skip the pull this session. + if (memfsStartupPolicy === "skip") { + // Run enable/disable logic but skip the git pull. + try { + const { applyMemfsFlags } = await import("./agent/memoryFilesystem"); + await applyMemfsFlags(agent.id, memfsFlag, noMemfsFlag, { + pullOnExistingRepo: false, + agentTags: agent.tags, + }); + } catch (error) { console.error( - "Memory has merge conflicts. Run in interactive mode to resolve.", + `Memory flags failed: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exit(1); + } + } else if (memfsStartupPolicy === "background") { + // Fire pull async; don't block session initialisation. + const { applyMemfsFlags } = await import("./agent/memoryFilesystem"); + applyMemfsFlags(agent.id, memfsFlag, noMemfsFlag, { + pullOnExistingRepo: true, + agentTags: agent.tags, + }).catch((error) => { + // Log to stderr only — the session is already live. + console.error( + `[memfs background pull] ${error instanceof Error ? error.message : String(error)}`, + ); + }); + } else { + // "blocking" — original behaviour. + try { + const { applyMemfsFlags } = await import("./agent/memoryFilesystem"); + const memfsResult = await applyMemfsFlags( + agent.id, + memfsFlag, + noMemfsFlag, + { pullOnExistingRepo: true, agentTags: agent.tags }, + ); + if (memfsResult.pullSummary?.includes("CONFLICT")) { + console.error( + "Memory has merge conflicts. Run in interactive mode to resolve.", + ); + process.exit(1); + } + } catch (error) { + console.error( + `Memory git sync failed: ${error instanceof Error ? error.message : String(error)}`, ); process.exit(1); } - } catch (error) { - console.error( - `Memory git sync failed: ${error instanceof Error ? error.message : String(error)}`, - ); - process.exit(1); } try { @@ -2711,6 +2756,23 @@ async function runBidirectionalMode( uuid: randomUUID(), }; console.log(JSON.stringify(registerResponse)); + } else if (subtype === "bootstrap_session_state") { + const bootstrapReq = message.request as BootstrapSessionStateRequest; + const bootstrapResp = await handleBootstrapSessionState({ + bootstrapReq, + sessionContext: { + agentId: agent.id, + conversationId, + model: agent.llm_config?.model, + tools: availableTools, + memfsEnabled: settingsManager.isMemfsEnabled(agent.id), + sessionId, + }, + requestId: requestId ?? "", + client, + hasPendingApproval: false, // TODO: wire approval state when available + }); + console.log(JSON.stringify(bootstrapResp)); } else if (subtype === "list_messages") { const listReq = message.request as ListMessagesControlRequest; const listResp = await handleListMessages({ diff --git a/src/tests/headless/bootstrap-handler.test.ts b/src/tests/headless/bootstrap-handler.test.ts new file mode 100644 index 0000000..7e30f95 --- /dev/null +++ b/src/tests/headless/bootstrap-handler.test.ts @@ -0,0 +1,318 @@ +/** + * Handler-level tests for bootstrap_session_state using mock Letta clients. + * + * Verifies: + * 1. Correct routing (conversations vs agents path based on session conversationId) + * 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" + * 7. Explicit conversation uses conversations.messages.list + * + * No network. No CLI subprocess. No process.stdout. + */ +import { describe, expect, mock, test } from "bun:test"; +import type { + BootstrapHandlerClient, + BootstrapHandlerSessionContext, +} from "../../agent/bootstrapHandler"; +import { handleBootstrapSessionState } from "../../agent/bootstrapHandler"; + +// ───────────────────────────────────────────────────────────────────────────── +// Mock factory +// ───────────────────────────────────────────────────────────────────────────── + +function makeClient( + convMessages: unknown[] = [], + agentMessages: unknown[] = [], +): { + client: BootstrapHandlerClient; + convListSpy: ReturnType; + agentListSpy: ReturnType; +} { + const convListSpy = mock(async () => ({ + getPaginatedItems: () => convMessages, + })); + const agentListSpy = mock(async () => ({ + items: agentMessages, + })); + + const client: BootstrapHandlerClient = { + conversations: { + messages: { + list: convListSpy as unknown as BootstrapHandlerClient["conversations"]["messages"]["list"], + }, + }, + agents: { + messages: { + list: agentListSpy as unknown as BootstrapHandlerClient["agents"]["messages"]["list"], + }, + }, + }; + + return { client, convListSpy, agentListSpy }; +} + +const BASE_CTX: BootstrapHandlerSessionContext = { + agentId: "agent-test-123", + conversationId: "default", + model: "anthropic/claude-sonnet-4-5", + tools: ["Bash", "Read", "Write"], + memfsEnabled: false, + sessionId: "sess-test", +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Routing +// ───────────────────────────────────────────────────────────────────────────── + +describe("bootstrap_session_state routing", () => { + test("default conversation uses agents.messages.list", async () => { + const { client, agentListSpy, convListSpy } = makeClient( + [], + [{ id: "msg-1", type: "user_message" }], + ); + + await handleBootstrapSessionState({ + bootstrapReq: { subtype: "bootstrap_session_state" }, + sessionContext: { ...BASE_CTX, conversationId: "default" }, + requestId: "req-1", + client, + }); + + expect(agentListSpy).toHaveBeenCalledTimes(1); + expect(convListSpy).toHaveBeenCalledTimes(0); + + // 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"); + }); + + test("named conversation uses conversations.messages.list", async () => { + const { client, convListSpy, agentListSpy } = makeClient([ + { id: "msg-1", type: "user_message" }, + ]); + + await handleBootstrapSessionState({ + bootstrapReq: { subtype: "bootstrap_session_state" }, + sessionContext: { ...BASE_CTX, conversationId: "conv-abc-123" }, + requestId: "req-2", + client, + }); + + expect(convListSpy).toHaveBeenCalledTimes(1); + expect(agentListSpy).toHaveBeenCalledTimes(0); + + const callArgs = (convListSpy.mock.calls[0] as unknown[])[0]; + expect(callArgs).toBe("conv-abc-123"); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Response shape +// ───────────────────────────────────────────────────────────────────────────── + +describe("bootstrap_session_state response shape", () => { + test("success response includes all required fields", async () => { + const messages = [ + { id: "msg-3", type: "assistant_message" }, + { id: "msg-2", type: "user_message" }, + { id: "msg-1", type: "user_message" }, + ]; + const { client } = makeClient([], messages); + + const resp = await handleBootstrapSessionState({ + bootstrapReq: { subtype: "bootstrap_session_state" }, + sessionContext: BASE_CTX, + requestId: "req-3", + client, + }); + + expect(resp.type).toBe("control_response"); + expect(resp.response.subtype).toBe("success"); + expect(resp.response.request_id).toBe("req-3"); + expect(resp.session_id).toBe("sess-test"); + expect(typeof resp.uuid).toBe("string"); + + const payload = (resp.response as { response: Record }) + .response; + expect(payload.agent_id).toBe("agent-test-123"); + expect(payload.conversation_id).toBe("default"); + expect(payload.model).toBe("anthropic/claude-sonnet-4-5"); + expect(payload.tools).toEqual(["Bash", "Read", "Write"]); + expect(payload.memfs_enabled).toBe(false); + expect(Array.isArray(payload.messages)).toBe(true); + expect((payload.messages as unknown[]).length).toBe(3); + expect(typeof payload.has_more).toBe("boolean"); + expect(typeof payload.has_pending_approval).toBe("boolean"); + }); + + test("has_pending_approval defaults to false", async () => { + const { client } = makeClient(); + const resp = await handleBootstrapSessionState({ + bootstrapReq: { subtype: "bootstrap_session_state" }, + sessionContext: BASE_CTX, + requestId: "req-4", + client, + }); + const payload = (resp.response as { response: Record }) + .response; + expect(payload.has_pending_approval).toBe(false); + }); + + test("has_pending_approval reflects caller-provided value", async () => { + const { client } = makeClient(); + const resp = await handleBootstrapSessionState({ + bootstrapReq: { subtype: "bootstrap_session_state" }, + sessionContext: BASE_CTX, + requestId: "req-5", + client, + hasPendingApproval: true, + }); + const payload = (resp.response as { response: Record }) + .response; + expect(payload.has_pending_approval).toBe(true); + }); + + test("timings are present and numeric", async () => { + const { client } = makeClient(); + const resp = await handleBootstrapSessionState({ + bootstrapReq: { subtype: "bootstrap_session_state" }, + sessionContext: BASE_CTX, + requestId: "req-6", + client, + }); + const payload = (resp.response as { response: Record }) + .response; + const timings = payload.timings as Record; + expect(typeof timings).toBe("object"); + expect(typeof timings.resolve_ms).toBe("number"); + expect(typeof timings.list_messages_ms).toBe("number"); + expect(typeof timings.total_ms).toBe("number"); + // Sanity: total_ms >= list_messages_ms + expect(timings.total_ms as number).toBeGreaterThanOrEqual( + timings.list_messages_ms as number, + ); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Pagination +// ───────────────────────────────────────────────────────────────────────────── + +describe("bootstrap_session_state pagination", () => { + test("has_more is false when messages < limit", async () => { + const messages = Array.from({ length: 5 }, (_, i) => ({ + id: `msg-${i}`, + type: "user_message", + })); + const { client } = makeClient([], messages); + + const resp = await handleBootstrapSessionState({ + bootstrapReq: { subtype: "bootstrap_session_state", limit: 50 }, + sessionContext: BASE_CTX, + requestId: "req-7", + client, + }); + const payload = (resp.response as { response: Record }) + .response; + expect(payload.has_more).toBe(false); + }); + + test("has_more is true when messages === limit", async () => { + const limit = 10; + const messages = Array.from({ length: limit }, (_, i) => ({ + id: `msg-${i}`, + })); + const { client } = makeClient([], messages); + + const resp = await handleBootstrapSessionState({ + bootstrapReq: { subtype: "bootstrap_session_state", limit }, + sessionContext: BASE_CTX, + requestId: "req-8", + client, + }); + const payload = (resp.response as { response: Record }) + .response; + expect(payload.has_more).toBe(true); + }); + + test("next_before is last message id when messages present", async () => { + const messages = [ + { id: "msg-newest" }, + { id: "msg-middle" }, + { id: "msg-oldest" }, + ]; + const { client } = makeClient([], messages); + + const resp = await handleBootstrapSessionState({ + bootstrapReq: { subtype: "bootstrap_session_state" }, + sessionContext: BASE_CTX, + requestId: "req-9", + client, + }); + const payload = (resp.response as { response: Record }) + .response; + // Last item in array is oldest when order=desc + expect(payload.next_before).toBe("msg-oldest"); + }); + + test("next_before is null when no messages", async () => { + const { client } = makeClient([], []); + const resp = await handleBootstrapSessionState({ + bootstrapReq: { subtype: "bootstrap_session_state" }, + sessionContext: BASE_CTX, + requestId: "req-10", + client, + }); + const payload = (resp.response as { response: Record }) + .response; + expect(payload.next_before).toBeNull(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Error path +// ───────────────────────────────────────────────────────────────────────────── + +describe("bootstrap_session_state error handling", () => { + test("client error returns error envelope", async () => { + const throwingClient: BootstrapHandlerClient = { + conversations: { + messages: { + list: async () => { + throw new Error("Network timeout"); + }, + }, + }, + agents: { + messages: { + list: async () => { + throw new Error("Network timeout"); + }, + }, + }, + }; + + const resp = await handleBootstrapSessionState({ + bootstrapReq: { subtype: "bootstrap_session_state" }, + sessionContext: BASE_CTX, + requestId: "req-err", + client: throwingClient, + }); + + expect(resp.type).toBe("control_response"); + expect(resp.response.subtype).toBe("error"); + const errorResp = resp.response as { + subtype: "error"; + error: string; + request_id: string; + }; + expect(errorResp.error).toContain("Network timeout"); + expect(errorResp.request_id).toBe("req-err"); + }); +}); diff --git a/src/types/protocol.ts b/src/types/protocol.ts index e1ef358..4163553 100644 --- a/src/types/protocol.ts +++ b/src/types/protocol.ts @@ -271,8 +271,55 @@ export type SdkToCliControlRequest = | { subtype: "initialize" } | { subtype: "interrupt" } | RegisterExternalToolsRequest + | BootstrapSessionStateRequest | ListMessagesControlRequest; +/** + * Request to bootstrap session state (SDK → CLI). + * Returns resolved session metadata, initial history page, and optional pending + * approval snapshot — all in a single round-trip to minimise cold-open latency. + */ +export interface BootstrapSessionStateRequest { + subtype: "bootstrap_session_state"; + /** Max messages to include in the initial history page. Defaults to 50. */ + limit?: number; + /** Sort order for initial history page. Defaults to "desc". */ + order?: "asc" | "desc"; +} + +/** + * Successful bootstrap_session_state response payload. + */ +export interface BootstrapSessionStatePayload { + /** Resolved agent ID for this session. */ + agent_id: string; + /** Resolved conversation ID for this session. */ + conversation_id: string; + /** LLM model handle. */ + model: string | undefined; + /** Tool names registered on the agent. */ + tools: string[]; + /** Whether memfs (git-backed memory) is enabled. */ + memfs_enabled: boolean; + /** Initial history page (same shape as list_messages response). */ + messages: unknown[]; + /** Cursor to fetch older messages (null if none). */ + next_before: string | null; + /** Whether more history pages exist. */ + has_more: boolean; + /** Whether there is a pending approval waiting for a response. */ + has_pending_approval: boolean; + /** Optional wall-clock timings in milliseconds. */ + timings?: { + /** Time to resolve agent + conversation context. */ + resolve_ms: number; + /** Time to fetch the initial message page. */ + list_messages_ms: number; + /** Total bootstrap wall-clock time. */ + total_ms: number; + }; +} + /** * Request to list conversation messages (SDK → CLI). * Returns paginated messages from a specific conversation.