feat: add typed wire format for stream-json protocol (#445)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
5
build.js
5
build.js
@@ -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`);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
424
src/headless.ts
424
src/headless.ts
@@ -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
|
||||||
|
|||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
255
src/types/wire.ts
Normal 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
11
tsconfig.types.json
Normal 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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user