switchroom 0.19.22 → 0.19.23
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 +2 -1
- package/dist/auth-broker/index.js +68 -1
- package/dist/cli/notion-write-pretool.mjs +2 -1
- package/dist/cli/switchroom.js +552 -320
- package/dist/host-control/main.js +69 -2
- package/dist/vault/approvals/kernel-server.js +71 -4
- package/dist/vault/broker/server.js +71 -4
- package/package.json +5 -4
- package/profiles/_base/start.sh.hbs +101 -0
- package/profiles/_shared/agent-self-service.md.hbs +64 -109
- package/profiles/_shared/delegation-golden-rule.md.hbs +5 -5
- package/profiles/_shared/dev-protocol.md.hbs +13 -42
- package/profiles/_shared/execution-discipline.md.hbs +7 -14
- package/profiles/coding/CLAUDE.md.hbs +0 -6
- package/profiles/default/CLAUDE.md.hbs +21 -50
- package/skills/dev-protocol/SKILL.md +90 -107
- package/telegram-plugin/bunfig.toml +10 -0
- package/telegram-plugin/dist/gateway/gateway.js +108 -16
- package/telegram-plugin/gateway/backstop-delivery.ts +97 -16
- package/telegram-plugin/gateway/captured-answer-resume.ts +46 -17
- package/telegram-plugin/gateway/gateway.ts +9 -7
- package/telegram-plugin/gateway/outbound-send-path.ts +8 -1
- package/telegram-plugin/gateway/stream-render.ts +6 -0
- package/telegram-plugin/gateway/turn-record-status.ts +19 -0
- package/telegram-plugin/gateway/turns-jsonl-rotate.ts +65 -0
- package/telegram-plugin/tests/agent-state-dir-preload.test.ts +33 -0
- package/telegram-plugin/tests/backstop-delivery.test.ts +204 -7
- package/telegram-plugin/tests/backstop-readback-probe.test.ts +12 -0
- package/telegram-plugin/tests/captured-answer-resume.test.ts +104 -0
- package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +92 -1
- package/vendor/hindsight-memory/scripts/drain_pending.py +113 -11
- package/vendor/hindsight-memory/scripts/lib/pending.py +802 -65
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +54 -7
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +1445 -11
- package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +78 -6
- package/vendor/hindsight-memory/tests/test_drain_pending.py +17 -2
- package/vendor/hindsight-memory/tests/test_pending.py +12 -4
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
*
|
|
15
15
|
* ── The fix (design-of-record §2.2) ─────────────────────────────────────────
|
|
16
16
|
* Make the re-present a *byte-identical captured-answer resume* of ONLY the
|
|
17
|
-
* not-yet
|
|
18
|
-
*
|
|
17
|
+
* not-yet-LANDED tail — never a regeneration — so a chunk that already reached
|
|
18
|
+
* the chat is provably never re-posted:
|
|
19
19
|
*
|
|
20
20
|
* 1. On a partial (`!delivered`) turn-flush, persist a {@link
|
|
21
21
|
* CapturedDeliverySnapshot} (the split chunks + each landed chunk's ids and
|
|
@@ -25,9 +25,9 @@
|
|
|
25
25
|
* 2. The obligation sweep's `represent` branch becomes source-aware: when the
|
|
26
26
|
* obligation carries a captured-delivery snapshot it drives THIS resume
|
|
27
27
|
* (re-run `runBackstopDelivery` for the SAME `turnId` and the SAME captured
|
|
28
|
-
* chunks, resuming at the first
|
|
29
|
-
*
|
|
30
|
-
*
|
|
28
|
+
* chunks, resuming at the first UNSENT index) instead of pushing a
|
|
29
|
+
* fresh-generation inbound. No snapshot ⇒ the genuine "model wrote nothing
|
|
30
|
+
* / never fired a backstop" case falls through to fresh generation
|
|
31
31
|
* unchanged.
|
|
32
32
|
* 3. The in-memory ledger is rehydrated from the snapshot AND reconciled against
|
|
33
33
|
* the durable outbound-text oracle (`hasOutboundWithText`), so a chunk that
|
|
@@ -39,9 +39,29 @@
|
|
|
39
39
|
* chunks (not a regenerated answer). #3278's per-chunk state machine
|
|
40
40
|
* (`unsent → pending → landed-unconfirmed → {landed-confirmed | unsent}`) binds
|
|
41
41
|
* here too: the resume re-PROBES `landed-unconfirmed` chunks (never re-sends
|
|
42
|
-
* them) and re-SENDS only `unsent` ones, so a
|
|
42
|
+
* them) and re-SENDS only `unsent` ones, so a landed chunk is never duplicated
|
|
43
43
|
* by either the in-fire retry OR the cross-represent resume.
|
|
44
44
|
*
|
|
45
|
+
* ── The CLOSE condition is LANDED, not confirmed (#3702) ────────────────────
|
|
46
|
+
* `runBackstopDelivery`'s verdict is evidence-based: every chunk landed a
|
|
47
|
+
* message id and at least one is a fresh non-card id. An inconclusive read-back
|
|
48
|
+
* does not lower it (only a POSITIVE `absent`, which demotes the chunk back to
|
|
49
|
+
* `unsent`, does). So a resume over a snapshot whose chunks ALL landed but were
|
|
50
|
+
* never corroborated closes the obligation having sent NOTHING — the re-probe
|
|
51
|
+
* ran, resolved nothing, and the landed-id evidence stood.
|
|
52
|
+
*
|
|
53
|
+
* That is deliberate, not an oversight. The alternative — keep the obligation
|
|
54
|
+
* OPEN because nothing corroborated the ids — has no corrective action
|
|
55
|
+
* available: the resume never re-sends a LANDED chunk, so every subsequent
|
|
56
|
+
* represent would re-run the same shed probe, send zero messages, and burn
|
|
57
|
+
* represent budget until the cap escalates a false "the agent never answered
|
|
58
|
+
* you" nudge to the operator. Representing cannot deliver anything the first
|
|
59
|
+
* delivery did not; the only thing it can produce is noise. When the durable
|
|
60
|
+
* outbound-text oracle CAN corroborate (history enabled + the row present) it
|
|
61
|
+
* confirms the chunk at hydration and the same close happens by the older
|
|
62
|
+
* `hasOutboundWithText` route — this path is the same decision when that oracle
|
|
63
|
+
* is unavailable.
|
|
64
|
+
*
|
|
45
65
|
* This module is PURE state + orchestration over injected effects — no Telegram,
|
|
46
66
|
* no SQLite, no gateway module state — so the resume decision is unit-testable
|
|
47
67
|
* (see tests/captured-answer-resume.test.ts). The gateway owns the real
|
|
@@ -55,10 +75,10 @@ import type { CapturedDeliverySnapshot, Obligation } from './obligation-ledger.j
|
|
|
55
75
|
/**
|
|
56
76
|
* Build the durable {@link CapturedDeliverySnapshot} from the live per-chunk
|
|
57
77
|
* ledger after a partial `runBackstopDelivery`. Captures every LANDED chunk's
|
|
58
|
-
* message ids + confirmed flag so the resume can (a) never re-send a
|
|
59
|
-
*
|
|
60
|
-
* it. Chunks that never landed (send threw) are simply absent → the
|
|
61
|
-
* them as `unsent` and re-sends them. Pure.
|
|
78
|
+
* message ids + confirmed flag so the resume can (a) never re-send a chunk that
|
|
79
|
+
* already landed and (b) re-PROBE a landed-unconfirmed chunk instead of blindly
|
|
80
|
+
* re-sending it. Chunks that never landed (send threw) are simply absent → the
|
|
81
|
+
* resume treats them as `unsent` and re-sends them. Pure.
|
|
62
82
|
*/
|
|
63
83
|
export function buildCapturedDeliverySnapshot(
|
|
64
84
|
ledger: BackstopDeliveryLedger,
|
|
@@ -165,9 +185,13 @@ export interface CapturedResumeDispatcher {
|
|
|
165
185
|
* The obligation-sweep's captured-answer resume driver. Owns the in-flight guard
|
|
166
186
|
* + the deliver→close/leave-open orchestration so the gateway sweep stays a
|
|
167
187
|
* two-line dispatch. On each dispatch it consumes one represent-budget unit
|
|
168
|
-
* (bounding the ladder → escalate), re-delivers only the
|
|
188
|
+
* (bounding the ladder → escalate), re-delivers only the UNSENT tail (a landed
|
|
189
|
+
* chunk is re-probed, never re-sent), and:
|
|
169
190
|
* - fully delivered ⇒ record the supersede tail, close the obligation, GC the
|
|
170
|
-
* ledger (the represent ladder stops)
|
|
191
|
+
* ledger (the represent ladder stops). Since #3702 "delivered" means every
|
|
192
|
+
* chunk LANDED — so a snapshot that is already fully landed closes here
|
|
193
|
+
* having sent nothing (see the module header for why that is the right
|
|
194
|
+
* call, and why leaving it OPEN would only manufacture a false escalation);
|
|
171
195
|
* - still partial / error ⇒ leave the obligation OPEN so the next eligible
|
|
172
196
|
* sweep (after the per-represent grace) retries, until the represent cap
|
|
173
197
|
* escalates it to the operator nudge — no infinite loop.
|
|
@@ -176,7 +200,7 @@ export function createCapturedResumeDispatcher(ports: CapturedResumePorts): Capt
|
|
|
176
200
|
const stderr = ports.stderr ?? (() => {})
|
|
177
201
|
const inFlight = new Set<string>()
|
|
178
202
|
|
|
179
|
-
/** Re-deliver the
|
|
203
|
+
/** Re-deliver the UNSENT tail of `o`'s captured answer, byte-identical,
|
|
180
204
|
* by rehydrating the per-chunk ledger from the snapshot reconciled against the
|
|
181
205
|
* durable text oracle. Never regenerates. */
|
|
182
206
|
function deliver(o: Obligation, snapshot: CapturedDeliverySnapshot): Promise<{ delivered: boolean; sentIds: number[] }> {
|
|
@@ -221,16 +245,21 @@ export function createCapturedResumeDispatcher(ports: CapturedResumePorts): Capt
|
|
|
221
245
|
if (delivered) {
|
|
222
246
|
ports.obligationLedger.close(o.originTurnId)
|
|
223
247
|
ports.backstopDeliveryLedger.clear(o.originTurnId)
|
|
248
|
+
// `sentIds` is `ledger.sentIds()` — every LANDED id, which since #3702
|
|
249
|
+
// routinely includes ids no read-back corroborated. Say "landed", not
|
|
250
|
+
// "confirmed": claiming confirmation for an uncorroborated id is exactly
|
|
251
|
+
// the overstatement this PR removed from the delivery verdict.
|
|
224
252
|
stderr(
|
|
225
253
|
`telegram gateway: captured-answer resume delivered — origin=${o.originTurnId} ` +
|
|
226
|
-
`${sentIds.length}
|
|
254
|
+
`${sentIds.length} message id(s) landed; obligation closed\n`,
|
|
227
255
|
)
|
|
228
256
|
} else {
|
|
229
|
-
//
|
|
230
|
-
//
|
|
257
|
+
// Not fully landed (a chunk never got an id, or a positive `absent`
|
|
258
|
+
// demoted one) — leave the obligation OPEN for the next paced sweep /
|
|
259
|
+
// escalation (bounded by the represent cap).
|
|
231
260
|
stderr(
|
|
232
261
|
`telegram gateway: captured-answer resume partial — origin=${o.originTurnId} ` +
|
|
233
|
-
`tail still not
|
|
262
|
+
`tail still not landed; left OPEN for retry\n`,
|
|
234
263
|
)
|
|
235
264
|
}
|
|
236
265
|
} catch (err) {
|
|
@@ -718,7 +718,7 @@ import {
|
|
|
718
718
|
import { CurrentTurnMap } from './current-turn-map.js'
|
|
719
719
|
import { resolveAnswerThreadId } from './answer-thread-resolve.js'
|
|
720
720
|
import { decideObligationTurnEnd } from './obligation-turn-end.js'
|
|
721
|
-
import { maybeRotate } from './turns-jsonl-rotate.js'
|
|
721
|
+
import { maybeRotate, resolveAgentStateDir, resolveTurnsJsonlPath } from './turns-jsonl-rotate.js'
|
|
722
722
|
import {
|
|
723
723
|
buildTurnRecord,
|
|
724
724
|
finalizeBackstopSendGated,
|
|
@@ -1032,7 +1032,7 @@ const blockedApprovalStore = createBlockedApprovalStore(
|
|
|
1032
1032
|
// Fallback: the agent's OWN state dir, which the scaffold chowns to the agent
|
|
1033
1033
|
// uid, so a write there always succeeds. Guarantees the record can never be
|
|
1034
1034
|
// silently lost when the shared dir isn't writable by this agent's uid.
|
|
1035
|
-
|
|
1035
|
+
resolveAgentStateDir(),
|
|
1036
1036
|
)
|
|
1037
1037
|
|
|
1038
1038
|
/**
|
|
@@ -2456,9 +2456,9 @@ async function deliverAnswer(args: {
|
|
|
2456
2456
|
* the send alive if the user deleted their message. Null for synthesized
|
|
2457
2457
|
* turns (cron/handback) — those send bare, as before. */
|
|
2458
2458
|
replyToMessageId: number | null
|
|
2459
|
-
/** #3282 captured-answer RESUME (see captured-answer-resume.ts): re-deliver the SAME byte-identical chunks + pre-hydrate the ledger (
|
|
2459
|
+
/** #3282 captured-answer RESUME (see captured-answer-resume.ts): re-deliver the SAME byte-identical chunks + pre-hydrate the ledger (unsent tail only; a landed chunk is re-probed, never re-sent). */
|
|
2460
2460
|
resume?: { snapshot: CapturedDeliverySnapshot; hydrate: (ledger: BackstopDeliveryLedger, turnId: string) => void }
|
|
2461
|
-
}): Promise<{ sentIds: number[]; chunkCount: number; delivered: boolean; exhausted: boolean }> {
|
|
2461
|
+
}): Promise<{ sentIds: number[]; chunkCount: number; delivered: boolean; exhausted: boolean; landedUnconfirmed: number }> {
|
|
2462
2462
|
const { chatId, turnId } = args
|
|
2463
2463
|
// Spacers into `\n\n` gaps then split (as executeReply); a resume re-delivers the EXACT captured chunks (byte-stable, no re-split).
|
|
2464
2464
|
const chunks = args.resume
|
|
@@ -2594,6 +2594,7 @@ async function deliverAnswer(args: {
|
|
|
2594
2594
|
chunkCount: result.chunkCount,
|
|
2595
2595
|
delivered: result.delivered,
|
|
2596
2596
|
exhausted: result.exhausted,
|
|
2597
|
+
landedUnconfirmed: result.landedUnconfirmedIds.length, // #3702 — the caller stamps this on the turn record
|
|
2597
2598
|
}
|
|
2598
2599
|
}
|
|
2599
2600
|
|
|
@@ -3427,6 +3428,7 @@ export type CurrentTurn = {
|
|
|
3427
3428
|
// turn-end paths (reply-tool tail, silent-marker, genuine no-reply), where
|
|
3428
3429
|
// the legacy `finalAnswerDelivered` reading still applies unchanged.
|
|
3429
3430
|
deliveryOutcome?: DeliveryOutcome
|
|
3431
|
+
landedUnconfirmed?: number // #3702 — landed ids no read-back corroborated; emitted as `landed_unconfirmed` (rationale: turn-record-status.ts)
|
|
3430
3432
|
// Feed-reopen-after-ack refinement — whether the reply that set
|
|
3431
3433
|
// `finalAnswerDelivered` was a *substantive* final answer (stream
|
|
3432
3434
|
// `done`, or ≥200 chars) as opposed to a short pinging interim ACK.
|
|
@@ -5012,7 +5014,7 @@ function snapshotContextOccupancy(): void {
|
|
|
5012
5014
|
} catch {
|
|
5013
5015
|
cap = null; // config unreadable → show occupancy without a ratio
|
|
5014
5016
|
}
|
|
5015
|
-
const stateDir =
|
|
5017
|
+
const stateDir = resolveAgentStateDir();
|
|
5016
5018
|
writeContextOccupancySnapshot(
|
|
5017
5019
|
stateDir,
|
|
5018
5020
|
buildContextOccupancy(occupancy, cap, Date.now()),
|
|
@@ -5033,12 +5035,12 @@ function emitTurnRecord(turn: CurrentTurn, endedAt: number): void {
|
|
|
5033
5035
|
toolCallCount: turn.toolCallCount ?? 0,
|
|
5034
5036
|
turnId: turn.turnId,
|
|
5035
5037
|
finalAnswerDelivered: turn.finalAnswerDelivered,
|
|
5036
|
-
deliveryOutcome: turn.deliveryOutcome,
|
|
5038
|
+
deliveryOutcome: turn.deliveryOutcome, landedUnconfirmed: turn.landedUnconfirmed,
|
|
5037
5039
|
},
|
|
5038
5040
|
endedAt,
|
|
5039
5041
|
),
|
|
5040
5042
|
) + '\n'
|
|
5041
|
-
const turnsPath =
|
|
5043
|
+
const turnsPath = resolveTurnsJsonlPath() // honours SWITCHROOM_AGENT_STATE_DIR
|
|
5042
5044
|
// Size-cap rotation: keep at most one rotated generation so the file can't
|
|
5043
5045
|
// grow unbounded on a long-lived agent. Best-effort (never throws).
|
|
5044
5046
|
maybeRotate(turnsPath, {
|
|
@@ -611,7 +611,14 @@ export function createBackstopReadBack(
|
|
|
611
611
|
}
|
|
612
612
|
try {
|
|
613
613
|
const r = await w.gate(() => w.editMessageText(messageId, body, editApiOpts), gateOpts)
|
|
614
|
-
|
|
614
|
+
// A shed resolves the SEND_GATE_SHED sentinel; a gate no-op drop (the
|
|
615
|
+
// identical payload is already the last one sent for this message id) or
|
|
616
|
+
// an expired queue entry resolves `undefined`. In BOTH cases the edit
|
|
617
|
+
// never reached Telegram, so there is no evidence of existence —
|
|
618
|
+
// `ambiguous`, never a fabricated `exists`. Only a real API result
|
|
619
|
+
// (grammy resolves `true` or the edited Message) proves presence.
|
|
620
|
+
if (w.isShed(r) || r === undefined) return 'ambiguous'
|
|
621
|
+
return 'exists'
|
|
615
622
|
} catch (err) {
|
|
616
623
|
return classifyReadBackError(err)
|
|
617
624
|
}
|
|
@@ -1856,6 +1856,12 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
1856
1856
|
sentIds = delivery.sentIds
|
|
1857
1857
|
chunkCount = delivery.chunkCount
|
|
1858
1858
|
delivered = delivery.delivered
|
|
1859
|
+
// #3702 — how many landed ids the read-back never corroborated.
|
|
1860
|
+
// Stamped on the turn so `emitTurnRecord` writes `landed_unconfirmed`
|
|
1861
|
+
// (omitted when 0): the fleet-visible counter for deliveries we call
|
|
1862
|
+
// `complete` on the Bot API's ack alone, because the probe is
|
|
1863
|
+
// inconclusive. Observational only — it never changes the status.
|
|
1864
|
+
turn.landedUnconfirmed = delivery.landedUnconfirmed
|
|
1859
1865
|
|
|
1860
1866
|
// #546 dedup: record what turn-flush just sent so a late-arriving
|
|
1861
1867
|
// reply / stream_reply with the same content gets suppressed.
|
|
@@ -148,6 +148,20 @@ export interface TurnRecordRow {
|
|
|
148
148
|
tools: number
|
|
149
149
|
status: TurnStatus
|
|
150
150
|
turn_id: string
|
|
151
|
+
/**
|
|
152
|
+
* How many landed message ids of this turn's backstop delivery the read-back
|
|
153
|
+
* probe never corroborated (`sentIds` minus the confirmed subset). OMITTED
|
|
154
|
+
* when zero, so an ordinary row is byte-identical to before.
|
|
155
|
+
*
|
|
156
|
+
* This is the measurable counterpart of the delivery verdict: since an
|
|
157
|
+
* inconclusive probe counts as delivered, a `complete` row carrying
|
|
158
|
+
* `landed_unconfirmed > 0` is a turn we called delivered on the Bot API's
|
|
159
|
+
* ack alone. Counting those is how the fleet can tell whether that optimism
|
|
160
|
+
* is ever wrong (a `landed_unconfirmed` turn followed by a "you never
|
|
161
|
+
* answered me" is the falsifying observation). It is NOT a failure signal and
|
|
162
|
+
* nothing escalates on it.
|
|
163
|
+
*/
|
|
164
|
+
landed_unconfirmed?: number
|
|
151
165
|
}
|
|
152
166
|
|
|
153
167
|
/**
|
|
@@ -165,6 +179,7 @@ export function buildTurnRecord(
|
|
|
165
179
|
turnId: string
|
|
166
180
|
finalAnswerDelivered: boolean
|
|
167
181
|
deliveryOutcome?: DeliveryOutcome
|
|
182
|
+
landedUnconfirmed?: number
|
|
168
183
|
},
|
|
169
184
|
endedAt: number,
|
|
170
185
|
): TurnRecordRow {
|
|
@@ -175,5 +190,9 @@ export function buildTurnRecord(
|
|
|
175
190
|
tools: turn.toolCallCount ?? 0,
|
|
176
191
|
status: computeTurnStatus(turn),
|
|
177
192
|
turn_id: turn.turnId,
|
|
193
|
+
// Emitted ONLY when non-zero (see `TurnRecordRow.landed_unconfirmed`).
|
|
194
|
+
...(turn.landedUnconfirmed != null && turn.landedUnconfirmed > 0
|
|
195
|
+
? { landed_unconfirmed: turn.landedUnconfirmed }
|
|
196
|
+
: {}),
|
|
178
197
|
}
|
|
179
198
|
}
|
|
@@ -13,6 +13,71 @@
|
|
|
13
13
|
*/
|
|
14
14
|
export const TURNS_JSONL_MAX_BYTES = 5 * 1024 * 1024 // 5 MiB
|
|
15
15
|
|
|
16
|
+
/** The agent state dir inside a switchroom agent container (bind-mounted to
|
|
17
|
+
* `~/.switchroom/agents/<name>/` on the host). */
|
|
18
|
+
export const DEFAULT_AGENT_STATE_DIR = '/state/agent'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the agent state dir from the environment — the ONE reader for
|
|
22
|
+
* `SWITCHROOM_AGENT_STATE_DIR` that every writer into that dir must use.
|
|
23
|
+
*
|
|
24
|
+
* Normalisation is the point. The gateway had two writers into this dir a few
|
|
25
|
+
* lines apart (the context-occupancy snapshot and the turn record) reading the
|
|
26
|
+
* env var with two different expressions: a bare
|
|
27
|
+
* `process.env.SWITCHROOM_AGENT_STATE_DIR ?? '/state/agent'` and this one. For
|
|
28
|
+
* a value like `"/x/ "` or `"/x/"` those resolve to DIFFERENT directories, so
|
|
29
|
+
* the two artifacts of the same turn would land in two places — exactly the
|
|
30
|
+
* kind of split-brain state that made the turn-record leak hard to see. Both
|
|
31
|
+
* call sites now share this function.
|
|
32
|
+
*
|
|
33
|
+
* Blank/whitespace-only is treated as unset (a compose file that renders an
|
|
34
|
+
* empty value must not send state to `/turns.jsonl` at the filesystem root),
|
|
35
|
+
* and a trailing slash is stripped so joins never double it.
|
|
36
|
+
*/
|
|
37
|
+
export function resolveAgentStateDir(
|
|
38
|
+
env: Record<string, string | undefined> = process.env,
|
|
39
|
+
): string {
|
|
40
|
+
const dir = env.SWITCHROOM_AGENT_STATE_DIR?.trim()
|
|
41
|
+
return dir != null && dir !== '' ? dir.replace(/\/+$/, '') : DEFAULT_AGENT_STATE_DIR
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Resolve the turn-record path from the environment.
|
|
46
|
+
*
|
|
47
|
+
* `emitTurnRecord` used to hard-code `/state/agent/turns.jsonl`, ignoring
|
|
48
|
+
* `SWITCHROOM_AGENT_STATE_DIR` — which the sibling context-occupancy writer a
|
|
49
|
+
* few lines above it in `gateway.ts` already honours. Inside an agent container
|
|
50
|
+
* that path is the bind-mounted PRODUCTION `~/.switchroom/agents/<name>/
|
|
51
|
+
* turns.jsonl`, so any test that drove the real turn-end funnel while running
|
|
52
|
+
* in an agent container appended its synthetic rows straight into that agent's
|
|
53
|
+
* production turn record — even when the test had pointed every state-dir env
|
|
54
|
+
* var at a tmpdir. Those rows are then read back by the fleet-health L0 sensor
|
|
55
|
+
* (`src/fleet-health/scan.ts`) as that agent's real production turns.
|
|
56
|
+
*
|
|
57
|
+
* Honouring the env var is the root-cause fix: production containers do not set
|
|
58
|
+
* it (default unchanged), and a test that isolates its state dir now isolates
|
|
59
|
+
* its turn records with it.
|
|
60
|
+
*
|
|
61
|
+
* ── Operator coupling: setting this var RELOCATES the fleet-health input ──
|
|
62
|
+
*
|
|
63
|
+
* `agent.env` in `switchroom.yaml` is propagated verbatim into the container
|
|
64
|
+
* (`src/agents/compose.ts` `userEnv`), so an operator CAN set
|
|
65
|
+
* `SWITCHROOM_AGENT_STATE_DIR` on an agent. If they point it anywhere other
|
|
66
|
+
* than the bind-mounted `/state/agent`, this file moves with it — but the
|
|
67
|
+
* fleet-health L0 sensor reads `~/.switchroom/agents/<name>/turns.jsonl` at a
|
|
68
|
+
* FIXED host path (`src/fleet-health/scan.ts`). That agent then presents no
|
|
69
|
+
* turns artifact, lands in the scan's `skipped[]`, and goes quiet on the health
|
|
70
|
+
* board: no findings, no ledger entries, indistinguishable from a healthy
|
|
71
|
+
* agent. Do not set this var in production `agent.env`; it exists so tests (and
|
|
72
|
+
* the vitest `agent-state-dir-guard` setup file) can isolate agent state into a
|
|
73
|
+
* tmpdir.
|
|
74
|
+
*/
|
|
75
|
+
export function resolveTurnsJsonlPath(
|
|
76
|
+
env: Record<string, string | undefined> = process.env,
|
|
77
|
+
): string {
|
|
78
|
+
return `${resolveAgentStateDir(env)}/turns.jsonl`
|
|
79
|
+
}
|
|
80
|
+
|
|
16
81
|
export interface RotateFs {
|
|
17
82
|
statSize: (path: string) => number | undefined // undefined ⇒ file absent
|
|
18
83
|
rename: (from: string, to: string) => void
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Runtime alarm for the BUN half of the state-dir hermeticity guard.
|
|
7
|
+
*
|
|
8
|
+
* vitest loads `tests/vitest-setup/agent-state-dir-guard.mjs` via
|
|
9
|
+
* `test.setupFiles`; `bun test` loads the same file via `[test] preload` in
|
|
10
|
+
* bunfig.toml (repo root) and telegram-plugin/bunfig.toml (CI's bun-test-run
|
|
11
|
+
* has `working-directory: telegram-plugin`, and bun reads the bunfig in its CWD
|
|
12
|
+
* only). Without the bun half, ~75 bun-run test files still default every agent
|
|
13
|
+
* state writer to `/state/agent` — inside an agent container that is a LIVE
|
|
14
|
+
* agent's bind-mounted state dir, which is exactly how 356 synthetic turn rows
|
|
15
|
+
* ended up in production `turns.jsonl` files and were scored as real drift by
|
|
16
|
+
* the fleet-health sensor.
|
|
17
|
+
*
|
|
18
|
+
* `npm run lint:agent-state-dir-hermeticity` pins the WIRING statically; this
|
|
19
|
+
* pins the EFFECT, so a bunfig that is present but no longer loading the guard
|
|
20
|
+
* (wrong relative path, bun config-discovery change) fails a test rather than
|
|
21
|
+
* silently un-protecting the runner.
|
|
22
|
+
*/
|
|
23
|
+
describe('bun test runs with the agent state dir redirected', () => {
|
|
24
|
+
for (const k of ['SWITCHROOM_AGENT_STATE_DIR', 'SWITCHROOM_RUNTIME_STATE_DIR']) {
|
|
25
|
+
it(`${k} points at a tmp dir, not a live agent state dir`, () => {
|
|
26
|
+
const v = process.env[k]
|
|
27
|
+
expect(v, `${k} unset — bunfig.toml \`[test] preload\` did not run`).toBeTruthy()
|
|
28
|
+
expect(v).not.toBe('/state/agent')
|
|
29
|
+
expect(v!.startsWith(tmpdir())).toBe(true)
|
|
30
|
+
expect(existsSync(v!)).toBe(true)
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
})
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from '../gateway/backstop-delivery.js'
|
|
11
11
|
import {
|
|
12
12
|
backstopSendOutcomeGated,
|
|
13
|
+
buildTurnRecord,
|
|
13
14
|
finalizeBackstopSendGated,
|
|
14
15
|
computeTurnStatus,
|
|
15
16
|
} from '../gateway/turn-record-status.js'
|
|
@@ -348,25 +349,29 @@ describe('#3278 runBackstopDelivery — read-back drives the delivery outcome',
|
|
|
348
349
|
expect(ledger.hasConfirmedChunk('#drop', 0)).toBe(true)
|
|
349
350
|
})
|
|
350
351
|
|
|
351
|
-
it('429/ambiguous probe ⇒ NOT re-sent, stays landed-unconfirmed,
|
|
352
|
+
it('429/ambiguous probe ⇒ NOT re-sent, stays landed-unconfirmed, still DELIVERED', async () => {
|
|
352
353
|
const ledger = new BackstopDeliveryLedger()
|
|
353
354
|
const sendChunk = vi.fn(async () => [950])
|
|
354
355
|
const readBack = vi.fn(async (): Promise<ReadBackResult> => 'ambiguous')
|
|
355
356
|
const res = await runBackstopDelivery(ledger, '#amb', ['answer'], null, { sendChunk, readBack }, 3)
|
|
356
357
|
expect(sendChunk).toHaveBeenCalledTimes(1) // never re-sent on ambiguous
|
|
357
|
-
|
|
358
|
-
|
|
358
|
+
// An inconclusive probe establishes nothing, so the landed-id evidence
|
|
359
|
+
// stands: the answer IS in the chat and the turn is not a failure.
|
|
360
|
+
expect(res.delivered).toBe(true)
|
|
361
|
+
expect(res.confirmed).toBe(false) // landed-unconfirmed, honestly reported
|
|
362
|
+
expect(res.exhausted).toBe(false)
|
|
359
363
|
expect(ledger.hasConfirmedChunk('#amb', 0)).toBe(false)
|
|
360
364
|
expect(ledger.landedUnconfirmedIndices('#amb', 1)).toEqual([0]) // still landed-unconfirmed
|
|
361
365
|
})
|
|
362
366
|
|
|
363
|
-
it('a read-back adapter THAT THROWS is
|
|
367
|
+
it('a read-back adapter THAT THROWS is ambiguous — never re-sent, never a failure', async () => {
|
|
364
368
|
const ledger = new BackstopDeliveryLedger()
|
|
365
369
|
const sendChunk = vi.fn(async () => [951])
|
|
366
370
|
const readBack = vi.fn(async () => { throw new Error('boom') })
|
|
367
371
|
const res = await runBackstopDelivery(ledger, '#throw', ['answer'], null, { sendChunk, readBack }, 3)
|
|
368
372
|
expect(sendChunk).toHaveBeenCalledTimes(1)
|
|
369
|
-
expect(res.delivered).toBe(
|
|
373
|
+
expect(res.delivered).toBe(true)
|
|
374
|
+
expect(res.confirmed).toBe(false)
|
|
370
375
|
})
|
|
371
376
|
|
|
372
377
|
it('#3278 CORE: API-ack fresh id but read-back ABSENT ⇒ send_failed, NOT complete', async () => {
|
|
@@ -403,15 +408,207 @@ describe('#3278 runBackstopDelivery — read-back drives the delivery outcome',
|
|
|
403
408
|
expect(ledger.hasConfirmedChunk('#noprobe', 0)).toBe(true)
|
|
404
409
|
})
|
|
405
410
|
|
|
406
|
-
it('partial: chunk 0 confirmed, chunk 1 ambiguous ⇒ delivered
|
|
411
|
+
it('partial: chunk 0 confirmed, chunk 1 ambiguous ⇒ both landed ⇒ delivered, chunk 0 not re-sent', async () => {
|
|
407
412
|
const ledger = new BackstopDeliveryLedger()
|
|
408
413
|
const calls: number[] = []
|
|
409
414
|
const sendChunk = vi.fn(async (i: number) => { calls.push(i); return [700 + i] })
|
|
410
415
|
const readBack = vi.fn(async (i: number): Promise<ReadBackResult> => (i === 0 ? 'exists' : 'ambiguous'))
|
|
411
416
|
const res = await runBackstopDelivery(ledger, '#part', ['c0', 'c1'], null, { sendChunk, readBack }, 3)
|
|
412
|
-
expect(res.delivered).toBe(
|
|
417
|
+
expect(res.delivered).toBe(true) // every chunk landed a fresh id
|
|
418
|
+
expect(res.confirmed).toBe(false) // ...but chunk 1 was never corroborated
|
|
413
419
|
expect(calls.filter(i => i === 0)).toHaveLength(1) // confirmed chunk never re-sent
|
|
414
420
|
expect(ledger.hasConfirmedChunk('#part', 0)).toBe(true)
|
|
415
421
|
expect(ledger.hasConfirmedChunk('#part', 1)).toBe(false)
|
|
416
422
|
})
|
|
423
|
+
|
|
424
|
+
// ── The measurement bug this PR fixes ────────────────────────────────────
|
|
425
|
+
//
|
|
426
|
+
// The probe is issued at cosmetic priority in the same millisecond as the
|
|
427
|
+
// send it probes, so the per-chat token bucket (1/sec, just consumed by that
|
|
428
|
+
// very send) sheds it: in production it resolved `ambiguous` 146 times in two
|
|
429
|
+
// weeks and `absent` ZERO times. Under the old confirmation-gated verdict each
|
|
430
|
+
// of those became `delivered:false` → `send_failed` on a turn whose answer the
|
|
431
|
+
// user had received, plus an error reaction and a still-OPEN obligation.
|
|
432
|
+
it('AMBIGUOUS probe ⇒ turn record `complete`, NOT send_failed (the fixed bug)', async () => {
|
|
433
|
+
const ledger = new BackstopDeliveryLedger()
|
|
434
|
+
const cardId = 500
|
|
435
|
+
const sendChunk = vi.fn(async () => [971]) // a fresh, non-card chat id
|
|
436
|
+
const readBack = vi.fn(async (): Promise<ReadBackResult> => 'ambiguous')
|
|
437
|
+
const res = await runBackstopDelivery(ledger, '#shed', ['answer'], cardId, { sendChunk, readBack }, 3)
|
|
438
|
+
const turn: { finalAnswerDelivered: boolean; deliveryOutcome?: 'delivered' | 'failed' | 'suppressed' } = {
|
|
439
|
+
finalAnswerDelivered: true,
|
|
440
|
+
}
|
|
441
|
+
finalizeBackstopSendGated(turn, {
|
|
442
|
+
threw: !res.delivered, sentIds: res.sentIds, chunkCount: res.chunkCount, cardMessageId: cardId,
|
|
443
|
+
})
|
|
444
|
+
expect(computeTurnStatus(turn)).toBe('complete')
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
it('an ambiguous probe on a CARD-ONLY delivery is still send_failed (guard 7 holds)', async () => {
|
|
448
|
+
const ledger = new BackstopDeliveryLedger()
|
|
449
|
+
const cardId = 501
|
|
450
|
+
// The only id that landed IS the progress card — swept ~60-90s later, so
|
|
451
|
+
// the user receives nothing. Ambiguity must not rescue this.
|
|
452
|
+
const sendChunk = vi.fn(async () => [cardId])
|
|
453
|
+
const readBack = vi.fn(async (): Promise<ReadBackResult> => 'ambiguous')
|
|
454
|
+
const res = await runBackstopDelivery(ledger, '#cardonly', ['answer'], cardId, { sendChunk, readBack }, 3)
|
|
455
|
+
expect(res.delivered).toBe(false)
|
|
456
|
+
const turn: { finalAnswerDelivered: boolean; deliveryOutcome?: 'delivered' | 'failed' | 'suppressed' } = {
|
|
457
|
+
finalAnswerDelivered: true,
|
|
458
|
+
}
|
|
459
|
+
finalizeBackstopSendGated(turn, {
|
|
460
|
+
threw: !res.delivered, sentIds: res.sentIds, chunkCount: res.chunkCount, cardMessageId: cardId,
|
|
461
|
+
})
|
|
462
|
+
expect(computeTurnStatus(turn)).toBe('send_failed')
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
it('an ambiguous probe on a chunk that never landed is still NOT delivered', async () => {
|
|
466
|
+
const ledger = new BackstopDeliveryLedger()
|
|
467
|
+
// chunk 0 lands; chunk 1 throws on every attempt → never landed.
|
|
468
|
+
const sendChunk = vi.fn(async (i: number) => {
|
|
469
|
+
if (i === 1) throw new Error('flood')
|
|
470
|
+
return [800 + i]
|
|
471
|
+
})
|
|
472
|
+
const readBack = vi.fn(async (): Promise<ReadBackResult> => 'ambiguous')
|
|
473
|
+
const res = await runBackstopDelivery(ledger, '#gap', ['c0', 'c1'], null, { sendChunk, readBack }, 2)
|
|
474
|
+
expect(res.delivered).toBe(false)
|
|
475
|
+
expect(res.exhausted).toBe(true)
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
// Mixed sequence: the two probe states that MOVE the verdict, composed in one
|
|
479
|
+
// run. Chunk 0's first send is silently discarded (`absent` ⇒ demote ⇒
|
|
480
|
+
// re-send) and the probe on the RE-SEND is shed (`ambiguous`). Under the old
|
|
481
|
+
// confirmation-gated verdict the re-sent chunk was never `landed-confirmed`,
|
|
482
|
+
// so the whole turn came out `delivered:false` — a `send_failed` for an answer
|
|
483
|
+
// that had just been successfully re-sent. Demote-and-re-send and
|
|
484
|
+
// ambiguity-is-not-failure must compose.
|
|
485
|
+
it('absent ⇒ demote ⇒ re-send, then AMBIGUOUS on the re-send ⇒ delivered, sent exactly twice', async () => {
|
|
486
|
+
const ledger = new BackstopDeliveryLedger()
|
|
487
|
+
let sends = 0
|
|
488
|
+
const sendChunk = vi.fn(async () => { sends++; return [990 + sends] })
|
|
489
|
+
let probes = 0
|
|
490
|
+
const readBack = vi.fn(async (): Promise<ReadBackResult> => {
|
|
491
|
+
probes++
|
|
492
|
+
return probes === 1 ? 'absent' : 'ambiguous'
|
|
493
|
+
})
|
|
494
|
+
const res = await runBackstopDelivery(ledger, '#mixed', ['answer'], null, { sendChunk, readBack }, 3)
|
|
495
|
+
expect(sendChunk).toHaveBeenCalledTimes(2) // initial + exactly one re-send
|
|
496
|
+
expect(readBack).toHaveBeenCalledTimes(2) // probed once per landing
|
|
497
|
+
expect(res.delivered).toBe(true) // the re-send landed a fresh id; ambiguity proves nothing
|
|
498
|
+
expect(res.confirmed).toBe(false) // ...and is honestly reported as uncorroborated
|
|
499
|
+
expect(res.sentIds).toEqual([992]) // the demoted first id is gone; the re-send's id stands
|
|
500
|
+
expect(res.landedUnconfirmedIds).toEqual([992])
|
|
501
|
+
// The outcome the user actually experiences: a `complete` turn record.
|
|
502
|
+
const turn: { finalAnswerDelivered: boolean; deliveryOutcome?: 'delivered' | 'failed' | 'suppressed' } = {
|
|
503
|
+
finalAnswerDelivered: true,
|
|
504
|
+
}
|
|
505
|
+
finalizeBackstopSendGated(turn, {
|
|
506
|
+
threw: !res.delivered, sentIds: res.sentIds, chunkCount: res.chunkCount, cardMessageId: null,
|
|
507
|
+
})
|
|
508
|
+
expect(computeTurnStatus(turn)).toBe('complete')
|
|
509
|
+
})
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
// ── The observability of the new optimism (#3702 L2/L5/L6a) ─────────────────
|
|
513
|
+
//
|
|
514
|
+
// Counting an inconclusive probe as delivered is a BET. These pin the two
|
|
515
|
+
// artifacts that let the fleet find out if it is ever wrong: the per-delivery
|
|
516
|
+
// stderr line, and the `landed_unconfirmed` field on the turns.jsonl row.
|
|
517
|
+
describe('#3702 landed-unconfirmed is MEASURED, not silently assumed', () => {
|
|
518
|
+
it('a delivered-but-unconfirmed turn writes the diagnostic stderr line', async () => {
|
|
519
|
+
const ledger = new BackstopDeliveryLedger()
|
|
520
|
+
const lines: string[] = []
|
|
521
|
+
const res = await runBackstopDelivery(
|
|
522
|
+
ledger, '#obs', ['answer'], null,
|
|
523
|
+
{
|
|
524
|
+
sendChunk: async () => [1001],
|
|
525
|
+
readBack: async (): Promise<ReadBackResult> => 'ambiguous',
|
|
526
|
+
stderr: (s) => lines.push(s),
|
|
527
|
+
},
|
|
528
|
+
3,
|
|
529
|
+
)
|
|
530
|
+
expect(res.delivered).toBe(true)
|
|
531
|
+
expect(res.confirmed).toBe(false)
|
|
532
|
+
const diag = lines.filter(l => l.includes('backstop delivery landed-unconfirmed'))
|
|
533
|
+
expect(diag).toHaveLength(1)
|
|
534
|
+
expect(diag[0]).toContain('#obs')
|
|
535
|
+
expect(diag[0]).toContain('1 of 1 landed id(s)') // the measured quantity, not just a warning
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
it('a fully CONFIRMED delivery writes NO landed-unconfirmed line (no false alarm)', async () => {
|
|
539
|
+
const ledger = new BackstopDeliveryLedger()
|
|
540
|
+
const lines: string[] = []
|
|
541
|
+
const res = await runBackstopDelivery(
|
|
542
|
+
ledger, '#conf', ['answer'], null,
|
|
543
|
+
{
|
|
544
|
+
sendChunk: async () => [1002],
|
|
545
|
+
readBack: async (): Promise<ReadBackResult> => 'exists',
|
|
546
|
+
stderr: (s) => lines.push(s),
|
|
547
|
+
},
|
|
548
|
+
3,
|
|
549
|
+
)
|
|
550
|
+
expect(res.confirmed).toBe(true)
|
|
551
|
+
expect(res.landedUnconfirmedIds).toEqual([])
|
|
552
|
+
expect(lines.filter(l => l.includes('backstop delivery landed-unconfirmed'))).toHaveLength(0)
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
it('landedUnconfirmedIds is exactly the uncorroborated subset of sentIds', async () => {
|
|
556
|
+
const ledger = new BackstopDeliveryLedger()
|
|
557
|
+
// chunk 0 confirmed, chunk 1 landed-unconfirmed (shed probe).
|
|
558
|
+
const res = await runBackstopDelivery(
|
|
559
|
+
ledger, '#subset', ['c0', 'c1'], null,
|
|
560
|
+
{
|
|
561
|
+
sendChunk: async (i: number) => [1100 + i],
|
|
562
|
+
readBack: async (i: number): Promise<ReadBackResult> => (i === 0 ? 'exists' : 'ambiguous'),
|
|
563
|
+
},
|
|
564
|
+
3,
|
|
565
|
+
)
|
|
566
|
+
expect(res.sentIds).toEqual([1100, 1101])
|
|
567
|
+
expect(res.landedUnconfirmedIds).toEqual([1101]) // c0 is corroborated; c1 is the bet
|
|
568
|
+
expect(res.delivered).toBe(true)
|
|
569
|
+
expect(res.confirmed).toBe(false)
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
it('the count reaches turns.jsonl: a complete turn carries `landed_unconfirmed`', async () => {
|
|
573
|
+
const ledger = new BackstopDeliveryLedger()
|
|
574
|
+
const res = await runBackstopDelivery(
|
|
575
|
+
ledger, '#row', ['c0', 'c1'], null,
|
|
576
|
+
{
|
|
577
|
+
sendChunk: async (i: number) => [1200 + i],
|
|
578
|
+
readBack: async (): Promise<ReadBackResult> => 'ambiguous',
|
|
579
|
+
},
|
|
580
|
+
3,
|
|
581
|
+
)
|
|
582
|
+
const turn = {
|
|
583
|
+
agent: 'test-agent',
|
|
584
|
+
startedAt: 1_000_000,
|
|
585
|
+
toolCallCount: 0,
|
|
586
|
+
turnId: '#row',
|
|
587
|
+
finalAnswerDelivered: true,
|
|
588
|
+
deliveryOutcome: 'delivered' as const,
|
|
589
|
+
landedUnconfirmed: res.landedUnconfirmedIds.length,
|
|
590
|
+
}
|
|
591
|
+
const row = buildTurnRecord(turn, 1_002_000)
|
|
592
|
+
// The status is honest AND the bet is visible on the same row — that pairing
|
|
593
|
+
// is the point: a `complete` we could not corroborate is countable.
|
|
594
|
+
expect(row.status).toBe('complete')
|
|
595
|
+
expect(row.landed_unconfirmed).toBe(2)
|
|
596
|
+
})
|
|
597
|
+
|
|
598
|
+
it('an ordinary confirmed turn omits the field entirely (row shape unchanged)', () => {
|
|
599
|
+
const row = buildTurnRecord(
|
|
600
|
+
{
|
|
601
|
+
agent: 'test-agent',
|
|
602
|
+
startedAt: 1_000_000,
|
|
603
|
+
toolCallCount: 0,
|
|
604
|
+
turnId: '#plain',
|
|
605
|
+
finalAnswerDelivered: true,
|
|
606
|
+
deliveryOutcome: 'delivered',
|
|
607
|
+
landedUnconfirmed: 0,
|
|
608
|
+
},
|
|
609
|
+
1_002_000,
|
|
610
|
+
)
|
|
611
|
+
expect(row.status).toBe('complete')
|
|
612
|
+
expect('landed_unconfirmed' in row).toBe(false)
|
|
613
|
+
})
|
|
417
614
|
})
|
|
@@ -141,4 +141,16 @@ describe('#3278 createBackstopReadBack — full gate+edit+classify wiring', () =
|
|
|
141
141
|
const readBack = createBackstopReadBack(wiring({ gate, editMessageText }))
|
|
142
142
|
expect(await readBack(0, [55], 'answer')).toBe('ambiguous')
|
|
143
143
|
})
|
|
144
|
+
|
|
145
|
+
test('gate NO-OP drop (resolves undefined) ⇒ ambiguous, NOT a false exists', async () => {
|
|
146
|
+
// The gate's edit path drops a repeat of the last payload it actually sent
|
|
147
|
+
// (`counters.dropped`) and drops an expired queue entry, resolving
|
|
148
|
+
// `undefined` in both cases. Nothing reached Telegram, so nothing was
|
|
149
|
+
// proven — classifying it `exists` would fabricate a confirmation from a
|
|
150
|
+
// call that never happened.
|
|
151
|
+
const gate = vi.fn(async () => undefined)
|
|
152
|
+
const editMessageText = vi.fn(async () => true)
|
|
153
|
+
const readBack = createBackstopReadBack(wiring({ gate, editMessageText }))
|
|
154
|
+
expect(await readBack(0, [55], 'answer')).toBe('ambiguous')
|
|
155
|
+
})
|
|
144
156
|
})
|