switchroom 0.18.7 → 0.18.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/cli/switchroom.js +905 -758
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +111 -34
- package/skills/switchroom-runtime/SKILL.md +2 -0
- package/telegram-plugin/dist/gateway/gateway.js +46273 -44324
- package/telegram-plugin/flood-circuit-breaker.ts +123 -0
- package/telegram-plugin/gateway/activity-card-store.ts +63 -18
- package/telegram-plugin/gateway/approval-card-stores.ts +99 -0
- package/telegram-plugin/gateway/boot-card.ts +27 -0
- package/telegram-plugin/gateway/bot-commands-ops-info.ts +194 -0
- package/telegram-plugin/gateway/busy-ack.ts +106 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +2660 -0
- package/telegram-plugin/gateway/gateway.ts +1169 -3043
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +181 -23
- package/telegram-plugin/gateway/inbound-delivery-machine.ts +8 -0
- package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
- package/telegram-plugin/gateway/model-command.ts +23 -11
- package/telegram-plugin/gateway/outbound-send-path.ts +375 -0
- package/telegram-plugin/gateway/pending-state-stores.ts +106 -0
- package/telegram-plugin/gateway/register-bot-commands.ts +30 -0
- package/telegram-plugin/gateway/session-model-file.ts +198 -0
- package/telegram-plugin/gateway/status-pin-store.ts +82 -22
- package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
- package/telegram-plugin/hooks/hooks.json +10 -10
- package/telegram-plugin/hooks/run-hook.sh +84 -0
- package/telegram-plugin/model-unavailable.ts +26 -0
- package/telegram-plugin/pty-partial-handler.ts +39 -0
- package/telegram-plugin/render/rich-render.ts +79 -1
- package/telegram-plugin/retry-api-call.ts +62 -0
- package/telegram-plugin/shared/bot-runtime.ts +8 -1
- package/telegram-plugin/silence-poke.ts +14 -0
- package/telegram-plugin/stream-controller.ts +156 -38
- package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
- package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
- package/telegram-plugin/tests/approval-card-stores.test.ts +124 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
- package/telegram-plugin/tests/busy-ack.test.ts +121 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +701 -0
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +11 -4
- package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +75 -0
- package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +5 -1
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +177 -25
- package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +418 -0
- package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +348 -0
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +141 -52
- package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -1
- package/telegram-plugin/tests/model-command.test.ts +2 -2
- package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
- package/telegram-plugin/tests/outbound-send-chunks.test.ts +304 -0
- package/telegram-plugin/tests/outbound-send-path.test.ts +222 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +34 -15
- package/telegram-plugin/tests/pending-state-stores.test.ts +235 -0
- package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
- package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
- package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
- package/telegram-plugin/tests/session-model-file.test.ts +132 -0
- package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
- package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +18 -4
- package/telegram-plugin/tests/vault-approval-posture.test.ts +15 -7
- package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +8 -4
- package/telegram-plugin/tests/vault-grant-union.test.ts +8 -4
- package/telegram-plugin/tests/vault-grant-wizard.test.ts +8 -1
- package/telegram-plugin/tests/vault-grants-revoke.test.ts +8 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +8 -4
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +8 -4
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +8 -4
- package/telegram-plugin/tests/voice-send.test.ts +308 -0
- package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
- package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
- package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
- package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
- package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
- package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
- package/telegram-plugin/voice-ondemand.ts +25 -1
- package/telegram-plugin/voice-send.ts +154 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
repairEscapedWhitespace,
|
|
4
|
+
normalizeParagraphBreaks,
|
|
5
|
+
normalizePunctuation,
|
|
6
|
+
stripExcessBold,
|
|
7
|
+
addParagraphSpacers,
|
|
8
|
+
splitMarkdownChunks,
|
|
9
|
+
hardSliceToCap,
|
|
10
|
+
RICH_MESSAGE_MAX_CHARS,
|
|
11
|
+
} from '../format.js'
|
|
12
|
+
import { scrubVoice } from '../text-voice-scrub.js'
|
|
13
|
+
import { redact } from '../secret-detect/redact.js'
|
|
14
|
+
import { OutboundDedupCache } from '../recent-outbound-dedup.js'
|
|
15
|
+
import {
|
|
16
|
+
normalizeOutboundBody,
|
|
17
|
+
computeEffectiveText,
|
|
18
|
+
computeReplyChunks,
|
|
19
|
+
resplitOversizeChunk,
|
|
20
|
+
chunkText,
|
|
21
|
+
} from '../gateway/outbound-send-path.js'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Golden-transcript harness for the outbound send-path extraction (#2996,
|
|
25
|
+
* plan §3B).
|
|
26
|
+
*
|
|
27
|
+
* `executeReply` is not exported (same constraint the sibling
|
|
28
|
+
* gateway-outbound-redact.test.ts documents), so the golden reference is a
|
|
29
|
+
* VERBATIM inline copy of the pre-extraction pipeline transforms (below).
|
|
30
|
+
* Every fixture is run through BOTH the inline reference and the extracted
|
|
31
|
+
* `outbound-send-path.ts` module; the test asserts byte-identical output.
|
|
32
|
+
* If the extraction ever drifts from the inline pipeline, these diverge.
|
|
33
|
+
*
|
|
34
|
+
* The reference below is copied line-for-line from the executeReply entry
|
|
35
|
+
* (normalize → redact → punctuation/bold → voice scrub), the effective-text
|
|
36
|
+
* spacing decision, the chunk decision, and the oversize re-split. The
|
|
37
|
+
* `redact` injected here is the same `redact()` the gateway's
|
|
38
|
+
* `redactOutboundText` wraps.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
// ── Verbatim inline reference (the code as it lived in gateway.ts) ──────────
|
|
42
|
+
|
|
43
|
+
function referenceNormalize(rawText: string): { text: string; voiceReplaced: number } {
|
|
44
|
+
let text = normalizeParagraphBreaks(repairEscapedWhitespace(rawText))
|
|
45
|
+
// redactOutboundText(text, 'reply') → redact(text) (the stderr log is a
|
|
46
|
+
// side effect the pure module leaves to the injected redactor).
|
|
47
|
+
text = redact(text)
|
|
48
|
+
text = stripExcessBold(normalizePunctuation(text))
|
|
49
|
+
let voiceReplaced = 0
|
|
50
|
+
const scrub = scrubVoice(text)
|
|
51
|
+
if (scrub.replaced > 0) {
|
|
52
|
+
text = scrub.scrubbed
|
|
53
|
+
voiceReplaced = scrub.replaced
|
|
54
|
+
}
|
|
55
|
+
return { text, voiceReplaced }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function referenceEffectiveText(text: string, literalText: boolean): string {
|
|
59
|
+
return literalText ? text : addParagraphSpacers(text)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function referenceChunks(
|
|
63
|
+
effectiveText: string,
|
|
64
|
+
literalText: boolean,
|
|
65
|
+
limit: number,
|
|
66
|
+
chunkMode: 'length' | 'newline',
|
|
67
|
+
): string[] {
|
|
68
|
+
return literalText
|
|
69
|
+
? chunkText(effectiveText, limit, chunkMode)
|
|
70
|
+
: splitMarkdownChunks(effectiveText, limit)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function referenceResplit(chunk: string): string[] {
|
|
74
|
+
const subPieces = splitMarkdownChunks(chunk, RICH_MESSAGE_MAX_CHARS)
|
|
75
|
+
return subPieces.length > 1 ? subPieces : hardSliceToCap(chunk, RICH_MESSAGE_MAX_CHARS)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const injectedRedact = (text: string, _site: string): string => redact(text)
|
|
79
|
+
|
|
80
|
+
// ── Representative outbound fixtures ────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
const FIXTURES: Record<string, string> = {
|
|
83
|
+
plain: 'Hello there, this is a normal reply.',
|
|
84
|
+
multiParagraph: 'First paragraph here.\n\nSecond paragraph here.\n\nThird one.',
|
|
85
|
+
codeFence:
|
|
86
|
+
'Here is code:\n\n```ts\nconst x = 1 // a comment — with an em-dash\nconst y = 2\n```\n\nDone.',
|
|
87
|
+
emDashes: 'This — that — and the other thing. En–dash here too.',
|
|
88
|
+
excessBold: '**bold one** and **bold two** and **bold three** and **bold four**.',
|
|
89
|
+
// Assembled at runtime so the source holds no contiguous token literal
|
|
90
|
+
// (scripts/check-no-pii-secrets.mjs rejects contiguous sk-ant-… literals).
|
|
91
|
+
secretApiKey: `Your key is ${'sk-ant-' + 'api03-' + 'ABCD'.repeat(12)} and more text.`,
|
|
92
|
+
jsonEscaped: 'Line one\\nLine two\\n\\nParagraph two with a \\t tab.',
|
|
93
|
+
bullets: '- item one\n- item two\n- item three with a — dash',
|
|
94
|
+
oversize: 'x'.repeat(RICH_MESSAGE_MAX_CHARS + 5000),
|
|
95
|
+
multiChunkProse: Array.from({ length: 60 }, (_, i) => `Paragraph number ${i} with some filler text to grow length.`).join('\n\n'),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
describe('outbound-send-path — normalizeOutboundBody parity with inline pipeline', () => {
|
|
99
|
+
for (const [name, raw] of Object.entries(FIXTURES)) {
|
|
100
|
+
it(`normalize byte-identical: ${name}`, () => {
|
|
101
|
+
const ref = referenceNormalize(raw)
|
|
102
|
+
const got = normalizeOutboundBody(raw, 'reply', injectedRedact)
|
|
103
|
+
expect(got.text).toBe(ref.text)
|
|
104
|
+
expect(got.voiceReplaced).toBe(ref.voiceReplaced)
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
it('secret fixture is actually redacted (mask fired)', () => {
|
|
109
|
+
const got = normalizeOutboundBody(FIXTURES.secretApiKey, 'reply', injectedRedact)
|
|
110
|
+
expect(got.text).not.toContain('sk-ant-' + 'api03-' + 'ABCD'.repeat(12))
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('em/en dashes are removed by the normalize pipeline', () => {
|
|
114
|
+
const got = normalizeOutboundBody(FIXTURES.emDashes, 'reply', injectedRedact)
|
|
115
|
+
// normalizePunctuation + scrubVoice between them strip em/en dashes from
|
|
116
|
+
// prose; the exact stage that fires is an implementation detail, but no
|
|
117
|
+
// raw em-dash survives the pipeline.
|
|
118
|
+
expect(got.text).not.toContain('—')
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
describe('outbound-send-path — effective text + chunk parity', () => {
|
|
123
|
+
for (const [name, raw] of Object.entries(FIXTURES)) {
|
|
124
|
+
for (const literalText of [true, false]) {
|
|
125
|
+
it(`effectiveText + chunks byte-identical: ${name} literal=${literalText}`, () => {
|
|
126
|
+
const { text } = normalizeOutboundBody(raw, 'reply', injectedRedact)
|
|
127
|
+
const refEff = referenceEffectiveText(text, literalText)
|
|
128
|
+
const gotEff = computeEffectiveText(text, literalText)
|
|
129
|
+
expect(gotEff).toBe(refEff)
|
|
130
|
+
|
|
131
|
+
const limit = RICH_MESSAGE_MAX_CHARS
|
|
132
|
+
const chunkMode: 'length' | 'newline' = 'length'
|
|
133
|
+
const refChunks = referenceChunks(refEff, literalText, limit, chunkMode)
|
|
134
|
+
const gotChunks = computeReplyChunks({ effectiveText: gotEff, literalText, limit, chunkMode })
|
|
135
|
+
expect(gotChunks).toEqual(refChunks)
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
it('literal newline-mode chunking parity on a large body', () => {
|
|
141
|
+
const body = FIXTURES.multiChunkProse
|
|
142
|
+
const limit = 400
|
|
143
|
+
expect(computeReplyChunks({ effectiveText: body, literalText: true, limit, chunkMode: 'newline' }))
|
|
144
|
+
.toEqual(chunkText(body, limit, 'newline'))
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('oversize chunk re-split parity + every piece under the wire cap', () => {
|
|
148
|
+
const oversize = FIXTURES.oversize
|
|
149
|
+
const ref = referenceResplit(oversize)
|
|
150
|
+
const got = resplitOversizeChunk(oversize)
|
|
151
|
+
expect(got).toEqual(ref)
|
|
152
|
+
for (const piece of got) expect(piece.length).toBeLessThanOrEqual(RICH_MESSAGE_MAX_CHARS)
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
describe('outbound-send-path — chunkText golden snapshots', () => {
|
|
157
|
+
it('length mode hard-cuts at the limit', () => {
|
|
158
|
+
expect(chunkText('abcdefghij', 4, 'length')).toEqual(['abcd', 'efgh', 'ij'])
|
|
159
|
+
})
|
|
160
|
+
it('newline mode prefers a paragraph break past halfway', () => {
|
|
161
|
+
expect(chunkText('aaaa\n\nbbbbbbbb', 8, 'newline')).toEqual(['aaaa\n', 'bbbbbbbb'])
|
|
162
|
+
})
|
|
163
|
+
it('short text is returned whole', () => {
|
|
164
|
+
expect(chunkText('short', 100, 'length')).toEqual(['short'])
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
// ── Cross-surface dedup suppression (the load-bearing singleton contract) ────
|
|
169
|
+
//
|
|
170
|
+
// The plan calls out that `outboundDedup` MUST be the SAME injected instance
|
|
171
|
+
// across executeReply / answer-stream / turn-flush — a hoist-once bug (the
|
|
172
|
+
// gateway landmine comment) broke all three. The dedup KEY on every surface is
|
|
173
|
+
// the post-`normalizeOutboundBody` text, so a stream-path record must suppress
|
|
174
|
+
// a reply-path check for the same normalized content on the same singleton.
|
|
175
|
+
|
|
176
|
+
describe('outbound-send-path — cross-surface dedup suppression', () => {
|
|
177
|
+
it('stream-path record suppresses reply-path check on the shared singleton', () => {
|
|
178
|
+
const dedup = new OutboundDedupCache()
|
|
179
|
+
const chatId = '12345'
|
|
180
|
+
const threadId = undefined
|
|
181
|
+
const turnKey = 'turn-abc'
|
|
182
|
+
const t0 = 1_000_000
|
|
183
|
+
|
|
184
|
+
// Both surfaces normalize the same raw model text identically.
|
|
185
|
+
const rawFromModel = 'The answer is 42 — computed carefully across the whole set of inputs.'
|
|
186
|
+
const streamText = normalizeOutboundBody(rawFromModel, 'stream_reply', injectedRedact).text
|
|
187
|
+
const replyText = normalizeOutboundBody(rawFromModel, 'reply', injectedRedact).text
|
|
188
|
+
expect(replyText).toBe(streamText) // same key on every surface
|
|
189
|
+
|
|
190
|
+
// Miss before anything recorded.
|
|
191
|
+
expect(dedup.check(chatId, threadId, replyText, t0, turnKey)).toBeNull()
|
|
192
|
+
|
|
193
|
+
// Answer-stream records first (same singleton, same turnKey).
|
|
194
|
+
dedup.record(chatId, threadId, streamText, t0, turnKey)
|
|
195
|
+
|
|
196
|
+
// Reply path now sees the within-turn duplicate and is suppressed.
|
|
197
|
+
const hit = dedup.check(chatId, threadId, replyText, t0 + 500, turnKey)
|
|
198
|
+
expect(hit).not.toBeNull()
|
|
199
|
+
expect(hit!.matched).toBe(true)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('a DISTINCT normalized reply is NOT suppressed (no false dedup)', () => {
|
|
203
|
+
const dedup = new OutboundDedupCache()
|
|
204
|
+
const chatId = '12345'
|
|
205
|
+
const turnKey = 'turn-abc'
|
|
206
|
+
const t0 = 2_000_000
|
|
207
|
+
const a = normalizeOutboundBody('First distinct answer with enough length to record.', 'stream_reply', injectedRedact).text
|
|
208
|
+
const b = normalizeOutboundBody('Second completely different answer, also long enough.', 'reply', injectedRedact).text
|
|
209
|
+
dedup.record(chatId, undefined, a, t0, turnKey)
|
|
210
|
+
expect(dedup.check(chatId, undefined, b, t0 + 500, turnKey)).toBeNull()
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('cross-turn identical content is NOT suppressed (distinct turnKeys)', () => {
|
|
214
|
+
const dedup = new OutboundDedupCache()
|
|
215
|
+
const chatId = '12345'
|
|
216
|
+
const t0 = 3_000_000
|
|
217
|
+
const text = normalizeOutboundBody('Same content typed twice across two turns, long enough to record.', 'reply', injectedRedact).text
|
|
218
|
+
dedup.record(chatId, undefined, text, t0, 'turn-1')
|
|
219
|
+
// A later, separate turn with the same content still delivers.
|
|
220
|
+
expect(dedup.check(chatId, undefined, text, t0 + 500, 'turn-2')).toBeNull()
|
|
221
|
+
})
|
|
222
|
+
})
|
|
@@ -20,6 +20,16 @@ import { dirname, resolve } from 'node:path'
|
|
|
20
20
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
21
21
|
const read = (p: string) => readFileSync(resolve(__dirname, '..', p), 'utf8')
|
|
22
22
|
const GATEWAY = read('gateway/gateway.ts')
|
|
23
|
+
// #2996 Phase 3: the in-memory approval-card Maps + their TTL sweeps moved
|
|
24
|
+
// behind approval-card-stores.ts. The gateway now delegates each family sweep
|
|
25
|
+
// to `<store>.sweep(now)`; the per-entry sweepExpiredEntries guard lives in the
|
|
26
|
+
// store module.
|
|
27
|
+
const CARD_STORES = read('gateway/approval-card-stores.ts')
|
|
28
|
+
// #2996 Phase 5: the callback-query handler families (vault access/save,
|
|
29
|
+
// mental-model, deferred-secret, grant wizard, operator-event, auth dashboard)
|
|
30
|
+
// moved verbatim behind callback-query-handlers.ts; handler-body pins read
|
|
31
|
+
// that module while staging/boot/sweep wiring stays pinned on gateway.ts.
|
|
32
|
+
const CB_HANDLERS = read('gateway/callback-query-handlers.ts')
|
|
23
33
|
|
|
24
34
|
function slice(src: string, header: string, span = 3000): string {
|
|
25
35
|
const start = src.indexOf(header)
|
|
@@ -90,7 +100,7 @@ describe('boot restore (Defect A)', () => {
|
|
|
90
100
|
})
|
|
91
101
|
|
|
92
102
|
it('a Save tap on a restored (valueless) card degrades gracefully instead of writing empty', () => {
|
|
93
|
-
const fn = slice(
|
|
103
|
+
const fn = slice(CB_HANDLERS, 'async function handleVaultRequestSaveCallback', 9000)
|
|
94
104
|
expect(fn).toMatch(/pending\.restoredWithoutValue \|\| pending\.value\.length === 0/)
|
|
95
105
|
expect(fn).toMatch(/lost to a gateway restart/)
|
|
96
106
|
expect(fn).toMatch(/buildVaultSaveFailedInbound/)
|
|
@@ -102,15 +112,15 @@ describe('boot restore (Defect A)', () => {
|
|
|
102
112
|
describe('resolution clears the durable store (Defect A)', () => {
|
|
103
113
|
it('vault access approve/deny remove from the store', () => {
|
|
104
114
|
// deny path
|
|
105
|
-
const deny = slice(
|
|
115
|
+
const deny = slice(CB_HANDLERS, 'async function handleVaultRequestAccessCallback', 4000)
|
|
106
116
|
expect(deny).toMatch(/pendingCardStore\.remove\(stageId\)/)
|
|
107
117
|
// approve path (performVaultAccessApproval) removes on every terminal branch
|
|
108
|
-
const approve = slice(
|
|
118
|
+
const approve = slice(CB_HANDLERS, 'async function performVaultAccessApproval', 9000)
|
|
109
119
|
expect(approve).toMatch(/pendingCardStore\.remove\(stageId\)/)
|
|
110
120
|
})
|
|
111
121
|
|
|
112
122
|
it('vault save resolution paths remove from the store', () => {
|
|
113
|
-
const fn = slice(
|
|
123
|
+
const fn = slice(CB_HANDLERS, 'async function handleVaultRequestSaveCallback', 12000)
|
|
114
124
|
// discard / write-fail / success / passphrase-missing all clear the store.
|
|
115
125
|
const count = (fn.match(/pendingCardStore\.remove\(stageId\)/g) ?? []).length
|
|
116
126
|
expect(count).toBeGreaterThanOrEqual(3)
|
|
@@ -124,7 +134,7 @@ describe('resolution clears the durable store (Defect A)', () => {
|
|
|
124
134
|
})
|
|
125
135
|
|
|
126
136
|
it('mental model resolve removes from the store', () => {
|
|
127
|
-
const fn = slice(
|
|
137
|
+
const fn = slice(CB_HANDLERS, 'async function handleMentalModelProposeCallback', 4000)
|
|
128
138
|
expect(fn).toMatch(/pendingCardStore\.remove\(stageId\)/)
|
|
129
139
|
})
|
|
130
140
|
})
|
|
@@ -141,10 +151,13 @@ describe('TTL expiry wakes the parked agent (Defect B)', () => {
|
|
|
141
151
|
})
|
|
142
152
|
|
|
143
153
|
it('sweepExpiredApprovalCards runs all four family sweeps', () => {
|
|
154
|
+
// #2996 Phase 3: three families now delegate to their store's `.sweep(now)`;
|
|
155
|
+
// request_secret still routes via sweepSecretRequests (which also sweeps the
|
|
156
|
+
// transient armedSecretCaptures) and that in turn calls the store sweep.
|
|
144
157
|
const fn = slice(GATEWAY, 'function sweepExpiredApprovalCards', 600)
|
|
145
|
-
expect(fn).toMatch(/
|
|
146
|
-
expect(fn).toMatch(/
|
|
147
|
-
expect(fn).toMatch(/
|
|
158
|
+
expect(fn).toMatch(/pendingVaultRequestAccesses\.sweep\(now\)/)
|
|
159
|
+
expect(fn).toMatch(/pendingVaultRequestSaves\.sweep\(now\)/)
|
|
160
|
+
expect(fn).toMatch(/pendingMentalModelProposes\.sweep\(now\)/)
|
|
148
161
|
expect(fn).toMatch(/sweepSecretRequests\(now\)/)
|
|
149
162
|
})
|
|
150
163
|
|
|
@@ -173,14 +186,20 @@ describe('TTL expiry wakes the parked agent (Defect B)', () => {
|
|
|
173
186
|
})
|
|
174
187
|
|
|
175
188
|
it('each family sweep is per-entry guarded via sweepExpiredEntries', () => {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
189
|
+
// #2996 Phase 3: the per-entry guard moved into the store module's `.sweep`,
|
|
190
|
+
// which is a thin pass-through to the same pure sweepExpiredEntries core.
|
|
191
|
+
// The three vault/mental families delegate to that store method; the
|
|
192
|
+
// request_secret family's sweepSecretRequests delegates to it too.
|
|
193
|
+
expect(CARD_STORES).toMatch(/sweep:\s*\(now\)\s*=>\s*\n?\s*sweepExpiredEntries\(/)
|
|
194
|
+
const secretSweep = slice(GATEWAY, 'function sweepSecretRequests', 500)
|
|
195
|
+
expect(secretSweep).toMatch(/pendingSecretRequests\.sweep\(now\)/)
|
|
196
|
+
for (const store of [
|
|
197
|
+
'const pendingVaultRequestAccesses = createSweepableCardStore',
|
|
198
|
+
'const pendingVaultRequestSaves = createSweepableCardStore',
|
|
199
|
+
'const pendingMentalModelProposes = createSweepableCardStore',
|
|
200
|
+
'const pendingSecretRequests = createSweepableCardStore',
|
|
181
201
|
]) {
|
|
182
|
-
|
|
183
|
-
expect(fn, fnName).toMatch(/sweepExpiredEntries\(/)
|
|
202
|
+
expect(GATEWAY, store).toContain(store)
|
|
184
203
|
}
|
|
185
204
|
})
|
|
186
205
|
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract + race/ordering pins for the long-tail pending-state stores
|
|
3
|
+
* (gateway/pending-state-stores.ts), extracted in #2996 Phase 3 step 2.
|
|
4
|
+
*
|
|
5
|
+
* Written BEFORE the long-tail Maps moved behind the store module. They lock:
|
|
6
|
+
*
|
|
7
|
+
* 1. Each family's EXACT `isExpired` comparison direction survives verbatim —
|
|
8
|
+
* TTL directions differ across families (`now - staged_at > TTL`,
|
|
9
|
+
* `now - startedAt > TTL`, `now - createdAt > TTL`, `now - armed_at > TTL`,
|
|
10
|
+
* and the absolute `now > expiresAt`), and a store must delete exactly the
|
|
11
|
+
* entries the old open-coded loop deleted, not one tick early or late.
|
|
12
|
+
* 2. `sweep` is delete-during-iteration safe (JS Map iterators tolerate
|
|
13
|
+
* deleting the current key — the raw reaper loops relied on this).
|
|
14
|
+
* 3. The gateway-visible EAGER-SWEEP-AT-ADD pattern (the sweep called right
|
|
15
|
+
* after a `.set` at a staging site, e.g. `sweepSecretRequests()` after the
|
|
16
|
+
* `pendingSecretRequests.set` in executeRequestSecret) is single-shot: an
|
|
17
|
+
* add followed by an eager sweep expires only entries already past TTL and
|
|
18
|
+
* leaves the just-added fresh entry intact.
|
|
19
|
+
* 4. Map-surface parity for every operation the call sites use, including the
|
|
20
|
+
* LRU eviction pattern `agentButtonMeta` uses (`keys().next().value`) and
|
|
21
|
+
* the live `size` getter.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { describe, it, expect } from 'vitest'
|
|
25
|
+
import {
|
|
26
|
+
createSweepableStore,
|
|
27
|
+
createPlainStore,
|
|
28
|
+
} from '../gateway/pending-state-stores.js'
|
|
29
|
+
|
|
30
|
+
// TTL constants mirror the gateway family values so the direction pins are
|
|
31
|
+
// meaningful (values themselves are not the contract — the direction is).
|
|
32
|
+
const VAULT_INPUT_TTL_MS = 5 * 60 * 1000
|
|
33
|
+
const VAULT_PASSPHRASE_TTL_MS = 30 * 60 * 1000
|
|
34
|
+
const DEFERRED_SECRET_TTL_MS = 5 * 60 * 1000
|
|
35
|
+
const ARMED_SECRET_CAPTURE_TTL_MS = 10 * 60_000
|
|
36
|
+
const ALWAYS_ALLOW_CORRELATION_TTL_MS = 30_000
|
|
37
|
+
const MENTAL_MODEL_CORRELATION_TTL_MS = 720_000
|
|
38
|
+
const REAUTH_INTERCEPT_TTL_MS = 10 * 60_000
|
|
39
|
+
|
|
40
|
+
describe('pending-state-stores: per-family isExpired direction (byte-identical)', () => {
|
|
41
|
+
it('pendingVaultOps — now - startedAt > VAULT_INPUT_TTL_MS', () => {
|
|
42
|
+
const store = createSweepableStore<{ startedAt: number }>(
|
|
43
|
+
(v, now) => now - v.startedAt > VAULT_INPUT_TTL_MS,
|
|
44
|
+
)
|
|
45
|
+
const now = 10_000_000
|
|
46
|
+
store.set('fresh', { startedAt: now - VAULT_INPUT_TTL_MS }) // exactly TTL old: NOT expired (> is strict)
|
|
47
|
+
store.set('stale', { startedAt: now - VAULT_INPUT_TTL_MS - 1 })
|
|
48
|
+
store.sweep(now)
|
|
49
|
+
expect(store.has('fresh')).toBe(true)
|
|
50
|
+
expect(store.has('stale')).toBe(false)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('vaultPassphraseCache — absolute now > expiresAt', () => {
|
|
54
|
+
const store = createSweepableStore<{ expiresAt: number }>(
|
|
55
|
+
(v, now) => now > v.expiresAt,
|
|
56
|
+
)
|
|
57
|
+
const now = 10_000_000
|
|
58
|
+
store.set('atExpiry', { expiresAt: now }) // now > now is false → NOT expired
|
|
59
|
+
store.set('past', { expiresAt: now - 1 })
|
|
60
|
+
store.sweep(now)
|
|
61
|
+
expect(store.has('atExpiry')).toBe(true)
|
|
62
|
+
expect(store.has('past')).toBe(false)
|
|
63
|
+
void VAULT_PASSPHRASE_TTL_MS
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('deferredSecrets — now - staged_at > DEFERRED_SECRET_TTL_MS', () => {
|
|
67
|
+
const store = createSweepableStore<{ staged_at: number }>(
|
|
68
|
+
(v, now) => now - v.staged_at > DEFERRED_SECRET_TTL_MS,
|
|
69
|
+
)
|
|
70
|
+
const now = 10_000_000
|
|
71
|
+
store.set('fresh', { staged_at: now - DEFERRED_SECRET_TTL_MS })
|
|
72
|
+
store.set('stale', { staged_at: now - DEFERRED_SECRET_TTL_MS - 1 })
|
|
73
|
+
store.sweep(now)
|
|
74
|
+
expect(store.has('fresh')).toBe(true)
|
|
75
|
+
expect(store.has('stale')).toBe(false)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('armedSecretCaptures — now - armed_at > ARMED_SECRET_CAPTURE_TTL_MS', () => {
|
|
79
|
+
const store = createSweepableStore<{ armed_at: number }>(
|
|
80
|
+
(v, now) => now - v.armed_at > ARMED_SECRET_CAPTURE_TTL_MS,
|
|
81
|
+
)
|
|
82
|
+
const now = 10_000_000
|
|
83
|
+
store.set('fresh', { armed_at: now - ARMED_SECRET_CAPTURE_TTL_MS })
|
|
84
|
+
store.set('stale', { armed_at: now - ARMED_SECRET_CAPTURE_TTL_MS - 1 })
|
|
85
|
+
store.sweep(now)
|
|
86
|
+
expect(store.has('fresh')).toBe(true)
|
|
87
|
+
expect(store.has('stale')).toBe(false)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('pendingAlwaysAllowCorrelations — 30s now - createdAt > TTL', () => {
|
|
91
|
+
const store = createSweepableStore<{ createdAt: number }>(
|
|
92
|
+
(v, now) => now - v.createdAt > ALWAYS_ALLOW_CORRELATION_TTL_MS,
|
|
93
|
+
)
|
|
94
|
+
const now = 10_000_000
|
|
95
|
+
store.set('fresh', { createdAt: now - ALWAYS_ALLOW_CORRELATION_TTL_MS })
|
|
96
|
+
store.set('stale', { createdAt: now - ALWAYS_ALLOW_CORRELATION_TTL_MS - 1 })
|
|
97
|
+
store.sweep(now)
|
|
98
|
+
expect(store.has('fresh')).toBe(true)
|
|
99
|
+
expect(store.has('stale')).toBe(false)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('pendingMentalModelCorrelations — 720s now - createdAt > TTL (outlives the 30s window)', () => {
|
|
103
|
+
const store = createSweepableStore<{ createdAt: number }>(
|
|
104
|
+
(v, now) => now - v.createdAt > MENTAL_MODEL_CORRELATION_TTL_MS,
|
|
105
|
+
)
|
|
106
|
+
const now = 10_000_000
|
|
107
|
+
// A slow-but-valid tap 10 min in must NOT be swept (the whole point of the
|
|
108
|
+
// dedicated 720s TTL vs the 30s always-allow window).
|
|
109
|
+
store.set('slowTap', { createdAt: now - 10 * 60_000 })
|
|
110
|
+
store.set('stale', { createdAt: now - MENTAL_MODEL_CORRELATION_TTL_MS - 1 })
|
|
111
|
+
store.sweep(now)
|
|
112
|
+
expect(store.has('slowTap')).toBe(true)
|
|
113
|
+
expect(store.has('stale')).toBe(false)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('pendingReauthFlows — now - startedAt > REAUTH_INTERCEPT_TTL_MS', () => {
|
|
117
|
+
const store = createSweepableStore<{ startedAt: number }>(
|
|
118
|
+
(v, now) => now - v.startedAt > REAUTH_INTERCEPT_TTL_MS,
|
|
119
|
+
)
|
|
120
|
+
const now = 10_000_000
|
|
121
|
+
store.set('fresh', { startedAt: now - REAUTH_INTERCEPT_TTL_MS })
|
|
122
|
+
store.set('stale', { startedAt: now - REAUTH_INTERCEPT_TTL_MS - 1 })
|
|
123
|
+
store.sweep(now)
|
|
124
|
+
expect(store.has('fresh')).toBe(true)
|
|
125
|
+
expect(store.has('stale')).toBe(false)
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe('pending-state-stores: sweep safety', () => {
|
|
130
|
+
it('deletes every past-TTL entry across a multi-entry map (delete-during-iteration safe)', () => {
|
|
131
|
+
const store = createSweepableStore<{ staged_at: number }>(
|
|
132
|
+
(v, now) => now - v.staged_at > 1000,
|
|
133
|
+
)
|
|
134
|
+
for (let i = 0; i < 10; i++) store.set(`e${i}`, { staged_at: i % 2 === 0 ? 0 : 9_999_999 })
|
|
135
|
+
store.sweep(1_000_000) // even entries stale, odd entries fresh
|
|
136
|
+
for (let i = 0; i < 10; i++) {
|
|
137
|
+
expect(store.has(`e${i}`)).toBe(i % 2 === 1)
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('is single-shot across two sweep ticks', () => {
|
|
142
|
+
const store = createSweepableStore<{ staged_at: number }>(
|
|
143
|
+
(v, now) => now - v.staged_at > 1000,
|
|
144
|
+
)
|
|
145
|
+
store.set('one', { staged_at: 0 })
|
|
146
|
+
store.sweep(1_000_000)
|
|
147
|
+
expect(store.size).toBe(0)
|
|
148
|
+
expect(() => store.sweep(1_000_000)).not.toThrow() // second tick — nothing left
|
|
149
|
+
expect(store.size).toBe(0)
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
describe('pending-state-stores: eager-sweep-at-add pattern (gateway-visible)', () => {
|
|
154
|
+
// Mirrors executeRequestSecret: dedupe-drop prior stage for the same target,
|
|
155
|
+
// .set the new one, then eagerly sweep. The just-added fresh entry survives;
|
|
156
|
+
// a stale sibling is reaped in the same eager pass.
|
|
157
|
+
it('add + eager sweep expires only stale siblings, keeps the fresh add', () => {
|
|
158
|
+
const TTL = 30 * 60_000
|
|
159
|
+
const store = createSweepableStore<{ chat_id: string; key: string; staged_at: number }>(
|
|
160
|
+
(v, now) => now - v.staged_at > TTL,
|
|
161
|
+
)
|
|
162
|
+
const now = 100_000_000
|
|
163
|
+
store.set('old', { chat_id: 'c', key: 'k1', staged_at: now - TTL - 1 }) // stale
|
|
164
|
+
// Dedupe pass (same target) would delete a live dup; here targets differ.
|
|
165
|
+
const fresh = { chat_id: 'c', key: 'k2', staged_at: now }
|
|
166
|
+
store.set('new', fresh)
|
|
167
|
+
store.sweep(now) // eager sweep at the add site
|
|
168
|
+
expect(store.has('new')).toBe(true) // fresh add never self-expires
|
|
169
|
+
expect(store.get('new')).toBe(fresh)
|
|
170
|
+
expect(store.has('old')).toBe(false) // stale sibling reaped in the eager pass
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('dedupe-then-add-then-sweep leaves exactly the new entry', () => {
|
|
174
|
+
const TTL = 30 * 60_000
|
|
175
|
+
const store = createSweepableStore<{ chat_id: string; key: string; staged_at: number }>(
|
|
176
|
+
(v, now) => now - v.staged_at > TTL,
|
|
177
|
+
)
|
|
178
|
+
const now = 100_000_000
|
|
179
|
+
store.set('dup', { chat_id: 'c', key: 'k', staged_at: now - 1000 })
|
|
180
|
+
// dedupe: drop prior stage for the same (chat, key)
|
|
181
|
+
for (const [sid, p] of store) {
|
|
182
|
+
if (p.chat_id === 'c' && p.key === 'k') store.delete(sid)
|
|
183
|
+
}
|
|
184
|
+
store.set('fresh', { chat_id: 'c', key: 'k', staged_at: now })
|
|
185
|
+
store.sweep(now)
|
|
186
|
+
expect([...store.keys()]).toEqual(['fresh'])
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
describe('pending-state-stores: plain store (no sweep)', () => {
|
|
191
|
+
it('has no sweep method and preserves Map surface', () => {
|
|
192
|
+
const store = createPlainStore<{ n: number }>()
|
|
193
|
+
expect((store as { sweep?: unknown }).sweep).toBeUndefined()
|
|
194
|
+
store.set('a', { n: 1 })
|
|
195
|
+
store.set('b', { n: 2 })
|
|
196
|
+
expect(store.size).toBe(2)
|
|
197
|
+
expect(store.get('a')?.n).toBe(1)
|
|
198
|
+
expect(store.has('b')).toBe(true)
|
|
199
|
+
expect(store.delete('a')).toBe(true)
|
|
200
|
+
expect(store.has('a')).toBe(false)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('agentButtonMeta LRU eviction — keys().next().value is the oldest insertion', () => {
|
|
204
|
+
const AGENT_BUTTON_META_MAX = 3
|
|
205
|
+
const store = createPlainStore<Map<string, { ack: string }>>()
|
|
206
|
+
const remember = (key: string) => {
|
|
207
|
+
store.set(key, new Map([['cb', { ack: key }]]))
|
|
208
|
+
while (store.size > AGENT_BUTTON_META_MAX) {
|
|
209
|
+
const oldest = store.keys().next().value
|
|
210
|
+
if (oldest === undefined) break
|
|
211
|
+
store.delete(oldest)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
remember('m1')
|
|
215
|
+
remember('m2')
|
|
216
|
+
remember('m3')
|
|
217
|
+
remember('m4') // evicts m1 (oldest insertion)
|
|
218
|
+
expect(store.has('m1')).toBe(false)
|
|
219
|
+
expect([...store.keys()]).toEqual(['m2', 'm3', 'm4'])
|
|
220
|
+
expect(store.get('m4')?.get('cb')?.ack).toBe('m4')
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('pendingAskUser surface — values()/entries()/get/delete used by call sites', () => {
|
|
224
|
+
const store = createPlainStore<{ chatId: string; messageId: number | null }>()
|
|
225
|
+
store.set('ask1', { chatId: 'c1', messageId: 10 })
|
|
226
|
+
store.set('ask2', { chatId: 'c2', messageId: null })
|
|
227
|
+
expect([...store.values()].map((v) => v.chatId).sort()).toEqual(['c1', 'c2'])
|
|
228
|
+
const collected: string[] = []
|
|
229
|
+
for (const [askId] of store.entries()) collected.push(askId)
|
|
230
|
+
expect(collected.sort()).toEqual(['ask1', 'ask2'])
|
|
231
|
+
expect(store.get('ask1')?.messageId).toBe(10)
|
|
232
|
+
store.delete('ask1')
|
|
233
|
+
expect(store.size).toBe(1)
|
|
234
|
+
})
|
|
235
|
+
})
|
|
@@ -9,6 +9,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
|
9
9
|
import {
|
|
10
10
|
createPtyPartialHandler,
|
|
11
11
|
handlePtyPartialPure,
|
|
12
|
+
looksLikeRawApiError,
|
|
12
13
|
type PtyHandlerState,
|
|
13
14
|
type PtyHandlerDeps,
|
|
14
15
|
} from '../pty-partial-handler.js'
|
|
@@ -65,6 +66,35 @@ describe('handlePtyPartialPure', () => {
|
|
|
65
66
|
expect(bot.api.sendMessage).not.toHaveBeenCalled()
|
|
66
67
|
})
|
|
67
68
|
|
|
69
|
+
it('suppresses a raw API-error TUI line so it never leaks to chat (#2922 Bug 3)', async () => {
|
|
70
|
+
const state = makeState({ currentSessionChatId: '1' })
|
|
71
|
+
const deps = makeDeps(bot)
|
|
72
|
+
// The exact shape Claude Code's TUI renders on a transient 429.
|
|
73
|
+
const raw =
|
|
74
|
+
"API Error: Server is temporarily limiting requests (not your usage limit) · " +
|
|
75
|
+
"b'{\"type\":\"error\",\"error\":{\"type\":\"rate_limit_error\"}}'"
|
|
76
|
+
const action = handlePtyPartialPure(raw, state, deps)
|
|
77
|
+
expect(action).toBe('error-suppressed')
|
|
78
|
+
await microtaskFlush()
|
|
79
|
+
// Nothing sent — the operator-event pipeline owns the user-facing card.
|
|
80
|
+
expect(bot.api.sendMessage).not.toHaveBeenCalled()
|
|
81
|
+
expect(state.activeDraftStreams.size).toBe(0)
|
|
82
|
+
// Not recorded as a preview, so it can't poison later dedup either.
|
|
83
|
+
expect(state.lastPtyPreviewByChat.size).toBe(0)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('does NOT suppress ordinary assistant text that merely mentions errors', async () => {
|
|
87
|
+
const state = makeState({ currentSessionChatId: '1' })
|
|
88
|
+
const action = handlePtyPartialPure(
|
|
89
|
+
"Here's how to handle an error in your retry loop:",
|
|
90
|
+
state,
|
|
91
|
+
makeDeps(bot),
|
|
92
|
+
)
|
|
93
|
+
expect(action).toBe('update-new')
|
|
94
|
+
await microtaskFlush()
|
|
95
|
+
expect(bot.api.sendMessage).toHaveBeenCalledTimes(1)
|
|
96
|
+
})
|
|
97
|
+
|
|
68
98
|
it('dedups when same text arrives twice in a row', async () => {
|
|
69
99
|
const state = makeState({ currentSessionChatId: '1' })
|
|
70
100
|
const deps = makeDeps(bot)
|
|
@@ -324,3 +354,29 @@ describe('createPtyPartialHandler — session + buffer replay', () => {
|
|
|
324
354
|
expect(state.activeDraftStreams.size).toBe(0)
|
|
325
355
|
})
|
|
326
356
|
})
|
|
357
|
+
|
|
358
|
+
describe('looksLikeRawApiError (#2922 Bug 3)', () => {
|
|
359
|
+
it('flags the CLI "API Error: … · b\'{…}\'" line', () => {
|
|
360
|
+
expect(
|
|
361
|
+
looksLikeRawApiError(
|
|
362
|
+
"API Error: Server is temporarily limiting requests · b'{\"type\":\"error\"}'",
|
|
363
|
+
),
|
|
364
|
+
).toBe(true)
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
it('flags a bare rate_limit_error JSON body', () => {
|
|
368
|
+
expect(
|
|
369
|
+
looksLikeRawApiError('{"type":"error","error":{"type":"rate_limit_error"}}'),
|
|
370
|
+
).toBe(true)
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
it('flags overloaded_error and is_error markers', () => {
|
|
374
|
+
expect(looksLikeRawApiError('{"type":"overloaded_error"}')).toBe(true)
|
|
375
|
+
expect(looksLikeRawApiError('{"is_error":true,"content":"boom"}')).toBe(true)
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
it('does not flag ordinary prose mentioning "error"', () => {
|
|
379
|
+
expect(looksLikeRawApiError('I hit an error handling that request')).toBe(false)
|
|
380
|
+
expect(looksLikeRawApiError('')).toBe(false)
|
|
381
|
+
})
|
|
382
|
+
})
|