talon-agent 1.8.0 → 1.8.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "1.8.0",
3
+ "version": "1.8.1",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
@@ -38,9 +38,13 @@ const { registerClaudeModelsStatic, CLAUDE_MODELS_STATIC } =
38
38
  await import("../backend/claude-sdk/models.js");
39
39
  registerClaudeModelsStatic(CLAUDE_MODELS_STATIC);
40
40
 
41
+ // convertSdkModels collapses base + 1M variants into a single canonical ID
42
+ // per family+version, preferring the 1M variant (and "default" when the SDK
43
+ // marks one canonical). So sonnet/sonnet[1m] → "default", opus/opus[1m] →
44
+ // "opus[1m]", and plain "haiku" stays.
41
45
  const SDK_MODEL_IDS = {
42
46
  sonnet: "default",
43
- opus: "opus",
47
+ opus: "opus[1m]",
44
48
  haiku: "haiku",
45
49
  } as const;
46
50
 
@@ -58,36 +58,40 @@ describe("registerClaudeModels", () => {
58
58
  clearModels();
59
59
  });
60
60
 
61
- it("keeps SDK IDs/display names and collapses duplicates", async () => {
61
+ it("collapses family+version duplicates (base + 1M + claude-*) into a single canonical entry", async () => {
62
62
  const { registerClaudeModels } =
63
63
  await import("../backend/claude-sdk/models.js");
64
64
  const { getModels, resolveModelId } = await import("../core/models.js");
65
65
 
66
66
  await registerClaudeModels({ model: "default" });
67
67
 
68
+ // sonnet, sonnet[1m], claude-sonnet-4-6 all share family+version and
69
+ // collapse into "default" (the SDK's recommended canonical). opus/opus[1m]
70
+ // collapse into opus[1m] (1M-preferred since no "default" exists for that
71
+ // family). haiku stands alone.
68
72
  const anthropicModels = getModels("anthropic");
69
73
  expect(anthropicModels.map((model) => model.id)).toEqual([
70
74
  "default",
71
- "sonnet[1m]",
72
- "opus",
73
75
  "opus[1m]",
74
76
  "haiku",
75
77
  ]);
76
78
 
77
79
  expect(
78
80
  anthropicModels.find((model) => model.id === "default")?.displayName,
79
- ).toBe("Default (recommended)");
81
+ ).toBe("Sonnet 4.6");
80
82
  expect(
81
- anthropicModels.find((model) => model.id === "sonnet[1m]")?.displayName,
82
- ).toBe("Sonnet (1M context)");
83
- // claude-sonnet-4-6 collapsed into "default" as alias
83
+ anthropicModels.find((model) => model.id === "opus[1m]")?.displayName,
84
+ ).toBe("Opus 4.6");
84
85
  expect(
85
- anthropicModels.some((model) => model.id === "claude-sonnet-4-6"),
86
- ).toBe(false);
86
+ anthropicModels.find((model) => model.id === "haiku")?.displayName,
87
+ ).toBe("Haiku 4.5");
87
88
 
89
+ expect(resolveModelId("sonnet")).toBe("default");
90
+ expect(resolveModelId("sonnet[1m]")).toBe("default");
88
91
  expect(resolveModelId("claude-sonnet-4-6")).toBe("default");
89
- expect(resolveModelId("claude-sonnet-4-6[1m]")).toBe("sonnet[1m]");
90
- expect(resolveModelId("claude-opus-4-6")).toBe("opus");
92
+ expect(resolveModelId("claude-sonnet-4-6[1m]")).toBe("default");
93
+ expect(resolveModelId("opus")).toBe("opus[1m]");
94
+ expect(resolveModelId("claude-opus-4-6")).toBe("opus[1m]");
91
95
  });
92
96
 
