switchroom 0.17.5 → 0.17.10
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 +39 -5
- package/dist/auth-broker/index.js +386 -208
- package/dist/cli/notion-write-pretool.mjs +36 -3
- package/dist/cli/switchroom.js +1185 -585
- package/dist/host-control/main.js +149 -15
- package/dist/vault/approvals/kernel-server.js +141 -56
- package/dist/vault/broker/server.js +143 -58
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +50 -6
- package/profiles/default/CLAUDE.md +116 -0
- package/skills/mental-model-curator/SKILL.md +162 -0
- package/telegram-plugin/auth-snapshot-format.ts +50 -2
- 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/dist/bridge/bridge.js +85 -1
- package/telegram-plugin/dist/gateway/gateway.js +2597 -645
- 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 +293 -0
- package/telegram-plugin/gateway/auth-command.ts +1 -1
- package/telegram-plugin/gateway/gateway.ts +1414 -119
- 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/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/scoped-approval.ts +59 -0
- package/telegram-plugin/silent-end.ts +78 -0
- package/telegram-plugin/subagent-watcher.ts +60 -6
- package/telegram-plugin/tests/activity-card-store.test.ts +436 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +75 -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 +33 -9
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/inbound-spool.test.ts +105 -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/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/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/worktree-watch-cwds.test.ts +103 -0
- 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/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 +60 -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/config.py +37 -0
- 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 +153 -4
- 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_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_recall_exit_codes.py +49 -2
|
@@ -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
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
|
|
35
35
|
import { describe, it, expect } from "vitest";
|
|
36
36
|
import { spinUp } from "../harness.js";
|
|
37
|
+
import type { ObservedMessage } from "../driver.js";
|
|
37
38
|
|
|
38
39
|
// The prompt pushes the model into a tool-heavy state where it has
|
|
39
40
|
// produced "an answer" internally but hasn't yet realised it must
|
|
@@ -77,12 +78,33 @@ describe("uat: silent-end recovery", () => {
|
|
|
77
78
|
// fallback wording ("still working… (no update from agent
|
|
78
79
|
// in N min)") that means the silent-end loop fired AND the
|
|
79
80
|
// model didn't recover. Acceptable outcome — the user got
|
|
80
|
-
// something — but a design-health alarm. Log it
|
|
81
|
-
|
|
81
|
+
// something — but a design-health alarm. Log it AND assert the
|
|
82
|
+
// `exhausted` latch gave fire-once (transport-side), matching the
|
|
83
|
+
// channel twin exactly — the job-spec Prove-it claim is
|
|
84
|
+
// "asserted transport-side in a DM AND a supergroup alike", so the
|
|
85
|
+
// DM twin must make the same exactly-once assertion, not just warn.
|
|
86
|
+
if (/no update from agent|didn't send a reply/i.test(reply.text)) {
|
|
82
87
|
console.warn(
|
|
83
88
|
`[silent-end-recovery] reply was the framework fallback — `
|
|
84
89
|
+ `model never replied on its own. Reply text: ${JSON.stringify(reply.text.slice(0, 200))}`,
|
|
85
90
|
);
|
|
91
|
+
|
|
92
|
+
// No SECOND fallback-shaped message should follow within a short
|
|
93
|
+
// window (the `exhausted` latch gives fire-once).
|
|
94
|
+
let secondFallback: ObservedMessage | null = null;
|
|
95
|
+
try {
|
|
96
|
+
secondFallback = await sc.expectMessage(
|
|
97
|
+
/no update from agent|didn't send a reply/i,
|
|
98
|
+
{ from: "bot", timeout: 15_000 },
|
|
99
|
+
);
|
|
100
|
+
} catch {
|
|
101
|
+
// Timeout is the expected/good outcome — no second fallback fired.
|
|
102
|
+
}
|
|
103
|
+
expect(
|
|
104
|
+
secondFallback,
|
|
105
|
+
"the dark-turn/framework fallback fired MORE than once for the same turn "
|
|
106
|
+
+ "(the `exhausted` latch should give fire-once)",
|
|
107
|
+
).toBeNull();
|
|
86
108
|
}
|
|
87
109
|
} finally {
|
|
88
110
|
await sc.tearDown();
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ownership filter for the worktree-isolated cwds the subagent-watcher should
|
|
3
|
+
* additionally watch (deterministic-turn-liveness.md Known Gap 2 + the #2893
|
|
4
|
+
* ownership-predicate review fix).
|
|
5
|
+
*
|
|
6
|
+
* A sub-agent dispatched into a `switchroom worktree claim` cwd runs under a
|
|
7
|
+
* different project-dir slug than the agent's own `agentCwd`, so the #1116
|
|
8
|
+
* foreign-slug filter would skip it forever unless the watcher also watches
|
|
9
|
+
* the slugs of worktrees THIS agent owns. This helper derives that set from
|
|
10
|
+
* the host-global worktree registry, and it is deliberately fail-CLOSED:
|
|
11
|
+
*
|
|
12
|
+
* - Unset/empty identity ⇒ contribute NOTHING. `ownerAgent` is optional in
|
|
13
|
+
* the registry, so a naive `r.ownerAgent === process.env.SWITCHROOM_AGENT_NAME`
|
|
14
|
+
* with the env var unset becomes `undefined === undefined` and matches
|
|
15
|
+
* every OWNERLESS record in the host-global registry — including other
|
|
16
|
+
* agents' worktrees. That is precisely the #1116 leak the filter exists to
|
|
17
|
+
* prevent, reintroduced by failing OPEN. With no identity we cannot prove
|
|
18
|
+
* ownership of anything, so we return `[]`.
|
|
19
|
+
* - A registry read failure ⇒ `[]` (best-effort; never disturb the base
|
|
20
|
+
* agentCwd watch).
|
|
21
|
+
* - Owner match ⇒ include, realpath'd. Claude Code mints the project slug off
|
|
22
|
+
* the process's PHYSICAL cwd, so a symlinked base (macOS `/tmp` →
|
|
23
|
+
* `/private/tmp`) would otherwise derive a slug that misses the physical
|
|
24
|
+
* one; realpath best-effort, falling back to the raw path.
|
|
25
|
+
*/
|
|
26
|
+
import { realpathSync } from "node:fs";
|
|
27
|
+
|
|
28
|
+
export interface WorktreeOwnershipRecord {
|
|
29
|
+
path: string;
|
|
30
|
+
ownerAgent?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface OwnedWorktreeCwdsOptions {
|
|
34
|
+
/** The agent's identity — `process.env.SWITCHROOM_AGENT_NAME`. */
|
|
35
|
+
self: string | undefined;
|
|
36
|
+
/** The host-global registry read (`listRecords` from src/worktree/registry). */
|
|
37
|
+
listRecords: () => WorktreeOwnershipRecord[];
|
|
38
|
+
/** Injectable for tests; defaults to `fs.realpathSync`. */
|
|
39
|
+
realpath?: (p: string) => string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function ownedWorktreeCwds(opts: OwnedWorktreeCwdsOptions): string[] {
|
|
43
|
+
const { self } = opts;
|
|
44
|
+
if (self == null || self === "") return [];
|
|
45
|
+
const rp = opts.realpath ?? realpathSync;
|
|
46
|
+
try {
|
|
47
|
+
return opts
|
|
48
|
+
.listRecords()
|
|
49
|
+
.filter((r) => r.ownerAgent === self)
|
|
50
|
+
.map((r) => {
|
|
51
|
+
try {
|
|
52
|
+
return rp(r.path);
|
|
53
|
+
} catch {
|
|
54
|
+
return r.path;
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
} catch {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
Binary file
|