switchroom 0.14.8 → 0.14.9
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/dist/agent-scheduler/index.js +80 -80
- package/dist/auth-broker/index.js +80 -80
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +82 -82
- package/dist/cli/skill-validate-pretool.mjs +72 -72
- package/dist/cli/switchroom.js +357 -357
- package/dist/host-control/main.js +148 -148
- package/dist/vault/approvals/kernel-server.js +82 -82
- package/dist/vault/broker/server.js +83 -83
- package/package.json +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +112 -112
- package/telegram-plugin/dist/gateway/gateway.js +194 -321
- package/telegram-plugin/dist/server.js +160 -160
- package/telegram-plugin/gateway/gateway.ts +41 -97
- package/telegram-plugin/tests/tool-activity-summary.test.ts +0 -216
- package/telegram-plugin/tool-activity-summary.ts +18 -197
|
@@ -53,14 +53,7 @@ import { OutboundDedupCache } from '../recent-outbound-dedup.js'
|
|
|
53
53
|
import { createInboundCoalescer, inboundCoalesceKey } from './inbound-coalesce.js'
|
|
54
54
|
import { StatusReactionController } from '../status-reactions.js'
|
|
55
55
|
import { isTelegramReplyTool, isTelegramSurfaceTool } from '../tool-names.js'
|
|
56
|
-
import {
|
|
57
|
-
makeEmptyActivityState,
|
|
58
|
-
registerAndRender,
|
|
59
|
-
describeToolUse,
|
|
60
|
-
appendActivityLine,
|
|
61
|
-
appendActivityLabel,
|
|
62
|
-
type ActivityState,
|
|
63
|
-
} from '../tool-activity-summary.js'
|
|
56
|
+
import { appendActivityLabel } from '../tool-activity-summary.js'
|
|
64
57
|
import { toolLabel } from '../tool-labels.js'
|
|
65
58
|
import { createTypingWrapper } from '../typing-wrap.js'
|
|
66
59
|
import { type DraftStreamHandle } from '../draft-stream.js'
|
|
@@ -1352,16 +1345,14 @@ type CurrentTurn = {
|
|
|
1352
1345
|
// repeats until the pending matches the last-sent.
|
|
1353
1346
|
// Result: at most one Telegram call in flight at a time; the
|
|
1354
1347
|
// final state always lands.
|
|
1355
|
-
toolActivity: ActivityState
|
|
1356
1348
|
activityMessageId: number | null
|
|
1357
1349
|
activityInFlight: Promise<void> | null
|
|
1358
1350
|
activityPendingRender: string | null
|
|
1359
1351
|
activityLastSentRender: string | null
|
|
1360
|
-
// Accumulating friendly-action feed for this turn
|
|
1361
|
-
//
|
|
1362
|
-
//
|
|
1363
|
-
//
|
|
1364
|
-
// per turn.
|
|
1352
|
+
// Accumulating friendly-action feed for this turn. Each non-surface
|
|
1353
|
+
// tool_label appends a line via `appendActivityLabel`; the feed renders
|
|
1354
|
+
// (via `renderActivityFeed`) as a capped chronological list into the
|
|
1355
|
+
// in-place edited activity message and clears on reply. Reset per turn.
|
|
1365
1356
|
mirrorLines: string[]
|
|
1366
1357
|
// Issue #195 — answer-lane streaming. Lazily created on the first text
|
|
1367
1358
|
// event of a turn (once enough text has accumulated, the stream itself
|
|
@@ -3254,25 +3245,16 @@ const ANSWER_STREAM_VISIBLE_ENABLED = (() => {
|
|
|
3254
3245
|
return true
|
|
3255
3246
|
})()
|
|
3256
3247
|
|
|
3257
|
-
// Activity
|
|
3258
|
-
//
|
|
3259
|
-
//
|
|
3260
|
-
//
|
|
3261
|
-
//
|
|
3262
|
-
//
|
|
3263
|
-
//
|
|
3264
|
-
// the
|
|
3265
|
-
//
|
|
3266
|
-
//
|
|
3267
|
-
// historical: an earlier design mirrored into the compose-area draft; the feed
|
|
3268
|
-
// is now a normal edited message.) Default OFF (canary). Kill switch:
|
|
3269
|
-
// SWITCHROOM_DRAFT_MIRROR unset/0/false/off/no.
|
|
3270
|
-
const DRAFT_MIRROR_ENABLED = (() => {
|
|
3271
|
-
const raw = process.env.SWITCHROOM_DRAFT_MIRROR
|
|
3272
|
-
if (raw == null) return false
|
|
3273
|
-
const v = raw.trim().toLowerCase()
|
|
3274
|
-
return !(v === '0' || v === 'false' || v === 'off' || v === 'no')
|
|
3275
|
-
})()
|
|
3248
|
+
// Activity feed. The gateway streams a live "what it's doing" tool-activity
|
|
3249
|
+
// feed for every turn. The PreToolUse sidecar emits a `tool_label` per tool
|
|
3250
|
+
// call (flush-independent, so it stays real-time on fast/clustered-tool
|
|
3251
|
+
// turns); each label appends to `turn.mirrorLines`, and `renderActivityFeed`
|
|
3252
|
+
// renders the capped list into an in-place EDITED message (sendMessage +
|
|
3253
|
+
// editMessageText) anchored as a native reply-quote to the user's question.
|
|
3254
|
+
// The feed clears on the first reply (hand-off to the answer) and again at
|
|
3255
|
+
// turn_end (the no-reply safety net). It does NOT touch the answer-stream's
|
|
3256
|
+
// draft/visible lane — the two render on separate surfaces, so they never
|
|
3257
|
+
// collide.
|
|
3276
3258
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
3277
3259
|
const progressDriver: any = null
|
|
3278
3260
|
const unpinProgressCardForChat: ((chatId: string, threadId: number | undefined) => void) | null = null
|
|
@@ -6946,23 +6928,18 @@ async function drainActivitySummary(turn: CurrentTurn): Promise<void> {
|
|
|
6946
6928
|
while (turn.activityPendingRender !== turn.activityLastSentRender) {
|
|
6947
6929
|
const target = turn.activityPendingRender
|
|
6948
6930
|
if (target == null) break
|
|
6949
|
-
//
|
|
6950
|
-
// (
|
|
6951
|
-
//
|
|
6952
|
-
//
|
|
6953
|
-
|
|
6954
|
-
// re-escape or re-wrap (double-escaping would surface literal tags).
|
|
6955
|
-
// - feed OFF: the legacy verb-count summary is plain text — escape and
|
|
6956
|
-
// wrap in a single <i>.
|
|
6957
|
-
const html = DRAFT_MIRROR_ENABLED ? target : `<i>${escapeHtmlForTg(target)}</i>`
|
|
6931
|
+
// `renderActivityFeed` already emitted ready Telegram HTML with per-line
|
|
6932
|
+
// markup (<b>→ current</b> / <i>✓ done</i>) and escaped each label's
|
|
6933
|
+
// <,>,& itself (#1942 class) — send verbatim, do NOT re-escape or
|
|
6934
|
+
// re-wrap (double-escaping would surface literal tags).
|
|
6935
|
+
const html = target
|
|
6958
6936
|
const chat = turn.sessionChatId
|
|
6959
6937
|
const thread = turn.sessionThreadId
|
|
6960
6938
|
// Native reply-quote: anchor the feed message to the user's question so
|
|
6961
6939
|
// it renders as a quoted header (reply_parameters renders on a real
|
|
6962
|
-
// message; edits preserve it).
|
|
6963
|
-
//
|
|
6964
|
-
|
|
6965
|
-
const replyAnchor = DRAFT_MIRROR_ENABLED && turn.sourceMessageId != null
|
|
6940
|
+
// message; edits preserve it). allow_sending_without_reply so a deleted
|
|
6941
|
+
// source can't drop the send.
|
|
6942
|
+
const replyAnchor = turn.sourceMessageId != null
|
|
6966
6943
|
? { reply_parameters: { message_id: turn.sourceMessageId, allow_sending_without_reply: true } }
|
|
6967
6944
|
: {}
|
|
6968
6945
|
try {
|
|
@@ -7090,7 +7067,6 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
7090
7067
|
lastAssistantMsgId: null,
|
|
7091
7068
|
lastAssistantDone: false,
|
|
7092
7069
|
toolCallCount: 0,
|
|
7093
|
-
toolActivity: makeEmptyActivityState(),
|
|
7094
7070
|
activityMessageId: null,
|
|
7095
7071
|
activityInFlight: null,
|
|
7096
7072
|
activityPendingRender: null,
|
|
@@ -7228,51 +7204,20 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
7228
7204
|
turn.orphanedReplyTimeoutId = null
|
|
7229
7205
|
}
|
|
7230
7206
|
// The model's real reply takes over as the authoritative
|
|
7231
|
-
// surface, so delete the activity
|
|
7232
|
-
// sees the real reply land in the same beat the
|
|
7233
|
-
// disappears.
|
|
7234
|
-
// the DRAFT_MIRROR feed); turn_end is the no-reply safety net.
|
|
7207
|
+
// surface, so delete the activity feed message — the user
|
|
7208
|
+
// sees the real reply land in the same beat the feed
|
|
7209
|
+
// disappears. turn_end is the no-reply safety net.
|
|
7235
7210
|
if (wasFirstReply) {
|
|
7236
7211
|
clearActivitySummary(turn)
|
|
7237
7212
|
}
|
|
7238
7213
|
}
|
|
7239
|
-
//
|
|
7240
|
-
//
|
|
7241
|
-
//
|
|
7242
|
-
//
|
|
7243
|
-
//
|
|
7244
|
-
//
|
|
7245
|
-
//
|
|
7246
|
-
//
|
|
7247
|
-
// Single-flight coalescing (PR #1926 review): modern Claude emits
|
|
7248
|
-
// multiple tool_uses in a synchronous burst (parallel Reads,
|
|
7249
|
-
// Bashes, etc.). All would otherwise race past the message-id
|
|
7250
|
-
// capture and produce N messages. Pattern mirrors answer-stream:
|
|
7251
|
-
// update `activityPendingRender` synchronously here; a single
|
|
7252
|
-
// worker promise drains the pending state, sending or editing
|
|
7253
|
-
// exactly once at a time and re-running until pending matches
|
|
7254
|
-
// the last-sent. Captures `turn` so a late drain after turn-swap
|
|
7255
|
-
// can't corrupt the next turn's atom.
|
|
7256
|
-
//
|
|
7257
|
-
// This (flush-gated) tool_use path drives the summary ONLY when
|
|
7258
|
-
// DRAFT_MIRROR is OFF: the legacy generic verb-count summary
|
|
7259
|
-
// ("Ran 5 commands") via registerAndRender. When DRAFT_MIRROR is
|
|
7260
|
-
// ON the summary is instead driven by the real-time `tool_label`
|
|
7261
|
-
// event (PreToolUse sidecar, fires at tool-call time regardless of
|
|
7262
|
-
// when claude flushes the transcript) — see `case 'tool_label'`.
|
|
7263
|
-
// That's the determinism fix: on a fast/clustered-tool turn the
|
|
7264
|
-
// JSONL tool_use rows aren't on disk until ~turn-end, so sourcing
|
|
7265
|
-
// the feed here lost it; the sidecar is flush-independent. Both
|
|
7266
|
-
// producers feed `activityPendingRender` and clear on first reply.
|
|
7267
|
-
if (!DRAFT_MIRROR_ENABLED && !turn.replyCalled && !isTelegramSurfaceTool(name)) {
|
|
7268
|
-
const rendered = registerAndRender(turn.toolActivity, name)
|
|
7269
|
-
if (rendered != null) {
|
|
7270
|
-
turn.activityPendingRender = rendered
|
|
7271
|
-
if (turn.activityInFlight == null) {
|
|
7272
|
-
turn.activityInFlight = drainActivitySummary(turn)
|
|
7273
|
-
}
|
|
7274
|
-
}
|
|
7275
|
-
}
|
|
7214
|
+
// The live activity feed is driven by the real-time `tool_label`
|
|
7215
|
+
// event (PreToolUse sidecar) rather than this flush-gated tool_use
|
|
7216
|
+
// path — see `case 'tool_label'`. The sidecar fires at tool-call
|
|
7217
|
+
// time regardless of when claude flushes the transcript, which is
|
|
7218
|
+
// the determinism fix: on a fast/clustered-tool turn the JSONL
|
|
7219
|
+
// tool_use rows aren't on disk until ~turn-end, so sourcing the
|
|
7220
|
+
// feed here would lose them.
|
|
7276
7221
|
if (!ctrl) return
|
|
7277
7222
|
if (isTelegramSurfaceTool(name)) return
|
|
7278
7223
|
ctrl.setTool(name)
|
|
@@ -7282,13 +7227,12 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
7282
7227
|
return
|
|
7283
7228
|
}
|
|
7284
7229
|
case 'tool_label': {
|
|
7285
|
-
//
|
|
7230
|
+
// Real-time activity-feed driver. The PreToolUse hook wrote this
|
|
7286
7231
|
// label synchronously at tool-call time; the sidecar surfaced it
|
|
7287
7232
|
// here (~250ms) independent of the transcript flush. Accumulate it
|
|
7288
7233
|
// into the live feed and edit the activity message in place — this
|
|
7289
7234
|
// is what makes the feed deterministic on fast/clustered-tool turns
|
|
7290
7235
|
// where the JSONL tool_use rows arrive too late.
|
|
7291
|
-
if (!DRAFT_MIRROR_ENABLED) return
|
|
7292
7236
|
const turn = currentTurn
|
|
7293
7237
|
if (turn == null) return
|
|
7294
7238
|
// Surface tools (reply/stream_reply/react) are the conversation, not
|
|
@@ -7582,13 +7526,13 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
7582
7526
|
clearTimeout(turn.orphanedReplyTimeoutId)
|
|
7583
7527
|
turn.orphanedReplyTimeoutId = null
|
|
7584
7528
|
}
|
|
7585
|
-
//
|
|
7586
|
-
//
|
|
7587
|
-
//
|
|
7588
|
-
//
|
|
7589
|
-
//
|
|
7590
|
-
//
|
|
7591
|
-
if (
|
|
7529
|
+
// Clear the activity feed at the real end of the turn. This is the
|
|
7530
|
+
// no-reply safety net — a turn that ends without ever calling reply
|
|
7531
|
+
// (the answer is delivered by turn-flush / silent-end) still has its
|
|
7532
|
+
// feed removed. On a normal turn the feed was already cleared at the
|
|
7533
|
+
// first reply (the hand-off); clearActivitySummary is idempotent, so
|
|
7534
|
+
// the second call is a no-op.
|
|
7535
|
+
if (turn != null) {
|
|
7592
7536
|
clearActivitySummary(turn)
|
|
7593
7537
|
}
|
|
7594
7538
|
// #549 fix — flush any pending preamble BEFORE the answer stream is
|
|
@@ -1,10 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
2
|
import {
|
|
3
|
-
makeEmptyActivityState,
|
|
4
|
-
register,
|
|
5
|
-
formatSummary,
|
|
6
|
-
registerAndRender,
|
|
7
|
-
verbForTool,
|
|
8
3
|
describeToolUse,
|
|
9
4
|
appendActivityLine,
|
|
10
5
|
appendActivityLabel,
|
|
@@ -77,217 +72,6 @@ describe("describeToolUse — friendly per-tool rendering (draft-mirror)", () =>
|
|
|
77
72
|
});
|
|
78
73
|
});
|
|
79
74
|
|
|
80
|
-
describe("verbForTool — tool name → past-tense verb", () => {
|
|
81
|
-
it("maps standard CLI tools to readable verbs", () => {
|
|
82
|
-
expect(verbForTool("Read")).toBe("read");
|
|
83
|
-
expect(verbForTool("Write")).toBe("created");
|
|
84
|
-
expect(verbForTool("Edit")).toBe("edited");
|
|
85
|
-
expect(verbForTool("MultiEdit")).toBe("edited");
|
|
86
|
-
expect(verbForTool("NotebookEdit")).toBe("edited");
|
|
87
|
-
expect(verbForTool("Bash")).toBe("ran");
|
|
88
|
-
expect(verbForTool("BashOutput")).toBe("ran");
|
|
89
|
-
expect(verbForTool("WebSearch")).toBe("searched");
|
|
90
|
-
expect(verbForTool("Grep")).toBe("searched");
|
|
91
|
-
expect(verbForTool("Glob")).toBe("searched");
|
|
92
|
-
expect(verbForTool("WebFetch")).toBe("fetched");
|
|
93
|
-
expect(verbForTool("Task")).toBe("dispatched");
|
|
94
|
-
expect(verbForTool("Agent")).toBe("dispatched");
|
|
95
|
-
expect(verbForTool("TodoWrite")).toBe("noted");
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it("skips user-facing switchroom-telegram tools (those ARE the surface)", () => {
|
|
99
|
-
expect(verbForTool("mcp__switchroom-telegram__reply")).toBeNull();
|
|
100
|
-
expect(verbForTool("mcp__switchroom-telegram__stream_reply")).toBeNull();
|
|
101
|
-
expect(verbForTool("mcp__switchroom-telegram__edit_message")).toBeNull();
|
|
102
|
-
expect(verbForTool("mcp__switchroom-telegram__react")).toBeNull();
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it("maps recognised MCP tools (hindsight, google-workspace, notion) to specific verbs", () => {
|
|
106
|
-
// hindsight: recall/reflect → searched, retain/update_memory → saved
|
|
107
|
-
expect(verbForTool("mcp__hindsight__reflect")).toBe("searched");
|
|
108
|
-
expect(verbForTool("mcp__hindsight__recall")).toBe("searched");
|
|
109
|
-
expect(verbForTool("mcp__hindsight__retain")).toBe("saved");
|
|
110
|
-
expect(verbForTool("mcp__hindsight__update_memory")).toBe("saved");
|
|
111
|
-
// google-workspace / claude.ai variants: read-shaped → searched, write-shaped → edited
|
|
112
|
-
expect(verbForTool("mcp__google-workspace__list_files")).toBe("searched");
|
|
113
|
-
expect(verbForTool("mcp__claude_ai_Gmail__search_messages")).toBe("searched");
|
|
114
|
-
expect(verbForTool("mcp__google-workspace__create_file")).toBe("edited");
|
|
115
|
-
expect(verbForTool("mcp__claude_ai_Google_Drive__download_file_content")).toBe("searched");
|
|
116
|
-
// notion: query/get → searched, create/update → edited
|
|
117
|
-
expect(verbForTool("mcp__notion__query_database")).toBe("searched");
|
|
118
|
-
expect(verbForTool("mcp__claude_ai_Notion__notion-search")).toBe("searched");
|
|
119
|
-
expect(verbForTool("mcp__claude_ai_Notion__notion-update-page")).toBe("edited");
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it("returns 'used' for genuinely unknown MCP / future tools (generic fallback)", () => {
|
|
123
|
-
expect(verbForTool("mcp__random-third-party__do_thing")).toBe("used");
|
|
124
|
-
expect(verbForTool("SomeFutureUnknownTool")).toBe("used");
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
it("returns null for empty toolName (defensive)", () => {
|
|
128
|
-
expect(verbForTool("")).toBeNull();
|
|
129
|
-
});
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
describe("register + formatSummary — Claude Code-style summary", () => {
|
|
133
|
-
it("formats a single Read as 'Read a file'", () => {
|
|
134
|
-
const s = makeEmptyActivityState();
|
|
135
|
-
register(s, "Read");
|
|
136
|
-
expect(formatSummary(s)).toBe("Read a file");
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
it("formats multiple Reads as 'Read N files'", () => {
|
|
140
|
-
const s = makeEmptyActivityState();
|
|
141
|
-
register(s, "Read");
|
|
142
|
-
register(s, "Read");
|
|
143
|
-
register(s, "Read");
|
|
144
|
-
expect(formatSummary(s)).toBe("Read 3 files");
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
it("formats single Bash as 'Ran a command'", () => {
|
|
148
|
-
const s = makeEmptyActivityState();
|
|
149
|
-
register(s, "Bash");
|
|
150
|
-
expect(formatSummary(s)).toBe("Ran a command");
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it("formats multiple Bash as 'Ran N commands'", () => {
|
|
154
|
-
const s = makeEmptyActivityState();
|
|
155
|
-
for (let i = 0; i < 5; i++) register(s, "Bash");
|
|
156
|
-
expect(formatSummary(s)).toBe("Ran 5 commands");
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
it("joins multiple verb-classes with commas (first-occurrence order)", () => {
|
|
160
|
-
const s = makeEmptyActivityState();
|
|
161
|
-
// Tools fire in this order: Read → Bash → Edit
|
|
162
|
-
register(s, "Read");
|
|
163
|
-
register(s, "Bash");
|
|
164
|
-
register(s, "Edit");
|
|
165
|
-
// The summary renders chronologically: read, ran, edited.
|
|
166
|
-
expect(formatSummary(s)).toBe("Read a file, ran a command, edited a file");
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
it("matches the Claude Code screenshot examples", () => {
|
|
170
|
-
// "Ran 5 commands, read a file"
|
|
171
|
-
const s1 = makeEmptyActivityState();
|
|
172
|
-
for (let i = 0; i < 5; i++) register(s1, "Bash");
|
|
173
|
-
register(s1, "Read");
|
|
174
|
-
expect(formatSummary(s1)).toBe("Ran 5 commands, read a file");
|
|
175
|
-
|
|
176
|
-
// "Edited a file, read a file, ran a command"
|
|
177
|
-
const s2 = makeEmptyActivityState();
|
|
178
|
-
register(s2, "Edit");
|
|
179
|
-
register(s2, "Read");
|
|
180
|
-
register(s2, "Bash");
|
|
181
|
-
expect(formatSummary(s2)).toBe("Edited a file, read a file, ran a command");
|
|
182
|
-
|
|
183
|
-
// "Created a file, ran a command"
|
|
184
|
-
const s3 = makeEmptyActivityState();
|
|
185
|
-
register(s3, "Write");
|
|
186
|
-
register(s3, "Bash");
|
|
187
|
-
expect(formatSummary(s3)).toBe("Created a file, ran a command");
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
it("returns null when state is empty", () => {
|
|
191
|
-
expect(formatSummary(makeEmptyActivityState())).toBeNull();
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
it("ignores user-facing tools (reply/stream_reply etc.)", () => {
|
|
195
|
-
const s = makeEmptyActivityState();
|
|
196
|
-
register(s, "mcp__switchroom-telegram__reply");
|
|
197
|
-
register(s, "mcp__switchroom-telegram__stream_reply");
|
|
198
|
-
expect(formatSummary(s)).toBeNull(); // nothing tracked
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
it("includes generic 'used' for genuinely-unknown MCP tools (fallback)", () => {
|
|
202
|
-
const s = makeEmptyActivityState();
|
|
203
|
-
register(s, "mcp__random-third-party__do_thing");
|
|
204
|
-
expect(formatSummary(s)).toBe("Used a tool");
|
|
205
|
-
register(s, "mcp__another-unknown-server__something_else");
|
|
206
|
-
expect(formatSummary(s)).toBe("Used 2 tools");
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
it("maps recognised MCP tools to natural-language summaries (no generic 'Used N tools')", () => {
|
|
210
|
-
// hindsight search shows up as 'searched' (memory)
|
|
211
|
-
const s = makeEmptyActivityState();
|
|
212
|
-
register(s, "mcp__hindsight__reflect");
|
|
213
|
-
expect(formatSummary(s)).toBe("Ran a search");
|
|
214
|
-
register(s, "mcp__hindsight__reflect");
|
|
215
|
-
expect(formatSummary(s)).toBe("Ran 2 searches");
|
|
216
|
-
// hindsight retain shows up as 'saved a memory'
|
|
217
|
-
register(s, "mcp__hindsight__retain");
|
|
218
|
-
expect(formatSummary(s)).toBe("Ran 2 searches, saved a memory");
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
it("tracks firstToolName for forensic / telemetry use", () => {
|
|
222
|
-
const s = makeEmptyActivityState();
|
|
223
|
-
register(s, "Read");
|
|
224
|
-
register(s, "Bash");
|
|
225
|
-
expect(s.firstToolName).toBe("Read");
|
|
226
|
-
});
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
describe("parallel-tool-use coalescing — render only reflects accumulated state", () => {
|
|
230
|
-
it("synchronous burst of N tool_uses produces the right summary at each step", () => {
|
|
231
|
-
// Modern Claude emits parallel tool_uses in a tight sync loop. The
|
|
232
|
-
// gateway calls register() N times before any async drain runs.
|
|
233
|
-
// After N registers, the rendered string should reflect ALL of them
|
|
234
|
-
// — so when the drain fires once with the latest pendingRender, the
|
|
235
|
-
// sent text is correct and complete.
|
|
236
|
-
const s = makeEmptyActivityState();
|
|
237
|
-
register(s, "Read");
|
|
238
|
-
register(s, "Read");
|
|
239
|
-
register(s, "Read");
|
|
240
|
-
register(s, "Bash");
|
|
241
|
-
register(s, "Bash");
|
|
242
|
-
expect(formatSummary(s)).toBe("Read 3 files, ran 2 commands");
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
it("ordering is preserved across a chronological burst", () => {
|
|
246
|
-
const s = makeEmptyActivityState();
|
|
247
|
-
// Simulates: Bash, then Read, then Bash, then Read, then Edit
|
|
248
|
-
register(s, "Bash");
|
|
249
|
-
register(s, "Read");
|
|
250
|
-
register(s, "Bash");
|
|
251
|
-
register(s, "Read");
|
|
252
|
-
register(s, "Edit");
|
|
253
|
-
// Bash was first, then Read, then Edit. Counts: bash 2, read 2, edit 1.
|
|
254
|
-
expect(formatSummary(s)).toBe(
|
|
255
|
-
"Ran 2 commands, read 2 files, edited a file",
|
|
256
|
-
);
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
it("registerAndRender returns null on user-facing tools (no race contribution)", () => {
|
|
260
|
-
const s = makeEmptyActivityState();
|
|
261
|
-
register(s, "Read");
|
|
262
|
-
// A reply tool fires concurrently — should not enter the activity state.
|
|
263
|
-
expect(
|
|
264
|
-
registerAndRender(s, "mcp__switchroom-telegram__reply"),
|
|
265
|
-
).toBeNull();
|
|
266
|
-
// State still reflects only the Read.
|
|
267
|
-
expect(formatSummary(s)).toBe("Read a file");
|
|
268
|
-
});
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
describe("registerAndRender — ergonomic full-pipeline call", () => {
|
|
272
|
-
it("returns the updated rendered text on a real tool (chronological)", () => {
|
|
273
|
-
const s = makeEmptyActivityState();
|
|
274
|
-
expect(registerAndRender(s, "Read")).toBe("Read a file");
|
|
275
|
-
// Bash fires AFTER Read — chronological order shows read first.
|
|
276
|
-
expect(registerAndRender(s, "Bash")).toBe(
|
|
277
|
-
"Read a file, ran a command",
|
|
278
|
-
);
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
it("returns null on a surface tool (no-op)", () => {
|
|
282
|
-
const s = makeEmptyActivityState();
|
|
283
|
-
expect(
|
|
284
|
-
registerAndRender(s, "mcp__switchroom-telegram__reply"),
|
|
285
|
-
).toBeNull();
|
|
286
|
-
// State unchanged
|
|
287
|
-
expect(s.firstToolName).toBeNull();
|
|
288
|
-
});
|
|
289
|
-
});
|
|
290
|
-
|
|
291
75
|
describe("appendActivityLine + renderActivityFeed — accumulating activity feed", () => {
|
|
292
76
|
it("accumulates distinct actions chronologically (newest = current → bold, earlier = done ✓ italic)", () => {
|
|
293
77
|
const lines: string[] = [];
|