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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
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",
@@ -58,24 +58,19 @@ describe("registerClaudeModels", () => {
58
58
  clearModels();
59
59
  });
60
60
 
61
- it("keeps SDK IDs/display names and maps 1M upgrades explicitly", async () => {
61
+ it("keeps SDK IDs/display names and collapses duplicates", async () => {
62
62
  const { registerClaudeModels } =
63
63
  await import("../backend/claude-sdk/models.js");
64
- const {
65
- get1mContextModelId,
66
- getModels,
67
- resolveModelId,
68
- supports1mContext,
69
- } = await import("../core/models.js");
64
+ const { getModels, resolveModelId } = await import("../core/models.js");
70
65
 
71
66
  await registerClaudeModels({ model: "default" });
72
67
 
73
68
  const anthropicModels = getModels("anthropic");
74
69
  expect(anthropicModels.map((model) => model.id)).toEqual([
75
- "opus",
76
- "opus[1m]",
77
70
  "default",
78
71
  "sonnet[1m]",
72
+ "opus",
73
+ "opus[1m]",
79
74
  "haiku",
80
75
  ]);
81
76
 
@@ -85,6 +80,7 @@ describe("registerClaudeModels", () => {
85
80
  expect(
86
81
  anthropicModels.find((model) => model.id === "sonnet[1m]")?.displayName,
87
82
  ).toBe("Sonnet (1M context)");
83
+ // claude-sonnet-4-6 collapsed into "default" as alias
88
84
  expect(
89
85
  anthropicModels.some((model) => model.id === "claude-sonnet-4-6"),
90
86
  ).toBe(false);
@@ -92,14 +88,6 @@ describe("registerClaudeModels", () => {
92
88
  expect(resolveModelId("claude-sonnet-4-6")).toBe("default");
93
89
  expect(resolveModelId("claude-sonnet-4-6[1m]")).toBe("sonnet[1m]");
94
90
  expect(resolveModelId("claude-opus-4-6")).toBe("opus");
95
-
96
- expect(get1mContextModelId("default")).toBe("sonnet[1m]");
97
- expect(get1mContextModelId("claude-sonnet-4-6")).toBe("sonnet[1m]");
98
- expect(get1mContextModelId("opus")).toBe("opus[1m]");
99
- expect(get1mContextModelId("haiku")).toBeNull();
100
-
101
- expect(supports1mContext("claude-sonnet-4-6")).toBe(true);
102
- expect(supports1mContext("haiku")).toBe(false);
103
91
  });
104
92
 
105
93
  it("derives compatibility aliases from SDK metadata instead of hardcoded versions", async () => {
@@ -140,8 +128,7 @@ describe("registerClaudeModels", () => {
140
128
 
141
129
  const { registerClaudeModels } =
142
130
  await import("../backend/claude-sdk/models.js");
143
- const { get1mContextModelId, resolveModelId } =
144
- await import("../core/models.js");
131
+ const { resolveModelId } = await import("../core/models.js");
145
132
 
146
133
  await registerClaudeModels({ model: "default" });
147
134
 
@@ -151,7 +138,5 @@ describe("registerClaudeModels", () => {
151
138
  expect(resolveModelId("claude-opus-4-6")).toBe("opus");
152
139
  expect(resolveModelId("claude-haiku-5-0")).toBe("haiku");
153
140
  expect(resolveModelId("claude-haiku-4-5")).toBe("haiku");
154
- expect(get1mContextModelId("claude-sonnet-4-6")).toBe("sonnet[1m]");
155
- expect(get1mContextModelId("claude-sonnet-5-0")).toBe("sonnet[1m]");
156
141
  });
157
142
  });
@@ -55,11 +55,6 @@ describe("buildSdkOptions", () => {
55
55
  description: "Sonnet 4.6 · Best for everyday tasks",
56
56
  aliases: ["claude-sonnet-4-6"],
57
57
  provider: "anthropic",
58
- capabilities: {
59
- supports1mContext: true,
60
- oneMillionContextModelId: "sonnet[1m]",
61
- },
62
- tier: "balanced",
63
58
  fallback: "haiku",
64
59
  },
65
60
  {
@@ -69,8 +64,6 @@ describe("buildSdkOptions", () => {
69
64
  "Sonnet 4.6 with 1M context · Billed as extra usage · $3/$15 per Mtok",
70
65
  aliases: ["claude-sonnet-4-6[1m]"],
71
66
  provider: "anthropic",
72
- capabilities: { supports1mContext: true },
73
- tier: "balanced",
74
67
  fallback: "haiku",
75
68
  },
76
69
  {
@@ -79,23 +72,22 @@ describe("buildSdkOptions", () => {
79
72
  description: "Haiku 4.5 · Fastest for quick answers",
80
73
  aliases: ["claude-haiku-4-5"],
81
74
  provider: "anthropic",
82
- capabilities: { supports1mContext: false },
83
- tier: "economy",
84
75
  },
85
76
  ]);
86
77
  });
87
78
 
88
- it("uses the exact mapped 1M SDK model for legacy Sonnet IDs", async () => {
79
+ it("resolves legacy aliases to canonical model ID and passes through", async () => {
89
80
  const { buildSdkOptions } =
90
81
  await import("../backend/claude-sdk/options.js");
91
82
 
92
83
  const { activeModel, options } = buildSdkOptions("chat-1");
93
84
 
94
85
  expect(activeModel).toBe("claude-sonnet-4-6");
95
- expect(options.model).toBe("sonnet[1m]");
86
+ // Model is passed through as resolved — SDK handles context window
87
+ expect(options.model).toBe("default");
96
88
  });
97
89
 
98
- it("leaves models without a mapped 1M variant unchanged", async () => {
90
+ it("passes model through unchanged when no alias resolution needed", async () => {
99
91
  mockGetChatSettings.mockReturnValue({ model: "haiku" });
100
92
 
101
93
  const { buildSdkOptions } =
@@ -105,7 +97,7 @@ describe("buildSdkOptions", () => {
105
97
  expect(options.model).toBe("haiku");
106
98
  });
107
99
 
108
- it("resolves legacy 1M aliases to canonical SDK model IDs", async () => {
100
+ it("resolves 1M aliases to their canonical SDK model ID", async () => {
109
101
  mockGetChatSettings.mockReturnValue({ model: "claude-sonnet-4-6[1m]" });
110
102
 
111
103
  const { buildSdkOptions } =
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Minimal MCP server fixture for lifecycle integration tests.
3
+ *
4
+ * Uses the same McpServer + StdioServerTransport + stdin-close pattern
5
+ * as the real src/core/tools/mcp-server.ts, but without Talon tool
6
+ * composition so it starts fast and has no external dependencies.
7
+ *
8
+ * Signals readiness by writing "READY\n" to stderr.
9
+ */
10
+
11
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
12
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
13
+
14
+ const server = new McpServer({ name: "test-mcp", version: "1.0.0" });
15
+
16
+ // Register a trivial tool so the server has something to serve
17
+ server.tool("ping", "health check", {}, async () => ({
18
+ content: [{ type: "text", text: "pong" }],
19
+ }));
20
+
21
+ async function main() {
22
+ const transport = new StdioServerTransport();
23
+ await server.connect(transport);
24
+
25
+ // Same graceful self-termination as the real mcp-server.ts
26
+ process.stdin.on("end", () => {
27
+ server.close().finally(() => process.exit(0));
28
+ });
29
+
30
+ // Signal readiness to the test harness
31
+ process.stderr.write("READY\n");
32
+ }
33
+
34
+ main().catch((err) => {
35
+ process.stderr.write(`test-mcp-server error: ${err}\n`);
36
+ process.exit(1);
37
+ });
@@ -1816,6 +1816,49 @@ describe("createStreamCallbacks — onTextBlock delivers message via sendHtml",
1816
1816
 
1817
1817
  expect(sendMsgCount()).toBeGreaterThan(before);
1818
1818
  }, 3000);
1819
+
1820
+ it("does not send the same OpenCode response twice when onTextBlock already delivered it", async () => {
1821
+ const sendMsgCount = () =>
1822
+ (mockBot.api.sendMessage as ReturnType<typeof vi.fn>).mock.calls.length;
1823
+
1824
+ executeMock.mockImplementationOnce(
1825
+ async (params: Record<string, unknown>) => {
1826
+ const onTextBlock = params.onTextBlock as (
1827
+ text: string,
1828
+ ) => Promise<void>;
1829
+ await onTextBlock?.("Hi! 👋");
1830
+ return {
1831
+ text: "Hi! 👋",
1832
+ durationMs: 5,
1833
+ inputTokens: 1,
1834
+ outputTokens: 2,
1835
+ cacheRead: 0,
1836
+ cacheWrite: 0,
1837
+ bridgeMessageCount: 0,
1838
+ };
1839
+ },
1840
+ );
1841
+
1842
+ const before = sendMsgCount();
1843
+ const ctx = {
1844
+ chat: { id: 96003, type: "private" },
1845
+ message: {
1846
+ text: "test duplicate suppression",
1847
+ message_id: 952,
1848
+ reply_to_message: null,
1849
+ },
1850
+ me: { id: 999, username: "testbot" },
1851
+ from: { id: 94, first_name: "Mika" },
1852
+ } as any;
1853
+
1854
+ await handleTextMessage(ctx, mockBot, {
1855
+ ...mockConfig,
1856
+ backend: "opencode",
1857
+ });
1858
+ await new Promise((r) => setTimeout(r, 700));
1859
+
1860
+ expect(sendMsgCount() - before).toBe(1);
1861
+ }, 3000);
1819
1862
  });
1820
1863
 
1821
1864
  describe("sendHtml — falls back to plain text on HTML send failure", () => {
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Integration tests for MCP server subprocess lifecycle.
3
+ *
4
+ * Spawns real MCP server processes (using the same SDK transport as
5
+ * production) and verifies that closing stdin causes graceful exit.
6
+ * This is the OS-agnostic teardown mechanism used during hot-reload.
7
+ */
8
+
9
+ import { describe, it, expect, afterEach } from "vitest";
10
+ import { spawn, type ChildProcess } from "node:child_process";
11
+ import { resolve } from "node:path";
12
+ import { pathToFileURL } from "node:url";
13
+
14
+ const FIXTURE = resolve(__dirname, "fixtures/test-mcp-server.ts");
15
+ const TSX_LOADER = pathToFileURL(
16
+ resolve(__dirname, "../../node_modules/tsx/dist/esm/index.mjs"),
17
+ ).href;
18
+ const STARTUP_TIMEOUT = 15_000;
19
+ const EXIT_TIMEOUT = 10_000;
20
+
21
+ // Track spawned processes for cleanup
22
+ const spawned: ChildProcess[] = [];
23
+
24
+ afterEach(() => {
25
+ for (const child of spawned) {
26
+ if (child.exitCode === null && child.signalCode === null) {
27
+ child.kill("SIGKILL");
28
+ }
29
+ }
30
+ spawned.length = 0;
31
+ });
32
+
33
+ function spawnMcpServer(env?: Record<string, string>): ChildProcess {
34
+ const child = spawn(process.execPath, ["--import", TSX_LOADER, FIXTURE], {
35
+ env: { ...process.env, ...env },
36
+ stdio: ["pipe", "pipe", "pipe"],
37
+ });
38
+ spawned.push(child);
39
+ return child;
40
+ }
41
+
42
+ /** Wait for the server to write "READY\n" on stderr. */
43
+ function waitForReady(child: ChildProcess): Promise<void> {
44
+ return new Promise((resolve, reject) => {
45
+ const timer = setTimeout(
46
+ () => reject(new Error("MCP server did not become ready in time")),
47
+ STARTUP_TIMEOUT,
48
+ );
49
+ let buf = "";
50
+ child.stderr!.on("data", (chunk: Buffer) => {
51
+ buf += chunk.toString();
52
+ if (buf.includes("READY")) {
53
+ clearTimeout(timer);
54
+ resolve();
55
+ }
56
+ });
57
+ child.on("error", (err) => {
58
+ clearTimeout(timer);
59
+ reject(err);
60
+ });
61
+ child.on("exit", (code) => {
62
+ clearTimeout(timer);
63
+ if (!buf.includes("READY")) {
64
+ reject(new Error(`MCP server exited early (code=${code}): ${buf}`));
65
+ }
66
+ });
67
+ });
68
+ }
69
+
70
+ /** Wait for the process to exit, with a timeout. Returns exit code or null on timeout. */
71
+ function waitForExit(
72
+ child: ChildProcess,
73
+ timeoutMs = EXIT_TIMEOUT,
74
+ ): Promise<number | null> {
75
+ return new Promise((resolve) => {
76
+ if (child.exitCode !== null) {
77
+ resolve(child.exitCode);
78
+ return;
79
+ }
80
+ const timer = setTimeout(() => resolve(null), timeoutMs);
81
+ child.on("exit", (code) => {
82
+ clearTimeout(timer);
83
+ resolve(code);
84
+ });
85
+ });
86
+ }
87
+
88
+ function isRunning(child: ChildProcess): boolean {
89
+ return child.exitCode === null && child.signalCode === null;
90
+ }
91
+
92
+ describe("MCP server subprocess lifecycle", () => {
93
+ it(
94
+ "exits gracefully when stdin is closed",
95
+ async () => {
96
+ const server = spawnMcpServer();
97
+ await waitForReady(server);
98
+
99
+ expect(isRunning(server)).toBe(true);
100
+
101
+ // Close stdin — this is what the SDK does when setMcpServers({}) is called
102
+ server.stdin!.end();
103
+
104
+ const exitCode = await waitForExit(server);
105
+ expect(exitCode).toBe(0);
106
+ },
107
+ STARTUP_TIMEOUT + EXIT_TIMEOUT,
108
+ );
109
+
110
+ it(
111
+ "old server exits while new server keeps running (reload simulation)",
112
+ async () => {
113
+ // Spawn "old" MCP server (as if loaded with first plugin config)
114
+ const oldServer = spawnMcpServer({
115
+ TALON_RELOAD_AT: "2024-01-01T00:00:00.000Z",
116
+ });
117
+ await waitForReady(oldServer);
118
+ expect(isRunning(oldServer)).toBe(true);
119
+
120
+ // Spawn "new" MCP server (as if reloaded with fresh plugin config)
121
+ const newServer = spawnMcpServer({
122
+ TALON_RELOAD_AT: "2024-01-01T00:01:00.000Z",
123
+ });
124
+ await waitForReady(newServer);
125
+ expect(isRunning(newServer)).toBe(true);
126
+
127
+ // Simulate two-phase teardown: close old server's stdin
128
+ oldServer.stdin!.end();
129
+
130
+ const oldExitCode = await waitForExit(oldServer);
131
+ expect(oldExitCode).toBe(0);
132
+
133
+ // New server must still be running
134
+ expect(isRunning(newServer)).toBe(true);
135
+
136
+ // Cleanup new server
137
+ newServer.stdin!.end();
138
+ await waitForExit(newServer);
139
+ },
140
+ 2 * STARTUP_TIMEOUT + EXIT_TIMEOUT,
141
+ );
142
+
143
+ it(
144
+ "multiple old servers all exit when their stdin is closed",
145
+ async () => {
146
+ // Spawn 3 servers (simulating accumulated orphans from repeated reloads)
147
+ const servers = [];
148
+ for (let i = 0; i < 3; i++) {
149
+ const s = spawnMcpServer({ TALON_RELOAD_AT: `reload-${i}` });
150
+ await waitForReady(s);
151
+ servers.push(s);
152
+ }
153
+
154
+ for (const s of servers) expect(isRunning(s)).toBe(true);
155
+
156
+ // Close stdin on all three simultaneously (simulating batch teardown)
157
+ for (const s of servers) s.stdin!.end();
158
+
159
+ // All three should exit cleanly
160
+ const exits = await Promise.all(servers.map((s) => waitForExit(s)));
161
+ expect(exits).toEqual([0, 0, 0]);
162
+ },
163
+ 3 * STARTUP_TIMEOUT + EXIT_TIMEOUT,
164
+ );
165
+ });
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import {
3
+ incrementCounter,
4
+ recordHistogram,
5
+ getMetrics,
6
+ resetMetrics,
7
+ } from "../util/metrics.js";
8
+
9
+ describe("metrics", () => {
10
+ beforeEach(() => resetMetrics());
11
+
12
+ it("increments counters", () => {
13
+ incrementCounter("test.count");
14
+ incrementCounter("test.count");
15
+ incrementCounter("test.count", 3);
16
+ expect(getMetrics().counters["test.count"]).toBe(5);
17
+ });
18
+
19
+ it("records histograms with percentiles", () => {
20
+ for (let i = 1; i <= 100; i++) recordHistogram("latency", i);
21
+ const h = getMetrics().histograms["latency"];
22
+ expect(h.count).toBe(100);
23
+ expect(h.p50).toBe(51);
24
+ expect(h.p95).toBe(96);
25
+ expect(h.p99).toBe(100);
26
+ expect(h.avg).toBe(51);
27
+ });
28
+
29
+ it("caps histogram at MAX_HISTOGRAM_SIZE", () => {
30
+ for (let i = 0; i < 1500; i++) recordHistogram("big", i);
31
+ expect(getMetrics().histograms["big"].count).toBe(1000);
32
+ });
33
+
34
+ it("resets all metrics", () => {
35
+ incrementCounter("x");
36
+ recordHistogram("y", 1);
37
+ resetMetrics();
38
+ const m = getMetrics();
39
+ expect(Object.keys(m.counters)).toHaveLength(0);
40
+ expect(Object.keys(m.histograms)).toHaveLength(0);
41
+ });
42
+
43
+ it("handles empty histograms", () => {
44
+ expect(getMetrics().histograms).toEqual({});
45
+ });
46
+
47
+ it("drops NaN, Infinity, and -Infinity from histograms", () => {
48
+ recordHistogram("clean", 10);
49
+ recordHistogram("clean", NaN);
50
+ recordHistogram("clean", Infinity);
51
+ recordHistogram("clean", -Infinity);
52
+ recordHistogram("clean", 20);
53
+ const h = getMetrics().histograms["clean"];
54
+ expect(h.count).toBe(2);
55
+ expect(h.avg).toBe(15);
56
+ });
57
+
58
+ it("caps counter keys at MAX_METRIC_KEYS", () => {
59
+ // Fill up to the cap (500)
60
+ for (let i = 0; i < 500; i++) incrementCounter(`key_${i}`);
61
+ expect(Object.keys(getMetrics().counters)).toHaveLength(500);
62
+ // New key beyond cap is silently dropped
63
+ incrementCounter("overflow_key");
64
+ expect(getMetrics().counters["overflow_key"]).toBeUndefined();
65
+ // Existing keys still work
66
+ incrementCounter("key_0", 5);
67
+ expect(getMetrics().counters["key_0"]).toBe(6);
68
+ });
69
+
70
+ it("caps histogram keys at MAX_METRIC_KEYS", () => {
71
+ for (let i = 0; i < 500; i++) recordHistogram(`h_${i}`, i);
72
+ expect(Object.keys(getMetrics().histograms)).toHaveLength(500);
73
+ recordHistogram("overflow_hist", 42);
74
+ expect(getMetrics().histograms["overflow_hist"]).toBeUndefined();
75
+ });
76
+ });
@@ -0,0 +1,117 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ vi.mock("../util/log.js", () => ({
4
+ log: vi.fn(),
5
+ logError: vi.fn(),
6
+ logWarn: vi.fn(),
7
+ }));
8
+
9
+ const { getOpenCodeModelSelectionValue, resolveOpenCodeModelInput } =
10
+ await import("../backend/opencode/index.js");
11
+
12
+ const catalog = {
13
+ generatedAt: Date.now(),
14
+ providers: [],
15
+ connectedProviders: [],
16
+ loginProviders: [],
17
+ connectedModels: [],
18
+ connectedFreeModels: [],
19
+ models: [
20
+ {
21
+ id: "gpt-5",
22
+ name: "GPT-5",
23
+ providerID: "openai",
24
+ providerName: "OpenAI",
25
+ providerSource: "api",
26
+ connected: false,
27
+ selectable: false,
28
+ loginRequired: true,
29
+ envRequired: false,
30
+ authMethods: ["OAuth"],
31
+ free: false,
32
+ status: "active",
33
+ contextWindow: 400000,
34
+ outputWindow: 128000,
35
+ reasoning: true,
36
+ attachment: true,
37
+ toolcall: true,
38
+ costInput: 1,
39
+ costOutput: 2,
40
+ costCacheRead: 0,
41
+ costCacheWrite: 0,
42
+ },
43
+ {
44
+ id: "gpt-5",
45
+ name: "GPT-5",
46
+ providerID: "github-copilot",
47
+ providerName: "GitHub Copilot",
48
+ providerSource: "api",
49
+ connected: true,
50
+ selectable: true,
51
+ loginRequired: false,
52
+ envRequired: false,
53
+ authMethods: [],
54
+ free: false,
55
+ status: "active",
56
+ contextWindow: 400000,
57
+ outputWindow: 128000,
58
+ reasoning: true,
59
+ attachment: true,
60
+ toolcall: true,
61
+ costInput: 1,
62
+ costOutput: 2,
63
+ costCacheRead: 0,
64
+ costCacheWrite: 0,
65
+ },
66
+ {
67
+ id: "big-pickle",
68
+ name: "Big Pickle",
69
+ providerID: "opencode",
70
+ providerName: "OpenCode Zen",
71
+ providerSource: "api",
72
+ connected: true,
73
+ selectable: true,
74
+ loginRequired: false,
75
+ envRequired: false,
76
+ authMethods: [],
77
+ free: true,
78
+ status: "active",
79
+ contextWindow: 200000,
80
+ outputWindow: 128000,
81
+ reasoning: true,
82
+ attachment: false,
83
+ toolcall: true,
84
+ costInput: 0,
85
+ costOutput: 0,
86
+ costCacheRead: 0,
87
+ costCacheWrite: 0,
88
+ },
89
+ ],
90
+ };
91
+
92
+ describe("OpenCode model resolution", () => {
93
+ it("resolves provider-qualified model queries exactly", () => {
94
+ const resolution = resolveOpenCodeModelInput(
95
+ "github-copilot/gpt-5",
96
+ catalog,
97
+ );
98
+
99
+ expect(resolution.kind).toBe("exact");
100
+ if (resolution.kind !== "exact") return;
101
+ expect(resolution.model.providerID).toBe("github-copilot");
102
+ });
103
+
104
+ it("uses provider-qualified storage values only for colliding model ids", () => {
105
+ const duplicateValue = getOpenCodeModelSelectionValue(
106
+ catalog.models[0],
107
+ catalog,
108
+ );
109
+ const uniqueValue = getOpenCodeModelSelectionValue(
110
+ catalog.models[2],
111
+ catalog,
112
+ );
113
+
114
+ expect(duplicateValue).toBe("openai/gpt-5");
115
+ expect(uniqueValue).toBe("big-pickle");
116
+ });
117
+ });
@@ -0,0 +1,105 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ vi.mock("../util/log.js", () => ({
4
+ log: vi.fn(),
5
+ logError: vi.fn(),
6
+ logWarn: vi.fn(),
7
+ }));
8
+
9
+ const { summarizeOpenCodeAssistantMessages } =
10
+ await import("../backend/opencode/index.js");
11
+
12
+ describe("OpenCode assistant summaries", () => {
13
+ it("aggregates usage across the full assistant chain for a turn", () => {
14
+ const summary = summarizeOpenCodeAssistantMessages(
15
+ [
16
+ {
17
+ info: {
18
+ role: "assistant",
19
+ modelID: "big-pickle",
20
+ providerID: "opencode",
21
+ time: { created: 110, completed: 120 },
22
+ tokens: {
23
+ input: 100,
24
+ output: 20,
25
+ reasoning: 5,
26
+ cache: { read: 1, write: 2 },
27
+ },
28
+ cost: 0.1,
29
+ },
30
+ parts: [{ type: "tool" }],
31
+ },
32
+ {
33
+ info: {
34
+ role: "assistant",
35
+ modelID: "big-pickle",
36
+ providerID: "opencode",
37
+ time: { created: 121, completed: 140 },
38
+ tokens: {
39
+ input: 150,
40
+ output: 30,
41
+ reasoning: 7,
42
+ cache: { read: 3, write: 4 },
43
+ },
44
+ cost: 0.2,
45
+ },
46
+ parts: [{ type: "text", text: "done" }],
47
+ },
48
+ ],
49
+ 100,
50
+ );
51
+
52
+ expect(summary.usage.assistantMessages).toBe(2);
53
+ expect(summary.usage.inputTokens).toBe(250);
54
+ expect(summary.usage.outputTokens).toBe(50);
55
+ expect(summary.usage.reasoningTokens).toBe(12);
56
+ expect(summary.usage.cacheRead).toBe(4);
57
+ expect(summary.usage.cacheWrite).toBe(6);
58
+ expect(summary.usage.costUsd).toBeCloseTo(0.3);
59
+ expect(summary.latestAssistant?.createdAt).toBe(121);
60
+ });
61
+
62
+ it("ignores incomplete placeholder assistant messages when selecting the latest snapshot", () => {
63
+ const summary = summarizeOpenCodeAssistantMessages([
64
+ {
65
+ info: {
66
+ role: "assistant",
67
+ modelID: "nemotron-3-super-free",
68
+ providerID: "opencode",
69
+ time: { created: 200, completed: 230 },
70
+ finish: "stop",
71
+ tokens: {
72
+ input: 400,
73
+ output: 40,
74
+ reasoning: 10,
75
+ cache: { read: 0, write: 0 },
76
+ },
77
+ cost: 0,
78
+ },
79
+ parts: [{ type: "text", text: "final" }],
80
+ },
81
+ {
82
+ info: {
83
+ role: "assistant",
84
+ modelID: "nemotron-3-super-free",
85
+ providerID: "opencode",
86
+ time: { created: 240, completed: undefined },
87
+ finish: undefined,
88
+ tokens: {
89
+ input: 0,
90
+ output: 0,
91
+ reasoning: 0,
92
+ cache: { read: 0, write: 0 },
93
+ },
94
+ cost: 0,
95
+ },
96
+ parts: [],
97
+ },
98
+ ]);
99
+
100
+ expect(summary.latestAssistant?.createdAt).toBe(200);
101
+ expect(summary.usage.assistantMessages).toBe(1);
102
+ expect(summary.usage.inputTokens).toBe(400);
103
+ expect(summary.usage.outputTokens).toBe(40);
104
+ });
105
+ });