switchroom 0.19.14 → 0.19.16
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 +1 -1
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +31 -2
- package/telegram-plugin/dist/gateway/gateway.js +1690 -932
- package/telegram-plugin/dist/server.js +31 -2
- package/telegram-plugin/gateway/background-shell-liveness.ts +65 -0
- package/telegram-plugin/gateway/forward-origin.ts +6 -1
- package/telegram-plugin/gateway/gateway.ts +10 -57
- package/telegram-plugin/gateway/narrative-lane.ts +11 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +25 -23
- package/telegram-plugin/gateway/outbox-listen-markup.ts +67 -0
- package/telegram-plugin/gateway/outbox-sweep.ts +124 -20
- package/telegram-plugin/gateway/rich-message-handler.ts +241 -0
- package/telegram-plugin/gateway/silence-poke-session-event.ts +89 -0
- package/telegram-plugin/gateway/stream-render.ts +107 -15
- package/telegram-plugin/gateway/unhandled-message.ts +14 -0
- package/telegram-plugin/hooks/narration-classify.d.mts +23 -0
- package/telegram-plugin/hooks/narration-classify.mjs +210 -0
- package/telegram-plugin/hooks/silent-end-scan.mjs +136 -82
- package/telegram-plugin/narrative-flush.ts +35 -0
- package/telegram-plugin/outbox.ts +73 -3
- package/telegram-plugin/session-tail.ts +88 -1
- package/telegram-plugin/shown-ledger.ts +145 -0
- package/telegram-plugin/silence-poke.ts +118 -1
- package/telegram-plugin/silent-end.ts +42 -0
- package/telegram-plugin/tests/background-shell-liveness.test.ts +72 -0
- package/telegram-plugin/tests/backstop-exactly-once.test.ts +335 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +14 -0
- package/telegram-plugin/tests/feed-survival.test.ts +7 -1
- package/telegram-plugin/tests/fixtures/bg-shell-liveness-3519.jsonl +3 -0
- package/telegram-plugin/tests/forward-origin.test.ts +20 -0
- package/telegram-plugin/tests/forwarded-rich-message-coalesce.test.ts +290 -0
- package/telegram-plugin/tests/forwarded-rich-message.test.ts +305 -0
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +1 -0
- package/telegram-plugin/tests/narration-leak-3513.test.ts +352 -0
- package/telegram-plugin/tests/outbox-sweep-listen-button.test.ts +253 -0
- package/telegram-plugin/tests/session-tail.test.ts +91 -1
- package/telegram-plugin/tests/silence-poke.test.ts +280 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +42 -13
- package/telegram-plugin/tests/silent-end.test.ts +7 -1
- package/telegram-plugin/tests/tts-normalize.test.ts +66 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +35 -3
- package/telegram-plugin/tests/voice-normalize-text.test.ts +82 -1
- package/telegram-plugin/tts-normalize.ts +12 -0
- package/telegram-plugin/turn-flush-safety.ts +66 -53
- package/telegram-plugin/voice-normalize-text.ts +100 -0
- package/telegram-plugin/voice-ondemand.ts +71 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'fs'
|
|
3
|
+
import { join } from 'path'
|
|
4
|
+
import { applyBackgroundShellLiveness } from '../gateway/background-shell-liveness.js'
|
|
5
|
+
import { projectTranscriptLine, type SessionEvent } from '../session-tail.js'
|
|
6
|
+
|
|
7
|
+
// #3519 sharpen — the glue that maps parsed session events to the silence-poke
|
|
8
|
+
// background-shell registry. Driven by the REAL fixtures (carrie session
|
|
9
|
+
// a6d2d33a-…, claude v2.1.197; see tests/fixtures/bg-shell-liveness-3519.jsonl)
|
|
10
|
+
// parsed through the production projectTranscriptLine path, so the event shapes
|
|
11
|
+
// here are exactly what the gateway sees at runtime.
|
|
12
|
+
describe('applyBackgroundShellLiveness (real markers)', () => {
|
|
13
|
+
const FIXTURE = join(__dirname, 'fixtures', 'bg-shell-liveness-3519.jsonl')
|
|
14
|
+
const lines = readFileSync(FIXTURE, 'utf8').split('\n').filter(l => l.length > 0)
|
|
15
|
+
const aliveEv = projectTranscriptLine(lines[0]).find(e => e.kind === 'tool_result')!
|
|
16
|
+
const deadEv = projectTranscriptLine(lines[1])[0]
|
|
17
|
+
// The real shell id carried by both the ALIVE and DEAD fixture lines.
|
|
18
|
+
const REAL_ID = aliveEv.kind === 'tool_result' ? aliveEv.backgroundTaskId : undefined
|
|
19
|
+
|
|
20
|
+
function spyRegistry() {
|
|
21
|
+
const calls: Array<{ fn: 'alive' | 'dead'; key: string; id: string }> = []
|
|
22
|
+
return {
|
|
23
|
+
calls,
|
|
24
|
+
noteBackgroundShellAlive: (key: string, id: string) => calls.push({ fn: 'alive', key, id }),
|
|
25
|
+
noteBackgroundShellDead: (key: string, id: string) => calls.push({ fn: 'dead', key, id }),
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
it('ALIVE: a real tool_result with backgroundTaskId → noteBackgroundShellAlive', () => {
|
|
30
|
+
const r = spyRegistry()
|
|
31
|
+
applyBackgroundShellLiveness(r, 'c:0', aliveEv)
|
|
32
|
+
expect(r.calls).toEqual([{ fn: 'alive', key: 'c:0', id: REAL_ID! }])
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('DEAD: a real <task-notification> completion → noteBackgroundShellDead', () => {
|
|
36
|
+
const r = spyRegistry()
|
|
37
|
+
applyBackgroundShellLiveness(r, 'c:0', deadEv)
|
|
38
|
+
expect(r.calls).toEqual([{ fn: 'dead', key: 'c:0', id: REAL_ID! }])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('DEAD: a KillShell tool_use → noteBackgroundShellDead by shell_id', () => {
|
|
42
|
+
const r = spyRegistry()
|
|
43
|
+
const ev: SessionEvent = { kind: 'tool_use', toolName: 'KillShell', toolUseId: 't9', input: { shell_id: REAL_ID! } }
|
|
44
|
+
applyBackgroundShellLiveness(r, 'c:0', ev)
|
|
45
|
+
expect(r.calls).toEqual([{ fn: 'dead', key: 'c:0', id: REAL_ID! }])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('no-op: an ordinary (foreground-completed) tool_result carries no marker', () => {
|
|
49
|
+
const r = spyRegistry()
|
|
50
|
+
const ev: SessionEvent = { kind: 'tool_result', toolUseId: 't1', toolName: null }
|
|
51
|
+
applyBackgroundShellLiveness(r, 'c:0', ev)
|
|
52
|
+
expect(r.calls).toEqual([])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('DEFENSIVE: a hypothetical non-terminal <task-notification> does NOT mark the shell dead', () => {
|
|
56
|
+
// Guards a future CLI that emits an interim (still-running) notification.
|
|
57
|
+
// The terminal-status gate must keep a live shell in the alive-set — only
|
|
58
|
+
// completed/failed/killed drop it. Built from the SAME real shell id so the
|
|
59
|
+
// scenario is a faithful "what if this id got an interim update" case.
|
|
60
|
+
const r = spyRegistry()
|
|
61
|
+
const interim: SessionEvent = { kind: 'task_notification', taskId: REAL_ID!, status: 'running' }
|
|
62
|
+
applyBackgroundShellLiveness(r, 'c:0', interim)
|
|
63
|
+
expect(r.calls).toEqual([])
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('no-op: a non-KillShell tool_use is ignored', () => {
|
|
67
|
+
const r = spyRegistry()
|
|
68
|
+
const ev: SessionEvent = { kind: 'tool_use', toolName: 'Bash', toolUseId: 't1', input: { command: 'ls' } }
|
|
69
|
+
applyBackgroundShellLiveness(r, 'c:0', ev)
|
|
70
|
+
expect(r.calls).toEqual([])
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* backstop-exactly-once.test.ts — outcome-asserting regression suite for the
|
|
3
|
+
* switchroom#3513 FOLLOW-UP: the deterministic, model-discipline-free
|
|
4
|
+
* "exactly-once-among-backstops" outbound contract.
|
|
5
|
+
*
|
|
6
|
+
* The invariant under test:
|
|
7
|
+
* - Any text block followed by a NON-ephemeral (turn-continuing) tool_use in
|
|
8
|
+
* the SAME turn — including the cross-message shape ([text-only message] →
|
|
9
|
+
* [tool_use in the NEXT message]) — is suppressed from the backstop delivery
|
|
10
|
+
* UNCONDITIONALLY (no length / wording gate). This replaces #3515's
|
|
11
|
+
* substance/wording heuristic on the backstop path.
|
|
12
|
+
* - The delivered block is the TERMINAL RUN (the suffix of non-tool-followed
|
|
13
|
+
* blocks), joined; a multi-paragraph terminal answer stays whole.
|
|
14
|
+
* - The empty-terminal corner (last block was itself tool-followed) delivers
|
|
15
|
+
* that last block IFF it clears the substantive floor, else nothing.
|
|
16
|
+
* - An explicit reply-tool (E0) send is journaled with
|
|
17
|
+
* `replyAlreadyDeliveredThisTurn=true` and is NOT counted by
|
|
18
|
+
* `backstopAlreadyDelivered`, so a legitimate later explicit reply is never
|
|
19
|
+
* blocked by the backstop exactly-once guard.
|
|
20
|
+
* - An answer followed ONLY by an ephemeral surface tool (react / pin / typing
|
|
21
|
+
* / edit / delete) still delivers — those tools are not turn-continuing.
|
|
22
|
+
*
|
|
23
|
+
* Every test asserts an OUTCOME and is written to be RED on the pre-fix logic
|
|
24
|
+
* (per-message provenance + substance-gated wording strip + no backstop-scoped
|
|
25
|
+
* durable read).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { describe, it, expect } from 'vitest'
|
|
29
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
30
|
+
import { tmpdir } from 'node:os'
|
|
31
|
+
import { join } from 'node:path'
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
selectBackstopDelivery,
|
|
35
|
+
isEphemeralTool,
|
|
36
|
+
EPHEMERAL_TOOLS,
|
|
37
|
+
SUBSTANTIVE_MIN_CHARS,
|
|
38
|
+
} from '../hooks/narration-classify.mjs'
|
|
39
|
+
import { scanTurnForFinalReply, scanForOutboxCapture } from '../hooks/silent-end-scan.mjs'
|
|
40
|
+
import {
|
|
41
|
+
appendDelivered,
|
|
42
|
+
backstopAlreadyDelivered,
|
|
43
|
+
isBackstopDeliveredEntry,
|
|
44
|
+
outboxAlreadyDelivered,
|
|
45
|
+
} from '../outbox.js'
|
|
46
|
+
import { decideCapturedProseDelivery, writeSilentEndState } from '../silent-end.js'
|
|
47
|
+
|
|
48
|
+
// ── Fixture builders (parity with silent-end-interrupt-stop-scan.test.ts) ────
|
|
49
|
+
|
|
50
|
+
const ENQUEUE = JSON.stringify({
|
|
51
|
+
type: 'queue-operation',
|
|
52
|
+
operation: 'enqueue',
|
|
53
|
+
content: '<channel source="switchroom-telegram" chat_id="111" message_id="42">hi</channel>',
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
function assistantText(text: string) {
|
|
57
|
+
return JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text }] } })
|
|
58
|
+
}
|
|
59
|
+
function assistantToolUse(name: string, input: Record<string, unknown> = {}) {
|
|
60
|
+
return JSON.stringify({
|
|
61
|
+
type: 'assistant',
|
|
62
|
+
message: { content: [{ type: 'tool_use', id: `t-${name}`, name, input }] },
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
function jsonl(...lines: string[]) {
|
|
66
|
+
return lines.join('\n')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const BIG = (label: string) =>
|
|
70
|
+
`${label}: ` + 'X'.repeat(SUBSTANTIVE_MIN_CHARS + 20)
|
|
71
|
+
|
|
72
|
+
// ── selectBackstopDelivery — the shared coalescer ────────────────────────────
|
|
73
|
+
|
|
74
|
+
describe('selectBackstopDelivery — deterministic backstop coalescer (#3513 follow-up)', () => {
|
|
75
|
+
it('excludes a tool-followed block UNCONDITIONALLY, even a substantive one (no substance carve-out)', () => {
|
|
76
|
+
// Pre-fix: a ≥floor block followed by a tool was KEPT (the #3237 substance
|
|
77
|
+
// gate). New contract: suppressed unconditionally; only the terminal block
|
|
78
|
+
// survives. RED on the old substance-gated logic.
|
|
79
|
+
const answer = BIG('the real answer')
|
|
80
|
+
const closer = 'Saved that to memory.'
|
|
81
|
+
const out = selectBackstopDelivery([
|
|
82
|
+
{ text: answer, followedByToolUse: true },
|
|
83
|
+
{ text: closer, followedByToolUse: false },
|
|
84
|
+
])
|
|
85
|
+
expect(out).not.toBeNull()
|
|
86
|
+
expect(out!.text).toBe(closer)
|
|
87
|
+
expect(out!.text).not.toContain('the real answer')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('joins the maximal terminal suffix run of non-tool-followed blocks (multi-paragraph answer stays whole)', () => {
|
|
91
|
+
const p1 = 'First paragraph of the genuine answer.'
|
|
92
|
+
const p2 = 'Second paragraph, still part of the same answer.'
|
|
93
|
+
const out = selectBackstopDelivery([
|
|
94
|
+
{ text: 'Let me look…', followedByToolUse: true }, // suppressed
|
|
95
|
+
{ text: p1, followedByToolUse: false },
|
|
96
|
+
{ text: p2, followedByToolUse: false },
|
|
97
|
+
])
|
|
98
|
+
expect(out!.text).toBe(`${p1}\n\n${p2}`)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('empty-terminal corner: last block was tool-followed → delivered IFF ≥ substantive floor', () => {
|
|
102
|
+
const big = BIG('trailing but substantive')
|
|
103
|
+
const overFloor = selectBackstopDelivery([{ text: big, followedByToolUse: true }])
|
|
104
|
+
expect(overFloor!.text).toBe(big)
|
|
105
|
+
const short = selectBackstopDelivery([{ text: 'ok…', followedByToolUse: true }])
|
|
106
|
+
expect(short).toBeNull()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('undefined provenance fails OPEN — the block is delivered, never dropped', () => {
|
|
110
|
+
const out = selectBackstopDelivery([{ text: 'Let me check.' }, { text: 'the answer' }])
|
|
111
|
+
// No structural signal → both are the terminal run → joined (no wording strip).
|
|
112
|
+
expect(out!.text).toBe('Let me check.\n\nthe answer')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('all-empty / no-blocks → null', () => {
|
|
116
|
+
expect(selectBackstopDelivery([])).toBeNull()
|
|
117
|
+
expect(selectBackstopDelivery([{ text: ' ', followedByToolUse: false }])).toBeNull()
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
// ── isEphemeralTool — MF4 exact set ─────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
describe('isEphemeralTool — the exact ephemeral surface set (MF4)', () => {
|
|
124
|
+
it('recognises exactly {react, send_typing, pin_message, delete_message, edit_message}, MCP-prefixed or bare', () => {
|
|
125
|
+
expect([...EPHEMERAL_TOOLS].sort()).toEqual(
|
|
126
|
+
['delete_message', 'edit_message', 'pin_message', 'react', 'send_typing'].sort(),
|
|
127
|
+
)
|
|
128
|
+
for (const t of EPHEMERAL_TOOLS) {
|
|
129
|
+
expect(isEphemeralTool(t)).toBe(true)
|
|
130
|
+
expect(isEphemeralTool(`mcp__switchroom-telegram__${t}`)).toBe(true)
|
|
131
|
+
expect(isEphemeralTool(`mcp__clerk-telegram__${t}`)).toBe(true)
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('progress_update is NOT ephemeral (MF4 — dropped from the set)', () => {
|
|
136
|
+
expect(isEphemeralTool('progress_update')).toBe(false)
|
|
137
|
+
expect(isEphemeralTool('mcp__switchroom-telegram__progress_update')).toBe(false)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('work / reply tools are NOT ephemeral (turn-continuing)', () => {
|
|
141
|
+
for (const t of ['Bash', 'Read', 'retain', 'download_attachment', 'reply', 'stream_reply']) {
|
|
142
|
+
expect(isEphemeralTool(t)).toBe(false)
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
// ── Cross-message split-shape leak — the ROOT-CAUSE regression (MF1) ─────────
|
|
148
|
+
|
|
149
|
+
describe('cross-message split-shape provenance (MF1 — RED on per-message logic)', () => {
|
|
150
|
+
it('scanTurnForFinalReply: substantive narration in msg1, work-tool in msg2, real answer in msg3 → ONLY the answer delivers (narration suppressed)', () => {
|
|
151
|
+
// Pre-fix: `followedByToolUse` was computed per-message, so the narration
|
|
152
|
+
// block — the LAST content of its OWN message — never saw the tool that
|
|
153
|
+
// arrived in the SEPARATE next message. Its provenance read `false`, so the
|
|
154
|
+
// wording heuristic (not a structural tool signal) decided its fate; a
|
|
155
|
+
// substantive narration that didn't LOOK like narration leaked, joined with
|
|
156
|
+
// the answer. The per-turn two-pass marks it tool-followed → the terminal-run
|
|
157
|
+
// coalescer excludes it UNCONDITIONALLY, leaving only the genuine answer.
|
|
158
|
+
const narration = BIG('checking the gateway logs to see what happened before I answer')
|
|
159
|
+
const answer = BIG('here is the settled, terminal answer the user asked for')
|
|
160
|
+
const text = jsonl(
|
|
161
|
+
ENQUEUE,
|
|
162
|
+
assistantText(narration), // msg1 — text only
|
|
163
|
+
assistantToolUse('Bash', { command: 'tail -f log' }), // msg2 — turn-continuing tool
|
|
164
|
+
assistantText(answer), // msg3 — the real terminal answer
|
|
165
|
+
)
|
|
166
|
+
const r = scanTurnForFinalReply(text)
|
|
167
|
+
expect(r.decided).toBe('block')
|
|
168
|
+
expect(r.pendingText).toBe(answer)
|
|
169
|
+
expect(r.pendingText).not.toContain('checking the gateway logs')
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('scanForOutboxCapture: same cross-message shape → captures ONLY the answer, never the narration', () => {
|
|
173
|
+
const narration = BIG('cross-referencing the ledger before summarising the result')
|
|
174
|
+
const answer = BIG('the durable, delivered-once answer for the outbox sweep')
|
|
175
|
+
const text = jsonl(
|
|
176
|
+
ENQUEUE,
|
|
177
|
+
assistantText(narration), // msg1
|
|
178
|
+
assistantToolUse('Read', { file_path: '/tmp/x' }), // msg2 — turn-continuing tool
|
|
179
|
+
assistantText(answer), // msg3 — terminal answer
|
|
180
|
+
)
|
|
181
|
+
const cap = scanForOutboxCapture(text)
|
|
182
|
+
expect(cap.capture).toBe(true)
|
|
183
|
+
if (cap.capture) {
|
|
184
|
+
expect(cap.text).toBe(answer.trim())
|
|
185
|
+
expect(cap.text).not.toContain('cross-referencing the ledger')
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('scanTurnForFinalReply: same shape but the trailing block IS terminal (no later tool) → delivered', () => {
|
|
190
|
+
// Control: when the substantive block is genuinely terminal (nothing follows
|
|
191
|
+
// it in the whole turn), it IS the answer and must deliver.
|
|
192
|
+
const answer = BIG('the genuine terminal answer')
|
|
193
|
+
const text = jsonl(
|
|
194
|
+
ENQUEUE,
|
|
195
|
+
assistantToolUse('Bash', { command: 'ls' }), // work happened first
|
|
196
|
+
assistantText(answer), // terminal — no tool after
|
|
197
|
+
)
|
|
198
|
+
const r = scanTurnForFinalReply(text)
|
|
199
|
+
expect(r.decided).toBe('block')
|
|
200
|
+
expect(r.pendingText).toBe(answer)
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
// ── answer-then-ephemeral-react still delivers (MF4b) ───────────────────────
|
|
205
|
+
|
|
206
|
+
describe('answer-then-ephemeral-tool still delivers (MF4b)', () => {
|
|
207
|
+
it('a terminal answer followed ONLY by a react/pin/edit is NOT suppressed', () => {
|
|
208
|
+
const answer = BIG('the answer the user was waiting for')
|
|
209
|
+
// Ephemeral tools are not recorded as work-tool markers, so the answer stays
|
|
210
|
+
// terminal and delivers.
|
|
211
|
+
const text = jsonl(
|
|
212
|
+
ENQUEUE,
|
|
213
|
+
assistantText(answer),
|
|
214
|
+
assistantToolUse('mcp__switchroom-telegram__react', { emoji: '👍' }),
|
|
215
|
+
)
|
|
216
|
+
const r = scanTurnForFinalReply(text)
|
|
217
|
+
expect(r.decided).toBe('block')
|
|
218
|
+
expect(r.pendingText).toBe(answer)
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
// ── backstopAlreadyDelivered — exactly-once-among-backstops (MF2) ────────────
|
|
223
|
+
|
|
224
|
+
describe('backstopAlreadyDelivered — backstop-scoped durable exactly-once (MF2)', () => {
|
|
225
|
+
let dir: string
|
|
226
|
+
|
|
227
|
+
it('classifies delivery sources: flush + sweep + non-E0 reply-tool are backstops; E0 reply is NOT', () => {
|
|
228
|
+
expect(isBackstopDeliveredEntry({ turnNonce: 'n', textSha256: 'x', ts: 0, deliverySource: 'flush' })).toBe(true)
|
|
229
|
+
expect(isBackstopDeliveredEntry({ turnNonce: 'n', textSha256: 'x', ts: 0, deliverySource: 'sweep' })).toBe(true)
|
|
230
|
+
// E3 captured-prose bridge journals as reply-tool with replyAlready=false → backstop.
|
|
231
|
+
expect(
|
|
232
|
+
isBackstopDeliveredEntry({
|
|
233
|
+
turnNonce: 'n', textSha256: 'x', ts: 0,
|
|
234
|
+
deliverySource: 'reply-tool', replyAlreadyDeliveredThisTurn: false,
|
|
235
|
+
}),
|
|
236
|
+
).toBe(true)
|
|
237
|
+
// E0 explicit reply journals with replyAlready=true → NOT a backstop.
|
|
238
|
+
expect(
|
|
239
|
+
isBackstopDeliveredEntry({
|
|
240
|
+
turnNonce: 'n', textSha256: 'x', ts: 0,
|
|
241
|
+
deliverySource: 'reply-tool', replyAlreadyDeliveredThisTurn: true,
|
|
242
|
+
}),
|
|
243
|
+
).toBe(false)
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('a prior FLUSH delivery is seen by backstopAlreadyDelivered; a prior E0 reply is NOT', () => {
|
|
247
|
+
dir = mkdtempSync(join(tmpdir(), 'backstop-once-'))
|
|
248
|
+
try {
|
|
249
|
+
// E0 reply for turn A — must NOT count as a backstop.
|
|
250
|
+
appendDelivered(
|
|
251
|
+
{ turnNonce: 'A', textSha256: 'ha', ts: 1, deliverySource: 'reply-tool', replyAlreadyDeliveredThisTurn: true },
|
|
252
|
+
dir,
|
|
253
|
+
)
|
|
254
|
+
expect(backstopAlreadyDelivered('A', dir)).toBe(false)
|
|
255
|
+
// …but outboxAlreadyDelivered (the E4 sweep dedup, source-agnostic) DOES see it.
|
|
256
|
+
expect(outboxAlreadyDelivered('A', dir)).toBe(true)
|
|
257
|
+
|
|
258
|
+
// Flush backstop for turn B — must count.
|
|
259
|
+
appendDelivered({ turnNonce: 'B', textSha256: 'hb', ts: 2, deliverySource: 'flush' }, dir)
|
|
260
|
+
expect(backstopAlreadyDelivered('B', dir)).toBe(true)
|
|
261
|
+
|
|
262
|
+
// Unknown nonce → false.
|
|
263
|
+
expect(backstopAlreadyDelivered('C', dir)).toBe(false)
|
|
264
|
+
expect(backstopAlreadyDelivered('', dir)).toBe(false)
|
|
265
|
+
} finally {
|
|
266
|
+
rmSync(dir, { recursive: true, force: true })
|
|
267
|
+
}
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('E3 bridge decision skips when a prior backstop already delivered the nonce (already-delivered), but an E0 recap does NOT block it', () => {
|
|
271
|
+
dir = mkdtempSync(join(tmpdir(), 'backstop-e3-'))
|
|
272
|
+
try {
|
|
273
|
+
const turnKey = 'c:_'
|
|
274
|
+
const turnId = 'c:_#42'
|
|
275
|
+
const answer = BIG('the recovered answer')
|
|
276
|
+
writeSilentEndState({ chatId: 'c', threadId: null, turnKey }, { stateDir: dir })
|
|
277
|
+
// Persist pendingText onto the record so the bridge has something to deliver.
|
|
278
|
+
// (writeSilentEndState doesn't set pendingText; simulate the hook's write.)
|
|
279
|
+
appendDelivered({ turnNonce: turnId, textSha256: 'z', ts: 1, deliverySource: 'flush' }, dir)
|
|
280
|
+
|
|
281
|
+
// With a prior FLUSH backstop for this turnId, the bridge must refuse.
|
|
282
|
+
const blocked = decideCapturedProseDelivery(
|
|
283
|
+
{ turnKey, turnId, minChars: 1 },
|
|
284
|
+
{
|
|
285
|
+
stateDir: dir,
|
|
286
|
+
backstopDeliveredNonceHit: (n) => backstopAlreadyDelivered(n ?? '', dir),
|
|
287
|
+
},
|
|
288
|
+
)
|
|
289
|
+
// The state file itself carries no pendingText here, so the decision is
|
|
290
|
+
// no-substantive-prose OR already-delivered — assert it does NOT deliver.
|
|
291
|
+
expect(blocked.deliver).toBe(false)
|
|
292
|
+
|
|
293
|
+
// A prior E0 reply recap for the same nonce must NOT trip the backstop guard.
|
|
294
|
+
const dir2 = mkdtempSync(join(tmpdir(), 'backstop-e0-'))
|
|
295
|
+
try {
|
|
296
|
+
appendDelivered(
|
|
297
|
+
{ turnNonce: turnId, textSha256: 'z', ts: 1, deliverySource: 'reply-tool', replyAlreadyDeliveredThisTurn: true },
|
|
298
|
+
dir2,
|
|
299
|
+
)
|
|
300
|
+
expect(backstopAlreadyDelivered(turnId, dir2)).toBe(false)
|
|
301
|
+
} finally {
|
|
302
|
+
rmSync(dir2, { recursive: true, force: true })
|
|
303
|
+
}
|
|
304
|
+
void answer
|
|
305
|
+
} finally {
|
|
306
|
+
rmSync(dir, { recursive: true, force: true })
|
|
307
|
+
}
|
|
308
|
+
})
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
// ── multi-substantive-block → one delivery; empty-terminal → one delivery ────
|
|
312
|
+
|
|
313
|
+
describe('single-delivery selection through the scan (MF5)', () => {
|
|
314
|
+
it('multiple substantive terminal blocks → ONE joined delivery (never a second bubble)', () => {
|
|
315
|
+
const p1 = BIG('first half of the answer')
|
|
316
|
+
const p2 = BIG('second half of the answer')
|
|
317
|
+
const text = jsonl(ENQUEUE, assistantText(p1), assistantText(p2))
|
|
318
|
+
const r = scanTurnForFinalReply(text)
|
|
319
|
+
expect(r.decided).toBe('block')
|
|
320
|
+
expect(r.pendingText).toBe(`${p1}\n\n${p2}`)
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('empty terminal (last block tool-followed) → the prior substantive terminal run is delivered once', () => {
|
|
324
|
+
const answer = BIG('the settled answer')
|
|
325
|
+
const text = jsonl(
|
|
326
|
+
ENQUEUE,
|
|
327
|
+
assistantText(answer), // terminal? no — a tool follows in the next msg
|
|
328
|
+
assistantToolUse('retain', { text: 'note' }),
|
|
329
|
+
)
|
|
330
|
+
// answer is tool-followed → empty terminal run → rule 3 delivers it iff ≥floor.
|
|
331
|
+
const r = scanTurnForFinalReply(text)
|
|
332
|
+
expect(r.decided).toBe('block')
|
|
333
|
+
expect(r.pendingText).toBe(answer)
|
|
334
|
+
})
|
|
335
|
+
})
|
|
@@ -195,6 +195,20 @@ describe('planUnhandledMessage — service-noise classification', () => {
|
|
|
195
195
|
})
|
|
196
196
|
})
|
|
197
197
|
|
|
198
|
+
it('legacy forward_* wire keys are envelope metadata — the placeholder names the CONTENT, never "forward_from" (row-944 mislabel)', () => {
|
|
199
|
+
const plan = planUnhandledMessage({
|
|
200
|
+
message_id: 944, chat: {}, date: 0,
|
|
201
|
+
forward_from: { id: 1, is_bot: true, first_name: 'Klanker' },
|
|
202
|
+
forward_date: 1784830000,
|
|
203
|
+
mystery_future_content: {},
|
|
204
|
+
})
|
|
205
|
+
expect(plan).toMatchObject({
|
|
206
|
+
action: 'turn',
|
|
207
|
+
text: '(unhandled message content: mystery_future_content)',
|
|
208
|
+
})
|
|
209
|
+
expect(plan.contentKeys).toEqual(['mystery_future_content'])
|
|
210
|
+
})
|
|
211
|
+
|
|
198
212
|
it('a message mixing noise with real content still becomes a turn', () => {
|
|
199
213
|
const plan = planUnhandledMessage({
|
|
200
214
|
message_id: 1, chat: {}, date: 0,
|
|
@@ -191,12 +191,18 @@ describe('silence-poke — isLegitimatelyWorking callback (default-on defer)', (
|
|
|
191
191
|
// When isLegitimatelyWorking is wired, it is consulted; the legacy flag
|
|
192
192
|
// is not consulted for the new path. Verify by having callback=false and
|
|
193
193
|
// inFlightTools non-empty — the fallback fires because the callback says "no".
|
|
194
|
+
// NOTE (#3519): the in-flight tool here must be a NON-Bash tool. A `Bash`
|
|
195
|
+
// arms the CLI-side background-bash defer (a foreground Bash the callback
|
|
196
|
+
// is blind to once it moves to the background), which intentionally holds
|
|
197
|
+
// the fallback back even when the callback returns false — so using Bash
|
|
198
|
+
// would exercise that new defer rather than the callback-supersedes-legacy
|
|
199
|
+
// path this test pins. `Grep` can never be a detached background process.
|
|
194
200
|
const f = setupSilenceDeps({
|
|
195
201
|
thresholds: { fallback: 300_000, fallbackHardCeiling: 900_000 },
|
|
196
202
|
isLegitimatelyWorking: () => false,
|
|
197
203
|
})
|
|
198
204
|
startTurn('chat:0', 0)
|
|
199
|
-
noteToolStart('chat:0', 't1', '
|
|
205
|
+
noteToolStart('chat:0', 't1', 'Grep', 'audit', 10_000)
|
|
200
206
|
__tickForTests(300_000)
|
|
201
207
|
// callback says false → no defer, fallback fires
|
|
202
208
|
expect(f.fallbacks).toHaveLength(1)
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
{"parentUuid":"69f789b8-6f8a-46cd-baf0-58fb2114b866","isSidechain":false,"promptId":"4a2992ca-82ba-49cd-bde6-c4c9ac2e67eb","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01B7T3y1t95oHDEqYKwSmqaW","type":"tool_result","content":"Command running in background with ID: bxa4sv3dq. Output is being written to: /tmp/claude-10098/-home-user--switchroom-agents-carrie/fdc3453e-36da-4480-8c69-ebbcae6b4195/tasks/bxa4sv3dq.output. You will be notified when it completes. To check interim output, use Read on that file path.","is_error":false}]},"uuid":"36ad560b-fe0c-4a1a-aab5-215de2ceab8a","timestamp":"2026-07-03T00:35:56.966Z","toolUseResult":{"stdout":"","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false,"backgroundTaskId":"bxa4sv3dq"},"sourceToolAssistantUUID":"69f789b8-6f8a-46cd-baf0-58fb2114b866","userType":"external","entrypoint":"cli","cwd":"~/.switchroom/agents/carrie","sessionId":"a6d2d33a-a8a6-40ce-81d0-cb4bd867ac89","version":"2.1.197","gitBranch":"HEAD"}
|
|
2
|
+
{"type":"queue-operation","operation":"enqueue","timestamp":"2026-07-03T00:42:51.932Z","sessionId":"a6d2d33a-a8a6-40ce-81d0-cb4bd867ac89","content":"<task-notification>\n<task-id>bxa4sv3dq</task-id>\n<tool-use-id>toolu_01B7T3y1t95oHDEqYKwSmqaW</tool-use-id>\n<output-file>/tmp/claude-10098/-home-user--switchroom-agents-carrie/fdc3453e-36da-4480-8c69-ebbcae6b4195/tasks/bxa4sv3dq.output</output-file>\n<status>completed</status>\n<summary>Background command \"Search for any Buildkite product spec material on disk\" completed (exit code 0)</summary>\n</task-notification>"}
|
|
3
|
+
{"parentUuid":"62dd16ed-917b-4092-861e-81437bf138fb","isSidechain":false,"attachment":{"type":"queued_command","prompt":"<task-notification>\n<task-id>bxa4sv3dq</task-id>\n<tool-use-id>toolu_01B7T3y1t95oHDEqYKwSmqaW</tool-use-id>\n<output-file>/tmp/claude-10098/-home-user--switchroom-agents-carrie/fdc3453e-36da-4480-8c69-ebbcae6b4195/tasks/bxa4sv3dq.output</output-file>\n<status>completed</status>\n<summary>Background command \"Search for any Buildkite product spec material on disk\" completed (exit code 0)</summary>\n</task-notification>","commandMode":"task-notification","timestamp":"2026-07-03T00:42:51.932Z"},"type":"attachment","uuid":"3702ef15-3c4e-4c5d-84c1-e5c2c59b038e","timestamp":"2026-07-03T00:42:51.932Z","userType":"external","entrypoint":"cli","cwd":"/share/code/product-playbook/playbook","sessionId":"a6d2d33a-a8a6-40ce-81d0-cb4bd867ac89","version":"2.1.197","gitBranch":"HEAD"}
|
|
@@ -247,6 +247,25 @@ describe('buildForwardOriginMeta — channel-tag attrs', () => {
|
|
|
247
247
|
expect(meta.forwarded_from).toHaveLength(FORWARDED_FROM_NAME_MAX)
|
|
248
248
|
})
|
|
249
249
|
|
|
250
|
+
it('channel origin emits forwarded_message_id so the agent can deep-link the source post (G2)', () => {
|
|
251
|
+
const meta = buildForwardOriginMeta([
|
|
252
|
+
{ name: 'Release Notes (@relnotes)', type: 'channel', id: -100400500, date: DATE, messageId: 555 },
|
|
253
|
+
])
|
|
254
|
+
expect(Object.keys(meta)).toEqual([
|
|
255
|
+
'forwarded_from',
|
|
256
|
+
'forwarded_from_type',
|
|
257
|
+
'forwarded_from_id',
|
|
258
|
+
'forwarded_date',
|
|
259
|
+
'forwarded_message_id',
|
|
260
|
+
])
|
|
261
|
+
expect(meta.forwarded_message_id).toBe('555')
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('non-channel origins (no messageId) omit forwarded_message_id entirely', () => {
|
|
265
|
+
const meta = buildForwardOriginMeta([parseForwardOrigin(userOrigin())!])
|
|
266
|
+
expect(meta.forwarded_message_id).toBeUndefined()
|
|
267
|
+
})
|
|
268
|
+
|
|
250
269
|
it('no origins → empty record (no attrs on a normal message)', () => {
|
|
251
270
|
expect(buildForwardOriginMeta([])).toEqual({})
|
|
252
271
|
})
|
|
@@ -305,6 +324,7 @@ describe('coalesced bursts — dedupe + numbered siblings', () => {
|
|
|
305
324
|
'forwarded_from_type_2',
|
|
306
325
|
'forwarded_from_id_2',
|
|
307
326
|
'forwarded_date_2',
|
|
327
|
+
'forwarded_message_id_2',
|
|
308
328
|
])
|
|
309
329
|
expect(meta.forwarded_from_2).toBe('Release Notes (@relnotes)')
|
|
310
330
|
expect(meta.forwarded_from_type_2).toBe('channel')
|