switchroom 0.18.24 → 0.18.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/switchroom.js +59 -11
- package/dist/host-control/main.js +1 -1
- package/package.json +2 -2
- package/telegram-plugin/dist/bridge/bridge.js +26 -0
- package/telegram-plugin/dist/gateway/gateway.js +1827 -831
- package/telegram-plugin/dist/server.js +26 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +7 -0
- package/telegram-plugin/gateway/gateway.ts +314 -3
- package/telegram-plugin/gateway/model-command.ts +188 -56
- package/telegram-plugin/gateway/redelivery-decision.ts +139 -0
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +42 -1
- package/telegram-plugin/history.ts +118 -0
- package/telegram-plugin/registry/turns-schema.ts +89 -1
- package/telegram-plugin/render/code-segments.ts +210 -0
- package/telegram-plugin/render/dollar-math-guard.ts +126 -0
- package/telegram-plugin/render/emphasis-guard.ts +158 -0
- package/telegram-plugin/render/inline-pairs-guard.ts +171 -0
- package/telegram-plugin/render/line-start-guard.ts +167 -0
- package/telegram-plugin/render/rich-render.ts +7 -0
- package/telegram-plugin/rich-send.ts +48 -2
- package/telegram-plugin/session-tail.ts +185 -0
- package/telegram-plugin/subagent-watcher.ts +45 -0
- package/telegram-plugin/tests/crash-redelivery-resume-exclusion.test.ts +133 -0
- package/telegram-plugin/tests/crash-redelivery-wiring.test.ts +72 -0
- package/telegram-plugin/tests/history.test.ts +91 -0
- package/telegram-plugin/tests/model-command.test.ts +189 -12
- package/telegram-plugin/tests/redelivery-decision.test.ts +84 -0
- package/telegram-plugin/tests/registry-turns.test.ts +51 -0
- package/telegram-plugin/tests/render/dollar-math-guard.test.ts +162 -0
- package/telegram-plugin/tests/render/emphasis-guard.test.ts +205 -0
- package/telegram-plugin/tests/render/guard-composition.test.ts +138 -0
- package/telegram-plugin/tests/render/inline-pairs-guard.test.ts +171 -0
- package/telegram-plugin/tests/render/line-start-guard.test.ts +164 -0
- package/telegram-plugin/tests/session-model-source.test.ts +11 -0
- package/telegram-plugin/tests/session-tail.test.ts +145 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +50 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +109 -0
- package/telegram-plugin/tests/trailing-answer-projector.test.ts +124 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +125 -0
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +306 -0
- package/telegram-plugin/tool-activity-summary.ts +54 -3
- package/telegram-plugin/worker-activity-feed.ts +104 -0
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +762 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +13 -1
- package/vendor/hindsight-memory/scripts/lib/client.py +14 -4
- package/vendor/hindsight-memory/scripts/lib/config.py +8 -0
- package/vendor/hindsight-memory/scripts/lib/pacing.py +102 -0
- package/vendor/hindsight-memory/scripts/lib/watermark.py +213 -0
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +344 -0
- package/vendor/hindsight-memory/scripts/retain.py +299 -143
- package/vendor/hindsight-memory/scripts/session_start.py +14 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill.py +362 -0
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +350 -0
- package/vendor/hindsight-memory/tests/test_hooks.py +8 -2
|
@@ -125,6 +125,19 @@ export interface WorkerEntry {
|
|
|
125
125
|
lastActivityAt: number
|
|
126
126
|
/** Number of tool calls seen so far. */
|
|
127
127
|
toolCount: number
|
|
128
|
+
/**
|
|
129
|
+
* Running TOTAL tokens across every assistant message the worker has emitted
|
|
130
|
+
* (input + output + cache_creation, summed via sumUsageTokens; cache_read is
|
|
131
|
+
* excluded — replayed cached context, not new work).
|
|
132
|
+
* Accumulated from `sub_agent_usage` events, deduped by `seenUsageMessageIds`
|
|
133
|
+
* so the multi-line split-message shape (one logical message persisted as
|
|
134
|
+
* several JSONL lines sharing one `message.id` + identical `usage`) counts
|
|
135
|
+
* once. Rendered on the worker card's metrics line. 0 for a worker that never
|
|
136
|
+
* emitted a usage block (e.g. a non-Claude/litellm transcript).
|
|
137
|
+
*/
|
|
138
|
+
totalTokens: number
|
|
139
|
+
/** message.id set already folded into `totalTokens` (usage dedup). */
|
|
140
|
+
seenUsageMessageIds: Set<string>
|
|
128
141
|
/** True once a stall notification has been sent (suppresses repeat). */
|
|
129
142
|
stallNotified: boolean
|
|
130
143
|
/**
|
|
@@ -557,6 +570,9 @@ export interface SubagentWatcherConfig {
|
|
|
557
570
|
state: WorkerState
|
|
558
571
|
outcome: 'completed' | 'failed' | 'orphan'
|
|
559
572
|
toolCount: number
|
|
573
|
+
/** Final running TOTAL tokens across the worker's whole life
|
|
574
|
+
* (`WorkerEntry.totalTokens`). For the terminal worker-feed card. */
|
|
575
|
+
totalTokens: number
|
|
560
576
|
durationMs: number
|
|
561
577
|
/** Dispatch-time task description, for the handback envelope. */
|
|
562
578
|
description: string
|
|
@@ -615,6 +631,10 @@ export interface SubagentWatcherConfig {
|
|
|
615
631
|
lastTool: { name: string; sanitisedArg: string } | null
|
|
616
632
|
/** Tool-use count observed so far. */
|
|
617
633
|
toolCount: number
|
|
634
|
+
/** Running TOTAL tokens across the worker's assistant messages so far
|
|
635
|
+
* (`WorkerEntry.totalTokens`). Threaded onto the worker/nested card's
|
|
636
|
+
* metrics line. 0 for a worker that has emitted no usage yet / ever. */
|
|
637
|
+
totalTokens: number
|
|
618
638
|
/** Friendly display line for THIS tick. Set on `sub_agent_tool_use`
|
|
619
639
|
* events to a `describeToolUse` label ("Reading X", "Running a
|
|
620
640
|
* command") so a foreground sub-agent that runs tools without
|
|
@@ -1089,6 +1109,8 @@ export function readSubTail(
|
|
|
1089
1109
|
lastTool: { name: string; sanitisedArg: string } | null
|
|
1090
1110
|
/** Tool-use count observed so far. */
|
|
1091
1111
|
toolCount: number
|
|
1112
|
+
/** Running total tokens so far (see SubagentWatcherConfig.onProgress). */
|
|
1113
|
+
totalTokens: number
|
|
1092
1114
|
/** Friendly display line for THIS tick (set on tool ticks; see the
|
|
1093
1115
|
* SubagentWatcherConfig.onProgress doc). */
|
|
1094
1116
|
progressLine?: string
|
|
@@ -1179,6 +1201,7 @@ export function readSubTail(
|
|
|
1179
1201
|
},
|
|
1180
1202
|
lastTool: entry.lastTool,
|
|
1181
1203
|
toolCount: entry.toolCount,
|
|
1204
|
+
totalTokens: entry.totalTokens,
|
|
1182
1205
|
model: entry.currentModel,
|
|
1183
1206
|
skeleton: true,
|
|
1184
1207
|
})
|
|
@@ -1320,6 +1343,7 @@ export function readSubTail(
|
|
|
1320
1343
|
},
|
|
1321
1344
|
lastTool: entry.lastTool,
|
|
1322
1345
|
toolCount: entry.toolCount,
|
|
1346
|
+
totalTokens: entry.totalTokens,
|
|
1323
1347
|
model: entry.currentModel,
|
|
1324
1348
|
})
|
|
1325
1349
|
return true
|
|
@@ -1502,6 +1526,22 @@ export function readSubTail(
|
|
|
1502
1526
|
}
|
|
1503
1527
|
continue
|
|
1504
1528
|
}
|
|
1529
|
+
if (ev.kind === 'sub_agent_usage') {
|
|
1530
|
+
// Accumulate the worker's running total tokens, deduped by
|
|
1531
|
+
// message.id: the ≥2.1.x split-message shape stamps the SAME `usage`
|
|
1532
|
+
// block on every JSONL line of one logical assistant message, so
|
|
1533
|
+
// counting each line would 2-3x over-count. A null messageId is
|
|
1534
|
+
// un-dedupable (older/edge shapes) — count it as-is (its usage is
|
|
1535
|
+
// real). No card render here; the total rides the next onProgress
|
|
1536
|
+
// tick's payload like the model does.
|
|
1537
|
+
if (ev.messageId == null) {
|
|
1538
|
+
entry.totalTokens += ev.totalTokens
|
|
1539
|
+
} else if (!entry.seenUsageMessageIds.has(ev.messageId)) {
|
|
1540
|
+
entry.seenUsageMessageIds.add(ev.messageId)
|
|
1541
|
+
entry.totalTokens += ev.totalTokens
|
|
1542
|
+
}
|
|
1543
|
+
continue
|
|
1544
|
+
}
|
|
1505
1545
|
if (ev.kind === 'sub_agent_tool_use') {
|
|
1506
1546
|
// Narrative-dedup gate step 2: a sub_agent_text block was pending;
|
|
1507
1547
|
// this tool is the lookahead that decides it (SHOW unless it drafts
|
|
@@ -1567,6 +1607,7 @@ export function readSubTail(
|
|
|
1567
1607
|
},
|
|
1568
1608
|
lastTool: entry.lastTool,
|
|
1569
1609
|
toolCount: entry.toolCount,
|
|
1610
|
+
totalTokens: entry.totalTokens,
|
|
1570
1611
|
progressLine: toolLine,
|
|
1571
1612
|
model: entry.currentModel,
|
|
1572
1613
|
})
|
|
@@ -1892,6 +1933,8 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
1892
1933
|
dispatchedAt: n,
|
|
1893
1934
|
lastActivityAt: n,
|
|
1894
1935
|
toolCount: 0,
|
|
1936
|
+
totalTokens: 0,
|
|
1937
|
+
seenUsageMessageIds: new Set<string>(),
|
|
1895
1938
|
stallNotified: false,
|
|
1896
1939
|
stalledAt: null,
|
|
1897
1940
|
completionNotified: false,
|
|
@@ -2157,6 +2200,7 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
2157
2200
|
// registerAgent's short-circuit + Gap 1 promotion).
|
|
2158
2201
|
outcome: entry.errored ? 'failed' : entry.historical ? 'orphan' : 'completed',
|
|
2159
2202
|
toolCount: entry.toolCount,
|
|
2203
|
+
totalTokens: entry.totalTokens,
|
|
2160
2204
|
durationMs: nowFn() - entry.dispatchedAt,
|
|
2161
2205
|
description: entry.description,
|
|
2162
2206
|
// For a failure, fall back to the error detail when the worker
|
|
@@ -2184,6 +2228,7 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
2184
2228
|
state: entry.state,
|
|
2185
2229
|
outcome: 'failed',
|
|
2186
2230
|
toolCount: entry.toolCount,
|
|
2231
|
+
totalTokens: entry.totalTokens,
|
|
2187
2232
|
durationMs: nowFn() - entry.dispatchedAt,
|
|
2188
2233
|
description: entry.description,
|
|
2189
2234
|
resultText: entry.lastResultText,
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { decideRedeliverCapture } from '../gateway/redelivery-decision.js'
|
|
3
|
+
import {
|
|
4
|
+
decideBootResumeKind,
|
|
5
|
+
RESUME_SYNTHETIC_PROMPT_PREFIX,
|
|
6
|
+
} from '../gateway/resume-inbound-builder.js'
|
|
7
|
+
import type { Turn, TurnEndedVia } from '../registry/turns-schema.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Pins the MUTUAL EXCLUSION between crash-survival redelivery and the resume
|
|
11
|
+
* synthetic — the double-send guard. The gateway boot block composes exactly
|
|
12
|
+
* these two pure predicates: `decideBootResumeKind` classifies the interrupted
|
|
13
|
+
* turn, then `decideRedeliverCapture({ willBeResumed: kind === 'resume', ... })`
|
|
14
|
+
* decides whether to ALSO stage a redelivery.
|
|
15
|
+
*
|
|
16
|
+
* The load-bearing outcome: an interrupted turn that WILL be resumed (the model
|
|
17
|
+
* re-runs and emits a fresh answer) must NOT also redeliver its recovered draft
|
|
18
|
+
* — otherwise the same answer reaches the user twice. Conversely, a turn that
|
|
19
|
+
* will NOT be resumed (watchdog report, boot_resume:never suppression,
|
|
20
|
+
* resume-of-a-resume loop-guard) MUST redeliver, because nothing else re-answers.
|
|
21
|
+
*
|
|
22
|
+
* This mirrors the gateway wiring exactly, so it asserts the real composed
|
|
23
|
+
* outcome, not an isolated code path.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const RESUME_MAX_AGE_MS = 10_800_000 // 3h, gateway default
|
|
27
|
+
|
|
28
|
+
function makeTurn(over: Partial<Turn> = {}): Turn {
|
|
29
|
+
return {
|
|
30
|
+
turn_key: '900001:_#7',
|
|
31
|
+
chat_id: '900001',
|
|
32
|
+
thread_id: null,
|
|
33
|
+
started_at: Date.now() - 60_000, // 1 min ago — well within maxAge
|
|
34
|
+
ended_at: null,
|
|
35
|
+
ended_via: null,
|
|
36
|
+
last_assistant_msg_id: null,
|
|
37
|
+
last_assistant_done: null,
|
|
38
|
+
last_user_msg_id: null,
|
|
39
|
+
user_prompt_preview: 'deploy the staging stack',
|
|
40
|
+
assistant_reply_preview: null,
|
|
41
|
+
tool_call_count: 2,
|
|
42
|
+
interrupt_reason: null,
|
|
43
|
+
resumed_at: null,
|
|
44
|
+
session_id: 'sess-abcd', // durably pinned → redelivery is eligible on the floor
|
|
45
|
+
...over,
|
|
46
|
+
} as Turn
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Compose the two predicates exactly as the gateway boot block does. */
|
|
50
|
+
function composeGate(turn: Turn, suppressed: boolean) {
|
|
51
|
+
const bootResumeKind = decideBootResumeKind({
|
|
52
|
+
pending: turn,
|
|
53
|
+
suppressed,
|
|
54
|
+
ageMs: Math.max(0, Date.now() - turn.started_at),
|
|
55
|
+
maxAgeMs: RESUME_MAX_AGE_MS,
|
|
56
|
+
})
|
|
57
|
+
const capture = decideRedeliverCapture({
|
|
58
|
+
willBeResumed: bootResumeKind === 'resume',
|
|
59
|
+
hasSessionId: Boolean(turn.session_id),
|
|
60
|
+
})
|
|
61
|
+
return { bootResumeKind, capture }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe('crash-redelivery ↔ resume mutual exclusion (no double-send)', () => {
|
|
65
|
+
it('(a) an interrupted turn that WILL be resumed does NOT also redeliver', () => {
|
|
66
|
+
// ended_via 'restart' → decideBootResumeKind returns 'resume': the model
|
|
67
|
+
// re-runs and emits a fresh answer that supersedes any recovered draft.
|
|
68
|
+
const turn = makeTurn({ ended_via: 'restart' as TurnEndedVia })
|
|
69
|
+
const { bootResumeKind, capture } = composeGate(turn, /* suppressed */ false)
|
|
70
|
+
expect(bootResumeKind).toBe('resume')
|
|
71
|
+
expect(capture.capture).toBe(false)
|
|
72
|
+
expect(capture.skipReason).toBe('will-be-resumed')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('(a2) a still-open (ended_via=null) killed-mid-flight turn is resumed, not redelivered', () => {
|
|
76
|
+
const turn = makeTurn({ ended_via: null })
|
|
77
|
+
const { bootResumeKind, capture } = composeGate(turn, false)
|
|
78
|
+
expect(bootResumeKind).toBe('resume')
|
|
79
|
+
expect(capture.capture).toBe(false)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('(b) a watchdog-timeout turn (report, no auto re-answer) DOES redeliver', () => {
|
|
83
|
+
// ended_via 'timeout' → 'report': the synthetic only ASKS the user whether
|
|
84
|
+
// to retry — it does not auto-re-answer. Redelivery is the correct recovery.
|
|
85
|
+
const turn = makeTurn({ ended_via: 'timeout' as TurnEndedVia })
|
|
86
|
+
const { bootResumeKind, capture } = composeGate(turn, false)
|
|
87
|
+
expect(bootResumeKind).toBe('report')
|
|
88
|
+
expect(capture.capture).toBe(true)
|
|
89
|
+
expect(capture.skipReason).toBeUndefined()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('(b2) a boot_resume:never-suppressed turn (defer-suppressed) DOES redeliver', () => {
|
|
93
|
+
// suppressed=true → 'defer-suppressed': no synthetic re-run, so redelivery
|
|
94
|
+
// is the ONLY recovery send.
|
|
95
|
+
const turn = makeTurn({ ended_via: 'restart' as TurnEndedVia })
|
|
96
|
+
const { bootResumeKind, capture } = composeGate(turn, /* suppressed */ true)
|
|
97
|
+
expect(bootResumeKind).toBe('defer-suppressed')
|
|
98
|
+
expect(capture.capture).toBe(true)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('(b3) a resume-of-a-resume (loop-guard → defer-loop) turn DOES redeliver', () => {
|
|
102
|
+
// A turn whose prompt is itself a resume synthetic → 'defer-loop': the chain
|
|
103
|
+
// is capped, no re-run happens, so redelivery must still recover the answer.
|
|
104
|
+
const turn = makeTurn({
|
|
105
|
+
ended_via: 'restart' as TurnEndedVia,
|
|
106
|
+
user_prompt_preview: `${RESUME_SYNTHETIC_PROMPT_PREFIX} Continue the interrupted deploy.`,
|
|
107
|
+
})
|
|
108
|
+
const { bootResumeKind, capture } = composeGate(turn, false)
|
|
109
|
+
expect(bootResumeKind).toBe('defer-loop')
|
|
110
|
+
expect(capture.capture).toBe(true)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('(b4) a STALE resume downgraded to report DOES redeliver (not suppressed)', () => {
|
|
114
|
+
// A 'restart' turn older than maxAge downgrades resume→report in
|
|
115
|
+
// selectResumeBuilder. Because the final kind is 'report' (no re-run),
|
|
116
|
+
// redelivery must fire — the gate keys on the FINAL kind, not the raw
|
|
117
|
+
// ended_via.
|
|
118
|
+
const turn = makeTurn({
|
|
119
|
+
ended_via: 'restart' as TurnEndedVia,
|
|
120
|
+
started_at: Date.now() - (RESUME_MAX_AGE_MS + 60_000),
|
|
121
|
+
})
|
|
122
|
+
const { bootResumeKind, capture } = composeGate(turn, false)
|
|
123
|
+
expect(bootResumeKind).toBe('report')
|
|
124
|
+
expect(capture.capture).toBe(true)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('eligibility floor still applies: no session_id → no redelivery even when not resumed', () => {
|
|
128
|
+
const turn = makeTurn({ ended_via: 'timeout' as TurnEndedVia, session_id: null })
|
|
129
|
+
const { capture } = composeGate(turn, false)
|
|
130
|
+
expect(capture.capture).toBe(false)
|
|
131
|
+
expect(capture.skipReason).toBe('no-session-id')
|
|
132
|
+
})
|
|
133
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { projectTrailingAnswerFromTranscript } from '../session-tail.js'
|
|
3
|
+
import { decideRedeliver, REDELIVERY_PREFIX } from '../gateway/redelivery-decision.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Pins the crash-survival redelivery WIRING GLUE — the composition
|
|
7
|
+
* `maybeRedeliverUndeliveredAnswer` performs in the boot send: project the
|
|
8
|
+
* interrupted turn's trailing answer from its transcript, then feed
|
|
9
|
+
* (`text`, `trailingIsText`) plus the durable oracle result into
|
|
10
|
+
* `decideRedeliver`. The oracle (`hasOutboundWithText`) and the projector are
|
|
11
|
+
* unit-tested in their own suites (history.test.ts / trailing-answer-projector
|
|
12
|
+
* .test.ts); this asserts the two ends plumb together — the projected answer is
|
|
13
|
+
* what gets framed, and a dangling mid-tool turn is refused before any send.
|
|
14
|
+
*
|
|
15
|
+
* The raw Telegram socket send and the post-`getMe()` connect sequencing are NOT
|
|
16
|
+
* unit-testable here (they require a connected grammy client); the ordering
|
|
17
|
+
* guarantee is documented on `maybeRedeliverUndeliveredAnswer` and enforced by
|
|
18
|
+
* its single call site inside the `didOneTimeSetup` block, which is unreachable
|
|
19
|
+
* until `bot.api.getMe()` resolves.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const FINAL_ANSWER = 'The deploy finished — all three services are green and healthy.'
|
|
23
|
+
|
|
24
|
+
function compose(transcript: string, hasDeliveredText: boolean) {
|
|
25
|
+
const projected = projectTrailingAnswerFromTranscript(transcript)
|
|
26
|
+
return decideRedeliver({
|
|
27
|
+
capturedText: projected.text,
|
|
28
|
+
trailingIsText: projected.trailingIsText,
|
|
29
|
+
hasDeliveredText,
|
|
30
|
+
alreadyRedelivered: false,
|
|
31
|
+
ageMs: 60_000,
|
|
32
|
+
maxAgeMs: 10_800_000,
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function line(obj: unknown): string {
|
|
37
|
+
return JSON.stringify(obj)
|
|
38
|
+
}
|
|
39
|
+
const completedTurn = [
|
|
40
|
+
line({ type: 'user', message: { role: 'user', content: 'deploy status?' } }),
|
|
41
|
+
line({ type: 'assistant', message: { content: [{ type: 'text', text: 'Checking.' }] } }),
|
|
42
|
+
line({ type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Bash', id: 'toolu_x', input: {} }] } }),
|
|
43
|
+
line({ type: 'assistant', message: { content: [{ type: 'text', text: FINAL_ANSWER }] } }),
|
|
44
|
+
].join('\n')
|
|
45
|
+
const danglingToolTurn = [
|
|
46
|
+
line({ type: 'user', message: { role: 'user', content: 'run the migration' } }),
|
|
47
|
+
line({ type: 'assistant', message: { content: [{ type: 'text', text: 'On it.' }] } }),
|
|
48
|
+
line({ type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Bash', id: 'toolu_y', input: {} }] } }),
|
|
49
|
+
].join('\n')
|
|
50
|
+
|
|
51
|
+
describe('crash-redelivery wiring glue (project → decide)', () => {
|
|
52
|
+
it('frames the PROJECTED trailing answer when the oracle says it was not delivered', () => {
|
|
53
|
+
const d = compose(completedTurn, /* hasDeliveredText */ false)
|
|
54
|
+
expect(d.redeliver).toBe(true)
|
|
55
|
+
expect(d.framedText?.startsWith(REDELIVERY_PREFIX)).toBe(true)
|
|
56
|
+
expect(d.framedText).toContain(FINAL_ANSWER)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('skips when the oracle reports the projected answer was already delivered', () => {
|
|
60
|
+
expect(compose(completedTurn, /* hasDeliveredText */ true).skipReason).toBe('already-delivered')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('refuses a turn killed mid-tool before any send', () => {
|
|
64
|
+
// The projector RESETS the buffer on the trailing tool_use, so the recovered
|
|
65
|
+
// text is empty AND trailingIsText is false — either guard refuses. The
|
|
66
|
+
// decision reports 'empty-text' (checked first); the load-bearing property is
|
|
67
|
+
// simply that no send happens for a dangling mid-tool turn.
|
|
68
|
+
const d = compose(danglingToolTurn, false)
|
|
69
|
+
expect(d.redeliver).toBe(false)
|
|
70
|
+
expect(d.skipReason).toBe('empty-text')
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
getRecentOutboundCount,
|
|
15
15
|
getLatestInboundMessageId,
|
|
16
16
|
hasOutboundDeliveredSince,
|
|
17
|
+
hasOutboundWithText,
|
|
18
|
+
normalizeDeliveryText,
|
|
17
19
|
_resetForTests,
|
|
18
20
|
} from '../history.js'
|
|
19
21
|
|
|
@@ -872,3 +874,92 @@ describe('forwarded-message origin columns', () => {
|
|
|
872
874
|
expect(stored).toContain('Bob') // surrounding name preserved
|
|
873
875
|
})
|
|
874
876
|
})
|
|
877
|
+
|
|
878
|
+
// ---------------------------------------------------------------------------
|
|
879
|
+
// hasOutboundWithText — durable text-identity delivery oracle (crash-survival
|
|
880
|
+
// redelivery). Keys on the ANSWER TEXT, not a chat+time window, so an interim
|
|
881
|
+
// progress_update earlier in the same turn does NOT false-positive "delivered".
|
|
882
|
+
// ---------------------------------------------------------------------------
|
|
883
|
+
|
|
884
|
+
describe('hasOutboundWithText (durable text-identity oracle)', () => {
|
|
885
|
+
it('does NOT match an interim progress message against the (undelivered) final answer', () => {
|
|
886
|
+
initHistory(stateDir, 30)
|
|
887
|
+
// The turn sent only an interim progress_update; the real final answer was
|
|
888
|
+
// lost in the crash and never recorded. The time-windowed oracle would say
|
|
889
|
+
// "delivered" off the progress row — the text-identity oracle must not.
|
|
890
|
+
recordOutbound({
|
|
891
|
+
chat_id: '1', thread_id: null, message_ids: [10],
|
|
892
|
+
texts: ['on it — pulling yesterday’s GitHub activity'], ts: 200,
|
|
893
|
+
})
|
|
894
|
+
const finalAnswer = 'The deploy finished — all three services are green.'
|
|
895
|
+
expect(hasOutboundWithText('1', finalAnswer, null)).toBe(false)
|
|
896
|
+
// sanity: the coarse time-window oracle DOES false-positive here (this is
|
|
897
|
+
// exactly why we cannot use it as the redelivery gate).
|
|
898
|
+
expect(hasOutboundDeliveredSince('1', 100 * 1000, null, 1)).toBe(true)
|
|
899
|
+
})
|
|
900
|
+
|
|
901
|
+
it('matches when the final answer text was actually delivered', () => {
|
|
902
|
+
initHistory(stateDir, 30)
|
|
903
|
+
const finalAnswer = 'The deploy finished — all three services are green.'
|
|
904
|
+
recordOutbound({ chat_id: '1', thread_id: null, message_ids: [11], texts: [finalAnswer], ts: 300 })
|
|
905
|
+
expect(hasOutboundWithText('1', finalAnswer, null)).toBe(true)
|
|
906
|
+
})
|
|
907
|
+
|
|
908
|
+
it('matches a delivered chunk-1 against a longer projected answer (multi-chunk, no double-send)', () => {
|
|
909
|
+
initHistory(stateDir, 30)
|
|
910
|
+
const chunk1 = 'Part one of a long answer that was split across chunks.'
|
|
911
|
+
recordOutbound({ chat_id: '1', thread_id: null, message_ids: [12], texts: [chunk1], ts: 300 })
|
|
912
|
+
// The re-projected full answer starts with chunk-1 → treated as delivered.
|
|
913
|
+
expect(hasOutboundWithText('1', chunk1 + ' Part two continues here.', null)).toBe(true)
|
|
914
|
+
})
|
|
915
|
+
|
|
916
|
+
it('ignores whitespace/spacer differences via normalization', () => {
|
|
917
|
+
initHistory(stateDir, 30)
|
|
918
|
+
recordOutbound({ chat_id: '1', thread_id: null, message_ids: [13], texts: ['hello world'], ts: 300 })
|
|
919
|
+
expect(hasOutboundWithText('1', 'hello world', null)).toBe(true)
|
|
920
|
+
expect(normalizeDeliveryText('hello world')).toBe('hello world')
|
|
921
|
+
})
|
|
922
|
+
|
|
923
|
+
it('empty/whitespace text never matches', () => {
|
|
924
|
+
initHistory(stateDir, 30)
|
|
925
|
+
recordOutbound({ chat_id: '1', thread_id: null, message_ids: [14], texts: ['real'], ts: 300 })
|
|
926
|
+
expect(hasOutboundWithText('1', ' ', null)).toBe(false)
|
|
927
|
+
})
|
|
928
|
+
|
|
929
|
+
it('scopes by chat (a different chat does not satisfy the match)', () => {
|
|
930
|
+
initHistory(stateDir, 30)
|
|
931
|
+
recordOutbound({ chat_id: '1', thread_id: null, message_ids: [15], texts: ['scoped answer'], ts: 300 })
|
|
932
|
+
expect(hasOutboundWithText('2', 'scoped answer', null)).toBe(false)
|
|
933
|
+
expect(hasOutboundWithText('1', 'scoped answer', null)).toBe(true)
|
|
934
|
+
})
|
|
935
|
+
|
|
936
|
+
// diff-review defect #1 — a SHORT final answer must not false-positive-match an
|
|
937
|
+
// unrelated earlier row via the bidirectional-prefix rule (that would suppress a
|
|
938
|
+
// genuine redelivery = permanent silence). Short texts require full equality.
|
|
939
|
+
it('does NOT suppress a short answer that merely shares a prefix with an unrelated row', () => {
|
|
940
|
+
initHistory(stateDir, 30)
|
|
941
|
+
// An earlier turn delivered a longer line that starts with the short answer.
|
|
942
|
+
recordOutbound({ chat_id: '1', thread_id: null, message_ids: [20], texts: ['Done, deploying now.'], ts: 300 })
|
|
943
|
+
// The interrupted turn's real final answer was the short "Done." — never sent.
|
|
944
|
+
expect(hasOutboundWithText('1', 'Done.', null)).toBe(false)
|
|
945
|
+
})
|
|
946
|
+
|
|
947
|
+
it('still suppresses a short answer that was genuinely delivered (exact match)', () => {
|
|
948
|
+
initHistory(stateDir, 30)
|
|
949
|
+
recordOutbound({ chat_id: '1', thread_id: null, message_ids: [21], texts: ['Done.'], ts: 300 })
|
|
950
|
+
expect(hasOutboundWithText('1', 'Done.', null)).toBe(true)
|
|
951
|
+
})
|
|
952
|
+
|
|
953
|
+
// sinceMs scope: only rows delivered at/after the interrupted turn's started_at
|
|
954
|
+
// count, so an unrelated PRIOR turn's identical text can never suppress.
|
|
955
|
+
it('scopes by sinceMs (a prior-turn row before the floor does not match)', () => {
|
|
956
|
+
initHistory(stateDir, 30)
|
|
957
|
+
// Prior turn delivered this exact text at ts=200s.
|
|
958
|
+
recordOutbound({ chat_id: '1', thread_id: null, message_ids: [22], texts: ['repeated answer'], ts: 200 })
|
|
959
|
+
// Interrupted turn started at 250s (250_000 ms) — the prior row is out of scope.
|
|
960
|
+
expect(hasOutboundWithText('1', 'repeated answer', null, 250_000)).toBe(false)
|
|
961
|
+
// A row delivered within the turn window (ts=300s) does match.
|
|
962
|
+
recordOutbound({ chat_id: '1', thread_id: null, message_ids: [23], texts: ['repeated answer'], ts: 300 })
|
|
963
|
+
expect(hasOutboundWithText('1', 'repeated answer', null, 250_000)).toBe(true)
|
|
964
|
+
})
|
|
965
|
+
})
|