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,216 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
classifyMemoryToolCall,
|
|
4
|
+
detectMemoryLegibilityEvent,
|
|
5
|
+
isMemoryLegibilityEnabled,
|
|
6
|
+
renderMemoryLegibilityLine,
|
|
7
|
+
MemoryLegibilityStager,
|
|
8
|
+
CREATE_DIRECTIVE_TOOL,
|
|
9
|
+
INVALIDATE_MEMORY_TOOL,
|
|
10
|
+
UPDATE_MEMORY_TOOL,
|
|
11
|
+
} from '../memory-legibility.js'
|
|
12
|
+
|
|
13
|
+
describe('isMemoryLegibilityEnabled — default-ON kill switch', () => {
|
|
14
|
+
it('is ON when unset (fresh setup / defaults principle)', () => {
|
|
15
|
+
expect(isMemoryLegibilityEnabled(undefined)).toBe(true)
|
|
16
|
+
})
|
|
17
|
+
it('is ON for any value except the literal "0"', () => {
|
|
18
|
+
expect(isMemoryLegibilityEnabled('1')).toBe(true)
|
|
19
|
+
expect(isMemoryLegibilityEnabled('')).toBe(true)
|
|
20
|
+
expect(isMemoryLegibilityEnabled('true')).toBe(true)
|
|
21
|
+
})
|
|
22
|
+
it('is OFF only for "0"', () => {
|
|
23
|
+
expect(isMemoryLegibilityEnabled('0')).toBe(false)
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('classifyMemoryToolCall — the reusable "did a material memory op happen?" primitive (#2848 Stage B imports this)', () => {
|
|
28
|
+
it('classifies create_directive as directive-create', () => {
|
|
29
|
+
expect(classifyMemoryToolCall(CREATE_DIRECTIVE_TOOL, { content: 'x', name: 'y' })).toBe(
|
|
30
|
+
'directive-create',
|
|
31
|
+
)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('classifies invalidate_memory as memory-invalidate', () => {
|
|
35
|
+
expect(classifyMemoryToolCall(INVALIDATE_MEMORY_TOOL, { memory_id: 'm1' })).toBe(
|
|
36
|
+
'memory-invalidate',
|
|
37
|
+
)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('classifies update_memory as memory-invalidate ONLY when it applies the demote-from-recall tag', () => {
|
|
41
|
+
expect(
|
|
42
|
+
classifyMemoryToolCall(UPDATE_MEMORY_TOOL, {
|
|
43
|
+
memory_id: 'm1',
|
|
44
|
+
add_tags: ['[demote-from-recall]'],
|
|
45
|
+
}),
|
|
46
|
+
).toBe('memory-invalidate')
|
|
47
|
+
// bracket-less + case variant still matches
|
|
48
|
+
expect(
|
|
49
|
+
classifyMemoryToolCall(UPDATE_MEMORY_TOOL, { add_tags: ['DEMOTE-FROM-RECALL'] }),
|
|
50
|
+
).toBe('memory-invalidate')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('does NOT classify a benign update_memory edit (no demote tag) — material-only', () => {
|
|
54
|
+
expect(
|
|
55
|
+
classifyMemoryToolCall(UPDATE_MEMORY_TOOL, { memory_id: 'm1', add_tags: ['urgent'] }),
|
|
56
|
+
).toBeNull()
|
|
57
|
+
expect(classifyMemoryToolCall(UPDATE_MEMORY_TOOL, { content: 'edited' })).toBeNull()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('does NOT classify ordinary recall / reflect / retain — no line on routine memory ops', () => {
|
|
61
|
+
for (const tool of [
|
|
62
|
+
'mcp__hindsight__recall',
|
|
63
|
+
'mcp__hindsight__reflect',
|
|
64
|
+
'mcp__hindsight__retain',
|
|
65
|
+
'mcp__hindsight__sync_retain',
|
|
66
|
+
'mcp__hindsight__list_directives',
|
|
67
|
+
'Bash',
|
|
68
|
+
'Read',
|
|
69
|
+
]) {
|
|
70
|
+
expect(classifyMemoryToolCall(tool, { query: 'x' })).toBeNull()
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe('detectMemoryLegibilityEvent — extracts the human detail', () => {
|
|
76
|
+
it('reads directive body from the real `content` wire field', () => {
|
|
77
|
+
expect(
|
|
78
|
+
detectMemoryLegibilityEvent(CREATE_DIRECTIVE_TOOL, {
|
|
79
|
+
name: 'ts-pref',
|
|
80
|
+
content: 'Always prefer TypeScript for this user',
|
|
81
|
+
}),
|
|
82
|
+
).toEqual({ kind: 'remembered', detail: 'Always prefer TypeScript for this user' })
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('falls back to `text` then `name` when `content` is absent', () => {
|
|
86
|
+
expect(detectMemoryLegibilityEvent(CREATE_DIRECTIVE_TOOL, { text: 'Prefer TS' })).toEqual({
|
|
87
|
+
kind: 'remembered',
|
|
88
|
+
detail: 'Prefer TS',
|
|
89
|
+
})
|
|
90
|
+
expect(detectMemoryLegibilityEvent(CREATE_DIRECTIVE_TOOL, { name: 'Prefer TS' })).toEqual({
|
|
91
|
+
kind: 'remembered',
|
|
92
|
+
detail: 'Prefer TS',
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('surfaces an invalidate reason but omits the opaque memory_id', () => {
|
|
97
|
+
expect(
|
|
98
|
+
detectMemoryLegibilityEvent(INVALIDATE_MEMORY_TOOL, {
|
|
99
|
+
memory_id: 'mem_abc123',
|
|
100
|
+
reason: 'superseded deploy runbook',
|
|
101
|
+
}),
|
|
102
|
+
).toEqual({ kind: 'forgot', detail: 'superseded deploy runbook' })
|
|
103
|
+
// no reason → empty detail (render uses the bare fallback), never the id
|
|
104
|
+
expect(detectMemoryLegibilityEvent(INVALIDATE_MEMORY_TOOL, { memory_id: 'mem_abc' })).toEqual({
|
|
105
|
+
kind: 'forgot',
|
|
106
|
+
detail: '',
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('returns null for non-material tools', () => {
|
|
111
|
+
expect(detectMemoryLegibilityEvent('mcp__hindsight__recall', { query: 'x' })).toBeNull()
|
|
112
|
+
expect(detectMemoryLegibilityEvent(UPDATE_MEMORY_TOOL, { content: 'edit' })).toBeNull()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('tolerates undefined input', () => {
|
|
116
|
+
expect(detectMemoryLegibilityEvent(CREATE_DIRECTIVE_TOOL, undefined)).toEqual({
|
|
117
|
+
kind: 'remembered',
|
|
118
|
+
detail: '',
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
describe('MemoryLegibilityStager — confirm-before-legibility (Fix 1.3, #2903)', () => {
|
|
124
|
+
const evt = { kind: 'remembered' as const, detail: 'Always prefer TypeScript' }
|
|
125
|
+
|
|
126
|
+
it('sends the line only on a CONFIRMED-successful tool_result', () => {
|
|
127
|
+
const s = new MemoryLegibilityStager<{ chatId: string }>()
|
|
128
|
+
s.stage('toolu_ok', evt, { chatId: '123' })
|
|
129
|
+
expect(s.size).toBe(1)
|
|
130
|
+
const resolved = s.confirm('toolu_ok', undefined) // undefined isError = success
|
|
131
|
+
expect(resolved).not.toBeNull()
|
|
132
|
+
expect(resolved!.event).toEqual(evt)
|
|
133
|
+
expect(resolved!.meta).toEqual({ chatId: '123' })
|
|
134
|
+
expect(s.size).toBe(0) // consumed
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('does NOT send on a FAILED write — a failed create_directive shows NO line', () => {
|
|
138
|
+
const s = new MemoryLegibilityStager<{ chatId: string }>()
|
|
139
|
+
s.stage('toolu_fail', evt, { chatId: '123' })
|
|
140
|
+
// is_error:true on the tool_result (engine down / isError envelope)
|
|
141
|
+
const resolved = s.confirm('toolu_fail', true)
|
|
142
|
+
expect(resolved).toBeNull()
|
|
143
|
+
// the staged entry is consumed even on failure — no leak, no later re-send
|
|
144
|
+
expect(s.size).toBe(0)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('returns null when no event was staged for the id (non-material tool result)', () => {
|
|
148
|
+
const s = new MemoryLegibilityStager<{ chatId: string }>()
|
|
149
|
+
expect(s.confirm('unknown-id', undefined)).toBeNull()
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('returns null for an empty/missing toolUseId (cannot correlate → no unconfirmed send)', () => {
|
|
153
|
+
const s = new MemoryLegibilityStager<{ chatId: string }>()
|
|
154
|
+
expect(s.confirm('', undefined)).toBeNull()
|
|
155
|
+
expect(s.confirm(null, undefined)).toBeNull()
|
|
156
|
+
expect(s.confirm(undefined, undefined)).toBeNull()
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('evicts the oldest staged entry past the cap so a crashy turn cannot leak', () => {
|
|
160
|
+
const s = new MemoryLegibilityStager<{ n: number }>(2)
|
|
161
|
+
s.stage('a', evt, { n: 1 })
|
|
162
|
+
s.stage('b', evt, { n: 2 })
|
|
163
|
+
s.stage('c', evt, { n: 3 }) // evicts 'a'
|
|
164
|
+
expect(s.size).toBe(2)
|
|
165
|
+
expect(s.confirm('a', undefined)).toBeNull() // evicted
|
|
166
|
+
expect(s.confirm('c', undefined)).not.toBeNull()
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
describe('renderMemoryLegibilityLine — terse HTML one-liner', () => {
|
|
171
|
+
it('renders a remembered directive in quotes', () => {
|
|
172
|
+
expect(
|
|
173
|
+
renderMemoryLegibilityLine({ kind: 'remembered', detail: 'Always prefer TypeScript' }),
|
|
174
|
+
).toBe('📌 <i>remembered:</i> "Always prefer TypeScript"')
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('renders a forgot line with the reason', () => {
|
|
178
|
+
expect(renderMemoryLegibilityLine({ kind: 'forgot', detail: 'superseded runbook' })).toBe(
|
|
179
|
+
'✂️ <i>forgot:</i> superseded runbook',
|
|
180
|
+
)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('uses a bare fallback when there is no legible detail', () => {
|
|
184
|
+
expect(renderMemoryLegibilityLine({ kind: 'remembered', detail: '' })).toBe(
|
|
185
|
+
'📌 <i>remembered a new directive.</i>',
|
|
186
|
+
)
|
|
187
|
+
expect(renderMemoryLegibilityLine({ kind: 'forgot', detail: ' ' })).toBe(
|
|
188
|
+
'✂️ <i>forgot a memory.</i>',
|
|
189
|
+
)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('HTML-escapes the detail so it cannot break parse_mode:HTML', () => {
|
|
193
|
+
const out = renderMemoryLegibilityLine({
|
|
194
|
+
kind: 'remembered',
|
|
195
|
+
detail: 'use <b> & compare a < b',
|
|
196
|
+
})
|
|
197
|
+
expect(out).toBe('📌 <i>remembered:</i> "use <b> & compare a < b"')
|
|
198
|
+
// the only literal < / > left are the wrapper <i> tags
|
|
199
|
+
expect(out).not.toContain('<b>')
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('strips markdown and collapses whitespace before truncating', () => {
|
|
203
|
+
const out = renderMemoryLegibilityLine({
|
|
204
|
+
kind: 'remembered',
|
|
205
|
+
detail: '**Always** prefer `TypeScript`',
|
|
206
|
+
})
|
|
207
|
+
expect(out).toBe('📌 <i>remembered:</i> "Always prefer TypeScript"')
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('truncates very long directive bodies with an ellipsis', () => {
|
|
211
|
+
const long = 'x'.repeat(400)
|
|
212
|
+
const out = renderMemoryLegibilityLine({ kind: 'remembered', detail: long })
|
|
213
|
+
expect(out.length).toBeLessThan(220)
|
|
214
|
+
expect(out).toContain('…')
|
|
215
|
+
})
|
|
216
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract pins for `handleMentalModelProposeCallback` — the operator-tap
|
|
3
|
+
* resolver for a mental-model proposal card (hindsight Phase 5, Fix 6.1).
|
|
4
|
+
*
|
|
5
|
+
* These are source-inspection pins (the handler needs a live grammY Context +
|
|
6
|
+
* hostd dispatch to run, so we assert its structure the same way the vault
|
|
7
|
+
* callback posture tests do). Load-bearing invariants:
|
|
8
|
+
*
|
|
9
|
+
* 1. The `allowFrom` authorization gate is the FIRST thing the handler does —
|
|
10
|
+
* a tap from anyone not on the operator allow-list is refused before any
|
|
11
|
+
* pending lookup, pending delete, or hostd dispatch runs. This is the
|
|
12
|
+
* "an agent can never self-approve" boundary.
|
|
13
|
+
* 2. The TTL is enforced at TAP time, not only on the next propose's sweep —
|
|
14
|
+
* a card past MENTAL_MODEL_PROPOSE_TTL_MS is refused and its keyboard is
|
|
15
|
+
* edited away, even when no fresh proposal has swept it.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, expect, it } from 'vitest'
|
|
19
|
+
import { readFileSync } from 'node:fs'
|
|
20
|
+
import { resolve } from 'node:path'
|
|
21
|
+
|
|
22
|
+
const gatewaySrc = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8')
|
|
23
|
+
|
|
24
|
+
function proposeCallbackBlock(): string {
|
|
25
|
+
return (
|
|
26
|
+
gatewaySrc.split('async function handleMentalModelProposeCallback')[1]?.split('\nasync function')[0] ?? ''
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('handleMentalModelProposeCallback — authorization gate', () => {
|
|
31
|
+
it('refuses a tap from a non-operator via the allowFrom allow-list FIRST', () => {
|
|
32
|
+
const block = proposeCallbackBlock()
|
|
33
|
+
expect(block).toMatch(/if \(!access\.allowFrom\.includes\(senderId\)\)/)
|
|
34
|
+
expect(block).toMatch(/Not authorized/)
|
|
35
|
+
// The allowFrom guard must precede the pending lookup / delete: no state
|
|
36
|
+
// mutation before authorization.
|
|
37
|
+
const beforeGuard = block.split('access.allowFrom.includes(senderId)')[0] ?? ''
|
|
38
|
+
expect(beforeGuard).not.toMatch(/pendingMentalModelProposes\.get/)
|
|
39
|
+
expect(beforeGuard).not.toMatch(/pendingMentalModelProposes\.delete/)
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('handleMentalModelProposeCallback — TTL enforced at tap time', () => {
|
|
44
|
+
it('checks staged_at against MENTAL_MODEL_PROPOSE_TTL_MS at tap and edits the card away', () => {
|
|
45
|
+
const block = proposeCallbackBlock()
|
|
46
|
+
expect(block).toMatch(/Date\.now\(\) - pending\.staged_at > MENTAL_MODEL_PROPOSE_TTL_MS/)
|
|
47
|
+
// The expired branch deletes the pending entry and clears the keyboard so a
|
|
48
|
+
// stale card can't be tapped into an approval.
|
|
49
|
+
const ttlBranch =
|
|
50
|
+
block.split('Date.now() - pending.staged_at > MENTAL_MODEL_PROPOSE_TTL_MS')[1]?.split('Single-shot')[0] ?? ''
|
|
51
|
+
expect(ttlBranch).toMatch(/pendingMentalModelProposes\.delete\(stageId\)/)
|
|
52
|
+
expect(ttlBranch).toMatch(/expired/)
|
|
53
|
+
expect(ttlBranch).toMatch(/inline_keyboard: \[\]/)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('executeMentalModelPropose — schema caps enforced up-front', () => {
|
|
58
|
+
it('rejects an over-length source_query and an over-cap max_tokens before posting a card', () => {
|
|
59
|
+
const fn =
|
|
60
|
+
gatewaySrc.split('async function executeMentalModelPropose')[1]?.split('\nasync function')[0] ?? ''
|
|
61
|
+
expect(fn).toMatch(/source_query\.length > MENTAL_MODEL_SOURCE_QUERY_MAX/)
|
|
62
|
+
expect(fn).toMatch(/n > MENTAL_MODEL_MAX_TOKENS_CAP/)
|
|
63
|
+
// Caps mirror the schema (src/config/schema.ts): 2000 / 8192.
|
|
64
|
+
expect(gatewaySrc).toMatch(/MENTAL_MODEL_SOURCE_QUERY_MAX = 2000/)
|
|
65
|
+
expect(gatewaySrc).toMatch(/MENTAL_MODEL_MAX_TOKENS_CAP = 8192/)
|
|
66
|
+
})
|
|
67
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { renderMentalModelProposeCard } from "../gateway/mental-model-propose-card.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The proposal RENDERS an operator approval card (hindsight Phase 5). Mirrors
|
|
6
|
+
* the vault-request-access card: agent name + model name + source query +
|
|
7
|
+
* rationale, with the same escaping discipline.
|
|
8
|
+
*/
|
|
9
|
+
describe("renderMentalModelProposeCard", () => {
|
|
10
|
+
it("renders a card carrying the agent, model name, source query, and rationale", () => {
|
|
11
|
+
const card = renderMentalModelProposeCard({
|
|
12
|
+
agent: "coach",
|
|
13
|
+
name: "training-plan-state",
|
|
14
|
+
source_query: "What is the athlete's current plan?",
|
|
15
|
+
reason: "I keep re-deriving the plan state every session",
|
|
16
|
+
refresh_after_consolidation: true,
|
|
17
|
+
max_tokens: 1024,
|
|
18
|
+
});
|
|
19
|
+
expect(card).toContain("proposes a mental model");
|
|
20
|
+
expect(card).toContain("coach");
|
|
21
|
+
// Model name rendered inside a code span (literal).
|
|
22
|
+
expect(card).toContain("`training-plan-state`");
|
|
23
|
+
expect(card).toContain("What is the athlete's current plan?");
|
|
24
|
+
expect(card).toContain("I keep re-deriving the plan state every session");
|
|
25
|
+
expect(card).toContain("refresh after consolidation: `on`");
|
|
26
|
+
// max_tokens is surfaced so the operator approves exactly what's applied.
|
|
27
|
+
expect(card).toContain("max tokens: `1024`");
|
|
28
|
+
// The card explains the approve/deny semantics so the operator can decide.
|
|
29
|
+
expect(card).toContain("Approve");
|
|
30
|
+
expect(card).toContain("Deny");
|
|
31
|
+
expect(card).toContain("memory.mental_models[]");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("renders 'why: not provided' when no rationale is supplied (agent-side omission is visible)", () => {
|
|
35
|
+
const card = renderMentalModelProposeCard({
|
|
36
|
+
agent: "coach",
|
|
37
|
+
name: "plan",
|
|
38
|
+
source_query: "q",
|
|
39
|
+
});
|
|
40
|
+
expect(card).toContain("why: _not provided_");
|
|
41
|
+
expect(card).toContain("refresh after consolidation: `off`");
|
|
42
|
+
// No max_tokens on the proposal → the applied model inherits the default;
|
|
43
|
+
// render that explicitly rather than hiding the field.
|
|
44
|
+
expect(card).toContain("max tokens: `default`");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("defuses a backtick in the model name so it can't break out of the code span", () => {
|
|
48
|
+
const card = renderMentalModelProposeCard({
|
|
49
|
+
agent: "coach",
|
|
50
|
+
name: "pl`an",
|
|
51
|
+
source_query: "q",
|
|
52
|
+
});
|
|
53
|
+
// The raw backtick must not survive verbatim inside the span.
|
|
54
|
+
expect(card).not.toContain("`pl`an`");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { parseDocument } from "yaml";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import {
|
|
8
|
+
buildMentalModelAppendDiff,
|
|
9
|
+
readDeclaredMentalModelNames,
|
|
10
|
+
} from "../gateway/mental-model-propose-diff.js";
|
|
11
|
+
import { ensureDeclaredMentalModels } from "../../src/memory/hindsight.js";
|
|
12
|
+
|
|
13
|
+
/** Apply a unified diff the way hostd does (git apply -p1) and return the
|
|
14
|
+
* patched file contents, or null if the patch didn't apply. */
|
|
15
|
+
function gitApply(before: string, diff: string): string | null {
|
|
16
|
+
const dir = mkdtempSync(join(tmpdir(), "mmp-apply-"));
|
|
17
|
+
try {
|
|
18
|
+
writeFileSync(join(dir, "switchroom.yaml"), before);
|
|
19
|
+
writeFileSync(join(dir, "patch.diff"), diff);
|
|
20
|
+
const init = spawnSync("git", ["init", "-q"], { cwd: dir });
|
|
21
|
+
if (init.status !== 0) return null;
|
|
22
|
+
const r = spawnSync(
|
|
23
|
+
"git",
|
|
24
|
+
["apply", "--whitespace=nowarn", "--recount", "-p1", "patch.diff"],
|
|
25
|
+
{ cwd: dir, encoding: "utf-8" },
|
|
26
|
+
);
|
|
27
|
+
if (r.status !== 0) return null;
|
|
28
|
+
return readFileSync(join(dir, "switchroom.yaml"), "utf-8");
|
|
29
|
+
} finally {
|
|
30
|
+
rmSync(dir, { recursive: true, force: true });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* hindsight Phase 5 — the persistence primitive for the agent-proposes →
|
|
36
|
+
* human-approves mental-model flow. An approved proposal must become a
|
|
37
|
+
* first-class DECLARED model in agents.<agent>.memory.mental_models[] so the
|
|
38
|
+
* exact same #2874 ensure/reconcile path consumes it.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
const CONFIG_NO_MODELS = `agents:
|
|
42
|
+
coach:
|
|
43
|
+
persona: fitness
|
|
44
|
+
memory:
|
|
45
|
+
collection: coach-bank
|
|
46
|
+
lawyer:
|
|
47
|
+
persona: legal
|
|
48
|
+
`;
|
|
49
|
+
|
|
50
|
+
const CONFIG_WITH_MODELS = `agents:
|
|
51
|
+
coach:
|
|
52
|
+
persona: fitness
|
|
53
|
+
memory:
|
|
54
|
+
collection: coach-bank
|
|
55
|
+
mental_models:
|
|
56
|
+
- name: training-plan-state
|
|
57
|
+
source_query: What is the athlete's current plan?
|
|
58
|
+
`;
|
|
59
|
+
|
|
60
|
+
describe("readDeclaredMentalModelNames", () => {
|
|
61
|
+
it("returns [] when the agent has no declared models", () => {
|
|
62
|
+
expect(readDeclaredMentalModelNames(CONFIG_NO_MODELS, "coach")).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
it("reads the declared model names for the agent (and not siblings)", () => {
|
|
65
|
+
expect(readDeclaredMentalModelNames(CONFIG_WITH_MODELS, "coach")).toEqual([
|
|
66
|
+
"training-plan-state",
|
|
67
|
+
]);
|
|
68
|
+
expect(readDeclaredMentalModelNames(CONFIG_WITH_MODELS, "lawyer")).toEqual([]);
|
|
69
|
+
});
|
|
70
|
+
it("never throws on unparseable config", () => {
|
|
71
|
+
expect(readDeclaredMentalModelNames(":\n bad: [", "coach")).toEqual([]);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("buildMentalModelAppendDiff — appends the declaration", () => {
|
|
76
|
+
it("creates memory.mental_models[] when absent and appends the model", () => {
|
|
77
|
+
const r = buildMentalModelAppendDiff({
|
|
78
|
+
configText: CONFIG_NO_MODELS,
|
|
79
|
+
agentName: "coach",
|
|
80
|
+
spec: { name: "training-plan-state", source_query: "What is the plan?" },
|
|
81
|
+
});
|
|
82
|
+
expect(r.ok).toBe(true);
|
|
83
|
+
if (!r.ok) return;
|
|
84
|
+
// The appended `after` declares the model under the right agent.
|
|
85
|
+
expect(readDeclaredMentalModelNames(r.after, "coach")).toContain(
|
|
86
|
+
"training-plan-state",
|
|
87
|
+
);
|
|
88
|
+
// And the diff is a real git-apply-shaped unified diff.
|
|
89
|
+
expect(r.diff).toContain("--- a/switchroom.yaml");
|
|
90
|
+
expect(r.diff).toContain("+++ b/switchroom.yaml");
|
|
91
|
+
expect(r.diff).toContain("training-plan-state");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("appends to an existing mental_models[] without dropping the prior model", () => {
|
|
95
|
+
const r = buildMentalModelAppendDiff({
|
|
96
|
+
configText: CONFIG_WITH_MODELS,
|
|
97
|
+
agentName: "coach",
|
|
98
|
+
spec: { name: "injury-history", source_query: "Which injuries?", max_tokens: 1024 },
|
|
99
|
+
});
|
|
100
|
+
expect(r.ok).toBe(true);
|
|
101
|
+
if (!r.ok) return;
|
|
102
|
+
const names = readDeclaredMentalModelNames(r.after, "coach");
|
|
103
|
+
expect(names).toEqual(["training-plan-state", "injury-history"]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("only serialises knobs that are set (schema-clean minimal declaration)", () => {
|
|
107
|
+
const r = buildMentalModelAppendDiff({
|
|
108
|
+
configText: CONFIG_NO_MODELS,
|
|
109
|
+
agentName: "coach",
|
|
110
|
+
spec: { name: "plan", source_query: "q", refresh_after_consolidation: true },
|
|
111
|
+
});
|
|
112
|
+
expect(r.ok).toBe(true);
|
|
113
|
+
if (!r.ok) return;
|
|
114
|
+
const model = (parseDocument(r.after).toJS() as any).agents.coach.memory.mental_models[0];
|
|
115
|
+
expect(model).toEqual({
|
|
116
|
+
name: "plan",
|
|
117
|
+
source_query: "q",
|
|
118
|
+
refresh_after_consolidation: true,
|
|
119
|
+
});
|
|
120
|
+
expect("max_tokens" in model).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("the synthesized diff round-trips through git apply against the live config", () => {
|
|
124
|
+
const r = buildMentalModelAppendDiff({
|
|
125
|
+
configText: CONFIG_WITH_MODELS,
|
|
126
|
+
agentName: "coach",
|
|
127
|
+
spec: { name: "injury-history", source_query: "Which injuries?" },
|
|
128
|
+
});
|
|
129
|
+
expect(r.ok).toBe(true);
|
|
130
|
+
if (!r.ok) return;
|
|
131
|
+
// hostd applies with git apply; prove the patch actually lands.
|
|
132
|
+
const applied = gitApply(CONFIG_WITH_MODELS, r.diff);
|
|
133
|
+
expect(applied).not.toBeNull();
|
|
134
|
+
expect(readDeclaredMentalModelNames(applied!, "coach")).toEqual([
|
|
135
|
+
"training-plan-state",
|
|
136
|
+
"injury-history",
|
|
137
|
+
]);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("buildMentalModelAppendDiff — guardrails", () => {
|
|
142
|
+
it("REJECTS a duplicate-name proposal (the idempotent-ensure key must be unique)", () => {
|
|
143
|
+
const r = buildMentalModelAppendDiff({
|
|
144
|
+
configText: CONFIG_WITH_MODELS,
|
|
145
|
+
agentName: "coach",
|
|
146
|
+
spec: { name: "training-plan-state", source_query: "different query" },
|
|
147
|
+
});
|
|
148
|
+
expect(r.ok).toBe(false);
|
|
149
|
+
if (r.ok) return;
|
|
150
|
+
expect(r.error).toBe("duplicate");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("REJECTS a proposal for an agent that is not in the config (never invents one)", () => {
|
|
154
|
+
const r = buildMentalModelAppendDiff({
|
|
155
|
+
configText: CONFIG_NO_MODELS,
|
|
156
|
+
agentName: "ghost",
|
|
157
|
+
spec: { name: "x", source_query: "q" },
|
|
158
|
+
});
|
|
159
|
+
expect(r.ok).toBe(false);
|
|
160
|
+
if (r.ok) return;
|
|
161
|
+
expect(r.error).toBe("agent-not-found");
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("appended declaration is consumed by #2874's ensure path", () => {
|
|
166
|
+
it("the appended model is ensured by ensureDeclaredMentalModels (create fired)", async () => {
|
|
167
|
+
const r = buildMentalModelAppendDiff({
|
|
168
|
+
configText: CONFIG_NO_MODELS,
|
|
169
|
+
agentName: "coach",
|
|
170
|
+
spec: { name: "training-plan-state", source_query: "What is the plan?" },
|
|
171
|
+
});
|
|
172
|
+
expect(r.ok).toBe(true);
|
|
173
|
+
if (!r.ok) return;
|
|
174
|
+
|
|
175
|
+
// Read the newly-declared model set exactly as scaffold/reconcile would.
|
|
176
|
+
const declared = (parseDocument(r.after).toJS() as any).agents.coach.memory
|
|
177
|
+
.mental_models;
|
|
178
|
+
|
|
179
|
+
const mockFetch = vi
|
|
180
|
+
.fn()
|
|
181
|
+
// init
|
|
182
|
+
.mockResolvedValueOnce({ ok: true, headers: new Map([["mcp-session-id", "s"]]), text: async () => "" })
|
|
183
|
+
// list (empty → create)
|
|
184
|
+
.mockResolvedValueOnce({ ok: true, text: async () => `data: {"result":{"content":[{"text":${JSON.stringify(JSON.stringify({ items: [] }))}}]}}\n` })
|
|
185
|
+
// create
|
|
186
|
+
.mockResolvedValueOnce({ ok: true, text: async () => 'data: {"result":{"isError":false}}\n' });
|
|
187
|
+
|
|
188
|
+
const outcomes = await ensureDeclaredMentalModels(
|
|
189
|
+
"http://test.local/mcp/",
|
|
190
|
+
"coach-bank",
|
|
191
|
+
declared,
|
|
192
|
+
{ fetchImpl: mockFetch as any },
|
|
193
|
+
);
|
|
194
|
+
expect(outcomes).toEqual([{ name: "training-plan-state", ok: true }]);
|
|
195
|
+
// create_mental_model actually fired with the declared source_query.
|
|
196
|
+
const createBody = JSON.parse(mockFetch.mock.calls[2][1].body);
|
|
197
|
+
expect(createBody.params.name).toBe("create_mental_model");
|
|
198
|
+
expect(createBody.params.arguments.name).toBe("training-plan-state");
|
|
199
|
+
expect(createBody.params.arguments.source_query).toBe("What is the plan?");
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
buildMentalModelProposeAppliedInbound,
|
|
4
|
+
buildMentalModelProposeDeniedInbound,
|
|
5
|
+
buildMentalModelProposeFailedInbound,
|
|
6
|
+
} from "../gateway/mental-model-propose-inbound-builders.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The synthetic-inbound shapes are load-bearing: the bridge keys on
|
|
10
|
+
* `meta.source` to render `<channel source="...">` and resume the agent's turn.
|
|
11
|
+
* A drifted source string or dropped meta field silently breaks the wake-up.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const ctx = {
|
|
15
|
+
agent: "coach",
|
|
16
|
+
name: "training-plan-state",
|
|
17
|
+
chat_id: "555",
|
|
18
|
+
threadId: 99,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe("mental-model proposal synthetic inbounds", () => {
|
|
22
|
+
it("applied inbound carries source + forensic meta and threads the topic", () => {
|
|
23
|
+
const m = buildMentalModelProposeAppliedInbound({
|
|
24
|
+
ctx,
|
|
25
|
+
stageId: "st1",
|
|
26
|
+
operatorId: "op-7",
|
|
27
|
+
nowMs: 1234,
|
|
28
|
+
});
|
|
29
|
+
expect(m.type).toBe("inbound");
|
|
30
|
+
expect(m.chatId).toBe("555");
|
|
31
|
+
expect(m.threadId).toBe(99);
|
|
32
|
+
expect(m.meta.source).toBe("mental_model_proposal_applied");
|
|
33
|
+
expect(m.meta.agent).toBe("coach");
|
|
34
|
+
expect(m.meta.name).toBe("training-plan-state");
|
|
35
|
+
expect(m.meta.stage_id).toBe("st1");
|
|
36
|
+
expect(m.meta.operator_id).toBe("op-7");
|
|
37
|
+
expect(m.meta.message_thread_id).toBe("99");
|
|
38
|
+
expect(m.text).toContain("approved");
|
|
39
|
+
expect(m.text).toContain("training-plan-state");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("denied inbound tells the agent NOTHING was written", () => {
|
|
43
|
+
const m = buildMentalModelProposeDeniedInbound({
|
|
44
|
+
ctx: { agent: "coach", name: "plan", chat_id: "1" },
|
|
45
|
+
stageId: "st2",
|
|
46
|
+
operatorId: "op-1",
|
|
47
|
+
nowMs: 5,
|
|
48
|
+
});
|
|
49
|
+
expect(m.meta.source).toBe("mental_model_proposal_denied");
|
|
50
|
+
expect(m.threadId).toBeUndefined();
|
|
51
|
+
expect(m.meta.message_thread_id).toBeUndefined();
|
|
52
|
+
expect(m.text).toContain("denied");
|
|
53
|
+
expect(m.text.toUpperCase()).toContain("NOTHING");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("failed inbound is honest that the model did NOT land", () => {
|
|
57
|
+
const m = buildMentalModelProposeFailedInbound({
|
|
58
|
+
ctx: { agent: "coach", name: "plan", chat_id: "1" },
|
|
59
|
+
stageId: "st3",
|
|
60
|
+
operatorId: "op-1",
|
|
61
|
+
reason: "hostd down",
|
|
62
|
+
nowMs: 5,
|
|
63
|
+
});
|
|
64
|
+
expect(m.meta.source).toBe("mental_model_proposal_failed");
|
|
65
|
+
expect(m.text).toContain("hostd down");
|
|
66
|
+
expect(m.text).toContain("NOT");
|
|
67
|
+
});
|
|
68
|
+
});
|