feat: normalize stream_event contract + add live integration coverage (#52)
This commit is contained in:
35
README.md
35
README.md
@@ -77,3 +77,38 @@ const session = createSession("agent-123", {
|
||||
---
|
||||
|
||||
Made with 💜 in San Francisco
|
||||
|
||||
## Live integration tests (opt-in)
|
||||
|
||||
The SDK includes live integration tests that hit real Letta Cloud endpoints and verify runtime contracts for:
|
||||
|
||||
- session init shape
|
||||
- send/stream lifecycle (`assistant`, `reasoning`, `stream_event`, `result`)
|
||||
- `listMessages()` backfill/pagination shape
|
||||
- concurrent `listMessages()` during active stream
|
||||
- tool lifecycle (`tool_call` -> `tool_result`)
|
||||
|
||||
These tests are opt-in and skipped by default.
|
||||
|
||||
```bash
|
||||
# Required
|
||||
export LETTA_API_KEY=sk-let-...
|
||||
|
||||
# Optional
|
||||
export LETTA_AGENT_ID=agent-... # force a specific agent
|
||||
export LETTA_CONVERSATION_ID=conv-... # force a specific conversation for init test
|
||||
export LETTA_BASE_URL=https://api.letta.com
|
||||
export LETTA_LIVE_TEST_TIMEOUT_MS=180000
|
||||
|
||||
# Run live tests
|
||||
bun run test:live
|
||||
|
||||
# Run and record sanitized fixtures to src/tests/fixtures/live/
|
||||
bun run test:live:record
|
||||
```
|
||||
|
||||
Safety notes:
|
||||
|
||||
- live tests create/use real conversations on the target account
|
||||
- fixture recording redacts obvious secrets/tokens and local home paths
|
||||
- keep fixture recording disabled in CI unless you explicitly want refreshed snapshots
|
||||
|
||||
@@ -19,7 +19,9 @@
|
||||
"build": "bun run build.ts",
|
||||
"dev": "bun run build.ts --watch",
|
||||
"check": "tsc --noEmit",
|
||||
"test": "bun test"
|
||||
"test": "bun test",
|
||||
"test:live": "LETTA_LIVE_INTEGRATION=1 bun test src/tests/live.integration.test.ts",
|
||||
"test:live:record": "LETTA_LIVE_INTEGRATION=1 LETTA_RECORD_FIXTURES=1 bun test src/tests/live.integration.test.ts"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -44,6 +44,10 @@ export type {
|
||||
SDKReasoningMessage,
|
||||
SDKResultMessage,
|
||||
SDKStreamEventMessage,
|
||||
SDKStreamEventPayload,
|
||||
SDKStreamEventDeltaPayload,
|
||||
SDKStreamEventMessagePayload,
|
||||
SDKUnknownStreamEventPayload,
|
||||
SDKErrorMessage,
|
||||
SDKRetryMessage,
|
||||
SkillSource,
|
||||
@@ -74,6 +78,8 @@ export type {
|
||||
|
||||
export { Session } from "./session.js";
|
||||
|
||||
export { extractStreamTextDelta } from "./stream-events.js";
|
||||
|
||||
// Tool helpers
|
||||
export {
|
||||
jsonResult,
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
ExecuteExternalToolRequest,
|
||||
ListMessagesOptions,
|
||||
ListMessagesResult,
|
||||
SDKStreamEventPayload,
|
||||
} from "./types.js";
|
||||
import {
|
||||
isHeadlessAutoAllowTool,
|
||||
@@ -780,17 +781,13 @@ export class Session implements AsyncDisposable {
|
||||
// Stream event (partial message updates)
|
||||
if (wireMsg.type === "stream_event") {
|
||||
const msg = wireMsg as WireMessage & {
|
||||
event: {
|
||||
type: string;
|
||||
index?: number;
|
||||
delta?: { type?: string; text?: string; reasoning?: string };
|
||||
content_block?: { type?: string; text?: string };
|
||||
};
|
||||
event: unknown;
|
||||
uuid: string;
|
||||
};
|
||||
const eventPayload = (msg.event ?? {}) as SDKStreamEventPayload;
|
||||
return {
|
||||
type: "stream_event",
|
||||
event: msg.event,
|
||||
event: eventPayload,
|
||||
uuid: msg.uuid,
|
||||
};
|
||||
}
|
||||
|
||||
88
src/stream-events.ts
Normal file
88
src/stream-events.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { SDKStreamEventPayload } from "./types.js";
|
||||
|
||||
export type StreamTextKind = "assistant" | "reasoning";
|
||||
|
||||
export interface StreamTextDelta {
|
||||
kind: StreamTextKind;
|
||||
text: string;
|
||||
}
|
||||
|
||||
function extractTextFromContent(content: unknown): string | null {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
const pieces: string[] = [];
|
||||
for (const part of content) {
|
||||
if (typeof part === "string") {
|
||||
pieces.push(part);
|
||||
continue;
|
||||
}
|
||||
if (!part || typeof part !== "object") {
|
||||
continue;
|
||||
}
|
||||
const rec = part as Record<string, unknown>;
|
||||
if (typeof rec.text === "string") {
|
||||
pieces.push(rec.text);
|
||||
}
|
||||
}
|
||||
const joined = pieces.join("");
|
||||
return joined.length > 0 ? joined : null;
|
||||
}
|
||||
|
||||
if (content && typeof content === "object") {
|
||||
const rec = content as Record<string, unknown>;
|
||||
if (typeof rec.text === "string") {
|
||||
return rec.text;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract appendable assistant/reasoning text from a stream_event payload.
|
||||
*
|
||||
* Supports both shapes currently emitted by headless mode:
|
||||
* 1) content_block style: { type, delta: { text|reasoning } }
|
||||
* 2) message chunk style: { message_type: "assistant_message"|"reasoning_message", ... }
|
||||
*/
|
||||
export function extractStreamTextDelta(event: SDKStreamEventPayload): StreamTextDelta | null {
|
||||
if (!event || typeof event !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rec = event as Record<string, unknown>;
|
||||
|
||||
const maybeDelta = rec.delta;
|
||||
if (maybeDelta && typeof maybeDelta === "object") {
|
||||
const delta = maybeDelta as Record<string, unknown>;
|
||||
|
||||
if (typeof delta.reasoning === "string" && delta.reasoning.length > 0) {
|
||||
return { kind: "reasoning", text: delta.reasoning };
|
||||
}
|
||||
|
||||
if (typeof delta.text === "string" && delta.text.length > 0) {
|
||||
return { kind: "assistant", text: delta.text };
|
||||
}
|
||||
}
|
||||
|
||||
const messageType = rec.message_type;
|
||||
if (messageType === "reasoning_message") {
|
||||
const reasoningText =
|
||||
typeof rec.reasoning === "string" ? rec.reasoning : extractTextFromContent(rec.content);
|
||||
if (reasoningText && reasoningText.length > 0) {
|
||||
return { kind: "reasoning", text: reasoningText };
|
||||
}
|
||||
}
|
||||
|
||||
if (messageType === "assistant_message") {
|
||||
const assistantText = extractTextFromContent(rec.content);
|
||||
if (assistantText && assistantText.length > 0) {
|
||||
return { kind: "assistant", text: assistantText };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
39
src/tests/fixtures/live/init_contract.json
vendored
Normal file
39
src/tests/fixtures/live/init_contract.json
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"selectedAgentName": "letta-code-agent",
|
||||
"init": {
|
||||
"type": "init",
|
||||
"agentId": "agent-d0ab297f-c180-4347-b3ab-20165fbe0f2f",
|
||||
"sessionId": "agent-d0ab297f-c180-4347-b3ab-20165fbe0f2f",
|
||||
"conversationId": "conv-d87b461c-e855-49d6-9688-f69e3864f44e",
|
||||
"model": "claude-sonnet-4-5-20250929",
|
||||
"tools": [
|
||||
"AskUserQuestion",
|
||||
"Bash",
|
||||
"TaskOutput",
|
||||
"Edit",
|
||||
"EnterPlanMode",
|
||||
"ExitPlanMode",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"TaskStop",
|
||||
"Read",
|
||||
"Skill",
|
||||
"Task",
|
||||
"TodoWrite",
|
||||
"Write"
|
||||
],
|
||||
"memfsEnabled": false,
|
||||
"skillSources": [
|
||||
"bundled",
|
||||
"global",
|
||||
"agent",
|
||||
"project"
|
||||
],
|
||||
"systemInfoReminderEnabled": true,
|
||||
"sleeptime": {
|
||||
"trigger": "step-count",
|
||||
"behavior": "reminder",
|
||||
"stepCount": 25
|
||||
}
|
||||
}
|
||||
}
|
||||
7574
src/tests/fixtures/live/list_messages_during_stream.json
vendored
Normal file
7574
src/tests/fixtures/live/list_messages_during_stream.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
51
src/tests/fixtures/live/list_messages_pagination.json
vendored
Normal file
51
src/tests/fixtures/live/list_messages_pagination.json
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"init": {
|
||||
"type": "init",
|
||||
"agentId": "agent-d0ab297f-c180-4347-b3ab-20165fbe0f2f",
|
||||
"sessionId": "agent-d0ab297f-c180-4347-b3ab-20165fbe0f2f",
|
||||
"conversationId": "conv-3b89b7f3-c9b9-4ae2-94c5-55e3e5d44234",
|
||||
"model": "claude-sonnet-4-5-20250929",
|
||||
"tools": [
|
||||
"AskUserQuestion",
|
||||
"Bash",
|
||||
"TaskOutput",
|
||||
"Edit",
|
||||
"EnterPlanMode",
|
||||
"ExitPlanMode",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"TaskStop",
|
||||
"Read",
|
||||
"Skill",
|
||||
"Task",
|
||||
"TodoWrite",
|
||||
"Write"
|
||||
],
|
||||
"memfsEnabled": false,
|
||||
"skillSources": [
|
||||
"bundled",
|
||||
"global",
|
||||
"agent",
|
||||
"project"
|
||||
],
|
||||
"systemInfoReminderEnabled": true,
|
||||
"sleeptime": {
|
||||
"trigger": "step-count",
|
||||
"behavior": "reminder",
|
||||
"stepCount": 25
|
||||
}
|
||||
},
|
||||
"page1Summary": {
|
||||
"count": 4,
|
||||
"hasMore": false,
|
||||
"nextBefore": "message-2fcbada1-fd32-4004-88cb-395bc7cf1e88",
|
||||
"sampleId": "message-5c16d0db-1c1c-4e5c-ac66-30f72367759c",
|
||||
"sampleDiscriminator": "reasoning_message"
|
||||
},
|
||||
"page2Summary": {
|
||||
"count": 0,
|
||||
"hasMore": false,
|
||||
"nextBefore": null,
|
||||
"sampleId": null
|
||||
}
|
||||
}
|
||||
36
src/tests/fixtures/live/resume_conversation_init.json
vendored
Normal file
36
src/tests/fixtures/live/resume_conversation_init.json
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"type": "init",
|
||||
"agentId": "agent-d0ab297f-c180-4347-b3ab-20165fbe0f2f",
|
||||
"sessionId": "agent-d0ab297f-c180-4347-b3ab-20165fbe0f2f",
|
||||
"conversationId": "conv-d87b461c-e855-49d6-9688-f69e3864f44e",
|
||||
"model": "claude-sonnet-4-5-20250929",
|
||||
"tools": [
|
||||
"AskUserQuestion",
|
||||
"Bash",
|
||||
"TaskOutput",
|
||||
"Edit",
|
||||
"EnterPlanMode",
|
||||
"ExitPlanMode",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"TaskStop",
|
||||
"Read",
|
||||
"Skill",
|
||||
"Task",
|
||||
"TodoWrite",
|
||||
"Write"
|
||||
],
|
||||
"memfsEnabled": false,
|
||||
"skillSources": [
|
||||
"bundled",
|
||||
"global",
|
||||
"agent",
|
||||
"project"
|
||||
],
|
||||
"systemInfoReminderEnabled": true,
|
||||
"sleeptime": {
|
||||
"trigger": "step-count",
|
||||
"behavior": "reminder",
|
||||
"stepCount": 25
|
||||
}
|
||||
}
|
||||
483
src/tests/fixtures/live/send_stream_basic.json
vendored
Normal file
483
src/tests/fixtures/live/send_stream_basic.json
vendored
Normal file
@@ -0,0 +1,483 @@
|
||||
{
|
||||
"init": {
|
||||
"type": "init",
|
||||
"agentId": "agent-d0ab297f-c180-4347-b3ab-20165fbe0f2f",
|
||||
"sessionId": "agent-d0ab297f-c180-4347-b3ab-20165fbe0f2f",
|
||||
"conversationId": "conv-5567c301-1ce6-4eee-a4a6-a4ac62e088f1",
|
||||
"model": "claude-sonnet-4-5-20250929",
|
||||
"tools": [
|
||||
"AskUserQuestion",
|
||||
"Bash",
|
||||
"TaskOutput",
|
||||
"Edit",
|
||||
"EnterPlanMode",
|
||||
"ExitPlanMode",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"TaskStop",
|
||||
"Read",
|
||||
"Skill",
|
||||
"Task",
|
||||
"TodoWrite",
|
||||
"Write"
|
||||
],
|
||||
"memfsEnabled": false,
|
||||
"skillSources": [
|
||||
"bundled",
|
||||
"global",
|
||||
"agent",
|
||||
"project"
|
||||
],
|
||||
"systemInfoReminderEnabled": true,
|
||||
"sleeptime": {
|
||||
"trigger": "step-count",
|
||||
"behavior": "reminder",
|
||||
"stepCount": 25
|
||||
}
|
||||
},
|
||||
"prompt": "Reply with exactly this text and nothing else: SDK_LIVE_OK_opmuof",
|
||||
"messages": [
|
||||
{
|
||||
"type": "stream_event",
|
||||
"uuid": "7f876da4-3260-4ce3-b5d3-876f63ce1280",
|
||||
"event": {
|
||||
"id": "message-7f876da4-3260-4ce3-b5d3-876f63ce12f9",
|
||||
"date": "2026-02-23T06:04:47+00:00",
|
||||
"name": null,
|
||||
"message_type": "reasoning_message",
|
||||
"otid": "7f876da4-3260-4ce3-b5d3-876f63ce1280",
|
||||
"sender_id": null,
|
||||
"step_id": "step-6695658c-942c-48f8-987b-79fcd41a4368",
|
||||
"is_err": null,
|
||||
"seq_id": 1,
|
||||
"run_id": "run-fd4a4e96-b8c1-4d69-bbc0-7b4371f5fd14",
|
||||
"source": "reasoner_model",
|
||||
"reasoning": "The user is asking me to reply",
|
||||
"signature": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "stream_event",
|
||||
"uuid": "7f876da4-3260-4ce3-b5d3-876f63ce1280",
|
||||
"event": {
|
||||
"id": "message-7f876da4-3260-4ce3-b5d3-876f63ce12f9",
|
||||
"date": "2026-02-23T06:04:48+00:00",
|
||||
"name": null,
|
||||
"message_type": "reasoning_message",
|
||||
"otid": "7f876da4-3260-4ce3-b5d3-876f63ce1280",
|
||||
"sender_id": null,
|
||||
"step_id": "step-6695658c-942c-48f8-987b-79fcd41a4368",
|
||||
"is_err": null,
|
||||
"seq_id": 2,
|
||||
"run_id": "run-fd4a4e96-b8c1-4d69-bbc0-7b4371f5fd14",
|
||||
"source": "reasoner_model",
|
||||
"reasoning": " with exactly \"",
|
||||
"signature": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "stream_event",
|
||||
"uuid": "7f876da4-3260-4ce3-b5d3-876f63ce1280",
|
||||
"event": {
|
||||
"id": "message-7f876da4-3260-4ce3-b5d3-876f63ce12f9",
|
||||
"date": "2026-02-23T06:04:48+00:00",
|
||||
"name": null,
|
||||
"message_type": "reasoning_message",
|
||||
"otid": "7f876da4-3260-4ce3-b5d3-876f63ce1280",
|
||||
"sender_id": null,
|
||||
"step_id": "step-6695658c-942c-48f8-987b-79fcd41a4368",
|
||||
"is_err": null,
|
||||
"seq_id": 3,
|
||||
"run_id": "run-fd4a4e96-b8c1-4d69-bbc0-7b4371f5fd14",
|
||||
"source": "reasoner_model",
|
||||
"reasoning": "SDK_LIVE_OK_op",
|
||||
"signature": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "stream_event",
|
||||
"uuid": "7f876da4-3260-4ce3-b5d3-876f63ce1280",
|
||||
"event": {
|
||||
"id": "message-7f876da4-3260-4ce3-b5d3-876f63ce12f9",
|
||||
"date": "2026-02-23T06:04:48+00:00",
|
||||
"name": null,
|
||||
"message_type": "reasoning_message",
|
||||
"otid": "7f876da4-3260-4ce3-b5d3-876f63ce1280",
|
||||
"sender_id": null,
|
||||
"step_id": "step-6695658c-942c-48f8-987b-79fcd41a4368",
|
||||
"is_err": null,
|
||||
"seq_id": 4,
|
||||
"run_id": "run-fd4a4e96-b8c1-4d69-bbc0-7b4371f5fd14",
|
||||
"source": "reasoner_model",
|
||||
"reasoning": "muof\" and",
|
||||
"signature": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "stream_event",
|
||||
"uuid": "7f876da4-3260-4ce3-b5d3-876f63ce1280",
|
||||
"event": {
|
||||
"id": "message-7f876da4-3260-4ce3-b5d3-876f63ce12f9",
|
||||
"date": "2026-02-23T06:04:48+00:00",
|
||||
"name": null,
|
||||
"message_type": "reasoning_message",
|
||||
"otid": "7f876da4-3260-4ce3-b5d3-876f63ce1280",
|
||||
"sender_id": null,
|
||||
"step_id": "step-6695658c-942c-48f8-987b-79fcd41a4368",
|
||||
"is_err": null,
|
||||
"seq_id": 5,
|
||||
"run_id": "run-fd4a4e96-b8c1-4d69-bbc0-7b4371f5fd14",
|
||||
"source": "reasoner_model",
|
||||
"reasoning": " nothing else. This appears to be a",
|
||||
"signature": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "stream_event",
|
||||
"uuid": "7f876da4-3260-4ce3-b5d3-876f63ce1280",
|
||||
"event": {
|
||||
"id": "message-7f876da4-3260-4ce3-b5d3-876f63ce12f9",
|
||||
"date": "2026-02-23T06:04:48+00:00",
|
||||
"name": null,
|
||||
"message_type": "reasoning_message",
|
||||
"otid": "7f876da4-3260-4ce3-b5d3-876f63ce1280",
|
||||
"sender_id": null,
|
||||
"step_id": "step-6695658c-942c-48f8-987b-79fcd41a4368",
|
||||
"is_err": null,
|
||||
"seq_id": 6,
|
||||
"run_id": "run-fd4a4e96-b8c1-4d69-bbc0-7b4371f5fd14",
|
||||
"source": "reasoner_model",
|
||||
"reasoning": " test or",
|
||||
"signature": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "stream_event",
|
||||
"uuid": "7f876da4-3260-4ce3-b5d3-876f63ce1280",
|
||||
"event": {
|
||||
"id": "message-7f876da4-3260-4ce3-b5d3-876f63ce12f9",
|
||||
"date": "2026-02-23T06:04:48+00:00",
|
||||
"name": null,
|
||||
"message_type": "reasoning_message",
|
||||
"otid": "7f876da4-3260-4ce3-b5d3-876f63ce1280",
|
||||
"sender_id": null,
|
||||
"step_id": "step-6695658c-942c-48f8-987b-79fcd41a4368",
|
||||
"is_err": null,
|
||||
"seq_id": 7,
|
||||
"run_id": "run-fd4a4e96-b8c1-4d69-bbc0-7b4371f5fd14",
|
||||
"source": "reasoner_model",
|
||||
"reasoning": " verification",
|
||||
"signature": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "stream_event",
|
||||
"uuid": "7f876da4-3260-4ce3-b5d3-876f63ce1280",
|
||||
"event": {
|
||||
"id": "message-7f876da4-3260-4ce3-b5d3-876f63ce12f9",
|
||||
"date": "2026-02-23T06:04:48+00:00",
|
||||
"name": null,
|
||||
"message_type": "reasoning_message",
|
||||
"otid": "7f876da4-3260-4ce3-b5d3-876f63ce1280",
|
||||
"sender_id": null,
|
||||
"step_id": "step-6695658c-942c-48f8-987b-79fcd41a4368",
|
||||
"is_err": null,
|
||||
"seq_id": 8,
|
||||
"run_id": "run-fd4a4e96-b8c1-4d69-bbc0-7b4371f5fd14",
|
||||
"source": "reasoner_model",
|
||||
"reasoning": " message,",
|
||||
"signature": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "stream_event",
|
||||
"uuid": "7f876da4-3260-4ce3-b5d3-876f63ce1280",
|
||||
"event": {
|
||||
"id": "message-7f876da4-3260-4ce3-b5d3-876f63ce12f9",
|
||||
"date": "2026-02-23T06:04:48+00:00",
|
||||
"name": null,
|
||||
"message_type": "reasoning_message",
|
||||
"otid": "7f876da4-3260-4ce3-b5d3-876f63ce1280",
|
||||
"sender_id": null,
|
||||
"step_id": "step-6695658c-942c-48f8-987b-79fcd41a4368",
|
||||
"is_err": null,
|
||||
"seq_id": 9,
|
||||
"run_id": "run-fd4a4e96-b8c1-4d69-bbc0-7b4371f5fd14",
|
||||
"source": "reasoner_model",
|
||||
"reasoning": " likely",
|
||||
"signature": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "stream_event",
|
||||
"uuid": "7f876da4-3260-4ce3-b5d3-876f63ce1280",
|
||||
"event": {
|
||||
"id": "message-7f876da4-3260-4ce3-b5d3-876f63ce12f9",
|
||||
"date": "2026-02-23T06:04:48+00:00",
|
||||
"name": null,
|
||||
"message_type": "reasoning_message",
|
||||
"otid": "7f876da4-3260-4ce3-b5d3-876f63ce1280",
|
||||
"sender_id": null,
|
||||
"step_id": "step-6695658c-942c-48f8-987b-79fcd41a4368",
|
||||
"is_err": null,
|
||||
"seq_id": 10,
|
||||
"run_id": "run-fd4a4e96-b8c1-4d69-bbc0-7b4371f5fd14",
|
||||
"source": "reasoner_model",
|
||||
"reasoning": " for",
|
||||
"signature": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "stream_event",
|
||||
"uuid": "7f876da4-3260-4ce3-b5d3-876f63ce1280",
|
||||
"event": {
|
||||
"id": "message-7f876da4-3260-4ce3-b5d3-876f63ce12f9",
|
||||
"date": "2026-02-23T06:04:48+00:00",
|
||||
"name": null,
|
||||
"message_type": "reasoning_message",
|
||||
"otid": "7f876da4-3260-4ce3-b5d3-876f63ce1280",
|
||||
"sender_id": null,
|
||||
"step_id": "step-6695658c-942c-48f8-987b-79fcd41a4368",
|
||||
"is_err": null,
|
||||
"seq_id": 11,
|
||||
"run_id": "run-fd4a4e96-b8c1-4d69-bbc0-7b4371f5fd14",
|
||||
"source": "reasoner_model",
|
||||
"reasoning": " the SDK",
|
||||
"signature": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "stream_event",
|
||||
"uuid": "7f876da4-3260-4ce3-b5d3-876f63ce1280",
|
||||
"event": {
|
||||
"id": "message-7f876da4-3260-4ce3-b5d3-876f63ce12f9",
|
||||
"date": "2026-02-23T06:04:48+00:00",
|
||||
"name": null,
|
||||
"message_type": "reasoning_message",
|
||||
"otid": "7f876da4-3260-4ce3-b5d3-876f63ce1280",
|
||||
"sender_id": null,
|
||||
"step_id": "step-6695658c-942c-48f8-987b-79fcd41a4368",
|
||||
"is_err": null,
|
||||
"seq_id": 12,
|
||||
"run_id": "run-fd4a4e96-b8c1-4d69-bbc0-7b4371f5fd14",
|
||||
"source": "reasoner_model",
|
||||
"reasoning": " integration",
|
||||
"signature": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "stream_event",
|
||||
"uuid": "7f876da4-3260-4ce3-b5d3-876f63ce1280",
|
||||
"event": {
|
||||
"id": "message-7f876da4-3260-4ce3-b5d3-876f63ce12f9",
|
||||
"date": "2026-02-23T06:04:48+00:00",
|
||||
"name": null,
|
||||
"message_type": "reasoning_message",
|
||||
"otid": "7f876da4-3260-4ce3-b5d3-876f63ce1280",
|
||||
"sender_id": null,
|
||||
"step_id": "step-6695658c-942c-48f8-987b-79fcd41a4368",
|
||||
"is_err": null,
|
||||
"seq_id": 13,
|
||||
"run_id": "run-fd4a4e96-b8c1-4d69-bbc0-7b4371f5fd14",
|
||||
"source": "reasoner_model",
|
||||
"reasoning": ". I should",
|
||||
"signature": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "stream_event",
|
||||
"uuid": "7f876da4-3260-4ce3-b5d3-876f63ce1280",
|
||||
"event": {
|
||||
"id": "message-7f876da4-3260-4ce3-b5d3-876f63ce12f9",
|
||||
"date": "2026-02-23T06:04:48+00:00",
|
||||
"name": null,
|
||||
"message_type": "reasoning_message",
|
||||
"otid": "7f876da4-3260-4ce3-b5d3-876f63ce1280",
|
||||
"sender_id": null,
|
||||
"step_id": "step-6695658c-942c-48f8-987b-79fcd41a4368",
|
||||
"is_err": null,
|
||||
"seq_id": 14,
|
||||
"run_id": "run-fd4a4e96-b8c1-4d69-bbc0-7b4371f5fd14",
|
||||
"source": "reasoner_model",
|
||||
"reasoning": " respond with",
|
||||
"signature": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "stream_event",
|
||||
"uuid": "7f876da4-3260-4ce3-b5d3-876f63ce1280",
|
||||
"event": {
|
||||
"id": "message-7f876da4-3260-4ce3-b5d3-876f63ce12f9",
|
||||
"date": "2026-02-23T06:04:48+00:00",
|
||||
"name": null,
|
||||
"message_type": "reasoning_message",
|
||||
"otid": "7f876da4-3260-4ce3-b5d3-876f63ce1280",
|
||||
"sender_id": null,
|
||||
"step_id": "step-6695658c-942c-48f8-987b-79fcd41a4368",
|
||||
"is_err": null,
|
||||
"seq_id": 15,
|
||||
"run_id": "run-fd4a4e96-b8c1-4d69-bbc0-7b4371f5fd14",
|
||||
"source": "reasoner_model",
|
||||
"reasoning": " exactly",
|
||||
"signature": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "stream_event",
|
||||
"uuid": "7f876da4-3260-4ce3-b5d3-876f63ce1280",
|
||||
"event": {
|
||||
"id": "message-7f876da4-3260-4ce3-b5d3-876f63ce12f9",
|
||||
"date": "2026-02-23T06:04:48+00:00",
|
||||
"name": null,
|
||||
"message_type": "reasoning_message",
|
||||
"otid": "7f876da4-3260-4ce3-b5d3-876f63ce1280",
|
||||
"sender_id": null,
|
||||
"step_id": "step-6695658c-942c-48f8-987b-79fcd41a4368",
|
||||
"is_err": null,
|
||||
"seq_id": 16,
|
||||
"run_id": "run-fd4a4e96-b8c1-4d69-bbc0-7b4371f5fd14",
|
||||
"source": "reasoner_model",
|
||||
"reasoning": " that",
|
||||
"signature": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "stream_event",
|
||||
"uuid": "7f876da4-3260-4ce3-b5d3-876f63ce1280",
|
||||
"event": {
|
||||
"id": "message-7f876da4-3260-4ce3-b5d3-876f63ce12f9",
|
||||
"date": "2026-02-23T06:04:48+00:00",
|
||||
"name": null,
|
||||
"message_type": "reasoning_message",
|
||||
"otid": "7f876da4-3260-4ce3-b5d3-876f63ce1280",
|
||||
"sender_id": null,
|
||||
"step_id": "step-6695658c-942c-48f8-987b-79fcd41a4368",
|
||||
"is_err": null,
|
||||
"seq_id": 17,
|
||||
"run_id": "run-fd4a4e96-b8c1-4d69-bbc0-7b4371f5fd14",
|
||||
"source": "reasoner_model",
|
||||
"reasoning": " text and no",
|
||||
"signature": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "stream_event",
|
||||
"uuid": "7f876da4-3260-4ce3-b5d3-876f63ce1280",
|
||||
"event": {
|
||||
"id": "message-7f876da4-3260-4ce3-b5d3-876f63ce12f9",
|
||||
"date": "2026-02-23T06:04:48+00:00",
|
||||
"name": null,
|
||||
"message_type": "reasoning_message",
|
||||
"otid": "7f876da4-3260-4ce3-b5d3-876f63ce1280",
|
||||
"sender_id": null,
|
||||
"step_id": "step-6695658c-942c-48f8-987b-79fcd41a4368",
|
||||
"is_err": null,
|
||||
"seq_id": 18,
|
||||
"run_id": "run-fd4a4e96-b8c1-4d69-bbc0-7b4371f5fd14",
|
||||
"source": "reasoner_model",
|
||||
"reasoning": " additional",
|
||||
"signature": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "stream_event",
|
||||
"uuid": "7f876da4-3260-4ce3-b5d3-876f63ce1280",
|
||||
"event": {
|
||||
"id": "message-7f876da4-3260-4ce3-b5d3-876f63ce12f9",
|
||||
"date": "2026-02-23T06:04:48+00:00",
|
||||
"name": null,
|
||||
"message_type": "reasoning_message",
|
||||
"otid": "7f876da4-3260-4ce3-b5d3-876f63ce1280",
|
||||
"sender_id": null,
|
||||
"step_id": "step-6695658c-942c-48f8-987b-79fcd41a4368",
|
||||
"is_err": null,
|
||||
"seq_id": 19,
|
||||
"run_id": "run-fd4a4e96-b8c1-4d69-bbc0-7b4371f5fd14",
|
||||
"source": "reasoner_model",
|
||||
"reasoning": " content.",
|
||||
"signature": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "stream_event",
|
||||
"uuid": "7f876da4-3260-4ce3-b5d3-876f63ce1281",
|
||||
"event": {
|
||||
"id": "message-7f876da4-3260-4ce3-b5d3-876f63ce12f9",
|
||||
"date": "2026-02-23T06:04:48+00:00",
|
||||
"name": null,
|
||||
"message_type": "assistant_message",
|
||||
"otid": "7f876da4-3260-4ce3-b5d3-876f63ce1281",
|
||||
"sender_id": null,
|
||||
"step_id": "step-6695658c-942c-48f8-987b-79fcd41a4368",
|
||||
"is_err": null,
|
||||
"seq_id": 20,
|
||||
"run_id": "run-fd4a4e96-b8c1-4d69-bbc0-7b4371f5fd14",
|
||||
"content": "SDK"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "stream_event",
|
||||
"uuid": "7f876da4-3260-4ce3-b5d3-876f63ce1281",
|
||||
"event": {
|
||||
"id": "message-7f876da4-3260-4ce3-b5d3-876f63ce12f9",
|
||||
"date": "2026-02-23T06:04:49+00:00",
|
||||
"name": null,
|
||||
"message_type": "assistant_message",
|
||||
"otid": "7f876da4-3260-4ce3-b5d3-876f63ce1281",
|
||||
"sender_id": null,
|
||||
"step_id": "step-6695658c-942c-48f8-987b-79fcd41a4368",
|
||||
"is_err": null,
|
||||
"seq_id": 21,
|
||||
"run_id": "run-fd4a4e96-b8c1-4d69-bbc0-7b4371f5fd14",
|
||||
"content": "_LIVE_OK_opmu"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "stream_event",
|
||||
"uuid": "7f876da4-3260-4ce3-b5d3-876f63ce1281",
|
||||
"event": {
|
||||
"id": "message-7f876da4-3260-4ce3-b5d3-876f63ce12f9",
|
||||
"date": "2026-02-23T06:04:49+00:00",
|
||||
"name": null,
|
||||
"message_type": "assistant_message",
|
||||
"otid": "7f876da4-3260-4ce3-b5d3-876f63ce1281",
|
||||
"sender_id": null,
|
||||
"step_id": "step-6695658c-942c-48f8-987b-79fcd41a4368",
|
||||
"is_err": null,
|
||||
"seq_id": 22,
|
||||
"run_id": "run-fd4a4e96-b8c1-4d69-bbc0-7b4371f5fd14",
|
||||
"content": "of"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "stream_event",
|
||||
"uuid": "e92869da-9f7c-453d-9e5a-e73b3e8c4369",
|
||||
"event": {
|
||||
"message_type": "stop_reason",
|
||||
"stop_reason": "end_turn"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "stream_event",
|
||||
"uuid": "85663d3a-0a18-47b9-9c7c-7986cf4eb629",
|
||||
"event": {
|
||||
"message_type": "usage_statistics",
|
||||
"completion_tokens": "<redacted>",
|
||||
"prompt_tokens": "<redacted>",
|
||||
"total_tokens": "<redacted>",
|
||||
"step_count": 1,
|
||||
"run_ids": null,
|
||||
"cached_input_tokens": "<redacted>",
|
||||
"cache_write_tokens": "<redacted>",
|
||||
"reasoning_tokens": "<redacted>",
|
||||
"context_tokens": "<redacted>"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "result",
|
||||
"success": true,
|
||||
"durationMs": 5680,
|
||||
"conversationId": "conv-5567c301-1ce6-4eee-a4a6-a4ac62e088f1"
|
||||
}
|
||||
]
|
||||
}
|
||||
3488
src/tests/fixtures/live/stream_event_partials.json
vendored
Normal file
3488
src/tests/fixtures/live/stream_event_partials.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1994
src/tests/fixtures/live/tool_lifecycle.json
vendored
Normal file
1994
src/tests/fixtures/live/tool_lifecycle.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
552
src/tests/live.integration.test.ts
Normal file
552
src/tests/live.integration.test.ts
Normal file
@@ -0,0 +1,552 @@
|
||||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import { createSession, resumeSession, type Session } from "../index.js";
|
||||
import type {
|
||||
SDKMessage,
|
||||
SDKResultMessage,
|
||||
SDKStreamEventMessage,
|
||||
ListMessagesResult,
|
||||
} from "../types.js";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const API_KEY = process.env.LETTA_API_KEY;
|
||||
const RUN_LIVE = process.env.LETTA_LIVE_INTEGRATION === "1" && !!API_KEY;
|
||||
const RECORD_FIXTURES = process.env.LETTA_RECORD_FIXTURES === "1";
|
||||
const BASE_URL = process.env.LETTA_BASE_URL ?? "https://api.letta.com";
|
||||
const AGENT_ID_OVERRIDE = process.env.LETTA_AGENT_ID;
|
||||
const CONVERSATION_ID_OVERRIDE = process.env.LETTA_CONVERSATION_ID;
|
||||
const TEST_TIMEOUT_MS = Number(process.env.LETTA_LIVE_TEST_TIMEOUT_MS ?? "180000");
|
||||
|
||||
const describeLive = RUN_LIVE ? describe : describe.skip;
|
||||
|
||||
type AgentSummary = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
tools?: Array<{ name?: string }>;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
let agentId = "";
|
||||
let selectedAgentName = "";
|
||||
let seededConversationId = "";
|
||||
let ensureReadyPromise: Promise<void> | null = null;
|
||||
const openedSessions: Session[] = [];
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function log(message: string, data?: unknown): void {
|
||||
if (data === undefined) {
|
||||
console.log(`[live-sdk:${nowIso()}] ${message}`);
|
||||
return;
|
||||
}
|
||||
console.log(`[live-sdk:${nowIso()}] ${message}`, data);
|
||||
}
|
||||
|
||||
function hasTool(agent: AgentSummary, toolName: string): boolean {
|
||||
return !!agent.tools?.some((t) => t?.name === toolName);
|
||||
}
|
||||
|
||||
function pickBestAgent(agents: AgentSummary[]): AgentSummary | null {
|
||||
if (agents.length === 0) return null;
|
||||
|
||||
const byNameAndBash = agents.find(
|
||||
(a) => /big\s*chungus|lettabot/i.test(a.name ?? "") && hasTool(a, "Bash"),
|
||||
);
|
||||
if (byNameAndBash) return byNameAndBash;
|
||||
|
||||
const byTagAndBash = agents.find(
|
||||
(a) => (a.tags ?? []).includes("origin:letta-code") && hasTool(a, "Bash"),
|
||||
);
|
||||
if (byTagAndBash) return byTagAndBash;
|
||||
|
||||
const byBash = agents.find((a) => hasTool(a, "Bash"));
|
||||
if (byBash) return byBash;
|
||||
|
||||
return agents.find((a) => typeof a.id === "string") ?? null;
|
||||
}
|
||||
|
||||
async function discoverAgent(): Promise<{ id: string; name: string }> {
|
||||
if (AGENT_ID_OVERRIDE) return { id: AGENT_ID_OVERRIDE, name: "override" };
|
||||
if (!API_KEY) throw new Error("LETTA_API_KEY is required");
|
||||
|
||||
const response = await fetch(`${BASE_URL}/v1/agents?limit=200`, {
|
||||
headers: { Authorization: `Bearer ${API_KEY}` },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Failed to discover agent: ${response.status} ${text}`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as AgentSummary[];
|
||||
if (!Array.isArray(payload) || payload.length === 0) {
|
||||
throw new Error("No agents found for LETTA_API_KEY");
|
||||
}
|
||||
|
||||
const picked = pickBestAgent(payload);
|
||||
if (!picked?.id) throw new Error("Could not pick a valid agent from discovered agents");
|
||||
|
||||
return { id: picked.id, name: picked.name ?? "unnamed" };
|
||||
}
|
||||
|
||||
async function ensureAgentReady(): Promise<void> {
|
||||
if (agentId) return;
|
||||
if (!ensureReadyPromise) {
|
||||
ensureReadyPromise = (async () => {
|
||||
const discovered = await discoverAgent();
|
||||
agentId = discovered.id;
|
||||
selectedAgentName = discovered.name;
|
||||
log(`using agentId=${agentId} (${selectedAgentName})`);
|
||||
})();
|
||||
}
|
||||
await ensureReadyPromise;
|
||||
}
|
||||
|
||||
function redactString(value: string): string {
|
||||
return value
|
||||
.replace(/sk-let-[A-Za-z0-9:=+/_-]+/g, "<redacted-api-key>")
|
||||
.replace(/\/Users\/[^\s"']+/g, "/Users/<redacted>");
|
||||
}
|
||||
|
||||
function sanitizeValue(value: unknown): unknown {
|
||||
if (typeof value === "string") return redactString(value);
|
||||
if (Array.isArray(value)) return value.map(sanitizeValue);
|
||||
if (value && typeof value === "object") {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (k.toLowerCase().includes("token") || k.toLowerCase().includes("authorization")) {
|
||||
out[k] = "<redacted>";
|
||||
} else {
|
||||
out[k] = sanitizeValue(v);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function summarizeMessage(message: SDKMessage): Record<string, unknown> {
|
||||
const base: Record<string, unknown> = { type: message.type };
|
||||
|
||||
if ("uuid" in message && typeof message.uuid === "string") {
|
||||
base.uuid = message.uuid;
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case "assistant":
|
||||
case "reasoning":
|
||||
base.contentPreview = message.content.slice(0, 160);
|
||||
base.contentLength = message.content.length;
|
||||
break;
|
||||
case "tool_call":
|
||||
base.toolCallId = message.toolCallId;
|
||||
base.toolName = message.toolName;
|
||||
base.toolInput = sanitizeValue(message.toolInput);
|
||||
break;
|
||||
case "tool_result":
|
||||
base.toolCallId = message.toolCallId;
|
||||
base.isError = message.isError;
|
||||
base.contentPreview = message.content.slice(0, 160);
|
||||
base.contentLength = message.content.length;
|
||||
break;
|
||||
case "stream_event":
|
||||
base.event = sanitizeValue(message.event);
|
||||
break;
|
||||
case "result":
|
||||
base.success = message.success;
|
||||
base.error = message.error;
|
||||
base.stopReason = message.stopReason;
|
||||
base.durationMs = message.durationMs;
|
||||
base.conversationId = message.conversationId;
|
||||
break;
|
||||
case "error":
|
||||
base.message = message.message;
|
||||
base.stopReason = message.stopReason;
|
||||
base.runId = message.runId;
|
||||
base.apiError = sanitizeValue(message.apiError);
|
||||
break;
|
||||
case "retry":
|
||||
base.reason = message.reason;
|
||||
base.attempt = message.attempt;
|
||||
base.maxAttempts = message.maxAttempts;
|
||||
base.delayMs = message.delayMs;
|
||||
base.runId = message.runId;
|
||||
break;
|
||||
case "init":
|
||||
base.agentId = message.agentId;
|
||||
base.sessionId = message.sessionId;
|
||||
base.conversationId = message.conversationId;
|
||||
base.model = message.model;
|
||||
break;
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
async function writeFixture(name: string, body: unknown): Promise<void> {
|
||||
if (!RECORD_FIXTURES) return;
|
||||
|
||||
const thisFile = fileURLToPath(import.meta.url);
|
||||
const fixtureDir = join(dirname(thisFile), "fixtures", "live");
|
||||
await mkdir(fixtureDir, { recursive: true });
|
||||
const target = join(fixtureDir, `${name}.json`);
|
||||
await writeFile(target, JSON.stringify(sanitizeValue(body), null, 2), "utf8");
|
||||
log(`wrote fixture ${target}`);
|
||||
}
|
||||
|
||||
async function collectTurn(session: Session, prompt: string): Promise<SDKMessage[]> {
|
||||
await session.send(prompt);
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
const start = Date.now();
|
||||
|
||||
for await (const message of session.stream()) {
|
||||
messages.push(message);
|
||||
if (Date.now() - start > TEST_TIMEOUT_MS) {
|
||||
throw new Error(`stream timed out after ${TEST_TIMEOUT_MS}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
function expectTerminalResult(messages: SDKMessage[]): SDKResultMessage {
|
||||
const last = messages[messages.length - 1];
|
||||
expect(last).toBeDefined();
|
||||
expect(last?.type).toBe("result");
|
||||
return last as SDKResultMessage;
|
||||
}
|
||||
|
||||
function hasRenderableContent(messages: SDKMessage[]): boolean {
|
||||
return messages.some((m) => m.type === "assistant" || m.type === "reasoning" || m.type === "stream_event");
|
||||
}
|
||||
|
||||
function pickAnyMessageId(page: ListMessagesResult): string | null {
|
||||
for (const item of page.messages) {
|
||||
if (item && typeof item === "object") {
|
||||
const obj = item as Record<string, unknown>;
|
||||
if (typeof obj.id === "string") return obj.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function assertRawMessageShape(page: ListMessagesResult): void {
|
||||
for (const item of page.messages) {
|
||||
expect(item && typeof item === "object").toBe(true);
|
||||
const obj = item as Record<string, unknown>;
|
||||
const discriminator = obj.message_type ?? obj.type;
|
||||
expect(typeof discriminator === "string").toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
describeLive("live integration: letta-code-sdk", () => {
|
||||
afterAll(() => {
|
||||
for (const session of openedSessions) {
|
||||
session.close();
|
||||
}
|
||||
openedSessions.length = 0;
|
||||
});
|
||||
|
||||
test(
|
||||
"createSession initialize returns stable init contract",
|
||||
async () => {
|
||||
await ensureAgentReady();
|
||||
|
||||
const session = createSession(agentId, {
|
||||
permissionMode: "bypassPermissions",
|
||||
includePartialMessages: true,
|
||||
});
|
||||
openedSessions.push(session);
|
||||
|
||||
const init = await session.initialize();
|
||||
seededConversationId = init.conversationId;
|
||||
|
||||
expect(init.type).toBe("init");
|
||||
expect(init.agentId).toBe(agentId);
|
||||
expect(init.sessionId.length).toBeGreaterThan(5);
|
||||
expect(init.conversationId.startsWith("conv-")).toBe(true);
|
||||
expect(Array.isArray(init.tools)).toBe(true);
|
||||
|
||||
await writeFixture("init_contract", {
|
||||
selectedAgentName,
|
||||
init,
|
||||
});
|
||||
},
|
||||
TEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
test(
|
||||
"resumeSession(conversationId) rehydrates existing conversation",
|
||||
async () => {
|
||||
await ensureAgentReady();
|
||||
|
||||
const conversationId = CONVERSATION_ID_OVERRIDE || seededConversationId;
|
||||
expect(conversationId.startsWith("conv-")).toBe(true);
|
||||
|
||||
const session = resumeSession(conversationId, {
|
||||
permissionMode: "bypassPermissions",
|
||||
includePartialMessages: true,
|
||||
});
|
||||
openedSessions.push(session);
|
||||
|
||||
const init = await session.initialize();
|
||||
expect(init.conversationId).toBe(conversationId);
|
||||
|
||||
await writeFixture("resume_conversation_init", init);
|
||||
},
|
||||
TEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
test(
|
||||
"send + stream yields renderable messages and terminal result",
|
||||
async () => {
|
||||
await ensureAgentReady();
|
||||
|
||||
const session = createSession(agentId, {
|
||||
permissionMode: "bypassPermissions",
|
||||
includePartialMessages: true,
|
||||
});
|
||||
openedSessions.push(session);
|
||||
|
||||
const init = await session.initialize();
|
||||
const nonce = Math.random().toString(36).slice(2, 8);
|
||||
const prompt = `Reply with exactly this text and nothing else: SDK_LIVE_OK_${nonce}`;
|
||||
|
||||
const messages = await collectTurn(session, prompt);
|
||||
const result = expectTerminalResult(messages);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.conversationId).toBe(init.conversationId);
|
||||
expect(hasRenderableContent(messages)).toBe(true);
|
||||
|
||||
await writeFixture("send_stream_basic", {
|
||||
init,
|
||||
prompt,
|
||||
messages: messages.map(summarizeMessage),
|
||||
});
|
||||
},
|
||||
TEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
test(
|
||||
"includePartialMessages=true contract: stream_event if available, otherwise assistant/reasoning fallback",
|
||||
async () => {
|
||||
await ensureAgentReady();
|
||||
|
||||
const session = createSession(agentId, {
|
||||
permissionMode: "bypassPermissions",
|
||||
includePartialMessages: true,
|
||||
});
|
||||
openedSessions.push(session);
|
||||
|
||||
const init = await session.initialize();
|
||||
const prompt =
|
||||
"Produce 40 short bullet points about test observability. One bullet per line.";
|
||||
|
||||
const messages = await collectTurn(session, prompt);
|
||||
const result = expectTerminalResult(messages);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const streamEvents = messages.filter((m): m is SDKStreamEventMessage => m.type === "stream_event");
|
||||
const deltaEvents = streamEvents.filter((m) => m.event.type === "content_block_delta");
|
||||
const fallbackRenderable = messages.filter((m) => m.type === "assistant" || m.type === "reasoning");
|
||||
|
||||
expect(streamEvents.length + fallbackRenderable.length).toBeGreaterThan(0);
|
||||
if (deltaEvents.length === 0) {
|
||||
log("no stream_event delta observed on this turn; fallback render path still validated", {
|
||||
selectedAgentName,
|
||||
agentId,
|
||||
});
|
||||
}
|
||||
|
||||
await writeFixture("stream_event_partials", {
|
||||
init,
|
||||
prompt,
|
||||
counts: {
|
||||
total: messages.length,
|
||||
streamEvents: streamEvents.length,
|
||||
deltaEvents: deltaEvents.length,
|
||||
assistantOrReasoning: fallbackRenderable.length,
|
||||
},
|
||||
messages: messages.map(summarizeMessage),
|
||||
});
|
||||
},
|
||||
TEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
test(
|
||||
"listMessages returns raw API messages and paginates",
|
||||
async () => {
|
||||
await ensureAgentReady();
|
||||
|
||||
const session = createSession(agentId, {
|
||||
permissionMode: "bypassPermissions",
|
||||
includePartialMessages: true,
|
||||
});
|
||||
openedSessions.push(session);
|
||||
|
||||
const init = await session.initialize();
|
||||
|
||||
await collectTurn(
|
||||
session,
|
||||
"Respond with two short lines proving this thread has content for backfill testing.",
|
||||
);
|
||||
|
||||
const page1 = await session.listMessages({
|
||||
conversationId: init.conversationId,
|
||||
limit: 25,
|
||||
order: "desc",
|
||||
});
|
||||
|
||||
expect(Array.isArray(page1.messages)).toBe(true);
|
||||
expect(page1.messages.length).toBeGreaterThan(0);
|
||||
expect(typeof page1.hasMore).toBe("boolean");
|
||||
assertRawMessageShape(page1);
|
||||
|
||||
let page2: ListMessagesResult | null = null;
|
||||
if (page1.nextBefore) {
|
||||
page2 = await session.listMessages({
|
||||
conversationId: init.conversationId,
|
||||
before: page1.nextBefore,
|
||||
limit: 25,
|
||||
order: "desc",
|
||||
});
|
||||
expect(Array.isArray(page2.messages)).toBe(true);
|
||||
assertRawMessageShape(page2);
|
||||
}
|
||||
|
||||
await writeFixture("list_messages_pagination", {
|
||||
init,
|
||||
page1Summary: {
|
||||
count: page1.messages.length,
|
||||
hasMore: page1.hasMore,
|
||||
nextBefore: page1.nextBefore,
|
||||
sampleId: pickAnyMessageId(page1),
|
||||
sampleDiscriminator:
|
||||
((page1.messages[0] as Record<string, unknown> | undefined)?.message_type as string | undefined) ??
|
||||
((page1.messages[0] as Record<string, unknown> | undefined)?.type as string | undefined) ??
|
||||
null,
|
||||
},
|
||||
page2Summary: page2
|
||||
? {
|
||||
count: page2.messages.length,
|
||||
hasMore: page2.hasMore,
|
||||
nextBefore: page2.nextBefore,
|
||||
sampleId: pickAnyMessageId(page2),
|
||||
}
|
||||
: null,
|
||||
});
|
||||
},
|
||||
TEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
test(
|
||||
"listMessages is safe while stream is active",
|
||||
async () => {
|
||||
await ensureAgentReady();
|
||||
|
||||
const session = createSession(agentId, {
|
||||
permissionMode: "bypassPermissions",
|
||||
includePartialMessages: true,
|
||||
});
|
||||
openedSessions.push(session);
|
||||
|
||||
const init = await session.initialize();
|
||||
const prompt =
|
||||
"Write a medium-length response with at least 30 numbered lines describing integration test anti-patterns.";
|
||||
|
||||
await session.send(prompt);
|
||||
|
||||
const streamMessages: SDKMessage[] = [];
|
||||
const streamPromise = (async () => {
|
||||
for await (const m of session.stream()) {
|
||||
streamMessages.push(m);
|
||||
}
|
||||
})();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
const page = await session.listMessages({
|
||||
conversationId: init.conversationId,
|
||||
limit: 10,
|
||||
order: "desc",
|
||||
});
|
||||
|
||||
expect(Array.isArray(page.messages)).toBe(true);
|
||||
expect(page.messages.length).toBeGreaterThan(0);
|
||||
|
||||
await streamPromise;
|
||||
const result = expectTerminalResult(streamMessages);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
await writeFixture("list_messages_during_stream", {
|
||||
init,
|
||||
pageSummary: {
|
||||
count: page.messages.length,
|
||||
hasMore: page.hasMore,
|
||||
nextBefore: page.nextBefore,
|
||||
},
|
||||
streamSummary: streamMessages.map(summarizeMessage),
|
||||
});
|
||||
},
|
||||
TEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
test(
|
||||
"tool lifecycle contract (best-effort): if tool_call appears, tool_result must correlate",
|
||||
async () => {
|
||||
await ensureAgentReady();
|
||||
|
||||
const session = createSession(agentId, {
|
||||
permissionMode: "bypassPermissions",
|
||||
includePartialMessages: true,
|
||||
});
|
||||
openedSessions.push(session);
|
||||
|
||||
await session.initialize();
|
||||
|
||||
const attempts: Array<{ prompt: string; messages: SDKMessage[] }> = [];
|
||||
const prompts = [
|
||||
"Use the Bash tool to run: echo LETTA_SDK_LIVE_TOOL_1. Then return only the command output.",
|
||||
"You must invoke Bash. Run exactly: echo LETTA_SDK_LIVE_TOOL_2 and report the output.",
|
||||
"Call Bash now: echo LETTA_SDK_LIVE_TOOL_3",
|
||||
];
|
||||
|
||||
let toolCalls: Array<Extract<SDKMessage, { type: "tool_call" }>> = [];
|
||||
let toolResults: Array<Extract<SDKMessage, { type: "tool_result" }>> = [];
|
||||
|
||||
for (const prompt of prompts) {
|
||||
const messages = await collectTurn(session, prompt);
|
||||
attempts.push({ prompt, messages });
|
||||
toolCalls = messages.filter((m): m is Extract<SDKMessage, { type: "tool_call" }> => m.type === "tool_call");
|
||||
toolResults = messages.filter((m): m is Extract<SDKMessage, { type: "tool_result" }> => m.type === "tool_result");
|
||||
if (toolCalls.length > 0) break;
|
||||
}
|
||||
|
||||
if (toolCalls.length === 0) {
|
||||
log("tool lifecycle best-effort test observed no tool_call on this agent/model; not failing", {
|
||||
selectedAgentName,
|
||||
agentId,
|
||||
});
|
||||
} else {
|
||||
expect(toolResults.length).toBeGreaterThan(0);
|
||||
const callIds = new Set(toolCalls.map((m) => m.toolCallId));
|
||||
for (const result of toolResults) {
|
||||
expect(callIds.has(result.toolCallId)).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
await writeFixture("tool_lifecycle", {
|
||||
selectedAgentName,
|
||||
attempts: attempts.map((a) => ({
|
||||
prompt: a.prompt,
|
||||
messages: a.messages.map(summarizeMessage),
|
||||
})),
|
||||
});
|
||||
},
|
||||
TEST_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
66
src/tests/stream-events.test.ts
Normal file
66
src/tests/stream-events.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { extractStreamTextDelta } from "../stream-events.js";
|
||||
|
||||
describe("extractStreamTextDelta", () => {
|
||||
test("extracts assistant delta from content_block_delta shape", () => {
|
||||
const out = extractStreamTextDelta({
|
||||
type: "content_block_delta",
|
||||
delta: { type: "text_delta", text: "hello" },
|
||||
});
|
||||
|
||||
expect(out).toEqual({ kind: "assistant", text: "hello" });
|
||||
});
|
||||
|
||||
test("extracts reasoning delta from content_block_delta shape", () => {
|
||||
const out = extractStreamTextDelta({
|
||||
type: "content_block_delta",
|
||||
delta: { type: "reasoning_delta", reasoning: "think" },
|
||||
});
|
||||
|
||||
expect(out).toEqual({ kind: "reasoning", text: "think" });
|
||||
});
|
||||
|
||||
test("extracts reasoning from message_type chunk shape", () => {
|
||||
const out = extractStreamTextDelta({
|
||||
message_type: "reasoning_message",
|
||||
reasoning: "step by step",
|
||||
});
|
||||
|
||||
expect(out).toEqual({ kind: "reasoning", text: "step by step" });
|
||||
});
|
||||
|
||||
test("extracts assistant text from assistant_message string content", () => {
|
||||
const out = extractStreamTextDelta({
|
||||
message_type: "assistant_message",
|
||||
content: "final answer",
|
||||
});
|
||||
|
||||
expect(out).toEqual({ kind: "assistant", text: "final answer" });
|
||||
});
|
||||
|
||||
test("extracts assistant text from assistant_message content parts", () => {
|
||||
const out = extractStreamTextDelta({
|
||||
message_type: "assistant_message",
|
||||
content: [
|
||||
{ type: "text", text: "hello " },
|
||||
{ type: "text", text: "world" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(out).toEqual({ kind: "assistant", text: "hello world" });
|
||||
});
|
||||
|
||||
test("returns null for non-text stream events", () => {
|
||||
const out = extractStreamTextDelta({
|
||||
message_type: "tool_call_message",
|
||||
tool_call: { name: "Bash", arguments: "{}" },
|
||||
});
|
||||
|
||||
expect(out).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for unknown/empty shapes", () => {
|
||||
expect(extractStreamTextDelta({ type: "content_block_stop" })).toBeNull();
|
||||
expect(extractStreamTextDelta({})).toBeNull();
|
||||
});
|
||||
});
|
||||
41
src/types.ts
41
src/types.ts
@@ -471,14 +471,43 @@ export interface SDKResultMessage {
|
||||
conversationId: string | null;
|
||||
}
|
||||
|
||||
export interface SDKStreamEventDeltaPayload {
|
||||
type: string;
|
||||
index?: number;
|
||||
delta?: { type?: string; text?: string; reasoning?: string };
|
||||
content_block?: { type?: string; text?: string };
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SDKStreamEventMessagePayload {
|
||||
message_type: string;
|
||||
id?: string;
|
||||
otid?: string | null;
|
||||
content?: unknown;
|
||||
reasoning?: string;
|
||||
name?: string;
|
||||
tool_call?: unknown;
|
||||
tool_calls?: unknown;
|
||||
tool_call_id?: string;
|
||||
tool_return?: string;
|
||||
status?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SDKUnknownStreamEventPayload {
|
||||
type?: string;
|
||||
message_type?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type SDKStreamEventPayload =
|
||||
| SDKStreamEventDeltaPayload
|
||||
| SDKStreamEventMessagePayload
|
||||
| SDKUnknownStreamEventPayload;
|
||||
|
||||
export interface SDKStreamEventMessage {
|
||||
type: "stream_event";
|
||||
event: {
|
||||
type: string; // "content_block_start" | "content_block_delta" | "content_block_stop"
|
||||
index?: number;
|
||||
delta?: { type?: string; text?: string; reasoning?: string };
|
||||
content_block?: { type?: string; text?: string };
|
||||
};
|
||||
event: SDKStreamEventPayload;
|
||||
uuid: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user