ashlrcode 1.0.0

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.
Files changed (133) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +295 -0
  3. package/package.json +46 -0
  4. package/src/__tests__/branded-types.test.ts +47 -0
  5. package/src/__tests__/context.test.ts +163 -0
  6. package/src/__tests__/cost-tracker.test.ts +274 -0
  7. package/src/__tests__/cron.test.ts +197 -0
  8. package/src/__tests__/dream.test.ts +204 -0
  9. package/src/__tests__/error-handler.test.ts +192 -0
  10. package/src/__tests__/features.test.ts +69 -0
  11. package/src/__tests__/file-history.test.ts +177 -0
  12. package/src/__tests__/hooks.test.ts +145 -0
  13. package/src/__tests__/keybindings.test.ts +159 -0
  14. package/src/__tests__/model-patches.test.ts +82 -0
  15. package/src/__tests__/permissions-rules.test.ts +121 -0
  16. package/src/__tests__/permissions.test.ts +108 -0
  17. package/src/__tests__/project-config.test.ts +63 -0
  18. package/src/__tests__/retry.test.ts +321 -0
  19. package/src/__tests__/router.test.ts +158 -0
  20. package/src/__tests__/session-compact.test.ts +191 -0
  21. package/src/__tests__/session.test.ts +145 -0
  22. package/src/__tests__/skill-registry.test.ts +130 -0
  23. package/src/__tests__/speculation.test.ts +196 -0
  24. package/src/__tests__/tasks-v2.test.ts +267 -0
  25. package/src/__tests__/telemetry.test.ts +149 -0
  26. package/src/__tests__/tool-executor.test.ts +141 -0
  27. package/src/__tests__/tool-registry.test.ts +166 -0
  28. package/src/__tests__/undercover.test.ts +93 -0
  29. package/src/__tests__/workflow.test.ts +195 -0
  30. package/src/agent/async-context.ts +64 -0
  31. package/src/agent/context.ts +245 -0
  32. package/src/agent/cron.ts +189 -0
  33. package/src/agent/dream.ts +165 -0
  34. package/src/agent/error-handler.ts +108 -0
  35. package/src/agent/ipc.ts +256 -0
  36. package/src/agent/kairos.ts +207 -0
  37. package/src/agent/loop.ts +314 -0
  38. package/src/agent/model-patches.ts +68 -0
  39. package/src/agent/speculation.ts +219 -0
  40. package/src/agent/sub-agent.ts +125 -0
  41. package/src/agent/system-prompt.ts +231 -0
  42. package/src/agent/team.ts +220 -0
  43. package/src/agent/tool-executor.ts +162 -0
  44. package/src/agent/workflow.ts +189 -0
  45. package/src/agent/worktree-manager.ts +86 -0
  46. package/src/autopilot/queue.ts +186 -0
  47. package/src/autopilot/scanner.ts +245 -0
  48. package/src/autopilot/types.ts +58 -0
  49. package/src/bridge/bridge-client.ts +57 -0
  50. package/src/bridge/bridge-server.ts +81 -0
  51. package/src/cli.ts +1120 -0
  52. package/src/config/features.ts +51 -0
  53. package/src/config/git.ts +137 -0
  54. package/src/config/hooks.ts +201 -0
  55. package/src/config/permissions.ts +251 -0
  56. package/src/config/project-config.ts +63 -0
  57. package/src/config/remote-settings.ts +163 -0
  58. package/src/config/settings-sync.ts +170 -0
  59. package/src/config/settings.ts +113 -0
  60. package/src/config/undercover.ts +76 -0
  61. package/src/config/upgrade-notice.ts +65 -0
  62. package/src/mcp/client.ts +197 -0
  63. package/src/mcp/manager.ts +125 -0
  64. package/src/mcp/oauth.ts +252 -0
  65. package/src/mcp/types.ts +61 -0
  66. package/src/persistence/memory.ts +129 -0
  67. package/src/persistence/session.ts +289 -0
  68. package/src/planning/plan-mode.ts +128 -0
  69. package/src/planning/plan-tools.ts +138 -0
  70. package/src/providers/anthropic.ts +177 -0
  71. package/src/providers/cost-tracker.ts +184 -0
  72. package/src/providers/retry.ts +264 -0
  73. package/src/providers/router.ts +159 -0
  74. package/src/providers/types.ts +79 -0
  75. package/src/providers/xai.ts +217 -0
  76. package/src/repl.tsx +1384 -0
  77. package/src/setup.ts +119 -0
  78. package/src/skills/loader.ts +78 -0
  79. package/src/skills/registry.ts +78 -0
  80. package/src/skills/types.ts +11 -0
  81. package/src/state/file-history.ts +264 -0
  82. package/src/telemetry/event-log.ts +116 -0
  83. package/src/tools/agent.ts +133 -0
  84. package/src/tools/ask-user.ts +229 -0
  85. package/src/tools/bash.ts +146 -0
  86. package/src/tools/config.ts +147 -0
  87. package/src/tools/diff.ts +137 -0
  88. package/src/tools/file-edit.ts +123 -0
  89. package/src/tools/file-read.ts +82 -0
  90. package/src/tools/file-write.ts +82 -0
  91. package/src/tools/glob.ts +76 -0
  92. package/src/tools/grep.ts +187 -0
  93. package/src/tools/ls.ts +77 -0
  94. package/src/tools/lsp.ts +375 -0
  95. package/src/tools/mcp-resources.ts +83 -0
  96. package/src/tools/mcp-tool.ts +47 -0
  97. package/src/tools/memory.ts +148 -0
  98. package/src/tools/notebook-edit.ts +133 -0
  99. package/src/tools/peers.ts +113 -0
  100. package/src/tools/powershell.ts +83 -0
  101. package/src/tools/registry.ts +114 -0
  102. package/src/tools/send-message.ts +75 -0
  103. package/src/tools/sleep.ts +50 -0
  104. package/src/tools/snip.ts +143 -0
  105. package/src/tools/tasks.ts +349 -0
  106. package/src/tools/team.ts +309 -0
  107. package/src/tools/todo-write.ts +93 -0
  108. package/src/tools/tool-search.ts +83 -0
  109. package/src/tools/types.ts +52 -0
  110. package/src/tools/web-browser.ts +263 -0
  111. package/src/tools/web-fetch.ts +118 -0
  112. package/src/tools/web-search.ts +107 -0
  113. package/src/tools/workflow.ts +188 -0
  114. package/src/tools/worktree.ts +143 -0
  115. package/src/types/branded.ts +22 -0
  116. package/src/ui/App.tsx +184 -0
  117. package/src/ui/BuddyPanel.tsx +52 -0
  118. package/src/ui/PermissionPrompt.tsx +29 -0
  119. package/src/ui/banner.ts +217 -0
  120. package/src/ui/buddy-ai.ts +108 -0
  121. package/src/ui/buddy.ts +466 -0
  122. package/src/ui/context-bar.ts +60 -0
  123. package/src/ui/effort.ts +65 -0
  124. package/src/ui/keybindings.ts +143 -0
  125. package/src/ui/markdown.ts +271 -0
  126. package/src/ui/message-renderer.ts +73 -0
  127. package/src/ui/mode.ts +80 -0
  128. package/src/ui/notifications.ts +57 -0
  129. package/src/ui/speech-bubble.ts +95 -0
  130. package/src/ui/spinner.ts +116 -0
  131. package/src/ui/theme.ts +98 -0
  132. package/src/version.ts +5 -0
  133. package/src/voice/voice-mode.ts +169 -0
