mixdog 0.9.22 → 0.9.23
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/README.md +1 -4
- package/package.json +1 -1
- package/scripts/build-runtime-windows.ps1 +242 -242
- package/scripts/channel-daemon-smoke.mjs +165 -0
- package/scripts/channel-daemon-stub.mjs +69 -0
- package/scripts/recall-usecase-cases.json +1 -1
- package/scripts/smoke-runtime-negative.ps1 +106 -106
- package/scripts/statusline-quota-hysteresis-test.mjs +37 -0
- package/scripts/tool-efficiency-diag.mjs +1 -1
- package/scripts/tool-smoke.mjs +30 -17
- package/src/rules/lead/02-channels.md +3 -3
- package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +187 -0
- package/src/runtime/agent/orchestrator/mcp/client.mjs +40 -3
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +5 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +3 -2
- package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +6 -3
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -4
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +7 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +30 -14
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +24 -11
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +6 -5
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +56 -1
- package/src/runtime/channels/lib/owned-runtime.mjs +126 -167
- package/src/runtime/channels/lib/owner-heartbeat.mjs +11 -60
- package/src/runtime/channels/lib/parent-bridge.mjs +16 -1
- package/src/runtime/channels/lib/runtime-paths.mjs +32 -113
- package/src/runtime/channels/lib/seat-lock.mjs +196 -0
- package/src/runtime/channels/lib/session-discovery.mjs +2 -1
- package/src/runtime/channels/lib/tool-dispatch.mjs +24 -8
- package/src/runtime/channels/lib/worker-main.mjs +42 -11
- package/src/runtime/memory/index.mjs +54 -7
- package/src/runtime/memory/lib/embedding-warmup.mjs +7 -0
- package/src/runtime/memory/lib/pg/process.mjs +85 -40
- package/src/runtime/memory/lib/pg/supervisor.mjs +82 -17
- package/src/runtime/shared/atomic-file.mjs +20 -4
- package/src/runtime/shared/tool-primitives.mjs +31 -1
- package/src/runtime/shared/tool-surface.mjs +23 -13
- package/src/session-runtime/config-lifecycle.mjs +48 -7
- package/src/session-runtime/lifecycle-api.mjs +9 -0
- package/src/session-runtime/mcp-glue.mjs +63 -1
- package/src/session-runtime/resource-api.mjs +62 -8
- package/src/session-runtime/runtime-core.mjs +32 -2
- package/src/session-runtime/session-text.mjs +41 -0
- package/src/session-runtime/session-turn-api.mjs +24 -0
- package/src/session-runtime/settings-api.mjs +8 -1
- package/src/session-runtime/tool-catalog.mjs +306 -38
- package/src/session-runtime/tool-defs.mjs +7 -7
- package/src/session-runtime/workflow.mjs +2 -1
- package/src/standalone/channel-daemon-client.mjs +224 -0
- package/src/standalone/channel-daemon-transport.mjs +351 -0
- package/src/standalone/channel-daemon.mjs +139 -0
- package/src/standalone/channel-worker.mjs +213 -4
- package/src/standalone/hook-bus.mjs +71 -3
- package/src/tui/App.jsx +105 -17
- package/src/tui/app/clipboard.mjs +39 -19
- package/src/tui/app/doctor.mjs +57 -0
- package/src/tui/app/extension-pickers.mjs +53 -9
- package/src/tui/app/maintenance-pickers.mjs +0 -5
- package/src/tui/app/slash-dispatch.mjs +4 -4
- package/src/tui/app/text-layout.mjs +11 -0
- package/src/tui/app/use-mouse-input.mjs +235 -51
- package/src/tui/app/use-prompt-handlers.mjs +49 -30
- package/src/tui/app/use-transcript-scroll.mjs +124 -27
- package/src/tui/app/use-transcript-window.mjs +55 -1
- package/src/tui/components/Message.jsx +1 -1
- package/src/tui/components/PromptInput.jsx +3 -1
- package/src/tui/components/QueuedCommands.jsx +21 -10
- package/src/tui/components/ToolExecution.jsx +2 -2
- package/src/tui/dist/index.mjs +635 -306
- package/src/tui/engine/session-api.mjs +17 -7
- package/src/tui/engine/session-flow.mjs +6 -0
- package/src/tui/engine.mjs +8 -0
- package/src/tui/index.jsx +62 -18
- package/src/tui/paste-attachments.mjs +26 -0
- package/src/ui/statusline.mjs +45 -5
- package/src/ui/tool-card.mjs +8 -1
- package/src/vendor/statusline/bin/statusline-route.mjs +23 -7
- package/src/workflows/bench/WORKFLOW.md +46 -0
- package/src/workflows/default/WORKFLOW.md +6 -0
- package/src/workflows/solo/WORKFLOW.md +5 -0
- package/vendor/ink/build/ink.js +23 -1
- package/vendor/ink/build/output.js +154 -71
- package/vendor/ink/build/render-node-to-output.js +44 -2
- package/vendor/ink/build/render.js +4 -0
- package/vendor/ink/build/renderer.js +4 -1
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Scripted two-client simulation for the machine-global channels daemon
|
|
2
|
+
// transport (no Discord token needed — the channels runtime is stubbed).
|
|
3
|
+
//
|
|
4
|
+
// Verifies the transport + attach client contracts that later slices depend on:
|
|
5
|
+
// 1. two TUIs attach to ONE daemon and each get an independent SSE stream;
|
|
6
|
+
// 2. tool calls round-trip per client and carry the caller's leadPid;
|
|
7
|
+
// 3. notifies are TARGETED (never broadcast) to the routing-pointer client;
|
|
8
|
+
// 4. a bind-intent call (rebind_current_transcript) moves the pointer;
|
|
9
|
+
// 5. once every client deregisters, the daemon self-shutdown fires.
|
|
10
|
+
//
|
|
11
|
+
// Run: node scripts/channel-daemon-smoke.mjs
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { createChannelDaemonTransport } from '../src/standalone/channel-daemon-transport.mjs';
|
|
17
|
+
import { attachToDaemon } from '../src/standalone/channel-daemon-client.mjs';
|
|
18
|
+
|
|
19
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const STUB_ENTRY = path.join(HERE, 'channel-daemon-stub.mjs');
|
|
21
|
+
|
|
22
|
+
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
23
|
+
const waitFor = async (fn, ms = 1500) => {
|
|
24
|
+
const end = Date.now() + ms;
|
|
25
|
+
while (Date.now() < end) { if (fn()) return true; await delay(20); }
|
|
26
|
+
return fn();
|
|
27
|
+
};
|
|
28
|
+
let failures = 0;
|
|
29
|
+
function check(label, cond) {
|
|
30
|
+
const ok = !!cond;
|
|
31
|
+
if (!ok) failures++;
|
|
32
|
+
console.log(`${ok ? 'PASS' : 'FAIL'} ${label}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function main() {
|
|
36
|
+
const tmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-daemon-smoke-'));
|
|
37
|
+
const discoveryPath = path.join(tmp, 'channel-daemon.json');
|
|
38
|
+
let clientsEmptyFired = false;
|
|
39
|
+
let sideEffects = 0; // counts non-idempotent 'reply' dispatches
|
|
40
|
+
|
|
41
|
+
// Stub channels runtime: echoes the call + the caller identity the transport
|
|
42
|
+
// resolved, so we can assert per-client leadPid threading.
|
|
43
|
+
const handleCall = async (name, args, ctx) => {
|
|
44
|
+
if (name === 'reply') { sideEffects++; await delay(40); } // simulate a side effect
|
|
45
|
+
return { ok: true, name, args, leadPid: ctx.leadPid };
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const transport = createChannelDaemonTransport({
|
|
49
|
+
handleCall,
|
|
50
|
+
discoveryPath,
|
|
51
|
+
clientGraceMs: 250,
|
|
52
|
+
sweepMs: 1000,
|
|
53
|
+
onClientsEmpty: () => { clientsEmptyFired = true; },
|
|
54
|
+
log: (m) => process.env.DAEMON_SMOKE_VERBOSE && console.log(`[daemon] ${m}`),
|
|
55
|
+
});
|
|
56
|
+
const { port, token } = await transport.start();
|
|
57
|
+
const discovery = { port, token, pid: process.pid };
|
|
58
|
+
|
|
59
|
+
const notA = [];
|
|
60
|
+
const notB = [];
|
|
61
|
+
const clientA = await attachToDaemon({ discovery, leadPid: process.pid, cwd: 'A', onNotify: (m) => notA.push(m) });
|
|
62
|
+
const clientB = await attachToDaemon({ discovery, leadPid: process.pid, cwd: 'B', onNotify: (m) => notB.push(m) });
|
|
63
|
+
await delay(150); // let both SSE streams attach
|
|
64
|
+
|
|
65
|
+
check('two clients registered', transport._clientsForTest.size === 2);
|
|
66
|
+
|
|
67
|
+
// (2) calls round-trip per client.
|
|
68
|
+
const rA = await clientA.call('reload_config', { k: 1 });
|
|
69
|
+
check('client A call round-trips', rA?.ok === true && rA?.name === 'reload_config');
|
|
70
|
+
const rB = await clientB.call('fetch', { q: 2 });
|
|
71
|
+
check('client B call round-trips', rB?.ok === true && rB?.name === 'fetch');
|
|
72
|
+
|
|
73
|
+
// (3) notify targets the pointer client. First registrant (A) is the pointer.
|
|
74
|
+
transport.notify('notifications/claude/channel', { content: 'to-A' });
|
|
75
|
+
await delay(120);
|
|
76
|
+
check('notify #1 delivered to A only',
|
|
77
|
+
notA.length === 1 && notA[0]?.params?.content === 'to-A' && notB.length === 0);
|
|
78
|
+
|
|
79
|
+
// (4) bind-intent call moves the pointer to B.
|
|
80
|
+
await clientB.call('rebind_current_transcript', { transcriptPath: '/tmp/b.jsonl' });
|
|
81
|
+
transport.notify('notifications/claude/channel', { content: 'to-B' });
|
|
82
|
+
await delay(120);
|
|
83
|
+
check('notify #2 delivered to B only after rebind',
|
|
84
|
+
notB.length === 1 && notB[0]?.params?.content === 'to-B' && notA.length === 1);
|
|
85
|
+
|
|
86
|
+
// (fix 1) idempotent replay: two /call with the SAME callId (a retried
|
|
87
|
+
// transport failure) must run the non-idempotent side-effect exactly once.
|
|
88
|
+
const dupId = 'dup-call-1';
|
|
89
|
+
const [d1, d2] = await Promise.all([
|
|
90
|
+
clientA.call('reply', { n: 1 }, { callId: dupId }),
|
|
91
|
+
clientA.call('reply', { n: 1 }, { callId: dupId }),
|
|
92
|
+
]);
|
|
93
|
+
check('idempotent replay: same callId → exactly one side-effect',
|
|
94
|
+
sideEffects === 1 && d1?.ok === true && d2?.ok === true);
|
|
95
|
+
|
|
96
|
+
// (5) deregister both → self-shutdown fires after the client grace window.
|
|
97
|
+
await clientA.close();
|
|
98
|
+
await clientB.close();
|
|
99
|
+
await delay(600);
|
|
100
|
+
check('daemon self-shutdown fired after last client left', clientsEmptyFired === true);
|
|
101
|
+
|
|
102
|
+
await transport.stop();
|
|
103
|
+
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
|
|
104
|
+
|
|
105
|
+
await flipTest();
|
|
106
|
+
|
|
107
|
+
console.log(failures === 0 ? '\nALL PASS' : `\n${failures} FAILURE(S)`);
|
|
108
|
+
process.exit(failures === 0 ? 0 : 1);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Flip coverage: two TUIs attach to ONE daemon via the REAL channel-worker.mjs
|
|
112
|
+
// spawn-or-attach path (against the stub daemon entry — no Discord token). The
|
|
113
|
+
// first worker spawns the daemon; the second attaches to it.
|
|
114
|
+
async function flipTest() {
|
|
115
|
+
const tmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-flip-smoke-'));
|
|
116
|
+
// channel-worker.mjs resolves DAEMON_ENTRY + runtimeRoot at import/call time,
|
|
117
|
+
// so the env must be set BEFORE importing it.
|
|
118
|
+
process.env.MIXDOG_RUNTIME_ROOT = tmp;
|
|
119
|
+
process.env.MIXDOG_DATA_DIR = tmp;
|
|
120
|
+
process.env.MIXDOG_CHANNEL_DAEMON_ENTRY = STUB_ENTRY;
|
|
121
|
+
const { createStandaloneChannelWorker } = await import('../src/standalone/channel-worker.mjs');
|
|
122
|
+
const { readDaemonDiscovery } = await import('../src/standalone/channel-daemon-client.mjs');
|
|
123
|
+
const discFile = path.join(tmp, 'channel-daemon.json');
|
|
124
|
+
|
|
125
|
+
const notA = [];
|
|
126
|
+
const notB = [];
|
|
127
|
+
const mk = (onNotify) => createStandaloneChannelWorker({ entry: STUB_ENTRY, rootDir: tmp, dataDir: tmp, cwd: tmp, onNotify });
|
|
128
|
+
const wA = mk((m) => notA.push(m));
|
|
129
|
+
const wB = mk((m) => notB.push(m));
|
|
130
|
+
|
|
131
|
+
await wA.start(); // spawns the (stub) daemon then attaches
|
|
132
|
+
await wB.start(); // attaches to the SAME daemon (no second spawn)
|
|
133
|
+
check('flip: worker A call round-trips', (await wA.execute('reply', { x: 1 }))?.ok === true);
|
|
134
|
+
check('flip: worker B call round-trips', (await wB.execute('reply', { y: 2 }))?.ok === true);
|
|
135
|
+
|
|
136
|
+
// B claims the transcript → routing pointer moves to B; a notify emitted by
|
|
137
|
+
// the daemon (on A's 'fetch') must reach B only, never both TUIs.
|
|
138
|
+
await wB.execute('rebind_current_transcript', { transcriptPath: path.join(tmp, 'b.jsonl') });
|
|
139
|
+
await wA.execute('fetch', { probe: true });
|
|
140
|
+
await waitFor(() => notB.some((m) => m?.params?.content === 'ping-from-stub'), 1500);
|
|
141
|
+
check('flip: notify targets the pointer TUI (B) only',
|
|
142
|
+
notB.some((m) => m?.params?.content === 'ping-from-stub') &&
|
|
143
|
+
!notA.some((m) => m?.params?.content === 'ping-from-stub'));
|
|
144
|
+
|
|
145
|
+
// (fix 1) daemon death mid-session: SIGKILL it, then the surviving client's
|
|
146
|
+
// next call must transparently respawn + re-attach (bounded retry inside
|
|
147
|
+
// execute) — no TUI process restart.
|
|
148
|
+
const beforeKill = readDaemonDiscovery(discFile);
|
|
149
|
+
if (beforeKill?.pid) { try { process.kill(beforeKill.pid, 'SIGKILL'); } catch {} }
|
|
150
|
+
await delay(400);
|
|
151
|
+
let recovered = false;
|
|
152
|
+
try { recovered = (await wA.execute('reply', { after: 'kill' }))?.ok === true; } catch {}
|
|
153
|
+
const afterKill = readDaemonDiscovery(discFile);
|
|
154
|
+
check('flip: call transparently respawns+reattaches after daemon death',
|
|
155
|
+
recovered === true && !!afterKill?.pid && afterKill.pid !== beforeKill?.pid);
|
|
156
|
+
|
|
157
|
+
await wA.stop();
|
|
158
|
+
await wB.stop();
|
|
159
|
+
await delay(700);
|
|
160
|
+
const disc = readDaemonDiscovery(discFile);
|
|
161
|
+
check('flip: daemon self-shutdown after last TUI detaches', disc === null);
|
|
162
|
+
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
main().catch((err) => { console.error(err); process.exit(1); });
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Stub channels daemon for the flip smoke: same lifecycle contract as
|
|
2
|
+
// src/standalone/channel-daemon.mjs (pid-verified singleton claim, HTTP+SSE
|
|
3
|
+
// transport, discovery file, ready handshake, client-grace self-shutdown) but
|
|
4
|
+
// with a STUB runtime instead of worker-main — no Discord token needed. The
|
|
5
|
+
// real channel-worker.mjs spawn-or-attach path forks THIS via
|
|
6
|
+
// MIXDOG_CHANNEL_DAEMON_ENTRY, so the flip is exercised end to end.
|
|
7
|
+
process.env.MIXDOG_WORKER_MODE = process.env.MIXDOG_WORKER_MODE || '1';
|
|
8
|
+
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { mkdirSync } from 'node:fs';
|
|
12
|
+
import { claimSingletonOwner, releaseSingletonOwner } from '../src/runtime/shared/singleton-owner.mjs';
|
|
13
|
+
import { createChannelDaemonTransport } from '../src/standalone/channel-daemon-transport.mjs';
|
|
14
|
+
|
|
15
|
+
function runtimeRoot() {
|
|
16
|
+
return process.env.MIXDOG_RUNTIME_ROOT
|
|
17
|
+
? path.resolve(process.env.MIXDOG_RUNTIME_ROOT)
|
|
18
|
+
: path.join(os.tmpdir(), 'mixdog');
|
|
19
|
+
}
|
|
20
|
+
const RUNTIME_ROOT = runtimeRoot();
|
|
21
|
+
const DATA_DIR = process.env.MIXDOG_DATA_DIR ? path.resolve(process.env.MIXDOG_DATA_DIR) : RUNTIME_ROOT;
|
|
22
|
+
const DISCOVERY_PATH = path.join(RUNTIME_ROOT, 'channel-daemon.json');
|
|
23
|
+
const OWNER_PATH = path.join(DATA_DIR, 'channel-daemon-owner.json');
|
|
24
|
+
|
|
25
|
+
function log(line) { if (process.env.DAEMON_SMOKE_VERBOSE) process.stderr.write(`[stub-daemon] ${line}\n`); }
|
|
26
|
+
|
|
27
|
+
let transport = null;
|
|
28
|
+
let shuttingDown = false;
|
|
29
|
+
async function shutdown(reason, code = 0) {
|
|
30
|
+
if (shuttingDown) return;
|
|
31
|
+
shuttingDown = true;
|
|
32
|
+
try { await transport?.stop?.(); } catch {}
|
|
33
|
+
try { releaseSingletonOwner(OWNER_PATH, process.pid); } catch {}
|
|
34
|
+
process.exit(code);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function main() {
|
|
38
|
+
try { mkdirSync(RUNTIME_ROOT, { recursive: true }); } catch {}
|
|
39
|
+
const claim = claimSingletonOwner(OWNER_PATH, { kind: 'channel-runtime-daemon', pid: process.pid, meta: { cwd: process.cwd() } });
|
|
40
|
+
if (!claim.owned) { process.exit(0); } // race loser → spawner attaches to winner
|
|
41
|
+
process.on('exit', () => { try { releaseSingletonOwner(OWNER_PATH, process.pid); } catch {} });
|
|
42
|
+
|
|
43
|
+
// Stub runtime: echo the call + caller identity, and (for 'fetch') emit a
|
|
44
|
+
// notify AFTER responding so the smoke can assert targeted routing.
|
|
45
|
+
const handleCall = async (name, args, ctx) => {
|
|
46
|
+
if (name === 'fetch') {
|
|
47
|
+
setTimeout(() => { try { transport.notify('notifications/claude/channel', { content: 'ping-from-stub' }); } catch {} }, 20);
|
|
48
|
+
}
|
|
49
|
+
return { ok: true, name, args, leadPid: ctx.leadPid };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
transport = createChannelDaemonTransport({
|
|
53
|
+
handleCall,
|
|
54
|
+
discoveryPath: DISCOVERY_PATH,
|
|
55
|
+
clientGraceMs: 250,
|
|
56
|
+
sweepMs: 1000,
|
|
57
|
+
log,
|
|
58
|
+
onClientsEmpty: () => { void shutdown('no live clients'); },
|
|
59
|
+
});
|
|
60
|
+
const { port, token } = await transport.start();
|
|
61
|
+
if (process.send) { try { process.send({ type: 'ready', port, token }); } catch {} }
|
|
62
|
+
log(`ready port=${port} pid=${process.pid}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
process.on('SIGTERM', () => { void shutdown('SIGTERM'); });
|
|
66
|
+
process.on('SIGINT', () => { void shutdown('SIGINT'); });
|
|
67
|
+
process.on('message', (msg) => { if (msg && msg.type === 'shutdown') void shutdown('IPC shutdown'); });
|
|
68
|
+
|
|
69
|
+
main().catch((err) => { log(`fatal: ${err?.stack || err}`); void shutdown('fatal', 2); });
|
|
@@ -15,4 +15,4 @@
|
|
|
15
15
|
{ "id": "uc5-yesterday", "label": "UC5 calendar yesterday", "args": { "period": "yesterday", "limit": 10 }, "expect": "browse" },
|
|
16
16
|
{ "id": "uc5-thisweek", "label": "UC5 calendar this_week", "args": { "period": "this_week", "limit": 10 }, "expect": "browse" },
|
|
17
17
|
{ "id": "uc6-category", "label": "UC6 category decision 7d", "args": { "period": "7d", "category": "decision", "limit": 10 }, "expect": "browse" }
|
|
18
|
-
]
|
|
18
|
+
]
|
|
@@ -1,106 +1,106 @@
|
|
|
1
|
-
# smoke-runtime-negative.ps1 — Negative-path tests against released win32-x64
|
|
2
|
-
# runtime tarball. Validates: download, sha256, corrupt-tarball, fresh-extract
|
|
3
|
-
# boot, double-init refusal, hostile-env survival.
|
|
4
|
-
#
|
|
5
|
-
# Env: TAG / OS / ARCH / RUNTIME_RELEASE_REPOSITORY (default tribgames/mixdog)
|
|
6
|
-
|
|
7
|
-
$ErrorActionPreference = 'Stop'
|
|
8
|
-
|
|
9
|
-
$Tag = if ($env:TAG) { $env:TAG } else { 'runtime-v0.4.0' }
|
|
10
|
-
$Os = if ($env:OS) { $env:OS } else { 'win32' }
|
|
11
|
-
$Arch = if ($env:ARCH) { $env:ARCH } else { 'x64' }
|
|
12
|
-
$ReleaseRepo = if ($env:RUNTIME_RELEASE_REPOSITORY) { $env:RUNTIME_RELEASE_REPOSITORY } else { 'tribgames/mixdog' }
|
|
13
|
-
$PgVer = '16.4'
|
|
14
|
-
$PgvectorVer = '0.8.2'
|
|
15
|
-
|
|
16
|
-
$Asset = "mixdog-runtime-${Os}-${Arch}-pg${PgVer}-pgvector${PgvectorVer}.tar.gz"
|
|
17
|
-
$Url = "https://github.com/${ReleaseRepo}/releases/download/${Tag}/${Asset}"
|
|
18
|
-
|
|
19
|
-
$Work = New-Item -ItemType Directory -Force -Path "$env:TEMP\smoke-$([guid]::NewGuid().ToString('N'))" | Select-Object -ExpandProperty FullName
|
|
20
|
-
|
|
21
|
-
try {
|
|
22
|
-
Write-Host "==> Negative-path smoke for ${Os}-${Arch} from $Url"
|
|
23
|
-
|
|
24
|
-
Write-Host "==> Test 1: HEAD reaches release asset"
|
|
25
|
-
$resp = Invoke-WebRequest -Uri $Url -Method Head -UseBasicParsing -ErrorAction Stop
|
|
26
|
-
Write-Host " HTTP $($resp.StatusCode)"
|
|
27
|
-
|
|
28
|
-
Write-Host "==> Test 2: full download + sha256"
|
|
29
|
-
$TarPath = Join-Path $Work $Asset
|
|
30
|
-
Invoke-WebRequest -Uri $Url -OutFile $TarPath -UseBasicParsing
|
|
31
|
-
$sha = (Get-FileHash -Algorithm SHA256 $TarPath).Hash.ToLower()
|
|
32
|
-
Write-Host " sha256=$sha"
|
|
33
|
-
|
|
34
|
-
Write-Host "==> Test 3: corrupt tarball — flip a byte and ensure tar errors"
|
|
35
|
-
$CorruptPath = "$Work\corrupt.tar.gz"
|
|
36
|
-
Copy-Item $TarPath $CorruptPath
|
|
37
|
-
$bytes = [byte[]]::new(1); $rand = [System.Random]::new(); $rand.NextBytes($bytes)
|
|
38
|
-
$stream = [System.IO.File]::OpenWrite($CorruptPath)
|
|
39
|
-
$stream.Position = 102400
|
|
40
|
-
$stream.Write($bytes, 0, 1)
|
|
41
|
-
$stream.Close()
|
|
42
|
-
$CorruptDir = "$Work\corrupt"
|
|
43
|
-
New-Item -ItemType Directory -Force -Path $CorruptDir | Out-Null
|
|
44
|
-
$TarProc = Start-Process -FilePath tar -ArgumentList @('-xzf', $CorruptPath, '-C', $CorruptDir.Replace('\','/')) -Wait:$false -PassThru -WindowStyle Hidden
|
|
45
|
-
if (-not $TarProc.WaitForExit(15000)) {
|
|
46
|
-
Stop-Process -Id $TarProc.Id -Force -ErrorAction SilentlyContinue
|
|
47
|
-
Write-Host " PASS: corrupted tarball extraction timed out and was stopped"
|
|
48
|
-
} elseif ($TarProc.ExitCode -eq 0) {
|
|
49
|
-
Write-Host " WARN: corrupted tarball extracted without error (tar gzip recovery)"
|
|
50
|
-
} else {
|
|
51
|
-
Write-Host " PASS: corrupted tarball rejected by tar (exit $($TarProc.ExitCode))"
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
Write-Host "==> Test 4: fresh-extract boot (extension load + distance query)"
|
|
55
|
-
$FreshDir = "$Work\fresh"
|
|
56
|
-
New-Item -ItemType Directory -Force -Path $FreshDir | Out-Null
|
|
57
|
-
& tar -xzf $TarPath -C ($FreshDir.Replace('\','/'))
|
|
58
|
-
if ($LASTEXITCODE -ne 0) { throw "fresh extract failed" }
|
|
59
|
-
|
|
60
|
-
$PgBin = "$FreshDir\bin"
|
|
61
|
-
$Data = "$FreshDir\pgdata"
|
|
62
|
-
$Log = "$FreshDir\pg.log"
|
|
63
|
-
$Port = 55897
|
|
64
|
-
|
|
65
|
-
# Hostile env: minimal PATH (System32 only), no PGROOT/PGDATA
|
|
66
|
-
$SavedPath = $env:PATH
|
|
67
|
-
$SavedPgRoot = $env:PGROOT
|
|
68
|
-
$env:PATH = "$env:SystemRoot\System32;$env:SystemRoot"
|
|
69
|
-
$env:PGROOT = $null
|
|
70
|
-
$env:PGDATA = $null
|
|
71
|
-
|
|
72
|
-
try {
|
|
73
|
-
& "$PgBin\postgres.exe" --version
|
|
74
|
-
if ($LASTEXITCODE -ne 0) { throw "FAIL: postgres --version" }
|
|
75
|
-
& "$PgBin\initdb.exe" -D $Data --username=postgres --auth-local=trust --no-locale -E UTF8 | Out-Null
|
|
76
|
-
if ($LASTEXITCODE -ne 0) { throw "FAIL: initdb" }
|
|
77
|
-
& "$PgBin\pg_ctl.exe" -D $Data -o "-p $Port -h 127.0.0.1" -l $Log -w start
|
|
78
|
-
if ($LASTEXITCODE -ne 0) { Get-Content $Log | Select-Object -Last 30; throw "FAIL: pg_ctl start" }
|
|
79
|
-
|
|
80
|
-
try {
|
|
81
|
-
& "$PgBin\psql.exe" -h 127.0.0.1 -p $Port -U postgres -d postgres -c "CREATE EXTENSION vector;" | Out-Null
|
|
82
|
-
if ($LASTEXITCODE -ne 0) { throw "FAIL: CREATE EXTENSION" }
|
|
83
|
-
$ExtV = & "$PgBin\psql.exe" -h 127.0.0.1 -p $Port -U postgres -d postgres -tAc "SELECT extversion FROM pg_extension WHERE extname='vector';"
|
|
84
|
-
if ($ExtV.Trim() -ne $PgvectorVer) { throw "FAIL: extversion='$ExtV' expected='$PgvectorVer'" }
|
|
85
|
-
Write-Host " PASS: fresh-extract boot + vector extension"
|
|
86
|
-
} finally {
|
|
87
|
-
& "$PgBin\pg_ctl.exe" -D $Data -m fast stop 2>$null | Out-Null
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
Write-Host "==> Test 5: second initdb on initialized dir refused"
|
|
91
|
-
$rc = (Start-Process -FilePath "$PgBin\initdb.exe" -ArgumentList "-D",$Data,"-U","postgres" -Wait -PassThru -NoNewWindow).ExitCode
|
|
92
|
-
if ($rc -eq 0) {
|
|
93
|
-
Write-Host " WARN: second initdb succeeded (unexpected)"
|
|
94
|
-
} else {
|
|
95
|
-
Write-Host " PASS: second initdb refused (exit $rc)"
|
|
96
|
-
}
|
|
97
|
-
} finally {
|
|
98
|
-
$env:PATH = $SavedPath
|
|
99
|
-
$env:PGROOT = $SavedPgRoot
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
Write-Host "==> All negative-path smokes: PASS"
|
|
103
|
-
}
|
|
104
|
-
finally {
|
|
105
|
-
Remove-Item -Recurse -Force $Work -ErrorAction SilentlyContinue
|
|
106
|
-
}
|
|
1
|
+
# smoke-runtime-negative.ps1 — Negative-path tests against released win32-x64
|
|
2
|
+
# runtime tarball. Validates: download, sha256, corrupt-tarball, fresh-extract
|
|
3
|
+
# boot, double-init refusal, hostile-env survival.
|
|
4
|
+
#
|
|
5
|
+
# Env: TAG / OS / ARCH / RUNTIME_RELEASE_REPOSITORY (default tribgames/mixdog)
|
|
6
|
+
|
|
7
|
+
$ErrorActionPreference = 'Stop'
|
|
8
|
+
|
|
9
|
+
$Tag = if ($env:TAG) { $env:TAG } else { 'runtime-v0.4.0' }
|
|
10
|
+
$Os = if ($env:OS) { $env:OS } else { 'win32' }
|
|
11
|
+
$Arch = if ($env:ARCH) { $env:ARCH } else { 'x64' }
|
|
12
|
+
$ReleaseRepo = if ($env:RUNTIME_RELEASE_REPOSITORY) { $env:RUNTIME_RELEASE_REPOSITORY } else { 'tribgames/mixdog' }
|
|
13
|
+
$PgVer = '16.4'
|
|
14
|
+
$PgvectorVer = '0.8.2'
|
|
15
|
+
|
|
16
|
+
$Asset = "mixdog-runtime-${Os}-${Arch}-pg${PgVer}-pgvector${PgvectorVer}.tar.gz"
|
|
17
|
+
$Url = "https://github.com/${ReleaseRepo}/releases/download/${Tag}/${Asset}"
|
|
18
|
+
|
|
19
|
+
$Work = New-Item -ItemType Directory -Force -Path "$env:TEMP\smoke-$([guid]::NewGuid().ToString('N'))" | Select-Object -ExpandProperty FullName
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
Write-Host "==> Negative-path smoke for ${Os}-${Arch} from $Url"
|
|
23
|
+
|
|
24
|
+
Write-Host "==> Test 1: HEAD reaches release asset"
|
|
25
|
+
$resp = Invoke-WebRequest -Uri $Url -Method Head -UseBasicParsing -ErrorAction Stop
|
|
26
|
+
Write-Host " HTTP $($resp.StatusCode)"
|
|
27
|
+
|
|
28
|
+
Write-Host "==> Test 2: full download + sha256"
|
|
29
|
+
$TarPath = Join-Path $Work $Asset
|
|
30
|
+
Invoke-WebRequest -Uri $Url -OutFile $TarPath -UseBasicParsing
|
|
31
|
+
$sha = (Get-FileHash -Algorithm SHA256 $TarPath).Hash.ToLower()
|
|
32
|
+
Write-Host " sha256=$sha"
|
|
33
|
+
|
|
34
|
+
Write-Host "==> Test 3: corrupt tarball — flip a byte and ensure tar errors"
|
|
35
|
+
$CorruptPath = "$Work\corrupt.tar.gz"
|
|
36
|
+
Copy-Item $TarPath $CorruptPath
|
|
37
|
+
$bytes = [byte[]]::new(1); $rand = [System.Random]::new(); $rand.NextBytes($bytes)
|
|
38
|
+
$stream = [System.IO.File]::OpenWrite($CorruptPath)
|
|
39
|
+
$stream.Position = 102400
|
|
40
|
+
$stream.Write($bytes, 0, 1)
|
|
41
|
+
$stream.Close()
|
|
42
|
+
$CorruptDir = "$Work\corrupt"
|
|
43
|
+
New-Item -ItemType Directory -Force -Path $CorruptDir | Out-Null
|
|
44
|
+
$TarProc = Start-Process -FilePath tar -ArgumentList @('-xzf', $CorruptPath, '-C', $CorruptDir.Replace('\','/')) -Wait:$false -PassThru -WindowStyle Hidden
|
|
45
|
+
if (-not $TarProc.WaitForExit(15000)) {
|
|
46
|
+
Stop-Process -Id $TarProc.Id -Force -ErrorAction SilentlyContinue
|
|
47
|
+
Write-Host " PASS: corrupted tarball extraction timed out and was stopped"
|
|
48
|
+
} elseif ($TarProc.ExitCode -eq 0) {
|
|
49
|
+
Write-Host " WARN: corrupted tarball extracted without error (tar gzip recovery)"
|
|
50
|
+
} else {
|
|
51
|
+
Write-Host " PASS: corrupted tarball rejected by tar (exit $($TarProc.ExitCode))"
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
Write-Host "==> Test 4: fresh-extract boot (extension load + distance query)"
|
|
55
|
+
$FreshDir = "$Work\fresh"
|
|
56
|
+
New-Item -ItemType Directory -Force -Path $FreshDir | Out-Null
|
|
57
|
+
& tar -xzf $TarPath -C ($FreshDir.Replace('\','/'))
|
|
58
|
+
if ($LASTEXITCODE -ne 0) { throw "fresh extract failed" }
|
|
59
|
+
|
|
60
|
+
$PgBin = "$FreshDir\bin"
|
|
61
|
+
$Data = "$FreshDir\pgdata"
|
|
62
|
+
$Log = "$FreshDir\pg.log"
|
|
63
|
+
$Port = 55897
|
|
64
|
+
|
|
65
|
+
# Hostile env: minimal PATH (System32 only), no PGROOT/PGDATA
|
|
66
|
+
$SavedPath = $env:PATH
|
|
67
|
+
$SavedPgRoot = $env:PGROOT
|
|
68
|
+
$env:PATH = "$env:SystemRoot\System32;$env:SystemRoot"
|
|
69
|
+
$env:PGROOT = $null
|
|
70
|
+
$env:PGDATA = $null
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
& "$PgBin\postgres.exe" --version
|
|
74
|
+
if ($LASTEXITCODE -ne 0) { throw "FAIL: postgres --version" }
|
|
75
|
+
& "$PgBin\initdb.exe" -D $Data --username=postgres --auth-local=trust --no-locale -E UTF8 | Out-Null
|
|
76
|
+
if ($LASTEXITCODE -ne 0) { throw "FAIL: initdb" }
|
|
77
|
+
& "$PgBin\pg_ctl.exe" -D $Data -o "-p $Port -h 127.0.0.1" -l $Log -w start
|
|
78
|
+
if ($LASTEXITCODE -ne 0) { Get-Content $Log | Select-Object -Last 30; throw "FAIL: pg_ctl start" }
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
& "$PgBin\psql.exe" -h 127.0.0.1 -p $Port -U postgres -d postgres -c "CREATE EXTENSION vector;" | Out-Null
|
|
82
|
+
if ($LASTEXITCODE -ne 0) { throw "FAIL: CREATE EXTENSION" }
|
|
83
|
+
$ExtV = & "$PgBin\psql.exe" -h 127.0.0.1 -p $Port -U postgres -d postgres -tAc "SELECT extversion FROM pg_extension WHERE extname='vector';"
|
|
84
|
+
if ($ExtV.Trim() -ne $PgvectorVer) { throw "FAIL: extversion='$ExtV' expected='$PgvectorVer'" }
|
|
85
|
+
Write-Host " PASS: fresh-extract boot + vector extension"
|
|
86
|
+
} finally {
|
|
87
|
+
& "$PgBin\pg_ctl.exe" -D $Data -m fast stop 2>$null | Out-Null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
Write-Host "==> Test 5: second initdb on initialized dir refused"
|
|
91
|
+
$rc = (Start-Process -FilePath "$PgBin\initdb.exe" -ArgumentList "-D",$Data,"-U","postgres" -Wait -PassThru -NoNewWindow).ExitCode
|
|
92
|
+
if ($rc -eq 0) {
|
|
93
|
+
Write-Host " WARN: second initdb succeeded (unexpected)"
|
|
94
|
+
} else {
|
|
95
|
+
Write-Host " PASS: second initdb refused (exit $rc)"
|
|
96
|
+
}
|
|
97
|
+
} finally {
|
|
98
|
+
$env:PATH = $SavedPath
|
|
99
|
+
$env:PGROOT = $SavedPgRoot
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
Write-Host "==> All negative-path smokes: PASS"
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
Remove-Item -Recurse -Force $Work -ErrorAction SilentlyContinue
|
|
106
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { acceptQuotaSnapshot } from '../src/ui/statusline.mjs';
|
|
4
|
+
|
|
5
|
+
// Monotonic hysteresis for the 5H/7D usage segment: once a value has rendered,
|
|
6
|
+
// an OLDER shared-cache snapshot (written by another mixdog instance) must not
|
|
7
|
+
// displace it, but a NEWER one — or confirmed own-instance live data — must.
|
|
8
|
+
test('older shared-cache snapshot is rejected, newer is accepted', () => {
|
|
9
|
+
const displayedOwnLive = { segments: ['5H 12%'], asOf: 2000, owned: true };
|
|
10
|
+
|
|
11
|
+
// Another instance overwrites the shared cache with an OLDER snapshot →
|
|
12
|
+
// metricsMatch flips false, source alternates to that unowned older snapshot.
|
|
13
|
+
assert.equal(
|
|
14
|
+
acceptQuotaSnapshot(displayedOwnLive, { asOf: 1000, owned: false }),
|
|
15
|
+
false,
|
|
16
|
+
'older unowned snapshot must not displace displayed own-live value',
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
// A strictly newer shared snapshot is allowed to advance the value.
|
|
20
|
+
assert.equal(
|
|
21
|
+
acceptQuotaSnapshot(displayedOwnLive, { asOf: 3000, owned: false }),
|
|
22
|
+
true,
|
|
23
|
+
'newer snapshot must replace the displayed value',
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('own-instance live data always wins; empty timestamps preserve prior behavior', () => {
|
|
28
|
+
const displayedShared = { segments: ['5H 12%'], asOf: 5000, owned: false };
|
|
29
|
+
// Own-instance live data replaces even an apparently newer shared snapshot.
|
|
30
|
+
assert.equal(acceptQuotaSnapshot(displayedShared, { asOf: 1, owned: true }), true);
|
|
31
|
+
// Nothing displayed yet → accept.
|
|
32
|
+
assert.equal(acceptQuotaSnapshot(undefined, { asOf: 1000, owned: false }), true);
|
|
33
|
+
// No comparable timestamps → accept (byte-for-byte prior behavior).
|
|
34
|
+
assert.equal(acceptQuotaSnapshot({ asOf: 0, owned: true }, { asOf: 0, owned: false }), true);
|
|
35
|
+
// Shared→shared same asOf → accept (idempotent refresh).
|
|
36
|
+
assert.equal(acceptQuotaSnapshot({ asOf: 2000, owned: false }, { asOf: 2000, owned: false }), true);
|
|
37
|
+
});
|
|
@@ -88,4 +88,4 @@ for (const target of ['heavy-worker', 'worker']) {
|
|
|
88
88
|
console.log(` single-call batches: ${batch1}/${batchTot} (${pct(batch1, batchTot)}%)`);
|
|
89
89
|
console.log(` content greps with -C/-A/-B: ${grepContentWithCtx}/${grepContent} (${pct(grepContentWithCtx, grepContent)}%) grep->read follow-ups: ${grepThenRead}`);
|
|
90
90
|
console.log(` same-path re-reads >=3x: ${rereads}`);
|
|
91
|
-
}
|
|
91
|
+
}
|
package/scripts/tool-smoke.mjs
CHANGED
|
@@ -906,7 +906,7 @@ const fullDefaults = defaultDeferredToolNames(smokeCatalog, 'full');
|
|
|
906
906
|
if (fullDefaults.size !== 10) {
|
|
907
907
|
throw new Error(`full default surface should stay 10 tools, got ${fullDefaults.size}: ${[...fullDefaults].join(', ')}`);
|
|
908
908
|
}
|
|
909
|
-
for (const name of ['read', 'code_graph', 'grep', 'find', 'glob', 'list', 'apply_patch', 'explore', 'Skill', '
|
|
909
|
+
for (const name of ['read', 'code_graph', 'grep', 'find', 'glob', 'list', 'apply_patch', 'explore', 'Skill', 'load_tool']) {
|
|
910
910
|
assertHas(fullDefaults, name);
|
|
911
911
|
}
|
|
912
912
|
for (const name of ['shell', 'task', 'agent', 'recall', 'search', 'web_fetch', 'cwd']) {
|
|
@@ -917,7 +917,7 @@ const leadDefaults = defaultDeferredToolNames(smokeCatalog, 'lead');
|
|
|
917
917
|
if (leadDefaults.size !== 16) {
|
|
918
918
|
throw new Error(`lead default surface should stay 16 tools for this static catalog, got ${leadDefaults.size}: ${[...leadDefaults].join(', ')}`);
|
|
919
919
|
}
|
|
920
|
-
for (const name of ['read', 'code_graph', 'grep', 'find', 'glob', 'list', 'shell', 'task', 'apply_patch', 'explore', 'agent', 'recall', 'search', 'web_fetch', 'Skill', '
|
|
920
|
+
for (const name of ['read', 'code_graph', 'grep', 'find', 'glob', 'list', 'shell', 'task', 'apply_patch', 'explore', 'agent', 'recall', 'search', 'web_fetch', 'Skill', 'load_tool']) {
|
|
921
921
|
assertHas(leadDefaults, name);
|
|
922
922
|
}
|
|
923
923
|
if (TOOL_SEARCH_TOOL.annotations?.agentHidden !== true) {
|
|
@@ -943,7 +943,7 @@ for (const [name, cap] of [
|
|
|
943
943
|
['recall', 2400],
|
|
944
944
|
['search', 3200],
|
|
945
945
|
['web_fetch', 900],
|
|
946
|
-
['
|
|
946
|
+
['load_tool', 900],
|
|
947
947
|
]) {
|
|
948
948
|
const tool = smokeCatalog.find((item) => item?.name === name);
|
|
949
949
|
const size = toolSchemaSize(tool);
|
|
@@ -954,7 +954,7 @@ const readonlyDefaults = defaultDeferredToolNames(smokeCatalog, 'readonly');
|
|
|
954
954
|
if (readonlyDefaults.size !== 9) {
|
|
955
955
|
throw new Error(`readonly default surface should stay 9 tools, got ${readonlyDefaults.size}: ${[...readonlyDefaults].join(', ')}`);
|
|
956
956
|
}
|
|
957
|
-
for (const name of ['read', 'code_graph', 'grep', 'find', 'glob', 'list', 'explore', 'Skill', '
|
|
957
|
+
for (const name of ['read', 'code_graph', 'grep', 'find', 'glob', 'list', 'explore', 'Skill', 'load_tool']) {
|
|
958
958
|
assertHas(readonlyDefaults, name);
|
|
959
959
|
}
|
|
960
960
|
for (const name of ['apply_patch', 'agent', 'shell']) {
|
|
@@ -1359,8 +1359,8 @@ setInternalToolsProvider({
|
|
|
1359
1359
|
throw new Error(`agent context must not inject environment reminder: ${visible.slice(0, 1200)}`);
|
|
1360
1360
|
}
|
|
1361
1361
|
const workerToolNames = (workerSession.tools || []).map((tool) => tool?.name).filter(Boolean);
|
|
1362
|
-
if (workerToolNames.includes('
|
|
1363
|
-
throw new Error(`agent session schema must not expose deferred
|
|
1362
|
+
if (workerToolNames.includes('load_tool')) {
|
|
1363
|
+
throw new Error(`agent session schema must not expose deferred load_tool: ${workerToolNames.join(', ')}`);
|
|
1364
1364
|
}
|
|
1365
1365
|
for (const name of ['shell', 'task']) {
|
|
1366
1366
|
if (!workerToolNames.includes(name)) {
|
|
@@ -1420,8 +1420,8 @@ setInternalToolsProvider({
|
|
|
1420
1420
|
if (JSON.stringify(writeTools) !== JSON.stringify(expectedWriteTools)) {
|
|
1421
1421
|
throw new Error(`read-write agent schema must be fixed allow-list: expected=${expectedWriteTools.join(', ')} actual=${writeTools.join(', ')}`);
|
|
1422
1422
|
}
|
|
1423
|
-
if (readTools.includes('
|
|
1424
|
-
throw new Error(`agent session fixed schemas must omit
|
|
1423
|
+
if (readTools.includes('load_tool') || writeTools.includes('load_tool')) {
|
|
1424
|
+
throw new Error(`agent session fixed schemas must omit load_tool: read=${readTools.join(', ')} write=${writeTools.join(', ')}`);
|
|
1425
1425
|
}
|
|
1426
1426
|
if (readTools.includes('shell')) {
|
|
1427
1427
|
throw new Error(`read agent schema must omit shell: read=${readTools.join(', ')}`);
|
|
@@ -1846,25 +1846,38 @@ if (!/Use after search/i.test(webFetchTool?.description || '') || !webFetchProps
|
|
|
1846
1846
|
if (!/offset/i.test(webFetchProps.startIndex?.description || '') || !/Maximum characters/i.test(webFetchProps.maxLength?.description || '')) {
|
|
1847
1847
|
throw new Error('web_fetch schema must describe paging window fields');
|
|
1848
1848
|
}
|
|
1849
|
-
if (!/deferred tools/i.test(TOOL_SEARCH_TOOL.description || '')
|
|
1850
|
-
|
|
1849
|
+
if (!/deferred tools/i.test(TOOL_SEARCH_TOOL.description || '')
|
|
1850
|
+
|| !TOOL_SEARCH_TOOL.inputSchema?.properties?.names
|
|
1851
|
+
|| !TOOL_SEARCH_TOOL.inputSchema?.properties?.select) {
|
|
1852
|
+
throw new Error('load_tool schema must preserve loader guidance plus names + legacy select fields');
|
|
1851
1853
|
}
|
|
1852
1854
|
const toolSearchSession = {
|
|
1853
1855
|
tools: smokeCatalog.filter((tool) => fullDefaults.has(tool?.name)),
|
|
1854
1856
|
deferredToolCatalog: smokeCatalog.slice(),
|
|
1855
1857
|
deferredSelectedTools: [...fullDefaults],
|
|
1856
1858
|
};
|
|
1857
|
-
//
|
|
1858
|
-
//
|
|
1859
|
+
// load_tool is a pure loader: a free-text query is NOT a search. It loads
|
|
1860
|
+
// nothing, returns an error steering to names[], and never activates tools.
|
|
1859
1861
|
const listQueryResult = JSON.parse(__renderToolSearchForTest({ query: 'shell' }, toolSearchSession, 'full'));
|
|
1860
|
-
if (listQueryResult.selected) {
|
|
1861
|
-
throw new Error(`
|
|
1862
|
+
if (listQueryResult.selected || (Array.isArray(listQueryResult.loaded) && listQueryResult.loaded.length)) {
|
|
1863
|
+
throw new Error(`load_tool free-text query must not load: ${JSON.stringify(listQueryResult)}`);
|
|
1862
1864
|
}
|
|
1863
|
-
if (!listQueryResult.
|
|
1864
|
-
throw new Error(`
|
|
1865
|
+
if (!listQueryResult.error || !/names/i.test(listQueryResult.error)) {
|
|
1866
|
+
throw new Error(`load_tool free-text query must steer to names[]: ${JSON.stringify(listQueryResult)}`);
|
|
1865
1867
|
}
|
|
1866
1868
|
if (listQueryResult.activeTools.includes('shell') || (Array.isArray(listQueryResult.discoveredTools) && listQueryResult.discoveredTools.includes('shell'))) {
|
|
1867
|
-
throw new Error(`
|
|
1869
|
+
throw new Error(`load_tool free-text query must not activate/discover tools: ${JSON.stringify(listQueryResult)}`);
|
|
1870
|
+
}
|
|
1871
|
+
// names[] is the primary loader input (aliases expand, tools activate).
|
|
1872
|
+
const namesLoadResult = JSON.parse(__renderToolSearchForTest({ names: ['shell', 'recall'] }, {
|
|
1873
|
+
tools: smokeCatalog.filter((tool) => fullDefaults.has(tool?.name)),
|
|
1874
|
+
deferredToolCatalog: smokeCatalog.slice(),
|
|
1875
|
+
deferredSelectedTools: [...fullDefaults],
|
|
1876
|
+
}, 'full'));
|
|
1877
|
+
for (const name of ['shell', 'recall']) {
|
|
1878
|
+
if (!namesLoadResult.activeTools.includes(name) || !namesLoadResult.loaded.includes(name)) {
|
|
1879
|
+
throw new Error(`load_tool names[] must load ${name}: ${JSON.stringify(namesLoadResult)}`);
|
|
1880
|
+
}
|
|
1868
1881
|
}
|
|
1869
1882
|
// query "select:a,b" is the explicit query-side loader (aliases expand).
|
|
1870
1883
|
const bulkSelectResult = JSON.parse(__renderToolSearchForTest({ query: 'select:shell,recall' }, toolSearchSession, 'full'));
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
# Channels
|
|
2
|
-
|
|
3
|
-
- Channel features are handled by the runtime.
|
|
1
|
+
# Channels
|
|
2
|
+
|
|
3
|
+
- Channel features are handled by the runtime.
|