switchroom 0.17.6 → 0.17.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +38 -4
- package/dist/auth-broker/index.js +302 -203
- package/dist/cli/notion-write-pretool.mjs +35 -2
- package/dist/cli/switchroom.js +1178 -576
- package/dist/host-control/main.js +148 -14
- package/dist/vault/approvals/kernel-server.js +140 -55
- package/dist/vault/broker/server.js +142 -57
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +50 -6
- package/profiles/default/CLAUDE.md +116 -0
- package/skills/mental-model-curator/SKILL.md +162 -0
- package/telegram-plugin/bridge/bridge.ts +80 -1
- package/telegram-plugin/bridge/ipc-client.ts +19 -0
- package/telegram-plugin/bridge/permission-ledger.ts +61 -0
- package/telegram-plugin/consolidation-legibility.ts +279 -0
- package/telegram-plugin/dist/bridge/bridge.js +85 -1
- package/telegram-plugin/dist/gateway/gateway.js +2565 -610
- package/telegram-plugin/dist/server.js +86 -2
- package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
- package/telegram-plugin/gateway/activity-card-store.ts +293 -0
- package/telegram-plugin/gateway/gateway.ts +1376 -82
- package/telegram-plugin/gateway/inbound-spool.ts +22 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
- package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
- package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
- package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
- package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
- package/telegram-plugin/gateway/permission-rearm.ts +115 -0
- package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
- package/telegram-plugin/memory-legibility.ts +217 -0
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/telegram-plugin/scoped-approval.ts +59 -0
- package/telegram-plugin/silent-end.ts +78 -0
- package/telegram-plugin/subagent-watcher.ts +60 -6
- package/telegram-plugin/tests/activity-card-store.test.ts +436 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
- package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +33 -9
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
- package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
- package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
- package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
- package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
- package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
- package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
- package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
- package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
- package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
- package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
- package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +103 -0
- package/telegram-plugin/uat/assertions.ts +88 -4
- package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
- package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
- package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
- package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
- package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
- package/telegram-plugin/worktree-watch-cwds.ts +60 -0
- package/vendor/hindsight-memory/hooks/hooks.json +9 -0
- package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +445 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +37 -0
- package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
- package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
- package/vendor/hindsight-memory/scripts/recall.py +153 -4
- package/vendor/hindsight-memory/scripts/retain.py +17 -0
- package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_capture_nudge.py +185 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
- package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JTBD scenario — a correction lands as a directive and survives `/reset`.
|
|
3
|
+
*
|
|
4
|
+
* Serves: `reference/jobs/remember-across-sessions.md` — the job's headline
|
|
5
|
+
* criterion is "a rule set once stays respected / a correction sticks."
|
|
6
|
+
*
|
|
7
|
+
* ## Why this exists (issue #2848 Stage B)
|
|
8
|
+
*
|
|
9
|
+
* Directive capture was guidance-only: the model is *told* to call
|
|
10
|
+
* `create_directive` on a durable correction, and a Stage A audit measured a
|
|
11
|
+
* **~55% miss rate** — the same broadcast correction captured by one agent,
|
|
12
|
+
* silently dropped by two others. Stage B adds a DETERMINISTIC regex nudge in
|
|
13
|
+
* the vendored recall hook (`vendor/hindsight-memory/scripts/recall.py`): on a
|
|
14
|
+
* correction-shaped inbound it appends a terse advisory telling the model to
|
|
15
|
+
* persist the rule with `create_directive` before answering. Detection is pure
|
|
16
|
+
* regex — the judgment happens IN the interactive session (claude-native
|
|
17
|
+
* invariant: no model callsite, no silent hook-side write).
|
|
18
|
+
*
|
|
19
|
+
* ## Contract this asserts
|
|
20
|
+
*
|
|
21
|
+
* 1. **Capture**: after a correction-shaped DM ("from now on, always end every
|
|
22
|
+
* reply with <MARKER>"), an ACTIVE directive referencing the rule exists in
|
|
23
|
+
* the agent's hindsight bank (queried via the REST API, the same surface
|
|
24
|
+
* Stage A used).
|
|
25
|
+
* 2. **Survival across `/reset`**: after `/reset` clears the session, a fresh
|
|
26
|
+
* neutral follow-up is still answered honoring the rule (the MARKER appears
|
|
27
|
+
* in the reply) — proving the directive was re-injected from the bank, not
|
|
28
|
+
* merely held in the wiped session context.
|
|
29
|
+
*
|
|
30
|
+
* ## Self-skip
|
|
31
|
+
*
|
|
32
|
+
* Self-skips GREEN when the mtcute driver isn't wired (no
|
|
33
|
+
* TELEGRAM_UAT_DRIVER_SESSION etc.), so it never reds an unwired host. The
|
|
34
|
+
* whole uat/** tree is excluded from gating CI regardless; run live with
|
|
35
|
+
* `bun run --cwd telegram-plugin test:uat jtbd-directive-capture-nudge-dm`.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { describe, it, expect } from "vitest";
|
|
39
|
+
import { randomBytes } from "node:crypto";
|
|
40
|
+
import { spinUp } from "../harness.js";
|
|
41
|
+
import { isActivityFeedMessage, isWorkerFeedMessage } from "../assertions.js";
|
|
42
|
+
import type { ObservedMessage } from "../driver.js";
|
|
43
|
+
|
|
44
|
+
const AGENT = "test-harness";
|
|
45
|
+
|
|
46
|
+
// Match the first non-empty bot reply that is neither the worker-activity
|
|
47
|
+
// feed nor the tool-activity feed (same guard as jtbd-memory-survives).
|
|
48
|
+
const isReply = (m: ObservedMessage): boolean =>
|
|
49
|
+
/\S/.test(m.text) && !isWorkerFeedMessage(m) && !isActivityFeedMessage(m);
|
|
50
|
+
|
|
51
|
+
const CORRECTION_REPLY_BUDGET_MS = 60_000;
|
|
52
|
+
const DIRECTIVE_SETTLE_MS = 12_000;
|
|
53
|
+
const RESET_SETTLE_MS = 45_000;
|
|
54
|
+
const POSTRESET_REPLY_BUDGET_MS = 120_000;
|
|
55
|
+
|
|
56
|
+
// Unique per-run marker so directive-content matching and the honored-rule
|
|
57
|
+
// check can't latch onto stale state from a prior run.
|
|
58
|
+
const MARKER = `SR_UAT_DIRECTIVE_${randomBytes(6).toString("hex").toUpperCase()}`;
|
|
59
|
+
|
|
60
|
+
// Hindsight REST base. Bank id == agent name (Stage A + CLAUDE.md). Override
|
|
61
|
+
// via HINDSIGHT_UAT_API_URL if the host runs it elsewhere.
|
|
62
|
+
const HINDSIGHT_BASE =
|
|
63
|
+
process.env.HINDSIGHT_UAT_API_URL ??
|
|
64
|
+
process.env.HINDSIGHT_API_URL ??
|
|
65
|
+
"http://127.0.0.1:18888";
|
|
66
|
+
|
|
67
|
+
interface Directive {
|
|
68
|
+
content?: string;
|
|
69
|
+
name?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function fetchActiveDirectives(bank: string): Promise<Directive[]> {
|
|
73
|
+
const url = `${HINDSIGHT_BASE}/v1/default/banks/${encodeURIComponent(bank)}/directives?active_only=false`;
|
|
74
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(8_000) });
|
|
75
|
+
if (!res.ok) {
|
|
76
|
+
throw new Error(`hindsight directives HTTP ${res.status} for bank ${bank} (${url})`);
|
|
77
|
+
}
|
|
78
|
+
const body = (await res.json()) as { items?: Directive[] };
|
|
79
|
+
return Array.isArray(body.items) ? body.items : [];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const uatWired = Boolean(
|
|
83
|
+
process.env.TELEGRAM_API_ID &&
|
|
84
|
+
process.env.TELEGRAM_API_HASH &&
|
|
85
|
+
process.env.TELEGRAM_UAT_DRIVER_SESSION &&
|
|
86
|
+
(process.env.TELEGRAM_TEST_BOT_USERNAME || false),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
(uatWired ? describe : describe.skip)(
|
|
90
|
+
"uat: correction lands as a directive and survives /reset (remember-across-sessions JTBD)",
|
|
91
|
+
() => {
|
|
92
|
+
it(
|
|
93
|
+
"a standing-rule correction is persisted as a directive and honored after /reset",
|
|
94
|
+
async () => {
|
|
95
|
+
// --- Phase 1: give the agent a durable, correction-shaped rule ---
|
|
96
|
+
const sc1 = await spinUp({ agent: AGENT });
|
|
97
|
+
try {
|
|
98
|
+
await sc1.sendDM(
|
|
99
|
+
`From now on, always end every reply you send me with this exact ` +
|
|
100
|
+
`marker on its own final line: ${MARKER}. This is a standing ` +
|
|
101
|
+
`rule for how you should behave going forward — please persist ` +
|
|
102
|
+
`it so it sticks across sessions. Confirm you've noted it.`,
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const reply = await sc1.expectMessage(isReply, {
|
|
106
|
+
from: "bot",
|
|
107
|
+
timeout: CORRECTION_REPLY_BUDGET_MS,
|
|
108
|
+
});
|
|
109
|
+
expect(reply.text.length).toBeGreaterThan(0);
|
|
110
|
+
|
|
111
|
+
// Give the in-turn create_directive call (nudged by the hook) time
|
|
112
|
+
// to land in the bank before we query it.
|
|
113
|
+
await new Promise((r) => setTimeout(r, DIRECTIVE_SETTLE_MS));
|
|
114
|
+
|
|
115
|
+
// --- Phase 2: CAPTURE — an active directive references the rule ---
|
|
116
|
+
const directives = await fetchActiveDirectives(AGENT);
|
|
117
|
+
const matched = directives.some((d) => {
|
|
118
|
+
const hay = `${d.content ?? ""} ${d.name ?? ""}`;
|
|
119
|
+
return (
|
|
120
|
+
hay.includes(MARKER) ||
|
|
121
|
+
/end (?:every|each|your) repl/i.test(hay) ||
|
|
122
|
+
/marker/i.test(hay)
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
if (!matched) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`[directive-capture] CONTRACT FAILED (capture): no active directive ` +
|
|
128
|
+
`references the standing rule. The correction was correction-shaped ` +
|
|
129
|
+
`(the recall hook should have nudged create_directive) but nothing ` +
|
|
130
|
+
`persisted. Directives seen: ` +
|
|
131
|
+
`${JSON.stringify(directives.map((d) => d.content ?? d.name).slice(0, 8))}`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
expect(matched).toBe(true);
|
|
135
|
+
} finally {
|
|
136
|
+
await sc1.tearDown();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// --- Phase 3: /reset clears the session ---
|
|
140
|
+
const scReset = await spinUp({ agent: AGENT });
|
|
141
|
+
try {
|
|
142
|
+
await scReset.sendDM("/reset");
|
|
143
|
+
} finally {
|
|
144
|
+
await scReset.tearDown();
|
|
145
|
+
}
|
|
146
|
+
// Let the reset complete + the bridge reattach before probing.
|
|
147
|
+
await new Promise((r) => setTimeout(r, RESET_SETTLE_MS));
|
|
148
|
+
|
|
149
|
+
// --- Phase 4: SURVIVAL — a fresh neutral turn still honors the rule ---
|
|
150
|
+
const sc2 = await spinUp({ agent: AGENT });
|
|
151
|
+
try {
|
|
152
|
+
await sc2.sendDM(
|
|
153
|
+
`What is 2 + 2? Answer normally — nothing special about this message.`,
|
|
154
|
+
);
|
|
155
|
+
const reply = await sc2.expectMessage(isReply, {
|
|
156
|
+
from: "bot",
|
|
157
|
+
timeout: POSTRESET_REPLY_BUDGET_MS,
|
|
158
|
+
});
|
|
159
|
+
expect(reply.text.length).toBeGreaterThan(0);
|
|
160
|
+
|
|
161
|
+
// The rule was NOT in this session's context (it was wiped by
|
|
162
|
+
// /reset). If the marker appears, the directive was re-injected from
|
|
163
|
+
// the bank on recall — the correction stuck.
|
|
164
|
+
const honored = reply.text.includes(MARKER);
|
|
165
|
+
if (!honored) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`[directive-capture] CONTRACT FAILED (survival): after /reset the ` +
|
|
168
|
+
`agent did not honor the standing rule — marker ${MARKER} absent ` +
|
|
169
|
+
`from the reply, so the directive did not survive/re-inject. Reply: ` +
|
|
170
|
+
`${JSON.stringify(reply.text.slice(0, 400))}`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
expect(honored).toBe(true);
|
|
174
|
+
} finally {
|
|
175
|
+
await sc2.tearDown();
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
CORRECTION_REPLY_BUDGET_MS +
|
|
179
|
+
DIRECTIVE_SETTLE_MS +
|
|
180
|
+
RESET_SETTLE_MS +
|
|
181
|
+
POSTRESET_REPLY_BUDGET_MS +
|
|
182
|
+
30_000,
|
|
183
|
+
);
|
|
184
|
+
},
|
|
185
|
+
);
|