feat(headless): add bootstrap_session_state + memfs startup policy (#1104)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
185
src/agent/bootstrapHandler.ts
Normal file
185
src/agent/bootstrapHandler.ts
Normal file
@@ -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<BootstrapMessagesPage>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
agents: {
|
||||||
|
messages: {
|
||||||
|
list(
|
||||||
|
agentId: string,
|
||||||
|
opts: {
|
||||||
|
limit: number;
|
||||||
|
order: "asc" | "desc";
|
||||||
|
before?: string;
|
||||||
|
after?: string;
|
||||||
|
conversation_id?: "default";
|
||||||
|
},
|
||||||
|
): Promise<BootstrapAgentsPage>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ControlResponse> {
|
||||||
|
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<string, unknown>,
|
||||||
|
},
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
parseRetryAfterHeaderMs,
|
parseRetryAfterHeaderMs,
|
||||||
shouldRetryRunMetadataError,
|
shouldRetryRunMetadataError,
|
||||||
} from "./agent/approval-recovery";
|
} from "./agent/approval-recovery";
|
||||||
|
import { handleBootstrapSessionState } from "./agent/bootstrapHandler";
|
||||||
import { getClient } from "./agent/client";
|
import { getClient } from "./agent/client";
|
||||||
import { setAgentContext, setConversationId } from "./agent/context";
|
import { setAgentContext, setConversationId } from "./agent/context";
|
||||||
import { createAgent } from "./agent/create";
|
import { createAgent } from "./agent/create";
|
||||||
@@ -77,6 +78,7 @@ import {
|
|||||||
} from "./tools/manager";
|
} from "./tools/manager";
|
||||||
import type {
|
import type {
|
||||||
AutoApprovalMessage,
|
AutoApprovalMessage,
|
||||||
|
BootstrapSessionStateRequest,
|
||||||
CanUseToolControlRequest,
|
CanUseToolControlRequest,
|
||||||
CanUseToolResponse,
|
CanUseToolResponse,
|
||||||
ControlRequest,
|
ControlRequest,
|
||||||
@@ -286,6 +288,7 @@ export async function handleHeadlessCommand(
|
|||||||
|
|
||||||
memfs: { type: "boolean" },
|
memfs: { type: "boolean" },
|
||||||
"no-memfs": { type: "boolean" },
|
"no-memfs": { type: "boolean" },
|
||||||
|
"memfs-startup": { type: "string" }, // "blocking" | "background" | "skip"
|
||||||
"no-skills": { type: "boolean" },
|
"no-skills": { type: "boolean" },
|
||||||
"no-bundled-skills": { type: "boolean" },
|
"no-bundled-skills": { type: "boolean" },
|
||||||
"no-system-info-reminder": { 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 skillSourcesRaw = values["skill-sources"] as string | undefined;
|
||||||
const memfsFlag = values.memfs as boolean | undefined;
|
const memfsFlag = values.memfs as boolean | undefined;
|
||||||
const noMemfsFlag = values["no-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
|
const requestedMemoryPromptMode: "memfs" | "standard" | undefined = memfsFlag
|
||||||
? "memfs"
|
? "memfs"
|
||||||
: noMemfsFlag
|
: noMemfsFlag
|
||||||
@@ -955,25 +967,58 @@ export async function handleHeadlessCommand(
|
|||||||
const isSubagent = process.env.LETTA_CODE_AGENT_ROLE === "subagent";
|
const isSubagent = process.env.LETTA_CODE_AGENT_ROLE === "subagent";
|
||||||
|
|
||||||
// Apply memfs flags and auto-enable from server tag when local settings are missing.
|
// Apply memfs flags and auto-enable from server tag when local settings are missing.
|
||||||
try {
|
// Respects memfsStartupPolicy:
|
||||||
const { applyMemfsFlags } = await import("./agent/memoryFilesystem");
|
// "blocking" (default) – await the pull; exit on conflict.
|
||||||
const memfsResult = await applyMemfsFlags(
|
// "background" – fire pull async; session init proceeds immediately.
|
||||||
agent.id,
|
// "skip" – skip the pull this session.
|
||||||
memfsFlag,
|
if (memfsStartupPolicy === "skip") {
|
||||||
noMemfsFlag,
|
// Run enable/disable logic but skip the git pull.
|
||||||
{ pullOnExistingRepo: true, agentTags: agent.tags },
|
try {
|
||||||
);
|
const { applyMemfsFlags } = await import("./agent/memoryFilesystem");
|
||||||
if (memfsResult.pullSummary?.includes("CONFLICT")) {
|
await applyMemfsFlags(agent.id, memfsFlag, noMemfsFlag, {
|
||||||
|
pullOnExistingRepo: false,
|
||||||
|
agentTags: agent.tags,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
console.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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
`Memory git sync failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -2711,6 +2756,23 @@ async function runBidirectionalMode(
|
|||||||
uuid: randomUUID(),
|
uuid: randomUUID(),
|
||||||
};
|
};
|
||||||
console.log(JSON.stringify(registerResponse));
|
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") {
|
} else if (subtype === "list_messages") {
|
||||||
const listReq = message.request as ListMessagesControlRequest;
|
const listReq = message.request as ListMessagesControlRequest;
|
||||||
const listResp = await handleListMessages({
|
const listResp = await handleListMessages({
|
||||||
|
|||||||
318
src/tests/headless/bootstrap-handler.test.ts
Normal file
318
src/tests/headless/bootstrap-handler.test.ts
Normal file
@@ -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<typeof mock>;
|
||||||
|
agentListSpy: ReturnType<typeof mock>;
|
||||||
|
} {
|
||||||
|
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<string, unknown> })
|
||||||
|
.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<string, unknown> })
|
||||||
|
.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<string, unknown> })
|
||||||
|
.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<string, unknown> })
|
||||||
|
.response;
|
||||||
|
const timings = payload.timings as Record<string, unknown>;
|
||||||
|
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<string, unknown> })
|
||||||
|
.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<string, unknown> })
|
||||||
|
.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<string, unknown> })
|
||||||
|
.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<string, unknown> })
|
||||||
|
.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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -271,8 +271,55 @@ export type SdkToCliControlRequest =
|
|||||||
| { subtype: "initialize" }
|
| { subtype: "initialize" }
|
||||||
| { subtype: "interrupt" }
|
| { subtype: "interrupt" }
|
||||||
| RegisterExternalToolsRequest
|
| RegisterExternalToolsRequest
|
||||||
|
| BootstrapSessionStateRequest
|
||||||
| ListMessagesControlRequest;
|
| 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).
|
* Request to list conversation messages (SDK → CLI).
|
||||||
* Returns paginated messages from a specific conversation.
|
* Returns paginated messages from a specific conversation.
|
||||||
|
|||||||
Reference in New Issue
Block a user