switchroom 0.17.6 → 0.18.3
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 +65 -5
- package/dist/auth-broker/index.js +6623 -514
- package/dist/cli/notion-write-pretool.mjs +64 -4
- package/dist/cli/switchroom.js +1888 -1162
- package/dist/host-control/main.js +6306 -162
- package/dist/vault/approvals/kernel-server.js +6014 -202
- package/dist/vault/broker/server.js +6741 -940
- package/package.json +1 -1
- package/profiles/_base/settings.json.hbs +2 -2
- package/profiles/_base/start.sh.hbs +218 -25
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md +116 -0
- 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/mental-model-curator/SKILL.md +162 -0
- package/telegram-plugin/auth-snapshot-format.ts +22 -24
- package/telegram-plugin/bridge/bridge.ts +80 -1
- package/telegram-plugin/bridge/ipc-client.ts +19 -0
- package/telegram-plugin/bridge/permission-ledger.ts +61 -0
- package/telegram-plugin/consolidation-legibility.ts +279 -0
- package/telegram-plugin/context-exhaustion.ts +124 -0
- package/telegram-plugin/dist/bridge/bridge.js +85 -1
- package/telegram-plugin/dist/gateway/gateway.js +25802 -8488
- package/telegram-plugin/dist/server.js +86 -2
- package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
- package/telegram-plugin/gateway/activity-card-store.ts +369 -0
- package/telegram-plugin/gateway/gateway.ts +1861 -172
- package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
- package/telegram-plugin/gateway/inbound-spool.ts +22 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
- package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
- package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
- package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
- package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
- package/telegram-plugin/gateway/model-command.ts +70 -10
- package/telegram-plugin/gateway/permission-rearm.ts +115 -0
- package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
- package/telegram-plugin/memory-legibility.ts +217 -0
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/telegram-plugin/package.json +6 -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/scoped-approval.ts +59 -0
- package/telegram-plugin/silent-end.ts +78 -0
- package/telegram-plugin/stream-controller.ts +14 -3
- package/telegram-plugin/subagent-watcher.ts +60 -6
- package/telegram-plugin/tests/activity-card-store.test.ts +530 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
- package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +30 -16
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
- package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +44 -9
- 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/inbound-spool.test.ts +105 -0
- package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
- package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
- package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
- package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
- package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
- package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
- package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -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/orphaned-reply-rearm.test.ts +123 -163
- package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
- package/telegram-plugin/tests/permission-rearm.test.ts +126 -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/scoped-grant-persist.test.ts +223 -0
- package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
- package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
- package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +198 -0
- package/telegram-plugin/turn-liveness-floor.ts +35 -1
- package/telegram-plugin/uat/assertions.ts +88 -4
- package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
- package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
- package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
- package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
- package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
- package/telegram-plugin/worktree-watch-cwds.ts +135 -0
- package/vendor/hindsight-memory/hooks/hooks.json +9 -0
- 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/directive_verify.py +445 -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/lib/client.py +11 -1
- package/vendor/hindsight-memory/scripts/lib/config.py +46 -2
- package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
- package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
- package/vendor/hindsight-memory/scripts/recall.py +217 -10
- package/vendor/hindsight-memory/scripts/retain.py +17 -0
- package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -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/scripts/tests/test_directive_capture_nudge.py +185 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
- package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -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
- package/vendor/hindsight-memory/tests/test_client.py +43 -0
- package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
- package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { parse } from "../../render/parse.js";
|
|
3
|
+
import {
|
|
4
|
+
render,
|
|
5
|
+
renderSafe,
|
|
6
|
+
SUPPORTED_INLINE,
|
|
7
|
+
SUPPORTED_BLOCK,
|
|
8
|
+
} from "../../render/render.js";
|
|
9
|
+
import { RICH_MESSAGE_MAX_CHARS } from "../../format.js";
|
|
10
|
+
import type { Document, TableNode } from "../../render/ir.js";
|
|
11
|
+
|
|
12
|
+
/** Round-trip helper: parse markdown -> IR -> render -> re-parse, and assert
|
|
13
|
+
* the RE-PARSED IR is structurally equivalent to the original (ignoring
|
|
14
|
+
* source offsets, which legitimately differ after re-rendering). */
|
|
15
|
+
function assertRoundTripsStructurally(md: string) {
|
|
16
|
+
const doc = parse(md);
|
|
17
|
+
const out = render(doc);
|
|
18
|
+
const reparsed = parse(out);
|
|
19
|
+
expect(stripPositions(reparsed)).toEqual(stripPositions(doc));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function stripPositions(node: any): any {
|
|
23
|
+
if (Array.isArray(node)) return node.map(stripPositions);
|
|
24
|
+
if (node && typeof node === "object") {
|
|
25
|
+
const { start, end, ...rest } = node;
|
|
26
|
+
const out: any = {};
|
|
27
|
+
for (const [k, v] of Object.entries(rest)) out[k] = stripPositions(v);
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
return node;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("render: inline palette", () => {
|
|
34
|
+
it("bold", () => {
|
|
35
|
+
expect(render(parse("**hi**"))).toBe("**hi**");
|
|
36
|
+
});
|
|
37
|
+
it("italic", () => {
|
|
38
|
+
expect(render(parse("*hi*"))).toBe("*hi*");
|
|
39
|
+
});
|
|
40
|
+
it("strike", () => {
|
|
41
|
+
expect(render(parse("~~hi~~"))).toBe("~~hi~~");
|
|
42
|
+
});
|
|
43
|
+
it("inline code", () => {
|
|
44
|
+
expect(render(parse("`hi`"))).toBe("`hi`");
|
|
45
|
+
});
|
|
46
|
+
it("link", () => {
|
|
47
|
+
expect(render(parse("[label](https://example.com)"))).toBe(
|
|
48
|
+
"[label](https://example.com)",
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
it("underline", () => {
|
|
52
|
+
expect(render(parse("__hi__"))).toBe("__hi__");
|
|
53
|
+
});
|
|
54
|
+
it("spoiler round-trips through the `||…||` delimiter", () => {
|
|
55
|
+
expect(render(parse("a ||hidden|| b"))).toBe("a ||hidden|| b");
|
|
56
|
+
});
|
|
57
|
+
it("highlight round-trips through the `==…==` delimiter", () => {
|
|
58
|
+
expect(render(parse("a ==marked== b"))).toBe("a ==marked== b");
|
|
59
|
+
});
|
|
60
|
+
it("underline vs bold stay distinct through a round trip", () => {
|
|
61
|
+
assertRoundTripsStructurally("__under__ and **bold**");
|
|
62
|
+
});
|
|
63
|
+
it("nested inline spans", () => {
|
|
64
|
+
assertRoundTripsStructurally("**bold *and italic* text**");
|
|
65
|
+
});
|
|
66
|
+
it("nested bold inside underline", () => {
|
|
67
|
+
assertRoundTripsStructurally("__a **b** c__");
|
|
68
|
+
});
|
|
69
|
+
it("escapes markdown-special characters in plain text", () => {
|
|
70
|
+
const out = render(parse("cost is 5 dollars"));
|
|
71
|
+
expect(out).toBe("cost is 5 dollars");
|
|
72
|
+
});
|
|
73
|
+
it("re-escapes literal specials so they don't re-trigger formatting", () => {
|
|
74
|
+
// A literal asterisk in prose (escaped in the source) must still be a
|
|
75
|
+
// literal asterisk after a render round trip, not accidentally start
|
|
76
|
+
// emphasis.
|
|
77
|
+
const doc = parse("a \\* literal star");
|
|
78
|
+
const out = render(doc);
|
|
79
|
+
expect(parse(out).blocks[0]).toMatchObject({ type: "paragraph" });
|
|
80
|
+
const reparsedText = (parse(out).blocks[0] as any).children
|
|
81
|
+
.map((c: any) => c.text ?? "")
|
|
82
|
+
.join("");
|
|
83
|
+
expect(reparsedText).toContain("*");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("render: block palette", () => {
|
|
88
|
+
it("heading levels 1-6", () => {
|
|
89
|
+
for (let level = 1; level <= 6; level++) {
|
|
90
|
+
const md = `${"#".repeat(level)} Title`;
|
|
91
|
+
expect(render(parse(md))).toBe(md);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
it("plain blockquote", () => {
|
|
95
|
+
expect(render(parse("> quoted line"))).toBe("> quoted line");
|
|
96
|
+
});
|
|
97
|
+
it("expandable blockquote uses **> on the first line", () => {
|
|
98
|
+
const doc: Document = {
|
|
99
|
+
blocks: [
|
|
100
|
+
{
|
|
101
|
+
type: "blockquote",
|
|
102
|
+
expandable: true,
|
|
103
|
+
start: 0,
|
|
104
|
+
end: 0,
|
|
105
|
+
children: [
|
|
106
|
+
{
|
|
107
|
+
type: "paragraph",
|
|
108
|
+
start: 0,
|
|
109
|
+
end: 0,
|
|
110
|
+
children: [{ type: "plain", text: "hidden gem", start: 0, end: 0 }],
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
};
|
|
116
|
+
expect(render(doc)).toBe("**> hidden gem");
|
|
117
|
+
});
|
|
118
|
+
it("multi-line expandable blockquote continues with a plain > marker", () => {
|
|
119
|
+
const doc: Document = {
|
|
120
|
+
blocks: [
|
|
121
|
+
{
|
|
122
|
+
type: "blockquote",
|
|
123
|
+
expandable: true,
|
|
124
|
+
start: 0,
|
|
125
|
+
end: 0,
|
|
126
|
+
children: [
|
|
127
|
+
{
|
|
128
|
+
type: "paragraph",
|
|
129
|
+
start: 0,
|
|
130
|
+
end: 0,
|
|
131
|
+
children: [{ type: "plain", text: "line one", start: 0, end: 0 }],
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
type: "paragraph",
|
|
135
|
+
start: 0,
|
|
136
|
+
end: 0,
|
|
137
|
+
children: [{ type: "plain", text: "line two", start: 0, end: 0 }],
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
};
|
|
143
|
+
const out = render(doc);
|
|
144
|
+
const lines = out.split("\n");
|
|
145
|
+
expect(lines[0]).toBe("**> line one");
|
|
146
|
+
for (const line of lines.slice(1)) {
|
|
147
|
+
expect(line.startsWith("> ") || line === ">").toBe(true);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
it("fenced code block with language", () => {
|
|
151
|
+
const md = "```bash\necho hi\n```";
|
|
152
|
+
expect(render(parse(md))).toBe(md);
|
|
153
|
+
});
|
|
154
|
+
it("widens the fence when the content itself contains backticks", () => {
|
|
155
|
+
const doc: Document = {
|
|
156
|
+
blocks: [
|
|
157
|
+
{
|
|
158
|
+
type: "code-block",
|
|
159
|
+
text: "```\nnested fence\n```",
|
|
160
|
+
language: null,
|
|
161
|
+
start: 0,
|
|
162
|
+
end: 0,
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
};
|
|
166
|
+
const out = render(doc);
|
|
167
|
+
// Must not accidentally close early — the emitted fence must be a longer
|
|
168
|
+
// backtick run than any run inside the content.
|
|
169
|
+
const openFenceMatch = out.match(/^`+/);
|
|
170
|
+
expect(openFenceMatch).not.toBeNull();
|
|
171
|
+
expect(openFenceMatch![0].length).toBeGreaterThan(3);
|
|
172
|
+
// Re-parsing the rendered form must recover a single code-block with the
|
|
173
|
+
// exact original text.
|
|
174
|
+
const reparsed = parse(out);
|
|
175
|
+
expect(reparsed.blocks).toHaveLength(1);
|
|
176
|
+
expect(reparsed.blocks[0]).toMatchObject({
|
|
177
|
+
type: "code-block",
|
|
178
|
+
text: "```\nnested fence\n```",
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
it("unordered list", () => {
|
|
182
|
+
assertRoundTripsStructurally("- first\n- second\n- third");
|
|
183
|
+
});
|
|
184
|
+
it("ordered list preserves start number", () => {
|
|
185
|
+
assertRoundTripsStructurally("5. five\n6. six");
|
|
186
|
+
});
|
|
187
|
+
it("task list item checked state", () => {
|
|
188
|
+
assertRoundTripsStructurally("- [x] done\n- [ ] not done");
|
|
189
|
+
});
|
|
190
|
+
it("nested list round-trips structurally", () => {
|
|
191
|
+
assertRoundTripsStructurally("- a\n - nested1\n - nested2\n- b");
|
|
192
|
+
});
|
|
193
|
+
it("tight 3-level nested list stays tight — no injected blank lines (regression)", () => {
|
|
194
|
+
// Regression: `renderListItem` used to join an item's paragraph and its
|
|
195
|
+
// nested sub-list with a blank line (renderBlocks' `\n\n`), turning a tight
|
|
196
|
+
// nested list into `- a\n\n - b\n\n - c\n- d`. Tight lists must stay
|
|
197
|
+
// tight: exact-string round trip, no blank line injected anywhere.
|
|
198
|
+
const md = "- a\n - b\n - c\n- d";
|
|
199
|
+
expect(render(parse(md))).toBe(md);
|
|
200
|
+
});
|
|
201
|
+
it("loose list keeps its blank lines between top-level items (tight/loose distinction)", () => {
|
|
202
|
+
// The fix must NOT hardcode single-newline everywhere: a genuinely loose
|
|
203
|
+
// list (blank line between items in the source) round-trips WITH the blank
|
|
204
|
+
// lines preserved.
|
|
205
|
+
const md = "- a\n\n- b\n\n- c";
|
|
206
|
+
expect(render(parse(md))).toBe(md);
|
|
207
|
+
});
|
|
208
|
+
it("loose item with a trailing paragraph keeps its internal blank line", () => {
|
|
209
|
+
const md = "- a\n\n more about a\n\n- b";
|
|
210
|
+
expect(render(parse(md))).toBe(md);
|
|
211
|
+
});
|
|
212
|
+
it("thematic break", () => {
|
|
213
|
+
expect(render(parse("---"))).toBe("---");
|
|
214
|
+
});
|
|
215
|
+
it("GFM table with mixed alignment", () => {
|
|
216
|
+
const md = [
|
|
217
|
+
"| Left | Center | Right |",
|
|
218
|
+
"| :--- | :---: | ---: |",
|
|
219
|
+
"| a | b | c |",
|
|
220
|
+
"| d | e | f |",
|
|
221
|
+
].join("\n");
|
|
222
|
+
assertRoundTripsStructurally(md);
|
|
223
|
+
});
|
|
224
|
+
it("table cell content survives round trip", () => {
|
|
225
|
+
const md = ["| Col |", "| --- |", "| **bold** cell |"].join("\n");
|
|
226
|
+
const out = render(parse(md));
|
|
227
|
+
expect(out).toContain("**bold** cell");
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe("render: full document", () => {
|
|
232
|
+
it("multiple blocks are separated by a blank line", () => {
|
|
233
|
+
const doc = parse("# Title\n\nSome text.\n\n- one\n- two");
|
|
234
|
+
const out = render(doc);
|
|
235
|
+
expect(out).toBe("# Title\n\nSome text.\n\n- one\n- two");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("round trips the parser's own torture-set style sample", () => {
|
|
239
|
+
const md = [
|
|
240
|
+
"# Report",
|
|
241
|
+
"",
|
|
242
|
+
"A **bold** claim with *italic* support and `a code span`, plus a " +
|
|
243
|
+
"[link](https://example.com) and ~~a retraction~~.",
|
|
244
|
+
"",
|
|
245
|
+
"> a quoted aside",
|
|
246
|
+
"",
|
|
247
|
+
"| Metric | Value |",
|
|
248
|
+
"| --- | ---: |",
|
|
249
|
+
"| latency | 12ms |",
|
|
250
|
+
"",
|
|
251
|
+
"```json\n{\"ok\":true}\n```",
|
|
252
|
+
"",
|
|
253
|
+
"1. step one",
|
|
254
|
+
"2. step two",
|
|
255
|
+
].join("\n");
|
|
256
|
+
assertRoundTripsStructurally(md);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe("render: construct allowlist", () => {
|
|
261
|
+
it("SUPPORTED_INLINE + SUPPORTED_BLOCK cover every IR node type the parser can emit", () => {
|
|
262
|
+
// Every construct that appears in this mixed document must be in the
|
|
263
|
+
// allowlist — otherwise the renderer's exhaustiveness `default` would have
|
|
264
|
+
// escaped it to literal text (the gap-#2 safety net) rather than emitting
|
|
265
|
+
// native markup.
|
|
266
|
+
const md = [
|
|
267
|
+
"# H",
|
|
268
|
+
"",
|
|
269
|
+
"__u__ **b** *i* ~~s~~ `c` ||sp|| ==hi== [l](https://e.com)",
|
|
270
|
+
"",
|
|
271
|
+
"> quote",
|
|
272
|
+
"",
|
|
273
|
+
"```ts\nx\n```",
|
|
274
|
+
"",
|
|
275
|
+
"- a\n- b",
|
|
276
|
+
"",
|
|
277
|
+
"---",
|
|
278
|
+
"",
|
|
279
|
+
"| A |\n| --- |\n| 1 |",
|
|
280
|
+
].join("\n");
|
|
281
|
+
const doc = parse(md);
|
|
282
|
+
const inlineTypes = new Set<string>();
|
|
283
|
+
const blockTypes = new Set<string>();
|
|
284
|
+
const walkI = (n: any) => {
|
|
285
|
+
inlineTypes.add(n.type);
|
|
286
|
+
(n.children ?? []).forEach(walkI);
|
|
287
|
+
};
|
|
288
|
+
const walkB = (n: any) => {
|
|
289
|
+
blockTypes.add(n.type);
|
|
290
|
+
switch (n.type) {
|
|
291
|
+
case "paragraph":
|
|
292
|
+
case "heading":
|
|
293
|
+
n.children.forEach(walkI);
|
|
294
|
+
break;
|
|
295
|
+
case "blockquote":
|
|
296
|
+
n.children.forEach(walkB);
|
|
297
|
+
break;
|
|
298
|
+
case "list":
|
|
299
|
+
n.items.forEach((it: any) => it.children.forEach(walkB));
|
|
300
|
+
break;
|
|
301
|
+
case "table":
|
|
302
|
+
[n.header, ...n.rows].forEach((r: any) =>
|
|
303
|
+
r.cells.forEach((c: any) => c.children.forEach(walkI)),
|
|
304
|
+
);
|
|
305
|
+
break;
|
|
306
|
+
// code-block, thematic-break: leaf blocks, no children to walk.
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
doc.blocks.forEach(walkB);
|
|
310
|
+
for (const t of blockTypes) expect(SUPPORTED_BLOCK).toContain(t as any);
|
|
311
|
+
for (const t of inlineTypes) expect(SUPPORTED_INLINE).toContain(t as any);
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
describe("renderSafe: degradation tracking", () => {
|
|
316
|
+
it("reports no degradations for a clean under-cap document", () => {
|
|
317
|
+
const result = renderSafe(parse("**hi**"), "**hi**", RICH_MESSAGE_MAX_CHARS);
|
|
318
|
+
expect(result.degradations).toEqual([]);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("degrades a MALFORMED table to a code fence and records `table-malformed`", () => {
|
|
322
|
+
// A structurally-broken table (header has 1 column, a body row has 2).
|
|
323
|
+
// mdast normalises real tables, so synthesise the broken IR directly.
|
|
324
|
+
const src = "| A |\n| --- |\n| 1 | 2 |";
|
|
325
|
+
const bad: Document = {
|
|
326
|
+
blocks: [
|
|
327
|
+
{
|
|
328
|
+
type: "table",
|
|
329
|
+
start: 0,
|
|
330
|
+
end: src.length,
|
|
331
|
+
align: [],
|
|
332
|
+
header: { cells: [{ children: [], start: 0, end: 1 }], start: 0, end: 1 },
|
|
333
|
+
rows: [
|
|
334
|
+
{
|
|
335
|
+
cells: [
|
|
336
|
+
{ children: [], start: 0, end: 1 },
|
|
337
|
+
{ children: [], start: 0, end: 1 },
|
|
338
|
+
],
|
|
339
|
+
start: 0,
|
|
340
|
+
end: 2,
|
|
341
|
+
},
|
|
342
|
+
],
|
|
343
|
+
} as TableNode,
|
|
344
|
+
],
|
|
345
|
+
};
|
|
346
|
+
const result = renderSafe(bad, src, RICH_MESSAGE_MAX_CHARS);
|
|
347
|
+
expect(result.mode).toBe("markdown");
|
|
348
|
+
expect(result.degradations).toHaveLength(1);
|
|
349
|
+
expect(result.degradations[0]).toMatchObject({
|
|
350
|
+
construct: "table",
|
|
351
|
+
reason: "table-malformed",
|
|
352
|
+
});
|
|
353
|
+
// Content preserved as a preformatted code fence (not broken pipes).
|
|
354
|
+
expect(result.text).toContain(src);
|
|
355
|
+
expect(result.text.startsWith("```")).toBe(true);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("records an `oversize` degradation when an atomic block is swapped to plain source", () => {
|
|
359
|
+
const smallPara = "Summary line.";
|
|
360
|
+
const hugeRow = "| " + "x".repeat(200) + " |";
|
|
361
|
+
const rows = Array.from({ length: 50 }, () => hugeRow).join("\n");
|
|
362
|
+
const md = `${smallPara}\n\n| Col |\n| --- |\n${rows}`;
|
|
363
|
+
const result = renderSafe(parse(md), md, 500);
|
|
364
|
+
// At least one degradation with an oversize/document reason is recorded.
|
|
365
|
+
expect(result.degradations.length).toBeGreaterThan(0);
|
|
366
|
+
expect(
|
|
367
|
+
result.degradations.some((d) => d.reason === "oversize" || d.reason === "document-oversize"),
|
|
368
|
+
).toBe(true);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("records a `document-oversize` degradation on whole-doc plain fallback", () => {
|
|
372
|
+
const hugeRow = "| " + "x".repeat(2000) + " |";
|
|
373
|
+
const rows = Array.from({ length: 100 }, () => hugeRow).join("\n");
|
|
374
|
+
const md = `| Col |\n| --- |\n${rows}`;
|
|
375
|
+
const result = renderSafe(parse(md), md, 50);
|
|
376
|
+
expect(result.mode).toBe("plain");
|
|
377
|
+
expect(result.degradations.some((d) => d.reason === "document-oversize")).toBe(true);
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
describe("renderSafe: oversized-content fallback", () => {
|
|
382
|
+
it("returns markdown mode unchanged when under the cap", () => {
|
|
383
|
+
const doc = parse("**hi**");
|
|
384
|
+
const result = renderSafe(doc, "**hi**", RICH_MESSAGE_MAX_CHARS);
|
|
385
|
+
expect(result.mode).toBe("markdown");
|
|
386
|
+
expect(result.text).toBe("**hi**");
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("swaps only the oversized atomic block to plain source text", () => {
|
|
390
|
+
// One small paragraph + one giant table (an atomic, non-bisectable
|
|
391
|
+
// construct). The cap is set low enough that only the table trips it.
|
|
392
|
+
const smallPara = "Summary line.";
|
|
393
|
+
const hugeRow = "| " + "x".repeat(200) + " |";
|
|
394
|
+
const rows = Array.from({ length: 50 }, () => hugeRow).join("\n");
|
|
395
|
+
const md = `${smallPara}\n\n| Col |\n| --- |\n${rows}`;
|
|
396
|
+
const doc = parse(md);
|
|
397
|
+
const maxLen = 500; // full doc exceeds this; the table alone also does
|
|
398
|
+
const result = renderSafe(doc, md, maxLen);
|
|
399
|
+
|
|
400
|
+
// Either the table got swapped to plain (raw slice) while the small
|
|
401
|
+
// paragraph stayed rich, or (if that still didn't fit) the WHOLE
|
|
402
|
+
// document fell back to plain — both are acceptable per the "never
|
|
403
|
+
// truncate mid-tag" contract. What must NEVER happen: a truncated /
|
|
404
|
+
// broken markdown table (an unterminated row).
|
|
405
|
+
expect(result.text.length).toBeGreaterThan(0);
|
|
406
|
+
// No dangling unterminated fence or half-row: every line that starts
|
|
407
|
+
// with "|" must also end with "|" (a torn row would violate this).
|
|
408
|
+
for (const line of result.text.split("\n")) {
|
|
409
|
+
if (line.trimStart().startsWith("|")) {
|
|
410
|
+
expect(line.trimEnd().endsWith("|")).toBe(true);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("falls back to plain text for the whole document when nothing fits", () => {
|
|
416
|
+
const hugeRow = "| " + "x".repeat(2000) + " |";
|
|
417
|
+
const rows = Array.from({ length: 100 }, () => hugeRow).join("\n");
|
|
418
|
+
const md = `| Col |\n| --- |\n${rows}`;
|
|
419
|
+
const doc = parse(md);
|
|
420
|
+
const result = renderSafe(doc, md, 50); // impossibly small cap
|
|
421
|
+
expect(result.mode).toBe("plain");
|
|
422
|
+
expect(result.text).toBe(md);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it("never emits a body with an unbalanced code fence", () => {
|
|
426
|
+
const bigCode = "```\n" + "line of code\n".repeat(500) + "```";
|
|
427
|
+
const doc = parse(bigCode);
|
|
428
|
+
const result = renderSafe(doc, bigCode, 200);
|
|
429
|
+
const fenceCount = (result.text.match(/```/g) ?? []).length;
|
|
430
|
+
// A well-formed body has an even number of ``` delimiters (or zero, if
|
|
431
|
+
// it fell back to plain text where the fence markers are inert text).
|
|
432
|
+
if (result.mode === "markdown") {
|
|
433
|
+
expect(fenceCount % 2).toBe(0);
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
parseRichRenderEnabled,
|
|
4
|
+
richRenderEnabled,
|
|
5
|
+
renderOutbound,
|
|
6
|
+
maybeRenderOutbound,
|
|
7
|
+
} from "../../render/rich-render.js";
|
|
8
|
+
|
|
9
|
+
describe("parseRichRenderEnabled", () => {
|
|
10
|
+
it("defaults OFF when unset", () => {
|
|
11
|
+
expect(parseRichRenderEnabled(undefined)).toBe(false);
|
|
12
|
+
});
|
|
13
|
+
it("accepts the truthy tokens", () => {
|
|
14
|
+
for (const v of ["1", "true", "on", "yes", "TRUE", " On "]) {
|
|
15
|
+
expect(parseRichRenderEnabled(v)).toBe(true);
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
it("treats everything else as OFF", () => {
|
|
19
|
+
for (const v of ["0", "false", "off", "no", "", "maybe"]) {
|
|
20
|
+
expect(parseRichRenderEnabled(v)).toBe(false);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("richRenderEnabled", () => {
|
|
26
|
+
it("reads SWITCHROOM_RICH_RENDER, default OFF", () => {
|
|
27
|
+
expect(richRenderEnabled({} as NodeJS.ProcessEnv)).toBe(false);
|
|
28
|
+
expect(
|
|
29
|
+
richRenderEnabled({ SWITCHROOM_RICH_RENDER: "1" } as NodeJS.ProcessEnv),
|
|
30
|
+
).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("maybeRenderOutbound", () => {
|
|
35
|
+
it("flag OFF is a byte-for-byte passthrough (no behavioural change)", () => {
|
|
36
|
+
const raw = "**bold** and a\n\n**> collapsible quote\n> second line";
|
|
37
|
+
const r = maybeRenderOutbound(raw, {} as NodeJS.ProcessEnv);
|
|
38
|
+
expect(r.mode).toBe("markdown");
|
|
39
|
+
expect(r.text).toBe(raw);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("flag ON routes through parse -> renderSafe", () => {
|
|
43
|
+
const r = maybeRenderOutbound("**> collapsible", {
|
|
44
|
+
SWITCHROOM_RICH_RENDER: "1",
|
|
45
|
+
} as NodeJS.ProcessEnv);
|
|
46
|
+
expect(r.mode).toBe("markdown");
|
|
47
|
+
// The expandable blockquote round-trips back to the `**> ` marker.
|
|
48
|
+
expect(r.text).toContain("**> ");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("flag ON preserves plain prose through the round-trip", () => {
|
|
52
|
+
const r = maybeRenderOutbound("just some plain text", {
|
|
53
|
+
SWITCHROOM_RICH_RENDER: "1",
|
|
54
|
+
} as NodeJS.ProcessEnv);
|
|
55
|
+
expect(r.text).toContain("just some plain text");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("flag ON round-trips underline / spoiler / highlight", () => {
|
|
59
|
+
const on = { SWITCHROOM_RICH_RENDER: "1" } as NodeJS.ProcessEnv;
|
|
60
|
+
expect(maybeRenderOutbound("__u__", on).text).toBe("__u__");
|
|
61
|
+
expect(maybeRenderOutbound("a ||s|| b", on).text).toBe("a ||s|| b");
|
|
62
|
+
expect(maybeRenderOutbound("a ==m== b", on).text).toBe("a ==m== b");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("flag OFF passes new constructs through untouched too", () => {
|
|
66
|
+
const raw = "__u__ and ||s|| and ==m==";
|
|
67
|
+
const r = maybeRenderOutbound(raw, {} as NodeJS.ProcessEnv);
|
|
68
|
+
expect(r.text).toBe(raw);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("renderOutbound (flag-independent)", () => {
|
|
73
|
+
it("renders an expandable blockquote round-trip end to end", () => {
|
|
74
|
+
const r = renderOutbound("**> hidden line one\n> hidden line two");
|
|
75
|
+
expect(r.mode).toBe("markdown");
|
|
76
|
+
expect(r.text.split("\n")[0]).toMatch(/^\*\*> /);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("falls back to plain mode for oversized atomic content", () => {
|
|
80
|
+
// A single code block far over the cap cannot be safely emitted as rich.
|
|
81
|
+
const huge = "```\n" + "x".repeat(40000) + "\n```";
|
|
82
|
+
const r = renderOutbound(huge, 1000);
|
|
83
|
+
expect(r.mode).toBe("plain");
|
|
84
|
+
});
|
|
85
|
+
});
|