switchroom 0.15.44 → 0.16.4
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 +122 -88
- package/dist/auth-broker/index.js +463 -177
- package/dist/cli/autoaccept-poll.js +4842 -35
- package/dist/cli/drive-write-pretool.mjs +17 -14
- package/dist/cli/notion-write-pretool.mjs +117 -86
- package/dist/cli/self-improve-apply-guard-pretool.mjs +626 -0
- package/dist/cli/self-improve-stop.mjs +428 -0
- package/dist/cli/skill-validate-pretool.mjs +72 -72
- package/dist/cli/switchroom.js +3249 -1241
- package/dist/cli/ui/index.html +1 -1
- package/dist/host-control/main.js +2833 -355
- package/dist/vault/approvals/kernel-server.js +7482 -7439
- package/dist/vault/broker/server.js +11315 -11272
- package/examples/minimal.yaml +1 -0
- package/examples/switchroom.yaml +1 -0
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +88 -1
- package/profiles/_shared/execution-discipline.md.hbs +18 -0
- package/profiles/default/CLAUDE.md.hbs +3 -22
- 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 +167 -124
- package/telegram-plugin/dist/gateway/gateway.js +3039 -1159
- package/telegram-plugin/dist/server.js +215 -172
- 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 +1837 -291
- package/telegram-plugin/gateway/inject-handler.test.ts +2 -1
- 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-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,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M-2: `activityEverOpened` sticky-true invariant — structural assertion.
|
|
3
|
+
*
|
|
4
|
+
* `activityEverOpened` is set to `true` exactly once, when the activity feed
|
|
5
|
+
* posts its first message (the `sendMessage` path in `drainActivitySummary`).
|
|
6
|
+
* It must NEVER be reset to false or cleared — unlike `activityMessageId`, which
|
|
7
|
+
* is nulled by `clearActivitySummary` to indicate that the persistent message was
|
|
8
|
+
* finalized/deleted. The sticky-true invariant lets the turn-end DEGRADED check
|
|
9
|
+
* (`detectStatusSurfaceDegraded`) distinguish "feed never opened" (the
|
|
10
|
+
* resume-400 signature) from "feed opened + finalized".
|
|
11
|
+
*
|
|
12
|
+
* Load-bearing constraints:
|
|
13
|
+
* 1. `activityEverOpened = true` is set exactly ONCE in gateway.ts (at the
|
|
14
|
+
* send-message success site in drainActivitySummary).
|
|
15
|
+
* 2. `turn.activityEverOpened = false` NEVER appears in gateway.ts (it is only
|
|
16
|
+
* initialised to `false` in the turn-initialiser object literal, never reset
|
|
17
|
+
* via a standalone assignment).
|
|
18
|
+
*
|
|
19
|
+
* These are STRUCTURAL (source-read) assertions. Pattern: silence-liveness-wiring.test.ts.
|
|
20
|
+
*/
|
|
21
|
+
import { describe, it, expect } from 'vitest'
|
|
22
|
+
import { readFileSync } from 'node:fs'
|
|
23
|
+
import { resolve } from 'node:path'
|
|
24
|
+
|
|
25
|
+
const gatewaySrc = readFileSync(
|
|
26
|
+
resolve(__dirname, '..', 'gateway', 'gateway.ts'),
|
|
27
|
+
'utf-8',
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
describe('M-2: activityEverOpened sticky-true invariant', () => {
|
|
31
|
+
it('activityEverOpened = true appears exactly once (set at send-message success)', () => {
|
|
32
|
+
const setTrueMatches = [...gatewaySrc.matchAll(/activityEverOpened\s*=\s*true/g)]
|
|
33
|
+
expect(setTrueMatches).toHaveLength(1)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('turn.activityEverOpened = false never appears (no standalone reset)', () => {
|
|
37
|
+
// The only `false` value must be in the object literal initialiser
|
|
38
|
+
// (e.g. `activityEverOpened: false`), never a standalone reassignment.
|
|
39
|
+
const resetMatches = [...gatewaySrc.matchAll(/turn\.activityEverOpened\s*=\s*false/g)]
|
|
40
|
+
expect(resetMatches).toHaveLength(0)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('activityEverOpened is initialised false in the turn object literal (per-turn reset)', () => {
|
|
44
|
+
// The object literal form `activityEverOpened: false` must exist (per-turn init).
|
|
45
|
+
expect(gatewaySrc).toMatch(/activityEverOpened:\s*false/)
|
|
46
|
+
})
|
|
47
|
+
})
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
27
|
-
import { createAnswerStream
|
|
27
|
+
import { createAnswerStream } from '../answer-stream.js'
|
|
28
28
|
|
|
29
29
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
30
30
|
|
|
@@ -47,7 +47,6 @@ function makeEditMessageText() {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
beforeEach(() => {
|
|
50
|
-
__resetDraftIdForTests()
|
|
51
50
|
nextMessageId = 2000
|
|
52
51
|
vi.useFakeTimers()
|
|
53
52
|
})
|
|
@@ -70,9 +69,7 @@ describe('answer-stream materialize() — dedup callbacks (#646)', () => {
|
|
|
70
69
|
const onMetric = vi.fn()
|
|
71
70
|
|
|
72
71
|
const stream = createAnswerStream({
|
|
73
|
-
chatId: 'chat646',
|
|
74
|
-
isPrivateChat: false,
|
|
75
|
-
minInitialChars: 0,
|
|
72
|
+
chatId: 'chat646', minInitialChars: 0,
|
|
76
73
|
throttleMs: 250,
|
|
77
74
|
sendMessage,
|
|
78
75
|
editMessageText,
|
|
@@ -112,9 +109,7 @@ describe('answer-stream materialize() — dedup callbacks (#646)', () => {
|
|
|
112
109
|
const log = vi.fn()
|
|
113
110
|
|
|
114
111
|
const stream = createAnswerStream({
|
|
115
|
-
chatId: 'chat646',
|
|
116
|
-
isPrivateChat: false,
|
|
117
|
-
minInitialChars: 0,
|
|
112
|
+
chatId: 'chat646', minInitialChars: 0,
|
|
118
113
|
throttleMs: 250,
|
|
119
114
|
sendMessage,
|
|
120
115
|
editMessageText,
|
|
@@ -158,9 +153,7 @@ describe('answer-stream materialize() — dedup callbacks (#646)', () => {
|
|
|
158
153
|
const editMessageText = makeEditMessageText()
|
|
159
154
|
|
|
160
155
|
const stream = createAnswerStream({
|
|
161
|
-
chatId: 'chat646',
|
|
162
|
-
isPrivateChat: false,
|
|
163
|
-
minInitialChars: 0,
|
|
156
|
+
chatId: 'chat646', minInitialChars: 0,
|
|
164
157
|
throttleMs: 250,
|
|
165
158
|
sendMessage,
|
|
166
159
|
editMessageText,
|
|
@@ -185,9 +178,7 @@ describe('answer-stream materialize() — dedup callbacks (#646)', () => {
|
|
|
185
178
|
const recordDedup = vi.fn()
|
|
186
179
|
|
|
187
180
|
const stream = createAnswerStream({
|
|
188
|
-
chatId: 'chat646',
|
|
189
|
-
isPrivateChat: false,
|
|
190
|
-
minInitialChars: 0,
|
|
181
|
+
chatId: 'chat646', minInitialChars: 0,
|
|
191
182
|
throttleMs: 250,
|
|
192
183
|
sendMessage: sendMessage as never,
|
|
193
184
|
editMessageText,
|
|
@@ -229,9 +220,7 @@ describe('answer-stream materialize() — dedup callbacks (#646)', () => {
|
|
|
229
220
|
const editMessageText = makeEditMessageText()
|
|
230
221
|
|
|
231
222
|
const stream = createAnswerStream({
|
|
232
|
-
chatId: 'chat646',
|
|
233
|
-
isPrivateChat: false,
|
|
234
|
-
minInitialChars: 0,
|
|
223
|
+
chatId: 'chat646', minInitialChars: 0,
|
|
235
224
|
throttleMs: 250,
|
|
236
225
|
sendMessage,
|
|
237
226
|
editMessageText,
|
|
@@ -276,9 +265,7 @@ describe('answer-stream materialize() — recordOutbound callback (#648)', () =>
|
|
|
276
265
|
const recordOutbound = vi.fn()
|
|
277
266
|
|
|
278
267
|
const stream = createAnswerStream({
|
|
279
|
-
chatId: 'chat648',
|
|
280
|
-
isPrivateChat: false,
|
|
281
|
-
minInitialChars: 0,
|
|
268
|
+
chatId: 'chat648', minInitialChars: 0,
|
|
282
269
|
throttleMs: 250,
|
|
283
270
|
sendMessage,
|
|
284
271
|
editMessageText,
|
|
@@ -303,9 +290,7 @@ describe('answer-stream materialize() — recordOutbound callback (#648)', () =>
|
|
|
303
290
|
const recordOutbound = vi.fn()
|
|
304
291
|
|
|
305
292
|
const stream = createAnswerStream({
|
|
306
|
-
chatId: 'chat648',
|
|
307
|
-
isPrivateChat: false,
|
|
308
|
-
minInitialChars: 0,
|
|
293
|
+
chatId: 'chat648', minInitialChars: 0,
|
|
309
294
|
throttleMs: 250,
|
|
310
295
|
sendMessage,
|
|
311
296
|
editMessageText,
|
|
@@ -331,9 +316,7 @@ describe('answer-stream materialize() — recordOutbound callback (#648)', () =>
|
|
|
331
316
|
const recordOutbound = vi.fn()
|
|
332
317
|
|
|
333
318
|
const stream = createAnswerStream({
|
|
334
|
-
chatId: 'chat648',
|
|
335
|
-
isPrivateChat: false,
|
|
336
|
-
minInitialChars: 0,
|
|
319
|
+
chatId: 'chat648', minInitialChars: 0,
|
|
337
320
|
throttleMs: 250,
|
|
338
321
|
sendMessage: sendMessage as never,
|
|
339
322
|
editMessageText,
|
|
@@ -3,10 +3,14 @@
|
|
|
3
3
|
* opt-in only on a truthy value. Guards against an accidental flip back to
|
|
4
4
|
* default-on (which would reintroduce the unformatted-preliminary flash +
|
|
5
5
|
* delete-on-every-reply — see the gateway gate comment).
|
|
6
|
+
*
|
|
7
|
+
* The draft transport (sendMessageDraft) is permanently retired — the lane is
|
|
8
|
+
* now either VISIBLE (opt-in) or DORMANT (the unconditional default). The
|
|
9
|
+
* resolveAnswerLaneConfig 2-state enumeration is the regression guard.
|
|
6
10
|
*/
|
|
7
11
|
|
|
8
12
|
import { describe, it, expect } from 'vitest'
|
|
9
|
-
import { parseVisibleAnswerStreamEnabled,
|
|
13
|
+
import { parseVisibleAnswerStreamEnabled, resolveAnswerLaneConfig } from '../answer-stream-flag.js'
|
|
10
14
|
|
|
11
15
|
describe('parseVisibleAnswerStreamEnabled — default OFF, opt-in', () => {
|
|
12
16
|
it('defaults OFF when unset', () => {
|
|
@@ -26,47 +30,24 @@ describe('parseVisibleAnswerStreamEnabled — default OFF, opt-in', () => {
|
|
|
26
30
|
})
|
|
27
31
|
})
|
|
28
32
|
|
|
29
|
-
describe('parseDraftLaneRetiredEnabled — default RETIRED (2026-06-05), kill-switch off', () => {
|
|
30
|
-
it('defaults to RETIRED (true) when unset — the draft lane is gone by default', () => {
|
|
31
|
-
expect(parseDraftLaneRetiredEnabled(undefined)).toBe(true)
|
|
32
|
-
})
|
|
33
|
-
|
|
34
|
-
it('stays RETIRED for any non-disable value (including unrecognized)', () => {
|
|
35
|
-
for (const v of ['1', 'true', 'on', 'yes', '', ' ', 'whatever', 'retired']) {
|
|
36
|
-
expect(parseDraftLaneRetiredEnabled(v)).toBe(true)
|
|
37
|
-
}
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
it('restores the legacy draft (false) ONLY on an explicit disable (case/space-insensitive)', () => {
|
|
41
|
-
for (const v of ['0', 'false', 'off', 'no', ' FALSE ', 'Off', 'NO']) {
|
|
42
|
-
expect(parseDraftLaneRetiredEnabled(v)).toBe(false)
|
|
43
|
-
}
|
|
44
|
-
})
|
|
45
|
-
})
|
|
46
|
-
|
|
47
33
|
// ── resolveAnswerLaneConfig — TOTAL-ENUMERATION REGRESSION PROOF ─────────────
|
|
48
34
|
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
// load-bearing INVARIANT: opensVisiblePreview === visibleEnabled, ALWAYS. That
|
|
54
|
-
// invariant is exactly what v0.14.68 broke (it made the preview depend on the
|
|
55
|
-
// draft flag), so a future change that re-conflates them fails here, not in prod.
|
|
35
|
+
// The draft transport is permanently retired. The input space is now a single
|
|
36
|
+
// boolean (visibleEnabled), yielding exactly 2 states: visible or dormant.
|
|
37
|
+
// We enumerate ALL of it and assert the full decision table plus the load-bearing
|
|
38
|
+
// INVARIANT: opensVisiblePreview === visibleEnabled, ALWAYS.
|
|
56
39
|
describe('resolveAnswerLaneConfig — total enumeration (flash-regression proof)', () => {
|
|
57
40
|
const MAX = Number.MAX_SAFE_INTEGER
|
|
58
41
|
const ALL = [
|
|
59
|
-
{ visibleEnabled: false
|
|
60
|
-
{ visibleEnabled:
|
|
61
|
-
{ visibleEnabled: true, draftFnAvailable: false }, // opt-in visible
|
|
62
|
-
{ visibleEnabled: true, draftFnAvailable: true }, // visible wins over draft
|
|
42
|
+
{ visibleEnabled: false }, // the DEFAULT (visible off, draft permanently retired → dormant)
|
|
43
|
+
{ visibleEnabled: true }, // opt-in visible
|
|
63
44
|
]
|
|
64
45
|
|
|
65
|
-
it('the input space is exactly
|
|
66
|
-
expect(ALL.length).toBe(
|
|
46
|
+
it('the input space is exactly 2 rows', () => {
|
|
47
|
+
expect(ALL.length).toBe(2)
|
|
67
48
|
})
|
|
68
49
|
|
|
69
|
-
it('INVARIANT (the regression guard): opensVisiblePreview === visibleEnabled for EVERY
|
|
50
|
+
it('INVARIANT (the regression guard): opensVisiblePreview === visibleEnabled for EVERY input', () => {
|
|
70
51
|
for (const input of ALL) {
|
|
71
52
|
expect(resolveAnswerLaneConfig(input).opensVisiblePreview).toBe(input.visibleEnabled)
|
|
72
53
|
}
|
|
@@ -79,39 +60,25 @@ describe('resolveAnswerLaneConfig — total enumeration (flash-regression proof)
|
|
|
79
60
|
}
|
|
80
61
|
})
|
|
81
62
|
|
|
82
|
-
it('DEFAULT (visible off
|
|
83
|
-
expect(resolveAnswerLaneConfig({ visibleEnabled: false
|
|
63
|
+
it('DEFAULT (visible off) → DORMANT: no preview, MAX gate (no flash)', () => {
|
|
64
|
+
expect(resolveAnswerLaneConfig({ visibleEnabled: false })).toEqual({
|
|
84
65
|
minInitialChars: MAX,
|
|
85
|
-
usesDraftTransport: false,
|
|
86
66
|
opensVisiblePreview: false,
|
|
87
67
|
state: 'dormant',
|
|
88
68
|
})
|
|
89
69
|
})
|
|
90
70
|
|
|
91
|
-
it('visible
|
|
92
|
-
expect(resolveAnswerLaneConfig({ visibleEnabled:
|
|
93
|
-
minInitialChars:
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
state: 'draft',
|
|
71
|
+
it('visible on → VISIBLE: preview opens on the first chunk (minChars 1)', () => {
|
|
72
|
+
expect(resolveAnswerLaneConfig({ visibleEnabled: true })).toEqual({
|
|
73
|
+
minInitialChars: 1,
|
|
74
|
+
opensVisiblePreview: true,
|
|
75
|
+
state: 'visible',
|
|
97
76
|
})
|
|
98
77
|
})
|
|
99
78
|
|
|
100
|
-
it('visible
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
usesDraftTransport: false,
|
|
105
|
-
opensVisiblePreview: true,
|
|
106
|
-
state: 'visible',
|
|
107
|
-
})
|
|
108
|
-
}
|
|
109
|
-
})
|
|
110
|
-
|
|
111
|
-
it('a visible preview NEVER opens unless explicitly enabled (no draftFnAvailable forces it on)', () => {
|
|
112
|
-
// The exact v0.14.68 failure shape: retiring the draft (draftFnAvailable=false)
|
|
113
|
-
// must NOT open a visible preview.
|
|
114
|
-
expect(resolveAnswerLaneConfig({ visibleEnabled: false, draftFnAvailable: false }).opensVisiblePreview).toBe(false)
|
|
115
|
-
expect(resolveAnswerLaneConfig({ visibleEnabled: false, draftFnAvailable: false }).minInitialChars).toBe(MAX)
|
|
79
|
+
it('a visible preview NEVER opens unless explicitly enabled', () => {
|
|
80
|
+
// The exact v0.14.68 failure shape: retiring the draft must NOT open a preview.
|
|
81
|
+
expect(resolveAnswerLaneConfig({ visibleEnabled: false }).opensVisiblePreview).toBe(false)
|
|
82
|
+
expect(resolveAnswerLaneConfig({ visibleEnabled: false }).minInitialChars).toBe(MAX)
|
|
116
83
|
})
|
|
117
84
|
})
|
|
@@ -1,10 +1,22 @@
|
|
|
1
|
-
import { describe, expect, it, vi, beforeEach
|
|
1
|
+
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
createAnswerStream,
|
|
5
|
-
__resetDraftIdForTests,
|
|
6
5
|
} from '../answer-stream.js'
|
|
7
6
|
|
|
7
|
+
// Throttle window anchored one hour ahead of "now". With lastSentAt=0 and a
|
|
8
|
+
// real Date.now(), update() computes sinceLast = Date.now() - 0, which is
|
|
9
|
+
// always < this anchor, so update() schedules a (cancelled-before-it-fires)
|
|
10
|
+
// timer and buffers pendingText instead of sending immediately. materialize()
|
|
11
|
+
// then cancels the scheduled timer and applies the silent-marker guard to the
|
|
12
|
+
// buffered text. This replaces the old draft-transport path (which bypassed the
|
|
13
|
+
// length gate). Anchoring to now+1h (rather than MAX_SAFE_INTEGER) keeps the
|
|
14
|
+
// scheduled timer's delay within 32-bit range, so it stays runner-agnostic:
|
|
15
|
+
// no vi.setSystemTime (which bun's vitest shim lacks) and no Node
|
|
16
|
+
// TimeoutOverflowWarning under runners whose fake-timer shim is a no-op.
|
|
17
|
+
const HOUR_MS = 60 * 60 * 1000
|
|
18
|
+
let throttleAnchorMs = 0
|
|
19
|
+
|
|
8
20
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
9
21
|
|
|
10
22
|
type SendMessageFn = (
|
|
@@ -29,13 +41,6 @@ type EditMessageTextFn = (
|
|
|
29
41
|
},
|
|
30
42
|
) => Promise<unknown>
|
|
31
43
|
|
|
32
|
-
type SendMessageDraftFn = (
|
|
33
|
-
chatId: string,
|
|
34
|
-
draftId: number,
|
|
35
|
-
text: string,
|
|
36
|
-
params?: { message_thread_id?: number },
|
|
37
|
-
) => Promise<unknown>
|
|
38
|
-
|
|
39
44
|
let nextMessageId = 9000
|
|
40
45
|
|
|
41
46
|
function makeSendMessage(): ReturnType<typeof vi.fn> & SendMessageFn {
|
|
@@ -49,18 +54,9 @@ function makeEditMessageText(): ReturnType<typeof vi.fn> & EditMessageTextFn {
|
|
|
49
54
|
return vi.fn(async () => {}) as unknown as ReturnType<typeof vi.fn> & EditMessageTextFn
|
|
50
55
|
}
|
|
51
56
|
|
|
52
|
-
function makeSendMessageDraft(): ReturnType<typeof vi.fn> & SendMessageDraftFn {
|
|
53
|
-
return vi.fn(async () => {}) as unknown as ReturnType<typeof vi.fn> & SendMessageDraftFn
|
|
54
|
-
}
|
|
55
|
-
|
|
56
57
|
beforeEach(() => {
|
|
57
|
-
__resetDraftIdForTests()
|
|
58
58
|
nextMessageId = 9000
|
|
59
|
-
|
|
60
|
-
})
|
|
61
|
-
|
|
62
|
-
afterEach(() => {
|
|
63
|
-
vi.useRealTimers()
|
|
59
|
+
throttleAnchorMs = Date.now() + HOUR_MS
|
|
64
60
|
})
|
|
65
61
|
|
|
66
62
|
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
@@ -70,9 +66,16 @@ afterEach(() => {
|
|
|
70
66
|
* NO_REPLY / HEARTBEAT_OK silent markers and suppress outbound Telegram
|
|
71
67
|
* messages when the whole turn body is one of those tokens.
|
|
72
68
|
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
69
|
+
* Previously in DMs the draft transport bypassed the minInitialChars length
|
|
70
|
+
* gate in update(), so short markers like "NO_REPLY" (8 chars) reached
|
|
71
|
+
* pendingText; then materialize() would send them as real messages.
|
|
72
|
+
*
|
|
73
|
+
* The draft transport is permanently retired. These tests now use:
|
|
74
|
+
* - minInitialChars: 0 — bypasses the length gate so update() sets pendingText
|
|
75
|
+
* - throttleMs: throttleAnchorMs (now + 1h) — with lastSentAt=0 and a real
|
|
76
|
+
* Date.now(), sinceLast is always < the throttle window, so update() schedules
|
|
77
|
+
* a timer rather than firing sendMessage immediately. materialize() cancels that
|
|
78
|
+
* timer and applies the silent-marker guard to pendingText before any send.
|
|
76
79
|
*
|
|
77
80
|
* Mirrors the sentinel suppression already present in:
|
|
78
81
|
* - server.ts (reply/stream_reply MCP tool handlers)
|
|
@@ -81,26 +84,23 @@ afterEach(() => {
|
|
|
81
84
|
*/
|
|
82
85
|
describe('answer-stream — silent-marker suppression at materialize()', () => {
|
|
83
86
|
it('NO_REPLY as the sole chunk — no outbound message, suppression log line emitted', async () => {
|
|
84
|
-
// Use isPrivateChat: true + sendMessageDraft to replicate the exact repro
|
|
85
|
-
// conditions from #299: DM chat bypasses the minInitialChars length gate
|
|
86
|
-
// in update(), so "NO_REPLY" (8 chars) reaches pendingText and materialize()
|
|
87
|
-
// would previously send it as a real Telegram message.
|
|
88
87
|
const sendMessage = makeSendMessage()
|
|
89
88
|
const editMessageText = makeEditMessageText()
|
|
90
|
-
const sendMessageDraft = makeSendMessageDraft()
|
|
91
89
|
const logs: string[] = []
|
|
92
90
|
const stream = createAnswerStream({
|
|
93
91
|
chatId: 'chat42',
|
|
94
|
-
|
|
95
|
-
|
|
92
|
+
// minInitialChars: 0 bypasses the length gate so short markers like
|
|
93
|
+
// "NO_REPLY" (8 chars) set pendingText. Combined with vi.setSystemTime(0),
|
|
94
|
+
// the throttle keeps update() from firing sendMessage immediately, so
|
|
95
|
+
// materialize() can apply the silent-marker guard.
|
|
96
|
+
minInitialChars: 0,
|
|
97
|
+
throttleMs: throttleAnchorMs,
|
|
96
98
|
sendMessage,
|
|
97
99
|
editMessageText,
|
|
98
|
-
sendMessageDraft,
|
|
99
100
|
log: (msg) => logs.push(msg),
|
|
100
101
|
})
|
|
101
102
|
|
|
102
103
|
// Simulate: model emits exactly NO_REPLY, no reply/stream_reply call.
|
|
103
|
-
// In a DM, update() bypasses the length gate and sets pendingText.
|
|
104
104
|
stream.update('NO_REPLY')
|
|
105
105
|
// materialize() is what gateway.ts calls at turn_end when no tool reply
|
|
106
106
|
// was made — this is the path that was broken in #299 (msg id=8268).
|
|
@@ -115,14 +115,12 @@ describe('answer-stream — silent-marker suppression at materialize()', () => {
|
|
|
115
115
|
it('HEARTBEAT_OK as the sole chunk — no outbound message', async () => {
|
|
116
116
|
const sendMessage = makeSendMessage()
|
|
117
117
|
const editMessageText = makeEditMessageText()
|
|
118
|
-
const sendMessageDraft = makeSendMessageDraft()
|
|
119
118
|
const stream = createAnswerStream({
|
|
120
119
|
chatId: 'chat43',
|
|
121
|
-
|
|
122
|
-
throttleMs:
|
|
120
|
+
minInitialChars: 0,
|
|
121
|
+
throttleMs: throttleAnchorMs,
|
|
123
122
|
sendMessage,
|
|
124
123
|
editMessageText,
|
|
125
|
-
sendMessageDraft,
|
|
126
124
|
})
|
|
127
125
|
|
|
128
126
|
stream.update('HEARTBEAT_OK')
|
|
@@ -135,15 +133,13 @@ describe('answer-stream — silent-marker suppression at materialize()', () => {
|
|
|
135
133
|
it('NO_REPLY. (trailing period) — suppressed by trailing-punctuation tolerance', async () => {
|
|
136
134
|
const sendMessage = makeSendMessage()
|
|
137
135
|
const editMessageText = makeEditMessageText()
|
|
138
|
-
const sendMessageDraft = makeSendMessageDraft()
|
|
139
136
|
const logs: string[] = []
|
|
140
137
|
const stream = createAnswerStream({
|
|
141
138
|
chatId: 'chat44',
|
|
142
|
-
|
|
143
|
-
throttleMs:
|
|
139
|
+
minInitialChars: 0,
|
|
140
|
+
throttleMs: throttleAnchorMs,
|
|
144
141
|
sendMessage,
|
|
145
142
|
editMessageText,
|
|
146
|
-
sendMessageDraft,
|
|
147
143
|
log: (msg) => logs.push(msg),
|
|
148
144
|
})
|
|
149
145
|
|
|
@@ -158,22 +154,19 @@ describe('answer-stream — silent-marker suppression at materialize()', () => {
|
|
|
158
154
|
it('substring match ("the agent suggested NO_REPLY earlier") — NOT suppressed, materialises normally', async () => {
|
|
159
155
|
const sendMessage = makeSendMessage()
|
|
160
156
|
const editMessageText = makeEditMessageText()
|
|
161
|
-
const sendMessageDraft = makeSendMessageDraft()
|
|
162
157
|
const stream = createAnswerStream({
|
|
163
158
|
chatId: 'chat45',
|
|
164
|
-
|
|
165
|
-
throttleMs:
|
|
159
|
+
minInitialChars: 0,
|
|
160
|
+
throttleMs: throttleAnchorMs,
|
|
166
161
|
sendMessage,
|
|
167
162
|
editMessageText,
|
|
168
|
-
sendMessageDraft,
|
|
169
163
|
})
|
|
170
164
|
|
|
171
165
|
const prose = 'the agent suggested NO_REPLY earlier'
|
|
172
166
|
stream.update(prose)
|
|
173
|
-
// materialize() should send a fresh message — only 1 call, from materialize
|
|
174
|
-
// itself (update() in draft mode sends a draft, not a sendMessage call).
|
|
175
167
|
const msgId = await stream.materialize()
|
|
176
168
|
|
|
169
|
+
// materialize() sends a fresh message — prose is not a silent marker
|
|
177
170
|
expect(sendMessage).toHaveBeenCalledTimes(1)
|
|
178
171
|
expect(sendMessage).toHaveBeenCalledWith(
|
|
179
172
|
'chat45',
|
|
@@ -190,15 +183,13 @@ describe('answer-stream — silent-marker suppression at materialize()', () => {
|
|
|
190
183
|
// not on any intermediate chunk.
|
|
191
184
|
const sendMessage = makeSendMessage()
|
|
192
185
|
const editMessageText = makeEditMessageText()
|
|
193
|
-
const sendMessageDraft = makeSendMessageDraft()
|
|
194
186
|
const logs: string[] = []
|
|
195
187
|
const stream = createAnswerStream({
|
|
196
188
|
chatId: 'chat47',
|
|
197
|
-
|
|
198
|
-
throttleMs:
|
|
189
|
+
minInitialChars: 0,
|
|
190
|
+
throttleMs: throttleAnchorMs,
|
|
199
191
|
sendMessage,
|
|
200
192
|
editMessageText,
|
|
201
|
-
sendMessageDraft,
|
|
202
193
|
log: (msg) => logs.push(msg),
|
|
203
194
|
})
|
|
204
195
|
|
|
@@ -218,8 +209,7 @@ describe('answer-stream — silent-marker suppression at materialize()', () => {
|
|
|
218
209
|
const logs: string[] = []
|
|
219
210
|
const stream = createAnswerStream({
|
|
220
211
|
chatId: 'chat46',
|
|
221
|
-
|
|
222
|
-
throttleMs: 250,
|
|
212
|
+
throttleMs: throttleAnchorMs,
|
|
223
213
|
sendMessage,
|
|
224
214
|
editMessageText,
|
|
225
215
|
log: (msg) => logs.push(msg),
|