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.
- package/package.json +1 -1
- package/src/__tests__/chat-settings.test.ts +5 -1
- package/src/__tests__/claude-sdk-models.test.ts +20 -31
- package/src/__tests__/claude-sdk-options.test.ts +5 -13
- package/src/__tests__/fixtures/test-mcp-server.ts +37 -0
- package/src/__tests__/fuzz.test.ts +3 -3
- package/src/__tests__/handlers.test.ts +98 -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__/teams-frontend.test.ts +28 -0
- package/src/__tests__/telegram-formatting.test.ts +4 -0
- package/src/__tests__/telegram-helpers.test.ts +85 -47
- package/src/__tests__/telegram.test.ts +2 -3
- package/src/__tests__/terminal-commands.test.ts +93 -1
- package/src/__tests__/workspace.test.ts +48 -9
- package/src/backend/claude-sdk/constants.ts +9 -30
- 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 +184 -0
- package/src/backend/claude-sdk/models.ts +80 -112
- 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 +179 -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 +60 -2
- package/src/core/constants.ts +30 -0
- package/src/core/cron.ts +23 -2
- package/src/core/dream.ts +7 -6
- package/src/core/gateway-actions.ts +38 -2
- package/src/core/heartbeat.ts +7 -5
- package/src/core/models.ts +33 -51
- package/src/core/plugin.ts +15 -0
- package/src/core/tools/mcp-server.ts +23 -0
- package/src/core/tools/web.ts +2 -4
- package/src/core/types.ts +79 -0
- package/src/frontend/teams/formatting.ts +7 -1
- package/src/frontend/teams/index.ts +7 -4
- package/src/frontend/telegram/actions.ts +13 -4
- package/src/frontend/telegram/admin.ts +3 -4
- package/src/frontend/telegram/callbacks.ts +69 -35
- package/src/frontend/telegram/commands.ts +151 -53
- package/src/frontend/telegram/formatting.ts +4 -2
- package/src/frontend/telegram/handlers.ts +68 -20
- package/src/frontend/telegram/helpers.ts +142 -47
- package/src/frontend/telegram/index.ts +4 -1
- package/src/frontend/terminal/commands.ts +185 -17
- package/src/frontend/terminal/index.ts +8 -7
- 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
|
@@ -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("
|
|
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
|
-
"
|
|
75
|
+
"opus[1m]",
|
|
79
76
|
"haiku",
|
|
80
77
|
]);
|
|
81
78
|
|
|
82
79
|
expect(
|
|
83
80
|
anthropicModels.find((model) => model.id === "default")?.displayName,
|
|
84
|
-
).toBe("
|
|
81
|
+
).toBe("Sonnet 4.6");
|
|
85
82
|
expect(
|
|
86
|
-
anthropicModels.find((model) => model.id === "
|
|
87
|
-
).toBe("
|
|
83
|
+
anthropicModels.find((model) => model.id === "opus[1m]")?.displayName,
|
|
84
|
+
).toBe("Opus 4.6");
|
|
88
85
|
expect(
|
|
89
|
-
anthropicModels.
|
|
90
|
-
).toBe(
|
|
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("
|
|
94
|
-
expect(resolveModelId("
|
|
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 {
|
|
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("
|
|
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
|
+
});
|
|
@@ -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
|
+
});
|