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:
@@ -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()",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
71
src/runtime-context.ts
Normal 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();
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
372
src/types/protocol_v2.ts
Normal 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 };
|
||||
@@ -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
Reference in New Issue
Block a user