feat: add typed wire format for stream-json protocol (#445)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-01-01 20:05:41 -08:00
committed by GitHub
parent 47e4734ba1
commit 397560ef00
7 changed files with 618 additions and 256 deletions

View File

@@ -75,6 +75,11 @@ if (existsSync(bundledSkillsSrc)) {
console.log("📂 Copied bundled skills to skills/"); console.log("📂 Copied bundled skills to skills/");
} }
// Generate type declarations for wire types export
console.log("📝 Generating type declarations...");
await Bun.$`bunx tsc -p tsconfig.types.json`;
console.log(" Output: dist/types/wire.d.ts");
console.log("✅ Build complete!"); console.log("✅ Build complete!");
console.log(` Output: letta.js`); console.log(` Output: letta.js`);
console.log(` Size: ${(Bun.file(outputPath).size / 1024).toFixed(0)}KB`); console.log(` Size: ${(Bun.file(outputPath).size / 1024).toFixed(0)}KB`);

View File

@@ -12,8 +12,15 @@
"letta.js", "letta.js",
"scripts", "scripts",
"skills", "skills",
"vendor" "vendor",
"dist/types"
], ],
"exports": {
".": "./letta.js",
"./wire-types": {
"types": "./dist/types/wire.d.ts"
}
},
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/letta-ai/letta-code.git" "url": "https://github.com/letta-ai/letta-code.git"

View File

