talon-agent 1.7.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/chat-settings.test.ts +5 -1
  3. package/src/__tests__/claude-sdk-models.test.ts +20 -31
  4. package/src/__tests__/claude-sdk-options.test.ts +5 -13
  5. package/src/__tests__/fixtures/test-mcp-server.ts +37 -0
  6. package/src/__tests__/fuzz.test.ts +3 -3
  7. package/src/__tests__/handlers.test.ts +98 -0
  8. package/src/__tests__/mcp-lifecycle.test.ts +165 -0
  9. package/src/__tests__/metrics.test.ts +76 -0
  10. package/src/__tests__/opencode-models.test.ts +117 -0
  11. package/src/__tests__/opencode-summary.test.ts +105 -0
  12. package/src/__tests__/opencode-ui.test.ts +94 -0
  13. package/src/__tests__/plugin.test.ts +9 -8
  14. package/src/__tests__/reload-plugins.test.ts +137 -0
  15. package/src/__tests__/sessions.test.ts +0 -5
  16. package/src/__tests__/teams-frontend.test.ts +28 -0
  17. package/src/__tests__/telegram-formatting.test.ts +4 -0
  18. package/src/__tests__/telegram-helpers.test.ts +85 -47
  19. package/src/__tests__/telegram.test.ts +2 -3
  20. package/src/__tests__/terminal-commands.test.ts +93 -1
  21. package/src/__tests__/workspace.test.ts +48 -9
  22. package/src/backend/claude-sdk/constants.ts +9 -30
  23. package/src/backend/claude-sdk/handler.ts +24 -2
  24. package/src/backend/claude-sdk/index.ts +2 -1
  25. package/src/backend/claude-sdk/model-provider.ts +184 -0
  26. package/src/backend/claude-sdk/models.ts +80 -112
  27. package/src/backend/claude-sdk/options.ts +3 -8
  28. package/src/backend/opencode/handler.ts +198 -0
  29. package/src/backend/opencode/index.ts +39 -232
  30. package/src/backend/opencode/model-provider.ts +179 -0
  31. package/src/backend/opencode/models.ts +742 -0
  32. package/src/backend/opencode/server.ts +382 -0
  33. package/src/backend/opencode/sessions.ts +492 -0
  34. package/src/bootstrap.ts +60 -2
  35. package/src/core/constants.ts +30 -0
  36. package/src/core/cron.ts +23 -2
  37. package/src/core/dream.ts +7 -6
  38. package/src/core/gateway-actions.ts +38 -2
  39. package/src/core/heartbeat.ts +7 -5
  40. package/src/core/models.ts +33 -51
  41. package/src/core/plugin.ts +15 -0
  42. package/src/core/tools/mcp-server.ts +23 -0
  43. package/src/core/tools/web.ts +2 -4
  44. package/src/core/types.ts +79 -0
  45. package/src/frontend/teams/formatting.ts +7 -1
  46. package/src/frontend/teams/index.ts +7 -4
  47. package/src/frontend/telegram/actions.ts +13 -4
  48. package/src/frontend/telegram/admin.ts +3 -4
  49. package/src/frontend/telegram/callbacks.ts +69 -35
  50. package/src/frontend/telegram/commands.ts +151 -53
  51. package/src/frontend/telegram/formatting.ts +4 -2
  52. package/src/frontend/telegram/handlers.ts +68 -20
  53. package/src/frontend/telegram/helpers.ts +142 -47
  54. package/src/frontend/telegram/index.ts +4 -1
  55. package/src/frontend/terminal/commands.ts +185 -17
  56. package/src/frontend/terminal/index.ts +8 -7
  57. package/src/storage/media-index.ts +3 -3
  58. package/src/storage/sessions.ts +5 -0
  59. package/src/util/cleanup-registry.ts +4 -2
  60. package/src/util/metrics.ts +80 -0
  61. 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.1",
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",
@@ -38,9 +38,13 @@ const { registerClaudeModelsStatic, CLAUDE_MODELS_STATIC } =
38
38
  await import("../backend/claude-sdk/models.js");
39
39
  registerClaudeModelsStatic(CLAUDE_MODELS_STATIC);
