mixdog 0.9.38 → 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/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 +38 -2
- package/scripts/explore-prompt-policy-test.mjs +152 -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/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/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 +50 -60
- 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 -45
- 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/providers/codex-client-meta.mjs +21 -1
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +17 -38
- 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/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 +223 -42
- 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/runtime-core.mjs +35 -2
- package/src/session-runtime/tool-catalog.mjs +34 -0
- 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 +517 -142
- package/src/tui/engine/agent-job-feed.mjs +144 -17
- package/src/tui/engine/agent-response-tail.mjs +68 -0
- package/src/tui/engine/notification-plan.mjs +16 -0
- package/src/tui/engine/session-api.mjs +8 -2
- package/src/tui/engine/session-flow.mjs +19 -1
- 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 +77 -42
- 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,349 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtempSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { parseGrepCoverage, summarizeToolArgs } from '../src/runtime/agent/orchestrator/agent-trace-format.mjs';
|
|
8
|
+
import { executeGrepTool } from '../src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs';
|
|
9
|
+
import { validateBuiltinArgs } from '../src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs';
|
|
10
|
+
|
|
11
|
+
test('grep coverage parser follows path:line and path-line formatter syntax', () => {
|
|
12
|
+
assert.deepEqual(
|
|
13
|
+
parseGrepCoverage('src/a.mjs:10:match\nsrc/a.mjs-11-context', 'grep', { output_mode: 'content_with_context' }, 'normal'),
|
|
14
|
+
[{ path: 'src/a.mjs', line: 10 }, { path: 'src/a.mjs', line: 11 }],
|
|
15
|
+
);
|
|
16
|
+
assert.deepEqual(
|
|
17
|
+
parseGrepCoverage('10:match\n11-context', 'grep', { path: 'src/a.mjs', output_mode: 'content_with_context' }, 'normal'),
|
|
18
|
+
[{ path: 'src/a.mjs', line: 10 }, { path: 'src/a.mjs', line: 11 }],
|
|
19
|
+
);
|
|
20
|
+
assert.deepEqual(
|
|
21
|
+
parseGrepCoverage('10:match', 'grep', { path: 'src/a.mjs' }, 'normal'),
|
|
22
|
+
[{ path: 'src/a.mjs', line: 10 }],
|
|
23
|
+
);
|
|
24
|
+
assert.equal(parseGrepCoverage('src/a.mjs:10:match', 'grep', { output_mode: 'files_with_matches' }, 'normal'), null);
|
|
25
|
+
assert.equal(parseGrepCoverage('Error: failed', 'grep', { output_mode: 'content_with_context' }, 'error'), null);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('omitted grep mode receives contextual head-limit clamping', () => {
|
|
29
|
+
const args = { pattern: 'needle', head_limit: 999 };
|
|
30
|
+
assert.equal(validateBuiltinArgs('grep', args), null);
|
|
31
|
+
assert.equal(args.head_limit, 40);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('production summaries retain code_graph files[]', () => {
|
|
35
|
+
assert.deepEqual(summarizeToolArgs('code_graph', { mode: 'symbols', files: ['src/a.mjs', 'src/b.mjs'] }).files, ['src/a.mjs', 'src/b.mjs']);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('grep content_with_context supplies surrounding lines without explicit context', async () => {
|
|
39
|
+
const dir = mkdtempSync(join(tmpdir(), 'mixdog-grep-'));
|
|
40
|
+
writeFileSync(join(dir, 'sample.mjs'), 'before\nneedle\nafter\n');
|
|
41
|
+
const output = await executeGrepTool(
|
|
42
|
+
{ pattern: 'needle', path: 'sample.mjs', output_mode: 'content_with_context' },
|
|
43
|
+
dir,
|
|
44
|
+
async () => '',
|
|
45
|
+
);
|
|
46
|
+
assert.match(String(output), /before/);
|
|
47
|
+
assert.match(String(output), /needle/);
|
|
48
|
+
assert.match(String(output), /after/);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('grep output defaults to context while explicit content stays bare', async () => {
|
|
52
|
+
const dir = mkdtempSync(join(tmpdir(), 'mixdog-grep-mode-'));
|
|
53
|
+
writeFileSync(join(dir, 'sample.mjs'), 'before\nneedle\nafter\n');
|
|
54
|
+
const run = (args) => executeGrepTool(args, dir, async () => '');
|
|
55
|
+
const omitted = await run({ pattern: 'needle', path: 'sample.mjs' });
|
|
56
|
+
const bare = await run({ pattern: 'needle', path: 'sample.mjs', output_mode: 'content' });
|
|
57
|
+
const explicitZero = await run({ pattern: 'needle', path: 'sample.mjs', '-A': 0, '-B': 0, '-C': 0 });
|
|
58
|
+
assert.match(String(omitted), /before/);
|
|
59
|
+
assert.match(String(omitted), /after/);
|
|
60
|
+
assert.doesNotMatch(String(bare), /before/);
|
|
61
|
+
assert.doesNotMatch(String(bare), /after/);
|
|
62
|
+
assert.doesNotMatch(String(explicitZero), /before/);
|
|
63
|
+
assert.doesNotMatch(String(explicitZero), /after/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('grep path fan-out is bounded, ordered, isolated, and capped', async () => {
|
|
67
|
+
const dir = mkdtempSync(join(tmpdir(), 'mixdog-grep-paths-'));
|
|
68
|
+
const paths = Array.from({ length: 11 }, (_, index) => `missing-${index}.mjs`);
|
|
69
|
+
let active = 0;
|
|
70
|
+
let peak = 0;
|
|
71
|
+
const child = async () => {
|
|
72
|
+
active++;
|
|
73
|
+
peak = Math.max(peak, active);
|
|
74
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
75
|
+
active--;
|
|
76
|
+
return '';
|
|
77
|
+
};
|
|
78
|
+
const scopedCacheOutcome = {};
|
|
79
|
+
const output = await executeGrepTool(
|
|
80
|
+
{ pattern: 'needle', path: paths },
|
|
81
|
+
dir,
|
|
82
|
+
child,
|
|
83
|
+
null,
|
|
84
|
+
{ scopedCacheOutcome },
|
|
85
|
+
);
|
|
86
|
+
assert.equal(peak, 4);
|
|
87
|
+
assert.match(output, /^# grep missing-0\.mjs\nError: path does not exist:/);
|
|
88
|
+
assert.match(output, /# grep missing-9\.mjs\nError: path does not exist:/);
|
|
89
|
+
assert.doesNotMatch(output, /# grep missing-10\.mjs\n/);
|
|
90
|
+
assert.match(output, /\[capped at 10 of 11 paths\]$/);
|
|
91
|
+
assert.equal(scopedCacheOutcome.complete, false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('nested grep path and pattern fan-out stays within four concurrent calls', async () => {
|
|
95
|
+
const dir = mkdtempSync(join(tmpdir(), 'mixdog-grep-nested-'));
|
|
96
|
+
let active = 0;
|
|
97
|
+
let peak = 0;
|
|
98
|
+
const child = async () => {
|
|
99
|
+
active++;
|
|
100
|
+
peak = Math.max(peak, active);
|
|
101
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
102
|
+
active--;
|
|
103
|
+
return '';
|
|
104
|
+
};
|
|
105
|
+
await executeGrepTool(
|
|
106
|
+
{ pattern: ['needle-a', 'needle-b', 'needle-c'], path: ['missing-a.mjs', 'missing-b.mjs', 'missing-c.mjs', 'missing-d.mjs'] },
|
|
107
|
+
dir,
|
|
108
|
+
child,
|
|
109
|
+
);
|
|
110
|
+
assert.equal(peak, 4);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('path-section grep coverage survives legacy aggregate output', () => {
|
|
114
|
+
assert.deepEqual(
|
|
115
|
+
parseGrepCoverage(
|
|
116
|
+
'# grep src/a.mjs\n1:match\n# grep src/b.mjs\n2-context',
|
|
117
|
+
'grep',
|
|
118
|
+
{ path: ['src/a.mjs', 'src/b.mjs'], output_mode: 'content_with_context' },
|
|
119
|
+
'normal',
|
|
120
|
+
),
|
|
121
|
+
[{ path: 'src/a.mjs', line: 1 }, { path: 'src/b.mjs', line: 2 }],
|
|
122
|
+
);
|
|
123
|
+
assert.deepEqual(
|
|
124
|
+
parseGrepCoverage(
|
|
125
|
+
'# grep src/a.mjs\n# grep pattern:"one"\n1:match\n# grep pattern:"two"\n2-context\n# grep src/b.mjs\n# grep pattern:"one"\n3:match',
|
|
126
|
+
'grep',
|
|
127
|
+
{ path: ['src/a.mjs', 'src/b.mjs'], pattern: ['one', 'two'], output_mode: 'content_with_context' },
|
|
128
|
+
'normal',
|
|
129
|
+
),
|
|
130
|
+
[{ path: 'src/a.mjs', line: 1 }, { path: 'src/a.mjs', line: 2 }, { path: 'src/b.mjs', line: 3 }],
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('oversized path aggregate marks scoped cache incomplete before outer cap', async () => {
|
|
135
|
+
const dir = mkdtempSync(join(tmpdir(), 'mixdog-grep-output-cap-'));
|
|
136
|
+
const body = Array.from({ length: 1_500 }, () => 'needle\n').join('');
|
|
137
|
+
writeFileSync(join(dir, 'a.txt'), body);
|
|
138
|
+
writeFileSync(join(dir, 'b.txt'), body);
|
|
139
|
+
const scopedCacheOutcome = { complete: true };
|
|
140
|
+
const output = await executeGrepTool(
|
|
141
|
+
{ pattern: 'needle', path: ['a.txt', 'b.txt'], output_mode: 'content', head_limit: 0 },
|
|
142
|
+
dir,
|
|
143
|
+
async () => '',
|
|
144
|
+
null,
|
|
145
|
+
{ scopedCacheOutcome, toolOutputMaxBytes: 100 },
|
|
146
|
+
);
|
|
147
|
+
assert.ok(output.length > 100);
|
|
148
|
+
assert.equal(scopedCacheOutcome.complete, false);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
function runCases(cases) {
|
|
152
|
+
const dir = mkdtempSync(join(tmpdir(), 'mixdog-routing-'));
|
|
153
|
+
const trace = join(dir, 'trace.jsonl');
|
|
154
|
+
const rows = cases.flatMap(([session_id, calls]) => calls.map((call, index) => ({
|
|
155
|
+
kind: 'tool', session_id, ts: index + 1, ...call,
|
|
156
|
+
})));
|
|
157
|
+
writeFileSync(trace, rows.map((row) => JSON.stringify(row)).join('\n'));
|
|
158
|
+
const result = spawnSync(process.execPath, ['scripts/routing-corpus.mjs', '--trace', trace, '--json'], { encoding: 'utf8' });
|
|
159
|
+
assert.equal(result.status, 0, result.stderr);
|
|
160
|
+
return JSON.parse(result.stdout);
|
|
161
|
+
}
|
|
162
|
+
function flags(corpus, session) {
|
|
163
|
+
return corpus.cases.find((item) => item.session_id === session)?.flags || [];
|
|
164
|
+
}
|
|
165
|
+
function observations(corpus, session) {
|
|
166
|
+
return corpus.cases.find((item) => item.session_id === session)?.observations || [];
|
|
167
|
+
}
|
|
168
|
+
const call = (tool_name, tool_args, extra = {}) => ({
|
|
169
|
+
tool_name, tool_args,
|
|
170
|
+
tool_args_hash: `synthetic-${tool_name}-${JSON.stringify(tool_args)}`,
|
|
171
|
+
...extra,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('routing corpus uses full hashes, result status, and mutation boundaries', () => {
|
|
175
|
+
const corpus = runCases([
|
|
176
|
+
['hash-collision', [
|
|
177
|
+
call('grep', { pattern: 'same-summary' }, { tool_args_hash: 'hash-a', iteration: 1 }),
|
|
178
|
+
call('grep', { pattern: 'same-summary' }, { tool_args_hash: 'hash-b', iteration: 2 }),
|
|
179
|
+
]],
|
|
180
|
+
['failed-retry', [
|
|
181
|
+
call('read', { path: 'a.mjs' }, { result_kind: 'error', tool_args_hash: 'same', iteration: 1 }),
|
|
182
|
+
call('read', { path: 'a.mjs' }, { result_kind: 'success', tool_args_hash: 'same', iteration: 2 }),
|
|
183
|
+
]],
|
|
184
|
+
['mutation-reset', [
|
|
185
|
+
call('read', { path: 'a.mjs' }, { tool_args_hash: 'same', iteration: 1 }),
|
|
186
|
+
call('shell', { command: 'echo changed' }, { iteration: 2 }),
|
|
187
|
+
call('read', { path: 'a.mjs' }, { tool_args_hash: 'same', iteration: 3 }),
|
|
188
|
+
]],
|
|
189
|
+
['positive-relookup', [
|
|
190
|
+
call('grep', { pattern: 'same' }, { tool_args_hash: 'same-hash', iteration: 1 }),
|
|
191
|
+
call('grep', { pattern: 'same' }, { tool_args_hash: 'same-hash', iteration: 2 }),
|
|
192
|
+
]],
|
|
193
|
+
]);
|
|
194
|
+
assert.deepEqual(flags(corpus, 'hash-collision'), []);
|
|
195
|
+
assert.deepEqual(flags(corpus, 'failed-retry'), []);
|
|
196
|
+
assert.deepEqual(flags(corpus, 'mutation-reset'), []);
|
|
197
|
+
assert.ok(flags(corpus, 'positive-relookup').includes('grep_relookup'));
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('valid explore inspection is clean and context grep to same-file read is high-confidence', () => {
|
|
201
|
+
const corpus = runCases([
|
|
202
|
+
['explore-inspection', [
|
|
203
|
+
call('explore', { query: 'where is config' }, { result_kind: 'success', iteration: 1 }),
|
|
204
|
+
call('read', { path: 'config.mjs' }, { result_kind: 'success', iteration: 2 }),
|
|
205
|
+
]],
|
|
206
|
+
['context-read', [
|
|
207
|
+
call('grep', { pattern: 'needle', path: 'a.mjs', output_mode: 'content_with_context' }, { result_kind: 'success', result_lines_est: 4, grep_coverage: [{ path: 'a.mjs', line: 1 }, { path: 'a.mjs', line: 2 }, { path: 'a.mjs', line: 3 }], iteration: 1 }),
|
|
208
|
+
call('read', { path: 'a.mjs', offset: 0, limit: 3 }, { result_kind: 'success', iteration: 2 }),
|
|
209
|
+
]],
|
|
210
|
+
['files-only', [
|
|
211
|
+
call('grep', { pattern: 'needle', path: 'a.mjs', output_mode: 'files_with_matches' }, { result_kind: 'success', result_lines_est: 1, iteration: 1 }),
|
|
212
|
+
call('read', { path: 'a.mjs' }, { result_kind: 'success', iteration: 2 }),
|
|
213
|
+
]],
|
|
214
|
+
['different-path', [
|
|
215
|
+
call('grep', { pattern: 'needle', path: 'a.mjs', output_mode: 'content_with_context' }, { result_kind: 'success', result_lines_est: 1, grep_coverage: [{ path: 'a.mjs', line: 1 }], iteration: 1 }),
|
|
216
|
+
call('read', { path: 'b.mjs', offset: 0, limit: 1 }, { result_kind: 'success', iteration: 2 }),
|
|
217
|
+
]],
|
|
218
|
+
['full-file-read', [
|
|
219
|
+
call('grep', { pattern: 'needle', path: 'a.mjs', output_mode: 'content_with_context' }, { result_kind: 'success', grep_coverage: [{ path: 'a.mjs', line: 1 }], iteration: 1 }),
|
|
220
|
+
call('read', { path: 'a.mjs' }, { result_kind: 'success', iteration: 2 }),
|
|
221
|
+
]],
|
|
222
|
+
['zero-limit', [
|
|
223
|
+
call('grep', { pattern: 'needle', path: 'a.mjs', output_mode: 'content_with_context' }, { grep_coverage: [{ path: 'a.mjs', line: 1 }], iteration: 1 }),
|
|
224
|
+
call('read', { path: 'a.mjs', offset: 0, limit: 0 }, { iteration: 2 }),
|
|
225
|
+
]],
|
|
226
|
+
['partial-coverage', [
|
|
227
|
+
call('grep', { pattern: 'needle', path: 'a.mjs', output_mode: 'content_with_context' }, { grep_coverage: [{ path: 'a.mjs', line: 1 }, { path: 'a.mjs', line: 3 }], iteration: 1 }),
|
|
228
|
+
call('read', { path: 'a.mjs', offset: 0, limit: 3 }, { iteration: 2 }),
|
|
229
|
+
]],
|
|
230
|
+
['relative-absolute', [
|
|
231
|
+
call('grep', { pattern: 'needle', path: 'a.mjs', output_mode: 'content_with_context' }, { cwd: '/tmp/project', grep_coverage: [{ path: 'a.mjs', line: 1 }], iteration: 1 }),
|
|
232
|
+
call('read', { path: '/tmp/project/a.mjs', offset: 0, limit: 1 }, { cwd: '/tmp/project', iteration: 2 }),
|
|
233
|
+
]],
|
|
234
|
+
['posix-case', [
|
|
235
|
+
call('grep', { pattern: 'needle', path: 'A.mjs', output_mode: 'content_with_context' }, { cwd: '/tmp/project', grep_coverage: [{ path: 'A.mjs', line: 1 }], iteration: 1 }),
|
|
236
|
+
call('read', { path: '/tmp/project/a.mjs', offset: 0, limit: 1 }, { cwd: '/tmp/project', iteration: 2 }),
|
|
237
|
+
]],
|
|
238
|
+
]);
|
|
239
|
+
assert.deepEqual(flags(corpus, 'explore-inspection'), []);
|
|
240
|
+
assert.ok(flags(corpus, 'context-read').includes('grep_context_then_read'));
|
|
241
|
+
assert.ok(!flags(corpus, 'files-only').includes('grep_context_then_read'));
|
|
242
|
+
assert.ok(!flags(corpus, 'different-path').includes('grep_context_then_read'));
|
|
243
|
+
assert.ok(!flags(corpus, 'full-file-read').includes('grep_context_then_read'));
|
|
244
|
+
assert.ok(!flags(corpus, 'zero-limit').includes('grep_context_then_read'));
|
|
245
|
+
assert.ok(!flags(corpus, 'partial-coverage').includes('grep_context_then_read'));
|
|
246
|
+
assert.ok(flags(corpus, 'relative-absolute').includes('grep_context_then_read'));
|
|
247
|
+
assert.ok(!flags(corpus, 'posix-case').includes('grep_context_then_read'));
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('same-iteration compatible calls are visible observations, not defects', () => {
|
|
251
|
+
const families = [
|
|
252
|
+
['read', [{ path: 'a', offset: 0, limit: 10 }, { path: 'b', offset: 10, limit: 10 }]],
|
|
253
|
+
['grep', [{ pattern: 'a', path: '.' }, { pattern: 'b', path: '.' }]],
|
|
254
|
+
['find', [{ query: 'a' }, { query: 'b' }]],
|
|
255
|
+
['glob', [{ pattern: '*.a', path: '.' }, { pattern: '*.b', path: '.' }]],
|
|
256
|
+
['list', [{ path: 'a' }, { path: 'b' }]],
|
|
257
|
+
['explore', [{ query: 'a' }, { query: 'b' }]],
|
|
258
|
+
['code_graph', [{ mode: 'symbols', symbols: ['A'] }, { mode: 'symbols', symbols: ['B'] }]],
|
|
259
|
+
];
|
|
260
|
+
const cases = families.map(([tool, args], index) => [`batch-${index}`, args.map((tool_args) => call(tool, tool_args, { iteration: 1 }))]);
|
|
261
|
+
cases.push(
|
|
262
|
+
['grep-paths', [
|
|
263
|
+
call('grep', { pattern: 'same', path: 'a' }, { iteration: 1 }),
|
|
264
|
+
call('grep', { pattern: 'same', path: 'b' }, { iteration: 1 }),
|
|
265
|
+
]],
|
|
266
|
+
['graph-files-in-symbol-mode', [
|
|
267
|
+
call('code_graph', { mode: 'symbols', files: ['src/a.mjs'] }, { iteration: 1 }),
|
|
268
|
+
call('code_graph', { mode: 'symbols', files: ['src/b.mjs'] }, { iteration: 1 }),
|
|
269
|
+
]],
|
|
270
|
+
['graph-singular-symbols-mode', [
|
|
271
|
+
call('code_graph', { mode: 'symbols', symbol: 'A' }, { iteration: 1 }),
|
|
272
|
+
call('code_graph', { mode: 'symbols', symbol: 'B' }, { iteration: 1 }),
|
|
273
|
+
]],
|
|
274
|
+
['unsupported-symbol-files', [
|
|
275
|
+
call('code_graph', { mode: 'symbol_search', files: ['src/a.mjs'] }, { iteration: 1 }),
|
|
276
|
+
call('code_graph', { mode: 'symbol_search', files: ['src/b.mjs'] }, { iteration: 1 }),
|
|
277
|
+
]],
|
|
278
|
+
['plain-reads', [
|
|
279
|
+
call('read', { path: 'a' }, { iteration: 1 }),
|
|
280
|
+
call('read', { path: 'b' }, { iteration: 1 }),
|
|
281
|
+
]],
|
|
282
|
+
['mixed-read-regions', [
|
|
283
|
+
call('read', { path: 'a', offset: 0, limit: 4 }, { iteration: 1 }),
|
|
284
|
+
call('read', { path: [{ path: 'b', offset: 4, limit: 4 }] }, { iteration: 1 }),
|
|
285
|
+
]],
|
|
286
|
+
['singleton-array-read', [
|
|
287
|
+
call('read', { path: ['a'] }, { iteration: 1 }),
|
|
288
|
+
call('read', { path: ['b'] }, { iteration: 1 }),
|
|
289
|
+
]],
|
|
290
|
+
['incompatible', [
|
|
291
|
+
call('grep', { pattern: 'a', path: '.', output_mode: 'count' }, { iteration: 1 }),
|
|
292
|
+
call('grep', { pattern: 'b', path: '.', output_mode: 'content' }, { iteration: 1 }),
|
|
293
|
+
]],
|
|
294
|
+
['mixed-three', [
|
|
295
|
+
call('grep', { pattern: 'a', path: '.' }, { iteration: 1 }),
|
|
296
|
+
call('grep', { pattern: 'b', path: '.' }, { iteration: 1 }),
|
|
297
|
+
call('grep', { pattern: 'c', path: '.', output_mode: 'count' }, { iteration: 1 }),
|
|
298
|
+
]],
|
|
299
|
+
['different-iteration', [
|
|
300
|
+
call('read', { path: 'a' }, { iteration: 1 }),
|
|
301
|
+
call('read', { path: 'b' }, { iteration: 2 }),
|
|
302
|
+
]],
|
|
303
|
+
);
|
|
304
|
+
const corpus = runCases(cases);
|
|
305
|
+
for (let index = 0; index < families.length; index += 1) {
|
|
306
|
+
assert.ok(observations(corpus, `batch-${index}`).includes('same_turn_batch_opportunity'), `${families[index][0]}: ${observations(corpus, `batch-${index}`)}`);
|
|
307
|
+
assert.deepEqual(flags(corpus, `batch-${index}`), []);
|
|
308
|
+
}
|
|
309
|
+
for (const session of ['grep-paths', 'graph-files-in-symbol-mode', 'graph-singular-symbols-mode', 'plain-reads', 'mixed-read-regions', 'singleton-array-read']) {
|
|
310
|
+
assert.ok(observations(corpus, session).includes('same_turn_batch_opportunity'));
|
|
311
|
+
assert.deepEqual(flags(corpus, session), []);
|
|
312
|
+
}
|
|
313
|
+
assert.ok(!flags(corpus, 'incompatible').includes('missed_array_batch'));
|
|
314
|
+
assert.ok(observations(corpus, 'mixed-three').includes('same_turn_batch_opportunity'));
|
|
315
|
+
assert.deepEqual(flags(corpus, 'mixed-three'), []);
|
|
316
|
+
assert.ok(!flags(corpus, 'different-iteration').includes('missed_array_batch'));
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('successful overlapping reads flag once, while exact relookup does not double-count overlap', () => {
|
|
320
|
+
const corpus = runCases([
|
|
321
|
+
['overlap', [
|
|
322
|
+
call('read', { path: 'src/a.mjs', offset: 0, limit: 10 }, { iteration: 1 }),
|
|
323
|
+
call('read', { path: './src/a.mjs', offset: 5, limit: 10 }, { iteration: 2 }),
|
|
324
|
+
]],
|
|
325
|
+
['duplicate', [
|
|
326
|
+
call('read', { path: 'src/a.mjs', offset: 0, limit: 10 }, { iteration: 1, tool_args_hash: 'same' }),
|
|
327
|
+
call('read', { path: 'src/a.mjs', offset: 0, limit: 10 }, { iteration: 2, tool_args_hash: 'same' }),
|
|
328
|
+
]],
|
|
329
|
+
['posix-case', [
|
|
330
|
+
call('read', { path: 'A.mjs', offset: 0, limit: 10 }, { cwd: '/tmp/project', iteration: 1 }),
|
|
331
|
+
call('read', { path: 'a.mjs', offset: 5, limit: 10 }, { cwd: '/tmp/project', iteration: 2 }),
|
|
332
|
+
]],
|
|
333
|
+
['windows-case', [
|
|
334
|
+
call('read', { path: 'C:\\Project\\A.mjs', offset: 0, limit: 10 }, { cwd: 'C:\\Project', iteration: 1 }),
|
|
335
|
+
call('read', { path: 'c:/project/a.mjs', offset: 5, limit: 10 }, { cwd: 'C:\\Project', iteration: 2 }),
|
|
336
|
+
]],
|
|
337
|
+
]);
|
|
338
|
+
assert.deepEqual(flags(corpus, 'overlap'), ['read_overlap']);
|
|
339
|
+
assert.deepEqual(flags(corpus, 'duplicate'), ['read_relookup']);
|
|
340
|
+
assert.deepEqual(flags(corpus, 'posix-case'), []);
|
|
341
|
+
assert.deepEqual(flags(corpus, 'windows-case'), ['read_overlap']);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test('corpus summary exposes precise flags', () => {
|
|
345
|
+
const corpus = runCases([['empty', [call('read', { path: 'a' })]]]);
|
|
346
|
+
for (const key of ['grep_relookup', 'read_relookup', 'read_overlap', 'grep_context_then_read', 'missed_array_batch']) {
|
|
347
|
+
assert.equal(typeof corpus.summary[key].count, 'number');
|
|
348
|
+
}
|
|
349
|
+
});
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// set by re-running --eval before/after (input constant, only rules change).
|
|
6
6
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
7
7
|
import { homedir } from 'node:os';
|
|
8
|
-
import { basename, resolve } from 'node:path';
|
|
8
|
+
import { basename, resolve, win32 } from 'node:path';
|
|
9
9
|
import { resolvePluginData } from '../src/runtime/shared/plugin-paths.mjs';
|
|
10
10
|
|
|
11
11
|
function argValue(name, fallback = null) {
|
|
@@ -64,7 +64,10 @@ function argsSummary(tool, args) {
|
|
|
64
64
|
switch (tool) {
|
|
65
65
|
case 'grep': return clip(Array.isArray(args.pattern) ? `[${args.pattern.length}]${args.pattern[0]}` : args.pattern);
|
|
66
66
|
case 'read': return clip(basename(String(Array.isArray(args.path) ? args.path[0] : args.path || '')));
|
|
67
|
-
case 'code_graph':
|
|
67
|
+
case 'code_graph': {
|
|
68
|
+
const values = args.symbols || args.files || args.symbol || args.file || '';
|
|
69
|
+
return clip(`${args.mode || '?'}:${Array.isArray(values) ? `[${values.length}]${values[0] || ''}` : values}`);
|
|
70
|
+
}
|
|
68
71
|
case 'explore': return arr(args.query);
|
|
69
72
|
case 'find': return clip(args.query);
|
|
70
73
|
case 'glob': return clip(Array.isArray(args.pattern) ? args.pattern[0] : args.pattern);
|
|
@@ -75,9 +78,119 @@ function argsSummary(tool, args) {
|
|
|
75
78
|
}
|
|
76
79
|
}
|
|
77
80
|
|
|
78
|
-
function
|
|
79
|
-
|
|
80
|
-
|
|
81
|
+
function isMutation(tool) {
|
|
82
|
+
return ['apply_patch', 'edit', 'edit_many', 'write', 'shell'].includes(tool);
|
|
83
|
+
}
|
|
84
|
+
function failed(row) {
|
|
85
|
+
const kind = field(row, 'result_kind');
|
|
86
|
+
return /^(error|failed|failure|command-exit|timeout|permission)$/i.test(String(kind || ''));
|
|
87
|
+
}
|
|
88
|
+
function targetValues(tool, args) {
|
|
89
|
+
if (!args || typeof args !== 'object') return [];
|
|
90
|
+
let key = 'path';
|
|
91
|
+
if (tool === 'code_graph') {
|
|
92
|
+
const fileMode = ['overview', 'imports', 'dependents', 'related', 'impact'].includes(args.mode);
|
|
93
|
+
const symbolMode = ['find_symbol', 'symbol_search', 'search', 'references', 'callers', 'callees'].includes(args.mode);
|
|
94
|
+
key = fileMode || (args.mode === 'symbols' && (args.files != null || args.file != null))
|
|
95
|
+
? (args.files != null ? 'files' : 'file')
|
|
96
|
+
: symbolMode || args.mode === 'symbols' ? (args.symbols != null ? 'symbols' : 'symbol') : 'file';
|
|
97
|
+
} else if (tool === 'grep' || tool === 'glob') key = 'pattern';
|
|
98
|
+
else if (tool === 'find' || tool === 'explore') key = 'query';
|
|
99
|
+
const value = args[key];
|
|
100
|
+
return Array.isArray(value) ? value : value == null ? [] : [value];
|
|
101
|
+
}
|
|
102
|
+
function batchFields(tool, args) {
|
|
103
|
+
if (tool === 'grep' || tool === 'glob') return ['pattern', 'path'];
|
|
104
|
+
if (tool === 'code_graph') {
|
|
105
|
+
const fileMode = ['overview', 'imports', 'dependents', 'related', 'impact'].includes(args?.mode);
|
|
106
|
+
const symbolMode = ['find_symbol', 'symbol_search', 'search', 'references', 'callers', 'callees'].includes(args?.mode);
|
|
107
|
+
if (symbolMode && args?.files == null && args?.file == null) return ['symbols', 'symbol'];
|
|
108
|
+
if (symbolMode) return [];
|
|
109
|
+
return [fileMode || args?.mode === 'symbols'
|
|
110
|
+
? (args?.files != null ? 'files' : args?.file != null ? 'file' : args?.symbols != null ? 'symbols' : 'symbol')
|
|
111
|
+
: null].filter(Boolean);
|
|
112
|
+
}
|
|
113
|
+
return [tool === 'read' || tool === 'list' ? 'path' : tool === 'find' || tool === 'explore' ? 'query' : null].filter(Boolean);
|
|
114
|
+
}
|
|
115
|
+
function compatibleBatchCalls(tool, left, right) {
|
|
116
|
+
if (tool === 'read') {
|
|
117
|
+
const nonBatch = Object.keys({ ...left, ...right }).filter((key) => !['path', 'offset', 'limit'].includes(key));
|
|
118
|
+
return nonBatch.every((key) => JSON.stringify(left?.[key]) === JSON.stringify(right?.[key]));
|
|
119
|
+
}
|
|
120
|
+
const fields = batchFields(tool, left);
|
|
121
|
+
if (!fields.length) return false;
|
|
122
|
+
const differing = fields.filter((key) => JSON.stringify(left?.[key]) !== JSON.stringify(right?.[key]));
|
|
123
|
+
if (differing.length !== 1) return false;
|
|
124
|
+
const batchKey = differing[0];
|
|
125
|
+
if ((Array.isArray(left?.[batchKey]) && left[batchKey].length !== 1) || (Array.isArray(right?.[batchKey]) && right[batchKey].length !== 1)) return false;
|
|
126
|
+
return Object.keys({ ...left, ...right }).filter((key) => !fields.includes(key))
|
|
127
|
+
.every((key) => JSON.stringify(left?.[key]) === JSON.stringify(right?.[key]));
|
|
128
|
+
}
|
|
129
|
+
function readTargets(args) {
|
|
130
|
+
if (!args || typeof args !== 'object') return null;
|
|
131
|
+
const values = Array.isArray(args.path) ? args.path : [args.path];
|
|
132
|
+
return values.map((value) => {
|
|
133
|
+
if (typeof value === 'string') return { path: value, offset: args.offset ?? null, limit: args.limit ?? null };
|
|
134
|
+
if (value && typeof value === 'object' && typeof value.path === 'string') {
|
|
135
|
+
return { path: value.path, offset: value.offset ?? null, limit: value.limit ?? null };
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
}).filter(Boolean);
|
|
139
|
+
}
|
|
140
|
+
function batchSpec(tool, args, forcedField = null) {
|
|
141
|
+
if (!args || typeof args !== 'object') return null;
|
|
142
|
+
if (tool === 'code_graph') {
|
|
143
|
+
if (!['symbols', 'find_symbol', 'symbol_search', 'search', 'references', 'callers', 'callees', 'overview', 'imports', 'dependents', 'related', 'impact'].includes(args.mode)) return null;
|
|
144
|
+
const fileMode = ['overview', 'imports', 'dependents', 'related', 'impact'].includes(args.mode);
|
|
145
|
+
const symbolMode = ['find_symbol', 'symbol_search', 'search', 'references', 'callers', 'callees'].includes(args.mode);
|
|
146
|
+
if (symbolMode && (args.files != null || args.file != null)) return null;
|
|
147
|
+
const field = fileMode || (args.mode === 'symbols' && (args.files != null || args.file != null))
|
|
148
|
+
? (args.files != null ? 'files' : 'file')
|
|
149
|
+
: (args.symbols != null ? 'symbols' : 'symbol');
|
|
150
|
+
return { field, values: targetValues(tool, args) };
|
|
151
|
+
}
|
|
152
|
+
const fieldName = forcedField || (tool === 'read' || tool === 'list' ? 'path' : tool === 'grep' || tool === 'glob' ? 'pattern' : tool === 'find' || tool === 'explore' ? 'query' : null);
|
|
153
|
+
if (!fieldName) return null;
|
|
154
|
+
const value = args[fieldName];
|
|
155
|
+
return { field: fieldName, values: Array.isArray(value) ? value : value == null ? [] : [value] };
|
|
156
|
+
}
|
|
157
|
+
function sameIterationBatchObservations(sequence) {
|
|
158
|
+
let found = false;
|
|
159
|
+
for (let i = 0; i < sequence.length; i += 1) {
|
|
160
|
+
const first = sequence[i];
|
|
161
|
+
if (isMutation(first.tool) || first.it == null) continue;
|
|
162
|
+
const group = [first];
|
|
163
|
+
for (let j = i + 1; j < sequence.length; j += 1) {
|
|
164
|
+
const next = sequence[j];
|
|
165
|
+
if (next.it !== first.it) break;
|
|
166
|
+
if (isMutation(next.tool)) break;
|
|
167
|
+
group.push(next);
|
|
168
|
+
}
|
|
169
|
+
for (const candidate of ['read', 'grep', 'find', 'glob', 'list', 'explore', 'code_graph']) {
|
|
170
|
+
if (candidate === 'read') {
|
|
171
|
+
const calls = group.filter((entry) => entry.tool === 'read' && !entry.failed).map((entry) => ({ entry, targets: readTargets(entry.rawArgs) })).filter(({ targets }) => targets?.length);
|
|
172
|
+
if (calls.some(({ entry, targets }, index) => calls.some(({ entry: other, targets: otherTargets }, otherIndex) => (
|
|
173
|
+
index < otherIndex
|
|
174
|
+
&& compatibleBatchCalls('read', entry.rawArgs, other.rawArgs)
|
|
175
|
+
&& targets.some((target) => otherTargets.some((otherTarget) => JSON.stringify(target) !== JSON.stringify(otherTarget)))
|
|
176
|
+
)))) found = true;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const firstArgs = group.find((entry) => entry.tool === candidate && !entry.failed)?.rawArgs;
|
|
180
|
+
for (const field of batchFields(candidate, firstArgs || {})) {
|
|
181
|
+
const calls = group.filter((entry) => entry.tool === candidate && !entry.failed).map((entry) => ({ entry, spec: batchSpec(candidate, entry.rawArgs, field) })).filter(({ spec }) => spec && spec.values.length === 1);
|
|
182
|
+
if (calls.length < 2) continue;
|
|
183
|
+
const distinct = new Set(calls.map(({ spec }) => JSON.stringify(spec.values[0])));
|
|
184
|
+
const compatiblePair = calls.some(({ entry, spec }, index) => calls.some(({ entry: other, spec: otherSpec }, otherIndex) => (
|
|
185
|
+
index < otherIndex
|
|
186
|
+
&& JSON.stringify(spec.values[0]) !== JSON.stringify(otherSpec.values[0])
|
|
187
|
+
&& compatibleBatchCalls(candidate, entry.rawArgs, other.rawArgs)
|
|
188
|
+
)));
|
|
189
|
+
if (distinct.size >= 2 && compatiblePair) found = true;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return found ? ['same_turn_batch_opportunity'] : [];
|
|
81
194
|
}
|
|
82
195
|
|
|
83
196
|
// shell used to INSPECT the filesystem (read/list/search/exists) instead of a
|
|
@@ -104,32 +217,98 @@ function buildCase(sid, toolRows) {
|
|
|
104
217
|
const it = field(r, 'iteration');
|
|
105
218
|
if (it != null) iters.add(it);
|
|
106
219
|
const tool = field(r, 'tool_name') || '?';
|
|
107
|
-
|
|
220
|
+
const rawArgs = field(r, 'tool_args');
|
|
221
|
+
const summaryArgs = rawArgs;
|
|
222
|
+
sequence.push({
|
|
223
|
+
it: it ?? null,
|
|
224
|
+
tool,
|
|
225
|
+
args: argsSummary(tool, rawArgs),
|
|
226
|
+
rawArgs,
|
|
227
|
+
inspectArgs: summaryArgs,
|
|
228
|
+
argsHash: field(r, 'tool_args_hash'),
|
|
229
|
+
resultKind: field(r, 'result_kind'),
|
|
230
|
+
resultLines: Number(field(r, 'result_lines_est') || 0),
|
|
231
|
+
coverage: field(r, 'grep_coverage'),
|
|
232
|
+
cwd: field(r, 'cwd') || '',
|
|
233
|
+
failed: failed(r),
|
|
234
|
+
});
|
|
108
235
|
}
|
|
109
|
-
// per-iteration tool counts for serial detection
|
|
110
|
-
const perIt = new Map();
|
|
111
|
-
for (const s of sequence) { if (s.it == null) continue; perIt.set(s.it, (perIt.get(s.it) || 0) + 1); }
|
|
112
|
-
const itOrder = [...perIt.keys()].sort((a, b) => a - b);
|
|
113
|
-
let longestSingleRun = 0, run = 0;
|
|
114
|
-
for (const it of itOrder) { if (perIt.get(it) === 1) { run += 1; longestSingleRun = Math.max(longestSingleRun, run); } else run = 0; }
|
|
115
|
-
|
|
116
236
|
const names = sequence.map((s) => s.tool);
|
|
117
|
-
const hasAnchor = names.some((n) => n === 'grep' || n === 'code_graph' || n === 'find' || n === 'read');
|
|
118
|
-
const exploreCalls = sequence.filter((s) => s.tool === 'explore');
|
|
119
237
|
const flags = [];
|
|
120
|
-
|
|
121
|
-
if (
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
238
|
+
const observations = [];
|
|
239
|
+
if (sequence.some((s) => s.tool === 'code_graph' && s.rawArgs?.mode === 'find_symbol' && !s.rawArgs?.file && !s.rawArgs?.files)) flags.push('find_symbol_noscope');
|
|
240
|
+
// Exact duplicate requests are the only relookup signal available in tool
|
|
241
|
+
// traces. Do not infer waste from counts, roles, turns, or explore→inspection:
|
|
242
|
+
// exploration followed by inspection can be the intended route.
|
|
243
|
+
const seenRequests = new Set();
|
|
244
|
+
const readWindows = [];
|
|
245
|
+
let pendingContextGrep = null;
|
|
246
|
+
for (const s of sequence) {
|
|
247
|
+
if (isMutation(s.tool)) {
|
|
248
|
+
seenRequests.clear();
|
|
249
|
+
readWindows.length = 0;
|
|
250
|
+
pendingContextGrep = null;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (s.failed) continue;
|
|
254
|
+
if (['grep', 'read'].includes(s.tool)) {
|
|
255
|
+
const key = s.argsHash ? `${s.tool}:${s.argsHash}` : null;
|
|
256
|
+
const duplicate = key && seenRequests.has(key);
|
|
257
|
+
if (duplicate) flags.push(`${s.tool}_relookup`);
|
|
258
|
+
if (key) seenRequests.add(key);
|
|
259
|
+
if (s.tool === 'read' && !duplicate) {
|
|
260
|
+
const normalize = (value, cwd) => {
|
|
261
|
+
const raw = String(value);
|
|
262
|
+
const base = String(cwd || process.cwd());
|
|
263
|
+
const winStyle = /^[A-Za-z]:[\\/]/.test(raw) || /^[A-Za-z]:[\\/]/.test(base) || raw.includes('\\');
|
|
264
|
+
const resolved = (winStyle ? win32.resolve(base, raw) : resolve(base, raw)).replace(/\\/g, '/');
|
|
265
|
+
return winStyle ? resolved.toLowerCase() : resolved;
|
|
266
|
+
};
|
|
267
|
+
const bounded = (offset, limit) => Number.isFinite(Number(offset)) && Number(offset) >= 0
|
|
268
|
+
&& Number.isFinite(Number(limit)) && Number(limit) > 0;
|
|
269
|
+
for (const target of readTargets(s.rawArgs) || []) {
|
|
270
|
+
const start = bounded(target.offset, target.limit) ? Number(target.offset) : 0;
|
|
271
|
+
const end = bounded(target.offset, target.limit) ? start + Number(target.limit) : Infinity;
|
|
272
|
+
const path = normalize(target.path, s.cwd);
|
|
273
|
+
if (!flags.includes('read_overlap') && readWindows.some((prior) => prior.path === path && start < prior.end && prior.start < end)) flags.push('read_overlap');
|
|
274
|
+
readWindows.push({ path, start, end });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (s.tool === 'grep' && Array.isArray(s.coverage) && s.coverage.length) {
|
|
279
|
+
pendingContextGrep = s;
|
|
280
|
+
} else if (s.tool === 'read' && pendingContextGrep) {
|
|
281
|
+
// Coverage is emitted by the trace formatter from actual path:line
|
|
282
|
+
// output. Missing/boundedness-unknown coverage intentionally stays
|
|
283
|
+
// unclassified; whole-file reads are a documented residual limitation.
|
|
284
|
+
const pathArg = s.rawArgs?.path;
|
|
285
|
+
const bounded = (offset, limit) => Number.isFinite(Number(offset)) && Number(offset) >= 0
|
|
286
|
+
&& Number.isFinite(Number(limit)) && Number(limit) > 0;
|
|
287
|
+
const region = typeof pathArg === 'string' && bounded(s.rawArgs.offset, s.rawArgs.limit)
|
|
288
|
+
? { path: pathArg, start: Number(s.rawArgs.offset) + 1, end: Number(s.rawArgs.offset) + Number(s.rawArgs.limit) }
|
|
289
|
+
: pathArg && typeof pathArg === 'object' && pathArg.path && bounded(pathArg.offset, pathArg.limit)
|
|
290
|
+
? { path: pathArg.path, start: Number(pathArg.offset) + 1, end: Number(pathArg.offset) + Number(pathArg.limit) }
|
|
291
|
+
: null;
|
|
292
|
+
if (region) {
|
|
293
|
+
const normalize = (value, cwd) => {
|
|
294
|
+
const raw = String(value);
|
|
295
|
+
const base = String(cwd || process.cwd());
|
|
296
|
+
const winStyle = /^[A-Za-z]:[\\/]/.test(raw) || /^[A-Za-z]:[\\/]/.test(base) || raw.includes('\\');
|
|
297
|
+
const resolved = resolve(base, raw).replace(/\\/g, '/');
|
|
298
|
+
return winStyle ? resolved.toLowerCase() : resolved;
|
|
299
|
+
};
|
|
300
|
+
const wanted = normalize(region.path, s.cwd || pendingContextGrep.cwd);
|
|
301
|
+
const covered = new Set((pendingContextGrep.coverage || [])
|
|
302
|
+
.filter((item) => normalize(item.path, pendingContextGrep.cwd) === wanted)
|
|
303
|
+
.map((item) => Number(item.line)));
|
|
304
|
+
let complete = true;
|
|
305
|
+
for (let line = region.start; line <= region.end; line += 1) if (!covered.has(line)) { complete = false; break; }
|
|
306
|
+
if (complete) flags.push('grep_context_then_read');
|
|
307
|
+
}
|
|
308
|
+
pendingContextGrep = null;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
observations.push(...sameIterationBatchObservations(sequence));
|
|
133
312
|
// shell used for filesystem inspection instead of dedicated tools
|
|
134
313
|
if (sequence.some((s) => s.tool === 'shell' && isShellInspect(s.rawArgs?.command))) flags.push('shell_inspect');
|
|
135
314
|
|
|
@@ -137,17 +316,17 @@ function buildCase(sid, toolRows) {
|
|
|
137
316
|
session_id: sid,
|
|
138
317
|
short_id: shortId(sid),
|
|
139
318
|
agent, model,
|
|
140
|
-
turns: iters.size
|
|
319
|
+
turns: iters.size,
|
|
141
320
|
tools: sequence.length,
|
|
142
321
|
first_tool: names[0] || null,
|
|
143
|
-
longest_single_run: longestSingleRun,
|
|
144
322
|
flags,
|
|
323
|
+
observations,
|
|
145
324
|
max_ts: Math.max(...sorted.map((r) => Number(r.ts || 0))),
|
|
146
|
-
sequence: sequence.map((s) => ({ it: s.it, tool: s.tool, args: s.args })),
|
|
325
|
+
sequence: sequence.map((s) => ({ it: s.it, tool: s.tool, args: s.args, result_kind: s.resultKind, result_lines_est: s.resultLines, grep_coverage: s.coverage })),
|
|
147
326
|
};
|
|
148
327
|
}
|
|
149
328
|
|
|
150
|
-
const FLAG_KEYS = ['
|
|
329
|
+
const FLAG_KEYS = ['find_symbol_noscope', 'read_relookup', 'read_overlap', 'grep_relookup', 'grep_context_then_read', 'missed_array_batch', 'shell_inspect'];
|
|
151
330
|
|
|
152
331
|
function buildCorpus(rows, { limit, sinceTs, agentFilter }) {
|
|
153
332
|
let filtered = rows.filter((r) => r.kind === 'tool' && sessionId(r));
|