feat(listen): add protocol_v2, move ws server to v2 (#1387)

Co-authored-by: Shubham Naik <shub@letta.com>
Co-authored-by: Letta Code <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-03-16 14:46:56 -07:00
committed by GitHub
parent 8ecf39798c
commit 3edaf91ee4
12 changed files with 4215 additions and 2106 deletions

View File

@@ -339,6 +339,9 @@ export async function getResumeData(
): Promise<ResumeData> {
try {
const includeMessageHistory = options.includeMessageHistory ?? true;
const agentWithInContext = agent as AgentState & {
in_context_message_ids?: string[] | null;
};
let inContextMessageIds: string[] | null | undefined;
let messages: Message[] = [];
@@ -446,50 +449,36 @@ export async function getResumeData(
messageHistory: prepareMessageHistory(messages),
};
} else {
// Use agent messages API for "default" conversation or when no conversation ID
// (agent's primary message history without explicit conversation isolation)
inContextMessageIds = agent.message_ids;
if (!inContextMessageIds || inContextMessageIds.length === 0) {
debugWarn(
"check-approval",
"No in-context messages (default/agent API) - no pending approvals",
);
// No in-context messages = empty default conversation, don't show random history
return {
pendingApproval: null,
pendingApprovals: [],
messageHistory: [],
};
}
// Fetch the last in-context message directly by ID
// (We already checked inContextMessageIds.length > 0 above)
const lastInContextId = inContextMessageIds.at(-1);
if (!lastInContextId) {
throw new Error("Expected at least one in-context message");
}
const retrievedMessages = await client.messages.retrieve(lastInContextId);
// Fetch message history for backfill through the default conversation route.
// Default conversation uses the "default" sentinel plus agent_id as a query param.
// Wrapped in try/catch so backfill failures don't crash the CLI (e.g., older servers
// may not support this pattern)
if (includeMessageHistory && isBackfillEnabled()) {
// For the default conversation, use the agent's in-context message IDs as
// the primary anchor, mirroring the explicit-conversation path. Fall back
// to the default-conversation message stream only when that anchor is not
// available, and keep using the stream for backfill/history.
inContextMessageIds = agentWithInContext.in_context_message_ids;
const lastInContextId = inContextMessageIds?.at(-1);
let defaultConversationMessages: Message[] = [];
if ((includeMessageHistory && isBackfillEnabled()) || !lastInContextId) {
const listLimit =
includeMessageHistory && isBackfillEnabled()
? BACKFILL_PAGE_LIMIT
: 1;
try {
const messagesPage = await client.agents.messages.list(agent.id, {
conversation_id: "default",
limit: BACKFILL_PAGE_LIMIT,
limit: listLimit,
order: "desc",
});
messages = sortChronological(messagesPage.getPaginatedItems());
defaultConversationMessages = sortChronological(
messagesPage.getPaginatedItems(),
);
if (includeMessageHistory && isBackfillEnabled()) {
messages = defaultConversationMessages;
}
if (isDebugEnabled()) {
debugLog(
"check-approval",
"conversations.messages.list(default, agent_id=%s) returned %d messages",
agent.id,
messages.length,
defaultConversationMessages.length,
);
}
} catch (backfillError) {
@@ -500,18 +489,88 @@ export async function getResumeData(
}
}
// Find the approval_request_message variant if it exists
if (lastInContextId) {
const retrievedMessages =
await client.messages.retrieve(lastInContextId);
const messageToCheck =
retrievedMessages.find(
(msg) => msg.message_type === "approval_request_message",
) ?? retrievedMessages[0];
if (messageToCheck) {
debugWarn(
"check-approval",
`Found last in-context message: ${messageToCheck.id} (type: ${messageToCheck.message_type})` +
(retrievedMessages.length > 1
? ` - had ${retrievedMessages.length} variants`
: ""),
);
if (messageToCheck.message_type === "approval_request_message") {
const { pendingApproval, pendingApprovals } =
extractApprovals(messageToCheck);
return {
pendingApproval,
pendingApprovals,
messageHistory: prepareMessageHistory(messages),
};
}
} else {
debugWarn(
"check-approval",
`Last in-context message ${lastInContextId} not found via retrieve`,
);
}
return {
pendingApproval: null,
pendingApprovals: [],
messageHistory: prepareMessageHistory(messages),
};
}
if (isDebugEnabled()) {
debugLog(
"check-approval",
"default conversation message stream returned %d messages for agent_id=%s",
defaultConversationMessages.length,
agent.id,
);
}
if (defaultConversationMessages.length === 0) {
debugWarn(
"check-approval",
"No messages in default conversation stream - no pending approvals",
);
return {
pendingApproval: null,
pendingApprovals: [],
messageHistory: [],
};
}
const lastDefaultMessage =
defaultConversationMessages[defaultConversationMessages.length - 1];
const latestMessageId = lastDefaultMessage?.id;
const latestMessageVariants = latestMessageId
? defaultConversationMessages.filter(
(msg) => msg.id === latestMessageId,
)
: [];
const messageToCheck =
retrievedMessages.find(
latestMessageVariants.find(
(msg) => msg.message_type === "approval_request_message",
) ?? retrievedMessages[0];
) ??
latestMessageVariants[latestMessageVariants.length - 1] ??
lastDefaultMessage;
if (messageToCheck) {
debugWarn(
"check-approval",
`Found last in-context message: ${messageToCheck.id} (type: ${messageToCheck.message_type})` +
(retrievedMessages.length > 1
? ` - had ${retrievedMessages.length} variants`
(latestMessageVariants.length > 1
? ` - had ${latestMessageVariants.length} variants`
: ""),
);
@@ -527,7 +586,7 @@ export async function getResumeData(
} else {
debugWarn(
"check-approval",
`Last in-context message ${lastInContextId} not found via retrieve (default/agent API)`,
"Last default conversation message not found after list()",
);
}

View File

@@ -6,7 +6,7 @@
import path, { basename } from "node:path";
import type { AdvancedDiffResult, AdvancedHunk } from "../cli/helpers/diff";
import type { DiffHunk, DiffHunkLine, DiffPreview } from "../types/protocol";
import type { DiffHunk, DiffHunkLine, DiffPreview } from "../types/protocol_v2";
function parseHunkLinePrefix(raw: string): DiffHunkLine | null {
if (raw.length === 0) {

71
src/runtime-context.ts Normal file
View File

@@ -0,0 +1,71 @@
import { AsyncLocalStorage } from "node:async_hooks";
import type { SkillSource } from "./agent/skills";
export type RuntimePermissionMode =
| "default"
| "acceptEdits"
| "plan"
| "bypassPermissions";
export interface RuntimeContextSnapshot {
agentId?: string | null;
conversationId?: string | null;
skillsDirectory?: string | null;
skillSources?: SkillSource[];
workingDirectory?: string | null;
permissionMode?: RuntimePermissionMode;
planFilePath?: string | null;
modeBeforePlan?: RuntimePermissionMode | null;
}
const runtimeContextStorage = new AsyncLocalStorage<RuntimeContextSnapshot>();
export function getRuntimeContext(): RuntimeContextSnapshot | undefined {
return runtimeContextStorage.getStore();
}
export function runWithRuntimeContext<T>(
snapshot: RuntimeContextSnapshot,
fn: () => T,
): T {
const parent = runtimeContextStorage.getStore();
return runtimeContextStorage.run(
{
...parent,
...snapshot,
...(snapshot.skillSources
? { skillSources: [...snapshot.skillSources] }
: {}),
},
fn,
);
}
export function runOutsideRuntimeContext<T>(fn: () => T): T {
return runtimeContextStorage.exit(fn);
}
export function updateRuntimeContext(
update: Partial<RuntimeContextSnapshot>,
): void {
const current = runtimeContextStorage.getStore();
if (!current) {
return;
}
Object.assign(
current,
update,
update.skillSources && {
skillSources: [...update.skillSources],
},
);
}
export function getCurrentWorkingDirectory(): string {
const workingDirectory = runtimeContextStorage.getStore()?.workingDirectory;
if (typeof workingDirectory === "string" && workingDirectory.length > 0) {
return workingDirectory;
}
return process.env.USER_CWD || process.cwd();
}

View File

@@ -4,12 +4,16 @@ import type { AgentState } from "@letta-ai/letta-client/resources/agents/agents"
import type { Message } from "@letta-ai/letta-client/resources/agents/messages";
import { getResumeData } from "../../agent/check-approval";
function makeAgent(overrides: Partial<AgentState> = {}): AgentState {
type ResumeAgentState = AgentState & {
in_context_message_ids?: string[] | null;
};
function makeAgent(overrides: Partial<ResumeAgentState> = {}): AgentState {
return {
id: "agent-test",
message_ids: ["msg-last"],
...overrides,
} as AgentState;
} as ResumeAgentState;
}
function makeApprovalMessage(id = "msg-last"): Message {
@@ -74,7 +78,9 @@ describe("getResumeData", () => {
const conversationsList = mock(async () => ({
getPaginatedItems: () => [],
}));
const agentsList = mock(async () => ({ items: [] }));
const agentsList = mock(async () => ({
getPaginatedItems: () => [makeApprovalMessage()],
}));
const messagesRetrieve = mock(async () => [makeApprovalMessage()]);
const client = {
@@ -88,7 +94,10 @@ describe("getResumeData", () => {
const resume = await getResumeData(
client,
makeAgent({ message_ids: ["msg-last"] }),
makeAgent({
message_ids: ["msg-last"],
in_context_message_ids: ["msg-last"],
}),
"default",
{ includeMessageHistory: false },
);
@@ -99,6 +108,60 @@ describe("getResumeData", () => {
expect(resume.messageHistory).toEqual([]);
});
test("default conversation resume uses in-context ids instead of stale agent.message_ids", async () => {
const agentsList = mock(async () => ({
getPaginatedItems: () => [makeApprovalMessage("msg-default-latest")],
}));
const messagesRetrieve = mock(async () => [
makeApprovalMessage("msg-live"),
]);
const client = {
agents: { messages: { list: agentsList } },
messages: { retrieve: messagesRetrieve },
} as unknown as Letta;
const resume = await getResumeData(
client,
makeAgent({
message_ids: ["msg-stale"],
in_context_message_ids: ["msg-live"],
}),
"default",
{ includeMessageHistory: false },
);
expect(messagesRetrieve).toHaveBeenCalledWith("msg-live");
expect(messagesRetrieve).toHaveBeenCalledTimes(1);
expect(agentsList).toHaveBeenCalledTimes(0);
expect(resume.pendingApprovals).toHaveLength(1);
expect(resume.pendingApprovals[0]?.toolCallId).toBe("tool-1");
});
test("default conversation falls back to default conversation stream when in-context ids are unavailable", async () => {
const agentsList = mock(async () => ({
getPaginatedItems: () => [makeApprovalMessage("msg-default-latest")],
}));
const messagesRetrieve = mock(async () => [makeUserMessage("msg-stale")]);
const client = {
agents: { messages: { list: agentsList } },
messages: { retrieve: messagesRetrieve },
} as unknown as Letta;
const resume = await getResumeData(
client,
makeAgent({ in_context_message_ids: [] }),
"default",
{ includeMessageHistory: false },
);
expect(messagesRetrieve).toHaveBeenCalledTimes(0);
expect(agentsList).toHaveBeenCalledTimes(1);
expect(resume.pendingApprovals).toHaveLength(1);
expect(resume.pendingApprovals[0]?.toolCallId).toBe("tool-1");
});
test("default behavior keeps backfill enabled when options are omitted", async () => {
const conversationsRetrieve = mock(async () => ({
in_context_message_ids: ["msg-last"],
@@ -119,7 +182,11 @@ describe("getResumeData", () => {
messages: { retrieve: messagesRetrieve },
} as unknown as Letta;
const resume = await getResumeData(client, makeAgent(), "default");
const resume = await getResumeData(
client,
makeAgent({ in_context_message_ids: ["msg-last"] }),
"default",
);
expect(messagesRetrieve).toHaveBeenCalledTimes(1);
expect(agentsList).toHaveBeenCalledTimes(1);

View File

@@ -2,7 +2,7 @@
* Tests for the static transcript sync protocol types (LSS1).
*
* Verifies structural correctness, discriminant exhaustiveness, and
* membership in WireMessage / WsProtocolEvent unions.
* membership in the legacy WireMessage union.
*/
import { describe, expect, test } from "bun:test";
@@ -13,7 +13,6 @@ import type {
TranscriptSupplementMessage,
WireMessage,
} from "../../types/protocol";
import type { WsProtocolEvent } from "../../websocket/listen-client";
// ── Helpers ───────────────────────────────────────────────────────
@@ -186,45 +185,6 @@ describe("WireMessage union membership", () => {
});
});
describe("WsProtocolEvent union membership", () => {
test("TranscriptBackfillMessage is assignable to WsProtocolEvent", () => {
const msg: WsProtocolEvent = {
...ENVELOPE,
type: "transcript_backfill",
messages: [],
is_final: true,
};
expect(msg.type).toBe("transcript_backfill");
});
test("QueueSnapshotMessage is assignable to WsProtocolEvent", () => {
const msg: WsProtocolEvent = {
...ENVELOPE,
type: "queue_snapshot",
items: [],
};
expect(msg.type).toBe("queue_snapshot");
});
test("SyncCompleteMessage is assignable to WsProtocolEvent", () => {
const msg: WsProtocolEvent = {
...ENVELOPE,
type: "sync_complete",
had_pending_turn: false,
};
expect(msg.type).toBe("sync_complete");
});
test("TranscriptSupplementMessage is assignable to WsProtocolEvent", () => {
const msg: WsProtocolEvent = {
...ENVELOPE,
type: "transcript_supplement",
messages: [],
};
expect(msg.type).toBe("transcript_supplement");
});
});
// ── Discriminant exhaustiveness ───────────────────────────────────
describe("type discriminants are unique across all four types", () => {

File diff suppressed because it is too large Load Diff

View File

@@ -210,6 +210,8 @@ describe("extractInterruptToolReturns", () => {
test("emitInterruptToolReturnMessage emits deterministic per-tool terminal messages", () => {
const runtime = createRuntime();
const socket = new MockSocket(WebSocket.OPEN) as unknown as WebSocket;
runtime.activeAgentId = "agent-1";
runtime.activeConversationId = "default";
const approvals: ApprovalResult[] = [
{
type: "tool",
@@ -231,32 +233,45 @@ describe("extractInterruptToolReturns", () => {
JSON.parse(raw),
);
const toolReturnFrames = parsed.filter(
(payload) => payload.message_type === "tool_return_message",
(payload) =>
payload.type === "stream_delta" &&
payload.delta?.message_type === "tool_return_message",
);
expect(toolReturnFrames).toHaveLength(2);
expect(toolReturnFrames[0]).toMatchObject({
run_id: "run-1",
delta: {
run_id: "run-1",
tool_returns: [
{ tool_call_id: "call-a", status: "success", tool_return: "704" },
],
},
});
expect(toolReturnFrames[1]).toMatchObject({
delta: {
run_id: "run-1",
tool_returns: [
{
tool_call_id: "call-b",
status: "error",
tool_return: "User interrupted the stream",
},
],
},
});
expect(toolReturnFrames[0].delta).toMatchObject({
tool_returns: [
{ tool_call_id: "call-a", status: "success", tool_return: "704" },
],
});
expect(toolReturnFrames[1]).toMatchObject({
run_id: "run-1",
tool_returns: [
{
tool_call_id: "call-b",
status: "error",
tool_return: "User interrupted the stream",
},
],
});
expect(toolReturnFrames[0]).not.toHaveProperty("tool_call_id");
expect(toolReturnFrames[0]).not.toHaveProperty("status");
expect(toolReturnFrames[0]).not.toHaveProperty("tool_return");
expect(toolReturnFrames[1]).not.toHaveProperty("tool_call_id");
expect(toolReturnFrames[1]).not.toHaveProperty("status");
expect(toolReturnFrames[1]).not.toHaveProperty("tool_return");
expect(toolReturnFrames[0].delta.tool_call_id).toBe("call-a");
expect(toolReturnFrames[0].delta.status).toBe("success");
expect(toolReturnFrames[0].delta.tool_return).toBe("704");
expect(toolReturnFrames[1].delta.tool_call_id).toBe("call-b");
expect(toolReturnFrames[1].delta.status).toBe("error");
expect(toolReturnFrames[1].delta.tool_return).toBe(
"User interrupted the stream",
);
});
});
@@ -791,9 +806,8 @@ describe("stale Path-B IDs: clearing after successful send prevents re-denial",
describe("cancel-induced stop reason reclassification", () => {
/**
* Mirrors the effectiveStopReason computation from the Case 3 stream path.
* Both the legacy (sendClientMessage) and modern (emitToWS) branches now
* use effectiveStopReason — this test verifies the reclassification logic
* that both branches depend on.
* Both the legacy and canonical listener branches use effectiveStopReason.
* This test verifies the reclassification logic those branches depend on.
*/
function computeEffectiveStopReason(
cancelRequested: boolean,

View File

@@ -7,10 +7,9 @@
* - Single message: enqueued → dequeued, no blocked, real queue_len
* - Two rapid synchronous arrivals: second gets blocked(runtime_busy)
* because pendingTurns is incremented before the .then() chain
* - Connection close: queue_cleared("shutdown") emitted once
* - Per-turn error: no queue_cleared — queue continues for remaining turns
* - Connection close: queue clear still happens once in QueueRuntime
* - Per-turn error: no queue clear — queue continues for remaining turns
* - ApprovalCreate payloads (no `content` field) are not enqueued
* - QueueLifecycleEvent is assignable to WsProtocolEvent (type-level)
*/
import { describe, expect, test } from "bun:test";
@@ -23,17 +22,6 @@ import type {
QueueItem,
} from "../../queue/queueRuntime";
import { QueueRuntime } from "../../queue/queueRuntime";
import type { QueueLifecycleEvent } from "../../types/protocol";
import type { WsProtocolEvent } from "../../websocket/listen-client";
// ── Type-level assertion: QueueLifecycleEvent ⊆ WsProtocolEvent ──
// Imports the real WsProtocolEvent from listen-client. If QueueLifecycleEvent
// is ever removed from that union, this assertion fails at compile time.
type _AssertAssignable = QueueLifecycleEvent extends WsProtocolEvent
? true
: never;
const _typeCheck: _AssertAssignable = true;
void _typeCheck; // suppress unused warning
// ── Helpers ───────────────────────────────────────────────────────
@@ -264,7 +252,7 @@ describe("ApprovalCreate payloads", () => {
});
describe("connection close", () => {
test("clear(shutdown) emits queue_cleared exactly once for intentional close", () => {
test("clear(shutdown) reports a single clear callback for intentional close", () => {
const { q, rec } = buildRuntime();
q.clear("shutdown");
expect(rec.cleared).toHaveLength(1);
@@ -302,13 +290,13 @@ describe("per-turn error — no queue_cleared", () => {
// First turn: simulate error — finally still runs
simulateTurnStart(q, turns, arrival1, skipIds);
simulateTurnEnd(q, turns); // error path still hits finally
expect(rec.cleared).toHaveLength(0); // no queue_cleared
expect(rec.cleared).toHaveLength(0); // no queue clear
// Second callback no-ops; first turn already consumed coalesced batch.
simulateTurnStart(q, turns, arrival2, skipIds);
expect(rec.dequeued).toHaveLength(1);
simulateTurnEnd(q, turns);
expect(turns.value).toBe(0);
expect(rec.cleared).toHaveLength(0); // still no queue_cleared
expect(rec.cleared).toHaveLength(0); // still no queue clear
});
});

View File

@@ -365,6 +365,35 @@ export type QueueItemKind =
| "approval_result"
| "overlay_action";
/**
* Canonical queue item wire shape used by listener state snapshots
* and queue lifecycle transport events.
*/
export interface QueueRuntimeItemWire {
/** Stable queue item identifier. */
id: string;
/** Correlates this queue item back to the originating client submit payload. */
client_message_id: string;
kind: QueueItemKind;
source: QueueItemSource;
/** Full queue item content; renderers may truncate for display. */
content: MessageCreate["content"] | string;
/** ISO8601 UTC enqueue timestamp. */
enqueued_at: string;
}
/**
* Queue item shape used by static queue_snapshot events.
* Includes legacy item_id for compatibility and allows optional expanded fields.
*/
export interface QueueSnapshotItem
extends Omit<Partial<QueueRuntimeItemWire>, "kind" | "source"> {
/** @deprecated Use `id` when present. */
item_id: string;
kind: QueueItemKind;
source: QueueItemSource;
}
/**
* Emitted synchronously when an item enters the queue.
* A queue item is a discrete, submitted unit of work (post-Enter for user
@@ -377,13 +406,13 @@ export interface QueueItemEnqueuedEvent extends MessageEnvelope {
/** @deprecated Use `id`. */
item_id: string;
/** Correlates this queue item back to the originating client submit payload. */
client_message_id: string;
client_message_id: QueueRuntimeItemWire["client_message_id"];
source: QueueItemSource;
kind: QueueItemKind;
/** Full queue item content; renderers may truncate for display. */
content?: MessageCreate["content"] | string;
content?: QueueRuntimeItemWire["content"];
/** ISO8601 UTC enqueue timestamp. */
enqueued_at?: string;
enqueued_at?: QueueRuntimeItemWire["enqueued_at"];
queue_len: number;
}
@@ -782,14 +811,7 @@ export interface TranscriptBackfillMessage extends MessageEnvelope {
export interface QueueSnapshotMessage extends MessageEnvelope {
type: "queue_snapshot";
/** Items currently in the queue, in enqueue order. */
items: Array<{
/** Stable queue item identifier. Preferred field. */
id?: string;
/** @deprecated Use `id`. */
item_id: string;
kind: QueueItemKind;
source: QueueItemSource;
}>;
items: QueueSnapshotItem[];
}
/**

372
src/types/protocol_v2.ts Normal file
View File

@@ -0,0 +1,372 @@
/**
* Protocol V2 (alpha hard-cut contract)
*
* This file defines the runtime-scoped websocket contract for device-mode UIs.
* It is intentionally self-defined and does not import transport/event shapes
* from the legacy protocol.ts surface.
*/
import type { MessageCreate } from "@letta-ai/letta-client/resources/agents/agents";
import type { LettaStreamingResponse } from "@letta-ai/letta-client/resources/agents/messages";
import type { StopReasonType } from "@letta-ai/letta-client/resources/runs/runs";
/**
* Runtime identity for all state and delta events.
*/
export interface RuntimeScope {
agent_id: string;
conversation_id: string;
}
/**
* Base envelope shared by all v2 websocket messages.
*/
export interface RuntimeEnvelope {
runtime: RuntimeScope;
event_seq: number;
emitted_at: string;
idempotency_key: string;
}
export type DevicePermissionMode =
| "default"
| "acceptEdits"
| "plan"
| "bypassPermissions";
export type ToolsetName =
| "codex"
| "codex_snake"
| "default"
| "gemini"
| "gemini_snake"
| "none";
export type ToolsetPreference = ToolsetName | "auto";
export interface AvailableSkillSummary {
id: string;
name: string;
description: string;
path: string;
source: "bundled" | "global" | "agent" | "project";
}
export interface BashBackgroundProcessSummary {
process_id: string;
kind: "bash";
command: string;
started_at_ms: number | null;
status: string;
exit_code: number | null;
}
export interface AgentTaskBackgroundProcessSummary {
process_id: string;
kind: "agent_task";
task_type: string;
description: string;
started_at_ms: number;
status: string;
subagent_id: string | null;
error?: string;
}
export type BackgroundProcessSummary =
| BashBackgroundProcessSummary
| AgentTaskBackgroundProcessSummary;
export interface DiffHunkLine {
type: "context" | "add" | "remove";
content: string;
}
export interface DiffHunk {
oldStart: number;
oldLines: number;
newStart: number;
newLines: number;
lines: DiffHunkLine[];
}
export type DiffPreview =
| { mode: "advanced"; fileName: string; hunks: DiffHunk[] }
| { mode: "fallback"; fileName: string; reason: string }
| { mode: "unpreviewable"; fileName: string; reason: string };
export interface CanUseToolControlRequestBody {
subtype: "can_use_tool";
tool_name: string;
input: Record<string, unknown>;
tool_call_id: string;
permission_suggestions: string[];
blocked_path: string | null;
diffs?: DiffPreview[];
}
export type ControlRequestBody = CanUseToolControlRequestBody;
export interface ControlRequest {
type: "control_request";
request_id: string;
request: ControlRequestBody;
agent_id?: string;
conversation_id?: string;
}
export interface PendingControlRequest {
request_id: string;
request: ControlRequestBody;
}
/**
* Bottom-bar and device execution context state.
*/
export interface DeviceStatus {
current_connection_id: string | null;
connection_name: string | null;
is_online: boolean;
is_processing: boolean;
current_permission_mode: DevicePermissionMode;
current_working_directory: string | null;
letta_code_version: string | null;
current_toolset: ToolsetName | null;
current_toolset_preference: ToolsetPreference;
current_loaded_tools: string[];
current_available_skills: AvailableSkillSummary[];
background_processes: BackgroundProcessSummary[];
pending_control_requests: PendingControlRequest[];
}
export type LoopStatus =
| "SENDING_API_REQUEST"
| "WAITING_FOR_API_RESPONSE"
| "RETRYING_API_REQUEST"
| "PROCESSING_API_RESPONSE"
| "EXECUTING_CLIENT_SIDE_TOOL"
| "EXECUTING_COMMAND"
| "WAITING_ON_APPROVAL"
| "WAITING_ON_INPUT";
export type QueueMessageKind =
| "message"
| "task_notification"
| "approval_result"
| "overlay_action";
export type QueueMessageSource =
| "user"
| "task_notification"
| "subagent"
| "system";
export interface QueueMessage {
id: string;
client_message_id: string;
kind: QueueMessageKind;
source: QueueMessageSource;
content: MessageCreate["content"] | string;
enqueued_at: string;
}
/**
* Loop state is intentionally small and finite.
* Message-level details are projected from runtime deltas.
*
* Queue state is delivered separately via `update_queue` messages.
*/
export interface LoopState {
status: LoopStatus;
active_run_ids: string[];
}
export interface DeviceStatusUpdateMessage extends RuntimeEnvelope {
type: "update_device_status";
device_status: DeviceStatus;
}
export interface LoopStatusUpdateMessage extends RuntimeEnvelope {
type: "update_loop_status";
loop_status: LoopState;
}
/**
* Full snapshot of the turn queue.
* Emitted on every queue mutation (enqueue, dequeue, clear, drop).
* Queue is typically 0-5 items so full snapshot is cheap and idempotent.
*/
export interface QueueUpdateMessage extends RuntimeEnvelope {
type: "update_queue";
queue: QueueMessage[];
}
/**
* Standard Letta message delta forwarded through the stream channel.
*/
export type MessageDelta = { type: "message" } & LettaStreamingResponse;
export interface UmiLifecycleMessageBase {
id: string;
date: string;
message_type: string;
run_id?: string;
}
export interface ClientToolStartMessage extends UmiLifecycleMessageBase {
message_type: "client_tool_start";
tool_call_id: string;
}
export interface ClientToolEndMessage extends UmiLifecycleMessageBase {
message_type: "client_tool_end";
tool_call_id: string;
status: "success" | "error";
}
export interface CommandStartMessage extends UmiLifecycleMessageBase {
message_type: "command_start";
command_id: string;
input: string;
}
export interface CommandEndMessage extends UmiLifecycleMessageBase {
message_type: "command_end";
command_id: string;
input: string;
output: string;
success: boolean;
dim_output?: boolean;
preformatted?: boolean;
}
export interface StatusMessage extends UmiLifecycleMessageBase {
message_type: "status";
message: string;
level: "info" | "success" | "warning";
}
export interface RetryMessage extends UmiLifecycleMessageBase {
message_type: "retry";
message: string;
reason: StopReasonType;
attempt: number;
max_attempts: number;
delay_ms: number;
}
export interface LoopErrorMessage extends UmiLifecycleMessageBase {
message_type: "loop_error";
message: string;
stop_reason: StopReasonType;
is_terminal: boolean;
api_error?: LettaStreamingResponse.LettaErrorMessage;
}
/**
* Expanded message-delta union.
* stream_delta is the only message stream event the WS server emits in v2.
*/
export type StreamDelta =
| MessageDelta
| ClientToolStartMessage
| ClientToolEndMessage
| CommandStartMessage
| CommandEndMessage
| StatusMessage
| RetryMessage
| LoopErrorMessage;
export interface StreamDeltaMessage extends RuntimeEnvelope {
type: "stream_delta";
delta: StreamDelta;
}
export interface ApprovalResponseAllowDecision {
behavior: "allow";
updated_input?: Record<string, unknown> | null;
updated_permissions?: string[];
}
export interface ApprovalResponseDenyDecision {
behavior: "deny";
message: string;
}
export type ApprovalResponseDecision =
| ApprovalResponseAllowDecision
| ApprovalResponseDenyDecision;
export type ApprovalResponseBody =
| {
request_id: string;
decision: ApprovalResponseDecision;
}
| {
request_id: string;
error: string;
};
/**
* Controller -> execution-environment commands.
* In v2, the WS server accepts only:
* - input (chat-loop ingress envelope)
* - change_device_state (device runtime mutation)
* - abort_message (abort request)
*/
export interface InputCreateMessagePayload {
kind: "create_message";
messages: Array<MessageCreate & { client_message_id?: string }>;
}
export type InputApprovalResponsePayload = {
kind: "approval_response";
} & ApprovalResponseBody;
export type InputPayload =
| InputCreateMessagePayload
| InputApprovalResponsePayload;
export interface InputCommand {
type: "input";
runtime: RuntimeScope;
payload: InputPayload;
}
export interface ChangeDeviceStatePayload {
mode?: DevicePermissionMode;
cwd?: string;
agent_id?: string | null;
conversation_id?: string | null;
}
export interface ChangeDeviceStateCommand {
type: "change_device_state";
runtime: RuntimeScope;
payload: ChangeDeviceStatePayload;
}
export interface AbortMessageCommand {
type: "abort_message";
runtime: RuntimeScope;
request_id?: string;
run_id?: string | null;
}
export interface SyncCommand {
type: "sync";
runtime: RuntimeScope;
}
export type WsProtocolCommand =
| InputCommand
| ChangeDeviceStateCommand
| AbortMessageCommand
| SyncCommand;
export type WsProtocolMessage =
| DeviceStatusUpdateMessage
| LoopStatusUpdateMessage
| QueueUpdateMessage
| StreamDeltaMessage;
export type { StopReasonType };

View File

@@ -1,4 +1,4 @@
import type { QueueBlockedReason } from "../../types/protocol";
import type { QueueBlockedReason } from "../../queue/queueRuntime";
export type ListenerQueueGatingConditions = {
isProcessing: boolean;

File diff suppressed because it is too large Load Diff