mixdog 0.9.37 → 0.9.39
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/package.json +9 -4
- package/scripts/abort-recovery-test.mjs +43 -2
- package/scripts/agent-tag-reuse-smoke.mjs +146 -6
- package/scripts/agent-terminal-reap-test.mjs +127 -0
- package/scripts/agent-trace-io-test.mjs +69 -0
- package/scripts/code-graph-disk-hit-test.mjs +224 -0
- package/scripts/dispatch-persist-recovery-test.mjs +141 -0
- package/scripts/execution-completion-dedup-test.mjs +157 -0
- package/scripts/execution-pending-resume-kick-test.mjs +57 -2
- package/scripts/execution-resume-esc-integration-test.mjs +174 -0
- package/scripts/explore-bench.mjs +101 -8
- package/scripts/explore-prompt-policy-test.mjs +156 -11
- package/scripts/find-fuzzy-hidden-test.mjs +122 -0
- package/scripts/internal-comms-bench-test.mjs +226 -0
- package/scripts/internal-comms-bench.mjs +185 -58
- package/scripts/internal-comms-smoke.mjs +171 -23
- package/scripts/live-worker-smoke.mjs +38 -2
- package/scripts/memory-cycle-routing-test.mjs +111 -0
- package/scripts/memory-rule-contract-test.mjs +93 -0
- package/scripts/notify-completion-mirror-test.mjs +73 -0
- package/scripts/output-style-smoke.mjs +2 -2
- package/scripts/rg-runner-test.mjs +240 -0
- package/scripts/routing-corpus-test.mjs +349 -0
- package/scripts/routing-corpus.mjs +211 -32
- package/scripts/session-orphan-sweep-test.mjs +83 -0
- package/scripts/session-sweep.mjs +266 -0
- package/scripts/steering-drain-buckets-test.mjs +179 -0
- package/scripts/tool-smoke.mjs +21 -13
- package/scripts/tool-tui-presentation-test.mjs +202 -0
- package/src/agents/heavy-worker/AGENT.md +10 -7
- package/src/agents/reviewer/AGENT.md +6 -4
- package/src/agents/worker/AGENT.md +7 -5
- package/src/rules/agent/00-common.md +4 -4
- package/src/rules/agent/00-core.md +11 -14
- package/src/rules/agent/20-skip-protocol.md +3 -3
- package/src/rules/agent/30-explorer.md +56 -48
- package/src/rules/agent/40-cycle1-agent.md +15 -24
- package/src/rules/agent/41-cycle2-agent.md +33 -57
- package/src/rules/agent/42-cycle3-agent.md +28 -42
- package/src/rules/lead/01-general.md +7 -10
- package/src/rules/lead/lead-brief.md +11 -14
- package/src/rules/lead/lead-tool.md +6 -5
- package/src/rules/shared/01-tool.md +44 -41
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +37 -1
- package/src/runtime/agent/orchestrator/agent-trace-io.mjs +20 -5
- package/src/runtime/agent/orchestrator/dispatch-persist.mjs +31 -8
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +13 -7
- package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +21 -1
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +6 -6
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +17 -38
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +5 -4
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +119 -3
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +17 -8
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +270 -78
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +2 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +18 -52
- package/src/runtime/agent/orchestrator/session/manager/delivered-completions.mjs +123 -0
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +15 -2
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +41 -2
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/store.mjs +339 -63
- package/src/runtime/agent/orchestrator/stall-policy.mjs +37 -0
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +12 -1
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +25 -20
- package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +6 -2
- package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +118 -53
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +106 -29
- package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +8 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +4 -2
- package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +3 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +4 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +33 -2
- package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +151 -64
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +37 -13
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +84 -49
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +172 -23
- package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +3 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +68 -5
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +17 -3
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +24 -10
- package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +6 -3
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -7
- package/src/runtime/agent/orchestrator/tools/code-graph.mjs +1 -0
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -4
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +4 -3
- package/src/runtime/memory/lib/memory-cycle3.mjs +12 -2
- package/src/runtime/shared/tool-primitives.mjs +4 -1
- package/src/runtime/shared/tool-status.mjs +27 -0
- package/src/runtime/shared/tool-surface.mjs +6 -3
- package/src/session-runtime/config-helpers.mjs +14 -0
- package/src/session-runtime/context-status.mjs +1 -0
- package/src/session-runtime/effort.mjs +6 -2
- package/src/session-runtime/model-recency.mjs +5 -2
- package/src/session-runtime/provider-models.mjs +3 -3
- package/src/session-runtime/runtime-core.mjs +78 -10
- package/src/session-runtime/tool-catalog.mjs +34 -0
- package/src/session-runtime/warmup-schedulers.mjs +7 -1
- package/src/standalone/agent-tool/notify.mjs +13 -0
- package/src/standalone/agent-tool.mjs +45 -69
- package/src/standalone/explore-tool.mjs +6 -7
- package/src/tui/App.jsx +31 -0
- package/src/tui/app/model-options.mjs +5 -3
- package/src/tui/app/model-picker.mjs +12 -24
- package/src/tui/app/transcript-window.mjs +1 -0
- package/src/tui/components/ToolExecution.jsx +11 -6
- package/src/tui/components/TranscriptItem.jsx +1 -1
- package/src/tui/components/tool-execution/surface-detail.mjs +24 -10
- package/src/tui/components/tool-execution/text-format.mjs +10 -19
- package/src/tui/dist/index.mjs +533 -143
- package/src/tui/engine/agent-job-feed.mjs +153 -16
- package/src/tui/engine/agent-response-tail.mjs +68 -0
- package/src/tui/engine/notification-plan.mjs +16 -0
- package/src/tui/engine/queue-helpers.mjs +8 -0
- package/src/tui/engine/session-api.mjs +8 -2
- package/src/tui/engine/session-flow.mjs +34 -2
- package/src/tui/engine/tool-card-results.mjs +54 -32
- package/src/tui/engine/tool-result-status.mjs +75 -21
- package/src/tui/engine/turn.mjs +83 -43
- package/src/tui/engine.mjs +63 -2
- package/src/workflows/bench/WORKFLOW.md +25 -35
- package/src/workflows/default/WORKFLOW.md +38 -32
- package/src/workflows/solo/WORKFLOW.md +19 -22
- package/scripts/_jitter-fuzz.mjs +0 -44410
- package/scripts/_jitter-fuzz2.mjs +0 -44400
- package/scripts/_jitter-probe.mjs +0 -44397
- package/scripts/_jp2.mjs +0 -45614
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Production-path regression: runtime completion notification -> feed enqueue
|
|
2
|
+
// -> session drain -> real runTurn -> Esc. This deliberately uses the real
|
|
3
|
+
// feed, queue, API, and turn implementations rather than fabricating restore
|
|
4
|
+
// state, so ownership survives the same path used by the TUI.
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
|
|
8
|
+
import { createAgentJobFeed } from '../src/tui/engine/agent-job-feed.mjs';
|
|
9
|
+
import { createSessionFlow } from '../src/tui/engine/session-flow.mjs';
|
|
10
|
+
import { createRunTurn } from '../src/tui/engine/turn.mjs';
|
|
11
|
+
import { createEngineApiA } from '../src/tui/engine/session-api.mjs';
|
|
12
|
+
import { _clearDeliveredCompletions } from '../src/runtime/agent/orchestrator/session/manager/delivered-completions.mjs';
|
|
13
|
+
|
|
14
|
+
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
|
15
|
+
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
16
|
+
|
|
17
|
+
function completion(executionId, body) {
|
|
18
|
+
return {
|
|
19
|
+
content: `Async agent task ${executionId} completed finished.\n\nResult:\n> ${body}`,
|
|
20
|
+
meta: { type: 'agent_task_result', execution_id: executionId, status: 'completed' },
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function makeHarness() {
|
|
25
|
+
let seq = 0;
|
|
26
|
+
let notify = null;
|
|
27
|
+
let activeAsk = null;
|
|
28
|
+
let feed = null;
|
|
29
|
+
const asks = [];
|
|
30
|
+
const pending = [];
|
|
31
|
+
const itemIndexById = new Map();
|
|
32
|
+
const state = {
|
|
33
|
+
items: [],
|
|
34
|
+
queued: [],
|
|
35
|
+
busy: false,
|
|
36
|
+
commandBusy: false,
|
|
37
|
+
spinner: null,
|
|
38
|
+
thinking: null,
|
|
39
|
+
lastTurn: null,
|
|
40
|
+
stats: { turns: 0, inputTokens: 0, outputTokens: 0 },
|
|
41
|
+
};
|
|
42
|
+
const runtime = {
|
|
43
|
+
id: null,
|
|
44
|
+
toolMode: 'auto',
|
|
45
|
+
onNotification: (fn) => { notify = fn; return () => {}; },
|
|
46
|
+
consumePendingSessionReset: () => null,
|
|
47
|
+
ask: async (text, options) => new Promise((resolve, reject) => {
|
|
48
|
+
activeAsk = { text, options, reject };
|
|
49
|
+
asks.push(activeAsk);
|
|
50
|
+
}),
|
|
51
|
+
abort: () => {
|
|
52
|
+
const error = new Error('interrupted');
|
|
53
|
+
error.name = 'SessionClosedError';
|
|
54
|
+
activeAsk?.reject(error);
|
|
55
|
+
return true;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
const bag = {
|
|
59
|
+
runtime,
|
|
60
|
+
nextId: () => `id_${++seq}`,
|
|
61
|
+
tuiDebug: () => {},
|
|
62
|
+
LEAD_TURN_TIMEOUT_MS: 300_000,
|
|
63
|
+
flags: { leadTurnEpoch: 0, drainEpoch: 0, disposed: false, draining: false, activePromptRestore: null },
|
|
64
|
+
pending,
|
|
65
|
+
pendingNotificationKeys: new Set(),
|
|
66
|
+
displayedExecutionNotificationKeys: new Set(),
|
|
67
|
+
listeners: new Set(),
|
|
68
|
+
itemIndexById,
|
|
69
|
+
getState: () => state,
|
|
70
|
+
set: (patch) => Object.assign(state, patch),
|
|
71
|
+
pushItem: (item) => {
|
|
72
|
+
state.items = [...state.items, item];
|
|
73
|
+
if (item?.id != null) itemIndexById.set(item.id, state.items.length - 1);
|
|
74
|
+
},
|
|
75
|
+
patchItem: (id, patch) => {
|
|
76
|
+
const index = state.items.findIndex((item) => item.id === id);
|
|
77
|
+
if (index < 0) return false;
|
|
78
|
+
state.items = state.items.map((item, i) => i === index ? { ...item, ...patch } : item);
|
|
79
|
+
return true;
|
|
80
|
+
},
|
|
81
|
+
replaceItems: (items) => items,
|
|
82
|
+
pushNotice: () => {},
|
|
83
|
+
pushUserOrSyntheticItem: (text, id) => {
|
|
84
|
+
state.items = [...state.items, { kind: 'injected', id, text }];
|
|
85
|
+
},
|
|
86
|
+
autoClearState: () => ({ enabled: false }),
|
|
87
|
+
agentStatusState: () => ({}),
|
|
88
|
+
routeState: () => ({}),
|
|
89
|
+
syncContextStats: () => {},
|
|
90
|
+
denyAllToolApprovals: () => {},
|
|
91
|
+
requestToolApproval: async () => ({ approved: false }),
|
|
92
|
+
markToolCallActive: () => {},
|
|
93
|
+
markToolCallDone: () => {},
|
|
94
|
+
clearActiveToolSummary: () => {},
|
|
95
|
+
patchToolCardResult: () => {},
|
|
96
|
+
flushToolResults: () => {},
|
|
97
|
+
// session-flow captures this dependency before the feed is constructed;
|
|
98
|
+
// keep that production ordering while forwarding to the live feed.
|
|
99
|
+
flushDeferredExecutionPendingResumeKick: () => feed?.flushDeferredExecutionPendingResumeKick(),
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
Object.assign(bag, createSessionFlow(bag));
|
|
103
|
+
feed = createAgentJobFeed({
|
|
104
|
+
runtime,
|
|
105
|
+
getState: bag.getState,
|
|
106
|
+
set: bag.set,
|
|
107
|
+
nextId: bag.nextId,
|
|
108
|
+
getDisposed: () => bag.flags.disposed,
|
|
109
|
+
patchItem: bag.patchItem,
|
|
110
|
+
enqueue: (...args) => bag.enqueue(...args),
|
|
111
|
+
drain: (...args) => bag.drain(...args),
|
|
112
|
+
pushUserOrSyntheticItem: bag.pushUserOrSyntheticItem,
|
|
113
|
+
makeQueueEntry: (...args) => bag.makeQueueEntry(...args),
|
|
114
|
+
getPending: () => pending,
|
|
115
|
+
agentStatusState: bag.agentStatusState,
|
|
116
|
+
displayedExecutionNotificationKeys: bag.displayedExecutionNotificationKeys,
|
|
117
|
+
pushNotice: bag.pushNotice,
|
|
118
|
+
});
|
|
119
|
+
Object.assign(bag, feed);
|
|
120
|
+
bag.runTurn = createRunTurn(bag);
|
|
121
|
+
const api = createEngineApiA(bag);
|
|
122
|
+
feed.subscribeRuntimeNotifications();
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
api,
|
|
126
|
+
state,
|
|
127
|
+
asks,
|
|
128
|
+
deliver: (event) => notify(event),
|
|
129
|
+
activeAsk: () => activeAsk,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
for (const phase of ['before first delta', 'after response progress']) {
|
|
134
|
+
test(`runtime completion Esc ${phase} does not restart it, while a new completion wakes`, async () => {
|
|
135
|
+
_clearDeliveredCompletions();
|
|
136
|
+
const harness = makeHarness();
|
|
137
|
+
|
|
138
|
+
const first = completion('execution_A', 'first result');
|
|
139
|
+
harness.deliver(first);
|
|
140
|
+
await tick();
|
|
141
|
+
assert.equal(harness.asks.length, 1, 'runtime notification drained into a real turn');
|
|
142
|
+
assert.match(harness.activeAsk().text, /first result/, 'completion body reached runtime.ask');
|
|
143
|
+
assert.equal(first.modelVisibleDelivered, true, 'notification was acknowledged on the TUI path');
|
|
144
|
+
|
|
145
|
+
if (phase === 'after response progress') {
|
|
146
|
+
harness.activeAsk().options.onTextDelta('partial response\n');
|
|
147
|
+
await wait(40);
|
|
148
|
+
assert.equal(
|
|
149
|
+
harness.state.items.some((item) => item.kind === 'assistant' && /partial response/.test(item.text)),
|
|
150
|
+
true,
|
|
151
|
+
'response progress reached the live turn',
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
assert.equal(harness.api.abort().aborted, true, 'Esc aborts the active runTurn');
|
|
156
|
+
await tick();
|
|
157
|
+
assert.equal(harness.state.busy, false, 'busy clears after the real abort unwind');
|
|
158
|
+
assert.equal(harness.state.spinner, null, 'spinner clears after Esc');
|
|
159
|
+
assert.equal(harness.state.thinking, null, 'thinking clears after Esc');
|
|
160
|
+
|
|
161
|
+
harness.deliver(completion('execution_A', 'duplicate retry'));
|
|
162
|
+
await tick();
|
|
163
|
+
assert.equal(harness.asks.length, 1, 'duplicate execution delivery cannot restart the aborted completion');
|
|
164
|
+
|
|
165
|
+
harness.deliver(completion('execution_B', 'later result'));
|
|
166
|
+
await tick();
|
|
167
|
+
assert.equal(harness.asks.length, 2, 'a genuinely new execution still wakes a turn');
|
|
168
|
+
assert.match(harness.activeAsk().text, /later result/);
|
|
169
|
+
|
|
170
|
+
// Finish the second intentionally-open mocked provider request.
|
|
171
|
+
harness.api.abort();
|
|
172
|
+
await tick();
|
|
173
|
+
});
|
|
174
|
+
}
|
|
@@ -6,13 +6,77 @@
|
|
|
6
6
|
// node scripts/explore-bench.mjs [roundLabel]
|
|
7
7
|
// Fresh process per run = no in-memory result cache carryover.
|
|
8
8
|
import { readFileSync, existsSync } from 'node:fs';
|
|
9
|
-
import { join } from 'node:path';
|
|
9
|
+
import { join, isAbsolute } from 'node:path';
|
|
10
10
|
import { homedir } from 'node:os';
|
|
11
11
|
import { runExplore } from '../src/standalone/explore-tool.mjs';
|
|
12
12
|
|
|
13
13
|
const ROUND = process.argv[2] || 'r0';
|
|
14
14
|
const CWD = 'C:/Project/mixdog';
|
|
15
15
|
|
|
16
|
+
// Anchor helpers: an anchor line carries a `path:line`. Strict quality =
|
|
17
|
+
// expected token must land ON such a line (not merely somewhere in the text),
|
|
18
|
+
// and anchors===0 is always a miss (except expectFail). Spot-accuracy verifies
|
|
19
|
+
// the referenced file exists and the line number is within the file length.
|
|
20
|
+
function anchorLinesOf(text) {
|
|
21
|
+
return text.split('\n').filter((l) => /:\d+/.test(l));
|
|
22
|
+
}
|
|
23
|
+
// Variant-aware token matching for the fabricated-line guard. Split
|
|
24
|
+
// camelCase and snake_case/kebab joins into atomic sub-tokens, then compare
|
|
25
|
+
// on a light stem so plural/verb forms unify (importance/importanceScore/
|
|
26
|
+
// chunk_importance -> {importance,score,chunk}; scoring~score, memories~memory).
|
|
27
|
+
// Matching is exact stem-equality of sub-tokens (never substring), so it does
|
|
28
|
+
// not loosen into generic-word overlap.
|
|
29
|
+
function splitSubtokens(s) {
|
|
30
|
+
return String(s)
|
|
31
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2') // camelCase boundary
|
|
32
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') // ACRONYMWord boundary
|
|
33
|
+
.split(/[^A-Za-z0-9]+/) // snake_case / kebab / punctuation
|
|
34
|
+
.map((t) => t.toLowerCase())
|
|
35
|
+
.filter(Boolean);
|
|
36
|
+
}
|
|
37
|
+
function stemToken(t) {
|
|
38
|
+
if (t.length <= 4) return t;
|
|
39
|
+
return t
|
|
40
|
+
.replace(/ies$/, 'y')
|
|
41
|
+
.replace(/(ings|ing|edly|ed|es|ly|s)$/, '')
|
|
42
|
+
.replace(/e$/, '');
|
|
43
|
+
}
|
|
44
|
+
function checkAnchorLine(line, ctxTokens = []) {
|
|
45
|
+
const m = line.match(/([A-Za-z0-9_.\/\\-]+):(\d+)/);
|
|
46
|
+
if (!m) return null; // no parseable path:line
|
|
47
|
+
const p = m[1];
|
|
48
|
+
const ln = Number(m[2]);
|
|
49
|
+
const full = isAbsolute(p) ? p : join(CWD, p);
|
|
50
|
+
if (!existsSync(full)) return false;
|
|
51
|
+
try {
|
|
52
|
+
const fileLines = readFileSync(full, 'utf8').split('\n');
|
|
53
|
+
if (!(ln >= 1 && ln <= fileLines.length)) return false;
|
|
54
|
+
// Fabricated-line guard: the cited line's ±2 window must contain a
|
|
55
|
+
// query/expected token, so a plausible-but-wrong line number fails.
|
|
56
|
+
if (ctxTokens.length) {
|
|
57
|
+
// ±2 lines around the 1-based cited line (indices ln-3 .. ln+1 inclusive).
|
|
58
|
+
const win = fileLines.slice(Math.max(0, ln - 3), ln + 2).join('\n');
|
|
59
|
+
const winStems = [...new Set(splitSubtokens(win).map(stemToken))];
|
|
60
|
+
const wantStems = ctxTokens.map((t) => stemToken(t.toLowerCase()));
|
|
61
|
+
// Exact stem-equality unifies plural/verb variants; a prefix relation
|
|
62
|
+
// additionally bridges derivational forms (compact/compaction) without
|
|
63
|
+
// matching generic filler. Guard the bridge so a short generic line
|
|
64
|
+
// token can't satisfy a longer wanted token (impl~implementation,
|
|
65
|
+
// clas~classifier): require the shorter stem >=5 chars AND a
|
|
66
|
+
// shorter/longer length ratio >=0.6.
|
|
67
|
+
const prefixBridge = (s, w) => {
|
|
68
|
+
if (!(s.startsWith(w) || w.startsWith(s))) return false;
|
|
69
|
+
const short = Math.min(s.length, w.length);
|
|
70
|
+
const long = Math.max(s.length, w.length);
|
|
71
|
+
return short >= 5 && short / long >= 0.6;
|
|
72
|
+
};
|
|
73
|
+
const hitStem = (w) => winStems.some((s) => s === w || prefixBridge(s, w));
|
|
74
|
+
if (!wantStems.some(hitStem)) return false;
|
|
75
|
+
}
|
|
76
|
+
return true;
|
|
77
|
+
} catch { return false; }
|
|
78
|
+
}
|
|
79
|
+
|
|
16
80
|
// High-fanout timing attribution: emit lightweight per-iteration send_ms
|
|
17
81
|
// loop rows (no verbose payload estimate) so we can split LLM send time
|
|
18
82
|
// from tool time under the parallel round below. Trace-only; no behavior
|
|
@@ -68,6 +132,13 @@ const QUERIES = [
|
|
|
68
132
|
q: 'where is the mixdog config json file path resolved (data dir)',
|
|
69
133
|
expect: ['config', 'plugin-paths'],
|
|
70
134
|
},
|
|
135
|
+
{
|
|
136
|
+
// path-only class: the correct answer is a verified file/dir location on
|
|
137
|
+
// disk; a bare path (no :line) is an allowed HIT for this query.
|
|
138
|
+
q: 'where does mixdog store background shell job stdout logs on disk',
|
|
139
|
+
expect: ['shell-jobs', 'shell-job-paths', 'getShellJobsDir', 'shellJobStdout'],
|
|
140
|
+
pathOnly: true,
|
|
141
|
+
},
|
|
71
142
|
{
|
|
72
143
|
q: 'GraphQL schema stitching resolver implementation', // not in this repo
|
|
73
144
|
expectFail: true,
|
|
@@ -98,17 +169,38 @@ function readTraceSince(ts) {
|
|
|
98
169
|
const t0 = Date.now();
|
|
99
170
|
// Parallel round: all queries fire at once. Per-session trace attribution is
|
|
100
171
|
// by session_id so counts stay correct; per-query ms includes queueing.
|
|
101
|
-
const results = await Promise.all(QUERIES.map(async ({ q, expect, expectFail }) => {
|
|
172
|
+
const results = await Promise.all(QUERIES.map(async ({ q, expect, expectFail, pathOnly }) => {
|
|
102
173
|
const qt0 = Date.now();
|
|
103
174
|
const res = await runExplore({ query: q, cwd: CWD }, { callerCwd: CWD });
|
|
104
175
|
const text = res?.content?.[0]?.text || '';
|
|
105
|
-
const
|
|
176
|
+
const aLines = anchorLinesOf(text);
|
|
177
|
+
const anchors = aLines.length;
|
|
106
178
|
const failed = /EXPLORATION_FAILED/.test(text);
|
|
107
|
-
|
|
179
|
+
// Context tokens for the fabricated-line guard: expanded expected + query
|
|
180
|
+
// words, length>=4 to drop filler.
|
|
181
|
+
const ctxTokens = [
|
|
182
|
+
...(expect || []).flatMap((e) => e.toLowerCase().split(/[^a-z0-9]+/)),
|
|
183
|
+
...q.toLowerCase().split(/[^a-z0-9]+/),
|
|
184
|
+
].filter((t) => t.length >= 4);
|
|
185
|
+
// Spot-check up to 2 anchors per query for file/line validity + line context.
|
|
186
|
+
let badAnchors = 0;
|
|
187
|
+
for (const l of aLines.slice(0, 2)) {
|
|
188
|
+
if (checkAnchorLine(l, ctxTokens) === false) badAnchors++;
|
|
189
|
+
}
|
|
190
|
+
const allLines = text.split('\n').filter((l) => l.trim());
|
|
191
|
+
// Strict hit: expected token must appear ON an anchor line (>=1 anchor). For
|
|
192
|
+
// the path-only class, an expected token on any verified path line HITs even
|
|
193
|
+
// without :line. expectFail wants EXPLORATION_FAILED with zero anchors.
|
|
108
194
|
const hit = expectFail
|
|
109
195
|
? (failed && anchors === 0)
|
|
110
|
-
:
|
|
111
|
-
|
|
196
|
+
: pathOnly
|
|
197
|
+
? (!failed && (expect || []).some(
|
|
198
|
+
(e) => allLines.some((l) => l.toLowerCase().includes(e.toLowerCase())),
|
|
199
|
+
))
|
|
200
|
+
: (!failed && anchors > 0 && (expect || []).some(
|
|
201
|
+
(e) => aLines.some((l) => l.toLowerCase().includes(e.toLowerCase())),
|
|
202
|
+
));
|
|
203
|
+
return { q: q.slice(0, 48), ms: Date.now() - qt0, anchors, failed, hit, badAnchors, bytes: text.length };
|
|
112
204
|
}));
|
|
113
205
|
|
|
114
206
|
// Identify explorer sessions from trace rows, then attribute both tool calls
|
|
@@ -145,9 +237,10 @@ const sendCount = [...sendBySession.values()].reduce((a, s) => a + s.sends, 0);
|
|
|
145
237
|
|
|
146
238
|
console.log(`\n=== explore-bench round=${ROUND} ===`);
|
|
147
239
|
for (const r of results) {
|
|
148
|
-
console.log(` [${String(r.ms).padStart(6)}ms] ${r.hit ? 'HIT ' : 'MISS'} anchors=${r.anchors} failed=${r.failed} bytes=${r.bytes} ${r.q}`);
|
|
240
|
+
console.log(` [${String(r.ms).padStart(6)}ms] ${r.hit ? 'HIT ' : 'MISS'} anchors=${r.anchors} bad=${r.badAnchors} failed=${r.failed} bytes=${r.bytes} ${r.q}`);
|
|
149
241
|
}
|
|
150
|
-
|
|
242
|
+
const badTotal = results.reduce((a, r) => a + r.badAnchors, 0);
|
|
243
|
+
console.log(` quality: ${results.filter((r) => r.hit).length}/${results.length} hits (strict) bad-anchors=${badTotal}`);
|
|
151
244
|
console.log(` sessions=${bySession.size} toolcalls p50=${p50} max=${callCounts[callCounts.length - 1] ?? 0} total=${callCounts.reduce((a, b) => a + b, 0)}`);
|
|
152
245
|
for (const [id, s] of bySession) console.log(` ${id.slice(-8)}: ${s.tools.join(',')}`);
|
|
153
246
|
const wallMs = Date.now() - t0;
|
|
@@ -3,25 +3,170 @@ import test from 'node:test';
|
|
|
3
3
|
import assert from 'node:assert/strict';
|
|
4
4
|
import { readFileSync } from 'node:fs';
|
|
5
5
|
import { buildExplorerPrompt } from '../src/standalone/explore-tool.mjs';
|
|
6
|
+
import { EXPLORE_TOOL } from '../src/standalone/explore-tool.mjs';
|
|
6
7
|
import { BUILTIN_TOOLS } from '../src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs';
|
|
8
|
+
import { CODE_GRAPH_TOOL_DEFS } from '../src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs';
|
|
9
|
+
import { isEagerDispatchable } from '../src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs';
|
|
7
10
|
|
|
8
|
-
test('explore per-query prompt
|
|
11
|
+
test('explore per-query prompt contains only escaped query XML', () => {
|
|
9
12
|
const prompt = buildExplorerPrompt('display model usage show usage model_usage provider_usage session cache usage state');
|
|
10
|
-
assert.
|
|
11
|
-
assert.doesNotMatch(prompt, /
|
|
13
|
+
assert.equal(prompt, '<query>display model usage show usage model_usage provider_usage session cache usage state</query>');
|
|
14
|
+
assert.doesNotMatch(prompt, /Reminder:|BUDGET|TURN 1|STOP and answer|verdicts|ratings|recommendations/i);
|
|
15
|
+
assert.equal(buildExplorerPrompt('where is <agent> & status?'), '<query>where is <agent> & status?</query>');
|
|
12
16
|
});
|
|
13
17
|
|
|
14
|
-
test('
|
|
18
|
+
test('builtin route descriptions carry the shortest verified-path policy', () => {
|
|
15
19
|
const byName = Object.fromEntries(BUILTIN_TOOLS.map((tool) => [tool.name, tool]));
|
|
16
|
-
|
|
20
|
+
// Conditional find-first: only genuinely guessed fragments route through
|
|
21
|
+
// find, and in the SAME turn — project root itself is a verified scope.
|
|
22
|
+
assert.match(byName.grep.description, /project root counts as verified/i);
|
|
23
|
+
assert.match(byName.grep.description, /guessed path fragment → find first/i);
|
|
17
24
|
assert.match(byName.grep.description, /no path "\." \+ guessed src\/\*\*/i);
|
|
18
|
-
assert.match(byName.glob.description, /
|
|
19
|
-
assert.match(byName.
|
|
25
|
+
assert.match(byName.glob.description, /project root is verified/i);
|
|
26
|
+
assert.match(byName.glob.description, /Guessed root\/name → find first/i);
|
|
27
|
+
assert.match(byName.find.description, /output paths are verified/i);
|
|
28
|
+
assert.match(byName.read.description, /guessed path\/name → find first/i);
|
|
29
|
+
assert.match(byName.read.description, /Batch paths\/regions as real arrays.*path\[\]/i);
|
|
30
|
+
assert.match(byName.shell.description, /Shell\/write calls are serial/i);
|
|
31
|
+
assert.doesNotMatch(byName.shell.description, /parallel calls/i);
|
|
32
|
+
assert.match(byName.find.description, /lookup only for unknown partial paths\/names/i);
|
|
33
|
+
assert.match(byName.find.description, /project root or already-verified roots/i);
|
|
34
|
+
assert.doesNotMatch(byName.grep.description, /find for that fragment before any grep\/glob/i);
|
|
35
|
+
assert.match(byName.grep.description, /nonzero content_with_context result resolves that search concept/i);
|
|
36
|
+
assert.match(byName.grep.description, /Only zero\/error results may change tokens or scope/i);
|
|
37
|
+
assert.match(`${byName.code_graph?.description || ''} ${CODE_GRAPH_TOOL_DEFS[0]?.inputSchema?.properties?.symbols?.description || ''}`, /multiple exact symbols use one symbols\[\] call/i);
|
|
38
|
+
assert.match(CODE_GRAPH_TOOL_DEFS[0]?.description || '', /verified source files only/i);
|
|
20
39
|
});
|
|
21
40
|
|
|
22
|
-
test('
|
|
41
|
+
test('shared tool policy routes facets without duplicate content acquisition', () => {
|
|
42
|
+
const rule = readFileSync(new URL('../src/rules/shared/01-tool.md', import.meta.url), 'utf8');
|
|
43
|
+
const policy = rule.replace(/\s+/g, ' ');
|
|
44
|
+
assert.match(policy, /for each facet choose exactly one shortest locator route/i);
|
|
45
|
+
assert.match(policy, /broad\/uncertain→\s*`explore`.*partial path\/name→\s*`find`.*verified root\+wildcard→\s*`glob`.*quoted\/non-identifier literal or regex→\s*`grep`.*exact code identifier\/relation→\s*`code_graph` before grep/i);
|
|
46
|
+
assert.match(policy, /grep only for a requested literal occurrence or after graph zero\/error/i);
|
|
47
|
+
assert.match(policy, /batch compatible targets/i);
|
|
48
|
+
assert.match(policy, /parallelize distinct facets only, never alternative routes for one facet/i);
|
|
49
|
+
assert.match(policy, /put independent read-only calls in one turn; they may run concurrently regardless of tool/i);
|
|
50
|
+
assert.match(policy, /after locator results, collect all known candidate files\/regions before inspection/i);
|
|
51
|
+
assert.match(policy, /batch compatible reads.*same-file regions.*in one `path\[\]` call and graph targets in arrays/i);
|
|
52
|
+
assert.match(policy, /parallelize independent incompatible read-only inspections/i);
|
|
53
|
+
assert.match(policy, /do not start a singleton while a known compatible candidate remains/i);
|
|
54
|
+
assert.match(policy, /shell\/write calls are serial/i);
|
|
55
|
+
assert.match(policy, /later turns are only for targets dependent on prior results or unresolved facets/i);
|
|
56
|
+
assert.match(policy, /stop when evidence covers the deliverable/i);
|
|
57
|
+
assert.match(policy, /sufficient contextual grep means no overlapping `read`/i);
|
|
58
|
+
assert.match(policy, /files_with_matches.*count.*capped.*insufficient context.*inspect only missing content/i);
|
|
59
|
+
assert.match(policy, /known file\/span→`read` directly without `grep`/i);
|
|
60
|
+
assert.doesNotMatch(policy, /retrieval[^.]{0,120}\b(?:one|at most \d+)\s+(?:lookup|inspection|turn|call)/i);
|
|
61
|
+
assert.doesNotMatch(policy, /\bput independent calls in one turn\b/i);
|
|
62
|
+
assert.doesNotMatch(policy, /\b(?:all|every)\s+(?:independent\s+)?(?:calls|tool calls)\b[^.]{0,80}\bone turn\b/i);
|
|
63
|
+
assert.doesNotMatch(policy, /\bone lookup\b[^.]{0,80}\bat most\b[^.]{0,40}\binspection\b/i);
|
|
64
|
+
assert.doesNotMatch(policy, /all (?:independent )?(?:calls|tool calls|targets)[^.]{0,100}\bone (?:tool )?(?:message|call)/i);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('explorer locator policy retains its compact behavioral contract', () => {
|
|
23
68
|
const rule = readFileSync(new URL('../src/rules/agent/30-explorer.md', import.meta.url), 'utf8');
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
69
|
+
const policy = rule.replace(/\s+/g, ' ');
|
|
70
|
+
const required = [
|
|
71
|
+
/Locator only:[\s\S]*WHERE \(`path:line`\), never WHY[\s\S]*You ARE `explore`; never[\s\S]*call it/i,
|
|
72
|
+
/ONLY grep\/find\/glob\/code_graph[\s\S]*`read`\/`list` are forbidden/i,
|
|
73
|
+
/Turn 1 \(`turn 1\/3`\)[\s\S]*gathers all known facets in ONE message[\s\S]*Broad\/uncertain is Explorer input[\s\S]*split it into concrete facets[\s\S]*apply the shared one-route\/batch contract[\s\S]*concept facets use grep[\s\S]*single-tool turn is valid[\s\S]*follow-up is only for unresolved pre-anchor\/\s*zero-hit facets/i,
|
|
74
|
+
/follow-up is only for unresolved pre-anchor\/\s*zero-hit facets under this budget/i,
|
|
75
|
+
/broad searches use `output_mode:"files_with_matches"`[\s\S]*`content_with_context` plus `head_limit` only on a path returned THIS session/i,
|
|
76
|
+
/Each pattern is one identifier or camel\/snake variant[\s\S]*spaces are only verbatim copied quoted\s*error\/log literals[\s\S]*Translate non-English queries to English identifiers first[\s\S]*non-ASCII only for quoted literals[\s\S]*Include concept synonyms[\s\S]*not prose phrases/i,
|
|
77
|
+
/Scope is session cwd; omit `path` freely[\s\S]*only from an exact find-returned path \(turn-2 recovery earliest\)[\s\S]*never guess or\s*invent directories[\s\S]*`path:"\."` with guessed `src\/\*\*`[\s\S]*After zero hits, change TOKENS\/scope, not wording\/guessed paths/i,
|
|
78
|
+
/anchor is any `path:line` with a query token\/synonym[\s\S]*code_graph hits[\s\S]*generic-only schema\/handler\/config\/resolver\/index\/error words are zero[\s\S]*Never re-locate an anchor[\s\S]*without `:line`[\s\S]*PRE-anchor/i,
|
|
79
|
+
/After every result[\s\S]*specific-token anchor means STOP and answer NOW[\s\S]*pre-anchors count as zero[\s\S]*Never re-confirm\/upgrade an anchor/i,
|
|
80
|
+
/sole legal follow-up[\s\S]*code-location query[\s\S]*only with pre-anchors[\s\S]*one scoped `content_with_context` grep with `head_limit` on those paths[\s\S]*If zero, remaining zero-anchor recovery turns are legal under the 3-turn budget but must change tokens\/scope[\s\S]*never a second minting hop, anchor upgrade, or fabricated\/estimated line/i,
|
|
81
|
+
/at most 3 turns \(expect 1\)[\s\S]*every tool message `turn N\/3`[\s\S]*normally two messages[\s\S]*third\/extra tool call[\s\S]*unless turn 1 has zero anchors[\s\S]*must change tokens\/scope/i,
|
|
82
|
+
/Single-hop exception[\s\S]*first matching entry\/definition anchors concept\/value\/\s*default[\s\S]*do not trace chains\/value-search[\s\S]*explicit flow or default-resolution query[\s\S]*entry anchor but not its resolved value[\s\S]*ONE resolving hop, then stop/i,
|
|
83
|
+
/Answer in ≤3 lines[\s\S]*`path:line — symbol — short reason`[\s\S]*Copy every cited `path:line` VERBATIM[\s\S]*THIS-session tool result[\s\S]*never estimate\/adjust\/recall/i,
|
|
84
|
+
/Code-location answers require `:line` on every line[\s\S]*`EXPLORATION_FAILED`[\s\S]*never a bare filename or vague prose/i,
|
|
85
|
+
/file\/dir-location queries[\s\S]*exact verified file\/dir path without `:line`[\s\S]*do not force a line\/failure/i,
|
|
86
|
+
/Emit `EXPLORATION_FAILED` only after the budget is spent with zero anchors[\s\S]*prefer a weak anchor to a false miss/i,
|
|
87
|
+
];
|
|
88
|
+
for (const behavior of required) assert.match(policy, behavior);
|
|
89
|
+
assert.doesNotMatch(policy, /grep[^.]{0,120}\band\b[^.]{0,120}code_graph[^.]{0,120}\band\b[^.]{0,120}find/i);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('canonical schemas advertise safe batching without changing tool shapes', () => {
|
|
93
|
+
const graph = readFileSync(new URL('../src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs', import.meta.url), 'utf8');
|
|
94
|
+
const patch = readFileSync(new URL('../src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs', import.meta.url), 'utf8');
|
|
95
|
+
assert.match(graph, /Batch symbols\[\]\/files\[\] by mode/i);
|
|
96
|
+
assert.match(patch, /one patch/i);
|
|
97
|
+
assert.match(patch, /one file block per target/i);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('code graph and eager-dispatch boundaries preserve runtime shape', () => {
|
|
101
|
+
const schema = CODE_GRAPH_TOOL_DEFS[0]?.inputSchema?.properties || {};
|
|
102
|
+
const fileModes = ['overview', 'imports', 'dependents', 'related', 'impact', 'symbols'];
|
|
103
|
+
const symbolModes = ['symbols', 'find_symbol', 'symbol_search', 'search', 'references', 'callers', 'callees'];
|
|
104
|
+
assert.deepEqual(new Set(schema.mode?.enum), new Set([...fileModes, ...symbolModes]));
|
|
105
|
+
const assertBatchShape = (field) => {
|
|
106
|
+
assert.equal(field?.anyOf?.length, 2);
|
|
107
|
+
assert.deepEqual(field.anyOf.map((entry) => entry.type), ['string', 'array']);
|
|
108
|
+
assert.equal(field.anyOf[1].items.type, 'string');
|
|
109
|
+
assert.equal(field.anyOf[1].minItems, 1);
|
|
110
|
+
};
|
|
111
|
+
assertBatchShape(schema.files);
|
|
112
|
+
assertBatchShape(schema.symbols);
|
|
113
|
+
assert.equal(schema.file, undefined);
|
|
114
|
+
assert.equal(schema.symbol, undefined);
|
|
115
|
+
assert.equal(schema.language, undefined);
|
|
116
|
+
const tools = [
|
|
117
|
+
...BUILTIN_TOOLS,
|
|
118
|
+
{ name: 'mcp_read', annotations: { readOnlyHint: true } },
|
|
119
|
+
{ name: 'mcp_write', annotations: { readOnlyHint: false } },
|
|
120
|
+
];
|
|
121
|
+
assert.equal(isEagerDispatchable('read', tools), true);
|
|
122
|
+
assert.equal(isEagerDispatchable('shell', tools), false);
|
|
123
|
+
assert.equal(isEagerDispatchable('mcp_read', tools), true);
|
|
124
|
+
assert.equal(isEagerDispatchable('mcp_write', tools), false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('code graph descriptions partition file and symbol targets', () => {
|
|
128
|
+
const description = CODE_GRAPH_TOOL_DEFS[0]?.description || '';
|
|
129
|
+
const mode = CODE_GRAPH_TOOL_DEFS[0]?.inputSchema?.properties?.mode?.description || '';
|
|
130
|
+
assert.match(mode, /file modes=\{overview,imports,dependents,related,impact\}.*symbols with files.*files\[\].*file outline/i);
|
|
131
|
+
assert.match(mode, /symbol modes=\{find_symbol,symbol_search,search,references,callers,callees\}.*fileless symbols.*symbol_search keywords/i);
|
|
132
|
+
assert.match(description, /exact identifiers.*find_symbol\/references\/callers\/callees.*keywords.*symbol_search\/search/i);
|
|
133
|
+
assert.match(description, /IDs→graph; literal\/zero→grep/i);
|
|
134
|
+
assert.match(description, /unsupported target arrays.*omitted.*never silently mixed/i);
|
|
135
|
+
assert.match(CODE_GRAPH_TOOL_DEFS[0]?.inputSchema?.properties?.files?.description || '', /supported targets only/i);
|
|
136
|
+
assert.match(CODE_GRAPH_TOOL_DEFS[0]?.inputSchema?.properties?.symbols?.description || '', /exact identifiers.*keywords/i);
|
|
137
|
+
const grep = Object.fromEntries(BUILTIN_TOOLS.map((tool) => [tool.name, tool])).grep;
|
|
138
|
+
assert.match(grep.description, /quoted\/non-identifier literal or regex→grep/i);
|
|
139
|
+
assert.doesNotMatch(grep.inputSchema.properties.pattern.description, /code_graph/i);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('retrieval schemas require their primary arguments and preserve region paths', () => {
|
|
143
|
+
const byName = Object.fromEntries(BUILTIN_TOOLS.map((tool) => [tool.name, tool]));
|
|
144
|
+
const read = byName.read.inputSchema;
|
|
145
|
+
assert.deepEqual(read.required, ['path']);
|
|
146
|
+
const region = read.properties.path.anyOf[1].items.anyOf[1];
|
|
147
|
+
assert.deepEqual(region.required, ['path']);
|
|
148
|
+
assert.deepEqual(byName.grep.inputSchema.anyOf, [{ required: ['pattern'] }, { required: ['glob'] }]);
|
|
149
|
+
assert.deepEqual(byName.grep.inputSchema.properties.output_mode.enum, ['content_with_context', 'files_with_matches', 'count']);
|
|
150
|
+
const grepSchema = byName.grep.inputSchema;
|
|
151
|
+
const valid = (value) => grepSchema.anyOf.some((branch) => branch.required.every((key) => Object.hasOwn(value, key)));
|
|
152
|
+
assert.equal(valid({ pattern: 'x' }), true);
|
|
153
|
+
assert.equal(valid({ glob: '*.mjs' }), true);
|
|
154
|
+
assert.equal(valid({}), false);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('grep scopes do not masquerade as read regions', () => {
|
|
158
|
+
const pattern = Object.fromEntries(BUILTIN_TOOLS.map((tool) => [tool.name, tool])).grep.inputSchema.properties.pattern.description;
|
|
159
|
+
assert.match(pattern, /path\[\] batches verified scopes only/i);
|
|
160
|
+
assert.match(pattern, /file\/span reads use read path\[\] regions/i);
|
|
161
|
+
assert.doesNotMatch(pattern, /known files\/spans use path\[\]/i);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('explore freezes returned facets and avoids re-location', () => {
|
|
165
|
+
assert.match(EXPLORE_TOOL.description, /freezes the LOCATION only/i);
|
|
166
|
+
assert.match(EXPLORE_TOOL.description, /read\/code_graph detail inspection is valid when content was not returned/i);
|
|
167
|
+
assert.match(EXPLORE_TOOL.description, /never re-locate it/i);
|
|
168
|
+
assert.match(EXPLORE_TOOL.description, /search only unresolved facets/i);
|
|
169
|
+
const rule = readFileSync(new URL('../src/rules/shared/01-tool.md', import.meta.url), 'utf8');
|
|
170
|
+
assert.match(rule, /freezes the LOCATION only[\s\S]*read or[\s\S]*code_graph detail inspection is valid[\s\S]*never re-locate it/i);
|
|
171
|
+
assert.match(rule, /nonzero[\s\S]*content_with_context[\s\S]*result resolves[\s\S]*act directly/i);
|
|
27
172
|
});
|
|
@@ -32,6 +32,93 @@ test('fuzzyRank floor drops a purely scattered subsequence but keeps a substring
|
|
|
32
32
|
assert.deepEqual(ranked.map((r) => r.item.path), ['src/abcdef.txt']);
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
+
// Frozen copy of the pre-optimization scorer: this must not call production
|
|
36
|
+
// fuzzy helpers, so rank parity also detects scoring regressions.
|
|
37
|
+
function legacyFuzzyScore(query, str) {
|
|
38
|
+
if (!query) return 0;
|
|
39
|
+
const normalizedQuery = String(query).replace(/[\/\\_.\-\s]+/g, '');
|
|
40
|
+
if (!normalizedQuery) return 0;
|
|
41
|
+
const q = normalizedQuery.toLowerCase();
|
|
42
|
+
const s = str.toLowerCase();
|
|
43
|
+
const qlen = q.length;
|
|
44
|
+
const slen = s.length;
|
|
45
|
+
if (qlen === 0) return 0;
|
|
46
|
+
if (qlen > slen) return null;
|
|
47
|
+
|
|
48
|
+
const lastSep = Math.max(str.lastIndexOf('/'), str.lastIndexOf('\\'));
|
|
49
|
+
let score = 0;
|
|
50
|
+
let si = 0;
|
|
51
|
+
let prevMatch = -2;
|
|
52
|
+
let firstMatchIdx = -1;
|
|
53
|
+
for (let qi = 0; qi < qlen; qi++) {
|
|
54
|
+
const qc = q[qi];
|
|
55
|
+
let found = -1;
|
|
56
|
+
for (let k = si; k < slen; k++) {
|
|
57
|
+
if (s[k] === qc) { found = k; break; }
|
|
58
|
+
}
|
|
59
|
+
if (found === -1) return null;
|
|
60
|
+
if (firstMatchIdx === -1) firstMatchIdx = found;
|
|
61
|
+
score += 1;
|
|
62
|
+
if (found === prevMatch + 1) score += 5;
|
|
63
|
+
const prevCh = found > 0 ? str[found - 1] : undefined;
|
|
64
|
+
if (prevCh === undefined
|
|
65
|
+
|| prevCh === '/' || prevCh === '\\' || prevCh === '_' || prevCh === '-'
|
|
66
|
+
|| prevCh === '.' || prevCh === ' '
|
|
67
|
+
|| (/[a-z0-9]/.test(prevCh) && /[A-Z]/.test(str[found]))) {
|
|
68
|
+
score += 8;
|
|
69
|
+
}
|
|
70
|
+
if (str[found] === normalizedQuery[qi]) score += 1;
|
|
71
|
+
prevMatch = found;
|
|
72
|
+
si = found + 1;
|
|
73
|
+
}
|
|
74
|
+
if (firstMatchIdx > lastSep) score += 10;
|
|
75
|
+
score -= Math.floor(slen / 16);
|
|
76
|
+
score -= Math.floor(firstMatchIdx / 8);
|
|
77
|
+
return score;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function referenceFuzzyRank(query, items, limit = 0) {
|
|
81
|
+
const normQuery = String(query || '').toLowerCase().replace(/[\/\\_.\-\s]+/g, '');
|
|
82
|
+
const floor = normQuery.length * 4;
|
|
83
|
+
const scored = [];
|
|
84
|
+
for (const item of items) {
|
|
85
|
+
const p = String(item.path || '');
|
|
86
|
+
const pathScore = legacyFuzzyScore(query, p);
|
|
87
|
+
const base = p.split(/[\\/]/).pop() || '';
|
|
88
|
+
const baseScore = legacyFuzzyScore(query, base);
|
|
89
|
+
const score = Math.max(pathScore ?? -Infinity, baseScore === null ? -Infinity : baseScore + 40);
|
|
90
|
+
if (!Number.isFinite(score)) continue;
|
|
91
|
+
const strong = normQuery.length > 0
|
|
92
|
+
&& (String(base || '').toLowerCase().replace(/[\/\\_.\-\s]+/g, '').includes(normQuery)
|
|
93
|
+
|| String(p || '').toLowerCase().replace(/[\/\\_.\-\s]+/g, '').includes(normQuery));
|
|
94
|
+
if (!strong && score < floor) continue;
|
|
95
|
+
scored.push({ item, score });
|
|
96
|
+
}
|
|
97
|
+
scored.sort((a, b) => (b.score - a.score)
|
|
98
|
+
|| (a.item.path < b.item.path ? -1 : a.item.path > b.item.path ? 1 : 0));
|
|
99
|
+
return limit > 0 ? scored.slice(0, limit) : scored;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
test('fuzzyRank exactly matches the frozen legacy full-sort reference ranker', () => {
|
|
103
|
+
const fragments = ['alpha', 'Beta', 'tool-events', 'src', 'x_y', 'log', 'camelCase', 'archive'];
|
|
104
|
+
const items = Array.from({ length: 1200 }, (_, i) => ({
|
|
105
|
+
path: i % 97 === 0
|
|
106
|
+
? 'duplicate/tool-events.log'
|
|
107
|
+
: `${fragments[i % fragments.length]}/${fragments[(i * 7) % fragments.length]}-${i}.mjs`,
|
|
108
|
+
}));
|
|
109
|
+
for (const query of ['', 'tool', 'ToolEvents', 'a_b', 'camel']) {
|
|
110
|
+
for (const limit of [-5, 0, 0.5, 1, 2, 25, 300, 2000]) {
|
|
111
|
+
const expected = referenceFuzzyRank(query, items, limit);
|
|
112
|
+
const actual = fuzzyRank(query, items, limit);
|
|
113
|
+
assert.deepEqual(
|
|
114
|
+
actual.map(({ item, score }) => ({ index: items.findIndex((candidate) => candidate === item), score })),
|
|
115
|
+
expected.map(({ item, score }) => ({ index: items.findIndex((candidate) => candidate === item), score })),
|
|
116
|
+
`query=${JSON.stringify(query)}, limit=${limit}`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
35
122
|
// ── hidden-directory discovery via the find tool ─────────────────────────
|
|
36
123
|
|
|
37
124
|
function makeRepo() {
|
|
@@ -69,6 +156,41 @@ test('find hidden:false skips dot-directories', async () => {
|
|
|
69
156
|
}
|
|
70
157
|
});
|
|
71
158
|
|
|
159
|
+
test('find respects .gitignore on the common path, then deterministically falls back for an exact ignored filename', async () => {
|
|
160
|
+
const root = mkdtempSync(join(tmpdir(), 'mixdog-find-gitignore-'));
|
|
161
|
+
try {
|
|
162
|
+
writeFileSync(join(root, '.gitignore'), 'ignored-tree/\n');
|
|
163
|
+
mkdirSync(join(root, 'src'), { recursive: true });
|
|
164
|
+
mkdirSync(join(root, 'ignored-tree'), { recursive: true });
|
|
165
|
+
writeFileSync(join(root, 'src', 'common-visible.mjs'), 'x\n');
|
|
166
|
+
writeFileSync(join(root, 'ignored-tree', 'common-ignored.mjs'), 'x\n');
|
|
167
|
+
writeFileSync(join(root, 'src', 'needle-target.mjs.bak'), 'x\n');
|
|
168
|
+
writeFileSync(join(root, 'ignored-tree', 'needle-target.mjs'), 'x\n');
|
|
169
|
+
writeFileSync(join(root, 'ignored-tree', 'LICENSE'), 'x\n');
|
|
170
|
+
writeFileSync(join(root, 'ignored-tree', '[slug].tsx'), 'x\n');
|
|
171
|
+
writeFileSync(join(root, 'ignored-tree', 'my file.txt'), 'x\n');
|
|
172
|
+
|
|
173
|
+
const common = await executeFuzzyFindTool({ query: 'common-visible.mjs', head_limit: 1 }, root);
|
|
174
|
+
assert.ok(common.includes('src/common-visible.mjs'), `common path must keep visible files: ${common}`);
|
|
175
|
+
assert.ok(!common.includes('ignored-tree/common-ignored.mjs'), `common path must skip .gitignored trees: ${common}`);
|
|
176
|
+
assert.ok(common.includes('[gitignored trees not searched; retry with include_noise:true]'), `filled common path must disclose skipped trees: ${common}`);
|
|
177
|
+
|
|
178
|
+
const exact = await executeFuzzyFindTool({ query: 'needle-target.mjs' }, root);
|
|
179
|
+
assert.ok(exact.includes('src/needle-target.mjs.bak'), `visible fuzzy decoy must remain in pass one: ${exact}`);
|
|
180
|
+
assert.ok(exact.includes('ignored-tree/needle-target.mjs'), `exact ignored filename must trigger fallback: ${exact}`);
|
|
181
|
+
assert.ok(!exact.includes('[gitignored trees not searched'), `fallback result must not claim ignored trees were skipped: ${exact}`);
|
|
182
|
+
|
|
183
|
+
const extensionless = await executeFuzzyFindTool({ query: 'LICENSE' }, root);
|
|
184
|
+
assert.ok(extensionless.includes('ignored-tree/LICENSE'), `extensionless exact filename must trigger fallback: ${extensionless}`);
|
|
185
|
+
const literalGlob = await executeFuzzyFindTool({ query: '[slug].tsx' }, root);
|
|
186
|
+
assert.ok(literalGlob.includes('ignored-tree/[slug].tsx'), `literal-glob exact filename must trigger fallback: ${literalGlob}`);
|
|
187
|
+
const spaced = await executeFuzzyFindTool({ query: 'my file.txt' }, root);
|
|
188
|
+
assert.ok(spaced.includes('ignored-tree/my file.txt'), `space-containing exact filename must trigger fallback: ${spaced}`);
|
|
189
|
+
} finally {
|
|
190
|
+
rmSync(root, { recursive: true, force: true });
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
72
194
|
// ── narrowed-pass merge / dedup backstop ─────────────────────────────────
|
|
73
195
|
|
|
74
196
|
test('exact-name hit survives among many decoys and is not duplicated', async () => {
|