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.
Files changed (49) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/claude-sdk-models.test.ts +6 -21
  3. package/src/__tests__/claude-sdk-options.test.ts +5 -13
  4. package/src/__tests__/fixtures/test-mcp-server.ts +37 -0
  5. package/src/__tests__/handlers.test.ts +43 -0
  6. package/src/__tests__/mcp-lifecycle.test.ts +165 -0
  7. package/src/__tests__/metrics.test.ts +76 -0
  8. package/src/__tests__/opencode-models.test.ts +117 -0
  9. package/src/__tests__/opencode-summary.test.ts +105 -0
  10. package/src/__tests__/opencode-ui.test.ts +94 -0
  11. package/src/__tests__/plugin.test.ts +9 -8
  12. package/src/__tests__/reload-plugins.test.ts +137 -0
  13. package/src/__tests__/sessions.test.ts +0 -5
  14. package/src/__tests__/telegram-helpers.test.ts +63 -20
  15. package/src/__tests__/terminal-commands.test.ts +175 -1
  16. package/src/backend/claude-sdk/handler.ts +24 -2
  17. package/src/backend/claude-sdk/index.ts +2 -1
  18. package/src/backend/claude-sdk/model-provider.ts +209 -0
  19. package/src/backend/claude-sdk/models.ts +31 -96
  20. package/src/backend/claude-sdk/options.ts +3 -8
  21. package/src/backend/opencode/handler.ts +198 -0
  22. package/src/backend/opencode/index.ts +39 -232
  23. package/src/backend/opencode/model-provider.ts +167 -0
  24. package/src/backend/opencode/models.ts +742 -0
  25. package/src/backend/opencode/server.ts +382 -0
  26. package/src/backend/opencode/sessions.ts +492 -0
  27. package/src/bootstrap.ts +56 -2
  28. package/src/core/cron.ts +23 -2
  29. package/src/core/dream.ts +5 -4
  30. package/src/core/gateway-actions.ts +38 -2
  31. package/src/core/heartbeat.ts +5 -3
  32. package/src/core/models.ts +29 -45
  33. package/src/core/plugin.ts +15 -0
  34. package/src/core/tools/mcp-server.ts +23 -0
  35. package/src/core/types.ts +72 -0
  36. package/src/frontend/teams/index.ts +2 -0
  37. package/src/frontend/telegram/actions.ts +13 -4
  38. package/src/frontend/telegram/callbacks.ts +68 -34
  39. package/src/frontend/telegram/commands.ts +150 -52
  40. package/src/frontend/telegram/handlers.ts +78 -21
  41. package/src/frontend/telegram/helpers.ts +135 -13
  42. package/src/frontend/telegram/index.ts +4 -1
  43. package/src/frontend/terminal/commands.ts +248 -5
  44. package/src/frontend/terminal/index.ts +2 -0
  45. package/src/storage/media-index.ts +3 -3
  46. package/src/storage/sessions.ts +5 -0
  47. package/src/util/cleanup-registry.ts +4 -2
  48. package/src/util/metrics.ts +80 -0
  49. 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"]).toEqual({
314
- command: "node",
315
- args: ["/tmp/server.js"],
316
- env: {
317
- API_KEY: "secret",
318
- TALON_BRIDGE_URL: "http://localhost:19876",
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
- "Opus 4.6",
73
+ "Sonnet 4.6",
88
74
  );
89
75
  expect(formatCompactModelLabel(getTelegramModelOptions()[1]!)).toBe(
90
- "Sonnet",
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
- mockGetChatSettings.mockReturnValueOnce({ model: "claude-opus-4-6" });
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", () => {