switchroom 0.15.45 → 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 +3158 -1178
- package/dist/host-control/main.js +2833 -355
- package/dist/vault/approvals/kernel-server.js +7479 -7439
- package/dist/vault/broker/server.js +11312 -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 +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 +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,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Property / invariant proof for the turn-liveness primitive (#2527).
|
|
3
|
+
*
|
|
4
|
+
* This is the ANTI-WHACK-A-MOLE test: instead of one scenario per bug, it
|
|
5
|
+
* fuzzes the turn-shape space (timing × tool churn × reply pattern × role ×
|
|
6
|
+
* NESTING × surface) and asserts the primitive's invariants hold for ANY
|
|
7
|
+
* shape — so a turn type nobody enumerated is covered by construction.
|
|
8
|
+
*
|
|
9
|
+
* It drives the REAL `silence-poke` wiring (startTurn → ticks → floorState →
|
|
10
|
+
* onMidTurnFloor → fire-once latch + clock reset) plus the pure terminal
|
|
11
|
+
* decision, and checks each fired floor against an independent oracle.
|
|
12
|
+
*
|
|
13
|
+
* Invariants asserted across the corpus:
|
|
14
|
+
* I1 liveness: the floor fires exactly when the oracle says it should
|
|
15
|
+
* (a user turn working silently past the floor threshold,
|
|
16
|
+
* below the 300s fallback window, undelivered).
|
|
17
|
+
* I2 fire-once: at most one floor beat per turn.
|
|
18
|
+
* I3 role gate: a system/cron turn never fires a floor beat.
|
|
19
|
+
* I4 delivered: a turn that delivered before any silent window fires none.
|
|
20
|
+
* I5 terminal: terminal reason = decideTerminalReason(role, delivered).
|
|
21
|
+
* I6 surface parity: the SAME shape on a DM (threadId null) and a forum
|
|
22
|
+
* topic (threadId set) produces identical floor + terminal
|
|
23
|
+
* outcomes — parity by construction, not by duplicated code.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
27
|
+
import {
|
|
28
|
+
startTurn,
|
|
29
|
+
noteOutbound,
|
|
30
|
+
noteToolStart,
|
|
31
|
+
noteToolEnd,
|
|
32
|
+
pokeFloorNow,
|
|
33
|
+
__tickForTests,
|
|
34
|
+
__setDepsForTests,
|
|
35
|
+
__resetAllForTests,
|
|
36
|
+
type SilencePokeMetric,
|
|
37
|
+
type MidTurnFloorContext,
|
|
38
|
+
} from '../silence-poke.js'
|
|
39
|
+
import {
|
|
40
|
+
decideTerminalReason,
|
|
41
|
+
type LoopRole,
|
|
42
|
+
} from '../turn-liveness-floor.js'
|
|
43
|
+
|
|
44
|
+
const ORIGINAL_KILL = process.env.SWITCHROOM_DISABLE_SILENCE_POKE
|
|
45
|
+
const ORIGINAL_FLOOR_KILL = process.env.SWITCHROOM_TG_LIVENESS_FLOOR
|
|
46
|
+
|
|
47
|
+
const FLOOR_MS = 45_000
|
|
48
|
+
const FALLBACK_MS = 300_000
|
|
49
|
+
const TICK_MS = 5_000
|
|
50
|
+
|
|
51
|
+
/** Deterministic LCG so the corpus is reproducible (Math.random would make
|
|
52
|
+
* failures un-repeatable). */
|
|
53
|
+
function makeRng(seed: number): () => number {
|
|
54
|
+
let s = seed >>> 0
|
|
55
|
+
return () => {
|
|
56
|
+
s = (s * 1664525 + 1013904223) >>> 0
|
|
57
|
+
return s / 0x1_0000_0000
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type Surface = 'dm' | 'topic'
|
|
62
|
+
interface Ev { t: number; kind: 'tool_start' | 'tool_end' | 'reply_substantive' | 'reply_ack' }
|
|
63
|
+
interface Shape {
|
|
64
|
+
role: LoopRole
|
|
65
|
+
events: Ev[]
|
|
66
|
+
totalMs: number
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Generate a random turn shape — varied timing, churn, reply pattern, role,
|
|
70
|
+
* and nesting (overlapping tool calls model nested/parallel sub-agents). */
|
|
71
|
+
function genShape(rng: () => number): Shape {
|
|
72
|
+
const roleRoll = rng()
|
|
73
|
+
// mostly user (the case that owes liveness), some system, some sub-agent.
|
|
74
|
+
const role: LoopRole = roleRoll < 0.7 ? 'user' : roleRoll < 0.85 ? 'system' : 'sub-agent'
|
|
75
|
+
const totalMs = 10_000 + Math.floor(rng() * 600_000) // up to 10 min
|
|
76
|
+
const events: Ev[] = []
|
|
77
|
+
|
|
78
|
+
// Tool churn — 0..8 (possibly overlapping → nesting / fan-out).
|
|
79
|
+
const nTools = Math.floor(rng() * 9)
|
|
80
|
+
for (let i = 0; i < nTools; i++) {
|
|
81
|
+
const start = Math.floor(rng() * totalMs)
|
|
82
|
+
const dur = 1_000 + Math.floor(rng() * 120_000)
|
|
83
|
+
events.push({ t: start, kind: 'tool_start' })
|
|
84
|
+
events.push({ t: Math.min(totalMs, start + dur), kind: 'tool_end' })
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Reply pattern — none / one ack / one substantive / ack-then-substantive.
|
|
88
|
+
const replyRoll = rng()
|
|
89
|
+
if (replyRoll < 0.25) {
|
|
90
|
+
// none
|
|
91
|
+
} else if (replyRoll < 0.5) {
|
|
92
|
+
events.push({ t: Math.floor(rng() * totalMs), kind: 'reply_ack' })
|
|
93
|
+
} else if (replyRoll < 0.8) {
|
|
94
|
+
events.push({ t: Math.floor(rng() * totalMs), kind: 'reply_substantive' })
|
|
95
|
+
} else {
|
|
96
|
+
const a = Math.floor(rng() * totalMs)
|
|
97
|
+
events.push({ t: a, kind: 'reply_ack' })
|
|
98
|
+
events.push({ t: Math.min(totalMs, a + Math.floor(rng() * 60_000)), kind: 'reply_substantive' })
|
|
99
|
+
}
|
|
100
|
+
events.sort((x, y) => x.t - y.t)
|
|
101
|
+
return { role, events, totalMs }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface RunResult {
|
|
105
|
+
floors: MidTurnFloorContext[]
|
|
106
|
+
/** oracle fire times (independent reference model). */
|
|
107
|
+
oracleFires: number[]
|
|
108
|
+
deliveredAtEnd: boolean
|
|
109
|
+
terminal: 'done' | 'undelivered'
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Run one shape on one surface through the REAL silence-poke + an oracle. */
|
|
113
|
+
function runShape(shape: Shape, surface: Surface): RunResult {
|
|
114
|
+
const threadId = surface === 'topic' ? 99 : null
|
|
115
|
+
const key = threadId == null ? 'chat' : `chat:${threadId}`
|
|
116
|
+
|
|
117
|
+
// Sim state (also feeds the oracle).
|
|
118
|
+
let delivered = false
|
|
119
|
+
let inFlight = 0
|
|
120
|
+
let lastOutbound: number | null = null
|
|
121
|
+
let oracleFloorFired = false
|
|
122
|
+
const oracleFires: number[] = []
|
|
123
|
+
const floors: MidTurnFloorContext[] = []
|
|
124
|
+
const emitted: SilencePokeMetric[] = []
|
|
125
|
+
|
|
126
|
+
__setDepsForTests({
|
|
127
|
+
emitMetric: (e) => emitted.push(e),
|
|
128
|
+
onFrameworkFallback: () => { /* not under test here */ },
|
|
129
|
+
isLegitimatelyWorking: () => inFlight > 0,
|
|
130
|
+
floorState: () => ({ role: shape.role, finalAnswerDelivered: delivered }),
|
|
131
|
+
onMidTurnFloor: (ctx) => { floors.push(ctx) },
|
|
132
|
+
thresholdsMs: { fallback: FALLBACK_MS, floor: FLOOR_MS },
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
startTurn(key, 0)
|
|
136
|
+
|
|
137
|
+
let evIdx = 0
|
|
138
|
+
const applyEventsUpTo = (t: number) => {
|
|
139
|
+
while (evIdx < shape.events.length && shape.events[evIdx].t <= t) {
|
|
140
|
+
const ev = shape.events[evIdx++]
|
|
141
|
+
switch (ev.kind) {
|
|
142
|
+
case 'tool_start':
|
|
143
|
+
inFlight++
|
|
144
|
+
noteToolStart(key, `tool-${evIdx}`, 'Bash', 'doing a thing', ev.t)
|
|
145
|
+
break
|
|
146
|
+
case 'tool_end':
|
|
147
|
+
if (inFlight > 0) inFlight--
|
|
148
|
+
noteToolEnd(key, `tool-${evIdx}`, ev.t)
|
|
149
|
+
break
|
|
150
|
+
case 'reply_ack':
|
|
151
|
+
noteOutbound(key, ev.t)
|
|
152
|
+
lastOutbound = ev.t
|
|
153
|
+
break
|
|
154
|
+
case 'reply_substantive':
|
|
155
|
+
noteOutbound(key, ev.t)
|
|
156
|
+
lastOutbound = ev.t
|
|
157
|
+
delivered = true
|
|
158
|
+
break
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
for (let t = 0; t <= shape.totalMs; t += TICK_MS) {
|
|
164
|
+
applyEventsUpTo(t)
|
|
165
|
+
// Oracle: mirror the floor gate against the same pre-tick state.
|
|
166
|
+
const silence = t - (lastOutbound ?? 0)
|
|
167
|
+
if (
|
|
168
|
+
!oracleFloorFired &&
|
|
169
|
+
shape.role === 'user' &&
|
|
170
|
+
!delivered &&
|
|
171
|
+
inFlight > 0 &&
|
|
172
|
+
silence >= FLOOR_MS &&
|
|
173
|
+
silence < FALLBACK_MS
|
|
174
|
+
) {
|
|
175
|
+
oracleFloorFired = true
|
|
176
|
+
oracleFires.push(t)
|
|
177
|
+
}
|
|
178
|
+
__tickForTests(t)
|
|
179
|
+
}
|
|
180
|
+
applyEventsUpTo(shape.totalMs)
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
floors,
|
|
184
|
+
oracleFires,
|
|
185
|
+
deliveredAtEnd: delivered,
|
|
186
|
+
terminal: decideTerminalReason({ enabled: true, role: shape.role, finalAnswerDelivered: delivered }),
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
beforeEach(() => {
|
|
191
|
+
__resetAllForTests()
|
|
192
|
+
delete process.env.SWITCHROOM_DISABLE_SILENCE_POKE
|
|
193
|
+
delete process.env.SWITCHROOM_TG_LIVENESS_FLOOR
|
|
194
|
+
})
|
|
195
|
+
afterEach(() => {
|
|
196
|
+
__resetAllForTests()
|
|
197
|
+
if (ORIGINAL_KILL != null) process.env.SWITCHROOM_DISABLE_SILENCE_POKE = ORIGINAL_KILL
|
|
198
|
+
else delete process.env.SWITCHROOM_DISABLE_SILENCE_POKE
|
|
199
|
+
if (ORIGINAL_FLOOR_KILL != null) process.env.SWITCHROOM_TG_LIVENESS_FLOOR = ORIGINAL_FLOOR_KILL
|
|
200
|
+
else delete process.env.SWITCHROOM_TG_LIVENESS_FLOOR
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
describe('turn-liveness primitive — fuzzed invariants (#2527)', () => {
|
|
204
|
+
it('holds the invariants across 2000 random turn shapes × both surfaces', () => {
|
|
205
|
+
const rng = makeRng(0xC0FFEE)
|
|
206
|
+
const N = 2000
|
|
207
|
+
for (let i = 0; i < N; i++) {
|
|
208
|
+
const shape = genShape(rng)
|
|
209
|
+
const dm = runShape(shape, 'dm')
|
|
210
|
+
// Reset between surface runs (same module, fresh state).
|
|
211
|
+
__resetAllForTests()
|
|
212
|
+
const topic = runShape(shape, 'topic')
|
|
213
|
+
__resetAllForTests()
|
|
214
|
+
|
|
215
|
+
const ctx = `shape#${i} role=${shape.role} events=${JSON.stringify(shape.events)}`
|
|
216
|
+
|
|
217
|
+
// I2 — fire-once per turn.
|
|
218
|
+
expect(dm.floors.length, `I2 fire-once dm ${ctx}`).toBeLessThanOrEqual(1)
|
|
219
|
+
expect(topic.floors.length, `I2 fire-once topic ${ctx}`).toBeLessThanOrEqual(1)
|
|
220
|
+
|
|
221
|
+
// I3 — a non-user role never fires a floor beat.
|
|
222
|
+
if (shape.role !== 'user') {
|
|
223
|
+
expect(dm.floors.length, `I3 role-gate ${ctx}`).toBe(0)
|
|
224
|
+
expect(topic.floors.length, `I3 role-gate ${ctx}`).toBe(0)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// I1 + I4 — the floor fires exactly when the oracle says (which encodes
|
|
228
|
+
// "user + working + silent ≥ floor + < fallback + undelivered").
|
|
229
|
+
expect(dm.floors.length, `I1/I4 oracle-count dm ${ctx}`).toBe(dm.oracleFires.length)
|
|
230
|
+
if (dm.oracleFires.length === 1) {
|
|
231
|
+
expect(dm.floors[0].silenceMs, `I1 oracle-time dm ${ctx}`).toBeGreaterThanOrEqual(FLOOR_MS)
|
|
232
|
+
expect(dm.floors[0].silenceMs, `I1 oracle-time dm ${ctx}`).toBeLessThan(FALLBACK_MS)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// I5 — terminal honesty: undelivered user turn → 'undelivered', else 'done'.
|
|
236
|
+
const expectedTerminal = shape.role === 'user' && !dm.deliveredAtEnd ? 'undelivered' : 'done'
|
|
237
|
+
expect(dm.terminal, `I5 terminal ${ctx}`).toBe(expectedTerminal)
|
|
238
|
+
|
|
239
|
+
// I6 — surface parity: identical floor count + terminal on DM vs topic.
|
|
240
|
+
expect(topic.floors.length, `I6 parity floor-count ${ctx}`).toBe(dm.floors.length)
|
|
241
|
+
expect(topic.terminal, `I6 parity terminal ${ctx}`).toBe(dm.terminal)
|
|
242
|
+
// The fired beat must route to the right surface.
|
|
243
|
+
if (topic.floors.length === 1) {
|
|
244
|
+
expect(topic.floors[0].threadId, `I6 parity thread-route ${ctx}`).toBe(99)
|
|
245
|
+
}
|
|
246
|
+
if (dm.floors.length === 1) {
|
|
247
|
+
expect(dm.floors[0].threadId, `I6 parity dm-route ${ctx}`).toBeNull()
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('the documented #2527 case: 6-min busy-silent user turn fires exactly one floor beat', () => {
|
|
253
|
+
// role=user, a long tool stretch, no reply — the overlord-on-marko case.
|
|
254
|
+
const shape: Shape = {
|
|
255
|
+
role: 'user',
|
|
256
|
+
events: [
|
|
257
|
+
{ t: 2_000, kind: 'tool_start' },
|
|
258
|
+
{ t: 360_000, kind: 'tool_end' },
|
|
259
|
+
],
|
|
260
|
+
totalMs: 365_000,
|
|
261
|
+
}
|
|
262
|
+
const dm = runShape(shape, 'dm')
|
|
263
|
+
__resetAllForTests()
|
|
264
|
+
const topic = runShape(shape, 'topic')
|
|
265
|
+
|
|
266
|
+
expect(dm.floors.length).toBe(1)
|
|
267
|
+
expect(dm.floors[0].silenceMs).toBeGreaterThanOrEqual(FLOOR_MS)
|
|
268
|
+
expect(dm.terminal).toBe('undelivered') // never delivered → not a false 👍
|
|
269
|
+
// Parity: the supergroup topic behaves identically.
|
|
270
|
+
expect(topic.floors.length).toBe(1)
|
|
271
|
+
expect(topic.floors[0].threadId).toBe(99)
|
|
272
|
+
expect(topic.terminal).toBe('undelivered')
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('a cron turn that works silently never fires the floor and keeps 👍', () => {
|
|
276
|
+
const shape: Shape = {
|
|
277
|
+
role: 'system',
|
|
278
|
+
events: [
|
|
279
|
+
{ t: 1_000, kind: 'tool_start' },
|
|
280
|
+
{ t: 200_000, kind: 'tool_end' },
|
|
281
|
+
],
|
|
282
|
+
totalMs: 210_000,
|
|
283
|
+
}
|
|
284
|
+
const r = runShape(shape, 'dm')
|
|
285
|
+
expect(r.floors.length).toBe(0)
|
|
286
|
+
expect(r.terminal).toBe('done')
|
|
287
|
+
})
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
describe('pokeFloorNow — "Status?" mid-turn short-circuit (#2527)', () => {
|
|
291
|
+
function setup(role: LoopRole, delivered: boolean) {
|
|
292
|
+
const floors: MidTurnFloorContext[] = []
|
|
293
|
+
let working = false
|
|
294
|
+
__setDepsForTests({
|
|
295
|
+
emitMetric: () => {},
|
|
296
|
+
onFrameworkFallback: () => {},
|
|
297
|
+
isLegitimatelyWorking: () => working,
|
|
298
|
+
floorState: () => ({ role, finalAnswerDelivered: delivered }),
|
|
299
|
+
onMidTurnFloor: (ctx) => { floors.push(ctx) },
|
|
300
|
+
thresholdsMs: { fallback: FALLBACK_MS, floor: FLOOR_MS },
|
|
301
|
+
})
|
|
302
|
+
return { floors, setWorking: (w: boolean) => { working = w } }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
it('fires immediately for a user turn even before the threshold and with no tool in flight', () => {
|
|
306
|
+
const { floors } = setup('user', false)
|
|
307
|
+
startTurn('chat', 0)
|
|
308
|
+
pokeFloorNow('chat', 3_000) // 3s in, not working — the user explicitly asked
|
|
309
|
+
expect(floors.length).toBe(1)
|
|
310
|
+
expect(floors[0].forced).toBe(true)
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it('is fire-once: a second "Status?" within the same turn is a no-op', () => {
|
|
314
|
+
const { floors } = setup('user', false)
|
|
315
|
+
startTurn('chat', 0)
|
|
316
|
+
pokeFloorNow('chat', 3_000)
|
|
317
|
+
pokeFloorNow('chat', 6_000)
|
|
318
|
+
expect(floors.length).toBe(1)
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('does nothing for a system turn (role gate holds even when forced)', () => {
|
|
322
|
+
const { floors } = setup('system', false)
|
|
323
|
+
startTurn('chat', 0)
|
|
324
|
+
pokeFloorNow('chat', 3_000)
|
|
325
|
+
expect(floors.length).toBe(0)
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
it('does nothing once a substantive answer has been delivered', () => {
|
|
329
|
+
const { floors } = setup('user', true)
|
|
330
|
+
startTurn('chat', 0)
|
|
331
|
+
pokeFloorNow('chat', 3_000)
|
|
332
|
+
expect(floors.length).toBe(0)
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
it('is a no-op when there is no live turn for the key', () => {
|
|
336
|
+
const { floors } = setup('user', false)
|
|
337
|
+
pokeFloorNow('chat', 3_000) // no startTurn
|
|
338
|
+
expect(floors.length).toBe(0)
|
|
339
|
+
})
|
|
340
|
+
})
|
|
@@ -173,6 +173,29 @@ describe("statusPairedText", () => {
|
|
|
173
173
|
expect(out).toContain("Status: <code>running</code> · up 3h 12m");
|
|
174
174
|
});
|
|
175
175
|
|
|
176
|
+
// demo mode (the `/status demo` suffix) — masks the paired-user tag only.
|
|
177
|
+
describe("demo mode", () => {
|
|
178
|
+
it("WITHOUT demo, the real handle still renders", () => {
|
|
179
|
+
expect(statusPairedText({ user: "@ken_real", meta })).toContain("Paired as @ken_real.");
|
|
180
|
+
});
|
|
181
|
+
it("WITH demo, the handle is masked to a @demo_user form", () => {
|
|
182
|
+
const out = statusPairedText({ user: "@ken_real", meta, demo: true });
|
|
183
|
+
expect(out).not.toContain("@ken_real");
|
|
184
|
+
expect(out).toMatch(/Paired as @demo_user\d*\./);
|
|
185
|
+
});
|
|
186
|
+
it("WITH demo, a numeric sender id is masked to a @handle, not a raw number", () => {
|
|
187
|
+
const out = statusPairedText({ user: "12345", meta, demo: true });
|
|
188
|
+
expect(out).not.toContain("12345");
|
|
189
|
+
expect(out).toMatch(/Paired as @demo_user\d*\./);
|
|
190
|
+
});
|
|
191
|
+
it("WITH demo, the agent/model/auth topology is NOT masked", () => {
|
|
192
|
+
const out = statusPairedText({ user: "@ken_real", meta, demo: true });
|
|
193
|
+
// Out-of-scope fields stay real.
|
|
194
|
+
expect(out).toContain("Auth: ✓ Max · expires 29 days");
|
|
195
|
+
expect(out).toContain("<code>sonnet</code>");
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
176
199
|
// Issue #142 PR 3 — audit details surfaced on /status when the gateway
|
|
177
200
|
// successfully loads switchroom.yaml. Pre-#142 this content lived in
|
|
178
201
|
// the SessionStart greeting card; now it's pulled on demand.
|
|
@@ -439,10 +462,16 @@ describe("TELEGRAM_MENU_COMMANDS (slash-menu shape)", () => {
|
|
|
439
462
|
).not.toMatch(/<code>\/reauth\b/);
|
|
440
463
|
});
|
|
441
464
|
|
|
442
|
-
it("menu is short enough for a mobile keyboard (<=
|
|
465
|
+
it("menu is short enough for a mobile keyboard (<= 21 entries)", () => {
|
|
443
466
|
// Hard cap: Telegram autocomplete on mobile shows ~8-10 commands
|
|
444
|
-
// without scrolling.
|
|
445
|
-
|
|
467
|
+
// without scrolling. 21 is a generous upper bound (well under
|
|
468
|
+
// Telegram's own 100-command limit). /whoami brought it to 21.
|
|
469
|
+
expect(TELEGRAM_MENU_COMMANDS.length).toBeLessThanOrEqual(21);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it("menu includes /whoami (sandbox introspection)", () => {
|
|
473
|
+
const names = TELEGRAM_MENU_COMMANDS.map(c => c.command);
|
|
474
|
+
expect(names, "missing /whoami from Telegram menu").toContain("whoami");
|
|
446
475
|
});
|
|
447
476
|
|
|
448
477
|
it("every menu command is documented in switchroomHelpText", () => {
|