@@ -4,7 +4,10 @@ import type {
AgentState, AgentState,
MessageCreate, MessageCreate,
} from "@letta-ai/letta-client/resources/agents/agents"; } from "@letta-ai/letta-client/resources/agents/agents";
import type { ApprovalCreate } from "@letta-ai/letta-client/resources/agents/messages"; import type {
ApprovalCreate,
LettaStreamingResponse,
} from "@letta-ai/letta-client/resources/agents/messages";
import type { StopReasonType } from "@letta-ai/letta-client/resources/runs/runs"; import type { StopReasonType } from "@letta-ai/letta-client/resources/runs/runs";
import type { ApprovalResult } from "./agent/approval-execution"; import type { ApprovalResult } from "./agent/approval-execution";
import { getClient } from "./agent/client"; import { getClient } from "./agent/client";
@@ -28,6 +31,16 @@ import {
forceUpsertTools, forceUpsertTools,
isToolsNotFoundError, isToolsNotFoundError,
} from "./tools/manager"; } from "./tools/manager";
import type {
AutoApprovalMessage,
ControlResponse,
ErrorMessage,
MessageWire,
ResultMessage,
RetryMessage,
StreamEvent,
SystemInitMessage,
} from "./types/wire";
// Maximum number of times to retry a turn when the backend // Maximum number of times to retry a turn when the backend
// reports an `llm_api_error` stop reason. This helps smooth // reports an `llm_api_error` stop reason. This helps smooth
@@ -434,14 +447,18 @@ export async function handleHeadlessCommand(
// Output init event for stream-json format // Output init event for stream-json format
if (outputFormat === "stream-json") { if (outputFormat === "stream-json") {
const initEvent = { const initEvent: SystemInitMessage = {
type: "system", type: "system",
subtype: "init", subtype: "init",
session_id: sessionId, session_id: sessionId,
agent_id: agent.id, agent_id: agent.id,
model: agent.llm_config?.model, model: agent.llm_config?.model ?? "",
tools: agent.tools?.map((t) => t.name) || [], tools:
agent.tools?.map((t) => t.name).filter((n): n is string => !!n) || [],
cwd: process.cwd(), cwd: process.cwd(),
mcp_servers: [],
permission_mode: "",
slash_commands: [],
uuid: `init-${agent.id}`, uuid: `init-${agent.id}`,
}; };
console.log(JSON.stringify(initEvent)); console.log(JSON.stringify(initEvent));
@@ -468,6 +485,8 @@ export async function handleHeadlessCommand(
toolName: string; toolName: string;
toolArgs: string; toolArgs: string;
}; };
reason: string;
matchedRule: string;
} }
| { | {
type: "deny"; type: "deny";
@@ -523,6 +542,8 @@ export async function handleHeadlessCommand(
decisions.push({ decisions.push({
type: "approve", type: "approve",
approval: currentApproval, approval: currentApproval,
reason: permission.reason || "Allowed by permission rule",
matchedRule: permission.matchedRule || "auto-approved",
}); });
} }
@@ -535,16 +556,19 @@ export async function handleHeadlessCommand(
if (outputFormat === "stream-json") { if (outputFormat === "stream-json") {
for (const decision of decisions) { for (const decision of decisions) {
if (decision.type === "approve") { if (decision.type === "approve") {
console.log( const autoApprovalMsg: AutoApprovalMessage = {
JSON.stringify({ type: "auto_approval",
type: "auto_approval", tool_call: {
tool_name: decision.approval.toolName, name: decision.approval.toolName,
tool_call_id: decision.approval.toolCallId, tool_call_id: decision.approval.toolCallId,
tool_args: decision.approval.toolArgs, arguments: decision.approval.toolArgs,
session_id: sessionId, },
uuid: `auto-approval-${decision.approval.toolCallId}`, reason: decision.reason,
}), matched_rule: decision.matchedRule,
); session_id: sessionId,
uuid: `auto-approval-${decision.approval.toolCallId}`,
};
console.log(JSON.stringify(autoApprovalMsg));
} }
} }
} }
@@ -640,28 +664,54 @@ export async function handleHeadlessCommand(
runIds.add(chunk.run_id); runIds.add(chunk.run_id);
} }
// Detect mid-stream errors (errors without message_type) // Detect mid-stream errors
// Case 1: LettaErrorMessage from the API (has message_type: "error_message")
if (
"message_type" in chunk &&
chunk.message_type === "error_message"
) {
// This is a LettaErrorMessage - nest it in our wire format
const apiError = chunk as LettaStreamingResponse.LettaErrorMessage;
const errorEvent: ErrorMessage = {
type: "error",
message: apiError.message,
stop_reason: "error",
run_id: apiError.run_id,
api_error: apiError,
session_id: sessionId,
uuid: crypto.randomUUID(),
};
console.log(JSON.stringify(errorEvent));
// Still accumulate for tracking
const { onChunk: accumulatorOnChunk } = await import(
"./cli/helpers/accumulator"
);
accumulatorOnChunk(buffers, chunk);
continue;
}
// Case 2: Generic error object without message_type
const chunkWithError = chunk as typeof chunk & { const chunkWithError = chunk as typeof chunk & {
error?: { message?: string; detail?: string }; error?: { message?: string; detail?: string };
}; };
if (chunkWithError.error && !chunk.message_type) { if (chunkWithError.error && !("message_type" in chunk)) {
// Emit as error event // Emit as error event
const errorMsg = const errorText =
chunkWithError.error.message || "An error occurred"; chunkWithError.error.message || "An error occurred";
const errorDetail = chunkWithError.error.detail || ""; const errorDetail = chunkWithError.error.detail || "";
const fullErrorText = errorDetail const fullErrorText = errorDetail
? `${errorMsg}: ${errorDetail}` ? `${errorText}: ${errorDetail}`
: errorMsg; : errorText;
console.log( const errorEvent: ErrorMessage = {
JSON.stringify({ type: "error",
type: "error", message: fullErrorText,
message: fullErrorText, stop_reason: "error",
detail: errorDetail, session_id: sessionId,
session_id: sessionId, uuid: crypto.randomUUID(),
uuid: crypto.randomUUID(), };
}), console.log(JSON.stringify(errorEvent));
);
// Still accumulate for tracking // Still accumulate for tracking
const { onChunk: accumulatorOnChunk } = await import( const { onChunk: accumulatorOnChunk } = await import(
@@ -786,18 +836,19 @@ export async function handleHeadlessCommand(
); );
if (missing.length === 0) { if (missing.length === 0) {
shouldOutputChunk = false; shouldOutputChunk = false;
console.log( const autoApprovalMsg: AutoApprovalMessage = {
JSON.stringify({ type: "auto_approval",
type: "auto_approval", tool_call: {
tool_name: nextName, name: nextName,
tool_call_id: id, tool_call_id: id,
tool_args: incomingArgs, arguments: incomingArgs || "{}",
reason: permission.reason, },
matched_rule: permission.matchedRule, reason: permission.reason || "Allowed by permission rule",
session_id: sessionId, matched_rule: permission.matchedRule || "auto-approved",
uuid: `auto-approval-${id}`, session_id: sessionId,
}), uuid: `auto-approval-${id}`,
); };
console.log(JSON.stringify(autoApprovalMsg));
autoApprovalEmitted.add(id); autoApprovalEmitted.add(id);
} }
} }
@@ -816,24 +867,22 @@ export async function handleHeadlessCommand(
if (includePartialMessages) { if (includePartialMessages) {
// Emit as stream_event wrapper (like Claude Code with --include-partial-messages) // Emit as stream_event wrapper (like Claude Code with --include-partial-messages)
console.log( const streamEvent: StreamEvent = {
JSON.stringify({ type: "stream_event",
type: "stream_event", event: chunk,
event: chunk, session_id: sessionId,
session_id: sessionId, uuid: uuid || crypto.randomUUID(),
uuid, };
}), console.log(JSON.stringify(streamEvent));
);
} else { } else {
// Emit as regular message (default) // Emit as regular message (default)
console.log( const msg: MessageWire = {
JSON.stringify({ type: "message",
type: "message", ...chunk,
...chunk, session_id: sessionId,
session_id: sessionId, uuid: uuid || crypto.randomUUID(),
uuid, };
}), console.log(JSON.stringify(msg));
);
} }
} }
@@ -991,18 +1040,17 @@ export async function handleHeadlessCommand(
llmApiErrorRetries = attempt; llmApiErrorRetries = attempt;
if (outputFormat === "stream-json") { if (outputFormat === "stream-json") {
console.log( const retryMsg: RetryMessage = {
JSON.stringify({ type: "retry",
type: "retry", reason: "llm_api_error",
reason: "llm_api_error", attempt,
attempt, max_attempts: LLM_API_ERROR_MAX_RETRIES,
max_attempts: LLM_API_ERROR_MAX_RETRIES, delay_ms: delayMs,
delay_ms: delayMs, run_id: lastRunId ?? undefined,
run_id: lastRunId, session_id: sessionId,
session_id: sessionId, uuid: `retry-${lastRunId || crypto.randomUUID()}`,
uuid: `retry-${lastRunId || crypto.randomUUID()}`, };
}), console.log(JSON.stringify(retryMsg));
);
} else { } else {
const delaySeconds = Math.round(delayMs / 1000); const delaySeconds = Math.round(delayMs / 1000);
console.error( console.error(
@@ -1065,18 +1113,17 @@ export async function handleHeadlessCommand(
llmApiErrorRetries = attempt; llmApiErrorRetries = attempt;
if (outputFormat === "stream-json") { if (outputFormat === "stream-json") {
console.log( const retryMsg: RetryMessage = {
JSON.stringify({ type: "retry",
type: "retry", reason: "llm_api_error",
reason: "llm_api_error", attempt,
attempt, max_attempts: LLM_API_ERROR_MAX_RETRIES,
max_attempts: LLM_API_ERROR_MAX_RETRIES, delay_ms: delayMs,
delay_ms: delayMs, run_id: lastRunId ?? undefined,
run_id: lastRunId, session_id: sessionId,
session_id: sessionId, uuid: `retry-${lastRunId || crypto.randomUUID()}`,
uuid: `retry-${lastRunId || crypto.randomUUID()}`, };
}), console.log(JSON.stringify(retryMsg));
);
} else { } else {
const delaySeconds = Math.round(delayMs / 1000); const delaySeconds = Math.round(delayMs / 1000);
console.error( console.error(
@@ -1135,16 +1182,15 @@ export async function handleHeadlessCommand(
if (outputFormat === "stream-json") { if (outputFormat === "stream-json") {
// Emit error event // Emit error event
console.log( const errorMsg: ErrorMessage = {
JSON.stringify({ type: "error",
type: "error", message: errorMessage,
message: errorMessage, stop_reason: stopReason,
stop_reason: stopReason, run_id: lastRunId ?? undefined,
run_id: lastRunId, session_id: sessionId,
session_id: sessionId, uuid: `error-${lastRunId || crypto.randomUUID()}`,
uuid: `error-${lastRunId || crypto.randomUUID()}`, };
}), console.log(JSON.stringify(errorMsg));
);
} else { } else {
console.error(`Error: ${errorMessage}`); console.error(`Error: ${errorMessage}`);
} }
@@ -1158,15 +1204,15 @@ export async function handleHeadlessCommand(
const errorDetails = formatErrorDetails(error, agent.id); const errorDetails = formatErrorDetails(error, agent.id);
if (outputFormat === "stream-json") { if (outputFormat === "stream-json") {
console.log( const errorMsg: ErrorMessage = {
JSON.stringify({ type: "error",
type: "error", message: errorDetails,
message: errorDetails, stop_reason: "error",
run_id: lastKnownRunId, run_id: lastKnownRunId ?? undefined,
session_id: sessionId, session_id: sessionId,
uuid: `error-${lastKnownRunId || crypto.randomUUID()}`, uuid: `error-${lastKnownRunId || crypto.randomUUID()}`,
}), };
); console.log(JSON.stringify(errorMsg));
} else { } else {
console.error(`Error: ${errorDetails}`); console.error(`Error: ${errorDetails}`);
} }
@@ -1250,10 +1296,9 @@ export async function handleHeadlessCommand(
allRunIds.size > 0 allRunIds.size > 0
? `result-${Array.from(allRunIds).pop()}` ? `result-${Array.from(allRunIds).pop()}`
: `result-${agent.id}`; : `result-${agent.id}`;
const resultEvent = { const resultEvent: ResultMessage = {
type: "result", type: "result",
subtype: "success", subtype: "success",
is_error: false,
session_id: sessionId, session_id: sessionId,
duration_ms: Math.round(stats.totalWallMs), duration_ms: Math.round(stats.totalWallMs),
duration_api_ms: Math.round(stats.totalApiMs), duration_api_ms: Math.round(stats.totalApiMs),
@@ -1330,14 +1375,14 @@ async function runBidirectionalMode(
try { try {
message = JSON.parse(line); message = JSON.parse(line);
} catch { } catch {
console.log( const errorMsg: ErrorMessage = {
JSON.stringify({ type: "error",
type: "error", message: "Invalid JSON input",
message: "Invalid JSON input", stop_reason: "error",
session_id: sessionId, session_id: sessionId,
uuid: crypto.randomUUID(), uuid: crypto.randomUUID(),
}), };
); console.log(JSON.stringify(errorMsg));
continue; continue;
} }
@@ -1348,52 +1393,49 @@ async function runBidirectionalMode(
if (subtype === "initialize") { if (subtype === "initialize") {
// Return session info // Return session info
console.log( const initResponse: ControlResponse = {
JSON.stringify({ type: "control_response",
type: "control_response", response: {
subtype: "success",
request_id: requestId ?? "",
response: { response: {
subtype: "success", agent_id: agent.id,
request_id: requestId, model: agent.llm_config?.model,
response: { tools: agent.tools?.map((t) => t.name) || [],
agent_id: agent.id,
model: agent.llm_config?.model,
tools: agent.tools?.map((t) => t.name) || [],
},
}, },
session_id: sessionId, },
uuid: crypto.randomUUID(), session_id: sessionId,
}), uuid: crypto.randomUUID(),
); };
console.log(JSON.stringify(initResponse));
} else if (subtype === "interrupt") { } else if (subtype === "interrupt") {
// Abort current operation if any // Abort current operation if any
if (currentAbortController !== null) { if (currentAbortController !== null) {
(currentAbortController as AbortController).abort(); (currentAbortController as AbortController).abort();
currentAbortController = null; currentAbortController = null;
} }
console.log( const interruptResponse: ControlResponse = {
JSON.stringify({ type: "control_response",
type: "control_response", response: {
response: { subtype: "success",
subtype: "success", request_id: requestId ?? "",
request_id: requestId, },
}, session_id: sessionId,
session_id: sessionId, uuid: crypto.randomUUID(),
uuid: crypto.randomUUID(), };
}), console.log(JSON.stringify(interruptResponse));
);
} else { } else {
console.log( const errorResponse: ControlResponse = {
JSON.stringify({ type: "control_response",
type: "control_response", response: {
response: { subtype: "error",
subtype: "error", request_id: requestId ?? "",
request_id: requestId, error: `Unknown control request subtype: ${subtype}`,
message: `Unknown control request subtype: ${subtype}`, },
}, session_id: sessionId,
session_id: sessionId, uuid: crypto.randomUUID(),
uuid: crypto.randomUUID(), };
}), console.log(JSON.stringify(errorResponse));
);
} }
continue; continue;
} }
@@ -1429,23 +1471,21 @@ async function runBidirectionalMode(
const uuid = chunkWithIds.otid || chunkWithIds.id; const uuid = chunkWithIds.otid || chunkWithIds.id;
if (includePartialMessages) { if (includePartialMessages) {
console.log( const streamEvent: StreamEvent = {
JSON.stringify({ type: "stream_event",
type: "stream_event", event: chunk,
event: chunk, session_id: sessionId,
session_id: sessionId, uuid: uuid || crypto.randomUUID(),
uuid, };
}), console.log(JSON.stringify(streamEvent));
);
} else { } else {
console.log( const msg: MessageWire = {
JSON.stringify({ type: "message",
type: "message", ...chunk,
...chunk, session_id: sessionId,
session_id: sessionId, uuid: uuid || crypto.randomUUID(),
uuid, };
}), console.log(JSON.stringify(msg));
);
} }
// Accumulate for result // Accumulate for result
@@ -1466,30 +1506,32 @@ async function runBidirectionalMode(
) as Extract<Line, { kind: "assistant" }> | undefined; ) as Extract<Line, { kind: "assistant" }> | undefined;
const resultText = lastAssistant?.text || ""; const resultText = lastAssistant?.text || "";
console.log( const resultMsg: ResultMessage = {
JSON.stringify({ type: "result",
type: "result", subtype: currentAbortController?.signal.aborted
subtype: currentAbortController?.signal.aborted ? "interrupted"
? "interrupted" : "success",
: "success", session_id: sessionId,
is_error: false, duration_ms: Math.round(durationMs),
session_id: sessionId, duration_api_ms: 0, // Not tracked in bidirectional mode
duration_ms: Math.round(durationMs), num_turns: 1,
result: resultText, result: resultText,
agent_id: agent.id, agent_id: agent.id,
uuid: `result-${agent.id}-${Date.now()}`, run_ids: [],
}), usage: null,
); uuid: `result-${agent.id}-${Date.now()}`,
};
console.log(JSON.stringify(resultMsg));
} catch (error) { } catch (error) {
console.log( const errorMsg: ErrorMessage = {
JSON.stringify({ type: "error",
type: "error", message:
message: error instanceof Error ? error.message : "Unknown error occurred",
error instanceof Error ? error.message : "Unknown error occurred", stop_reason: "error",
session_id: sessionId, session_id: sessionId,
uuid: crypto.randomUUID(), uuid: crypto.randomUUID(),
}), };
); console.log(JSON.stringify(errorMsg));
} finally { } finally {
currentAbortController = null; currentAbortController = null;
} }
@@ -1497,14 +1539,14 @@ async function runBidirectionalMode(
} }
// Unknown message type // Unknown message type
console.log( const errorMsg: ErrorMessage = {
JSON.stringify({ type: "error",
type: "error", message: `Unknown message type: ${message.type}`,
message: `Unknown message type: ${message.type}`, stop_reason: "error",
session_id: sessionId, session_id: sessionId,
uuid: crypto.randomUUID(), uuid: crypto.randomUUID(),
}), };
); console.log(JSON.stringify(errorMsg));
} }
// Stdin closed, exit gracefully // Stdin closed, exit gracefully

View File

@@ -1,9 +1,17 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import type {
ControlResponse,
ErrorMessage,
ResultMessage,
StreamEvent,
SystemInitMessage,
WireMessage,
} from "../types/wire";
/** /**
* Tests for --input-format stream-json bidirectional communication. * Tests for --input-format stream-json bidirectional communication.
* These verify the SDK can communicate with the CLI via stdin/stdout. * These verify the CLI's wire format for bidirectional communication.
*/ */
// Prescriptive prompt to ensure single-step response without tool use // Prescriptive prompt to ensure single-step response without tool use
@@ -57,7 +65,7 @@ async function runBidirectional(
let inputIndex = 0; let inputIndex = 0;
const writeNextInput = () => { const writeNextInput = () => {
if (inputIndex < inputs.length) { if (inputIndex < inputs.length) {
proc.stdin?.write(inputs[inputIndex] + "\n"); proc.stdin?.write(`${inputs[inputIndex]}\n`);
inputIndex++; inputIndex++;
setTimeout(writeNextInput, 1000); // 1s between inputs setTimeout(writeNextInput, 1000); // 1s between inputs
} else { } else {
@@ -108,32 +116,35 @@ describe("input-format stream-json", () => {
test( test(
"initialize control request returns session info", "initialize control request returns session info",
async () => { async () => {
const objects = await runBidirectional([ const objects = (await runBidirectional([
JSON.stringify({ JSON.stringify({
type: "control_request", type: "control_request",
request_id: "init_1", request_id: "init_1",
request: { subtype: "initialize" }, request: { subtype: "initialize" },
}), }),
]); ])) as WireMessage[];
// Should have init event // Should have init event
const initEvent = objects.find( const initEvent = objects.find(
(o: any) => o.type === "system" && o.subtype === "init", (o): o is SystemInitMessage =>
o.type === "system" && "subtype" in o && o.subtype === "init",
); );
expect(initEvent).toBeDefined(); expect(initEvent).toBeDefined();
expect((initEvent as any).agent_id).toBeDefined(); expect(initEvent?.agent_id).toBeDefined();
expect((initEvent as any).session_id).toBeDefined(); expect(initEvent?.session_id).toBeDefined();
expect((initEvent as any).model).toBeDefined(); expect(initEvent?.model).toBeDefined();
expect((initEvent as any).tools).toBeInstanceOf(Array); expect(initEvent?.tools).toBeInstanceOf(Array);
// Should have control_response // Should have control_response
const controlResponse = objects.find( const controlResponse = objects.find(
(o: any) => o.type === "control_response", (o): o is ControlResponse => o.type === "control_response",
); );
expect(controlResponse).toBeDefined(); expect(controlResponse).toBeDefined();
expect((controlResponse as any).response.subtype).toBe("success"); expect(controlResponse?.response.subtype).toBe("success");
expect((controlResponse as any).response.request_id).toBe("init_1"); expect(controlResponse?.response.request_id).toBe("init_1");
expect((controlResponse as any).response.response.agent_id).toBeDefined(); if (controlResponse?.response.subtype === "success") {
expect(controlResponse.response.response?.agent_id).toBeDefined();
}
}, },
{ timeout: 30000 }, { timeout: 30000 },
); );
@@ -141,7 +152,7 @@ describe("input-format stream-json", () => {
test( test(
"user message returns assistant response and result", "user message returns assistant response and result",
async () => { async () => {
const objects = await runBidirectional( const objects = (await runBidirectional(
[ [
JSON.stringify({ JSON.stringify({
type: "user", type: "user",
@@ -150,41 +161,47 @@ describe("input-format stream-json", () => {
], ],
[], [],
10000, 10000,
); )) as WireMessage[];
// Should have init event // Should have init event
const initEvent = objects.find( const initEvent = objects.find(
(o: any) => o.type === "system" && o.subtype === "init", (o): o is SystemInitMessage =>
o.type === "system" && "subtype" in o && o.subtype === "init",
); );
expect(initEvent).toBeDefined(); expect(initEvent).toBeDefined();
// Should have message events // Should have message events
const messageEvents = objects.filter((o: any) => o.type === "message"); const messageEvents = objects.filter(
(o): o is WireMessage & { type: "message" } => o.type === "message",
);
expect(messageEvents.length).toBeGreaterThan(0); expect(messageEvents.length).toBeGreaterThan(0);
// All messages should have session_id // All messages should have session_id
// uuid is present on content messages (reasoning, assistant) but not meta messages (stop_reason, usage_statistics) // uuid is present on content messages (reasoning, assistant) but not meta messages (stop_reason, usage_statistics)
for (const msg of messageEvents) { for (const msg of messageEvents) {
expect((msg as any).session_id).toBeDefined(); expect(msg.session_id).toBeDefined();
} }
// Content messages should have uuid // Content messages should have uuid
const contentMessages = messageEvents.filter( const contentMessages = messageEvents.filter(
(m: any) => (m) =>
m.message_type === "reasoning_message" || "message_type" in m &&
m.message_type === "assistant_message", (m.message_type === "reasoning_message" ||
m.message_type === "assistant_message"),
); );
for (const msg of contentMessages) { for (const msg of contentMessages) {
expect((msg as any).uuid).toBeDefined(); expect(msg.uuid).toBeDefined();
} }
// Should have result // Should have result
const result = objects.find((o: any) => o.type === "result"); const result = objects.find(
(o): o is ResultMessage => o.type === "result",
);
expect(result).toBeDefined(); expect(result).toBeDefined();
expect((result as any).subtype).toBe("success"); expect(result?.subtype).toBe("success");
expect((result as any).session_id).toBeDefined(); expect(result?.session_id).toBeDefined();
expect((result as any).agent_id).toBeDefined(); expect(result?.agent_id).toBeDefined();
expect((result as any).duration_ms).toBeGreaterThan(0); expect(result?.duration_ms).toBeGreaterThan(0);
}, },
{ timeout: 60000 }, { timeout: 60000 },
); );
@@ -192,7 +209,7 @@ describe("input-format stream-json", () => {
test( test(
"multi-turn conversation maintains context", "multi-turn conversation maintains context",
async () => { async () => {
const objects = await runBidirectional( const objects = (await runBidirectional(
[ [
JSON.stringify({ JSON.stringify({
type: "user", type: "user",
@@ -211,23 +228,29 @@ describe("input-format stream-json", () => {
], ],
[], [],
20000, 20000,
); )) as WireMessage[];
// Should have at least two results (one per turn) // Should have at least two results (one per turn)
const results = objects.filter((o: any) => o.type === "result"); const results = objects.filter(
(o): o is ResultMessage => o.type === "result",
);
expect(results.length).toBeGreaterThanOrEqual(2); expect(results.length).toBeGreaterThanOrEqual(2);
// Both results should be successful // Both results should be successful
for (const result of results) { for (const result of results) {
expect((result as any).subtype).toBe("success"); expect(result.subtype).toBe("success");
expect((result as any).session_id).toBeDefined(); expect(result.session_id).toBeDefined();
expect((result as any).agent_id).toBeDefined(); expect(result.agent_id).toBeDefined();
} }
// The session_id should be consistent across turns (same agent) // The session_id should be consistent across turns (same agent)
const firstSessionId = (results[0] as any).session_id; const firstResult = results[0];
const lastSessionId = (results[results.length - 1] as any).session_id; const lastResult = results[results.length - 1];
expect(firstSessionId).toBe(lastSessionId); expect(firstResult).toBeDefined();
expect(lastResult).toBeDefined();
if (firstResult && lastResult) {
expect(firstResult.session_id).toBe(lastResult.session_id);
}
}, },
{ timeout: 120000 }, { timeout: 120000 },
); );
@@ -235,7 +258,7 @@ describe("input-format stream-json", () => {
test( test(
"interrupt control request is acknowledged", "interrupt control request is acknowledged",
async () => { async () => {
const objects = await runBidirectional( const objects = (await runBidirectional(
[ [
JSON.stringify({ JSON.stringify({
type: "control_request", type: "control_request",
@@ -245,15 +268,15 @@ describe("input-format stream-json", () => {
], ],
[], [],
8000, // Longer wait for CI 8000, // Longer wait for CI
); )) as WireMessage[];
// Should have control_response for interrupt // Should have control_response for interrupt
const controlResponse = objects.find( const controlResponse = objects.find(
(o: any) => (o): o is ControlResponse =>
o.type === "control_response" && o.response?.request_id === "int_1", o.type === "control_response" && o.response?.request_id === "int_1",
); );
expect(controlResponse).toBeDefined(); expect(controlResponse).toBeDefined();
expect((controlResponse as any).response.subtype).toBe("success"); expect(controlResponse?.response.subtype).toBe("success");
}, },
{ timeout: 30000 }, { timeout: 30000 },
); );
@@ -261,7 +284,7 @@ describe("input-format stream-json", () => {
test( test(
"--include-partial-messages emits stream_event in bidirectional mode", "--include-partial-messages emits stream_event in bidirectional mode",
async () => { async () => {
const objects = await runBidirectional( const objects = (await runBidirectional(
[ [
JSON.stringify({ JSON.stringify({
type: "user", type: "user",
@@ -270,35 +293,38 @@ describe("input-format stream-json", () => {
], ],
["--include-partial-messages"], ["--include-partial-messages"],
10000, 10000,
); )) as WireMessage[];
// Should have stream_event messages (not just "message" type) // Should have stream_event messages (not just "message" type)
const streamEvents = objects.filter( const streamEvents = objects.filter(
(o: any) => o.type === "stream_event", (o): o is StreamEvent => o.type === "stream_event",
); );
expect(streamEvents.length).toBeGreaterThan(0); expect(streamEvents.length).toBeGreaterThan(0);
// Each stream_event should have the event payload and session_id // Each stream_event should have the event payload and session_id
// uuid is present on content events but not meta events (stop_reason, usage_statistics) // uuid is present on content events but not meta events (stop_reason, usage_statistics)
for (const event of streamEvents) { for (const event of streamEvents) {
expect((event as any).event).toBeDefined(); expect(event.event).toBeDefined();
expect((event as any).session_id).toBeDefined(); expect(event.session_id).toBeDefined();
} }
// Content events should have uuid // Content events should have uuid
const contentEvents = streamEvents.filter( const contentEvents = streamEvents.filter(
(e: any) => (e) =>
e.event?.message_type === "reasoning_message" || "message_type" in e.event &&
e.event?.message_type === "assistant_message", (e.event.message_type === "reasoning_message" ||
e.event.message_type === "assistant_message"),
); );
for (const event of contentEvents) { for (const event of contentEvents) {
expect((event as any).uuid).toBeDefined(); expect(event.uuid).toBeDefined();
} }
// Should still have result // Should still have result
const result = objects.find((o: any) => o.type === "result"); const result = objects.find(
(o): o is ResultMessage => o.type === "result",
);
expect(result).toBeDefined(); expect(result).toBeDefined();
expect((result as any).subtype).toBe("success"); expect(result?.subtype).toBe("success");
}, },
{ timeout: 60000 }, { timeout: 60000 },
); );
@@ -306,22 +332,22 @@ describe("input-format stream-json", () => {
test( test(
"unknown control request returns error", "unknown control request returns error",
async () => { async () => {
const objects = await runBidirectional([ const objects = (await runBidirectional([
JSON.stringify({ JSON.stringify({
type: "control_request", type: "control_request",
request_id: "unknown_1", request_id: "unknown_1",
request: { subtype: "unknown_subtype" }, request: { subtype: "unknown_subtype" },
}), }),
]); ])) as WireMessage[];
// Should have control_response with error // Should have control_response with error
const controlResponse = objects.find( const controlResponse = objects.find(
(o: any) => (o): o is ControlResponse =>
o.type === "control_response" && o.type === "control_response" &&
o.response?.request_id === "unknown_1", o.response?.request_id === "unknown_1",
); );
expect(controlResponse).toBeDefined(); expect(controlResponse).toBeDefined();
expect((controlResponse as any).response.subtype).toBe("error"); expect(controlResponse?.response.subtype).toBe("error");
}, },
{ timeout: 30000 }, { timeout: 30000 },
); );
@@ -330,12 +356,16 @@ describe("input-format stream-json", () => {
"invalid JSON input returns error message", "invalid JSON input returns error message",
async () => { async () => {
// Use raw string instead of JSON // Use raw string instead of JSON
const objects = await runBidirectional(["not valid json"]); const objects = (await runBidirectional([
"not valid json",
])) as WireMessage[];
// Should have error message // Should have error message
const errorMsg = objects.find((o: any) => o.type === "error"); const errorMsg = objects.find(
(o): o is ErrorMessage => o.type === "error",
);
expect(errorMsg).toBeDefined(); expect(errorMsg).toBeDefined();
expect((errorMsg as any).message).toContain("Invalid JSON"); expect(errorMsg?.message).toContain("Invalid JSON");
}, },
{ timeout: 30000 }, { timeout: 30000 },
); );

View File

@@ -1,9 +1,14 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import type {
ResultMessage,
StreamEvent,
SystemInitMessage,
} from "../types/wire";
/** /**
* Tests for stream-json output format. * Tests for stream-json output format.
* These verify the message structure matches the SDK-compatible format. * These verify the message structure matches the wire format types.
*/ */
async function runHeadlessCommand( async function runHeadlessCommand(
@@ -80,8 +85,9 @@ describe("stream-json format", () => {
}); });
expect(initLine).toBeDefined(); expect(initLine).toBeDefined();
if (!initLine) throw new Error("initLine not found");
const init = JSON.parse(initLine!); const init = JSON.parse(initLine) as SystemInitMessage;
expect(init.type).toBe("system"); expect(init.type).toBe("system");
expect(init.subtype).toBe("init"); expect(init.subtype).toBe("init");
expect(init.agent_id).toBeDefined(); expect(init.agent_id).toBeDefined();
@@ -106,8 +112,12 @@ describe("stream-json format", () => {
}); });
expect(messageLine).toBeDefined(); expect(messageLine).toBeDefined();
if (!messageLine) throw new Error("messageLine not found");
const msg = JSON.parse(messageLine!); const msg = JSON.parse(messageLine) as {
session_id: string;
uuid: string;
};
expect(msg.session_id).toBeDefined(); expect(msg.session_id).toBeDefined();
expect(msg.uuid).toBeDefined(); expect(msg.uuid).toBeDefined();
// uuid should be otid or id from the Letta SDK chunk // uuid should be otid or id from the Letta SDK chunk
@@ -126,8 +136,9 @@ describe("stream-json format", () => {
}); });
expect(resultLine).toBeDefined(); expect(resultLine).toBeDefined();
if (!resultLine) throw new Error("resultLine not found");
const result = JSON.parse(resultLine!); const result = JSON.parse(resultLine) as ResultMessage & { uuid: string };
expect(result.type).toBe("result"); expect(result.type).toBe("result");
expect(result.subtype).toBe("success"); expect(result.subtype).toBe("success");
expect(result.session_id).toBeDefined(); expect(result.session_id).toBeDefined();
@@ -154,14 +165,15 @@ describe("stream-json format", () => {
}); });
expect(streamEventLine).toBeDefined(); expect(streamEventLine).toBeDefined();
if (!streamEventLine) throw new Error("streamEventLine not found");
const event = JSON.parse(streamEventLine!); const event = JSON.parse(streamEventLine) as StreamEvent;
expect(event.type).toBe("stream_event"); expect(event.type).toBe("stream_event");
expect(event.event).toBeDefined(); expect(event.event).toBeDefined();
expect(event.session_id).toBeDefined(); expect(event.session_id).toBeDefined();
expect(event.uuid).toBeDefined(); expect(event.uuid).toBeDefined();
// The event should contain the original Letta SDK chunk // The event should contain the original Letta SDK chunk
expect(event.event.message_type).toBeDefined(); expect("message_type" in event.event).toBe(true);
}, },
{ timeout: 60000 }, { timeout: 60000 },
); );

255
src/types/wire.ts Normal file
View File

@@ -0,0 +1,255 @@
/**
* Wire Format Types
*
* These types define the JSON structure emitted by headless.ts when running
* in stream-json mode. They enable typed consumption of the bidirectional
* JSON protocol.
*
* Design principle: Compose from @letta-ai/letta-client types where possible.
*/
import type { MessageCreate } from "@letta-ai/letta-client/resources/agents/agents";
import type {
AssistantMessage as LettaAssistantMessage,
ReasoningMessage as LettaReasoningMessage,
LettaStreamingResponse,
ToolCallMessage as LettaToolCallMessage,
ToolCall,
} from "@letta-ai/letta-client/resources/agents/messages";
import type { StopReasonType } from "@letta-ai/letta-client/resources/runs/runs";
import type { ToolReturnMessage as LettaToolReturnMessage } from "@letta-ai/letta-client/resources/tools";
// Re-export letta-client types that consumers may need
export type {
LettaStreamingResponse,
ToolCall,
StopReasonType,
MessageCreate,
LettaToolReturnMessage,
};
// ═══════════════════════════════════════════════════════════════
// BASE ENVELOPE
// All wire messages include these fields
// ═══════════════════════════════════════════════════════════════
export interface MessageEnvelope {
session_id: string;
uuid: string;
}
// ═══════════════════════════════════════════════════════════════
// SYSTEM MESSAGES
// ═══════════════════════════════════════════════════════════════
export interface SystemInitMessage extends MessageEnvelope {
type: "system";
subtype: "init";
agent_id: string;
model: string;
tools: string[];
cwd: string;
mcp_servers: Array<{ name: string; status: string }>;
permission_mode: string;
slash_commands: string[];
// output_style omitted - Letta Code doesn't have output styles feature
}
export type SystemMessage = SystemInitMessage;
// ═══════════════════════════════════════════════════════════════
// CONTENT MESSAGES
// These wrap letta-client message types with the wire envelope
// ═══════════════════════════════════════════════════════════════
/**
* Wire format for assistant messages.
* Extends LettaAssistantMessage with wire envelope fields.
*/
export interface AssistantMessageWire
extends LettaAssistantMessage,
MessageEnvelope {
type: "message";
}
/**
* Wire format for tool call messages.
* Extends LettaToolCallMessage with wire envelope fields.
*/
export interface ToolCallMessageWire
extends LettaToolCallMessage,
MessageEnvelope {
type: "message";
}
/**
* Wire format for reasoning messages.
* Extends LettaReasoningMessage with wire envelope fields.
*/
export interface ReasoningMessageWire
extends LettaReasoningMessage,
MessageEnvelope {
type: "message";
}
/**
* Wire format for tool return messages.
* Extends LettaToolReturnMessage with wire envelope fields.
*/
export interface ToolReturnMessageWire
extends LettaToolReturnMessage,
MessageEnvelope {
type: "message";
}
export type ContentMessage =
| AssistantMessageWire
| ToolCallMessageWire
| ReasoningMessageWire
| ToolReturnMessageWire;
/**
* Generic message wrapper for spreading LettaStreamingResponse chunks.
* Used when the exact message type is determined at runtime.
*/
export type MessageWire = {
type: "message";
session_id: string;
uuid: string;
} & LettaStreamingResponse;
// ═══════════════════════════════════════════════════════════════
// STREAM EVENTS (partial message updates)
// ═══════════════════════════════════════════════════════════════
export interface StreamEvent extends MessageEnvelope {
type: "stream_event";
event: LettaStreamingResponse;
}
// ═══════════════════════════════════════════════════════════════
// AUTO APPROVAL
// ═══════════════════════════════════════════════════════════════
export interface AutoApprovalMessage extends MessageEnvelope {
type: "auto_approval";
tool_call: ToolCall;
reason: string;
matched_rule: string;
}
// ═══════════════════════════════════════════════════════════════
// ERROR & RETRY
// ═══════════════════════════════════════════════════════════════
export interface ErrorMessage extends MessageEnvelope {
type: "error";
/** High-level error message from the CLI */
message: string;
stop_reason: StopReasonType;
run_id?: string;
/** Nested API error when the error originated from Letta API */
api_error?: LettaStreamingResponse.LettaErrorMessage;
}
export interface RetryMessage extends MessageEnvelope {
type: "retry";
/** The stop reason that triggered the retry. Uses StopReasonType from letta-client. */
reason: StopReasonType;
attempt: number;
max_attempts: number;
delay_ms: number;
run_id?: string;
}
// ═══════════════════════════════════════════════════════════════
// RESULT
// ═══════════════════════════════════════════════════════════════
/**
* Result subtypes.
* For errors, use stop_reason field with StopReasonType from letta-client.
*/
export type ResultSubtype = "success" | "interrupted" | "error";
/**
* Usage statistics from letta-client.
* Re-exported for convenience.
*/
export type UsageStatistics = LettaStreamingResponse.LettaUsageStatistics;
export interface ResultMessage extends MessageEnvelope {
type: "result";
subtype: ResultSubtype;
agent_id: string;
duration_ms: number;
duration_api_ms: number;
num_turns: number;
result: string | null;
run_ids: string[];
usage: UsageStatistics | null;
/**
* Present when subtype is "error".
* Uses StopReasonType from letta-client (e.g., 'error', 'max_steps', 'llm_api_error').
*/
stop_reason?: StopReasonType;
}
// ═══════════════════════════════════════════════════════════════
// CONTROL PROTOCOL
// ═══════════════════════════════════════════════════════════════
// Requests (external → CLI)
export interface ControlRequest {
type: "control_request";
request_id: string;
request: ControlRequestBody;
}
export type ControlRequestBody =
| { subtype: "initialize" }
| { subtype: "interrupt" };
// Responses (CLI → external)
export interface ControlResponse extends MessageEnvelope {
type: "control_response";
response: ControlResponseBody;
}
export type ControlResponseBody =
| {
subtype: "success";
request_id: string;
response?: Record<string, unknown>;
}
| { subtype: "error"; request_id: string; error: string };
// ═══════════════════════════════════════════════════════════════
// USER INPUT
// ═══════════════════════════════════════════════════════════════
/**
* User input message for bidirectional communication.
* Uses MessageCreate from letta-client for multimodal content support.
*/
export interface UserInput {
type: "user";
message: MessageCreate;
}
// ═══════════════════════════════════════════════════════════════
// UNION TYPE
// ═══════════════════════════════════════════════════════════════
/**
* Union of all wire message types that can be emitted by headless.ts
*/
export type WireMessage =
| SystemMessage
| ContentMessage
| StreamEvent
| AutoApprovalMessage
| ErrorMessage
| RetryMessage
| ResultMessage
| ControlResponse;

11
tsconfig.types.json Normal file
View File

@@ -0,0 +1,11 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"emitDeclarationOnly": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist/types"
},
"include": ["src/types/wire.ts"]
}