switchroom 0.18.23 → 0.18.25
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 +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +26 -0
- package/telegram-plugin/dist/gateway/gateway.js +1608 -841
- package/telegram-plugin/dist/server.js +26 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +7 -0
- package/telegram-plugin/gateway/gateway.ts +524 -16
- 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/reply-owner-resolve.ts +160 -0
- 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/reply-owner-resolve.test.ts +279 -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-coalesce.test.ts +117 -1
- 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 +222 -10
- 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
|
@@ -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
|
+
})
|
|
@@ -200,6 +200,91 @@ describe("handleModelCommand — show / help never inject (picker-wedge guard)",
|
|
|
200
200
|
});
|
|
201
201
|
});
|
|
202
202
|
|
|
203
|
+
describe("handleModelCommand — set — #3241 poll-until-signal wiring", () => {
|
|
204
|
+
it("forwards successPattern + errorPattern + settleBeforeSendMs to the inject primitive", async () => {
|
|
205
|
+
const seen: Array<{ command: string; opts: unknown }> = [];
|
|
206
|
+
const { deps } = makeDeps({
|
|
207
|
+
inject: async (_agent, command, opts) => {
|
|
208
|
+
seen.push({ command, opts });
|
|
209
|
+
return okResult("⏺ Set model to Sonnet 5 for this session");
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
await handleModelCommand({ kind: "set", model: "sonnet" }, deps);
|
|
213
|
+
expect(seen).toHaveLength(1);
|
|
214
|
+
const opts = seen[0].opts as {
|
|
215
|
+
successPattern?: RegExp;
|
|
216
|
+
errorPattern?: RegExp;
|
|
217
|
+
settleBeforeSendMs?: number;
|
|
218
|
+
};
|
|
219
|
+
// A success pattern that matches claude's confirmation line, an error pattern
|
|
220
|
+
// that matches the "not found" line, and a clean-prompt pre-send wait.
|
|
221
|
+
expect(opts.successPattern?.test("⏺ Set model to Sonnet 5")).toBe(true);
|
|
222
|
+
expect(opts.errorPattern?.test("⎿ Model 'x' not found")).toBe(true);
|
|
223
|
+
expect(typeof opts.settleBeforeSendMs).toBe("number");
|
|
224
|
+
expect(opts.settleBeforeSendMs).toBeGreaterThan(0);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("confirmation that lands after a banner → records the live model for /status", async () => {
|
|
228
|
+
// The inject primitive (poll-until-signal) is responsible for returning the
|
|
229
|
+
// confirmation and not the banner; here the handler receives that confirmation
|
|
230
|
+
// and must record it as the session override.
|
|
231
|
+
const { deps } = makeDeps({
|
|
232
|
+
inject: async () =>
|
|
233
|
+
okResult("⠋ extending Claude Fable 5 access…\n⎿ Set model to Fable 5 for this session"),
|
|
234
|
+
});
|
|
235
|
+
const reply = await handleModelCommand({ kind: "set", model: "fable" }, deps);
|
|
236
|
+
expect(reply.selectedModel).toBe("Fable 5");
|
|
237
|
+
expect(reply.optimistic).toBeUndefined();
|
|
238
|
+
expect(reply.text).toContain("Set model to Fable 5");
|
|
239
|
+
// The banner is scrollback and must not leak as prose above the confirmation.
|
|
240
|
+
expect(reply.text).not.toContain("extending Claude Fable 5 access");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("scraped error line → failure verdict AND no override recorded (retract)", async () => {
|
|
244
|
+
const { deps } = makeDeps({
|
|
245
|
+
inject: async () => okResult("⎿ Model 'claude-bogus-99' not found"),
|
|
246
|
+
});
|
|
247
|
+
const reply = await handleModelCommand({ kind: "set", model: "claude-bogus-99" }, deps);
|
|
248
|
+
expect(reply.text).toContain("did not take");
|
|
249
|
+
expect(reply.selectedModel).toBeUndefined();
|
|
250
|
+
expect(reply.optimistic).toBeUndefined();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// #3242 review MEDIUM 1 — an access/entitlement denial must NOT be recorded
|
|
254
|
+
// optimistically. Each of these lines matches neither the confirmation prefix
|
|
255
|
+
// nor the OLD bad-id error regex, so before the widen they slipped into the
|
|
256
|
+
// optimistic branch and falsely recorded the switch.
|
|
257
|
+
for (const denial of [
|
|
258
|
+
"⎿ Fable is not available on your plan",
|
|
259
|
+
"⎿ access denied",
|
|
260
|
+
"⎿ Fable requires a Pro subscription",
|
|
261
|
+
"⎿ This model is not enabled for your account",
|
|
262
|
+
"⎿ No access to Fable on this tier",
|
|
263
|
+
"⎿ Fable 5 is currently unavailable",
|
|
264
|
+
]) {
|
|
265
|
+
it(`access-denial line → failure verdict AND no override (retract): ${JSON.stringify(denial)}`, async () => {
|
|
266
|
+
const { deps } = makeDeps({ inject: async () => okResult(denial) });
|
|
267
|
+
const reply = await handleModelCommand({ kind: "set", model: "fable" }, deps);
|
|
268
|
+
expect(reply.text).toContain("did not take");
|
|
269
|
+
expect(reply.selectedModel).toBeUndefined();
|
|
270
|
+
expect(reply.optimistic).toBeUndefined();
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
it("a genuine confirmation is NEVER flipped to a failure by the widened denial regex", async () => {
|
|
275
|
+
// Confirmation-first ordering: even if scrollback in the same region says
|
|
276
|
+
// something "unavailable", a real "Set model to …" line wins.
|
|
277
|
+
const mixed = [
|
|
278
|
+
"the metrics endpoint was unavailable earlier",
|
|
279
|
+
"⎿ Set model to Fable 5 for this session",
|
|
280
|
+
].join("\n");
|
|
281
|
+
const { deps } = makeDeps({ inject: async () => okResult(mixed) });
|
|
282
|
+
const reply = await handleModelCommand({ kind: "set", model: "fable" }, deps);
|
|
283
|
+
expect(reply.text).not.toContain("did not take");
|
|
284
|
+
expect(reply.selectedModel).toBe("Fable 5");
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
203
288
|
describe("handleModelCommand — set", () => {
|
|
204
289
|
it("injects exactly `/model <name>` once and relays a genuine confirmation + persistence note", async () => {
|
|
205
290
|
const { deps, calls } = makeDeps();
|
|
@@ -228,13 +313,17 @@ describe("handleModelCommand — set", () => {
|
|
|
228
313
|
expect(reply.text).not.toContain("summary you asked for");
|
|
229
314
|
expect(reply.text).not.toContain("rollback plan");
|
|
230
315
|
expect(reply.text).not.toContain("<pre>");
|
|
231
|
-
//
|
|
232
|
-
//
|
|
316
|
+
// #3241 part B — poll-until-signal already waited the full window, so a
|
|
317
|
+
// missing confirmation line with NO scraped error is a SILENT switch, not a
|
|
318
|
+
// failure. Record the requested model optimistically so /status is right,
|
|
319
|
+
// and say so honestly (no false "switched (session)", no scrollback leak).
|
|
233
320
|
expect(reply.text).toContain("/model fable");
|
|
234
|
-
expect(reply.text).toContain("couldn't
|
|
321
|
+
expect(reply.text).toContain("couldn't read a confirmation");
|
|
235
322
|
expect(reply.text).toContain("/status");
|
|
323
|
+
expect(reply.text).not.toContain("Recorded");
|
|
236
324
|
expect(reply.text).not.toContain("switched (session)");
|
|
237
|
-
expect(reply.selectedModel).
|
|
325
|
+
expect(reply.selectedModel).toBe("Fable");
|
|
326
|
+
expect(reply.optimistic).toBe(true);
|
|
238
327
|
expect(reply.html).toBe(true);
|
|
239
328
|
});
|
|
240
329
|
|
|
@@ -260,15 +349,17 @@ describe("handleModelCommand — set", () => {
|
|
|
260
349
|
const { deps } = makeDeps({ inject: async () => okResult(prose) });
|
|
261
350
|
const reply = await handleModelCommand({ kind: "set", model: "fable" }, deps);
|
|
262
351
|
// The anchored regex rejects all three lines, so nothing leaks and there is
|
|
263
|
-
// no <pre> block.
|
|
264
|
-
//
|
|
352
|
+
// no <pre> block. No confirmation AND no scraped error → #3241 optimistic
|
|
353
|
+
// record: the switch is reported as sent + recorded, never a false
|
|
354
|
+
// "switched", and no scrollback prose leaks.
|
|
265
355
|
expect(reply.text).not.toContain("<pre>");
|
|
266
356
|
expect(reply.text).not.toContain("switched the deploy");
|
|
267
357
|
expect(reply.text).not.toContain("set model behaviour");
|
|
268
358
|
expect(reply.text).not.toContain("kept model changes");
|
|
269
|
-
expect(reply.text).toContain("couldn't
|
|
359
|
+
expect(reply.text).toContain("couldn't read a confirmation");
|
|
270
360
|
expect(reply.text).not.toContain("switched (session)");
|
|
271
|
-
expect(reply.selectedModel).
|
|
361
|
+
expect(reply.selectedModel).toBe("Fable");
|
|
362
|
+
expect(reply.optimistic).toBe(true);
|
|
272
363
|
expect(reply.html).toBe(true);
|
|
273
364
|
});
|
|
274
365
|
|
|
@@ -290,7 +381,13 @@ describe("handleModelCommand — set", () => {
|
|
|
290
381
|
}),
|
|
291
382
|
});
|
|
292
383
|
const reply = await handleModelCommand({ kind: "set", model: "sonnet" }, deps);
|
|
293
|
-
|
|
384
|
+
// #3241 part B — an empty capture can't carry an error line, so the send is
|
|
385
|
+
// treated as a silent switch and the requested model is recorded
|
|
386
|
+
// optimistically (was: "no response captured", recorded nothing).
|
|
387
|
+
expect(reply.text).toContain("couldn't read a confirmation");
|
|
388
|
+
expect(reply.text).toContain("/status");
|
|
389
|
+
expect(reply.selectedModel).toBe("Sonnet");
|
|
390
|
+
expect(reply.optimistic).toBe(true);
|
|
294
391
|
});
|
|
295
392
|
|
|
296
393
|
it("session_missing failure surfaces the tmux-supervisor hint", async () => {
|
|
@@ -475,9 +572,12 @@ describe("handleModelCommand — busy gate + honest unverified reporting", () =>
|
|
|
475
572
|
const reply = await handleModelCommand({ kind: "set", model: "opus" }, deps);
|
|
476
573
|
expect(reply.text).not.toContain("did not take");
|
|
477
574
|
expect(reply.text).not.toContain("model not found");
|
|
478
|
-
// No confirmation
|
|
479
|
-
|
|
480
|
-
|
|
575
|
+
// No LINE-ANCHORED error and no confirmation → #3241 optimistic record: the
|
|
576
|
+
// mid-sentence "model not found" prose does NOT flip the switch to a failure,
|
|
577
|
+
// and the requested model is recorded (was: "couldn't confirm", nothing).
|
|
578
|
+
expect(reply.text).toContain("couldn't read a confirmation");
|
|
579
|
+
expect(reply.selectedModel).toBe("Opus");
|
|
580
|
+
expect(reply.optimistic).toBe(true);
|
|
481
581
|
});
|
|
482
582
|
|
|
483
583
|
it("recognises claude v2.1.205's real arg-form confirmation (⎿ glyph + 'and saved as your default')", async () => {
|
|
@@ -713,6 +813,7 @@ import {
|
|
|
713
813
|
EXTRA_CLAUDE_ALIASES,
|
|
714
814
|
externalModelNames,
|
|
715
815
|
isSrToClaudeTransition,
|
|
816
|
+
optimisticModelRecordLabel,
|
|
716
817
|
type ModelMenuDeps,
|
|
717
818
|
} from "../gateway/model-command.js";
|
|
718
819
|
import { labelTag } from "../../src/agents/model-picker.js";
|
|
@@ -909,6 +1010,62 @@ describe("handleModelMenuCallback", () => {
|
|
|
909
1010
|
});
|
|
910
1011
|
});
|
|
911
1012
|
|
|
1013
|
+
// #3242 review MEDIUM 2 — the alias BUTTON (mdl:alias:<alias>, e.g. the Fable
|
|
1014
|
+
// button) must be symmetric with the typed set path: record-on-send / retract-
|
|
1015
|
+
// on-scraped-error, handling BOTH ok and ok_no_output. Before the fix a silent
|
|
1016
|
+
// successful button-switch (ok_no_output, or ok with no confirmation line) fell
|
|
1017
|
+
// through to "Switch failed" and dropped the override.
|
|
1018
|
+
describe("handleModelMenuCallback — alias button symmetry (#3242 MEDIUM 2)", () => {
|
|
1019
|
+
it("ok_no_output (silent switch via the button) → optimistic record, not a failure", async () => {
|
|
1020
|
+
const { deps } = makeMenuDeps({
|
|
1021
|
+
inject: async () => ({
|
|
1022
|
+
outcome: "ok_no_output" as const,
|
|
1023
|
+
output: "",
|
|
1024
|
+
truncated: false,
|
|
1025
|
+
command: "/model",
|
|
1026
|
+
meta: { description: "Open model picker", expectsOutput: true },
|
|
1027
|
+
}),
|
|
1028
|
+
});
|
|
1029
|
+
const out = await handleModelMenuCallback(`${MODEL_CALLBACK_ALIAS}fable`, deps);
|
|
1030
|
+
expect(out.selectedModel).toBe("Fable"); // normalized display form
|
|
1031
|
+
expect(out.selectedModelToken).toBe("fable");
|
|
1032
|
+
// Provisional copy (#3242 FIX 2): doesn't assert the switch succeeded, points
|
|
1033
|
+
// at /status, and never reads as a failure.
|
|
1034
|
+
expect(out.reply.text).not.toContain("failed");
|
|
1035
|
+
expect(out.reply.text).toContain("/status");
|
|
1036
|
+
expect(out.reply.text).toContain("/model fable");
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
it("ok with no confirmation line (banner-only) → optimistic record", async () => {
|
|
1040
|
+
const { deps } = makeMenuDeps({
|
|
1041
|
+
inject: async () => okResult("⠋ extending Claude Fable 5 access…"),
|
|
1042
|
+
});
|
|
1043
|
+
const out = await handleModelMenuCallback(`${MODEL_CALLBACK_ALIAS}fable`, deps);
|
|
1044
|
+
expect(out.selectedModel).toBe("Fable");
|
|
1045
|
+
expect(out.selectedModelToken).toBe("fable");
|
|
1046
|
+
// The banner must not leak as the confirmation.
|
|
1047
|
+
expect(out.reply.text).not.toContain("extending Claude Fable 5 access");
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
it("scraped access-denial line via the button → failure, no override (retract)", async () => {
|
|
1051
|
+
const { deps } = makeMenuDeps({
|
|
1052
|
+
inject: async () => okResult("⎿ Fable is not available on your plan"),
|
|
1053
|
+
});
|
|
1054
|
+
const out = await handleModelMenuCallback(`${MODEL_CALLBACK_ALIAS}fable`, deps);
|
|
1055
|
+
expect(out.selectedModel).toBeUndefined();
|
|
1056
|
+
expect(out.reply.text).toContain("did not take");
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
it("genuine confirmation via the button still records the display name", async () => {
|
|
1060
|
+
const { deps } = makeMenuDeps({
|
|
1061
|
+
inject: async () => okResult("⎿ Set model to Fable 5 for this session"),
|
|
1062
|
+
});
|
|
1063
|
+
const out = await handleModelMenuCallback(`${MODEL_CALLBACK_ALIAS}fable`, deps);
|
|
1064
|
+
expect(out.selectedModel).toBe("Fable 5");
|
|
1065
|
+
expect(out.selectedModelToken).toBe("fable");
|
|
1066
|
+
});
|
|
1067
|
+
});
|
|
1068
|
+
|
|
912
1069
|
describe("sessionModelFromConfirmation", () => {
|
|
913
1070
|
it("pulls the model name from claude's session-switch confirmation", () => {
|
|
914
1071
|
expect(sessionModelFromConfirmation("Set model to Fable 5 for this session only")).toBe("Fable 5");
|
|
@@ -1170,6 +1327,26 @@ describe("isSrToClaudeTransition", () => {
|
|
|
1170
1327
|
expect(isSrToClaudeTransition("sr-gemini-2.5-pro", "sr-deepseek-r1")).toBe(false);
|
|
1171
1328
|
});
|
|
1172
1329
|
|
|
1330
|
+
// #3242 review FIX 1 — optimisticModelRecordLabel must NOT de-prefix an sr-*
|
|
1331
|
+
// token: the stored selectedModel doubles as the sr-*→Claude sentinel that
|
|
1332
|
+
// isSrToClaudeTransition (prevModel.startsWith('sr-')) reads. De-prefixing to
|
|
1333
|
+
// a friendly label ("kimi k2") would silently kill the graceful restart that
|
|
1334
|
+
// tears down LiteLLM routing on a subsequent Claude switch.
|
|
1335
|
+
it("optimisticModelRecordLabel keeps sr-* tokens verbatim so the sentinel survives", () => {
|
|
1336
|
+
const recorded = optimisticModelRecordLabel("sr-kimi-k2");
|
|
1337
|
+
expect(recorded).toBe("sr-kimi-k2"); // NOT "kimi k2"
|
|
1338
|
+
// The recorded value still trips the sr→Claude transition on a later switch.
|
|
1339
|
+
expect(isSrToClaudeTransition(recorded, "opus")).toBe(true);
|
|
1340
|
+
// Regression guard: the de-prefixed form would NOT — that was the bug.
|
|
1341
|
+
expect(isSrToClaudeTransition("kimi k2", "opus")).toBe(false);
|
|
1342
|
+
});
|
|
1343
|
+
|
|
1344
|
+
it("optimisticModelRecordLabel Title-cases a bare Claude alias, leaves full ids as-is", () => {
|
|
1345
|
+
expect(optimisticModelRecordLabel("fable")).toBe("Fable");
|
|
1346
|
+
expect(optimisticModelRecordLabel("opus")).toBe("Opus");
|
|
1347
|
+
expect(optimisticModelRecordLabel("claude-opus-4-8")).toBe("claude-opus-4-8");
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1173
1350
|
it("false when switching to sr-* from Claude (Claude → sr-*)", () => {
|
|
1174
1351
|
expect(isSrToClaudeTransition("Sonnet", "sr-gemini-2.5-pro")).toBe(false);
|
|
1175
1352
|
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
decideRedeliver,
|
|
4
|
+
frameRedelivery,
|
|
5
|
+
REDELIVERY_PREFIX,
|
|
6
|
+
type RedeliverDecisionInput,
|
|
7
|
+
} from '../gateway/redelivery-decision.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Pins the crash-survival redelivery decision predicate. The load-bearing
|
|
11
|
+
* property is the DURABLE TEXT-IDENTITY oracle: an interim `progress_update`
|
|
12
|
+
* earlier in the same turn must NOT suppress a genuinely-undelivered final
|
|
13
|
+
* answer (the exact MISS = permanent-silence bug this fix closes), while an
|
|
14
|
+
* already-delivered final answer MUST be suppressed.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const base: RedeliverDecisionInput = {
|
|
18
|
+
capturedText: 'The deploy finished — all three services are green.',
|
|
19
|
+
trailingIsText: true,
|
|
20
|
+
hasDeliveredText: false,
|
|
21
|
+
alreadyRedelivered: false,
|
|
22
|
+
ageMs: 60_000,
|
|
23
|
+
maxAgeMs: 10_800_000, // 3h
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('decideRedeliver — text-identity oracle', () => {
|
|
27
|
+
it('REDELIVERS an undelivered final answer even when an interim message was sent (the MISS bug)', () => {
|
|
28
|
+
// The MISS scenario: a progress_update landed a role=assistant row earlier
|
|
29
|
+
// in the turn, so a chat+time-window oracle would say "delivered" and skip.
|
|
30
|
+
// The text-identity oracle keys on the ANSWER text — which was never sent —
|
|
31
|
+
// so hasDeliveredText is false and redelivery fires.
|
|
32
|
+
const d = decideRedeliver({ ...base, hasDeliveredText: false })
|
|
33
|
+
expect(d.redeliver).toBe(true)
|
|
34
|
+
expect(d.framedText).toContain(base.capturedText)
|
|
35
|
+
expect(d.framedText?.startsWith(REDELIVERY_PREFIX)).toBe(true)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('SUPPRESSES when the final answer text was already delivered', () => {
|
|
39
|
+
const d = decideRedeliver({ ...base, hasDeliveredText: true })
|
|
40
|
+
expect(d.redeliver).toBe(false)
|
|
41
|
+
expect(d.skipReason).toBe('already-delivered')
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('decideRedeliver — other skip reasons', () => {
|
|
46
|
+
it('skips empty / whitespace text', () => {
|
|
47
|
+
expect(decideRedeliver({ ...base, capturedText: ' ' }).skipReason).toBe('empty-text')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('skips when trailing content is a dangling tool_use (mid-stream, not an answer)', () => {
|
|
51
|
+
expect(decideRedeliver({ ...base, trailingIsText: false }).skipReason).toBe('trailing-not-text')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('skips an already-redelivered turn (at-most-once ledger)', () => {
|
|
55
|
+
expect(decideRedeliver({ ...base, alreadyRedelivered: true }).skipReason).toBe('already-redelivered')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('skips a stale turn beyond maxAgeMs', () => {
|
|
59
|
+
expect(decideRedeliver({ ...base, ageMs: 4 * 3_600_000 }).skipReason).toBe('stale')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('precedence: empty-text wins over delivered/redelivered/stale', () => {
|
|
63
|
+
const d = decideRedeliver({
|
|
64
|
+
...base,
|
|
65
|
+
capturedText: '',
|
|
66
|
+
hasDeliveredText: true,
|
|
67
|
+
alreadyRedelivered: true,
|
|
68
|
+
ageMs: 999_999_999,
|
|
69
|
+
})
|
|
70
|
+
expect(d.skipReason).toBe('empty-text')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('precedence: already-redelivered is checked before the age failsafe', () => {
|
|
74
|
+
const d = decideRedeliver({ ...base, alreadyRedelivered: true, ageMs: 999_999_999 })
|
|
75
|
+
expect(d.skipReason).toBe('already-redelivered')
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('frameRedelivery', () => {
|
|
80
|
+
it('frames as a recovered draft, never a clean final answer', () => {
|
|
81
|
+
const framed = frameRedelivery(' hello world ')
|
|
82
|
+
expect(framed).toBe(`${REDELIVERY_PREFIX}\n\nhello world`)
|
|
83
|
+
})
|
|
84
|
+
})
|