switchroom 0.18.7 → 0.18.9
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/README.md +2 -2
- package/dist/cli/switchroom.js +905 -758
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +111 -34
- package/skills/switchroom-runtime/SKILL.md +2 -0
- package/telegram-plugin/dist/gateway/gateway.js +46273 -44324
- package/telegram-plugin/flood-circuit-breaker.ts +123 -0
- package/telegram-plugin/gateway/activity-card-store.ts +63 -18
- package/telegram-plugin/gateway/approval-card-stores.ts +99 -0
- package/telegram-plugin/gateway/boot-card.ts +27 -0
- package/telegram-plugin/gateway/bot-commands-ops-info.ts +194 -0
- package/telegram-plugin/gateway/busy-ack.ts +106 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +2660 -0
- package/telegram-plugin/gateway/gateway.ts +1169 -3043
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +181 -23
- package/telegram-plugin/gateway/inbound-delivery-machine.ts +8 -0
- package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
- package/telegram-plugin/gateway/model-command.ts +23 -11
- package/telegram-plugin/gateway/outbound-send-path.ts +375 -0
- package/telegram-plugin/gateway/pending-state-stores.ts +106 -0
- package/telegram-plugin/gateway/register-bot-commands.ts +30 -0
- package/telegram-plugin/gateway/session-model-file.ts +198 -0
- package/telegram-plugin/gateway/status-pin-store.ts +82 -22
- package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
- package/telegram-plugin/hooks/hooks.json +10 -10
- package/telegram-plugin/hooks/run-hook.sh +84 -0
- package/telegram-plugin/model-unavailable.ts +26 -0
- package/telegram-plugin/pty-partial-handler.ts +39 -0
- package/telegram-plugin/render/rich-render.ts +79 -1
- package/telegram-plugin/retry-api-call.ts +62 -0
- package/telegram-plugin/shared/bot-runtime.ts +8 -1
- package/telegram-plugin/silence-poke.ts +14 -0
- package/telegram-plugin/stream-controller.ts +156 -38
- package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
- package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
- package/telegram-plugin/tests/approval-card-stores.test.ts +124 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
- package/telegram-plugin/tests/busy-ack.test.ts +121 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +701 -0
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +11 -4
- package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +75 -0
- package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +5 -1
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +177 -25
- package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +418 -0
- package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +348 -0
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +141 -52
- package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -1
- package/telegram-plugin/tests/model-command.test.ts +2 -2
- package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
- package/telegram-plugin/tests/outbound-send-chunks.test.ts +304 -0
- package/telegram-plugin/tests/outbound-send-path.test.ts +222 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +34 -15
- package/telegram-plugin/tests/pending-state-stores.test.ts +235 -0
- package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
- package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
- package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
- package/telegram-plugin/tests/session-model-file.test.ts +132 -0
- package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
- package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +18 -4
- package/telegram-plugin/tests/vault-approval-posture.test.ts +15 -7
- package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +8 -4
- package/telegram-plugin/tests/vault-grant-union.test.ts +8 -4
- package/telegram-plugin/tests/vault-grant-wizard.test.ts +8 -1
- package/telegram-plugin/tests/vault-grants-revoke.test.ts +8 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +8 -4
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +8 -4
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +8 -4
- package/telegram-plugin/tests/voice-send.test.ts +308 -0
- package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
- package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
- package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
- package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
- package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
- package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
- package/telegram-plugin/voice-ondemand.ts +25 -1
- package/telegram-plugin/voice-send.ts +154 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UAT scenario named by `reference/jobs/approve-what-my-agent-can-touch.md`
|
|
3
|
+
* § Prove it — "Card survives a gateway restart (DM)".
|
|
4
|
+
*
|
|
5
|
+
* Contract under test (#2989 durability fix): an agent-initiated
|
|
6
|
+
* `vault_request_access` card issued BEFORE a gateway restart is still
|
|
7
|
+
* tappable AFTER it. The gateway persists card metadata to the durable
|
|
8
|
+
* pending-card store, restores it on boot, and a tap on the still-valid card
|
|
9
|
+
* grants and resumes the parked agent exactly like a pre-restart tap —
|
|
10
|
+
* never the "Card expired — ask the agent to re-request" tombstone while the
|
|
11
|
+
* agent is parked and structurally cannot re-request.
|
|
12
|
+
*
|
|
13
|
+
* Load-bearing assertions:
|
|
14
|
+
* 1. The post-restart tap does NOT produce the expired-card tombstone edit.
|
|
15
|
+
* 2. The approve flow completes (passphrase → Granted card edit).
|
|
16
|
+
* 3. The parked agent resumes on the grant with no driver nudge.
|
|
17
|
+
*
|
|
18
|
+
* **Skipped by default.** To unskip:
|
|
19
|
+
*
|
|
20
|
+
* 1. Standard UAT preflight (`uat/SETUP.md` §5-6).
|
|
21
|
+
* 2. NOPASSWD sudo on the runner host (the scenario restarts test-harness).
|
|
22
|
+
* 3. `TELEGRAM_UAT_VAULT_PASSPHRASE` set in env.
|
|
23
|
+
* 4. Pre-create a sacrificial vault key:
|
|
24
|
+
*
|
|
25
|
+
* ```bash
|
|
26
|
+
* TMPF=$(mktemp) && printf '%s' 'sentinel-2989-value' > "$TMPF" && \
|
|
27
|
+
* switchroom vault set uat/card-survives-restart --file "$TMPF" \
|
|
28
|
+
* --format string ; shred -u "$TMPF"
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* 5. Remove `describe.skip` below.
|
|
32
|
+
*
|
|
33
|
+
* Why skipped: mutates vault state (mints a grant) and bounces the harness
|
|
34
|
+
* agent. Cleanup is operator-side (`switchroom vault revoke <grant-id>`).
|
|
35
|
+
*
|
|
36
|
+
* Module-level twin (every CI run): the restart round-trip in
|
|
37
|
+
* `tests/approval-card-restart-outcome.test.ts` and the store contract in
|
|
38
|
+
* `tests/pending-card-store.test.ts`.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { describe, expect, it } from "vitest";
|
|
42
|
+
import { execSync, spawn } from "node:child_process";
|
|
43
|
+
import { spinUp } from "../harness.js";
|
|
44
|
+
|
|
45
|
+
const AGENT = "test-harness";
|
|
46
|
+
const KEY = "uat/card-survives-restart";
|
|
47
|
+
const CARD_BUDGET_MS = 120_000;
|
|
48
|
+
const BOOT_BUDGET_MS = 180_000;
|
|
49
|
+
const RESUME_BUDGET_MS = 180_000;
|
|
50
|
+
|
|
51
|
+
function canShellSudo(): boolean {
|
|
52
|
+
try {
|
|
53
|
+
execSync("sudo -n true", { stdio: "ignore", timeout: 2_000 });
|
|
54
|
+
return true;
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function kickRestartDetached(name: string): void {
|
|
61
|
+
const child = spawn(
|
|
62
|
+
"sudo",
|
|
63
|
+
["-n", "env", `PATH=${process.env.PATH}`, `HOME=${process.env.HOME}`,
|
|
64
|
+
"switchroom", "agent", "restart", name, "--force"],
|
|
65
|
+
{ detached: true, stdio: "ignore" },
|
|
66
|
+
);
|
|
67
|
+
child.unref();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe.skip("uat: approval card survives a gateway restart (DM, #2989)", () => {
|
|
71
|
+
it(
|
|
72
|
+
"card issued pre-restart is tappable post-restart: grant lands and the agent resumes",
|
|
73
|
+
async () => {
|
|
74
|
+
if (!canShellSudo()) {
|
|
75
|
+
throw new Error("NOPASSWD sudo required — see header to set up.");
|
|
76
|
+
}
|
|
77
|
+
const passphrase = process.env.TELEGRAM_UAT_VAULT_PASSPHRASE;
|
|
78
|
+
if (!passphrase) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
"TELEGRAM_UAT_VAULT_PASSPHRASE must be set in env (see uat/SETUP.md).",
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
const sc = await spinUp({ agent: AGENT, settleMs: 0 });
|
|
84
|
+
try {
|
|
85
|
+
// 1. Park the agent on an approval card.
|
|
86
|
+
await sc.sendDM(
|
|
87
|
+
`Please call your vault_request_access MCP tool for the key ` +
|
|
88
|
+
`\`${KEY}\` (scope read, 30d, reason "UAT #2989 card durability"). ` +
|
|
89
|
+
`END YOUR TURN cleanly and wait; when the operator approves, resume ` +
|
|
90
|
+
`by running \`switchroom vault get ${KEY}\` and reporting success.`,
|
|
91
|
+
);
|
|
92
|
+
const card = await sc.expectMessage(/wants vault access/, {
|
|
93
|
+
from: "bot",
|
|
94
|
+
timeout: CARD_BUDGET_MS,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// 2. Bounce the gateway BEFORE anyone taps.
|
|
98
|
+
kickRestartDetached(AGENT);
|
|
99
|
+
await new Promise((r) => setTimeout(r, BOOT_BUDGET_MS));
|
|
100
|
+
|
|
101
|
+
// 3. Tap Approve on the ORIGINAL, pre-restart card message.
|
|
102
|
+
const kb = await sc.driver.getKeyboard(sc.botUserId, card.messageId);
|
|
103
|
+
const approveButton = kb!
|
|
104
|
+
.flat()
|
|
105
|
+
.find((b) => b.callbackData !== undefined && /approve/i.test(b.text));
|
|
106
|
+
expect(approveButton).toBeDefined();
|
|
107
|
+
await sc.driver.pressButton(
|
|
108
|
+
sc.botUserId,
|
|
109
|
+
card.messageId,
|
|
110
|
+
approveButton!.callbackData!,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
// 4. The restored card must run the REAL approve flow — the expired
|
|
114
|
+
// tombstone ("expired before you tapped") is the pre-#2989 defect.
|
|
115
|
+
const next = await sc.expectMessage(
|
|
116
|
+
(m) =>
|
|
117
|
+
/Reply with your passphrase|Granted|expired before you tapped/i.test(m.text),
|
|
118
|
+
{ from: "bot", timeout: 60_000 },
|
|
119
|
+
);
|
|
120
|
+
expect(next.text).not.toMatch(/expired before you tapped/i);
|
|
121
|
+
|
|
122
|
+
if (/passphrase/i.test(next.text)) {
|
|
123
|
+
await sc.sendDM(passphrase);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 5. Grant lands and the parked agent resumes without a nudge.
|
|
127
|
+
// Edits excluded — the "✅ Granted" edit of the card itself must
|
|
128
|
+
// not satisfy this; only a NEW message from the resumed turn does.
|
|
129
|
+
const resumed = await sc.expectMessage(
|
|
130
|
+
(m) => !m.edited && /(granted|access|vault get|succe)/i.test(m.text) && m.text.length > 40,
|
|
131
|
+
{ from: "bot", timeout: RESUME_BUDGET_MS },
|
|
132
|
+
);
|
|
133
|
+
expect(resumed.text.length).toBeGreaterThan(40);
|
|
134
|
+
} finally {
|
|
135
|
+
await sc.tearDown();
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
CARD_BUDGET_MS + BOOT_BUDGET_MS + RESUME_BUDGET_MS + 120_000,
|
|
139
|
+
);
|
|
140
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UAT scenario named by `reference/jobs/approve-what-my-agent-can-touch.md`
|
|
3
|
+
* § Prove it — "Denied request resumes the turn (DM)".
|
|
4
|
+
*
|
|
5
|
+
* Contract under test: the operator taps Deny on an agent-initiated
|
|
6
|
+
* `vault_request_access` card; the parked turn RESUMES (the gateway injects
|
|
7
|
+
* the `vault_grant_denied` synthetic), and the agent states plainly what it
|
|
8
|
+
* now can't do and continues or degrades — never left waiting on a card it
|
|
9
|
+
* already got an answer to, and never treating the denial as a reason to
|
|
10
|
+
* silently strand.
|
|
11
|
+
*
|
|
12
|
+
* Load-bearing assertion: after the Deny tap, the DRIVER sees a NEW bot turn
|
|
13
|
+
* that acknowledges the denial/degraded path WITHOUT the driver sending any
|
|
14
|
+
* further message. (The unit-level twin pins the synthetic's envelope in
|
|
15
|
+
* `tests/vault-grant-inbound-builders.test.ts`; this scenario proves the
|
|
16
|
+
* user-visible outcome over real Telegram.)
|
|
17
|
+
*
|
|
18
|
+
* Needs only the standard UAT preflight (`uat/SETUP.md` §5-6) — a Deny mints
|
|
19
|
+
* nothing and mutates no vault state, so no passphrase or sacrificial key is
|
|
20
|
+
* required. The requested key deliberately does not exist.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { describe, expect, it } from "vitest";
|
|
24
|
+
import { spinUp } from "../harness.js";
|
|
25
|
+
|
|
26
|
+
const KEY = "uat/deny-resume-nonexistent-key";
|
|
27
|
+
const CARD_BUDGET_MS = 120_000;
|
|
28
|
+
const RESUME_BUDGET_MS = 120_000;
|
|
29
|
+
|
|
30
|
+
// The deny synthetic steers the model toward naming the blocked capability
|
|
31
|
+
// and a fallback — this framing is the stable signal of a resumed turn.
|
|
32
|
+
const DENY_RESUME_FRAMING =
|
|
33
|
+
/den(?:ied|ial)|can(?:'|no)t|won'?t be able|without (?:that|the) (?:key|access|credential)|not (?:been )?grant/i;
|
|
34
|
+
|
|
35
|
+
describe("uat: denied vault request resumes the parked turn (DM)", () => {
|
|
36
|
+
it(
|
|
37
|
+
"operator taps Deny → parked turn resumes and the agent names what it can't do, with no nudge",
|
|
38
|
+
async () => {
|
|
39
|
+
const sc = await spinUp({ agent: "test-harness" });
|
|
40
|
+
try {
|
|
41
|
+
// 1. Park the agent on an approval card.
|
|
42
|
+
await sc.sendDM(
|
|
43
|
+
`Please call your vault_request_access MCP tool for the key ` +
|
|
44
|
+
`\`${KEY}\` (scope read, reason "UAT deny-resumes-turn"). Then END ` +
|
|
45
|
+
`YOUR TURN cleanly and wait. When you hear the operator's decision, ` +
|
|
46
|
+
`resume: if denied, tell me plainly that you can't read that key ` +
|
|
47
|
+
`and what you'd do instead.`,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
// 2. Wait for the approval card and find the Deny button.
|
|
51
|
+
const card = await sc.expectMessage(/wants vault access/, {
|
|
52
|
+
from: "bot",
|
|
53
|
+
timeout: CARD_BUDGET_MS,
|
|
54
|
+
});
|
|
55
|
+
const kb = await sc.driver.getKeyboard(sc.botUserId, card.messageId);
|
|
56
|
+
const denyButton = kb!
|
|
57
|
+
.flat()
|
|
58
|
+
.find((b) => b.callbackData !== undefined && /deny/i.test(b.text));
|
|
59
|
+
expect(denyButton).toBeDefined();
|
|
60
|
+
|
|
61
|
+
// 3. Tap Deny. NO further driver message after this point.
|
|
62
|
+
await sc.driver.pressButton(
|
|
63
|
+
sc.botUserId,
|
|
64
|
+
card.messageId,
|
|
65
|
+
denyButton!.callbackData!,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
// 4. The deny synthetic must wake the parked turn: a substantive
|
|
69
|
+
// bot reply that acknowledges the denial arrives unprompted.
|
|
70
|
+
// Edits are excluded — the gateway's own "🚫 Denied" edit of the
|
|
71
|
+
// approval card must not satisfy this; only a genuinely NEW
|
|
72
|
+
// message from a resumed turn counts.
|
|
73
|
+
const reply = await sc.expectMessage(
|
|
74
|
+
(m) => !m.edited && DENY_RESUME_FRAMING.test(m.text) && m.text.length > 40,
|
|
75
|
+
{ from: "bot", timeout: RESUME_BUDGET_MS },
|
|
76
|
+
);
|
|
77
|
+
expect(reply.text).toMatch(DENY_RESUME_FRAMING);
|
|
78
|
+
} finally {
|
|
79
|
+
await sc.tearDown();
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
CARD_BUDGET_MS + RESUME_BUDGET_MS + 60_000,
|
|
83
|
+
);
|
|
84
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UAT scenario named by `reference/jobs/approve-what-my-agent-can-touch.md`
|
|
3
|
+
* § Prove it — "Timeout wakes the agent as timeout-not-denial (DM)".
|
|
4
|
+
*
|
|
5
|
+
* Contract under test: an agent-initiated `vault_request_access` card ages
|
|
6
|
+
* past the approval TTL (60-min default) with NO operator tap. The reaper
|
|
7
|
+
* expires the card, records the missed-approvals re-offer, and injects the
|
|
8
|
+
* `vault_grant_timeout` synthetic — waking the parked agent with a *timeout,
|
|
9
|
+
* not a denial* outcome. The agent then says the request went unanswered
|
|
10
|
+
* (rather than reading silence as a "no" and rather than staying parked).
|
|
11
|
+
*
|
|
12
|
+
* A real 60-min wait is not viable in a scenario, so this test requires the
|
|
13
|
+
* test-harness gateway to run with a SHORT approval TTL:
|
|
14
|
+
*
|
|
15
|
+
* 1. Standard UAT preflight (`uat/SETUP.md` §5-6).
|
|
16
|
+
* 2. Set `channels.telegram.approval_timeout_minutes: 2` on the
|
|
17
|
+
* test-harness agent (threads to `SWITCHROOM_TG_APPROVAL_TIMEOUT_MS`,
|
|
18
|
+
* see `approvalTtlMs()` in gateway/permission-timeout.ts) and restart it.
|
|
19
|
+
* 3. Export `SWITCHROOM_UAT_APPROVAL_TTL_MINUTES=2` in the runner env so
|
|
20
|
+
* this scenario unskips and sizes its wait window.
|
|
21
|
+
*
|
|
22
|
+
* Self-skips green when `SWITCHROOM_UAT_APPROVAL_TTL_MINUTES` is unset (the
|
|
23
|
+
* default 60-min TTL cannot be observed within test wall-clock). The
|
|
24
|
+
* module-level twin covering the same outcome deterministically is
|
|
25
|
+
* `tests/approval-card-restart-outcome.test.ts` plus
|
|
26
|
+
* `tests/approval-timeout-inbound-builders.test.ts` (the "TIMEOUT, not a
|
|
27
|
+
* denial" wording pin).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { describe, expect, it } from "vitest";
|
|
31
|
+
import { spinUp } from "../harness.js";
|
|
32
|
+
|
|
33
|
+
const TTL_MINUTES = Number.parseInt(
|
|
34
|
+
process.env.SWITCHROOM_UAT_APPROVAL_TTL_MINUTES ?? "",
|
|
35
|
+
10,
|
|
36
|
+
);
|
|
37
|
+
const ttlTuned = Number.isFinite(TTL_MINUTES) && TTL_MINUTES > 0 && TTL_MINUTES <= 5;
|
|
38
|
+
|
|
39
|
+
const KEY = "uat/timeout-wake-nonexistent-key";
|
|
40
|
+
const CARD_BUDGET_MS = 120_000;
|
|
41
|
+
// TTL + reaper tick slack + model turn budget.
|
|
42
|
+
const WAKE_BUDGET_MS = (ttlTuned ? TTL_MINUTES : 2) * 60_000 + 240_000;
|
|
43
|
+
|
|
44
|
+
// The timeout synthetic's load-bearing wording is "TIMEOUT, not a denial" —
|
|
45
|
+
// the model is steered to say the request went unanswered, not refused.
|
|
46
|
+
const TIMEOUT_FRAMING =
|
|
47
|
+
/unanswer|timed?[ -]?out|no (?:response|answer|decision)|didn'?t (?:hear|get|respond)|went without/i;
|
|
48
|
+
|
|
49
|
+
(ttlTuned ? describe : describe.skip)(
|
|
50
|
+
"uat: unanswered approval card times out and wakes the agent as timeout-not-denial (DM)",
|
|
51
|
+
() => {
|
|
52
|
+
it(
|
|
53
|
+
"card ages past the TTL with no tap → agent says the request went unanswered, unprompted",
|
|
54
|
+
async () => {
|
|
55
|
+
const sc = await spinUp({ agent: "test-harness" });
|
|
56
|
+
try {
|
|
57
|
+
// 1. Park the agent on an approval card that nobody will answer.
|
|
58
|
+
await sc.sendDM(
|
|
59
|
+
`Please call your vault_request_access MCP tool for the key ` +
|
|
60
|
+
`\`${KEY}\` (scope read, reason "UAT timeout-wakes-agent"). Then ` +
|
|
61
|
+
`END YOUR TURN cleanly and wait for the operator. If the request ` +
|
|
62
|
+
`times out unanswered, tell me plainly that it went unanswered ` +
|
|
63
|
+
`(not that it was denied) and what you'll do about it.`,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
// 2. The card must render — and then we deliberately never tap it.
|
|
67
|
+
await sc.expectMessage(/wants vault access/, {
|
|
68
|
+
from: "bot",
|
|
69
|
+
timeout: CARD_BUDGET_MS,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// 3. TTL elapses → reaper fires the vault_grant_timeout synthetic →
|
|
73
|
+
// a substantive bot reply arrives with timeout (never denial)
|
|
74
|
+
// framing, with no further driver message. Edits are excluded —
|
|
75
|
+
// the reaper's own "timed out" edit of the approval card must
|
|
76
|
+
// not satisfy this; only a NEW message from the woken turn does.
|
|
77
|
+
const reply = await sc.expectMessage(
|
|
78
|
+
(m) => !m.edited && TIMEOUT_FRAMING.test(m.text) && m.text.length > 40,
|
|
79
|
+
{ from: "bot", timeout: WAKE_BUDGET_MS },
|
|
80
|
+
);
|
|
81
|
+
expect(reply.text).toMatch(TIMEOUT_FRAMING);
|
|
82
|
+
// A timeout must never be narrated as an operator refusal.
|
|
83
|
+
expect(reply.text).not.toMatch(/operator denied|was denied|refused my request/i);
|
|
84
|
+
} finally {
|
|
85
|
+
await sc.tearDown();
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
CARD_BUDGET_MS + WAKE_BUDGET_MS + 60_000,
|
|
89
|
+
);
|
|
90
|
+
},
|
|
91
|
+
);
|
|
@@ -49,6 +49,15 @@ export type VoiceOnDemandPayload = {
|
|
|
49
49
|
* the pre-synth queue after a successful background synth. Absent on
|
|
50
50
|
* pre-feature entries, on kill-switched gateways, and until the job runs. */
|
|
51
51
|
filePath?: string
|
|
52
|
+
/** Telegram's reusable file_id for the audio, captured from the FIRST
|
|
53
|
+
* successful `sendVoice` for this entry. On subsequent taps the gateway
|
|
54
|
+
* sends BY this id (a plain string, not an InputFile) so Telegram serves
|
|
55
|
+
* the already-uploaded bytes — no disk read, no re-upload round-trip, so
|
|
56
|
+
* the voice note arrives near-instantly. Absent until the first send;
|
|
57
|
+
* refreshed if a stale id is rejected and the audio is re-uploaded. It
|
|
58
|
+
* lives on the entry, so the 7-day sweep's `prune()` drops it together
|
|
59
|
+
* with the entry — a dead id is never resurrected. */
|
|
60
|
+
telegramFileId?: string
|
|
52
61
|
/** Epoch ms the entry was stored (put time) — recorded alongside filePath
|
|
53
62
|
* per #2763 so the sweep/introspection can reason about entry age. */
|
|
54
63
|
createdAt?: number
|
|
@@ -136,7 +145,7 @@ export class VoiceOnDemandCache {
|
|
|
136
145
|
this.flush()
|
|
137
146
|
return null
|
|
138
147
|
}
|
|
139
|
-
const { text, voice, speed, filePath } = entry
|
|
148
|
+
const { text, voice, speed, filePath, telegramFileId } = entry
|
|
140
149
|
// createdAt stays internal (persisted for sweep/introspection) so the
|
|
141
150
|
// returned shape only grows when a pre-synth file actually exists —
|
|
142
151
|
// pre-#2763 callers and tests see the exact old payload.
|
|
@@ -145,6 +154,7 @@ export class VoiceOnDemandCache {
|
|
|
145
154
|
speed,
|
|
146
155
|
...(voice !== undefined ? { voice } : {}),
|
|
147
156
|
...(filePath !== undefined ? { filePath } : {}),
|
|
157
|
+
...(telegramFileId !== undefined ? { telegramFileId } : {}),
|
|
148
158
|
}
|
|
149
159
|
}
|
|
150
160
|
|
|
@@ -159,6 +169,20 @@ export class VoiceOnDemandCache {
|
|
|
159
169
|
this.flush()
|
|
160
170
|
}
|
|
161
171
|
|
|
172
|
+
/** Record (or refresh) Telegram's reusable file_id on an existing entry,
|
|
173
|
+
* captured from a successful `sendVoice`. Mirrors {@link setFilePath}: no-op
|
|
174
|
+
* if the entry has expired or been evicted meanwhile, and does NOT bump LRU
|
|
175
|
+
* position or TTL — the entry's lifetime stays anchored at put time so a
|
|
176
|
+
* captured id can never resurrect a dying entry. Passing an empty string is
|
|
177
|
+
* ignored (nothing to store). */
|
|
178
|
+
setTelegramFileId(token: string, fileId: string): void {
|
|
179
|
+
if (fileId.length === 0) return
|
|
180
|
+
const entry = this.store.get(token)
|
|
181
|
+
if (entry == null || entry.expiresAt <= this.now()) return
|
|
182
|
+
entry.telegramFileId = fileId
|
|
183
|
+
this.flush()
|
|
184
|
+
}
|
|
185
|
+
|
|
162
186
|
/** Drop entries whose pre-synth files the sweep deleted (#2763). Unknown
|
|
163
187
|
* tokens are ignored (file may belong to an already-evicted entry). */
|
|
164
188
|
prune(tokens: Iterable<string>): void {
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-demand voice send with Telegram file_id reuse.
|
|
3
|
+
*
|
|
4
|
+
* The Listen-button tap used to re-upload the audio bytes as a fresh InputFile
|
|
5
|
+
* on EVERY send — an upload + server-side ingest round-trip each time, which is
|
|
6
|
+
* the delay users felt on repeat taps. Telegram returns a reusable `file_id`
|
|
7
|
+
* after the first upload; sending BY that id (a plain string, not an InputFile)
|
|
8
|
+
* makes Telegram serve the already-stored bytes, which is near-instant and
|
|
9
|
+
* skips the disk read entirely.
|
|
10
|
+
*
|
|
11
|
+
* {@link sendVoiceReusingFileId} is the dependency-free decision core so it can
|
|
12
|
+
* be unit-tested without importing the 25k-line gateway (which has import-time
|
|
13
|
+
* side effects) or grammy. The gateway supplies the concrete send closures.
|
|
14
|
+
*
|
|
15
|
+
* Contract:
|
|
16
|
+
* 1. If a stored file_id exists → send BY id (no disk read, no upload). On
|
|
17
|
+
* success capture the returned id (Telegram may echo the same one).
|
|
18
|
+
* 2. If the stored id send fails with ANY HTTP 400 → treat the id as
|
|
19
|
+
* stale/invalid (regardless of the exact wording), fall through to the
|
|
20
|
+
* upload path ONCE and refresh the stored id so a dead id can never
|
|
21
|
+
* permanently break Listen. Any NON-400 error (network / 5xx / 403
|
|
22
|
+
* blocked-by-user) is a real failure and is surfaced (no silent
|
|
23
|
+
* re-upload storm, no papering over a 403).
|
|
24
|
+
* 3. If no id yet → load the audio (disk fast-path or on-demand synth) and
|
|
25
|
+
* send it as an InputFile, capturing the returned file_id for next time.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** The subset of a Telegram Message we read back: the sent voice's file_id. */
|
|
29
|
+
export type SentVoiceMessage = { voice?: { file_id?: string } } | undefined | null
|
|
30
|
+
|
|
31
|
+
/** Extract the reusable file_id from a `sendVoice` response, if present. */
|
|
32
|
+
export function extractVoiceFileId(sent: SentVoiceMessage): string | undefined {
|
|
33
|
+
const id = sent?.voice?.file_id
|
|
34
|
+
return typeof id === 'string' && id.length > 0 ? id : undefined
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* True iff `err` looks like Telegram rejecting a file_id we sent (expired or
|
|
39
|
+
* malformed id) — the one case where re-uploading from disk is the right
|
|
40
|
+
* recovery. Duck-typed so this module stays grammy-free: a GrammyError carries
|
|
41
|
+
* `error_code` (number) and `description` (string). A 400 whose text names the
|
|
42
|
+
* file identifier is a stale-id signal; any other error is a genuine failure
|
|
43
|
+
* the caller must NOT paper over with a silent re-upload.
|
|
44
|
+
*/
|
|
45
|
+
export function isInvalidTelegramFileIdError(err: unknown): boolean {
|
|
46
|
+
if (err == null || typeof err !== 'object') return false
|
|
47
|
+
const e = err as { error_code?: unknown; description?: unknown; message?: unknown }
|
|
48
|
+
const code = typeof e.error_code === 'number' ? e.error_code : undefined
|
|
49
|
+
if (code !== undefined && code !== 400) return false
|
|
50
|
+
const text = String(e.description ?? e.message ?? '').toLowerCase()
|
|
51
|
+
return (
|
|
52
|
+
text.includes('file identifier') ||
|
|
53
|
+
text.includes('file_id') ||
|
|
54
|
+
text.includes('wrong file') ||
|
|
55
|
+
text.includes('wrong remote file') ||
|
|
56
|
+
text.includes('wrong padding')
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* True iff `err` is an HTTP 400-class Telegram rejection (grammy `error_code`
|
|
62
|
+
* 400), regardless of the description wording. This is the re-upload trigger:
|
|
63
|
+
* a send-by-file_id that fails with a 400 is treated as a stale/invalid id and
|
|
64
|
+
* recovered by re-uploading from disk ONCE, superseding the narrower
|
|
65
|
+
* substring-matched {@link isInvalidTelegramFileIdError}. Rationale: a
|
|
66
|
+
* re-upload is always safe recovery and can never be worse than re-hitting a
|
|
67
|
+
* guaranteed-failing id, whereas Telegram may return a stale-id 400 with
|
|
68
|
+
* wording outside the known substring set. Non-400 errors (network / 5xx /
|
|
69
|
+
* 403 blocked-by-user) are NOT 400 and must remain real failures — never
|
|
70
|
+
* re-uploaded. Duck-typed so this module stays grammy-free.
|
|
71
|
+
*/
|
|
72
|
+
export function isHttp400Error(err: unknown): boolean {
|
|
73
|
+
if (err == null || typeof err !== 'object') return false
|
|
74
|
+
const e = err as { error_code?: unknown }
|
|
75
|
+
return e.error_code === 400
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type ReuseFileIdDeps = {
|
|
79
|
+
/** The entry's stored Telegram file_id, or null if none captured yet. */
|
|
80
|
+
fileId: string | null
|
|
81
|
+
/** Send by an already-uploaded file_id string. Resolves to the sent Message. */
|
|
82
|
+
sendByFileId: (fileId: string) => Promise<SentVoiceMessage>
|
|
83
|
+
/** Load the audio bytes (disk fast-path, then on-demand synth). Returns null
|
|
84
|
+
* when audio is unavailable — the impl has already surfaced any user-facing
|
|
85
|
+
* toast in that case. Only invoked on the upload path (never when a valid
|
|
86
|
+
* file_id served the tap). */
|
|
87
|
+
loadAudio: () => Promise<Uint8Array | null>
|
|
88
|
+
/** Upload the audio as an InputFile. Resolves to the sent Message. */
|
|
89
|
+
sendByUpload: (audio: Uint8Array) => Promise<SentVoiceMessage>
|
|
90
|
+
/** Persist a freshly captured/refreshed file_id onto the cache entry. */
|
|
91
|
+
onFileId: (fileId: string) => void
|
|
92
|
+
/** Classify a `sendByFileId` rejection: true → re-upload from disk once +
|
|
93
|
+
* refresh the id; false → surface as a real failure. Defaults to
|
|
94
|
+
* {@link isHttp400Error} — ANY HTTP 400 is treated as a stale/invalid id
|
|
95
|
+
* (safe recovery), superseding the narrower substring-matched
|
|
96
|
+
* {@link isInvalidTelegramFileIdError}. Non-400 errors (network / 5xx /
|
|
97
|
+
* 403) stay real failures and never re-upload. */
|
|
98
|
+
isInvalidFileIdError?: (err: unknown) => boolean
|
|
99
|
+
log?: (line: string) => void
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export type ReuseFileIdResult =
|
|
103
|
+
| { ok: true; path: 'file_id' | 'upload'; refreshed: boolean }
|
|
104
|
+
| { ok: false; reason: 'no-audio' }
|
|
105
|
+
| { ok: false; reason: 'send-failed'; error: unknown }
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Send a voice note reusing a stored file_id when possible, with a disk/synth
|
|
109
|
+
* re-upload fallback on a stale id. See the module header for the full
|
|
110
|
+
* contract. Never throws — send failures are returned as `{ ok: false }`.
|
|
111
|
+
*/
|
|
112
|
+
export async function sendVoiceReusingFileId(deps: ReuseFileIdDeps): Promise<ReuseFileIdResult> {
|
|
113
|
+
const shouldReupload = deps.isInvalidFileIdError ?? isHttp400Error
|
|
114
|
+
|
|
115
|
+
// 1. Fast path — reuse the stored file_id. No disk read, no re-upload.
|
|
116
|
+
if (deps.fileId != null && deps.fileId.length > 0) {
|
|
117
|
+
try {
|
|
118
|
+
const sent = await deps.sendByFileId(deps.fileId)
|
|
119
|
+
const fresh = extractVoiceFileId(sent)
|
|
120
|
+
if (fresh != null) deps.onFileId(fresh)
|
|
121
|
+
return { ok: true, path: 'file_id', refreshed: false }
|
|
122
|
+
} catch (err) {
|
|
123
|
+
if (!shouldReupload(err)) {
|
|
124
|
+
// Non-400 (network / 5xx / 403 blocked-by-user) — a real failure, not a
|
|
125
|
+
// stale id. Never re-upload; surface it so the caller can retry/report.
|
|
126
|
+
return { ok: false, reason: 'send-failed', error: err }
|
|
127
|
+
}
|
|
128
|
+
// Any 400 — treat the stored id as stale/invalid regardless of wording and
|
|
129
|
+
// recover by re-uploading below ONCE + refreshing the id. This can never
|
|
130
|
+
// be worse than re-hitting a guaranteed-failing id, and covers stale-id
|
|
131
|
+
// 400s whose text falls outside isInvalidTelegramFileIdError's substrings.
|
|
132
|
+
deps.log?.(
|
|
133
|
+
`voice-ondemand: stored file_id rejected (${String(
|
|
134
|
+
(err as { description?: unknown; message?: unknown })?.description ??
|
|
135
|
+
(err as { message?: unknown })?.message ??
|
|
136
|
+
err,
|
|
137
|
+
)}) — re-uploading from disk\n`,
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 2. Upload path — load audio then send as an InputFile; capture the id.
|
|
143
|
+
const audio = await deps.loadAudio()
|
|
144
|
+
if (audio == null) return { ok: false, reason: 'no-audio' }
|
|
145
|
+
const refreshed = deps.fileId != null && deps.fileId.length > 0
|
|
146
|
+
try {
|
|
147
|
+
const sent = await deps.sendByUpload(audio)
|
|
148
|
+
const fresh = extractVoiceFileId(sent)
|
|
149
|
+
if (fresh != null) deps.onFileId(fresh)
|
|
150
|
+
return { ok: true, path: 'upload', refreshed }
|
|
151
|
+
} catch (err) {
|
|
152
|
+
return { ok: false, reason: 'send-failed', error: err }
|
|
153
|
+
}
|
|
154
|
+
}
|