mixdog 0.9.22 → 0.9.24
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/boot-smoke.mjs +1 -1
- package/scripts/channel-daemon-smoke.mjs +483 -0
- package/scripts/channel-daemon-stub.mjs +80 -0
- package/scripts/debounced-skills-async-save-test.mjs +57 -0
- package/scripts/explore-bench-tmp.mjs +17 -0
- package/scripts/find-fuzzy-hidden-test.mjs +145 -0
- package/scripts/mcp-grace-deferred-test.mjs +149 -0
- package/scripts/statusline-quota-hysteresis-test.mjs +37 -0
- package/scripts/tool-smoke.mjs +68 -47
- package/src/rules/agent/30-explorer.md +6 -0
- package/src/rules/shared/01-tool.md +11 -4
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +76 -1
- package/src/runtime/agent/orchestrator/config.mjs +33 -7
- package/src/runtime/agent/orchestrator/context/collect.mjs +43 -8
- package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +226 -0
- package/src/runtime/agent/orchestrator/mcp/client.mjs +184 -33
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +38 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +39 -1
- 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/agent-loop.mjs +24 -7
- 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/runtime-liveness.mjs +11 -1
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +14 -3
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +40 -6
- package/src/runtime/agent/orchestrator/session/store.mjs +30 -14
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +13 -5
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +62 -24
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -6
- package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +20 -0
- package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +34 -3
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +220 -27
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +61 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +67 -27
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +6 -5
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -5
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +97 -54
- package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +49 -31
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +35 -2
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +126 -22
- package/src/runtime/channels/lib/crash-log.mjs +4 -2
- package/src/runtime/channels/lib/output-forwarder.mjs +2 -1
- package/src/runtime/channels/lib/owned-runtime.mjs +67 -223
- package/src/runtime/channels/lib/owner-heartbeat.mjs +9 -62
- 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/session-discovery.mjs +2 -1
- package/src/runtime/channels/lib/tool-dispatch.mjs +22 -14
- package/src/runtime/channels/lib/tool-format.mjs +7 -2
- package/src/runtime/channels/lib/worker-main.mjs +42 -30
- 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/memory/lib/query-handlers.mjs +4 -1
- package/src/runtime/memory/lib/recall-format.mjs +7 -3
- package/src/runtime/memory/tool-defs.mjs +1 -1
- package/src/runtime/shared/atomic-file.mjs +150 -6
- package/src/runtime/shared/background-tasks.mjs +1 -1
- package/src/runtime/shared/config.mjs +53 -1
- package/src/runtime/shared/tool-execution-contract.mjs +1 -1
- package/src/runtime/shared/tool-primitives.mjs +31 -1
- package/src/runtime/shared/tool-surface.mjs +42 -13
- package/src/runtime/shared/update-checker.mjs +3 -0
- package/src/runtime/shared/user-data-guard.mjs +66 -0
- package/src/session-runtime/config-lifecycle.mjs +221 -20
- package/src/session-runtime/lifecycle-api.mjs +9 -0
- package/src/session-runtime/mcp-glue.mjs +93 -1
- package/src/session-runtime/resource-api.mjs +62 -8
- package/src/session-runtime/runtime-core.mjs +118 -4
- package/src/session-runtime/session-text.mjs +41 -0
- package/src/session-runtime/session-turn-api.mjs +50 -0
- package/src/session-runtime/settings-api.mjs +8 -1
- package/src/session-runtime/tool-catalog.mjs +350 -38
- package/src/session-runtime/tool-defs.mjs +7 -7
- package/src/session-runtime/workflow.mjs +2 -1
- package/src/standalone/channel-admin.mjs +32 -3
- package/src/standalone/channel-daemon-client.mjs +226 -0
- package/src/standalone/channel-daemon-transport.mjs +545 -0
- package/src/standalone/channel-daemon.mjs +176 -0
- package/src/standalone/channel-worker.mjs +224 -4
- package/src/standalone/explore-tool.mjs +87 -15
- package/src/standalone/hook-bus.mjs +71 -3
- package/src/tui/App.jsx +107 -19
- 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/StatusLine.jsx +3 -3
- package/src/tui/components/ToolExecution.jsx +16 -4
- package/src/tui/components/TranscriptItem.jsx +1 -1
- package/src/tui/dist/index.mjs +820 -326
- package/src/tui/engine/agent-job-feed.mjs +5 -0
- package/src/tui/engine/notification-plan.mjs +5 -0
- package/src/tui/engine/session-api.mjs +23 -8
- package/src/tui/engine/session-flow.mjs +6 -0
- package/src/tui/engine/tool-card-results.mjs +14 -5
- package/src/tui/engine/turn.mjs +9 -2
- package/src/tui/engine.mjs +32 -5
- package/src/tui/index.jsx +62 -18
- package/src/tui/paste-attachments.mjs +26 -0
- package/src/ui/statusline-agents.mjs +36 -0
- package/src/ui/statusline.mjs +60 -10
- 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,17 @@
|
|
|
1
|
+
import { runExplore } from '../src/standalone/explore-tool.mjs';
|
|
2
|
+
const queries = [
|
|
3
|
+
'standalone explore tool implementation entry point',
|
|
4
|
+
'V4A patch format parsing/conversion implementation',
|
|
5
|
+
'recall memory entries persistent store (sqlite) read/query implementation',
|
|
6
|
+
'output styles: how style definitions are loaded and applied at session runtime',
|
|
7
|
+
'Discord backend outbound message send implementation',
|
|
8
|
+
'shell command destructive/danger policy scan before execution',
|
|
9
|
+
'where does mixdog store per-user data and sessions on this machine (out of repo)',
|
|
10
|
+
'channel runtime crash logging write path',
|
|
11
|
+
];
|
|
12
|
+
const t0 = Date.now();
|
|
13
|
+
const res = await runExplore({ query: queries, cwd: 'C:/Project/mixdog' }, { callerCwd: 'C:/Project/mixdog' });
|
|
14
|
+
console.log('BATCH_ELAPSED_MS', Date.now() - t0);
|
|
15
|
+
const txt = res.content?.[0]?.text || '';
|
|
16
|
+
console.log('FAILED_COUNT', (txt.match(/EXPLORATION_FAILED/g) || []).length);
|
|
17
|
+
process.exit(0);
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { fuzzyRank } from '../src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs';
|
|
7
|
+
import { executeFuzzyFindTool } from '../src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs';
|
|
8
|
+
import { runRg } from '../src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs';
|
|
9
|
+
|
|
10
|
+
// ── fuzzyRank score floor ────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
test('fuzzyRank keeps an exact basename/substring hit and drops scattered noise', () => {
|
|
13
|
+
const items = [
|
|
14
|
+
{ path: '.mixdog/data/tool-events.log' },
|
|
15
|
+
// subsequence-only junk: the query chars appear in order but scattered
|
|
16
|
+
// mid-word across dirs, no contiguity or word boundaries.
|
|
17
|
+
{ path: 'pgadmin/xtx/oxoxl/exvxexnxtx/sxlxoxg/records.dat' },
|
|
18
|
+
];
|
|
19
|
+
const ranked = fuzzyRank('tool-events.log', items);
|
|
20
|
+
assert.ok(ranked.length >= 1, 'the real file must survive the floor');
|
|
21
|
+
assert.equal(ranked[0].item.path, '.mixdog/data/tool-events.log');
|
|
22
|
+
assert.ok(
|
|
23
|
+
!ranked.some((r) => r.item.path.startsWith('pgadmin/')),
|
|
24
|
+
'scattered subsequence junk must be filtered out',
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('fuzzyRank floor drops a purely scattered subsequence but keeps a substring', () => {
|
|
29
|
+
const strong = { path: 'src/abcdef.txt' };
|
|
30
|
+
const junk = { path: 'xax/xbxcx/xdxexfx/z.bin' };
|
|
31
|
+
const ranked = fuzzyRank('abcdef', [strong, junk]);
|
|
32
|
+
assert.deepEqual(ranked.map((r) => r.item.path), ['src/abcdef.txt']);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// ── hidden-directory discovery via the find tool ─────────────────────────
|
|
36
|
+
|
|
37
|
+
function makeRepo() {
|
|
38
|
+
const root = mkdtempSync(join(tmpdir(), 'mixdog-find-hidden-'));
|
|
39
|
+
mkdirSync(join(root, '.mixdog', 'data'), { recursive: true });
|
|
40
|
+
writeFileSync(join(root, '.mixdog', 'data', 'tool-events.log'), 'x\n');
|
|
41
|
+
mkdirSync(join(root, 'src'), { recursive: true });
|
|
42
|
+
writeFileSync(join(root, 'src', 'unrelated.txt'), 'x\n');
|
|
43
|
+
// .git noise must stay pruned even with hidden default true.
|
|
44
|
+
mkdirSync(join(root, '.git'), { recursive: true });
|
|
45
|
+
writeFileSync(join(root, '.git', 'tool-events.log'), 'x\n');
|
|
46
|
+
return root;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
test('find surfaces a file under a dot-directory top-ranked (hidden default true)', async () => {
|
|
50
|
+
const root = makeRepo();
|
|
51
|
+
try {
|
|
52
|
+
const out = await executeFuzzyFindTool({ query: 'tool-events.log' }, root);
|
|
53
|
+
const lines = out.split('\n').filter(Boolean);
|
|
54
|
+
assert.ok(lines.length >= 1, `expected a hit, got: ${out}`);
|
|
55
|
+
assert.equal(lines[0], '.mixdog/data/tool-events.log');
|
|
56
|
+
assert.ok(!out.includes('.git/'), '.git must stay pruned as noise');
|
|
57
|
+
} finally {
|
|
58
|
+
rmSync(root, { recursive: true, force: true });
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('find hidden:false skips dot-directories', async () => {
|
|
63
|
+
const root = makeRepo();
|
|
64
|
+
try {
|
|
65
|
+
const out = await executeFuzzyFindTool({ query: 'tool-events.log', hidden: false }, root);
|
|
66
|
+
assert.ok(!out.includes('.mixdog'), `hidden:false must not descend dot-dirs: ${out}`);
|
|
67
|
+
} finally {
|
|
68
|
+
rmSync(root, { recursive: true, force: true });
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// ── narrowed-pass merge / dedup backstop ─────────────────────────────────
|
|
73
|
+
|
|
74
|
+
test('exact-name hit survives among many decoys and is not duplicated', async () => {
|
|
75
|
+
const root = mkdtempSync(join(tmpdir(), 'mixdog-find-merge-'));
|
|
76
|
+
try {
|
|
77
|
+
// A deep exact-name target plus a pile of unrelated decoys. The target
|
|
78
|
+
// matches both the broad enumeration and the query-narrowed pass, so it
|
|
79
|
+
// exercises the merge+dedup path.
|
|
80
|
+
mkdirSync(join(root, 'deep', 'nested', 'here'), { recursive: true });
|
|
81
|
+
writeFileSync(join(root, 'deep', 'nested', 'here', 'tool-events.log'), 'x\n');
|
|
82
|
+
mkdirSync(join(root, 'noise'), { recursive: true });
|
|
83
|
+
for (let i = 0; i < 200; i++) {
|
|
84
|
+
writeFileSync(join(root, 'noise', `decoy-${i}.bin`), 'x\n');
|
|
85
|
+
}
|
|
86
|
+
const out = await executeFuzzyFindTool({ query: 'tool-events.log' }, root);
|
|
87
|
+
const lines = out.split('\n').filter(Boolean);
|
|
88
|
+
assert.equal(lines[0], 'deep/nested/here/tool-events.log');
|
|
89
|
+
const hits = lines.filter((l) => l === 'deep/nested/here/tool-events.log');
|
|
90
|
+
assert.equal(hits.length, 1, `merge must dedup, got ${hits.length}: ${out}`);
|
|
91
|
+
} finally {
|
|
92
|
+
rmSync(root, { recursive: true, force: true });
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ── truncation warning surfaces when the broad pass reports .truncated ────
|
|
97
|
+
|
|
98
|
+
test('truncated broad enumeration emits a visible warning, still ranks the exact hit, and is not cached', async () => {
|
|
99
|
+
const root = makeRepo();
|
|
100
|
+
try {
|
|
101
|
+
// Test-only seam: wrap the real runRg so the BROAD pass (the one without
|
|
102
|
+
// `--iglob`) reports truncation via the boxed-String contract the tool
|
|
103
|
+
// relies on (.truncated=true), while the narrowed pass is left intact.
|
|
104
|
+
// Production options never carry __runRg, so the real path is unchanged.
|
|
105
|
+
const truncatingRunRg = async (argsList, execOptions) => {
|
|
106
|
+
const out = await runRg(argsList, execOptions);
|
|
107
|
+
if (argsList.includes('--iglob')) return out; // narrowed backstop unchanged
|
|
108
|
+
const boxed = new String(String(out));
|
|
109
|
+
boxed.truncated = true;
|
|
110
|
+
return boxed;
|
|
111
|
+
};
|
|
112
|
+
const out = await executeFuzzyFindTool(
|
|
113
|
+
{ query: 'tool-events.log' }, root, { __runRg: truncatingRunRg },
|
|
114
|
+
);
|
|
115
|
+
assert.ok(out.includes('[warning]'), `truncated broad pass must warn: ${out}`);
|
|
116
|
+
assert.ok(out.includes('truncated at 20MB cap'), `warning text must surface: ${out}`);
|
|
117
|
+
const lines = out.split('\n').filter(Boolean);
|
|
118
|
+
assert.equal(lines[0], '.mixdog/data/tool-events.log', 'exact hit must still rank first');
|
|
119
|
+
// Not cached: a normal follow-up run (no injected truncation) must NOT
|
|
120
|
+
// return the warning — proving the truncated result was never cached.
|
|
121
|
+
const out2 = await executeFuzzyFindTool({ query: 'tool-events.log' }, root);
|
|
122
|
+
assert.ok(!out2.includes('[warning]'), `truncated result must not be cached/served: ${out2}`);
|
|
123
|
+
} finally {
|
|
124
|
+
rmSync(root, { recursive: true, force: true });
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// ── narrowed backstop treats the query as a literal (glob metachars) ──────
|
|
129
|
+
|
|
130
|
+
test('a query with glob metachars still gets the narrowed backstop', async () => {
|
|
131
|
+
const root = mkdtempSync(join(tmpdir(), 'mixdog-find-glob-'));
|
|
132
|
+
try {
|
|
133
|
+
// "[slug].tsx" contains a character-class metachar; a raw *[slug].tsx*
|
|
134
|
+
// iglob would match one of s/l/u/g, not the literal filename. The tool
|
|
135
|
+
// must escape it so the exact-name file is still surfaced.
|
|
136
|
+
mkdirSync(join(root, 'app'), { recursive: true });
|
|
137
|
+
writeFileSync(join(root, 'app', '[slug].tsx'), 'x\n');
|
|
138
|
+
writeFileSync(join(root, 'app', 'slug.tsx'), 'x\n'); // class-match decoy
|
|
139
|
+
const out = await executeFuzzyFindTool({ query: '[slug].tsx' }, root);
|
|
140
|
+
const lines = out.split('\n').filter(Boolean);
|
|
141
|
+
assert.ok(lines.includes('app/[slug].tsx'), `literal metachar hit must surface: ${out}`);
|
|
142
|
+
} finally {
|
|
143
|
+
rmSync(root, { recursive: true, force: true });
|
|
144
|
+
}
|
|
145
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Regression tests for turn-time deferred-manifest construction (claude-code
|
|
2
|
+
// style). An MCP server that finishes its handshake BETWEEN session-create and
|
|
3
|
+
// the user's FIRST send is folded — on the first turn, synchronously, with no
|
|
4
|
+
// boot await — into the INITIAL <available-deferred-tools> manifest (BP1) and
|
|
5
|
+
// pre-marked announced, so the first-turn tool-catalog reconcile emits NO late
|
|
6
|
+
// <system-reminder> for it. A server that connects AFTER the first turn keeps
|
|
7
|
+
// the append-only late-reminder path. Unit-style: exercise the tool-catalog
|
|
8
|
+
// exports directly (no runtime, no spawn) the way session-turn-api/runtime-core
|
|
9
|
+
// wire them.
|
|
10
|
+
import test from 'node:test';
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import {
|
|
13
|
+
applyDeferredToolSurface,
|
|
14
|
+
refreshInitialDeferredMcpSurface,
|
|
15
|
+
reconcileDeferredMcpToolCatalog,
|
|
16
|
+
} from '../src/session-runtime/tool-catalog.mjs';
|
|
17
|
+
|
|
18
|
+
function baseSession() {
|
|
19
|
+
return {
|
|
20
|
+
provider: 'legacy',
|
|
21
|
+
tools: [{ name: 'read', description: 'read a file' }, { name: 'grep', description: 'search' }],
|
|
22
|
+
messages: [{ role: 'system', content: 'BASE PROMPT' }],
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// A freshly-created session: the create-time surface is baked WITHOUT any MCP
|
|
27
|
+
// (server still mid-handshake at create). `shell` is a deferred (non-active)
|
|
28
|
+
// standalone tool so BP1 carries a manifest block even before MCP arrives.
|
|
29
|
+
function createdSession() {
|
|
30
|
+
const session = baseSession();
|
|
31
|
+
applyDeferredToolSurface(session, 'full', [{ name: 'shell', description: 'run a command' }], { provider: 'legacy' });
|
|
32
|
+
return session;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function systemContent(session) {
|
|
36
|
+
return session.messages.find((m) => m.role === 'system').content;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const mcpTool = { name: 'mcp__unity__get_scene', description: 'Return the active Unity scene graph.' };
|
|
40
|
+
const mcpTool2 = { name: 'mcp__unity__run_tests', description: 'Run the Unity test runner.' };
|
|
41
|
+
|
|
42
|
+
// Mirror the session-turn-api first-turn gate: refresh runs ONCE, only for a
|
|
43
|
+
// session flagged fresh (deferredInitialRefreshPending) at create; a resumed
|
|
44
|
+
// session (reloaded transcript, flag absent) takes the late-reconcile path and
|
|
45
|
+
// its already-baked BP1 is never rebuilt or re-announced.
|
|
46
|
+
function firstTurnGate(session, liveMcp) {
|
|
47
|
+
if (session.deferredInitialRefreshPending) {
|
|
48
|
+
session.deferredInitialRefreshPending = false;
|
|
49
|
+
return refreshInitialDeferredMcpSurface(session, liveMcp) ? 'refreshed' : 'refresh-noop';
|
|
50
|
+
}
|
|
51
|
+
return 'late';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
test('MCP connecting between session-create and first send lands in the INITIAL manifest, not a late reminder', () => {
|
|
55
|
+
const session = createdSession();
|
|
56
|
+
assert.ok(!systemContent(session).includes('mcp__unity__get_scene'), 'MCP absent from create-time BP1');
|
|
57
|
+
|
|
58
|
+
// First-turn refresh: the live registry now includes the connected server.
|
|
59
|
+
const changed = refreshInitialDeferredMcpSurface(session, [mcpTool]);
|
|
60
|
+
assert.equal(changed, true, 'first-turn refresh folded the newly-connected MCP tool');
|
|
61
|
+
|
|
62
|
+
const sys = systemContent(session);
|
|
63
|
+
assert.ok(sys.includes('<available-deferred-tools>'), 'manifest block present');
|
|
64
|
+
assert.ok(sys.includes('mcp__unity__get_scene'), 'MCP tool in the INITIAL manifest');
|
|
65
|
+
assert.ok(session.deferredAnnouncedTools.includes('mcp__unity__get_scene'), 'MCP tool pre-marked announced');
|
|
66
|
+
|
|
67
|
+
let enqueued = null;
|
|
68
|
+
const result = reconcileDeferredMcpToolCatalog(session, [mcpTool], {
|
|
69
|
+
enqueue: (text) => { enqueued = text; return true; },
|
|
70
|
+
});
|
|
71
|
+
assert.equal(result, null, 'no late-tool announcement for a first-turn-folded tool');
|
|
72
|
+
assert.equal(enqueued, null, 'nothing enqueued for a first-turn-folded tool');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('an MCP server connecting AFTER the first turn is still announced via the late reminder', () => {
|
|
76
|
+
const session = createdSession();
|
|
77
|
+
// First turn: no MCP connected yet — nothing to fold.
|
|
78
|
+
assert.equal(refreshInitialDeferredMcpSurface(session, []), false, 'no live MCP => first-turn no-op');
|
|
79
|
+
assert.ok(!(session.deferredAnnouncedTools || []).includes('mcp__unity__get_scene'), 'MCP not pre-announced');
|
|
80
|
+
|
|
81
|
+
// A later turn: the server has since connected → late path fires.
|
|
82
|
+
let enqueued = null;
|
|
83
|
+
const result = reconcileDeferredMcpToolCatalog(session, [mcpTool], {
|
|
84
|
+
enqueue: (text) => { enqueued = text; return true; },
|
|
85
|
+
});
|
|
86
|
+
assert.deepEqual(result, ['mcp__unity__get_scene'], 'late tool announced');
|
|
87
|
+
assert.ok(enqueued && enqueued.includes('mcp__unity__get_scene'), 'late reminder enqueued');
|
|
88
|
+
assert.ok(enqueued.includes('connected after this session started'), 'reminder carries the late-tool sentinel');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('recreated session (MCP already connected at create) seeds its manifest, no late re-announce', () => {
|
|
92
|
+
// Recreate/reset path: createCurrentSession folds the live MCP tools into the
|
|
93
|
+
// surface at create time, so a cwd-change recreate seeds its BP1 directly and
|
|
94
|
+
// never needs the first-turn refresh.
|
|
95
|
+
const session = baseSession();
|
|
96
|
+
applyDeferredToolSurface(session, 'full', [{ name: 'shell', description: 'run a command' }, mcpTool], { provider: 'legacy' });
|
|
97
|
+
assert.ok(systemContent(session).includes('mcp__unity__get_scene'), 'MCP in recreated BP1');
|
|
98
|
+
assert.ok(session.deferredAnnouncedTools.includes('mcp__unity__get_scene'), 'recreated MCP pre-marked announced');
|
|
99
|
+
|
|
100
|
+
let enqueued = null;
|
|
101
|
+
const result = reconcileDeferredMcpToolCatalog(session, [mcpTool], {
|
|
102
|
+
enqueue: (text) => { enqueued = text; return true; },
|
|
103
|
+
});
|
|
104
|
+
assert.equal(result, null, 'no late announcement on recreate');
|
|
105
|
+
assert.equal(enqueued, null, 'nothing enqueued on recreate');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('first-turn refresh is idempotent: a re-render with no new MCP is a no-op and keeps ONE manifest block', () => {
|
|
109
|
+
const session = createdSession();
|
|
110
|
+
assert.equal(refreshInitialDeferredMcpSurface(session, [mcpTool]), true, 'first fold applies');
|
|
111
|
+
const once = systemContent(session);
|
|
112
|
+
assert.equal(refreshInitialDeferredMcpSurface(session, [mcpTool]), false, 'no genuinely-new MCP => no-op');
|
|
113
|
+
const twice = systemContent(session);
|
|
114
|
+
assert.equal(once, twice, 'BP1 byte-identical on re-render');
|
|
115
|
+
assert.equal((twice.match(/<available-deferred-tools>/g) || []).length, 1, 'exactly one manifest block (no duplicate)');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('a second newly-connected MCP tool re-renders BP1 in place (both listed, still one block)', () => {
|
|
119
|
+
const session = createdSession();
|
|
120
|
+
refreshInitialDeferredMcpSurface(session, [mcpTool]);
|
|
121
|
+
assert.equal(refreshInitialDeferredMcpSurface(session, [mcpTool, mcpTool2]), true, 're-fold applies the new tool');
|
|
122
|
+
const sys = systemContent(session);
|
|
123
|
+
assert.ok(sys.includes('mcp__unity__get_scene') && sys.includes('mcp__unity__run_tests'), 'both MCP tools listed');
|
|
124
|
+
assert.equal((sys.match(/<available-deferred-tools>/g) || []).length, 1, 'still exactly one block (rebuilt in place)');
|
|
125
|
+
assert.ok(session.deferredAnnouncedTools.includes('mcp__unity__run_tests'), 'the new tool is pre-announced too');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('a resumed session (no fresh flag, prior baked BP1) is NOT refreshed on its next turn', () => {
|
|
129
|
+
// A prior run baked BP1 with the MCP tool; resume reloads that transcript.
|
|
130
|
+
const session = baseSession();
|
|
131
|
+
applyDeferredToolSurface(session, 'full', [{ name: 'shell', description: 'run a command' }, mcpTool], { provider: 'legacy' });
|
|
132
|
+
const before = systemContent(session);
|
|
133
|
+
const announcedBefore = [...session.deferredAnnouncedTools];
|
|
134
|
+
// Resume path never sets the per-session fresh flag.
|
|
135
|
+
assert.equal(session.deferredInitialRefreshPending, undefined, 'resumed session carries no fresh flag');
|
|
136
|
+
const route = firstTurnGate(session, [mcpTool, mcpTool2]);
|
|
137
|
+
assert.equal(route, 'late', 'resumed session takes the late-reconcile path, not the initial refresh');
|
|
138
|
+
assert.equal(systemContent(session), before, 'BP1 untouched on resume (no rebuild)');
|
|
139
|
+
assert.deepEqual([...session.deferredAnnouncedTools], announcedBefore, 'announced set unchanged on resume');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('a fresh session (flagged) is refreshed exactly once, then falls to the late path', () => {
|
|
143
|
+
const session = createdSession();
|
|
144
|
+
session.deferredInitialRefreshPending = true;
|
|
145
|
+
assert.equal(firstTurnGate(session, [mcpTool]), 'refreshed', 'fresh session refreshes on its first turn');
|
|
146
|
+
assert.equal(session.deferredInitialRefreshPending, false, 'fresh flag consumed (one-shot)');
|
|
147
|
+
assert.ok(session.deferredAnnouncedTools.includes('mcp__unity__get_scene'), 'first-turn MCP pre-announced');
|
|
148
|
+
assert.equal(firstTurnGate(session, [mcpTool2]), 'late', 'second turn no longer refreshes');
|
|
149
|
+
});
|
|
@@ -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
|
+
});
|
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']) {
|
|
@@ -1050,32 +1050,28 @@ const prevChannelSingleton = process.env.MIXDOG_CHANNEL_SINGLETON;
|
|
|
1050
1050
|
const prevChannelWorkerProcess = process.env.MIXDOG_CHANNEL_WORKER_PROCESS;
|
|
1051
1051
|
const prevRuntimeRoot = process.env.MIXDOG_RUNTIME_ROOT;
|
|
1052
1052
|
const prevEnvOut = process.env.SMOKE_CHANNEL_ENV_OUT;
|
|
1053
|
+
const prevDaemonEntry = process.env.MIXDOG_CHANNEL_DAEMON_ENTRY;
|
|
1053
1054
|
try {
|
|
1054
|
-
|
|
1055
|
+
// Daemon-mode worker env coverage: start() spawn-or-attaches the machine
|
|
1056
|
+
// -global daemon (the stub daemon entry — no Discord token) instead of
|
|
1057
|
+
// forking `entry`, so assert the flags on the SPAWNED DAEMON's env (the stub
|
|
1058
|
+
// dumps them to SMOKE_CHANNEL_ENV_OUT). The old fork-path env assertion died
|
|
1059
|
+
// with the fork path itself; full flip/attach coverage lives in
|
|
1060
|
+
// scripts/channel-daemon-smoke.mjs.
|
|
1061
|
+
const stubEntry = join(root, 'scripts', 'channel-daemon-stub.mjs');
|
|
1055
1062
|
const dataDir = join(channelWorkerTmp, 'data');
|
|
1056
1063
|
const runtimeDir = join(channelWorkerTmp, 'runtime');
|
|
1057
1064
|
const envOut = join(channelWorkerTmp, 'env.json');
|
|
1058
1065
|
mkdirSync(dataDir, { recursive: true });
|
|
1059
1066
|
mkdirSync(runtimeDir, { recursive: true });
|
|
1060
|
-
writeFileSync(entry, `
|
|
1061
|
-
import { writeFileSync } from 'node:fs';
|
|
1062
|
-
writeFileSync(process.env.SMOKE_CHANNEL_ENV_OUT, JSON.stringify({
|
|
1063
|
-
cliOwned: process.env.MIXDOG_CLI_OWNED,
|
|
1064
|
-
daemon: process.env.MIXDOG_CHANNEL_DAEMON,
|
|
1065
|
-
}));
|
|
1066
|
-
process.send?.({ type: 'ready' });
|
|
1067
|
-
process.on('message', (msg) => {
|
|
1068
|
-
if (msg?.type === 'shutdown') process.exit(0);
|
|
1069
|
-
});
|
|
1070
|
-
setInterval(() => {}, 10000);
|
|
1071
|
-
`);
|
|
1072
1067
|
process.env.MIXDOG_CHANNEL_DAEMON = '1';
|
|
1073
1068
|
process.env.MIXDOG_CHANNEL_SINGLETON = '1';
|
|
1074
1069
|
process.env.MIXDOG_CHANNEL_WORKER_PROCESS = '1';
|
|
1075
1070
|
process.env.MIXDOG_RUNTIME_ROOT = runtimeDir;
|
|
1076
1071
|
process.env.SMOKE_CHANNEL_ENV_OUT = envOut;
|
|
1072
|
+
process.env.MIXDOG_CHANNEL_DAEMON_ENTRY = stubEntry;
|
|
1077
1073
|
channelEnvWorker = createStandaloneChannelWorker({
|
|
1078
|
-
entry,
|
|
1074
|
+
entry: stubEntry,
|
|
1079
1075
|
rootDir: root,
|
|
1080
1076
|
dataDir,
|
|
1081
1077
|
cwd: root,
|
|
@@ -1100,6 +1096,11 @@ setInterval(() => {}, 10000);
|
|
|
1100
1096
|
else process.env.MIXDOG_RUNTIME_ROOT = prevRuntimeRoot;
|
|
1101
1097
|
if (prevEnvOut == null) delete process.env.SMOKE_CHANNEL_ENV_OUT;
|
|
1102
1098
|
else process.env.SMOKE_CHANNEL_ENV_OUT = prevEnvOut;
|
|
1099
|
+
if (prevDaemonEntry == null) delete process.env.MIXDOG_CHANNEL_DAEMON_ENTRY;
|
|
1100
|
+
else process.env.MIXDOG_CHANNEL_DAEMON_ENTRY = prevDaemonEntry;
|
|
1101
|
+
// Detach only ends OUR attachment; the stub daemon self-shuts after its
|
|
1102
|
+
// client-grace window. Give it that window before deleting its tmp root.
|
|
1103
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, 700));
|
|
1103
1104
|
rmSync(channelWorkerTmp, { recursive: true, force: true });
|
|
1104
1105
|
}
|
|
1105
1106
|
|
|
@@ -1191,10 +1192,10 @@ if (EXPLORE_TOOL.annotations?.agentHidden === true) {
|
|
|
1191
1192
|
}
|
|
1192
1193
|
}
|
|
1193
1194
|
const exploreProps = EXPLORE_TOOL.inputSchema?.properties || {};
|
|
1194
|
-
if (!/
|
|
1195
|
-
throw new Error('explore description must
|
|
1195
|
+
if (!/broad\/uncertain/i.test(EXPLORE_TOOL.description || '') || !/machine-wide/i.test(EXPLORE_TOOL.description || '') || !/independent targets/i.test(EXPLORE_TOOL.description || '') || (EXPLORE_TOOL.description || '').length > 600) {
|
|
1196
|
+
throw new Error('explore description must keep the locator + facet fan-out contract');
|
|
1196
1197
|
}
|
|
1197
|
-
if (!/Narrow locator query/i.test(exploreProps.query?.description || '') || !/independent
|
|
1198
|
+
if (!/Narrow locator query/i.test(exploreProps.query?.description || '') || !/independent facets/i.test(exploreProps.query?.description || '') || !/Project\/root/i.test(exploreProps.cwd?.description || '')) {
|
|
1198
1199
|
throw new Error('explore schema must stay compact and preserve query/cwd shape');
|
|
1199
1200
|
}
|
|
1200
1201
|
const normalizedExplore = normalizeExploreQueries('["where is model selection?"," ","which file owns agent async?"]');
|
|
@@ -1359,8 +1360,8 @@ setInternalToolsProvider({
|
|
|
1359
1360
|
throw new Error(`agent context must not inject environment reminder: ${visible.slice(0, 1200)}`);
|
|
1360
1361
|
}
|
|
1361
1362
|
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
|
|
1363
|
+
if (workerToolNames.includes('load_tool')) {
|
|
1364
|
+
throw new Error(`agent session schema must not expose deferred load_tool: ${workerToolNames.join(', ')}`);
|
|
1364
1365
|
}
|
|
1365
1366
|
for (const name of ['shell', 'task']) {
|
|
1366
1367
|
if (!workerToolNames.includes(name)) {
|
|
@@ -1412,7 +1413,11 @@ setInternalToolsProvider({
|
|
|
1412
1413
|
const writeTools = (writeAgentSession.tools || []).map((tool) => tool?.name).filter(Boolean);
|
|
1413
1414
|
const fullTools = (fullAgentSession.tools || []).map((tool) => tool?.name).filter(Boolean);
|
|
1414
1415
|
const publicExploreTools = (publicExploreSession.tools || []).map((tool) => tool?.name).filter(Boolean);
|
|
1415
|
-
|
|
1416
|
+
// Read-role AGENT sessions carry shell/task so review/debug agents can run
|
|
1417
|
+
// their own verification (build/test); the plain readonly preset (public
|
|
1418
|
+
// explore role) still omits them.
|
|
1419
|
+
const expectedReadTools = ['code_graph', 'find', 'glob', 'list', 'grep', 'read', 'shell', 'task', 'explore', 'search', 'web_fetch', 'Skill'];
|
|
1420
|
+
const expectedPublicReadTools = ['code_graph', 'find', 'glob', 'list', 'grep', 'read', 'explore', 'search', 'web_fetch', 'Skill'];
|
|
1416
1421
|
const expectedWriteTools = ['code_graph', 'find', 'glob', 'list', 'grep', 'read', 'apply_patch', 'shell', 'task', 'explore', 'search', 'web_fetch', 'Skill'];
|
|
1417
1422
|
if (JSON.stringify(readTools) !== JSON.stringify(expectedReadTools)) {
|
|
1418
1423
|
throw new Error(`read agent schema must be fixed allow-list: expected=${expectedReadTools.join(', ')} actual=${readTools.join(', ')}`);
|
|
@@ -1420,15 +1425,18 @@ setInternalToolsProvider({
|
|
|
1420
1425
|
if (JSON.stringify(writeTools) !== JSON.stringify(expectedWriteTools)) {
|
|
1421
1426
|
throw new Error(`read-write agent schema must be fixed allow-list: expected=${expectedWriteTools.join(', ')} actual=${writeTools.join(', ')}`);
|
|
1422
1427
|
}
|
|
1423
|
-
if (readTools.includes('
|
|
1424
|
-
throw new Error(`agent session fixed schemas must omit
|
|
1428
|
+
if (readTools.includes('load_tool') || writeTools.includes('load_tool')) {
|
|
1429
|
+
throw new Error(`agent session fixed schemas must omit load_tool: read=${readTools.join(', ')} write=${writeTools.join(', ')}`);
|
|
1425
1430
|
}
|
|
1426
|
-
if (readTools.includes('
|
|
1427
|
-
throw new Error(`read agent schema must omit
|
|
1431
|
+
if (readTools.includes('apply_patch')) {
|
|
1432
|
+
throw new Error(`read agent schema must omit apply_patch: read=${readTools.join(', ')}`);
|
|
1428
1433
|
}
|
|
1429
|
-
for (const name of ['shell', '
|
|
1430
|
-
if (readTools.includes(name)) {
|
|
1431
|
-
throw new Error(`read agent schema must
|
|
1434
|
+
for (const name of ['shell', 'task']) {
|
|
1435
|
+
if (!readTools.includes(name)) {
|
|
1436
|
+
throw new Error(`read agent schema must carry verification tool ${name}: read=${readTools.join(', ')}`);
|
|
1437
|
+
}
|
|
1438
|
+
if (publicExploreTools.includes(name)) {
|
|
1439
|
+
throw new Error(`public explore role must omit ${name}: explore=${publicExploreTools.join(', ')}`);
|
|
1432
1440
|
}
|
|
1433
1441
|
}
|
|
1434
1442
|
for (const name of ['apply_patch', 'shell', 'task']) {
|
|
@@ -1450,13 +1458,13 @@ setInternalToolsProvider({
|
|
|
1450
1458
|
if (!fullTools.includes('explore')) {
|
|
1451
1459
|
throw new Error(`full agent schema must expose explore: full=${fullTools.join(', ')}`);
|
|
1452
1460
|
}
|
|
1453
|
-
//
|
|
1454
|
-
//
|
|
1455
|
-
//
|
|
1456
|
-
//
|
|
1457
|
-
//
|
|
1458
|
-
if (JSON.stringify(publicExploreTools) !== JSON.stringify(
|
|
1459
|
-
throw new Error(`public explore role must ship the
|
|
1461
|
+
// The explore wrapper stays IN the schema for every read role — including
|
|
1462
|
+
// the explore agent itself. Recursion is broken at call time in
|
|
1463
|
+
// pre-dispatch-deny.mjs via recursiveWrapperToolNameForPublicAgent, not by
|
|
1464
|
+
// schema stripping. (Read AGENT sessions add shell/task on top, so the
|
|
1465
|
+
// public explore bundle is its own cache group now.)
|
|
1466
|
+
if (JSON.stringify(publicExploreTools) !== JSON.stringify(expectedPublicReadTools)) {
|
|
1467
|
+
throw new Error(`public explore role must ship the readonly bundle (incl. explore): expected=${expectedPublicReadTools.join(', ')} actual=${publicExploreTools.join(', ')}`);
|
|
1460
1468
|
}
|
|
1461
1469
|
if (recursiveWrapperToolNameForPublicAgent('explore') !== 'explore') {
|
|
1462
1470
|
throw new Error('call-time anti-recursion must map public explore agent to its own wrapper tool');
|
|
@@ -1846,25 +1854,38 @@ if (!/Use after search/i.test(webFetchTool?.description || '') || !webFetchProps
|
|
|
1846
1854
|
if (!/offset/i.test(webFetchProps.startIndex?.description || '') || !/Maximum characters/i.test(webFetchProps.maxLength?.description || '')) {
|
|
1847
1855
|
throw new Error('web_fetch schema must describe paging window fields');
|
|
1848
1856
|
}
|
|
1849
|
-
if (!/deferred tools/i.test(TOOL_SEARCH_TOOL.description || '')
|
|
1850
|
-
|
|
1857
|
+
if (!/deferred tools/i.test(TOOL_SEARCH_TOOL.description || '')
|
|
1858
|
+
|| !TOOL_SEARCH_TOOL.inputSchema?.properties?.names
|
|
1859
|
+
|| !TOOL_SEARCH_TOOL.inputSchema?.properties?.select) {
|
|
1860
|
+
throw new Error('load_tool schema must preserve loader guidance plus names + legacy select fields');
|
|
1851
1861
|
}
|
|
1852
1862
|
const toolSearchSession = {
|
|
1853
1863
|
tools: smokeCatalog.filter((tool) => fullDefaults.has(tool?.name)),
|
|
1854
1864
|
deferredToolCatalog: smokeCatalog.slice(),
|
|
1855
1865
|
deferredSelectedTools: [...fullDefaults],
|
|
1856
1866
|
};
|
|
1857
|
-
//
|
|
1858
|
-
//
|
|
1867
|
+
// load_tool is a pure loader: a free-text query is NOT a search. It loads
|
|
1868
|
+
// nothing, returns an error steering to names[], and never activates tools.
|
|
1859
1869
|
const listQueryResult = JSON.parse(__renderToolSearchForTest({ query: 'shell' }, toolSearchSession, 'full'));
|
|
1860
|
-
if (listQueryResult.selected) {
|
|
1861
|
-
throw new Error(`
|
|
1870
|
+
if (listQueryResult.selected || (Array.isArray(listQueryResult.loaded) && listQueryResult.loaded.length)) {
|
|
1871
|
+
throw new Error(`load_tool free-text query must not load: ${JSON.stringify(listQueryResult)}`);
|
|
1862
1872
|
}
|
|
1863
|
-
if (!listQueryResult.
|
|
1864
|
-
throw new Error(`
|
|
1873
|
+
if (!listQueryResult.error || !/names/i.test(listQueryResult.error)) {
|
|
1874
|
+
throw new Error(`load_tool free-text query must steer to names[]: ${JSON.stringify(listQueryResult)}`);
|
|
1865
1875
|
}
|
|
1866
1876
|
if (listQueryResult.activeTools.includes('shell') || (Array.isArray(listQueryResult.discoveredTools) && listQueryResult.discoveredTools.includes('shell'))) {
|
|
1867
|
-
throw new Error(`
|
|
1877
|
+
throw new Error(`load_tool free-text query must not activate/discover tools: ${JSON.stringify(listQueryResult)}`);
|
|
1878
|
+
}
|
|
1879
|
+
// names[] is the primary loader input (aliases expand, tools activate).
|
|
1880
|
+
const namesLoadResult = JSON.parse(__renderToolSearchForTest({ names: ['shell', 'recall'] }, {
|
|
1881
|
+
tools: smokeCatalog.filter((tool) => fullDefaults.has(tool?.name)),
|
|
1882
|
+
deferredToolCatalog: smokeCatalog.slice(),
|
|
1883
|
+
deferredSelectedTools: [...fullDefaults],
|
|
1884
|
+
}, 'full'));
|
|
1885
|
+
for (const name of ['shell', 'recall']) {
|
|
1886
|
+
if (!namesLoadResult.activeTools.includes(name) || !namesLoadResult.loaded.includes(name)) {
|
|
1887
|
+
throw new Error(`load_tool names[] must load ${name}: ${JSON.stringify(namesLoadResult)}`);
|
|
1888
|
+
}
|
|
1868
1889
|
}
|
|
1869
1890
|
// query "select:a,b" is the explicit query-side loader (aliases expand).
|
|
1870
1891
|
const bulkSelectResult = JSON.parse(__renderToolSearchForTest({ query: 'select:shell,recall' }, toolSearchSession, 'full'));
|
|
@@ -22,6 +22,12 @@ synonyms, library/domain names), plus `find` with name fragments, plus `code_gra
|
|
|
22
22
|
symbol_search when the query names an identifier. A single-pattern,
|
|
23
23
|
single-tool first turn is a defect.
|
|
24
24
|
|
|
25
|
+
A turn-1 broad grep MUST set `output_mode:"files_with_matches"` — an
|
|
26
|
+
unscoped `content`/`content_with_context` scan reads every match body and is
|
|
27
|
+
a defect (a full-content scan across the tree costs seconds). Use
|
|
28
|
+
`content_with_context` ONLY against a `path` that appeared in an earlier
|
|
29
|
+
result THIS session, and always with `head_limit`.
|
|
30
|
+
|
|
25
31
|
Search tokens are CODE tokens: first translate natural-language or
|
|
26
32
|
non-English queries into probable English identifiers (e.g. "최대 루프 반복
|
|
27
33
|
횟수" → maxLoop, loop-policy, iterations). Grep non-ASCII text only when
|
|
@@ -4,12 +4,19 @@
|
|
|
4
4
|
prior result. Merge equivalent variants/scopes into ONE call wherever the
|
|
5
5
|
schema accepts arrays (`pattern[]`, `path[]`, `symbols[]`, `query[]`).
|
|
6
6
|
- Route by what is already known: known symbol/relation → `code_graph`;
|
|
7
|
-
exact text in a known scope → `grep`; unknown location
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
exact text in a known scope → `grep`; unknown location, machine-wide/
|
|
8
|
+
out-of-repo whereabouts, or concept-level question → `explore` (which uses
|
|
9
|
+
the hardened `find` internally); name fragment → `find`; exact name pattern
|
|
10
|
+
→ `glob`; known directory → `list`; known file/region → `read`.
|
|
11
|
+
- `explore` fan-out: at task start, decompose what the task needs to know
|
|
12
|
+
into independent facets (implementation site, config/load path, tests,
|
|
13
|
+
error origin, ...) and send them as ONE `query[]` call — facets run in
|
|
14
|
+
parallel. Never fan out rephrasings of the same target; on
|
|
15
|
+
EXPLORATION_FAILED, retry once with changed tokens.
|
|
10
16
|
- Valid anchors come from user input or tool output in this session; locate
|
|
11
17
|
anything else with `find` before `grep`/`read`. On ENOENT the next call is
|
|
12
|
-
`find` on the basename — never a retried guess
|
|
18
|
+
`find` on the basename — never a retried guess; and never guess an absolute
|
|
19
|
+
path outside the project — `find` from a verified broad root instead.
|
|
13
20
|
- Retrieval stops when evidence covers the deliverable: single-answer tasks
|
|
14
21
|
end at the first sufficient anchor; enumeration tasks (review, audit) end
|
|
15
22
|
when the stated scope is covered. Never re-verify a hit already on screen;
|