switchroom 0.20.11 → 0.20.13
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 +5 -2
- package/dist/auth-broker/index.js +32 -25
- package/dist/cli/notion-write-pretool.mjs +5 -2
- package/dist/cli/self-improve-stop.mjs +13 -1
- package/dist/cli/switchroom.js +3069 -1146
- package/dist/host-control/main.js +33 -26
- package/dist/vault/approvals/kernel-server.js +32 -25
- package/dist/vault/broker/server.js +32 -25
- package/examples/personal-google-workspace-mcp/compose.yaml +1 -1
- package/package.json +7 -4
- package/skills/switchroom-architecture/telegram.md +0 -1
- package/skills/switchroom-cli/SKILL.md +0 -1
- package/skills/switchroom-release/SKILL.md +3 -2
- package/telegram-plugin/README.md +2 -11
- package/telegram-plugin/bridge/bridge.ts +0 -12
- package/telegram-plugin/bunfig.toml +9 -5
- package/telegram-plugin/chat-lock.ts +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +0 -12
- package/telegram-plugin/dist/gateway/gateway.js +219 -127
- package/telegram-plugin/dist/server.js +0 -12
- package/telegram-plugin/gateway/captured-answer-resume.ts +23 -1
- package/telegram-plugin/gateway/gateway.ts +25 -59
- package/telegram-plugin/gateway/liveness-wiring.ts +6 -1
- package/telegram-plugin/gateway/outbound-send-path.ts +111 -6
- package/telegram-plugin/gateway/outbox-sweep.ts +69 -0
- package/telegram-plugin/gateway/stale-pin-sweep.ts +4 -3
- package/telegram-plugin/gateway/status-pin-store.ts +10 -9
- package/telegram-plugin/gateway/stream-render.ts +8 -4
- package/telegram-plugin/gateway/turn-record-status.ts +32 -1
- package/telegram-plugin/hooks/audience-classify.d.mts +26 -0
- package/telegram-plugin/hooks/audience-classify.mjs +193 -0
- package/telegram-plugin/hooks/hooks.json +13 -12
- package/telegram-plugin/hooks/narration-classify.mjs +1 -2
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +31 -1
- package/telegram-plugin/hooks/silent-end-scan.mjs +9 -2
- package/telegram-plugin/outbox.ts +69 -3
- package/telegram-plugin/silent-end.ts +48 -5
- package/telegram-plugin/status-pin.ts +2 -5
- package/telegram-plugin/tests/backstop-exactly-once.test.ts +8 -2
- package/telegram-plugin/tests/captured-answer-resume.test.ts +26 -11
- package/telegram-plugin/tests/framework-fallback-duration-guard.test.ts +125 -0
- package/telegram-plugin/tests/hindsight-bank-preload.test.ts +50 -0
- package/telegram-plugin/tests/outbox-live-path-review-4490.test.ts +613 -0
- package/telegram-plugin/tests/outbox-self-improve-review.test.ts +401 -0
- package/telegram-plugin/tests/pin-message-tool-retired.test.ts +64 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +38 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +40 -1
- package/telegram-plugin/worker-activity-feed.ts +1 -1
- package/vendor/hindsight-memory/scripts/recall.py +140 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_latency_instrumentation.py +277 -0
|
@@ -82,6 +82,26 @@ export interface SilentEndState {
|
|
|
82
82
|
* throw", which delivers exactly as before. It never suppresses.
|
|
83
83
|
*/
|
|
84
84
|
replyToolThrewThisTurn?: boolean
|
|
85
|
+
/**
|
|
86
|
+
* #4490 — did the turn that produced `pendingText` originate from a
|
|
87
|
+
* self-improvement review inbound (`source="self_improve_review"`)?
|
|
88
|
+
*
|
|
89
|
+
* Stamped by the Stop hook's single-writer election
|
|
90
|
+
* (`silent-end-interrupt-stop.mjs` → `buildNextState`) from the SAME
|
|
91
|
+
* enqueue-envelope `source` tag `writeOutboxRecord` reads for the outbox
|
|
92
|
+
* capture path; the gateway's own `writeSilentEndState` never sets it.
|
|
93
|
+
* Consumed by `deliverCapturedProse` (`gateway/outbound-send-path.ts`),
|
|
94
|
+
* which is the ELECTED path's deliverer — it never writes an outbox record,
|
|
95
|
+
* so the sweep's audience gate / self-improvement framing can never reach
|
|
96
|
+
* this message, and this is the only way that machine can learn the
|
|
97
|
+
* provenance. Restores the #4141-style symmetry #4485 left one-sided (card
|
|
98
|
+
* gate + title framing applied at the sweep only).
|
|
99
|
+
*
|
|
100
|
+
* Optional and only ever `true`: absent means "no positive evidence of a
|
|
101
|
+
* review-origin turn", which delivers exactly as before. It never widens
|
|
102
|
+
* suppression on a normal user turn.
|
|
103
|
+
*/
|
|
104
|
+
reviewOriginated?: boolean
|
|
85
105
|
}
|
|
86
106
|
|
|
87
107
|
export interface SilentEndDeps {
|
|
@@ -346,6 +366,15 @@ export interface CapturedProseDecision {
|
|
|
346
366
|
* `true` only on positive evidence; never affects `deliver`.
|
|
347
367
|
*/
|
|
348
368
|
replyToolThrewThisTurn?: boolean
|
|
369
|
+
/**
|
|
370
|
+
* #4490 — the persisted record says this turn originated from a
|
|
371
|
+
* self-improvement review inbound. Surfaced (not acted on) so
|
|
372
|
+
* `deliverCapturedProse` can apply the SAME card-gate / title-framing rules
|
|
373
|
+
* the outbox sweep applies. `true` only on positive evidence; never affects
|
|
374
|
+
* `deliver` here — the audience decision is made downstream, exactly as
|
|
375
|
+
* `replyToolThrewThisTurn`'s banner decision is.
|
|
376
|
+
*/
|
|
377
|
+
reviewOriginated?: boolean
|
|
349
378
|
/** Machine-readable reason (for logs / tests). */
|
|
350
379
|
reason:
|
|
351
380
|
| 'captured-prose'
|
|
@@ -426,17 +455,31 @@ export function decideCapturedProseDelivery(
|
|
|
426
455
|
// AFTER every `deliver:false` gate above — the banner is a labelling
|
|
427
456
|
// concern, and must never become an input to whether we deliver at all.
|
|
428
457
|
...(state.replyToolThrewThisTurn === true ? { replyToolThrewThisTurn: true } : {}),
|
|
458
|
+
// #4490: same discipline for the review-origin signal — placed after every
|
|
459
|
+
// `deliver:false` gate so it can never become an input to whether the
|
|
460
|
+
// (structurally different) "should we deliver at all" decision above
|
|
461
|
+
// fires. The caller decides suppression/framing from this raw flag.
|
|
462
|
+
...(state.reviewOriginated === true ? { reviewOriginated: true } : {}),
|
|
429
463
|
}
|
|
430
464
|
}
|
|
431
465
|
|
|
432
466
|
/**
|
|
433
467
|
* Outcome of a captured-prose send attempt (Option A transcript-prose bridge).
|
|
434
|
-
* - `sent`
|
|
435
|
-
* - `skipped-dedup
|
|
436
|
-
*
|
|
437
|
-
*
|
|
468
|
+
* - `sent` — the answer was delivered fresh this call.
|
|
469
|
+
* - `skipped-dedup` — the exact answer already went out (dedup hit);
|
|
470
|
+
* nothing new was sent, but the answer IS with the
|
|
471
|
+
* user.
|
|
472
|
+
* - `failed` — the send threw; the answer did NOT reach the
|
|
473
|
+
* user.
|
|
474
|
+
* - `suppressed-internal`— #4490: the audience gate classified this prose
|
|
475
|
+
* as internal (a non-card self-improvement review
|
|
476
|
+
* turn) and it was never sent. Falls through the
|
|
477
|
+
* SAME close-obligation + clear-state bookkeeping
|
|
478
|
+
* as `sent` / `skipped-dedup` below — no operator
|
|
479
|
+
* is waiting on a message that was never meant to
|
|
480
|
+
* reach them, so there is nothing to recover.
|
|
438
481
|
*/
|
|
439
|
-
export type CapturedProseSendOutcome = 'sent' | 'skipped-dedup' | 'failed'
|
|
482
|
+
export type CapturedProseSendOutcome = 'sent' | 'skipped-dedup' | 'failed' | 'suppressed-internal'
|
|
440
483
|
|
|
441
484
|
/**
|
|
442
485
|
* The bookkeeping effects the gateway applies after a captured-prose send
|
|
@@ -210,12 +210,9 @@ export function isUnpinTerminalError(err: unknown): boolean {
|
|
|
210
210
|
* Deliberately IN-MEMORY / per-boot only: pin rights may be granted later, and
|
|
211
211
|
* Telegram surfaces the new permission to the bot only on a fresh chat-member
|
|
212
212
|
* fetch — a gateway restart. Clearing the cache on restart is therefore the
|
|
213
|
-
* correct re-enable trigger.
|
|
214
|
-
* also clears its entry (rights were granted mid-session).
|
|
213
|
+
* correct re-enable trigger.
|
|
215
214
|
*
|
|
216
|
-
* Scope: the AUTO status-pin path only
|
|
217
|
-
* never consults this cache — it always attempts and surfaces the error to the
|
|
218
|
-
* agent as a normal tool error.
|
|
215
|
+
* Scope: the AUTO status-pin path only (`pin_status_while_working`).
|
|
219
216
|
*/
|
|
220
217
|
export class PinRightsCache {
|
|
221
218
|
private readonly blocked = new Set<string>()
|
|
@@ -121,9 +121,9 @@ describe('selectBackstopDelivery — deterministic backstop coalescer (#3513 fol
|
|
|
121
121
|
// ── isEphemeralTool — MF4 exact set ─────────────────────────────────────────
|
|
122
122
|
|
|
123
123
|
describe('isEphemeralTool — the exact ephemeral surface set (MF4)', () => {
|
|
124
|
-
it('recognises exactly {react, send_typing,
|
|
124
|
+
it('recognises exactly {react, send_typing, delete_message, edit_message}, MCP-prefixed or bare', () => {
|
|
125
125
|
expect([...EPHEMERAL_TOOLS].sort()).toEqual(
|
|
126
|
-
['delete_message', 'edit_message', '
|
|
126
|
+
['delete_message', 'edit_message', 'react', 'send_typing'].sort(),
|
|
127
127
|
)
|
|
128
128
|
for (const t of EPHEMERAL_TOOLS) {
|
|
129
129
|
expect(isEphemeralTool(t)).toBe(true)
|
|
@@ -132,6 +132,12 @@ describe('isEphemeralTool — the exact ephemeral surface set (MF4)', () => {
|
|
|
132
132
|
}
|
|
133
133
|
})
|
|
134
134
|
|
|
135
|
+
it('pin_message is NOT ephemeral — the pin_message MCP tool was retired (#4452)', () => {
|
|
136
|
+
expect(EPHEMERAL_TOOLS.has('pin_message')).toBe(false)
|
|
137
|
+
expect(isEphemeralTool('pin_message')).toBe(false)
|
|
138
|
+
expect(isEphemeralTool('mcp__switchroom-telegram__pin_message')).toBe(false)
|
|
139
|
+
})
|
|
140
|
+
|
|
135
141
|
it('progress_update is NOT ephemeral (MF4 — dropped from the set)', () => {
|
|
136
142
|
expect(isEphemeralTool('progress_update')).toBe(false)
|
|
137
143
|
expect(isEphemeralTool('mcp__switchroom-telegram__progress_update')).toBe(false)
|
|
@@ -1,14 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it,
|
|
2
|
-
|
|
3
|
-
// The dispatcher's resume closure reaches for the durable outbound-text oracle
|
|
4
|
-
// (history.hasOutboundWithText) to reconcile crash-idempotency. Mock it so these
|
|
5
|
-
// stay pure vitest unit tests (no bun:sqlite / real DB) and we can control the
|
|
6
|
-
// "did this chunk's text land durably?" answer per scenario.
|
|
7
|
-
let mockOracle: (chatId: string, text: string, threadId: number | null, since: number) => boolean = () => false
|
|
8
|
-
vi.mock('../history.js', () => ({
|
|
9
|
-
hasOutboundWithText: (chatId: string, text: string, threadId: number | null, since: number) =>
|
|
10
|
-
mockOracle(chatId, text, threadId, since),
|
|
11
|
-
}))
|
|
1
|
+
import { describe, expect, it, beforeEach } from 'vitest'
|
|
12
2
|
|
|
13
3
|
import {
|
|
14
4
|
BackstopDeliveryLedger,
|
|
@@ -36,7 +26,24 @@ import {
|
|
|
36
26
|
* (c) a fully-landed answer's represent sends NOTHING.
|
|
37
27
|
* A test that would still pass if the resume re-sent a confirmed chunk is not a
|
|
38
28
|
* test — every scenario asserts the exact `sendChunk` index set.
|
|
29
|
+
*
|
|
30
|
+
* The dispatcher's resume closure reaches for the durable outbound-text oracle
|
|
31
|
+
* (history.hasOutboundWithText) to reconcile crash-idempotency. `mockOracle`
|
|
32
|
+
* below is injected per-scenario via `createCapturedResumeDispatcher`'s
|
|
33
|
+
* `hasOutboundWithText` port (see `gateway/captured-answer-resume.ts`) — NOT
|
|
34
|
+
* via `vi.mock('../history.js', ...)`. That used to module-mock `history.js`,
|
|
35
|
+
* which looks file-scoped under vitest but under bun's vitest-compat layer
|
|
36
|
+
* `vi.mock` maps onto the process-global `mock.module`: `bun test` runs the
|
|
37
|
+
* whole `telegram-plugin/` surface in one process
|
|
38
|
+
* (`telegram-plugin/scripts/bun-test-ci.sh`), so the mock retroactively
|
|
39
|
+
* rebound `hasOutboundWithText` for every OTHER file in the same sweep —
|
|
40
|
+
* including `tests/history.test.ts`'s calls to the REAL implementation. That
|
|
41
|
+
* was the actual root cause of #4488/#4491: a row `history.test.ts` had
|
|
42
|
+
* genuinely just written to its own real bun:sqlite DB was reported "not
|
|
43
|
+
* found" because its `hasOutboundWithText` import had been silently rebound
|
|
44
|
+
* to this file's `() => false` default. See check-bun-module-mock-scope.mjs.
|
|
39
45
|
*/
|
|
46
|
+
let mockOracle: (chatId: string, text: string, threadId: number | null, since: number) => boolean = () => false
|
|
40
47
|
|
|
41
48
|
beforeEach(() => {
|
|
42
49
|
mockOracle = () => false
|
|
@@ -192,6 +199,7 @@ describe('#3282 createCapturedResumeDispatcher — the represent RESUME outcome'
|
|
|
192
199
|
oblLedger.noteCapturedDelivery('#t1', snapshot)
|
|
193
200
|
|
|
194
201
|
const dispatcher = createCapturedResumeDispatcher({
|
|
202
|
+
hasOutboundWithText: mockOracle,
|
|
195
203
|
deliverAnswer: makeResumeDeliverAnswer(resumeLedger, sentIdx),
|
|
196
204
|
obligationLedger: oblLedger,
|
|
197
205
|
backstopDeliveryLedger: resumeLedger,
|
|
@@ -228,6 +236,7 @@ describe('#3282 createCapturedResumeDispatcher — the represent RESUME outcome'
|
|
|
228
236
|
mockOracle = (_c, txt) => txt === 'c0'
|
|
229
237
|
|
|
230
238
|
const dispatcher = createCapturedResumeDispatcher({
|
|
239
|
+
hasOutboundWithText: mockOracle,
|
|
231
240
|
deliverAnswer: makeResumeDeliverAnswer(resumeLedger, sentIdx),
|
|
232
241
|
obligationLedger: oblLedger,
|
|
233
242
|
backstopDeliveryLedger: resumeLedger,
|
|
@@ -258,6 +267,7 @@ describe('#3282 createCapturedResumeDispatcher — the represent RESUME outcome'
|
|
|
258
267
|
oblLedger.noteCapturedDelivery('#t1', snapshot)
|
|
259
268
|
|
|
260
269
|
const dispatcher = createCapturedResumeDispatcher({
|
|
270
|
+
hasOutboundWithText: mockOracle,
|
|
261
271
|
deliverAnswer: makeResumeDeliverAnswer(resumeLedger, sentIdx),
|
|
262
272
|
obligationLedger: oblLedger,
|
|
263
273
|
backstopDeliveryLedger: resumeLedger,
|
|
@@ -302,6 +312,7 @@ describe('#3282 createCapturedResumeDispatcher — the represent RESUME outcome'
|
|
|
302
312
|
|
|
303
313
|
const lines: string[] = []
|
|
304
314
|
const dispatcher = createCapturedResumeDispatcher({
|
|
315
|
+
hasOutboundWithText: mockOracle,
|
|
305
316
|
deliverAnswer: async (a) => {
|
|
306
317
|
a.resume.hydrate(resumeLedger, a.turnId)
|
|
307
318
|
const res = await runBackstopDelivery(
|
|
@@ -351,6 +362,7 @@ describe('#3282 createCapturedResumeDispatcher — the represent RESUME outcome'
|
|
|
351
362
|
|
|
352
363
|
let probes = 0
|
|
353
364
|
const dispatcher = createCapturedResumeDispatcher({
|
|
365
|
+
hasOutboundWithText: mockOracle,
|
|
354
366
|
deliverAnswer: async (a) => {
|
|
355
367
|
a.resume.hydrate(resumeLedger, a.turnId)
|
|
356
368
|
const res = await runBackstopDelivery(
|
|
@@ -388,6 +400,7 @@ describe('#3282 createCapturedResumeDispatcher — the represent RESUME outcome'
|
|
|
388
400
|
|
|
389
401
|
// A resume whose tail send keeps throwing ⇒ not delivered ⇒ obligation stays OPEN.
|
|
390
402
|
const dispatcher = createCapturedResumeDispatcher({
|
|
403
|
+
hasOutboundWithText: mockOracle,
|
|
391
404
|
deliverAnswer: async (a) => {
|
|
392
405
|
a.resume.hydrate(resumeLedger, a.turnId)
|
|
393
406
|
const res = await runBackstopDelivery(
|
|
@@ -423,6 +436,7 @@ describe('#3282 createCapturedResumeDispatcher — the represent RESUME outcome'
|
|
|
423
436
|
oblLedger.noteCapturedDelivery('#t1', snapshot)
|
|
424
437
|
|
|
425
438
|
const dispatcher = createCapturedResumeDispatcher({
|
|
439
|
+
hasOutboundWithText: mockOracle,
|
|
426
440
|
deliverAnswer: async (a) => {
|
|
427
441
|
deliverCalls++
|
|
428
442
|
a.resume.hydrate(resumeLedger, a.turnId)
|
|
@@ -448,6 +462,7 @@ describe('#3282 createCapturedResumeDispatcher — the represent RESUME outcome'
|
|
|
448
462
|
it('no captured snapshot ⇒ dispatch is a no-op (caller falls through to fresh generation)', async () => {
|
|
449
463
|
let delivered = false
|
|
450
464
|
const dispatcher = createCapturedResumeDispatcher({
|
|
465
|
+
hasOutboundWithText: mockOracle,
|
|
451
466
|
deliverAnswer: async () => { delivered = true; return { delivered: true, sentIds: [] } },
|
|
452
467
|
obligationLedger: new ObligationLedger(2),
|
|
453
468
|
backstopDeliveryLedger: new BackstopDeliveryLedger(),
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M0 metric-corruption regression — the framework_fallback `turn_ended` emitter
|
|
3
|
+
* must never write an absolute Unix-epoch value as `duration_ms`.
|
|
4
|
+
*
|
|
5
|
+
* ROOT CAUSE (fixed by this change): the 300 s framework-fallback unwedge in
|
|
6
|
+
* `liveness-wiring.ts` read the turn's start from `activeTurnStartedAt.get(key)`
|
|
7
|
+
* and, guarded only by a `!= null` presence check, computed
|
|
8
|
+
* `Date.now() - turnStartedAt` inline. When the map held `0` (a bogus / not-yet
|
|
9
|
+
* stamped start — reproduced here exactly as the parked-turn fixture does with
|
|
10
|
+
* `activeTurnStartedAt.set(KEY, 0)`), it emitted `duration_ms = Date.now() - 0`,
|
|
11
|
+
* i.e. the current epoch-ms — a ~56,000-year "duration". The analysed dataset
|
|
12
|
+
* carried 110 such rows (`duration_ms === ts`, `ended_via: "framework_fallback"`),
|
|
13
|
+
* making every latency aggregate unusable.
|
|
14
|
+
*
|
|
15
|
+
* The two `stream-render.ts` turn_ended paths already guarded with
|
|
16
|
+
* `startedAt > 0 ? … : 0`; this path had drifted. The fix routes all emitters
|
|
17
|
+
* through the shared `computeTurnDurationMs`, which returns 0 for a non-positive
|
|
18
|
+
* start.
|
|
19
|
+
*
|
|
20
|
+
* This test drives the REAL `onFrameworkFallback` (via `buildSilencePokeOptions`,
|
|
21
|
+
* same standard as the other liveness-wiring tests) with a 0 start and asserts on
|
|
22
|
+
* the ACTUAL emitted `turn_ended` row (captured through the real runtime-metrics
|
|
23
|
+
* JSONL sink, pinned to a temp file). It asserts the OUTCOME — the emitted
|
|
24
|
+
* `duration_ms` — not a code path.
|
|
25
|
+
*
|
|
26
|
+
* Pre-fix: `duration_ms` ≈ `Date.now()` (~1.78e12). Post-fix: `duration_ms === 0`.
|
|
27
|
+
*/
|
|
28
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
29
|
+
import { mkdtempSync, readFileSync, existsSync } from 'node:fs'
|
|
30
|
+
import { tmpdir } from 'node:os'
|
|
31
|
+
import { join } from 'node:path'
|
|
32
|
+
import { buildSilencePokeOptions } from '../gateway/liveness-wiring.js'
|
|
33
|
+
import {
|
|
34
|
+
__setRuntimeMetricsPathForTests,
|
|
35
|
+
} from '../runtime-metrics.js'
|
|
36
|
+
import { makeLivenessFixture, makeTurn, statusKeyForTests } from './helpers/liveness-wiring-fixture.js'
|
|
37
|
+
|
|
38
|
+
const CHAT = '-100999'
|
|
39
|
+
|
|
40
|
+
function readTurnEndedRows(path: string): Array<Record<string, unknown>> {
|
|
41
|
+
if (!existsSync(path)) return []
|
|
42
|
+
return readFileSync(path, 'utf-8')
|
|
43
|
+
.split('\n')
|
|
44
|
+
.filter((l) => l.trim() !== '')
|
|
45
|
+
.map((l) => JSON.parse(l) as Record<string, unknown>)
|
|
46
|
+
.filter((r) => r.kind === 'turn_ended')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('framework_fallback turn_ended never emits an epoch value as duration_ms', () => {
|
|
50
|
+
let metricsPath: string
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
const dir = mkdtempSync(join(tmpdir(), 'fallback-duration-'))
|
|
54
|
+
metricsPath = join(dir, 'runtime-metrics.jsonl')
|
|
55
|
+
__setRuntimeMetricsPathForTests(metricsPath)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
__setRuntimeMetricsPathForTests(null)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('emits duration_ms === 0 for a zero/bogus start instead of Date.now()', async () => {
|
|
63
|
+
const KEY = statusKeyForTests(CHAT, null)
|
|
64
|
+
const fx = makeLivenessFixture()
|
|
65
|
+
|
|
66
|
+
// The wedged turn's start is 0 — the exact corruption trigger. Before the
|
|
67
|
+
// fix this made the fallback emit `Date.now() - 0`.
|
|
68
|
+
fx.activeTurnStartedAt.set(KEY, 0)
|
|
69
|
+
fx.setCurrentTurn(
|
|
70
|
+
makeTurn({ sessionChatId: CHAT, sessionThreadId: undefined, turnId: `${KEY}#42` }),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
const opts = buildSilencePokeOptions(fx.deps)
|
|
74
|
+
await opts.onFrameworkFallback({
|
|
75
|
+
key: KEY,
|
|
76
|
+
chatId: CHAT,
|
|
77
|
+
threadId: null,
|
|
78
|
+
fallbackKind: 'working',
|
|
79
|
+
silenceMs: 302_000,
|
|
80
|
+
inFlightTools: [],
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const rows = readTurnEndedRows(metricsPath)
|
|
84
|
+
const fallbackRow = rows.find((r) => r.ended_via === 'framework_fallback')
|
|
85
|
+
expect(fallbackRow, 'framework_fallback turn_ended row was emitted').toBeDefined()
|
|
86
|
+
|
|
87
|
+
const duration = fallbackRow!.duration_ms as number
|
|
88
|
+
// The falsifying assertion: pre-fix this is ~Date.now() (an epoch-ms value,
|
|
89
|
+
// ~1.78e12), and in particular equals the row's own `ts`. Post-fix it is 0.
|
|
90
|
+
expect(duration).toBe(0)
|
|
91
|
+
// Belt-and-braces: a duration can never be an absolute epoch stamp, and can
|
|
92
|
+
// never equal the emission timestamp.
|
|
93
|
+
expect(duration).not.toBe(fallbackRow!.ts as number)
|
|
94
|
+
expect(duration).toBeLessThan(365 * 24 * 60 * 60 * 1000) // < 1 year, sane bound
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('emits a real elapsed duration for a valid positive start', async () => {
|
|
98
|
+
const KEY = statusKeyForTests(CHAT, null)
|
|
99
|
+
const fx = makeLivenessFixture()
|
|
100
|
+
|
|
101
|
+
const startedAt = Date.now() - 302_000 // ~5 min ago
|
|
102
|
+
fx.activeTurnStartedAt.set(KEY, startedAt)
|
|
103
|
+
fx.setCurrentTurn(
|
|
104
|
+
makeTurn({ sessionChatId: CHAT, sessionThreadId: undefined, turnId: `${KEY}#43` }),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
const opts = buildSilencePokeOptions(fx.deps)
|
|
108
|
+
await opts.onFrameworkFallback({
|
|
109
|
+
key: KEY,
|
|
110
|
+
chatId: CHAT,
|
|
111
|
+
threadId: null,
|
|
112
|
+
fallbackKind: 'working',
|
|
113
|
+
silenceMs: 302_000,
|
|
114
|
+
inFlightTools: [],
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
const fallbackRow = readTurnEndedRows(metricsPath).find(
|
|
118
|
+
(r) => r.ended_via === 'framework_fallback',
|
|
119
|
+
)
|
|
120
|
+
const duration = fallbackRow!.duration_ms as number
|
|
121
|
+
// Around 302 s, with generous slack for test-runtime jitter.
|
|
122
|
+
expect(duration).toBeGreaterThanOrEqual(302_000)
|
|
123
|
+
expect(duration).toBeLessThan(360_000)
|
|
124
|
+
})
|
|
125
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
HINDSIGHT_BANK_GUARD_MARKER,
|
|
5
|
+
hindsightBankGuardTrips,
|
|
6
|
+
resetHindsightBankGuardTrips,
|
|
7
|
+
} from '../../tests/vitest-setup/hindsight-bank-guard-core.mjs'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Runtime alarm for the BUN half of the Hindsight bank hermeticity guard.
|
|
11
|
+
*
|
|
12
|
+
* vitest loads `tests/vitest-setup/hindsight-bank-guard.mjs` via
|
|
13
|
+
* `test.setupFiles`; `bun test` loads the same file via `[test] preload` in
|
|
14
|
+
* bunfig.toml (repo root) and telegram-plugin/bunfig.toml (CI's bun-test-run
|
|
15
|
+
* has `working-directory: telegram-plugin`, and bun reads the bunfig in its CWD
|
|
16
|
+
* only). Without the bun half, every bun-run test file can still reach the
|
|
17
|
+
* FLEET's Hindsight — which auto-creates a bank on miss, so one stray request
|
|
18
|
+
* mints a bank in the live instance. That is how eleven throwaway banks
|
|
19
|
+
* appeared there on 2026-07-30, one of them named `clerk`, colliding with a
|
|
20
|
+
* live agent and erasing the annotation that documented where that agent's
|
|
21
|
+
* memory actually lives.
|
|
22
|
+
*
|
|
23
|
+
* `npm run lint:hindsight-bank-hermeticity` pins the WIRING statically; this
|
|
24
|
+
* pins the EFFECT, so a bunfig that is present but no longer loading the guard
|
|
25
|
+
* (wrong relative path, bun config-discovery change) fails a test rather than
|
|
26
|
+
* silently un-protecting the runner.
|
|
27
|
+
*
|
|
28
|
+
* Imports the CORE, never the setup file — importing the setup file would
|
|
29
|
+
* INSTALL the guard and let this alarm heal itself. The replay target is
|
|
30
|
+
* 192.0.2.1 (TEST-NET-1, RFC 5737): unroutable, so an unwired run fails on the
|
|
31
|
+
* assertion rather than doing the thing the guard exists to prevent.
|
|
32
|
+
*/
|
|
33
|
+
describe('bun test runs with the fleet Hindsight blocked', () => {
|
|
34
|
+
it('rejects a bank request to a fleet Hindsight origin', async () => {
|
|
35
|
+
resetHindsightBankGuardTrips()
|
|
36
|
+
let err: unknown
|
|
37
|
+
try {
|
|
38
|
+
await fetch('http://192.0.2.1:18888/v1/default/banks/clerk/config', {
|
|
39
|
+
signal: AbortSignal.timeout(250),
|
|
40
|
+
})
|
|
41
|
+
} catch (e) {
|
|
42
|
+
err = e
|
|
43
|
+
}
|
|
44
|
+
expect(
|
|
45
|
+
String((err as Error | undefined)?.message ?? ''),
|
|
46
|
+
'bunfig.toml `[test] preload` did not install the Hindsight bank guard',
|
|
47
|
+
).toContain(HINDSIGHT_BANK_GUARD_MARKER)
|
|
48
|
+
expect(hindsightBankGuardTrips()).toBe(1)
|
|
49
|
+
})
|
|
50
|
+
})
|