agentxjs 2.9.0-dev-20260317033749 → 2.9.0-dev-20260317035728

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxjs",
3
- "version": "2.9.0-dev-20260317033749",
3
+ "version": "2.9.0-dev-20260317035728",
4
4
  "description": "AgentX Client SDK - Local and remote AI agent management",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -23,12 +23,12 @@
23
23
  "test": "bun test bdd/"
24
24
  },
25
25
  "dependencies": {
26
- "@agentxjs/core": "2.9.0-dev-20260317033749",
26
+ "@agentxjs/core": "2.9.0-dev-20260317035728",
27
27
  "@deepracticex/id": "^0.2.0",
28
28
  "@deepracticex/logger": "^1.2.0"
29
29
  },
30
30
  "devDependencies": {
31
- "@agentxjs/devtools": "2.2.2-dev-20260317033749",
31
+ "@agentxjs/devtools": "2.2.2-dev-20260317035728",
32
32
  "@deepracticex/bdd": "^0.3.0",
33
33
  "tsx": "^4.19.0",
34
34
  "typescript": "^5.3.3"
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Tool Call End-to-End Round-Trip Test
3
+ *
4
+ * Uses REAL MonoDriver + Runtime + SQLite persistence.
5
+ * Sends a message that triggers a tool call, then restores
6
+ * from persistence and verifies tool calls survive.
7
+ *
8
+ * Requires ANTHROPIC_API_KEY or DEEPRACTICE_API_KEY in .env.local
9
+ */
10
+
11
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
12
+ import { mkdtemp, rm } from "node:fs/promises";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import type { Message } from "@agentxjs/core/agent";
16
+ import type { DriverConfig } from "@agentxjs/core/driver";
17
+ import { EventBusImpl } from "@agentxjs/core/event";
18
+ import { createAgentXRuntime } from "@agentxjs/core/runtime";
19
+ import { createMonoDriver } from "@agentxjs/mono-driver";
20
+ import { createPersistence, sqliteDriver } from "@agentxjs/node-platform/persistence";
21
+ import { messagesToConversations } from "../reducer";
22
+ import type { AssistantConversation, ToolBlock } from "../types";
23
+
24
+ let tempDir: string;
25
+ let persistence: Awaited<ReturnType<typeof createPersistence>>;
26
+
27
+ const apiKey = process.env.ANTHROPIC_API_KEY || process.env.DEEPRACTICE_API_KEY;
28
+ const baseUrl = process.env.DEEPRACTICE_BASE_URL;
29
+
30
+ beforeAll(async () => {
31
+ tempDir = await mkdtemp(join(tmpdir(), "agentx-tool-e2e-"));
32
+ persistence = await createPersistence(sqliteDriver({ path: join(tempDir, "test.db") }));
33
+ });
34
+
35
+ afterAll(async () => {
36
+ await rm(tempDir, { recursive: true, force: true });
37
+ });
38
+
39
+ describe("Tool call E2E round-trip", () => {
40
+ test("tool call survives persist → restore", async () => {
41
+ if (!apiKey) {
42
+ console.log("Skipping: no API key");
43
+ return;
44
+ }
45
+
46
+ // Create runtime with a simple bash tool
47
+ const createDriver = (config: DriverConfig) =>
48
+ createMonoDriver({
49
+ ...config,
50
+ apiKey: apiKey!,
51
+ baseUrl,
52
+ model: process.env.DEEPRACTICE_MODEL || "claude-sonnet-4-20250514",
53
+ provider: "anthropic",
54
+ maxSteps: 5,
55
+ });
56
+
57
+ const eventBus = new EventBusImpl();
58
+ const runtime = createAgentXRuntime(
59
+ {
60
+ containerRepository: persistence.containers,
61
+ imageRepository: persistence.images,
62
+ sessionRepository: persistence.sessions,
63
+ eventBus,
64
+ bashProvider: {
65
+ type: "test",
66
+ execute: async (cmd: string) => ({
67
+ stdout: `executed: ${cmd}`,
68
+ stderr: "",
69
+ exitCode: 0,
70
+ }),
71
+ },
72
+ },
73
+ createDriver
74
+ );
75
+
76
+ // Create image
77
+ const { createImage } = await import("@agentxjs/core/image");
78
+ const image = await createImage(
79
+ {
80
+ containerId: "default",
81
+ name: "Tool Test Agent",
82
+ embody: {
83
+ systemPrompt:
84
+ "You have a bash tool. When asked to run a command, use the bash tool. Be brief.",
85
+ },
86
+ },
87
+ {
88
+ imageRepository: persistence.images,
89
+ sessionRepository: persistence.sessions,
90
+ }
91
+ );
92
+
93
+ const imageId = image.toRecord().imageId;
94
+ const sessionId = image.toRecord().sessionId;
95
+
96
+ // Create agent and send a message that should trigger tool use
97
+ const agent = await runtime.createAgent({ imageId });
98
+
99
+ // Debug: track ALL events
100
+ const allEvents: string[] = [];
101
+ eventBus.onAny((event: any) => {
102
+ allEvents.push(event.type);
103
+ if (event.type === "assistant_message") {
104
+ const content = event.data?.content;
105
+ const hasToolCall =
106
+ Array.isArray(content) && content.some((p: any) => p.type === "tool-call");
107
+ console.log(
108
+ ` [EVENT] assistant_message: hasToolCall=${hasToolCall}, content=${JSON.stringify(content).substring(0, 80)}`
109
+ );
110
+ }
111
+ });
112
+
113
+ // Wait for response completion
114
+ const responsePromise = new Promise<void>((resolve) => {
115
+ eventBus.onAny((event: any) => {
116
+ if (event.type === "message_stop" && event.data?.stopReason === "end_turn") {
117
+ setTimeout(resolve, 500);
118
+ }
119
+ });
120
+ });
121
+
122
+ // Send message that triggers bash tool
123
+ await runtime.receive(
124
+ agent.instanceId,
125
+ 'Run the command "echo hello world" using the bash tool.'
126
+ );
127
+
128
+ // Wait for completion
129
+ await responsePromise;
130
+
131
+ // Debug: show event sequence
132
+ console.log("\n=== Event sequence ===");
133
+ console.log(` ${allEvents.join(" → ")}`);
134
+
135
+ // === Phase 1: Check persisted messages ===
136
+ const messages = await persistence.sessions.getMessages(sessionId);
137
+
138
+ console.log("\n=== Persisted messages ===");
139
+ for (const msg of messages) {
140
+ const content = (msg as any).content ?? (msg as any).toolResult;
141
+ const preview =
142
+ typeof content === "string"
143
+ ? content.substring(0, 80)
144
+ : JSON.stringify(content).substring(0, 120);
145
+ console.log(` ${msg.subtype}: ${preview}`);
146
+ }
147
+
148
+ // Should have: user + assistant(tool-call) + tool-result + assistant(text)
149
+ const assistantMsgs = messages.filter((m) => m.subtype === "assistant");
150
+ const toolResultMsgs = messages.filter((m) => m.subtype === "tool-result");
151
+
152
+ console.log(
153
+ `\n Total: ${messages.length} messages (${assistantMsgs.length} assistant, ${toolResultMsgs.length} tool-result)`
154
+ );
155
+
156
+ // Verify assistant message has tool-call content parts
157
+ const assistantWithToolCall = assistantMsgs.find((m) => {
158
+ const content = (m as any).content;
159
+ if (Array.isArray(content)) {
160
+ return content.some((p: any) => p.type === "tool-call");
161
+ }
162
+ return false;
163
+ });
164
+
165
+ console.log(
166
+ ` Assistant with tool-call: ${assistantWithToolCall ? "YES" : "NO - BUG IN PERSISTENCE"}`
167
+ );
168
+
169
+ // === Phase 2: Restore from persistence (simulate page refresh) ===
170
+ const conversations = messagesToConversations(messages);
171
+
172
+ console.log("\n=== Restored conversations ===");
173
+ for (const conv of conversations) {
174
+ if (conv.role !== "error") {
175
+ const blocks = (conv as any).blocks;
176
+ console.log(
177
+ ` ${conv.role}: [${blocks.map((b: any) => `${b.type}${b.type === "tool" ? `(${b.toolName})` : ""}`).join(", ")}]`
178
+ );
179
+ }
180
+ }
181
+
182
+ // Find tool blocks
183
+ const assistantConvs = conversations.filter(
184
+ (c) => c.role === "assistant"
185
+ ) as AssistantConversation[];
186
+ const allBlocks = assistantConvs.flatMap((c) => c.blocks);
187
+ const toolBlocks = allBlocks.filter((b) => b.type === "tool") as ToolBlock[];
188
+
189
+ console.log(`\n Tool blocks after restore: ${toolBlocks.length}`);
190
+ for (const tb of toolBlocks) {
191
+ console.log(
192
+ ` ${tb.toolName}: status=${tb.status}, result=${tb.toolResult?.substring(0, 50)}`
193
+ );
194
+ }
195
+
196
+ // THE CRITICAL ASSERTION
197
+ expect(toolBlocks.length).toBeGreaterThanOrEqual(1);
198
+ expect(toolBlocks[0].status).toBe("completed");
199
+ expect(toolBlocks[0].toolResult).toBeDefined();
200
+
201
+ // Cleanup
202
+ await runtime.destroyAgent(agent.instanceId);
203
+ await runtime.shutdown();
204
+ }, 60_000);
205
+ });
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Tool Call Round-Trip Test
3
+ *
4
+ * Simulates the FULL lifecycle:
5
+ * 1. MonoDriver emits stream events (tool call flow)
6
+ * 2. MessageAssembler processes into Messages
7
+ * 3. Messages are "persisted" (collected)
8
+ * 4. messagesToConversations restores from "persistence"
9
+ * 5. Verify tool calls survive the round-trip
10
+ *
11
+ * This reproduces the page-refresh bug: tool calls lost after reconnection.
12
+ */
13
+
14
+ import { describe, expect, test } from "bun:test";
15
+ import type { Message } from "@agentxjs/core/agent";
16
+ import {
17
+ createInitialMessageAssemblerState,
18
+ messageAssemblerProcessor,
19
+ } from "@agentxjs/core/agent";
20
+ import { messagesToConversations } from "../reducer";
21
+ import type { AssistantConversation, ToolBlock } from "../types";
22
+
23
+ /**
24
+ * Helper: create a stream event
25
+ */
26
+ function streamEvent(type: string, data: Record<string, unknown>) {
27
+ return { type, timestamp: Date.now(), data };
28
+ }
29
+
30
+ /**
31
+ * Helper: process events through MessageAssembler, collect output messages
32
+ */
33
+ function processEvents(events: Array<{ type: string; timestamp: number; data: any }>) {
34
+ let state = createInitialMessageAssemblerState();
35
+ const allMessages: Message[] = [];
36
+
37
+ for (const event of events) {
38
+ const [newState, outputs] = messageAssemblerProcessor(state, event);
39
+ state = newState;
40
+
41
+ for (const output of outputs) {
42
+ // Collect message-type outputs (these are what Runtime persists)
43
+ if (output.type === "assistant_message" || output.type === "tool_result_message") {
44
+ allMessages.push(output.data as Message);
45
+ }
46
+ }
47
+ }
48
+
49
+ return allMessages;
50
+ }
51
+
52
+ describe("Tool call round-trip (persist → restore)", () => {
53
+ test("single tool call: events → messages → conversations", () => {
54
+ // Simulate MonoDriver event sequence for a tool call
55
+ const events = [
56
+ // Step 1: LLM decides to call a tool
57
+ streamEvent("message_start", { messageId: "msg_1", model: "claude" }),
58
+ streamEvent("text_delta", { text: "Let me check that." }),
59
+ streamEvent("tool_use_start", { toolCallId: "call_1", toolName: "bash" }),
60
+ streamEvent("input_json_delta", { partialJson: '{"command":' }),
61
+ streamEvent("input_json_delta", { partialJson: '"ls /tmp"}' }),
62
+ streamEvent("tool_use_stop", {
63
+ toolCallId: "call_1",
64
+ toolName: "bash",
65
+ input: { command: "ls /tmp" },
66
+ }),
67
+ // MonoDriver injects message_stop before tool_result
68
+ streamEvent("message_stop", { stopReason: "tool_use" }),
69
+ // Tool result
70
+ streamEvent("tool_result", {
71
+ toolCallId: "call_1",
72
+ result: "file1.txt\nfile2.txt",
73
+ isError: false,
74
+ }),
75
+ // Step 2: LLM responds with text
76
+ streamEvent("message_start", { messageId: "msg_2", model: "claude" }),
77
+ streamEvent("text_delta", { text: "Found 2 files." }),
78
+ streamEvent("message_stop", { stopReason: "end_turn" }),
79
+ ];
80
+
81
+ // Phase 1: Process events → get persisted messages
82
+ const messages: Message[] = [
83
+ // User message (always persisted separately by Runtime)
84
+ {
85
+ id: "msg_user",
86
+ role: "user",
87
+ subtype: "user",
88
+ content: "List files in /tmp",
89
+ timestamp: Date.now(),
90
+ } as any,
91
+ // Messages from MessageAssembler
92
+ ...processEvents(events),
93
+ ];
94
+
95
+ console.log("\n=== Persisted messages ===");
96
+ for (const msg of messages) {
97
+ console.log(
98
+ ` ${msg.subtype}: ${JSON.stringify((msg as any).content ?? (msg as any).toolResult).substring(0, 100)}`
99
+ );
100
+ }
101
+
102
+ // Phase 2: Restore from "persistence" (page refresh scenario)
103
+ const conversations = messagesToConversations(messages);
104
+
105
+ console.log("\n=== Restored conversations ===");
106
+ for (const conv of conversations) {
107
+ console.log(
108
+ ` ${conv.role}: blocks=[${conv.role !== "error" ? (conv as any).blocks.map((b: any) => b.type).join(", ") : conv.message}]`
109
+ );
110
+ }
111
+
112
+ // Verify: user message exists
113
+ expect(conversations[0].role).toBe("user");
114
+
115
+ // Verify: assistant conversation has tool block
116
+ const assistantConvs = conversations.filter(
117
+ (c) => c.role === "assistant"
118
+ ) as AssistantConversation[];
119
+ expect(assistantConvs.length).toBeGreaterThan(0);
120
+
121
+ // Find tool block in any assistant conversation
122
+ const allBlocks = assistantConvs.flatMap((c) => c.blocks);
123
+ const toolBlocks = allBlocks.filter((b) => b.type === "tool") as ToolBlock[];
124
+
125
+ console.log("\n=== Tool blocks ===");
126
+ for (const tb of toolBlocks) {
127
+ console.log(
128
+ ` ${tb.toolName}: id=${tb.toolUseId}, status=${tb.status}, result=${tb.toolResult}`
129
+ );
130
+ }
131
+
132
+ // THIS IS THE CRITICAL ASSERTION
133
+ // If this fails, tool calls are lost on page refresh
134
+ expect(toolBlocks.length).toBeGreaterThanOrEqual(1);
135
+ expect(toolBlocks[0].toolName).toBe("bash");
136
+ expect(toolBlocks[0].toolUseId).toBe("call_1");
137
+ expect(toolBlocks[0].status).toBe("completed");
138
+ expect(toolBlocks[0].toolResult).toBe("file1.txt\nfile2.txt");
139
+
140
+ // Verify text is also present
141
+ const textBlocks = allBlocks.filter((b) => b.type === "text");
142
+ expect(textBlocks.length).toBeGreaterThanOrEqual(1);
143
+ });
144
+
145
+ test("multi-step tool call: second step text survives", () => {
146
+ // This tests the MonoDriver multi-step flow where message_start
147
+ // is only emitted once but there are multiple steps
148
+ const events = [
149
+ // Single message_start for entire multi-step
150
+ streamEvent("message_start", { messageId: "msg_1", model: "claude" }),
151
+ // Step 1: tool call
152
+ streamEvent("tool_use_start", { toolCallId: "call_1", toolName: "write" }),
153
+ streamEvent("input_json_delta", { partialJson: '{"path":"test.txt","content":"hello"}' }),
154
+ streamEvent("tool_use_stop", {
155
+ toolCallId: "call_1",
156
+ toolName: "write",
157
+ input: { path: "test.txt", content: "hello" },
158
+ }),
159
+ // MonoDriver injects message_stop before tool_result
160
+ streamEvent("message_stop", { stopReason: "tool_use" }),
161
+ streamEvent("tool_result", {
162
+ toolCallId: "call_1",
163
+ result: { success: true },
164
+ isError: false,
165
+ }),
166
+ // Step 2: NO new message_start (MonoDriver only emits once)
167
+ // This is where the bug might be
168
+ streamEvent("message_start", { messageId: "msg_2", model: "claude" }),
169
+ streamEvent("text_delta", { text: "File written successfully." }),
170
+ streamEvent("message_stop", { stopReason: "end_turn" }),
171
+ ];
172
+
173
+ const messages: Message[] = [
174
+ {
175
+ id: "msg_user",
176
+ role: "user",
177
+ subtype: "user",
178
+ content: "Write hello to test.txt",
179
+ timestamp: Date.now(),
180
+ } as any,
181
+ ...processEvents(events),
182
+ ];
183
+
184
+ console.log("\n=== Multi-step persisted messages ===");
185
+ for (const msg of messages) {
186
+ console.log(
187
+ ` ${msg.subtype}: ${JSON.stringify((msg as any).content ?? (msg as any).toolResult).substring(0, 120)}`
188
+ );
189
+ }
190
+
191
+ const conversations = messagesToConversations(messages);
192
+
193
+ console.log("\n=== Multi-step restored conversations ===");
194
+ for (const conv of conversations) {
195
+ if (conv.role !== "error") {
196
+ const ac = conv as any;
197
+ console.log(
198
+ ` ${conv.role}: blocks=[${ac.blocks.map((b: any) => `${b.type}${b.type === "tool" ? `(${b.toolName})` : ""}`).join(", ")}]`
199
+ );
200
+ }
201
+ }
202
+
203
+ // User + at least one assistant
204
+ expect(conversations[0].role).toBe("user");
205
+
206
+ const assistantConvs = conversations.filter(
207
+ (c) => c.role === "assistant"
208
+ ) as AssistantConversation[];
209
+ const allBlocks = assistantConvs.flatMap((c) => c.blocks);
210
+
211
+ // Tool call should be present
212
+ const toolBlocks = allBlocks.filter((b) => b.type === "tool") as ToolBlock[];
213
+ expect(toolBlocks.length).toBe(1);
214
+ expect(toolBlocks[0].toolName).toBe("write");
215
+
216
+ // Final text should also be present
217
+ const textBlocks = allBlocks.filter((b) => b.type === "text");
218
+ expect(textBlocks.length).toBeGreaterThanOrEqual(1);
219
+ const hasSuccessText = textBlocks.some((b) => (b as any).content.includes("successfully"));
220
+ expect(hasSuccessText).toBe(true);
221
+ });
222
+ });