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
package/package.json
CHANGED
|
@@ -58,24 +58,19 @@ describe("registerClaudeModels", () => {
|
|
|
58
58
|
clearModels();
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
-
it("keeps SDK IDs/display names and
|
|
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 {
|
|
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("
|
|
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
|
-
|
|
86
|
+
// Model is passed through as resolved — SDK handles context window
|
|
87
|
+
expect(options.model).toBe("default");
|
|
96
88
|
});
|
|
97
89
|
|
|
98
|
-
it("
|
|
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
|
|
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
|
+
});
|