switchroom 0.13.57 → 0.13.59
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/cli/switchroom.js +451 -343
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +611 -759
- package/telegram-plugin/gateway/gateway.ts +191 -92
- package/telegram-plugin/tests/tool-activity-summary.test.ts +191 -0
- package/telegram-plugin/tool-activity-summary.ts +164 -0
- package/telegram-plugin/tests/tool-intent-surface.test.ts +0 -128
- package/telegram-plugin/tool-intent-surface.ts +0 -155
|
@@ -53,7 +53,12 @@ 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 {
|
|
56
|
+
import { allocateDraftId } from '../draft-transport.js'
|
|
57
|
+
import {
|
|
58
|
+
makeEmptyActivityState,
|
|
59
|
+
registerAndRender,
|
|
60
|
+
type ActivityState,
|
|
61
|
+
} from '../tool-activity-summary.js'
|
|
57
62
|
import { toolLabel } from '../tool-labels.js'
|
|
58
63
|
import { createTypingWrapper } from '../typing-wrap.js'
|
|
59
64
|
import { type DraftStreamHandle } from '../draft-stream.js'
|
|
@@ -81,7 +86,6 @@ import { classifyInbound } from '../inbound-classifier.js'
|
|
|
81
86
|
import * as silencePoke from '../silence-poke.js'
|
|
82
87
|
import * as pendingProgress from '../pending-work-progress.js'
|
|
83
88
|
import { writeSilentEndState, clearSilentEndState, recordUndeliveredTurnEnd } from '../silent-end.js'
|
|
84
|
-
import { markAckSent, clearAckSent } from '../ack-flag.js'
|
|
85
89
|
import { isFinalAnswerReply } from '../final-answer-detect.js'
|
|
86
90
|
import { createAnswerStream, type AnswerStreamHandle } from '../answer-stream.js'
|
|
87
91
|
import { type SessionEvent } from '../session-tail.js'
|
|
@@ -1292,15 +1296,39 @@ type CurrentTurn = {
|
|
|
1292
1296
|
// Phase 1 of #332: count of tool_use events in the current turn, for
|
|
1293
1297
|
// the tool_call_count column in the turns registry.
|
|
1294
1298
|
toolCallCount: number
|
|
1295
|
-
// Tool-
|
|
1296
|
-
//
|
|
1297
|
-
//
|
|
1298
|
-
//
|
|
1299
|
-
//
|
|
1300
|
-
//
|
|
1301
|
-
//
|
|
1302
|
-
//
|
|
1303
|
-
|
|
1299
|
+
// Tool-activity summary — mirrors Claude Code's native chat-UI
|
|
1300
|
+
// rendering ("Ran 5 commands, read a file"). Counters are
|
|
1301
|
+
// incremented in `case 'tool_use'`; `activityMessageId` holds the
|
|
1302
|
+
// Telegram message id we send/edit so a single message accumulates
|
|
1303
|
+
// the summary in place. Stops updating once `replyCalled` flips —
|
|
1304
|
+
// the model's own reply lands below the summary as the actual
|
|
1305
|
+
// content.
|
|
1306
|
+
//
|
|
1307
|
+
// Parallel-tool-use coalescing (PR #1926 review): modern Claude
|
|
1308
|
+
// emits multiple tool_uses in a tight synchronous loop (e.g. 3
|
|
1309
|
+
// parallel Reads). Without coalescing, each would see
|
|
1310
|
+
// `activityMessageId == null` and fire its own sendMessage,
|
|
1311
|
+
// producing N messages instead of one editable summary. Pattern
|
|
1312
|
+
// mirrors `telegram-plugin/answer-stream.ts`:
|
|
1313
|
+
// - `activityInFlight` — promise that resolves when the current
|
|
1314
|
+
// send/edit settles. While set, NEW tool_uses just update
|
|
1315
|
+
// `activityState` and `activityPendingRender` and return.
|
|
1316
|
+
// - When the in-flight resolves, it picks the latest
|
|
1317
|
+
// `activityPendingRender`, fires the next send/edit, and
|
|
1318
|
+
// repeats until the pending matches the last-sent.
|
|
1319
|
+
// Result: at most one Telegram call in flight at a time; the
|
|
1320
|
+
// final state always lands.
|
|
1321
|
+
toolActivity: ActivityState
|
|
1322
|
+
activityMessageId: number | null
|
|
1323
|
+
// Draft-transport id when the activity summary is streamed via
|
|
1324
|
+
// sendMessageDraft (DM-only, no thread). Each call to
|
|
1325
|
+
// sendMessageDraft(chat, draftId, text) REPLACES the draft text —
|
|
1326
|
+
// simpler than send+edit. Cleared by `clearActivitySummary` (which
|
|
1327
|
+
// sends an empty draft) when the model's reply takes over.
|
|
1328
|
+
activityDraftId: number | null
|
|
1329
|
+
activityInFlight: Promise<void> | null
|
|
1330
|
+
activityPendingRender: string | null
|
|
1331
|
+
activityLastSentRender: string | null
|
|
1304
1332
|
// Issue #195 — answer-lane streaming. Lazily created on the first text
|
|
1305
1333
|
// event of a turn (once enough text has accumulated, the stream itself
|
|
1306
1334
|
// gates on minInitialChars). Materialized and cleared at turn_end.
|
|
@@ -4955,16 +4983,6 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
4955
4983
|
// silence-poke clock so the next poke is measured from this send.
|
|
4956
4984
|
signalTracker.noteOutbound(statusKey(chat_id, threadId), Date.now())
|
|
4957
4985
|
silencePoke.noteOutbound(statusKey(chat_id, threadId), Date.now())
|
|
4958
|
-
// Ack-first gate (`reference/conversational-pacing.md` beat 1):
|
|
4959
|
-
// touch the state-dir flag so the ack-first-pretool hook lets
|
|
4960
|
-
// subsequent non-reply tool calls through this turn. Cleared at
|
|
4961
|
-
// turn_started. Best-effort — a write failure shouldn't break
|
|
4962
|
-
// reply, and the hook is kill-switched anyway.
|
|
4963
|
-
try {
|
|
4964
|
-
markAckSent()
|
|
4965
|
-
} catch (err) {
|
|
4966
|
-
process.stderr.write(`telegram gateway: markAckSent failed: ${err}\n`)
|
|
4967
|
-
}
|
|
4968
4986
|
// #1741 — only clear silent-end state on a plausibly-final reply.
|
|
4969
4987
|
// An interim ack (disable_notification:true, short text, no done)
|
|
4970
4988
|
// must NOT clear the state file; otherwise a turn that ends with
|
|
@@ -5560,13 +5578,6 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
|
|
|
5560
5578
|
const sKey = statusKey(streamChatId, streamThreadId)
|
|
5561
5579
|
signalTracker.noteOutbound(sKey, Date.now())
|
|
5562
5580
|
silencePoke.noteOutbound(sKey, Date.now())
|
|
5563
|
-
// Ack-first gate: stream_reply's first emit also unlocks subsequent
|
|
5564
|
-
// tool calls. See ack-flag.ts + ack-first-pretool.ts.
|
|
5565
|
-
try {
|
|
5566
|
-
markAckSent()
|
|
5567
|
-
} catch (err) {
|
|
5568
|
-
process.stderr.write(`telegram gateway: markAckSent (stream_reply) failed: ${err}\n`)
|
|
5569
|
-
}
|
|
5570
5581
|
// #1741 — see executeReply for the rationale: only a plausibly-
|
|
5571
5582
|
// final stream_reply clears the silent-end state. An interim
|
|
5572
5583
|
// ack via stream_reply must NOT clear; the Stop hook needs
|
|
@@ -6777,6 +6788,120 @@ function closeProgressLane(chatId: string, threadId: number | undefined): void {
|
|
|
6777
6788
|
}
|
|
6778
6789
|
}
|
|
6779
6790
|
|
|
6791
|
+
/**
|
|
6792
|
+
* Drain the tool-activity summary's pending render queue. Single-flight
|
|
6793
|
+
* by construction (caller assigns the returned promise to
|
|
6794
|
+
* `turn.activityInFlight`; while set, new tool_uses only update
|
|
6795
|
+
* `turn.activityPendingRender` and return).
|
|
6796
|
+
*
|
|
6797
|
+
* Transport priority (mirrors the existing answer-stream pattern):
|
|
6798
|
+
*
|
|
6799
|
+
* 1. DM with no thread AND sendMessageDraft API available →
|
|
6800
|
+
* DRAFT TRANSPORT. Each call REPLACES the draft text (no
|
|
6801
|
+
* edit-in-place needed); the user sees a live preview in their
|
|
6802
|
+
* Telegram compose area as the agent works. When the model's
|
|
6803
|
+
* reply tool lands, `clearActivitySummary` sends an empty draft
|
|
6804
|
+
* to wipe it — only the real reply persists.
|
|
6805
|
+
*
|
|
6806
|
+
* 2. Anything else (forum topic, draft API absent) → fall through
|
|
6807
|
+
* to sendMessage + editMessageText. The activity message is a
|
|
6808
|
+
* real chat message; `clearActivitySummary` deletes it when the
|
|
6809
|
+
* reply tool takes over.
|
|
6810
|
+
*
|
|
6811
|
+
* The drain holds a reference to `turn`, so a turn-swap mid-drain
|
|
6812
|
+
* doesn't corrupt the next turn's atom — late writes land on the
|
|
6813
|
+
* captured `turn` (already-completed turn, harmless).
|
|
6814
|
+
*/
|
|
6815
|
+
async function drainActivitySummary(turn: CurrentTurn): Promise<void> {
|
|
6816
|
+
try {
|
|
6817
|
+
while (turn.activityPendingRender !== turn.activityLastSentRender) {
|
|
6818
|
+
const target = turn.activityPendingRender
|
|
6819
|
+
if (target == null) break
|
|
6820
|
+
const html = `<i>${target}</i>`
|
|
6821
|
+
const chat = turn.sessionChatId
|
|
6822
|
+
const thread = turn.sessionThreadId
|
|
6823
|
+
// sendMessageDraft doesn't support forum threads.
|
|
6824
|
+
const useDraft = turn.isDm && thread == null && sendMessageDraftFn != null
|
|
6825
|
+
try {
|
|
6826
|
+
if (useDraft) {
|
|
6827
|
+
if (turn.activityDraftId == null) {
|
|
6828
|
+
turn.activityDraftId = allocateDraftId()
|
|
6829
|
+
}
|
|
6830
|
+
const draftId = turn.activityDraftId
|
|
6831
|
+
await sendMessageDraftFn!(chat, draftId, html, undefined)
|
|
6832
|
+
} else if (turn.activityMessageId == null) {
|
|
6833
|
+
const sent = await robustApiCall(
|
|
6834
|
+
() => bot.api.sendMessage(chat, html, {
|
|
6835
|
+
...(thread != null ? { message_thread_id: thread } : {}),
|
|
6836
|
+
parse_mode: 'HTML',
|
|
6837
|
+
disable_notification: true,
|
|
6838
|
+
}),
|
|
6839
|
+
{ chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.send' },
|
|
6840
|
+
)
|
|
6841
|
+
turn.activityMessageId = sent.message_id
|
|
6842
|
+
} else {
|
|
6843
|
+
const id = turn.activityMessageId
|
|
6844
|
+
await robustApiCall(
|
|
6845
|
+
() => bot.api.editMessageText(chat, id, html, { parse_mode: 'HTML' }),
|
|
6846
|
+
{ chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.edit' },
|
|
6847
|
+
)
|
|
6848
|
+
}
|
|
6849
|
+
turn.activityLastSentRender = target
|
|
6850
|
+
} catch (err) {
|
|
6851
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
6852
|
+
if (!msg.includes('message is not modified')) {
|
|
6853
|
+
process.stderr.write(`telegram gateway: activity-summary drain failed: ${msg}\n`)
|
|
6854
|
+
}
|
|
6855
|
+
// Mark as sent so we don't infinite-loop on a stuck render.
|
|
6856
|
+
turn.activityLastSentRender = target
|
|
6857
|
+
}
|
|
6858
|
+
}
|
|
6859
|
+
} finally {
|
|
6860
|
+
turn.activityInFlight = null
|
|
6861
|
+
}
|
|
6862
|
+
}
|
|
6863
|
+
|
|
6864
|
+
/**
|
|
6865
|
+
* Clear the activity summary when the model's reply tool takes over
|
|
6866
|
+
* as the authoritative surface. Awaits any in-flight render so we
|
|
6867
|
+
* don't race a stale write against the clear, then either sends an
|
|
6868
|
+
* empty draft (clears the compose-area preview) or deletes the
|
|
6869
|
+
* persisted message. Idempotent + best-effort — failure stderr-logs
|
|
6870
|
+
* but does not block.
|
|
6871
|
+
*
|
|
6872
|
+
* Called from `case 'tool_use'` the moment we see a Telegram reply
|
|
6873
|
+
* tool fire, so the user sees the real reply land in the same beat
|
|
6874
|
+
* the summary disappears.
|
|
6875
|
+
*/
|
|
6876
|
+
function clearActivitySummary(turn: CurrentTurn): void {
|
|
6877
|
+
const chat = turn.sessionChatId
|
|
6878
|
+
const thread = turn.sessionThreadId
|
|
6879
|
+
const inFlight = turn.activityInFlight ?? Promise.resolve()
|
|
6880
|
+
void inFlight.then(async () => {
|
|
6881
|
+
if (turn.activityDraftId != null && sendMessageDraftFn != null) {
|
|
6882
|
+
const draftId = turn.activityDraftId
|
|
6883
|
+
turn.activityDraftId = null
|
|
6884
|
+
try {
|
|
6885
|
+
// Empty text → Telegram clears the draft.
|
|
6886
|
+
await sendMessageDraftFn(chat, draftId, '', undefined)
|
|
6887
|
+
} catch (err) {
|
|
6888
|
+
process.stderr.write(`telegram gateway: activity-summary draft-clear failed: ${err}\n`)
|
|
6889
|
+
}
|
|
6890
|
+
} else if (turn.activityMessageId != null) {
|
|
6891
|
+
const id = turn.activityMessageId
|
|
6892
|
+
turn.activityMessageId = null
|
|
6893
|
+
try {
|
|
6894
|
+
await robustApiCall(
|
|
6895
|
+
() => bot.api.deleteMessage(chat, id),
|
|
6896
|
+
{ chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.delete' },
|
|
6897
|
+
)
|
|
6898
|
+
} catch (err) {
|
|
6899
|
+
process.stderr.write(`telegram gateway: activity-summary delete failed: ${err}\n`)
|
|
6900
|
+
}
|
|
6901
|
+
}
|
|
6902
|
+
})
|
|
6903
|
+
}
|
|
6904
|
+
|
|
6780
6905
|
function handleSessionEvent(ev: SessionEvent): void {
|
|
6781
6906
|
switch (ev.kind) {
|
|
6782
6907
|
case 'enqueue': {
|
|
@@ -6801,14 +6926,6 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
6801
6926
|
statusKey(ev.chatId, enqThreadId),
|
|
6802
6927
|
'handback',
|
|
6803
6928
|
)
|
|
6804
|
-
// Ack-first gate (`reference/conversational-pacing.md` beat 1):
|
|
6805
|
-
// wipe the prior turn's `ack-sent.flag` so the ack-first-
|
|
6806
|
-
// pretool hook re-arms for this fresh turn. Centralised HERE
|
|
6807
|
-
// (not in handleInbound) because `enqueue` is the single
|
|
6808
|
-
// canonical fresh-turn atom — fires for real inbounds, cron
|
|
6809
|
-
// fires, subagent-handback channel wakes, vault-grant resumes,
|
|
6810
|
-
// and restart markers alike. Best-effort — see ack-flag.ts.
|
|
6811
|
-
clearAckSent()
|
|
6812
6929
|
}
|
|
6813
6930
|
if (ev.chatId) {
|
|
6814
6931
|
// Issue #195: if a previous turn left an answer-lane stream open
|
|
@@ -6842,7 +6959,12 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
6842
6959
|
lastAssistantMsgId: null,
|
|
6843
6960
|
lastAssistantDone: false,
|
|
6844
6961
|
toolCallCount: 0,
|
|
6845
|
-
|
|
6962
|
+
toolActivity: makeEmptyActivityState(),
|
|
6963
|
+
activityMessageId: null,
|
|
6964
|
+
activityDraftId: null,
|
|
6965
|
+
activityInFlight: null,
|
|
6966
|
+
activityPendingRender: null,
|
|
6967
|
+
activityLastSentRender: null,
|
|
6846
6968
|
answerStream: null,
|
|
6847
6969
|
isDm: isDmChatId(ev.chatId),
|
|
6848
6970
|
}
|
|
@@ -6954,70 +7076,47 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
6954
7076
|
// Phase tracking removed in #553 PR 5 — phases only fed the
|
|
6955
7077
|
// placeholder-heartbeat label, which has been retired.
|
|
6956
7078
|
if (isTelegramReplyTool(name)) {
|
|
7079
|
+
const wasFirstReply = !turn.replyCalled
|
|
6957
7080
|
turn.replyCalled = true
|
|
6958
7081
|
if (turn.orphanedReplyTimeoutId != null) {
|
|
6959
7082
|
clearTimeout(turn.orphanedReplyTimeoutId)
|
|
6960
7083
|
turn.orphanedReplyTimeoutId = null
|
|
6961
7084
|
}
|
|
7085
|
+
// The model's real reply takes over as the authoritative
|
|
7086
|
+
// surface. Clear the activity summary — for drafts, send an
|
|
7087
|
+
// empty draft to wipe the compose-area preview; for persisted
|
|
7088
|
+
// messages, delete. The user sees the real reply land in the
|
|
7089
|
+
// same beat the summary disappears.
|
|
7090
|
+
if (wasFirstReply) {
|
|
7091
|
+
clearActivitySummary(turn)
|
|
7092
|
+
}
|
|
6962
7093
|
}
|
|
6963
7094
|
// Tool-intent surface — companion to the PreToolUse ack-first gate
|
|
6964
7095
|
// (#1921). On the FIRST non-reply tool_use of a turn AND only when
|
|
6965
|
-
//
|
|
6966
|
-
//
|
|
6967
|
-
//
|
|
6968
|
-
//
|
|
6969
|
-
//
|
|
6970
|
-
//
|
|
6971
|
-
//
|
|
6972
|
-
|
|
6973
|
-
|
|
6974
|
-
|
|
6975
|
-
|
|
6976
|
-
|
|
6977
|
-
|
|
6978
|
-
|
|
6979
|
-
|
|
6980
|
-
|
|
6981
|
-
|
|
6982
|
-
|
|
6983
|
-
|
|
6984
|
-
|
|
6985
|
-
|
|
6986
|
-
|
|
6987
|
-
|
|
6988
|
-
try {
|
|
6989
|
-
markAckSent()
|
|
6990
|
-
} catch (err) {
|
|
6991
|
-
process.stderr.write(`telegram gateway: intent-surface markAckSent failed: ${err}\n`)
|
|
7096
|
+
// Tool-activity summary — same shape Claude Code natively renders
|
|
7097
|
+
// in its CLI/chat UI ("Ran 5 commands, read a file"). The gateway
|
|
7098
|
+
// accumulates non-reply tool_use events into `turn.toolActivity`
|
|
7099
|
+
// and sends ONE Telegram message that edits in place as more tools
|
|
7100
|
+
// land. Stops editing once the model calls `reply` — the summary
|
|
7101
|
+
// line stays as the final state. No model-side prompting; no per-
|
|
7102
|
+
// tool labels. Just surface what's already in the stream.
|
|
7103
|
+
//
|
|
7104
|
+
// Single-flight coalescing (PR #1926 review): modern Claude emits
|
|
7105
|
+
// multiple tool_uses in a synchronous burst (parallel Reads,
|
|
7106
|
+
// Bashes, etc.). All would otherwise race past the message-id
|
|
7107
|
+
// capture and produce N messages. Pattern mirrors answer-stream:
|
|
7108
|
+
// update `activityPendingRender` synchronously here; a single
|
|
7109
|
+
// worker promise drains the pending state, sending or editing
|
|
7110
|
+
// exactly once at a time and re-running until pending matches
|
|
7111
|
+
// the last-sent. Captures `turn` so a late drain after turn-swap
|
|
7112
|
+
// can't corrupt the next turn's atom.
|
|
7113
|
+
if (!turn.replyCalled && !isTelegramSurfaceTool(name)) {
|
|
7114
|
+
const rendered = registerAndRender(turn.toolActivity, name)
|
|
7115
|
+
if (rendered != null) {
|
|
7116
|
+
turn.activityPendingRender = rendered
|
|
7117
|
+
if (turn.activityInFlight == null) {
|
|
7118
|
+
turn.activityInFlight = drainActivitySummary(turn)
|
|
6992
7119
|
}
|
|
6993
|
-
const surfaceChat = turn.sessionChatId
|
|
6994
|
-
const surfaceThread = turn.sessionThreadId
|
|
6995
|
-
const surfaceText = surface.text
|
|
6996
|
-
void (async () => {
|
|
6997
|
-
try {
|
|
6998
|
-
await robustApiCall(
|
|
6999
|
-
() => bot.api.sendMessage(surfaceChat, surfaceText, {
|
|
7000
|
-
...(surfaceThread != null ? { message_thread_id: surfaceThread } : {}),
|
|
7001
|
-
parse_mode: 'HTML',
|
|
7002
|
-
// Framework-narrating beat — silent, ambient, not a
|
|
7003
|
-
// device buzz. The user is meant to glance and know
|
|
7004
|
-
// the model is alive + on-task.
|
|
7005
|
-
disable_notification: true,
|
|
7006
|
-
}),
|
|
7007
|
-
{ chat_id: surfaceChat, ...(surfaceThread != null ? { threadId: surfaceThread } : {}), verb: 'intent-surface' },
|
|
7008
|
-
)
|
|
7009
|
-
// Deliberately NOT calling signalTracker.noteOutbound /
|
|
7010
|
-
// silencePoke.noteOutbound here — framework-owned
|
|
7011
|
-
// ambient messages are not model-author outbounds, so
|
|
7012
|
-
// they should not reset the TTFO clock or short-circuit
|
|
7013
|
-
// the silence-poke ladder. Mirrors the sibling
|
|
7014
|
-
// `onAwarenessPing` handler (silence-poke.ts:169
|
|
7015
|
-
// contract: "Caller must NOT call back into noteOutbound
|
|
7016
|
-
// for this — it's a framework-sourced message").
|
|
7017
|
-
} catch (err) {
|
|
7018
|
-
process.stderr.write(`telegram gateway: intent-surface send failed: ${err}\n`)
|
|
7019
|
-
}
|
|
7020
|
-
})()
|
|
7021
7120
|
}
|
|
7022
7121
|
}
|
|
7023
7122
|
if (!ctrl) return
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
makeEmptyActivityState,
|
|
4
|
+
register,
|
|
5
|
+
formatSummary,
|
|
6
|
+
registerAndRender,
|
|
7
|
+
verbForTool,
|
|
8
|
+
} from "../tool-activity-summary.js";
|
|
9
|
+
|
|
10
|
+
describe("verbForTool — tool name → past-tense verb", () => {
|
|
11
|
+
it("maps standard CLI tools to readable verbs", () => {
|
|
12
|
+
expect(verbForTool("Read")).toBe("read");
|
|
13
|
+
expect(verbForTool("Write")).toBe("created");
|
|
14
|
+
expect(verbForTool("Edit")).toBe("edited");
|
|
15
|
+
expect(verbForTool("MultiEdit")).toBe("edited");
|
|
16
|
+
expect(verbForTool("NotebookEdit")).toBe("edited");
|
|
17
|
+
expect(verbForTool("Bash")).toBe("ran");
|
|
18
|
+
expect(verbForTool("BashOutput")).toBe("ran");
|
|
19
|
+
expect(verbForTool("WebSearch")).toBe("searched");
|
|
20
|
+
expect(verbForTool("Grep")).toBe("searched");
|
|
21
|
+
expect(verbForTool("Glob")).toBe("searched");
|
|
22
|
+
expect(verbForTool("WebFetch")).toBe("fetched");
|
|
23
|
+
expect(verbForTool("Task")).toBe("dispatched");
|
|
24
|
+
expect(verbForTool("Agent")).toBe("dispatched");
|
|
25
|
+
expect(verbForTool("TodoWrite")).toBe("noted");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("skips user-facing switchroom-telegram tools (those ARE the surface)", () => {
|
|
29
|
+
expect(verbForTool("mcp__switchroom-telegram__reply")).toBeNull();
|
|
30
|
+
expect(verbForTool("mcp__switchroom-telegram__stream_reply")).toBeNull();
|
|
31
|
+
expect(verbForTool("mcp__switchroom-telegram__edit_message")).toBeNull();
|
|
32
|
+
expect(verbForTool("mcp__switchroom-telegram__react")).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("returns 'used' for unknown / non-switchroom MCP tools", () => {
|
|
36
|
+
expect(verbForTool("mcp__google-workspace__list_files")).toBe("used");
|
|
37
|
+
expect(verbForTool("mcp__notion__query_database")).toBe("used");
|
|
38
|
+
expect(verbForTool("SomeFutureUnknownTool")).toBe("used");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns null for empty toolName (defensive)", () => {
|
|
42
|
+
expect(verbForTool("")).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("register + formatSummary — Claude Code-style summary", () => {
|
|
47
|
+
it("formats a single Read as 'Read a file'", () => {
|
|
48
|
+
const s = makeEmptyActivityState();
|
|
49
|
+
register(s, "Read");
|
|
50
|
+
expect(formatSummary(s)).toBe("Read a file");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("formats multiple Reads as 'Read N files'", () => {
|
|
54
|
+
const s = makeEmptyActivityState();
|
|
55
|
+
register(s, "Read");
|
|
56
|
+
register(s, "Read");
|
|
57
|
+
register(s, "Read");
|
|
58
|
+
expect(formatSummary(s)).toBe("Read 3 files");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("formats single Bash as 'Ran a command'", () => {
|
|
62
|
+
const s = makeEmptyActivityState();
|
|
63
|
+
register(s, "Bash");
|
|
64
|
+
expect(formatSummary(s)).toBe("Ran a command");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("formats multiple Bash as 'Ran N commands'", () => {
|
|
68
|
+
const s = makeEmptyActivityState();
|
|
69
|
+
for (let i = 0; i < 5; i++) register(s, "Bash");
|
|
70
|
+
expect(formatSummary(s)).toBe("Ran 5 commands");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("joins multiple verb-classes with commas (first-occurrence order)", () => {
|
|
74
|
+
const s = makeEmptyActivityState();
|
|
75
|
+
// Tools fire in this order: Read → Bash → Edit
|
|
76
|
+
register(s, "Read");
|
|
77
|
+
register(s, "Bash");
|
|
78
|
+
register(s, "Edit");
|
|
79
|
+
// The summary renders chronologically: read, ran, edited.
|
|
80
|
+
expect(formatSummary(s)).toBe("Read a file, ran a command, edited a file");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("matches the Claude Code screenshot examples", () => {
|
|
84
|
+
// "Ran 5 commands, read a file"
|
|
85
|
+
const s1 = makeEmptyActivityState();
|
|
86
|
+
for (let i = 0; i < 5; i++) register(s1, "Bash");
|
|
87
|
+
register(s1, "Read");
|
|
88
|
+
expect(formatSummary(s1)).toBe("Ran 5 commands, read a file");
|
|
89
|
+
|
|
90
|
+
// "Edited a file, read a file, ran a command"
|
|
91
|
+
const s2 = makeEmptyActivityState();
|
|
92
|
+
register(s2, "Edit");
|
|
93
|
+
register(s2, "Read");
|
|
94
|
+
register(s2, "Bash");
|
|
95
|
+
expect(formatSummary(s2)).toBe("Edited a file, read a file, ran a command");
|
|
96
|
+
|
|
97
|
+
// "Created a file, ran a command"
|
|
98
|
+
const s3 = makeEmptyActivityState();
|
|
99
|
+
register(s3, "Write");
|
|
100
|
+
register(s3, "Bash");
|
|
101
|
+
expect(formatSummary(s3)).toBe("Created a file, ran a command");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("returns null when state is empty", () => {
|
|
105
|
+
expect(formatSummary(makeEmptyActivityState())).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("ignores user-facing tools (reply/stream_reply etc.)", () => {
|
|
109
|
+
const s = makeEmptyActivityState();
|
|
110
|
+
register(s, "mcp__switchroom-telegram__reply");
|
|
111
|
+
register(s, "mcp__switchroom-telegram__stream_reply");
|
|
112
|
+
expect(formatSummary(s)).toBeNull(); // nothing tracked
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("includes generic 'used' for unknown MCP tools", () => {
|
|
116
|
+
const s = makeEmptyActivityState();
|
|
117
|
+
register(s, "mcp__google-workspace__list_files");
|
|
118
|
+
expect(formatSummary(s)).toBe("Used a tool");
|
|
119
|
+
register(s, "mcp__google-workspace__create_file");
|
|
120
|
+
expect(formatSummary(s)).toBe("Used 2 tools");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("tracks firstToolName for forensic / telemetry use", () => {
|
|
124
|
+
const s = makeEmptyActivityState();
|
|
125
|
+
register(s, "Read");
|
|
126
|
+
register(s, "Bash");
|
|
127
|
+
expect(s.firstToolName).toBe("Read");
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("parallel-tool-use coalescing — render only reflects accumulated state", () => {
|
|
132
|
+
it("synchronous burst of N tool_uses produces the right summary at each step", () => {
|
|
133
|
+
// Modern Claude emits parallel tool_uses in a tight sync loop. The
|
|
134
|
+
// gateway calls register() N times before any async drain runs.
|
|
135
|
+
// After N registers, the rendered string should reflect ALL of them
|
|
136
|
+
// — so when the drain fires once with the latest pendingRender, the
|
|
137
|
+
// sent text is correct and complete.
|
|
138
|
+
const s = makeEmptyActivityState();
|
|
139
|
+
register(s, "Read");
|
|
140
|
+
register(s, "Read");
|
|
141
|
+
register(s, "Read");
|
|
142
|
+
register(s, "Bash");
|
|
143
|
+
register(s, "Bash");
|
|
144
|
+
expect(formatSummary(s)).toBe("Read 3 files, ran 2 commands");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("ordering is preserved across a chronological burst", () => {
|
|
148
|
+
const s = makeEmptyActivityState();
|
|
149
|
+
// Simulates: Bash, then Read, then Bash, then Read, then Edit
|
|
150
|
+
register(s, "Bash");
|
|
151
|
+
register(s, "Read");
|
|
152
|
+
register(s, "Bash");
|
|
153
|
+
register(s, "Read");
|
|
154
|
+
register(s, "Edit");
|
|
155
|
+
// Bash was first, then Read, then Edit. Counts: bash 2, read 2, edit 1.
|
|
156
|
+
expect(formatSummary(s)).toBe(
|
|
157
|
+
"Ran 2 commands, read 2 files, edited a file",
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("registerAndRender returns null on user-facing tools (no race contribution)", () => {
|
|
162
|
+
const s = makeEmptyActivityState();
|
|
163
|
+
register(s, "Read");
|
|
164
|
+
// A reply tool fires concurrently — should not enter the activity state.
|
|
165
|
+
expect(
|
|
166
|
+
registerAndRender(s, "mcp__switchroom-telegram__reply"),
|
|
167
|
+
).toBeNull();
|
|
168
|
+
// State still reflects only the Read.
|
|
169
|
+
expect(formatSummary(s)).toBe("Read a file");
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("registerAndRender — ergonomic full-pipeline call", () => {
|
|
174
|
+
it("returns the updated rendered text on a real tool (chronological)", () => {
|
|
175
|
+
const s = makeEmptyActivityState();
|
|
176
|
+
expect(registerAndRender(s, "Read")).toBe("Read a file");
|
|
177
|
+
// Bash fires AFTER Read — chronological order shows read first.
|
|
178
|
+
expect(registerAndRender(s, "Bash")).toBe(
|
|
179
|
+
"Read a file, ran a command",
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("returns null on a surface tool (no-op)", () => {
|
|
184
|
+
const s = makeEmptyActivityState();
|
|
185
|
+
expect(
|
|
186
|
+
registerAndRender(s, "mcp__switchroom-telegram__reply"),
|
|
187
|
+
).toBeNull();
|
|
188
|
+
// State unchanged
|
|
189
|
+
expect(s.firstToolName).toBeNull();
|
|
190
|
+
});
|
|
191
|
+
});
|