switchroom 0.20.7 → 0.20.9
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 +111 -14
- package/dist/auth-broker/index.js +113 -30
- package/dist/cli/autoaccept-poll.js +5 -3
- package/dist/cli/drive-write-pretool.mjs +5 -3
- package/dist/cli/ms-365-write-pretool.mjs +5 -3
- package/dist/cli/notion-write-pretool.mjs +67 -6
- package/dist/cli/switchroom.js +389 -31
- package/dist/host-control/main.js +69 -8
- package/dist/vault/approvals/kernel-server.js +68 -7
- package/dist/vault/broker/server.js +68 -7
- package/package.json +1 -1
- package/profiles/default/CLAUDE.md.hbs +12 -13
- package/telegram-plugin/ask-user.ts +6 -7
- package/telegram-plugin/bridge/ipc-client.ts +17 -1
- package/telegram-plugin/dist/bridge/bridge.js +5 -2
- package/telegram-plugin/dist/gateway/gateway.js +410 -178
- package/telegram-plugin/dist/server.js +5 -2
- package/telegram-plugin/gateway/auth-broker-client.ts +1 -1
- package/telegram-plugin/gateway/auth-command.ts +4 -2
- package/telegram-plugin/gateway/boot-reason.ts +61 -0
- package/telegram-plugin/gateway/checklist-fallback.ts +8 -1
- package/telegram-plugin/gateway/cron-session.ts +66 -0
- package/telegram-plugin/gateway/gateway.ts +36 -34
- package/telegram-plugin/gateway/narrative-lane.ts +21 -1
- package/telegram-plugin/gateway/outbound-send-path.ts +9 -1
- package/telegram-plugin/gateway/represent-delivery-guard.ts +33 -2
- package/telegram-plugin/gateway/stream-render.ts +11 -2
- package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +21 -1
- package/telegram-plugin/gateway/subagent-handback-marker.ts +94 -0
- package/telegram-plugin/gateway/throttle-tier-wiring.ts +93 -18
- package/telegram-plugin/render/emphasis-guard.ts +92 -12
- package/telegram-plugin/render/line-start-guard.ts +27 -2
- package/telegram-plugin/sticker-aliases.ts +12 -14
- package/telegram-plugin/tests/ask-user.test.ts +15 -0
- package/telegram-plugin/tests/boot-card-reason.test.ts +88 -0
- package/telegram-plugin/tests/checklist-fallback.test.ts +21 -0
- package/telegram-plugin/tests/cron-bridge-drain-spool-ack.test.ts +150 -0
- package/telegram-plugin/tests/handback-tasknotif-dedup.test.ts +248 -0
- package/telegram-plugin/tests/ipc-client-reconnect-rejection.test.ts +70 -0
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +86 -0
- package/telegram-plugin/tests/queued-card-surface.test.ts +66 -0
- package/telegram-plugin/tests/render/emphasis-guard.test.ts +105 -6
- package/telegram-plugin/tests/render/heading-guard-blockquote-glued-hash.test.ts +123 -36
- package/telegram-plugin/tests/reply-quote-wire.test.ts +47 -0
- package/telegram-plugin/tests/represent-guard.test.ts +45 -0
- package/telegram-plugin/tests/sticker-aliases.test.ts +43 -0
- package/telegram-plugin/tests/throttle-tier-probe-only.test.ts +216 -0
- package/telegram-plugin/tests/throttle-tier-route-429-wiring.test.ts +92 -0
- package/telegram-plugin/tests/throttle-tier-route-429.test.ts +71 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +83 -0
- package/telegram-plugin/throttle-tier.ts +59 -0
- package/telegram-plugin/turn-flush-safety.ts +79 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #4348 — the Tier-1 cheap-cron bridge-register drain must route through the
|
|
3
|
+
* shared `redeliverBufferedInbound` chokepoint so a delivered cron fire is
|
|
4
|
+
* `spool.ack`'d exactly once and CANNOT re-fire after a restart.
|
|
5
|
+
*
|
|
6
|
+
* The bug: `onClientRegistered`'s `<agent>-cron` branch did a raw
|
|
7
|
+
* `pendingInboundBuffer.drain()` + `client.send()` loop and returned early,
|
|
8
|
+
* never reaching `spool.ack` (which lives only inside
|
|
9
|
+
* `redeliverBufferedInbound`). A due cron tick spooled during the boot window
|
|
10
|
+
* (before the cron bridge registered) stayed live in the durable spool, so
|
|
11
|
+
* boot-replay re-pushed it on the next restart and the SAME fire was delivered
|
|
12
|
+
* a second time — a duplicate cron delivery bounded only by the 15-min
|
|
13
|
+
* escalation sweep.
|
|
14
|
+
*
|
|
15
|
+
* These tests assert the OUTCOME on the real drain seam
|
|
16
|
+
* (`drainCronBridgeOnRegister`), against a REAL spool + buffer:
|
|
17
|
+
* 1. the boot-window fire is delivered to the cron bridge, AND
|
|
18
|
+
* 2. its durable spool entry is acked (liveCount → 0), AND
|
|
19
|
+
* 3. a simulated restart's boot-replay finds nothing to re-push, so the fire
|
|
20
|
+
* does NOT re-fire.
|
|
21
|
+
*
|
|
22
|
+
* RED-before-GREEN: with the pre-#4348 raw-drain body the fire is delivered but
|
|
23
|
+
* the spool entry stays live, so assertions (2)+(3) fail. With the fix routing
|
|
24
|
+
* through `redeliverBufferedInbound` they pass.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { describe, it, expect } from 'vitest'
|
|
28
|
+
import {
|
|
29
|
+
createInboundSpool,
|
|
30
|
+
type InboundSpoolFsSeam,
|
|
31
|
+
type InboundSpool,
|
|
32
|
+
} from '../gateway/inbound-spool.js'
|
|
33
|
+
import { createPendingInboundBuffer } from '../gateway/pending-inbound-buffer.js'
|
|
34
|
+
import { drainCronBridgeOnRegister, cronIdentity } from '../gateway/cron-session.js'
|
|
35
|
+
import type { InboundMessage } from '../gateway/ipc-protocol.js'
|
|
36
|
+
|
|
37
|
+
const SPOOL_PATH = '/state/agent/telegram/inbound-spool.jsonl'
|
|
38
|
+
|
|
39
|
+
/** In-memory fake fs for the spool — models append + atomic-rename compaction.
|
|
40
|
+
* Shared across "restarts" so the durable JSONL survives, exactly like the
|
|
41
|
+
* persistent per-agent volume. */
|
|
42
|
+
function fakeFs(): InboundSpoolFsSeam {
|
|
43
|
+
const files = new Map<string, string>()
|
|
44
|
+
return {
|
|
45
|
+
appendFileSync: (p, d) => files.set(p, (files.get(p) ?? '') + d),
|
|
46
|
+
readFileSync: (p) => files.get(p) ?? '',
|
|
47
|
+
writeFileSync: (p, d) => files.set(p, d),
|
|
48
|
+
renameSync: (from, to) => {
|
|
49
|
+
files.set(to, files.get(from) ?? '')
|
|
50
|
+
files.delete(from)
|
|
51
|
+
},
|
|
52
|
+
existsSync: (p) => files.has(p),
|
|
53
|
+
statSizeSync: (p) => Buffer.byteLength(files.get(p) ?? ''),
|
|
54
|
+
fsyncFileSync: () => {},
|
|
55
|
+
fsyncDirSync: () => {},
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function cronFire(over: Partial<InboundMessage> = {}): InboundMessage {
|
|
60
|
+
return {
|
|
61
|
+
type: 'inbound',
|
|
62
|
+
chatId: 'c1',
|
|
63
|
+
messageId: 0, // synthetic — cron fires carry no Telegram messageId
|
|
64
|
+
user: 'system',
|
|
65
|
+
userId: 0,
|
|
66
|
+
ts: 1000,
|
|
67
|
+
text: 'Time for the daily digest',
|
|
68
|
+
meta: { source: 'cron', session: 'cron' },
|
|
69
|
+
...over,
|
|
70
|
+
} as InboundMessage
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A capturing stand-in for the just-registered cron IPC client. */
|
|
74
|
+
function fakeClient(agentName: string): {
|
|
75
|
+
agentName: string
|
|
76
|
+
send: (msg: unknown) => void
|
|
77
|
+
sent: unknown[]
|
|
78
|
+
} {
|
|
79
|
+
const sent: unknown[] = []
|
|
80
|
+
return { agentName, send: (m) => void sent.push(m), sent }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Simulate the gateway's boot-replay: re-push every live (un-acked) spool
|
|
84
|
+
* entry into a fresh in-memory buffer, exactly as gateway.ts does at boot. */
|
|
85
|
+
function bootReplayInto(spool: InboundSpool): ReturnType<typeof createPendingInboundBuffer> {
|
|
86
|
+
const buffer = createPendingInboundBuffer({ log: () => {}, spool })
|
|
87
|
+
for (const { agent, msg } of spool.liveEntries()) buffer.push(agent, msg)
|
|
88
|
+
return buffer
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe('#4348 cron-bridge drain routes through the spool-ack chokepoint', () => {
|
|
92
|
+
it('acks the boot-window cron fire and does not re-fire after restart', () => {
|
|
93
|
+
const fs = fakeFs()
|
|
94
|
+
const spool = createInboundSpool({ path: SPOOL_PATH, fs, log: () => {} })
|
|
95
|
+
const cronAgent = cronIdentity('overlord') // "overlord-cron"
|
|
96
|
+
|
|
97
|
+
// Boot window: a due cron tick arrives BEFORE the cron bridge registers.
|
|
98
|
+
// It is buffered (in-memory) AND durably spooled by the same push.
|
|
99
|
+
const buffer = createPendingInboundBuffer({ log: () => {}, spool })
|
|
100
|
+
buffer.push(cronAgent, cronFire())
|
|
101
|
+
expect(spool.liveCount()).toBe(1) // durably recorded, not yet delivered
|
|
102
|
+
|
|
103
|
+
// The cron bridge registers → the drain seam under test runs.
|
|
104
|
+
const client = fakeClient(cronAgent)
|
|
105
|
+
const result = drainCronBridgeOnRegister(client, buffer, spool)
|
|
106
|
+
|
|
107
|
+
// (1) The fire was delivered to the cron bridge.
|
|
108
|
+
expect(result.drained).toBe(1)
|
|
109
|
+
expect(result.redelivered).toBe(1)
|
|
110
|
+
const deliveredFires = client.sent.filter(
|
|
111
|
+
(m): m is InboundMessage => (m as InboundMessage).type === 'inbound',
|
|
112
|
+
)
|
|
113
|
+
expect(deliveredFires).toHaveLength(1)
|
|
114
|
+
expect(deliveredFires[0]!.text).toBe('Time for the daily digest')
|
|
115
|
+
|
|
116
|
+
// (2) The durable spool entry is tombstoned — the whole point of #4348.
|
|
117
|
+
expect(spool.liveCount()).toBe(0)
|
|
118
|
+
expect(spool.liveEntries()).toHaveLength(0)
|
|
119
|
+
|
|
120
|
+
// (3) Simulated restart: boot-replay finds nothing to re-push, so the SAME
|
|
121
|
+
// fire does NOT re-fire. (Pre-fix this replayed the un-acked entry.)
|
|
122
|
+
const afterRestart = bootReplayInto(spool)
|
|
123
|
+
expect(afterRestart.drain(cronAgent)).toHaveLength(0)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('a send failure re-buffers the fire and leaves the spool entry live (lossless)', () => {
|
|
127
|
+
const fs = fakeFs()
|
|
128
|
+
const spool = createInboundSpool({ path: SPOOL_PATH, fs, log: () => {} })
|
|
129
|
+
const cronAgent = cronIdentity('overlord')
|
|
130
|
+
|
|
131
|
+
const buffer = createPendingInboundBuffer({ log: () => {}, spool })
|
|
132
|
+
buffer.push(cronAgent, cronFire())
|
|
133
|
+
|
|
134
|
+
// Client whose send throws for inbound fires (bridge wedged mid-drain).
|
|
135
|
+
const throwing = {
|
|
136
|
+
agentName: cronAgent,
|
|
137
|
+
send: (m: unknown) => {
|
|
138
|
+
if ((m as InboundMessage).type === 'inbound') throw new Error('socket gone')
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
const result = drainCronBridgeOnRegister(throwing, buffer, spool)
|
|
142
|
+
|
|
143
|
+
// Not delivered → re-buffered, and the spool entry stays LIVE so the next
|
|
144
|
+
// register (or boot-replay) retries it. Nothing is dropped, nothing acked.
|
|
145
|
+
expect(result.redelivered).toBe(0)
|
|
146
|
+
expect(result.rebuffered).toBe(1)
|
|
147
|
+
expect(spool.liveCount()).toBe(1)
|
|
148
|
+
expect(buffer.drain(cronAgent)).toHaveLength(1)
|
|
149
|
+
})
|
|
150
|
+
})
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Double-wake dedup (v0.20.8 candidate) — one background sub-agent completion
|
|
3
|
+
* must produce ONE parent wake, not two.
|
|
4
|
+
*
|
|
5
|
+
* The bug: a single background sub-agent completion fanned out to TWO
|
|
6
|
+
* independent wakes of the parent session —
|
|
7
|
+
* 1. the claude CLI's own `<task-notification>` (enqueued by the CLI itself;
|
|
8
|
+
* switchroom read it only as a background-shell liveness signal), and
|
|
9
|
+
* 2. the gateway-synthesized `subagent_handback` inbound (subagent-watcher
|
|
10
|
+
* `onFinish` → `pendingInboundBuffer.push`).
|
|
11
|
+
* Nothing linked them. The CLI-native wake cannot be suppressed (it is the
|
|
12
|
+
* CLI's internal queue), so the fix gates the ONE lever switchroom holds — the
|
|
13
|
+
* handback enqueue — on a recently-seen terminal `<task-notification>` for
|
|
14
|
+
* EXACTLY the same task/agent id (`CliTaskNotificationLedger`,
|
|
15
|
+
* subagent-handback-marker.ts; consumed by `decideSubagentHandback`).
|
|
16
|
+
*
|
|
17
|
+
* FAIL-OPEN contract pinned here: every uncertain path DELIVERS the handback.
|
|
18
|
+
* A dropped real wake (silent worker, user waits forever) is strictly worse
|
|
19
|
+
* than an occasional double.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, it, expect } from 'vitest'
|
|
23
|
+
import {
|
|
24
|
+
CliTaskNotificationLedger,
|
|
25
|
+
TASK_NOTIFICATION_DEDUP_TTL_MS,
|
|
26
|
+
} from '../gateway/subagent-handback-marker.js'
|
|
27
|
+
import { decideSubagentHandback } from '../gateway/subagent-handback-inbound-builder.js'
|
|
28
|
+
import { projectTranscriptLine } from '../session-tail.js'
|
|
29
|
+
import { applyBackgroundShellLiveness } from '../gateway/background-shell-liveness.js'
|
|
30
|
+
import { readFileSync } from 'node:fs'
|
|
31
|
+
import { join, dirname } from 'node:path'
|
|
32
|
+
import { fileURLToPath } from 'node:url'
|
|
33
|
+
|
|
34
|
+
const T0 = 1_700_000_000_000
|
|
35
|
+
|
|
36
|
+
const base = {
|
|
37
|
+
handbackEnvValue: undefined as string | undefined,
|
|
38
|
+
outcome: 'completed' as 'completed' | 'failed' | 'orphan',
|
|
39
|
+
isBackground: true,
|
|
40
|
+
fleetChatId: '777',
|
|
41
|
+
ownerChatId: '999',
|
|
42
|
+
taskDescription: 'Do the thing',
|
|
43
|
+
resultText: 'Done.',
|
|
44
|
+
jsonlAgentId: 'a204deeaedb27b580',
|
|
45
|
+
nowMs: T0,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// A real captured queue-operation enqueue line shape (fixture
|
|
49
|
+
// bg-shell-liveness-3519.jsonl), retargeted at an AGENT task id — the live
|
|
50
|
+
// transcripts prove `<task-id>` for a backgrounded agent equals the
|
|
51
|
+
// `agent-<id>.jsonl` stem the subagent-watcher uses as `agentId`
|
|
52
|
+
// (verified: <task-id>a204deeaedb27b580</task-id> ↔
|
|
53
|
+
// subagents/agent-a204deeaedb27b580.jsonl).
|
|
54
|
+
function notifLine(taskId: string, status: string): string {
|
|
55
|
+
return JSON.stringify({
|
|
56
|
+
type: 'queue-operation',
|
|
57
|
+
operation: 'enqueue',
|
|
58
|
+
timestamp: '2026-08-04T00:00:00.000Z',
|
|
59
|
+
sessionId: 'deadbeef-0000-4000-8000-000000000000',
|
|
60
|
+
content:
|
|
61
|
+
`<task-notification>\n<task-id>${taskId}</task-id>\n` +
|
|
62
|
+
`<tool-use-id>toolu_01TESTTESTTESTTEST</tool-use-id>\n` +
|
|
63
|
+
`<output-file>/tmp/tasks/${taskId}.output</output-file>\n` +
|
|
64
|
+
`<status>${status}</status>\n<summary>Agent done</summary>\n</task-notification>`,
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe('CliTaskNotificationLedger', () => {
|
|
69
|
+
it('records a terminal notification and reports it within the TTL', () => {
|
|
70
|
+
const l = new CliTaskNotificationLedger()
|
|
71
|
+
l.record('a204deeaedb27b580', 'completed', T0)
|
|
72
|
+
expect(l.seenRecently('a204deeaedb27b580', T0 + 5_000)).toBe(true)
|
|
73
|
+
expect(l.seenRecently('a204deeaedb27b580', T0 + TASK_NOTIFICATION_DEDUP_TTL_MS)).toBe(true)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('fail-open: expires after the TTL (a resumed worker second completion delivers)', () => {
|
|
77
|
+
const l = new CliTaskNotificationLedger()
|
|
78
|
+
l.record('a204deeaedb27b580', 'completed', T0)
|
|
79
|
+
expect(l.seenRecently('a204deeaedb27b580', T0 + TASK_NOTIFICATION_DEDUP_TTL_MS + 1)).toBe(false)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('fail-open: a DIFFERENT task id never matches', () => {
|
|
83
|
+
const l = new CliTaskNotificationLedger()
|
|
84
|
+
l.record('a204deeaedb27b580', 'completed', T0)
|
|
85
|
+
expect(l.seenRecently('a999999999999999', T0 + 1)).toBe(false)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('fail-open: non-terminal statuses and empty ids are never recorded', () => {
|
|
89
|
+
const l = new CliTaskNotificationLedger()
|
|
90
|
+
l.record('a204deeaedb27b580', 'running', T0)
|
|
91
|
+
l.record('', 'completed', T0)
|
|
92
|
+
expect(l.seenRecently('a204deeaedb27b580', T0 + 1)).toBe(false)
|
|
93
|
+
expect(l.seenRecently('', T0 + 1)).toBe(false)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('records failed and killed as terminal (the CLI wakes the parent for those too)', () => {
|
|
97
|
+
const l = new CliTaskNotificationLedger()
|
|
98
|
+
l.record('aaa', 'failed', T0)
|
|
99
|
+
l.record('bbb', 'killed', T0)
|
|
100
|
+
expect(l.seenRecently('aaa', T0 + 1)).toBe(true)
|
|
101
|
+
expect(l.seenRecently('bbb', T0 + 1)).toBe(true)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('prunes expired entries on write (bounded memory)', () => {
|
|
105
|
+
const l = new CliTaskNotificationLedger()
|
|
106
|
+
l.record('old', 'completed', T0)
|
|
107
|
+
l.record('new', 'completed', T0 + TASK_NOTIFICATION_DEDUP_TTL_MS + 60_000)
|
|
108
|
+
// The old entry is both expired AND physically pruned; either way the
|
|
109
|
+
// observable contract is: it no longer suppresses.
|
|
110
|
+
expect(l.seenRecently('old', T0 + TASK_NOTIFICATION_DEDUP_TTL_MS + 60_001)).toBe(false)
|
|
111
|
+
expect(l.seenRecently('new', T0 + TASK_NOTIFICATION_DEDUP_TTL_MS + 60_001)).toBe(true)
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
describe('decideSubagentHandback × CLI task-notification dedup', () => {
|
|
116
|
+
// ── Outcome (a): one background completion → exactly ONE parent wake ──────
|
|
117
|
+
// RED-before/GREEN-after core: on pre-fix code this decision delivered a
|
|
118
|
+
// second wake; post-fix it is suppressed with the dedicated reason.
|
|
119
|
+
it('suppresses the handback when the CLI already woke the parent for this exact completion', () => {
|
|
120
|
+
const ledger = new CliTaskNotificationLedger()
|
|
121
|
+
// The CLI's wake for this completion (wake #1) is observed by the tail…
|
|
122
|
+
for (const ev of projectTranscriptLine(notifLine('a204deeaedb27b580', 'completed'))) {
|
|
123
|
+
if (ev.kind === 'task_notification') ledger.record(ev.taskId, ev.status, T0)
|
|
124
|
+
}
|
|
125
|
+
// …then the watcher's onFinish runs the decide gate (would-be wake #2).
|
|
126
|
+
const wakes: string[] = ['cli-task-notification'] // wake #1 already happened
|
|
127
|
+
const d = decideSubagentHandback({
|
|
128
|
+
...base,
|
|
129
|
+
cliTaskNotificationSeen: ledger.seenRecently(base.jsonlAgentId, T0 + 2_000),
|
|
130
|
+
})
|
|
131
|
+
if (d.deliver) wakes.push('subagent_handback')
|
|
132
|
+
expect(d).toEqual({ deliver: false, reason: 'cli-task-notification' })
|
|
133
|
+
expect(wakes).toHaveLength(1) // exactly one parent wake, not two
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('suppresses a FAILED completion the CLI already reported, too', () => {
|
|
137
|
+
const d = decideSubagentHandback({
|
|
138
|
+
...base,
|
|
139
|
+
outcome: 'failed',
|
|
140
|
+
cliTaskNotificationSeen: true,
|
|
141
|
+
})
|
|
142
|
+
expect(d).toEqual({ deliver: false, reason: 'cli-task-notification' })
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
// ── Outcome (b): fail-open — a handback with NO matching notification fires ─
|
|
146
|
+
it('fail-open: delivers when no notification was seen (flag false)', () => {
|
|
147
|
+
const d = decideSubagentHandback({ ...base, cliTaskNotificationSeen: false })
|
|
148
|
+
expect(d.deliver).toBe(true)
|
|
149
|
+
if (d.deliver) {
|
|
150
|
+
expect(d.chatId).toBe('777')
|
|
151
|
+
expect(d.inbound.meta?.source).toBe('subagent_handback')
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('fail-open: delivers when the flag is omitted entirely (older callers)', () => {
|
|
156
|
+
const d = decideSubagentHandback({ ...base })
|
|
157
|
+
expect(d.deliver).toBe(true)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('fail-open end-to-end: a notification for a DIFFERENT agent id does not suppress', () => {
|
|
161
|
+
const ledger = new CliTaskNotificationLedger()
|
|
162
|
+
for (const ev of projectTranscriptLine(notifLine('a999999999999999', 'completed'))) {
|
|
163
|
+
if (ev.kind === 'task_notification') ledger.record(ev.taskId, ev.status, T0)
|
|
164
|
+
}
|
|
165
|
+
const d = decideSubagentHandback({
|
|
166
|
+
...base,
|
|
167
|
+
cliTaskNotificationSeen: ledger.seenRecently(base.jsonlAgentId, T0 + 2_000),
|
|
168
|
+
})
|
|
169
|
+
expect(d.deliver).toBe(true)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('fail-open end-to-end: an EXPIRED notification does not suppress', () => {
|
|
173
|
+
const ledger = new CliTaskNotificationLedger()
|
|
174
|
+
ledger.record(base.jsonlAgentId, 'completed', T0)
|
|
175
|
+
const d = decideSubagentHandback({
|
|
176
|
+
...base,
|
|
177
|
+
cliTaskNotificationSeen: ledger.seenRecently(
|
|
178
|
+
base.jsonlAgentId,
|
|
179
|
+
T0 + TASK_NOTIFICATION_DEDUP_TTL_MS + 1,
|
|
180
|
+
),
|
|
181
|
+
})
|
|
182
|
+
expect(d.deliver).toBe(true)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('gate ordering: foreground/outcome/env gates still win over the dedup reason', () => {
|
|
186
|
+
expect(
|
|
187
|
+
decideSubagentHandback({ ...base, isBackground: false, cliTaskNotificationSeen: true }),
|
|
188
|
+
).toEqual({ deliver: false, reason: 'foreground' })
|
|
189
|
+
expect(
|
|
190
|
+
decideSubagentHandback({ ...base, outcome: 'orphan', cliTaskNotificationSeen: true }),
|
|
191
|
+
).toEqual({ deliver: false, reason: 'outcome-not-terminal' })
|
|
192
|
+
expect(
|
|
193
|
+
decideSubagentHandback({ ...base, handbackEnvValue: '0', cliTaskNotificationSeen: true }),
|
|
194
|
+
).toEqual({ deliver: false, reason: 'env-disabled' })
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
// ── Outcome (c): the task-notification keeps working as a liveness signal ────
|
|
199
|
+
// Recording into the dedup ledger is an ADDITIVE read of the same event; the
|
|
200
|
+
// background-shell-liveness consumer must still see the shell as DEAD.
|
|
201
|
+
describe('task_notification still drives background-shell liveness (not swallowed)', () => {
|
|
202
|
+
it('one projected event feeds BOTH the liveness registry and the dedup ledger', () => {
|
|
203
|
+
const dead: string[] = []
|
|
204
|
+
const alive: string[] = []
|
|
205
|
+
const registry = {
|
|
206
|
+
noteBackgroundShellAlive: (_k: string, id: string) => void alive.push(id),
|
|
207
|
+
noteBackgroundShellDead: (_k: string, id: string) => void dead.push(id),
|
|
208
|
+
}
|
|
209
|
+
const ledger = new CliTaskNotificationLedger()
|
|
210
|
+
const events = projectTranscriptLine(notifLine('bxa4sv3dq', 'completed'))
|
|
211
|
+
expect(events).toHaveLength(1)
|
|
212
|
+
for (const ev of events) {
|
|
213
|
+
applyBackgroundShellLiveness(registry, 'chat:-', ev)
|
|
214
|
+
if (ev.kind === 'task_notification') ledger.record(ev.taskId, ev.status, T0)
|
|
215
|
+
}
|
|
216
|
+
expect(dead).toEqual(['bxa4sv3dq']) // liveness signal intact
|
|
217
|
+
expect(ledger.seenRecently('bxa4sv3dq', T0 + 1)).toBe(true) // dedup recorded
|
|
218
|
+
expect(alive).toEqual([])
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('a foreground-shell task-notification with NO handback in flight changes nothing else', () => {
|
|
222
|
+
// A background Bash shell death has no subagent-watcher onFinish — the
|
|
223
|
+
// ledger entry simply ages out; nothing consults it for shells.
|
|
224
|
+
const ledger = new CliTaskNotificationLedger()
|
|
225
|
+
ledger.record('bxa4sv3dq', 'completed', T0)
|
|
226
|
+
expect(ledger.seenRecently('bxa4sv3dq', T0 + TASK_NOTIFICATION_DEDUP_TTL_MS + 1)).toBe(false)
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
// ── Wiring pins — the gateway must actually consult the ledger ───────────────
|
|
231
|
+
// gateway.ts is not unit-instantiable; pin the two wiring sites statically
|
|
232
|
+
// (repo precedent: subagent-handback-marker.test.ts scans gateway source).
|
|
233
|
+
describe('gateway wiring pins', () => {
|
|
234
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
235
|
+
const gatewaySrc = readFileSync(join(here, '..', 'gateway', 'gateway.ts'), 'utf8')
|
|
236
|
+
|
|
237
|
+
it('onSessionEvent records terminal task-notifications into the ledger', () => {
|
|
238
|
+
expect(gatewaySrc).toMatch(
|
|
239
|
+
/if \(ev\.kind === 'task_notification'\) cliTaskNotifLedger\.record\(ev\.taskId, ev\.status, Date\.now\(\)\)/,
|
|
240
|
+
)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('the onFinish decide callsite passes the fail-open seenRecently read', () => {
|
|
244
|
+
expect(gatewaySrc).toMatch(
|
|
245
|
+
/cliTaskNotificationSeen: cliTaskNotifLedger\.seenRecently\(agentId, Date\.now\(\)\)/,
|
|
246
|
+
)
|
|
247
|
+
})
|
|
248
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression test for fleet-audit B2 — the bridge's background reconnect
|
|
3
|
+
* loop must never surface a failed connect attempt as a process-level
|
|
4
|
+
* unhandledRejection.
|
|
5
|
+
*
|
|
6
|
+
* Live evidence (kdogg, 2026-07-17):
|
|
7
|
+
* bridge-crash.log: `unhandledRejection … Error: Failed to connect
|
|
8
|
+
* | at doConnect (dist/server.js:24188) …`
|
|
9
|
+
*
|
|
10
|
+
* Mechanism: `scheduleReconnect()`'s timer invoked `doConnect()` without a
|
|
11
|
+
* rejection handler. `doConnect` returns a promise that REJECTS whenever
|
|
12
|
+
* the `Bun.connect` attempt fails (gateway restarting out from under a
|
|
13
|
+
* long-lived bridge — the exact window every planned `docker restart`
|
|
14
|
+
* opens). The initial-connect call sites attach a `.catch`; the retry
|
|
15
|
+
* timer did not, so every failed background retry escaped as an
|
|
16
|
+
* unhandledRejection. Pre-#3033 that killed the bridge process outright
|
|
17
|
+
* (Claude Code never respawns a dead MCP server → mute agent); post-#3033
|
|
18
|
+
* it still spams `bridge-crash.log` with pseudo-crash breadcrumbs and
|
|
19
|
+
* leans on a global process handler for survival.
|
|
20
|
+
*
|
|
21
|
+
* Outcome asserted: with nothing listening on the socket path, the client
|
|
22
|
+
* runs through its initial attempt AND several background retries without
|
|
23
|
+
* a single unhandledRejection reaching the process. RED without the
|
|
24
|
+
* `.catch` in scheduleReconnect's timer, GREEN with it.
|
|
25
|
+
*
|
|
26
|
+
* Run with: bun test telegram-plugin/tests/ipc-client-reconnect-rejection.test.ts
|
|
27
|
+
*/
|
|
28
|
+
import { describe, it, expect } from "bun:test";
|
|
29
|
+
import { tmpdir } from "node:os";
|
|
30
|
+
import { join } from "node:path";
|
|
31
|
+
import { createIpcClient } from "../bridge/ipc-client.js";
|
|
32
|
+
|
|
33
|
+
describe("ipc-client background reconnect", () => {
|
|
34
|
+
it("a failed reconnect attempt never escapes as a process-level unhandledRejection", async () => {
|
|
35
|
+
const captured: unknown[] = [];
|
|
36
|
+
const onUnhandled = (err: unknown) => {
|
|
37
|
+
captured.push(err);
|
|
38
|
+
};
|
|
39
|
+
process.on("unhandledRejection", onUnhandled);
|
|
40
|
+
|
|
41
|
+
// Nothing listens here — every connect attempt fails, exercising both
|
|
42
|
+
// the (already-handled) initial attempt and the retry-timer path.
|
|
43
|
+
const socketPath = join(tmpdir(), `ipc-b2-${crypto.randomUUID()}.sock`);
|
|
44
|
+
|
|
45
|
+
const handle = await createIpcClient({
|
|
46
|
+
socketPath,
|
|
47
|
+
agentName: "b2-test",
|
|
48
|
+
onInbound: () => {},
|
|
49
|
+
onPermission: () => {},
|
|
50
|
+
onStatus: () => {},
|
|
51
|
+
log: () => {},
|
|
52
|
+
reconnectDelayMs: 15,
|
|
53
|
+
maxReconnectDelayMs: 30,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
// Long enough for several background retries (15ms, 30ms, 30ms, …)
|
|
58
|
+
// plus the macrotask on which unhandledRejection is delivered.
|
|
59
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
60
|
+
} finally {
|
|
61
|
+
handle.close();
|
|
62
|
+
// Give any in-flight rejection its delivery tick before detaching.
|
|
63
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
64
|
+
process.off("unhandledRejection", onUnhandled);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
expect(handle.isConnected()).toBe(false);
|
|
68
|
+
expect(captured).toEqual([]);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -282,6 +282,92 @@ describe('narrative-lane golden — clearActivitySummary finalize', () => {
|
|
|
282
282
|
})
|
|
283
283
|
})
|
|
284
284
|
|
|
285
|
+
// ── #4348 — silent-sentinel activity card is SUPPRESSED (deleted, not finalized) ─
|
|
286
|
+
// A turn whose entire user-facing outcome is a bare NO_REPLY / HEARTBEAT_OK
|
|
287
|
+
// (the sentinel-reply-guard dropped the sentinel-only reply, or the flush net
|
|
288
|
+
// classified the captured text as silent) must leave NO visible telemetry card
|
|
289
|
+
// in the chat — most visibly on the forced-synthesis handback turns a
|
|
290
|
+
// background sub-agent injects. A real reply's card must still finalize.
|
|
291
|
+
describe('narrative-lane golden — silent-sentinel card suppression (#4348)', () => {
|
|
292
|
+
// Open the feed card on a fresh (working) turn FIRST — the card-open gate
|
|
293
|
+
// (mayOpenActivityCard) won't open one once the answer is already delivered —
|
|
294
|
+
// then stamp the turn's terminal outcome, exactly as the real turn does:
|
|
295
|
+
// the card opens mid-work, and lastReplyText / finalAnswerEverDelivered are
|
|
296
|
+
// only known at turn end.
|
|
297
|
+
async function openThenClear(over: Partial<CurrentTurn>) {
|
|
298
|
+
const { lane, calls } = makeLane()
|
|
299
|
+
const turn = makeLaneTurn(lane)
|
|
300
|
+
lane.showNarrativeStep(turn, 'Doing the work now')
|
|
301
|
+
await turn.activityInFlight
|
|
302
|
+
const cardId = turn.activityMessageId
|
|
303
|
+
expect(cardId).not.toBeNull()
|
|
304
|
+
Object.assign(turn, over)
|
|
305
|
+
lane.clearActivitySummary(turn)
|
|
306
|
+
await settle()
|
|
307
|
+
return { calls, cardId, turn }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
it('reply("NO_REPLY") turn: DELETES the card, never edits a done record', async () => {
|
|
311
|
+
// The guard drops the sentinel-only reply before chat, but the blocked
|
|
312
|
+
// tool_use still stamps lastReplyText — the exact `✓ NO_REPLY` noise card.
|
|
313
|
+
const { calls, cardId, turn } = await openThenClear({
|
|
314
|
+
replyCalled: true,
|
|
315
|
+
lastReplyText: 'NO_REPLY',
|
|
316
|
+
finalAnswerEverDelivered: false,
|
|
317
|
+
})
|
|
318
|
+
expect(calls.filter((c) => c.method === 'deleteMessage' && c.message_id === cardId)).toHaveLength(1)
|
|
319
|
+
expect(calls.filter((c) => c.method === 'editMessageText' && c.message_id === cardId)).toHaveLength(0)
|
|
320
|
+
expect(turn.activityMessageId).toBeNull()
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('HEARTBEAT_OK. (trailing punctuation, case-insensitive) is also suppressed', async () => {
|
|
324
|
+
const { calls, cardId } = await openThenClear({
|
|
325
|
+
replyCalled: true,
|
|
326
|
+
lastReplyText: 'heartbeat_ok.',
|
|
327
|
+
finalAnswerEverDelivered: false,
|
|
328
|
+
})
|
|
329
|
+
expect(calls.filter((c) => c.method === 'deleteMessage' && c.message_id === cardId)).toHaveLength(1)
|
|
330
|
+
expect(calls.filter((c) => c.method === 'editMessageText' && c.message_id === cardId)).toHaveLength(0)
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('flush path (no reply): prose + trailing NO_REPLY (H6/#2053) is suppressed', async () => {
|
|
334
|
+
const { calls, cardId } = await openThenClear({
|
|
335
|
+
replyCalled: false,
|
|
336
|
+
lastReplyText: '',
|
|
337
|
+
capturedText: ["Nothing actionable in today's digest.", 'NO_REPLY'],
|
|
338
|
+
finalAnswerEverDelivered: false,
|
|
339
|
+
})
|
|
340
|
+
expect(calls.filter((c) => c.method === 'deleteMessage' && c.message_id === cardId)).toHaveLength(1)
|
|
341
|
+
expect(calls.filter((c) => c.method === 'editMessageText' && c.message_id === cardId)).toHaveLength(0)
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it('normal reply turn: still FINALIZES the card (edit, no delete)', async () => {
|
|
345
|
+
// The guarantee the fix must not break: a real answer keeps its record.
|
|
346
|
+
const { calls, cardId, turn } = await openThenClear({
|
|
347
|
+
replyCalled: true,
|
|
348
|
+
lastReplyText: 'The fix is deployed and the tests are green.',
|
|
349
|
+
finalAnswerEverDelivered: true,
|
|
350
|
+
})
|
|
351
|
+
expect(calls.filter((c) => c.method === 'editMessageText' && c.message_id === cardId).length)
|
|
352
|
+
.toBeGreaterThanOrEqual(1)
|
|
353
|
+
expect(calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
|
|
354
|
+
expect(turn.activityMessageId).toBeNull()
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
it('reply "prose\\nNO_REPLY" that WAS delivered (finalAnswerEverDelivered) keeps its card', async () => {
|
|
358
|
+
// The guard does NOT drop a reply with non-marker content, so its prose
|
|
359
|
+
// reached chat — endsWithSilentMarker must NOT suppress a delivered reply.
|
|
360
|
+
const { calls, cardId } = await openThenClear({
|
|
361
|
+
replyCalled: true,
|
|
362
|
+
lastReplyText: 'Here is the full answer to your question.\nNO_REPLY',
|
|
363
|
+
finalAnswerEverDelivered: true,
|
|
364
|
+
})
|
|
365
|
+
expect(calls.filter((c) => c.method === 'editMessageText' && c.message_id === cardId).length)
|
|
366
|
+
.toBeGreaterThanOrEqual(1)
|
|
367
|
+
expect(calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
|
|
368
|
+
})
|
|
369
|
+
})
|
|
370
|
+
|
|
285
371
|
// ── THREE-MODULE cross-surface dedup (Amendment 1) ────────────────────────
|
|
286
372
|
// The REAL P4-A handleSessionEvent turn-flush, with the REAL P4-B lane wired
|
|
287
373
|
// into its deps, records the delivered answer into ONE OutboundDedupCache;
|
|
@@ -260,3 +260,69 @@ describe('Part B — handback-while-busy: exactly one card, never a frozen "Queu
|
|
|
260
260
|
expect(rec.edits).toHaveLength(0)
|
|
261
261
|
})
|
|
262
262
|
})
|
|
263
|
+
|
|
264
|
+
describe('Part B — synthetic (fabricated) message ids never 400 the queued card', () => {
|
|
265
|
+
/** Recording bot that enforces the REAL Telegram Bot API contract on
|
|
266
|
+
* `reply_parameters.message_id`: anything non-integer or beyond signed int32
|
|
267
|
+
* is hard-rejected with the exact 400 the live gateway hit
|
|
268
|
+
* (gateway-supervisor.log 2026-08-04, msg=1785846295635) — BEFORE recording,
|
|
269
|
+
* exactly like the wire call. `allow_sending_without_reply` does not bypass
|
|
270
|
+
* the range check, only the message-not-found case. */
|
|
271
|
+
function withTelegramStrictBot(h: Harness) {
|
|
272
|
+
const sends: SendRec[] = []
|
|
273
|
+
let nextId = 9001
|
|
274
|
+
;(h.deps as unknown as { bot: unknown }).bot = {
|
|
275
|
+
api: {
|
|
276
|
+
sendRichMessage: async (chatId: string, msg: { markdown: string }, opts: Record<string, unknown>) => {
|
|
277
|
+
const rp = opts.reply_parameters as { message_id?: unknown } | undefined
|
|
278
|
+
if (rp != null) {
|
|
279
|
+
const mid = rp.message_id
|
|
280
|
+
if (typeof mid !== 'number' || !Number.isInteger(mid) || mid <= 0 || mid >= 2 ** 31) {
|
|
281
|
+
throw new Error(
|
|
282
|
+
`Call to 'sendRichMessage' failed! (400: Bad Request: field "message_id" must be a valid Number)`,
|
|
283
|
+
)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const id = nextId++
|
|
287
|
+
sends.push({ chatId, markdown: msg.markdown, opts, id })
|
|
288
|
+
return { message_id: id }
|
|
289
|
+
},
|
|
290
|
+
editMessageText: async () => true,
|
|
291
|
+
deleteMessage: async () => true,
|
|
292
|
+
},
|
|
293
|
+
}
|
|
294
|
+
return { sends }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
it('sends the queued card UNANCHORED (no 400) when the parked message id is a fabricated Date.now() timestamp', async () => {
|
|
298
|
+
const h = makeHarness()
|
|
299
|
+
const rec = withTelegramStrictBot(h)
|
|
300
|
+
|
|
301
|
+
// Turn A mints on an idle session.
|
|
302
|
+
handleSessionEvent(h.deps, enqueue('501'))
|
|
303
|
+
expect(rec.sends).toHaveLength(0)
|
|
304
|
+
|
|
305
|
+
// A synthetic enqueue (subagent handback / boot resume) parks mid-turn with
|
|
306
|
+
// a fabricated ms-timestamp message id — finite, but NOT a Telegram id.
|
|
307
|
+
handleSessionEvent(h.deps, enqueue('1785846295635', 'handback: worker done'))
|
|
308
|
+
await settle()
|
|
309
|
+
expect(__parkedTurnStartCountForTest()).toBe(1)
|
|
310
|
+
|
|
311
|
+
// The card SENT (no 400 — RED on the pre-fix guard, which forwarded the
|
|
312
|
+
// 13-digit id into reply_parameters and lost the whole card)…
|
|
313
|
+
expect(rec.sends).toHaveLength(1)
|
|
314
|
+
// …and it sent WITHOUT reply-linkage: no reply_parameters at all.
|
|
315
|
+
expect(rec.sends[0]!.opts.reply_parameters).toBeUndefined()
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
it('still reply-anchors when the parked message id is a real Telegram id', async () => {
|
|
319
|
+
const h = makeHarness()
|
|
320
|
+
const rec = withTelegramStrictBot(h)
|
|
321
|
+
|
|
322
|
+
handleSessionEvent(h.deps, enqueue('501'))
|
|
323
|
+
handleSessionEvent(h.deps, enqueue('502', 'real mid-turn message'))
|
|
324
|
+
await settle()
|
|
325
|
+
expect(rec.sends).toHaveLength(1)
|
|
326
|
+
expect((rec.sends[0]!.opts.reply_parameters as { message_id: number }).message_id).toBe(502)
|
|
327
|
+
})
|
|
328
|
+
})
|