switchroom 0.12.21 → 0.12.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/switchroom.js +23 -2
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +7 -6
- package/telegram-plugin/gateway/gateway.ts +40 -1
- package/telegram-plugin/gateway/inbound-delivery-machine.ts +435 -0
- package/telegram-plugin/tests/inbound-delivery-machine.test.ts +475 -0
- package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +157 -0
- package/telegram-plugin/uat/scenarios/jtbd-fast-trivial-dm.test.ts +127 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-survives-restart-dm.test.ts +239 -0
- package/telegram-plugin/uat/scenarios/jtbd-wake-audit-content-dm.test.ts +145 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JTBD scenario — short happy path: trivial questions reply FAST.
|
|
3
|
+
*
|
|
4
|
+
* Serves: `reference/know-what-my-agent-is-doing.md` — the short-path
|
|
5
|
+
* contract: a question with no real work should produce a plain reply
|
|
6
|
+
* with no ceremony (no soft-commit, no progress chunks) within a tight
|
|
7
|
+
* budget. Users judge agent speed on THIS path more than any other.
|
|
8
|
+
*
|
|
9
|
+
* Also serves: the always-on vision (`reference/vision.md`). An agent
|
|
10
|
+
* that takes 30+ seconds to answer "what's 2+2" is not "always-on" —
|
|
11
|
+
* it's awake but unresponsive.
|
|
12
|
+
*
|
|
13
|
+
* ## Targets
|
|
14
|
+
*
|
|
15
|
+
* From `reference/conversational-pacing.md` and the post-v0.12.22
|
|
16
|
+
* baseline measurements:
|
|
17
|
+
*
|
|
18
|
+
* - **TTFO p95 (vision target):** < 30s — the published contract.
|
|
19
|
+
* This test asserts the FAST-trivial case, not p95, so we tighten.
|
|
20
|
+
* - **Trivial-prompt TTFO (this test):** < 12s as hard contract,
|
|
21
|
+
* < 6s as the vision target. The mtcute post-restart UAT measured
|
|
22
|
+
* 19.4s on a COLD-START fresh-restart; a warm fast-trivial should
|
|
23
|
+
* be materially faster — the dominant cost on cold start is
|
|
24
|
+
* boot+session-resume which doesn't apply here.
|
|
25
|
+
* - **Soft-commit ceremony:** must NOT fire for trivial prompts.
|
|
26
|
+
* If the reply contains a soft-commit preamble ("let me check
|
|
27
|
+
* that for you, back in a few"), the conversational-pacing
|
|
28
|
+
* prompt classified the trivial prompt as slow — a regression.
|
|
29
|
+
*
|
|
30
|
+
* ## What this catches that other UATs don't
|
|
31
|
+
*
|
|
32
|
+
* - `jtbd-soft-commit-dm.test.ts` exercises slow prompts (the soft
|
|
33
|
+
* commit SHOULD fire). This test asserts the inverse — fast prompts
|
|
34
|
+
* should skip ceremony.
|
|
35
|
+
* - `jtbd-always-on-after-restart-dm.test.ts` asserts <120s after a
|
|
36
|
+
* cold restart. This test asserts <12s on a warm agent — a much
|
|
37
|
+
* tighter bar that catches steady-state latency regressions
|
|
38
|
+
* (model swap, MCP server slowdown, gateway middleware cost, etc.).
|
|
39
|
+
* - `smoke-dm-reply.test.ts` confirms the agent replies AT ALL but
|
|
40
|
+
* has no latency assertion — a 50s reply would pass smoke. This
|
|
41
|
+
* one fails.
|
|
42
|
+
*
|
|
43
|
+
* ## Forensic signal on a yellow-band pass
|
|
44
|
+
*
|
|
45
|
+
* If TTFO lands in 6-12s, the test passes but logs a forensic warning
|
|
46
|
+
* so a future regression in this code path is visible BEFORE it
|
|
47
|
+
* crosses the hard contract. Yellow-band drift is the canary for
|
|
48
|
+
* "something's getting slower" — better to chase it at 8s than at 28s.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
import { describe, it, expect } from "vitest";
|
|
52
|
+
import { spinUp } from "../harness.js";
|
|
53
|
+
|
|
54
|
+
const AGENT = "test-harness";
|
|
55
|
+
|
|
56
|
+
// Hard contract for trivial-prompt TTFO.
|
|
57
|
+
const HARD_TTFO_MS = 12_000;
|
|
58
|
+
|
|
59
|
+
// Vision target: trivial prompts feel near-instant.
|
|
60
|
+
const VISION_TTFO_MS = 6_000;
|
|
61
|
+
|
|
62
|
+
const TRIVIAL_PROMPT = "Reply with just the number: what is 2 + 2?";
|
|
63
|
+
|
|
64
|
+
const SOFT_COMMIT_PHRASES = [
|
|
65
|
+
/let me/i,
|
|
66
|
+
/back in/i,
|
|
67
|
+
/one (sec|moment)/i,
|
|
68
|
+
/checking/i,
|
|
69
|
+
/looking into/i,
|
|
70
|
+
/hold on/i,
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
describe("uat: short happy path — trivial prompt is FAST", () => {
|
|
74
|
+
it(
|
|
75
|
+
`trivial prompt → reply lands within ${HARD_TTFO_MS / 1000}s`,
|
|
76
|
+
async () => {
|
|
77
|
+
const sc = await spinUp({ agent: AGENT });
|
|
78
|
+
try {
|
|
79
|
+
const sendStart = Date.now();
|
|
80
|
+
await sc.sendDM(TRIVIAL_PROMPT);
|
|
81
|
+
|
|
82
|
+
const firstReply = await sc.expectMessage(/\S/, {
|
|
83
|
+
from: "bot",
|
|
84
|
+
timeout: HARD_TTFO_MS + 5_000,
|
|
85
|
+
});
|
|
86
|
+
const ttfo = Date.now() - sendStart;
|
|
87
|
+
|
|
88
|
+
expect(firstReply.text.length).toBeGreaterThan(0);
|
|
89
|
+
|
|
90
|
+
if (ttfo >= HARD_TTFO_MS) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`[fast-trivial] TTFO=${ttfo}ms exceeds hard contract ` +
|
|
93
|
+
`${HARD_TTFO_MS}ms — trivial-prompt latency regression.`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
expect(ttfo).toBeLessThan(HARD_TTFO_MS);
|
|
97
|
+
|
|
98
|
+
const triggeredSoftCommit = SOFT_COMMIT_PHRASES.some((re) =>
|
|
99
|
+
re.test(firstReply.text),
|
|
100
|
+
);
|
|
101
|
+
if (triggeredSoftCommit) {
|
|
102
|
+
console.warn(
|
|
103
|
+
`[fast-trivial] First reply contains soft-commit phrasing — ` +
|
|
104
|
+
`the conversational-pacing prompt likely classified the ` +
|
|
105
|
+
`trivial prompt as slow. Text: ${JSON.stringify(firstReply.text.slice(0, 200))}`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (ttfo >= VISION_TTFO_MS) {
|
|
110
|
+
console.warn(
|
|
111
|
+
`[fast-trivial] TTFO=${ttfo}ms — passed hard contract ` +
|
|
112
|
+
`(${HARD_TTFO_MS}ms) but slower than the vision target ` +
|
|
113
|
+
`(${VISION_TTFO_MS}ms). Forensic canary for delivery-path drift.`,
|
|
114
|
+
);
|
|
115
|
+
} else {
|
|
116
|
+
console.log(
|
|
117
|
+
`[fast-trivial] TTFO=${ttfo}ms — within vision target ` +
|
|
118
|
+
`(<${VISION_TTFO_MS}ms). Snappy.`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
} finally {
|
|
122
|
+
await sc.tearDown();
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
HARD_TTFO_MS + 15_000,
|
|
126
|
+
);
|
|
127
|
+
});
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JTBD scenario — memory survives across restart (the "fleet differentiator").
|
|
3
|
+
*
|
|
4
|
+
* Serves: `reference/remember-across-sessions.md` — the JTBD says:
|
|
5
|
+
*
|
|
6
|
+
* *Outcome:* The agent brings back relevant facts, preferences,
|
|
7
|
+
* decisions, and open threads from past conversations, in the right
|
|
8
|
+
* moment, without the user reminding it.
|
|
9
|
+
*
|
|
10
|
+
* *Stakes:* An agent with no memory is a stranger every time. The
|
|
11
|
+
* user stops sharing context because they're tired of repeating
|
|
12
|
+
* it. The relationship never compounds.
|
|
13
|
+
*
|
|
14
|
+
* Memory IS the moat. If hindsight silently drops captures, or if a
|
|
15
|
+
* restart wipes recent recall, the multi-agent specialist proposition
|
|
16
|
+
* collapses to "9 chatbots with no context, each costing a separate
|
|
17
|
+
* conversation thread to bring up to speed." This is the most
|
|
18
|
+
* expensive trust-leak in the product because regressions are
|
|
19
|
+
* invisible for days (the user keeps re-explaining, attributing the
|
|
20
|
+
* cost to "agents are like that" not "switchroom broke memory").
|
|
21
|
+
*
|
|
22
|
+
* ## Contract this asserts
|
|
23
|
+
*
|
|
24
|
+
* 1. **Capture works**: agent confirms it remembers a unique token in
|
|
25
|
+
* its first reply (capture-side observable via reply content).
|
|
26
|
+
* 2. **Survival works**: after a marker-safe restart of the agent, the
|
|
27
|
+
* same token is recalled in response to a follow-up question.
|
|
28
|
+
* 3. **Timing is reasonable**: post-restart recall reply lands within
|
|
29
|
+
* the always-on cold-start budget (vision target <30s; hard
|
|
30
|
+
* contract <120s, same as `jtbd-always-on-after-restart-dm.test.ts`).
|
|
31
|
+
*
|
|
32
|
+
* ## What this catches that other UATs don't
|
|
33
|
+
*
|
|
34
|
+
* - `jtbd-always-on-after-restart-dm.test.ts` asserts the agent REPLIES
|
|
35
|
+
* post-restart. This asserts the agent REMEMBERS post-restart.
|
|
36
|
+
* - `jtbd-status-query-dm.test.ts` and friends test conversational
|
|
37
|
+
* pacing. None test memory.
|
|
38
|
+
* - No existing UAT exercises hindsight recall as a vision contract.
|
|
39
|
+
*
|
|
40
|
+
* ## Honest scope caveat
|
|
41
|
+
*
|
|
42
|
+
* Hindsight capture is opportunistic (the agent decides when to
|
|
43
|
+
* remember, not the user). This test uses an EXPLICIT recall prompt
|
|
44
|
+
* ("please remember exactly this token") which heavily biases the
|
|
45
|
+
* model toward capturing it. A future scenario should test IMPLICIT
|
|
46
|
+
* recall (the agent inferring relevance without being asked) — the
|
|
47
|
+
* harder + more valuable JTBD case — but that's flaky against any
|
|
48
|
+
* single model, so we start with the explicit-capture baseline as the
|
|
49
|
+
* floor.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import { describe, it, expect, beforeAll } from "vitest";
|
|
53
|
+
import { execSync } from "node:child_process";
|
|
54
|
+
import { randomBytes } from "node:crypto";
|
|
55
|
+
import { spinUp } from "../harness.js";
|
|
56
|
+
|
|
57
|
+
const AGENT = "test-harness";
|
|
58
|
+
|
|
59
|
+
const RESTART_BUDGET_MS = 90_000;
|
|
60
|
+
const CAPTURE_REPLY_BUDGET_MS = 60_000;
|
|
61
|
+
const RECALL_REPLY_BUDGET_MS = 120_000;
|
|
62
|
+
const VISION_RECALL_BUDGET_MS = 30_000;
|
|
63
|
+
|
|
64
|
+
// Unique per-run token so we know the model isn't echoing a stale
|
|
65
|
+
// answer from a prior cached conversation.
|
|
66
|
+
const TOKEN = `SWITCHROOM_UAT_MEM_${randomBytes(8).toString("hex").toUpperCase()}`;
|
|
67
|
+
|
|
68
|
+
function canShellSudo(): boolean {
|
|
69
|
+
try {
|
|
70
|
+
execSync("sudo -n true", { stdio: "ignore", timeout: 2_000 });
|
|
71
|
+
return true;
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function restartAgent(name: string): void {
|
|
78
|
+
execSync(
|
|
79
|
+
`sudo -n env PATH=$PATH HOME=$HOME switchroom agent restart ${name} --force`,
|
|
80
|
+
{ stdio: ["ignore", "pipe", "pipe"], timeout: RESTART_BUDGET_MS },
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const sudoOk = canShellSudo();
|
|
85
|
+
|
|
86
|
+
// UNSKIPPED 2026-05-20 after root-cause + fix.
|
|
87
|
+
//
|
|
88
|
+
// Original failure: the first live run on 2026-05-20 FAILED — after
|
|
89
|
+
// capture → restart → recall, the agent replied "I don't have that
|
|
90
|
+
// token — no SWITCHROOM_UAT_MEM_* value was ever shared with me to
|
|
91
|
+
// remember." Documented at the time as a known vision gap.
|
|
92
|
+
//
|
|
93
|
+
// Root cause: the vendored hindsight-memory plugin's default
|
|
94
|
+
// `retainEveryNTurns: 10` throttled auto-retention to every 10
|
|
95
|
+
// turns. A 2-turn UAT session (capture turn → restart) NEVER reached
|
|
96
|
+
// the threshold, so the Stop hook's retain.py skipped (`turn_count %
|
|
97
|
+
// retain_every_n != 0`) and the token never persisted. The recall
|
|
98
|
+
// query at the new boot found nothing.
|
|
99
|
+
//
|
|
100
|
+
// Fix: switchroom's scaffold (src/agents/scaffold.ts) now applies a
|
|
101
|
+
// post-copy override that sets `retainEveryNTurns: 1` in the
|
|
102
|
+
// per-agent settings.json. Every turn end retains. Vendor file
|
|
103
|
+
// stays untouched. See project_hindsight_memory_gap_root_cause.md.
|
|
104
|
+
//
|
|
105
|
+
// Live re-run after the fix: capture TTFO=22.7s, recall TTFO=14.1s,
|
|
106
|
+
// token round-tripped successfully. The remember-across-sessions
|
|
107
|
+
// JTBD is now met for single-turn explicit-memory prompts.
|
|
108
|
+
//
|
|
109
|
+
// Likely root causes (any/all):
|
|
110
|
+
// - Hindsight capture is opportunistic — the model decides when to
|
|
111
|
+
// invoke `hindsight_save`. The "please remember exactly this
|
|
112
|
+
// token" prompt didn't trigger a save in the model's judgment.
|
|
113
|
+
// - The post-turn Stop hook (which writes hindsight) may not have
|
|
114
|
+
// flushed before the marker-safe restart killed the container.
|
|
115
|
+
// - Recall at the new boot may not query hindsight pre-reply.
|
|
116
|
+
//
|
|
117
|
+
// This UAT is SKIPPED but kept in-tree as an EXECUTABLE SPECIFICATION
|
|
118
|
+
// of the contract. Unskip the test when the underlying memory pipeline
|
|
119
|
+
// is fixed — passing this test is the gate for `remember-across-sessions`
|
|
120
|
+
// being a satisfied JTBD.
|
|
121
|
+
//
|
|
122
|
+
// Tracked as: memory-pipeline work in the post-Phase-2b roadmap.
|
|
123
|
+
//
|
|
124
|
+
// (Memory is the moat — see comment block above. Shipping the test
|
|
125
|
+
// as a known-failing skip is more honest than not shipping it at all.)
|
|
126
|
+
(sudoOk ? describe : describe.skip)(
|
|
127
|
+
"uat: memory survives across restart (remember-across-sessions JTBD)",
|
|
128
|
+
() => {
|
|
129
|
+
it(
|
|
130
|
+
"agent remembers a unique token after capture → restart → recall",
|
|
131
|
+
async () => {
|
|
132
|
+
// --- Phase 1: Capture ---
|
|
133
|
+
const sc1 = await spinUp({ agent: AGENT });
|
|
134
|
+
try {
|
|
135
|
+
const captureStart = Date.now();
|
|
136
|
+
await sc1.sendDM(
|
|
137
|
+
`Please remember exactly this token for later: ${TOKEN}. ` +
|
|
138
|
+
`Confirm in your reply that you've noted it. ` +
|
|
139
|
+
`(This is a memory-survival UAT — store it via hindsight.)`,
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const captureReply = await sc1.expectMessage(/\S/, {
|
|
143
|
+
from: "bot",
|
|
144
|
+
timeout: CAPTURE_REPLY_BUDGET_MS,
|
|
145
|
+
});
|
|
146
|
+
const captureTtfo = Date.now() - captureStart;
|
|
147
|
+
|
|
148
|
+
// The agent's first reply should acknowledge the token. We
|
|
149
|
+
// don't require the token to be echoed verbatim (the agent
|
|
150
|
+
// may say "noted" without repeating), but we DO require a
|
|
151
|
+
// non-empty reply that doesn't error.
|
|
152
|
+
expect(captureReply.text.length).toBeGreaterThan(0);
|
|
153
|
+
console.log(
|
|
154
|
+
`[memory-survives] capture phase: TTFO=${captureTtfo}ms, ` +
|
|
155
|
+
`reply length=${captureReply.text.length}`,
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
// Brief settle so any async hindsight write has time to flush
|
|
159
|
+
// before we kill the container. Hindsight captures are
|
|
160
|
+
// typically post-turn-end via a Stop hook; turn-complete
|
|
161
|
+
// signals from the gateway run within ~1-3s after the reply.
|
|
162
|
+
await new Promise((r) => setTimeout(r, 10_000));
|
|
163
|
+
} finally {
|
|
164
|
+
await sc1.tearDown();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// --- Phase 2: Restart ---
|
|
168
|
+
restartAgent(AGENT);
|
|
169
|
+
// Settle so the bridge sidecar reattaches and the new claude
|
|
170
|
+
// session loads hindsight before the recall inbound arrives.
|
|
171
|
+
await new Promise((r) => setTimeout(r, 8_000));
|
|
172
|
+
|
|
173
|
+
// --- Phase 3: Recall ---
|
|
174
|
+
const sc2 = await spinUp({ agent: AGENT });
|
|
175
|
+
try {
|
|
176
|
+
const recallStart = Date.now();
|
|
177
|
+
await sc2.sendDM(
|
|
178
|
+
`Earlier I asked you to remember a token starting with ` +
|
|
179
|
+
`SWITCHROOM_UAT_MEM_. What was the full token? ` +
|
|
180
|
+
`Reply with the token only, no extra text.`,
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
const recallReply = await sc2.expectMessage(/\S/, {
|
|
184
|
+
from: "bot",
|
|
185
|
+
timeout: RECALL_REPLY_BUDGET_MS + 5_000,
|
|
186
|
+
});
|
|
187
|
+
const recallTtfo = Date.now() - recallStart;
|
|
188
|
+
|
|
189
|
+
expect(recallReply.text.length).toBeGreaterThan(0);
|
|
190
|
+
|
|
191
|
+
// HARD CONTRACT — memory survival. If the token doesn't
|
|
192
|
+
// appear, hindsight either didn't capture it OR the recall
|
|
193
|
+
// failed to surface it.
|
|
194
|
+
const tokenInReply = recallReply.text.includes(TOKEN);
|
|
195
|
+
if (!tokenInReply) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`[memory-survives] CONTRACT FAILED: token ${TOKEN} not ` +
|
|
198
|
+
`present in recall reply. Either hindsight capture missed ` +
|
|
199
|
+
`the original message (likely if the post-turn-end Stop ` +
|
|
200
|
+
`hook didn't run before restart) OR the recall query ` +
|
|
201
|
+
`didn't find the entry. Reply was: ` +
|
|
202
|
+
`${JSON.stringify(recallReply.text.slice(0, 400))}`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
expect(tokenInReply).toBe(true);
|
|
206
|
+
|
|
207
|
+
// Timing contract — recall on a cold-restarted agent should
|
|
208
|
+
// still feel "always-on". Same bound as the post-restart
|
|
209
|
+
// first-message UAT.
|
|
210
|
+
if (recallTtfo >= RECALL_REPLY_BUDGET_MS) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
`[memory-survives] recall TTFO=${recallTtfo}ms exceeds ` +
|
|
213
|
+
`${RECALL_REPLY_BUDGET_MS}ms — matches the wedge symptom`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
expect(recallTtfo).toBeLessThan(RECALL_REPLY_BUDGET_MS);
|
|
217
|
+
|
|
218
|
+
if (recallTtfo >= VISION_RECALL_BUDGET_MS) {
|
|
219
|
+
console.warn(
|
|
220
|
+
`[memory-survives] recall TTFO=${recallTtfo}ms — passed ` +
|
|
221
|
+
`contract (${RECALL_REPLY_BUDGET_MS}ms) but slower than ` +
|
|
222
|
+
`vision target (${VISION_RECALL_BUDGET_MS}ms). Hindsight ` +
|
|
223
|
+
`query latency canary.`,
|
|
224
|
+
);
|
|
225
|
+
} else {
|
|
226
|
+
console.log(
|
|
227
|
+
`[memory-survives] recall TTFO=${recallTtfo}ms — ` +
|
|
228
|
+
`within vision target. Token round-tripped successfully.`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
} finally {
|
|
232
|
+
await sc2.tearDown();
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
// Outer budget: capture + 10s settle + restart + 8s settle + recall.
|
|
236
|
+
CAPTURE_REPLY_BUDGET_MS + 10_000 + RESTART_BUDGET_MS + 8_000 + RECALL_REPLY_BUDGET_MS + 10_000,
|
|
237
|
+
);
|
|
238
|
+
},
|
|
239
|
+
);
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JTBD scenario — wake-audit content visibility post-restart.
|
|
3
|
+
*
|
|
4
|
+
* Serves: `reference/restart-and-know-what-im-running.md` — the JTBD:
|
|
5
|
+
*
|
|
6
|
+
* *Outcome:* After any restart, the user is told what config is live.
|
|
7
|
+
* Model, tools, skills, memory backend, auth state. **No need to ask.**
|
|
8
|
+
*
|
|
9
|
+
* *Stakes:* If the user has to probe to find out what they're talking
|
|
10
|
+
* to, they don't know what they're talking to. Agents drift silently,
|
|
11
|
+
* bad configs ship unnoticed, and trust leaks away a turn at a time.
|
|
12
|
+
*
|
|
13
|
+
* The existing `jtbd-always-on-after-restart-dm.test.ts` UAT validates
|
|
14
|
+
* that the agent REPLIES post-restart. This one validates that the
|
|
15
|
+
* agent's content reflects awareness of its own config — i.e. that
|
|
16
|
+
* the wake-audit / boot card is doing its job.
|
|
17
|
+
*
|
|
18
|
+
* ## Soft contract (this version)
|
|
19
|
+
*
|
|
20
|
+
* The strictest contract — the JTBD's "no need to ask" — would require
|
|
21
|
+
* observing a proactive wake-audit message immediately after restart
|
|
22
|
+
* without any user prompt. That requires harness support for observing
|
|
23
|
+
* `editMessageText` events (the boot card is an edit of a pinned
|
|
24
|
+
* message, not a fresh send), which `mtcute` doesn't currently
|
|
25
|
+
* surface in the same way as `sendMessage`.
|
|
26
|
+
*
|
|
27
|
+
* This UAT relaxes to: after restart, the user asks "what are you
|
|
28
|
+
* running?" — the agent's reply must contain identifiable config
|
|
29
|
+
* signals (model name OR "claude" OR an MCP server name OR "skill"
|
|
30
|
+
* OR "memory" OR "switchroom"). A fully amnesiac agent that says
|
|
31
|
+
* "I'm an AI assistant" would fail this.
|
|
32
|
+
*
|
|
33
|
+
* A FUTURE strict UAT should observe the boot card edit directly
|
|
34
|
+
* — that's the true vision contract. This is the floor.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { describe, it, expect } from "vitest";
|
|
38
|
+
import { execSync } from "node:child_process";
|
|
39
|
+
import { spinUp } from "../harness.js";
|
|
40
|
+
|
|
41
|
+
const AGENT = "test-harness";
|
|
42
|
+
const RESTART_BUDGET_MS = 90_000;
|
|
43
|
+
const REPLY_BUDGET_MS = 60_000;
|
|
44
|
+
|
|
45
|
+
// Config signals — at least ONE must appear in the agent's reply to
|
|
46
|
+
// the "what are you running" question. These cover:
|
|
47
|
+
// - model identity (`claude`, `sonnet`, `opus`, `haiku`)
|
|
48
|
+
// - tooling layer (`switchroom`, `mcp`, `tool`)
|
|
49
|
+
// - capability surface (`skill`, `memory`, `hindsight`)
|
|
50
|
+
// - operational state (`agent`, `running`, `version`)
|
|
51
|
+
const CONFIG_SIGNAL_REGEX =
|
|
52
|
+
/\b(claude|sonnet|opus|haiku|switchroom|mcp|hindsight|skill|memory|agent|model|running|version)\b/i;
|
|
53
|
+
|
|
54
|
+
function canShellSudo(): boolean {
|
|
55
|
+
try {
|
|
56
|
+
execSync("sudo -n true", { stdio: "ignore", timeout: 2_000 });
|
|
57
|
+
return true;
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function restartAgent(name: string): void {
|
|
64
|
+
execSync(
|
|
65
|
+
`sudo -n env PATH=$PATH HOME=$HOME switchroom agent restart ${name} --force`,
|
|
66
|
+
{ stdio: ["ignore", "pipe", "pipe"], timeout: RESTART_BUDGET_MS },
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const sudoOk = canShellSudo();
|
|
71
|
+
|
|
72
|
+
(sudoOk ? describe : describe.skip)(
|
|
73
|
+
"uat: wake-audit content post-restart (restart-and-know JTBD)",
|
|
74
|
+
() => {
|
|
75
|
+
it(
|
|
76
|
+
"agent describes its own config when asked post-restart",
|
|
77
|
+
async () => {
|
|
78
|
+
restartAgent(AGENT);
|
|
79
|
+
// Settle for bridge re-attach.
|
|
80
|
+
await new Promise((r) => setTimeout(r, 8_000));
|
|
81
|
+
|
|
82
|
+
const sc = await spinUp({ agent: AGENT });
|
|
83
|
+
try {
|
|
84
|
+
await sc.sendDM(
|
|
85
|
+
"Briefly: what model are you running, and what tools/skills do " +
|
|
86
|
+
"you have available? One short paragraph is fine.",
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const reply = await sc.expectMessage(/\S/, {
|
|
90
|
+
from: "bot",
|
|
91
|
+
timeout: REPLY_BUDGET_MS,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(reply.text.length).toBeGreaterThan(0);
|
|
95
|
+
|
|
96
|
+
// The reply must include AT LEAST ONE config signal. A
|
|
97
|
+
// generic "I'm an AI assistant ready to help" without any
|
|
98
|
+
// model/tool reference would fail — that's the failure mode
|
|
99
|
+
// we want to catch.
|
|
100
|
+
const matchedSignal = CONFIG_SIGNAL_REGEX.exec(reply.text);
|
|
101
|
+
if (matchedSignal == null) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`[wake-audit-content] CONTRACT FAILED: agent reply to ` +
|
|
104
|
+
`"what are you running?" contained NO config signals ` +
|
|
105
|
+
`(model, tools, skills, mcp, memory, etc.). The ` +
|
|
106
|
+
`\`restart-and-know-what-im-running\` JTBD requires the ` +
|
|
107
|
+
`user to know what's live without probing — at minimum ` +
|
|
108
|
+
`the agent should respond to a direct question. Reply: ` +
|
|
109
|
+
`${JSON.stringify(reply.text.slice(0, 400))}`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
expect(matchedSignal).not.toBeNull();
|
|
113
|
+
console.log(
|
|
114
|
+
`[wake-audit-content] config signal "${matchedSignal[0]}" ` +
|
|
115
|
+
`present in reply. Length=${reply.text.length}, snippet: ` +
|
|
116
|
+
`${JSON.stringify(reply.text.slice(0, 120))}`,
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
// Optional: count how many distinct signals appeared. A
|
|
120
|
+
// wake-audit-rich reply mentions several (model + skills +
|
|
121
|
+
// mcp). A bare-minimum compliant reply mentions one.
|
|
122
|
+
const allSignals =
|
|
123
|
+
reply.text.match(new RegExp(CONFIG_SIGNAL_REGEX.source, "gi")) ?? [];
|
|
124
|
+
const uniqueSignals = new Set(allSignals.map((s) => s.toLowerCase()));
|
|
125
|
+
if (uniqueSignals.size < 2) {
|
|
126
|
+
console.warn(
|
|
127
|
+
`[wake-audit-content] only ${uniqueSignals.size} distinct ` +
|
|
128
|
+
`config signal(s) present — reply meets the floor but ` +
|
|
129
|
+
`is not config-rich. Vision target: model + tools + ` +
|
|
130
|
+
`skills + memory all visible in the wake-audit.`,
|
|
131
|
+
);
|
|
132
|
+
} else {
|
|
133
|
+
console.log(
|
|
134
|
+
`[wake-audit-content] config-rich reply: ${uniqueSignals.size} ` +
|
|
135
|
+
`distinct signals: ${Array.from(uniqueSignals).slice(0, 6).join(", ")}`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
} finally {
|
|
139
|
+
await sc.tearDown();
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
RESTART_BUDGET_MS + 8_000 + REPLY_BUDGET_MS + 10_000,
|
|
143
|
+
);
|
|
144
|
+
},
|
|
145
|
+
);
|