switchroom 0.17.6 → 0.18.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/workspace-dynamic-hook.sh +12 -13
- package/dist/agent-scheduler/index.js +65 -5
- package/dist/auth-broker/index.js +6623 -514
- package/dist/cli/notion-write-pretool.mjs +64 -4
- package/dist/cli/switchroom.js +1888 -1162
- package/dist/host-control/main.js +6306 -162
- package/dist/vault/approvals/kernel-server.js +6014 -202
- package/dist/vault/broker/server.js +6741 -940
- package/package.json +1 -1
- package/profiles/_base/settings.json.hbs +2 -2
- package/profiles/_base/start.sh.hbs +218 -25
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md +116 -0
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/skills/mental-model-curator/SKILL.md +162 -0
- package/telegram-plugin/auth-snapshot-format.ts +22 -24
- package/telegram-plugin/bridge/bridge.ts +80 -1
- package/telegram-plugin/bridge/ipc-client.ts +19 -0
- package/telegram-plugin/bridge/permission-ledger.ts +61 -0
- package/telegram-plugin/consolidation-legibility.ts +279 -0
- package/telegram-plugin/context-exhaustion.ts +124 -0
- package/telegram-plugin/dist/bridge/bridge.js +85 -1
- package/telegram-plugin/dist/gateway/gateway.js +25802 -8488
- package/telegram-plugin/dist/server.js +86 -2
- package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
- package/telegram-plugin/gateway/activity-card-store.ts +369 -0
- package/telegram-plugin/gateway/gateway.ts +1861 -172
- package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
- package/telegram-plugin/gateway/inbound-spool.ts +22 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
- package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
- package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
- package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
- package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
- package/telegram-plugin/gateway/model-command.ts +70 -10
- package/telegram-plugin/gateway/permission-rearm.ts +115 -0
- package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
- package/telegram-plugin/memory-legibility.ts +217 -0
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/telegram-plugin/package.json +6 -0
- package/telegram-plugin/quota-watch.ts +4 -6
- package/telegram-plugin/registry/turns-schema.test.ts +97 -0
- package/telegram-plugin/registry/turns-schema.ts +78 -0
- package/telegram-plugin/render/ir.ts +209 -0
- package/telegram-plugin/render/parse.ts +363 -0
- package/telegram-plugin/render/render.ts +440 -0
- package/telegram-plugin/render/rich-render.ts +72 -0
- package/telegram-plugin/scoped-approval.ts +59 -0
- package/telegram-plugin/silent-end.ts +78 -0
- package/telegram-plugin/stream-controller.ts +14 -3
- package/telegram-plugin/subagent-watcher.ts +60 -6
- package/telegram-plugin/tests/activity-card-store.test.ts +530 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
- package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +30 -16
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
- package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +44 -9
- package/telegram-plugin/tests/feed-survival.test.ts +39 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
- package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
- package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
- package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
- package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
- package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
- package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
- package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
- package/telegram-plugin/tests/model-command.test.ts +193 -16
- package/telegram-plugin/tests/narrative-render.test.ts +125 -0
- package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
- package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
- package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
- package/telegram-plugin/tests/quota-watch.test.ts +1 -4
- package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
- package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
- package/telegram-plugin/tests/render/parse.test.ts +393 -0
- package/telegram-plugin/tests/render/render.test.ts +436 -0
- package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
- package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
- package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
- package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
- package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +198 -0
- package/telegram-plugin/turn-liveness-floor.ts +35 -1
- package/telegram-plugin/uat/assertions.ts +88 -4
- package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
- package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
- package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
- package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
- package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
- package/telegram-plugin/worktree-watch-cwds.ts +135 -0
- package/vendor/hindsight-memory/hooks/hooks.json +9 -0
- package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +445 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
- package/vendor/hindsight-memory/scripts/lib/config.py +46 -2
- package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
- package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
- package/vendor/hindsight-memory/scripts/recall.py +217 -10
- package/vendor/hindsight-memory/scripts/retain.py +17 -0
- package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_capture_nudge.py +185 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
- package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/test_client.py +43 -0
- package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
- package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
resolveMentalModelProposal,
|
|
4
|
+
type MentalModelPendingProposal,
|
|
5
|
+
type ResolveDeps,
|
|
6
|
+
} from "../gateway/mental-model-propose-resolve.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The approve/deny orchestration invariants (hindsight Phase 5):
|
|
10
|
+
* - APPROVE appends config (dispatches the config edit) AND ensures.
|
|
11
|
+
* - DENY writes NOTHING (no config read, diff, dispatch, or ensure).
|
|
12
|
+
* - A duplicate proposal is rejected BEFORE any edit is dispatched.
|
|
13
|
+
* Self-approve is impossible by construction: this function is only reached
|
|
14
|
+
* after the gateway callback authorizes an allow-listed operator; there is no
|
|
15
|
+
* agent path to it (covered by the config-edit self-scope tests + the callback
|
|
16
|
+
* authorization gate).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const CONFIG_NO_MODELS = `agents:
|
|
20
|
+
coach:
|
|
21
|
+
persona: fitness
|
|
22
|
+
memory:
|
|
23
|
+
collection: coach-bank
|
|
24
|
+
`;
|
|
25
|
+
const CONFIG_DUP = `agents:
|
|
26
|
+
coach:
|
|
27
|
+
persona: fitness
|
|
28
|
+
memory:
|
|
29
|
+
collection: coach-bank
|
|
30
|
+
mental_models:
|
|
31
|
+
- name: plan
|
|
32
|
+
source_query: existing
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
function pending(overrides: Partial<MentalModelPendingProposal> = {}): MentalModelPendingProposal {
|
|
36
|
+
return {
|
|
37
|
+
agent: "coach",
|
|
38
|
+
chat_id: "123",
|
|
39
|
+
spec: { name: "plan", source_query: "What is the plan?" },
|
|
40
|
+
reason: "recurring lookup",
|
|
41
|
+
...overrides,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function makeDeps(configText: string, overrides: Partial<ResolveDeps> = {}): {
|
|
46
|
+
deps: ResolveDeps;
|
|
47
|
+
spies: {
|
|
48
|
+
readConfigText: ReturnType<typeof vi.fn>;
|
|
49
|
+
registerPreApproval: ReturnType<typeof vi.fn>;
|
|
50
|
+
clearPreApproval: ReturnType<typeof vi.fn>;
|
|
51
|
+
dispatchConfigEdit: ReturnType<typeof vi.fn>;
|
|
52
|
+
ensureModel: ReturnType<typeof vi.fn>;
|
|
53
|
+
injectInbound: ReturnType<typeof vi.fn>;
|
|
54
|
+
};
|
|
55
|
+
} {
|
|
56
|
+
const spies = {
|
|
57
|
+
readConfigText: vi.fn(() => configText),
|
|
58
|
+
registerPreApproval: vi.fn(),
|
|
59
|
+
clearPreApproval: vi.fn(),
|
|
60
|
+
dispatchConfigEdit: vi.fn(async () => ({ state: "applied" as const })),
|
|
61
|
+
ensureModel: vi.fn(async () => {}),
|
|
62
|
+
injectInbound: vi.fn(),
|
|
63
|
+
};
|
|
64
|
+
const deps: ResolveDeps = {
|
|
65
|
+
readConfigText: spies.readConfigText,
|
|
66
|
+
registerPreApproval: spies.registerPreApproval,
|
|
67
|
+
clearPreApproval: spies.clearPreApproval,
|
|
68
|
+
dispatchConfigEdit: spies.dispatchConfigEdit,
|
|
69
|
+
ensureModel: spies.ensureModel,
|
|
70
|
+
injectInbound: spies.injectInbound,
|
|
71
|
+
now: () => 1_700_000_000_000,
|
|
72
|
+
...overrides,
|
|
73
|
+
};
|
|
74
|
+
return { deps, spies };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe("resolveMentalModelProposal — APPROVE", () => {
|
|
78
|
+
it("appends config (dispatches the append edit) AND ensures, then wakes the agent", async () => {
|
|
79
|
+
const { deps, spies } = makeDeps(CONFIG_NO_MODELS);
|
|
80
|
+
const out = await resolveMentalModelProposal("approve", pending(), "stage1", "op-42", deps);
|
|
81
|
+
|
|
82
|
+
expect(out).toEqual({ outcome: "applied" });
|
|
83
|
+
// Config was read + an edit dispatched whose diff APPENDS the model.
|
|
84
|
+
expect(spies.readConfigText).toHaveBeenCalledTimes(1);
|
|
85
|
+
expect(spies.dispatchConfigEdit).toHaveBeenCalledTimes(1);
|
|
86
|
+
const editArg = spies.dispatchConfigEdit.mock.calls[0][0];
|
|
87
|
+
expect(editArg.agent).toBe("coach");
|
|
88
|
+
expect(editArg.diff).toContain("+++ b/switchroom.yaml");
|
|
89
|
+
expect(editArg.diff).toContain("plan");
|
|
90
|
+
// Single-tap correlation registered then cleared (single-shot).
|
|
91
|
+
expect(spies.registerPreApproval).toHaveBeenCalledWith("coach", editArg.diff);
|
|
92
|
+
expect(spies.clearPreApproval).toHaveBeenCalledWith("coach", editArg.diff);
|
|
93
|
+
// Ensured.
|
|
94
|
+
expect(spies.ensureModel).toHaveBeenCalledWith(pending().spec);
|
|
95
|
+
// Agent woken with the applied inbound.
|
|
96
|
+
expect(spies.injectInbound).toHaveBeenCalledTimes(1);
|
|
97
|
+
expect(spies.injectInbound.mock.calls[0][0].meta.source).toBe(
|
|
98
|
+
"mental_model_proposal_applied",
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("a failed config edit yields a 'failed' inbound (honest — nothing landed)", async () => {
|
|
103
|
+
const { deps, spies } = makeDeps(CONFIG_NO_MODELS, {
|
|
104
|
+
dispatchConfigEdit: vi.fn(async () => ({ state: "error" as const, reason: "hostd down" })),
|
|
105
|
+
});
|
|
106
|
+
const out = await resolveMentalModelProposal("approve", pending(), "s", "op", deps);
|
|
107
|
+
expect(out).toEqual({ outcome: "failed", reason: "hostd down" });
|
|
108
|
+
expect(spies.ensureModel).not.toHaveBeenCalled();
|
|
109
|
+
expect(spies.injectInbound.mock.calls[0][0].meta.source).toBe(
|
|
110
|
+
"mental_model_proposal_failed",
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("REJECTS a duplicate BEFORE dispatching any edit (no write, no correlation)", async () => {
|
|
115
|
+
const { deps, spies } = makeDeps(CONFIG_DUP);
|
|
116
|
+
const out = await resolveMentalModelProposal("approve", pending(), "s", "op", deps);
|
|
117
|
+
expect(out.outcome).toBe("rejected");
|
|
118
|
+
expect(spies.dispatchConfigEdit).not.toHaveBeenCalled();
|
|
119
|
+
expect(spies.registerPreApproval).not.toHaveBeenCalled();
|
|
120
|
+
expect(spies.ensureModel).not.toHaveBeenCalled();
|
|
121
|
+
expect(spies.injectInbound.mock.calls[0][0].meta.source).toBe(
|
|
122
|
+
"mental_model_proposal_failed",
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("a successful declaration is NOT flipped to failure by an ensure error", async () => {
|
|
127
|
+
const { deps, spies } = makeDeps(CONFIG_NO_MODELS, {
|
|
128
|
+
ensureModel: vi.fn(async () => {
|
|
129
|
+
throw new Error("hindsight unreachable");
|
|
130
|
+
}),
|
|
131
|
+
});
|
|
132
|
+
const out = await resolveMentalModelProposal("approve", pending(), "s", "op", deps);
|
|
133
|
+
// Model IS declared (reconcile will ensure regardless); still "applied".
|
|
134
|
+
expect(out).toEqual({ outcome: "applied" });
|
|
135
|
+
expect(spies.injectInbound.mock.calls[0][0].meta.source).toBe(
|
|
136
|
+
"mental_model_proposal_applied",
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("resolveMentalModelProposal — DENY writes NOTHING", () => {
|
|
142
|
+
it("never reads config, builds a diff, dispatches an edit, or ensures", async () => {
|
|
143
|
+
const { deps, spies } = makeDeps(CONFIG_NO_MODELS);
|
|
144
|
+
const out = await resolveMentalModelProposal("deny", pending(), "stage1", "op-42", deps);
|
|
145
|
+
|
|
146
|
+
expect(out).toEqual({ outcome: "denied" });
|
|
147
|
+
expect(spies.readConfigText).not.toHaveBeenCalled();
|
|
148
|
+
expect(spies.dispatchConfigEdit).not.toHaveBeenCalled();
|
|
149
|
+
expect(spies.registerPreApproval).not.toHaveBeenCalled();
|
|
150
|
+
expect(spies.ensureModel).not.toHaveBeenCalled();
|
|
151
|
+
// Only side effect is waking the agent with the denied inbound.
|
|
152
|
+
expect(spies.injectInbound).toHaveBeenCalledTimes(1);
|
|
153
|
+
expect(spies.injectInbound.mock.calls[0][0].meta.source).toBe(
|
|
154
|
+
"mental_model_proposal_denied",
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for telegram-plugin/gateway/missed-approvals-card.ts (#2862).
|
|
3
|
+
*
|
|
4
|
+
* Pure builders — no gateway. Contract under test:
|
|
5
|
+
* - parseMissedApprovalCallback round-trips the keyboard's callback_data;
|
|
6
|
+
* - renderMissedApprovalsDigest lists entries, escapes HTML, and collapses
|
|
7
|
+
* past the cap to "…and N more";
|
|
8
|
+
* - buildMissedApprovalRetryInbound pins source='missed_approval_retry',
|
|
9
|
+
* frames the retry as a QUESTION (never a verdict), and carries the
|
|
10
|
+
* actions in the text.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect } from 'vitest'
|
|
14
|
+
import {
|
|
15
|
+
MISSED_APPROVAL_CALLBACK_PREFIX,
|
|
16
|
+
MISSED_APPROVAL_RENDER_CAP,
|
|
17
|
+
renderMissedApprovalsDigest,
|
|
18
|
+
missedApprovalsKeyboard,
|
|
19
|
+
parseMissedApprovalCallback,
|
|
20
|
+
buildMissedApprovalRetryInbound,
|
|
21
|
+
} from '../gateway/missed-approvals-card.js'
|
|
22
|
+
import type { MissedApproval } from '../gateway/missed-approvals-store.js'
|
|
23
|
+
|
|
24
|
+
function miss(over: Partial<MissedApproval> = {}): MissedApproval {
|
|
25
|
+
return {
|
|
26
|
+
requestId: 'r',
|
|
27
|
+
toolName: 'mcp__brevo__post',
|
|
28
|
+
action: 'post to Brevo',
|
|
29
|
+
chatId: '-100123',
|
|
30
|
+
threadId: 7,
|
|
31
|
+
timedOutAt: Date.UTC(2026, 6, 4, 23, 2, 0),
|
|
32
|
+
...over,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('missed-approvals callback shape', () => {
|
|
37
|
+
it('keyboard emits retry + dismiss callbacks that parse back', () => {
|
|
38
|
+
const kb = missedApprovalsKeyboard('abc123')
|
|
39
|
+
const [[retry, dismiss]] = kb.inline_keyboard
|
|
40
|
+
expect(retry.callback_data).toBe(`${MISSED_APPROVAL_CALLBACK_PREFIX}retry:abc123`)
|
|
41
|
+
expect(dismiss.callback_data).toBe(`${MISSED_APPROVAL_CALLBACK_PREFIX}dismiss:abc123`)
|
|
42
|
+
expect(parseMissedApprovalCallback(retry.callback_data)).toEqual({ action: 'retry', digestId: 'abc123' })
|
|
43
|
+
expect(parseMissedApprovalCallback(dismiss.callback_data)).toEqual({ action: 'dismiss', digestId: 'abc123' })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('callback_data stays within Telegram 64-byte limit', () => {
|
|
47
|
+
const kb = missedApprovalsKeyboard('deadbeef')
|
|
48
|
+
for (const row of kb.inline_keyboard) {
|
|
49
|
+
for (const btn of row) {
|
|
50
|
+
expect(Buffer.byteLength(btn.callback_data, 'utf8')).toBeLessThanOrEqual(64)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('rejects foreign / malformed callbacks', () => {
|
|
56
|
+
expect(parseMissedApprovalCallback('skprop:approve:x')).toBeNull()
|
|
57
|
+
expect(parseMissedApprovalCallback('missre:bogus:x')).toBeNull()
|
|
58
|
+
expect(parseMissedApprovalCallback('missre:retry:')).toBeNull()
|
|
59
|
+
expect(parseMissedApprovalCallback('missre:retry')).toBeNull()
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('renderMissedApprovalsDigest', () => {
|
|
64
|
+
it('lists each entry with its natural action', () => {
|
|
65
|
+
const text = renderMissedApprovalsDigest([
|
|
66
|
+
miss({ action: 'post to Brevo' }),
|
|
67
|
+
miss({ action: 'send an email' }),
|
|
68
|
+
])
|
|
69
|
+
expect(text).toContain('Missed approvals while you were away')
|
|
70
|
+
expect(text).toContain('post to Brevo')
|
|
71
|
+
expect(text).toContain('send an email')
|
|
72
|
+
expect(text).not.toContain('…and')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('collapses past the cap to "…and N more"', () => {
|
|
76
|
+
const entries = Array.from({ length: MISSED_APPROVAL_RENDER_CAP + 3 }, (_, i) =>
|
|
77
|
+
miss({ requestId: `r${i}`, action: `action ${i}` }),
|
|
78
|
+
)
|
|
79
|
+
const text = renderMissedApprovalsDigest(entries)
|
|
80
|
+
expect(text).toContain('action 0')
|
|
81
|
+
expect(text).toContain(`action ${MISSED_APPROVAL_RENDER_CAP - 1}`)
|
|
82
|
+
// Entry at index == cap must NOT be listed individually.
|
|
83
|
+
expect(text).not.toContain(`action ${MISSED_APPROVAL_RENDER_CAP}`)
|
|
84
|
+
expect(text).toContain('…and 3 more')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('escapes HTML in the action text', () => {
|
|
88
|
+
const text = renderMissedApprovalsDigest([miss({ action: 'run: rm <a> & <b>' })])
|
|
89
|
+
expect(text).toContain('rm <a> & <b>')
|
|
90
|
+
expect(text).not.toContain('<a>')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('names the agent when provided', () => {
|
|
94
|
+
const text = renderMissedApprovalsDigest([miss()], { agentName: 'marko' })
|
|
95
|
+
expect(text).toContain('marko')
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe('buildMissedApprovalRetryInbound', () => {
|
|
100
|
+
it('pins source + origin surface + operator id (single action)', () => {
|
|
101
|
+
const inbound = buildMissedApprovalRetryInbound({
|
|
102
|
+
ctx: { agent: 'marko', chat_id: '-100123', threadId: 7 },
|
|
103
|
+
actions: ['post to Brevo'],
|
|
104
|
+
operatorId: '8201250670',
|
|
105
|
+
nowMs: 123,
|
|
106
|
+
})
|
|
107
|
+
expect(inbound.type).toBe('inbound')
|
|
108
|
+
expect(inbound.chatId).toBe('-100123')
|
|
109
|
+
expect(inbound.threadId).toBe(7)
|
|
110
|
+
expect(inbound.meta.source).toBe('missed_approval_retry')
|
|
111
|
+
expect(inbound.meta.agent).toBe('marko')
|
|
112
|
+
expect(inbound.meta.operator_id).toBe('8201250670')
|
|
113
|
+
expect(inbound.meta.action_count).toBe('1')
|
|
114
|
+
expect(inbound.meta.message_thread_id).toBe('7')
|
|
115
|
+
expect(inbound.text).toContain('post to Brevo')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('frames the retry as a QUESTION, never a verdict (no-self-escalation)', () => {
|
|
119
|
+
const inbound = buildMissedApprovalRetryInbound({
|
|
120
|
+
ctx: { agent: 'marko', chat_id: '-100123' },
|
|
121
|
+
actions: ['post to Brevo'],
|
|
122
|
+
operatorId: 'x',
|
|
123
|
+
})
|
|
124
|
+
// It asks the agent to re-attempt; it must NOT approve/deny on the
|
|
125
|
+
// operator's behalf.
|
|
126
|
+
expect(inbound.text.toLowerCase()).toContain('re-attempt')
|
|
127
|
+
expect(inbound.text.toLowerCase()).toContain('approve again')
|
|
128
|
+
expect(inbound.text.toLowerCase()).not.toMatch(/\b(approved|allow(ed)? this|permission granted)\b/)
|
|
129
|
+
// No thread → no thread fields.
|
|
130
|
+
expect(inbound.threadId).toBeUndefined()
|
|
131
|
+
expect(inbound.meta.message_thread_id).toBeUndefined()
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('lists every action for a multi-miss retry', () => {
|
|
135
|
+
const inbound = buildMissedApprovalRetryInbound({
|
|
136
|
+
ctx: { agent: 'marko', chat_id: '-100123' },
|
|
137
|
+
actions: ['post to Brevo', 'send an email', 'update the CRM'],
|
|
138
|
+
operatorId: 'x',
|
|
139
|
+
})
|
|
140
|
+
expect(inbound.meta.action_count).toBe('3')
|
|
141
|
+
expect(inbound.text).toContain('post to Brevo')
|
|
142
|
+
expect(inbound.text).toContain('send an email')
|
|
143
|
+
expect(inbound.text).toContain('update the CRM')
|
|
144
|
+
})
|
|
145
|
+
})
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for telegram-plugin/gateway/missed-approvals-store.ts (#2862).
|
|
3
|
+
*
|
|
4
|
+
* Persisted list of approvals that TTL-expired while the operator was away.
|
|
5
|
+
* Contract under test:
|
|
6
|
+
* - append at auto-deny; dedup on requestId; survive reload (new instance);
|
|
7
|
+
* - cap pending (drop oldest) so the file stays bounded;
|
|
8
|
+
* - promoteToDigest moves pending → delivered (so a second read sees empty
|
|
9
|
+
* pending → "posts once");
|
|
10
|
+
* - getDigest / removeDigest for the retry/dismiss taps.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
14
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
15
|
+
import { join } from 'node:path'
|
|
16
|
+
import { tmpdir } from 'node:os'
|
|
17
|
+
import {
|
|
18
|
+
createMissedApprovalsStore,
|
|
19
|
+
MAX_PENDING,
|
|
20
|
+
type MissedApproval,
|
|
21
|
+
} from '../gateway/missed-approvals-store.js'
|
|
22
|
+
|
|
23
|
+
function makeTmpDir() {
|
|
24
|
+
return mkdtempSync(join(tmpdir(), 'missed-approvals-store-test-'))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function entry(over: Partial<MissedApproval> = {}): MissedApproval {
|
|
28
|
+
return {
|
|
29
|
+
requestId: 'fztws',
|
|
30
|
+
toolName: 'mcp__brevo__post',
|
|
31
|
+
action: 'post to Brevo',
|
|
32
|
+
chatId: '-100123',
|
|
33
|
+
threadId: 7,
|
|
34
|
+
timedOutAt: 1_700_000_000_000,
|
|
35
|
+
...over,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('createMissedApprovalsStore', () => {
|
|
40
|
+
let dir: string
|
|
41
|
+
beforeEach(() => { dir = makeTmpDir() })
|
|
42
|
+
afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
|
|
43
|
+
|
|
44
|
+
it('starts empty', () => {
|
|
45
|
+
const store = createMissedApprovalsStore(dir)
|
|
46
|
+
expect(store.listPending()).toEqual([])
|
|
47
|
+
expect(store.pendingCount()).toBe(0)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('append + listPending returns the entry', () => {
|
|
51
|
+
const store = createMissedApprovalsStore(dir)
|
|
52
|
+
store.add(entry())
|
|
53
|
+
expect(store.listPending()).toHaveLength(1)
|
|
54
|
+
expect(store.listPending()[0].action).toBe('post to Brevo')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('dedups on requestId (replace, not duplicate)', () => {
|
|
58
|
+
const store = createMissedApprovalsStore(dir)
|
|
59
|
+
store.add(entry({ requestId: 'aaa', action: 'first' }))
|
|
60
|
+
store.add(entry({ requestId: 'aaa', action: 'second' }))
|
|
61
|
+
expect(store.listPending()).toHaveLength(1)
|
|
62
|
+
expect(store.listPending()[0].action).toBe('second')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('survives reload (a fresh instance sees persisted entries)', () => {
|
|
66
|
+
const s1 = createMissedApprovalsStore(dir)
|
|
67
|
+
s1.add(entry({ requestId: 'a' }))
|
|
68
|
+
s1.add(entry({ requestId: 'b' }))
|
|
69
|
+
const s2 = createMissedApprovalsStore(dir)
|
|
70
|
+
expect(s2.pendingCount()).toBe(2)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('caps pending at MAX_PENDING, dropping the oldest', () => {
|
|
74
|
+
const store = createMissedApprovalsStore(dir)
|
|
75
|
+
for (let i = 0; i < MAX_PENDING + 5; i++) {
|
|
76
|
+
store.add(entry({ requestId: `r${i}`, timedOutAt: 1_700_000_000_000 + i }))
|
|
77
|
+
}
|
|
78
|
+
const pending = store.listPending()
|
|
79
|
+
expect(pending).toHaveLength(MAX_PENDING)
|
|
80
|
+
// Oldest (r0..r4) dropped; newest (r<MAX+4>) retained.
|
|
81
|
+
expect(pending.some(e => e.requestId === 'r0')).toBe(false)
|
|
82
|
+
expect(pending.some(e => e.requestId === `r${MAX_PENDING + 4}`)).toBe(true)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('promoteToDigest moves entries out of pending into a delivered record', () => {
|
|
86
|
+
const store = createMissedApprovalsStore(dir)
|
|
87
|
+
store.add(entry({ requestId: 'a', action: 'post to Brevo' }))
|
|
88
|
+
store.add(entry({ requestId: 'b', action: 'send email' }))
|
|
89
|
+
const entries = store.listPending()
|
|
90
|
+
store.promoteToDigest({
|
|
91
|
+
digestId: 'deadbeef',
|
|
92
|
+
chatId: '-100123',
|
|
93
|
+
threadId: 7,
|
|
94
|
+
entries,
|
|
95
|
+
deliveredAt: Date.now(),
|
|
96
|
+
})
|
|
97
|
+
// Pending is now empty — a second operator-activity event posts nothing.
|
|
98
|
+
expect(store.listPending()).toEqual([])
|
|
99
|
+
const digest = store.getDigest('deadbeef')
|
|
100
|
+
expect(digest?.entries).toHaveLength(2)
|
|
101
|
+
expect(digest?.entries.map(e => e.action)).toEqual(['post to Brevo', 'send email'])
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('removeDigest drops the delivered record (dismiss / retry consumed it)', () => {
|
|
105
|
+
const store = createMissedApprovalsStore(dir)
|
|
106
|
+
store.add(entry({ requestId: 'a' }))
|
|
107
|
+
store.promoteToDigest({
|
|
108
|
+
digestId: 'cafef00d',
|
|
109
|
+
chatId: '-100123',
|
|
110
|
+
threadId: 7,
|
|
111
|
+
entries: store.listPending(),
|
|
112
|
+
deliveredAt: Date.now(),
|
|
113
|
+
})
|
|
114
|
+
expect(store.getDigest('cafef00d')).toBeDefined()
|
|
115
|
+
store.removeDigest('cafef00d')
|
|
116
|
+
expect(store.getDigest('cafef00d')).toBeUndefined()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('promote + remove survive reload', () => {
|
|
120
|
+
const s1 = createMissedApprovalsStore(dir)
|
|
121
|
+
s1.add(entry({ requestId: 'a' }))
|
|
122
|
+
s1.promoteToDigest({
|
|
123
|
+
digestId: 'd1',
|
|
124
|
+
chatId: '-100123',
|
|
125
|
+
threadId: null,
|
|
126
|
+
entries: s1.listPending(),
|
|
127
|
+
deliveredAt: Date.now(),
|
|
128
|
+
})
|
|
129
|
+
const s2 = createMissedApprovalsStore(dir)
|
|
130
|
+
expect(s2.getDigest('d1')?.entries).toHaveLength(1)
|
|
131
|
+
expect(s2.listPending()).toEqual([])
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('clear() wipes the backing file', () => {
|
|
135
|
+
const store = createMissedApprovalsStore(dir)
|
|
136
|
+
store.add(entry())
|
|
137
|
+
store.clear()
|
|
138
|
+
expect(store.listPending()).toEqual([])
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('tolerates a corrupt backing file (returns empty)', () => {
|
|
142
|
+
const store = createMissedApprovalsStore(dir)
|
|
143
|
+
// Write garbage directly, then ensure reads don't throw.
|
|
144
|
+
writeFileSync(join(dir, 'missed-approvals.json'), 'not json')
|
|
145
|
+
expect(store.listPending()).toEqual([])
|
|
146
|
+
})
|
|
147
|
+
})
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source-text pins for the missed-approvals re-offer wiring (#2862).
|
|
3
|
+
*
|
|
4
|
+
* gateway.ts has top-level side effects and isn't unit-importable; the store
|
|
5
|
+
* and card logic are unit-tested in missed-approvals-store.test.ts /
|
|
6
|
+
* missed-approvals-card.test.ts. These pins lock the gateway wiring so it can't
|
|
7
|
+
* silently regress:
|
|
8
|
+
* - the store is constructed + kill switch defined;
|
|
9
|
+
* - the TTL auto-deny appends the miss (anchored to the card origin);
|
|
10
|
+
* - all three operator-activity paths trigger the digest;
|
|
11
|
+
* - the digest posts ONE card per surface then promotes (clears pending);
|
|
12
|
+
* - the callback dispatcher routes `missre:`;
|
|
13
|
+
* - retry rides deliverResumeSyntheticOrBuffer; dismiss removes + edits.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, it, expect } from 'vitest'
|
|
17
|
+
import { readFileSync } from 'node:fs'
|
|
18
|
+
import { fileURLToPath } from 'node:url'
|
|
19
|
+
import { dirname, resolve } from 'node:path'
|
|
20
|
+
|
|
21
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
22
|
+
const read = (p: string) => readFileSync(resolve(__dirname, '..', p), 'utf8')
|
|
23
|
+
const GATEWAY = read('gateway/gateway.ts')
|
|
24
|
+
|
|
25
|
+
function slice(src: string, header: string, span = 2000): string {
|
|
26
|
+
const start = src.indexOf(header)
|
|
27
|
+
expect(start, `expected to find ${header}`).toBeGreaterThan(-1)
|
|
28
|
+
return src.slice(start, start + span)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('missed-approvals re-offer wiring (#2862)', () => {
|
|
32
|
+
it('constructs the persisted store and defines the kill switch', () => {
|
|
33
|
+
expect(GATEWAY).toContain('createMissedApprovalsStore(STATE_DIR)')
|
|
34
|
+
expect(GATEWAY).toContain('SWITCHROOM_MISSED_APPROVAL_REOFFER')
|
|
35
|
+
expect(GATEWAY).toMatch(/MISSED_APPROVAL_REOFFER_ENABLED\s*=\s*\n?\s*process\.env\.SWITCHROOM_MISSED_APPROVAL_REOFFER !== '0'/)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('appends the miss at TTL auto-deny, anchored to the card origin, gated by the kill switch', () => {
|
|
39
|
+
// Within the pending-permission TTL sweep block.
|
|
40
|
+
const sweep = slice(GATEWAY, 'for (const [k, v] of pendingPermissions)', 3600)
|
|
41
|
+
expect(sweep).toContain('if (MISSED_APPROVAL_REOFFER_ENABLED) {')
|
|
42
|
+
expect(sweep).toContain('missedApprovalsStore.add({')
|
|
43
|
+
expect(sweep).toContain('const origin = v.cards[0]')
|
|
44
|
+
expect(sweep).toContain('action: naturalAction(v.tool_name, v.input_preview)')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('all three operator-activity paths trigger the digest', () => {
|
|
48
|
+
const calls = GATEWAY.match(/maybePostMissedApprovalDigest\(/g) ?? []
|
|
49
|
+
// 1 definition + 3 activity callsites.
|
|
50
|
+
expect(calls.length).toBeGreaterThanOrEqual(4)
|
|
51
|
+
expect(GATEWAY).toContain("maybePostMissedApprovalDigest('operator inbound')")
|
|
52
|
+
expect(GATEWAY).toContain("maybePostMissedApprovalDigest('operator answered via /approve|/deny')")
|
|
53
|
+
expect(GATEWAY).toContain("maybePostMissedApprovalDigest('operator answered a permission card')")
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('the digest posts one card per surface, then promotes (clears pending → posts once)', () => {
|
|
57
|
+
const fn = slice(GATEWAY, 'function maybePostMissedApprovalDigest(', 2400)
|
|
58
|
+
expect(fn).toContain('if (!MISSED_APPROVAL_REOFFER_ENABLED) return')
|
|
59
|
+
expect(fn).toContain('missedApprovalsStore.listPending()')
|
|
60
|
+
// Groups by origin surface.
|
|
61
|
+
expect(fn).toMatch(/const groups = new Map/)
|
|
62
|
+
expect(fn).toContain('missedApprovalsKeyboard(digestId)')
|
|
63
|
+
expect(fn).toContain('missedApprovalsStore.promoteToDigest({')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('routes the missre: callback family', () => {
|
|
67
|
+
expect(GATEWAY).toContain("data.startsWith('missre:')")
|
|
68
|
+
expect(GATEWAY).toContain('handleMissedApprovalCallback(ctx, data)')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('retry injects a synthetic inbound via the shared resume-or-buffer path', () => {
|
|
72
|
+
const fn = slice(GATEWAY, 'async function handleMissedApprovalCallback(', 2600)
|
|
73
|
+
// authorization gate first.
|
|
74
|
+
expect(fn).toContain('access.allowFrom.includes(senderId)')
|
|
75
|
+
// retry synthesizes + delivers via the cron/synthetic inbound primitive.
|
|
76
|
+
expect(fn).toContain('buildMissedApprovalRetryInbound({')
|
|
77
|
+
expect(fn).toContain('deliverResumeSyntheticOrBuffer(agent, synthetic)')
|
|
78
|
+
// dismiss clears the record + edits the card closed.
|
|
79
|
+
expect(fn).toContain('missedApprovalsStore.removeDigest(parsed.digestId)')
|
|
80
|
+
expect(fn).toContain('Dismissed')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('never synthesizes a verdict on retry (no-self-escalation)', () => {
|
|
84
|
+
const fn = slice(GATEWAY, 'async function handleMissedApprovalCallback(', 2600)
|
|
85
|
+
// The handler must not dispatch a permission verdict — retry is a question.
|
|
86
|
+
expect(fn).not.toContain('dispatchPermissionVerdict')
|
|
87
|
+
expect(fn).not.toContain("behavior: 'allow'")
|
|
88
|
+
})
|
|
89
|
+
})
|