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 +1 -1
- package/src/__tests__/chat-settings.test.ts +5 -1
- package/src/__tests__/claude-sdk-models.test.ts +17 -13
- package/src/__tests__/fuzz.test.ts +3 -3
- package/src/__tests__/handlers.test.ts +55 -0
- package/src/__tests__/teams-frontend.test.ts +28 -0
- package/src/__tests__/telegram-formatting.test.ts +4 -0
- package/src/__tests__/telegram-helpers.test.ts +23 -28
- package/src/__tests__/telegram.test.ts +2 -3
- package/src/__tests__/terminal-commands.test.ts +40 -122
- package/src/__tests__/workspace.test.ts +48 -9
- package/src/backend/claude-sdk/constants.ts +9 -30
- package/src/backend/claude-sdk/model-provider.ts +14 -39
- package/src/backend/claude-sdk/models.ts +49 -16
- package/src/backend/opencode/model-provider.ts +12 -0
- package/src/bootstrap.ts +4 -0
- package/src/core/constants.ts +30 -0
- package/src/core/dream.ts +2 -2
- package/src/core/heartbeat.ts +2 -2
- package/src/core/models.ts +5 -7
- package/src/core/tools/web.ts +2 -4
- package/src/core/types.ts +8 -1
- package/src/frontend/teams/formatting.ts +7 -1
- package/src/frontend/teams/index.ts +5 -4
- package/src/frontend/telegram/admin.ts +3 -4
- package/src/frontend/telegram/callbacks.ts +1 -1
- package/src/frontend/telegram/commands.ts +2 -2
- package/src/frontend/telegram/formatting.ts +4 -2
- package/src/frontend/telegram/handlers.ts +12 -21
- package/src/frontend/telegram/helpers.ts +8 -35
- package/src/frontend/terminal/commands.ts +127 -202
- package/src/frontend/terminal/index.ts +6 -7
package/package.json
CHANGED
|
@@ -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("
|
|
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("
|
|
81
|
+
).toBe("Sonnet 4.6");
|
|
80
82
|
expect(
|
|
81
|
-
anthropicModels.find((model) => model.id === "
|
|
82
|
-
).toBe("
|
|
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.
|
|
86
|
-
).toBe(
|
|
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("
|
|
90
|
-
expect(resolveModelId("
|
|
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("<>&");
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
+
it("escapes quotes so output is safe in attribute contexts", () => {
|
|
80
|
+
expect(escapeHtml(`"'`)).toBe(""'");
|
|
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: "
|
|
23
|
+
displayName: "Sonnet 4.6",
|
|
21
24
|
description: "Sonnet 4.6 · Best for everyday tasks",
|
|
22
|
-
aliases: [
|
|
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
|
|
46
|
-
description:
|
|
47
|
-
|
|
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
|
|
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
|
|
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(
|
|
29
|
+
expect(result).toContain("print('hello')");
|
|
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
|
-
"<script>alert(
|
|
50
|
+
"<script>alert('xss')</script>",
|
|
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
|
|
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"
|
|
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("
|
|
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
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
*
|
|
2
|
+
* Claude SDK backend constants — thinking effort, streaming, and
|
|
3
|
+
* chat-specific tool restrictions.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
* configuration, and streaming parameters.
|
|
5
|
+
* Core disallowed-tool lists live in core/constants.ts (backend-agnostic).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
import {
|
|
9
|
+
DISALLOWED_TOOLS_CORE,
|
|
10
|
+
DISALLOWED_TOOLS_BACKGROUND,
|
|
11
|
+
} from "../../core/constants.js";
|
|
9
12
|
|
|
10
|
-
|
|
11
|
-
|
|
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<
|