@@ -0,0 +1,267 @@
1
+ import { describe, test, expect, beforeEach } from "bun:test";
2
+ import {
3
+ taskCreateTool,
4
+ taskUpdateTool,
5
+ taskListTool,
6
+ taskGetTool,
7
+ resetTasks,
8
+ } from "../tools/tasks.ts";
9
+ import type { ToolContext } from "../tools/types.ts";
10
+
11
+ const ctx: ToolContext = {
12
+ cwd: "/tmp",
13
+ requestPermission: async () => true,
14
+ };
15
+
16
+ describe("TaskCreate", () => {
17
+ beforeEach(() => {
18
+ resetTasks();
19
+ });
20
+
21
+ test("creates task with string ID using source prefix", async () => {
22
+ const result = await taskCreateTool.call(
23
+ { subject: "Write tests", description: "Unit tests for tasks" },
24
+ ctx,
25
+ );
26
+ expect(result).toContain("u-001");
27
+ expect(result).toContain("Write tests");
28
+ });
29
+
30
+ test("creates task with agent source prefix", async () => {
31
+ const result = await taskCreateTool.call(
32
+ { subject: "Agent task", description: "From agent", source: "a" },
33
+ ctx,
34
+ );
35
+ expect(result).toContain("a-001");
36
+ });
37
+
38
+ test("increments ID counter across creates", async () => {
39
+ await taskCreateTool.call({ subject: "First", description: "" }, ctx);
40
+ const result = await taskCreateTool.call({ subject: "Second", description: "" }, ctx);
41
+ expect(result).toContain("u-002");
42
+ });
43
+
44
+ test("creates task with blockedBy dependency", async () => {
45
+ await taskCreateTool.call({ subject: "First", description: "" }, ctx);
46
+ await taskCreateTool.call(
47
+ { subject: "Second", description: "", blockedBy: ["u-001"] },
48
+ ctx,
49
+ );
50
+
51
+ // Check that the blocker has blocks[] wired
52
+ const firstJson = await taskGetTool.call({ taskId: "u-001" }, ctx);
53
+ const first = JSON.parse(firstJson);
54
+ expect(first.blocks).toContain("u-002");
55
+
56
+ // Check the blocked task has blockedBy[]
57
+ const secondJson = await taskGetTool.call({ taskId: "u-002" }, ctx);
58
+ const second = JSON.parse(secondJson);
59
+ expect(second.blockedBy).toContain("u-001");
60
+ });
61
+
62
+ test("creates task with owner field", async () => {
63
+ await taskCreateTool.call(
64
+ { subject: "Owned task", description: "", owner: "agent-1" },
65
+ ctx,
66
+ );
67
+
68
+ const json = await taskGetTool.call({ taskId: "u-001" }, ctx);
69
+ const task = JSON.parse(json);
70
+ expect(task.owner).toBe("agent-1");
71
+ });
72
+ });
73
+
74
+ describe("TaskUpdate", () => {
75
+ beforeEach(() => {
76
+ resetTasks();
77
+ });
78
+
79
+ test("updates task status to in_progress", async () => {
80
+ await taskCreateTool.call({ subject: "Do stuff", description: "" }, ctx);
81
+ await taskUpdateTool.call({ taskId: "u-001", status: "in_progress" }, ctx);
82
+
83
+ const json = await taskGetTool.call({ taskId: "u-001" }, ctx);
84
+ const task = JSON.parse(json);
85
+ expect(task.status).toBe("in_progress");
86
+ });
87
+
88
+ test("updates task status to completed with timestamp", async () => {
89
+ await taskCreateTool.call({ subject: "Do stuff", description: "" }, ctx);
90
+ await taskUpdateTool.call({ taskId: "u-001", status: "completed" }, ctx);
91
+
92
+ const json = await taskGetTool.call({ taskId: "u-001" }, ctx);
93
+ const task = JSON.parse(json);
94
+ expect(task.status).toBe("completed");
95
+ expect(task.completedAt).toBeDefined();
96
+ });
97
+
98
+ test("returns not found for invalid taskId", async () => {
99
+ const result = await taskUpdateTool.call({ taskId: "u-999" }, ctx);
100
+ expect(result).toContain("not found");
101
+ });
102
+
103
+ test("addBlocks wires both directions", async () => {
104
+ await taskCreateTool.call({ subject: "Task A", description: "" }, ctx);
105
+ await taskCreateTool.call({ subject: "Task B", description: "" }, ctx);
106
+
107
+ await taskUpdateTool.call(
108
+ { taskId: "u-001", addBlocks: ["u-002"] },
109
+ ctx,
110
+ );
111
+
112
+ const aJson = await taskGetTool.call({ taskId: "u-001" }, ctx);
113
+ const a = JSON.parse(aJson);
114
+ expect(a.blocks).toContain("u-002");
115
+
116
+ const bJson = await taskGetTool.call({ taskId: "u-002" }, ctx);
117
+ const b = JSON.parse(bJson);
118
+ expect(b.blockedBy).toContain("u-001");
119
+ });
120
+
121
+ test("addBlockedBy wires both directions", async () => {
122
+ await taskCreateTool.call({ subject: "Task A", description: "" }, ctx);
123
+ await taskCreateTool.call({ subject: "Task B", description: "" }, ctx);
124
+
125
+ await taskUpdateTool.call(
126
+ { taskId: "u-002", addBlockedBy: ["u-001"] },
127
+ ctx,
128
+ );
129
+
130
+ const aJson = await taskGetTool.call({ taskId: "u-001" }, ctx);
131
+ const a = JSON.parse(aJson);
132
+ expect(a.blocks).toContain("u-002");
133
+
134
+ const bJson = await taskGetTool.call({ taskId: "u-002" }, ctx);
135
+ const b = JSON.parse(bJson);
136
+ expect(b.blockedBy).toContain("u-001");
137
+ });
138
+
139
+ test("addBlocks does not duplicate entries", async () => {
140
+ await taskCreateTool.call({ subject: "Task A", description: "" }, ctx);
141
+ await taskCreateTool.call({ subject: "Task B", description: "" }, ctx);
142
+
143
+ await taskUpdateTool.call({ taskId: "u-001", addBlocks: ["u-002"] }, ctx);
144
+ await taskUpdateTool.call({ taskId: "u-001", addBlocks: ["u-002"] }, ctx);
145
+
146
+ const json = await taskGetTool.call({ taskId: "u-001" }, ctx);
147
+ const task = JSON.parse(json);
148
+ expect(task.blocks.filter((id: string) => id === "u-002")).toHaveLength(1);
149
+ });
150
+
151
+ test("updates owner field", async () => {
152
+ await taskCreateTool.call({ subject: "Task", description: "" }, ctx);
153
+ await taskUpdateTool.call({ taskId: "u-001", owner: "explorer" }, ctx);
154
+
155
+ const json = await taskGetTool.call({ taskId: "u-001" }, ctx);
156
+ const task = JSON.parse(json);
157
+ expect(task.owner).toBe("explorer");
158
+ });
159
+ });
160
+
161
+ describe("TaskGet", () => {
162
+ beforeEach(() => {
163
+ resetTasks();
164
+ });
165
+
166
+ test("returns full JSON detail for existing task", async () => {
167
+ await taskCreateTool.call(
168
+ { subject: "Build feature", description: "Details here", owner: "me" },
169
+ ctx,
170
+ );
171
+
172
+ const json = await taskGetTool.call({ taskId: "u-001" }, ctx);
173
+ const task = JSON.parse(json);
174
+ expect(task.id).toBe("u-001");
175
+ expect(task.subject).toBe("Build feature");
176
+ expect(task.description).toBe("Details here");
177
+ expect(task.status).toBe("pending");
178
+ expect(task.owner).toBe("me");
179
+ expect(task.createdAt).toBeDefined();
180
+ });
181
+
182
+ test("returns not found for missing task", async () => {
183
+ const result = await taskGetTool.call({ taskId: "u-999" }, ctx);
184
+ expect(result).toContain("not found");
185
+ });
186
+ });
187
+
188
+ describe("TaskList", () => {
189
+ beforeEach(() => {
190
+ resetTasks();
191
+ });
192
+
193
+ test("returns 'No tasks.' when empty", async () => {
194
+ const result = await taskListTool.call({}, ctx);
195
+ expect(result).toBe("No tasks.");
196
+ });
197
+
198
+ test("lists tasks with status icons", async () => {
199
+ await taskCreateTool.call({ subject: "Pending task", description: "" }, ctx);
200
+ await taskCreateTool.call({ subject: "Active task", description: "" }, ctx);
201
+ await taskUpdateTool.call({ taskId: "u-002", status: "in_progress" }, ctx);
202
+
203
+ const result = await taskListTool.call({}, ctx);
204
+ expect(result).toContain("○"); // pending
205
+ expect(result).toContain("●"); // in_progress
206
+ });
207
+
208
+ test("shows dependency annotations", async () => {
209
+ await taskCreateTool.call({ subject: "First", description: "" }, ctx);
210
+ await taskCreateTool.call(
211
+ { subject: "Second", description: "", blockedBy: ["u-001"] },
212
+ ctx,
213
+ );
214
+
215
+ const result = await taskListTool.call({}, ctx);
216
+ expect(result).toContain("→ blocks #u-002");
217
+ expect(result).toContain("← blocked by #u-001");
218
+ });
219
+
220
+ test("shows (blocked) when blockers are incomplete", async () => {
221
+ await taskCreateTool.call({ subject: "Blocker", description: "" }, ctx);
222
+ await taskCreateTool.call(
223
+ { subject: "Blocked", description: "", blockedBy: ["u-001"] },
224
+ ctx,
225
+ );
226
+
227
+ const result = await taskListTool.call({}, ctx);
228
+ expect(result).toContain("(blocked)");
229
+ });
230
+
231
+ test("completing a blocker removes (blocked) annotation", async () => {
232
+ await taskCreateTool.call({ subject: "Blocker", description: "" }, ctx);
233
+ await taskCreateTool.call(
234
+ { subject: "Blocked", description: "", blockedBy: ["u-001"] },
235
+ ctx,
236
+ );
237
+
238
+ // Complete the blocker
239
+ await taskUpdateTool.call({ taskId: "u-001", status: "completed" }, ctx);
240
+
241
+ const result = await taskListTool.call({}, ctx);
242
+ expect(result).not.toContain("(blocked)");
243
+ });
244
+
245
+ test("shows owner with @ prefix", async () => {
246
+ await taskCreateTool.call(
247
+ { subject: "Task", description: "", owner: "agent-1" },
248
+ ctx,
249
+ );
250
+
251
+ const result = await taskListTool.call({}, ctx);
252
+ expect(result).toContain("@agent-1");
253
+ });
254
+
255
+ test("shows progress summary", async () => {
256
+ await taskCreateTool.call({ subject: "A", description: "" }, ctx);
257
+ await taskCreateTool.call({ subject: "B", description: "" }, ctx);
258
+ await taskCreateTool.call({ subject: "C", description: "" }, ctx);
259
+ await taskUpdateTool.call({ taskId: "u-001", status: "completed" }, ctx);
260
+ await taskUpdateTool.call({ taskId: "u-002", status: "in_progress" }, ctx);
261
+
262
+ const result = await taskListTool.call({}, ctx);
263
+ expect(result).toContain("1/3 completed");
264
+ expect(result).toContain("1 in progress");
265
+ expect(result).toContain("1 pending");
266
+ });
267
+ });
@@ -0,0 +1,149 @@
1
+ import { test, expect, describe, beforeEach, afterEach } from "bun:test";
2
+ import { mkdtempSync, rmSync, existsSync, writeFileSync, mkdirSync } from "fs";
3
+ import { join } from "path";
4
+ import { tmpdir } from "os";
5
+ import { readFile } from "fs/promises";
6
+ import { setConfigDirForTests } from "../config/settings.ts";
7
+ import {
8
+ initTelemetry,
9
+ logEvent,
10
+ readRecentEvents,
11
+ formatEvents,
12
+ } from "../telemetry/event-log.ts";
13
+
14
+ describe("Telemetry", () => {
15
+ let configDir: string;
16
+
17
+ beforeEach(() => {
18
+ configDir = mkdtempSync(join(tmpdir(), "ashlrcode-telemetry-test-"));
19
+ setConfigDirForTests(configDir);
20
+ });
21
+
22
+ afterEach(() => {
23
+ setConfigDirForTests(null);
24
+ if (existsSync(configDir)) rmSync(configDir, { recursive: true, force: true });
25
+ });
26
+
27
+ test("initTelemetry sets session ID that appears in logged events", async () => {
28
+ initTelemetry("test-session-42");
29
+ await logEvent("session_start");
30
+
31
+ const events = await readRecentEvents();
32
+ expect(events.length).toBe(1);
33
+ expect(events[0]!.sessionId).toBe("test-session-42");
34
+ });
35
+
36
+ test("logEvent writes to JSONL file", async () => {
37
+ initTelemetry("sess-write");
38
+ await logEvent("turn_start", { prompt: "hello" });
39
+ await logEvent("turn_end", { tokens: 100 });
40
+
41
+ const logPath = join(configDir, "telemetry", "events.jsonl");
42
+ expect(existsSync(logPath)).toBe(true);
43
+
44
+ const content = await readFile(logPath, "utf-8");
45
+ const lines = content.trim().split("\n");
46
+ expect(lines.length).toBe(2);
47
+
48
+ const first = JSON.parse(lines[0]!);
49
+ expect(first.type).toBe("turn_start");
50
+ expect(first.data.prompt).toBe("hello");
51
+
52
+ const second = JSON.parse(lines[1]!);
53
+ expect(second.type).toBe("turn_end");
54
+ expect(second.data.tokens).toBe(100);
55
+ });
56
+
57
+ test("readRecentEvents reads back events", async () => {
58
+ initTelemetry("sess-read");
59
+ await logEvent("session_start");
60
+ await logEvent("tool_start", { tool: "Bash" });
61
+ await logEvent("tool_end", { tool: "Bash" });
62
+
63
+ const events = await readRecentEvents();
64
+ expect(events.length).toBe(3);
65
+ expect(events[0]!.type).toBe("session_start");
66
+ expect(events[1]!.type).toBe("tool_start");
67
+ expect(events[2]!.type).toBe("tool_end");
68
+ });
69
+
70
+ test("readRecentEvents respects count parameter", async () => {
71
+ initTelemetry("sess-count");
72
+ await logEvent("session_start");
73
+ await logEvent("turn_start");
74
+ await logEvent("turn_end");
75
+ await logEvent("session_end");
76
+
77
+ const events = await readRecentEvents(2);
78
+ expect(events.length).toBe(2);
79
+ // Should return the last 2 events
80
+ expect(events[0]!.type).toBe("turn_end");
81
+ expect(events[1]!.type).toBe("session_end");
82
+ });
83
+
84
+ test("readRecentEvents returns empty for non-existent log", async () => {
85
+ const events = await readRecentEvents();
86
+ expect(events).toEqual([]);
87
+ });
88
+
89
+ test("formatEvents produces readable output", () => {
90
+ const events = [
91
+ {
92
+ type: "session_start" as const,
93
+ timestamp: "2025-01-15T10:30:00.000Z",
94
+ sessionId: "s1",
95
+ },
96
+ {
97
+ type: "tool_start" as const,
98
+ timestamp: "2025-01-15T10:30:05.000Z",
99
+ sessionId: "s1",
100
+ data: { tool: "Bash" },
101
+ },
102
+ ];
103
+
104
+ const output = formatEvents(events);
105
+ expect(output).toContain("session_start");
106
+ expect(output).toContain("tool_start");
107
+ expect(output).toContain("Bash");
108
+ // Each line should be indented with two spaces
109
+ const lines = output.split("\n");
110
+ expect(lines.length).toBe(2);
111
+ for (const line of lines) {
112
+ expect(line.startsWith(" ")).toBe(true);
113
+ }
114
+ });
115
+
116
+ test("log rotation occurs when file exceeds size limit", async () => {
117
+ initTelemetry("sess-rotate");
118
+
119
+ // Manually create the telemetry dir and seed file
120
+ const telemetryDir = join(configDir, "telemetry");
121
+ mkdirSync(telemetryDir, { recursive: true });
122
+ const logPath = join(telemetryDir, "events.jsonl");
123
+ writeFileSync(logPath, "", "utf-8");
124
+
125
+ // Now overwrite with a large file that exceeds 5MB
126
+ const bigLine = JSON.stringify({
127
+ type: "session_start",
128
+ timestamp: new Date().toISOString(),
129
+ sessionId: "bulk",
130
+ data: { payload: "x".repeat(1000) },
131
+ }) + "\n";
132
+
133
+ const linesNeeded = Math.ceil((5 * 1024 * 1024) / bigLine.length) + 1;
134
+ const bulk = bigLine.repeat(linesNeeded);
135
+ writeFileSync(logPath, bulk, "utf-8");
136
+
137
+ // Log one more event which should trigger rotation
138
+ await logEvent("session_start", { after: "rotation" });
139
+
140
+ // The old file should have been rotated to events.1.jsonl
141
+ const rotatedPath = join(telemetryDir, "events.1.jsonl");
142
+ expect(existsSync(rotatedPath)).toBe(true);
143
+
144
+ // The rotated file should contain the bulk data + the new event
145
+ // (logEvent appends first, then rotates, so the new event is in the rotated file)
146
+ const rotatedContent = await readFile(rotatedPath, "utf-8");
147
+ expect(rotatedContent).toContain("rotation");
148
+ });
149
+ });
@@ -0,0 +1,141 @@
1
+ import { test, expect, describe, beforeEach } from "bun:test";
2
+ import { executeToolCalls } from "../agent/tool-executor.ts";
3
+ import { ToolRegistry } from "../tools/registry.ts";
4
+ import type { Tool, ToolContext } from "../tools/types.ts";
5
+ import type { ToolCall } from "../providers/types.ts";
6
+
7
+ function makeTool(name: string, concurrencySafe: boolean, result = "ok"): Tool {
8
+ return {
9
+ name,
10
+ prompt: () => `Tool ${name}`,
11
+ inputSchema: () => ({ type: "object", properties: {} }),
12
+ isReadOnly: () => true,
13
+ isDestructive: () => false,
14
+ isConcurrencySafe: () => concurrencySafe,
15
+ validateInput: () => null,
16
+ call: async () => result,
17
+ };
18
+ }
19
+
20
+ function makeToolCall(name: string, id?: string): ToolCall {
21
+ return { id: id ?? `call_${name}`, name, input: {} };
22
+ }
23
+
24
+ const ctx: ToolContext = {
25
+ cwd: "/tmp",
26
+ requestPermission: async () => true,
27
+ };
28
+
29
+ describe("executeToolCalls", () => {
30
+ let registry: ToolRegistry;
31
+
32
+ beforeEach(() => {
33
+ registry = new ToolRegistry();
34
+ });
35
+
36
+ test("returns empty array for no tool calls", async () => {
37
+ const results = await executeToolCalls([], registry, ctx);
38
+ expect(results).toEqual([]);
39
+ });
40
+
41
+ test("executes a single tool call", async () => {
42
+ registry.register(makeTool("Read", true, "file content"));
43
+ const results = await executeToolCalls(
44
+ [makeToolCall("Read")],
45
+ registry,
46
+ ctx
47
+ );
48
+ expect(results).toHaveLength(1);
49
+ expect(results[0]!.result).toBe("file content");
50
+ expect(results[0]!.isError).toBe(false);
51
+ expect(results[0]!.name).toBe("Read");
52
+ });
53
+
54
+ test("runs concurrency-safe tools in parallel", async () => {
55
+ const startTimes: number[] = [];
56
+ const makeTimedTool = (name: string): Tool => ({
57
+ ...makeTool(name, true),
58
+ call: async () => {
59
+ startTimes.push(Date.now());
60
+ await new Promise((r) => setTimeout(r, 50));
61
+ return `${name} done`;
62
+ },
63
+ });
64
+
65
+ registry.register(makeTimedTool("A"));
66
+ registry.register(makeTimedTool("B"));
67
+
68
+ const results = await executeToolCalls(
69
+ [makeToolCall("A"), makeToolCall("B")],
70
+ registry,
71
+ ctx
72
+ );
73
+
74
+ expect(results).toHaveLength(2);
75
+ // Both should have started nearly simultaneously (within 30ms)
76
+ expect(Math.abs(startTimes[0]! - startTimes[1]!)).toBeLessThan(30);
77
+ });
78
+
79
+ test("runs non-concurrency-safe tools sequentially", async () => {
80
+ const executionOrder: string[] = [];
81
+ const makeSeqTool = (name: string): Tool => ({
82
+ ...makeTool(name, false),
83
+ call: async () => {
84
+ executionOrder.push(`${name}_start`);
85
+ await new Promise((r) => setTimeout(r, 20));
86
+ executionOrder.push(`${name}_end`);
87
+ return `${name} done`;
88
+ },
89
+ });
90
+
91
+ registry.register(makeSeqTool("X"));
92
+ registry.register(makeSeqTool("Y"));
93
+
94
+ await executeToolCalls(
95
+ [makeToolCall("X"), makeToolCall("Y")],
96
+ registry,
97
+ ctx
98
+ );
99
+
100
+ // Sequential: X should finish before Y starts
101
+ expect(executionOrder).toEqual(["X_start", "X_end", "Y_start", "Y_end"]);
102
+ });
103
+
104
+ test("fires onToolStart and onToolEnd callbacks", async () => {
105
+ registry.register(makeTool("T", true, "result"));
106
+
107
+ const starts: string[] = [];
108
+ const ends: string[] = [];
109
+
110
+ await executeToolCalls([makeToolCall("T")], registry, ctx, {
111
+ onToolStart: (name) => starts.push(name),
112
+ onToolEnd: (name) => ends.push(name),
113
+ });
114
+
115
+ expect(starts).toEqual(["T"]);
116
+ expect(ends).toEqual(["T"]);
117
+ });
118
+
119
+ test("handles errors from tool execution", async () => {
120
+ registry.register(makeTool("Fail", true));
121
+ // The tool is registered but executeToolCalls routes through registry.execute,
122
+ // which catches errors. An unknown tool would return isError.
123
+ const results = await executeToolCalls(
124
+ [makeToolCall("Unknown")],
125
+ registry,
126
+ ctx
127
+ );
128
+ expect(results).toHaveLength(1);
129
+ expect(results[0]!.isError).toBe(true);
130
+ });
131
+
132
+ test("preserves toolCallId in results", async () => {
133
+ registry.register(makeTool("R", true));
134
+ const results = await executeToolCalls(
135
+ [{ id: "my-custom-id", name: "R", input: {} }],
136
+ registry,
137
+ ctx
138
+ );
139
+ expect(results[0]!.toolCallId).toBe("my-custom-id");
140
+ });
141
+ });