switchroom 0.16.28 → 0.16.38
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/agent-scheduler/index.js +13 -2
- package/dist/auth-broker/index.js +13 -2
- package/dist/cli/notion-write-pretool.mjs +13 -2
- package/dist/cli/switchroom.js +60648 -59800
- package/dist/host-control/main.js +730 -18
- package/dist/vault/approvals/kernel-server.js +51 -37
- package/dist/vault/broker/server.js +72 -58
- package/package.json +2 -1
- package/profiles/_shared/agent-self-service.md.hbs +7 -2
- package/telegram-plugin/auth-snapshot-format.ts +173 -67
- package/telegram-plugin/auto-fallback-fleet.ts +3 -6
- package/telegram-plugin/card-format.ts +52 -2
- package/telegram-plugin/credits-watch.ts +4 -7
- package/telegram-plugin/dist/gateway/gateway.js +2855 -1034
- package/telegram-plugin/dist/server.js +0 -1
- package/telegram-plugin/format.ts +264 -7
- package/telegram-plugin/gateway/approval-card.ts +7 -14
- package/telegram-plugin/gateway/approvals-commands.ts +6 -9
- package/telegram-plugin/gateway/auth-command.ts +35 -38
- package/telegram-plugin/gateway/boot-card.ts +7 -2
- package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
- package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
- package/telegram-plugin/gateway/gateway.ts +1293 -97
- package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
- package/telegram-plugin/gateway/ipc-server.ts +91 -1
- package/telegram-plugin/gateway/linear-activity.ts +2 -5
- package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
- package/telegram-plugin/gateway/permission-card-store.ts +104 -0
- package/telegram-plugin/gateway/permission-timeout.ts +25 -6
- package/telegram-plugin/gateway/status-pin-store.ts +302 -0
- package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
- package/telegram-plugin/history.ts +17 -7
- package/telegram-plugin/idle-footer.ts +2 -2
- package/telegram-plugin/issues-card.ts +12 -7
- package/telegram-plugin/model-unavailable.ts +3 -6
- package/telegram-plugin/operator-events.ts +4 -6
- package/telegram-plugin/quota-check.ts +2 -2
- package/telegram-plugin/quota-watch.ts +7 -10
- package/telegram-plugin/server.ts +3 -1
- package/telegram-plugin/status-pin-driver.ts +102 -0
- package/telegram-plugin/status-pin.ts +76 -0
- package/telegram-plugin/stream-reply-handler.ts +14 -1
- package/telegram-plugin/subagent-watcher.ts +6 -3
- package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
- package/telegram-plugin/tests/card-format.test.ts +51 -0
- package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
- package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
- package/telegram-plugin/tests/credits-watch.test.ts +18 -0
- package/telegram-plugin/tests/history.test.ts +38 -2
- package/telegram-plugin/tests/idle-footer.test.ts +53 -9
- package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
- package/telegram-plugin/tests/issues-card.test.ts +24 -0
- package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +200 -1
- package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
- package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
- package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
- package/telegram-plugin/tests/quota-check.test.ts +9 -2
- package/telegram-plugin/tests/quota-watch.test.ts +7 -7
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
- package/telegram-plugin/tests/status-pin.test.ts +202 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
- package/telegram-plugin/tests/telegram-format.test.ts +88 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +82 -24
- package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
- package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
- package/telegram-plugin/tests/voice-ondemand.test.ts +202 -0
- package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
- package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
- package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
- package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +28 -10
- package/telegram-plugin/tool-activity-summary.ts +11 -8
- package/telegram-plugin/uat/driver.ts +8 -1
- package/telegram-plugin/voice-normalize-text.ts +340 -0
- package/telegram-plugin/voice-ondemand.ts +138 -0
- package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
- package/telegram-plugin/voice-synthesize.ts +128 -0
- package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
- package/telegram-plugin/worker-activity-feed.ts +13 -4
|
@@ -188,6 +188,24 @@ describe("evaluateCreditState — transition decisions (machinery, explicit fata
|
|
|
188
188
|
// The underscore is escaped so it can't open an italic run inside **…**.
|
|
189
189
|
expect(d.message).toContain("ev\\_il");
|
|
190
190
|
});
|
|
191
|
+
|
|
192
|
+
// Boundary-escaping after the #2695 escaper de-dup: the entry message escapes
|
|
193
|
+
// BOTH the agent name and the raw reason via the consolidated `escapeMarkdown`
|
|
194
|
+
// import. A metacharacter-laden value through each site proves the import swap
|
|
195
|
+
// preserved escaping at this call-site.
|
|
196
|
+
it("escapes metacharacters in both the agent name and the cached reason (#2695)", () => {
|
|
197
|
+
const d = evaluateCreditState({
|
|
198
|
+
agentName: "a_b*c",
|
|
199
|
+
currentReason: "weird_reason*x",
|
|
200
|
+
prev: HEALTHY,
|
|
201
|
+
now: NOW,
|
|
202
|
+
fatalReasons: new Set(["weird_reason*x"]),
|
|
203
|
+
});
|
|
204
|
+
expect(d.kind).toBe("notify");
|
|
205
|
+
if (d.kind !== "notify") return;
|
|
206
|
+
expect(d.message).toContain("a\\_b\\*c");
|
|
207
|
+
expect(d.message).toContain("weird\\_reason\\*x");
|
|
208
|
+
});
|
|
191
209
|
});
|
|
192
210
|
|
|
193
211
|
describe("evaluateCreditState — subscription-only default (the fix)", () => {
|
|
@@ -28,19 +28,55 @@ afterEach(() => {
|
|
|
28
28
|
})
|
|
29
29
|
|
|
30
30
|
describe('initHistory', () => {
|
|
31
|
-
it('creates history.db with chmod
|
|
31
|
+
it('creates history.db with chmod 0644', () => {
|
|
32
32
|
initHistory(stateDir, 30)
|
|
33
33
|
const dbPath = join(stateDir, 'history.db')
|
|
34
34
|
expect(existsSync(dbPath)).toBe(true)
|
|
35
35
|
const st = statSync(dbPath)
|
|
36
36
|
// Mask off the file-type bits — only the perm bits matter.
|
|
37
|
-
|
|
37
|
+
// 0644: the web container (uid 1000) opens history.db read-only to
|
|
38
|
+
// stream replies back to Hermes Desktop. Content is redacted on write,
|
|
39
|
+
// so world-readable is safe (owner-approved behaviour change).
|
|
40
|
+
expect(st.mode & 0o777).toBe(0o644)
|
|
38
41
|
})
|
|
39
42
|
|
|
40
43
|
it('is idempotent — second call is a no-op', () => {
|
|
41
44
|
initHistory(stateDir, 30)
|
|
42
45
|
expect(() => initHistory(stateDir, 30)).not.toThrow()
|
|
43
46
|
})
|
|
47
|
+
|
|
48
|
+
// Regression for HERMES #2689 review defect #1: the web process (a different
|
|
49
|
+
// uid than the agent) opens history.db READ-ONLY to stream Telegram replies
|
|
50
|
+
// back to Hermes Desktop. If the db/WAL sidecars were left 0600, that open
|
|
51
|
+
// fails and the real-time sync poller silently no-ops. Assert both the perm
|
|
52
|
+
// bits AND that a readonly open + the poller's exact SELECT succeed.
|
|
53
|
+
it('history.db is world-readable and the Hermes poller can open it read-only', async () => {
|
|
54
|
+
initHistory(stateDir, 30)
|
|
55
|
+
recordInbound({ chat_id: '1', thread_id: null, message_id: 1, user: 'a', user_id: '1', ts: 100, text: 'hi' })
|
|
56
|
+
recordOutbound({ chat_id: '1', thread_id: null, message_ids: [2], texts: ['hello'], ts: 200 })
|
|
57
|
+
|
|
58
|
+
const dbPath = join(stateDir, 'history.db')
|
|
59
|
+
// Force a WAL sidecar to exist so we exercise the -wal/-shm perm path too.
|
|
60
|
+
for (const suffix of ['', '-shm', '-wal']) {
|
|
61
|
+
const f = dbPath + suffix
|
|
62
|
+
if (existsSync(f)) expect(statSync(f).mode & 0o777).toBe(0o644)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Reproduce the poller's open (src/web/hermes-adapter.ts startHistoryPoll):
|
|
66
|
+
// readonly connection + MAX(rowid) + assistant-row SELECT.
|
|
67
|
+
const { Database } = await import('bun:sqlite')
|
|
68
|
+
const rdb = new Database(dbPath, { readonly: true })
|
|
69
|
+
try {
|
|
70
|
+
const max = rdb.prepare('SELECT MAX(rowid) AS r FROM messages').get() as { r: number | null }
|
|
71
|
+
expect(max.r).toBeGreaterThan(0)
|
|
72
|
+
const rows = rdb
|
|
73
|
+
.prepare("SELECT rowid, role, text FROM messages WHERE rowid > ? AND role = 'assistant' ORDER BY rowid ASC")
|
|
74
|
+
.all(0) as Array<{ role: string; text: string }>
|
|
75
|
+
expect(rows.some((r) => r.text === 'hello')).toBe(true)
|
|
76
|
+
} finally {
|
|
77
|
+
rdb.close()
|
|
78
|
+
}
|
|
79
|
+
})
|
|
44
80
|
})
|
|
45
81
|
|
|
46
82
|
describe('recordInbound + query', () => {
|
|
@@ -20,39 +20,83 @@ describe("formatIdleFooter", () => {
|
|
|
20
20
|
|
|
21
21
|
it("single old turn ended 1h ago → idle 1h ago", () => {
|
|
22
22
|
const r = row({ startedAt: NOW - 70 * 60_000, endedAt: NOW - 60 * 60_000 });
|
|
23
|
-
expect(formatIdleFooter([r], NOW)).toBe("🟢 idle · last reply 1h ago");
|
|
23
|
+
expect(formatIdleFooter([r], NOW)).toBe("🟢 idle · last reply **1h ago**");
|
|
24
24
|
});
|
|
25
25
|
|
|
26
26
|
it("single turn ended 3m ago → idle 3m ago", () => {
|
|
27
27
|
const r = row({ startedAt: NOW - 5 * 60_000, endedAt: NOW - 3 * 60_000 });
|
|
28
|
-
expect(formatIdleFooter([r], NOW)).toBe("🟢 idle · last reply 3m ago");
|
|
28
|
+
expect(formatIdleFooter([r], NOW)).toBe("🟢 idle · last reply **3m ago**");
|
|
29
29
|
});
|
|
30
30
|
|
|
31
31
|
it("single turn ended 10s ago → idle <1m ago", () => {
|
|
32
32
|
const r = row({ startedAt: NOW - 30_000, endedAt: NOW - 10_000 });
|
|
33
|
-
expect(formatIdleFooter([r], NOW)).toBe("🟢 idle · last reply
|
|
33
|
+
expect(formatIdleFooter([r], NOW)).toBe("🟢 idle · last reply **<1m ago**");
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
-
it("currently running turn started 2m ago → working since 2m ago", () => {
|
|
36
|
+
it("currently running turn started 2m ago → working since **2m ago**", () => {
|
|
37
37
|
const r = row({ startedAt: NOW - 2 * 60_000 });
|
|
38
|
-
expect(formatIdleFooter([r], NOW)).toBe("⚙️ working since 2m ago");
|
|
38
|
+
expect(formatIdleFooter([r], NOW)).toBe("⚙️ working since **2m ago**");
|
|
39
39
|
});
|
|
40
40
|
|
|
41
41
|
it("multiple turns, most recent is running → working since recent", () => {
|
|
42
42
|
const old = row({ turnKey: "t1", startedAt: NOW - 10 * 60_000, endedAt: NOW - 8 * 60_000 });
|
|
43
43
|
const running = row({ turnKey: "t2", startedAt: NOW - 90_000 }); // 1m 30s ago
|
|
44
|
-
expect(formatIdleFooter([old, running], NOW)).toBe("⚙️ working since 1m ago");
|
|
44
|
+
expect(formatIdleFooter([old, running], NOW)).toBe("⚙️ working since **1m ago**");
|
|
45
45
|
});
|
|
46
46
|
|
|
47
47
|
it("multiple turns, most recent is ended → idle last reply", () => {
|
|
48
48
|
const old = row({ turnKey: "t1", startedAt: NOW - 30 * 60_000, endedAt: NOW - 28 * 60_000 });
|
|
49
49
|
const recent = row({ turnKey: "t2", startedAt: NOW - 6 * 60_000, endedAt: NOW - 4 * 60_000 });
|
|
50
|
-
expect(formatIdleFooter([old, recent], NOW)).toBe("🟢 idle · last reply 4m ago");
|
|
50
|
+
expect(formatIdleFooter([old, recent], NOW)).toBe("🟢 idle · last reply **4m ago**");
|
|
51
51
|
});
|
|
52
52
|
|
|
53
53
|
it("day-old turn → 1d ago", () => {
|
|
54
54
|
const r = row({ startedAt: NOW - 26 * 3_600_000, endedAt: NOW - 25 * 3_600_000 });
|
|
55
|
-
expect(formatIdleFooter([r], NOW)).toBe("🟢 idle · last reply 1d ago");
|
|
55
|
+
expect(formatIdleFooter([r], NOW)).toBe("🟢 idle · last reply **1d ago**");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("no-data (quiet) state carries NO ** markup", () => {
|
|
59
|
+
const out = formatIdleFooter([], NOW);
|
|
60
|
+
expect(out).toBe("🟡 quiet · no turns yet");
|
|
61
|
+
expect(out).not.toContain("**");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("working state bolds ONLY the elapsed value, not the whole line", () => {
|
|
65
|
+
const r = row({ startedAt: NOW - 4 * 60_000 });
|
|
66
|
+
const out = formatIdleFooter([r], NOW);
|
|
67
|
+
// The bold span wraps the time and only the time.
|
|
68
|
+
const bold = out.match(/\*\*(.+?)\*\*/);
|
|
69
|
+
expect(bold).not.toBeNull();
|
|
70
|
+
expect(bold![1]).toBe("4m ago");
|
|
71
|
+
// The label "⚙️ working since " is outside the bold span.
|
|
72
|
+
expect(out).toBe("⚙️ working since **4m ago**");
|
|
73
|
+
// Exactly one bold span (two `**` delimiters).
|
|
74
|
+
expect((out.match(/\*\*/g) ?? []).length).toBe(2);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("idle state bolds ONLY the last-reply value, not the whole line", () => {
|
|
78
|
+
const r = row({ startedAt: NOW - 6 * 60_000, endedAt: NOW - 3 * 60_000 });
|
|
79
|
+
const out = formatIdleFooter([r], NOW);
|
|
80
|
+
const bold = out.match(/\*\*(.+?)\*\*/);
|
|
81
|
+
expect(bold).not.toBeNull();
|
|
82
|
+
expect(bold![1]).toBe("3m ago");
|
|
83
|
+
expect(out).toBe("🟢 idle · last reply **3m ago**");
|
|
84
|
+
expect((out.match(/\*\*/g) ?? []).length).toBe(2);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Table-driven across elapsed magnitudes: a future change to formatAgo that
|
|
88
|
+
// drops the bold (or widens it past the time value) is caught here.
|
|
89
|
+
it.each([
|
|
90
|
+
{ label: "seconds", deltaMs: 10_000, ago: "<1m ago" },
|
|
91
|
+
{ label: "minutes", deltaMs: 7 * 60_000, ago: "7m ago" },
|
|
92
|
+
{ label: "hours", deltaMs: 5 * 3_600_000, ago: "5h ago" },
|
|
93
|
+
{ label: "days", deltaMs: 3 * 86_400_000, ago: "3d ago" },
|
|
94
|
+
])("idle footer bolds the $label-magnitude elapsed value", ({ deltaMs, ago }) => {
|
|
95
|
+
const r = row({ startedAt: NOW - deltaMs - 1000, endedAt: NOW - deltaMs });
|
|
96
|
+
const out = formatIdleFooter([r], NOW);
|
|
97
|
+
expect(out).toBe(`🟢 idle · last reply **${ago}**`);
|
|
98
|
+
const bold = out.match(/\*\*(.+?)\*\*/);
|
|
99
|
+
expect(bold![1]).toBe(ago);
|
|
56
100
|
});
|
|
57
101
|
|
|
58
102
|
it("unsorted rows — picks max startedAt", () => {
|
|
@@ -61,6 +105,6 @@ describe("formatIdleFooter", () => {
|
|
|
61
105
|
const r2 = row({ turnKey: "t2", startedAt: NOW - 5 * 60_000, endedAt: NOW - 2 * 60_000 });
|
|
62
106
|
const r3 = row({ turnKey: "t3", startedAt: NOW - 40 * 60_000, endedAt: NOW - 38 * 60_000 });
|
|
63
107
|
// r2 has the largest startedAt, endedAt is 2m ago
|
|
64
|
-
expect(formatIdleFooter([r1, r2, r3], NOW)).toBe("🟢 idle · last reply 2m ago");
|
|
108
|
+
expect(formatIdleFooter([r1, r2, r3], NOW)).toBe("🟢 idle · last reply **2m ago**");
|
|
65
109
|
});
|
|
66
110
|
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation contract for the #2726 rollout status IPC verbs — hostd asks the
|
|
3
|
+
* caller agent's gateway to POST (terminal ping / first narration message) or
|
|
4
|
+
* EDIT (later phases) an ORDINARY operator-DM message. A rogue process on the
|
|
5
|
+
* same UDS must not inject a malformed payload: requestId + agentName required
|
|
6
|
+
* and shaped, text required non-empty and bounded, messageId (edit) an integer.
|
|
7
|
+
*
|
|
8
|
+
* No Bun-native API → vitest per the mixed-runner gotcha.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect } from "vitest";
|
|
11
|
+
import { validateClientMessage } from "../gateway/ipc-server.js";
|
|
12
|
+
import { RICH_MESSAGE_MAX_CHARS } from "../format.js";
|
|
13
|
+
|
|
14
|
+
const post = {
|
|
15
|
+
type: "rollout_status_post",
|
|
16
|
+
requestId: "ro-1",
|
|
17
|
+
agentName: "overlord",
|
|
18
|
+
text: "⏳ Rollout → v1.2.3 — applying",
|
|
19
|
+
};
|
|
20
|
+
const edit = {
|
|
21
|
+
type: "rollout_status_edit",
|
|
22
|
+
requestId: "ro-1",
|
|
23
|
+
agentName: "overlord",
|
|
24
|
+
messageId: 42,
|
|
25
|
+
text: "✅ Rollout → v1.2.3 — done",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
describe("validateClientMessage — rollout_status_post", () => {
|
|
29
|
+
it("accepts a well-formed message", () => {
|
|
30
|
+
expect(validateClientMessage(post)).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("rejects a missing / empty / over-long requestId", () => {
|
|
34
|
+
expect(validateClientMessage({ ...post, requestId: undefined })).toBe(false);
|
|
35
|
+
expect(validateClientMessage({ ...post, requestId: "" })).toBe(false);
|
|
36
|
+
expect(validateClientMessage({ ...post, requestId: "x".repeat(65) })).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("rejects a missing / malformed agentName", () => {
|
|
40
|
+
expect(validateClientMessage({ ...post, agentName: undefined })).toBe(false);
|
|
41
|
+
expect(validateClientMessage({ ...post, agentName: "Bad Name" })).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("rejects empty or over-long text", () => {
|
|
45
|
+
expect(validateClientMessage({ ...post, text: "" })).toBe(false);
|
|
46
|
+
expect(
|
|
47
|
+
validateClientMessage({ ...post, text: "x".repeat(RICH_MESSAGE_MAX_CHARS + 1) }),
|
|
48
|
+
).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("validateClientMessage — rollout_status_edit", () => {
|
|
53
|
+
it("accepts a well-formed message", () => {
|
|
54
|
+
expect(validateClientMessage(edit)).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("rejects a non-integer messageId", () => {
|
|
58
|
+
expect(validateClientMessage({ ...edit, messageId: 1.5 })).toBe(false);
|
|
59
|
+
expect(validateClientMessage({ ...edit, messageId: "42" })).toBe(false);
|
|
60
|
+
expect(validateClientMessage({ ...edit, messageId: undefined })).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("rejects empty text", () => {
|
|
64
|
+
expect(validateClientMessage({ ...edit, text: "" })).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -175,6 +175,30 @@ describe("renderIssuesCard", () => {
|
|
|
175
175
|
expect(out).toContain("`<x>::<y>`");
|
|
176
176
|
expect(out).toContain("<dangerous & stuff>");
|
|
177
177
|
});
|
|
178
|
+
|
|
179
|
+
it("stacks issue rows with GFM hard breaks so they don't collapse onto one line (#2669)", () => {
|
|
180
|
+
// Card-body regression: the rich-message renderer collapses lone-`\n`
|
|
181
|
+
// separated rows into a run-on blob. The issues card now routes its rows
|
|
182
|
+
// through stackCardLines, so each issue row is separated by a hard break.
|
|
183
|
+
const events = [
|
|
184
|
+
makeEvent({ fingerprint: "a::1", code: "1", severity: "critical", summary: "alpha down", detail: "Fix: restart alpha" }),
|
|
185
|
+
makeEvent({ fingerprint: "a::2", code: "2", severity: "error", summary: "beta slow" }),
|
|
186
|
+
];
|
|
187
|
+
const out = renderIssuesCard({ agentName: "klanker", events, now: 1_700_000_000_000 })!;
|
|
188
|
+
// Header → rows is a genuine paragraph gap (blank line preserved).
|
|
189
|
+
expect(out).toContain("\n\n");
|
|
190
|
+
// Each adjacent issue row (and its remediation line) is hard-broken, not
|
|
191
|
+
// soft — so no two non-blank body lines are glued by a LONE `\n`.
|
|
192
|
+
const softBreaks = out.split("\n\n").flatMap((block) => {
|
|
193
|
+
// Within a block, every internal `\n` must be a hard break (` \n`).
|
|
194
|
+
return [...block.matchAll(/(?<! {2})\n/g)];
|
|
195
|
+
});
|
|
196
|
+
expect(softBreaks.length).toBe(0);
|
|
197
|
+
// Both rows + the remediation line all rendered, stacked.
|
|
198
|
+
expect(out).toContain("alpha down");
|
|
199
|
+
expect(out).toContain("→ _restart alpha_");
|
|
200
|
+
expect(out).toContain("beta slow");
|
|
201
|
+
});
|
|
178
202
|
});
|
|
179
203
|
|
|
180
204
|
// ─── createIssuesCardHandle (lifecycle) ──────────────────────────────────────
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { decideObligationTurnEnd } from '../gateway/obligation-turn-end.js'
|
|
4
|
+
import { ObligationLedger } from '../gateway/obligation-ledger.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* #2624 — obligation close on replyCalled shipped test-free. Pin BOTH branches:
|
|
8
|
+
*
|
|
9
|
+
* - Normal reply → close at turn_end (finalAnswerDelivered OR replyCalled).
|
|
10
|
+
* - Ack-then-ghost / no-reply → NOT closed at turn_end; stays open so the
|
|
11
|
+
* idle sweep ends it via silence_fallback.
|
|
12
|
+
*/
|
|
13
|
+
describe('decideObligationTurnEnd (#2624)', () => {
|
|
14
|
+
it('final answer delivered → close', () => {
|
|
15
|
+
expect(decideObligationTurnEnd(true, false)).toBe('close')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('replyCalled (short sr-* reply demoted to non-final) → close', () => {
|
|
19
|
+
// The LiteLLM sr-* cascade: reply("OK", {disable_notification:true}) is
|
|
20
|
+
// below the 200-char backstop + notification-suppressed, so
|
|
21
|
+
// finalAnswerDelivered is false — but the model explicitly replied.
|
|
22
|
+
expect(decideObligationTurnEnd(false, true)).toBe('close')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('final answer AND replyCalled → close', () => {
|
|
26
|
+
expect(decideObligationTurnEnd(true, true)).toBe('close')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('no reply at all (ack-then-ghost / no-reply) → note-ended, NOT close', () => {
|
|
30
|
+
expect(decideObligationTurnEnd(false, false)).toBe('note-ended')
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('#2624 obligation lifecycle — end-to-end over the ledger', () => {
|
|
35
|
+
const openObligation = (ledger: ObligationLedger, turnId: string) =>
|
|
36
|
+
ledger.openIfAbsent({
|
|
37
|
+
originTurnId: turnId,
|
|
38
|
+
chatId: '123',
|
|
39
|
+
messageId: 1,
|
|
40
|
+
text: 'do the thing',
|
|
41
|
+
openedAt: 1_000,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('normal reply closes the obligation at turn_end', () => {
|
|
45
|
+
const ledger = new ObligationLedger()
|
|
46
|
+
openObligation(ledger, 'turn-A')
|
|
47
|
+
expect(ledger.isOpen('turn-A')).toBe(true)
|
|
48
|
+
|
|
49
|
+
// Simulate the gateway turn_end branch for a turn that called reply.
|
|
50
|
+
if (decideObligationTurnEnd(true, false) === 'close') {
|
|
51
|
+
ledger.close('turn-A')
|
|
52
|
+
} else {
|
|
53
|
+
ledger.noteTurnEnded('turn-A', 2_000)
|
|
54
|
+
}
|
|
55
|
+
expect(ledger.isOpen('turn-A')).toBe(false)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('ack-then-ghost turn_end does NOT close — obligation survives for silence_fallback', () => {
|
|
59
|
+
const ledger = new ObligationLedger()
|
|
60
|
+
openObligation(ledger, 'turn-B')
|
|
61
|
+
|
|
62
|
+
// turn_end with no reply: the ack-then-ghost path.
|
|
63
|
+
if (decideObligationTurnEnd(false, false) === 'close') {
|
|
64
|
+
ledger.close('turn-B')
|
|
65
|
+
} else {
|
|
66
|
+
ledger.noteTurnEnded('turn-B', 2_000)
|
|
67
|
+
}
|
|
68
|
+
// Still open at turn_end — the whole point: it is ended later via
|
|
69
|
+
// silence_fallback, NOT turn_end.
|
|
70
|
+
expect(ledger.isOpen('turn-B')).toBe(true)
|
|
71
|
+
|
|
72
|
+
// The silence-fallback sweep is what finally closes it.
|
|
73
|
+
expect(ledger.close('turn-B')).toBe(true)
|
|
74
|
+
expect(ledger.isOpen('turn-B')).toBe(false)
|
|
75
|
+
})
|
|
76
|
+
})
|
|
@@ -9,7 +9,12 @@
|
|
|
9
9
|
* (un-promoted break) are preferred over false positives (double-spaced list).
|
|
10
10
|
*/
|
|
11
11
|
import { describe, test, expect } from 'vitest'
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
normalizeParagraphBreaks,
|
|
14
|
+
splitCollapsedInlineBullets,
|
|
15
|
+
addParagraphSpacers,
|
|
16
|
+
PARAGRAPH_SPACER,
|
|
17
|
+
} from '../format.js'
|
|
13
18
|
|
|
14
19
|
describe('normalizeParagraphBreaks', () => {
|
|
15
20
|
test('promotes a lone prose paragraph break (prev ends with `.`, next is prose)', () => {
|
|
@@ -271,3 +276,197 @@ describe('normalizeParagraphBreaks', () => {
|
|
|
271
276
|
expect(normalizeParagraphBreaks(input)).toBe(input)
|
|
272
277
|
})
|
|
273
278
|
})
|
|
279
|
+
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
// splitCollapsedInlineBullets — stack an inline-collapsed bullet list
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
describe('splitCollapsedInlineBullets', () => {
|
|
285
|
+
test('splits the bath-bulb run-on (4 inline bullets → 4 lines)', () => {
|
|
286
|
+
const input =
|
|
287
|
+
'• Master Bath 1, clean • Master Bath 2, 33% packet loss • Master Bath 3, clean • Cabinet, clean'
|
|
288
|
+
expect(splitCollapsedInlineBullets(input)).toBe(
|
|
289
|
+
'• Master Bath 1, clean\n• Master Bath 2, 33% packet loss\n• Master Bath 3, clean\n• Cabinet, clean',
|
|
290
|
+
)
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
test('a bold segment inside one bullet survives the split', () => {
|
|
294
|
+
const input = '• A, clean • **Master Bath 2** (192.168.5.252), **33% loss** • C, clean'
|
|
295
|
+
expect(splitCollapsedInlineBullets(input)).toBe(
|
|
296
|
+
'• A, clean\n• **Master Bath 2** (192.168.5.252), **33% loss**\n• C, clean',
|
|
297
|
+
)
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
test('a <b> segment inside one bullet survives the split', () => {
|
|
301
|
+
const input = '• A, clean • <b>Master Bath 2</b>, <b>33% packet loss</b> • C, clean'
|
|
302
|
+
expect(splitCollapsedInlineBullets(input)).toBe(
|
|
303
|
+
'• A, clean\n• <b>Master Bath 2</b>, <b>33% packet loss</b>\n• C, clean',
|
|
304
|
+
)
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
test('splits on the middle-dot `·` separator too, and a `·`-led line', () => {
|
|
308
|
+
const input = '· one · two · three'
|
|
309
|
+
expect(splitCollapsedInlineBullets(input)).toBe('· one\n· two\n· three')
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
test('accepts a `-` or `*` leading marker (but only splits unicode interior bullets)', () => {
|
|
313
|
+
expect(splitCollapsedInlineBullets('- lead marker • two • three')).toBe(
|
|
314
|
+
'- lead marker\n• two\n• three',
|
|
315
|
+
)
|
|
316
|
+
expect(splitCollapsedInlineBullets('* lead marker • two')).toBe('* lead marker\n• two')
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
test('prose containing a mid-sentence `•` (line does not start with a bullet) is untouched', () => {
|
|
320
|
+
const input = 'The plan uses A • B notation throughout the design doc.'
|
|
321
|
+
expect(splitCollapsedInlineBullets(input)).toBe(input)
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
test('does NOT split on interior `-`/`*` (hyphens, ranges, multiplication)', () => {
|
|
325
|
+
const input = '• range 1-3 and a * b product stay on one bullet'
|
|
326
|
+
// No interior unicode bullet → line is returned unchanged.
|
|
327
|
+
expect(splitCollapsedInlineBullets(input)).toBe(input)
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
test('is idempotent on the bath-bulb input', () => {
|
|
331
|
+
const input =
|
|
332
|
+
'• Master Bath 1, clean • Master Bath 2, 33% packet loss • Master Bath 3, clean • Cabinet, clean'
|
|
333
|
+
const once = splitCollapsedInlineBullets(input)
|
|
334
|
+
const twice = splitCollapsedInlineBullets(once)
|
|
335
|
+
expect(twice).toBe(once)
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
test('an already-correct multi-line bullet list passes through unchanged', () => {
|
|
339
|
+
const input = '• Master Bath 1, clean\n• Master Bath 2, 33% loss\n• Cabinet, clean'
|
|
340
|
+
expect(splitCollapsedInlineBullets(input)).toBe(input)
|
|
341
|
+
})
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
// addParagraphSpacers — restore a VISIBLE blank line between prose paragraphs.
|
|
346
|
+
//
|
|
347
|
+
// The rich GFM renderer (post-#2669) collapses a `\n\n` paragraph gap TIGHT, so
|
|
348
|
+
// multi-paragraph replies render jammed together — the operator-confirmed
|
|
349
|
+
// regression vs the old HTML path (`parse_mode:"HTML"`, `\n\n` → real blank
|
|
350
|
+
// line). addParagraphSpacers wedges a U+00A0 spacer paragraph into each prose
|
|
351
|
+
// `\n\n` gap so the rich renderer shows a visible empty line. Conservative:
|
|
352
|
+
// only between two prose blocks, never adjacent to list/table/code/quote/heading.
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
describe('addParagraphSpacers', () => {
|
|
356
|
+
const gap = `\n\n${PARAGRAPH_SPACER}\n\n`
|
|
357
|
+
|
|
358
|
+
test('the spacer is a single non-breaking space (U+00A0), not an ASCII space', () => {
|
|
359
|
+
expect(PARAGRAPH_SPACER).toBe(' ')
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
test('inserts a visible spacer paragraph into a prose `\\n\\n` gap', () => {
|
|
363
|
+
expect(addParagraphSpacers('Paragraph one.\n\nParagraph two.')).toBe(
|
|
364
|
+
`Paragraph one.${gap}Paragraph two.`,
|
|
365
|
+
)
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
test('spaces every gap in a three-paragraph body', () => {
|
|
369
|
+
expect(addParagraphSpacers('One.\n\nTwo.\n\nThree.')).toBe(
|
|
370
|
+
`One.${gap}Two.${gap}Three.`,
|
|
371
|
+
)
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
test('is idempotent — a spaced gap is not doubled on a second pass', () => {
|
|
375
|
+
const once = addParagraphSpacers('Alpha.\n\nBravo.')
|
|
376
|
+
expect(addParagraphSpacers(once)).toBe(once)
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
test('leaves a single-`\\n` separation alone (only `\\n\\n` gaps are spaced)', () => {
|
|
380
|
+
// normalizeParagraphBreaks owns lone-break promotion; this pass only adds a
|
|
381
|
+
// visible spacer where a genuine blank-line gap already exists.
|
|
382
|
+
const input = 'Line one.\nLine two.'
|
|
383
|
+
expect(addParagraphSpacers(input)).toBe(input)
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
test('does NOT space a gap adjacent to a list (tight list rhythm preserved)', () => {
|
|
387
|
+
const input = 'Here are the steps.\n\n- first\n- second'
|
|
388
|
+
expect(addParagraphSpacers(input)).toBe(input)
|
|
389
|
+
const input2 = '- first\n- second\n\nClosing prose after the list.'
|
|
390
|
+
expect(addParagraphSpacers(input2)).toBe(input2)
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
test('does NOT space a gap adjacent to a table', () => {
|
|
394
|
+
const input = 'Intro.\n\n| a | b |\n| --- | --- |\n| 1 | 2 |'
|
|
395
|
+
expect(addParagraphSpacers(input)).toBe(input)
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
test('does NOT space a gap adjacent to a fenced code block', () => {
|
|
399
|
+
const input = 'Look here.\n\n```js\nconst a = 1;\n```\n\nDone.'
|
|
400
|
+
// Neither the prose→fence nor the fence→prose gap gets a spacer.
|
|
401
|
+
expect(addParagraphSpacers(input)).toBe(input)
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
test('does NOT space a gap adjacent to a heading or blockquote', () => {
|
|
405
|
+
expect(addParagraphSpacers('Intro prose.\n\n## Section\n\nBody prose.')).toBe(
|
|
406
|
+
'Intro prose.\n\n## Section\n\nBody prose.',
|
|
407
|
+
)
|
|
408
|
+
expect(addParagraphSpacers('Said.\n\n> a quote\n\nAfter.')).toBe(
|
|
409
|
+
'Said.\n\n> a quote\n\nAfter.',
|
|
410
|
+
)
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
test('never reaches inside a fenced block (interior blank line untouched)', () => {
|
|
414
|
+
const input = 'Before.\n\n```\nline 1\n\nline 2\n```\n\nAfter.'
|
|
415
|
+
const out = addParagraphSpacers(input)
|
|
416
|
+
// The fence interior — including its own blank line — is byte-for-byte intact.
|
|
417
|
+
expect(out).toContain('```\nline 1\n\nline 2\n```')
|
|
418
|
+
// And no spacer paragraph was injected anywhere (both gaps touch the fence).
|
|
419
|
+
expect(out).not.toContain(PARAGRAPH_SPACER)
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
test('a body with no paragraph gap is returned unchanged', () => {
|
|
423
|
+
expect(addParagraphSpacers('just one paragraph, no gap')).toBe('just one paragraph, no gap')
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
test('composes after normalizeParagraphBreaks: prose gap spaced, list left tight', () => {
|
|
427
|
+
const input = [
|
|
428
|
+
'Summary of the change.',
|
|
429
|
+
'',
|
|
430
|
+
'It does two things.',
|
|
431
|
+
'',
|
|
432
|
+
'- adds a normalizer',
|
|
433
|
+
'- lifts the cap',
|
|
434
|
+
].join('\n')
|
|
435
|
+
const out = addParagraphSpacers(normalizeParagraphBreaks(input))
|
|
436
|
+
// The two prose paragraphs gain a visible spacer between them.
|
|
437
|
+
expect(out).toContain(`Summary of the change.${gap}It does two things.`)
|
|
438
|
+
// The list stays tight (no spacer wedged before it).
|
|
439
|
+
expect(out).toContain('- adds a normalizer\n- lifts the cap')
|
|
440
|
+
expect(out).not.toContain(`${PARAGRAPH_SPACER}\n\n- adds`)
|
|
441
|
+
})
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
describe('normalizeParagraphBreaks — inline bullet split integration', () => {
|
|
445
|
+
test('splits the bath-bulb run-on as part of the full outbound pass', () => {
|
|
446
|
+
const input =
|
|
447
|
+
'• Master Bath 1, clean • Master Bath 2, 33% packet loss • Master Bath 3, clean • Cabinet, clean'
|
|
448
|
+
expect(normalizeParagraphBreaks(input)).toBe(
|
|
449
|
+
'• Master Bath 1, clean\n• Master Bath 2, 33% packet loss\n• Master Bath 3, clean\n• Cabinet, clean',
|
|
450
|
+
)
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
test('a `•` inside an inline code span is NOT split (code masked)', () => {
|
|
454
|
+
const input = '`• a • b • c` is a code span'
|
|
455
|
+
// The line does not start with a bullet marker (it starts with the masked
|
|
456
|
+
// code placeholder), and the bullets live inside masked code regardless.
|
|
457
|
+
expect(normalizeParagraphBreaks(input)).toBe(input)
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
test('a `•` inside a fenced code block is NOT split', () => {
|
|
461
|
+
const input = '```\n• a • b • c\n```'
|
|
462
|
+
expect(normalizeParagraphBreaks(input)).toBe(input)
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
test('idempotent through the full pass on the bath-bulb input', () => {
|
|
466
|
+
const input =
|
|
467
|
+
'• Master Bath 1, clean • Master Bath 2, 33% packet loss • Cabinet, clean'
|
|
468
|
+
const once = normalizeParagraphBreaks(input)
|
|
469
|
+
const twice = normalizeParagraphBreaks(once)
|
|
470
|
+
expect(twice).toBe(once)
|
|
471
|
+
})
|
|
472
|
+
})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural pin for card-ux fix 3 — the "Allow once" tap updates the SAME
|
|
3
|
+
* card instead of spawning a separate "▶️ … got it, continuing" message.
|
|
4
|
+
*
|
|
5
|
+
* What broke before: tapping Allow edited the card to "✅ Allowed once" AND
|
|
6
|
+
* posted a separate resume message (to EVERY resolvePermissionCardTargets()
|
|
7
|
+
* surface). Ken wants no split — the continuation folds into the tapped card.
|
|
8
|
+
*
|
|
9
|
+
* The turn-resume trigger (dispatchPermissionVerdict) and the glyph flip
|
|
10
|
+
* (resumeReactionAfterVerdict) MUST still fire; only the user-visible second
|
|
11
|
+
* message collapses. mtcute UAT can observe the card edit and a separate
|
|
12
|
+
* sendMessage, but wiring a full Grammy ctx here is heavy, so this is a
|
|
13
|
+
* source-structure pin over the exact callback block (mirrors
|
|
14
|
+
* permission-verdict-resume-guard.test.ts).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect } from 'vitest'
|
|
18
|
+
import { readFileSync } from 'node:fs'
|
|
19
|
+
import { fileURLToPath } from 'node:url'
|
|
20
|
+
import { dirname, resolve } from 'node:path'
|
|
21
|
+
|
|
22
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
23
|
+
const GATEWAY_SRC = readFileSync(
|
|
24
|
+
resolve(__dirname, '..', 'gateway', 'gateway.ts'),
|
|
25
|
+
'utf8',
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
// Isolate the single "Allow once / Deny" verdict-tap block: from the
|
|
29
|
+
// `labelWithResume` construction to the end of its finalizeCallback call.
|
|
30
|
+
function allowOnceBlock(): string {
|
|
31
|
+
const start = GATEWAY_SRC.indexOf('const resumeLine = process.env.SWITCHROOM_RESUME_MSG')
|
|
32
|
+
expect(start).toBeGreaterThan(-1)
|
|
33
|
+
// Cut a generous slice covering resumeLine + finalizeCallback + synthInbound.
|
|
34
|
+
return GATEWAY_SRC.slice(start, start + 2000)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('permission "Allow once" tap → single in-place card edit (card-ux fix 3)', () => {
|
|
38
|
+
it('folds the resume line into the card edit via formatPermissionResumeMessage', () => {
|
|
39
|
+
const block = allowOnceBlock()
|
|
40
|
+
expect(block).toContain('formatPermissionResumeMessage(')
|
|
41
|
+
// The card newText carries BOTH the verdict label and the resume line.
|
|
42
|
+
expect(block).toContain('const labelWithResume = resumeLine')
|
|
43
|
+
expect(block).toMatch(/newText:\s*baseText\s*\?\s*`\$\{baseText\}\\n\\n\$\{labelWithResume\}`/)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('does NOT post a separate resume message on this path', () => {
|
|
47
|
+
const block = allowOnceBlock()
|
|
48
|
+
expect(block).not.toContain('postPermissionResumeMessage(')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('still fires the turn-resume trigger and glyph flip', () => {
|
|
52
|
+
const block = allowOnceBlock()
|
|
53
|
+
expect(block).toContain('dispatchPermissionVerdict(')
|
|
54
|
+
expect(block).toContain('resumeReactionAfterVerdict()')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('honours the SWITCHROOM_RESUME_MSG=0 kill-switch for the folded line', () => {
|
|
58
|
+
const block = allowOnceBlock()
|
|
59
|
+
expect(block).toContain("process.env.SWITCHROOM_RESUME_MSG === '0'")
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('carries the card-folded-resume sentinel the resume guard keys off', () => {
|
|
63
|
+
const block = allowOnceBlock()
|
|
64
|
+
expect(block).toContain('card-folded-resume')
|
|
65
|
+
})
|
|
66
|
+
})
|