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.
- package/LICENSE +21 -0
- package/README.md +295 -0
- package/package.json +46 -0
- package/src/__tests__/branded-types.test.ts +47 -0
- package/src/__tests__/context.test.ts +163 -0
- package/src/__tests__/cost-tracker.test.ts +274 -0
- package/src/__tests__/cron.test.ts +197 -0
- package/src/__tests__/dream.test.ts +204 -0
- package/src/__tests__/error-handler.test.ts +192 -0
- package/src/__tests__/features.test.ts +69 -0
- package/src/__tests__/file-history.test.ts +177 -0
- package/src/__tests__/hooks.test.ts +145 -0
- package/src/__tests__/keybindings.test.ts +159 -0
- package/src/__tests__/model-patches.test.ts +82 -0
- package/src/__tests__/permissions-rules.test.ts +121 -0
- package/src/__tests__/permissions.test.ts +108 -0
- package/src/__tests__/project-config.test.ts +63 -0
- package/src/__tests__/retry.test.ts +321 -0
- package/src/__tests__/router.test.ts +158 -0
- package/src/__tests__/session-compact.test.ts +191 -0
- package/src/__tests__/session.test.ts +145 -0
- package/src/__tests__/skill-registry.test.ts +130 -0
- package/src/__tests__/speculation.test.ts +196 -0
- package/src/__tests__/tasks-v2.test.ts +267 -0
- package/src/__tests__/telemetry.test.ts +149 -0
- package/src/__tests__/tool-executor.test.ts +141 -0
- package/src/__tests__/tool-registry.test.ts +166 -0
- package/src/__tests__/undercover.test.ts +93 -0
- package/src/__tests__/workflow.test.ts +195 -0
- package/src/agent/async-context.ts +64 -0
- package/src/agent/context.ts +245 -0
- package/src/agent/cron.ts +189 -0
- package/src/agent/dream.ts +165 -0
- package/src/agent/error-handler.ts +108 -0
- package/src/agent/ipc.ts +256 -0
- package/src/agent/kairos.ts +207 -0
- package/src/agent/loop.ts +314 -0
- package/src/agent/model-patches.ts +68 -0
- package/src/agent/speculation.ts +219 -0
- package/src/agent/sub-agent.ts +125 -0
- package/src/agent/system-prompt.ts +231 -0
- package/src/agent/team.ts +220 -0
- package/src/agent/tool-executor.ts +162 -0
- package/src/agent/workflow.ts +189 -0
- package/src/agent/worktree-manager.ts +86 -0
- package/src/autopilot/queue.ts +186 -0
- package/src/autopilot/scanner.ts +245 -0
- package/src/autopilot/types.ts +58 -0
- package/src/bridge/bridge-client.ts +57 -0
- package/src/bridge/bridge-server.ts +81 -0
- package/src/cli.ts +1120 -0
- package/src/config/features.ts +51 -0
- package/src/config/git.ts +137 -0
- package/src/config/hooks.ts +201 -0
- package/src/config/permissions.ts +251 -0
- package/src/config/project-config.ts +63 -0
- package/src/config/remote-settings.ts +163 -0
- package/src/config/settings-sync.ts +170 -0
- package/src/config/settings.ts +113 -0
- package/src/config/undercover.ts +76 -0
- package/src/config/upgrade-notice.ts +65 -0
- package/src/mcp/client.ts +197 -0
- package/src/mcp/manager.ts +125 -0
- package/src/mcp/oauth.ts +252 -0
- package/src/mcp/types.ts +61 -0
- package/src/persistence/memory.ts +129 -0
- package/src/persistence/session.ts +289 -0
- package/src/planning/plan-mode.ts +128 -0
- package/src/planning/plan-tools.ts +138 -0
- package/src/providers/anthropic.ts +177 -0
- package/src/providers/cost-tracker.ts +184 -0
- package/src/providers/retry.ts +264 -0
- package/src/providers/router.ts +159 -0
- package/src/providers/types.ts +79 -0
- package/src/providers/xai.ts +217 -0
- package/src/repl.tsx +1384 -0
- package/src/setup.ts +119 -0
- package/src/skills/loader.ts +78 -0
- package/src/skills/registry.ts +78 -0
- package/src/skills/types.ts +11 -0
- package/src/state/file-history.ts +264 -0
- package/src/telemetry/event-log.ts +116 -0
- package/src/tools/agent.ts +133 -0
- package/src/tools/ask-user.ts +229 -0
- package/src/tools/bash.ts +146 -0
- package/src/tools/config.ts +147 -0
- package/src/tools/diff.ts +137 -0
- package/src/tools/file-edit.ts +123 -0
- package/src/tools/file-read.ts +82 -0
- package/src/tools/file-write.ts +82 -0
- package/src/tools/glob.ts +76 -0
- package/src/tools/grep.ts +187 -0
- package/src/tools/ls.ts +77 -0
- package/src/tools/lsp.ts +375 -0
- package/src/tools/mcp-resources.ts +83 -0
- package/src/tools/mcp-tool.ts +47 -0
- package/src/tools/memory.ts +148 -0
- package/src/tools/notebook-edit.ts +133 -0
- package/src/tools/peers.ts +113 -0
- package/src/tools/powershell.ts +83 -0
- package/src/tools/registry.ts +114 -0
- package/src/tools/send-message.ts +75 -0
- package/src/tools/sleep.ts +50 -0
- package/src/tools/snip.ts +143 -0
- package/src/tools/tasks.ts +349 -0
- package/src/tools/team.ts +309 -0
- package/src/tools/todo-write.ts +93 -0
- package/src/tools/tool-search.ts +83 -0
- package/src/tools/types.ts +52 -0
- package/src/tools/web-browser.ts +263 -0
- package/src/tools/web-fetch.ts +118 -0
- package/src/tools/web-search.ts +107 -0
- package/src/tools/workflow.ts +188 -0
- package/src/tools/worktree.ts +143 -0
- package/src/types/branded.ts +22 -0
- package/src/ui/App.tsx +184 -0
- package/src/ui/BuddyPanel.tsx +52 -0
- package/src/ui/PermissionPrompt.tsx +29 -0
- package/src/ui/banner.ts +217 -0
- package/src/ui/buddy-ai.ts +108 -0
- package/src/ui/buddy.ts +466 -0
- package/src/ui/context-bar.ts +60 -0
- package/src/ui/effort.ts +65 -0
- package/src/ui/keybindings.ts +143 -0
- package/src/ui/markdown.ts +271 -0
- package/src/ui/message-renderer.ts +73 -0
- package/src/ui/mode.ts +80 -0
- package/src/ui/notifications.ts +57 -0
- package/src/ui/speech-bubble.ts +95 -0
- package/src/ui/spinner.ts +116 -0
- package/src/ui/theme.ts +98 -0
- package/src/version.ts +5 -0
- package/src/voice/voice-mode.ts +169 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach } from "bun:test";
|
|
2
|
+
import { withRetry, CircuitBreaker, RetryError } from "../providers/retry.ts";
|
|
3
|
+
|
|
4
|
+
describe("withRetry", () => {
|
|
5
|
+
test("succeeds on first attempt", async () => {
|
|
6
|
+
const result = await withRetry(() => Promise.resolve("ok"), {
|
|
7
|
+
providerName: "test",
|
|
8
|
+
});
|
|
9
|
+
expect(result).toBe("ok");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("retries on rate limit (429) and succeeds", async () => {
|
|
13
|
+
let attempt = 0;
|
|
14
|
+
const result = await withRetry(
|
|
15
|
+
() => {
|
|
16
|
+
attempt++;
|
|
17
|
+
if (attempt < 3) throw new Error("429 Too Many Requests");
|
|
18
|
+
return Promise.resolve("recovered");
|
|
19
|
+
},
|
|
20
|
+
{ providerName: "test", baseDelayMs: 1, maxDelayMs: 10 },
|
|
21
|
+
);
|
|
22
|
+
expect(result).toBe("recovered");
|
|
23
|
+
expect(attempt).toBe(3);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("retries on server error (500) and succeeds", async () => {
|
|
27
|
+
let attempt = 0;
|
|
28
|
+
const result = await withRetry(
|
|
29
|
+
() => {
|
|
30
|
+
attempt++;
|
|
31
|
+
if (attempt < 2) throw new Error("500 Internal Server Error");
|
|
32
|
+
return Promise.resolve("recovered");
|
|
33
|
+
},
|
|
34
|
+
{ providerName: "test", baseDelayMs: 1, maxDelayMs: 10 },
|
|
35
|
+
);
|
|
36
|
+
expect(result).toBe("recovered");
|
|
37
|
+
expect(attempt).toBe(2);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("retries on 502 Bad Gateway", async () => {
|
|
41
|
+
let attempt = 0;
|
|
42
|
+
const result = await withRetry(
|
|
43
|
+
() => {
|
|
44
|
+
attempt++;
|
|
45
|
+
if (attempt < 2) throw new Error("502 Bad Gateway");
|
|
46
|
+
return Promise.resolve("ok");
|
|
47
|
+
},
|
|
48
|
+
{ providerName: "test", baseDelayMs: 1, maxDelayMs: 10 },
|
|
49
|
+
);
|
|
50
|
+
expect(result).toBe("ok");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("retries on 503 Service Unavailable", async () => {
|
|
54
|
+
let attempt = 0;
|
|
55
|
+
const result = await withRetry(
|
|
56
|
+
() => {
|
|
57
|
+
attempt++;
|
|
58
|
+
if (attempt < 2) throw new Error("503 Service Unavailable");
|
|
59
|
+
return Promise.resolve("ok");
|
|
60
|
+
},
|
|
61
|
+
{ providerName: "test", baseDelayMs: 1, maxDelayMs: 10 },
|
|
62
|
+
);
|
|
63
|
+
expect(result).toBe("ok");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("fails immediately on 400 (non-retryable)", async () => {
|
|
67
|
+
let attempt = 0;
|
|
68
|
+
try {
|
|
69
|
+
await withRetry(
|
|
70
|
+
() => {
|
|
71
|
+
attempt++;
|
|
72
|
+
throw new Error("400 Bad Request — invalid schema");
|
|
73
|
+
return Promise.resolve("never");
|
|
74
|
+
},
|
|
75
|
+
{ providerName: "test", baseDelayMs: 1 },
|
|
76
|
+
);
|
|
77
|
+
expect(true).toBe(false); // Should not reach
|
|
78
|
+
} catch (err: any) {
|
|
79
|
+
expect(attempt).toBe(1);
|
|
80
|
+
expect(err.message).toContain("400");
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("fails immediately on 401 (auth error) with clear message", async () => {
|
|
85
|
+
let attempt = 0;
|
|
86
|
+
try {
|
|
87
|
+
await withRetry(
|
|
88
|
+
() => {
|
|
89
|
+
attempt++;
|
|
90
|
+
throw new Error("401 Unauthorized");
|
|
91
|
+
},
|
|
92
|
+
{ providerName: "myProvider", baseDelayMs: 1 },
|
|
93
|
+
);
|
|
94
|
+
expect(true).toBe(false);
|
|
95
|
+
} catch (err: any) {
|
|
96
|
+
expect(attempt).toBe(1);
|
|
97
|
+
expect(err.message).toContain("myProvider");
|
|
98
|
+
expect(err.message).toContain("Authentication failed");
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("fails immediately on 403 (auth error)", async () => {
|
|
103
|
+
let attempt = 0;
|
|
104
|
+
try {
|
|
105
|
+
await withRetry(
|
|
106
|
+
() => {
|
|
107
|
+
attempt++;
|
|
108
|
+
throw new Error("403 Forbidden");
|
|
109
|
+
},
|
|
110
|
+
{ providerName: "test", baseDelayMs: 1 },
|
|
111
|
+
);
|
|
112
|
+
expect(true).toBe(false);
|
|
113
|
+
} catch (err: any) {
|
|
114
|
+
expect(attempt).toBe(1);
|
|
115
|
+
expect(err.message).toContain("Authentication failed");
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("respects maxRetries for rate limits", async () => {
|
|
120
|
+
let attempt = 0;
|
|
121
|
+
try {
|
|
122
|
+
await withRetry(
|
|
123
|
+
() => {
|
|
124
|
+
attempt++;
|
|
125
|
+
throw new Error("429 rate_limit exceeded");
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
providerName: "test",
|
|
129
|
+
baseDelayMs: 1,
|
|
130
|
+
maxDelayMs: 10,
|
|
131
|
+
maxRetriesRateLimit: 2,
|
|
132
|
+
},
|
|
133
|
+
);
|
|
134
|
+
expect(true).toBe(false);
|
|
135
|
+
} catch (err: any) {
|
|
136
|
+
// 1 initial + 2 retries = 3 attempts total
|
|
137
|
+
expect(attempt).toBe(3);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("respects maxRetries for server errors", async () => {
|
|
142
|
+
let attempt = 0;
|
|
143
|
+
try {
|
|
144
|
+
await withRetry(
|
|
145
|
+
() => {
|
|
146
|
+
attempt++;
|
|
147
|
+
throw new Error("500 internal server error");
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
providerName: "test",
|
|
151
|
+
baseDelayMs: 1,
|
|
152
|
+
maxDelayMs: 10,
|
|
153
|
+
maxRetriesServer: 1,
|
|
154
|
+
},
|
|
155
|
+
);
|
|
156
|
+
expect(true).toBe(false);
|
|
157
|
+
} catch (err: any) {
|
|
158
|
+
// 1 initial + 1 retry = 2 attempts
|
|
159
|
+
expect(attempt).toBe(2);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("respects maxRetries for network errors", async () => {
|
|
164
|
+
let attempt = 0;
|
|
165
|
+
try {
|
|
166
|
+
await withRetry(
|
|
167
|
+
() => {
|
|
168
|
+
attempt++;
|
|
169
|
+
throw new Error("ECONNREFUSED");
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
providerName: "test",
|
|
173
|
+
baseDelayMs: 1,
|
|
174
|
+
maxDelayMs: 10,
|
|
175
|
+
maxRetriesNetwork: 1,
|
|
176
|
+
},
|
|
177
|
+
);
|
|
178
|
+
expect(true).toBe(false);
|
|
179
|
+
} catch (err: any) {
|
|
180
|
+
expect(attempt).toBe(2);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe("CircuitBreaker", () => {
|
|
186
|
+
let breaker: CircuitBreaker;
|
|
187
|
+
|
|
188
|
+
beforeEach(() => {
|
|
189
|
+
breaker = new CircuitBreaker(3, 100); // low threshold and reset time for testing
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("starts in closed state", () => {
|
|
193
|
+
expect(breaker.getState()).toBe("closed");
|
|
194
|
+
expect(breaker.canRequest()).toBe(true);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("allows requests in closed state", () => {
|
|
198
|
+
expect(breaker.canRequest()).toBe(true);
|
|
199
|
+
expect(breaker.canRequest()).toBe(true);
|
|
200
|
+
expect(breaker.canRequest()).toBe(true);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("opens after threshold consecutive failures", () => {
|
|
204
|
+
breaker.recordFailure();
|
|
205
|
+
breaker.recordFailure();
|
|
206
|
+
expect(breaker.getState()).toBe("closed");
|
|
207
|
+
|
|
208
|
+
breaker.recordFailure(); // threshold = 3
|
|
209
|
+
expect(breaker.getState()).toBe("open");
|
|
210
|
+
expect(breaker.canRequest()).toBe(false);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("success resets failure count", () => {
|
|
214
|
+
breaker.recordFailure();
|
|
215
|
+
breaker.recordFailure();
|
|
216
|
+
breaker.recordSuccess();
|
|
217
|
+
|
|
218
|
+
// Should be back to closed with 0 failures
|
|
219
|
+
expect(breaker.getState()).toBe("closed");
|
|
220
|
+
|
|
221
|
+
// Need 3 more failures to open again
|
|
222
|
+
breaker.recordFailure();
|
|
223
|
+
breaker.recordFailure();
|
|
224
|
+
expect(breaker.getState()).toBe("closed");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("transitions to half-open after reset time", async () => {
|
|
228
|
+
// Open the circuit
|
|
229
|
+
breaker.recordFailure();
|
|
230
|
+
breaker.recordFailure();
|
|
231
|
+
breaker.recordFailure();
|
|
232
|
+
expect(breaker.getState()).toBe("open");
|
|
233
|
+
|
|
234
|
+
// Wait for reset time
|
|
235
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
236
|
+
|
|
237
|
+
// Should transition to half-open
|
|
238
|
+
expect(breaker.getState()).toBe("half-open");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("allows one probe request in half-open state", async () => {
|
|
242
|
+
// Open the circuit
|
|
243
|
+
breaker.recordFailure();
|
|
244
|
+
breaker.recordFailure();
|
|
245
|
+
breaker.recordFailure();
|
|
246
|
+
|
|
247
|
+
// Wait for reset time
|
|
248
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
249
|
+
|
|
250
|
+
// First request should be allowed (probe)
|
|
251
|
+
expect(breaker.canRequest()).toBe(true);
|
|
252
|
+
// Second request should be blocked (probe in flight)
|
|
253
|
+
expect(breaker.canRequest()).toBe(false);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("probe success resets to closed", async () => {
|
|
257
|
+
// Open the circuit
|
|
258
|
+
breaker.recordFailure();
|
|
259
|
+
breaker.recordFailure();
|
|
260
|
+
breaker.recordFailure();
|
|
261
|
+
|
|
262
|
+
// Wait for reset time
|
|
263
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
264
|
+
|
|
265
|
+
// Probe request
|
|
266
|
+
expect(breaker.canRequest()).toBe(true);
|
|
267
|
+
|
|
268
|
+
// Probe succeeds
|
|
269
|
+
breaker.recordSuccess();
|
|
270
|
+
expect(breaker.getState()).toBe("closed");
|
|
271
|
+
expect(breaker.canRequest()).toBe(true);
|
|
272
|
+
expect(breaker.canRequest()).toBe(true); // all requests allowed again
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("probe failure keeps circuit open", async () => {
|
|
276
|
+
// Open the circuit
|
|
277
|
+
breaker.recordFailure();
|
|
278
|
+
breaker.recordFailure();
|
|
279
|
+
breaker.recordFailure();
|
|
280
|
+
|
|
281
|
+
// Wait for reset time
|
|
282
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
283
|
+
|
|
284
|
+
// Probe request allowed
|
|
285
|
+
expect(breaker.canRequest()).toBe(true);
|
|
286
|
+
|
|
287
|
+
// Probe fails — failures now >= threshold so still open
|
|
288
|
+
breaker.recordFailure();
|
|
289
|
+
expect(breaker.getState()).toBe("open");
|
|
290
|
+
expect(breaker.canRequest()).toBe(false);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("probeInFlight prevents multiple simultaneous probes", async () => {
|
|
294
|
+
// Open the circuit
|
|
295
|
+
breaker.recordFailure();
|
|
296
|
+
breaker.recordFailure();
|
|
297
|
+
breaker.recordFailure();
|
|
298
|
+
|
|
299
|
+
// Wait for reset time — enters half-open via canRequest
|
|
300
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
301
|
+
|
|
302
|
+
// First probe allowed
|
|
303
|
+
expect(breaker.canRequest()).toBe(true);
|
|
304
|
+
// probeInFlight is now true — second probe blocked
|
|
305
|
+
expect(breaker.canRequest()).toBe(false);
|
|
306
|
+
expect(breaker.canRequest()).toBe(false);
|
|
307
|
+
|
|
308
|
+
// After recording failure, probeInFlight resets but state is still open
|
|
309
|
+
breaker.recordFailure();
|
|
310
|
+
expect(breaker.canRequest()).toBe(false); // still open, timer restarted
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("getStatus returns descriptive string", () => {
|
|
314
|
+
const status = breaker.getStatus();
|
|
315
|
+
expect(status).toContain("closed");
|
|
316
|
+
expect(status).toContain("0/3");
|
|
317
|
+
|
|
318
|
+
breaker.recordFailure();
|
|
319
|
+
expect(breaker.getStatus()).toContain("1/3");
|
|
320
|
+
});
|
|
321
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { ProviderRouter, CostTracker } from "../providers/router.ts";
|
|
3
|
+
|
|
4
|
+
// We can't easily test the full router without real providers, but we can test
|
|
5
|
+
// the cost tracking logic by creating a CostTracker directly.
|
|
6
|
+
|
|
7
|
+
describe("CostTracker", () => {
|
|
8
|
+
test("record accumulates usage and computes cost", () => {
|
|
9
|
+
const tracker = new CostTracker();
|
|
10
|
+
tracker.record("anthropic", "claude-sonnet-4-6-20250514", {
|
|
11
|
+
inputTokens: 1000,
|
|
12
|
+
outputTokens: 500,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
expect(tracker.totalInputTokens).toBe(1000);
|
|
16
|
+
expect(tracker.totalOutputTokens).toBe(500);
|
|
17
|
+
// (1000/1M)*3 + (500/1M)*15 = 0.003 + 0.0075 = 0.0105
|
|
18
|
+
expect(tracker.totalCostUSD).toBeCloseTo(0.0105, 6);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("reasoning tokens use separate pricing when available", () => {
|
|
22
|
+
const tracker = new CostTracker();
|
|
23
|
+
tracker.record("openai", "o1", {
|
|
24
|
+
inputTokens: 1000,
|
|
25
|
+
outputTokens: 500,
|
|
26
|
+
reasoningTokens: 200,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
expect(tracker.totalTokens.reasoningTokens).toBe(200);
|
|
30
|
+
// (1000/1M)*15 + (500/1M)*60 + (200/1M)*60 = 0.015 + 0.030 + 0.012 = 0.057
|
|
31
|
+
expect(tracker.totalCostUSD).toBeCloseTo(0.057, 6);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("multiple calls to same provider:model accumulate", () => {
|
|
35
|
+
const tracker = new CostTracker();
|
|
36
|
+
tracker.record("xai", "grok-3-fast", { inputTokens: 500, outputTokens: 200 });
|
|
37
|
+
tracker.record("xai", "grok-3-fast", { inputTokens: 500, outputTokens: 300 });
|
|
38
|
+
|
|
39
|
+
const breakdown = tracker.getBreakdown();
|
|
40
|
+
expect(breakdown).toHaveLength(1);
|
|
41
|
+
expect(breakdown[0]!.calls).toBe(2);
|
|
42
|
+
expect(breakdown[0]!.usage.inputTokens).toBe(1000);
|
|
43
|
+
expect(breakdown[0]!.usage.outputTokens).toBe(500);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("multiple providers show in breakdown", () => {
|
|
47
|
+
const tracker = new CostTracker();
|
|
48
|
+
tracker.record("xai", "grok-3-fast", { inputTokens: 1000, outputTokens: 500 });
|
|
49
|
+
tracker.record("anthropic", "claude-sonnet-4-6-20250514", { inputTokens: 2000, outputTokens: 1000 });
|
|
50
|
+
|
|
51
|
+
const breakdown = tracker.getBreakdown();
|
|
52
|
+
expect(breakdown).toHaveLength(2);
|
|
53
|
+
expect(tracker.totalInputTokens).toBe(3000);
|
|
54
|
+
expect(tracker.totalOutputTokens).toBe(1500);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("formatSummary includes reasoning when present", () => {
|
|
58
|
+
const tracker = new CostTracker();
|
|
59
|
+
tracker.record("openai", "o1", {
|
|
60
|
+
inputTokens: 10000,
|
|
61
|
+
outputTokens: 5000,
|
|
62
|
+
reasoningTokens: 3000,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const summary = tracker.formatSummary();
|
|
66
|
+
expect(summary).toContain("Cost:");
|
|
67
|
+
expect(summary).toContain("reasoning");
|
|
68
|
+
expect(summary).toContain("10K in");
|
|
69
|
+
expect(summary).toContain("5K out");
|
|
70
|
+
expect(summary).toContain("3K reasoning");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("formatSummary omits reasoning when zero", () => {
|
|
74
|
+
const tracker = new CostTracker();
|
|
75
|
+
tracker.record("xai", "grok-3-fast", { inputTokens: 1000, outputTokens: 500 });
|
|
76
|
+
|
|
77
|
+
const summary = tracker.formatSummary();
|
|
78
|
+
expect(summary).not.toContain("reasoning");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("formatSummary shows per-provider breakdown when multiple providers", () => {
|
|
82
|
+
const tracker = new CostTracker();
|
|
83
|
+
tracker.record("xai", "grok-3-fast", { inputTokens: 1000, outputTokens: 500 });
|
|
84
|
+
tracker.record("anthropic", "claude-sonnet-4-6-20250514", { inputTokens: 2000, outputTokens: 1000 });
|
|
85
|
+
|
|
86
|
+
const summary = tracker.formatSummary();
|
|
87
|
+
expect(summary).toContain("Per provider:");
|
|
88
|
+
expect(summary).toContain("xai:grok-3-fast");
|
|
89
|
+
expect(summary).toContain("anthropic:claude-sonnet-4-6-20250514");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("unknown models get default pricing", () => {
|
|
93
|
+
const tracker = new CostTracker();
|
|
94
|
+
tracker.record("custom", "unknown-model-v2", {
|
|
95
|
+
inputTokens: 1_000_000,
|
|
96
|
+
outputTokens: 1_000_000,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Default: $1/M input, $3/M output = $4 total
|
|
100
|
+
expect(tracker.totalCostUSD).toBeCloseTo(4.0, 2);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("partial model name matching works", () => {
|
|
104
|
+
const tracker = new CostTracker();
|
|
105
|
+
// "claude-sonnet-4-6-20250514" should match even with extra suffix
|
|
106
|
+
tracker.record("anthropic", "claude-sonnet-4-6-20250514-latest", {
|
|
107
|
+
inputTokens: 1_000_000,
|
|
108
|
+
outputTokens: 0,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Should match claude-sonnet-4-6 pricing: $3/M input
|
|
112
|
+
expect(tracker.totalCostUSD).toBeCloseTo(3.0, 2);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("ProviderRouter", () => {
|
|
117
|
+
test("uses the provider-specific name for OpenAI-compatible configs", () => {
|
|
118
|
+
const router = new ProviderRouter({
|
|
119
|
+
primary: {
|
|
120
|
+
provider: "openai",
|
|
121
|
+
apiKey: "test-key",
|
|
122
|
+
model: "gpt-test",
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
expect(router.currentProvider.name).toBe("openai");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("getCostSummary delegates to costTracker.formatSummary", () => {
|
|
130
|
+
const router = new ProviderRouter({
|
|
131
|
+
primary: {
|
|
132
|
+
provider: "openai",
|
|
133
|
+
apiKey: "test-key",
|
|
134
|
+
model: "gpt-4o",
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Both should return the same result
|
|
139
|
+
expect(router.getCostSummary()).toBe(router.costTracker.formatSummary());
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("legacy costs getter provides backward-compatible shape", () => {
|
|
143
|
+
const router = new ProviderRouter({
|
|
144
|
+
primary: {
|
|
145
|
+
provider: "openai",
|
|
146
|
+
apiKey: "test-key",
|
|
147
|
+
model: "gpt-4o",
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const costs = router.costs;
|
|
152
|
+
expect(costs.totalInputTokens).toBe(0);
|
|
153
|
+
expect(costs.totalOutputTokens).toBe(0);
|
|
154
|
+
expect(costs.totalReasoningTokens).toBe(0);
|
|
155
|
+
expect(costs.totalCostUSD).toBe(0);
|
|
156
|
+
expect(costs.perProvider).toBeInstanceOf(Map);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { Session, resumeSession, forkSession, compactSession } from "../persistence/session.ts";
|
|
3
|
+
import { rmSync, existsSync, mkdtempSync } from "fs";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { tmpdir } from "os";
|
|
6
|
+
import { setConfigDirForTests } from "../config/settings.ts";
|
|
7
|
+
|
|
8
|
+
describe("Session compact boundaries", () => {
|
|
9
|
+
let configDir: string;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
configDir = mkdtempSync(join(tmpdir(), "ashlrcode-compact-test-"));
|
|
13
|
+
setConfigDirForTests(configDir);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
setConfigDirForTests(null);
|
|
18
|
+
if (existsSync(configDir)) rmSync(configDir, { recursive: true, force: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// ── insertCompactBoundary ─────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
test("insertCompactBoundary writes a compact entry to the JSONL file", async () => {
|
|
24
|
+
const session = new Session("compact-write-test");
|
|
25
|
+
await session.init("xai", "grok");
|
|
26
|
+
await session.appendMessage({ role: "user", content: "hello" });
|
|
27
|
+
await session.appendMessage({ role: "assistant", content: "hi" });
|
|
28
|
+
|
|
29
|
+
await session.insertCompactBoundary("Summary of conversation", 2);
|
|
30
|
+
|
|
31
|
+
const sessionsDir = join(configDir, "sessions");
|
|
32
|
+
const content = await Bun.file(join(sessionsDir, "compact-write-test.jsonl")).text();
|
|
33
|
+
const lines = content.trim().split("\n");
|
|
34
|
+
const lastEntry = JSON.parse(lines[lines.length - 1]!);
|
|
35
|
+
expect(lastEntry.type).toBe("compact");
|
|
36
|
+
expect(lastEntry.data.summary).toBe("Summary of conversation");
|
|
37
|
+
expect(lastEntry.data.messageCountBefore).toBe(2);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// ── loadMessages with compact boundary ────────────────────────────────
|
|
41
|
+
|
|
42
|
+
test("loadMessages skips messages before compact boundary", async () => {
|
|
43
|
+
const session = new Session("compact-skip-test");
|
|
44
|
+
await session.init("xai", "grok");
|
|
45
|
+
|
|
46
|
+
// Messages before boundary
|
|
47
|
+
await session.appendMessage({ role: "user", content: "old message 1" });
|
|
48
|
+
await session.appendMessage({ role: "assistant", content: "old reply 1" });
|
|
49
|
+
|
|
50
|
+
// Insert boundary
|
|
51
|
+
await session.insertCompactBoundary("Old context summary", 2);
|
|
52
|
+
|
|
53
|
+
// Messages after boundary
|
|
54
|
+
await session.appendMessage({ role: "user", content: "new message" });
|
|
55
|
+
await session.appendMessage({ role: "assistant", content: "new reply" });
|
|
56
|
+
|
|
57
|
+
const messages = await session.loadMessages();
|
|
58
|
+
// Should have: 1 synthetic summary + 2 new messages = 3
|
|
59
|
+
expect(messages).toHaveLength(3);
|
|
60
|
+
|
|
61
|
+
// Should NOT contain old messages
|
|
62
|
+
const contents = messages.map((m) => m.content);
|
|
63
|
+
expect(contents).not.toContain("old message 1");
|
|
64
|
+
expect(contents).not.toContain("old reply 1");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("loadMessages injects summary as user message", async () => {
|
|
68
|
+
const session = new Session("compact-summary-test");
|
|
69
|
+
await session.init("xai", "grok");
|
|
70
|
+
|
|
71
|
+
await session.appendMessage({ role: "user", content: "hello" });
|
|
72
|
+
await session.insertCompactBoundary("This is the summary", 1);
|
|
73
|
+
await session.appendMessage({ role: "user", content: "next question" });
|
|
74
|
+
|
|
75
|
+
const messages = await session.loadMessages();
|
|
76
|
+
expect(messages[0]!.role).toBe("user");
|
|
77
|
+
expect(messages[0]!.content).toContain("[Previous session context]");
|
|
78
|
+
expect(messages[0]!.content).toContain("This is the summary");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("loadMessages returns all messages when no boundary exists", async () => {
|
|
82
|
+
const session = new Session("compact-none-test");
|
|
83
|
+
await session.init("xai", "grok");
|
|
84
|
+
|
|
85
|
+
await session.appendMessage({ role: "user", content: "msg1" });
|
|
86
|
+
await session.appendMessage({ role: "assistant", content: "msg2" });
|
|
87
|
+
await session.appendMessage({ role: "user", content: "msg3" });
|
|
88
|
+
|
|
89
|
+
const messages = await session.loadMessages();
|
|
90
|
+
expect(messages).toHaveLength(3);
|
|
91
|
+
expect(messages[0]!.content).toBe("msg1");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// ── loadAllMessages ignores boundaries ────────────────────────────────
|
|
95
|
+
|
|
96
|
+
test("loadAllMessages returns all messages ignoring compact boundaries", async () => {
|
|
97
|
+
const session = new Session("compact-all-test");
|
|
98
|
+
await session.init("xai", "grok");
|
|
99
|
+
|
|
100
|
+
await session.appendMessage({ role: "user", content: "before" });
|
|
101
|
+
await session.insertCompactBoundary("summary", 1);
|
|
102
|
+
await session.appendMessage({ role: "user", content: "after" });
|
|
103
|
+
|
|
104
|
+
const allMessages = await session.loadAllMessages();
|
|
105
|
+
expect(allMessages).toHaveLength(2);
|
|
106
|
+
expect(allMessages[0]!.content).toBe("before");
|
|
107
|
+
expect(allMessages[1]!.content).toBe("after");
|
|
108
|
+
// No synthetic summary injected
|
|
109
|
+
const contents = allMessages.map((m) => m.content);
|
|
110
|
+
expect(contents.some((c) => typeof c === "string" && c.includes("[Previous session context]"))).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ── compactSession ────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
test("compactSession creates boundary with summary of recent messages", async () => {
|
|
116
|
+
const session = new Session("compact-session-test");
|
|
117
|
+
await session.init("xai", "grok");
|
|
118
|
+
|
|
119
|
+
await session.appendMessage({ role: "user", content: "question 1" });
|
|
120
|
+
await session.appendMessage({ role: "assistant", content: "answer 1" });
|
|
121
|
+
await session.appendMessage({ role: "user", content: "question 2" });
|
|
122
|
+
await session.appendMessage({ role: "assistant", content: "answer 2" });
|
|
123
|
+
|
|
124
|
+
const result = await compactSession("compact-session-test");
|
|
125
|
+
expect(result.messagesBefore).toBe(4);
|
|
126
|
+
expect(result.summary).toContain("question 1");
|
|
127
|
+
expect(result.summary).toContain("answer 2");
|
|
128
|
+
|
|
129
|
+
// After compaction, loadMessages should return summary + no old messages
|
|
130
|
+
const messages = await session.loadMessages();
|
|
131
|
+
expect(messages[0]!.role).toBe("user");
|
|
132
|
+
expect(messages[0]!.content).toContain("[Previous session context]");
|
|
133
|
+
// Only the synthetic summary should be returned (no messages after the boundary)
|
|
134
|
+
expect(messages).toHaveLength(1);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// ── forkSession uses loadAllMessages ──────────────────────────────────
|
|
138
|
+
|
|
139
|
+
test("forkSession preserves full history including before compact boundary", async () => {
|
|
140
|
+
const session = new Session("compact-fork-source");
|
|
141
|
+
await session.init("xai", "grok");
|
|
142
|
+
|
|
143
|
+
await session.appendMessage({ role: "user", content: "old msg" });
|
|
144
|
+
await session.insertCompactBoundary("summary", 1);
|
|
145
|
+
await session.appendMessage({ role: "user", content: "new msg" });
|
|
146
|
+
|
|
147
|
+
const forked = await forkSession("compact-fork-source", "xai", "grok");
|
|
148
|
+
expect(forked).not.toBeNull();
|
|
149
|
+
|
|
150
|
+
// Fork should have ALL messages (loadAllMessages), not just post-boundary
|
|
151
|
+
const forkedMessages = await forked!.session.loadAllMessages();
|
|
152
|
+
expect(forkedMessages).toHaveLength(2);
|
|
153
|
+
expect(forkedMessages[0]!.content).toBe("old msg");
|
|
154
|
+
expect(forkedMessages[1]!.content).toBe("new msg");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ── Multiple boundaries ───────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
test("multiple boundaries: loadMessages uses the last one", async () => {
|
|
160
|
+
const session = new Session("compact-multi-test");
|
|
161
|
+
await session.init("xai", "grok");
|
|
162
|
+
|
|
163
|
+
await session.appendMessage({ role: "user", content: "era 1" });
|
|
164
|
+
await session.insertCompactBoundary("Summary of era 1", 1);
|
|
165
|
+
|
|
166
|
+
await session.appendMessage({ role: "user", content: "era 2" });
|
|
167
|
+
await session.insertCompactBoundary("Summary of eras 1+2", 2);
|
|
168
|
+
|
|
169
|
+
await session.appendMessage({ role: "user", content: "era 3" });
|
|
170
|
+
|
|
171
|
+
const messages = await session.loadMessages();
|
|
172
|
+
// Should use the LAST boundary: summary of eras 1+2 + era 3 message
|
|
173
|
+
expect(messages).toHaveLength(2);
|
|
174
|
+
expect(messages[0]!.content).toContain("Summary of eras 1+2");
|
|
175
|
+
expect(messages[1]!.content).toBe("era 3");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("multiple boundaries: loadAllMessages returns everything", async () => {
|
|
179
|
+
const session = new Session("compact-multi-all-test");
|
|
180
|
+
await session.init("xai", "grok");
|
|
181
|
+
|
|
182
|
+
await session.appendMessage({ role: "user", content: "era 1" });
|
|
183
|
+
await session.insertCompactBoundary("Summary 1", 1);
|
|
184
|
+
await session.appendMessage({ role: "user", content: "era 2" });
|
|
185
|
+
await session.insertCompactBoundary("Summary 2", 2);
|
|
186
|
+
await session.appendMessage({ role: "user", content: "era 3" });
|
|
187
|
+
|
|
188
|
+
const allMessages = await session.loadAllMessages();
|
|
189
|
+
expect(allMessages).toHaveLength(3);
|
|
190
|
+
});
|
|
191
|
+
});
|