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,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Narrated mid-turn work still shows words on the card — CHANNEL twin of
|
|
3
|
+
* `jtbd-liveness-narration-dm.test.ts` (Phase 4a, `deterministic-turn-liveness.md`).
|
|
4
|
+
*
|
|
5
|
+
* Surface parity bar per the job spec. Like the DM twin (and unlike the
|
|
6
|
+
* previous version of this file), it also asserts the no-mid-turn-ping
|
|
7
|
+
* invariant inline — the RFC and job spec claim the ping-free assertion is
|
|
8
|
+
* baked into EVERY liveness scenario, DM and channel alike. Self-skips green
|
|
9
|
+
* when no test supergroup is wired.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, it } from "vitest";
|
|
13
|
+
import { spinUp } from "../harness.js";
|
|
14
|
+
import { isActivityFeedMessage, isFrameworkFallbackText } from "../assertions.js";
|
|
15
|
+
import type { ObservedMessage } from "../driver.js";
|
|
16
|
+
|
|
17
|
+
const AGENT = "test-harness";
|
|
18
|
+
const SUPERGROUP_ID = Number.parseInt(process.env.SWITCHROOM_UAT_CHAT_ID ?? "", 10);
|
|
19
|
+
const OVERALL_BUDGET_MS = 150_000;
|
|
20
|
+
|
|
21
|
+
const NARRATED_WORK_PROMPT =
|
|
22
|
+
"Do four short steps, ONE AT A TIME. Before EACH step, write a brief " +
|
|
23
|
+
'one-sentence narration of what you are about to do (e.g. "Checking the ' +
|
|
24
|
+
'hostname now.") — this is not a reply, just your normal narration — then ' +
|
|
25
|
+
"run the step via Bash (`sleep 3` is fine as the step body). After all " +
|
|
26
|
+
"four steps, send a one-line final summary reply.";
|
|
27
|
+
|
|
28
|
+
/** Body (narration/tool) lines of a card, minus header + climb placeholder.
|
|
29
|
+
* Mirrors the DM twin's `narratedBodyLines`. */
|
|
30
|
+
function narratedBodyLines(text: string): string[] {
|
|
31
|
+
return text
|
|
32
|
+
.split("\n")
|
|
33
|
+
.map((l) => l.trim())
|
|
34
|
+
.filter((l) => l.length > 0)
|
|
35
|
+
.map((l) => l.replace(/^[→✓↳]+\s*/u, "").trim())
|
|
36
|
+
.filter(
|
|
37
|
+
(l) =>
|
|
38
|
+
l.length > 0 &&
|
|
39
|
+
!/^(?:🤖|🛠[️]?|⚙[️]?)\s/u.test(l) &&
|
|
40
|
+
!/^(?:(?:\d+m)?\d+s\s*·\s*\d+\s+tools?|(?:done|failed)\s*·)/iu.test(l) &&
|
|
41
|
+
!/^Working(?:\s*[·…]|$)/i.test(l) &&
|
|
42
|
+
!/^\+?\d+\s+earlier/i.test(l),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("uat: deterministic turn liveness — narration lands mid-turn (channel parity)", () => {
|
|
47
|
+
it(
|
|
48
|
+
"the model's own words appear on the activity card mid-turn in a supergroup, with no mid-turn ping",
|
|
49
|
+
async () => {
|
|
50
|
+
if (!Number.isFinite(SUPERGROUP_ID)) {
|
|
51
|
+
console.warn("[liveness-narration-channel] SWITCHROOM_UAT_CHAT_ID unset — skipping");
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const sc = await spinUp({ agent: AGENT, settleMs: 0 });
|
|
55
|
+
try {
|
|
56
|
+
await sc.driver.primeDialogs();
|
|
57
|
+
if (!(await sc.driver.canResolve(SUPERGROUP_ID))) {
|
|
58
|
+
console.warn(`[liveness-narration-channel] supergroup ${SUPERGROUP_ID} not resolvable — skipping`);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const iter = sc.driver
|
|
63
|
+
.observeMessages(SUPERGROUP_ID)
|
|
64
|
+
[Symbol.asyncIterator]();
|
|
65
|
+
|
|
66
|
+
await sc.driver.sendText(SUPERGROUP_ID, NARRATED_WORK_PROMPT);
|
|
67
|
+
const sentAt = Date.now();
|
|
68
|
+
|
|
69
|
+
const narrationSamples: string[] = [];
|
|
70
|
+
let answer: ObservedMessage | null = null;
|
|
71
|
+
let otherLoudMessage: ObservedMessage | null = null;
|
|
72
|
+
|
|
73
|
+
const deadline = Date.now() + 100_000;
|
|
74
|
+
while (Date.now() < deadline) {
|
|
75
|
+
if (answer) break;
|
|
76
|
+
const remaining = deadline - Date.now();
|
|
77
|
+
const next = await Promise.race([
|
|
78
|
+
iter.next(),
|
|
79
|
+
new Promise<{ done: true; value: undefined }>((r) =>
|
|
80
|
+
setTimeout(() => r({ done: true, value: undefined }), Math.max(0, remaining)),
|
|
81
|
+
),
|
|
82
|
+
]);
|
|
83
|
+
if (next.done || next.value == null) break;
|
|
84
|
+
const m = next.value as ObservedMessage;
|
|
85
|
+
if (m.senderUserId === sc.driverUserId) continue;
|
|
86
|
+
|
|
87
|
+
if (isActivityFeedMessage(m)) {
|
|
88
|
+
const narrated = narratedBodyLines(m.text);
|
|
89
|
+
if (narrated.length > 0) {
|
|
90
|
+
narrationSamples.push(narrated.join(" | "));
|
|
91
|
+
console.log(
|
|
92
|
+
`[liveness-narration-channel] narrated card content at +${Date.now() - sentAt}ms: ` +
|
|
93
|
+
JSON.stringify(narrated.join(" | ").slice(0, 140)),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
if (m.edited && m.silent === false) otherLoudMessage = otherLoudMessage ?? m;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (m.edited) continue;
|
|
100
|
+
if (isFrameworkFallbackText(m.text)) {
|
|
101
|
+
otherLoudMessage = otherLoudMessage ?? m;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (!answer && m.text.trim().length > 0) {
|
|
105
|
+
answer = m;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (m.silent === false) otherLoudMessage = otherLoudMessage ?? m;
|
|
109
|
+
}
|
|
110
|
+
await iter.return?.();
|
|
111
|
+
|
|
112
|
+
// No-mid-turn-ping invariant, inline in the channel twin too.
|
|
113
|
+
expect(
|
|
114
|
+
otherLoudMessage,
|
|
115
|
+
`a mid-turn message eroded the ping-free/edit-only guarantee in the supergroup: ` +
|
|
116
|
+
JSON.stringify(otherLoudMessage?.text?.slice(0, 120)),
|
|
117
|
+
).toBeNull();
|
|
118
|
+
|
|
119
|
+
if (narrationSamples.length === 0) {
|
|
120
|
+
console.warn(
|
|
121
|
+
"[liveness-narration-channel] INCONCLUSIVE — no narrated content observed on the " +
|
|
122
|
+
"card (model may have batched steps or the turn was too fast).",
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
expect(
|
|
127
|
+
answer,
|
|
128
|
+
"FAIL — no answer (distinct from the activity card) arrived within budget; the turn may be wedged.",
|
|
129
|
+
).not.toBeNull();
|
|
130
|
+
expect(answer?.chatId).toBe(SUPERGROUP_ID);
|
|
131
|
+
} finally {
|
|
132
|
+
await sc.tearDown();
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
OVERALL_BUDGET_MS,
|
|
136
|
+
);
|
|
137
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Narrated mid-turn work still shows words on the card (Phase 4a,
|
|
3
|
+
* `deterministic-turn-liveness.md`) — DM.
|
|
4
|
+
*
|
|
5
|
+
* The Phase-1 climb fills the GAPS the model leaves; it must never regress
|
|
6
|
+
* the pre-existing narration path (session-tail → gateway `case 'text'` →
|
|
7
|
+
* `stagePendingNarrative` → `showNarrativeStep` → `appendActivityLabel`).
|
|
8
|
+
* This scenario proves the model's own narrated steps still land on the
|
|
9
|
+
* card mid-turn, chronologically interleaved with the climb, when the model
|
|
10
|
+
* DOES talk (the complement of `jtbd-liveness-climb-dm`, which forces the
|
|
11
|
+
* model silent).
|
|
12
|
+
*
|
|
13
|
+
* IMPORTANT: switchroom has no streaming REPLY path. The words asserted here
|
|
14
|
+
* land on the pre-existing activity/liveness card (a card-edit surface), not
|
|
15
|
+
* a streamed reply — the final reply is still sent whole.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, expect, it } from "vitest";
|
|
19
|
+
import { spinUp } from "../harness.js";
|
|
20
|
+
import { isActivityFeedMessage, isFrameworkFallbackText } from "../assertions.js";
|
|
21
|
+
import type { ObservedMessage } from "../driver.js";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Extract the narration/tool body lines of an activity card, dropping the
|
|
25
|
+
* two-line header (`🤖 Agent` / `<elapsed> · <N> tools`) and the bare
|
|
26
|
+
* `Working…` placeholder the 0-label climb paints. Whatever remains is
|
|
27
|
+
* model-authored narration or tool labels — the content this scenario proves
|
|
28
|
+
* still lands on the card mid-turn.
|
|
29
|
+
*/
|
|
30
|
+
function narratedBodyLines(text: string): string[] {
|
|
31
|
+
return text
|
|
32
|
+
.split("\n")
|
|
33
|
+
.map((l) => l.trim())
|
|
34
|
+
.filter((l) => l.length > 0)
|
|
35
|
+
// strip glyphs so a `→ Working…` placeholder compares as `Working…`
|
|
36
|
+
.map((l) => l.replace(/^[→✓↳]+\s*/u, "").trim())
|
|
37
|
+
.filter(
|
|
38
|
+
(l) =>
|
|
39
|
+
l.length > 0 &&
|
|
40
|
+
// header line 1 (emoji + label, optional description)
|
|
41
|
+
!/^(?:🤖|🛠[️]?|⚙[️]?)\s/u.test(l) &&
|
|
42
|
+
// header line 2 (elapsed · tools, running or done)
|
|
43
|
+
!/^(?:(?:\d+m)?\d+s\s*·\s*\d+\s+tools?|(?:done|failed)\s*·)/iu.test(l) &&
|
|
44
|
+
// the bare climb placeholder is NOT narration
|
|
45
|
+
!/^Working(?:\s*[·…]|$)/i.test(l) &&
|
|
46
|
+
// rolling-overflow header is not narration
|
|
47
|
+
!/^\+?\d+\s+earlier/i.test(l),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const OVERALL_BUDGET_MS = 130_000;
|
|
52
|
+
|
|
53
|
+
const NARRATED_WORK_PROMPT =
|
|
54
|
+
"Do four short steps, ONE AT A TIME. Before EACH step, write a brief " +
|
|
55
|
+
'one-sentence narration of what you are about to do (e.g. "Checking the ' +
|
|
56
|
+
'hostname now.") — this is not a reply, just your normal narration — then ' +
|
|
57
|
+
"run the step via Bash (`sleep 3` is fine as the step body). After all " +
|
|
58
|
+
"four steps, send a one-line final summary reply.";
|
|
59
|
+
|
|
60
|
+
describe("uat: deterministic turn liveness — narration lands mid-turn (DM)", () => {
|
|
61
|
+
it(
|
|
62
|
+
"the model's own words appear on the activity card mid-turn (narration not regressed)",
|
|
63
|
+
async () => {
|
|
64
|
+
const sc = await spinUp({ agent: "test-harness" });
|
|
65
|
+
try {
|
|
66
|
+
const iter = sc.driver
|
|
67
|
+
.observeMessages(sc.botUserId)
|
|
68
|
+
[Symbol.asyncIterator]();
|
|
69
|
+
|
|
70
|
+
await sc.sendDM(NARRATED_WORK_PROMPT);
|
|
71
|
+
const sentAt = Date.now();
|
|
72
|
+
console.log("[liveness-narration-dm] prompt sent; watching for narrated card content…");
|
|
73
|
+
|
|
74
|
+
const narrationSamples: string[] = [];
|
|
75
|
+
let answer: ObservedMessage | null = null;
|
|
76
|
+
let otherLoudMessage: ObservedMessage | null = null;
|
|
77
|
+
|
|
78
|
+
const deadline = Date.now() + 90_000;
|
|
79
|
+
while (Date.now() < deadline) {
|
|
80
|
+
if (answer) break;
|
|
81
|
+
const remaining = deadline - Date.now();
|
|
82
|
+
const next = await Promise.race([
|
|
83
|
+
iter.next(),
|
|
84
|
+
new Promise<{ done: true; value: undefined }>((r) =>
|
|
85
|
+
setTimeout(() => r({ done: true, value: undefined }), Math.max(0, remaining)),
|
|
86
|
+
),
|
|
87
|
+
]);
|
|
88
|
+
if (next.done || next.value == null) break;
|
|
89
|
+
const m = next.value as ObservedMessage;
|
|
90
|
+
if (m.senderUserId === sc.driverUserId) continue;
|
|
91
|
+
|
|
92
|
+
if (isActivityFeedMessage(m)) {
|
|
93
|
+
// The card carries the two-line header + (climb placeholder |
|
|
94
|
+
// narrated body). Any body line that ISN'T the header or the bare
|
|
95
|
+
// "Working…" placeholder is model narration landing mid-turn.
|
|
96
|
+
const narrated = narratedBodyLines(m.text);
|
|
97
|
+
if (narrated.length > 0) {
|
|
98
|
+
narrationSamples.push(narrated.join(" | "));
|
|
99
|
+
console.log(
|
|
100
|
+
`[liveness-narration-dm] narrated card content at +${Date.now() - sentAt}ms: ` +
|
|
101
|
+
JSON.stringify(narrated.join(" | ").slice(0, 140)),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
if (m.edited && m.silent === false) otherLoudMessage = otherLoudMessage ?? m;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (m.edited) continue;
|
|
108
|
+
// A framework fallback TEXT send is a mid-turn erosion, not the
|
|
109
|
+
// answer — reject it from the answer lane so it trips the assertion.
|
|
110
|
+
if (isFrameworkFallbackText(m.text)) {
|
|
111
|
+
otherLoudMessage = otherLoudMessage ?? m;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (!answer && m.text.trim().length > 0) {
|
|
115
|
+
answer = m;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (m.silent === false) otherLoudMessage = otherLoudMessage ?? m;
|
|
119
|
+
}
|
|
120
|
+
await iter.return?.();
|
|
121
|
+
|
|
122
|
+
expect(
|
|
123
|
+
otherLoudMessage,
|
|
124
|
+
`a mid-turn message pinged the user's device: ${JSON.stringify(otherLoudMessage?.text?.slice(0, 120))}`,
|
|
125
|
+
).toBeNull();
|
|
126
|
+
|
|
127
|
+
if (narrationSamples.length === 0) {
|
|
128
|
+
console.warn(
|
|
129
|
+
"[liveness-narration-dm] INCONCLUSIVE — no narrated content observed on the " +
|
|
130
|
+
"card (model may have batched steps or the turn was too fast). Not " +
|
|
131
|
+
"necessarily a regression — but worth a human look if this recurs.",
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
// (No tautological `expect(narrationSamples.length).toBeGreaterThan(0)`
|
|
135
|
+
// in the else-branch — it was asserting a condition the branch already
|
|
136
|
+
// guarantees. The meaningful signals are the ping-free assertion above
|
|
137
|
+
// and the answer-arrived assertion below; narration presence is
|
|
138
|
+
// logged, not force-asserted, since a live model may legitimately
|
|
139
|
+
// batch its steps.)
|
|
140
|
+
|
|
141
|
+
expect(answer, "FAIL — no answer arrived within budget; the turn may be wedged.").not.toBeNull();
|
|
142
|
+
} finally {
|
|
143
|
+
await sc.tearDown();
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
OVERALL_BUDGET_MS,
|
|
147
|
+
);
|
|
148
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sparse chat-legible memory in a SUPERGROUP (#2849, hindsight Phase 4) — UAT.
|
|
3
|
+
*
|
|
4
|
+
* Channel twin of `jtbd-memory-legibility-dm`. A directive stored while the
|
|
5
|
+
* conversation is happening in a supergroup must surface its `📌 remembered:`
|
|
6
|
+
* line IN the supergroup — never the operator DM (the known status-routing
|
|
7
|
+
* bug class). Proves the memory-legibility status surface has DM/channel
|
|
8
|
+
* parity: it routes on the ORIGINATING topic (`turn.sessionChatId` /
|
|
9
|
+
* `sessionThreadId`), not the owner DM.
|
|
10
|
+
*
|
|
11
|
+
* Self-skips green when no test supergroup is wired (SWITCHROOM_UAT_CHAT_ID
|
|
12
|
+
* unset or not resolvable). Uses the General topic (mtcute here has no
|
|
13
|
+
* forum-topic create API).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, expect, it } from "vitest";
|
|
17
|
+
import { spinUp } from "../harness.js";
|
|
18
|
+
import { expectMessage } from "../assertions.js";
|
|
19
|
+
|
|
20
|
+
const AGENT = "test-harness";
|
|
21
|
+
const SUPERGROUP_ID = Number.parseInt(process.env.SWITCHROOM_UAT_CHAT_ID ?? "", 10);
|
|
22
|
+
|
|
23
|
+
const REMEMBER_PROMPT =
|
|
24
|
+
`Persist this as a standing directive using the ` +
|
|
25
|
+
`mcp__hindsight__create_directive tool, with the directive content set ` +
|
|
26
|
+
`to exactly: "Always greet me as Captain at the start of a reply". ` +
|
|
27
|
+
`Name the directive "greeting-captain". After creating it, send a brief ` +
|
|
28
|
+
`one-line confirmation.`;
|
|
29
|
+
|
|
30
|
+
const REMEMBERED_RE = /📌\s*remembered/i;
|
|
31
|
+
|
|
32
|
+
describe("uat: sparse chat-legible memory in a supergroup (#2849 channel parity)", () => {
|
|
33
|
+
it(
|
|
34
|
+
"surfaces the 📌 remembered line IN the supergroup, not the DM",
|
|
35
|
+
async () => {
|
|
36
|
+
if (!Number.isFinite(SUPERGROUP_ID)) {
|
|
37
|
+
console.warn("[memory-legibility channel UAT] SWITCHROOM_UAT_CHAT_ID unset — skipping");
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const sc = await spinUp({ agent: AGENT, settleMs: 0 });
|
|
41
|
+
try {
|
|
42
|
+
await sc.driver.primeDialogs();
|
|
43
|
+
if (!(await sc.driver.canResolve(SUPERGROUP_ID))) {
|
|
44
|
+
console.warn(`[memory-legibility channel UAT] supergroup ${SUPERGROUP_ID} not resolvable — skipping`);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
await sc.driver.sendText(SUPERGROUP_ID, REMEMBER_PROMPT);
|
|
48
|
+
|
|
49
|
+
const line = await expectMessage(sc.driver, SUPERGROUP_ID, REMEMBERED_RE, {
|
|
50
|
+
timeout: 90_000,
|
|
51
|
+
senderFilter: { notUserId: sc.driverUserId },
|
|
52
|
+
});
|
|
53
|
+
console.log(
|
|
54
|
+
`[memory-legibility channel UAT] remembered line (id=${line.messageId}, chat=${line.chatId}): ${JSON.stringify(line.text)}`,
|
|
55
|
+
);
|
|
56
|
+
expect(line.chatId).toBe(SUPERGROUP_ID); // parity proof: in the channel, not the DM
|
|
57
|
+
expect(line.fromBot).toBe(true);
|
|
58
|
+
expect(line.messageId).toBeGreaterThan(0);
|
|
59
|
+
expect(line.text).toMatch(/Captain/i);
|
|
60
|
+
} finally {
|
|
61
|
+
await sc.tearDown();
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
180_000,
|
|
65
|
+
);
|
|
66
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sparse chat-legible memory (#2849, hindsight Phase 4) — DM UAT.
|
|
3
|
+
*
|
|
4
|
+
* Serves the `remember-across-sessions` job. When the interactive session
|
|
5
|
+
* stores a new standing directive, ONE terse line must surface in the
|
|
6
|
+
* originating chat: `📌 remembered: "<directive>"`. It's a REAL message
|
|
7
|
+
* (sendMessage, not a draft/reaction) so mtcute can observe it. The line
|
|
8
|
+
* is sparse + material-only — ordinary recall never surfaces one — and is
|
|
9
|
+
* ON by default (kill-switch SWITCHROOM_MEMORY_LEGIBILITY=0).
|
|
10
|
+
*
|
|
11
|
+
* This scenario asks the agent to persist an explicit standing rule via
|
|
12
|
+
* the create_directive tool, then asserts the 📌 remembered line appears
|
|
13
|
+
* from the bot in the DM.
|
|
14
|
+
*
|
|
15
|
+
* Note: the model must actually call `mcp__hindsight__create_directive`.
|
|
16
|
+
* The prompt names the tool + the rule verbatim to make the tool call
|
|
17
|
+
* deterministic. Requires the test-harness agent running an image that
|
|
18
|
+
* includes #2849.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, expect, it } from "vitest";
|
|
22
|
+
import { spinUp } from "../harness.js";
|
|
23
|
+
|
|
24
|
+
const REMEMBER_PROMPT =
|
|
25
|
+
`Persist this as a standing directive using the ` +
|
|
26
|
+
`mcp__hindsight__create_directive tool, with the directive content set ` +
|
|
27
|
+
`to exactly: "Always greet me as Captain at the start of a reply". ` +
|
|
28
|
+
`Name the directive "greeting-captain". After creating it, send a brief ` +
|
|
29
|
+
`one-line confirmation.`;
|
|
30
|
+
|
|
31
|
+
// The legibility line: `📌 remembered: "<directive body, truncated>"`.
|
|
32
|
+
const REMEMBERED_RE = /📌\s*remembered/i;
|
|
33
|
+
|
|
34
|
+
describe("uat: sparse chat-legible memory — remembered line (#2849)", () => {
|
|
35
|
+
it(
|
|
36
|
+
"surfaces a 📌 remembered line in the DM when a directive is created",
|
|
37
|
+
async () => {
|
|
38
|
+
const sc = await spinUp({ agent: "test-harness" });
|
|
39
|
+
try {
|
|
40
|
+
await sc.sendDM(REMEMBER_PROMPT);
|
|
41
|
+
|
|
42
|
+
// The 📌 remembered line — a real bot message in this DM. May arrive
|
|
43
|
+
// alongside/near the agent's confirmation reply; generous budget for
|
|
44
|
+
// the tool round-trip + transcript flush.
|
|
45
|
+
const line = await sc.expectMessage(REMEMBERED_RE, {
|
|
46
|
+
from: "bot",
|
|
47
|
+
timeout: 90_000,
|
|
48
|
+
});
|
|
49
|
+
console.log(
|
|
50
|
+
`[memory-legibility UAT] remembered line (id=${line.messageId}): ${JSON.stringify(line.text)}`,
|
|
51
|
+
);
|
|
52
|
+
expect(line.messageId).toBeGreaterThan(0);
|
|
53
|
+
// The directive body should ride along in the line.
|
|
54
|
+
expect(line.text).toMatch(/Captain/i);
|
|
55
|
+
} finally {
|
|
56
|
+
await sc.tearDown();
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
180_000,
|
|
60
|
+
);
|
|
61
|
+
});
|
|
@@ -56,9 +56,20 @@
|
|
|
56
56
|
import { describe, it, expect } from "vitest";
|
|
57
57
|
import { spinUp } from "../harness.js";
|
|
58
58
|
import type { ObservedMessage } from "../driver.js";
|
|
59
|
+
import { richRenderEnabled } from "../../render/rich-render.js";
|
|
59
60
|
|
|
60
61
|
const AGENT = "test-harness";
|
|
61
62
|
|
|
63
|
+
// The rich-render wiring (parse -> IR -> renderSafe -> sendRichMessage) is
|
|
64
|
+
// gated behind `SWITCHROOM_RICH_RENDER` (default OFF). The expandable /
|
|
65
|
+
// collapsible round-trip proof below only runs when BOTH the driver creds AND
|
|
66
|
+
// the flag are present — the flag must be set in the gateway process under
|
|
67
|
+
// test for the renderer to actually shape the outbound message, so gating the
|
|
68
|
+
// scenario on the same flag keeps it honest (no false green when the wiring
|
|
69
|
+
// isn't live). When the flag is off, this scenario self-skips green exactly
|
|
70
|
+
// like the credential-less case.
|
|
71
|
+
const RICH_RENDER_ON = richRenderEnabled();
|
|
72
|
+
|
|
62
73
|
// The driver session is the load-bearing credential. Absent it, spinUp()
|
|
63
74
|
// throws in resolveConfig — so guard at describe level and self-skip green.
|
|
64
75
|
const HAS_DRIVER_CREDS =
|
|
@@ -105,13 +116,18 @@ const SAMPLE_LINES = [
|
|
|
105
116
|
* trip on the uat-host (PR #2745 first live run: present set was
|
|
106
117
|
* `bold, italic, code, strikethrough, text_link, blockquote, pre`).
|
|
107
118
|
*
|
|
108
|
-
* `spoiler` is
|
|
109
|
-
* span did NOT surface as a `spoiler`/`textMarked` entity
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
119
|
+
* `spoiler` is soft-observed HERE only because of THIS harness's decode path:
|
|
120
|
+
* the `||…||` span did NOT reliably surface as a `spoiler`/`textMarked` entity
|
|
121
|
+
* through the MTProto send→IV→decode round trip the UAT driver uses. This is a
|
|
122
|
+
* harness-decoder limitation, NOT a wire failure: a 2026-07 direct probe of the
|
|
123
|
+
* Bot API `sendRichMessage` endpoint (the actual production send path) confirmed
|
|
124
|
+
* `||spoiler||` DOES parse to a `spoiler` entity and `==highlight==` to `marked`
|
|
125
|
+
* on the live wire — see `reference/rfcs/telegram-native-formatting.md` §6a.
|
|
126
|
+
* Spoiler ships default-on; it stays soft here purely so this MTProto-decode
|
|
127
|
+
* gate doesn't red on its own decode gap. (Underline `__…__`, by contrast, is a
|
|
128
|
+
* genuine wire exclusion — it parses as bold; see §6a.) The decoder's
|
|
129
|
+
* `textMarked → spoiler` mapping is still pinned deterministically by the
|
|
130
|
+
* hosted unit suite.
|
|
115
131
|
*
|
|
116
132
|
* Lists and dividers carry no first-class Bot API entity (they render as
|
|
117
133
|
* bulleted/numbered/rule TEXT), so they are asserted on `reply.text` below.
|
|
@@ -252,3 +268,79 @@ function kindsPresent(msg: ObservedMessage): Set<string> {
|
|
|
252
268
|
);
|
|
253
269
|
},
|
|
254
270
|
);
|
|
271
|
+
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// Rich-render wiring proof — expandable / collapsible content round-trip.
|
|
274
|
+
//
|
|
275
|
+
// This is the piece the unit suite CANNOT prove: that a REAL markdown reply
|
|
276
|
+
// carrying the Bot API 10.1 expandable-blockquote marker (`**> `) flows
|
|
277
|
+
// through the live send path — parse.ts (marker -> IR `expandable: true`) ->
|
|
278
|
+
// render.ts (IR -> `**> ` markdown) -> renderSafe -> sendRichMessage — and
|
|
279
|
+
// that Telegram actually parses it back to a blockquote entity on the wire.
|
|
280
|
+
//
|
|
281
|
+
// Runs ONLY when the rich-render flag is on in this process (see
|
|
282
|
+
// RICH_RENDER_ON) AND the driver creds are present; self-skips green
|
|
283
|
+
// otherwise, so default CI (flag off) never reds on it.
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
const EXP_MARKER = "COLLAPSE9";
|
|
287
|
+
const EXPANDABLE_SAMPLE = [
|
|
288
|
+
`Reply with EXACTLY this and NOTHING else, keeping ${EXP_MARKER} verbatim:`,
|
|
289
|
+
"",
|
|
290
|
+
`${EXP_MARKER}: here is a collapsible section.`,
|
|
291
|
+
"",
|
|
292
|
+
"**> first hidden line of the expandable quote",
|
|
293
|
+
"> second hidden line",
|
|
294
|
+
"> third hidden line",
|
|
295
|
+
].join("\n");
|
|
296
|
+
|
|
297
|
+
(HAS_DRIVER_CREDS && RICH_RENDER_ON ? describe : describe.skip)(
|
|
298
|
+
"uat: expandable/collapsible content round-trips through the live renderer",
|
|
299
|
+
() => {
|
|
300
|
+
it(
|
|
301
|
+
"a `**>` reply parses -> renders -> sends -> decodes as a blockquote entity",
|
|
302
|
+
async () => {
|
|
303
|
+
const sc = await spinUp({ agent: AGENT });
|
|
304
|
+
try {
|
|
305
|
+
await sc.sendDM(EXPANDABLE_SAMPLE);
|
|
306
|
+
|
|
307
|
+
const reply = await sc.expectMessage(
|
|
308
|
+
(m: ObservedMessage) =>
|
|
309
|
+
m.text.includes(EXP_MARKER) || m.text === "\x01",
|
|
310
|
+
{ from: "bot", timeout: 90_000 },
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
// Decode regression gate (same as the primary scenario).
|
|
314
|
+
expect(
|
|
315
|
+
reply.text,
|
|
316
|
+
"expandable reply decoded as the \\x01 unsupported-media sentinel",
|
|
317
|
+
).not.toBe("\x01");
|
|
318
|
+
expect(reply.text).toContain(EXP_MARKER);
|
|
319
|
+
|
|
320
|
+
// The collapsible construct must survive as a real blockquote entity
|
|
321
|
+
// on the wire — proof the parse->render->send path produced valid
|
|
322
|
+
// Bot API 10.1 markdown, not stray `**>` literal text.
|
|
323
|
+
const present = kindsPresent(reply);
|
|
324
|
+
expect(
|
|
325
|
+
[...present],
|
|
326
|
+
`expandable quote did not decode as a blockquote entity ` +
|
|
327
|
+
`(present kinds: ${[...present].join(", ")})`,
|
|
328
|
+
).toContain("blockquote");
|
|
329
|
+
|
|
330
|
+
// The quoted body text must round-trip (content never lost).
|
|
331
|
+
expect(reply.text).toContain("first hidden line");
|
|
332
|
+
|
|
333
|
+
console.info(
|
|
334
|
+
"[uat] expandable round-trip entity structure: " +
|
|
335
|
+
JSON.stringify(
|
|
336
|
+
reply.entities.map((e) => ({ kind: e.kind, text: e.text })),
|
|
337
|
+
),
|
|
338
|
+
);
|
|
339
|
+
} finally {
|
|
340
|
+
await sc.tearDown();
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
120_000,
|
|
344
|
+
);
|
|
345
|
+
},
|
|
346
|
+
);
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Silent-end recovery — CHANNEL twin of `silent-end-recovery-dm.test.ts`
|
|
3
|
+
* (Phase 4a, `deterministic-turn-liveness.md`: the dark-turn guard must
|
|
4
|
+
* deliver exactly one fallback message on the real surface, in a DM AND a
|
|
5
|
+
* forum topic alike).
|
|
6
|
+
*
|
|
7
|
+
* Phase 2 (already shipped) hardens `recordUndeliveredTurnEnd` /
|
|
8
|
+
* `SILENT_END_FALLBACK_TEXT` as the ONE deterministic guard for a turn that
|
|
9
|
+
* ends having delivered no reply. This scenario proves it fires — at most
|
|
10
|
+
* once — for the same tool-heavy prompt shape, in a supergroup, AND that no
|
|
11
|
+
* mid-turn device ping is emitted before the terminal reply/fallback (the
|
|
12
|
+
* ping-free invariant the RFC/job spec claim is inline in every scenario).
|
|
13
|
+
* The terminal dark-turn fallback itself is a sanctioned loud unwedge send, so
|
|
14
|
+
* only pings STRICTLY BEFORE the terminal message count as erosion.
|
|
15
|
+
*
|
|
16
|
+
* Self-skips green when no test supergroup is wired.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, it, expect } from "vitest";
|
|
20
|
+
import { spinUp } from "../harness.js";
|
|
21
|
+
import { expectMessage, isActivityFeedMessage } from "../assertions.js";
|
|
22
|
+
import type { ObservedMessage } from "../driver.js";
|
|
23
|
+
|
|
24
|
+
const AGENT = "test-harness";
|
|
25
|
+
const SUPERGROUP_ID = Number.parseInt(process.env.SWITCHROOM_UAT_CHAT_ID ?? "", 10);
|
|
26
|
+
|
|
27
|
+
const TOOL_HEAVY_PROMPT =
|
|
28
|
+
"Pick a directory under /tmp that doesn't exist yet. Create it. " +
|
|
29
|
+
"List its contents (should be empty). Write a small file in it. " +
|
|
30
|
+
"List again. Then report what you did in a one-line reply.";
|
|
31
|
+
|
|
32
|
+
describe("uat: silent-end recovery (channel parity)", () => {
|
|
33
|
+
it(
|
|
34
|
+
"user asks in a supergroup → agent replies exactly once via the fallback ladder if needed, no mid-turn ping",
|
|
35
|
+
async () => {
|
|
36
|
+
if (!Number.isFinite(SUPERGROUP_ID)) {
|
|
37
|
+
console.warn("[silent-end-recovery-channel] SWITCHROOM_UAT_CHAT_ID unset — skipping");
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const sc = await spinUp({ agent: AGENT, settleMs: 0 });
|
|
41
|
+
try {
|
|
42
|
+
await sc.driver.primeDialogs();
|
|
43
|
+
if (!(await sc.driver.canResolve(SUPERGROUP_ID))) {
|
|
44
|
+
console.warn(`[silent-end-recovery-channel] supergroup ${SUPERGROUP_ID} not resolvable — skipping`);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const iter = sc.driver
|
|
49
|
+
.observeMessages(SUPERGROUP_ID)
|
|
50
|
+
[Symbol.asyncIterator]();
|
|
51
|
+
|
|
52
|
+
await sc.driver.sendText(SUPERGROUP_ID, TOOL_HEAVY_PROMPT);
|
|
53
|
+
const sentAt = Date.now();
|
|
54
|
+
|
|
55
|
+
// Same budget rationale as the DM twin: covers normal latency, one
|
|
56
|
+
// Stop-hook re-prompt cycle, and the worst-case 300s framework
|
|
57
|
+
// fallback. We observe the whole window so we can assert the ping-free
|
|
58
|
+
// invariant BEFORE the terminal reply, not just wait for the reply.
|
|
59
|
+
let reply: ObservedMessage | null = null;
|
|
60
|
+
let midTurnPing: ObservedMessage | null = null;
|
|
61
|
+
|
|
62
|
+
const deadline = Date.now() + 320_000;
|
|
63
|
+
while (Date.now() < deadline) {
|
|
64
|
+
if (reply) break;
|
|
65
|
+
const remaining = deadline - Date.now();
|
|
66
|
+
const next = await Promise.race([
|
|
67
|
+
iter.next(),
|
|
68
|
+
new Promise<{ done: true; value: undefined }>((r) =>
|
|
69
|
+
setTimeout(() => r({ done: true, value: undefined }), Math.max(0, remaining)),
|
|
70
|
+
),
|
|
71
|
+
]);
|
|
72
|
+
if (next.done || next.value == null) break;
|
|
73
|
+
const m = next.value as ObservedMessage;
|
|
74
|
+
if (m.senderUserId === sc.driverUserId) continue;
|
|
75
|
+
|
|
76
|
+
// Card edits are the sanctioned mid-turn liveness surface (silent by
|
|
77
|
+
// construction) — skip them from the ping accounting.
|
|
78
|
+
if (isActivityFeedMessage(m)) {
|
|
79
|
+
if (m.edited && m.silent === false) midTurnPing = midTurnPing ?? m;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (m.edited) continue;
|
|
83
|
+
// First substantive non-card bot message is the terminal reply
|
|
84
|
+
// (model answer OR the dark-turn fallback — both terminal, both may
|
|
85
|
+
// legitimately ping).
|
|
86
|
+
if (m.text.trim().length > 0) {
|
|
87
|
+
reply = m;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
expect(
|
|
93
|
+
midTurnPing,
|
|
94
|
+
`a mid-turn message pinged the device BEFORE the terminal reply in the supergroup: ` +
|
|
95
|
+
JSON.stringify(midTurnPing?.text?.slice(0, 120)),
|
|
96
|
+
).toBeNull();
|
|
97
|
+
|
|
98
|
+
expect(reply, "FAIL — no reply arrived within budget; the silent-end guard may have regressed.").not.toBeNull();
|
|
99
|
+
expect(reply!.text.length).toBeGreaterThan(0);
|
|
100
|
+
expect(reply!.chatId).toBe(SUPERGROUP_ID);
|
|
101
|
+
console.log(`[silent-end-recovery-channel] reply at +${Date.now() - sentAt}ms.`);
|
|
102
|
+
|
|
103
|
+
if (/no update from agent|didn't send a reply/i.test(reply!.text)) {
|
|
104
|
+
console.warn(
|
|
105
|
+
"[silent-end-recovery-channel] reply was the framework/dark-turn fallback — " +
|
|
106
|
+
`model never replied on its own. Reply text: ${JSON.stringify(reply!.text.slice(0, 200))}`,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// Exactly-once proof: no SECOND fallback-shaped message should
|
|
110
|
+
// follow within a short window (the `exhausted` latch).
|
|
111
|
+
let secondFallback: ObservedMessage | null = null;
|
|
112
|
+
try {
|
|
113
|
+
secondFallback = await expectMessage(
|
|
114
|
+
sc.driver,
|
|
115
|
+
SUPERGROUP_ID,
|
|
116
|
+
/no update from agent|didn't send a reply/i,
|
|
117
|
+
{ timeout: 15_000, senderFilter: { notUserId: sc.driverUserId } },
|
|
118
|
+
);
|
|
119
|
+
} catch {
|
|
120
|
+
// Timeout is the expected/good outcome — no second fallback fired.
|
|
121
|
+
}
|
|
122
|
+
expect(
|
|
123
|
+
secondFallback,
|
|
124
|
+
"the dark-turn/framework fallback fired MORE than once for the same turn " +
|
|
125
|
+
"(the `exhausted` latch should give fire-once)",
|
|
126
|
+
).toBeNull();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
await iter.return?.();
|
|
130
|
+
} finally {
|
|
131
|
+
await sc.tearDown();
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
360_000,
|
|
135
|
+
);
|
|
136
|
+
});
|