talon-agent 1.9.2 → 1.10.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.
@@ -1731,45 +1731,103 @@ describe("handleAnimationMessage — downloads and enqueues", () => {
1731
1731
  });
1732
1732
 
1733
1733
  describe("processAndReply — onToolUse callback triggers appendDailyLogResponse", () => {
1734
- it("calls appendDailyLogResponse when execute invokes onToolUse with send tool", async () => {
1735
- const { appendDailyLogResponse } = await import("../storage/daily-log.js");
1736
-
1737
- executeMock.mockImplementationOnce(
1738
- async (params: Record<string, unknown>) => {
1739
- // Simulate execute calling onToolUse with a "send" tool
1740
- const onToolUse = params.onToolUse as (
1741
- toolName: string,
1742
- input: Record<string, unknown>,
1743
- ) => void;
1744
- onToolUse?.("send", { type: "text", text: "Hello from Claude!" });
1745
- return {
1746
- text: "",
1747
- durationMs: 5,
1748
- inputTokens: 1,
1749
- outputTokens: 2,
1750
- cacheRead: 0,
1751
- cacheWrite: 0,
1752
- bridgeMessageCount: 1,
1753
- };
1754
- },
1755
- );
1756
-
1757
- const ctx = {
1758
- chat: { id: 96001, type: "private" },
1759
- message: { text: "hi", message_id: 950, reply_to_message: null },
1760
- me: { id: 999, username: "testbot" },
1761
- from: { id: 93, first_name: "Yuki" },
1762
- } as any;
1763
-
1764
- await handleTextMessage(ctx, mockBot, mockConfig);
1765
- await new Promise((r) => setTimeout(r, 700));
1734
+ // The SDK emits MCP-prefixed tool names in production
1735
+ // (`mcp__telegram-tools__send` etc.). Bare names like `send` are how the
1736
+ // tool is registered but NOT what the handler actually receives. This
1737
+ // test matrix exercises both shapes — the prefixed forms are what would
1738
+ // catch a regression where the strip-prefix logic gets dropped.
1739
+ const cases = [
1740
+ {
1741
+ label: "MCP-prefixed send (production shape)",
1742
+ toolName: "mcp__telegram-tools__send",
1743
+ input: { type: "text", text: "Production send text" },
1744
+ expectedText: "Production send text",
1745
+ shouldCapture: true,
1746
+ },
1747
+ {
1748
+ label: "MCP-prefixed end_turn (production shape)",
1749
+ toolName: "mcp__telegram-tools__end_turn",
1750
+ input: { text: "Production end_turn text" },
1751
+ expectedText: "Production end_turn text",
1752
+ shouldCapture: true,
1753
+ },
1754
+ {
1755
+ label: "bare send (defensive — registry-shape compat)",
1756
+ toolName: "send",
1757
+ input: { type: "text", text: "Bare send text" },
1758
+ expectedText: "Bare send text",
1759
+ shouldCapture: true,
1760
+ },
1761
+ {
1762
+ label: "bare end_turn (defensive — registry-shape compat)",
1763
+ toolName: "end_turn",
1764
+ input: { text: "Bare end_turn text" },
1765
+ expectedText: "Bare end_turn text",
1766
+ shouldCapture: true,
1767
+ },
1768
+ {
1769
+ label: "non-text send (photo) — should NOT capture",
1770
+ toolName: "mcp__telegram-tools__send",
1771
+ input: { type: "photo", file_path: "/x.jpg", caption: "ignored" },
1772
+ expectedText: "",
1773
+ shouldCapture: false,
1774
+ },
1775
+ {
1776
+ label: "react tool — should NOT capture",
1777
+ toolName: "mcp__telegram-tools__react",
1778
+ input: { message_id: 1, emoji: "👍" },
1779
+ expectedText: "",
1780
+ shouldCapture: false,
1781
+ },
1782
+ ];
1783
+
1784
+ for (const c of cases) {
1785
+ it(`${c.shouldCapture ? "captures" : "ignores"} ${c.label}`, async () => {
1786
+ const { appendDailyLogResponse } =
1787
+ await import("../storage/daily-log.js");
1788
+ const mockedFn = vi.mocked(appendDailyLogResponse);
1789
+ const before = mockedFn.mock.calls.length;
1790
+
1791
+ executeMock.mockImplementationOnce(
1792
+ async (params: Record<string, unknown>) => {
1793
+ const onToolUse = params.onToolUse as (
1794
+ toolName: string,
1795
+ input: Record<string, unknown>,
1796
+ ) => void;
1797
+ onToolUse?.(c.toolName, c.input);
1798
+ return {
1799
+ text: "",
1800
+ durationMs: 5,
1801
+ inputTokens: 1,
1802
+ outputTokens: 2,
1803
+ cacheRead: 0,
1804
+ cacheWrite: 0,
1805
+ bridgeMessageCount: 1,
1806
+ };
1807
+ },
1808
+ );
1766
1809
 
1767
- expect(appendDailyLogResponse).toHaveBeenCalledWith(
1768
- "Talon",
1769
- "Hello from Claude!",
1770
- expect.anything(),
1771
- );
1772
- }, 3000);
1810
+ const ctx = {
1811
+ chat: { id: 96001, type: "private" },
1812
+ message: { text: "hi", message_id: 950, reply_to_message: null },
1813
+ me: { id: 999, username: "testbot" },
1814
+ from: { id: 93, first_name: "Yuki" },
1815
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1816
+ } as any;
1817
+
1818
+ await handleTextMessage(ctx, mockBot, mockConfig);
1819
+ await new Promise((r) => setTimeout(r, 700));
1820
+
1821
+ const newCalls = mockedFn.mock.calls.slice(before);
1822
+ if (c.shouldCapture) {
1823
+ expect(newCalls).toHaveLength(1);
1824
+ expect(newCalls[0][0]).toBe("Talon");
1825
+ expect(newCalls[0][1]).toBe(c.expectedText);
1826
+ } else {
1827
+ expect(newCalls).toHaveLength(0);
1828
+ }
1829
+ }, 3000);
1830
+ }
1773
1831
  });
