switchroom 0.18.28 → 0.18.30
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/bin/handoff-briefing.sh +15 -2
- package/dist/agent-scheduler/index.js +111 -7
- package/dist/auth-broker/index.js +154 -73
- package/dist/cli/autoaccept-poll.js +8 -3
- package/dist/cli/drive-write-pretool.mjs +8 -3
- package/dist/cli/ms-365-write-pretool.mjs +158 -11
- package/dist/cli/notion-write-pretool.mjs +103 -4
- package/dist/cli/switchroom.js +2712 -2219
- package/dist/host-control/main.js +110 -70
- package/dist/vault/approvals/kernel-server.js +116 -70
- package/dist/vault/broker/server.js +314 -202
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +105 -34
- package/telegram-plugin/dist/bridge/bridge.js +71 -47
- package/telegram-plugin/dist/gateway/gateway.js +1128 -666
- package/telegram-plugin/dist/server.js +89 -64
- package/telegram-plugin/gateway/backstop-delivery.ts +272 -0
- package/telegram-plugin/gateway/forward-origin.ts +9 -1
- package/telegram-plugin/gateway/gateway.ts +656 -388
- package/telegram-plugin/gateway/model-command.ts +331 -602
- package/telegram-plugin/gateway/session-model-file.ts +40 -0
- package/telegram-plugin/gateway/turn-record-status.ts +45 -0
- package/telegram-plugin/gateway/unhandled-message.ts +177 -0
- package/telegram-plugin/history.ts +153 -23
- package/telegram-plugin/llm-error-present.ts +24 -0
- package/telegram-plugin/model-unavailable.ts +55 -0
- package/telegram-plugin/operator-events.ts +113 -0
- package/telegram-plugin/pending-user-notice.ts +88 -0
- package/telegram-plugin/shared/local-time.ts +99 -0
- package/telegram-plugin/tests/backstop-delivery.test.ts +250 -0
- package/telegram-plugin/tests/catch-all-forwarded-history.test.ts +103 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +264 -0
- package/telegram-plugin/tests/forward-origin.test.ts +30 -3
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +111 -60
- package/telegram-plugin/tests/history.test.ts +88 -0
- package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +278 -0
- package/telegram-plugin/tests/local-time.test.ts +135 -0
- package/telegram-plugin/tests/model-command.test.ts +427 -1512
- package/telegram-plugin/tests/session-model-file.test.ts +23 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +34 -0
- package/telegram-plugin/tier-downgrade.ts +4 -3
- package/telegram-plugin/turn-flush-safety.ts +25 -1
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +399 -2
- package/vendor/hindsight-memory/scripts/lib/client.py +47 -0
- package/vendor/hindsight-memory/scripts/lib/content.py +93 -7
- package/vendor/hindsight-memory/scripts/lib/turnlog.py +450 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +467 -0
- package/vendor/hindsight-memory/tests/test_content.py +63 -7
|
@@ -14,12 +14,14 @@
|
|
|
14
14
|
|
|
15
15
|
import { escapeMarkdown } from './format.js'
|
|
16
16
|
import { stripRawErrorBytes } from './raw-error-scrub.js'
|
|
17
|
+
import { isLitellmProxyAuthMisconfig } from './model-unavailable.js'
|
|
17
18
|
|
|
18
19
|
// ─── Taxonomy ────────────────────────────────────────────────────────────────
|
|
19
20
|
|
|
20
21
|
export type OperatorEventKind =
|
|
21
22
|
| 'credentials-expired'
|
|
22
23
|
| 'credentials-invalid'
|
|
24
|
+
| 'proxy-misconfig'
|
|
23
25
|
| 'credit-exhausted'
|
|
24
26
|
| 'quota-exhausted'
|
|
25
27
|
| 'rate-limited'
|
|
@@ -92,6 +94,20 @@ function classifyInner(raw: unknown): OperatorEventKind {
|
|
|
92
94
|
// Anthropic SDK: error_code field (newer SDK shape)
|
|
93
95
|
const sdkCode = extractString(obj, 'error_code') ?? ''
|
|
94
96
|
|
|
97
|
+
// LiteLLM-proxy AUTH misconfig — checked BEFORE the generic
|
|
98
|
+
// authentication_error branch. The proxy's internal fallback chain
|
|
99
|
+
// re-dispatches without the client's OAuth Authorization header onto a
|
|
100
|
+
// keyless deployment, so Anthropic returns a 401 authentication_error
|
|
101
|
+
// ("x-api-key header is required"). That is a HOST-side infra misconfig for
|
|
102
|
+
// the operator to fix — NOT an end-user login wall. Mapping it to
|
|
103
|
+
// credentials-invalid mis-fired a "🔑 re-authenticate" card at non-operator
|
|
104
|
+
// users who cannot re-auth anything (incident 2026-07-16). Route it to the
|
|
105
|
+
// operator-only infra kind instead. Scanned across type/code/message so the
|
|
106
|
+
// marker is caught wherever the proxy stamps it.
|
|
107
|
+
if (isLitellmProxyAuthMisconfig(`${errorType}\n${errorCode}\n${sdkCode}\n${message}`)) {
|
|
108
|
+
return 'proxy-misconfig'
|
|
109
|
+
}
|
|
110
|
+
|
|
95
111
|
// Map known Anthropic error types/codes first.
|
|
96
112
|
// Source: https://docs.anthropic.com/en/api/errors
|
|
97
113
|
if (
|
|
@@ -263,6 +279,27 @@ export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
|
|
|
263
279
|
},
|
|
264
280
|
}
|
|
265
281
|
|
|
282
|
+
// Operator-only infra fault. Deliberately NO "Reauth" button and NO
|
|
283
|
+
// login language: the OAuth credential is fine — the local LiteLLM proxy
|
|
284
|
+
// re-dispatched an internal fallback without forwarding the OAuth header
|
|
285
|
+
// onto a keyless deployment, so Anthropic 401'd it. The fix is in the
|
|
286
|
+
// proxy fallback config (host-side), not a re-auth. Dismiss-only.
|
|
287
|
+
case 'proxy-misconfig':
|
|
288
|
+
return {
|
|
289
|
+
text: [
|
|
290
|
+
`🛠️ **Model-gateway auth misconfig** for **${agent}**.`,
|
|
291
|
+
detail ? `_${detail}_` : '',
|
|
292
|
+
`The local LiteLLM proxy re-dispatched a fallback without forwarding the OAuth header (keyless deployment → Anthropic 401). Fix the proxy fallback config — this is NOT a login problem.`,
|
|
293
|
+
]
|
|
294
|
+
.filter(Boolean)
|
|
295
|
+
.join('\n'),
|
|
296
|
+
keyboard: {
|
|
297
|
+
inline_keyboard: [
|
|
298
|
+
[{ text: '❌ Dismiss', callback_data: `op:dismiss:${encodeURIComponent(ev.agent)}` }],
|
|
299
|
+
],
|
|
300
|
+
},
|
|
301
|
+
}
|
|
302
|
+
|
|
266
303
|
case 'credit-exhausted':
|
|
267
304
|
return {
|
|
268
305
|
text: [
|
|
@@ -487,4 +524,80 @@ export function resetAllCooldowns(): void {
|
|
|
487
524
|
cooldownMap.clear()
|
|
488
525
|
}
|
|
489
526
|
|
|
527
|
+
// ─── Audience routing (Ken's deterministic error-surfacing policy) ────────────
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Kinds that are OPERATOR-ACTIONABLE and NOT user-actionable: a credential /
|
|
531
|
+
* infra fault whose remedy (re-auth, switch account slot, fix the proxy
|
|
532
|
+
* fallback config) only the operator can perform. Per Ken's standing policy
|
|
533
|
+
* (2026-07): raw or misleading auth/infra errors must not reach non-operator
|
|
534
|
+
* end users — they cannot act on them and the diagnosis is often wrong for
|
|
535
|
+
* them (e.g. a proxy misconfig rendered as "your login expired"). These route
|
|
536
|
+
* to the operator surface ONLY; a non-operator user gets at most a brief
|
|
537
|
+
* plain-language "couldn't complete, it's on our side" notice.
|
|
538
|
+
*
|
|
539
|
+
* NOTE: `quota-exhausted` / `rate-limited` are deliberately EXCLUDED — they
|
|
540
|
+
* carry their own auto-fallback UX + card-collapse machinery and are handled
|
|
541
|
+
* upstream; this set is scoped to the credential/infra fault classes.
|
|
542
|
+
*/
|
|
543
|
+
export const OPERATOR_ACTIONABLE_KINDS: ReadonlySet<OperatorEventKind> = new Set<OperatorEventKind>([
|
|
544
|
+
'credentials-expired',
|
|
545
|
+
'credentials-invalid',
|
|
546
|
+
'credit-exhausted',
|
|
547
|
+
'proxy-misconfig',
|
|
548
|
+
])
|
|
549
|
+
|
|
550
|
+
export function isOperatorActionableKind(kind: OperatorEventKind): boolean {
|
|
551
|
+
return OPERATOR_ACTIONABLE_KINDS.has(kind)
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
export interface OperatorEventAudience {
|
|
555
|
+
/** Chats that receive the full operator card (with any action buttons). */
|
|
556
|
+
operatorChats: string[]
|
|
557
|
+
/** Non-operator chats that receive only the plain-language failure notice. */
|
|
558
|
+
userNoticeChats: string[]
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Split an operator-event's allowlist audience per Ken's routing policy.
|
|
563
|
+
*
|
|
564
|
+
* - Non-operator-actionable kinds (transient rate-limit, 5xx, crash, config
|
|
565
|
+
* warning, …) keep their existing broadcast: every allowlist chat is an
|
|
566
|
+
* `operatorChat`. Behavior unchanged.
|
|
567
|
+
* - Operator-actionable kinds (see {@link OPERATOR_ACTIONABLE_KINDS}) go to the
|
|
568
|
+
* OPERATOR chat only. `operatorChatId` is the operator (in switchroom the
|
|
569
|
+
* allowlist HEAD — `allowFrom[0]`); it stays an `operatorChat` even in a DM
|
|
570
|
+
* agent where the operator is their own user (so the operator NEVER has an
|
|
571
|
+
* error hidden from their own DM). Every other allowlist chat becomes a
|
|
572
|
+
* `userNoticeChat`.
|
|
573
|
+
*
|
|
574
|
+
* Pure — no IPC, no bot. `allowFrom` order is preserved.
|
|
575
|
+
*/
|
|
576
|
+
export function decideOperatorEventAudience(
|
|
577
|
+
kind: OperatorEventKind,
|
|
578
|
+
allowFrom: readonly string[],
|
|
579
|
+
operatorChatId: string | undefined,
|
|
580
|
+
): OperatorEventAudience {
|
|
581
|
+
if (!isOperatorActionableKind(kind)) {
|
|
582
|
+
return { operatorChats: [...allowFrom], userNoticeChats: [] }
|
|
583
|
+
}
|
|
584
|
+
const operator =
|
|
585
|
+
operatorChatId != null && allowFrom.includes(operatorChatId)
|
|
586
|
+
? operatorChatId
|
|
587
|
+
: allowFrom[0]
|
|
588
|
+
const operatorChats = operator != null ? [operator] : []
|
|
589
|
+
const userNoticeChats = allowFrom.filter((c) => c !== operator)
|
|
590
|
+
return { operatorChats, userNoticeChats }
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* The ONE brief, plain-language failure notice a non-operator user gets when a
|
|
595
|
+
* turn genuinely can't be served because of an operator-actionable fault.
|
|
596
|
+
* Deliberately carries NO diagnosis, NO agent internals, NO re-auth / config
|
|
597
|
+
* instructions, and NO raw error text — just an honest "it's on our side".
|
|
598
|
+
*/
|
|
599
|
+
export function renderUserFacingFailureNotice(): string {
|
|
600
|
+
return "⚠️ Sorry — I couldn't complete that just now. It's a problem on our side, not anything you did. Please try again shortly."
|
|
601
|
+
}
|
|
602
|
+
|
|
490
603
|
// ─── Markdown escape (#2669) ──────────────────────────────────────────────────
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pending-user-notice.ts — deterministic turn-outcome gate for the
|
|
3
|
+
* non-operator user failure notice (#3293 review finding 1).
|
|
4
|
+
*
|
|
5
|
+
* THE PROBLEM this closes: `emitGatewayOperatorEvent` fires whenever
|
|
6
|
+
* session-tail sees an api_error line, with no knowledge of whether the turn
|
|
7
|
+
* ultimately RECOVERS (e.g. the LiteLLM fallback 401s but a retry / another
|
|
8
|
+
* deployment serves the turn). Sending the "couldn't complete that — it's on
|
|
9
|
+
* our side" notice at error time would tell users a turn failed that actually
|
|
10
|
+
* completed. The 5-min per-kind operator-event cooldown debounces retry SPAM
|
|
11
|
+
* but cannot know the turn's outcome.
|
|
12
|
+
*
|
|
13
|
+
* THE MECHANISM: the notice is never sent at error time. It is SCHEDULED here,
|
|
14
|
+
* and resolved at the gateway's single turn-end funnel (`endCurrentTurnAtomic`):
|
|
15
|
+
* - turn ended WITH a delivered reply → the turn recovered → DROP the notice
|
|
16
|
+
* - turn ended WITHOUT a delivered reply → the turn genuinely died → SEND it
|
|
17
|
+
* Operator cards are NOT gated — they stay immediate (the operator must see
|
|
18
|
+
* the infra fault even when the turn recovers).
|
|
19
|
+
*
|
|
20
|
+
* CONSERVATIVE TTL: if no turn end resolves a scheduled notice within
|
|
21
|
+
* {@link PENDING_USER_NOTICE_TTL_MS} (error arrived between turns, or the turn
|
|
22
|
+
* record was lost), the notice is silently discarded — a missed notice costs a
|
|
23
|
+
* user some confusion; a FALSE "couldn't complete" on a served turn costs
|
|
24
|
+
* trust. Bias to silence.
|
|
25
|
+
*
|
|
26
|
+
* Pure module: no IPC, no bot, no FS. Injectable `now` throughout.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export interface PendingUserNotice {
|
|
30
|
+
/** Non-operator allowlist chats that should receive the notice. */
|
|
31
|
+
chatIds: string[]
|
|
32
|
+
/** The plain-language notice text (renderUserFacingFailureNotice()). */
|
|
33
|
+
text: string
|
|
34
|
+
agent: string
|
|
35
|
+
/** The operator-event kind that produced it (log/debug context only). */
|
|
36
|
+
kind: string
|
|
37
|
+
/** When the notice was scheduled (ms epoch). */
|
|
38
|
+
atMs: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** How long a scheduled notice may wait for a resolving turn end. */
|
|
42
|
+
export const PENDING_USER_NOTICE_TTL_MS = 10 * 60_000
|
|
43
|
+
|
|
44
|
+
export class PendingUserNoticeGate {
|
|
45
|
+
private pending: PendingUserNotice[] = []
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Schedule a notice for turn-end resolution. Collapses per agent — a burst
|
|
49
|
+
* of error lines within one turn holds ONE pending notice, not a stack.
|
|
50
|
+
*/
|
|
51
|
+
schedule(notice: PendingUserNotice): void {
|
|
52
|
+
this.prune(notice.atMs)
|
|
53
|
+
this.pending = this.pending.filter((p) => p.agent !== notice.agent)
|
|
54
|
+
this.pending.push(notice)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolve at turn end. `turnDeliveredReply` is the turn's outcome signal
|
|
59
|
+
* (the gateway passes `finalAnswerDelivered || replyCalled`):
|
|
60
|
+
* - true → the turn recovered; every pending notice is dropped, [] returned.
|
|
61
|
+
* - false → the turn died without a reply; the un-expired pending notices
|
|
62
|
+
* are returned EXACTLY ONCE for the caller to send.
|
|
63
|
+
* Either way the ledger is cleared (a notice never survives its turn end).
|
|
64
|
+
*/
|
|
65
|
+
resolveTurnEnd(turnDeliveredReply: boolean, now: number = Date.now()): PendingUserNotice[] {
|
|
66
|
+
this.prune(now)
|
|
67
|
+
const out = turnDeliveredReply ? [] : [...this.pending]
|
|
68
|
+
this.pending = []
|
|
69
|
+
return out
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** True when at least one un-expired notice is pending (does not mutate). */
|
|
73
|
+
hasPending(now: number = Date.now()): boolean {
|
|
74
|
+
return this.pending.some((p) => now - p.atMs < PENDING_USER_NOTICE_TTL_MS)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private prune(now: number): void {
|
|
78
|
+
this.pending = this.pending.filter((p) => now - p.atMs < PENDING_USER_NOTICE_TTL_MS)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Test-only: forget everything. */
|
|
82
|
+
reset(): void {
|
|
83
|
+
this.pending = []
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The process-wide gate the gateway consults. */
|
|
88
|
+
export const pendingUserNoticeGate = new PendingUserNoticeGate()
|
|
@@ -67,3 +67,102 @@ export function tzAbbrev(ms: number, tz: string): string {
|
|
|
67
67
|
.find((p) => p.type === 'timeZoneName')?.value ?? tz
|
|
68
68
|
)
|
|
69
69
|
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Resolve the agent's configured timezone from the process environment, the
|
|
73
|
+
* SAME cascade `bin/timezone-hook.sh` and the config resolver use:
|
|
74
|
+
* `SWITCHROOM_TIMEZONE` → `TZ` → `UTC`. Centralised so the inbound-tag,
|
|
75
|
+
* forwarded_date, and recent-buffer callsites can't drift.
|
|
76
|
+
*/
|
|
77
|
+
export function resolveEnvTimezone(env: NodeJS.ProcessEnv = process.env): string {
|
|
78
|
+
return env.SWITCHROOM_TIMEZONE ?? env.TZ ?? 'UTC'
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Full model-facing local wall-clock stamp, e.g.
|
|
83
|
+
* `Thursday 2026-07-16 04:09 PM AEST`.
|
|
84
|
+
*
|
|
85
|
+
* The deterministic replacement for the UTC ISO strings the model used to see
|
|
86
|
+
* on inbound channel tags (`ts="…Z"`), `forwarded_date`, and the
|
|
87
|
+
* `get_recent_messages` buffer. am/pm form in the agent's CONFIGURED timezone,
|
|
88
|
+
* with NO "UTC" / trailing-Z — so the LLM can never read one of these as UTC
|
|
89
|
+
* "now" and reason an offset wrong.
|
|
90
|
+
*
|
|
91
|
+
* Format matches the CORE of `bin/timezone-hook.sh`'s stamp —
|
|
92
|
+
* `%A %Y-%m-%d %I:%M %p %Z` (weekday, ISO date, am/pm, zone abbrev) — so the
|
|
93
|
+
* inbound-tag time and the UserPromptSubmit local-time hint read the same way.
|
|
94
|
+
* The hook additionally appends a ` (UTC±HH:MM)` numeric-offset LABEL that this
|
|
95
|
+
* helper deliberately omits: the abbrev (AEST/EDT) already disambiguates, and
|
|
96
|
+
* keeping the output free of any "UTC" substring makes the deterministic
|
|
97
|
+
* no-UTC-current-time guard trivially strict for every callsite. So it is NOT
|
|
98
|
+
* a byte-for-byte match — same core shape, minus the offset tail.
|
|
99
|
+
*
|
|
100
|
+
* Pure / total: an invalid IANA `tz` (misconfigured agent) degrades to the
|
|
101
|
+
* same am/pm shape rendered in UTC rather than throwing out of the inbound
|
|
102
|
+
* path — a bad zone must never crash a turn.
|
|
103
|
+
*/
|
|
104
|
+
export function fmtLocalStamp(ms: number, tz: string): string {
|
|
105
|
+
try {
|
|
106
|
+
const at = new Date(ms)
|
|
107
|
+
const weekday = new Intl.DateTimeFormat('en-US', {
|
|
108
|
+
timeZone: tz,
|
|
109
|
+
weekday: 'long',
|
|
110
|
+
}).format(at)
|
|
111
|
+
// localDay (en-CA) yields YYYY-MM-DD and throws first on a bad zone.
|
|
112
|
+
const date = localDay(ms, tz)
|
|
113
|
+
const time = new Intl.DateTimeFormat('en-US', {
|
|
114
|
+
timeZone: tz,
|
|
115
|
+
hour: '2-digit',
|
|
116
|
+
minute: '2-digit',
|
|
117
|
+
hour12: true,
|
|
118
|
+
}).format(at) // "04:09 PM"
|
|
119
|
+
return `${weekday} ${date} ${time} ${tzAbbrev(ms, tz)}`
|
|
120
|
+
} catch {
|
|
121
|
+
// Invalid IANA zone — degrade to am/pm in UTC rather than crash the turn.
|
|
122
|
+
if (tz !== 'UTC') return fmtLocalStamp(ms, 'UTC')
|
|
123
|
+
return new Date(ms).toISOString()
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Leading ISO-8601-Z timestamp at the start of a log line, e.g.
|
|
129
|
+
* `2026-07-16T04:09:00.123456789Z` or `2026-07-16T04:09:00Z`. Docker log
|
|
130
|
+
* lines (as surfaced by `switchroom agent logs`) carry one of these per line.
|
|
131
|
+
* Anchored at line start; the trailing capture is the rest of the line.
|
|
132
|
+
*/
|
|
133
|
+
const LEADING_ISO_Z = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z)(\s|$)/
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* DISPLAY-ONLY: rewrite a leading UTC ISO-8601-Z timestamp on each line of
|
|
137
|
+
* `text` into the caller's LOCAL am/pm wall clock via {@link fmtLocalStamp},
|
|
138
|
+
* so `/logs` output reads `Thursday 2026-07-16 02:09 PM AEST …` instead of a
|
|
139
|
+
* raw `…T04:09:00Z`. Applied at send time; never mutates stored logs.
|
|
140
|
+
*
|
|
141
|
+
* The leading stamp comes from `docker logs --timestamps` (requested by the
|
|
142
|
+
* /logs path via `switchroom agent logs --timestamps`) — docker's stamp is
|
|
143
|
+
* ALWAYS UTC ISO-Z, which is what makes this conversion deterministic. App-
|
|
144
|
+
* emitted stamps inside the line body (e.g. Python's `%H:%M:%S,mmm`) are
|
|
145
|
+
* deliberately NOT converted: post-#3275 containers run with local TZ baked,
|
|
146
|
+
* so those are already local wall clock — re-shifting them as UTC would be
|
|
147
|
+
* wrong. The `tz` passed by /logs is the GATEWAY/operator zone
|
|
148
|
+
* (`resolveEnvTimezone` of the gateway process), not the target agent's
|
|
149
|
+
* zone — intended, since /logs is an operator-facing surface.
|
|
150
|
+
*
|
|
151
|
+
* Pure / total — matches ONLY a well-formed leading ISO-Z stamp and preserves
|
|
152
|
+
* every other line (and any line whose timestamp doesn't parse) verbatim, so a
|
|
153
|
+
* non-timestamped or partial log line is passed through untouched. `\r`
|
|
154
|
+
* line endings are preserved.
|
|
155
|
+
*/
|
|
156
|
+
export function renderLogTimestampsLocal(text: string, tz: string): string {
|
|
157
|
+
if (!text) return text
|
|
158
|
+
return text
|
|
159
|
+
.split('\n')
|
|
160
|
+
.map((line) => {
|
|
161
|
+
const m = LEADING_ISO_Z.exec(line)
|
|
162
|
+
if (!m) return line
|
|
163
|
+
const ms = Date.parse(m[1])
|
|
164
|
+
if (Number.isNaN(ms)) return line
|
|
165
|
+
return `${fmtLocalStamp(ms, tz)}${m[2] === '' ? '' : ' '}${line.slice(m[0].length)}`
|
|
166
|
+
})
|
|
167
|
+
.join('\n')
|
|
168
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
BackstopDeliveryLedger,
|
|
5
|
+
backstopReceiptIds,
|
|
6
|
+
backstopDelivered,
|
|
7
|
+
runBackstopDelivery,
|
|
8
|
+
} from '../gateway/backstop-delivery.js'
|
|
9
|
+
import {
|
|
10
|
+
backstopSendOutcomeGated,
|
|
11
|
+
finalizeBackstopSendGated,
|
|
12
|
+
computeTurnStatus,
|
|
13
|
+
} from '../gateway/turn-record-status.js'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* #3276 — the turn-flush backstop delivered by editing the ephemeral progress
|
|
17
|
+
* card and counted that card-edit id as an answer delivery, so the turn record
|
|
18
|
+
* said `complete` while the card was GC'd and nothing durable reached the chat.
|
|
19
|
+
*
|
|
20
|
+
* These assert the deterministic OUTCOMES the fix guarantees:
|
|
21
|
+
* - a delivered answer is a FRESH non-card chat id (guard 7),
|
|
22
|
+
* - `complete` IFF such an id exists; card-only "success" ⇒ `send_failed`,
|
|
23
|
+
* - a per-turn backstop double-fire latch (guard 5),
|
|
24
|
+
* - a bounded retry resumes mid-chunk and never re-sends chunk 0 (guard 6),
|
|
25
|
+
* - terminal failure reports `delivered:false` so the caller leaves the
|
|
26
|
+
* delivery obligation OPEN (finding 1).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
describe('guard 7 — receipt gate: a delivered answer is a FRESH non-card chat id', () => {
|
|
30
|
+
it('primary: a fresh chat id is recorded whose id ≠ any card id', () => {
|
|
31
|
+
const cardId = 500
|
|
32
|
+
const sentIds = [777] // a fresh sendMessage id, not the card
|
|
33
|
+
const fresh = backstopReceiptIds(sentIds, cardId)
|
|
34
|
+
expect(fresh).toEqual([777])
|
|
35
|
+
expect(fresh).not.toContain(cardId)
|
|
36
|
+
expect(backstopDelivered(sentIds, cardId)).toBe(true)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('card-present: the delivered id excludes backstopCardMessageId', () => {
|
|
40
|
+
const cardId = 18944
|
|
41
|
+
// The pre-fix backstop pushed the card id into sentIds via editMessageText.
|
|
42
|
+
const raw = [18944]
|
|
43
|
+
expect(backstopReceiptIds(raw, cardId)).toEqual([])
|
|
44
|
+
expect(backstopDelivered(raw, cardId)).toBe(false)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('mixed: only the fresh ids survive the gate', () => {
|
|
48
|
+
const cardId = 18944
|
|
49
|
+
expect(backstopReceiptIds([18944, 18950, 18951], cardId)).toEqual([18950, 18951])
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('status honesty — complete IFF a real non-card id exists', () => {
|
|
54
|
+
it('card-only delivery ⇒ send_failed, never complete', () => {
|
|
55
|
+
const cardId = 18944
|
|
56
|
+
const turn: { finalAnswerDelivered: boolean; deliveryOutcome?: 'delivered' | 'failed' | 'suppressed' } = {
|
|
57
|
+
finalAnswerDelivered: true,
|
|
58
|
+
}
|
|
59
|
+
finalizeBackstopSendGated(turn, {
|
|
60
|
+
threw: false,
|
|
61
|
+
sentIds: [18944], // only the card was edited
|
|
62
|
+
chunkCount: 1,
|
|
63
|
+
cardMessageId: cardId,
|
|
64
|
+
})
|
|
65
|
+
expect(turn.deliveryOutcome).toBe('failed')
|
|
66
|
+
expect(computeTurnStatus(turn)).toBe('send_failed')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('fresh chat id delivered ⇒ complete', () => {
|
|
70
|
+
const turn: { finalAnswerDelivered: boolean; deliveryOutcome?: 'delivered' | 'failed' | 'suppressed' } = {
|
|
71
|
+
finalAnswerDelivered: true,
|
|
72
|
+
}
|
|
73
|
+
finalizeBackstopSendGated(turn, {
|
|
74
|
+
threw: false,
|
|
75
|
+
sentIds: [18950],
|
|
76
|
+
chunkCount: 1,
|
|
77
|
+
cardMessageId: 18944,
|
|
78
|
+
})
|
|
79
|
+
expect(turn.deliveryOutcome).toBe('delivered')
|
|
80
|
+
expect(computeTurnStatus(turn)).toBe('complete')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('partial multi-chunk (fewer fresh ids than chunks) ⇒ send_failed', () => {
|
|
84
|
+
expect(
|
|
85
|
+
backstopSendOutcomeGated({ threw: false, sentIds: [18950], chunkCount: 2, cardMessageId: null }),
|
|
86
|
+
).toBe('failed')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('throw ⇒ failed regardless of ids', () => {
|
|
90
|
+
expect(
|
|
91
|
+
backstopSendOutcomeGated({ threw: true, sentIds: [18950], chunkCount: 1, cardMessageId: null }),
|
|
92
|
+
).toBe('failed')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('empty split (0 chunks) ⇒ failed, not delivered', () => {
|
|
96
|
+
expect(
|
|
97
|
+
backstopSendOutcomeGated({ threw: false, sentIds: [], chunkCount: 0, cardMessageId: null }),
|
|
98
|
+
).toBe('failed')
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe('guard 5 — once-per-turn backstop double-fire latch', () => {
|
|
103
|
+
it('claim returns true exactly once per turnId', () => {
|
|
104
|
+
const ledger = new BackstopDeliveryLedger()
|
|
105
|
+
expect(ledger.claim('#18925')).toBe(true)
|
|
106
|
+
expect(ledger.claim('#18925')).toBe(false)
|
|
107
|
+
// A different turn is independent.
|
|
108
|
+
expect(ledger.claim('#18929')).toBe(true)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('release re-opens the latch after a terminal failure', () => {
|
|
112
|
+
const ledger = new BackstopDeliveryLedger()
|
|
113
|
+
ledger.claim('#t')
|
|
114
|
+
ledger.release('#t')
|
|
115
|
+
expect(ledger.claim('#t')).toBe(true)
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe('guard 6 — per-chunk idempotency ledger: retry never re-sends chunk 0', () => {
|
|
120
|
+
it('resumes at the first unsent chunk after a partial send', () => {
|
|
121
|
+
const ledger = new BackstopDeliveryLedger()
|
|
122
|
+
const turnId = '#partial'
|
|
123
|
+
const chunkCount = 3
|
|
124
|
+
ledger.markPending(turnId, 0)
|
|
125
|
+
ledger.recordChunk(turnId, 0, [1001])
|
|
126
|
+
ledger.markPending(turnId, 1)
|
|
127
|
+
ledger.recordChunk(turnId, 1, [1002])
|
|
128
|
+
ledger.markPending(turnId, 2) // in-flight, never acked
|
|
129
|
+
|
|
130
|
+
expect(ledger.hasChunk(turnId, 0)).toBe(true)
|
|
131
|
+
expect(ledger.hasChunk(turnId, 1)).toBe(true)
|
|
132
|
+
expect(ledger.hasChunk(turnId, 2)).toBe(false)
|
|
133
|
+
expect(ledger.unsentIndices(turnId, chunkCount)).toEqual([2])
|
|
134
|
+
|
|
135
|
+
ledger.recordChunk(turnId, 2, [1003])
|
|
136
|
+
expect(ledger.sentIds(turnId)).toEqual([1001, 1002, 1003])
|
|
137
|
+
expect(ledger.sentIds(turnId)[0]).toBe(1001)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('sentIds are returned in chunk-index order regardless of record order', () => {
|
|
141
|
+
const ledger = new BackstopDeliveryLedger()
|
|
142
|
+
ledger.recordChunk('#o', 2, [3])
|
|
143
|
+
ledger.recordChunk('#o', 0, [1])
|
|
144
|
+
ledger.recordChunk('#o', 1, [2])
|
|
145
|
+
expect(ledger.sentIds('#o')).toEqual([1, 2, 3])
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('entries() zip a resplit chunk (2 ids for 1 input chunk) in order', () => {
|
|
149
|
+
const ledger = new BackstopDeliveryLedger()
|
|
150
|
+
ledger.recordChunk('#z', 0, [10])
|
|
151
|
+
ledger.recordChunk('#z', 1, [11, 12]) // chunk 1 length-resplit into 2 sends
|
|
152
|
+
expect(ledger.entries('#z')).toEqual([
|
|
153
|
+
{ index: 0, messageIds: [10] },
|
|
154
|
+
{ index: 1, messageIds: [11, 12] },
|
|
155
|
+
])
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Integration oracles over `runBackstopDelivery` — the exact orchestration that
|
|
161
|
+
* replaced the card-coupled send. It drives the real retry/ledger/receipt code
|
|
162
|
+
* with an injected `sendChunk`, asserting on what reaches `recordOutbound` and
|
|
163
|
+
* on the `delivered`/`exhausted` decision the gateway feeds to the obligation
|
|
164
|
+
* ledger + turn record.
|
|
165
|
+
*/
|
|
166
|
+
describe('runBackstopDelivery — integration oracle over the delivery wiring', () => {
|
|
167
|
+
it('records a FRESH non-card id in history; delivered=true (#3276 primary)', async () => {
|
|
168
|
+
const ledger = new BackstopDeliveryLedger()
|
|
169
|
+
const cardId = 18944
|
|
170
|
+
const recorded: Array<{ ids: number[]; texts: string[] }> = []
|
|
171
|
+
const sendChunk = vi.fn(async (i: number) => [18950 + i]) // fresh chat ids
|
|
172
|
+
const res = await runBackstopDelivery(
|
|
173
|
+
ledger,
|
|
174
|
+
'#18925',
|
|
175
|
+
['the answer'],
|
|
176
|
+
cardId,
|
|
177
|
+
{ sendChunk, recordOutbound: (ids, texts) => recorded.push({ ids, texts }) },
|
|
178
|
+
)
|
|
179
|
+
expect(res.delivered).toBe(true)
|
|
180
|
+
expect(res.exhausted).toBe(false)
|
|
181
|
+
expect(recorded).toHaveLength(1)
|
|
182
|
+
// A real fresh chat id landed in history whose id ≠ the progress-card id.
|
|
183
|
+
expect(recorded[0].ids).toEqual([18950])
|
|
184
|
+
expect(recorded[0].ids).not.toContain(cardId)
|
|
185
|
+
expect(backstopReceiptIds(recorded[0].ids, cardId)).toEqual([18950])
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('card-only "delivery" can never happen — the receipt gate excludes the card id', async () => {
|
|
189
|
+
const ledger = new BackstopDeliveryLedger()
|
|
190
|
+
const cardId = 18944
|
|
191
|
+
// Even if a buggy send echoed the card id, the receipt gate drops it.
|
|
192
|
+
const sendChunk = vi.fn(async () => [cardId])
|
|
193
|
+
const res = await runBackstopDelivery(ledger, '#c', ['x'], cardId, { sendChunk })
|
|
194
|
+
expect(res.delivered).toBe(false)
|
|
195
|
+
expect(res.exhausted).toBe(true)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('retry resumes mid-chunk — chunk 0 is NOT re-sent on attempt 2 (guard 6, finding 1)', async () => {
|
|
199
|
+
const ledger = new BackstopDeliveryLedger()
|
|
200
|
+
const calls: number[] = []
|
|
201
|
+
let failedOnce = false
|
|
202
|
+
const sendChunk = vi.fn(async (i: number) => {
|
|
203
|
+
calls.push(i)
|
|
204
|
+
if (i === 2 && !failedOnce) {
|
|
205
|
+
failedOnce = true
|
|
206
|
+
throw new Error('FLOOD_WAIT_ACTIVE')
|
|
207
|
+
}
|
|
208
|
+
return [700 + i]
|
|
209
|
+
})
|
|
210
|
+
const res = await runBackstopDelivery(ledger, '#resume', ['c0', 'c1', 'c2'], null, { sendChunk }, 3)
|
|
211
|
+
|
|
212
|
+
expect(res.delivered).toBe(true)
|
|
213
|
+
expect(res.sentIds).toEqual([700, 701, 702])
|
|
214
|
+
// chunk 0 and 1 sent exactly once; chunk 2 attempted twice (fail, then ok).
|
|
215
|
+
expect(calls.filter(i => i === 0)).toHaveLength(1) // <-- chunk 0 never re-sent
|
|
216
|
+
expect(calls.filter(i => i === 1)).toHaveLength(1)
|
|
217
|
+
expect(calls.filter(i => i === 2)).toHaveLength(2)
|
|
218
|
+
expect(res.attempts).toBe(2)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('terminal failure ⇒ delivered=false / exhausted=true after maxAttempts (obligation left open)', async () => {
|
|
222
|
+
const ledger = new BackstopDeliveryLedger()
|
|
223
|
+
const sendChunk = vi.fn(async () => { throw new Error('FLOOD_WAIT_ACTIVE') })
|
|
224
|
+
const res = await runBackstopDelivery(ledger, '#dead', ['only chunk'], null, { sendChunk }, 3)
|
|
225
|
+
expect(res.delivered).toBe(false)
|
|
226
|
+
expect(res.exhausted).toBe(true)
|
|
227
|
+
expect(res.attempts).toBe(3) // exhausted the bounded retry
|
|
228
|
+
expect(res.sentIds).toEqual([]) // nothing landed
|
|
229
|
+
// This is the exact input the gateway uses: delivered=false ⇒ it records
|
|
230
|
+
// send_failed AND leaves the obligation OPEN (noteTurnEnded, not close).
|
|
231
|
+
expect(backstopSendOutcomeGated({
|
|
232
|
+
threw: !res.delivered, sentIds: res.sentIds, chunkCount: res.chunkCount, cardMessageId: null,
|
|
233
|
+
})).toBe('failed')
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('recordOutbound texts are ALIGNED to sent ids even when a chunk resplits (finding 5)', async () => {
|
|
237
|
+
const ledger = new BackstopDeliveryLedger()
|
|
238
|
+
const recorded: Array<{ ids: number[]; texts: string[] }> = []
|
|
239
|
+
// chunk 1 lands TWO ids (a length-resplit); a naive chunks.slice zip would
|
|
240
|
+
// misalign. entries()-based zip repeats the source text per landed id.
|
|
241
|
+
const sendChunk = vi.fn(async (i: number) => (i === 1 ? [11, 12] : [10]))
|
|
242
|
+
await runBackstopDelivery(
|
|
243
|
+
ledger, '#zip', ['A', 'B'], null,
|
|
244
|
+
{ sendChunk, recordOutbound: (ids, texts) => recorded.push({ ids, texts }) },
|
|
245
|
+
)
|
|
246
|
+
expect(recorded[0].ids).toEqual([10, 11, 12])
|
|
247
|
+
expect(recorded[0].texts).toEqual(['A', 'B', 'B'])
|
|
248
|
+
expect(recorded[0].ids).toHaveLength(recorded[0].texts.length)
|
|
249
|
+
})
|
|
250
|
+
})
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outcome regression for the forwarded-message history row (#3300 / #3162).
|
|
3
|
+
*
|
|
4
|
+
* SCOPE (honest): this suite pins the PERSISTENCE leg of the end-to-end
|
|
5
|
+
* chain — `parseForwardOrigin` (#3162) feeding `recordInbound` against the
|
|
6
|
+
* real bun:sqlite history store. The ROUTING leg (an update reaching the
|
|
7
|
+
* pipeline at all — the layer where the 2026-07-16 silent drop happened) is
|
|
8
|
+
* pinned by `catch-all-unhandled-message.test.ts`, which drives the real
|
|
9
|
+
* production catch-all module on a real grammy composer. Together the two
|
|
10
|
+
* suites cover the chain; this one alone also passes on pre-#3300 code
|
|
11
|
+
* because #3162's persistence was always correct — it was simply never
|
|
12
|
+
* reached for the dropped message.
|
|
13
|
+
*
|
|
14
|
+
* Runs under bun (history.ts uses bun:sqlite; gateway.ts itself is a
|
|
15
|
+
* side-effecting module that cannot be imported into a test).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
|
|
19
|
+
import { mkdtempSync, rmSync } from 'fs'
|
|
20
|
+
import { tmpdir } from 'os'
|
|
21
|
+
import { join } from 'path'
|
|
22
|
+
import {
|
|
23
|
+
initHistory,
|
|
24
|
+
recordInbound,
|
|
25
|
+
query as queryHistory,
|
|
26
|
+
_resetForTests as resetHistory,
|
|
27
|
+
} from '../history.js'
|
|
28
|
+
import { parseForwardOrigin } from '../gateway/forward-origin.js'
|
|
29
|
+
|
|
30
|
+
let stateDir: string
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
resetHistory()
|
|
34
|
+
stateDir = mkdtempSync(join(tmpdir(), 'catch-all-forward-'))
|
|
35
|
+
initHistory(stateDir, 0) // 0 disables the init-time prune so we can seed cleanly
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
resetHistory()
|
|
40
|
+
rmSync(stateDir, { recursive: true, force: true })
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('(a) a forwarded plain-text message records a history row with forwarded_from', () => {
|
|
44
|
+
it('parses the server-stamped forward_origin and persists forwarded_from', () => {
|
|
45
|
+
const chat_id = '777'
|
|
46
|
+
const message_id = 8100
|
|
47
|
+
// Telegram Bot API 7.0 forward_origin, server-stamped (untrusted body,
|
|
48
|
+
// trusted attrs) — a plain-text message forwarded from a user.
|
|
49
|
+
const forwardOrigin = parseForwardOrigin({
|
|
50
|
+
type: 'user',
|
|
51
|
+
date: 1_700_000_000,
|
|
52
|
+
sender_user: { id: 424242, is_bot: false, first_name: 'Ken', last_name: 'Thompson' },
|
|
53
|
+
})
|
|
54
|
+
expect(forwardOrigin).toBeDefined()
|
|
55
|
+
expect(forwardOrigin!.name).toBe('Ken Thompson')
|
|
56
|
+
|
|
57
|
+
recordInbound({
|
|
58
|
+
chat_id,
|
|
59
|
+
thread_id: null,
|
|
60
|
+
message_id,
|
|
61
|
+
user: 'ken',
|
|
62
|
+
user_id: '777',
|
|
63
|
+
ts: 1_700_000_100,
|
|
64
|
+
text: 'here is the brief I forwarded',
|
|
65
|
+
forwarded_from: forwardOrigin!.name,
|
|
66
|
+
forwarded_from_type: forwardOrigin!.type,
|
|
67
|
+
forwarded_from_id: forwardOrigin!.id != null ? String(forwardOrigin!.id) : null,
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const rows = queryHistory({ chat_id, limit: 10 })
|
|
71
|
+
expect(rows).toHaveLength(1)
|
|
72
|
+
const row = rows[0]
|
|
73
|
+
// A delivered turn is present (the message was NOT dropped) AND carries
|
|
74
|
+
// forwarded provenance.
|
|
75
|
+
expect(row.role).toBe('user')
|
|
76
|
+
expect(row.text).toBe('here is the brief I forwarded')
|
|
77
|
+
expect(row.forwarded_from).toBe('Ken Thompson')
|
|
78
|
+
expect(row.forwarded_from_type).toBe('user')
|
|
79
|
+
expect(row.forwarded_from_id).toBe('424242')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('a non-forwarded message leaves forwarded_from NULL (no false provenance)', () => {
|
|
83
|
+
const chat_id = '778'
|
|
84
|
+
// undefined forward_origin → parseForwardOrigin returns undefined.
|
|
85
|
+
const forwardOrigin = parseForwardOrigin(undefined)
|
|
86
|
+
expect(forwardOrigin).toBeUndefined()
|
|
87
|
+
|
|
88
|
+
recordInbound({
|
|
89
|
+
chat_id,
|
|
90
|
+
thread_id: null,
|
|
91
|
+
message_id: 8200,
|
|
92
|
+
user: 'ken',
|
|
93
|
+
user_id: '778',
|
|
94
|
+
ts: 1_700_000_200,
|
|
95
|
+
text: 'a normal message',
|
|
96
|
+
forwarded_from: forwardOrigin ?? null,
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
const rows = queryHistory({ chat_id, limit: 10 })
|
|
100
|
+
expect(rows).toHaveLength(1)
|
|
101
|
+
expect(rows[0].forwarded_from).toBeNull()
|
|
102
|
+
})
|
|
103
|
+
})
|