ctb 1.0.0 → 1.2.1

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.
@@ -0,0 +1,399 @@
1
+ /**
2
+ * Unit tests for session module - model, cost, thinking, and plan mode features.
3
+ */
4
+
5
+ import { beforeEach, describe, expect, test } from "bun:test";
6
+ import { ClaudeSession, getThinkingLevel } from "../session";
7
+
8
+ describe("getThinkingLevel", () => {
9
+ // Default keywords from config.ts:
10
+ // THINKING_KEYWORDS: "think,pensa,ragiona"
11
+ // THINKING_DEEP_KEYWORDS: "ultrathink,think hard,pensa bene"
12
+
13
+ describe("deep thinking triggers", () => {
14
+ test("triggers on 'think hard'", () => {
15
+ expect(getThinkingLevel("Please think hard about this")).toBe(50000);
16
+ });
17
+
18
+ test("triggers on 'ultrathink'", () => {
19
+ expect(getThinkingLevel("ultrathink about the problem")).toBe(50000);
20
+ });
21
+
22
+ test("triggers on 'pensa bene' (Italian)", () => {
23
+ expect(getThinkingLevel("pensa bene prima di rispondere")).toBe(50000);
24
+ });
25
+
26
+ test("is case insensitive", () => {
27
+ expect(getThinkingLevel("THINK HARD about this")).toBe(50000);
28
+ expect(getThinkingLevel("ULTRATHINK")).toBe(50000);
29
+ });
30
+ });
31
+
32
+ describe("normal thinking triggers", () => {
33
+ test("triggers on 'think'", () => {
34
+ expect(getThinkingLevel("Let me think about this")).toBe(10000);
35
+ });
36
+
37
+ test("triggers on 'pensa' (Italian)", () => {
38
+ expect(getThinkingLevel("pensa a questo problema")).toBe(10000);
39
+ });
40
+
41
+ test("triggers on 'ragiona' (Italian)", () => {
42
+ expect(getThinkingLevel("ragiona sul codice")).toBe(10000);
43
+ });
44
+
45
+ test("is case insensitive", () => {
46
+ expect(getThinkingLevel("THINK about it")).toBe(10000);
47
+ expect(getThinkingLevel("PENSA attentamente")).toBe(10000);
48
+ });
49
+ });
50
+
51
+ describe("no thinking triggers", () => {
52
+ test("returns 0 for simple messages", () => {
53
+ expect(getThinkingLevel("Hello")).toBe(0);
54
+ expect(getThinkingLevel("What time is it?")).toBe(0);
55
+ expect(getThinkingLevel("Show me the file")).toBe(0);
56
+ });
57
+
58
+ test("returns 0 for empty string", () => {
59
+ expect(getThinkingLevel("")).toBe(0);
60
+ });
61
+
62
+ test("returns 0 for unrelated words", () => {
63
+ expect(getThinkingLevel("analyze this code")).toBe(0);
64
+ expect(getThinkingLevel("consider the options")).toBe(0);
65
+ expect(getThinkingLevel("evaluate this approach")).toBe(0);
66
+ });
67
+ });
68
+
69
+ describe("priority", () => {
70
+ test("deep triggers take precedence over normal", () => {
71
+ // "think hard" should trigger deep (50000), not normal "think" (10000)
72
+ expect(getThinkingLevel("think hard")).toBe(50000);
73
+ });
74
+ });
75
+ });
76
+
77
+ describe("ClaudeSession", () => {
78
+ let session: ClaudeSession;
79
+
80
+ beforeEach(() => {
81
+ session = new ClaudeSession();
82
+ });
83
+
84
+ describe("model selection", () => {
85
+ test("defaults to sonnet", () => {
86
+ expect(session.currentModel).toBe("sonnet");
87
+ });
88
+
89
+ test("can be set to opus", () => {
90
+ session.currentModel = "opus";
91
+ expect(session.currentModel).toBe("opus");
92
+ });
93
+
94
+ test("can be set to haiku", () => {
95
+ session.currentModel = "haiku";
96
+ expect(session.currentModel).toBe("haiku");
97
+ });
98
+
99
+ test("can switch between models", () => {
100
+ session.currentModel = "opus";
101
+ expect(session.currentModel).toBe("opus");
102
+
103
+ session.currentModel = "haiku";
104
+ expect(session.currentModel).toBe("haiku");
105
+
106
+ session.currentModel = "sonnet";
107
+ expect(session.currentModel).toBe("sonnet");
108
+ });
109
+ });
110
+
111
+ describe("forceThinking", () => {
112
+ test("defaults to null", () => {
113
+ expect(session.forceThinking).toBeNull();
114
+ });
115
+
116
+ test("can be set to specific token count", () => {
117
+ session.forceThinking = 10000;
118
+ expect(session.forceThinking).toBe(10000);
119
+ });
120
+
121
+ test("can be set to 0 (off)", () => {
122
+ session.forceThinking = 0;
123
+ expect(session.forceThinking).toBe(0);
124
+ });
125
+
126
+ test("can be set to deep (50000)", () => {
127
+ session.forceThinking = 50000;
128
+ expect(session.forceThinking).toBe(50000);
129
+ });
130
+ });
131
+
132
+ describe("planMode", () => {
133
+ test("defaults to false", () => {
134
+ expect(session.planMode).toBe(false);
135
+ });
136
+
137
+ test("can be toggled on", () => {
138
+ session.planMode = true;
139
+ expect(session.planMode).toBe(true);
140
+ });
141
+
142
+ test("can be toggled off", () => {
143
+ session.planMode = true;
144
+ session.planMode = false;
145
+ expect(session.planMode).toBe(false);
146
+ });
147
+ });
148
+
149
+ describe("token usage tracking", () => {
150
+ test("starts with zero totals", () => {
151
+ expect(session.totalInputTokens).toBe(0);
152
+ expect(session.totalOutputTokens).toBe(0);
153
+ expect(session.totalCacheReadTokens).toBe(0);
154
+ });
155
+
156
+ test("can accumulate input tokens", () => {
157
+ session.totalInputTokens += 1000;
158
+ session.totalInputTokens += 500;
159
+ expect(session.totalInputTokens).toBe(1500);
160
+ });
161
+
162
+ test("can accumulate output tokens", () => {
163
+ session.totalOutputTokens += 2000;
164
+ session.totalOutputTokens += 800;
165
+ expect(session.totalOutputTokens).toBe(2800);
166
+ });
167
+
168
+ test("can accumulate cache read tokens", () => {
169
+ session.totalCacheReadTokens += 500;
170
+ session.totalCacheReadTokens += 300;
171
+ expect(session.totalCacheReadTokens).toBe(800);
172
+ });
173
+ });
174
+
175
+ describe("estimateCost", () => {
176
+ describe("sonnet pricing", () => {
177
+ test("calculates zero cost for zero tokens", () => {
178
+ const cost = session.estimateCost();
179
+ expect(cost.inputCost).toBe(0);
180
+ expect(cost.outputCost).toBe(0);
181
+ expect(cost.total).toBe(0);
182
+ });
183
+
184
+ test("calculates cost for 1M input tokens", () => {
185
+ session.totalInputTokens = 1_000_000;
186
+ const cost = session.estimateCost();
187
+ expect(cost.inputCost).toBe(3); // $3 per 1M input
188
+ expect(cost.outputCost).toBe(0);
189
+ expect(cost.total).toBe(3);
190
+ });
191
+
192
+ test("calculates cost for 1M output tokens", () => {
193
+ session.totalOutputTokens = 1_000_000;
194
+ const cost = session.estimateCost();
195
+ expect(cost.inputCost).toBe(0);
196
+ expect(cost.outputCost).toBe(15); // $15 per 1M output
197
+ expect(cost.total).toBe(15);
198
+ });
199
+
200
+ test("calculates combined cost", () => {
201
+ session.totalInputTokens = 500_000;
202
+ session.totalOutputTokens = 100_000;
203
+ const cost = session.estimateCost();
204
+ expect(cost.inputCost).toBe(1.5); // $3 * 0.5
205
+ expect(cost.outputCost).toBe(1.5); // $15 * 0.1
206
+ expect(cost.total).toBe(3);
207
+ });
208
+ });
209
+
210
+ describe("opus pricing", () => {
211
+ beforeEach(() => {
212
+ session.currentModel = "opus";
213
+ });
214
+
215
+ test("calculates cost for 1M input tokens", () => {
216
+ session.totalInputTokens = 1_000_000;
217
+ const cost = session.estimateCost();
218
+ expect(cost.inputCost).toBe(15); // $15 per 1M input
219
+ });
220
+
221
+ test("calculates cost for 1M output tokens", () => {
222
+ session.totalOutputTokens = 1_000_000;
223
+ const cost = session.estimateCost();
224
+ expect(cost.outputCost).toBe(75); // $75 per 1M output
225
+ });
226
+
227
+ test("calculates combined cost", () => {
228
+ session.totalInputTokens = 100_000;
229
+ session.totalOutputTokens = 50_000;
230
+ const cost = session.estimateCost();
231
+ expect(cost.inputCost).toBe(1.5); // $15 * 0.1
232
+ expect(cost.outputCost).toBe(3.75); // $75 * 0.05
233
+ expect(cost.total).toBe(5.25);
234
+ });
235
+ });
236
+
237
+ describe("haiku pricing", () => {
238
+ beforeEach(() => {
239
+ session.currentModel = "haiku";
240
+ });
241
+
242
+ test("calculates cost for 1M input tokens", () => {
243
+ session.totalInputTokens = 1_000_000;
244
+ const cost = session.estimateCost();
245
+ expect(cost.inputCost).toBe(0.25); // $0.25 per 1M input
246
+ });
247
+
248
+ test("calculates cost for 1M output tokens", () => {
249
+ session.totalOutputTokens = 1_000_000;
250
+ const cost = session.estimateCost();
251
+ expect(cost.outputCost).toBe(1.25); // $1.25 per 1M output
252
+ });
253
+
254
+ test("calculates combined cost", () => {
255
+ session.totalInputTokens = 4_000_000;
256
+ session.totalOutputTokens = 800_000;
257
+ const cost = session.estimateCost();
258
+ expect(cost.inputCost).toBe(1); // $0.25 * 4
259
+ expect(cost.outputCost).toBe(1); // $1.25 * 0.8
260
+ expect(cost.total).toBe(2);
261
+ });
262
+ });
263
+
264
+ describe("small token counts", () => {
265
+ test("calculates fractional costs", () => {
266
+ session.totalInputTokens = 1000;
267
+ session.totalOutputTokens = 500;
268
+ const cost = session.estimateCost();
269
+ expect(cost.inputCost).toBeCloseTo(0.003, 5); // $3 * 0.001
270
+ expect(cost.outputCost).toBeCloseTo(0.0075, 5); // $15 * 0.0005
271
+ expect(cost.total).toBeCloseTo(0.0105, 5);
272
+ });
273
+ });
274
+ });
275
+
276
+ describe("kill", () => {
277
+ test("resets sessionId", async () => {
278
+ session.sessionId = "test-session-123";
279
+ await session.kill();
280
+ expect(session.sessionId).toBeNull();
281
+ });
282
+
283
+ test("resets lastActivity", async () => {
284
+ session.lastActivity = new Date();
285
+ await session.kill();
286
+ expect(session.lastActivity).toBeNull();
287
+ });
288
+
289
+ test("resets token totals", async () => {
290
+ session.totalInputTokens = 10000;
291
+ session.totalOutputTokens = 5000;
292
+ session.totalCacheReadTokens = 2000;
293
+ await session.kill();
294
+ expect(session.totalInputTokens).toBe(0);
295
+ expect(session.totalOutputTokens).toBe(0);
296
+ expect(session.totalCacheReadTokens).toBe(0);
297
+ });
298
+
299
+ test("resets planMode", async () => {
300
+ session.planMode = true;
301
+ await session.kill();
302
+ expect(session.planMode).toBe(false);
303
+ });
304
+
305
+ test("resets forceThinking", async () => {
306
+ session.forceThinking = 50000;
307
+ await session.kill();
308
+ expect(session.forceThinking).toBeNull();
309
+ });
310
+
311
+ test("preserves currentModel", async () => {
312
+ session.currentModel = "opus";
313
+ await session.kill();
314
+ expect(session.currentModel).toBe("opus");
315
+ });
316
+ });
317
+
318
+ describe("isActive", () => {
319
+ test("returns false when no sessionId", () => {
320
+ expect(session.isActive).toBe(false);
321
+ });
322
+
323
+ test("returns true when sessionId exists", () => {
324
+ session.sessionId = "test-session-123";
325
+ expect(session.isActive).toBe(true);
326
+ });
327
+ });
328
+
329
+ describe("workingDir", () => {
330
+ test("can get working directory", () => {
331
+ expect(typeof session.workingDir).toBe("string");
332
+ });
333
+
334
+ test("setWorkingDir changes directory", () => {
335
+ const newDir = "/tmp/test-dir";
336
+ session.setWorkingDir(newDir);
337
+ expect(session.workingDir).toBe(newDir);
338
+ });
339
+
340
+ test("setWorkingDir clears sessionId", () => {
341
+ session.sessionId = "test-session-123";
342
+ session.setWorkingDir("/tmp/new-dir");
343
+ expect(session.sessionId).toBeNull();
344
+ });
345
+ });
346
+
347
+ describe("stop", () => {
348
+ test("returns false when nothing is running", async () => {
349
+ const result = await session.stop();
350
+ expect(result).toBe(false);
351
+ });
352
+ });
353
+
354
+ describe("interrupt flags", () => {
355
+ test("markInterrupt and consumeInterruptFlag work together", () => {
356
+ expect(session.consumeInterruptFlag()).toBe(false);
357
+ session.markInterrupt();
358
+ expect(session.consumeInterruptFlag()).toBe(true);
359
+ expect(session.consumeInterruptFlag()).toBe(false); // Consumed
360
+ });
361
+ });
362
+
363
+ describe("processing state", () => {
364
+ test("startProcessing returns cleanup function", () => {
365
+ expect(session.isRunning).toBe(false);
366
+ const cleanup = session.startProcessing();
367
+ expect(session.isRunning).toBe(true);
368
+ cleanup();
369
+ expect(session.isRunning).toBe(false);
370
+ });
371
+ });
372
+
373
+ describe("undo/checkpointing", () => {
374
+ test("canUndo is false by default", () => {
375
+ expect(session.canUndo).toBe(false);
376
+ });
377
+
378
+ test("undoCheckpoints is 0 by default", () => {
379
+ expect(session.undoCheckpoints).toBe(0);
380
+ });
381
+
382
+ test("undo fails when no session active", async () => {
383
+ const [success, message] = await session.undo();
384
+ expect(success).toBe(false);
385
+ expect(message).toContain("No active session");
386
+ });
387
+
388
+ test("kill resets checkpoints", async () => {
389
+ // Simulate having checkpoints (by accessing private property for testing)
390
+ // @ts-expect-error - accessing private for test
391
+ session._userMessageUuids = ["uuid1", "uuid2"];
392
+ expect(session.undoCheckpoints).toBe(2);
393
+
394
+ await session.kill();
395
+ expect(session.undoCheckpoints).toBe(0);
396
+ expect(session.canUndo).toBe(false);
397
+ });
398
+ });
399
+ });
@@ -0,0 +1,310 @@
1
+ /**
2
+ * Unit tests for shell command execution (!command feature).
3
+ */
4
+
5
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
6
+ import { spawn } from "node:child_process";
7
+ import { mkdirSync, rmdirSync, writeFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+
10
+ /**
11
+ * Execute a shell command and return output.
12
+ * This is a copy of the function from text.ts for testing.
13
+ */
14
+ async function execShellCommand(
15
+ command: string,
16
+ cwd: string,
17
+ ): Promise<{ stdout: string; stderr: string; exitCode: number }> {
18
+ return new Promise((resolve) => {
19
+ const proc = spawn("bash", ["-c", command], {
20
+ cwd,
21
+ timeout: 30000,
22
+ });
23
+
24
+ let stdout = "";
25
+ let stderr = "";
26
+
27
+ proc.stdout.on("data", (data) => {
28
+ stdout += data.toString();
29
+ });
30
+
31
+ proc.stderr.on("data", (data) => {
32
+ stderr += data.toString();
33
+ });
34
+
35
+ proc.on("close", (code) => {
36
+ resolve({ stdout, stderr, exitCode: code ?? 0 });
37
+ });
38
+
39
+ proc.on("error", (err) => {
40
+ resolve({ stdout, stderr: err.message, exitCode: 1 });
41
+ });
42
+ });
43
+ }
44
+
45
+ describe("Shell command execution", () => {
46
+ let testDir: string;
47
+
48
+ beforeEach(() => {
49
+ testDir = `/tmp/shell-test-${Date.now()}`;
50
+ mkdirSync(testDir, { recursive: true });
51
+ });
52
+
53
+ afterEach(() => {
54
+ try {
55
+ // Clean up test files
56
+ const files = ["test.txt", "output.txt", "script.sh"];
57
+ for (const file of files) {
58
+ try {
59
+ Bun.spawnSync(["rm", "-f", join(testDir, file)]);
60
+ } catch {
61
+ // Ignore
62
+ }
63
+ }
64
+ rmdirSync(testDir);
65
+ } catch {
66
+ // Ignore cleanup errors
67
+ }
68
+ });
69
+
70
+ describe("basic commands", () => {
71
+ test("executes echo command", async () => {
72
+ const result = await execShellCommand("echo hello", testDir);
73
+ expect(result.stdout.trim()).toBe("hello");
74
+ expect(result.exitCode).toBe(0);
75
+ });
76
+
77
+ test("executes pwd command", async () => {
78
+ const result = await execShellCommand("pwd", testDir);
79
+ // macOS resolves /tmp to /private/tmp
80
+ expect(result.stdout.trim()).toMatch(/\/?tmp\/shell-test-/);
81
+ expect(result.exitCode).toBe(0);
82
+ });
83
+
84
+ test("executes ls command", async () => {
85
+ // Create a test file
86
+ writeFileSync(join(testDir, "test.txt"), "content");
87
+ const result = await execShellCommand("ls", testDir);
88
+ expect(result.stdout).toContain("test.txt");
89
+ expect(result.exitCode).toBe(0);
90
+ });
91
+
92
+ test("executes mkdir command", async () => {
93
+ const result = await execShellCommand("mkdir -p subdir", testDir);
94
+ expect(result.exitCode).toBe(0);
95
+ // Clean up
96
+ Bun.spawnSync(["rmdir", join(testDir, "subdir")]);
97
+ });
98
+
99
+ test("executes cat command", async () => {
100
+ writeFileSync(join(testDir, "test.txt"), "file content");
101
+ const result = await execShellCommand("cat test.txt", testDir);
102
+ expect(result.stdout.trim()).toBe("file content");
103
+ expect(result.exitCode).toBe(0);
104
+ });
105
+ });
106
+
107
+ describe("command with arguments", () => {
108
+ test("handles multiple arguments", async () => {
109
+ const result = await execShellCommand("echo one two three", testDir);
110
+ expect(result.stdout.trim()).toBe("one two three");
111
+ });
112
+
113
+ test("handles quoted arguments", async () => {
114
+ const result = await execShellCommand('echo "hello world"', testDir);
115
+ expect(result.stdout.trim()).toBe("hello world");
116
+ });
117
+
118
+ test("handles special characters in arguments", async () => {
119
+ const result = await execShellCommand("echo 'test$var'", testDir);
120
+ expect(result.stdout.trim()).toBe("test$var");
121
+ });
122
+ });
123
+
124
+ describe("piped commands", () => {
125
+ test("handles simple pipe", async () => {
126
+ const result = await execShellCommand("echo hello | cat", testDir);
127
+ expect(result.stdout.trim()).toBe("hello");
128
+ });
129
+
130
+ test("handles grep pipe", async () => {
131
+ const result = await execShellCommand(
132
+ "echo -e 'line1\\nline2\\nline3' | grep line2",
133
+ testDir,
134
+ );
135
+ expect(result.stdout.trim()).toBe("line2");
136
+ });
137
+
138
+ test("handles wc pipe", async () => {
139
+ const result = await execShellCommand("echo hello | wc -c", testDir);
140
+ expect(Number.parseInt(result.stdout.trim())).toBeGreaterThan(0);
141
+ });
142
+ });
143
+
144
+ describe("error handling", () => {
145
+ test("returns non-zero exit code for failed command", async () => {
146
+ const result = await execShellCommand(
147
+ "ls /nonexistent-directory-12345",
148
+ testDir,
149
+ );
150
+ expect(result.exitCode).not.toBe(0);
151
+ });
152
+
153
+ test("captures stderr for errors", async () => {
154
+ const result = await execShellCommand(
155
+ "ls /nonexistent-directory-12345",
156
+ testDir,
157
+ );
158
+ expect(result.stderr.length).toBeGreaterThan(0);
159
+ });
160
+
161
+ test("handles command not found", async () => {
162
+ const result = await execShellCommand("nonexistent-command-xyz", testDir);
163
+ expect(result.exitCode).not.toBe(0);
164
+ });
165
+
166
+ test("handles syntax errors", async () => {
167
+ const result = await execShellCommand("echo 'unclosed", testDir);
168
+ expect(result.exitCode).not.toBe(0);
169
+ });
170
+ });
171
+
172
+ describe("working directory", () => {
173
+ test("executes in specified directory", async () => {
174
+ const result = await execShellCommand("pwd", testDir);
175
+ // macOS resolves /tmp to /private/tmp
176
+ expect(result.stdout.trim()).toMatch(/\/?tmp\/shell-test-/);
177
+ });
178
+
179
+ test("creates file in working directory", async () => {
180
+ await execShellCommand("touch newfile.txt", testDir);
181
+ const lsResult = await execShellCommand("ls newfile.txt", testDir);
182
+ expect(lsResult.exitCode).toBe(0);
183
+ // Clean up
184
+ Bun.spawnSync(["rm", join(testDir, "newfile.txt")]);
185
+ });
186
+
187
+ test("handles relative paths in working directory", async () => {
188
+ writeFileSync(join(testDir, "data.txt"), "content");
189
+ const result = await execShellCommand("cat ./data.txt", testDir);
190
+ expect(result.stdout.trim()).toBe("content");
191
+ });
192
+ });
193
+
194
+ describe("output handling", () => {
195
+ test("handles large stdout", async () => {
196
+ const result = await execShellCommand("seq 1 1000", testDir);
197
+ expect(result.stdout.split("\n").length).toBeGreaterThan(100);
198
+ });
199
+
200
+ test("handles multiline output", async () => {
201
+ const result = await execShellCommand(
202
+ "echo -e 'line1\\nline2\\nline3'",
203
+ testDir,
204
+ );
205
+ const lines = result.stdout.trim().split("\n");
206
+ expect(lines).toHaveLength(3);
207
+ });
208
+
209
+ test("handles mixed stdout and stderr", async () => {
210
+ const result = await execShellCommand("echo out; echo err >&2", testDir);
211
+ expect(result.stdout.trim()).toBe("out");
212
+ expect(result.stderr.trim()).toBe("err");
213
+ });
214
+
215
+ test("handles empty output", async () => {
216
+ const result = await execShellCommand("true", testDir);
217
+ expect(result.stdout).toBe("");
218
+ expect(result.exitCode).toBe(0);
219
+ });
220
+ });
221
+
222
+ describe("shell features", () => {
223
+ test("handles environment variables", async () => {
224
+ const result = await execShellCommand("echo $HOME", testDir);
225
+ expect(result.stdout.trim().length).toBeGreaterThan(0);
226
+ });
227
+
228
+ test("handles command substitution", async () => {
229
+ const result = await execShellCommand("echo $(pwd)", testDir);
230
+ // macOS resolves /tmp to /private/tmp
231
+ expect(result.stdout.trim()).toMatch(/\/?tmp\/shell-test-/);
232
+ });
233
+
234
+ test("handles glob patterns", async () => {
235
+ writeFileSync(join(testDir, "a.txt"), "");
236
+ writeFileSync(join(testDir, "b.txt"), "");
237
+ const result = await execShellCommand("ls *.txt", testDir);
238
+ expect(result.stdout).toContain("a.txt");
239
+ expect(result.stdout).toContain("b.txt");
240
+ // Clean up
241
+ Bun.spawnSync(["rm", join(testDir, "a.txt"), join(testDir, "b.txt")]);
242
+ });
243
+
244
+ test("handles redirections", async () => {
245
+ await execShellCommand("echo content > output.txt", testDir);
246
+ const result = await execShellCommand("cat output.txt", testDir);
247
+ expect(result.stdout.trim()).toBe("content");
248
+ });
249
+
250
+ test("handles && chaining", async () => {
251
+ const result = await execShellCommand(
252
+ "echo first && echo second",
253
+ testDir,
254
+ );
255
+ expect(result.stdout).toContain("first");
256
+ expect(result.stdout).toContain("second");
257
+ });
258
+
259
+ test("handles || chaining", async () => {
260
+ const result = await execShellCommand("false || echo fallback", testDir);
261
+ expect(result.stdout.trim()).toBe("fallback");
262
+ });
263
+ });
264
+ });
265
+
266
+ describe("Shell command prefix detection", () => {
267
+ test("detects ! prefix", () => {
268
+ const message = "!ls -la";
269
+ expect(message.startsWith("!")).toBe(true);
270
+ expect(message.slice(1).trim()).toBe("ls -la");
271
+ });
272
+
273
+ test("extracts command after prefix", () => {
274
+ const testCases = [
275
+ { input: "!pwd", expected: "pwd" },
276
+ { input: "! pwd", expected: "pwd" },
277
+ { input: "! pwd", expected: "pwd" },
278
+ { input: "!ls -la /tmp", expected: "ls -la /tmp" },
279
+ { input: "!echo hello world", expected: "echo hello world" },
280
+ ];
281
+
282
+ for (const { input, expected } of testCases) {
283
+ expect(input.slice(1).trim()).toBe(expected);
284
+ }
285
+ });
286
+
287
+ test("handles empty command after prefix", () => {
288
+ const message = "!";
289
+ const command = message.slice(1).trim();
290
+ expect(command).toBe("");
291
+ });
292
+
293
+ test("handles whitespace only after prefix", () => {
294
+ const message = "! ";
295
+ const command = message.slice(1).trim();
296
+ expect(command).toBe("");
297
+ });
298
+
299
+ test("does not confuse with exclamation in text", () => {
300
+ const notCommands = ["Hello! How are you?", "This is great!", "! at end"];
301
+
302
+ for (const text of notCommands) {
303
+ // These start with ! should still be detected
304
+ // But "Hello!" doesn't start with !
305
+ if (!text.startsWith("!")) {
306
+ expect(text.startsWith("!")).toBe(false);
307
+ }
308
+ }
309
+ });
310
+ });