40
40
 
41
+ // convertSdkModels collapses base + 1M variants into a single canonical ID
42
+ // per family+version, preferring the 1M variant (and "default" when the SDK
43
+ // marks one canonical). So sonnet/sonnet[1m] → "default", opus/opus[1m] →
44
+ // "opus[1m]", and plain "haiku" stays.
41
45
  const SDK_MODEL_IDS = {
42
46
  sonnet: "default",
43
- opus: "opus",
47
+ opus: "opus[1m]",
44
48
  haiku: "haiku",
45
49
  } as const;
46
50
 
@@ -58,48 +58,40 @@ describe("registerClaudeModels", () => {
58
58
  clearModels();
59
59
  });
60
60
 
61
- it("keeps SDK IDs/display names and maps 1M upgrades explicitly", async () => {
61
+ it("collapses family+version duplicates (base + 1M + claude-*) into a single canonical entry", async () => {
62
62
  const { registerClaudeModels } =
63
63
  await import("../backend/claude-sdk/models.js");
64
- 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
 
68
+ // sonnet, sonnet[1m], claude-sonnet-4-6 all share family+version and
69
+ // collapse into "default" (the SDK's recommended canonical). opus/opus[1m]
70
+ // collapse into opus[1m] (1M-preferred since no "default" exists for that
71
+ // family). haiku stands alone.
73
72
  const anthropicModels = getModels("anthropic");
74
73
  expect(anthropicModels.map((model) => model.id)).toEqual([
75
- "opus",
76
- "opus[1m]",
77
74
  "default",
78
- "sonnet[1m]",
75
+ "opus[1m]",
79
76
  "haiku",
80
77
  ]);
81
78
 
82
79
  expect(
83
80
  anthropicModels.find((model) => model.id === "default")?.displayName,
84
- ).toBe("Default (recommended)");
81
+ ).toBe("Sonnet 4.6");
85
82
  expect(
86
- anthropicModels.find((model) => model.id === "sonnet[1m]")?.displayName,
87
- ).toBe("Sonnet (1M context)");
83
+ anthropicModels.find((model) => model.id === "opus[1m]")?.displayName,
84
+ ).toBe("Opus 4.6");
88
85
  expect(
89
- anthropicModels.some((model) => model.id === "claude-sonnet-4-6"),
90
- ).toBe(false);
86
+ anthropicModels.find((model) => model.id === "haiku")?.displayName,
87
+ ).toBe("Haiku 4.5");
91
88
 
89
+ expect(resolveModelId("sonnet")).toBe("default");
90
+ expect(resolveModelId("sonnet[1m]")).toBe("default");
92
91
  expect(resolveModelId("claude-sonnet-4-6")).toBe("default");
93
- expect(resolveModelId("claude-sonnet-4-6[1m]")).toBe("sonnet[1m]");
94
- 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);
92
+ expect(resolveModelId("claude-sonnet-4-6[1m]")).toBe("default");
93
+ expect(resolveModelId("opus")).toBe("opus[1m]");
94
+ expect(resolveModelId("claude-opus-4-6")).toBe("opus[1m]");
103
95
  });
104
96
 
105
97
  it("derives compatibility aliases from SDK metadata instead of hardcoded versions", async () => {
@@ -140,18 +132,15 @@ describe("registerClaudeModels", () => {
140
132
 
141
133
  const { registerClaudeModels } =
142
134
  await import("../backend/claude-sdk/models.js");
143
- const { get1mContextModelId, resolveModelId } =
144
- await import("../core/models.js");
135
+ const { resolveModelId } = await import("../core/models.js");
145
136
 
146
137
  await registerClaudeModels({ model: "default" });
147
138
 
148
139
  expect(resolveModelId("claude-sonnet-5-0")).toBe("default");
149
140
  expect(resolveModelId("claude-sonnet-4-6")).toBe("default");
150
- expect(resolveModelId("claude-opus-5-0")).toBe("opus");
151
- expect(resolveModelId("claude-opus-4-6")).toBe("opus");
141
+ expect(resolveModelId("claude-opus-5-0")).toBe("opus[1m]");
142
+ expect(resolveModelId("claude-opus-4-6")).toBe("opus[1m]");
152
143
  expect(resolveModelId("claude-haiku-5-0")).toBe("haiku");
153
144
  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
145
  });
157
146
  });
