switchroom 0.13.25 → 0.13.27
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 +132 -10
- package/dist/vault/broker/server.js +32 -4
- package/package.json +1 -1
- package/telegram-plugin/active-reactions-sweep.ts +4 -4
- package/telegram-plugin/dist/gateway/gateway.js +239 -64
- package/telegram-plugin/docs/waiting-ux-spec.md +17 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +10 -6
- package/telegram-plugin/gateway/gateway.ts +166 -51
- package/telegram-plugin/gateway/inbound-spool.ts +69 -2
- package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +14 -0
- package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +256 -0
- package/telegram-plugin/pending-work-progress.ts +5 -1
- package/telegram-plugin/status-reactions.ts +70 -58
- package/telegram-plugin/stream-reply-handler.ts +7 -36
- package/telegram-plugin/subagent-watcher.ts +64 -3
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -3
- package/telegram-plugin/tests/inbound-spool-progress.test.ts +213 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +62 -0
- package/telegram-plugin/tests/multi-turn-continuity.test.ts +0 -1
- package/telegram-plugin/tests/outbound-ordering.test.ts +0 -1
- package/telegram-plugin/tests/parse-mode-rotation.test.ts +0 -1
- package/telegram-plugin/tests/reply-terminal-reaction.test.ts +78 -135
- package/telegram-plugin/tests/status-accent.test.ts +0 -1
- package/telegram-plugin/tests/status-reactions.test.ts +56 -27
- package/telegram-plugin/tests/stream-reply-error-paths.test.ts +0 -1
- package/telegram-plugin/tests/stream-reply-handler.test.ts +9 -25
- package/telegram-plugin/tests/streaming-e2e.test.ts +0 -1
- package/telegram-plugin/tests/streaming-orchestration.test.ts +0 -1
- package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +22 -0
- package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +269 -0
- package/telegram-plugin/uat/scenarios/jtbd-reflective-status-reaction-dm.test.ts +204 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pin the InboundMessage shape and decision logic for the synthetic
|
|
3
|
+
* `subagent_progress` envelope (#1720 — mid-flight progress beat).
|
|
4
|
+
*
|
|
5
|
+
* Three load-bearing guarantees:
|
|
6
|
+
*
|
|
7
|
+
* 1. The `meta.source` string is `subagent_progress`. The MCP
|
|
8
|
+
* channel notification wraps it as
|
|
9
|
+
* `<channel source="subagent_progress">`; the parent agent's
|
|
10
|
+
* beat-3 hook keys on exactly that tag.
|
|
11
|
+
* 2. `meta.expiresAt` is set on every envelope (`nowMs + 2 × interval`).
|
|
12
|
+
* Stale progress is a lie, so the spool's TTL filter MUST have a
|
|
13
|
+
* live numeric value to gate on.
|
|
14
|
+
* 3. The spool dedup id derived via `meta.subagent_jsonl_id` +
|
|
15
|
+
* `meta.bucket_idx` is deterministic — same (jsonl id, bucket idx)
|
|
16
|
+
* always collapses to the same `s:progress:...` id, so a re-fire
|
|
17
|
+
* within the same window is structurally a no-op.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { describe, it, expect } from 'vitest'
|
|
21
|
+
import {
|
|
22
|
+
buildSubagentProgressInbound,
|
|
23
|
+
decideSubagentProgress,
|
|
24
|
+
isEnvFlagOn,
|
|
25
|
+
DEFAULT_PROGRESS_INTERVAL_MS,
|
|
26
|
+
PROGRESS_DESC_MAX,
|
|
27
|
+
PROGRESS_RESULT_MAX,
|
|
28
|
+
} from '../gateway/subagent-progress-inbound-builder.js'
|
|
29
|
+
import { spoolId } from '../gateway/inbound-spool.js'
|
|
30
|
+
|
|
31
|
+
const FIXED_NOW = 1_700_000_000_000
|
|
32
|
+
const INTERVAL_MS = DEFAULT_PROGRESS_INTERVAL_MS
|
|
33
|
+
|
|
34
|
+
describe('buildSubagentProgressInbound', () => {
|
|
35
|
+
it('builds an envelope with the load-bearing meta.source and TTL', () => {
|
|
36
|
+
const inbound = buildSubagentProgressInbound({
|
|
37
|
+
ctx: {
|
|
38
|
+
chatId: '12345',
|
|
39
|
+
subagentJsonlId: 'jsonl-abc',
|
|
40
|
+
taskDescription: 'Crawl the repo for dead code',
|
|
41
|
+
latestSummary: 'scanning packages/server — 14 files in so far',
|
|
42
|
+
elapsedMs: 7 * 60 * 1000,
|
|
43
|
+
bucketIdx: 1,
|
|
44
|
+
progressIntervalMs: INTERVAL_MS,
|
|
45
|
+
},
|
|
46
|
+
nowMs: FIXED_NOW,
|
|
47
|
+
})
|
|
48
|
+
expect(inbound.type).toBe('inbound')
|
|
49
|
+
expect(inbound.chatId).toBe('12345')
|
|
50
|
+
expect(inbound.user).toBe('subagent-watcher')
|
|
51
|
+
expect(inbound.ts).toBe(FIXED_NOW)
|
|
52
|
+
expect(inbound.messageId).toBe(FIXED_NOW)
|
|
53
|
+
expect(inbound.meta.source).toBe('subagent_progress')
|
|
54
|
+
expect(inbound.meta.subagent_jsonl_id).toBe('jsonl-abc')
|
|
55
|
+
expect(inbound.meta.bucket_idx).toBe('1')
|
|
56
|
+
// TTL: nowMs + 2 × interval. Numeric, parseable, > now.
|
|
57
|
+
expect(inbound.meta.expiresAt).toBeDefined()
|
|
58
|
+
const exp = Number(inbound.meta.expiresAt)
|
|
59
|
+
expect(Number.isFinite(exp)).toBe(true)
|
|
60
|
+
expect(exp).toBe(FIXED_NOW + 2 * INTERVAL_MS)
|
|
61
|
+
// Body content
|
|
62
|
+
expect(inbound.text).toContain('Crawl the repo for dead code')
|
|
63
|
+
expect(inbound.text).toContain('scanning packages/server')
|
|
64
|
+
expect(inbound.text).toMatch(/beat 3/)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('handles an empty latest summary (tool-only worker) without breaking shape', () => {
|
|
68
|
+
const inbound = buildSubagentProgressInbound({
|
|
69
|
+
ctx: {
|
|
70
|
+
chatId: '99',
|
|
71
|
+
subagentJsonlId: 'jsonl-xyz',
|
|
72
|
+
taskDescription: 'Run the suite',
|
|
73
|
+
latestSummary: '',
|
|
74
|
+
elapsedMs: 6 * 60 * 1000,
|
|
75
|
+
bucketIdx: 1,
|
|
76
|
+
progressIntervalMs: INTERVAL_MS,
|
|
77
|
+
},
|
|
78
|
+
nowMs: FIXED_NOW,
|
|
79
|
+
})
|
|
80
|
+
expect(inbound.meta.source).toBe('subagent_progress')
|
|
81
|
+
expect(inbound.text).toContain('tool-only')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('caps an over-long latest summary and description', () => {
|
|
85
|
+
const inbound = buildSubagentProgressInbound({
|
|
86
|
+
ctx: {
|
|
87
|
+
chatId: '99',
|
|
88
|
+
subagentJsonlId: 'jsonl-xyz',
|
|
89
|
+
taskDescription: 'D'.repeat(PROGRESS_DESC_MAX + 500),
|
|
90
|
+
latestSummary: 'L'.repeat(PROGRESS_RESULT_MAX + 5000),
|
|
91
|
+
elapsedMs: 5 * 60 * 1000,
|
|
92
|
+
bucketIdx: 1,
|
|
93
|
+
progressIntervalMs: INTERVAL_MS,
|
|
94
|
+
},
|
|
95
|
+
nowMs: FIXED_NOW,
|
|
96
|
+
})
|
|
97
|
+
expect(inbound.text.length).toBeLessThan(
|
|
98
|
+
PROGRESS_RESULT_MAX + PROGRESS_DESC_MAX + 800,
|
|
99
|
+
)
|
|
100
|
+
expect(inbound.text).toContain('…')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
// Deterministic spoolId per bucket — the core #1720 guarantee.
|
|
104
|
+
it('two envelopes with the same (jsonl id, bucket idx) yield IDENTICAL spoolIds', () => {
|
|
105
|
+
const a = buildSubagentProgressInbound({
|
|
106
|
+
ctx: {
|
|
107
|
+
chatId: '12345',
|
|
108
|
+
subagentJsonlId: 'jsonl-abc',
|
|
109
|
+
taskDescription: 'x',
|
|
110
|
+
latestSummary: 'first line',
|
|
111
|
+
elapsedMs: 7 * 60 * 1000,
|
|
112
|
+
bucketIdx: 1,
|
|
113
|
+
progressIntervalMs: INTERVAL_MS,
|
|
114
|
+
},
|
|
115
|
+
nowMs: FIXED_NOW,
|
|
116
|
+
})
|
|
117
|
+
const b = buildSubagentProgressInbound({
|
|
118
|
+
ctx: {
|
|
119
|
+
chatId: '12345',
|
|
120
|
+
subagentJsonlId: 'jsonl-abc',
|
|
121
|
+
taskDescription: 'x',
|
|
122
|
+
latestSummary: 'a DIFFERENT line, also in bucket 1',
|
|
123
|
+
elapsedMs: 8 * 60 * 1000, // still bucket 1 (8 < 10)
|
|
124
|
+
bucketIdx: 1,
|
|
125
|
+
progressIntervalMs: INTERVAL_MS,
|
|
126
|
+
},
|
|
127
|
+
// Different nowMs — proves dedup is content-keyed, not ts-keyed.
|
|
128
|
+
nowMs: FIXED_NOW + 60_000,
|
|
129
|
+
})
|
|
130
|
+
expect(spoolId(a)).toBe(spoolId(b))
|
|
131
|
+
expect(spoolId(a)).toBe('s:progress:jsonl-abc:1')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('different bucket idx → different spoolId (envelopes do NOT collapse)', () => {
|
|
135
|
+
const bucket1 = buildSubagentProgressInbound({
|
|
136
|
+
ctx: {
|
|
137
|
+
chatId: '12345',
|
|
138
|
+
subagentJsonlId: 'jsonl-abc',
|
|
139
|
+
taskDescription: 'x',
|
|
140
|
+
latestSummary: 'b1',
|
|
141
|
+
elapsedMs: 7 * 60 * 1000,
|
|
142
|
+
bucketIdx: 1,
|
|
143
|
+
progressIntervalMs: INTERVAL_MS,
|
|
144
|
+
},
|
|
145
|
+
nowMs: FIXED_NOW,
|
|
146
|
+
})
|
|
147
|
+
const bucket2 = buildSubagentProgressInbound({
|
|
148
|
+
ctx: {
|
|
149
|
+
chatId: '12345',
|
|
150
|
+
subagentJsonlId: 'jsonl-abc',
|
|
151
|
+
taskDescription: 'x',
|
|
152
|
+
latestSummary: 'b2',
|
|
153
|
+
elapsedMs: 12 * 60 * 1000,
|
|
154
|
+
bucketIdx: 2,
|
|
155
|
+
progressIntervalMs: INTERVAL_MS,
|
|
156
|
+
},
|
|
157
|
+
nowMs: FIXED_NOW + 5 * 60 * 1000,
|
|
158
|
+
})
|
|
159
|
+
expect(spoolId(bucket1)).not.toBe(spoolId(bucket2))
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
describe('isEnvFlagOn — bool env parser', () => {
|
|
164
|
+
it('treats unset / empty / 0 / false / no / off as OFF (case-insensitive, trimmed)', () => {
|
|
165
|
+
for (const v of [undefined, '', '0', 'false', 'FALSE', 'False', 'no', 'NO', 'off', 'OFF', ' 0 ', ' false ']) {
|
|
166
|
+
expect(isEnvFlagOn(v), `value=${JSON.stringify(v)}`).toBe(false)
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
it('treats 1 / true / yes / arbitrary non-empty as ON', () => {
|
|
170
|
+
for (const v of ['1', 'true', 'TRUE', 'yes', 'on', 'enabled', 'kill', 'anything']) {
|
|
171
|
+
expect(isEnvFlagOn(v), `value=${JSON.stringify(v)}`).toBe(true)
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
describe('decideSubagentProgress', () => {
|
|
177
|
+
function baseInput(over: Partial<Parameters<typeof decideSubagentProgress>[0]> = {}) {
|
|
178
|
+
return {
|
|
179
|
+
disableEnvValue: undefined,
|
|
180
|
+
isBackground: true,
|
|
181
|
+
fleetChatId: '12345',
|
|
182
|
+
ownerChatId: '999',
|
|
183
|
+
subagentJsonlId: 'jsonl-abc',
|
|
184
|
+
taskDescription: 'x',
|
|
185
|
+
latestSummary: 'hi',
|
|
186
|
+
elapsedMs: 7 * 60 * 1000,
|
|
187
|
+
progressIntervalMs: INTERVAL_MS,
|
|
188
|
+
lastBucketIdx: null,
|
|
189
|
+
nowMs: FIXED_NOW,
|
|
190
|
+
...over,
|
|
191
|
+
} as const
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
it('delivers when all gates pass; bucketIdx is floor(elapsed/interval)', () => {
|
|
195
|
+
const d = decideSubagentProgress(baseInput())
|
|
196
|
+
expect(d.deliver).toBe(true)
|
|
197
|
+
if (d.deliver) {
|
|
198
|
+
expect(d.bucketIdx).toBe(1)
|
|
199
|
+
expect(d.chatId).toBe('12345')
|
|
200
|
+
expect(d.inbound.meta.source).toBe('subagent_progress')
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('kill-switch (SWITCHROOM_DISABLE_SUBAGENT_PROGRESS=1) skips delivery', () => {
|
|
205
|
+
const d = decideSubagentProgress(baseInput({ disableEnvValue: '1' }))
|
|
206
|
+
expect(d.deliver).toBe(false)
|
|
207
|
+
if (!d.deliver) expect(d.reason).toBe('env-disabled')
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('kill-switch treats `false`/`no`/`off`/empty/unset as OFF (not enabled)', () => {
|
|
211
|
+
for (const v of [undefined, '', '0', 'false', 'FALSE', 'False', 'no', 'NO', 'off', 'OFF', ' 0 ', ' false ']) {
|
|
212
|
+
const d = decideSubagentProgress(baseInput({ disableEnvValue: v }))
|
|
213
|
+
expect(d.deliver, `value=${JSON.stringify(v)} should NOT be env-disabled`).toBe(true)
|
|
214
|
+
}
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('kill-switch treats `1`/`true`/`yes`/arbitrary non-empty as ON', () => {
|
|
218
|
+
for (const v of ['1', 'true', 'TRUE', 'yes', 'on', 'enabled', 'kill']) {
|
|
219
|
+
const d = decideSubagentProgress(baseInput({ disableEnvValue: v }))
|
|
220
|
+
expect(d.deliver, `value=${JSON.stringify(v)} should be env-disabled`).toBe(false)
|
|
221
|
+
if (!d.deliver) expect(d.reason).toBe('env-disabled')
|
|
222
|
+
}
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('foreground sub-agents skip (parent already sees the narrative)', () => {
|
|
226
|
+
const d = decideSubagentProgress(baseInput({ isBackground: false }))
|
|
227
|
+
expect(d.deliver).toBe(false)
|
|
228
|
+
if (!d.deliver) expect(d.reason).toBe('foreground')
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('falls back to owner chat when fleet chat is empty', () => {
|
|
232
|
+
const d = decideSubagentProgress(baseInput({ fleetChatId: '' }))
|
|
233
|
+
expect(d.deliver).toBe(true)
|
|
234
|
+
if (d.deliver) expect(d.chatId).toBe('999')
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('no-chat when neither fleet nor owner resolves', () => {
|
|
238
|
+
const d = decideSubagentProgress(baseInput({ fleetChatId: '', ownerChatId: '' }))
|
|
239
|
+
expect(d.deliver).toBe(false)
|
|
240
|
+
if (!d.deliver) expect(d.reason).toBe('no-chat')
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('first bucket (idx 0) is suppressed — ambient liveness is plenty for the opening window', () => {
|
|
244
|
+
const d = decideSubagentProgress(baseInput({ elapsedMs: 60_000 }))
|
|
245
|
+
expect(d.deliver).toBe(false)
|
|
246
|
+
if (!d.deliver) expect(d.reason).toBe('first-bucket-suppressed')
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('same bucket as last fired is a no-op (dedup at the decision layer)', () => {
|
|
250
|
+
const d = decideSubagentProgress(baseInput({ lastBucketIdx: 1 }))
|
|
251
|
+
expect(d.deliver).toBe(false)
|
|
252
|
+
if (!d.deliver) expect(d.reason).toBe('bucket-already-fired')
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('next bucket fires even when prior bucket already fired', () => {
|
|
256
|
+
const d = decideSubagentProgress(baseInput({
|
|
257
|
+
elapsedMs: 12 * 60 * 1000,
|
|
258
|
+
lastBucketIdx: 1,
|
|
259
|
+
}))
|
|
260
|
+
expect(d.deliver).toBe(true)
|
|
261
|
+
if (d.deliver) expect(d.bucketIdx).toBe(2)
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('missing-jsonl-id refuses to mint a non-deterministic envelope', () => {
|
|
265
|
+
const d = decideSubagentProgress(baseInput({ subagentJsonlId: '' }))
|
|
266
|
+
expect(d.deliver).toBe(false)
|
|
267
|
+
if (!d.deliver) expect(d.reason).toBe('missing-jsonl-id')
|
|
268
|
+
})
|
|
269
|
+
})
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JTBD scenario — reflective status reaction (#1713).
|
|
3
|
+
*
|
|
4
|
+
* Serves the JTBD "know what my agent is actually doing" (see
|
|
5
|
+
* `reference/know-what-my-agent-is-doing.md`). The status reaction on
|
|
6
|
+
* the user's inbound is the *primary* ambient liveness signal — the
|
|
7
|
+
* user reads it as "what is the agent doing right now". When it
|
|
8
|
+
* collapses straight to 👍 mid-turn, the signal evaporates and the
|
|
9
|
+
* user is left wondering whether the agent is still working.
|
|
10
|
+
*
|
|
11
|
+
* #1713 defect: plain `reply` and `stream_reply done=true` were both
|
|
12
|
+
* firing the terminal 👍 immediately, regardless of whether the turn
|
|
13
|
+
* had actually ended (the model often continues with tools after a
|
|
14
|
+
* mid-turn ack or a stream-finalized answer). The fix makes the
|
|
15
|
+
* controller reflective:
|
|
16
|
+
*
|
|
17
|
+
* - Working states (🤔, ✍, 👨💻, ⚡, 🗜) are bidirectional and may
|
|
18
|
+
* re-enter any number of times within a turn.
|
|
19
|
+
* - Mid-turn replies (ack or final) are non-events for the reaction.
|
|
20
|
+
* - 🔥 / 😱 is non-terminal — recovery to a working state is allowed.
|
|
21
|
+
* - Only the `turn_end` IPC event (Stop hook) finalizes to 👍.
|
|
22
|
+
* - Rapid state flips coalesce via a 3-5s debounce window.
|
|
23
|
+
*
|
|
24
|
+
* This JTBD test asserts the controller contract end-to-end at the
|
|
25
|
+
* gateway-wiring level (via the StatusReactionController itself —
|
|
26
|
+
* the wiring is exhaustively covered by gateway integration tests
|
|
27
|
+
* and the unit suite at `telegram-plugin/tests/status-reactions.test.ts`).
|
|
28
|
+
* A live-Telegram harness UAT was considered but is poor value for
|
|
29
|
+
* this contract: real Bot-API reactions only expose the *current*
|
|
30
|
+
* emoji, not the full transition trail, so the unit-suite assertion
|
|
31
|
+
* shape is strictly more powerful. Live-harness coverage can be
|
|
32
|
+
* added as a follow-up once Bot-API reaction-history surfaces.
|
|
33
|
+
*
|
|
34
|
+
* Acceptance criteria (mapped from #1713 issue body):
|
|
35
|
+
* 1. Reply does NOT advance reaction to 👍.
|
|
36
|
+
* 2. Bidirectional transitions: thinking → tool → thinking works.
|
|
37
|
+
* 3. Only turn_end produces 👍.
|
|
38
|
+
* 4. Debounce coalesces rapid flips.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
42
|
+
import { StatusReactionController } from "../../status-reactions.js";
|
|
43
|
+
|
|
44
|
+
function makeEmitter() {
|
|
45
|
+
const calls: string[] = [];
|
|
46
|
+
const emit = vi.fn(async (emoji: string) => {
|
|
47
|
+
calls.push(emoji);
|
|
48
|
+
});
|
|
49
|
+
return { emit, calls };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function flush(): Promise<void> {
|
|
53
|
+
for (let i = 0; i < 8; i++) {
|
|
54
|
+
await Promise.resolve();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe("uat-jtbd: reflective status reaction (#1713)", () => {
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
vi.useFakeTimers();
|
|
61
|
+
});
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
vi.useRealTimers();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ── AC1 ──────────────────────────────────────────────────────────────
|
|
67
|
+
it("AC1: mid-turn reply does NOT advance reaction to 👍", async () => {
|
|
68
|
+
// Simulate a turn that:
|
|
69
|
+
// 1. Receives an inbound (👀).
|
|
70
|
+
// 2. The model thinks (🤔).
|
|
71
|
+
// 3. The model sends a reply mid-turn (NON-EVENT — no controller call).
|
|
72
|
+
// 4. The model continues with a tool call (👨💻).
|
|
73
|
+
// 5. turn_end finalizes (👍).
|
|
74
|
+
const { emit, calls } = makeEmitter();
|
|
75
|
+
const ctrl = new StatusReactionController(emit);
|
|
76
|
+
|
|
77
|
+
ctrl.setQueued(); // 👀
|
|
78
|
+
await flush();
|
|
79
|
+
|
|
80
|
+
ctrl.setThinking(); // → 🤔 (debounced)
|
|
81
|
+
vi.advanceTimersByTime(3500);
|
|
82
|
+
await flush();
|
|
83
|
+
|
|
84
|
+
// Mid-turn reply happens here — gateway does NOT call any controller
|
|
85
|
+
// method. The reaction must remain on 🤔.
|
|
86
|
+
expect(calls).toEqual(["👀", "🤔"]);
|
|
87
|
+
|
|
88
|
+
// Model continues working post-reply.
|
|
89
|
+
ctrl.setTool("Bash"); // → 👨💻 (debounced)
|
|
90
|
+
vi.advanceTimersByTime(3500);
|
|
91
|
+
await flush();
|
|
92
|
+
expect(calls).toEqual(["👀", "🤔", "👨💻"]);
|
|
93
|
+
|
|
94
|
+
// turn_end is the only terminal trigger.
|
|
95
|
+
ctrl.finalize("done");
|
|
96
|
+
await flush();
|
|
97
|
+
expect(calls).toEqual(["👀", "🤔", "👨💻", "👍"]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// ── AC2 ──────────────────────────────────────────────────────────────
|
|
101
|
+
it("AC2: bidirectional transitions — thinking → tool → thinking re-enters", async () => {
|
|
102
|
+
const { emit, calls } = makeEmitter();
|
|
103
|
+
const ctrl = new StatusReactionController(emit);
|
|
104
|
+
ctrl.setQueued();
|
|
105
|
+
await flush();
|
|
106
|
+
|
|
107
|
+
ctrl.setThinking();
|
|
108
|
+
vi.advanceTimersByTime(3500);
|
|
109
|
+
await flush();
|
|
110
|
+
expect(calls).toEqual(["👀", "🤔"]);
|
|
111
|
+
|
|
112
|
+
ctrl.setTool("Read"); // → 👨💻 (coding family)
|
|
113
|
+
vi.advanceTimersByTime(3500);
|
|
114
|
+
await flush();
|
|
115
|
+
expect(calls).toEqual(["👀", "🤔", "👨💻"]);
|
|
116
|
+
|
|
117
|
+
// Back to thinking. The same state may re-enter; controller is
|
|
118
|
+
// reflective, not a one-way ratchet.
|
|
119
|
+
ctrl.setThinking();
|
|
120
|
+
vi.advanceTimersByTime(3500);
|
|
121
|
+
await flush();
|
|
122
|
+
expect(calls).toEqual(["👀", "🤔", "👨💻", "🤔"]);
|
|
123
|
+
|
|
124
|
+
// And a non-terminal 😱 mid-turn (e.g. transient 5xx) must permit
|
|
125
|
+
// recovery to a working state.
|
|
126
|
+
ctrl.setError();
|
|
127
|
+
vi.advanceTimersByTime(3500);
|
|
128
|
+
await flush();
|
|
129
|
+
expect(calls).toEqual(["👀", "🤔", "👨💻", "🤔", "😱"]);
|
|
130
|
+
|
|
131
|
+
ctrl.setTool("WebFetch"); // recovery → ⚡
|
|
132
|
+
vi.advanceTimersByTime(3500);
|
|
133
|
+
await flush();
|
|
134
|
+
expect(calls).toEqual(["👀", "🤔", "👨💻", "🤔", "😱", "⚡"]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// ── AC3 ──────────────────────────────────────────────────────────────
|
|
138
|
+
it("AC3: only turn_end (finalize) produces 👍", async () => {
|
|
139
|
+
const { emit, calls } = makeEmitter();
|
|
140
|
+
const ctrl = new StatusReactionController(emit);
|
|
141
|
+
ctrl.setQueued();
|
|
142
|
+
await flush();
|
|
143
|
+
|
|
144
|
+
// Many working transitions; never call finalize. The reaction must
|
|
145
|
+
// not land on 👍.
|
|
146
|
+
for (let i = 0; i < 5; i++) {
|
|
147
|
+
ctrl.setThinking();
|
|
148
|
+
vi.advanceTimersByTime(3500);
|
|
149
|
+
await flush();
|
|
150
|
+
ctrl.setTool("Bash");
|
|
151
|
+
vi.advanceTimersByTime(3500);
|
|
152
|
+
await flush();
|
|
153
|
+
}
|
|
154
|
+
expect(calls).not.toContain("👍");
|
|
155
|
+
|
|
156
|
+
// turn_end fires → 👍 finally lands.
|
|
157
|
+
ctrl.finalize("done");
|
|
158
|
+
await flush();
|
|
159
|
+
expect(calls[calls.length - 1]).toBe("👍");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ── AC4 ──────────────────────────────────────────────────────────────
|
|
163
|
+
it("AC4: 3500ms debounce coalesces rapid state flips into one emit", async () => {
|
|
164
|
+
const { emit, calls } = makeEmitter();
|
|
165
|
+
const ctrl = new StatusReactionController(emit);
|
|
166
|
+
ctrl.setQueued();
|
|
167
|
+
await flush();
|
|
168
|
+
|
|
169
|
+
// Rapid flip storm — 10 transitions within ~2s.
|
|
170
|
+
for (let i = 0; i < 10; i++) {
|
|
171
|
+
if (i % 2 === 0) ctrl.setThinking();
|
|
172
|
+
else ctrl.setTool("Bash");
|
|
173
|
+
vi.advanceTimersByTime(200);
|
|
174
|
+
}
|
|
175
|
+
await flush();
|
|
176
|
+
|
|
177
|
+
// Debounce hasn't elapsed — only the queued emoji has landed.
|
|
178
|
+
expect(calls).toEqual(["👀"]);
|
|
179
|
+
|
|
180
|
+
// After the window closes, only the LAST state lands.
|
|
181
|
+
vi.advanceTimersByTime(3500);
|
|
182
|
+
await flush();
|
|
183
|
+
// Last call was setTool('Bash') on odd index — last index is 9 (odd).
|
|
184
|
+
expect(calls).toEqual(["👀", "👨💻"]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// ── AC5: terminal flushes any pending debounced state ────────────────
|
|
188
|
+
it("AC5: finalize() flushes a pending working state before emitting 👍", async () => {
|
|
189
|
+
// F1 guarantee (#553) — preserved through #1713. If the turn ends
|
|
190
|
+
// while a non-terminal reaction is mid-debounce, the pending state
|
|
191
|
+
// must land BEFORE the terminal so the user sees the working
|
|
192
|
+
// signal rather than a 👀 → 👍 collapse.
|
|
193
|
+
const { emit, calls } = makeEmitter();
|
|
194
|
+
const ctrl = new StatusReactionController(emit);
|
|
195
|
+
ctrl.setQueued();
|
|
196
|
+
await flush();
|
|
197
|
+
|
|
198
|
+
ctrl.setThinking(); // pending — debounce window open
|
|
199
|
+
// Turn ends before debounce elapses.
|
|
200
|
+
ctrl.finalize("done");
|
|
201
|
+
await flush();
|
|
202
|
+
expect(calls).toEqual(["👀", "🤔", "👍"]);
|
|
203
|
+
});
|
|
204
|
+
});
|