switchroom 0.17.10 → 0.18.6
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/workspace-dynamic-hook.sh +12 -13
- package/dist/agent-scheduler/index.js +29 -2
- package/dist/auth-broker/index.js +6163 -152
- package/dist/cli/notion-write-pretool.mjs +31 -3
- package/dist/cli/switchroom.js +695 -526
- package/dist/host-control/main.js +6184 -173
- package/dist/vault/approvals/kernel-server.js +5893 -165
- package/dist/vault/broker/server.js +6666 -921
- package/package.json +1 -1
- package/profiles/_base/settings.json.hbs +2 -2
- package/profiles/_base/start.sh.hbs +170 -21
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/skills/switchroom-release/SKILL.md +78 -0
- package/telegram-plugin/auth-snapshot-format.ts +37 -25
- package/telegram-plugin/context-exhaustion.ts +124 -0
- package/telegram-plugin/dist/gateway/gateway.js +25025 -9203
- package/telegram-plugin/gateway/activity-card-store.ts +76 -0
- package/telegram-plugin/gateway/gateway.ts +740 -106
- package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
- package/telegram-plugin/gateway/model-command.ts +70 -10
- package/telegram-plugin/gateway/resolve-person.ts +304 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +21 -1
- package/telegram-plugin/hooks/silent-end-scan.mjs +164 -40
- package/telegram-plugin/operator-events.ts +21 -0
- package/telegram-plugin/package.json +6 -0
- package/telegram-plugin/pending-work-progress.ts +42 -7
- package/telegram-plugin/quota-bar-format.ts +360 -0
- package/telegram-plugin/quota-watch.ts +4 -6
- package/telegram-plugin/registry/turns-schema.test.ts +97 -0
- package/telegram-plugin/registry/turns-schema.ts +78 -0
- package/telegram-plugin/render/ir.ts +209 -0
- package/telegram-plugin/render/parse.ts +363 -0
- package/telegram-plugin/render/render.ts +440 -0
- package/telegram-plugin/render/rich-render.ts +72 -0
- package/telegram-plugin/stream-controller.ts +14 -3
- package/telegram-plugin/subagent-watcher.ts +27 -9
- package/telegram-plugin/tests/activity-card-store.test.ts +94 -0
- package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +51 -16
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +11 -0
- package/telegram-plugin/tests/feed-survival.test.ts +39 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
- package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
- package/telegram-plugin/tests/model-command.test.ts +193 -16
- package/telegram-plugin/tests/narrative-render.test.ts +125 -0
- package/telegram-plugin/tests/operator-events.test.ts +16 -0
- package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
- package/telegram-plugin/tests/pending-work-progress.test.ts +116 -3
- package/telegram-plugin/tests/quota-bar-format.test.ts +444 -0
- package/telegram-plugin/tests/quota-watch.test.ts +1 -4
- package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
- package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
- package/telegram-plugin/tests/render/parse.test.ts +393 -0
- package/telegram-plugin/tests/render/render.test.ts +436 -0
- package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
- package/telegram-plugin/tests/resolve-person.test.ts +290 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +53 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +138 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +61 -0
- package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +19 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +97 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +98 -3
- package/telegram-plugin/turn-liveness-floor.ts +35 -1
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
- package/telegram-plugin/worker-activity-feed.ts +220 -15
- package/telegram-plugin/worktree-watch-cwds.ts +92 -17
- package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
- package/vendor/hindsight-memory/scripts/lib/config.py +9 -2
- package/vendor/hindsight-memory/scripts/recall.py +64 -6
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
- package/vendor/hindsight-memory/tests/test_client.py +43 -0
- package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
- package/profiles/default/CLAUDE.md +0 -116
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
- package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for quota-bar-format.ts — bar-building, time-left formatting, and
|
|
3
|
+
* the full block assembly. Pure functions, frozen-clock fixtures.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
import {
|
|
7
|
+
pickDot,
|
|
8
|
+
formatTimeLeft,
|
|
9
|
+
elapsedFraction,
|
|
10
|
+
buildBar,
|
|
11
|
+
accountStatus,
|
|
12
|
+
formatWindowRow,
|
|
13
|
+
renderQuotaBarAccount,
|
|
14
|
+
renderQuotaBarBlockFromListState,
|
|
15
|
+
renderUsageCard,
|
|
16
|
+
} from '../quota-bar-format.js';
|
|
17
|
+
import type { AccountSnapshot } from '../auth-snapshot-format.js';
|
|
18
|
+
import type { QuotaUtilization } from '../quota-check.js';
|
|
19
|
+
import type { ListStateData } from '../../src/auth/broker/client.js';
|
|
20
|
+
|
|
21
|
+
const NOW = new Date('2026-07-10T00:00:00.000Z');
|
|
22
|
+
|
|
23
|
+
function quota(part: Partial<QuotaUtilization>): QuotaUtilization {
|
|
24
|
+
return {
|
|
25
|
+
fiveHourUtilizationPct: 0,
|
|
26
|
+
sevenDayUtilizationPct: 0,
|
|
27
|
+
fiveHourResetAt: null,
|
|
28
|
+
sevenDayResetAt: null,
|
|
29
|
+
representativeClaim: null,
|
|
30
|
+
overageStatus: null,
|
|
31
|
+
overageDisabledReason: null,
|
|
32
|
+
...part,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── pickDot ──────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
describe('pickDot', () => {
|
|
39
|
+
it('green under 50%', () => {
|
|
40
|
+
expect(pickDot(0)).toBe('🟢');
|
|
41
|
+
expect(pickDot(49)).toBe('🟢');
|
|
42
|
+
});
|
|
43
|
+
it('yellow 50-89%', () => {
|
|
44
|
+
expect(pickDot(50)).toBe('🟡');
|
|
45
|
+
expect(pickDot(89)).toBe('🟡');
|
|
46
|
+
});
|
|
47
|
+
it('red 90%+', () => {
|
|
48
|
+
expect(pickDot(90)).toBe('🔴');
|
|
49
|
+
expect(pickDot(100)).toBe('🔴');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// ── formatTimeLeft ───────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
describe('formatTimeLeft', () => {
|
|
56
|
+
it('returns "resets now" when the target is null', () => {
|
|
57
|
+
expect(formatTimeLeft(null, NOW)).toBe('resets now');
|
|
58
|
+
});
|
|
59
|
+
it('returns "resets now" when the target has already passed', () => {
|
|
60
|
+
expect(formatTimeLeft(new Date(NOW.getTime() - 1000), NOW)).toBe('resets now');
|
|
61
|
+
});
|
|
62
|
+
it('returns "resets now" when the target is exactly now', () => {
|
|
63
|
+
expect(formatTimeLeft(new Date(NOW.getTime()), NOW)).toBe('resets now');
|
|
64
|
+
});
|
|
65
|
+
it('formats sub-hour deltas as minutes', () => {
|
|
66
|
+
expect(formatTimeLeft(new Date(NOW.getTime() + 45 * 60_000), NOW)).toBe('45m left');
|
|
67
|
+
});
|
|
68
|
+
it('formats sub-day deltas as compact hours+minutes', () => {
|
|
69
|
+
expect(formatTimeLeft(new Date(NOW.getTime() + (4 * 60 + 20) * 60_000), NOW)).toBe('4h20m left');
|
|
70
|
+
});
|
|
71
|
+
it('formats multi-day deltas as compact days+hours', () => {
|
|
72
|
+
expect(formatTimeLeft(new Date(NOW.getTime() + (3 * 24 + 1) * 60 * 60_000), NOW)).toBe('3d1h left');
|
|
73
|
+
});
|
|
74
|
+
it('drops the hours segment when exactly on a day boundary', () => {
|
|
75
|
+
expect(formatTimeLeft(new Date(NOW.getTime() + 2 * 24 * 60 * 60_000), NOW)).toBe('2d left');
|
|
76
|
+
});
|
|
77
|
+
it('returns "resets now" (never "NaNm left") for an invalid reset date', () => {
|
|
78
|
+
expect(formatTimeLeft(new Date('not-a-date'), NOW)).toBe('resets now');
|
|
79
|
+
expect(formatTimeLeft(new Date(NaN), NOW)).toBe('resets now');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ── elapsedFraction ──────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
describe('elapsedFraction', () => {
|
|
86
|
+
const fiveHourMs = 5 * 60 * 60 * 1000;
|
|
87
|
+
it('is 0 when there is no reset timestamp', () => {
|
|
88
|
+
expect(elapsedFraction(null, fiveHourMs, NOW)).toBe(0);
|
|
89
|
+
});
|
|
90
|
+
it('is 0 when the reset has already passed', () => {
|
|
91
|
+
expect(elapsedFraction(new Date(NOW.getTime() - 1000), fiveHourMs, NOW)).toBe(0);
|
|
92
|
+
});
|
|
93
|
+
it('is close to 1 just before reset', () => {
|
|
94
|
+
const frac = elapsedFraction(new Date(NOW.getTime() + 60_000), fiveHourMs, NOW);
|
|
95
|
+
expect(frac).toBeGreaterThan(0.99);
|
|
96
|
+
expect(frac).toBeLessThanOrEqual(1);
|
|
97
|
+
});
|
|
98
|
+
it('is ~0.5 halfway through the window', () => {
|
|
99
|
+
const frac = elapsedFraction(new Date(NOW.getTime() + fiveHourMs / 2), fiveHourMs, NOW);
|
|
100
|
+
expect(frac).toBeCloseTo(0.5, 5);
|
|
101
|
+
});
|
|
102
|
+
it('is 0 (never NaN) for an invalid reset date', () => {
|
|
103
|
+
expect(elapsedFraction(new Date('not-a-date'), fiveHourMs, NOW)).toBe(0);
|
|
104
|
+
expect(elapsedFraction(new Date(NaN), fiveHourMs, NOW)).toBe(0);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ── buildBar ─────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
describe('buildBar', () => {
|
|
111
|
+
it('is all empty cells with a tick at 0% pct and 0 elapsed fraction', () => {
|
|
112
|
+
expect(buildBar(0, 0)).toBe('┃░░░░░░░░░');
|
|
113
|
+
});
|
|
114
|
+
it('the tick always overrides the fill, even at 100% pct', () => {
|
|
115
|
+
expect(buildBar(100, 0.5)).toBe('█████┃████');
|
|
116
|
+
});
|
|
117
|
+
it('the tick overrides fill at the very last cell (100% pct, 100% elapsed)', () => {
|
|
118
|
+
expect(buildBar(100, 1)).toBe('█████████┃');
|
|
119
|
+
});
|
|
120
|
+
it('is exactly 10 characters for any pct/fraction combination', () => {
|
|
121
|
+
for (const pct of [0, 1, 33, 47, 89, 99, 100]) {
|
|
122
|
+
for (const frac of [0, 0.25, 0.5, 0.75, 1]) {
|
|
123
|
+
expect(buildBar(pct, frac).length).toBe(10);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
it('places the tick outside the filled region when there is room', () => {
|
|
128
|
+
// 20% filled (2 cells) + tick at fraction 0.8 → tick lands past the fill.
|
|
129
|
+
const bar = buildBar(20, 0.8);
|
|
130
|
+
expect(bar[0]).toBe('█');
|
|
131
|
+
expect(bar[1]).toBe('█');
|
|
132
|
+
expect(bar).toContain('┃');
|
|
133
|
+
});
|
|
134
|
+
it('over-pace: quota fill visibly punches through past the time-elapsed tick', () => {
|
|
135
|
+
// 80% quota used, only 30% of the way through the window — burning
|
|
136
|
+
// faster than the clock. Fill (8 cells) extends past the tick
|
|
137
|
+
// (index round(0.3*9)=3), so filled cells appear on BOTH sides of the
|
|
138
|
+
// tick — that's the "punch through" signal the operator asked for.
|
|
139
|
+
const bar = buildBar(80, 0.3);
|
|
140
|
+
expect(bar).toBe('███┃████░░');
|
|
141
|
+
const tickIndex = bar.indexOf('┃');
|
|
142
|
+
const filledAfterTick = bar.slice(tickIndex + 1).includes('█');
|
|
143
|
+
expect(filledAfterTick).toBe(true); // fill visibly overtakes the pace marker
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ── accountStatus ────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
describe('accountStatus', () => {
|
|
150
|
+
it('active wins even if the broker also flags the account exhausted', () => {
|
|
151
|
+
expect(accountStatus(true, true)).toBe('active');
|
|
152
|
+
});
|
|
153
|
+
it('reports exhausted for a non-active exhausted account', () => {
|
|
154
|
+
expect(accountStatus(false, true)).toBe('exhausted');
|
|
155
|
+
});
|
|
156
|
+
it('reports idle for a non-active, non-exhausted account', () => {
|
|
157
|
+
expect(accountStatus(false, false)).toBe('idle');
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ── formatWindowRow / renderQuotaBarAccount ─────────────────────────
|
|
162
|
+
|
|
163
|
+
describe('formatWindowRow', () => {
|
|
164
|
+
it('renders the locked row shape', () => {
|
|
165
|
+
const row = formatWindowRow('5h', 0, null, NOW);
|
|
166
|
+
expect(row).toBe('- 🟢 5h `[┃░░░░░░░░░] 0% / resets now`');
|
|
167
|
+
});
|
|
168
|
+
it('clamps a >100% probe value for display', () => {
|
|
169
|
+
const row = formatWindowRow('7d', 101, null, NOW);
|
|
170
|
+
expect(row).toContain('100%');
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe('renderQuotaBarAccount', () => {
|
|
175
|
+
it('renders a title line + two window rows', () => {
|
|
176
|
+
const q = quota({
|
|
177
|
+
fiveHourUtilizationPct: 0,
|
|
178
|
+
sevenDayUtilizationPct: 47,
|
|
179
|
+
sevenDayResetAt: new Date(NOW.getTime() + (3 * 24 + 1) * 60 * 60_000),
|
|
180
|
+
});
|
|
181
|
+
const lines = renderQuotaBarAccount('ken@example.com', true, false, q, NOW);
|
|
182
|
+
expect(lines).toHaveLength(3);
|
|
183
|
+
expect(lines[0]).toBe('- **ken@example.com** (active)');
|
|
184
|
+
expect(lines[1]).toBe('- 🟢 5h `[┃░░░░░░░░░] 0% / resets now`');
|
|
185
|
+
expect(lines[2]).toContain('🟢 7d');
|
|
186
|
+
expect(lines[2]).toContain('47% / 3d1h left');
|
|
187
|
+
});
|
|
188
|
+
it('escapes markdown-significant characters in the label (bold run, not a code span)', () => {
|
|
189
|
+
// Regression: the title line wraps label in **bold**, so a label
|
|
190
|
+
// containing `**` or `[...]` must be backslash-escaped (escapeMarkdown),
|
|
191
|
+
// not defused with codeSpanSafe (which only strips backticks and would
|
|
192
|
+
// leave `*`/`[`/`]` live, breaking the bold run or injecting a link).
|
|
193
|
+
const lines = renderQuotaBarAccount('a**b[x](evil)_c', false, false, null, NOW);
|
|
194
|
+
expect(lines[0]).toBe('- **a\\*\\*b\\[x\\](evil)\\_c** (idle)');
|
|
195
|
+
});
|
|
196
|
+
it('exhausted account at 0% utilization on both windows still shows exhausted status with healthy dots', () => {
|
|
197
|
+
const q = quota({ fiveHourUtilizationPct: 0, sevenDayUtilizationPct: 0 });
|
|
198
|
+
const lines = renderQuotaBarAccount('done@example.com', false, true, q, NOW);
|
|
199
|
+
expect(lines[0]).toBe('- **done@example.com** (exhausted)');
|
|
200
|
+
expect(lines[1]).toContain('🟢 5h');
|
|
201
|
+
expect(lines[2]).toContain('🟢 7d');
|
|
202
|
+
});
|
|
203
|
+
it('renders a distinct probe-failed warning (never a healthy green 0% bar) when quota is null', () => {
|
|
204
|
+
// #2959 blocking bug: a failed probe must NOT be indistinguishable from a
|
|
205
|
+
// fresh account with full headroom (🟢 0%). It must carry a ⚠️ glyph and a
|
|
206
|
+
// "no data" label, with no green dot and no fake 0% bar.
|
|
207
|
+
const lines = renderQuotaBarAccount('nobody@example.com', false, false, null, NOW);
|
|
208
|
+
expect(lines).toEqual([
|
|
209
|
+
'- **nobody@example.com** (idle)',
|
|
210
|
+
'- ⚠️ 5h `no data — probe failed`',
|
|
211
|
+
'- ⚠️ 7d `no data — probe failed`',
|
|
212
|
+
]);
|
|
213
|
+
const body = lines.join('\n');
|
|
214
|
+
expect(body).not.toContain('🟢');
|
|
215
|
+
expect(body).not.toContain('0%');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('renders a distinct thin-probe warning (never a healthy green 0% bar) when the probe is thin', () => {
|
|
219
|
+
// A thin/headerless probe carries no real utilization — must degrade to the
|
|
220
|
+
// same ⚠️ data-quality gap, not a confident 🟢 0%.
|
|
221
|
+
const thin = quota({ fiveHourUtilPresent: false, sevenDayUtilPresent: false });
|
|
222
|
+
const lines = renderQuotaBarAccount('thin@example.com', false, false, thin, NOW);
|
|
223
|
+
expect(lines).toEqual([
|
|
224
|
+
'- **thin@example.com** (idle)',
|
|
225
|
+
'- ⚠️ 5h `no data — thin probe`',
|
|
226
|
+
'- ⚠️ 7d `no data — thin probe`',
|
|
227
|
+
]);
|
|
228
|
+
expect(lines.join('\n')).not.toContain('🟢');
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// ── renderQuotaBarBlockFromListState ────────────────────────────────
|
|
233
|
+
|
|
234
|
+
describe('renderQuotaBarBlockFromListState', () => {
|
|
235
|
+
it('renders the locked multi-account block shape', () => {
|
|
236
|
+
const state: ListStateData = {
|
|
237
|
+
active: 'ken@example.com',
|
|
238
|
+
fallback_order: ['ken@example.com', 'alt@example.com'],
|
|
239
|
+
accounts: [
|
|
240
|
+
{
|
|
241
|
+
label: 'ken@example.com',
|
|
242
|
+
exhausted: false,
|
|
243
|
+
last_quota: {
|
|
244
|
+
fiveHourUtilizationPct: 0,
|
|
245
|
+
sevenDayUtilizationPct: 47,
|
|
246
|
+
fiveHourResetAt: new Date(NOW.getTime() + 60_000).toISOString(),
|
|
247
|
+
sevenDayResetAt: new Date(NOW.getTime() + (3 * 24 + 1) * 60 * 60_000).toISOString(),
|
|
248
|
+
representativeClaim: null,
|
|
249
|
+
overageStatus: null,
|
|
250
|
+
overageDisabledReason: null,
|
|
251
|
+
capturedAt: NOW.getTime(),
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
label: 'alt@example.com',
|
|
256
|
+
exhausted: true,
|
|
257
|
+
last_quota: {
|
|
258
|
+
fiveHourUtilizationPct: 0,
|
|
259
|
+
sevenDayUtilizationPct: 100,
|
|
260
|
+
fiveHourResetAt: new Date(NOW.getTime() - 60_000).toISOString(),
|
|
261
|
+
sevenDayResetAt: new Date(NOW.getTime() + 2 * 24 * 60 * 60_000 + 16 * 60 * 60_000).toISOString(),
|
|
262
|
+
representativeClaim: null,
|
|
263
|
+
overageStatus: null,
|
|
264
|
+
overageDisabledReason: null,
|
|
265
|
+
capturedAt: NOW.getTime(),
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
],
|
|
269
|
+
agents: [],
|
|
270
|
+
consumers: [],
|
|
271
|
+
};
|
|
272
|
+
const block = renderQuotaBarBlockFromListState(state, { now: NOW });
|
|
273
|
+
const lines = block.split('\n');
|
|
274
|
+
expect(lines).toEqual([
|
|
275
|
+
'- **ken@example.com** (active)',
|
|
276
|
+
'- 🟢 5h `[░░░░░░░░░┃] 0% / 1m left`',
|
|
277
|
+
'- 🟢 7d `[█████┃░░░░] 47% / 3d1h left`',
|
|
278
|
+
'- **alt@example.com** (exhausted)',
|
|
279
|
+
'- 🟢 5h `[┃░░░░░░░░░] 0% / resets now`',
|
|
280
|
+
'- 🔴 7d `[██████┃███] 100% / 2d16h left`',
|
|
281
|
+
]);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// ── renderUsageCard (bar block only — /usage no longer appends the ─────
|
|
286
|
+
// Format 2 table underneath; see gateway.ts's `bot.command('usage', ...)`)
|
|
287
|
+
|
|
288
|
+
describe('renderUsageCard', () => {
|
|
289
|
+
function snap(part: Partial<AccountSnapshot> & { label: string }): AccountSnapshot {
|
|
290
|
+
return {
|
|
291
|
+
isActive: false,
|
|
292
|
+
quota: null,
|
|
293
|
+
...part,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const snapshots: AccountSnapshot[] = [
|
|
298
|
+
snap({
|
|
299
|
+
label: 'ken@example.com',
|
|
300
|
+
isActive: true,
|
|
301
|
+
quota: quota({
|
|
302
|
+
fiveHourUtilizationPct: 0,
|
|
303
|
+
sevenDayUtilizationPct: 47,
|
|
304
|
+
fiveHourResetAt: new Date(NOW.getTime() + 60 * 60_000),
|
|
305
|
+
sevenDayResetAt: new Date(NOW.getTime() + (3 * 24 + 1) * 60 * 60_000),
|
|
306
|
+
}),
|
|
307
|
+
}),
|
|
308
|
+
];
|
|
309
|
+
const exhausted = new Map<string, boolean>([['ken@example.com', false]]);
|
|
310
|
+
|
|
311
|
+
it('renders the quota-bar block plus a recommendation + freshness footer — no Format 2 table', () => {
|
|
312
|
+
const out = renderUsageCard(snapshots, exhausted, { now: NOW });
|
|
313
|
+
const lines = out.split('\n');
|
|
314
|
+
// Bar block — account title + two window rows with the ASCII bar.
|
|
315
|
+
expect(lines[0]).toBe('- **ken@example.com** (active)');
|
|
316
|
+
expect(out).toContain('- 🟢 5h `[');
|
|
317
|
+
expect(out).toContain('- 🟢 7d `[');
|
|
318
|
+
// Reset info is carried by the bar rows themselves (relative
|
|
319
|
+
// time-left), not a separate table.
|
|
320
|
+
expect(out).toContain('1h0m left');
|
|
321
|
+
expect(out).toContain('left');
|
|
322
|
+
// The old Format 2 TABLE must be fully gone from this card.
|
|
323
|
+
expect(out).not.toContain('🔋 **Auth — fleet status**');
|
|
324
|
+
expect(out).not.toContain('| State | Account | 5h | 5h resets | 7d | 7d resets |');
|
|
325
|
+
// ...but the actionable recommendation footer is RESTORED (#2959 review).
|
|
326
|
+
expect(out).toContain('Recommendation: stay on ken@example.com.');
|
|
327
|
+
// Freshness marker present (no stamp supplied → bare "Live").
|
|
328
|
+
expect(lines[lines.length - 1]).toBe('_Live_');
|
|
329
|
+
// 3 bar lines + recommendation + freshness = 5.
|
|
330
|
+
expect(lines).toHaveLength(5);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('restores the "switch now" recommendation footer for a BLOCKED active account', () => {
|
|
334
|
+
// Matches the old renderAuthSnapshotFormat2 / recommendation() logic: an
|
|
335
|
+
// active account maxed on a window, with a healthy alternative, yields the
|
|
336
|
+
// most actionable line — "switch to Y now".
|
|
337
|
+
const blocked: AccountSnapshot[] = [
|
|
338
|
+
snap({
|
|
339
|
+
label: 'active@example.com',
|
|
340
|
+
isActive: true,
|
|
341
|
+
quota: quota({ fiveHourUtilizationPct: 100, sevenDayUtilizationPct: 100 }),
|
|
342
|
+
}),
|
|
343
|
+
snap({
|
|
344
|
+
label: 'healthy@example.com',
|
|
345
|
+
isActive: false,
|
|
346
|
+
quota: quota({ fiveHourUtilizationPct: 5, sevenDayUtilizationPct: 10 }),
|
|
347
|
+
}),
|
|
348
|
+
];
|
|
349
|
+
const ex = new Map<string, boolean>([
|
|
350
|
+
['active@example.com', true],
|
|
351
|
+
['healthy@example.com', false],
|
|
352
|
+
]);
|
|
353
|
+
const out = renderUsageCard(blocked, ex, { now: NOW });
|
|
354
|
+
expect(out).toContain(
|
|
355
|
+
'_Recommendation: active active@example.com is BLOCKED — switch to healthy@example.com now._',
|
|
356
|
+
);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('shows a "⚠ cached Nm ago" marker for stale-cache-served data instead of a live stamp', () => {
|
|
360
|
+
const out = renderUsageCard(snapshots, exhausted, {
|
|
361
|
+
now: NOW,
|
|
362
|
+
staleCachedAtMs: NOW.getTime() - 3 * 60_000,
|
|
363
|
+
});
|
|
364
|
+
const lines = out.split('\n');
|
|
365
|
+
expect(lines[lines.length - 1]).toBe('_⚠ cached 3m ago_');
|
|
366
|
+
expect(out).not.toContain('_Live');
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('shows a "Live · refreshed" stamp when the probe was live', () => {
|
|
370
|
+
const out = renderUsageCard(snapshots, exhausted, {
|
|
371
|
+
now: NOW,
|
|
372
|
+
liveProbedAtMs: NOW.getTime() - 5_000,
|
|
373
|
+
});
|
|
374
|
+
const lines = out.split('\n');
|
|
375
|
+
expect(lines[lines.length - 1]).toBe('_Live · refreshed 5s ago_');
|
|
376
|
+
expect(out).not.toContain('cached');
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('stale-cache marker takes precedence over a live stamp when both are supplied', () => {
|
|
380
|
+
const out = renderUsageCard(snapshots, exhausted, {
|
|
381
|
+
now: NOW,
|
|
382
|
+
staleCachedAtMs: NOW.getTime() - 60_000,
|
|
383
|
+
liveProbedAtMs: NOW.getTime(),
|
|
384
|
+
});
|
|
385
|
+
expect(out.split('\n').pop()).toBe('_⚠ cached 1m ago_');
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it('probeFailed renders an explicit "probe failed" marker instead of a false "Live" footer (#2959 honesty fix)', () => {
|
|
389
|
+
// A total probe failure: zero result rows, nothing served from cache.
|
|
390
|
+
// Every account row renders "⚠️ no data — probe failed"; the footer
|
|
391
|
+
// must NOT claim "Live". Pre-fix this rendered a bare `_Live_`.
|
|
392
|
+
const out = renderUsageCard(snapshots, exhausted, { now: NOW, probeFailed: true });
|
|
393
|
+
expect(out.split('\n').pop()).toBe('_⚠ probe failed — no live data_');
|
|
394
|
+
expect(out).not.toContain('_Live');
|
|
395
|
+
expect(out).not.toContain('cached');
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('probeFailed does not override real cached data (cache takes precedence)', () => {
|
|
399
|
+
// If some rows WERE served from cache, there IS real (stale) data —
|
|
400
|
+
// the cache marker must win over the probe-failed marker.
|
|
401
|
+
const out = renderUsageCard(snapshots, exhausted, {
|
|
402
|
+
now: NOW,
|
|
403
|
+
staleCachedAtMs: NOW.getTime() - 2 * 60_000,
|
|
404
|
+
probeFailed: true,
|
|
405
|
+
});
|
|
406
|
+
expect(out.split('\n').pop()).toBe('_⚠ cached 2m ago_');
|
|
407
|
+
expect(out).not.toContain('probe failed');
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it('handles an invalid/NaN reset date without emitting "NaN" or corrupting the bar', () => {
|
|
411
|
+
const bad: AccountSnapshot[] = [
|
|
412
|
+
snap({
|
|
413
|
+
label: 'ken@example.com',
|
|
414
|
+
isActive: true,
|
|
415
|
+
quota: quota({
|
|
416
|
+
fiveHourUtilizationPct: 10,
|
|
417
|
+
sevenDayUtilizationPct: 20,
|
|
418
|
+
fiveHourResetAt: new Date('not-a-date'),
|
|
419
|
+
sevenDayResetAt: new Date(NaN),
|
|
420
|
+
}),
|
|
421
|
+
}),
|
|
422
|
+
];
|
|
423
|
+
const out = renderUsageCard(bad, exhausted, { now: NOW });
|
|
424
|
+
expect(out).not.toContain('NaN');
|
|
425
|
+
// Malformed reset → treated as "resets now", tick at index 0.
|
|
426
|
+
expect(out).toContain('/ resets now`');
|
|
427
|
+
for (const line of out.split('\n')) {
|
|
428
|
+
const m = line.match(/\[(.+?)\]/);
|
|
429
|
+
if (m) expect(m[1].length).toBe(10); // bar stays exactly 10 cells
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it('masks the account-email label in demo mode', () => {
|
|
434
|
+
const out = renderUsageCard(snapshots, exhausted, { now: NOW, demo: true });
|
|
435
|
+
expect(out).not.toContain('ken@example.com');
|
|
436
|
+
// Bar rows are unaffected; only the title-line label is masked.
|
|
437
|
+
expect(out).toContain('5h `[');
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('does not mask the label when demo is omitted', () => {
|
|
441
|
+
const out = renderUsageCard(snapshots, exhausted, { now: NOW });
|
|
442
|
+
expect(out).toContain('- **ken@example.com** (active)');
|
|
443
|
+
});
|
|
444
|
+
});
|
|
@@ -696,12 +696,11 @@ import {
|
|
|
696
696
|
} from "../quota-watch.js";
|
|
697
697
|
|
|
698
698
|
describe("resolveQuotaWatchTuning", () => {
|
|
699
|
-
it("defaults: stale gate 60min, late-recovery 6h, dedup on
|
|
699
|
+
it("defaults: stale gate 60min, late-recovery 6h, dedup on", () => {
|
|
700
700
|
const t = resolveQuotaWatchTuning({});
|
|
701
701
|
expect(t.maxStaleMs).toBe(DEFAULT_QUOTA_WATCH_MAX_STALE_MS);
|
|
702
702
|
expect(t.lateRecoveryMs).toBe(DEFAULT_QUOTA_WATCH_LATE_RECOVERY_MS);
|
|
703
703
|
expect(t.fleetDedup).toBe(true);
|
|
704
|
-
expect(t.sendOnProbeFail).toBe(false);
|
|
705
704
|
});
|
|
706
705
|
|
|
707
706
|
it("each knob is individually kill-switchable", () => {
|
|
@@ -709,12 +708,10 @@ describe("resolveQuotaWatchTuning", () => {
|
|
|
709
708
|
SWITCHROOM_QUOTA_WATCH_MAX_STALE_MS: "0",
|
|
710
709
|
SWITCHROOM_QUOTA_WATCH_LATE_RECOVERY_MS: "0",
|
|
711
710
|
SWITCHROOM_QUOTA_WATCH_FLEET_DEDUP: "0",
|
|
712
|
-
SWITCHROOM_QUOTA_WATCH_SEND_ON_PROBE_FAIL: "1",
|
|
713
711
|
});
|
|
714
712
|
expect(t.maxStaleMs).toBe(0);
|
|
715
713
|
expect(t.lateRecoveryMs).toBe(0);
|
|
716
714
|
expect(t.fleetDedup).toBe(false);
|
|
717
|
-
expect(t.sendOnProbeFail).toBe(true);
|
|
718
715
|
});
|
|
719
716
|
|
|
720
717
|
it("garbage numeric values fall back to defaults", () => {
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
decideInboundDelivery,
|
|
5
|
+
reserveInboundDelivery,
|
|
6
|
+
} from '../gateway/inbound-delivery-gate.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* #2917 — rapid-fire DM: replies emitted late & out of order.
|
|
10
|
+
*
|
|
11
|
+
* Root cause: the #1556 delivery gate is documented as a LIVE read
|
|
12
|
+
* ("evaluated at delivery time — not a receipt-time snapshot"), but on the
|
|
13
|
+
* concurrent `handleInbound` path the gate decision and the busy-mark that
|
|
14
|
+
* records "a turn is in flight for this chat" were separated by an `await`
|
|
15
|
+
* (attachment download, composer-clear). Two same-chat inbounds each read
|
|
16
|
+
* "idle" during the other's async lead-in, both passed the gate, and both
|
|
17
|
+
* were delivered to the bridge — so the replies came back reordered.
|
|
18
|
+
*
|
|
19
|
+
* The fix couples the deliver decision with a synchronous RESERVE
|
|
20
|
+
* (`reserveInboundDelivery`): a fresh-turn deliver reserves the chat's busy
|
|
21
|
+
* key BEFORE any await, so a concurrent same-chat inbound observes the
|
|
22
|
+
* reservation on the live gate and buffers behind it — restoring per-chat
|
|
23
|
+
* FIFO without dropping anything (a buffered inbound drains on turn-complete
|
|
24
|
+
* / idle exactly as before).
|
|
25
|
+
*/
|
|
26
|
+
describe('reserveInboundDelivery — fresh-turn reservation flag', () => {
|
|
27
|
+
it('reserves a fresh-turn deliver (idle, non-steering, non-interrupt)', () => {
|
|
28
|
+
const r = reserveInboundDelivery({ turnInFlight: false, isSteering: false, isInterrupt: false })
|
|
29
|
+
expect(r.decision).toBe('deliver')
|
|
30
|
+
expect(r.reserve).toBe(true)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('does NOT reserve a buffered (mid-turn) inbound', () => {
|
|
34
|
+
const r = reserveInboundDelivery({ turnInFlight: true, isSteering: false, isInterrupt: false })
|
|
35
|
+
expect(r.decision).toBe('buffer-until-idle')
|
|
36
|
+
expect(r.reserve).toBe(false)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('does NOT reserve a steering deliver (amends the running turn)', () => {
|
|
40
|
+
const r = reserveInboundDelivery({ turnInFlight: true, isSteering: true, isInterrupt: false })
|
|
41
|
+
expect(r.decision).toBe('deliver')
|
|
42
|
+
expect(r.reserve).toBe(false)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('does NOT reserve an interrupt deliver (amends the running turn)', () => {
|
|
46
|
+
const r = reserveInboundDelivery({ turnInFlight: true, isSteering: false, isInterrupt: true })
|
|
47
|
+
expect(r.decision).toBe('deliver')
|
|
48
|
+
expect(r.reserve).toBe(false)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('decision stays byte-identical to decideInboundDelivery across the matrix', () => {
|
|
52
|
+
for (const turnInFlight of [false, true]) {
|
|
53
|
+
for (const isSteering of [false, true]) {
|
|
54
|
+
for (const isInterrupt of [false, true]) {
|
|
55
|
+
const input = { turnInFlight, isSteering, isInterrupt }
|
|
56
|
+
expect(reserveInboundDelivery(input).decision).toBe(decideInboundDelivery(input))
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A minimal model of the gateway's concurrent same-chat delivery path. Each
|
|
65
|
+
* inbound handler:
|
|
66
|
+
* 1. reads the LIVE gate off the shared busy set,
|
|
67
|
+
* 2. yields at an `await` (the composer-clear / attachment lead-in),
|
|
68
|
+
* 3. on a reserving deliver, marks the chat busy and appends to the wire.
|
|
69
|
+
*
|
|
70
|
+
* This reproduces the ordering race deterministically: two same-chat handlers
|
|
71
|
+
* whose async lead-ins overlap. The `syncReserve` flag toggles the fix — when
|
|
72
|
+
* off (legacy: mark busy only AFTER the await), both handlers deliver and the
|
|
73
|
+
* wire order is decided by the await race; when on (#2917: reserve BEFORE the
|
|
74
|
+
* await), the second handler sees the reservation and buffers, preserving FIFO.
|
|
75
|
+
*/
|
|
76
|
+
interface WireResult {
|
|
77
|
+
delivered: string[]
|
|
78
|
+
buffered: string[]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function runTwoConcurrentInbounds(opts: {
|
|
82
|
+
syncReserve: boolean
|
|
83
|
+
/** ms the first handler's lead-in await takes vs the second's. First is
|
|
84
|
+
* SLOWER so, without the fix, the second overtakes it on the wire. */
|
|
85
|
+
firstLeadInMs: number
|
|
86
|
+
secondLeadInMs: number
|
|
87
|
+
}): Promise<WireResult> {
|
|
88
|
+
const busy = new Set<string>()
|
|
89
|
+
const key = 'chatDM:_'
|
|
90
|
+
const delivered: string[] = []
|
|
91
|
+
const buffered: string[] = []
|
|
92
|
+
|
|
93
|
+
const handle = async (id: string, leadInMs: number): Promise<void> => {
|
|
94
|
+
// Step 1 — live gate read.
|
|
95
|
+
const gate = reserveInboundDelivery({
|
|
96
|
+
turnInFlight: busy.size > 0,
|
|
97
|
+
isSteering: false,
|
|
98
|
+
isInterrupt: false,
|
|
99
|
+
})
|
|
100
|
+
if (gate.decision === 'buffer-until-idle') {
|
|
101
|
+
buffered.push(id)
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
// Step 2 — the fix reserves synchronously BEFORE the await.
|
|
105
|
+
let reserved = false
|
|
106
|
+
if (opts.syncReserve && gate.reserve) {
|
|
107
|
+
busy.add(key)
|
|
108
|
+
reserved = true
|
|
109
|
+
}
|
|
110
|
+
// Step 3 — the async lead-in (composer-clear / attachment download).
|
|
111
|
+
await new Promise((r) => setTimeout(r, leadInMs))
|
|
112
|
+
// Step 4 — legacy marks busy only now, after the await.
|
|
113
|
+
if (!reserved) busy.add(key)
|
|
114
|
+
delivered.push(id)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
await Promise.all([
|
|
118
|
+
handle('msg1', opts.firstLeadInMs),
|
|
119
|
+
handle('msg2', opts.secondLeadInMs),
|
|
120
|
+
])
|
|
121
|
+
return { delivered, buffered }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
describe('rapid-fire same-chat delivery ordering (#2917)', () => {
|
|
125
|
+
it('LEGACY (mark-after-await): the faster second inbound overtakes the first — reorder', async () => {
|
|
126
|
+
// msg1's lead-in is slower, so with the busy-mark deferred until after the
|
|
127
|
+
// await BOTH pass the idle gate and BOTH deliver, with msg2 landing first.
|
|
128
|
+
const { delivered, buffered } = await runTwoConcurrentInbounds({
|
|
129
|
+
syncReserve: false,
|
|
130
|
+
firstLeadInMs: 20,
|
|
131
|
+
secondLeadInMs: 1,
|
|
132
|
+
})
|
|
133
|
+
expect(buffered).toEqual([]) // nothing buffered — the bug
|
|
134
|
+
expect(delivered).toEqual(['msg2', 'msg1']) // out of order
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('FIX (sync reserve): the second inbound sees the reservation and buffers — FIFO preserved', async () => {
|
|
138
|
+
const { delivered, buffered } = await runTwoConcurrentInbounds({
|
|
139
|
+
syncReserve: true,
|
|
140
|
+
firstLeadInMs: 20,
|
|
141
|
+
secondLeadInMs: 1,
|
|
142
|
+
})
|
|
143
|
+
// msg1 reserved synchronously; msg2's live gate sees busy → buffers.
|
|
144
|
+
expect(delivered).toEqual(['msg1'])
|
|
145
|
+
expect(buffered).toEqual(['msg2'])
|
|
146
|
+
// msg2 is not lost — it is held for the turn-complete / idle drain, which
|
|
147
|
+
// re-delivers it in order after msg1's turn ends.
|
|
148
|
+
})
|
|
149
|
+
})
|