mixdog 0.9.19 → 0.9.21
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/LICENSE +21 -0
- package/README.md +100 -34
- package/package.json +3 -2
- package/scripts/build-tui.mjs +6 -0
- package/scripts/hook-bus-test.mjs +8 -0
- package/scripts/log-writer-guard-smoke.mjs +131 -0
- package/scripts/reactive-compact-persist-smoke.mjs +22 -6
- package/scripts/recall-bench-cases.json +0 -1
- package/scripts/recall-quality-cases.json +1 -2
- package/scripts/session-ingest-compaction-smoke.mjs +241 -0
- package/scripts/tool-smoke.mjs +150 -45
- package/src/defaults/skills/setup/SKILL.md +327 -0
- package/src/help.mjs +2 -5
- package/src/lib/mixdog-debug.cjs +13 -0
- package/src/mixdog-session-runtime.mjs +7 -3328
- package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -2
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -10
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +40 -3
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +73 -3
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +663 -0
- package/src/runtime/agent/orchestrator/session/compact/engine.mjs +6 -0
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +153 -0
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +49 -0
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -1
- package/src/runtime/agent/orchestrator/session/loop.mjs +8 -1860
- package/src/runtime/agent/orchestrator/session/manager/agent-runtime-singleton.mjs +29 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +686 -0
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -12
- package/src/runtime/agent/orchestrator/session/manager/env-utils.mjs +8 -0
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +159 -0
- package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +143 -0
- package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +268 -0
- package/src/runtime/agent/orchestrator/session/manager/provider-cache-key.mjs +22 -0
- package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +26 -0
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +124 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +258 -0
- package/src/runtime/agent/orchestrator/session/manager/session-errors.mjs +20 -0
- package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +9 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +475 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +23 -0
- package/src/runtime/agent/orchestrator/session/manager.mjs +88 -2285
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +440 -0
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +153 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +4 -1
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +642 -0
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +23 -3
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +108 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +15 -3
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +24 -2
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +72 -8
- package/src/runtime/agent/orchestrator/tools/shell-policy-danger-target.mjs +5 -1
- package/src/runtime/channels/index.mjs +6 -2183
- package/src/runtime/channels/lib/inbound-handler.mjs +328 -0
- package/src/runtime/channels/lib/interaction-handlers.mjs +260 -0
- package/src/runtime/channels/lib/network-retry.mjs +23 -0
- package/src/runtime/channels/lib/owned-runtime.mjs +627 -0
- package/src/runtime/channels/lib/runtime-paths.mjs +20 -9
- package/src/runtime/channels/lib/transcript-binding.mjs +336 -0
- package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +39 -2
- package/src/runtime/channels/lib/worker-bootstrap.mjs +97 -0
- package/src/runtime/channels/lib/worker-ipc.mjs +201 -0
- package/src/runtime/channels/lib/worker-main.mjs +777 -0
- package/src/runtime/memory/index.mjs +163 -1725
- package/src/runtime/memory/lib/embedding-provider.mjs +4 -2
- package/src/runtime/memory/lib/embedding-worker.mjs +47 -6
- package/src/runtime/memory/lib/http-router.mjs +811 -0
- package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +6 -0
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +46 -9
- package/src/runtime/memory/lib/memory-cycle2.mjs +87 -11
- package/src/runtime/memory/lib/memory-embed.mjs +28 -7
- package/src/runtime/memory/lib/memory-recall-store.mjs +4 -4
- package/src/runtime/memory/lib/memory.mjs +39 -0
- package/src/runtime/memory/lib/query-handlers.mjs +2 -2
- package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
- package/src/runtime/shared/atomic-file.mjs +138 -80
- package/src/runtime/shared/child-guardian.mjs +61 -3
- package/src/session-runtime/boot-profile.mjs +36 -0
- package/src/session-runtime/channel-config-api.mjs +70 -0
- package/src/session-runtime/context-status.mjs +181 -0
- package/src/session-runtime/env.mjs +17 -0
- package/src/session-runtime/lifecycle-api.mjs +242 -0
- package/src/session-runtime/model-route-api.mjs +198 -0
- package/src/session-runtime/provider-auth-api.mjs +135 -0
- package/src/session-runtime/resource-api.mjs +282 -0
- package/src/session-runtime/runtime-core.mjs +2104 -0
- package/src/session-runtime/session-turn-api.mjs +274 -0
- package/src/session-runtime/tool-catalog.mjs +18 -264
- package/src/session-runtime/tool-defs.mjs +2 -2
- package/src/session-runtime/workflow-agents-api.mjs +238 -0
- package/src/standalone/agent-tool.mjs +2 -2
- package/src/standalone/channel-worker.mjs +67 -7
- package/src/standalone/folder-dialog.mjs +4 -1
- package/src/standalone/memory-runtime-proxy.mjs +154 -17
- package/src/standalone/seeds.mjs +28 -1
- package/src/tui/App.jsx +40 -28
- package/src/tui/app/core-memory-picker.mjs +1 -1
- package/src/tui/app/doctor.mjs +175 -0
- package/src/tui/app/slash-commands.mjs +1 -0
- package/src/tui/app/slash-dispatch.mjs +9 -0
- package/src/tui/app/use-mouse-input.mjs +6 -0
- package/src/tui/app/use-transcript-scroll.mjs +77 -3
- package/src/tui/dist/index.mjs +2851 -2162
- package/src/tui/engine/context-state.mjs +145 -0
- package/src/tui/engine/prompt-history.mjs +27 -0
- package/src/tui/engine/session-api-ext.mjs +478 -0
- package/src/tui/engine/session-api.mjs +564 -0
- package/src/tui/engine/session-flow.mjs +485 -0
- package/src/tui/engine/turn.mjs +1078 -0
- package/src/tui/engine.mjs +68 -2620
- package/src/tui/index.jsx +7 -0
- package/vendor/ink/build/ink.js +16 -1
- package/vendor/ink/build/output.js +30 -4
- package/vendor/ink/build/render.js +5 -0
- package/src/workflows/sequential/WORKFLOW.md +0 -51
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Compaction-append collision smoke for the session-ingest RUNTIME (not just
|
|
3
|
+
// the pure helpers). Reproduces the silent-drop bug: after compaction removes an
|
|
4
|
+
// earlier identical untimestamped message from the in-memory array, a newly
|
|
5
|
+
// APPENDED identical message used to reproduce an already-persisted ordinal →
|
|
6
|
+
// same source_ref → INSERT ... ON CONFLICT DO NOTHING silently dropped it.
|
|
7
|
+
//
|
|
8
|
+
// Proves, against an in-memory fake DB that enforces the source_ref uniqueness
|
|
9
|
+
// the real ON CONFLICT relies on:
|
|
10
|
+
// (1) post-compaction appended untimestamped identical msg → NEW row;
|
|
11
|
+
// (2) full identical re-ingest in a FRESH runtime (rows already in DB) → 0 dups;
|
|
12
|
+
// (3) subset re-ingest reproduces the same source_refs as a full re-ingest.
|
|
13
|
+
import { createSessionIngestRuntime } from '../src/runtime/memory/lib/session-ingest-runtime.mjs'
|
|
14
|
+
|
|
15
|
+
function assert(condition, message) {
|
|
16
|
+
if (!condition) throw new Error(message)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Minimal fake DB. Only the two statements the ingest loop issues are modelled:
|
|
20
|
+
// • SELECT COALESCE(MAX(source_turn) ...) → per-session max
|
|
21
|
+
// • INSERT INTO entries(...) ON CONFLICT DO NOTHING → dedupe on source_ref
|
|
22
|
+
// Any other query (the post-ingest raw-embedding flush's schema calls) returns
|
|
23
|
+
// an empty result; flushRawEmbeddings then hits `db._pool` (undefined) and its
|
|
24
|
+
// error is swallowed by the runtime, so the smoke never blocks on embeddings.
|
|
25
|
+
function createFakeDb() {
|
|
26
|
+
const rows = [] // { ts, role, content, source_ref, session_id, source_turn, project_id }
|
|
27
|
+
const bySourceRef = new Set()
|
|
28
|
+
let nextId = 1
|
|
29
|
+
return {
|
|
30
|
+
rows,
|
|
31
|
+
async query(sql, params = []) {
|
|
32
|
+
if (/MAX\(source_turn\)/.test(sql)) {
|
|
33
|
+
const sessionId = params[0]
|
|
34
|
+
let max = 0
|
|
35
|
+
for (const r of rows) if (r.session_id === sessionId && r.source_turn > max) max = r.source_turn
|
|
36
|
+
return { rows: [{ max_turn: max }] }
|
|
37
|
+
}
|
|
38
|
+
if (/INSERT INTO entries/.test(sql)) {
|
|
39
|
+
const [ts, role, content, source_ref, session_id, source_turn, project_id] = params
|
|
40
|
+
if (bySourceRef.has(source_ref)) return { rowCount: 0 } // ON CONFLICT DO NOTHING
|
|
41
|
+
bySourceRef.add(source_ref)
|
|
42
|
+
rows.push({ id: nextId++, ts, role, content, source_ref, session_id, source_turn, project_id })
|
|
43
|
+
return { rowCount: 1 }
|
|
44
|
+
}
|
|
45
|
+
return { rows: [] }
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Durable high-water store fake (mirrors the DB `meta` kv the facade wires). A
|
|
51
|
+
// SHARED store passed to two runtime instances simulates the state file
|
|
52
|
+
// surviving a process restart; an absent store simulates a deleted state file.
|
|
53
|
+
function makeRuntime(db, durable = new Map()) {
|
|
54
|
+
return createSessionIngestRuntime({
|
|
55
|
+
getDb: () => db,
|
|
56
|
+
log: () => {},
|
|
57
|
+
parseTsToMs: (v) => (typeof v === 'number' ? v : Number(v) || 0),
|
|
58
|
+
loadOrdinalHighWater: async (sessionId) => durable.get(sessionId) ?? null,
|
|
59
|
+
saveOrdinalHighWater: (sessionId, obj) => { durable.set(sessionId, obj); },
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const SESSION = 'compaction-smoke-sess'
|
|
64
|
+
// Untimestamped identical turns (no ts/timestamp → ordinal is what disambiguates).
|
|
65
|
+
const mk = (role, content) => ({ role, content })
|
|
66
|
+
|
|
67
|
+
// ── Scenario: live process ingests, compaction drops an earlier copy, append ──
|
|
68
|
+
const db = createFakeDb()
|
|
69
|
+
const rt = makeRuntime(db)
|
|
70
|
+
|
|
71
|
+
// Distinct object identities so compaction survival is by-reference (mirrors the
|
|
72
|
+
// runtime's immutable-transcript assumption).
|
|
73
|
+
const other1 = mk('assistant', 'sure, working on it')
|
|
74
|
+
const dupA = mk('user', 'run it again') // 1st identical untimestamped turn
|
|
75
|
+
const other2 = mk('assistant', 'done')
|
|
76
|
+
const dupB = mk('user', 'run it again') // 2nd identical untimestamped turn (distinct object)
|
|
77
|
+
|
|
78
|
+
// Call 1 (COLD): full array with two identical untimestamped user turns.
|
|
79
|
+
const arr1 = [other1, dupA, other2, dupB]
|
|
80
|
+
let r1 = await rt.ingestSessionMessages({ sessionId: SESSION, messages: arr1, limit: 5000 })
|
|
81
|
+
const userRows1 = db.rows.filter(r => r.role === 'user' && r.content === 'run it again')
|
|
82
|
+
assert(userRows1.length === 2, `cold ingest must persist BOTH identical untimestamped turns (got ${userRows1.length})`)
|
|
83
|
+
const refDupA = userRows1[0].source_ref
|
|
84
|
+
const refDupB = userRows1[1].source_ref
|
|
85
|
+
assert(refDupA !== refDupB, 'the two identical untimestamped turns must persist under distinct source_refs')
|
|
86
|
+
|
|
87
|
+
// Compaction: drop the FIRST identical copy (dupA) and some other rows; KEEP the
|
|
88
|
+
// second identical copy object (dupB) — this is what makes the array position of
|
|
89
|
+
// the surviving/new identical turn shift down onto an already-persisted ordinal.
|
|
90
|
+
// Then APPEND a genuinely new identical turn (distinct object).
|
|
91
|
+
const dupC = mk('user', 'run it again') // NEW appended identical turn
|
|
92
|
+
const arr2 = [dupB, mk('assistant', 'ok next'), dupC]
|
|
93
|
+
|
|
94
|
+
// Call 2 (WARM, same runtime/process): the appended dupC must persist as a NEW row.
|
|
95
|
+
let r2 = await rt.ingestSessionMessages({ sessionId: SESSION, messages: arr2, limit: 5000 })
|
|
96
|
+
const userRows2 = db.rows.filter(r => r.role === 'user' && r.content === 'run it again')
|
|
97
|
+
assert(
|
|
98
|
+
userRows2.length === 3,
|
|
99
|
+
`INVARIANT 1: post-compaction appended identical turn must persist as a NEW row (expected 3 total, got ${userRows2.length}) — this is the silent-drop bug`,
|
|
100
|
+
)
|
|
101
|
+
const refDupC = userRows2.find(r => r.source_ref !== refDupA && r.source_ref !== refDupB)?.source_ref
|
|
102
|
+
assert(refDupC, 'appended identical turn must have a fresh source_ref above the persisted high-water')
|
|
103
|
+
// dupB survived: it must have deduped (reused its recorded ordinal), NOT minted a new row.
|
|
104
|
+
assert(db.rows.filter(r => r.source_ref === refDupB).length === 1, 'survived identical turn must dedupe, not duplicate')
|
|
105
|
+
|
|
106
|
+
// ── INVARIANT 2: fresh runtime (new process), full identical re-ingest ────────
|
|
107
|
+
// Rows already in DB; re-ingesting the CURRENT array from start=0 must create
|
|
108
|
+
// ZERO new rows.
|
|
109
|
+
const rowCountBefore = db.rows.length
|
|
110
|
+
const rtFresh = makeRuntime(db) // cold state for this "process"
|
|
111
|
+
await rtFresh.ingestSessionMessages({ sessionId: SESSION, messages: arr2, limit: 5000 })
|
|
112
|
+
assert(
|
|
113
|
+
db.rows.length === rowCountBefore,
|
|
114
|
+
`INVARIANT 2: full identical re-ingest in a fresh process must create 0 duplicates (before=${rowCountBefore} after=${db.rows.length})`,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
// ── INVARIANT 3: subset re-ingest reproduces the same source_refs as full ─────
|
|
118
|
+
// Fresh DB + fresh runtime; ingest the full array, capture refs; then a NEW
|
|
119
|
+
// fresh DB/runtime ingesting the same array via a small window (subset, seeded
|
|
120
|
+
// from the prefix) must reproduce the identical source_refs.
|
|
121
|
+
function refsFor(messages, opts) {
|
|
122
|
+
const d = createFakeDb()
|
|
123
|
+
const r = makeRuntime(d)
|
|
124
|
+
return { d, r }
|
|
125
|
+
}
|
|
126
|
+
{
|
|
127
|
+
const fullMsgs = [mk('user', 'x'), mk('assistant', 'y'), mk('user', 'x'), mk('user', 'x'), mk('assistant', 'y')]
|
|
128
|
+
const { d: dFull, r: rFull } = refsFor()
|
|
129
|
+
await rFull.ingestSessionMessages({ sessionId: 'subset-sess', messages: fullMsgs, limit: 5000 })
|
|
130
|
+
const fullRefs = dFull.rows.filter(r => r.session_id === 'subset-sess').map(r => r.source_ref).sort()
|
|
131
|
+
|
|
132
|
+
// Subset: fresh process, window = last 2 messages, prefix seeded internally.
|
|
133
|
+
const { d: dSub, r: rSub } = refsFor()
|
|
134
|
+
await rSub.ingestSessionMessages({ sessionId: 'subset-sess', messages: fullMsgs, limit: 2 })
|
|
135
|
+
const subRefs = dSub.rows.filter(r => r.session_id === 'subset-sess').map(r => r.source_ref)
|
|
136
|
+
for (const ref of subRefs) {
|
|
137
|
+
assert(fullRefs.includes(ref), `INVARIANT 3: subset re-ingest source_ref ${ref} must match a full re-ingest ref`)
|
|
138
|
+
}
|
|
139
|
+
// The subset window's last two messages are `x`(3rd occurrence) and `y`(2nd);
|
|
140
|
+
// their refs must equal the full re-ingest's 3rd `x` and 2nd `y` refs.
|
|
141
|
+
assert(subRefs.length === 2, `subset window should ingest exactly its 2 messages (got ${subRefs.length})`)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── INVARIANT 1 across RESTART: fresh runtime (new process), same DB ──────────
|
|
145
|
+
// A restart = a NEW runtime instance (empty in-memory ordinal state AND empty
|
|
146
|
+
// WeakMap) ingesting a reloaded transcript against the SAME DB that already
|
|
147
|
+
// holds prior rows. When the reloaded (compacted) array still RETAINS the
|
|
148
|
+
// identical copies, positional ordinals reproduce each survivor's OWN live row,
|
|
149
|
+
// and a freshly appended identical turn takes a free ordinal and persists NEW.
|
|
150
|
+
{
|
|
151
|
+
const rdb = createFakeDb()
|
|
152
|
+
const S = 'restart-sess'
|
|
153
|
+
const rtA = makeRuntime(rdb)
|
|
154
|
+
await rtA.ingestSessionMessages({ sessionId: S, messages: [mk('user', 'ping'), mk('assistant', 'pong'), mk('user', 'ping')], limit: 5000 })
|
|
155
|
+
const pingBefore = rdb.rows.filter(r => r.content === 'ping')
|
|
156
|
+
assert(pingBefore.length === 2, `pre-restart should persist 2 identical pings (got ${pingBefore.length})`)
|
|
157
|
+
const beforeIds = new Set(pingBefore.map(r => r.id))
|
|
158
|
+
|
|
159
|
+
// Restart: brand-new runtime (fresh WeakMap + ordinal state), same DB. The
|
|
160
|
+
// reloaded compacted array RETAINS both ping copies (compaction dropped only
|
|
161
|
+
// the non-identical `pong`); append a genuinely new identical ping. The
|
|
162
|
+
// reloaded objects are DISTINCT references from the pre-restart ones.
|
|
163
|
+
const rtB = makeRuntime(rdb)
|
|
164
|
+
await rtB.ingestSessionMessages({ sessionId: S, messages: [mk('user', 'ping'), mk('user', 'ping'), mk('user', 'ping')], limit: 5000 })
|
|
165
|
+
const pingAfter = rdb.rows.filter(r => r.content === 'ping')
|
|
166
|
+
assert(pingAfter.length === 3, `RESTART: appended identical turn must persist as a NEW row (expected 3, got ${pingAfter.length})`)
|
|
167
|
+
// Row-identity (not ref-membership) check: the survivor re-ingest must DEDUPE
|
|
168
|
+
// onto the 2 pre-existing live rows (same ids, no re-insert) and add EXACTLY
|
|
169
|
+
// one NEW row (the append) — proving survivors mapped to live rows, not dropped.
|
|
170
|
+
const survivorRows = pingAfter.filter(r => beforeIds.has(r.id))
|
|
171
|
+
const newRows = pingAfter.filter(r => !beforeIds.has(r.id))
|
|
172
|
+
assert(survivorRows.length === 2, `RESTART: survivors must reuse the 2 live rows (got ${survivorRows.length})`)
|
|
173
|
+
assert(newRows.length === 1, `RESTART: exactly one NEW appended row expected (got ${newRows.length})`)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── INVARIANT 1 across RESTART, LATER-WARM append (durable high-water) ────────
|
|
177
|
+
// The path the reviewer disproved: after restart the cold replay of a COMPACTED
|
|
178
|
+
// array seeds occNext = survivor-count T, but the DB holds K>T copies. A NEW
|
|
179
|
+
// untimestamped identical turn arriving in a LATER (warm) call is distinguishable
|
|
180
|
+
// (it is genuinely new, not one of the cold-replay survivors) and MUST persist —
|
|
181
|
+
// only the durable per-identity high-water K makes that possible.
|
|
182
|
+
{
|
|
183
|
+
const durable = new Map() // survives the "restart" (shared across runtimes)
|
|
184
|
+
const rdb = createFakeDb() // DB rows survive the restart too
|
|
185
|
+
const S = 'restart-warm-sess'
|
|
186
|
+
|
|
187
|
+
// Pre-restart process: build K=3 persisted copies of an identical untimestamped
|
|
188
|
+
// turn (cold [X,other,X] → 2 copies; then in-process compaction+append → 3rd).
|
|
189
|
+
const rtA = makeRuntime(rdb, durable)
|
|
190
|
+
const xa1 = mk('user', 'again'); const xa2 = mk('user', 'again')
|
|
191
|
+
await rtA.ingestSessionMessages({ sessionId: S, messages: [xa1, mk('assistant', 'ok'), xa2], limit: 5000 })
|
|
192
|
+
const xa3 = mk('user', 'again') // in-process appended (compaction kept xa2)
|
|
193
|
+
await rtA.ingestSessionMessages({ sessionId: S, messages: [xa2, mk('assistant', 'ok2'), xa3], limit: 5000 })
|
|
194
|
+
assert(rdb.rows.filter(r => r.content === 'again').length === 3, 'pre-restart should hold K=3 identical copies')
|
|
195
|
+
assert(durable.has(S), 'durable high-water must have been persisted for the duplicate identity')
|
|
196
|
+
|
|
197
|
+
// Restart: fresh runtime (empty WeakMap + ordinal state), SAME db + durable.
|
|
198
|
+
// Reloaded COMPACTED array retains only ONE copy (T=1).
|
|
199
|
+
const rtB = makeRuntime(rdb, durable)
|
|
200
|
+
const rx = mk('user', 'again') // reloaded survivor (distinct object)
|
|
201
|
+
await rtB.ingestSessionMessages({ sessionId: S, messages: [rx], limit: 5000 }) // COLD replay (T=1)
|
|
202
|
+
assert(rdb.rows.filter(r => r.content === 'again').length === 3, 'cold replay of survivor must not add rows (dedupe)')
|
|
203
|
+
|
|
204
|
+
// LATER warm call on rtB appends a genuinely new identical turn.
|
|
205
|
+
const rxNew = mk('user', 'again')
|
|
206
|
+
const rowsBeforeAppend = rdb.rows.length
|
|
207
|
+
await rtB.ingestSessionMessages({ sessionId: S, messages: [rx, rxNew], limit: 5000 })
|
|
208
|
+
const againAfter = rdb.rows.filter(r => r.content === 'again')
|
|
209
|
+
assert(
|
|
210
|
+
againAfter.length === 4,
|
|
211
|
+
`LATER-WARM RESTART: appended identical turn must persist as a NEW row via durable K (expected 4, got ${againAfter.length})`,
|
|
212
|
+
)
|
|
213
|
+
assert(rdb.rows.length === rowsBeforeAppend + 1, 'exactly one new row from the later-warm append')
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── Zero-dup guarantee when a compacted reload DROPPED an identical copy ──────
|
|
217
|
+
// If the reloaded array is missing an earlier identical untimestamped copy AND
|
|
218
|
+
// the WeakMap survivor signal is gone (restart), the appended identical turn is
|
|
219
|
+
// information-theoretically indistinguishable from the survivors (see the
|
|
220
|
+
// DURABILITY note in session-ingest-runtime.mjs). Contract in that corner: NEVER
|
|
221
|
+
// mint a duplicate survivor row (ON CONFLICT dedupes). The append may collapse;
|
|
222
|
+
// it must not duplicate.
|
|
223
|
+
{
|
|
224
|
+
const rdb = createFakeDb()
|
|
225
|
+
const S = 'restart-drop-sess'
|
|
226
|
+
const rtA = makeRuntime(rdb)
|
|
227
|
+
await rtA.ingestSessionMessages({ sessionId: S, messages: [mk('user', 'echo'), mk('user', 'echo')], limit: 5000 })
|
|
228
|
+
const before = rdb.rows.filter(r => r.content === 'echo').length
|
|
229
|
+
assert(before === 2, `pre-restart should persist 2 identical echoes (got ${before})`)
|
|
230
|
+
const rowsBefore = rdb.rows.length
|
|
231
|
+
|
|
232
|
+
// Restart with a compacted reload that DROPPED one echo, then appended one
|
|
233
|
+
// (net 2 copies again). Must not mint a duplicate; the append collapses.
|
|
234
|
+
const rtB = makeRuntime(rdb)
|
|
235
|
+
await rtB.ingestSessionMessages({ sessionId: S, messages: [mk('user', 'echo'), mk('user', 'echo')], limit: 5000 })
|
|
236
|
+
const after = rdb.rows.filter(r => r.content === 'echo').length
|
|
237
|
+
assert(after === before, `RESTART+drop: must not mint a duplicate survivor row (before=${before} after=${after})`)
|
|
238
|
+
assert(rdb.rows.length === rowsBefore, 'RESTART+drop: no duplicate rows created')
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
process.stdout.write('session ingest compaction-append smoke passed \u2713\n')
|
package/scripts/tool-smoke.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { dirname, join, resolve } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { __applyStandaloneToolDefaultsForTest, __renderToolSearchForTest, compactToolSearchDescription, defaultDeferredToolNames, SKILL_TOOL, TOOL_SEARCH_TOOL } from '../src/mixdog-session-runtime.mjs';
|
|
7
|
+
import { applyInitialDeferredToolManifestToBp1, buildDeferredToolManifest } from '../src/runtime/agent/orchestrator/context/collect.mjs';
|
|
7
8
|
import { buildExplorerPrompt, EXPLORE_TOOL, MAX_FANOUT_QUERIES, normalizeExploreQueries } from '../src/standalone/explore-tool.mjs';
|
|
8
9
|
import { AGENT_TOOL, createStandaloneAgent } from '../src/standalone/agent-tool.mjs';
|
|
9
10
|
import { parseHeadlessRoleCommand } from '../src/app.mjs';
|
|
@@ -644,6 +645,72 @@ if (!/^Error[\s:[]/.test(String(stalePatchOut)) || !/apply_patch/i.test(String(s
|
|
|
644
645
|
throw new Error(`apply_patch stale context must return an Error result, not throw or pass:\n${stalePatchOut}`);
|
|
645
646
|
}
|
|
646
647
|
|
|
648
|
+
// Malformed-but-unambiguous patch openings must be absorbed (dry-run, so no
|
|
649
|
+
// write). Each targets the same known-good smoke.mjs line the cases above use.
|
|
650
|
+
const smokeBody = `@@
|
|
651
|
+
-process.stdout.write('smoke passed ✓\\n');
|
|
652
|
+
+process.stdout.write('smoke passed ok\\n');
|
|
653
|
+
*** End Patch
|
|
654
|
+
`;
|
|
655
|
+
const absorbCases = [
|
|
656
|
+
['leading blank lines', `\n\n*** Begin Patch\n*** Update File: scripts/smoke.mjs\n${smokeBody}`],
|
|
657
|
+
['decorated begin header', `*** Begin Patch (V4A) ***\n*** Update File: scripts/smoke.mjs\n${smokeBody}`],
|
|
658
|
+
['bare file path opening', `*** Begin Patch\nscripts/smoke.mjs\n${smokeBody}`],
|
|
659
|
+
['File: prefixed opening', `*** Begin Patch\nFile: scripts/smoke.mjs\n${smokeBody}`],
|
|
660
|
+
['unified body in envelope', `*** Begin Patch\n--- scripts/smoke.mjs\n+++ scripts/smoke.mjs\n${smokeBody}`],
|
|
661
|
+
];
|
|
662
|
+
for (const [label, patch] of absorbCases) {
|
|
663
|
+
const out = await executePatchTool('apply_patch', { base_path: root, dry_run: true, fuzzy: false, patch }, root);
|
|
664
|
+
assertOk(`apply_patch absorbs ${label}`, out, /checked|validated|dry|OK/i);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const ambiguousPatchOut = await executePatchTool('apply_patch', {
|
|
668
|
+
base_path: root,
|
|
669
|
+
dry_run: true,
|
|
670
|
+
fuzzy: false,
|
|
671
|
+
patch: `*** Begin Patch\nthis line is not a valid opening\n${smokeBody}`,
|
|
672
|
+
}, root);
|
|
673
|
+
if (!/^Error[\s:[]/.test(String(ambiguousPatchOut)) || !/before a file header|V4A/i.test(String(ambiguousPatchOut))) {
|
|
674
|
+
throw new Error(`apply_patch must keep erroring on genuinely ambiguous openings:\n${ambiguousPatchOut}`);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Unified-looking first body line but real V4A file sections appear later: the
|
|
678
|
+
// envelope must NOT be stripped to unified — it stays ambiguous and errors.
|
|
679
|
+
const mixedPatchOut = await executePatchTool('apply_patch', {
|
|
680
|
+
base_path: root,
|
|
681
|
+
dry_run: true,
|
|
682
|
+
fuzzy: false,
|
|
683
|
+
patch: `*** Begin Patch\n--- scripts/smoke.mjs\n*** Update File: scripts/smoke.mjs\n${smokeBody}`,
|
|
684
|
+
}, root);
|
|
685
|
+
if (!/^Error[\s:[]/.test(String(mixedPatchOut)) || !/before a file header|V4A/i.test(String(mixedPatchOut))) {
|
|
686
|
+
throw new Error(`apply_patch must keep erroring on mixed unified/V4A openings:\n${mixedPatchOut}`);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Compacted-history placeholder guard: EVERY [mixdog compacted …] variant must
|
|
690
|
+
// be rejected with the corrective message BEFORE format dispatch/salvage, both
|
|
691
|
+
// as the first line and standalone mid-body (after a *** Begin Patch header).
|
|
692
|
+
const compactedGuardCases = [
|
|
693
|
+
['legacy key: prefix', '[mixdog compacted patch: 4096 chars, sha256:deadbeefdeadbeef]\n*** Begin Patch\n*** Update File: a.txt\n+x\n*** End Patch\n'],
|
|
694
|
+
['variant key form', '[mixdog compacted patch v4a, sha256:deadbeefdeadbeef]\n*** Begin Patch\n*** Update File: a.txt\n+x\n*** End Patch\n'],
|
|
695
|
+
['no chars/sha detail', '[mixdog compacted old_string]\n'],
|
|
696
|
+
['mid-body standalone', '*** Begin Patch\n*** Update File: a.txt\n[mixdog compacted patch v4a, sha256:deadbeefdeadbeef]\n*** End Patch\n'],
|
|
697
|
+
];
|
|
698
|
+
for (const [label, patch] of compactedGuardCases) {
|
|
699
|
+
const out = await executePatchTool('apply_patch', { base_path: root, dry_run: true, fuzzy: false, patch }, root);
|
|
700
|
+
if (!/^Error[\s:[]/.test(String(out)) || !/compacted-history placeholder/i.test(String(out))) {
|
|
701
|
+
throw new Error(`apply_patch must reject compacted placeholder (${label}):\n${out}`);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
// A legit unified edit whose body content mentions the literal text on a diff
|
|
705
|
+
// line (+/-/space) must still parse — the guard only trips on non-diff lines.
|
|
706
|
+
const compactedFalsePositiveOut = await executePatchTool('apply_patch', {
|
|
707
|
+
base_path: root,
|
|
708
|
+
dry_run: true,
|
|
709
|
+
fuzzy: false,
|
|
710
|
+
patch: `*** Begin Patch\n*** Add File: compacted-note.txt\n+[mixdog compacted patch: 10 chars, sha256:abc]\n*** End Patch\n`,
|
|
711
|
+
}, root);
|
|
712
|
+
assertOk('apply_patch keeps diff-line placeholder text', compactedFalsePositiveOut, /checked|validated|dry|OK/i);
|
|
713
|
+
|
|
647
714
|
const shellOutPromise = executeBuiltinTool('shell', {
|
|
648
715
|
command: 'node --version',
|
|
649
716
|
cwd: root,
|
|
@@ -1485,7 +1552,10 @@ setInternalToolsProvider({
|
|
|
1485
1552
|
const resumed = await resumeSession(session.id, 'full');
|
|
1486
1553
|
const resumedTools = (resumed?.tools || []).map((tool) => tool?.name).filter(Boolean);
|
|
1487
1554
|
const expected = expectedForHiddenAgent(permission, schemaAllowedTools);
|
|
1488
|
-
|
|
1555
|
+
// Order-insensitive: the session tool surface follows catalog order, while
|
|
1556
|
+
// schemaAllowedTools declares an allow-set; only set equality is contractual.
|
|
1557
|
+
const asSet = (list) => JSON.stringify(list.slice().sort());
|
|
1558
|
+
if (expected && (asSet(tools) !== asSet(expected) || asSet(resumedTools) !== asSet(expected))) {
|
|
1489
1559
|
throw new Error(`hidden agent ${agent} schema mismatch: expected=${expected.join(', ')} tools=${tools.join(', ')} resumed=${resumedTools.join(', ')}`);
|
|
1490
1560
|
}
|
|
1491
1561
|
const leaked = tools.filter((name) => hiddenBadTools.has(name) && !(expected || []).includes(name));
|
|
@@ -1644,15 +1714,25 @@ if (/line\/context/i.test(JSON.stringify(readTool?.inputSchema || {}))) {
|
|
|
1644
1714
|
// session's provider/model in place, or a mid-conversation model/provider
|
|
1645
1715
|
// switch silently forces a full prompt-cache rewrite (seen as a
|
|
1646
1716
|
// promptΔ spike + cache_ratio=0% turn in session-bench).
|
|
1647
|
-
|
|
1717
|
+
// God-file splits move implementation into module dirs; scan facade + all
|
|
1718
|
+
// split modules so these source-text guards survive refactors.
|
|
1719
|
+
const readMjsSources = (rel) => {
|
|
1720
|
+
const abs = resolve(root, rel);
|
|
1721
|
+
if (rel.endsWith('.mjs')) return readFileSync(abs, 'utf8');
|
|
1722
|
+
return readdirSync(abs, { recursive: true })
|
|
1723
|
+
.filter((f) => String(f).endsWith('.mjs'))
|
|
1724
|
+
.map((f) => readFileSync(resolve(abs, String(f)), 'utf8'))
|
|
1725
|
+
.join('\n');
|
|
1726
|
+
};
|
|
1727
|
+
const runtimeSrc = [readMjsSources('src/mixdog-session-runtime.mjs'), readMjsSources('src/session-runtime')].join('\n');
|
|
1648
1728
|
const setRouteBlock = runtimeSrc.match(/async setRoute\(next, options = \{\}\) \{[\s\S]*?\n \},\n/)?.[0] || '';
|
|
1649
1729
|
if (!/applyToCurrentSession = options\?\.applyToCurrentSession === true/.test(setRouteBlock)) {
|
|
1650
1730
|
throw new Error('setRoute must default applyToCurrentSession to false (model changes apply to the next session only)');
|
|
1651
1731
|
}
|
|
1652
|
-
if (!/if \(!applyToCurrentSession\)/.test(setRouteBlock) || !/return route;/.test(setRouteBlock)) {
|
|
1732
|
+
if (!/if \(!applyToCurrentSession\)/.test(setRouteBlock) || !/return (?:route|getRoute\(\));/.test(setRouteBlock)) {
|
|
1653
1733
|
throw new Error('setRoute must early-return before touching the live session when applyToCurrentSession is false');
|
|
1654
1734
|
}
|
|
1655
|
-
const engineSrc =
|
|
1735
|
+
const engineSrc = [readMjsSources('src/tui/engine.mjs'), readMjsSources('src/tui/engine')].join('\n');
|
|
1656
1736
|
if (/setRoute\(\{ model: m \}, \{ applyToCurrentSession: true \}\)/.test(engineSrc)) {
|
|
1657
1737
|
throw new Error('TUI setModel must not force applyToCurrentSession:true (model changes must apply to the next session only)');
|
|
1658
1738
|
}
|
|
@@ -1774,16 +1854,23 @@ const toolSearchSession = {
|
|
|
1774
1854
|
deferredToolCatalog: smokeCatalog.slice(),
|
|
1775
1855
|
deferredSelectedTools: [...fullDefaults],
|
|
1776
1856
|
};
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
}
|
|
1857
|
+
// A plain query is a case-insensitive substring filter over name+description.
|
|
1858
|
+
// It lists matches only — it never auto-loads or ranks.
|
|
1859
|
+
const listQueryResult = JSON.parse(__renderToolSearchForTest({ query: 'shell' }, toolSearchSession, 'full'));
|
|
1860
|
+
if (listQueryResult.selected) {
|
|
1861
|
+
throw new Error(`tool_search plain query must not auto-load: ${JSON.stringify(listQueryResult.selected)}`);
|
|
1862
|
+
}
|
|
1863
|
+
if (!listQueryResult.matches.some((row) => row.name === 'shell')) {
|
|
1864
|
+
throw new Error(`tool_search plain query should list substring matches: ${JSON.stringify(listQueryResult.matches)}`);
|
|
1782
1865
|
}
|
|
1783
|
-
if (
|
|
1784
|
-
throw new Error(`tool_search query
|
|
1866
|
+
if (listQueryResult.activeTools.includes('shell') || (Array.isArray(listQueryResult.discoveredTools) && listQueryResult.discoveredTools.includes('shell'))) {
|
|
1867
|
+
throw new Error(`tool_search plain query must not activate/discover tools: ${JSON.stringify(listQueryResult)}`);
|
|
1785
1868
|
}
|
|
1869
|
+
// query "select:a,b" is the explicit query-side loader (aliases expand).
|
|
1786
1870
|
const bulkSelectResult = JSON.parse(__renderToolSearchForTest({ query: 'select:shell,recall' }, toolSearchSession, 'full'));
|
|
1871
|
+
if (bulkSelectResult.selected?.mode !== 'select') {
|
|
1872
|
+
throw new Error(`tool_search query-select must report select mode: ${JSON.stringify(bulkSelectResult.selected)}`);
|
|
1873
|
+
}
|
|
1787
1874
|
for (const name of ['shell', 'task', 'recall']) {
|
|
1788
1875
|
if (!bulkSelectResult.activeTools.includes(name)) {
|
|
1789
1876
|
throw new Error(`tool_search bulk select missing ${name}: ${JSON.stringify(bulkSelectResult)}`);
|
|
@@ -1858,7 +1945,8 @@ if (nativeGrokPatchTool?.type !== 'function' || nativeGrokPatchTool?.format || n
|
|
|
1858
1945
|
if (nativeGrokPatchTool.parameters?.properties?.patch?.type !== 'string') {
|
|
1859
1946
|
throw new Error(`Grok native tool_search apply_patch must preserve patch JSON schema: ${JSON.stringify(nativeGrokPatchTool)}`);
|
|
1860
1947
|
}
|
|
1861
|
-
|
|
1948
|
+
// Native query-select discovers (without mutating active schemas); aliases expand.
|
|
1949
|
+
const nativeSelectQuerySession = {
|
|
1862
1950
|
tools: smokeCatalog.filter((tool) => fullDefaults.has(tool?.name)),
|
|
1863
1951
|
deferredToolCatalog: smokeCatalog.slice(),
|
|
1864
1952
|
deferredSelectedTools: [...fullDefaults],
|
|
@@ -1866,16 +1954,20 @@ const nativeRunQuerySession = {
|
|
|
1866
1954
|
deferredProviderMode: 'native',
|
|
1867
1955
|
deferredNativeTools: true,
|
|
1868
1956
|
};
|
|
1869
|
-
const
|
|
1870
|
-
for (const name of ['
|
|
1871
|
-
if (!
|
|
1872
|
-
throw new Error(`native tool_search
|
|
1957
|
+
const nativeSelectQueryResult = JSON.parse(__renderToolSearchForTest({ query: 'select:search' }, nativeSelectQuerySession, 'full'));
|
|
1958
|
+
for (const name of ['search', 'web_fetch']) {
|
|
1959
|
+
if (!nativeSelectQueryResult.discoveredTools.includes(name)) {
|
|
1960
|
+
throw new Error(`native tool_search query-select should discover ${name}: ${JSON.stringify(nativeSelectQueryResult)}`);
|
|
1873
1961
|
}
|
|
1874
1962
|
}
|
|
1875
|
-
if (
|
|
1876
|
-
throw new Error(`native tool_search
|
|
1963
|
+
if (nativeSelectQueryResult.activeTools.includes('search') || nativeSelectQueryResult.activeTools.includes('web_fetch')) {
|
|
1964
|
+
throw new Error(`native tool_search must not mutate active schemas: ${JSON.stringify(nativeSelectQueryResult)}`);
|
|
1965
|
+
}
|
|
1966
|
+
if (!nativeSelectQueryResult.nativeToolSearch?.toolReferences?.includes('search')) {
|
|
1967
|
+
throw new Error(`native query-select must return nativeToolSearch payload: ${JSON.stringify(nativeSelectQueryResult.nativeToolSearch)}`);
|
|
1877
1968
|
}
|
|
1878
|
-
|
|
1969
|
+
// A plain query never auto-loads/discovers, even on native providers.
|
|
1970
|
+
const nativePlainQuerySession = {
|
|
1879
1971
|
tools: smokeCatalog.filter((tool) => fullDefaults.has(tool?.name)),
|
|
1880
1972
|
deferredToolCatalog: smokeCatalog.slice(),
|
|
1881
1973
|
deferredSelectedTools: [...fullDefaults],
|
|
@@ -1883,35 +1975,48 @@ const nativeWebQuerySession = {
|
|
|
1883
1975
|
deferredProviderMode: 'native',
|
|
1884
1976
|
deferredNativeTools: true,
|
|
1885
1977
|
};
|
|
1886
|
-
const
|
|
1887
|
-
|
|
1888
|
-
if (
|
|
1889
|
-
throw new Error(`native tool_search
|
|
1978
|
+
for (const q of ['run tests', 'web docs', 'memory previous', 'status']) {
|
|
1979
|
+
const r = JSON.parse(__renderToolSearchForTest({ query: q }, nativePlainQuerySession, 'full'));
|
|
1980
|
+
if (r.selected || r.discoveredTools.length) {
|
|
1981
|
+
throw new Error(`native tool_search plain query "${q}" must not auto-load/discover: ${JSON.stringify(r)}`);
|
|
1890
1982
|
}
|
|
1891
1983
|
}
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1984
|
+
// Skill-style deferred manifest: `- name: description` lines, `<`/`>` sanitized,
|
|
1985
|
+
// bare names allowed, header instructs direct calls, empty pool → ''.
|
|
1986
|
+
const manifestText = buildDeferredToolManifest([
|
|
1987
|
+
{ name: 'shell', description: 'Run commands.' },
|
|
1988
|
+
{ name: 'search', description: 'Web <search> now.' },
|
|
1989
|
+
'recall',
|
|
1990
|
+
]);
|
|
1991
|
+
if (!/<available-deferred-tools>/.test(manifestText) || !/- shell: Run commands\./.test(manifestText)) {
|
|
1992
|
+
throw new Error(`deferred manifest must render "- name: description" lines: ${manifestText}`);
|
|
1993
|
+
}
|
|
1994
|
+
if (!/call any tool listed below directly/i.test(manifestText)) {
|
|
1995
|
+
throw new Error(`deferred manifest must tell the model it can call listed tools directly: ${manifestText}`);
|
|
1996
|
+
}
|
|
1997
|
+
if (!/^- recall$/m.test(manifestText)) {
|
|
1998
|
+
throw new Error(`deferred manifest must allow bare names without descriptions: ${manifestText}`);
|
|
1999
|
+
}
|
|
2000
|
+
if (/[<>]/.test(manifestText.replace(/<\/?available-deferred-tools>/g, ''))) {
|
|
2001
|
+
throw new Error(`deferred manifest must sanitize angle brackets in descriptions: ${manifestText}`);
|
|
2002
|
+
}
|
|
2003
|
+
if (buildDeferredToolManifest([]) !== '') {
|
|
2004
|
+
throw new Error('empty deferred pool must yield an empty manifest');
|
|
2005
|
+
}
|
|
2006
|
+
const bp1ManifestSession = {
|
|
2007
|
+
messages: [{ role: 'system', content: 'BASE PROMPT' }],
|
|
2008
|
+
deferredToolCatalog: [
|
|
2009
|
+
{ name: 'shell', description: 'Run commands.' },
|
|
2010
|
+
{ name: 'recall', description: 'Recall prior work.' },
|
|
2011
|
+
],
|
|
1899
2012
|
};
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
2013
|
+
applyInitialDeferredToolManifestToBp1(bp1ManifestSession, ['shell', 'recall']);
|
|
2014
|
+
const bp1ManifestText = bp1ManifestSession.messages[0].content;
|
|
2015
|
+
if (!/- shell: Run commands\./.test(bp1ManifestText) || !/- recall: Recall prior work\./.test(bp1ManifestText)) {
|
|
2016
|
+
throw new Error(`BP1 deferred manifest must carry catalog descriptions: ${bp1ManifestText}`);
|
|
1903
2017
|
}
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
deferredToolCatalog: smokeCatalog.slice(),
|
|
1907
|
-
deferredSelectedTools: [...fullDefaults],
|
|
1908
|
-
deferredDiscoveredTools: [],
|
|
1909
|
-
deferredProviderMode: 'native',
|
|
1910
|
-
deferredNativeTools: true,
|
|
1911
|
-
};
|
|
1912
|
-
const ambiguousStatusResult = JSON.parse(__renderToolSearchForTest({ query: 'status' }, ambiguousStatusSession, 'full'));
|
|
1913
|
-
if (ambiguousStatusResult.selected || ambiguousStatusResult.discoveredTools.length) {
|
|
1914
|
-
throw new Error(`tool_search ambiguous status query must not auto-load: ${JSON.stringify(ambiguousStatusResult)}`);
|
|
2018
|
+
if (bp1ManifestSession.deferredToolBp1Applied !== true) {
|
|
2019
|
+
throw new Error('BP1 deferred manifest injection must mark deferredToolBp1Applied');
|
|
1915
2020
|
}
|
|
1916
2021
|
const replyTool = CHANNEL_TOOL_DEFS.find((tool) => tool.name === 'reply');
|
|
1917
2022
|
if (!/configured channel/i.test(replyTool?.description || '') || !/local .*paths/i.test(replyTool?.inputSchema?.properties?.files?.description || '')) {
|