talon-agent 1.9.1 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -3
- package/prompts/mempalace.md +24 -4
- package/prompts/telegram.md +24 -6
- package/src/__tests__/end-turn.test.ts +189 -0
- package/src/__tests__/handlers.test.ts +11 -5
- package/src/__tests__/log.test.ts +4 -3
- package/src/__tests__/tool-functional.test.ts +615 -0
- package/src/__tests__/tool-id-coercion.test.ts +136 -0
- package/src/backend/claude-sdk/handler.ts +108 -4
- package/src/backend/claude-sdk/stream.ts +67 -0
- package/src/core/gateway.ts +12 -2
- package/src/core/tools/history.ts +6 -5
- package/src/core/tools/index.ts +18 -0
- package/src/core/tools/members.ts +2 -1
- package/src/core/tools/messaging.ts +88 -9
- package/src/core/tools/schemas.ts +34 -0
- package/src/core/tools/stickers.ts +3 -2
- package/src/core/tools/types.ts +14 -0
- package/src/frontend/teams/index.ts +20 -10
- package/src/frontend/telegram/commands.ts +2 -3
- package/src/frontend/telegram/handlers.ts +4 -10
- package/src/util/log.ts +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "talon-agent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
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",
|
|
@@ -51,13 +51,14 @@
|
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
53
|
"@anthropic-ai/claude-agent-sdk": "^0.2.108",
|
|
54
|
+
"@anthropic-ai/sdk": "^0.95.0",
|
|
54
55
|
"@brave/brave-search-mcp-server": "^2.0.75",
|
|
55
56
|
"@clack/prompts": "^1.2.0",
|
|
56
57
|
"@grammyjs/auto-retry": "^2.0.2",
|
|
57
58
|
"@grammyjs/transformer-throttler": "^1.2.1",
|
|
58
59
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
59
60
|
"@opencode-ai/sdk": "^1.4.0",
|
|
60
|
-
"@playwright/mcp": "^0.0.
|
|
61
|
+
"@playwright/mcp": "^0.0.74",
|
|
61
62
|
"big-integer": "^1.6.52",
|
|
62
63
|
"cheerio": "^1.2.0",
|
|
63
64
|
"croner": "^10.0.1",
|
|
@@ -85,6 +86,6 @@
|
|
|
85
86
|
"vitest": "^4.1.3"
|
|
86
87
|
},
|
|
87
88
|
"overrides": {
|
|
88
|
-
"@anthropic-ai/sdk": "^0.
|
|
89
|
+
"@anthropic-ai/sdk": "^0.95.0"
|
|
89
90
|
}
|
|
90
91
|
}
|
package/prompts/mempalace.md
CHANGED
|
@@ -7,6 +7,7 @@ You have access to a local memory palace via MCP tools. The palace stores verbat
|
|
|
7
7
|
- **Wings** = top-level categories (people, projects, topics)
|
|
8
8
|
- **Rooms** = specific subjects within a wing
|
|
9
9
|
- **Drawers** = individual memory chunks (verbatim text)
|
|
10
|
+
- **Tunnels** = cross-wing links between related rooms (auto-created in mempalace 3.3.4+ when topics overlap, plus manual)
|
|
10
11
|
- **Knowledge Graph** = entity-relationship facts with temporal validity
|
|
11
12
|
|
|
12
13
|
### Protocol — FOLLOW EVERY SESSION
|
|
@@ -25,6 +26,7 @@ You have access to a local memory palace via MCP tools. The palace stores verbat
|
|
|
25
26
|
- `mempalace_status` — Palace overview: total drawers, wings, rooms.
|
|
26
27
|
- `mempalace_list_wings` / `mempalace_list_rooms` — Browse structure.
|
|
27
28
|
- `mempalace_get_taxonomy` — Full wing/room/count tree.
|
|
29
|
+
- `mempalace_get_aaak_spec` — Get the AAAK closet/compression spec. Only needed when reading/writing AAAK-compressed memories directly.
|
|
28
30
|
|
|
29
31
|
**Knowledge Graph (Temporal Facts):**
|
|
30
32
|
|
|
@@ -34,24 +36,42 @@ You have access to a local memory palace via MCP tools. The palace stores verbat
|
|
|
34
36
|
- `mempalace_kg_timeline` — Chronological story of an entity.
|
|
35
37
|
- `mempalace_kg_stats` — Graph overview: entities, triples, relationship types.
|
|
36
38
|
|
|
37
|
-
**Palace Graph
|
|
39
|
+
**Palace Graph & Cross-Wing Tunnels:**
|
|
38
40
|
|
|
39
41
|
- `mempalace_traverse` — Walk from a room, find connected ideas across wings.
|
|
40
42
|
- `mempalace_find_tunnels` — Find rooms that bridge two wings.
|
|
43
|
+
- `mempalace_follow_tunnels` — From a specific (wing, room) pair, walk the outbound tunnels and see connected rooms with drawer previews.
|
|
44
|
+
- `mempalace_create_tunnel` — Manually create a cross-wing tunnel between two (wing, room) pairs. Use when you spot a connection the auto-detector missed.
|
|
45
|
+
- `mempalace_list_tunnels` — List tunnels (optionally filtered by wing).
|
|
46
|
+
- `mempalace_delete_tunnel` — Remove a tunnel by ID when it's wrong or noisy.
|
|
41
47
|
- `mempalace_graph_stats` — Graph connectivity overview.
|
|
42
48
|
|
|
43
|
-
**
|
|
49
|
+
**Drawers:**
|
|
44
50
|
|
|
45
51
|
- `mempalace_add_drawer` — Store verbatim content into a wing/room. Auto-checks duplicates.
|
|
52
|
+
- `mempalace_get_drawer` — Fetch a single drawer by ID. Returns full content + metadata. Use after a search hit when you need the verbatim text.
|
|
53
|
+
- `mempalace_list_drawers` — Browse drawers in a wing/room with pagination (`limit`, `offset`). Use for inventory/cleanup, not search.
|
|
54
|
+
- `mempalace_update_drawer` — Edit an existing drawer's content, wing, or room in place. Use to refine misfiled or stale entries instead of delete + re-add.
|
|
46
55
|
- `mempalace_delete_drawer` — Remove a drawer by ID.
|
|
47
|
-
|
|
48
|
-
|
|
56
|
+
|
|
57
|
+
**Diary:**
|
|
58
|
+
|
|
59
|
+
- `mempalace_diary_write` — Write a session diary entry (`agent_name`, `entry`, `topic`, optional `wing`).
|
|
60
|
+
- `mempalace_diary_read` — Read recent diary entries. Optional `wing` filter scopes by project.
|
|
61
|
+
|
|
62
|
+
**Maintenance:**
|
|
63
|
+
|
|
64
|
+
- `mempalace_memories_filed_away` — Check whether a recent checkpoint was saved (message count, timestamp). Useful for confirming a stop-hook flush happened.
|
|
65
|
+
- `mempalace_reconnect` — Force reconnect to the palace database. Run after external CLI/scripts modify the palace directly (the in-memory HNSW index can otherwise go stale).
|
|
66
|
+
- `mempalace_hook_settings` — Toggle silent-save / desktop-toast for the auto-save hooks.
|
|
49
67
|
|
|
50
68
|
### Tips
|
|
51
69
|
|
|
52
70
|
- Search is **semantic** (meaning-based), not keyword. "What did we discuss about database performance?" works better than "database".
|
|
53
71
|
- The knowledge graph stores typed relationships with **time windows**. It knows WHEN things were true.
|
|
54
72
|
- Use `mempalace_check_duplicate` before storing new content to avoid clutter.
|
|
73
|
+
- **Tunnels auto-form** when drawers across different wings share topics (mempalace 3.3.4+). You don't have to wire connections by hand most of the time — but `mempalace_create_tunnel` is there when the auto-detector misses something obvious, and `mempalace_delete_tunnel` is there when it overreaches.
|
|
74
|
+
- After updating facts via the CLI / external scripts, call `mempalace_reconnect` so the live MCP server picks up the changes.
|
|
55
75
|
- Diary entries accumulate across sessions. Write them to build continuity of self.
|
|
56
76
|
- Entity detection runs per-language; results include `created_at` timestamps you can surface when the user asks "when did I last…".
|
|
57
77
|
|
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
|
|
@@ -0,0 +1,189 @@
|
|
|
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
|
+
} from "../backend/claude-sdk/stream.js";
|
|
18
|
+
import { messagingTools } from "../core/tools/messaging.js";
|
|
19
|
+
import { isTurnTerminator, ALL_TOOLS } from "../core/tools/index.js";
|
|
20
|
+
|
|
21
|
+
describe("normalizeForDedupe", () => {
|
|
22
|
+
it("trims, lowercases, and collapses whitespace", () => {
|
|
23
|
+
expect(normalizeForDedupe(" Hello World ")).toBe("hello world");
|
|
24
|
+
expect(normalizeForDedupe("HELLO\n\tWORLD")).toBe("hello world");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("strips emoji so prose-with-emoji matches messaging-tool-text", () => {
|
|
28
|
+
expect(normalizeForDedupe("Got it 👍")).toBe("got it");
|
|
29
|
+
expect(normalizeForDedupe("Done ✅ and dusted")).toBe("done and dusted");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("returns empty string for whitespace-only input", () => {
|
|
33
|
+
expect(normalizeForDedupe(" \n\t ")).toBe("");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("isDuplicateOfDelivered", () => {
|
|
38
|
+
it("returns false when nothing has been delivered yet", () => {
|
|
39
|
+
expect(isDuplicateOfDelivered("hello there", [])).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("returns false for very short candidates (below dedup threshold)", () => {
|
|
43
|
+
// Below MIN_DEDUP_LENGTH (10) — short replies like "ok" / "sure" should
|
|
44
|
+
// never be deduped, even if they happened to coincide with a longer
|
|
45
|
+
// delivered text containing them.
|
|
46
|
+
expect(isDuplicateOfDelivered("ok", ["ok thanks pal"])).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("matches when normalized candidate is a substring of delivered", () => {
|
|
50
|
+
const delivered = [normalizeForDedupe("Got it sur, pushing now")];
|
|
51
|
+
expect(isDuplicateOfDelivered("Got it sur, pushing now", delivered)).toBe(
|
|
52
|
+
true,
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("matches when normalized delivered is a substring of candidate", () => {
|
|
57
|
+
// Model called end_turn(text="Pushing now") then wrote prose
|
|
58
|
+
// "I'm pushing now and back in a sec." — fuzzy match catches this.
|
|
59
|
+
const delivered = [normalizeForDedupe("Pushing now")];
|
|
60
|
+
expect(
|
|
61
|
+
isDuplicateOfDelivered("I'm pushing now and back in a sec.", delivered),
|
|
62
|
+
).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("does not match unrelated content", () => {
|
|
66
|
+
const delivered = [normalizeForDedupe("PR #106 merged")];
|
|
67
|
+
expect(
|
|
68
|
+
isDuplicateOfDelivered("Got it, I'll look at the docker logs", delivered),
|
|
69
|
+
).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("ignores emoji differences when comparing", () => {
|
|
73
|
+
// Model wrote "Done 🎉" as prose, also called end_turn(text="Done")
|
|
74
|
+
const delivered = [normalizeForDedupe("Done")];
|
|
75
|
+
expect(isDuplicateOfDelivered("Done 🎉", delivered)).toBe(false);
|
|
76
|
+
// Above is false because "done" (3 chars) < MIN_DEDUP_LENGTH (10).
|
|
77
|
+
// For a longer match:
|
|
78
|
+
const longDelivered = [normalizeForDedupe("All set, pushing now")];
|
|
79
|
+
expect(
|
|
80
|
+
isDuplicateOfDelivered("All set, pushing now 🚀", longDelivered),
|
|
81
|
+
).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("createStreamState", () => {
|
|
86
|
+
it("initializes lastTrailingText and deliveredTextNorms", () => {
|
|
87
|
+
const state = createStreamState();
|
|
88
|
+
expect(state.lastTrailingText).toBe("");
|
|
89
|
+
expect(state.deliveredTextNorms).toEqual([]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("initializes turnTerminated to false", () => {
|
|
93
|
+
const state = createStreamState();
|
|
94
|
+
expect(state.turnTerminated).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("turn-terminator declaration", () => {
|
|
99
|
+
it("end_turn is declared with endsTurn: true", () => {
|
|
100
|
+
const endTurn = messagingTools.find((t) => t.name === "end_turn");
|
|
101
|
+
expect(endTurn?.endsTurn).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("send is NOT declared as a turn terminator", () => {
|
|
105
|
+
// `send` is for mid-turn rich content (photos, polls, scheduled messages,
|
|
106
|
+
// etc.) — calling it does NOT mean the model is done. Only end_turn
|
|
107
|
+
// declares the turn finished.
|
|
108
|
+
const send = messagingTools.find((t) => t.name === "send");
|
|
109
|
+
expect(send?.endsTurn).toBeFalsy();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("isTurnTerminator returns true for end_turn", () => {
|
|
113
|
+
expect(isTurnTerminator("end_turn")).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("isTurnTerminator returns false for non-terminator tools", () => {
|
|
117
|
+
expect(isTurnTerminator("send")).toBe(false);
|
|
118
|
+
expect(isTurnTerminator("react")).toBe(false);
|
|
119
|
+
expect(isTurnTerminator("fetch_url")).toBe(false);
|
|
120
|
+
expect(isTurnTerminator("nonexistent_tool")).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("only one turn terminator currently exists (end_turn)", () => {
|
|
124
|
+
// If a future change adds a second terminator, this test should fail
|
|
125
|
+
// and the author should document why a new terminator is necessary.
|
|
126
|
+
const terminators = ALL_TOOLS.filter((t) => t.endsTurn).map((t) => t.name);
|
|
127
|
+
expect(terminators).toEqual(["end_turn"]);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("end_turn tool definition", () => {
|
|
132
|
+
const endTurn = messagingTools.find((t) => t.name === "end_turn");
|
|
133
|
+
|
|
134
|
+
it("is registered in messagingTools", () => {
|
|
135
|
+
expect(endTurn).toBeDefined();
|
|
136
|
+
expect(endTurn?.tag).toBe("messaging");
|
|
137
|
+
expect(endTurn?.frontends).toEqual(["telegram", "teams"]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("has text, reply_to, and buttons schema fields", () => {
|
|
141
|
+
expect(endTurn?.schema).toBeDefined();
|
|
142
|
+
expect(endTurn?.schema.text).toBeDefined();
|
|
143
|
+
expect(endTurn?.schema.reply_to).toBeDefined();
|
|
144
|
+
expect(endTurn?.schema.buttons).toBeDefined();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("dispatches plain text via send_message bridge", async () => {
|
|
148
|
+
const bridge = vi.fn(async () => ({ ok: true }));
|
|
149
|
+
await endTurn!.execute({ text: "Hello sur" }, bridge);
|
|
150
|
+
expect(bridge).toHaveBeenCalledWith("send_message", {
|
|
151
|
+
text: "Hello sur",
|
|
152
|
+
reply_to_message_id: undefined,
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("dispatches text + reply_to via send_message bridge", async () => {
|
|
157
|
+
const bridge = vi.fn(async () => ({ ok: true }));
|
|
158
|
+
await endTurn!.execute({ text: "Yep", reply_to: 12345 }, bridge);
|
|
159
|
+
expect(bridge).toHaveBeenCalledWith("send_message", {
|
|
160
|
+
text: "Yep",
|
|
161
|
+
reply_to_message_id: 12345,
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("dispatches text + buttons via send_message_with_buttons bridge", async () => {
|
|
166
|
+
const bridge = vi.fn(async () => ({ ok: true }));
|
|
167
|
+
const buttons = [[{ text: "Click", callback_data: "x" }]];
|
|
168
|
+
await endTurn!.execute({ text: "Pick", buttons }, bridge);
|
|
169
|
+
expect(bridge).toHaveBeenCalledWith("send_message_with_buttons", {
|
|
170
|
+
text: "Pick",
|
|
171
|
+
rows: buttons,
|
|
172
|
+
reply_to_message_id: undefined,
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("ends silently with no bridge call when text is omitted", async () => {
|
|
177
|
+
const bridge = vi.fn(async () => ({ ok: true }));
|
|
178
|
+
const result = await endTurn!.execute({}, bridge);
|
|
179
|
+
expect(bridge).not.toHaveBeenCalled();
|
|
180
|
+
expect(result).toEqual({ ok: true, silent: true });
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("ends silently with no bridge call when text is whitespace-only", async () => {
|
|
184
|
+
const bridge = vi.fn(async () => ({ ok: true }));
|
|
185
|
+
const result = await endTurn!.execute({ text: " \n\t " }, bridge);
|
|
186
|
+
expect(bridge).not.toHaveBeenCalled();
|
|
187
|
+
expect(result).toEqual({ ok: true, silent: true });
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -2676,13 +2676,19 @@ describe("processAndReply — group message without senderId", () => {
|
|
|
2676
2676
|
}, 3000);
|
|
2677
2677
|
});
|
|
2678
2678
|
|
|
2679
|
-
describe("processAndReply —
|
|
2680
|
-
|
|
2679
|
+
describe("processAndReply — fallback text no longer suppressed", () => {
|
|
2680
|
+
// Pre-end_turn behavior: when bridgeMessageCount=0 and result.text was
|
|
2681
|
+
// non-empty, the frontend logged "Suppressed fallback text" and dropped
|
|
2682
|
+
// the content (the scratchpad bug). The handler now fires onTextBlock
|
|
2683
|
+
// with trailing text instead, so:
|
|
2684
|
+
// - the "Suppressed fallback" log is gone entirely
|
|
2685
|
+
// - the delivery path is exercised by createStreamCallbacks tests above
|
|
2686
|
+
it("does NOT log a 'Suppressed fallback' warning anymore", async () => {
|
|
2681
2687
|
const { log } = await import("../util/log.js");
|
|
2682
2688
|
(log as ReturnType<typeof vi.fn>).mockClear();
|
|
2683
2689
|
|
|
2684
2690
|
executeMock.mockResolvedValueOnce({
|
|
2685
|
-
text: "
|
|
2691
|
+
text: "trailing prose that used to be suppressed",
|
|
2686
2692
|
durationMs: 10,
|
|
2687
2693
|
inputTokens: 1,
|
|
2688
2694
|
outputTokens: 1,
|
|
@@ -2694,7 +2700,7 @@ describe("processAndReply — suppressed fallback text logged (L572 TRUE branch)
|
|
|
2694
2700
|
const ctx = {
|
|
2695
2701
|
chat: { id: 99600, type: "private" },
|
|
2696
2702
|
message: {
|
|
2697
|
-
text: "test
|
|
2703
|
+
text: "test fallback delivery",
|
|
2698
2704
|
message_id: 1600,
|
|
2699
2705
|
reply_to_message: null,
|
|
2700
2706
|
},
|
|
@@ -2709,7 +2715,7 @@ describe("processAndReply — suppressed fallback text logged (L572 TRUE branch)
|
|
|
2709
2715
|
const suppressedLog = logCalls.find((c: unknown[]) =>
|
|
2710
2716
|
String(c[1]).includes("Suppressed fallback"),
|
|
2711
2717
|
);
|
|
2712
|
-
expect(suppressedLog).
|
|
2718
|
+
expect(suppressedLog).toBeUndefined();
|
|
2713
2719
|
}, 3000);
|
|
2714
2720
|
});
|
|
2715
2721
|
|
|
@@ -71,10 +71,11 @@ describe("log", () => {
|
|
|
71
71
|
);
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
-
it("includes Error message in context", () => {
|
|
75
|
-
|
|
74
|
+
it("includes Error message and stack in context", () => {
|
|
75
|
+
const err = new Error("timeout");
|
|
76
|
+
logError("bridge", "request failed", err);
|
|
76
77
|
expect(mockError).toHaveBeenCalledWith(
|
|
77
|
-
{ component: "bridge", err: "timeout" },
|
|
78
|
+
{ component: "bridge", err: "timeout", stack: err.stack },
|
|
78
79
|
"request failed",
|
|
79
80
|
);
|
|
80
81
|
});
|