switchroom 0.15.45 → 0.16.5
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 +56 -15
- package/dist/auth-broker/index.js +383 -97
- package/dist/cli/autoaccept-poll.js +4842 -35
- package/dist/cli/drive-write-pretool.mjs +7 -4
- package/dist/cli/notion-write-pretool.mjs +35 -4
- package/dist/cli/self-improve-apply-guard-pretool.mjs +626 -0
- package/dist/cli/self-improve-stop.mjs +428 -0
- package/dist/cli/switchroom.js +2894 -841
- package/dist/host-control/main.js +2685 -207
- package/dist/vault/approvals/kernel-server.js +7453 -7413
- package/dist/vault/broker/server.js +11428 -11388
- package/examples/minimal.yaml +1 -0
- package/examples/switchroom.yaml +1 -0
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +97 -1
- package/profiles/_shared/execution-discipline.md.hbs +18 -0
- package/profiles/default/CLAUDE.md.hbs +0 -19
- package/telegram-plugin/.claude-plugin/plugin.json +2 -2
- package/telegram-plugin/answer-stream-flag.ts +12 -49
- package/telegram-plugin/answer-stream.ts +5 -150
- package/telegram-plugin/auth-snapshot-format.ts +280 -48
- package/telegram-plugin/auto-fallback-fleet.ts +44 -1
- package/telegram-plugin/context-exhaustion.ts +12 -0
- package/telegram-plugin/demo-mask.ts +154 -0
- package/telegram-plugin/dist/bridge/bridge.js +55 -12
- package/telegram-plugin/dist/gateway/gateway.js +2938 -977
- package/telegram-plugin/dist/server.js +55 -12
- package/telegram-plugin/docs/waiting-ux-spec.md +2 -2
- package/telegram-plugin/draft-stream.ts +47 -410
- package/telegram-plugin/final-answer-detect.ts +17 -12
- package/telegram-plugin/fleet-fallback-resume.ts +131 -0
- package/telegram-plugin/format.ts +56 -19
- package/telegram-plugin/gateway/auth-add-flow.ts +332 -127
- package/telegram-plugin/gateway/auth-broker-client.ts +2 -2
- package/telegram-plugin/gateway/auth-command.ts +70 -14
- package/telegram-plugin/gateway/clean-shutdown-marker.ts +44 -0
- package/telegram-plugin/gateway/config-approval-handler.test.ts +91 -4
- package/telegram-plugin/gateway/config-approval-handler.ts +94 -13
- package/telegram-plugin/gateway/current-turn-map.ts +188 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +3 -1
- package/telegram-plugin/gateway/effort-command.ts +8 -3
- package/telegram-plugin/gateway/emission-authority.ts +369 -0
- package/telegram-plugin/gateway/feed-open-gate.ts +292 -0
- package/telegram-plugin/gateway/gateway.ts +1857 -292
- package/telegram-plugin/gateway/inject-handler.test.ts +2 -1
- package/telegram-plugin/gateway/model-command.ts +115 -4
- package/telegram-plugin/gateway/ms365-write-approval.test.ts +4 -4
- package/telegram-plugin/gateway/represent-guard.ts +72 -0
- package/telegram-plugin/gateway/status-surface-log.test.ts +5 -4
- package/telegram-plugin/gateway/status-surface-log.ts +14 -3
- package/telegram-plugin/history.ts +33 -11
- package/telegram-plugin/hooks/repo-context-pretool.mjs +26 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +5 -0
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +8 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +39 -15
- package/telegram-plugin/issues-card.ts +4 -0
- package/telegram-plugin/model-unavailable.ts +124 -0
- package/telegram-plugin/narrative-dedup.ts +69 -0
- package/telegram-plugin/over-ping-safety-net.ts +70 -4
- package/telegram-plugin/package.json +3 -3
- package/telegram-plugin/pending-work-progress.ts +12 -0
- package/telegram-plugin/permission-rule.ts +32 -5
- package/telegram-plugin/permission-title.ts +152 -9
- package/telegram-plugin/quota-check.ts +13 -0
- package/telegram-plugin/quota-watch.ts +135 -7
- package/telegram-plugin/registry/turns-schema.test.ts +24 -0
- package/telegram-plugin/registry/turns-schema.ts +9 -0
- package/telegram-plugin/runtime-metrics.ts +13 -0
- package/telegram-plugin/session-tail.ts +96 -11
- package/telegram-plugin/silence-poke.ts +170 -24
- package/telegram-plugin/slot-banner-driver.ts +3 -0
- package/telegram-plugin/status-no-truncate.ts +44 -0
- package/telegram-plugin/status-reactions.ts +20 -3
- package/telegram-plugin/stream-controller.ts +4 -23
- package/telegram-plugin/stream-reply-handler.ts +6 -24
- package/telegram-plugin/streaming-metrics.ts +91 -0
- package/telegram-plugin/subagent-watcher.ts +212 -66
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +47 -0
- package/telegram-plugin/tests/answer-stream-dedup.test.ts +9 -26
- package/telegram-plugin/tests/answer-stream-flag.test.ts +25 -58
- package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +41 -51
- package/telegram-plugin/tests/answer-stream.test.ts +2 -411
- package/telegram-plugin/tests/auth-add-flow.test.ts +488 -253
- package/telegram-plugin/tests/auth-command-format2.test.ts +71 -1
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +376 -6
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +120 -0
- package/telegram-plugin/tests/cross-turn-card-gate.test.ts +424 -0
- package/telegram-plugin/tests/demo-mask.test.ts +127 -0
- package/telegram-plugin/tests/draft-stream.test.ts +0 -827
- package/telegram-plugin/tests/emission-authority-card-drain-gate.test.ts +236 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +488 -0
- package/telegram-plugin/tests/emission-authority-open-gate.test.ts +179 -0
- package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +395 -0
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +177 -0
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +146 -0
- package/telegram-plugin/tests/feed-open-gate.test.ts +259 -0
- package/telegram-plugin/tests/feed-survival.test.ts +526 -0
- package/telegram-plugin/tests/fleet-fallback-resume.test.ts +197 -0
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +117 -0
- package/telegram-plugin/tests/gateway-no-reply-single-emit.test.ts +4 -11
- package/telegram-plugin/tests/history.test.ts +60 -0
- package/telegram-plugin/tests/model-command.test.ts +134 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +118 -0
- package/telegram-plugin/tests/narrative-dedup.test.ts +118 -0
- package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +285 -0
- package/telegram-plugin/tests/over-ping-final-answer-decoupling.test.ts +194 -0
- package/telegram-plugin/tests/over-ping-safety-net.test.ts +2 -2
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +373 -0
- package/telegram-plugin/tests/permission-card-origin-kill-switch.test.ts +42 -0
- package/telegram-plugin/tests/permission-rule.test.ts +17 -0
- package/telegram-plugin/tests/permission-title.test.ts +206 -17
- package/telegram-plugin/tests/quota-watch.test.ts +252 -9
- package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -1
- package/telegram-plugin/tests/repo-context-pretool.test.ts +62 -0
- package/telegram-plugin/tests/represent-guard.test.ts +162 -0
- package/telegram-plugin/tests/session-tail.test.ts +147 -3
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +18 -0
- package/telegram-plugin/tests/status-card-budget-parity.test.ts +72 -0
- package/telegram-plugin/tests/status-surface-log.test.ts +146 -0
- package/telegram-plugin/tests/subagent-watcher-clip-narrative.test.ts +58 -0
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +102 -0
- package/telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts +225 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +147 -0
- package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +597 -0
- package/telegram-plugin/tests/telegram-format.test.ts +101 -6
- package/telegram-plugin/tests/tool-activity-summary.test.ts +550 -15
- package/telegram-plugin/tests/tool-label-pretool.test.ts +73 -0
- package/telegram-plugin/tests/tool-label-sidecar.test.ts +44 -0
- package/telegram-plugin/tests/tool-labels.test.ts +67 -0
- package/telegram-plugin/tests/turn-liveness-floor.test.ts +196 -0
- package/telegram-plugin/tests/turn-liveness-invariant.test.ts +340 -0
- package/telegram-plugin/tests/welcome-text.test.ts +32 -3
- package/telegram-plugin/tests/worker-activity-feed.test.ts +470 -22
- package/telegram-plugin/tool-activity-summary.ts +375 -58
- package/telegram-plugin/turn-liveness-floor.ts +240 -0
- package/telegram-plugin/uat/assertions.ts +115 -0
- package/telegram-plugin/uat/driver.ts +68 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +119 -133
- package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +94 -0
- package/telegram-plugin/uat/scenarios/jtbd-cross-turn-card-dm.test.ts +109 -0
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-thinkgap-dm.test.ts +478 -0
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +396 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-feed-open-dm.test.ts +202 -0
- package/telegram-plugin/uat/scenarios/jtbd-reply-is-last-dm.test.ts +202 -0
- package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +93 -87
- package/telegram-plugin/welcome-text.ts +13 -1
- package/telegram-plugin/worker-activity-feed.ts +157 -82
- package/telegram-plugin/draft-transport.ts +0 -122
- package/telegram-plugin/tests/draft-retirement-wiring.test.ts +0 -82
- package/telegram-plugin/tests/draft-transport.test.ts +0 -211
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the computeLabel function in hooks/tool-label-pretool.mjs.
|
|
3
|
+
*
|
|
4
|
+
* These tests verify:
|
|
5
|
+
* 1. computeLabel returns non-null for unknown built-in tools (never-null fallthrough)
|
|
6
|
+
* 2. computeLabel returns non-null for unknown MCP tools (humanized name fallback)
|
|
7
|
+
* 3. Surface-tool suppression works for non-switchroom-telegram telegram-suffixed keys
|
|
8
|
+
* (e.g. clerk-telegram, custom fork) — not just the hardcoded "switchroom-telegram"
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect } from 'vitest'
|
|
12
|
+
// The hook exports computeLabel for unit testing (see "Skip main() when imported" guard).
|
|
13
|
+
import { computeLabel } from '../hooks/tool-label-pretool.mjs'
|
|
14
|
+
|
|
15
|
+
describe('computeLabel — never-null built-in fallthrough', () => {
|
|
16
|
+
it('returns non-null for an unrecognized built-in tool', () => {
|
|
17
|
+
// A future Claude built-in tool that the hook doesn't know about yet.
|
|
18
|
+
// The never-null fallthrough must prevent a dark-turn (no feed, no label).
|
|
19
|
+
expect(computeLabel('SomeFutureBuiltin', {})).not.toBeNull()
|
|
20
|
+
// Must be a non-empty string too.
|
|
21
|
+
expect(computeLabel('SomeFutureBuiltin', {})).toBeTruthy()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('returns non-null for an unknown MCP tool (operator-configured server)', () => {
|
|
25
|
+
// An operator-installed MCP server the hook has no explicit label for.
|
|
26
|
+
// Falls through to the humanized-name fallback: "Using dothing".
|
|
27
|
+
expect(computeLabel('mcp__brandnew__dothing', {})).not.toBeNull()
|
|
28
|
+
expect(computeLabel('mcp__brandnew__dothing', {})).toBeTruthy()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('humanizes the tool name for an unknown MCP tool with no description', () => {
|
|
32
|
+
expect(computeLabel('mcp__brandnew__dothing', {})).toBe('Using dothing')
|
|
33
|
+
expect(computeLabel('mcp__acme_corp__send_message', {})).toBe('Using send message')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('uses the model-authored description for an unknown MCP tool when present', () => {
|
|
37
|
+
expect(
|
|
38
|
+
computeLabel('mcp__brandnew__dothing', { description: 'Fetched the quarterly report' }),
|
|
39
|
+
).toBe('Fetched the quarterly report')
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('computeLabel — surface-tool suppression is key-agnostic', () => {
|
|
44
|
+
it('returns null for telegram reply/stream_reply under any registration key', () => {
|
|
45
|
+
// Standard switchroom-telegram key.
|
|
46
|
+
expect(computeLabel('mcp__switchroom-telegram__reply', {})).toBeNull()
|
|
47
|
+
expect(computeLabel('mcp__switchroom-telegram__stream_reply', {})).toBeNull()
|
|
48
|
+
|
|
49
|
+
// Legacy clerk-telegram key — same plugin, different registration name.
|
|
50
|
+
expect(computeLabel('mcp__clerk-telegram__reply', {})).toBeNull()
|
|
51
|
+
expect(computeLabel('mcp__clerk-telegram__stream_reply', {})).toBeNull()
|
|
52
|
+
|
|
53
|
+
// Hypothetical custom fork.
|
|
54
|
+
expect(computeLabel('mcp__my-custom-telegram__reply', {})).toBeNull()
|
|
55
|
+
expect(computeLabel('mcp__my-custom-telegram__stream_reply', {})).toBeNull()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('returns null for telegram react under any registration key', () => {
|
|
59
|
+
expect(computeLabel('mcp__switchroom-telegram__react', {})).toBeNull()
|
|
60
|
+
expect(computeLabel('mcp__clerk-telegram__react', {})).toBeNull()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('returns null for telegram send_typing under any registration key', () => {
|
|
64
|
+
expect(computeLabel('mcp__switchroom-telegram__send_typing', {})).toBeNull()
|
|
65
|
+
expect(computeLabel('mcp__clerk-telegram__send_typing', {})).toBeNull()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('returns a label (not null) for telegram get_recent_messages', () => {
|
|
69
|
+
// get_recent_messages is a read/query tool, not a surface tool — it should be labeled.
|
|
70
|
+
expect(computeLabel('mcp__switchroom-telegram__get_recent_messages', {})).toBe('Reading chat history')
|
|
71
|
+
expect(computeLabel('mcp__clerk-telegram__get_recent_messages', {})).toBe('Reading chat history')
|
|
72
|
+
})
|
|
73
|
+
})
|
|
@@ -147,4 +147,48 @@ describe('tool-label-sidecar', () => {
|
|
|
147
147
|
expect(s.getLabel('A')).toBe('first')
|
|
148
148
|
s.stop()
|
|
149
149
|
})
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Issue #2461: the sidecar's `onLabel` callback exposes `toolName` so the
|
|
153
|
+
* gateway can filter surface tools (reply/stream_reply/edit_message/react)
|
|
154
|
+
* from the surfaced step count. This test pins that the toolName flows
|
|
155
|
+
* through correctly for both non-surface and surface-tool entries, so the
|
|
156
|
+
* gateway's `isTelegramSurfaceTool(ev.toolName)` guard works as the single
|
|
157
|
+
* filter that keeps reply/react out of `labeledToolCount`.
|
|
158
|
+
*
|
|
159
|
+
* Note: send_typing and sync_retain never appear here because
|
|
160
|
+
* computeLabel returns null for them → hook never writes to the sidecar.
|
|
161
|
+
*/
|
|
162
|
+
it('toolName flows through onLabel so callers can exclude surface tools from counts', () => {
|
|
163
|
+
const sessionId = 'sess-count'
|
|
164
|
+
const sched = makeManualScheduler()
|
|
165
|
+
const f = join(stateDir, `tool-labels-${sessionId}.jsonl`)
|
|
166
|
+
writeFileSync(
|
|
167
|
+
f,
|
|
168
|
+
// Non-surface tools — should be counted.
|
|
169
|
+
JSON.stringify({ ts: 1, tool_use_id: 'R1', agent_id: null, label: 'Reading CLAUDE.md', tool_name: 'Read' }) + '\n' +
|
|
170
|
+
JSON.stringify({ ts: 2, tool_use_id: 'M1', agent_id: null, label: 'Searching memory', tool_name: 'mcp__hindsight__recall' }) + '\n' +
|
|
171
|
+
// Surface tool — must NOT be counted.
|
|
172
|
+
JSON.stringify({ ts: 3, tool_use_id: 'RP', agent_id: null, label: 'Replying', tool_name: 'mcp__switchroom-telegram__reply' }) + '\n' +
|
|
173
|
+
// Another non-surface.
|
|
174
|
+
JSON.stringify({ ts: 4, tool_use_id: 'B1', agent_id: null, label: 'List workspace', tool_name: 'Bash' }) + '\n',
|
|
175
|
+
)
|
|
176
|
+
const s = createToolLabelSidecar({ stateDir, sessionId, scheduler: sched })
|
|
177
|
+
|
|
178
|
+
const SURFACE_TOOLS = new Set([
|
|
179
|
+
'mcp__switchroom-telegram__reply',
|
|
180
|
+
'mcp__switchroom-telegram__stream_reply',
|
|
181
|
+
'mcp__switchroom-telegram__edit_message',
|
|
182
|
+
'mcp__switchroom-telegram__react',
|
|
183
|
+
])
|
|
184
|
+
|
|
185
|
+
let surfacedCount = 0
|
|
186
|
+
s.onLabel((_id, _label, toolName) => {
|
|
187
|
+
if (!SURFACE_TOOLS.has(toolName)) surfacedCount++
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
// Read + mcp__hindsight__recall + Bash = 3 surfaced; reply = 0.
|
|
191
|
+
expect(surfacedCount).toBe(3)
|
|
192
|
+
s.stop()
|
|
193
|
+
})
|
|
150
194
|
})
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
2
|
import { toolLabel, isHumanDescription } from '../tool-labels.js'
|
|
3
|
+
import { isTelegramSurfaceTool } from '../tool-names.js'
|
|
3
4
|
|
|
4
5
|
describe('toolLabel', () => {
|
|
5
6
|
it('Read: uses basename of file_path', () => {
|
|
@@ -381,3 +382,69 @@ describe('isHumanDescription', () => {
|
|
|
381
382
|
expect(isHumanDescription('Bash')).toBe(false)
|
|
382
383
|
})
|
|
383
384
|
})
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Surface-tool exclusion contract (#2461).
|
|
388
|
+
*
|
|
389
|
+
* The gateway's `labeledToolCount` is incremented in `case 'tool_label':` AFTER
|
|
390
|
+
* `isTelegramSurfaceTool(ev.toolName)` returns false — so the count reflects
|
|
391
|
+
* only non-surface tools. This describe block pins that contract by verifying
|
|
392
|
+
* which tool names are classified as surface tools (never counted) vs
|
|
393
|
+
* non-surface tools (counted when they produce a label).
|
|
394
|
+
*
|
|
395
|
+
* send_typing and sync_retain are excluded at the hook level: `computeLabel`
|
|
396
|
+
* returns null for them → no sidecar line → no tool_label event → never reach
|
|
397
|
+
* the `labeledToolCount++` path. That suppression is tested in
|
|
398
|
+
* tool-label-sidecar.test.ts (the sidecar never gets those entries).
|
|
399
|
+
*/
|
|
400
|
+
describe('isTelegramSurfaceTool — surface-tool exclusion for labeledToolCount (#2461)', () => {
|
|
401
|
+
it('reply and stream_reply are surface tools — never counted', () => {
|
|
402
|
+
expect(isTelegramSurfaceTool('mcp__switchroom-telegram__reply')).toBe(true)
|
|
403
|
+
expect(isTelegramSurfaceTool('mcp__switchroom-telegram__stream_reply')).toBe(true)
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
it('edit_message and react are surface tools — never counted', () => {
|
|
407
|
+
expect(isTelegramSurfaceTool('mcp__switchroom-telegram__edit_message')).toBe(true)
|
|
408
|
+
expect(isTelegramSurfaceTool('mcp__switchroom-telegram__react')).toBe(true)
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it('Read tool is NOT a surface tool — counted when it produces a label', () => {
|
|
412
|
+
expect(isTelegramSurfaceTool('Read')).toBe(false)
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
it('mcp__hindsight__* tools are NOT surface tools — counted', () => {
|
|
416
|
+
expect(isTelegramSurfaceTool('mcp__hindsight__recall')).toBe(false)
|
|
417
|
+
expect(isTelegramSurfaceTool('mcp__hindsight__retain')).toBe(false)
|
|
418
|
+
expect(isTelegramSurfaceTool('mcp__hindsight__reflect')).toBe(false)
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
it('Bash, Edit, Write, Grep are NOT surface tools — counted', () => {
|
|
422
|
+
expect(isTelegramSurfaceTool('Bash')).toBe(false)
|
|
423
|
+
expect(isTelegramSurfaceTool('Edit')).toBe(false)
|
|
424
|
+
expect(isTelegramSurfaceTool('Write')).toBe(false)
|
|
425
|
+
expect(isTelegramSurfaceTool('Grep')).toBe(false)
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
it('operator MCP tools (perplexity, webkite, etc.) are NOT surface tools — counted', () => {
|
|
429
|
+
expect(isTelegramSurfaceTool('mcp__perplexity__perplexity_search')).toBe(false)
|
|
430
|
+
expect(isTelegramSurfaceTool('mcp__webkite__read')).toBe(false)
|
|
431
|
+
expect(isTelegramSurfaceTool('mcp__google-workspace__list_events')).toBe(false)
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
it('other switchroom-telegram tools (get_recent_messages, send_typing) are NOT surface tools', () => {
|
|
435
|
+
// get_recent_messages is a read/query tool — not a conversation surface tool.
|
|
436
|
+
expect(isTelegramSurfaceTool('mcp__switchroom-telegram__get_recent_messages')).toBe(false)
|
|
437
|
+
// send_typing is suppressed at the hook level (computeLabel returns null),
|
|
438
|
+
// so it never generates a tool_label event. But isTelegramSurfaceTool itself
|
|
439
|
+
// does NOT classify it as a surface tool — the two suppression mechanisms
|
|
440
|
+
// are orthogonal. The hook suppression is the right gate for send_typing.
|
|
441
|
+
expect(isTelegramSurfaceTool('mcp__switchroom-telegram__send_typing')).toBe(false)
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
it('clerk-telegram prefix also works (legacy registration key)', () => {
|
|
445
|
+
expect(isTelegramSurfaceTool('mcp__clerk-telegram__reply')).toBe(true)
|
|
446
|
+
expect(isTelegramSurfaceTool('mcp__clerk-telegram__stream_reply')).toBe(true)
|
|
447
|
+
expect(isTelegramSurfaceTool('mcp__clerk-telegram__edit_message')).toBe(true)
|
|
448
|
+
expect(isTelegramSurfaceTool('mcp__clerk-telegram__react')).toBe(true)
|
|
449
|
+
})
|
|
450
|
+
})
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit coverage for the mid-turn liveness floor decision (#2527).
|
|
3
|
+
*
|
|
4
|
+
* The floor is the missing TEXT signal during a busy-but-silent turn. These
|
|
5
|
+
* tests pin the pure policy: WHEN it fires (user role, undelivered, busy,
|
|
6
|
+
* past threshold, below the 300s fallback window, not already fired) and
|
|
7
|
+
* WHY it skips otherwise — plus the role derivation that keys the whole
|
|
8
|
+
* primitive (and is the antidote to per-surface / per-turn-type enumeration).
|
|
9
|
+
*
|
|
10
|
+
* The gateway wiring (send via the shared path, "Status?" short-circuit,
|
|
11
|
+
* role-aware terminal reaction) is covered separately; here we lock the
|
|
12
|
+
* decision so a regression in eligibility is caught without booting the
|
|
13
|
+
* 22k-line gateway.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, it, expect, afterEach } from 'vitest'
|
|
17
|
+
import {
|
|
18
|
+
deriveTurnRole,
|
|
19
|
+
decideMidTurnFloor,
|
|
20
|
+
decideTerminalReason,
|
|
21
|
+
midTurnFloorEnabled,
|
|
22
|
+
parsePostAnswerLivenessMs,
|
|
23
|
+
DEFAULT_FLOOR_THRESHOLD_MS,
|
|
24
|
+
type MidTurnFloorInput,
|
|
25
|
+
} from '../turn-liveness-floor.js'
|
|
26
|
+
|
|
27
|
+
const FALLBACK_MS = 300_000
|
|
28
|
+
|
|
29
|
+
/** A baseline that FIRES — each test perturbs exactly one field. */
|
|
30
|
+
function fireBase(over: Partial<MidTurnFloorInput> = {}): MidTurnFloorInput {
|
|
31
|
+
return {
|
|
32
|
+
enabled: true,
|
|
33
|
+
role: 'user',
|
|
34
|
+
finalAnswerDelivered: false,
|
|
35
|
+
silenceMs: 60_000,
|
|
36
|
+
floorThresholdMs: DEFAULT_FLOOR_THRESHOLD_MS,
|
|
37
|
+
fallbackThresholdMs: FALLBACK_MS,
|
|
38
|
+
legitimatelyWorking: true,
|
|
39
|
+
alreadyFired: false,
|
|
40
|
+
...over,
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('deriveTurnRole — the single provenance discriminator', () => {
|
|
45
|
+
it('a plain human DM (no envelope) is a user turn', () => {
|
|
46
|
+
expect(deriveTurnRole(undefined)).toBe('user')
|
|
47
|
+
expect(deriveTurnRole(null)).toBe('user')
|
|
48
|
+
expect(deriveTurnRole('hello there')).toBe('user')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('a cron/scheduled envelope is a system turn', () => {
|
|
52
|
+
expect(deriveTurnRole('<channel chat_id="123" source="cron">do the thing</channel>')).toBe('system')
|
|
53
|
+
expect(deriveTurnRole('<channel source="wake" chat_id="1">')).toBe('system')
|
|
54
|
+
expect(deriveTurnRole('<channel source="scheduler">')).toBe('system')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('an unknown source defaults to user (gets the never-silent guarantee, not silently exempt)', () => {
|
|
58
|
+
// A novel/unknown source must NOT opt out of the floor by accident.
|
|
59
|
+
expect(deriveTurnRole('<channel source="handback" chat_id="1">')).toBe('user')
|
|
60
|
+
expect(deriveTurnRole('<channel source="some-new-thing">')).toBe('user')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('never returns sub-agent — the gateway makes no turn atom for sub-agents', () => {
|
|
64
|
+
// Type-level guarantee, asserted at runtime for the documented cases.
|
|
65
|
+
const r = deriveTurnRole('<channel source="cron">')
|
|
66
|
+
expect(r === 'user' || r === 'system').toBe(true)
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('decideMidTurnFloor — fires on busy-but-silent user turns', () => {
|
|
71
|
+
it('fires for a user turn working silently past the threshold', () => {
|
|
72
|
+
expect(decideMidTurnFloor(fireBase())).toEqual({ kind: 'fire' })
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('skips when disabled (kill switch)', () => {
|
|
76
|
+
expect(decideMidTurnFloor(fireBase({ enabled: false }))).toEqual({ kind: 'skip', reason: 'disabled' })
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('skips when already fired (fire-once per turn)', () => {
|
|
80
|
+
expect(decideMidTurnFloor(fireBase({ alreadyFired: true }))).toEqual({ kind: 'skip', reason: 'already-fired' })
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('skips a system/cron turn — its silence is legitimate', () => {
|
|
84
|
+
expect(decideMidTurnFloor(fireBase({ role: 'system' }))).toEqual({ kind: 'skip', reason: 'non-user-role' })
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('skips a sub-agent turn — liveness is carried by the parent', () => {
|
|
88
|
+
expect(decideMidTurnFloor(fireBase({ role: 'sub-agent' }))).toEqual({ kind: 'skip', reason: 'non-user-role' })
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('skips once a substantive answer was delivered', () => {
|
|
92
|
+
expect(decideMidTurnFloor(fireBase({ finalAnswerDelivered: true }))).toEqual({ kind: 'skip', reason: 'answer-delivered' })
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('skips below the floor threshold (a normal short turn stays quiet)', () => {
|
|
96
|
+
expect(decideMidTurnFloor(fireBase({ silenceMs: 10_000 }))).toEqual({ kind: 'skip', reason: 'below-threshold' })
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('skips inside the 300s fallback window — the loud fallback owns that beat', () => {
|
|
100
|
+
expect(decideMidTurnFloor(fireBase({ silenceMs: FALLBACK_MS }))).toEqual({ kind: 'skip', reason: 'fallback-window' })
|
|
101
|
+
expect(decideMidTurnFloor(fireBase({ silenceMs: FALLBACK_MS + 1 }))).toEqual({ kind: 'skip', reason: 'fallback-window' })
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('skips when not legitimately working — a genuine wedge is the fallback\'s job', () => {
|
|
105
|
+
expect(decideMidTurnFloor(fireBase({ legitimatelyWorking: false }))).toEqual({ kind: 'skip', reason: 'not-working' })
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('fires exactly at the threshold boundary', () => {
|
|
109
|
+
expect(decideMidTurnFloor(fireBase({ silenceMs: DEFAULT_FLOOR_THRESHOLD_MS }))).toEqual({ kind: 'fire' })
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
describe('decideMidTurnFloor — forced "Status?" short-circuit', () => {
|
|
114
|
+
it('fires immediately on force, bypassing threshold and the working check', () => {
|
|
115
|
+
expect(decideMidTurnFloor(fireBase({ force: true, silenceMs: 1_000, legitimatelyWorking: false })))
|
|
116
|
+
.toEqual({ kind: 'fire' })
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('force still honours role / delivery / fire-once / enabled', () => {
|
|
120
|
+
expect(decideMidTurnFloor(fireBase({ force: true, role: 'system' }))).toEqual({ kind: 'skip', reason: 'non-user-role' })
|
|
121
|
+
expect(decideMidTurnFloor(fireBase({ force: true, finalAnswerDelivered: true }))).toEqual({ kind: 'skip', reason: 'answer-delivered' })
|
|
122
|
+
expect(decideMidTurnFloor(fireBase({ force: true, alreadyFired: true }))).toEqual({ kind: 'skip', reason: 'already-fired' })
|
|
123
|
+
expect(decideMidTurnFloor(fireBase({ force: true, enabled: false }))).toEqual({ kind: 'skip', reason: 'disabled' })
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
describe('decideTerminalReason — the "thumbs-up false done" gate', () => {
|
|
128
|
+
it('an undelivered user turn finalizes undelivered (😐), not done (👍)', () => {
|
|
129
|
+
expect(decideTerminalReason({ enabled: true, role: 'user', finalAnswerDelivered: false })).toBe('undelivered')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('a delivered user turn finalizes done', () => {
|
|
133
|
+
expect(decideTerminalReason({ enabled: true, role: 'user', finalAnswerDelivered: true })).toBe('done')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('a system/cron turn keeps done even when undelivered — its silence is legitimate', () => {
|
|
137
|
+
expect(decideTerminalReason({ enabled: true, role: 'system', finalAnswerDelivered: false })).toBe('done')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('a sub-agent turn keeps done', () => {
|
|
141
|
+
expect(decideTerminalReason({ enabled: true, role: 'sub-agent', finalAnswerDelivered: false })).toBe('done')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('the kill switch reverts to always-done', () => {
|
|
145
|
+
expect(decideTerminalReason({ enabled: false, role: 'user', finalAnswerDelivered: false })).toBe('done')
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
describe('midTurnFloorEnabled — default-on kill switch', () => {
|
|
150
|
+
const KEY = 'SWITCHROOM_TG_LIVENESS_FLOOR'
|
|
151
|
+
afterEach(() => { delete process.env[KEY] })
|
|
152
|
+
|
|
153
|
+
it('defaults on when unset', () => {
|
|
154
|
+
delete process.env[KEY]
|
|
155
|
+
expect(midTurnFloorEnabled()).toBe(true)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('disables on 0/false/off/no (case-insensitive)', () => {
|
|
159
|
+
for (const v of ['0', 'false', 'off', 'no', 'OFF', 'False']) {
|
|
160
|
+
process.env[KEY] = v
|
|
161
|
+
expect(midTurnFloorEnabled()).toBe(false)
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('stays on for any other value', () => {
|
|
166
|
+
for (const v of ['1', 'true', 'on', 'yes']) {
|
|
167
|
+
process.env[KEY] = v
|
|
168
|
+
expect(midTurnFloorEnabled()).toBe(true)
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
describe('parsePostAnswerLivenessMs — PR1 Item-3a DORMANT escape hatch (default OFF)', () => {
|
|
174
|
+
// The load-bearing contract: UNSET ⇒ 0 ⇒ behaviour byte-identical to today
|
|
175
|
+
// (the guarded post-answer branch in feedHeartbeatTick is dead). No surface
|
|
176
|
+
// ships enabled in this PR; this only pins that nothing changes by default.
|
|
177
|
+
it('UNSET (undefined) ⇒ 0 — exactly today’s silent post-answer behaviour', () => {
|
|
178
|
+
expect(parsePostAnswerLivenessMs(undefined)).toBe(0)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('empty / whitespace / non-numeric ⇒ 0 (dormant)', () => {
|
|
182
|
+
expect(parsePostAnswerLivenessMs('')).toBe(0)
|
|
183
|
+
expect(parsePostAnswerLivenessMs(' ')).toBe(0)
|
|
184
|
+
expect(parsePostAnswerLivenessMs('nope')).toBe(0)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('0 and negative ⇒ 0 (dormant — never enables a post-answer surface)', () => {
|
|
188
|
+
expect(parsePostAnswerLivenessMs('0')).toBe(0)
|
|
189
|
+
expect(parsePostAnswerLivenessMs('-5000')).toBe(0)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('a positive integer ⇒ that threshold in ms (the opt-in future surface)', () => {
|
|
193
|
+
expect(parsePostAnswerLivenessMs('8000')).toBe(8000)
|
|
194
|
+
expect(parsePostAnswerLivenessMs('45000')).toBe(45000)
|
|
195
|
+
})
|
|
196
|
+
})
|