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,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ownership predicate for the worktree-isolated cwds the subagent-watcher
|
|
3
|
+
* additionally watches (deterministic-turn-liveness.md Known Gap 2 / #2893
|
|
4
|
+
* review fix). Pins the fail-CLOSED behaviour: the review found the closure
|
|
5
|
+
* both FAILED OPEN (unset identity matched every ownerless record — the #1116
|
|
6
|
+
* leak reintroduced) and FAILED CLOSED (a plain `worktree claim` produced an
|
|
7
|
+
* ownerless record filtered out, so the sub-agent stayed invisible — Gap 2).
|
|
8
|
+
*
|
|
9
|
+
* Run with:
|
|
10
|
+
* bun test telegram-plugin/tests/worktree-watch-cwds.test.ts
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
13
|
+
import {
|
|
14
|
+
ownedWorktreeCwds,
|
|
15
|
+
__resetIdentityEscalationForTests,
|
|
16
|
+
type WorktreeOwnershipRecord,
|
|
17
|
+
} from "../worktree-watch-cwds.js";
|
|
18
|
+
|
|
19
|
+
const idPath = (p: string) => p; // identity realpath for deterministic tests
|
|
20
|
+
|
|
21
|
+
beforeEach(() => __resetIdentityEscalationForTests());
|
|
22
|
+
|
|
23
|
+
describe("ownedWorktreeCwds", () => {
|
|
24
|
+
const records: WorktreeOwnershipRecord[] = [
|
|
25
|
+
{ path: "/wt/mine-1", ownerAgent: "klanker" },
|
|
26
|
+
{ path: "/wt/mine-2", ownerAgent: "klanker" },
|
|
27
|
+
{ path: "/wt/theirs", ownerAgent: "reggie" },
|
|
28
|
+
{ path: "/wt/ownerless" }, // ownerAgent undefined
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
it("returns NOTHING when the agent identity is unset and no durable fallback given (fail-closed, no #1116 leak)", () => {
|
|
32
|
+
expect(
|
|
33
|
+
ownedWorktreeCwds({ self: undefined, listRecords: () => records, realpath: idPath }),
|
|
34
|
+
).toEqual([]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("returns NOTHING when the agent identity is empty string and no durable fallback given", () => {
|
|
38
|
+
expect(
|
|
39
|
+
ownedWorktreeCwds({ self: "", listRecords: () => records, realpath: idPath }),
|
|
40
|
+
).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// ---- Layer 2: durable, non-env identity fallback (#1116 / #2893) ----
|
|
44
|
+
|
|
45
|
+
it("(a) env SET → ownership resolves exactly as before (fast path unchanged)", () => {
|
|
46
|
+
expect(
|
|
47
|
+
ownedWorktreeCwds({
|
|
48
|
+
self: "klanker",
|
|
49
|
+
agentDir: "/home/x/.switchroom/agents/SHOULD_BE_IGNORED",
|
|
50
|
+
listRecords: () => records,
|
|
51
|
+
realpath: idPath,
|
|
52
|
+
}),
|
|
53
|
+
).toEqual(["/wt/mine-1", "/wt/mine-2"]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("(b) env UNSET but agentDir present → identity derived from dir basename, ownership resolves", () => {
|
|
57
|
+
const out = ownedWorktreeCwds({
|
|
58
|
+
self: undefined,
|
|
59
|
+
agentDir: "/home/x/.switchroom/agents/klanker",
|
|
60
|
+
listRecords: () => records,
|
|
61
|
+
realpath: idPath,
|
|
62
|
+
});
|
|
63
|
+
expect(out).toEqual(["/wt/mine-1", "/wt/mine-2"]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("(b) env EMPTY but agentDir present → same durable derivation", () => {
|
|
67
|
+
const out = ownedWorktreeCwds({
|
|
68
|
+
self: "",
|
|
69
|
+
agentDir: "/home/x/.switchroom/agents/klanker",
|
|
70
|
+
listRecords: () => records,
|
|
71
|
+
realpath: idPath,
|
|
72
|
+
});
|
|
73
|
+
expect(out).toEqual(["/wt/mine-1", "/wt/mine-2"]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("(b) durable fallback NEVER mis-attributes: a dir for a different agent matches only THAT agent's records, never klanker's", () => {
|
|
77
|
+
// agentDir names 'reggie' → resolves reggie's worktrees, never klanker's
|
|
78
|
+
// or the ownerless ones. A wrong basename fails-closed to [], it can never
|
|
79
|
+
// attribute to the WRONG owner.
|
|
80
|
+
const out = ownedWorktreeCwds({
|
|
81
|
+
self: undefined,
|
|
82
|
+
agentDir: "/home/x/.switchroom/agents/reggie",
|
|
83
|
+
listRecords: () => records,
|
|
84
|
+
realpath: idPath,
|
|
85
|
+
});
|
|
86
|
+
expect(out).toEqual(["/wt/theirs"]);
|
|
87
|
+
expect(out).not.toContain("/wt/ownerless");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("(c) BOTH env and agentDir unavailable → returns [] AND emits an escalated ERROR (no throw, no mis-attribution)", () => {
|
|
91
|
+
const logs: string[] = [];
|
|
92
|
+
const out = ownedWorktreeCwds({
|
|
93
|
+
self: undefined,
|
|
94
|
+
agentDir: undefined,
|
|
95
|
+
listRecords: () => records,
|
|
96
|
+
realpath: idPath,
|
|
97
|
+
log: (m) => logs.push(m),
|
|
98
|
+
});
|
|
99
|
+
expect(out).toEqual([]);
|
|
100
|
+
expect(logs).toHaveLength(1);
|
|
101
|
+
expect(logs[0]).toContain("ERROR");
|
|
102
|
+
expect(logs[0]).toContain("identity resolution FAILED");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("(c) blank agentDir (whitespace) is treated as unavailable → [] + escalated error", () => {
|
|
106
|
+
const logs: string[] = [];
|
|
107
|
+
const out = ownedWorktreeCwds({
|
|
108
|
+
self: "",
|
|
109
|
+
agentDir: " ",
|
|
110
|
+
listRecords: () => records,
|
|
111
|
+
realpath: idPath,
|
|
112
|
+
log: (m) => logs.push(m),
|
|
113
|
+
});
|
|
114
|
+
expect(out).toEqual([]);
|
|
115
|
+
expect(logs).toHaveLength(1);
|
|
116
|
+
expect(logs[0]).toContain("identity resolution FAILED");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("(c) the escalated error is one-shot per process (not re-logged every rescan tick)", () => {
|
|
120
|
+
const logs: string[] = [];
|
|
121
|
+
const call = () =>
|
|
122
|
+
ownedWorktreeCwds({
|
|
123
|
+
self: undefined,
|
|
124
|
+
agentDir: undefined,
|
|
125
|
+
listRecords: () => records,
|
|
126
|
+
realpath: idPath,
|
|
127
|
+
log: (m) => logs.push(m),
|
|
128
|
+
});
|
|
129
|
+
call();
|
|
130
|
+
call();
|
|
131
|
+
call();
|
|
132
|
+
expect(logs).toHaveLength(1);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("does NOT match ownerless records even when identity is set", () => {
|
|
136
|
+
const out = ownedWorktreeCwds({ self: "klanker", listRecords: () => records, realpath: idPath });
|
|
137
|
+
expect(out).not.toContain("/wt/ownerless");
|
|
138
|
+
expect(out).not.toContain("/wt/theirs");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("includes exactly the records this agent owns", () => {
|
|
142
|
+
expect(
|
|
143
|
+
ownedWorktreeCwds({ self: "klanker", listRecords: () => records, realpath: idPath }),
|
|
144
|
+
).toEqual(["/wt/mine-1", "/wt/mine-2"]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("returns [] when the registry read throws (best-effort; base watch undisturbed)", () => {
|
|
148
|
+
expect(
|
|
149
|
+
ownedWorktreeCwds({
|
|
150
|
+
self: "klanker",
|
|
151
|
+
listRecords: () => {
|
|
152
|
+
throw new Error("registry unavailable");
|
|
153
|
+
},
|
|
154
|
+
realpath: idPath,
|
|
155
|
+
}),
|
|
156
|
+
).toEqual([]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("realpaths owned paths (symlinked-base slug fix), falling back on failure", () => {
|
|
160
|
+
const out = ownedWorktreeCwds({
|
|
161
|
+
self: "klanker",
|
|
162
|
+
listRecords: () => [
|
|
163
|
+
{ path: "/tmp/mine", ownerAgent: "klanker" },
|
|
164
|
+
{ path: "/gone/mine", ownerAgent: "klanker" },
|
|
165
|
+
],
|
|
166
|
+
realpath: (p) => {
|
|
167
|
+
if (p === "/tmp/mine") return "/private/tmp/mine";
|
|
168
|
+
throw new Error("ENOENT");
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
expect(out).toEqual(["/private/tmp/mine", "/gone/mine"]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("two-tick mutation: a fresh claim/release is picked up without restart", () => {
|
|
175
|
+
// The provider is re-invoked every rescan tick, so a mutating registry
|
|
176
|
+
// (claim adds an owned record; release removes it) must be reflected
|
|
177
|
+
// tick-over-tick with no process restart.
|
|
178
|
+
let live: WorktreeOwnershipRecord[] = [{ path: "/wt/a", ownerAgent: "klanker" }];
|
|
179
|
+
const provider = () =>
|
|
180
|
+
ownedWorktreeCwds({ self: "klanker", listRecords: () => live, realpath: idPath });
|
|
181
|
+
|
|
182
|
+
// Tick 1: one owned worktree.
|
|
183
|
+
expect(provider()).toEqual(["/wt/a"]);
|
|
184
|
+
|
|
185
|
+
// Claim a second (as the ambient owner would default via SWITCHROOM_AGENT_NAME).
|
|
186
|
+
live = [
|
|
187
|
+
{ path: "/wt/a", ownerAgent: "klanker" },
|
|
188
|
+
{ path: "/wt/b", ownerAgent: "klanker" },
|
|
189
|
+
];
|
|
190
|
+
// Tick 2: both picked up, no restart.
|
|
191
|
+
expect(provider()).toEqual(["/wt/a", "/wt/b"]);
|
|
192
|
+
|
|
193
|
+
// Release the first.
|
|
194
|
+
live = [{ path: "/wt/b", ownerAgent: "klanker" }];
|
|
195
|
+
// Tick 3: reflects the release.
|
|
196
|
+
expect(provider()).toEqual(["/wt/b"]);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
@@ -227,13 +227,47 @@ export interface PostAnswerLivenessInput {
|
|
|
227
227
|
now: number
|
|
228
228
|
/** Staleness cap in ms; `<= 0` disables the cap. */
|
|
229
229
|
staleCapMs: number
|
|
230
|
+
/**
|
|
231
|
+
* A POSITIVE, independent signal that a sub-agent dispatch is still known to
|
|
232
|
+
* be outstanding (e.g. `turn.foregroundSubAgents.size > 0` — a foreground
|
|
233
|
+
* `Task`/`Agent` this turn dispatched and has not yet reported finished).
|
|
234
|
+
*
|
|
235
|
+
* ## The gap this closes
|
|
236
|
+
*
|
|
237
|
+
* The staleness cap above was built to stop the card climbing FOREVER once
|
|
238
|
+
* a worker's `onFinish` froze `subagentActivityAt` and nothing further would
|
|
239
|
+
* ever arrive — but the ONLY signal it read (`now - subagentActivityAt`) is
|
|
240
|
+
* identical whether the worker (a) actually finished, or (b) is still
|
|
241
|
+
* genuinely running a SINGLE long silent step (one long Bash call, a slow
|
|
242
|
+
* fetch) that simply hasn't produced a NEW distinguishable watcher tick.
|
|
243
|
+
* Case (b) is exactly the scenario `feed-heartbeat-climb.ts`'s 0-label climb
|
|
244
|
+
* and `worker-activity-feed.ts`'s own heartbeat both exist to handle
|
|
245
|
+
* deterministically elsewhere — this post-answer branch alone lacked that
|
|
246
|
+
* fallback, so it froze the card mid-delegation (the confirmed operator
|
|
247
|
+
* symptom: "Running a command" stuck for 41s / 3m12s while a sub-agent ran
|
|
248
|
+
* underneath).
|
|
249
|
+
*
|
|
250
|
+
* When `stillDispatched` is `true` we have POSITIVE evidence the worker has
|
|
251
|
+
* not reported completion, so the staleness cap is bypassed entirely and the
|
|
252
|
+
* verdict stays `'emit'` — the caller keeps climbing the card deterministically
|
|
253
|
+
* off wall-clock elapsed exactly like the sibling 0-label/worker-feed paths.
|
|
254
|
+
* When `false` (no such tracking available — e.g. a purely-background worker
|
|
255
|
+
* with no foreground registration), the ORIGINAL cap behaviour is preserved
|
|
256
|
+
* unchanged, so the runaway-climb-after-completion protection this cap was
|
|
257
|
+
* built for still applies wherever we have no better signal.
|
|
258
|
+
*/
|
|
259
|
+
stillDispatched: boolean
|
|
230
260
|
}
|
|
231
261
|
|
|
232
262
|
export function evaluatePostAnswerLiveness(input: PostAnswerLivenessInput): PostAnswerLivenessVerdict {
|
|
233
|
-
const { subagentActivityAt, finalAnswerDeliveredAt, now, staleCapMs } = input
|
|
263
|
+
const { subagentActivityAt, finalAnswerDeliveredAt, now, staleCapMs, stillDispatched } = input
|
|
234
264
|
const answeredAt = finalAnswerDeliveredAt ?? 0
|
|
235
265
|
// idle-gap: nothing surfaced after the answer → silent (reply-is-last preserved).
|
|
236
266
|
if (subagentActivityAt == null || subagentActivityAt <= answeredAt) return 'idle'
|
|
267
|
+
// A positive "still dispatched" signal overrides the staleness cap — we KNOW
|
|
268
|
+
// the worker hasn't reported done, so a quiet stretch is a long silent step,
|
|
269
|
+
// not completion. Never freeze the card while that's true.
|
|
270
|
+
if (stillDispatched) return 'emit'
|
|
237
271
|
// staleness cap: the worker's last advance is older than the cap → stop emitting.
|
|
238
272
|
if (staleCapMs > 0 && now - subagentActivityAt >= staleCapMs) return 'stale'
|
|
239
273
|
return 'emit'
|
|
@@ -46,16 +46,80 @@ export function isWorkerFeedMessage(m: ObservedMessage): boolean {
|
|
|
46
46
|
*/
|
|
47
47
|
const ACTIVITY_FEED_LINE_RE = /^[→✓]\s/u;
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* A body line of the activity card: an in-progress `→`, a done `✓`, a nested
|
|
51
|
+
* child `↳`, or the rolling `✓ +N earlier…` / `↳ +N earlier…` overflow
|
|
52
|
+
* headers (both covered by the leading glyph). Distinct from
|
|
53
|
+
* {@link ACTIVITY_FEED_LINE_RE} in that it does NOT require the trailing space
|
|
54
|
+
* — `↳→ …` nested-in-progress lines render glyph-adjacent.
|
|
55
|
+
*/
|
|
56
|
+
const ACTIVITY_BODY_LINE_RE = /^[→✓↳]/u;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The two-line header `renderActivityHeader`
|
|
60
|
+
* (telegram-plugin/tool-activity-summary.ts) prepends to the session
|
|
61
|
+
* activity / liveness card — the shape the pure-arrow
|
|
62
|
+
* {@link ACTIVITY_FEED_LINE_RE} predicate could never match, which is why the
|
|
63
|
+
* Phase-1 climb card (`silentTurnClimbRender`) was mis-classified as the
|
|
64
|
+
* answer and the whole climb test wall passed vacuously
|
|
65
|
+
* (`deterministic-turn-liveness.md` Phase 4a). Telegram strips the bold/italic
|
|
66
|
+
* entities, so the OBSERVED lines are:
|
|
67
|
+
*
|
|
68
|
+
* line 1: `<emoji> <label>` e.g. `🤖 Agent` (optionally ` · <description>`)
|
|
69
|
+
* line 2 running: `<elapsed> · <N> tool(s)` e.g. `12s · 0 tools`, `2m05s · 3 tools`
|
|
70
|
+
* line 2 done: `<state> · <N> tools · <elapsed>` e.g. `done · 3 tools · 41s`
|
|
71
|
+
*
|
|
72
|
+
* Elapsed is `formatFeedElapsed`: `<N>s` under a minute, else `<M>m<SS>s`.
|
|
73
|
+
*/
|
|
74
|
+
const LIVENESS_HEADER_L1_RE = /^(?:🤖|🛠[️]?|⚙[️]?)\s+\S/u;
|
|
75
|
+
const LIVENESS_ELAPSED = String.raw`(?:\d+m)?\d+s`;
|
|
76
|
+
const LIVENESS_HEADER_L2_RE = new RegExp(
|
|
77
|
+
`^(?:${LIVENESS_ELAPSED}\\s*·\\s*\\d+\\s+tools?` +
|
|
78
|
+
`|(?:done|failed)\\s*·\\s*\\d+\\s+tools?\\s*·\\s*${LIVENESS_ELAPSED})$`,
|
|
79
|
+
"iu",
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* True when `m` is the session activity / liveness card that carries the
|
|
84
|
+
* two-line `renderActivityHeader` (emoji + label, then the climbing
|
|
85
|
+
* `<elapsed> · <N> tools` status), followed only by `→`/`✓`/`↳` body lines.
|
|
86
|
+
*
|
|
87
|
+
* This is the card the Phase-1 climb (`feed-heartbeat-climb.ts`) and every
|
|
88
|
+
* headered activity feed render. The predicate stays strict — it requires
|
|
89
|
+
* BOTH header lines to match their exact shape — so a real reply that merely
|
|
90
|
+
* opens with an emoji or contains an arrow is never misclassified (the same
|
|
91
|
+
* documented reason the pure-arrow predicate demands every line be an activity
|
|
92
|
+
* line).
|
|
93
|
+
*/
|
|
94
|
+
export function isLivenessCardMessage(m: ObservedMessage): boolean {
|
|
95
|
+
const lines = m.text
|
|
96
|
+
.split("\n")
|
|
97
|
+
.map((l) => l.trim())
|
|
98
|
+
.filter((l) => l.length > 0);
|
|
99
|
+
if (lines.length < 2) return false;
|
|
100
|
+
if (!LIVENESS_HEADER_L1_RE.test(lines[0])) return false;
|
|
101
|
+
if (!LIVENESS_HEADER_L2_RE.test(lines[1])) return false;
|
|
102
|
+
// Any remaining line must be an activity body line — the moment prose
|
|
103
|
+
// appears below the header, this is no longer the card (guards against a
|
|
104
|
+
// reply that happens to lead with a header-shaped emoji).
|
|
105
|
+
return lines.slice(2).every((l) => ACTIVITY_BODY_LINE_RE.test(l));
|
|
106
|
+
}
|
|
107
|
+
|
|
49
108
|
/**
|
|
50
109
|
* True when `m` is the live tool-activity feed (the one-message list of
|
|
51
110
|
* "what the agent is doing this turn") rather than the agent's reply. A
|
|
52
|
-
* message qualifies
|
|
53
|
-
*
|
|
111
|
+
* message qualifies when EITHER every non-empty line is a pure activity line
|
|
112
|
+
* (`→`/`✓`, the header-less feed) OR it carries the two-line liveness header
|
|
113
|
+
* (see {@link isLivenessCardMessage}) — so a real reply that merely contains
|
|
114
|
+
* an arrow is never misclassified.
|
|
54
115
|
*
|
|
55
116
|
* Recall/reply scenarios must skip this in addition to
|
|
56
117
|
* {@link isWorkerFeedMessage}: on a turn that uses tools, the feed paints
|
|
57
118
|
* `→ Finding the right tool` as its own bot message before the real answer
|
|
58
|
-
* lands, and an `expectMessage(/\S/)` would otherwise latch onto it.
|
|
119
|
+
* lands, and an `expectMessage(/\S/)` would otherwise latch onto it. Before
|
|
120
|
+
* this predicate learned the header shape, the Phase-1 climb card (which
|
|
121
|
+
* ALWAYS carries the header) slipped through as an "answer" and silently
|
|
122
|
+
* broke the entire liveness-climb test wall.
|
|
59
123
|
*/
|
|
60
124
|
export function isActivityFeedMessage(m: ObservedMessage): boolean {
|
|
61
125
|
const lines = m.text
|
|
@@ -63,7 +127,8 @@ export function isActivityFeedMessage(m: ObservedMessage): boolean {
|
|
|
63
127
|
.map((l) => l.trim())
|
|
64
128
|
.filter((l) => l.length > 0);
|
|
65
129
|
if (lines.length === 0) return false;
|
|
66
|
-
|
|
130
|
+
if (lines.every((l) => ACTIVITY_FEED_LINE_RE.test(l))) return true;
|
|
131
|
+
return isLivenessCardMessage(m);
|
|
67
132
|
}
|
|
68
133
|
|
|
69
134
|
/**
|
|
@@ -83,6 +148,25 @@ export function isAnswer(m: ObservedMessage, driverUserId: number): boolean {
|
|
|
83
148
|
);
|
|
84
149
|
}
|
|
85
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Wording of the framework's own mid-turn / dark-turn fallback sends — the
|
|
153
|
+
* exact erosion class the liveness test wall exists to catch. A re-added
|
|
154
|
+
* cadence "still working…" text ping (the #2667 shape the RFC bans) or a
|
|
155
|
+
* dark-turn fallback would carry one of these phrases. Scenarios use this to
|
|
156
|
+
* REJECT such a message from the "answer" lane: without it, the first loud
|
|
157
|
+
* mid-turn framework send is swallowed as the turn's answer and the ping-free
|
|
158
|
+
* guarantee passes vacuously. Keep in sync with `SILENT_END_FALLBACK_TEXT`
|
|
159
|
+
* (gateway.ts) and `formatFrameworkFallbackText` (silence-poke.ts).
|
|
160
|
+
*/
|
|
161
|
+
export const FRAMEWORK_FALLBACK_RE =
|
|
162
|
+
/still working|no update from agent|didn't send a reply|finished working but|waiting for your approval/i;
|
|
163
|
+
|
|
164
|
+
/** True when `text` reads like a framework mid-turn/dark-turn fallback send
|
|
165
|
+
* (see {@link FRAMEWORK_FALLBACK_RE}) rather than a model-authored answer. */
|
|
166
|
+
export function isFrameworkFallbackText(text: string): boolean {
|
|
167
|
+
return FRAMEWORK_FALLBACK_RE.test(text);
|
|
168
|
+
}
|
|
169
|
+
|
|
86
170
|
export interface ReplyIsLastOptions {
|
|
87
171
|
/**
|
|
88
172
|
* The answer message that must be last in its foreground turn. The turn is
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import {
|
|
3
3
|
isActivityFeedMessage,
|
|
4
|
+
isFrameworkFallbackText,
|
|
5
|
+
isLivenessCardMessage,
|
|
4
6
|
isWorkerFeedMessage,
|
|
5
7
|
WORKER_FEED_RE,
|
|
6
8
|
} from "./assertions.js";
|
|
@@ -83,4 +85,71 @@ describe("isActivityFeedMessage", () => {
|
|
|
83
85
|
it("does NOT match an empty message", () => {
|
|
84
86
|
expect(isActivityFeedMessage(feed(" "))).toBe(false);
|
|
85
87
|
});
|
|
88
|
+
|
|
89
|
+
// The regression that silently broke the whole liveness-climb test wall
|
|
90
|
+
// (deterministic-turn-liveness.md Phase 4a): the climb card carries the
|
|
91
|
+
// two-line `renderActivityHeader`, which the pure-arrow predicate could
|
|
92
|
+
// never match — so it was classified as the answer and every climb test
|
|
93
|
+
// exited vacuously. isActivityFeedMessage must now recognise the header.
|
|
94
|
+
it("matches the Phase-1 climb card (two-line header + Working… body)", () => {
|
|
95
|
+
expect(
|
|
96
|
+
isActivityFeedMessage(feed("🤖 Agent\n12s · 0 tools\n→ Working…")),
|
|
97
|
+
).toBe(true);
|
|
98
|
+
expect(
|
|
99
|
+
isActivityFeedMessage(feed("🤖 Agent\n2m05s · 0 tools\n→ Working…")),
|
|
100
|
+
).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("matches a headered narration card (header + narrated → step)", () => {
|
|
104
|
+
expect(
|
|
105
|
+
isActivityFeedMessage(feed("🤖 Agent\n18s · 2 tools\n✓ Checking the hostname\n→ Writing the file")),
|
|
106
|
+
).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("isLivenessCardMessage", () => {
|
|
111
|
+
it("matches the climbing Working… card (running header)", () => {
|
|
112
|
+
expect(isLivenessCardMessage(feed("🤖 Agent\n12s · 0 tools\n→ Working…"))).toBe(true);
|
|
113
|
+
expect(isLivenessCardMessage(feed("🤖 Agent\n1m41s · 3 tools\n→ Working…"))).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("matches the done header shape", () => {
|
|
117
|
+
expect(isLivenessCardMessage(feed("🤖 Agent\ndone · 3 tools · 41s\n✓ Ran the check"))).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("matches a header with a description on line 1", () => {
|
|
121
|
+
expect(isLivenessCardMessage(feed("🤖 Agent · summarising the logs\n8s · 1 tool\n→ Working…"))).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("does NOT match a plain reply", () => {
|
|
125
|
+
expect(isLivenessCardMessage(feed("done! I created the file and listed it."))).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("does NOT match a reply that opens with an emoji but is prose", () => {
|
|
129
|
+
expect(
|
|
130
|
+
isLivenessCardMessage(feed("🤖 Agent here — I finished the task.\nAll four steps done.")),
|
|
131
|
+
).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("does NOT match when prose follows the header", () => {
|
|
135
|
+
expect(
|
|
136
|
+
isLivenessCardMessage(feed("🤖 Agent\n12s · 0 tools\nHere is your answer.")),
|
|
137
|
+
).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("does NOT match a bare single header line", () => {
|
|
141
|
+
expect(isLivenessCardMessage(feed("🤖 Agent"))).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("isFrameworkFallbackText", () => {
|
|
146
|
+
it("flags the mid-turn / dark-turn fallback wording", () => {
|
|
147
|
+
expect(isFrameworkFallbackText("⚠️ still working… (no update from agent in 5 min)")).toBe(true);
|
|
148
|
+
expect(isFrameworkFallbackText("The agent finished working but didn't send a reply.")).toBe(true);
|
|
149
|
+
expect(isFrameworkFallbackText("I'm blocked — waiting for your approval to proceed.")).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("does NOT flag an ordinary answer", () => {
|
|
153
|
+
expect(isFrameworkFallbackText("Done — I created /tmp/foo and wrote a file in it.")).toBe(false);
|
|
154
|
+
});
|
|
86
155
|
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fuzz: dead-air-between-visible-updates bound across turn shapes (Phase 4c,
|
|
3
|
+
* `deterministic-turn-liveness.md`). Non-required (`uat-fuzz`,
|
|
4
|
+
* `workflow_dispatch` + scheduled — see `ci-uat.yml`), scoped to `fuzz-*`.
|
|
5
|
+
*
|
|
6
|
+
* SCOPE HONESTY (read before trusting this as "the fuzz invariant" wholesale):
|
|
7
|
+
* the RFC's Phase 4c corpus is message-timing × turn-length × tool-churn ×
|
|
8
|
+
* sub-agent-fan-out × surface × role — a genuinely randomized property-fuzz.
|
|
9
|
+
* That full corpus already exists at the DECISION layer
|
|
10
|
+
* (`telegram-plugin/tests/turn-liveness-invariant.test.ts`, 2000 random
|
|
11
|
+
* shapes × both surfaces, fast/local/every-CI-run). What does NOT exist yet
|
|
12
|
+
* is a live-transport fuzz of the same breadth — each live turn burns real
|
|
13
|
+
* subscription quota and ~30-90s wall-clock, so a 2000-shape live corpus is
|
|
14
|
+
* not realistic to author or run from this sandbox (or CI, at any cadence
|
|
15
|
+
* short of a dedicated long-running canary). This file is the SCAFFOLD:
|
|
16
|
+
* a handful of FIXED, hand-picked turn shapes run on the real surface,
|
|
17
|
+
* checked against the SAME dead-air bound the decision-layer fuzz proves in
|
|
18
|
+
* the abstract. It is not a substitute for a true randomized live corpus —
|
|
19
|
+
* see the RFC's Known gaps / follow-up list, where this limitation is named
|
|
20
|
+
* explicitly rather than left implicit.
|
|
21
|
+
*
|
|
22
|
+
* Each case fires a turn shape and watches the two Phase-1 (keystone)
|
|
23
|
+
* invariants on the real surface — this is a climb/dead-air fuzz, NOT a
|
|
24
|
+
* dark-turn fuzz (the dark-turn at-most-once latch is proven in the
|
|
25
|
+
* `silent-end-recovery-{dm,channel}` scenarios, not here):
|
|
26
|
+
*
|
|
27
|
+
* - dead air between VISIBLE updates (card edits, narration, or the final
|
|
28
|
+
* answer) never exceeds `MAX_DEAD_AIR_MS` — generous slack over the
|
|
29
|
+
* ~6-12s Phase-1 bound to absorb live Bot API + model latency jitter;
|
|
30
|
+
* - zero mid-turn erosion: no non-final message with `silent===false`, and
|
|
31
|
+
* no framework fallback TEXT send masquerading as the answer.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { describe, expect, it } from "vitest";
|
|
35
|
+
import { spinUp } from "../harness.js";
|
|
36
|
+
import { isActivityFeedMessage, isFrameworkFallbackText } from "../assertions.js";
|
|
37
|
+
import type { ObservedMessage } from "../driver.js";
|
|
38
|
+
|
|
39
|
+
const MAX_DEAD_AIR_MS = 25_000; // generous slack over the ~6-12s Phase-1 bound
|
|
40
|
+
const CASE_BUDGET_MS = 130_000;
|
|
41
|
+
|
|
42
|
+
interface FuzzCase {
|
|
43
|
+
name: string;
|
|
44
|
+
prompt: string;
|
|
45
|
+
windowMs: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const CASES: FuzzCase[] = [
|
|
49
|
+
{
|
|
50
|
+
name: "single-silent-tool-short",
|
|
51
|
+
prompt:
|
|
52
|
+
"Run exactly one Bash command `sleep 20` with NO narration before or " +
|
|
53
|
+
"during it, then reply with a one-line confirmation.",
|
|
54
|
+
windowMs: 30_000,
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: "single-silent-tool-long",
|
|
58
|
+
prompt:
|
|
59
|
+
"Run exactly one Bash command `sleep 50` with NO narration before or " +
|
|
60
|
+
"during it, then reply with a one-line confirmation.",
|
|
61
|
+
windowMs: 60_000,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: "two-silent-tools-back-to-back",
|
|
65
|
+
prompt:
|
|
66
|
+
"Run Bash `sleep 20`, then immediately (no narration in between) run " +
|
|
67
|
+
"Bash `sleep 20` again, then reply with a one-line confirmation.",
|
|
68
|
+
windowMs: 50_000,
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
describe("uat-fuzz: liveness dead-air bound across a handful of turn shapes (Phase 4c scaffold)", () => {
|
|
73
|
+
for (const fc of CASES) {
|
|
74
|
+
it(
|
|
75
|
+
`[${fc.name}] dead air between visible updates never exceeds ${MAX_DEAD_AIR_MS}ms; no mid-turn ping`,
|
|
76
|
+
async () => {
|
|
77
|
+
const sc = await spinUp({ agent: "test-harness" });
|
|
78
|
+
try {
|
|
79
|
+
const iter = sc.driver
|
|
80
|
+
.observeMessages(sc.botUserId)
|
|
81
|
+
[Symbol.asyncIterator]();
|
|
82
|
+
|
|
83
|
+
await sc.sendDM(fc.prompt);
|
|
84
|
+
const sentAt = Date.now();
|
|
85
|
+
let lastVisibleAt = sentAt;
|
|
86
|
+
let maxGap = 0;
|
|
87
|
+
let answer: ObservedMessage | null = null;
|
|
88
|
+
let loudMidTurn: ObservedMessage | null = null;
|
|
89
|
+
|
|
90
|
+
const deadline = Date.now() + fc.windowMs + 40_000;
|
|
91
|
+
while (Date.now() < deadline) {
|
|
92
|
+
if (answer) break;
|
|
93
|
+
const remaining = deadline - Date.now();
|
|
94
|
+
const next = await Promise.race([
|
|
95
|
+
iter.next(),
|
|
96
|
+
new Promise<{ done: true; value: undefined }>((r) =>
|
|
97
|
+
setTimeout(() => r({ done: true, value: undefined }), Math.max(0, remaining)),
|
|
98
|
+
),
|
|
99
|
+
]);
|
|
100
|
+
if (next.done || next.value == null) break;
|
|
101
|
+
const m = next.value as ObservedMessage;
|
|
102
|
+
if (m.senderUserId === sc.driverUserId) continue;
|
|
103
|
+
|
|
104
|
+
const now = Date.now();
|
|
105
|
+
if (isActivityFeedMessage(m)) {
|
|
106
|
+
const gap = now - lastVisibleAt;
|
|
107
|
+
maxGap = Math.max(maxGap, gap);
|
|
108
|
+
lastVisibleAt = now;
|
|
109
|
+
if (m.edited && m.silent === false) loudMidTurn = loudMidTurn ?? m;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (m.edited) continue;
|
|
113
|
+
// A framework fallback TEXT send is a mid-turn erosion, not the
|
|
114
|
+
// answer — never let it be swallowed as the reply (else a re-added
|
|
115
|
+
// "still working…" ping would pass this fuzz vacuously).
|
|
116
|
+
if (isFrameworkFallbackText(m.text)) {
|
|
117
|
+
loudMidTurn = loudMidTurn ?? m;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (!answer && m.text.trim().length > 0) {
|
|
121
|
+
answer = m;
|
|
122
|
+
const gap = now - lastVisibleAt;
|
|
123
|
+
maxGap = Math.max(maxGap, gap);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (m.silent === false) loudMidTurn = loudMidTurn ?? m;
|
|
127
|
+
}
|
|
128
|
+
await iter.return?.();
|
|
129
|
+
|
|
130
|
+
console.log(
|
|
131
|
+
`[fuzz-liveness-climb][${fc.name}] maxGap=${maxGap}ms answer=${answer != null}`,
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
expect(
|
|
135
|
+
loudMidTurn,
|
|
136
|
+
`[${fc.name}] a mid-turn message pinged the device: ` +
|
|
137
|
+
JSON.stringify(loudMidTurn?.text?.slice(0, 120)),
|
|
138
|
+
).toBeNull();
|
|
139
|
+
|
|
140
|
+
expect(answer, `[${fc.name}] FAIL — no answer within budget; turn may be wedged.`).not.toBeNull();
|
|
141
|
+
|
|
142
|
+
expect(
|
|
143
|
+
maxGap,
|
|
144
|
+
`[${fc.name}] dead air of ${maxGap}ms between visible updates exceeds the ` +
|
|
145
|
+
`${MAX_DEAD_AIR_MS}ms bound — the Phase-1 climb should have kept the card ` +
|
|
146
|
+
"moving throughout the silent tool stretch.",
|
|
147
|
+
).toBeLessThanOrEqual(MAX_DEAD_AIR_MS);
|
|
148
|
+
} finally {
|
|
149
|
+
await sc.tearDown();
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
CASE_BUDGET_MS,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
});
|