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,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
+ });