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.
- package/package.json +10 -5
- package/prompts/telegram.md +24 -6
- package/src/__tests__/claude-sdk-options.test.ts +95 -0
- package/src/__tests__/end-turn.test.ts +307 -0
- package/src/__tests__/handlers.test.ts +107 -43
- package/src/__tests__/integration/sdk-stub.test.ts +208 -0
- package/src/__tests__/integration/stub-claude/build-sea.mjs +114 -0
- package/src/__tests__/integration/stub-claude/fake-claude.mjs +352 -0
- package/src/__tests__/integration/stub-claude/helpers.ts +263 -0
- package/src/__tests__/integration/stub-claude/protocol.ts +108 -0
- package/src/__tests__/integration/stub-claude/sea-config.json +7 -0
- package/src/__tests__/integration/talon-bootstrap.ts +206 -0
- package/src/__tests__/integration/talon-functional.test.ts +190 -0
- package/src/__tests__/package.functional.test.ts +178 -0
- package/src/backend/claude-sdk/handler.ts +110 -1
- package/src/backend/claude-sdk/options.ts +59 -1
- package/src/backend/claude-sdk/stream.ts +67 -0
- package/src/core/tools/index.ts +41 -0
- package/src/core/tools/messaging.ts +79 -1
- package/src/core/tools/types.ts +14 -0
- package/src/frontend/teams/index.ts +20 -10
- package/src/frontend/telegram/handlers.ts +16 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "talon-agent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.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",
|
|
@@ -41,13 +41,17 @@
|
|
|
41
41
|
"setup": "tsx src/cli.ts setup",
|
|
42
42
|
"dev": "tsx --watch src/index.ts",
|
|
43
43
|
"test": "vitest run",
|
|
44
|
+
"test:ci": "vitest run --reporter=verbose --reporter=json --outputFile=test-results.json",
|
|
45
|
+
"test:functional": "vitest run --reporter=verbose --reporter=json --outputFile=functional-results.json src/__tests__/package.functional.test.ts src/__tests__/tool-functional.test.ts src/__tests__/mcp-launcher.test.ts src/__tests__/mcp-launcher-functional.test.ts src/__tests__/integration/sdk-stub.test.ts src/__tests__/integration/talon-functional.test.ts",
|
|
46
|
+
"build:stub-sea": "node src/__tests__/integration/stub-claude/build-sea.mjs",
|
|
44
47
|
"test:watch": "vitest",
|
|
45
48
|
"test:coverage": "vitest run --coverage",
|
|
46
49
|
"typecheck": "tsc --noEmit",
|
|
47
50
|
"lint": "oxlint src/",
|
|
48
51
|
"knip": "knip",
|
|
49
52
|
"format": "prettier --write src/ prompts/",
|
|
50
|
-
"format:check": "prettier --check src/ prompts/"
|
|
53
|
+
"format:check": "prettier --check src/ prompts/",
|
|
54
|
+
"ci:protect": "node .github/scripts/enforce-ci-gate.mjs"
|
|
51
55
|
},
|
|
52
56
|
"dependencies": {
|
|
53
57
|
"@anthropic-ai/claude-agent-sdk": "^0.2.108",
|
|
@@ -58,7 +62,7 @@
|
|
|
58
62
|
"@grammyjs/transformer-throttler": "^1.2.1",
|
|
59
63
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
60
64
|
"@opencode-ai/sdk": "^1.4.0",
|
|
61
|
-
"@playwright/mcp": "^0.0.
|
|
65
|
+
"@playwright/mcp": "^0.0.75",
|
|
62
66
|
"big-integer": "^1.6.52",
|
|
63
67
|
"cheerio": "^1.2.0",
|
|
64
68
|
"croner": "^10.0.1",
|
|
@@ -71,7 +75,7 @@
|
|
|
71
75
|
"telegram": "^2.26.22",
|
|
72
76
|
"tsx": "^4.21.0",
|
|
73
77
|
"undici": "^8.0.2",
|
|
74
|
-
"write-file-atomic": "^
|
|
78
|
+
"write-file-atomic": "^8.0.0",
|
|
75
79
|
"zod": "^4.3.6"
|
|
76
80
|
},
|
|
77
81
|
"devDependencies": {
|
|
@@ -86,6 +90,7 @@
|
|
|
86
90
|
"vitest": "^4.1.3"
|
|
87
91
|
},
|
|
88
92
|
"overrides": {
|
|
89
|
-
"@anthropic-ai/sdk": "^0.95.0"
|
|
93
|
+
"@anthropic-ai/sdk": "^0.95.0",
|
|
94
|
+
"ip-address": "^10.1.1"
|
|
90
95
|
}
|
|
91
96
|
}
|
package/prompts/telegram.md
CHANGED
|
@@ -2,15 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
In groups, you'll see messages prefixed with [Name]: — use their name naturally.
|
|
4
4
|
|
|
5
|
-
###
|
|
5
|
+
### Response flow — IMPORTANT
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Your output stream (this prose right here) is **private scratchpad**. The user never sees it. The ONLY ways for content to reach the user are:
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
- **`end_turn(text=...)`** — the canonical way to deliver your final reply. Closes the turn. Optional `reply_to` for threaded replies, optional `buttons` for inline keyboards.
|
|
10
|
+
- **`end_turn()`** with no args — explicit silent close. Use this when you've done what you needed to (e.g. reacted with an emoji, ran a tool that didn't need a reply) and want to make it clear that the silence is intentional.
|
|
11
|
+
- **`send(...)`** — for mid-turn rich content (photos, polls, voice, stickers, scheduled messages, multi-message responses, multi-target). Does NOT close the turn — typically followed by `end_turn(...)` or `end_turn()`.
|
|
12
|
+
- **`react(message_id, emoji)`** — emoji reaction on a message. Often the right response to acknowledge without replying. Pair with `end_turn()` to close cleanly.
|
|
10
13
|
|
|
11
|
-
|
|
14
|
+
**There is no fallback.** Prose written without an `end_turn` / `send` call is scratchpad — dropped. If you write a thoughtful response in your output stream and forget to wrap it in `end_turn(text=...)`, the user sees nothing. Get into the habit of ending every turn with one of the closing options above.
|
|
12
15
|
|
|
13
|
-
|
|
16
|
+
Doing nothing — no tool call at all — is also a valid silent close (the model genuinely had nothing to do), but `end_turn()` makes the intent explicit and is preferred when the silence is deliberate.
|
|
17
|
+
|
|
18
|
+
**Flow enforcement:** if you produce trailing prose without calling `end_turn` / `send`, the system will re-prompt you ONCE with a `[FLOW VIOLATION]` reminder in the same session. You'll see your broken turn in history and get a fresh turn to redo it correctly. Burns 2x the tokens for that exchange, so just call `end_turn` the first time.
|
|
19
|
+
|
|
20
|
+
### When to use `send` vs `end_turn`
|
|
21
|
+
|
|
22
|
+
- **`end_turn`** = the final reply that ends your turn. Plain text + optional reply_to + optional buttons. The closer.
|
|
23
|
+
- **`send`** = anything richer or anything mid-turn: photos, polls, voice, scheduled messages, stickers, locations, dice, contacts, multi-message responses, replies to other chats.
|
|
24
|
+
|
|
25
|
+
For a plain text final reply, prefer `end_turn(text=...)` over `send(type="text", text=...)`. They reach the same delivery path, but the name makes the intent unambiguous.
|
|
26
|
+
|
|
27
|
+
### The `send` tool (rich content)
|
|
28
|
+
|
|
29
|
+
One tool, set `type` to choose what to send:
|
|
30
|
+
|
|
31
|
+
- `send(type="text", text="Hello!")` — plain text (use end_turn instead for final reply)
|
|
14
32
|
- `send(type="text", text="Hey", reply_to=12345)` — reply to a specific message
|
|
15
33
|
- `send(type="text", text="Pick", buttons=[[{"text":"A","callback_data":"a"}]])` — with buttons
|
|
16
34
|
- `send(type="text", text="Reminder", delay_seconds=60)` — schedule for later
|
|
@@ -54,7 +72,7 @@ The user's message ID is in the prompt as [msg_id:N]. Use with `reply_to` and `r
|
|
|
54
72
|
You don't HAVE to respond to every message. If a message doesn't need a response:
|
|
55
73
|
|
|
56
74
|
- React with an emoji using the `react` tool — this is the PREFERRED way to acknowledge without replying.
|
|
57
|
-
- Or
|
|
75
|
+
- Or call `end_turn()` with no args to end the turn silently.
|
|
58
76
|
- In groups, prefer reactions over replies for simple acknowledgements.
|
|
59
77
|
|
|
60
78
|
### Reactions
|
|
@@ -107,4 +107,99 @@ describe("buildSdkOptions", () => {
|
|
|
107
107
|
expect(activeModel).toBe("claude-sonnet-4-6[1m]");
|
|
108
108
|
expect(options.model).toBe("sonnet[1m]");
|
|
109
109
|
});
|
|
110
|
+
|
|
111
|
+
describe("PostToolBatch turn-terminator hook", () => {
|
|
112
|
+
type HookCallback = (
|
|
113
|
+
input: unknown,
|
|
114
|
+
toolUseID?: string,
|
|
115
|
+
ctx?: { signal: AbortSignal },
|
|
116
|
+
) => Promise<{ continue?: boolean; stopReason?: string }>;
|
|
117
|
+
|
|
118
|
+
const callHook = async (toolNames: string[]): Promise<unknown> => {
|
|
119
|
+
const { buildSdkOptions } =
|
|
120
|
+
await import("../backend/claude-sdk/options.js");
|
|
121
|
+
const { options } = buildSdkOptions("chat-hook-test");
|
|
122
|
+
|
|
123
|
+
const matchers = options.hooks?.PostToolBatch;
|
|
124
|
+
expect(matchers).toBeDefined();
|
|
125
|
+
expect(matchers!.length).toBe(1);
|
|
126
|
+
const hook = matchers![0]!.hooks[0] as unknown as HookCallback;
|
|
127
|
+
|
|
128
|
+
return hook(
|
|
129
|
+
{
|
|
130
|
+
hook_event_name: "PostToolBatch",
|
|
131
|
+
tool_calls: toolNames.map((name, i) => ({
|
|
132
|
+
tool_name: name,
|
|
133
|
+
tool_input: {},
|
|
134
|
+
tool_use_id: `tu_${i}`,
|
|
135
|
+
})),
|
|
136
|
+
},
|
|
137
|
+
undefined,
|
|
138
|
+
{ signal: new AbortController().signal },
|
|
139
|
+
);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
it("registers a PostToolBatch hook on the options object", async () => {
|
|
143
|
+
const { buildSdkOptions } =
|
|
144
|
+
await import("../backend/claude-sdk/options.js");
|
|
145
|
+
const { options } = buildSdkOptions("chat-hook-1");
|
|
146
|
+
expect(options.hooks?.PostToolBatch).toBeDefined();
|
|
147
|
+
expect(options.hooks!.PostToolBatch!.length).toBe(1);
|
|
148
|
+
expect(options.hooks!.PostToolBatch![0]!.hooks.length).toBe(1);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("returns continue:false when an MCP-prefixed end_turn is in the batch", async () => {
|
|
152
|
+
const result = (await callHook([
|
|
153
|
+
"mcp__telegram-tools__send",
|
|
154
|
+
"mcp__telegram-tools__end_turn",
|
|
155
|
+
])) as { continue: boolean; stopReason?: string };
|
|
156
|
+
expect(result.continue).toBe(false);
|
|
157
|
+
expect(result.stopReason).toMatch(/end_turn/i);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("returns continue:false when a bare end_turn is in the batch", async () => {
|
|
161
|
+
const result = (await callHook(["end_turn"])) as {
|
|
162
|
+
continue: boolean;
|
|
163
|
+
};
|
|
164
|
+
expect(result.continue).toBe(false);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("returns continue:true when no terminator is in the batch", async () => {
|
|
168
|
+
const result = (await callHook([
|
|
169
|
+
"mcp__telegram-tools__send",
|
|
170
|
+
"Read",
|
|
171
|
+
"Bash",
|
|
172
|
+
])) as { continue: boolean };
|
|
173
|
+
expect(result.continue).toBe(true);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("returns continue:true on an empty batch", async () => {
|
|
177
|
+
const result = (await callHook([])) as { continue: boolean };
|
|
178
|
+
expect(result.continue).toBe(true);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("ignores non-PostToolBatch events defensively", async () => {
|
|
182
|
+
const { buildSdkOptions } =
|
|
183
|
+
await import("../backend/claude-sdk/options.js");
|
|
184
|
+
const { options } = buildSdkOptions("chat-hook-defensive");
|
|
185
|
+
const hook = options.hooks!.PostToolBatch![0]!.hooks[0] as unknown as (
|
|
186
|
+
input: unknown,
|
|
187
|
+
id?: string,
|
|
188
|
+
ctx?: { signal: AbortSignal },
|
|
189
|
+
) => Promise<{ continue: boolean }>;
|
|
190
|
+
|
|
191
|
+
const result = await hook(
|
|
192
|
+
{
|
|
193
|
+
hook_event_name: "PostToolUse",
|
|
194
|
+
tool_name: "mcp__telegram-tools__end_turn",
|
|
195
|
+
tool_input: {},
|
|
196
|
+
tool_response: {},
|
|
197
|
+
tool_use_id: "tu_0",
|
|
198
|
+
},
|
|
199
|
+
undefined,
|
|
200
|
+
{ signal: new AbortController().signal },
|
|
201
|
+
);
|
|
202
|
+
expect(result.continue).toBe(true);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
110
205
|
});
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the `end_turn` tool and the cross-tool dedup helpers used to
|
|
3
|
+
* suppress duplicate deliveries when the model calls both `end_turn` and
|
|
4
|
+
* `send(type="text")` with similar content in the same turn.
|
|
5
|
+
*
|
|
6
|
+
* Covers:
|
|
7
|
+
* - normalizeForDedupe / isDuplicateOfDelivered (dedup math)
|
|
8
|
+
* - end_turn tool definition (schema, dispatch, silent path)
|
|
9
|
+
* - StreamState carries lastTrailingText and deliveredTextNorms
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, expect, vi } from "vitest";
|
|
13
|
+
import {
|
|
14
|
+
normalizeForDedupe,
|
|
15
|
+
isDuplicateOfDelivered,
|
|
16
|
+
createStreamState,
|
|
17
|
+
processAssistantMessage,
|
|
18
|
+
} from "../backend/claude-sdk/stream.js";
|
|
19
|
+
import type { SDKAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
|
|
20
|
+
import { messagingTools } from "../core/tools/messaging.js";
|
|
21
|
+
import {
|
|
22
|
+
isTurnTerminator,
|
|
23
|
+
stripMcpPrefix,
|
|
24
|
+
ALL_TOOLS,
|
|
25
|
+
} from "../core/tools/index.js";
|
|
26
|
+
|
|
27
|
+
describe("normalizeForDedupe", () => {
|
|
28
|
+
it("trims, lowercases, and collapses whitespace", () => {
|
|
29
|
+
expect(normalizeForDedupe(" Hello World ")).toBe("hello world");
|
|
30
|
+
expect(normalizeForDedupe("HELLO\n\tWORLD")).toBe("hello world");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("strips emoji so prose-with-emoji matches messaging-tool-text", () => {
|
|
34
|
+
expect(normalizeForDedupe("Got it 👍")).toBe("got it");
|
|
35
|
+
expect(normalizeForDedupe("Done ✅ and dusted")).toBe("done and dusted");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("returns empty string for whitespace-only input", () => {
|
|
39
|
+
expect(normalizeForDedupe(" \n\t ")).toBe("");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("isDuplicateOfDelivered", () => {
|
|
44
|
+
it("returns false when nothing has been delivered yet", () => {
|
|
45
|
+
expect(isDuplicateOfDelivered("hello there", [])).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("returns false for very short candidates (below dedup threshold)", () => {
|
|
49
|
+
// Below MIN_DEDUP_LENGTH (10) — short replies like "ok" / "sure" should
|
|
50
|
+
// never be deduped, even if they happened to coincide with a longer
|
|
51
|
+
// delivered text containing them.
|
|
52
|
+
expect(isDuplicateOfDelivered("ok", ["ok thanks pal"])).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("matches when normalized candidate is a substring of delivered", () => {
|
|
56
|
+
const delivered = [normalizeForDedupe("Got it sur, pushing now")];
|
|
57
|
+
expect(isDuplicateOfDelivered("Got it sur, pushing now", delivered)).toBe(
|
|
58
|
+
true,
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("matches when normalized delivered is a substring of candidate", () => {
|
|
63
|
+
// Model called end_turn(text="Pushing now") then wrote prose
|
|
64
|
+
// "I'm pushing now and back in a sec." — fuzzy match catches this.
|
|
65
|
+
const delivered = [normalizeForDedupe("Pushing now")];
|
|
66
|
+
expect(
|
|
67
|
+
isDuplicateOfDelivered("I'm pushing now and back in a sec.", delivered),
|
|
68
|
+
).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("does not match unrelated content", () => {
|
|
72
|
+
const delivered = [normalizeForDedupe("PR #106 merged")];
|
|
73
|
+
expect(
|
|
74
|
+
isDuplicateOfDelivered("Got it, I'll look at the docker logs", delivered),
|
|
75
|
+
).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("ignores emoji differences when comparing", () => {
|
|
79
|
+
// Model wrote "Done 🎉" as prose, also called end_turn(text="Done")
|
|
80
|
+
const delivered = [normalizeForDedupe("Done")];
|
|
81
|
+
expect(isDuplicateOfDelivered("Done 🎉", delivered)).toBe(false);
|
|
82
|
+
// Above is false because "done" (3 chars) < MIN_DEDUP_LENGTH (10).
|
|
83
|
+
// For a longer match:
|
|
84
|
+
const longDelivered = [normalizeForDedupe("All set, pushing now")];
|
|
85
|
+
expect(
|
|
86
|
+
isDuplicateOfDelivered("All set, pushing now 🚀", longDelivered),
|
|
87
|
+
).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("createStreamState", () => {
|
|
92
|
+
it("initializes lastTrailingText and deliveredTextNorms", () => {
|
|
93
|
+
const state = createStreamState();
|
|
94
|
+
expect(state.lastTrailingText).toBe("");
|
|
95
|
+
expect(state.deliveredTextNorms).toEqual([]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("initializes turnTerminated to false", () => {
|
|
99
|
+
const state = createStreamState();
|
|
100
|
+
expect(state.turnTerminated).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("turn-terminator declaration", () => {
|
|
105
|
+
it("end_turn is declared with endsTurn: true", () => {
|
|
106
|
+
const endTurn = messagingTools.find((t) => t.name === "end_turn");
|
|
107
|
+
expect(endTurn?.endsTurn).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("send is NOT declared as a turn terminator", () => {
|
|
111
|
+
// `send` is for mid-turn rich content (photos, polls, scheduled messages,
|
|
112
|
+
// etc.) — calling it does NOT mean the model is done. Only end_turn
|
|
113
|
+
// declares the turn finished.
|
|
114
|
+
const send = messagingTools.find((t) => t.name === "send");
|
|
115
|
+
expect(send?.endsTurn).toBeFalsy();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("isTurnTerminator returns true for end_turn", () => {
|
|
119
|
+
expect(isTurnTerminator("end_turn")).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("isTurnTerminator returns false for non-terminator tools", () => {
|
|
123
|
+
expect(isTurnTerminator("send")).toBe(false);
|
|
124
|
+
expect(isTurnTerminator("react")).toBe(false);
|
|
125
|
+
expect(isTurnTerminator("fetch_url")).toBe(false);
|
|
126
|
+
expect(isTurnTerminator("nonexistent_tool")).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("isTurnTerminator handles MCP-prefixed names", () => {
|
|
130
|
+
// Tools served through MCP arrive with a `mcp__<server>__` prefix.
|
|
131
|
+
// The check must normalize the prefix so the SDK's actual tool names
|
|
132
|
+
// match the registry. Without this, downstream branches gated on
|
|
133
|
+
// `state.turnTerminated` silently never fire — the flow-violation
|
|
134
|
+
// re-prompt skip and trailing-prose dedup both break.
|
|
135
|
+
expect(isTurnTerminator("mcp__telegram-tools__end_turn")).toBe(true);
|
|
136
|
+
expect(isTurnTerminator("mcp__teams-tools__end_turn")).toBe(true);
|
|
137
|
+
// Non-terminators with the same prefix shape still return false
|
|
138
|
+
expect(isTurnTerminator("mcp__telegram-tools__send")).toBe(false);
|
|
139
|
+
expect(isTurnTerminator("mcp__telegram-tools__react")).toBe(false);
|
|
140
|
+
// Server name with hyphen + underscore must still match the boundary
|
|
141
|
+
expect(isTurnTerminator("mcp__some-server-name__end_turn")).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("stripMcpPrefix strips the mcp__<server>__ prefix when present", () => {
|
|
145
|
+
expect(stripMcpPrefix("mcp__telegram-tools__end_turn")).toBe("end_turn");
|
|
146
|
+
expect(stripMcpPrefix("mcp__brave-search__brave_web_search")).toBe(
|
|
147
|
+
"brave_web_search",
|
|
148
|
+
);
|
|
149
|
+
// Non-greedy match takes the FIRST `__` after `mcp__` as the boundary
|
|
150
|
+
expect(stripMcpPrefix("mcp__a__b__c")).toBe("b__c");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("stripMcpPrefix returns input unchanged when no prefix matches", () => {
|
|
154
|
+
expect(stripMcpPrefix("end_turn")).toBe("end_turn");
|
|
155
|
+
expect(stripMcpPrefix("send")).toBe("send");
|
|
156
|
+
expect(stripMcpPrefix("Read")).toBe("Read");
|
|
157
|
+
// Looks like a prefix but missing the trailing `__`
|
|
158
|
+
expect(stripMcpPrefix("mcp__incomplete")).toBe("mcp__incomplete");
|
|
159
|
+
// Different prefix shape
|
|
160
|
+
expect(stripMcpPrefix("not_mcp__server__tool")).toBe(
|
|
161
|
+
"not_mcp__server__tool",
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("only one turn terminator currently exists (end_turn)", () => {
|
|
166
|
+
// If a future change adds a second terminator, this test should fail
|
|
167
|
+
// and the author should document why a new terminator is necessary.
|
|
168
|
+
const terminators = ALL_TOOLS.filter((t) => t.endsTurn).map((t) => t.name);
|
|
169
|
+
expect(terminators).toEqual(["end_turn"]);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("end_turn tool definition", () => {
|
|
174
|
+
const endTurn = messagingTools.find((t) => t.name === "end_turn");
|
|
175
|
+
|
|
176
|
+
it("is registered in messagingTools", () => {
|
|
177
|
+
expect(endTurn).toBeDefined();
|
|
178
|
+
expect(endTurn?.tag).toBe("messaging");
|
|
179
|
+
expect(endTurn?.frontends).toEqual(["telegram", "teams"]);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("has text, reply_to, and buttons schema fields", () => {
|
|
183
|
+
expect(endTurn?.schema).toBeDefined();
|
|
184
|
+
expect(endTurn?.schema.text).toBeDefined();
|
|
185
|
+
expect(endTurn?.schema.reply_to).toBeDefined();
|
|
186
|
+
expect(endTurn?.schema.buttons).toBeDefined();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("dispatches plain text via send_message bridge", async () => {
|
|
190
|
+
const bridge = vi.fn(async () => ({ ok: true }));
|
|
191
|
+
await endTurn!.execute({ text: "Hello sur" }, bridge);
|
|
192
|
+
expect(bridge).toHaveBeenCalledWith("send_message", {
|
|
193
|
+
text: "Hello sur",
|
|
194
|
+
reply_to_message_id: undefined,
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("dispatches text + reply_to via send_message bridge", async () => {
|
|
199
|
+
const bridge = vi.fn(async () => ({ ok: true }));
|
|
200
|
+
await endTurn!.execute({ text: "Yep", reply_to: 12345 }, bridge);
|
|
201
|
+
expect(bridge).toHaveBeenCalledWith("send_message", {
|
|
202
|
+
text: "Yep",
|
|
203
|
+
reply_to_message_id: 12345,
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("dispatches text + buttons via send_message_with_buttons bridge", async () => {
|
|
208
|
+
const bridge = vi.fn(async () => ({ ok: true }));
|
|
209
|
+
const buttons = [[{ text: "Click", callback_data: "x" }]];
|
|
210
|
+
await endTurn!.execute({ text: "Pick", buttons }, bridge);
|
|
211
|
+
expect(bridge).toHaveBeenCalledWith("send_message_with_buttons", {
|
|
212
|
+
text: "Pick",
|
|
213
|
+
rows: buttons,
|
|
214
|
+
reply_to_message_id: undefined,
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("ends silently with no bridge call when text is omitted", async () => {
|
|
219
|
+
const bridge = vi.fn(async () => ({ ok: true }));
|
|
220
|
+
const result = await endTurn!.execute({}, bridge);
|
|
221
|
+
expect(bridge).not.toHaveBeenCalled();
|
|
222
|
+
expect(result).toEqual({ ok: true, silent: true });
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("ends silently with no bridge call when text is whitespace-only", async () => {
|
|
226
|
+
const bridge = vi.fn(async () => ({ ok: true }));
|
|
227
|
+
const result = await endTurn!.execute({ text: " \n\t " }, bridge);
|
|
228
|
+
expect(bridge).not.toHaveBeenCalled();
|
|
229
|
+
expect(result).toEqual({ ok: true, silent: true });
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// ── Production wire-shape contract ──────────────────────────────────────────
|
|
234
|
+
//
|
|
235
|
+
// These tests pin the integration between the SDK's actual emitted tool
|
|
236
|
+
// names (always MCP-prefixed when served via MCP) and the registry checks
|
|
237
|
+
// the handler runs against them. They are the tests that would have caught
|
|
238
|
+
// the bug fixed in this PR — strict-equality `isTurnTerminator("end_turn")`
|
|
239
|
+
// passed in unit tests but the production code path called
|
|
240
|
+
// `isTurnTerminator("mcp__telegram-tools__end_turn")` and silently failed.
|
|
241
|
+
//
|
|
242
|
+
// Auto-derived from ALL_TOOLS so adding a new endsTurn tool or a new MCP
|
|
243
|
+
// frontend stays covered without manually adding cases.
|
|
244
|
+
|
|
245
|
+
describe("turn-terminator integration with SDK production tool name shapes", () => {
|
|
246
|
+
// Built-in MCP server names that the SDK is known to wire Talon's tools
|
|
247
|
+
// through. Keep this list in sync with the actual MCP server registration
|
|
248
|
+
// in src/core/tools/mcp-server.ts and frontend wiring.
|
|
249
|
+
const KNOWN_MCP_SERVERS = ["telegram-tools", "teams-tools"];
|
|
250
|
+
|
|
251
|
+
for (const tool of ALL_TOOLS.filter((t) => t.endsTurn)) {
|
|
252
|
+
for (const server of KNOWN_MCP_SERVERS) {
|
|
253
|
+
const sdkName = `mcp__${server}__${tool.name}`;
|
|
254
|
+
|
|
255
|
+
it(`isTurnTerminator(${sdkName}) === true`, () => {
|
|
256
|
+
// The SDK never emits bare names for MCP-served tools — it always
|
|
257
|
+
// includes the `mcp__<server>__` prefix. Strict equality against the
|
|
258
|
+
// registry's bare name was the production bug.
|
|
259
|
+
expect(isTurnTerminator(sdkName)).toBe(true);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it(`processAssistantMessage + isTurnTerminator: ${sdkName} flips state.turnTerminated`, () => {
|
|
263
|
+
// End-to-end check of the exact two-step the handler does:
|
|
264
|
+
// block.name -> tools[].name (via processAssistantMessage)
|
|
265
|
+
// tools[].name -> isTurnTerminator
|
|
266
|
+
// If either step normalizes inconsistently, this breaks.
|
|
267
|
+
const state = createStreamState();
|
|
268
|
+
const msg = {
|
|
269
|
+
type: "assistant",
|
|
270
|
+
message: {
|
|
271
|
+
content: [
|
|
272
|
+
{
|
|
273
|
+
type: "tool_use",
|
|
274
|
+
id: "tool_1",
|
|
275
|
+
name: sdkName,
|
|
276
|
+
input: { text: "Hello sur" },
|
|
277
|
+
},
|
|
278
|
+
],
|
|
279
|
+
},
|
|
280
|
+
} as unknown as SDKAssistantMessage;
|
|
281
|
+
|
|
282
|
+
const result = processAssistantMessage(msg, state);
|
|
283
|
+
expect(result.tools).toHaveLength(1);
|
|
284
|
+
expect(result.tools[0].name).toBe(sdkName);
|
|
285
|
+
|
|
286
|
+
// This is the exact line in handler.ts:
|
|
287
|
+
// if (isTurnTerminator(tool.name)) state.turnTerminated = true;
|
|
288
|
+
if (isTurnTerminator(result.tools[0].name)) {
|
|
289
|
+
state.turnTerminated = true;
|
|
290
|
+
}
|
|
291
|
+
expect(state.turnTerminated).toBe(true);
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
it("non-terminator tools stay non-terminator under MCP prefixing", () => {
|
|
297
|
+
// Make sure prefix-stripping doesn't accidentally promote arbitrary
|
|
298
|
+
// tools to terminators.
|
|
299
|
+
const nonTerminators = ALL_TOOLS.filter((t) => !t.endsTurn);
|
|
300
|
+
expect(nonTerminators.length).toBeGreaterThan(0);
|
|
301
|
+
for (const tool of nonTerminators.slice(0, 5)) {
|
|
302
|
+
for (const server of KNOWN_MCP_SERVERS) {
|
|
303
|
+
expect(isTurnTerminator(`mcp__${server}__${tool.name}`)).toBe(false);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
});
|