talon-agent 1.7.0 → 1.8.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.
- package/package.json +1 -1
- package/src/__tests__/claude-sdk-models.test.ts +6 -21
- package/src/__tests__/claude-sdk-options.test.ts +5 -13
- package/src/__tests__/fixtures/test-mcp-server.ts +37 -0
- package/src/__tests__/handlers.test.ts +43 -0
- package/src/__tests__/mcp-lifecycle.test.ts +165 -0
- package/src/__tests__/metrics.test.ts +76 -0
- package/src/__tests__/opencode-models.test.ts +117 -0
- package/src/__tests__/opencode-summary.test.ts +105 -0
- package/src/__tests__/opencode-ui.test.ts +94 -0
- package/src/__tests__/plugin.test.ts +9 -8
- package/src/__tests__/reload-plugins.test.ts +137 -0
- package/src/__tests__/sessions.test.ts +0 -5
- package/src/__tests__/telegram-helpers.test.ts +63 -20
- package/src/__tests__/terminal-commands.test.ts +175 -1
- package/src/backend/claude-sdk/handler.ts +24 -2
- package/src/backend/claude-sdk/index.ts +2 -1
- package/src/backend/claude-sdk/model-provider.ts +209 -0
- package/src/backend/claude-sdk/models.ts +31 -96
- package/src/backend/claude-sdk/options.ts +3 -8
- package/src/backend/opencode/handler.ts +198 -0
- package/src/backend/opencode/index.ts +39 -232
- package/src/backend/opencode/model-provider.ts +167 -0
- package/src/backend/opencode/models.ts +742 -0
- package/src/backend/opencode/server.ts +382 -0
- package/src/backend/opencode/sessions.ts +492 -0
- package/src/bootstrap.ts +56 -2
- package/src/core/cron.ts +23 -2
- package/src/core/dream.ts +5 -4
- package/src/core/gateway-actions.ts +38 -2
- package/src/core/heartbeat.ts +5 -3
- package/src/core/models.ts +29 -45
- package/src/core/plugin.ts +15 -0
- package/src/core/tools/mcp-server.ts +23 -0
- package/src/core/types.ts +72 -0
- package/src/frontend/teams/index.ts +2 -0
- package/src/frontend/telegram/actions.ts +13 -4
- package/src/frontend/telegram/callbacks.ts +68 -34
- package/src/frontend/telegram/commands.ts +150 -52
- package/src/frontend/telegram/handlers.ts +78 -21
- package/src/frontend/telegram/helpers.ts +135 -13
- package/src/frontend/telegram/index.ts +4 -1
- package/src/frontend/terminal/commands.ts +248 -5
- package/src/frontend/terminal/index.ts +2 -0
- package/src/storage/media-index.ts +3 -3
- package/src/storage/sessions.ts +5 -0
- package/src/util/cleanup-registry.ts +4 -2
- package/src/util/metrics.ts +80 -0
- package/src/util/trace.ts +4 -2
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
vi.mock("../backend/opencode/index.js", async (importOriginal) => {
|
|
4
|
+
const mod =
|
|
5
|
+
await importOriginal<typeof import("../backend/opencode/index.js")>();
|
|
6
|
+
return {
|
|
7
|
+
...mod,
|
|
8
|
+
getOpenCodeModelSelectionValue: vi.fn(
|
|
9
|
+
(model: { providerID?: string; id: string }) =>
|
|
10
|
+
`${model.providerID}/${model.id}`,
|
|
11
|
+
),
|
|
12
|
+
};
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const { formatOpenCodeSelectionError } =
|
|
16
|
+
await import("../backend/opencode/index.js");
|
|
17
|
+
|
|
18
|
+
function makeEntry(overrides: Record<string, unknown> = {}) {
|
|
19
|
+
return {
|
|
20
|
+
id: "m",
|
|
21
|
+
name: "M",
|
|
22
|
+
providerID: "p",
|
|
23
|
+
providerName: "P",
|
|
24
|
+
providerSource: "api",
|
|
25
|
+
connected: false,
|
|
26
|
+
selectable: false,
|
|
27
|
+
loginRequired: true,
|
|
28
|
+
envRequired: false,
|
|
29
|
+
authMethods: ["OAuth"],
|
|
30
|
+
free: false,
|
|
31
|
+
status: "active",
|
|
32
|
+
contextWindow: 400_000,
|
|
33
|
+
outputWindow: 128_000,
|
|
34
|
+
reasoning: true,
|
|
35
|
+
attachment: true,
|
|
36
|
+
toolcall: true,
|
|
37
|
+
costInput: 1,
|
|
38
|
+
costOutput: 2,
|
|
39
|
+
costCacheRead: 0,
|
|
40
|
+
costCacheWrite: 0,
|
|
41
|
+
...overrides,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const emptyCatalog = {
|
|
46
|
+
generatedAt: Date.now(),
|
|
47
|
+
providers: [],
|
|
48
|
+
models: [],
|
|
49
|
+
connectedProviders: [],
|
|
50
|
+
loginProviders: [],
|
|
51
|
+
connectedModels: [],
|
|
52
|
+
connectedFreeModels: [],
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
describe("formatOpenCodeSelectionError", () => {
|
|
56
|
+
it("includes provider details for ambiguous matches", () => {
|
|
57
|
+
const matches = [
|
|
58
|
+
makeEntry({
|
|
59
|
+
id: "gpt-5",
|
|
60
|
+
name: "GPT-5",
|
|
61
|
+
providerID: "openai",
|
|
62
|
+
providerName: "OpenAI",
|
|
63
|
+
}),
|
|
64
|
+
makeEntry({
|
|
65
|
+
id: "gpt-5",
|
|
66
|
+
name: "GPT-5",
|
|
67
|
+
providerID: "github-copilot",
|
|
68
|
+
providerName: "GitHub Copilot",
|
|
69
|
+
}),
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
const text = formatOpenCodeSelectionError(
|
|
73
|
+
"gpt-5",
|
|
74
|
+
{ kind: "ambiguous", matches: matches as any },
|
|
75
|
+
{ ...emptyCatalog, models: matches } as any,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
expect(text).toContain("OpenAI / openai");
|
|
79
|
+
expect(text).toContain("GitHub Copilot / github-copilot");
|
|
80
|
+
expect(text).toContain("login required");
|
|
81
|
+
expect(text).toContain("openai/gpt-5");
|
|
82
|
+
expect(text).toContain("github-copilot/gpt-5");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("returns missing message for no matches", () => {
|
|
86
|
+
const text = formatOpenCodeSelectionError(
|
|
87
|
+
"nonexistent",
|
|
88
|
+
{ kind: "missing", matches: [] } as any,
|
|
89
|
+
emptyCatalog as any,
|
|
90
|
+
);
|
|
91
|
+
expect(text).toContain("nonexistent");
|
|
92
|
+
expect(text).toContain("No OpenCode model matched");
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -310,15 +310,16 @@ describe("plugin system", () => {
|
|
|
310
310
|
]);
|
|
311
311
|
|
|
312
312
|
const servers = getPluginMcpServers("http://localhost:19876", "chat1");
|
|
313
|
-
expect(servers["standalone-tools"]).
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
TALON_CHAT_ID: "chat1",
|
|
320
|
-
},
|
|
313
|
+
expect(servers["standalone-tools"].command).toBe("node");
|
|
314
|
+
expect(servers["standalone-tools"].args).toEqual(["/tmp/server.js"]);
|
|
315
|
+
expect(servers["standalone-tools"].env).toMatchObject({
|
|
316
|
+
API_KEY: "secret",
|
|
317
|
+
TALON_BRIDGE_URL: "http://localhost:19876",
|
|
318
|
+
TALON_CHAT_ID: "chat1",
|
|
321
319
|
});
|
|
320
|
+
expect(servers["standalone-tools"].env.TALON_RELOAD_AT).toMatch(
|
|
321
|
+
/^\d{4}-\d{2}-\d{2}T/,
|
|
322
|
+
);
|
|
322
323
|
});
|
|
323
324
|
|
|
324
325
|
it("skips path plugins that collide with standalone MCP names", async () => {
|
|
@@ -25,6 +25,52 @@ vi.mock("cheerio", () => ({
|
|
|
25
25
|
}),
|
|
26
26
|
}));
|
|
27
27
|
|
|
28
|
+
// Mocks needed for handler.ts (getActiveQuery) transitive imports
|
|
29
|
+
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
|
|
30
|
+
query: vi.fn(),
|
|
31
|
+
}));
|
|
32
|
+
vi.mock("../storage/sessions.js", () => ({
|
|
33
|
+
getSession: vi.fn(),
|
|
34
|
+
incrementTurns: vi.fn(),
|
|
35
|
+
recordUsage: vi.fn(),
|
|
36
|
+
resetSession: vi.fn(),
|
|
37
|
+
setSessionId: vi.fn(),
|
|
38
|
+
setSessionName: vi.fn(),
|
|
39
|
+
}));
|
|
40
|
+
vi.mock("../storage/chat-settings.js", () => ({
|
|
41
|
+
getChatSettings: vi.fn(() => ({})),
|
|
42
|
+
setChatModel: vi.fn(),
|
|
43
|
+
}));
|
|
44
|
+
vi.mock("../core/errors.js", () => ({
|
|
45
|
+
classify: vi.fn(),
|
|
46
|
+
}));
|
|
47
|
+
vi.mock("../core/models.js", () => ({
|
|
48
|
+
getFallbackModel: vi.fn(),
|
|
49
|
+
}));
|
|
50
|
+
vi.mock("../util/trace.js", () => ({
|
|
51
|
+
traceMessage: vi.fn(),
|
|
52
|
+
}));
|
|
53
|
+
vi.mock("../util/time.js", () => ({
|
|
54
|
+
formatFullDatetime: vi.fn(() => ""),
|
|
55
|
+
}));
|
|
56
|
+
vi.mock("../backend/claude-sdk/state.js", () => ({
|
|
57
|
+
getConfig: vi.fn(() => ({})),
|
|
58
|
+
}));
|
|
59
|
+
vi.mock("../backend/claude-sdk/options.js", () => ({
|
|
60
|
+
buildSdkOptions: vi.fn(() => ({})),
|
|
61
|
+
buildMcpServers: vi.fn(() => ({})),
|
|
62
|
+
}));
|
|
63
|
+
vi.mock("../backend/claude-sdk/stream.js", () => ({
|
|
64
|
+
createStreamState: vi.fn(),
|
|
65
|
+
isSystemInit: vi.fn(),
|
|
66
|
+
isStreamEvent: vi.fn(),
|
|
67
|
+
isAssistant: vi.fn(),
|
|
68
|
+
isResult: vi.fn(),
|
|
69
|
+
processStreamDelta: vi.fn(),
|
|
70
|
+
processAssistantMessage: vi.fn(),
|
|
71
|
+
processResultMessage: vi.fn(),
|
|
72
|
+
}));
|
|
73
|
+
|
|
28
74
|
// Mock storage modules required by gateway-actions
|
|
29
75
|
vi.mock("../storage/history.js", () => ({
|
|
30
76
|
getRecentFormatted: vi.fn(() => ""),
|
|
@@ -76,12 +122,26 @@ vi.mock("../util/config.js", () => ({
|
|
|
76
122
|
}));
|
|
77
123
|
|
|
78
124
|
// Backend mock — passed as 3rd arg to handleSharedAction
|
|
125
|
+
const mockRefreshMcpServers = vi.fn();
|
|
79
126
|
const mockBackend = {
|
|
80
127
|
query: vi.fn(),
|
|
81
128
|
updateSystemPrompt: (...args: unknown[]) =>
|
|
82
129
|
mockUpdateSystemPrompt(
|
|
83
130
|
...(args as Parameters<typeof mockUpdateSystemPrompt>),
|
|
84
131
|
),
|
|
132
|
+
refreshMcpServers: (...args: unknown[]) =>
|
|
133
|
+
mockRefreshMcpServers(
|
|
134
|
+
...(args as Parameters<typeof mockRefreshMcpServers>),
|
|
135
|
+
),
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// Backend without refreshMcpServers (e.g. non-Claude backend)
|
|
139
|
+
const mockBackendNoMcp = {
|
|
140
|
+
query: vi.fn(),
|
|
141
|
+
updateSystemPrompt: (...args: unknown[]) =>
|
|
142
|
+
mockUpdateSystemPrompt(
|
|
143
|
+
...(args as Parameters<typeof mockUpdateSystemPrompt>),
|
|
144
|
+
),
|
|
85
145
|
};
|
|
86
146
|
|
|
87
147
|
// ── Import after mocks ────────────────────────────────────────────────────
|
|
@@ -101,6 +161,7 @@ describe("reload_plugins gateway action", () => {
|
|
|
101
161
|
mockGetPluginPromptAdditions.mockReturnValue("prompt additions");
|
|
102
162
|
mockRebuildSystemPrompt.mockImplementation(() => {});
|
|
103
163
|
mockUpdateSystemPrompt.mockImplementation(() => {});
|
|
164
|
+
mockRefreshMcpServers.mockResolvedValue(null);
|
|
104
165
|
});
|
|
105
166
|
|
|
106
167
|
it("returns loaded plugin names on success", async () => {
|
|
@@ -175,6 +236,72 @@ describe("reload_plugins gateway action", () => {
|
|
|
175
236
|
expect(result!.text).toContain("(0)");
|
|
176
237
|
expect(result!.text).toContain("(none)");
|
|
177
238
|
});
|
|
239
|
+
|
|
240
|
+
it("calls refreshMcpServers after plugin reload", async () => {
|
|
241
|
+
await handleSharedAction({ action: "reload_plugins" }, 12345, mockBackend);
|
|
242
|
+
expect(mockRefreshMcpServers).toHaveBeenCalledTimes(1);
|
|
243
|
+
expect(mockRefreshMcpServers).toHaveBeenCalledWith("12345");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("uses body._chatId over numeric chatId when present", async () => {
|
|
247
|
+
await handleSharedAction(
|
|
248
|
+
{ action: "reload_plugins", _chatId: "teams_chat_abc123" },
|
|
249
|
+
12345,
|
|
250
|
+
mockBackend,
|
|
251
|
+
);
|
|
252
|
+
expect(mockRefreshMcpServers).toHaveBeenCalledWith("teams_chat_abc123");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("falls back to String(chatId) when body._chatId is absent", async () => {
|
|
256
|
+
await handleSharedAction({ action: "reload_plugins" }, 99999, mockBackend);
|
|
257
|
+
expect(mockRefreshMcpServers).toHaveBeenCalledWith("99999");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("includes MCP update info in response", async () => {
|
|
261
|
+
mockRefreshMcpServers.mockResolvedValue({
|
|
262
|
+
added: ["foo-tools"],
|
|
263
|
+
removed: ["old-tools"],
|
|
264
|
+
errors: {},
|
|
265
|
+
});
|
|
266
|
+
const result = await handleSharedAction(
|
|
267
|
+
{ action: "reload_plugins" },
|
|
268
|
+
12345,
|
|
269
|
+
mockBackend,
|
|
270
|
+
);
|
|
271
|
+
expect(result!.ok).toBe(true);
|
|
272
|
+
expect(result!.text).toContain("MCP servers updated");
|
|
273
|
+
expect(result!.text).toContain("added: foo-tools");
|
|
274
|
+
expect(result!.text).toContain("removed: old-tools");
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("handles refreshMcpServers errors gracefully", async () => {
|
|
278
|
+
mockRefreshMcpServers.mockRejectedValue(
|
|
279
|
+
new Error("setMcpServers timed out"),
|
|
280
|
+
);
|
|
281
|
+
const result = await handleSharedAction(
|
|
282
|
+
{ action: "reload_plugins" },
|
|
283
|
+
12345,
|
|
284
|
+
mockBackend,
|
|
285
|
+
);
|
|
286
|
+
// Reload still succeeds — MCP failure is a warning, not a hard error
|
|
287
|
+
expect(result!.ok).toBe(true);
|
|
288
|
+
expect(result!.text).toContain("Plugins reloaded successfully");
|
|
289
|
+
expect(result!.text).toContain("Warning: MCP server refresh failed");
|
|
290
|
+
expect(result!.text).toContain("setMcpServers timed out");
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("works when refreshMcpServers is not defined on backend", async () => {
|
|
294
|
+
const result = await handleSharedAction(
|
|
295
|
+
{ action: "reload_plugins" },
|
|
296
|
+
12345,
|
|
297
|
+
mockBackendNoMcp,
|
|
298
|
+
);
|
|
299
|
+
expect(result!.ok).toBe(true);
|
|
300
|
+
expect(result!.text).toContain("Plugins reloaded successfully");
|
|
301
|
+
// Should not mention MCP at all when the method is absent
|
|
302
|
+
expect(result!.text).not.toContain("MCP");
|
|
303
|
+
expect(result!.text).not.toContain("Warning");
|
|
304
|
+
});
|
|
178
305
|
});
|
|
179
306
|
|
|
180
307
|
// ── Admin tool description tests ──────────────────────────────────────────
|
|
@@ -203,3 +330,13 @@ describe("admin tool description", () => {
|
|
|
203
330
|
expect(reloadTool!.tag).toBe("admin");
|
|
204
331
|
});
|
|
205
332
|
});
|
|
333
|
+
|
|
334
|
+
// ── getActiveQuery tests ─────────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
describe("getActiveQuery", () => {
|
|
337
|
+
it("is exported and returns undefined when no query is active", async () => {
|
|
338
|
+
const { getActiveQuery } = await import("../backend/claude-sdk/handler.js");
|
|
339
|
+
expect(typeof getActiveQuery).toBe("function");
|
|
340
|
+
expect(getActiveQuery("nonexistent-chat-id")).toBeUndefined();
|
|
341
|
+
});
|
|
342
|
+
});
|
|
@@ -826,11 +826,6 @@ describe("sessions — edge cases for branch coverage", () => {
|
|
|
826
826
|
expect(session.usage.fastestResponseMs).toBe(500);
|
|
827
827
|
});
|
|
828
828
|
|
|
829
|
-
it("placeholder test removed", () => {
|
|
830
|
-
// placeholder - actual test moved to end of file to avoid module isolation issues
|
|
831
|
-
expect(true).toBe(true);
|
|
832
|
-
});
|
|
833
|
-
|
|
834
829
|
it("saveSessions logs error with non-Error object thrown by writeFileAtomic", async () => {
|
|
835
830
|
const { logError } = await import("../util/log.js");
|
|
836
831
|
// Throw a plain string instead of an Error to cover the `err instanceof Error ? ... : err` false branch
|
|
@@ -2,10 +2,12 @@ import { beforeEach, describe, expect, it } from "vitest";
|
|
|
2
2
|
import { clearModels, registerModels } from "../core/models.js";
|
|
3
3
|
import {
|
|
4
4
|
formatCompactModelLabel,
|
|
5
|
+
formatDuration,
|
|
5
6
|
formatModelLabel,
|
|
6
7
|
formatModelOptionLabel,
|
|
7
8
|
getTelegramModelOptions,
|
|
8
9
|
isSelectedModel,
|
|
10
|
+
renderMetricsMessages,
|
|
9
11
|
renderSettingsKeyboard,
|
|
10
12
|
} from "../frontend/telegram/helpers.js";
|
|
11
13
|
|
|
@@ -19,11 +21,6 @@ describe("telegram helpers", () => {
|
|
|
19
21
|
description: "Sonnet 4.6 · Best for everyday tasks",
|
|
20
22
|
aliases: ["sonnet", "claude-sonnet-4-6"],
|
|
21
23
|
provider: "anthropic",
|
|
22
|
-
capabilities: {
|
|
23
|
-
supports1mContext: true,
|
|
24
|
-
oneMillionContextModelId: "sonnet[1m]",
|
|
25
|
-
},
|
|
26
|
-
tier: "balanced",
|
|
27
24
|
fallback: "haiku",
|
|
28
25
|
},
|
|
29
26
|
{
|
|
@@ -33,8 +30,6 @@ describe("telegram helpers", () => {
|
|
|
33
30
|
"Sonnet 4.6 with 1M context · Billed as extra usage · $3/$15 per Mtok",
|
|
34
31
|
aliases: ["claude-sonnet-4-6[1m]"],
|
|
35
32
|
provider: "anthropic",
|
|
36
|
-
capabilities: { supports1mContext: true },
|
|
37
|
-
tier: "balanced",
|
|
38
33
|
fallback: "haiku",
|
|
39
34
|
},
|
|
40
35
|
{
|
|
@@ -43,11 +38,6 @@ describe("telegram helpers", () => {
|
|
|
43
38
|
description: "Opus 4.6 · Most capable for complex work",
|
|
44
39
|
aliases: ["claude-opus-4-6"],
|
|
45
40
|
provider: "anthropic",
|
|
46
|
-
capabilities: {
|
|
47
|
-
supports1mContext: true,
|
|
48
|
-
oneMillionContextModelId: "opus[1m]",
|
|
49
|
-
},
|
|
50
|
-
tier: "premium",
|
|
51
41
|
fallback: "default",
|
|
52
42
|
},
|
|
53
43
|
{
|
|
@@ -57,8 +47,6 @@ describe("telegram helpers", () => {
|
|
|
57
47
|
"Opus 4.6 with 1M context · Billed as extra usage · $5/$25 per Mtok",
|
|
58
48
|
aliases: ["claude-opus-4-6[1m]"],
|
|
59
49
|
provider: "anthropic",
|
|
60
|
-
capabilities: { supports1mContext: true },
|
|
61
|
-
tier: "premium",
|
|
62
50
|
fallback: "default",
|
|
63
51
|
},
|
|
64
52
|
{
|
|
@@ -67,8 +55,6 @@ describe("telegram helpers", () => {
|
|
|
67
55
|
description: "Haiku 4.5 · Fastest for quick answers",
|
|
68
56
|
aliases: ["claude-haiku-4-5"],
|
|
69
57
|
provider: "anthropic",
|
|
70
|
-
capabilities: { supports1mContext: false },
|
|
71
|
-
tier: "economy",
|
|
72
58
|
},
|
|
73
59
|
]);
|
|
74
60
|
});
|
|
@@ -84,17 +70,17 @@ describe("telegram helpers", () => {
|
|
|
84
70
|
expect(formatModelLabel("claude-sonnet-4-6")).toBe("Sonnet 4.6");
|
|
85
71
|
expect(formatModelLabel("sonnet[1m]")).toBe("Sonnet 4.6");
|
|
86
72
|
expect(formatModelOptionLabel(getTelegramModelOptions()[0]!)).toBe(
|
|
87
|
-
"
|
|
73
|
+
"Sonnet 4.6",
|
|
88
74
|
);
|
|
89
75
|
expect(formatCompactModelLabel(getTelegramModelOptions()[1]!)).toBe(
|
|
90
|
-
"
|
|
76
|
+
"Opus 4.6",
|
|
91
77
|
);
|
|
92
78
|
});
|
|
93
79
|
|
|
94
80
|
it("shows a single clean option per model family", () => {
|
|
95
81
|
expect(getTelegramModelOptions().map((model) => model.id)).toEqual([
|
|
96
|
-
"opus",
|
|
97
82
|
"default",
|
|
83
|
+
"opus",
|
|
98
84
|
"haiku",
|
|
99
85
|
]);
|
|
100
86
|
});
|
|
@@ -108,6 +94,63 @@ describe("telegram helpers", () => {
|
|
|
108
94
|
.flat()
|
|
109
95
|
.map((button) => button.text);
|
|
110
96
|
|
|
111
|
-
expect(buttons).toContain("\u2713 Sonnet");
|
|
97
|
+
expect(buttons).toContain("\u2713 Sonnet 4.6");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("formatDuration", () => {
|
|
102
|
+
it("preserves millisecond precision for subsecond values", () => {
|
|
103
|
+
expect(formatDuration(250)).toBe("250ms");
|
|
104
|
+
expect(formatDuration(999)).toBe("999ms");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("keeps second-and-up formatting intact", () => {
|
|
108
|
+
expect(formatDuration(1_500)).toBe("1s");
|
|
109
|
+
expect(formatDuration(65_000)).toBe("1m 5s");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("renderMetricsMessages", () => {
|
|
114
|
+
it("formats latency metrics with millisecond precision", () => {
|
|
115
|
+
const messages = renderMetricsMessages({
|
|
116
|
+
counters: { queries_total: 7 },
|
|
117
|
+
histograms: {
|
|
118
|
+
response_latency_ms: {
|
|
119
|
+
count: 3,
|
|
120
|
+
p50: 250,
|
|
121
|
+
p95: 1_250,
|
|
122
|
+
p99: 2_000,
|
|
123
|
+
avg: 900,
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
expect(messages).toHaveLength(1);
|
|
129
|
+
expect(messages[0]).toContain("p50=250ms");
|
|
130
|
+
expect(messages[0]).toContain("p95=1s");
|
|
131
|
+
expect(messages[0]).toContain("avg=900ms");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("splits large metrics output into Telegram-safe chunks", () => {
|
|
135
|
+
const counters = Object.fromEntries(
|
|
136
|
+
Array.from({ length: 12 }, (_, i) => [`tool_calls.tool_${i}`, i + 1]),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const messages = renderMetricsMessages({ counters, histograms: {} }, 160);
|
|
140
|
+
|
|
141
|
+
expect(messages.length).toBeGreaterThan(1);
|
|
142
|
+
for (const message of messages) {
|
|
143
|
+
expect(message.length).toBeLessThanOrEqual(160);
|
|
144
|
+
}
|
|
145
|
+
expect(messages[0]).toContain("<b>📊 Metrics</b>");
|
|
146
|
+
expect(
|
|
147
|
+
messages.slice(1).every((message) => message.includes("(cont.)")),
|
|
148
|
+
).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("shows an empty-state message when no metrics exist", () => {
|
|
152
|
+
expect(renderMetricsMessages({ counters: {}, histograms: {} })).toEqual([
|
|
153
|
+
"<b>📊 Metrics</b>\n\n<i>No metrics recorded yet.</i>",
|
|
154
|
+
]);
|
|
112
155
|
});
|
|
113
156
|
});
|
|
@@ -90,6 +90,48 @@ 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
|
+
|
|
93
135
|
import {
|
|
94
136
|
registerCommand,
|
|
95
137
|
tryRunCommand,
|
|
@@ -215,6 +257,9 @@ describe("built-in commands", () => {
|
|
|
215
257
|
clearCommands();
|
|
216
258
|
registerBuiltinCommands();
|
|
217
259
|
vi.clearAllMocks();
|
|
260
|
+
mockGetOpenCodeModelSelectionValue.mockImplementation(
|
|
261
|
+
(model: Record<string, unknown>) => String(model.id ?? ""),
|
|
262
|
+
);
|
|
218
263
|
});
|
|
219
264
|
|
|
220
265
|
it("registers all expected commands", () => {
|
|
@@ -261,12 +306,17 @@ describe("built-in commands", () => {
|
|
|
261
306
|
|
|
262
307
|
describe("/model", () => {
|
|
263
308
|
it("shows current model when no arg given", async () => {
|
|
264
|
-
|
|
309
|
+
// getChatSettings is called twice: once at handler entry, once in the
|
|
310
|
+
// non-opencode branch — use mockReturnValue so both calls see the model.
|
|
311
|
+
mockGetChatSettings.mockReturnValue({ model: "claude-opus-4-6" });
|
|
265
312
|
const ctx = makeMockContext();
|
|
266
313
|
await tryRunCommand("/model", ctx);
|
|
267
314
|
expect(ctx.renderer.writeSystem).toHaveBeenCalledWith(
|
|
268
315
|
expect.stringContaining("claude-opus-4-6"),
|
|
269
316
|
);
|
|
317
|
+
mockGetChatSettings.mockImplementation(
|
|
318
|
+
(_chatId: string): Record<string, unknown> => ({}),
|
|
319
|
+
);
|
|
270
320
|
});
|
|
271
321
|
|
|
272
322
|
it("sets model when arg given", async () => {
|
|
@@ -277,6 +327,48 @@ describe("built-in commands", () => {
|
|
|
277
327
|
"claude-opus",
|
|
278
328
|
);
|
|
279
329
|
});
|
|
330
|
+
|
|
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
|
+
|
|
358
|
+
const ctx = makeMockContext({
|
|
359
|
+
config: { model: "nemotron-3-super-free", backend: "opencode" } as any,
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
await tryRunCommand("/model github-copilot/gpt-5", ctx);
|
|
363
|
+
|
|
364
|
+
expect(mockSetChatModel).toHaveBeenCalledWith(
|
|
365
|
+
"t_test_123",
|
|
366
|
+
"github-copilot/gpt-5",
|
|
367
|
+
);
|
|
368
|
+
expect(ctx.renderer.writeSystem).toHaveBeenCalledWith(
|
|
369
|
+
expect.stringContaining("github-copilot/gpt-5"),
|
|
370
|
+
);
|
|
371
|
+
});
|
|
280
372
|
});
|
|
281
373
|
|
|
282
374
|
describe("/rename", () => {
|
|
@@ -500,6 +592,88 @@ describe("/status command", () => {
|
|
|
500
592
|
).mock.calls.flat();
|
|
501
593
|
expect(calls.join(" ")).toContain("actions only");
|
|
502
594
|
});
|
|
595
|
+
|
|
596
|
+
it("/status uses live OpenCode usage totals when backend is opencode", async () => {
|
|
597
|
+
mockGetSessionInfo.mockReturnValueOnce({
|
|
598
|
+
turns: 14,
|
|
599
|
+
sessionId: "ses_live",
|
|
600
|
+
sessionName: undefined,
|
|
601
|
+
lastModel: "big-pickle",
|
|
602
|
+
usage: {
|
|
603
|
+
totalInputTokens: 100,
|
|
604
|
+
totalOutputTokens: 50,
|
|
605
|
+
totalCacheRead: 0,
|
|
606
|
+
totalCacheWrite: 0,
|
|
607
|
+
lastPromptTokens: 10,
|
|
608
|
+
estimatedCostUsd: 0,
|
|
609
|
+
totalResponseMs: 60_000,
|
|
610
|
+
lastResponseMs: 6_000,
|
|
611
|
+
fastestResponseMs: 5_000,
|
|
612
|
+
},
|
|
613
|
+
});
|
|
614
|
+
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
|
+
|
|
662
|
+
const ctx = makeMockContext({
|
|
663
|
+
config: {
|
|
664
|
+
model: "big-pickle",
|
|
665
|
+
backend: "opencode",
|
|
666
|
+
} as CommandContext["config"],
|
|
667
|
+
});
|
|
668
|
+
await tryRunCommand("/status", ctx);
|
|
669
|
+
|
|
670
|
+
const output = (ctx.renderer.writeln as ReturnType<typeof vi.fn>).mock.calls
|
|
671
|
+
.flat()
|
|
672
|
+
.join(" ");
|
|
673
|
+
expect(output).toContain("1,389,045");
|
|
674
|
+
expect(output).toContain("3,675");
|
|
675
|
+
expect(output).toContain("204,800");
|
|
676
|
+
});
|
|
503
677
|
});
|
|
504
678
|
|
|
505
679
|
describe("/effort command", () => {
|