1774
1832
 
1775
1833
  describe("createStreamCallbacks — onTextBlock delivers message via sendHtml", () => {
@@ -2676,13 +2734,19 @@ describe("processAndReply — group message without senderId", () => {
2676
2734
  }, 3000);
2677
2735
  });
2678
2736
 
2679
- describe("processAndReply — suppressed fallback text logged (L572 TRUE branch)", () => {
2680
- it("logs when bridgeMessageCount=0 and result.text is non-empty", async () => {
2737
+ describe("processAndReply — fallback text no longer suppressed", () => {
2738
+ // Pre-end_turn behavior: when bridgeMessageCount=0 and result.text was
2739
+ // non-empty, the frontend logged "Suppressed fallback text" and dropped
2740
+ // the content (the scratchpad bug). The handler now fires onTextBlock
2741
+ // with trailing text instead, so:
2742
+ // - the "Suppressed fallback" log is gone entirely
2743
+ // - the delivery path is exercised by createStreamCallbacks tests above
2744
+ it("does NOT log a 'Suppressed fallback' warning anymore", async () => {
2681
2745
  const { log } = await import("../util/log.js");
2682
2746
  (log as ReturnType<typeof vi.fn>).mockClear();
2683
2747
 
2684
2748
  executeMock.mockResolvedValueOnce({
2685
- text: "internal reasoning only",
2749
+ text: "trailing prose that used to be suppressed",
2686
2750
  durationMs: 10,
2687
2751
  inputTokens: 1,
2688
2752
  outputTokens: 1,
@@ -2694,7 +2758,7 @@ describe("processAndReply — suppressed fallback text logged (L572 TRUE branch)
2694
2758
  const ctx = {
2695
2759
  chat: { id: 99600, type: "private" },
2696
2760
  message: {
2697
- text: "test suppressed fallback",
2761
+ text: "test fallback delivery",
2698
2762
  message_id: 1600,
2699
2763
  reply_to_message: null,
2700
2764
  },
@@ -2709,7 +2773,7 @@ describe("processAndReply — suppressed fallback text logged (L572 TRUE branch)
2709
2773
  const suppressedLog = logCalls.find((c: unknown[]) =>
2710
2774
  String(c[1]).includes("Suppressed fallback"),
2711
2775
  );
2712
- expect(suppressedLog).toBeDefined();
2776
+ expect(suppressedLog).toBeUndefined();
2713
2777
  }, 3000);
2714
2778
  });
2715
2779
 
@@ -0,0 +1,208 @@
1
+ /**
2
+ * End-to-end integration tests against the real Claude Agent SDK driving a
3
+ * stub `claude` binary (see `stub-claude/`).
4
+ *
5
+ * Unlike the unit tests in `src/__tests__/`, these don't `vi.mock` the SDK —
6
+ * the real `query()` runs, real subprocess spawn, real hook dispatcher, real
7
+ * stream-json protocol. The binary is replaced with a scripted stub so we
8
+ * can assert on what the SDK produces given known inputs.
9
+ *
10
+ * What this catches that unit tests can't:
11
+ * - Bugs in our integration with the SDK's protocol shapes (e.g. the
12
+ * MCP-prefix bug in `isTurnTerminator` would have surfaced here).
13
+ * - Hook wiring — confirms options.hooks actually gets invoked by the SDK.
14
+ * - Stream sequence handling against authentic message ordering.
15
+ */
16
+
17
+ import { describe, it, expect } from "vitest";
18
+ import {
19
+ runWithStub,
20
+ cleanup,
21
+ assistantText,
22
+ assistantToolUse,
23
+ successResult,
24
+ fireHook,
25
+ } from "./stub-claude/helpers.js";
26
+
27
+ // On Windows the stub binary is a SEA-compiled `.exe` (built via
28
+ // `npm run build:stub-sea`). On POSIX it's the `.mjs` source with shebang.
29
+ // If the .exe isn't present (build step skipped), we skip the suite cleanly
30
+ // rather than fail with `ENOENT`.
31
+ import { existsSync } from "node:fs";
32
+ import { resolve as resolvePath, dirname as dirnamePath } from "node:path";
33
+ import { fileURLToPath as fileUrl } from "node:url";
34
+ const __testDir = dirnamePath(fileUrl(import.meta.url));
35
+ const stubBinaryPath = resolvePath(
36
+ __testDir,
37
+ process.platform === "win32"
38
+ ? "stub-claude/fake-claude.exe"
39
+ : "stub-claude/fake-claude.mjs",
40
+ );
41
+ const stubReady = existsSync(stubBinaryPath);
42
+
43
+ describe.skipIf(!stubReady)("SDK integration (stub binary)", () => {
44
+ it("completes a simple text-only turn", async () => {
45
+ const result = await runWithStub({
46
+ prompt: "say hi",
47
+ script: {
48
+ turns: [
49
+ {
50
+ emit: [assistantText("hello from stub"), successResult("hello")],
51
+ },
52
+ ],
53
+ },
54
+ });
55
+
56
+ // Should have system init, assistant message, and result
57
+ const types = result.messages.map((m) => (m as { type: string }).type);
58
+ expect(types).toContain("system");
59
+ expect(types).toContain("assistant");
60
+ expect(types).toContain("result");
61
+
62
+ cleanup(result);
63
+ }, 15000);
64
+
65
+ it("yields assistant content blocks intact", async () => {
66
+ const result = await runWithStub({
67
+ prompt: "test",
68
+ script: {
69
+ turns: [
70
+ {
71
+ emit: [assistantText("specific text payload"), successResult()],
72
+ },
73
+ ],
74
+ },
75
+ });
76
+
77
+ const assistant = result.messages.find(
78
+ (m) => (m as { type: string }).type === "assistant",
79
+ ) as
80
+ | { message: { content: { type: string; text?: string }[] } }
81
+ | undefined;
82
+
83
+ expect(assistant).toBeDefined();
84
+ const text = assistant?.message.content.find((b) => b.type === "text");
85
+ expect(text?.text).toBe("specific text payload");
86
+
87
+ cleanup(result);
88
+ }, 15000);
89
+
90
+ it("invokes registered PostToolBatch hooks", async () => {
91
+ // The stub emits an assistant message with a tool_use, then synthesizes a
92
+ // PostToolBatch hook fire (via fireHook). The SDK should look up the
93
+ // callback id we registered during init and invoke our hook function.
94
+ const result = await runWithStub({
95
+ prompt: "trigger end_turn",
96
+ script: {
97
+ turns: [
98
+ {
99
+ emit: [
100
+ assistantToolUse("mcp__telegram-tools__end_turn", {
101
+ text: "delivered",
102
+ }),
103
+ fireHook("PostToolBatch", {
104
+ tool_calls: [
105
+ {
106
+ tool_name: "mcp__telegram-tools__end_turn",
107
+ tool_input: { text: "delivered" },
108
+ tool_use_id: "tu_1",
109
+ },
110
+ ],
111
+ }),
112
+ successResult("delivered"),
113
+ ],
114
+ },
115
+ ],
116
+ },
117
+ sdkOptions: {
118
+ hooks: {
119
+ PostToolBatch: [
120
+ {
121
+ hooks: [
122
+ async () => ({
123
+ continue: false,
124
+ stopReason: "test: end_turn fired",
125
+ }),
126
+ ],
127
+ },
128
+ ],
129
+ },
130
+ },
131
+ });
132
+
133
+ expect(result.hookFires.PostToolBatch ?? 0).toBeGreaterThanOrEqual(1);
134
+
135
+ const calls = result.hookInputs.PostToolBatch ?? [];
136
+ const firstInput = calls[0] as
137
+ | { tool_calls: { tool_name: string }[] }
138
+ | undefined;
139
+ expect(firstInput?.tool_calls.map((tc) => tc.tool_name)).toContain(
140
+ "mcp__telegram-tools__end_turn",
141
+ );
142
+
143
+ cleanup(result);
144
+ }, 15000);
145
+
146
+ it("runs the production turn-terminator hook (PostToolBatch + isTurnTerminator)", async () => {
147
+ // This test composes the actual hook from `src/backend/claude-sdk/options.ts`
148
+ // — we reach into the module to grab it. If the hook implementation drifts
149
+ // from what the SDK calls, this test fails.
150
+ const { isTurnTerminator } = await import("../../core/tools/index.js");
151
+
152
+ const result = await runWithStub({
153
+ prompt: "drive end_turn through the real isTurnTerminator helper",
154
+ script: {
155
+ turns: [
156
+ {
157
+ emit: [
158
+ assistantToolUse("mcp__telegram-tools__end_turn", {
159
+ text: "ok",
160
+ }),
161
+ fireHook("PostToolBatch", {
162
+ tool_calls: [
163
+ {
164
+ tool_name: "mcp__telegram-tools__end_turn",
165
+ tool_input: { text: "ok" },
166
+ tool_use_id: "tu_1",
167
+ },
168
+ ],
169
+ }),
170
+ successResult(),
171
+ ],
172
+ },
173
+ ],
174
+ },
175
+ sdkOptions: {
176
+ hooks: {
177
+ PostToolBatch: [
178
+ {
179
+ hooks: [
180
+ async (input) => {
181
+ type Batch = {
182
+ hook_event_name: string;
183
+ tool_calls: { tool_name: string }[];
184
+ };
185
+ if ((input as Batch).hook_event_name !== "PostToolBatch") {
186
+ return { continue: true };
187
+ }
188
+ const batch = input as Batch;
189
+ const ended = batch.tool_calls.some((tc) =>
190
+ isTurnTerminator(tc.tool_name),
191
+ );
192
+ return ended
193
+ ? { continue: false, stopReason: "turn terminated" }
194
+ : { continue: true };
195
+ },
196
+ ],
197
+ },
198
+ ],
199
+ },
200
+ },
201
+ });
202
+
203
+ // Hook should have fired exactly once and matched the MCP-prefixed name.
204
+ expect(result.hookFires.PostToolBatch).toBe(1);
205
+
206
+ cleanup(result);
207
+ }, 15000);
208
+ });
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Build a Single Executable Application (SEA) wrapping `fake-claude.mjs`.
4
+ *
5
+ * Why we need this: on Windows, Node 20.19+ refuses to spawn `.cmd` / `.bat`
6
+ * shims via `child_process.spawn` without `shell: true` (CVE-2024-27980).
7
+ * The Claude Agent SDK calls `spawn(claudeBinary, args)` directly, so a
8
+ * `.cmd` wrapper around our `.mjs` stub is unspawnable. SEA produces a
9
+ * native `.exe` (or platform-equivalent binary) that the SDK can spawn.
10
+ *
11
+ * Steps:
12
+ * 1. Bundle `fake-claude.mjs` (ESM) → `fake-claude.cjs` (CJS) with esbuild,
13
+ * because Node SEA loads main as CommonJS by default.
14
+ * 2. Generate the SEA preparation blob:
15
+ * node --experimental-sea-config sea-config.json
16
+ * 3. Copy the running `node` binary to `fake-claude(.exe)`.
17
+ * 4. Inject the blob with postject (Sentinel: `NODE_SEA_FUSE_…`).
18
+ *
19
+ * The build is idempotent — safe to re-run. Output binary lives next to
20
+ * the source files in `stub-claude/`.
21
+ *
22
+ * Usage:
23
+ * node build-sea.mjs # builds for the current platform
24
+ * npm run build:stub-sea # same, via the npm script
25
+ */
26
+
27
+ import { spawnSync } from "node:child_process";
28
+ import { copyFileSync, chmodSync, existsSync, statSync } from "node:fs";
29
+ import { dirname, resolve } from "node:path";
30
+ import { fileURLToPath } from "node:url";
31
+
32
+ const __dirname = dirname(fileURLToPath(import.meta.url));
33
+ const BUNDLE_SRC = resolve(__dirname, "fake-claude.mjs");
34
+ const BUNDLE_DEST = resolve(__dirname, "fake-claude.cjs");
35
+ const BLOB_PATH = resolve(__dirname, "sea-prep.blob");
36
+ const EXE_NAME =
37
+ process.platform === "win32" ? "fake-claude.exe" : "fake-claude";
38
+ const EXE_PATH = resolve(__dirname, EXE_NAME);
39
+ const SEA_CONFIG = resolve(__dirname, "sea-config.json");
40
+ const SENTINEL_FUSE = "NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2";
41
+
42
+ function step(label) {
43
+ process.stdout.write(`\n[build-sea] ${label}\n`);
44
+ }
45
+
46
+ function run(label, cmd, args, opts = {}) {
47
+ step(label);
48
+ const result = spawnSync(cmd, args, {
49
+ stdio: "inherit",
50
+ cwd: __dirname,
51
+ shell: process.platform === "win32",
52
+ ...opts,
53
+ });
54
+ if (result.status !== 0) {
55
+ throw new Error(`${label} failed (exit ${result.status})`);
56
+ }
57
+ }
58
+
59
+ // ── 1. Bundle ESM → CJS ────────────────────────────────────────────────────
60
+ const esbuildBin = resolve(
61
+ __dirname,
62
+ "../../../../node_modules/.bin/esbuild" +
63
+ (process.platform === "win32" ? ".cmd" : ""),
64
+ );
65
+ if (!existsSync(esbuildBin)) {
66
+ throw new Error(
67
+ `esbuild not found at ${esbuildBin}. Run \`npm install\` first.`,
68
+ );
69
+ }
70
+ run("Bundling fake-claude.mjs → fake-claude.cjs", esbuildBin, [
71
+ BUNDLE_SRC,
72
+ "--bundle",
73
+ "--platform=node",
74
+ "--format=cjs",
75
+ `--outfile=${BUNDLE_DEST}`,
76
+ ]);
77
+
78
+ // ── 2. Generate SEA blob ───────────────────────────────────────────────────
79
+ run("Generating SEA preparation blob", process.execPath, [
80
+ "--experimental-sea-config",
81
+ SEA_CONFIG,
82
+ ]);
83
+
84
+ if (!existsSync(BLOB_PATH)) {
85
+ throw new Error(`Expected blob at ${BLOB_PATH} but it doesn't exist`);
86
+ }
87
+
88
+ // ── 3. Copy node binary ────────────────────────────────────────────────────
89
+ step(`Copying ${process.execPath} → ${EXE_PATH}`);
90
+ copyFileSync(process.execPath, EXE_PATH);
91
+ if (process.platform !== "win32") {
92
+ chmodSync(EXE_PATH, 0o755);
93
+ }
94
+
95
+ // ── 4. Inject blob via postject ────────────────────────────────────────────
96
+ const postjectArgs = [
97
+ "postject",
98
+ EXE_PATH,
99
+ "NODE_SEA_BLOB",
100
+ BLOB_PATH,
101
+ "--sentinel-fuse",
102
+ SENTINEL_FUSE,
103
+ ];
104
+ if (process.platform === "darwin") {
105
+ // macOS requires the mach-o segment name to be specified.
106
+ postjectArgs.push("--macho-segment-name", "NODE_SEA");
107
+ }
108
+ run("Injecting SEA blob with postject", "npx", ["--yes", ...postjectArgs]);
109
+
110
+ // ── Summary ────────────────────────────────────────────────────────────────
111
+ const finalSize = statSync(EXE_PATH).size;
112
+ step(
113
+ `Done. ${EXE_NAME} (${(finalSize / 1024 / 1024).toFixed(1)} MB) ready at ${EXE_PATH}`,
114
+ );