93
97
  it("derives compatibility aliases from SDK metadata instead of hardcoded versions", async () => {
@@ -134,8 +138,8 @@ describe("registerClaudeModels", () => {
134
138
 
135
139
  expect(resolveModelId("claude-sonnet-5-0")).toBe("default");
136
140
  expect(resolveModelId("claude-sonnet-4-6")).toBe("default");
137
- expect(resolveModelId("claude-opus-5-0")).toBe("opus");
138
- expect(resolveModelId("claude-opus-4-6")).toBe("opus");
141
+ expect(resolveModelId("claude-opus-5-0")).toBe("opus[1m]");
142
+ expect(resolveModelId("claude-opus-4-6")).toBe("opus[1m]");
139
143
  expect(resolveModelId("claude-haiku-5-0")).toBe("haiku");
140
144
  expect(resolveModelId("claude-haiku-4-5")).toBe("haiku");
141
145
  });
@@ -327,13 +327,13 @@ describe("fuzz: resolveModelName()", () => {
327
327
  it("known aliases resolve to the expected SDK model IDs", () => {
328
328
  const aliasMappings = [
329
329
  ["sonnet", "default"],
330
- ["opus", "opus"],
330
+ ["opus", "opus[1m]"],
331
331
  ["haiku", "haiku"],
332
332
  ["sonnet-4.6", "default"],
333
- ["opus-4.6", "opus"],
333
+ ["opus-4.6", "opus[1m]"],
334
334
  ["haiku-4.5", "haiku"],
335
335
  ["sonnet-4-6", "default"],
336
- ["opus-4-6", "opus"],
336
+ ["opus-4-6", "opus[1m]"],
337
337
  ["haiku-4-5", "haiku"],
338
338
  ] as const;
339
339
  fc.assert(
@@ -1917,6 +1917,61 @@ describe("sendHtml — falls back to plain text on HTML send failure", () => {
1917
1917
  // Restore sendMessage mock for other tests
1918
1918
  mockBot.api.sendMessage = vi.fn(async () => ({ message_id: 1 }));
1919
1919
  }, 3000);
1920
+
1921
+ it("fallback iterates to strip nested tag sequences", async () => {
1922
+ executeMock.mockResolvedValue({
1923
+ text: "",
1924
+ durationMs: 10,
1925
+ inputTokens: 1,
1926
+ outputTokens: 1,
1927
+ cacheRead: 0,
1928
+ cacheWrite: 0,
1929
+ bridgeMessageCount: 0,
1930
+ });
1931
+
1932
+ let callCount = 0;
1933
+ mockBot.api.sendMessage = vi.fn(async () => {
1934
+ callCount++;
1935
+ if (callCount === 1) throw new Error("Bad Request: can't parse entities");
1936
+ return { message_id: callCount };
1937
+ });
1938
+
1939
+ const { classify, friendlyMessage } = await import("../core/errors.js");
1940
+ executeMock.mockRejectedValueOnce(new Error("some error"));
1941
+ (classify as ReturnType<typeof vi.fn>).mockReturnValueOnce({
1942
+ reason: "error",
1943
+ message: "some error",
1944
+ retryable: false,
1945
+ });
1946
+ // A single-pass regex leaves a `<script>` survivor after one removal
1947
+ // of the inner placeholder — the iterative loop must keep going.
1948
+ (friendlyMessage as ReturnType<typeof vi.fn>).mockReturnValueOnce(
1949
+ "<scr<script>ipt>alert(1)</script> tail",
1950
+ );
1951
+
1952
+ const ctx = {
1953
+ chat: { id: 97002, type: "private" },
1954
+ message: {
1955
+ text: "nested tag fallback",
1956
+ message_id: 961,
1957
+ reply_to_message: null,
1958
+ },
1959
+ me: { id: 999, username: "testbot" },
1960
+ from: { id: 95, first_name: "Zoe" },
1961
+ } as any;
1962
+
1963
+ await handleTextMessage(ctx, mockBot, mockConfig);
1964
+ await new Promise((r) => setTimeout(r, 700));
1965
+
1966
+ expect(mockBot.api.sendMessage).toHaveBeenCalledTimes(2);
1967
+ const plain = (mockBot.api.sendMessage as ReturnType<typeof vi.fn>).mock
1968
+ .calls[1][1];
1969
+ expect(plain).not.toMatch(/<[^<>]*>/); // no complete tag remains
1970
+ expect(plain).not.toContain("<");
1971
+ expect(plain).toContain("alert(1)");
1972
+
1973
+ mockBot.api.sendMessage = vi.fn(async () => ({ message_id: 1 }));
1974
+ }, 3000);
1920
1975
  });
1921
1976
 
1922
1977
  describe("createStreamCallbacks — onStreamDelta streaming path", () => {
@@ -520,6 +520,34 @@ describe("teams formatting — default token type", () => {
520
520
  const result = stripHtmlFresh("<p>Hello <b>world</b></p>");
521
521
  expect(result).toBe("Hello world");
522
522
  });
523
+
524
+ it("stripHtml fallback iterates to remove nested tag sequences", async () => {
525
+ vi.resetModules();
526
+ vi.doMock("cheerio", () => ({
527
+ default: {},
528
+ load: vi.fn(() => {
529
+ throw new Error("cheerio unavailable");
530
+ }),
531
+ }));
532
+ vi.doMock("../util/log.js", () => ({
533
+ log: vi.fn(),
534
+ logError: vi.fn(),
535
+ logWarn: vi.fn(),
536
+ logDebug: vi.fn(),
537
+ }));
538
+
539
+ const { stripHtml: stripHtmlFresh } =
540
+ await import("../frontend/teams/formatting.js");
541
+ // Nested sequences must not leave any surviving `<...>` tag after the
542
+ // fallback runs. The iterative loop is what guarantees that — with a
543
+ // non-iterating single pass, certain crafted inputs can reconstruct a
544
+ // tag after the first removal.
545
+ const nested = "<scr<script>ipt>alert(1)</script>";
546
+ const result = stripHtmlFresh(nested);
547
+ expect(result).not.toMatch(/<[^<>]*>/); // no complete tag remains
548
+ expect(result).not.toContain("<");
549
+ expect(result).toContain("alert(1)");
550
+ });
523
551
  });
524
552
 
525
553
  // ── teams actions branch coverage ─────────────────────────────────────────
@@ -76,6 +76,10 @@ describe("escapeHtml", () => {
76
76
  expect(escapeHtml("<>&")).toBe("&lt;&gt;&amp;");
77
77
  });
78
78
 
79
+ it("escapes quotes so output is safe in attribute contexts", () => {
80
+ expect(escapeHtml(`"'`)).toBe("&quot;&#39;");
81
+ });
82
+
79
83
  it("passes through plain text unchanged", () => {
80
84
  expect(escapeHtml("hello world")).toBe("hello world");
81
85
  });
@@ -14,44 +14,34 @@ import {
14
14
  describe("telegram helpers", () => {
15
15
  beforeEach(() => {
16
16
  clearModels();
17
+ // Post-merge state: convertSdkModels collapses base/1M/claude-* variants
18
+ // of the same family+version into a single canonical entry. This fixture
19
+ // is what the registry looks like after that merge.
17
20
  registerModels([
18
21
  {
19
22
  id: "default",
20
- displayName: "Default (recommended)",
23
+ displayName: "Sonnet 4.6",
21
24
  description: "Sonnet 4.6 · Best for everyday tasks",
22
- aliases: ["sonnet", "claude-sonnet-4-6"],
25
+ aliases: [
26
+ "sonnet",
27
+ "sonnet[1m]",
28
+ "claude-sonnet-4-6",
29
+ "claude-sonnet-4-6[1m]",
30
+ ],
23
31
  provider: "anthropic",
24
32
  fallback: "haiku",
25
33
  },
26
- {
27
- id: "sonnet[1m]",
28
- displayName: "Sonnet (1M context)",
29
- description:
30
- "Sonnet 4.6 with 1M context · Billed as extra usage · $3/$15 per Mtok",
31
- aliases: ["claude-sonnet-4-6[1m]"],
32
- provider: "anthropic",
33
- fallback: "haiku",
34
- },
35
- {
36
- id: "opus",
37
- displayName: "Opus",
38
- description: "Opus 4.6 · Most capable for complex work",
39
- aliases: ["claude-opus-4-6"],
40
- provider: "anthropic",
41
- fallback: "default",
42
- },
43
34
  {
44
35
  id: "opus[1m]",
45
- displayName: "Opus (1M context)",
46
- description:
47
- "Opus 4.6 with 1M context · Billed as extra usage · $5/$25 per Mtok",
48
- aliases: ["claude-opus-4-6[1m]"],
36
+ displayName: "Opus 4.6",
37
+ description: "Opus 4.6 with 1M context · Large context window",
38
+ aliases: ["opus", "claude-opus-4-6", "claude-opus-4-6[1m]"],
49
39
  provider: "anthropic",
50
40
  fallback: "default",
51
41
  },
52
42
  {
53
43
  id: "haiku",
54
- displayName: "Haiku",
44
+ displayName: "Haiku 4.5",
55
45
  description: "Haiku 4.5 · Fastest for quick answers",
56
46
  aliases: ["claude-haiku-4-5"],
57
47
  provider: "anthropic",
@@ -59,16 +49,21 @@ describe("telegram helpers", () => {
59
49
  ]);
60
50
  });
61
51
 
62
- it("matches legacy aliases to the canonical selected model", () => {
52
+ it("matches legacy aliases and 1M variants to the canonical selected model", () => {
63
53
  expect(isSelectedModel("claude-sonnet-4-6", "default")).toBe(true);
54
+ // sonnet[1m] is merged into "default" — same canonical model.
64
55
  expect(isSelectedModel("sonnet[1m]", "default")).toBe(true);
56
+ expect(isSelectedModel("claude-sonnet-4-6[1m]", "default")).toBe(true);
65
57
  expect(isSelectedModel("claude-sonnet-4-6", "haiku")).toBe(false);
66
58
  });
67
59
 
68
- it("formats clean model labels for telegram users", () => {
60
+ it("formats labels using backend-registered displayName", () => {
69
61
  expect(formatModelLabel("default")).toBe("Sonnet 4.6");
70
62
  expect(formatModelLabel("claude-sonnet-4-6")).toBe("Sonnet 4.6");
63
+ // 1M variants collapse into the same entry — same clean label.
71
64
  expect(formatModelLabel("sonnet[1m]")).toBe("Sonnet 4.6");
65
+ expect(formatModelLabel("opus[1m]")).toBe("Opus 4.6");
66
+ expect(formatModelLabel("claude-opus-4-6")).toBe("Opus 4.6");
72
67
  expect(formatModelOptionLabel(getTelegramModelOptions()[0]!)).toBe(
73
68
  "Sonnet 4.6",
74
69
  );
@@ -77,10 +72,10 @@ describe("telegram helpers", () => {
77
72
  );
78
73
  });
79
74
 
80
- it("shows a single clean option per model family", () => {
75
+ it("shows one option per family+version (base/1M variants merged)", () => {
81
76
  expect(getTelegramModelOptions().map((model) => model.id)).toEqual([
82
77
  "default",
83
- "opus",
78
+ "opus[1m]",
84
79
  "haiku",
85
80
  ]);
86
81
  });
@@ -26,7 +26,7 @@ describe("markdownToTelegramHtml", () => {
26
26
  const input = "```python\nprint('hello')\n```";
27
27
  const result = markdownToTelegramHtml(input);
28
28
  expect(result).toContain('<code class="language-python">');
29
- expect(result).toContain("print('hello')");
29
+ expect(result).toContain("print(&#39;hello&#39;)");
30
30
  expect(result).toContain("<pre>");
31
31
  expect(result).toContain("</pre>");
32
32
  });
@@ -46,9 +46,8 @@ describe("markdownToTelegramHtml", () => {
46
46
  });
47
47
 
48
48
  it("escapes HTML special characters in plain text", () => {
49
- // escapeHtml handles &, <, > — single quotes are passed through
50
49
  expect(markdownToTelegramHtml("<script>alert('xss')</script>")).toBe(
51
- "&lt;script&gt;alert('xss')&lt;/script&gt;",
50
+ "&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;",
52
51
  );
53
52
  });
54
53
 
@@ -90,48 +90,6 @@ vi.mock("../core/plugin.js", () => ({
90
90
  getLoadedPlugins: () => mockGetLoadedPlugins(),
91
91
  }));
92
92
 
93
- const mockGetOpenCodeModelCatalog = vi.fn(async () => ({
94
- generatedAt: Date.now(),
95
- providers: [],
96
- models: [],
97
- connectedProviders: [],
98
- loginProviders: [],
99
- connectedModels: [],
100
- connectedFreeModels: [],
101
- }));
102
- const mockGetOpenCodeModelInfo = vi.fn<
103
- (modelId: string) => Promise<Record<string, unknown> | undefined>
104
- >(async (_modelId: string) => undefined);
105
- const mockGetOpenCodeQuickPickModels = vi.fn<
106
- (catalog: unknown, currentModelId?: string) => Array<unknown>
107
- >(() => []);
108
- const mockResolveOpenCodeModelInput = vi.fn<
109
- (query: string, catalog: unknown) => Record<string, unknown>
110
- >((_query: string) => ({
111
- kind: "missing",
112
- matches: [],
113
- }));
114
- const mockGetOpenCodeSessionSnapshot = vi.fn<
115
- (sessionId: string) => Promise<Record<string, unknown> | undefined>
116
- >(async (_sessionId: string) => undefined);
117
- const mockGetOpenCodeModelSelectionValue = vi.fn<
118
- (model: Record<string, unknown>, catalog: unknown) => string
119
- >((model: Record<string, unknown>) => String(model.id ?? ""));
120
- vi.mock("../backend/opencode/index.js", () => ({
121
- getOpenCodeModelCatalog: () => mockGetOpenCodeModelCatalog(),
122
- getOpenCodeModelInfo: (modelId: string) => mockGetOpenCodeModelInfo(modelId),
123
- getOpenCodeModelSelectionValue: (
124
- model: Record<string, unknown>,
125
- catalog: unknown,
126
- ) => mockGetOpenCodeModelSelectionValue(model, catalog),
127
- getOpenCodeQuickPickModels: (catalog: unknown, currentModelId?: string) =>
128
- mockGetOpenCodeQuickPickModels(catalog, currentModelId),
129
- resolveOpenCodeModelInput: (query: string, catalog: unknown) =>
130
- mockResolveOpenCodeModelInput(query, catalog),
131
- getOpenCodeSessionSnapshot: (sessionId: string) =>
132
- mockGetOpenCodeSessionSnapshot(sessionId),
133
- }));
134
-
135
93
  import {
136
94
  registerCommand,
137
95
  tryRunCommand,
@@ -257,9 +215,6 @@ describe("built-in commands", () => {
257
215
  clearCommands();
258
216
  registerBuiltinCommands();
259
217
  vi.clearAllMocks();
260
- mockGetOpenCodeModelSelectionValue.mockImplementation(
261
- (model: Record<string, unknown>) => String(model.id ?? ""),
262
- );
263
218
  });
264
219
 
265
220
  it("registers all expected commands", () => {
@@ -328,35 +283,25 @@ describe("built-in commands", () => {
328
283
  );
329
284
  });
330
285
 
331
- it("stores provider-qualified OpenCode model selections when needed", async () => {
332
- mockGetOpenCodeModelCatalog.mockResolvedValueOnce({
333
- generatedAt: Date.now(),
334
- providers: [],
335
- models: [],
336
- connectedProviders: [],
337
- loginProviders: [],
338
- connectedModels: [],
339
- connectedFreeModels: [],
340
- });
341
- mockResolveOpenCodeModelInput.mockReturnValueOnce({
342
- kind: "exact",
343
- model: {
344
- id: "gpt-5",
345
- providerID: "github-copilot",
346
- providerName: "GitHub Copilot",
347
- free: false,
348
- selectable: true,
349
- loginRequired: false,
350
- envRequired: false,
351
- authMethods: [],
352
- },
353
- });
354
- mockGetOpenCodeModelSelectionValue.mockReturnValueOnce(
355
- "github-copilot/gpt-5",
356
- );
357
-
286
+ it("stores provider-qualified model selections via backend.resolveModel", async () => {
358
287
  const ctx = makeMockContext({
359
- config: { model: "nemotron-3-super-free", backend: "opencode" } as any,
288
+ config: { model: "nemotron-3-super-free" } as any,
289
+ backend: {
290
+ query: vi.fn() as any,
291
+ resolveModel: vi.fn().mockResolvedValue({
292
+ kind: "exact",
293
+ model: {
294
+ id: "gpt-5",
295
+ displayName: "GPT-5",
296
+ provider: "github-copilot",
297
+ providerName: "GitHub Copilot",
298
+ free: false,
299
+ selectable: true,
300
+ },
301
+ storedValue: "github-copilot/gpt-5",
302
+ }),
303
+ formatModelError: vi.fn(),
304
+ },
360
305
  });
361
306
 
362
307
  await tryRunCommand("/model github-copilot/gpt-5", ctx);
@@ -366,7 +311,7 @@ describe("built-in commands", () => {
366
311
  "github-copilot/gpt-5",
367
312
  );
368
313
  expect(ctx.renderer.writeSystem).toHaveBeenCalledWith(
369
- expect.stringContaining("github-copilot/gpt-5"),
314
+ expect.stringContaining("GPT-5"),
370
315
  );
371
316
  });
372
317
  });
@@ -593,7 +538,7 @@ describe("/status command", () => {
593
538
  expect(calls.join(" ")).toContain("actions only");
594
539
  });
595
540
 
596
- it("/status uses live OpenCode usage totals when backend is opencode", async () => {
541
+ it("/status uses live backend usage totals via getSessionSnapshot", async () => {
597
542
  mockGetSessionInfo.mockReturnValueOnce({
598
543
  turns: 14,
599
544
  sessionId: "ses_live",
@@ -612,58 +557,31 @@ describe("/status command", () => {
612
557
  },
613
558
  });
614
559
  mockGetChatSettings.mockReturnValueOnce({ model: "big-pickle" });
615
- mockGetOpenCodeModelInfo.mockResolvedValueOnce({
616
- id: "big-pickle",
617
- name: "Big Pickle",
618
- providerID: "opencode",
619
- providerName: "OpenCode Zen",
620
- providerSource: "builtin",
621
- connected: true,
622
- selectable: true,
623
- loginRequired: false,
624
- envRequired: false,
625
- authMethods: [],
626
- free: true,
627
- status: "active",
628
- contextWindow: 204800,
629
- outputWindow: 128000,
630
- reasoning: true,
631
- attachment: false,
632
- toolcall: true,
633
- costInput: 0,
634
- costOutput: 0,
635
- costCacheRead: 0,
636
- costCacheWrite: 0,
637
- });
638
- mockGetOpenCodeSessionSnapshot.mockResolvedValueOnce({
639
- sessionId: "ses_live",
640
- assistant: {
641
- modelID: "big-pickle",
642
- providerID: "opencode",
643
- inputTokens: 42200,
644
- outputTokens: 20,
645
- reasoningTokens: 10,
646
- cacheRead: 0,
647
- cacheWrite: 0,
648
- costUsd: 0,
649
- totalTokens: 42220,
650
- },
651
- usage: {
652
- assistantMessages: 42,
653
- totalInputTokens: 1389045,
654
- totalOutputTokens: 3675,
655
- totalReasoningTokens: 4717,
656
- totalCacheRead: 0,
657
- totalCacheWrite: 0,
658
- totalCostUsd: 0,
659
- },
660
- });
661
560
 
662
561
  const ctx = makeMockContext({
663
562
  config: {
664
563
  model: "big-pickle",
665
- backend: "opencode",
666
564
  } as CommandContext["config"],
565
+ backend: {
566
+ query: vi.fn() as any,
567
+ getModelInfo: vi.fn().mockResolvedValue({
568
+ id: "big-pickle",
569
+ displayName: "Big Pickle",
570
+ provider: "opencode",
571
+ providerName: "OpenCode Zen",
572
+ free: true,
573
+ contextWindow: 204800,
574
+ selectable: true,
575
+ }),
576
+ getSessionSnapshot: vi.fn().mockResolvedValue({
577
+ inputTokens: 1389045,
578
+ outputTokens: 3675,
579
+ cacheRead: 0,
580
+ cacheWrite: 0,
581
+ contextModelId: "big-pickle",
582
+ }),
583
+ backendLabel: "OpenCode",
584
+ },
667
585
  });
668
586
  await tryRunCommand("/status", ctx);
669
587
 
@@ -6,10 +6,44 @@ import {
6
6
  existsSync,
7
7
  readdirSync,
8
8
  symlinkSync,
9
+ unlinkSync,
9
10
  } from "node:fs";
10
11
  import { join } from "node:path";
11
12
  import { tmpdir } from "node:os";
12
13
 
14
+ // Probe whether this platform/user can create symlinks (Windows requires
15
+ // Developer Mode or admin). Declaration-time gate via it.runIf avoids any
16
+ // runtime skip call in tests.
17
+ const canSymlink = (() => {
18
+ const probeDir = join(tmpdir(), `talon-ws-symlink-probe-${Date.now()}`);
19
+ const target = join(probeDir, "target");
20
+ const link = join(probeDir, "link");
21
+ try {
22
+ mkdirSync(probeDir, { recursive: true });
23
+ writeFileSync(target, "x");
24
+ symlinkSync(target, link);
25
+ return true;
26
+ } catch {
27
+ return false;
28
+ } finally {
29
+ try {
30
+ unlinkSync(link);
31
+ } catch {
32
+ /* ignore */
33
+ }
34
+ try {
35
+ unlinkSync(target);
36
+ } catch {
37
+ /* ignore */
38
+ }
39
+ try {
40
+ rmSync(probeDir, { recursive: true, force: true });
41
+ } catch {
42
+ /* ignore */
43
+ }
44
+ }
45
+ })();
46
+
13
47
  // Mock log to prevent pino initialization issues
14
48
  vi.mock("../util/log.js", () => ({
15
49
  log: vi.fn(),
@@ -216,15 +250,20 @@ describe("getWorkspaceDiskUsage — edge cases", () => {
216
250
  expect(usage).toBe(8);
217
251
  });
218
252
 
219
- it("skips symlinks entry.isFile() FALSE branch (L147)", () => {
220
- mkdirSync(TEST_ROOT, { recursive: true });
221
- writeFileSync(join(TEST_ROOT, "real.txt"), "hello"); // 5 bytes
222
- // symlink: isDirectory()=false, isFile()=false → skipped by walk
223
- symlinkSync(join(TEST_ROOT, "real.txt"), join(TEST_ROOT, "link.txt"));
224
- const usage = getWorkspaceDiskUsage(TEST_ROOT);
225
- // Only real.txt counts (5 bytes); symlink is not counted
226
- expect(usage).toBe(5);
227
- });
253
+ // Gated at declaration time via it.runIf Windows without Developer
254
+ // Mode / admin throws EPERM on symlinkSync, so the test only runs where
255
+ // the platform actually supports the primitive.
256
+ it.runIf(canSymlink)(
257
+ "skips symlinks — entry.isFile() FALSE branch (L147)",
258
+ () => {
259
+ mkdirSync(TEST_ROOT, { recursive: true });
260
+ writeFileSync(join(TEST_ROOT, "real.txt"), "hello"); // 5 bytes
261
+ symlinkSync(join(TEST_ROOT, "real.txt"), join(TEST_ROOT, "link.txt"));
262
+ const usage = getWorkspaceDiskUsage(TEST_ROOT);
263
+ // Only real.txt counts (5 bytes); symlink is not counted
264
+ expect(usage).toBe(5);
265
+ },
266
+ );
228
267
  });
229
268
 
230
269
  describe("startUploadCleanup — setInterval callback (function coverage)", () => {
@@ -1,32 +1,17 @@
1
1
  /**
2
- * Shared constants for Claude SDK backend and background agents.
2
+ * Claude SDK backend constants thinking effort, streaming, and
3
+ * chat-specific tool restrictions.
3
4
  *
4
- * Single source of truth for disallowed tool lists, thinking effort
5
- * configuration, and streaming parameters.
5
+ * Core disallowed-tool lists live in core/constants.ts (backend-agnostic).
6
6
  */
7
7
 
8
- // ── Disallowed tool lists ──────────────────────────────────────────────────
8
+ import {
9
+ DISALLOWED_TOOLS_CORE,
10
+ DISALLOWED_TOOLS_BACKGROUND,
11
+ } from "../../core/constants.js";
9
12
 
10
- /**
11
- * Core tools disallowed in all SDK query contexts (chat, heartbeat, dream).
12
- * These are interactive or planning-only tools that make no sense in a
13
- * headless agent context.
14
- */
15
- export const DISALLOWED_TOOLS_CORE = [
16
- "EnterPlanMode",
17
- "ExitPlanMode",
18
- "EnterWorktree",
19
- "ExitWorktree",
20
- "TodoWrite",
21
- "TodoRead",
22
- "TaskCreate",
23
- "TaskUpdate",
24
- "TaskGet",
25
- "TaskList",
26
- "TaskOutput",
27
- "TaskStop",
28
- "AskUserQuestion",
29
- ] as const;
13
+ // Re-export so existing backend imports keep working
14
+ export { DISALLOWED_TOOLS_CORE, DISALLOWED_TOOLS_BACKGROUND };
30
15
 
31
16
  /** Disallowed tools for the main chat handler (core + web tools replaced by Brave MCP). */
32
17
  export const DISALLOWED_TOOLS_CHAT = [
@@ -35,12 +20,6 @@ export const DISALLOWED_TOOLS_CHAT = [
35
20
  "WebFetch",
36
21
  ] as const;
37
22
 
38
- /** Disallowed tools for background agents — heartbeat and dream (core + Agent). */
39
- export const DISALLOWED_TOOLS_BACKGROUND = [
40
- ...DISALLOWED_TOOLS_CORE,
41
- "Agent",
42
- ] as const;
43
-
44
23
  // ── Thinking / effort configuration ────────────────────────────────────────
45
24
 
46
25
  export const EFFORT_MAP: Record<