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,274 @@
1
+ import { describe, test, expect, beforeEach } from "bun:test";
2
+ import { CostTracker } from "../providers/cost-tracker.ts";
3
+
4
+ describe("CostTracker", () => {
5
+ let tracker: CostTracker;
6
+
7
+ beforeEach(() => {
8
+ tracker = new CostTracker();
9
+ });
10
+
11
+ // ── Basic recording ──────────────────────────────────────────────────
12
+
13
+ test("records usage for a single provider/model", () => {
14
+ tracker.record("anthropic", "claude-sonnet-4-6-20250514", {
15
+ inputTokens: 1000,
16
+ outputTokens: 500,
17
+ });
18
+
19
+ expect(tracker.totalInputTokens).toBe(1000);
20
+ expect(tracker.totalOutputTokens).toBe(500);
21
+ expect(tracker.totalReasoningTokens).toBe(0);
22
+ });
23
+
24
+ test("accumulates usage across multiple calls to same model", () => {
25
+ tracker.record("xai", "grok-3-fast", { inputTokens: 500, outputTokens: 200 });
26
+ tracker.record("xai", "grok-3-fast", { inputTokens: 500, outputTokens: 300 });
27
+
28
+ expect(tracker.totalInputTokens).toBe(1000);
29
+ expect(tracker.totalOutputTokens).toBe(500);
30
+
31
+ const breakdown = tracker.getBreakdown();
32
+ expect(breakdown).toHaveLength(1);
33
+ expect(breakdown[0]!.calls).toBe(2);
34
+ });
35
+
36
+ test("handles partial usage (only inputTokens)", () => {
37
+ tracker.record("xai", "grok-3-fast", { inputTokens: 100 });
38
+ expect(tracker.totalInputTokens).toBe(100);
39
+ expect(tracker.totalOutputTokens).toBe(0);
40
+ });
41
+
42
+ test("handles empty usage object", () => {
43
+ tracker.record("xai", "grok-3-fast", {});
44
+ expect(tracker.totalInputTokens).toBe(0);
45
+ expect(tracker.totalOutputTokens).toBe(0);
46
+ expect(tracker.totalCostUSD).toBe(0);
47
+ expect(tracker.getBreakdown()[0]!.calls).toBe(1);
48
+ });
49
+
50
+ // ── Pricing calculations ──────────────────────────────────────────────
51
+
52
+ test("calculates Anthropic Sonnet pricing correctly", () => {
53
+ tracker.record("anthropic", "claude-sonnet-4-6-20250514", {
54
+ inputTokens: 1000,
55
+ outputTokens: 500,
56
+ });
57
+ // (1000/1M)*3 + (500/1M)*15 = 0.003 + 0.0075 = 0.0105
58
+ expect(tracker.totalCostUSD).toBeCloseTo(0.0105, 6);
59
+ });
60
+
61
+ test("calculates Anthropic Opus pricing correctly", () => {
62
+ tracker.record("anthropic", "claude-opus-4-6-20250514", {
63
+ inputTokens: 1_000_000,
64
+ outputTokens: 0,
65
+ });
66
+ // $15/M input
67
+ expect(tracker.totalCostUSD).toBeCloseTo(15.0, 2);
68
+ });
69
+
70
+ test("calculates xAI grok-3-fast pricing correctly", () => {
71
+ tracker.record("xai", "grok-3-fast", {
72
+ inputTokens: 1_000_000,
73
+ outputTokens: 1_000_000,
74
+ });
75
+ // $0.1/M input + $0.3/M output = $0.4
76
+ expect(tracker.totalCostUSD).toBeCloseTo(0.4, 2);
77
+ });
78
+
79
+ test("calculates GPT-4o pricing correctly", () => {
80
+ tracker.record("openai", "gpt-4o", {
81
+ inputTokens: 1_000_000,
82
+ outputTokens: 1_000_000,
83
+ });
84
+ // $2.5/M input + $10/M output = $12.5
85
+ expect(tracker.totalCostUSD).toBeCloseTo(12.5, 2);
86
+ });
87
+
88
+ test("free-tier models cost zero", () => {
89
+ tracker.record("groq", "llama-3.3-70b-versatile", {
90
+ inputTokens: 10_000_000,
91
+ outputTokens: 5_000_000,
92
+ });
93
+ expect(tracker.totalCostUSD).toBe(0);
94
+ });
95
+
96
+ // ── Reasoning token pricing ───────────────────────────────────────────
97
+
98
+ test("reasoning tokens use separate pricing when available (o1)", () => {
99
+ tracker.record("openai", "o1", {
100
+ inputTokens: 1000,
101
+ outputTokens: 500,
102
+ reasoningTokens: 200,
103
+ });
104
+ // (1000/1M)*15 + (500/1M)*60 + (200/1M)*60 = 0.015 + 0.030 + 0.012 = 0.057
105
+ expect(tracker.totalCostUSD).toBeCloseTo(0.057, 6);
106
+ expect(tracker.totalTokens.reasoningTokens).toBe(200);
107
+ });
108
+
109
+ test("reasoning tokens use output rate when no separate rate (sonnet)", () => {
110
+ tracker.record("anthropic", "claude-sonnet-4-6-20250514", {
111
+ inputTokens: 0,
112
+ outputTokens: 0,
113
+ reasoningTokens: 1_000_000,
114
+ });
115
+ // No reasoningPerMillion defined, falls back to outputPerMillion = $15/M
116
+ expect(tracker.totalCostUSD).toBeCloseTo(15.0, 2);
117
+ });
118
+
119
+ test("deepseek-reasoner reasoning pricing", () => {
120
+ tracker.record("deepseek", "deepseek-reasoner", {
121
+ inputTokens: 1_000_000,
122
+ outputTokens: 1_000_000,
123
+ reasoningTokens: 1_000_000,
124
+ });
125
+ // $0.55 + $2.19 + $2.19 = $4.93
126
+ expect(tracker.totalCostUSD).toBeCloseTo(4.93, 2);
127
+ });
128
+
129
+ // ── Partial model name matching ───────────────────────────────────────
130
+
131
+ test("matches model with date suffix", () => {
132
+ tracker.record("anthropic", "claude-sonnet-4-6-20250514-latest", {
133
+ inputTokens: 1_000_000,
134
+ outputTokens: 0,
135
+ });
136
+ // Should match claude-sonnet-4-6-20250514 pricing: $3/M input
137
+ expect(tracker.totalCostUSD).toBeCloseTo(3.0, 2);
138
+ });
139
+
140
+ test("matches shorter model name to longer key via key.startsWith(model)", () => {
141
+ // "grok-3-fast" starts with "grok-3" — getPricing iterates and finds the first match
142
+ tracker.record("xai", "grok-3", {
143
+ inputTokens: 1_000_000,
144
+ outputTokens: 0,
145
+ });
146
+ // grok-3-fast pricing: $0.1/M input
147
+ expect(tracker.totalCostUSD).toBeCloseTo(0.1, 2);
148
+ });
149
+
150
+ // ── Unknown model fallback ────────────────────────────────────────────
151
+
152
+ test("unknown models get default pricing ($1/M in, $3/M out)", () => {
153
+ tracker.record("custom", "unknown-model-v2", {
154
+ inputTokens: 1_000_000,
155
+ outputTokens: 1_000_000,
156
+ });
157
+ // Default: $1/M input + $3/M output = $4 total
158
+ expect(tracker.totalCostUSD).toBeCloseTo(4.0, 2);
159
+ });
160
+
161
+ test("unknown model reasoning tokens use output rate ($3/M)", () => {
162
+ tracker.record("custom", "mystery-model", {
163
+ inputTokens: 0,
164
+ outputTokens: 0,
165
+ reasoningTokens: 1_000_000,
166
+ });
167
+ // Default fallback: no reasoningPerMillion, so uses outputPerMillion = $3/M
168
+ expect(tracker.totalCostUSD).toBeCloseTo(3.0, 2);
169
+ });
170
+
171
+ // ── formatSummary ─────────────────────────────────────────────────────
172
+
173
+ test("formatSummary contains cost and token counts", () => {
174
+ tracker.record("xai", "grok-3-fast", { inputTokens: 1000, outputTokens: 500 });
175
+
176
+ const summary = tracker.formatSummary();
177
+ expect(summary).toContain("Cost:");
178
+ expect(summary).toContain("1K in");
179
+ expect(summary).toContain("500 out");
180
+ });
181
+
182
+ test("formatSummary includes reasoning when present", () => {
183
+ tracker.record("openai", "o1", {
184
+ inputTokens: 10000,
185
+ outputTokens: 5000,
186
+ reasoningTokens: 3000,
187
+ });
188
+
189
+ const summary = tracker.formatSummary();
190
+ expect(summary).toContain("reasoning");
191
+ expect(summary).toContain("10K in");
192
+ expect(summary).toContain("5K out");
193
+ expect(summary).toContain("3K reasoning");
194
+ });
195
+
196
+ test("formatSummary omits reasoning when zero", () => {
197
+ tracker.record("xai", "grok-3-fast", { inputTokens: 1000, outputTokens: 500 });
198
+
199
+ const summary = tracker.formatSummary();
200
+ expect(summary).not.toContain("reasoning");
201
+ });
202
+
203
+ test("formatSummary formats millions correctly", () => {
204
+ tracker.record("xai", "grok-3-fast", {
205
+ inputTokens: 2_500_000,
206
+ outputTokens: 1_200_000,
207
+ });
208
+
209
+ const summary = tracker.formatSummary();
210
+ expect(summary).toContain("2.5M in");
211
+ expect(summary).toContain("1.2M out");
212
+ });
213
+
214
+ // ── Multi-provider breakdown ──────────────────────────────────────────
215
+
216
+ test("formatSummary shows per-provider breakdown when multiple providers", () => {
217
+ tracker.record("xai", "grok-3-fast", { inputTokens: 1000, outputTokens: 500 });
218
+ tracker.record("anthropic", "claude-sonnet-4-6-20250514", { inputTokens: 2000, outputTokens: 1000 });
219
+
220
+ const summary = tracker.formatSummary();
221
+ expect(summary).toContain("Per provider:");
222
+ expect(summary).toContain("xai:grok-3-fast");
223
+ expect(summary).toContain("anthropic:claude-sonnet-4-6-20250514");
224
+ });
225
+
226
+ test("formatSummary does not show breakdown for single provider", () => {
227
+ tracker.record("xai", "grok-3-fast", { inputTokens: 1000, outputTokens: 500 });
228
+
229
+ const summary = tracker.formatSummary();
230
+ expect(summary).not.toContain("Per provider:");
231
+ });
232
+
233
+ test("multi-provider breakdown includes call counts", () => {
234
+ tracker.record("xai", "grok-3-fast", { inputTokens: 100, outputTokens: 50 });
235
+ tracker.record("xai", "grok-3-fast", { inputTokens: 100, outputTokens: 50 });
236
+ tracker.record("openai", "gpt-4o", { inputTokens: 200, outputTokens: 100 });
237
+
238
+ const summary = tracker.formatSummary();
239
+ expect(summary).toContain("2 calls");
240
+ expect(summary).toContain("1 calls");
241
+ });
242
+
243
+ test("getBreakdown returns correct number of entries", () => {
244
+ tracker.record("xai", "grok-3-fast", { inputTokens: 100, outputTokens: 50 });
245
+ tracker.record("openai", "gpt-4o", { inputTokens: 200, outputTokens: 100 });
246
+ tracker.record("anthropic", "claude-sonnet-4-6-20250514", { inputTokens: 300, outputTokens: 150 });
247
+
248
+ const breakdown = tracker.getBreakdown();
249
+ expect(breakdown).toHaveLength(3);
250
+ });
251
+
252
+ test("totalCostUSD sums across all providers", () => {
253
+ // xai grok-3-fast: (1M/1M)*0.1 + (1M/1M)*0.3 = 0.4
254
+ tracker.record("xai", "grok-3-fast", { inputTokens: 1_000_000, outputTokens: 1_000_000 });
255
+ // openai gpt-4o: (1M/1M)*2.5 + (1M/1M)*10 = 12.5
256
+ tracker.record("openai", "gpt-4o", { inputTokens: 1_000_000, outputTokens: 1_000_000 });
257
+
258
+ expect(tracker.totalCostUSD).toBeCloseTo(12.9, 2);
259
+ });
260
+
261
+ // ── Legacy compat getters ─────────────────────────────────────────────
262
+
263
+ test("legacy getters match totalTokens", () => {
264
+ tracker.record("openai", "o1", {
265
+ inputTokens: 100,
266
+ outputTokens: 200,
267
+ reasoningTokens: 50,
268
+ });
269
+
270
+ expect(tracker.totalInputTokens).toBe(tracker.totalTokens.inputTokens);
271
+ expect(tracker.totalOutputTokens).toBe(tracker.totalTokens.outputTokens);
272
+ expect(tracker.totalReasoningTokens).toBe(tracker.totalTokens.reasoningTokens);
273
+ });
274
+ });
@@ -0,0 +1,197 @@
1
+ import { test, expect, describe, beforeEach, afterEach } from "bun:test";
2
+ import { mkdtempSync, rmSync, existsSync } from "fs";
3
+ import { join } from "path";
4
+ import { tmpdir } from "os";
5
+ import { setConfigDirForTests } from "../config/settings.ts";
6
+ import {
7
+ createTrigger,
8
+ listTriggers,
9
+ deleteTrigger,
10
+ getDueTriggers,
11
+ markRun,
12
+ toggleTrigger,
13
+ type CronTrigger,
14
+ } from "../agent/cron.ts";
15
+
16
+ describe("Cron Triggers", () => {
17
+ let configDir: string;
18
+
19
+ beforeEach(() => {
20
+ configDir = mkdtempSync(join(tmpdir(), "ashlrcode-cron-test-"));
21
+ setConfigDirForTests(configDir);
22
+ });
23
+
24
+ afterEach(() => {
25
+ setConfigDirForTests(null);
26
+ if (existsSync(configDir)) rmSync(configDir, { recursive: true, force: true });
27
+ });
28
+
29
+ describe("parseDuration (via createTrigger)", () => {
30
+ test("handles '30s' — seconds", async () => {
31
+ const trigger = await createTrigger("test-s", "30s", "echo hi", "/tmp");
32
+ expect(trigger.schedule).toBe("30s");
33
+ // nextRun should be ~30 seconds from now
34
+ const nextMs = new Date(trigger.nextRun!).getTime();
35
+ const expectedMs = Date.now() + 30_000;
36
+ expect(Math.abs(nextMs - expectedMs)).toBeLessThan(2000);
37
+ });
38
+
39
+ test("handles '5m' — minutes", async () => {
40
+ const trigger = await createTrigger("test-m", "5m", "echo hi", "/tmp");
41
+ const nextMs = new Date(trigger.nextRun!).getTime();
42
+ const expectedMs = Date.now() + 5 * 60_000;
43
+ expect(Math.abs(nextMs - expectedMs)).toBeLessThan(2000);
44
+ });
45
+
46
+ test("handles '1h' — hours", async () => {
47
+ const trigger = await createTrigger("test-h", "1h", "echo hi", "/tmp");
48
+ const nextMs = new Date(trigger.nextRun!).getTime();
49
+ const expectedMs = Date.now() + 3_600_000;
50
+ expect(Math.abs(nextMs - expectedMs)).toBeLessThan(2000);
51
+ });
52
+
53
+ test("handles '2d' — days", async () => {
54
+ const trigger = await createTrigger("test-d", "2d", "echo hi", "/tmp");
55
+ const nextMs = new Date(trigger.nextRun!).getTime();
56
+ const expectedMs = Date.now() + 2 * 86_400_000;
57
+ expect(Math.abs(nextMs - expectedMs)).toBeLessThan(2000);
58
+ });
59
+
60
+ test("rejects invalid schedule format", async () => {
61
+ expect(createTrigger("bad", "5x", "echo", "/tmp")).rejects.toThrow(
62
+ /Invalid schedule/,
63
+ );
64
+ });
65
+ });
66
+
67
+ describe("createTrigger", () => {
68
+ test("saves trigger to disk", async () => {
69
+ const trigger = await createTrigger("my-task", "10m", "run tests", "/projects/foo");
70
+ expect(trigger.id).toMatch(/^trigger-/);
71
+ expect(trigger.name).toBe("my-task");
72
+ expect(trigger.prompt).toBe("run tests");
73
+ expect(trigger.cwd).toBe("/projects/foo");
74
+ expect(trigger.enabled).toBe(true);
75
+ expect(trigger.runCount).toBe(0);
76
+
77
+ // Verify file exists on disk
78
+ const filePath = join(configDir, "triggers", `${trigger.id}.json`);
79
+ expect(existsSync(filePath)).toBe(true);
80
+ });
81
+ });
82
+
83
+ describe("listTriggers", () => {
84
+ test("returns saved triggers", async () => {
85
+ await createTrigger("a", "1m", "do a", "/tmp");
86
+ // Small delay to ensure unique IDs (based on Date.now())
87
+ await new Promise((r) => setTimeout(r, 5));
88
+ await createTrigger("b", "2m", "do b", "/tmp");
89
+
90
+ const triggers = await listTriggers();
91
+ expect(triggers.length).toBe(2);
92
+ const names = triggers.map((t) => t.name);
93
+ expect(names).toContain("a");
94
+ expect(names).toContain("b");
95
+ });
96
+
97
+ test("returns empty when no triggers exist", async () => {
98
+ const triggers = await listTriggers();
99
+ expect(triggers).toEqual([]);
100
+ });
101
+ });
102
+
103
+ describe("deleteTrigger", () => {
104
+ test("removes trigger file", async () => {
105
+ const trigger = await createTrigger("delete-me", "1m", "x", "/tmp");
106
+ expect(await deleteTrigger(trigger.id)).toBe(true);
107
+
108
+ const filePath = join(configDir, "triggers", `${trigger.id}.json`);
109
+ expect(existsSync(filePath)).toBe(false);
110
+
111
+ const remaining = await listTriggers();
112
+ expect(remaining.length).toBe(0);
113
+ });
114
+
115
+ test("returns false for non-existent trigger", async () => {
116
+ expect(await deleteTrigger("trigger-doesnt-exist")).toBe(false);
117
+ });
118
+ });
119
+
120
+ describe("getDueTriggers", () => {
121
+ test("returns only due triggers", () => {
122
+ const now = Date.now();
123
+ const triggers: CronTrigger[] = [
124
+ {
125
+ id: "t1", name: "due", schedule: "1m", prompt: "x", cwd: "/",
126
+ enabled: true, runCount: 0, createdAt: new Date().toISOString(),
127
+ nextRun: new Date(now - 60_000).toISOString(), // 1 min ago — due
128
+ },
129
+ {
130
+ id: "t2", name: "not-yet", schedule: "1m", prompt: "x", cwd: "/",
131
+ enabled: true, runCount: 0, createdAt: new Date().toISOString(),
132
+ nextRun: new Date(now + 60_000).toISOString(), // 1 min future — not due
133
+ },
134
+ {
135
+ id: "t3", name: "disabled-due", schedule: "1m", prompt: "x", cwd: "/",
136
+ enabled: false, runCount: 0, createdAt: new Date().toISOString(),
137
+ nextRun: new Date(now - 60_000).toISOString(), // due but disabled
138
+ },
139
+ ];
140
+
141
+ const due = getDueTriggers(triggers);
142
+ expect(due.length).toBe(1);
143
+ expect(due[0]!.id).toBe("t1");
144
+ });
145
+
146
+ test("returns empty when none are due", () => {
147
+ const triggers: CronTrigger[] = [
148
+ {
149
+ id: "t1", name: "future", schedule: "1h", prompt: "x", cwd: "/",
150
+ enabled: true, runCount: 0, createdAt: new Date().toISOString(),
151
+ nextRun: new Date(Date.now() + 3_600_000).toISOString(),
152
+ },
153
+ ];
154
+ expect(getDueTriggers(triggers)).toEqual([]);
155
+ });
156
+ });
157
+
158
+ describe("markRun", () => {
159
+ test("updates lastRun, runCount, and nextRun", async () => {
160
+ const trigger = await createTrigger("mark-test", "5m", "x", "/tmp");
161
+ expect(trigger.runCount).toBe(0);
162
+ expect(trigger.lastRun).toBeUndefined();
163
+
164
+ await markRun(trigger.id);
165
+
166
+ // Re-read from disk via listTriggers
167
+ const triggers = await listTriggers();
168
+ const updated = triggers.find((t) => t.id === trigger.id)!;
169
+ expect(updated.runCount).toBe(1);
170
+ expect(updated.lastRun).toBeDefined();
171
+ expect(updated.nextRun).toBeDefined();
172
+
173
+ // nextRun should be ~5 min from now
174
+ const nextMs = new Date(updated.nextRun!).getTime();
175
+ const expectedMs = Date.now() + 5 * 60_000;
176
+ expect(Math.abs(nextMs - expectedMs)).toBeLessThan(2000);
177
+ });
178
+ });
179
+
180
+ describe("toggleTrigger", () => {
181
+ test("flips enabled flag", async () => {
182
+ const trigger = await createTrigger("toggle-test", "1m", "x", "/tmp");
183
+ expect(trigger.enabled).toBe(true);
184
+
185
+ const toggled = await toggleTrigger(trigger.id);
186
+ expect(toggled!.enabled).toBe(false);
187
+
188
+ const toggledBack = await toggleTrigger(trigger.id);
189
+ expect(toggledBack!.enabled).toBe(true);
190
+ });
191
+
192
+ test("returns null for non-existent trigger", async () => {
193
+ const result = await toggleTrigger("trigger-nope");
194
+ expect(result).toBeNull();
195
+ });
196
+ });
197
+ });
@@ -0,0 +1,204 @@
1
+ import { test, expect, describe, beforeEach, afterEach } from "bun:test";
2
+ import { existsSync, mkdtempSync, rmSync } from "fs";
3
+ import { join } from "path";
4
+ import { tmpdir } from "os";
5
+ import { writeFile, mkdir } from "fs/promises";
6
+ import {
7
+ generateDream,
8
+ loadRecentDreams,
9
+ formatDreamsForPrompt,
10
+ pruneOldDreams,
11
+ IdleDetector,
12
+ } from "../agent/dream.ts";
13
+ import { setConfigDirForTests } from "../config/settings.ts";
14
+ import type { Message } from "../providers/types.ts";
15
+
16
+ let configDir: string;
17
+
18
+ beforeEach(() => {
19
+ configDir = mkdtempSync(join(tmpdir(), "ashlrcode-dream-test-"));
20
+ setConfigDirForTests(configDir);
21
+ });
22
+
23
+ afterEach(() => {
24
+ setConfigDirForTests(null);
25
+ if (existsSync(configDir)) rmSync(configDir, { recursive: true, force: true });
26
+ });
27
+
28
+ describe("generateDream", () => {
29
+ test("creates a dream file on disk", async () => {
30
+ const messages: Message[] = [
31
+ { role: "user", content: "Hello world" },
32
+ { role: "assistant", content: "Hi there!" },
33
+ ];
34
+
35
+ const dream = await generateDream(messages, "test-session-1");
36
+
37
+ expect(dream.id).toMatch(/^dream-/);
38
+ expect(dream.sessionId).toBe("test-session-1");
39
+ expect(dream.turnCount).toBe(1); // 1 user message
40
+ expect(dream.summary).toContain("user: Hello world");
41
+
42
+ // File should exist on disk
43
+ const dreamPath = join(configDir, "dreams", `${dream.id}.json`);
44
+ expect(existsSync(dreamPath)).toBe(true);
45
+ });
46
+
47
+ test("extracts tool names from content blocks", async () => {
48
+ const messages: Message[] = [
49
+ { role: "user", content: "Do something" },
50
+ {
51
+ role: "assistant",
52
+ content: [
53
+ { type: "text", text: "Running a command" },
54
+ { type: "tool_use", id: "t1", name: "Bash", input: { command: "ls" } },
55
+ ],
56
+ },
57
+ {
58
+ role: "tool",
59
+ content: [
60
+ { type: "tool_result", tool_use_id: "t1", content: "file.txt" },
61
+ ],
62
+ },
63
+ ];
64
+
65
+ const dream = await generateDream(messages, "test-session-2");
66
+ expect(dream.toolsUsed).toContain("Bash");
67
+ });
68
+ });
69
+
70
+ describe("loadRecentDreams", () => {
71
+ test("loads saved dreams", async () => {
72
+ // Generate a couple of dreams
73
+ const msgs: Message[] = [{ role: "user", content: "Test" }];
74
+ await generateDream(msgs, "s1");
75
+ // Small delay so filenames differ
76
+ await new Promise((r) => setTimeout(r, 5));
77
+ await generateDream(msgs, "s2");
78
+
79
+ const dreams = await loadRecentDreams(10);
80
+ expect(dreams.length).toBe(2);
81
+ });
82
+
83
+ test("respects limit parameter", async () => {
84
+ const msgs: Message[] = [{ role: "user", content: "Test" }];
85
+ for (let i = 0; i < 5; i++) {
86
+ await generateDream(msgs, `s${i}`);
87
+ await new Promise((r) => setTimeout(r, 5));
88
+ }
89
+
90
+ const dreams = await loadRecentDreams(2);
91
+ expect(dreams.length).toBe(2);
92
+ });
93
+
94
+ test("returns empty array when no dreams dir exists", async () => {
95
+ const dreams = await loadRecentDreams();
96
+ expect(dreams).toEqual([]);
97
+ });
98
+ });
99
+
100
+ describe("formatDreamsForPrompt", () => {
101
+ test("returns empty string for no dreams", () => {
102
+ expect(formatDreamsForPrompt([])).toBe("");
103
+ });
104
+
105
+ test("formats dreams with header and content", () => {
106
+ const dreams = [
107
+ {
108
+ id: "dream-1",
109
+ timestamp: "2026-01-15T10:00:00.000Z",
110
+ summary: "user: Did some work\nassistant: Completed task",
111
+ sessionId: "s1",
112
+ turnCount: 3,
113
+ toolsUsed: ["Bash", "Read"],
114
+ },
115
+ ];
116
+
117
+ const result = formatDreamsForPrompt(dreams);
118
+ expect(result).toContain("## Recent Session Dreams");
119
+ expect(result).toContain("3 turns");
120
+ expect(result).toContain("tools: Bash, Read");
121
+ expect(result).toContain("user: Did some work");
122
+ });
123
+
124
+ test("omits tools section when no tools used", () => {
125
+ const dreams = [
126
+ {
127
+ id: "dream-2",
128
+ timestamp: "2026-01-15T10:00:00.000Z",
129
+ summary: "conversation summary",
130
+ sessionId: "s2",
131
+ turnCount: 1,
132
+ toolsUsed: [],
133
+ },
134
+ ];
135
+
136
+ const result = formatDreamsForPrompt(dreams);
137
+ expect(result).not.toContain("tools:");
138
+ });
139
+ });
140
+
141
+ describe("pruneOldDreams", () => {
142
+ test("keeps only recent N dreams", async () => {
143
+ const msgs: Message[] = [{ role: "user", content: "Test" }];
144
+ for (let i = 0; i < 5; i++) {
145
+ await generateDream(msgs, `s${i}`);
146
+ await new Promise((r) => setTimeout(r, 5));
147
+ }
148
+
149
+ const deleted = await pruneOldDreams(2);
150
+ expect(deleted).toBe(3);
151
+
152
+ const remaining = await loadRecentDreams(10);
153
+ expect(remaining.length).toBe(2);
154
+ });
155
+
156
+ test("returns 0 when no dreams dir exists", async () => {
157
+ const deleted = await pruneOldDreams(5);
158
+ expect(deleted).toBe(0);
159
+ });
160
+ });
161
+
162
+ describe("IdleDetector", () => {
163
+ test("fires callback after threshold", async () => {
164
+ let fired = false;
165
+ const detector = new IdleDetector(() => { fired = true; }, 50);
166
+ detector.ping();
167
+
168
+ await new Promise((r) => setTimeout(r, 100));
169
+ expect(fired).toBe(true);
170
+ detector.stop();
171
+ });
172
+
173
+ test("resets on ping", async () => {
174
+ let fireCount = 0;
175
+ const detector = new IdleDetector(() => { fireCount++; }, 80);
176
+
177
+ detector.ping();
178
+ await new Promise((r) => setTimeout(r, 40));
179
+ // Reset before it fires
180
+ detector.ping();
181
+ await new Promise((r) => setTimeout(r, 40));
182
+ // Reset again
183
+ detector.ping();
184
+ await new Promise((r) => setTimeout(r, 40));
185
+
186
+ // Should not have fired yet (each ping resets the 80ms timer)
187
+ expect(fireCount).toBe(0);
188
+
189
+ // Now wait for it to fire
190
+ await new Promise((r) => setTimeout(r, 100));
191
+ expect(fireCount).toBe(1);
192
+ detector.stop();
193
+ });
194
+
195
+ test("stop prevents callback from firing", async () => {
196
+ let fired = false;
197
+ const detector = new IdleDetector(() => { fired = true; }, 50);
198
+ detector.ping();
199
+ detector.stop();
200
+
201
+ await new Promise((r) => setTimeout(r, 100));
202
+ expect(fired).toBe(false);
203
+ });
204
+ });