@@ -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
+ });
@@ -327,13 +327,13 @@ describe("fuzz: resolveModelName()", () => {
327
327
  it("known aliases resolve to the expected SDK model IDs", () => {
328
328
  const aliasMappings = [
329
329
  ["sonnet", "default"],
330
- ["opus", "opus"],
330
+ ["opus", "opus[1m]"],
331
331
  ["haiku", "haiku"],
332
332
  ["sonnet-4.6", "default"],
333
- ["opus-4.6", "opus"],
333
+ ["opus-4.6", "opus[1m]"],
334
334
  ["haiku-4.5", "haiku"],
335
335
  ["sonnet-4-6", "default"],
336
- ["opus-4-6", "opus"],
336
+ ["opus-4-6", "opus[1m]"],
337
337
  ["haiku-4-5", "haiku"],
338
338
  ] as const;
339
339
  fc.assert(
@@ -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", () => {
@@ -1874,6 +1917,61 @@ describe("sendHtml — falls back to plain text on HTML send failure", () => {
1874
1917
  // Restore sendMessage mock for other tests
1875
1918
  mockBot.api.sendMessage = vi.fn(async () => ({ message_id: 1 }));
1876
1919
  }, 3000);
1920
+
1921
+ it("fallback iterates to strip nested tag sequences", async () => {
1922
+ executeMock.mockResolvedValue({
1923
+ text: "",
1924
+ durationMs: 10,
1925
+ inputTokens: 1,
1926
+ outputTokens: 1,
1927
+ cacheRead: 0,
1928
+ cacheWrite: 0,
1929
+ bridgeMessageCount: 0,
1930
+ });
1931
+
1932
+ let callCount = 0;
1933
+ mockBot.api.sendMessage = vi.fn(async () => {
1934
+ callCount++;
1935
+ if (callCount === 1) throw new Error("Bad Request: can't parse entities");
1936
+ return { message_id: callCount };
1937
+ });
1938
+
1939
+ const { classify, friendlyMessage } = await import("../core/errors.js");
1940
+ executeMock.mockRejectedValueOnce(new Error("some error"));
1941
+ (classify as ReturnType<typeof vi.fn>).mockReturnValueOnce({
1942
+ reason: "error",
1943
+ message: "some error",
1944
+ retryable: false,
1945
+ });
1946
+ // A single-pass regex leaves a `<script>` survivor after one removal
1947
+ // of the inner placeholder — the iterative loop must keep going.
1948
+ (friendlyMessage as ReturnType<typeof vi.fn>).mockReturnValueOnce(
1949
+ "<scr<script>ipt>alert(1)</script> tail",
1950
+ );
1951
+
1952
+ const ctx = {
1953
+ chat: { id: 97002, type: "private" },
1954
+ message: {
1955
+ text: "nested tag fallback",
1956
+ message_id: 961,
1957
+ reply_to_message: null,
1958
+ },
1959
+ me: { id: 999, username: "testbot" },
1960
+ from: { id: 95, first_name: "Zoe" },
1961
+ } as any;
1962
+
1963
+ await handleTextMessage(ctx, mockBot, mockConfig);
1964
+ await new Promise((r) => setTimeout(r, 700));
1965
+
1966
+ expect(mockBot.api.sendMessage).toHaveBeenCalledTimes(2);
1967
+ const plain = (mockBot.api.sendMessage as ReturnType<typeof vi.fn>).mock
1968
+ .calls[1][1];
1969
+ expect(plain).not.toMatch(/<[^<>]*>/); // no complete tag remains
1970
+ expect(plain).not.toContain("<");
1971
+ expect(plain).toContain("alert(1)");
1972
+
1973
+ mockBot.api.sendMessage = vi.fn(async () => ({ message_id: 1 }));
1974
+ }, 3000);
1877
1975
  });
1878
1976
 
1879
1977
  describe("createStreamCallbacks — onStreamDelta streaming path", () => {
@@ -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
+ });