switchroom 0.18.24 → 0.18.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/switchroom.js +59 -11
- package/dist/host-control/main.js +1 -1
- package/package.json +2 -2
- package/telegram-plugin/dist/bridge/bridge.js +26 -0
- package/telegram-plugin/dist/gateway/gateway.js +1827 -831
- package/telegram-plugin/dist/server.js +26 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +7 -0
- package/telegram-plugin/gateway/gateway.ts +314 -3
- package/telegram-plugin/gateway/model-command.ts +188 -56
- package/telegram-plugin/gateway/redelivery-decision.ts +139 -0
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +42 -1
- package/telegram-plugin/history.ts +118 -0
- package/telegram-plugin/registry/turns-schema.ts +89 -1
- package/telegram-plugin/render/code-segments.ts +210 -0
- package/telegram-plugin/render/dollar-math-guard.ts +126 -0
- package/telegram-plugin/render/emphasis-guard.ts +158 -0
- package/telegram-plugin/render/inline-pairs-guard.ts +171 -0
- package/telegram-plugin/render/line-start-guard.ts +167 -0
- package/telegram-plugin/render/rich-render.ts +7 -0
- package/telegram-plugin/rich-send.ts +48 -2
- package/telegram-plugin/session-tail.ts +185 -0
- package/telegram-plugin/subagent-watcher.ts +45 -0
- package/telegram-plugin/tests/crash-redelivery-resume-exclusion.test.ts +133 -0
- package/telegram-plugin/tests/crash-redelivery-wiring.test.ts +72 -0
- package/telegram-plugin/tests/history.test.ts +91 -0
- package/telegram-plugin/tests/model-command.test.ts +189 -12
- package/telegram-plugin/tests/redelivery-decision.test.ts +84 -0
- package/telegram-plugin/tests/registry-turns.test.ts +51 -0
- package/telegram-plugin/tests/render/dollar-math-guard.test.ts +162 -0
- package/telegram-plugin/tests/render/emphasis-guard.test.ts +205 -0
- package/telegram-plugin/tests/render/guard-composition.test.ts +138 -0
- package/telegram-plugin/tests/render/inline-pairs-guard.test.ts +171 -0
- package/telegram-plugin/tests/render/line-start-guard.test.ts +164 -0
- package/telegram-plugin/tests/session-model-source.test.ts +11 -0
- package/telegram-plugin/tests/session-tail.test.ts +145 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +50 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +109 -0
- package/telegram-plugin/tests/trailing-answer-projector.test.ts +124 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +125 -0
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +306 -0
- package/telegram-plugin/tool-activity-summary.ts +54 -3
- package/telegram-plugin/worker-activity-feed.ts +104 -0
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +762 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +13 -1
- package/vendor/hindsight-memory/scripts/lib/client.py +14 -4
- package/vendor/hindsight-memory/scripts/lib/config.py +8 -0
- package/vendor/hindsight-memory/scripts/lib/pacing.py +102 -0
- package/vendor/hindsight-memory/scripts/lib/watermark.py +213 -0
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +344 -0
- package/vendor/hindsight-memory/scripts/retain.py +299 -143
- package/vendor/hindsight-memory/scripts/session_start.py +14 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill.py +362 -0
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +350 -0
- package/vendor/hindsight-memory/tests/test_hooks.py +8 -2
|
@@ -200,6 +200,91 @@ describe("handleModelCommand — show / help never inject (picker-wedge guard)",
|
|
|
200
200
|
});
|
|
201
201
|
});
|
|
202
202
|
|
|
203
|
+
describe("handleModelCommand — set — #3241 poll-until-signal wiring", () => {
|
|
204
|
+
it("forwards successPattern + errorPattern + settleBeforeSendMs to the inject primitive", async () => {
|
|
205
|
+
const seen: Array<{ command: string; opts: unknown }> = [];
|
|
206
|
+
const { deps } = makeDeps({
|
|
207
|
+
inject: async (_agent, command, opts) => {
|
|
208
|
+
seen.push({ command, opts });
|
|
209
|
+
return okResult("⏺ Set model to Sonnet 5 for this session");
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
await handleModelCommand({ kind: "set", model: "sonnet" }, deps);
|
|
213
|
+
expect(seen).toHaveLength(1);
|
|
214
|
+
const opts = seen[0].opts as {
|
|
215
|
+
successPattern?: RegExp;
|
|
216
|
+
errorPattern?: RegExp;
|
|
217
|
+
settleBeforeSendMs?: number;
|
|
218
|
+
};
|
|
219
|
+
// A success pattern that matches claude's confirmation line, an error pattern
|
|
220
|
+
// that matches the "not found" line, and a clean-prompt pre-send wait.
|
|
221
|
+
expect(opts.successPattern?.test("⏺ Set model to Sonnet 5")).toBe(true);
|
|
222
|
+
expect(opts.errorPattern?.test("⎿ Model 'x' not found")).toBe(true);
|
|
223
|
+
expect(typeof opts.settleBeforeSendMs).toBe("number");
|
|
224
|
+
expect(opts.settleBeforeSendMs).toBeGreaterThan(0);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("confirmation that lands after a banner → records the live model for /status", async () => {
|
|
228
|
+
// The inject primitive (poll-until-signal) is responsible for returning the
|
|
229
|
+
// confirmation and not the banner; here the handler receives that confirmation
|
|
230
|
+
// and must record it as the session override.
|
|
231
|
+
const { deps } = makeDeps({
|
|
232
|
+
inject: async () =>
|
|
233
|
+
okResult("⠋ extending Claude Fable 5 access…\n⎿ Set model to Fable 5 for this session"),
|
|
234
|
+
});
|
|
235
|
+
const reply = await handleModelCommand({ kind: "set", model: "fable" }, deps);
|
|
236
|
+
expect(reply.selectedModel).toBe("Fable 5");
|
|
237
|
+
expect(reply.optimistic).toBeUndefined();
|
|
238
|
+
expect(reply.text).toContain("Set model to Fable 5");
|
|
239
|
+
// The banner is scrollback and must not leak as prose above the confirmation.
|
|
240
|
+
expect(reply.text).not.toContain("extending Claude Fable 5 access");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("scraped error line → failure verdict AND no override recorded (retract)", async () => {
|
|
244
|
+
const { deps } = makeDeps({
|
|
245
|
+
inject: async () => okResult("⎿ Model 'claude-bogus-99' not found"),
|
|
246
|
+
});
|
|
247
|
+
const reply = await handleModelCommand({ kind: "set", model: "claude-bogus-99" }, deps);
|
|
248
|
+
expect(reply.text).toContain("did not take");
|
|
249
|
+
expect(reply.selectedModel).toBeUndefined();
|
|
250
|
+
expect(reply.optimistic).toBeUndefined();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// #3242 review MEDIUM 1 — an access/entitlement denial must NOT be recorded
|
|
254
|
+
// optimistically. Each of these lines matches neither the confirmation prefix
|
|
255
|
+
// nor the OLD bad-id error regex, so before the widen they slipped into the
|
|
256
|
+
// optimistic branch and falsely recorded the switch.
|
|
257
|
+
for (const denial of [
|
|
258
|
+
"⎿ Fable is not available on your plan",
|
|
259
|
+
"⎿ access denied",
|
|
260
|
+
"⎿ Fable requires a Pro subscription",
|
|
261
|
+
"⎿ This model is not enabled for your account",
|
|
262
|
+
"⎿ No access to Fable on this tier",
|
|
263
|
+
"⎿ Fable 5 is currently unavailable",
|
|
264
|
+
]) {
|
|
265
|
+
it(`access-denial line → failure verdict AND no override (retract): ${JSON.stringify(denial)}`, async () => {
|
|
266
|
+
const { deps } = makeDeps({ inject: async () => okResult(denial) });
|
|
267
|
+
const reply = await handleModelCommand({ kind: "set", model: "fable" }, deps);
|
|
268
|
+
expect(reply.text).toContain("did not take");
|
|
269
|
+
expect(reply.selectedModel).toBeUndefined();
|
|
270
|
+
expect(reply.optimistic).toBeUndefined();
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
it("a genuine confirmation is NEVER flipped to a failure by the widened denial regex", async () => {
|
|
275
|
+
// Confirmation-first ordering: even if scrollback in the same region says
|
|
276
|
+
// something "unavailable", a real "Set model to …" line wins.
|
|
277
|
+
const mixed = [
|
|
278
|
+
"the metrics endpoint was unavailable earlier",
|
|
279
|
+
"⎿ Set model to Fable 5 for this session",
|
|
280
|
+
].join("\n");
|
|
281
|
+
const { deps } = makeDeps({ inject: async () => okResult(mixed) });
|
|
282
|
+
const reply = await handleModelCommand({ kind: "set", model: "fable" }, deps);
|
|
283
|
+
expect(reply.text).not.toContain("did not take");
|
|
284
|
+
expect(reply.selectedModel).toBe("Fable 5");
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
203
288
|
describe("handleModelCommand — set", () => {
|
|
204
289
|
it("injects exactly `/model <name>` once and relays a genuine confirmation + persistence note", async () => {
|
|
205
290
|
const { deps, calls } = makeDeps();
|
|
@@ -228,13 +313,17 @@ describe("handleModelCommand — set", () => {
|
|
|
228
313
|
expect(reply.text).not.toContain("summary you asked for");
|
|
229
314
|
expect(reply.text).not.toContain("rollback plan");
|
|
230
315
|
expect(reply.text).not.toContain("<pre>");
|
|
231
|
-
//
|
|
232
|
-
//
|
|
316
|
+
// #3241 part B — poll-until-signal already waited the full window, so a
|
|
317
|
+
// missing confirmation line with NO scraped error is a SILENT switch, not a
|
|
318
|
+
// failure. Record the requested model optimistically so /status is right,
|
|
319
|
+
// and say so honestly (no false "switched (session)", no scrollback leak).
|
|
233
320
|
expect(reply.text).toContain("/model fable");
|
|
234
|
-
expect(reply.text).toContain("couldn't
|
|
321
|
+
expect(reply.text).toContain("couldn't read a confirmation");
|
|
235
322
|
expect(reply.text).toContain("/status");
|
|
323
|
+
expect(reply.text).not.toContain("Recorded");
|
|
236
324
|
expect(reply.text).not.toContain("switched (session)");
|
|
237
|
-
expect(reply.selectedModel).
|
|
325
|
+
expect(reply.selectedModel).toBe("Fable");
|
|
326
|
+
expect(reply.optimistic).toBe(true);
|
|
238
327
|
expect(reply.html).toBe(true);
|
|
239
328
|
});
|
|
240
329
|
|
|
@@ -260,15 +349,17 @@ describe("handleModelCommand — set", () => {
|
|
|
260
349
|
const { deps } = makeDeps({ inject: async () => okResult(prose) });
|
|
261
350
|
const reply = await handleModelCommand({ kind: "set", model: "fable" }, deps);
|
|
262
351
|
// The anchored regex rejects all three lines, so nothing leaks and there is
|
|
263
|
-
// no <pre> block.
|
|
264
|
-
//
|
|
352
|
+
// no <pre> block. No confirmation AND no scraped error → #3241 optimistic
|
|
353
|
+
// record: the switch is reported as sent + recorded, never a false
|
|
354
|
+
// "switched", and no scrollback prose leaks.
|
|
265
355
|
expect(reply.text).not.toContain("<pre>");
|
|
266
356
|
expect(reply.text).not.toContain("switched the deploy");
|
|
267
357
|
expect(reply.text).not.toContain("set model behaviour");
|
|
268
358
|
expect(reply.text).not.toContain("kept model changes");
|
|
269
|
-
expect(reply.text).toContain("couldn't
|
|
359
|
+
expect(reply.text).toContain("couldn't read a confirmation");
|
|
270
360
|
expect(reply.text).not.toContain("switched (session)");
|
|
271
|
-
expect(reply.selectedModel).
|
|
361
|
+
expect(reply.selectedModel).toBe("Fable");
|
|
362
|
+
expect(reply.optimistic).toBe(true);
|
|
272
363
|
expect(reply.html).toBe(true);
|
|
273
364
|
});
|
|
274
365
|
|
|
@@ -290,7 +381,13 @@ describe("handleModelCommand — set", () => {
|
|
|
290
381
|
}),
|
|
291
382
|
});
|
|
292
383
|
const reply = await handleModelCommand({ kind: "set", model: "sonnet" }, deps);
|
|
293
|
-
|
|
384
|
+
// #3241 part B — an empty capture can't carry an error line, so the send is
|
|
385
|
+
// treated as a silent switch and the requested model is recorded
|
|
386
|
+
// optimistically (was: "no response captured", recorded nothing).
|
|
387
|
+
expect(reply.text).toContain("couldn't read a confirmation");
|
|
388
|
+
expect(reply.text).toContain("/status");
|
|
389
|
+
expect(reply.selectedModel).toBe("Sonnet");
|
|
390
|
+
expect(reply.optimistic).toBe(true);
|
|
294
391
|
});
|
|
295
392
|
|
|
296
393
|
it("session_missing failure surfaces the tmux-supervisor hint", async () => {
|
|
@@ -475,9 +572,12 @@ describe("handleModelCommand — busy gate + honest unverified reporting", () =>
|
|
|
475
572
|
const reply = await handleModelCommand({ kind: "set", model: "opus" }, deps);
|
|
476
573
|
expect(reply.text).not.toContain("did not take");
|
|
477
574
|
expect(reply.text).not.toContain("model not found");
|
|
478
|
-
// No confirmation
|
|
479
|
-
|
|
480
|
-
|
|
575
|
+
// No LINE-ANCHORED error and no confirmation → #3241 optimistic record: the
|
|
576
|
+
// mid-sentence "model not found" prose does NOT flip the switch to a failure,
|
|
577
|
+
// and the requested model is recorded (was: "couldn't confirm", nothing).
|
|
578
|
+
expect(reply.text).toContain("couldn't read a confirmation");
|
|
579
|
+
expect(reply.selectedModel).toBe("Opus");
|
|
580
|
+
expect(reply.optimistic).toBe(true);
|
|
481
581
|
});
|
|
482
582
|
|
|
483
583
|
it("recognises claude v2.1.205's real arg-form confirmation (⎿ glyph + 'and saved as your default')", async () => {
|
|
@@ -713,6 +813,7 @@ import {
|
|
|
713
813
|
EXTRA_CLAUDE_ALIASES,
|
|
714
814
|
externalModelNames,
|
|
715
815
|
isSrToClaudeTransition,
|
|
816
|
+
optimisticModelRecordLabel,
|
|
716
817
|
type ModelMenuDeps,
|
|
717
818
|
} from "../gateway/model-command.js";
|
|
718
819
|
import { labelTag } from "../../src/agents/model-picker.js";
|
|
@@ -909,6 +1010,62 @@ describe("handleModelMenuCallback", () => {
|
|
|
909
1010
|
});
|
|
910
1011
|
});
|
|
911
1012
|
|
|
1013
|
+
// #3242 review MEDIUM 2 — the alias BUTTON (mdl:alias:<alias>, e.g. the Fable
|
|
1014
|
+
// button) must be symmetric with the typed set path: record-on-send / retract-
|
|
1015
|
+
// on-scraped-error, handling BOTH ok and ok_no_output. Before the fix a silent
|
|
1016
|
+
// successful button-switch (ok_no_output, or ok with no confirmation line) fell
|
|
1017
|
+
// through to "Switch failed" and dropped the override.
|
|
1018
|
+
describe("handleModelMenuCallback — alias button symmetry (#3242 MEDIUM 2)", () => {
|
|
1019
|
+
it("ok_no_output (silent switch via the button) → optimistic record, not a failure", async () => {
|
|
1020
|
+
const { deps } = makeMenuDeps({
|
|
1021
|
+
inject: async () => ({
|
|
1022
|
+
outcome: "ok_no_output" as const,
|
|
1023
|
+
output: "",
|
|
1024
|
+
truncated: false,
|
|
1025
|
+
command: "/model",
|
|
1026
|
+
meta: { description: "Open model picker", expectsOutput: true },
|
|
1027
|
+
}),
|
|
1028
|
+
});
|
|
1029
|
+
const out = await handleModelMenuCallback(`${MODEL_CALLBACK_ALIAS}fable`, deps);
|
|
1030
|
+
expect(out.selectedModel).toBe("Fable"); // normalized display form
|
|
1031
|
+
expect(out.selectedModelToken).toBe("fable");
|
|
1032
|
+
// Provisional copy (#3242 FIX 2): doesn't assert the switch succeeded, points
|
|
1033
|
+
// at /status, and never reads as a failure.
|
|
1034
|
+
expect(out.reply.text).not.toContain("failed");
|
|
1035
|
+
expect(out.reply.text).toContain("/status");
|
|
1036
|
+
expect(out.reply.text).toContain("/model fable");
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
it("ok with no confirmation line (banner-only) → optimistic record", async () => {
|
|
1040
|
+
const { deps } = makeMenuDeps({
|
|
1041
|
+
inject: async () => okResult("⠋ extending Claude Fable 5 access…"),
|
|
1042
|
+
});
|
|
1043
|
+
const out = await handleModelMenuCallback(`${MODEL_CALLBACK_ALIAS}fable`, deps);
|
|
1044
|
+
expect(out.selectedModel).toBe("Fable");
|
|
1045
|
+
expect(out.selectedModelToken).toBe("fable");
|
|
1046
|
+
// The banner must not leak as the confirmation.
|
|
1047
|
+
expect(out.reply.text).not.toContain("extending Claude Fable 5 access");
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
it("scraped access-denial line via the button → failure, no override (retract)", async () => {
|
|
1051
|
+
const { deps } = makeMenuDeps({
|
|
1052
|
+
inject: async () => okResult("⎿ Fable is not available on your plan"),
|
|
1053
|
+
});
|
|
1054
|
+
const out = await handleModelMenuCallback(`${MODEL_CALLBACK_ALIAS}fable`, deps);
|
|
1055
|
+
expect(out.selectedModel).toBeUndefined();
|
|
1056
|
+
expect(out.reply.text).toContain("did not take");
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
it("genuine confirmation via the button still records the display name", async () => {
|
|
1060
|
+
const { deps } = makeMenuDeps({
|
|
1061
|
+
inject: async () => okResult("⎿ Set model to Fable 5 for this session"),
|
|
1062
|
+
});
|
|
1063
|
+
const out = await handleModelMenuCallback(`${MODEL_CALLBACK_ALIAS}fable`, deps);
|
|
1064
|
+
expect(out.selectedModel).toBe("Fable 5");
|
|
1065
|
+
expect(out.selectedModelToken).toBe("fable");
|
|
1066
|
+
});
|
|
1067
|
+
});
|
|
1068
|
+
|
|
912
1069
|
describe("sessionModelFromConfirmation", () => {
|
|
913
1070
|
it("pulls the model name from claude's session-switch confirmation", () => {
|
|
914
1071
|
expect(sessionModelFromConfirmation("Set model to Fable 5 for this session only")).toBe("Fable 5");
|
|
@@ -1170,6 +1327,26 @@ describe("isSrToClaudeTransition", () => {
|
|
|
1170
1327
|
expect(isSrToClaudeTransition("sr-gemini-2.5-pro", "sr-deepseek-r1")).toBe(false);
|
|
1171
1328
|
});
|
|
1172
1329
|
|
|
1330
|
+
// #3242 review FIX 1 — optimisticModelRecordLabel must NOT de-prefix an sr-*
|
|
1331
|
+
// token: the stored selectedModel doubles as the sr-*→Claude sentinel that
|
|
1332
|
+
// isSrToClaudeTransition (prevModel.startsWith('sr-')) reads. De-prefixing to
|
|
1333
|
+
// a friendly label ("kimi k2") would silently kill the graceful restart that
|
|
1334
|
+
// tears down LiteLLM routing on a subsequent Claude switch.
|
|
1335
|
+
it("optimisticModelRecordLabel keeps sr-* tokens verbatim so the sentinel survives", () => {
|
|
1336
|
+
const recorded = optimisticModelRecordLabel("sr-kimi-k2");
|
|
1337
|
+
expect(recorded).toBe("sr-kimi-k2"); // NOT "kimi k2"
|
|
1338
|
+
// The recorded value still trips the sr→Claude transition on a later switch.
|
|
1339
|
+
expect(isSrToClaudeTransition(recorded, "opus")).toBe(true);
|
|
1340
|
+
// Regression guard: the de-prefixed form would NOT — that was the bug.
|
|
1341
|
+
expect(isSrToClaudeTransition("kimi k2", "opus")).toBe(false);
|
|
1342
|
+
});
|
|
1343
|
+
|
|
1344
|
+
it("optimisticModelRecordLabel Title-cases a bare Claude alias, leaves full ids as-is", () => {
|
|
1345
|
+
expect(optimisticModelRecordLabel("fable")).toBe("Fable");
|
|
1346
|
+
expect(optimisticModelRecordLabel("opus")).toBe("Opus");
|
|
1347
|
+
expect(optimisticModelRecordLabel("claude-opus-4-8")).toBe("claude-opus-4-8");
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1173
1350
|
it("false when switching to sr-* from Claude (Claude → sr-*)", () => {
|
|
1174
1351
|
expect(isSrToClaudeTransition("Sonnet", "sr-gemini-2.5-pro")).toBe(false);
|
|
1175
1352
|
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
decideRedeliver,
|
|
4
|
+
frameRedelivery,
|
|
5
|
+
REDELIVERY_PREFIX,
|
|
6
|
+
type RedeliverDecisionInput,
|
|
7
|
+
} from '../gateway/redelivery-decision.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Pins the crash-survival redelivery decision predicate. The load-bearing
|
|
11
|
+
* property is the DURABLE TEXT-IDENTITY oracle: an interim `progress_update`
|
|
12
|
+
* earlier in the same turn must NOT suppress a genuinely-undelivered final
|
|
13
|
+
* answer (the exact MISS = permanent-silence bug this fix closes), while an
|
|
14
|
+
* already-delivered final answer MUST be suppressed.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const base: RedeliverDecisionInput = {
|
|
18
|
+
capturedText: 'The deploy finished — all three services are green.',
|
|
19
|
+
trailingIsText: true,
|
|
20
|
+
hasDeliveredText: false,
|
|
21
|
+
alreadyRedelivered: false,
|
|
22
|
+
ageMs: 60_000,
|
|
23
|
+
maxAgeMs: 10_800_000, // 3h
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('decideRedeliver — text-identity oracle', () => {
|
|
27
|
+
it('REDELIVERS an undelivered final answer even when an interim message was sent (the MISS bug)', () => {
|
|
28
|
+
// The MISS scenario: a progress_update landed a role=assistant row earlier
|
|
29
|
+
// in the turn, so a chat+time-window oracle would say "delivered" and skip.
|
|
30
|
+
// The text-identity oracle keys on the ANSWER text — which was never sent —
|
|
31
|
+
// so hasDeliveredText is false and redelivery fires.
|
|
32
|
+
const d = decideRedeliver({ ...base, hasDeliveredText: false })
|
|
33
|
+
expect(d.redeliver).toBe(true)
|
|
34
|
+
expect(d.framedText).toContain(base.capturedText)
|
|
35
|
+
expect(d.framedText?.startsWith(REDELIVERY_PREFIX)).toBe(true)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('SUPPRESSES when the final answer text was already delivered', () => {
|
|
39
|
+
const d = decideRedeliver({ ...base, hasDeliveredText: true })
|
|
40
|
+
expect(d.redeliver).toBe(false)
|
|
41
|
+
expect(d.skipReason).toBe('already-delivered')
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('decideRedeliver — other skip reasons', () => {
|
|
46
|
+
it('skips empty / whitespace text', () => {
|
|
47
|
+
expect(decideRedeliver({ ...base, capturedText: ' ' }).skipReason).toBe('empty-text')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('skips when trailing content is a dangling tool_use (mid-stream, not an answer)', () => {
|
|
51
|
+
expect(decideRedeliver({ ...base, trailingIsText: false }).skipReason).toBe('trailing-not-text')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('skips an already-redelivered turn (at-most-once ledger)', () => {
|
|
55
|
+
expect(decideRedeliver({ ...base, alreadyRedelivered: true }).skipReason).toBe('already-redelivered')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('skips a stale turn beyond maxAgeMs', () => {
|
|
59
|
+
expect(decideRedeliver({ ...base, ageMs: 4 * 3_600_000 }).skipReason).toBe('stale')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('precedence: empty-text wins over delivered/redelivered/stale', () => {
|
|
63
|
+
const d = decideRedeliver({
|
|
64
|
+
...base,
|
|
65
|
+
capturedText: '',
|
|
66
|
+
hasDeliveredText: true,
|
|
67
|
+
alreadyRedelivered: true,
|
|
68
|
+
ageMs: 999_999_999,
|
|
69
|
+
})
|
|
70
|
+
expect(d.skipReason).toBe('empty-text')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('precedence: already-redelivered is checked before the age failsafe', () => {
|
|
74
|
+
const d = decideRedeliver({ ...base, alreadyRedelivered: true, ageMs: 999_999_999 })
|
|
75
|
+
expect(d.skipReason).toBe('already-redelivered')
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('frameRedelivery', () => {
|
|
80
|
+
it('frames as a recovered draft, never a clean final answer', () => {
|
|
81
|
+
const framed = frameRedelivery(' hello world ')
|
|
82
|
+
expect(framed).toBe(`${REDELIVERY_PREFIX}\n\nhello world`)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
markOrphanedWithTimeoutClassification,
|
|
21
21
|
findLatestTurnIfInterrupted,
|
|
22
22
|
markTurnResumed,
|
|
23
|
+
markAnswerRedelivered,
|
|
24
|
+
stampTurnSessionId,
|
|
23
25
|
getTurnByKey,
|
|
24
26
|
} from '../registry/turns-schema.js'
|
|
25
27
|
|
|
@@ -574,3 +576,52 @@ describe('markTurnResumed', () => {
|
|
|
574
576
|
db.close()
|
|
575
577
|
})
|
|
576
578
|
})
|
|
579
|
+
|
|
580
|
+
// ---------------------------------------------------------------------------
|
|
581
|
+
// stampTurnSessionId + markAnswerRedelivered — crash-survival redelivery
|
|
582
|
+
// ---------------------------------------------------------------------------
|
|
583
|
+
|
|
584
|
+
describe('stampTurnSessionId', () => {
|
|
585
|
+
it('pins the session id on the turn (first-write-wins) and defaults null', () => {
|
|
586
|
+
const db = openTurnsDbInMemory()
|
|
587
|
+
recordTurnStart(db, { turnKey: 'sx:1', chatId: 'sx' })
|
|
588
|
+
expect(getTurnByKey(db, 'sx:1')!.session_id).toBeNull()
|
|
589
|
+
stampTurnSessionId(db, 'sx:1', 'sess-abc')
|
|
590
|
+
expect(getTurnByKey(db, 'sx:1')!.session_id).toBe('sess-abc')
|
|
591
|
+
// first-write-wins: a later different session id does not overwrite
|
|
592
|
+
stampTurnSessionId(db, 'sx:1', 'sess-def')
|
|
593
|
+
expect(getTurnByKey(db, 'sx:1')!.session_id).toBe('sess-abc')
|
|
594
|
+
db.close()
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
it('ignores an empty session id and no-ops on unknown key', () => {
|
|
598
|
+
const db = openTurnsDbInMemory()
|
|
599
|
+
recordTurnStart(db, { turnKey: 'sx:2', chatId: 'sx' })
|
|
600
|
+
stampTurnSessionId(db, 'sx:2', '')
|
|
601
|
+
expect(getTurnByKey(db, 'sx:2')!.session_id).toBeNull()
|
|
602
|
+
expect(() => stampTurnSessionId(db, 'nope', 'x')).not.toThrow()
|
|
603
|
+
db.close()
|
|
604
|
+
})
|
|
605
|
+
})
|
|
606
|
+
|
|
607
|
+
describe('markAnswerRedelivered', () => {
|
|
608
|
+
it('stamps answer_redelivered_at (first-write-wins) on a SEPARATE marker from resumed_at', () => {
|
|
609
|
+
const db = openTurnsDbInMemory()
|
|
610
|
+
recordTurnStart(db, { turnKey: 'rd:1', chatId: 'rd' })
|
|
611
|
+
recordTurnEnd(db, { turnKey: 'rd:1', endedVia: 'restart' })
|
|
612
|
+
expect(getTurnByKey(db, 'rd:1')!.answer_redelivered_at).toBeNull()
|
|
613
|
+
markAnswerRedelivered(db, 'rd:1', 1_700_000_000_000)
|
|
614
|
+
expect(getTurnByKey(db, 'rd:1')!.answer_redelivered_at).toBe(1_700_000_000_000)
|
|
615
|
+
// does not touch resumed_at — the two ledgers are independent
|
|
616
|
+
expect(getTurnByKey(db, 'rd:1')!.resumed_at).toBeNull()
|
|
617
|
+
markAnswerRedelivered(db, 'rd:1', 1_800_000_000_000)
|
|
618
|
+
expect(getTurnByKey(db, 'rd:1')!.answer_redelivered_at).toBe(1_700_000_000_000)
|
|
619
|
+
db.close()
|
|
620
|
+
})
|
|
621
|
+
|
|
622
|
+
it('no-ops for an unknown turn_key', () => {
|
|
623
|
+
const db = openTurnsDbInMemory()
|
|
624
|
+
expect(() => markAnswerRedelivered(db, 'nope:1')).not.toThrow()
|
|
625
|
+
db.close()
|
|
626
|
+
})
|
|
627
|
+
})
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { guardDollarMath } from "../../render/dollar-math-guard.js";
|
|
3
|
+
import { richMessage } from "../../rich-send.js";
|
|
4
|
+
import { computeReplyChunks } from "../../gateway/outbound-send-path.js";
|
|
5
|
+
import { RICH_MESSAGE_MAX_CHARS } from "../../format.js";
|
|
6
|
+
|
|
7
|
+
// Any U+1D400–U+1D7FF codepoint = a mathematical-alphanumeric (math-italic /
|
|
8
|
+
// math-bold) glyph — what a math renderer produces from a `$…$` span.
|
|
9
|
+
const MATH_GLYPH = /[\u{1D400}-\u{1D7FF}]/u;
|
|
10
|
+
|
|
11
|
+
/** Strip zero-width chars + defusing backslashes so we can assert the amount
|
|
12
|
+
* the reader copies is byte-identical to the original ASCII currency token.
|
|
13
|
+
* The wire body now flows through the composed richMessage guard, so besides
|
|
14
|
+
* the dollar defuser (`\$`) it may also carry the inline-pairs defuser (`\~`)
|
|
15
|
+
* for approximation tildes (`~$0.5M`) — strip both so the round-trip holds. */
|
|
16
|
+
function copyText(s: string): string {
|
|
17
|
+
return s.replace(/[]/g, "").replace(/\\([$~])/g, "$1");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("guardDollarMath (#3252)", () => {
|
|
21
|
+
const OFFENDING =
|
|
22
|
+
"acquisition ceiling is ~$0.5-0.9M but nearly all in small dealers ... is ~$150-450k";
|
|
23
|
+
|
|
24
|
+
it("neutralises the two-dollar-amount currency string so no `$…$` span can form", () => {
|
|
25
|
+
const out = guardDollarMath(OFFENDING);
|
|
26
|
+
// Both currency dollars are backslash-escaped → no unescaped `$` remains to
|
|
27
|
+
// open/close a math span.
|
|
28
|
+
expect(out).not.toMatch(/(?<!\\)\$/);
|
|
29
|
+
expect(out).toContain("\\$0.5-0.9M");
|
|
30
|
+
expect(out).toContain("\\$150-450k");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("carries no math-italic glyphs (switchroom never emits U+1D400-range)", () => {
|
|
34
|
+
expect(guardDollarMath(OFFENDING)).not.toMatch(MATH_GLYPH);
|
|
35
|
+
// …nor does the raw source; the guard is what keeps the WIRE bytes clean.
|
|
36
|
+
expect(OFFENDING).not.toMatch(MATH_GLYPH);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("preserves the visible amounts: stripping the defuser yields the original", () => {
|
|
40
|
+
expect(copyText(guardDollarMath(OFFENDING))).toBe(OFFENDING);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("is a strict no-op for a single `$digit` prose token", () => {
|
|
44
|
+
const s = "grab a $5 coffee on the way";
|
|
45
|
+
expect(guardDollarMath(s)).toBe(s);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("is a strict no-op when there is no `$digit` at all", () => {
|
|
49
|
+
const s = "the cost is unknown but the $ sign appears twice: $ and $";
|
|
50
|
+
expect(guardDollarMath(s)).toBe(s);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("never touches `$` inside a code span", () => {
|
|
54
|
+
const s = "shell math `$x = $y` is fine and `$z = $w` too";
|
|
55
|
+
// Two code spans, each with 2 `$digit`? No — non-digit. Use digits to prove
|
|
56
|
+
// code is skipped even when it WOULD otherwise trigger.
|
|
57
|
+
const withDigits = "compute `$1 + $2` and also `$3 + $4` inline";
|
|
58
|
+
expect(guardDollarMath(s)).toBe(s);
|
|
59
|
+
expect(guardDollarMath(withDigits)).toBe(withDigits);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("escapes prose dollars but leaves an adjacent code span verbatim", () => {
|
|
63
|
+
const s = "prices $10 and $20 — the var is `$PRICE = $10`";
|
|
64
|
+
const out = guardDollarMath(s);
|
|
65
|
+
// Prose amounts escaped:
|
|
66
|
+
expect(out).toContain("\\$10 and \\$20");
|
|
67
|
+
// Code span verbatim (its `$10` NOT escaped):
|
|
68
|
+
expect(out).toContain("`$PRICE = $10`");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// ── F3: widened detection (trailing `$`, `$.50`, `$ ` with a bare gap) ────
|
|
72
|
+
it("escapes a mix of leading- and trailing-`$` amounts (`$50 or 50$`)", () => {
|
|
73
|
+
const out = guardDollarMath("It costs $50 or 50$ depending on the vendor");
|
|
74
|
+
// Both dollars gone from the unescaped set → no `$…$` pair can form.
|
|
75
|
+
expect(out).not.toMatch(/(?<!\\)\$/);
|
|
76
|
+
expect(out).toContain("\\$50 or 50\\$");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("escapes when one dollar is bare (`$10 today (down from $ yesterday)`)", () => {
|
|
80
|
+
const out = guardDollarMath("$10 today (down from $ yesterday)");
|
|
81
|
+
expect(out).not.toMatch(/(?<!\\)\$/);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("escapes a `$.50` amount whose `$` is not digit-adjacent by one char", () => {
|
|
85
|
+
const out = guardDollarMath("$.50 here and $5 there");
|
|
86
|
+
expect(out).not.toMatch(/(?<!\\)\$/);
|
|
87
|
+
expect(out).toContain("\\$.50 here and \\$5 there");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("leaves two non-currency `$` (shell-var prose) untouched — no false positive", () => {
|
|
91
|
+
const s = "use $foo and $bar as the two variables";
|
|
92
|
+
expect(guardDollarMath(s)).toBe(s);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ── F5: idempotent — running the guard twice never doubles the backslash ──
|
|
96
|
+
it("is idempotent: guarding already-guarded text is a strict no-op", () => {
|
|
97
|
+
const once = guardDollarMath(OFFENDING);
|
|
98
|
+
expect(guardDollarMath(once)).toBe(once);
|
|
99
|
+
expect(once).not.toContain("\\\\$"); // never a doubled `\\$`
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// F1/F2 regression: exercise the ACTUAL reply pipe. The `reply` tool's final
|
|
104
|
+
// answer flows executeReply → computeReplyChunks → sendReplyChunks →
|
|
105
|
+
// richMessage(chunk). `richMessage` is the single wire seam the guard now lives
|
|
106
|
+
// in; these assertions FAIL against the pre-fix code (bare `{ markdown }` wrap,
|
|
107
|
+
// no guard) and pass after. We drive the real `computeReplyChunks` output
|
|
108
|
+
// through the real `richMessage`, not `guardDollarMath` in isolation.
|
|
109
|
+
describe("reply-path integration (#3252 F1/F2)", () => {
|
|
110
|
+
const OFFENDING =
|
|
111
|
+
"acquisition ceiling is ~$0.5-0.9M but nearly all in small dealers ... is ~$150-450k";
|
|
112
|
+
|
|
113
|
+
it("richMessage() escapes the `$…$` pair on the markdown wire body", () => {
|
|
114
|
+
const wire = richMessage(OFFENDING);
|
|
115
|
+
// The bytes handed to sendRichMessage/editMessageText carry no unescaped `$`.
|
|
116
|
+
expect(wire.markdown).not.toMatch(/(?<!\\)\$/);
|
|
117
|
+
expect(wire.markdown).not.toMatch(MATH_GLYPH);
|
|
118
|
+
// Reader-visible amounts intact once the defuser is stripped.
|
|
119
|
+
expect(copyText(wire.markdown)).toBe(OFFENDING);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("the full computeReplyChunks → richMessage reply pipe emits no unescaped `$`", () => {
|
|
123
|
+
// Exactly what executeReply feeds sendReplyChunks for a non-literal reply.
|
|
124
|
+
const chunks = computeReplyChunks({
|
|
125
|
+
effectiveText: OFFENDING,
|
|
126
|
+
literalText: false,
|
|
127
|
+
limit: RICH_MESSAGE_MAX_CHARS,
|
|
128
|
+
chunkMode: "newline",
|
|
129
|
+
});
|
|
130
|
+
expect(chunks.length).toBeGreaterThan(0);
|
|
131
|
+
for (const chunk of chunks) {
|
|
132
|
+
const wire = richMessage(chunk); // sendReplyChunks wraps each chunk this way
|
|
133
|
+
expect(wire.markdown).not.toMatch(/(?<!\\)\$/);
|
|
134
|
+
expect(wire.markdown).not.toMatch(MATH_GLYPH);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("a single-amount reply is left with its dollar untouched on the wire", () => {
|
|
139
|
+
const wire = richMessage("the retainer is $2500 per month");
|
|
140
|
+
expect(wire.markdown).toContain("$2500");
|
|
141
|
+
expect(wire.markdown).not.toContain("\\$2500");
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("guardDollarMath — link / table awareness (findings 1 & 3)", () => {
|
|
146
|
+
it("does NOT escape a `$` inside a markdown link destination", () => {
|
|
147
|
+
// Two dollars + a digit-adjacent one would normally arm the guard, but both
|
|
148
|
+
// live in URL query strings → structural → left verbatim.
|
|
149
|
+
const s = "buy [x](https://x.io?price=$5) or [y](https://y.io?price=$9)";
|
|
150
|
+
expect(guardDollarMath(s)).toBe(s);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("does NOT escape `$` inside a real table's cells", () => {
|
|
154
|
+
const s = ["| Item | Cost |", "| --- | --- |", "| a | $5 |", "| b | $9 |"].join("\n");
|
|
155
|
+
expect(guardDollarMath(s)).toBe(s);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("STILL escapes two currency dollars in ordinary prose", () => {
|
|
159
|
+
const out = guardDollarMath("spend was $5 today and $9 tomorrow");
|
|
160
|
+
expect(out).not.toMatch(/(?<!\\)\$/);
|
|
161
|
+
});
|
|
162
|
+
});
|