mixdog 0.9.36 → 0.9.37
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 +3 -2
- package/scripts/abort-recovery-test.mjs +118 -0
- package/scripts/compact-smoke.mjs +7 -7
- package/scripts/explore-bench-tmp.mjs +19 -0
- package/scripts/explore-bench.mjs +36 -6
- package/scripts/explore-prompt-policy-test.mjs +27 -0
- package/scripts/ingest-pure-conversation-smoke.mjs +11 -11
- package/scripts/openai-ws-early-settle-test.mjs +176 -0
- package/scripts/output-style-smoke.mjs +17 -17
- package/scripts/provider-toolcall-test.mjs +333 -14
- package/scripts/recall-bench-cases.json +3 -3
- package/scripts/recall-bench.mjs +1 -1
- package/scripts/recall-quality-cases.json +4 -4
- package/scripts/recall-usecase-cases.json +2 -2
- package/scripts/session-bench.mjs +13 -13
- package/scripts/steering-drain-buckets-test.mjs +72 -11
- package/scripts/submit-commandbusy-race-test.mjs +114 -0
- package/scripts/tool-smoke.mjs +83 -10
- package/src/app.mjs +2 -1
- package/src/defaults/cycle3-review-prompt.md +9 -0
- package/src/defaults/skills/setup/SKILL.md +93 -293
- package/src/lib/rules-builder.cjs +3 -2
- package/src/output-styles/default.md +2 -2
- package/src/output-styles/extreme-minimal.md +1 -1
- package/src/output-styles/minimal.md +1 -1
- package/src/output-styles/simple.md +2 -3
- package/src/rules/agent/30-explorer.md +15 -7
- package/src/rules/lead/01-general.md +4 -0
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +15 -3
- package/src/runtime/agent/orchestrator/config.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +6 -6
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +6 -6
- package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +35 -5
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +27 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +36 -37
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +72 -1
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +26 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +12 -28
- package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +18 -15
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +4 -2
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +113 -2
- package/src/runtime/agent/orchestrator/providers/registry.mjs +44 -5
- package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +78 -0
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +57 -31
- package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +8 -6
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +28 -2
- package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +1 -3
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +15 -2
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +26 -17
- package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +24 -16
- package/src/runtime/agent/orchestrator/session/result-classification.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +5 -2
- package/src/runtime/agent/orchestrator/stall-policy.mjs +18 -0
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +35 -20
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -7
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +77 -31
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +25 -3
- package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +9 -2
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +73 -25
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +17 -7
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -12
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +5 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +56 -6
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -0
- package/src/runtime/channels/lib/worker-main.mjs +5 -0
- package/src/runtime/memory/lib/core-memory-store.mjs +104 -0
- package/src/runtime/memory/lib/ko-morph.mjs +1 -1
- package/src/runtime/memory/lib/memory-cycle3.mjs +64 -4
- package/src/runtime/memory/lib/memory-text-utils.mjs +4 -4
- package/src/runtime/memory/lib/recall-format.mjs +3 -3
- package/src/runtime/shared/child-spawn-gate.mjs +15 -0
- package/src/runtime/shared/config.mjs +98 -12
- package/src/runtime/shared/process-listener-headroom.mjs +12 -0
- package/src/session-runtime/cwd-plugins.mjs +6 -3
- package/src/session-runtime/model-route-api.mjs +50 -2
- package/src/session-runtime/provider-auth-api.mjs +8 -1
- package/src/session-runtime/resource-api.mjs +22 -0
- package/src/session-runtime/runtime-core.mjs +13 -2
- package/src/session-runtime/settings-api.mjs +11 -2
- package/src/standalone/agent-tool.mjs +15 -9
- package/src/standalone/explore-tool.mjs +4 -1
- package/src/tui/App.jsx +6 -5
- package/src/tui/app/transcript-window.mjs +60 -10
- package/src/tui/app/use-transcript-window.mjs +2 -1
- package/src/tui/components/ContextPanel.jsx +4 -1
- package/src/tui/components/ToolExecution.jsx +15 -12
- package/src/tui/components/TranscriptItem.jsx +1 -1
- package/src/tui/components/tool-execution/surface-detail.mjs +8 -8
- package/src/tui/dist/index.mjs +467 -151
- package/src/tui/engine/agent-job-feed.mjs +26 -6
- package/src/tui/engine/notification-plan.mjs +3 -3
- package/src/tui/engine/render-timing.mjs +104 -8
- package/src/tui/engine/session-api.mjs +58 -3
- package/src/tui/engine/session-flow.mjs +78 -28
- package/src/tui/engine/tool-card-results.mjs +28 -13
- package/src/tui/engine/turn.mjs +232 -156
- package/src/tui/engine.mjs +17 -8
- package/src/tui/index.jsx +10 -1
- package/src/workflows/default/WORKFLOW.md +8 -4
- package/src/workflows/solo/WORKFLOW.md +8 -4
|
@@ -226,11 +226,11 @@ function cacheRatioFromUsage(rows) {
|
|
|
226
226
|
|
|
227
227
|
function cacheBreakExplanation(reason) {
|
|
228
228
|
const r = String(reason || '');
|
|
229
|
-
if (r === 'no_anchor') return 'delta
|
|
230
|
-
if (r === 'input_prefix_mismatch') return '
|
|
231
|
-
if (r.startsWith('response_output_mismatch')) return '
|
|
232
|
-
if (r === 'cache_key_changed') return 'cache key
|
|
233
|
-
return r ? '
|
|
229
|
+
if (r === 'no_anchor') return 'delta \uAE30\uC900\uC810/previous response \uC5C6\uC74C';
|
|
230
|
+
if (r === 'input_prefix_mismatch') return '\uC694\uCCAD prefix\uAC00 \uC774\uC804 turn\uACFC \uB2EC\uB77C\uC9D0';
|
|
231
|
+
if (r.startsWith('response_output_mismatch')) return '\uC774\uC804 \uC751\uB2F5 output \uCCB4\uC778\uC774 \uAE30\uB300\uAC12\uACFC \uB2E4\uB984';
|
|
232
|
+
if (r === 'cache_key_changed') return 'cache key \uBCC0\uACBD';
|
|
233
|
+
return r ? '\uC6D0\uC778 \uBBF8\uBD84\uB958' : '\uC6D0\uC778 \uAE30\uB85D \uC5C6\uC74C';
|
|
234
234
|
}
|
|
235
235
|
|
|
236
236
|
function classifyCacheBreakPhase(row, usageRows, transportRows) {
|
|
@@ -1342,27 +1342,27 @@ function buildExecutiveSummary(report) {
|
|
|
1342
1342
|
const tools = report.summary.total_tool_ms || 0;
|
|
1343
1343
|
const total = headers + stream + tools;
|
|
1344
1344
|
const dominant = [['stream', stream], ['tools', tools], ['headers', headers]].sort((a, b) => b[1] - a[1])[0];
|
|
1345
|
-
lines.push(
|
|
1345
|
+
lines.push(`\uC8FC \uBCD1\uBAA9=${dominant[0]} ${fmtMs(dominant[1])}/${fmtMs(total)}; cache=${fmtPct((report.summary.cache_ratio ?? 0) * 100)}; turns=${report.summary.turns}; tools=${report.summary.tool_calls}`);
|
|
1346
1346
|
if (report.cache.cache_breaks.length) {
|
|
1347
1347
|
const summary = countBy(report.cache.cache_breaks, (b) => `${b.reason || 'unknown'}/${b.phase || 'unknown'}`).slice(0, 3).map(([k, v]) => `${k}×${v}`).join(', ');
|
|
1348
|
-
lines.push(
|
|
1348
|
+
lines.push(`\uCE90\uC2DC \uAE68\uC9D0=${report.cache.cache_breaks.length} (${summary})`);
|
|
1349
1349
|
}
|
|
1350
1350
|
if (report.cache.actual_cache_misses?.length) {
|
|
1351
1351
|
const top = [...report.cache.actual_cache_misses].sort((a, b) => (b.uncached_tokens || 0) - (a.uncached_tokens || 0))[0];
|
|
1352
|
-
lines.push(
|
|
1352
|
+
lines.push(`\uC2E4\uC81C \uCE90\uC2DC \uBBF8\uC2A4=${report.cache.actual_cache_misses.length} (top uncached=${fmtTok(top?.uncached_tokens || 0)})`);
|
|
1353
1353
|
}
|
|
1354
1354
|
if (report.compact?.cache_breaks?.length) {
|
|
1355
1355
|
const summary = countBy(report.compact.cache_breaks, (b) => `${b.reason || 'unknown'}/${b.phase || 'unknown'}`).slice(0, 3).map(([k, v]) => `${k}×${v}`).join(', ');
|
|
1356
|
-
lines.push(
|
|
1356
|
+
lines.push(`\uCEF4\uD329\uD2B8 \uD638\uCD9C=${report.compact.sessions.length} session, cache reset=${report.compact.cache_breaks.length} (${summary})`);
|
|
1357
1357
|
}
|
|
1358
1358
|
if (report.tools.failures.length) {
|
|
1359
1359
|
const summary = countBy(report.tools.failures, (f) => f.category || f.tool || 'unknown').slice(0, 3).map(([k, v]) => `${k}×${v}`).join(', ');
|
|
1360
|
-
lines.push(
|
|
1360
|
+
lines.push(`\uD234 \uC2E4\uD328=${report.tools.failures.length} (${summary})`);
|
|
1361
1361
|
}
|
|
1362
1362
|
const topGrowth = report.tokens.growth_turns[0];
|
|
1363
|
-
if (topGrowth) lines.push(
|
|
1363
|
+
if (topGrowth) lines.push(`\uD1A0\uD070 \uC99D\uD3ED=${topGrowth.agent || '-'} it=${topGrowth.turn_label} promptΔ=${fmtTok(topGrowth.prompt_delta)} out=${fmtTok(topGrowth.output_tokens + topGrowth.thinking_tokens)}`);
|
|
1364
1364
|
const topChurn = report.tools.duplicates[0];
|
|
1365
|
-
if (topChurn) lines.push(
|
|
1365
|
+
if (topChurn) lines.push(`\uD234 \uD5DB\uB3CE=${topChurn.tool}×${topChurn.count} ${compactText(topChurn.target, 90)}`);
|
|
1366
1366
|
return lines;
|
|
1367
1367
|
}
|
|
1368
1368
|
|
|
@@ -1626,7 +1626,7 @@ function renderText(report) {
|
|
|
1626
1626
|
if (report.tools.failures.length) {
|
|
1627
1627
|
lines.push('tool failures:');
|
|
1628
1628
|
for (const f of report.tools.failures.slice(0, 10)) {
|
|
1629
|
-
lines.push(`- ${f.agent || '-'} it=${f.iteration ?? '-'} ${f.tool || '-'} ${fmtMs(f.tool_ms)} category=${f.category || '-'} reason=${f.reason || 'trace
|
|
1629
|
+
lines.push(`- ${f.agent || '-'} it=${f.iteration ?? '-'} ${f.tool || '-'} ${fmtMs(f.tool_ms)} category=${f.category || '-'} reason=${f.reason || 'trace\uC5D0 \uC0C1\uC138 stderr/\uC608\uC678 \uBBF8\uC800\uC7A5'} result=${Math.round(f.bytes / 1024)}KB/${f.lines}l: ${f.target}`);
|
|
1630
1630
|
if (opts.failuresOnly && f.preview) {
|
|
1631
1631
|
const preview = compactText(f.preview, 700);
|
|
1632
1632
|
lines.push(` preview: ${preview}`);
|
|
@@ -7,6 +7,7 @@ import { createSessionFlow } from '../src/tui/engine/session-flow.mjs';
|
|
|
7
7
|
// a valid session key). No provider/runTurn wiring needed.
|
|
8
8
|
function makeFlow() {
|
|
9
9
|
let seq = 0;
|
|
10
|
+
let runTurns = 0;
|
|
10
11
|
const state = { queued: [], busy: false };
|
|
11
12
|
const bag = {
|
|
12
13
|
runtime: { id: null },
|
|
@@ -27,33 +28,93 @@ function makeFlow() {
|
|
|
27
28
|
routeState: {},
|
|
28
29
|
syncContextStats: () => {},
|
|
29
30
|
flushDeferredExecutionPendingResumeKick: () => {},
|
|
31
|
+
runTurn: async () => {
|
|
32
|
+
runTurns += 1;
|
|
33
|
+
return 'done';
|
|
34
|
+
},
|
|
30
35
|
};
|
|
31
|
-
return { flow: createSessionFlow(bag), bag };
|
|
36
|
+
return { flow: createSessionFlow(bag), bag, getRunTurns: () => runTurns };
|
|
32
37
|
}
|
|
33
38
|
|
|
34
|
-
test('drainPendingSteering
|
|
39
|
+
test('drainPendingSteering drains prompt/next mid-turn but leaves later notifications', () => {
|
|
35
40
|
const { flow, bag } = makeFlow();
|
|
36
|
-
// Concurrent user steering (prompt bucket) + task notification (its own
|
|
37
|
-
// priority/mode bucket) queued while a turn is running.
|
|
38
41
|
bag.pending.push(flow.makeQueueEntry('steer the turn', { mode: 'prompt' }));
|
|
39
42
|
bag.pending.push(flow.makeQueueEntry('task finished', { mode: 'task-notification', key: 'task-1' }));
|
|
40
43
|
|
|
41
44
|
const out = flow.drainPendingSteering();
|
|
42
45
|
|
|
43
|
-
assert.equal(
|
|
44
|
-
assert.equal(out
|
|
45
|
-
assert.
|
|
46
|
-
assert.
|
|
46
|
+
assert.equal(out.length, 1, 'next-priority prompt is injected into the active continuation');
|
|
47
|
+
assert.equal(out[0], 'steer the turn');
|
|
48
|
+
assert.equal(bag.pending.length, 1, 'later task notification waits for post-turn or explicit later flush');
|
|
49
|
+
assert.equal(bag.pending[0].content, 'task finished');
|
|
47
50
|
});
|
|
48
51
|
|
|
49
|
-
test('drainPendingSteering
|
|
52
|
+
test('drainPendingSteering can explicitly flush later notifications', () => {
|
|
53
|
+
const { flow, bag } = makeFlow();
|
|
54
|
+
bag.pending.push(flow.makeQueueEntry('task finished', { mode: 'task-notification', key: 'task-1' }));
|
|
55
|
+
|
|
56
|
+
const out = flow.drainPendingSteering({ maxPriority: 'later' });
|
|
57
|
+
|
|
58
|
+
assert.equal(out.length, 1);
|
|
59
|
+
assert.equal(out[0], 'task finished');
|
|
60
|
+
assert.equal(bag.pending.length, 0);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('drainPendingSteering accepts runtime callback signature', () => {
|
|
64
|
+
const { flow, bag } = makeFlow();
|
|
65
|
+
bag.pending.push(flow.makeQueueEntry('task finished', { mode: 'task-notification', key: 'task-1' }));
|
|
66
|
+
|
|
67
|
+
const out = flow.drainPendingSteering('session-id', { maxPriority: 'later' });
|
|
68
|
+
|
|
69
|
+
assert.deepEqual(out, ['task finished']);
|
|
70
|
+
assert.equal(bag.pending.length, 0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('drained notification key remains deduped and displayed key is not cleared', () => {
|
|
74
|
+
const { flow, bag } = makeFlow();
|
|
75
|
+
bag.getState().busy = true;
|
|
76
|
+
assert.equal(flow.enqueue('task finished', { mode: 'task-notification', key: 'task-1' }), true);
|
|
77
|
+
bag.displayedExecutionNotificationKeys.add('task-1');
|
|
78
|
+
|
|
79
|
+
const out = flow.drainPendingSteering({ maxPriority: 'later' });
|
|
80
|
+
|
|
81
|
+
assert.deepEqual(out, ['task finished']);
|
|
82
|
+
assert.equal(flow.enqueue('task duplicate', { mode: 'task-notification', key: 'task-1' }), false);
|
|
83
|
+
assert.equal(bag.displayedExecutionNotificationKeys.has('task-1'), true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('drainPendingSteering leaves slash commands pending', () => {
|
|
50
87
|
const { flow, bag } = makeFlow();
|
|
51
88
|
bag.pending.push(flow.makeQueueEntry('/clear', { mode: 'prompt' }));
|
|
52
89
|
bag.pending.push(flow.makeQueueEntry('steer text', { mode: 'prompt' }));
|
|
53
90
|
|
|
54
91
|
const out = flow.drainPendingSteering();
|
|
55
92
|
|
|
56
|
-
assert.equal(out.length, 1, '
|
|
57
|
-
assert.equal(
|
|
93
|
+
assert.equal(out.length, 1, 'non-slash prompt drains');
|
|
94
|
+
assert.equal(out[0], 'steer text');
|
|
95
|
+
assert.equal(bag.pending.length, 1, 'slash command stays queued for post-turn processing');
|
|
96
|
+
assert.equal(bag.pending[0].content, '/clear');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('enqueue while busy does not start a parallel Lead turn', async () => {
|
|
100
|
+
const { flow, bag, getRunTurns } = makeFlow();
|
|
101
|
+
bag.getState().busy = true;
|
|
102
|
+
|
|
103
|
+
assert.equal(flow.enqueue('scheduled message', { mode: 'task-notification' }), true);
|
|
104
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
105
|
+
|
|
106
|
+
assert.equal(getRunTurns(), 0, 'busy enqueue must wait for active turn boundary');
|
|
107
|
+
assert.equal(bag.pending.length, 1, 'message remains pending for post-turn drain');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('post-turn drain does not send queued slash command to model', async () => {
|
|
111
|
+
const { flow, bag, getRunTurns } = makeFlow();
|
|
112
|
+
bag.pending.push(flow.makeQueueEntry('/clear', { mode: 'prompt' }));
|
|
113
|
+
|
|
114
|
+
await flow.drain();
|
|
115
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
116
|
+
|
|
117
|
+
assert.equal(getRunTurns(), 0, 'slash command must not be runTurn model text');
|
|
118
|
+
assert.equal(bag.pending.length, 1, 'slash command remains for command dispatcher');
|
|
58
119
|
assert.equal(bag.pending[0].content, '/clear');
|
|
59
120
|
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createSessionFlow } from '../src/tui/engine/session-flow.mjs';
|
|
4
|
+
import { createEngineApiA } from '../src/tui/engine/session-api.mjs';
|
|
5
|
+
|
|
6
|
+
// Regression harness for prompt loss when a submit races an in-flight session
|
|
7
|
+
// command (commandBusy) or an auto-clear. Wires the real session-flow queue
|
|
8
|
+
// (enqueue/drain) + the real submit() against a minimal bag whose set() mirrors
|
|
9
|
+
// engine.mjs's commandBusy-release drain kick.
|
|
10
|
+
function makeEngine({ autoClearBeforeSubmit } = {}) {
|
|
11
|
+
let seq = 0;
|
|
12
|
+
const executed = [];
|
|
13
|
+
let state = { queued: [], busy: false, commandBusy: false };
|
|
14
|
+
const bag = {
|
|
15
|
+
runtime: { id: null, consumePendingSessionReset: () => null },
|
|
16
|
+
nextId: () => `id_${++seq}`,
|
|
17
|
+
tuiDebug: () => {},
|
|
18
|
+
flags: {},
|
|
19
|
+
pending: [],
|
|
20
|
+
listeners: new Set(),
|
|
21
|
+
pendingNotificationKeys: new Set(),
|
|
22
|
+
displayedExecutionNotificationKeys: new Set(),
|
|
23
|
+
getState: () => state,
|
|
24
|
+
set: (patch) => {
|
|
25
|
+
if (!patch || typeof patch !== 'object') return false;
|
|
26
|
+
const released = state.commandBusy === true
|
|
27
|
+
&& Object.prototype.hasOwnProperty.call(patch, 'commandBusy')
|
|
28
|
+
&& patch.commandBusy === false;
|
|
29
|
+
state = { ...state, ...patch };
|
|
30
|
+
if (released) queueMicrotask(() => { void bag.drain?.(); });
|
|
31
|
+
return true;
|
|
32
|
+
},
|
|
33
|
+
pushItem: () => {},
|
|
34
|
+
patchItem: () => {},
|
|
35
|
+
replaceItems: (x) => x,
|
|
36
|
+
pushNotice: () => {},
|
|
37
|
+
pushUserOrSyntheticItem: (text) => { executed.push(text); },
|
|
38
|
+
autoClearState: () => ({ enabled: false }),
|
|
39
|
+
agentStatusState: () => ({}),
|
|
40
|
+
routeState: () => ({}),
|
|
41
|
+
syncContextStats: () => {},
|
|
42
|
+
denyAllToolApprovals: () => {},
|
|
43
|
+
updateAgentJobCard: () => {},
|
|
44
|
+
requeueEntriesFront: () => {},
|
|
45
|
+
resetStatsAndSyncContext: () => {},
|
|
46
|
+
flushDeferredExecutionPendingResumeKick: () => {},
|
|
47
|
+
runTurn: async () => 'ok',
|
|
48
|
+
};
|
|
49
|
+
Object.assign(bag, createSessionFlow(bag));
|
|
50
|
+
if (autoClearBeforeSubmit) bag.autoClearBeforeSubmit = autoClearBeforeSubmit;
|
|
51
|
+
const api = createEngineApiA(bag);
|
|
52
|
+
return {
|
|
53
|
+
api,
|
|
54
|
+
bag,
|
|
55
|
+
getExecuted: () => executed,
|
|
56
|
+
getState: () => bag.getState(),
|
|
57
|
+
mutateState: (mutator) => { mutator(state); },
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const tick = () => new Promise((r) => setTimeout(r, 0));
|
|
62
|
+
|
|
63
|
+
test('submit during commandBusy queues the prompt instead of dropping it', async () => {
|
|
64
|
+
const { api, bag, getExecuted } = makeEngine();
|
|
65
|
+
bag.set({ commandBusy: true });
|
|
66
|
+
const ok = api.submit('queued while busy');
|
|
67
|
+
assert.equal(ok, true, 'submit accepted (not dropped)');
|
|
68
|
+
assert.equal(bag.pending.length, 1, 'prompt preserved in queue');
|
|
69
|
+
await tick();
|
|
70
|
+
assert.equal(getExecuted().length, 0, 'drain bails while commandBusy');
|
|
71
|
+
bag.set({ commandBusy: false });
|
|
72
|
+
await tick(); await tick();
|
|
73
|
+
assert.deepEqual(getExecuted(), ['queued while busy'], 'prompt runs after command releases');
|
|
74
|
+
assert.equal(bag.pending.length, 0);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('blocked drain retries even if commandBusy is cleared outside set hook', async () => {
|
|
78
|
+
const { api, bag, getExecuted, mutateState } = makeEngine();
|
|
79
|
+
bag.set({ commandBusy: true });
|
|
80
|
+
assert.equal(api.submit('queued until direct release'), true);
|
|
81
|
+
assert.equal(bag.pending.length, 1);
|
|
82
|
+
await tick();
|
|
83
|
+
assert.equal(getExecuted().length, 0);
|
|
84
|
+
|
|
85
|
+
mutateState((state) => { state.commandBusy = false; });
|
|
86
|
+
await new Promise((r) => setTimeout(r, 80));
|
|
87
|
+
assert.deepEqual(getExecuted(), ['queued until direct release']);
|
|
88
|
+
assert.equal(bag.pending.length, 0);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('idle submit runs the prompt after autoClearBeforeSubmit resolves', async () => {
|
|
92
|
+
const { api, getExecuted, bag } = makeEngine();
|
|
93
|
+
const ok = api.submit('idle prompt');
|
|
94
|
+
assert.equal(ok, true);
|
|
95
|
+
await tick(); await tick();
|
|
96
|
+
assert.deepEqual(getExecuted(), ['idle prompt']);
|
|
97
|
+
assert.equal(bag.pending.length, 0);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('submit still enqueues when autoClearBeforeSubmit rejects', async () => {
|
|
101
|
+
const { api, getExecuted } = makeEngine({
|
|
102
|
+
autoClearBeforeSubmit: () => Promise.reject(new Error('compaction timed out')),
|
|
103
|
+
});
|
|
104
|
+
const ok = api.submit('survives rejection');
|
|
105
|
+
assert.equal(ok, true);
|
|
106
|
+
await tick(); await tick();
|
|
107
|
+
assert.deepEqual(getExecuted(), ['survives rejection'], 'rejected auto-clear must not lose the prompt');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('empty and whitespace submits are still rejected', () => {
|
|
111
|
+
const { api, bag } = makeEngine();
|
|
112
|
+
assert.equal(api.submit(' '), false);
|
|
113
|
+
assert.equal(bag.pending.length, 0);
|
|
114
|
+
});
|
package/scripts/tool-smoke.mjs
CHANGED
|
@@ -193,7 +193,12 @@ function assertOk(name, result, pattern = null) {
|
|
|
193
193
|
|
|
194
194
|
{
|
|
195
195
|
const prevTraceDisable = process.env.MIXDOG_AGENT_TRACE_DISABLE;
|
|
196
|
+
const prevOaiTransport = process.env.MIXDOG_OAI_TRANSPORT;
|
|
196
197
|
process.env.MIXDOG_AGENT_TRACE_DISABLE = '1';
|
|
198
|
+
// This smoke intentionally verifies the pinned WS-only escape hatch. Default
|
|
199
|
+
// transport is now refs-style auto (WS-first with HTTP fallback), so
|
|
200
|
+
// forceHttpFallback would legitimately call fakeHttp unless we pin ws-delta.
|
|
201
|
+
process.env.MIXDOG_OAI_TRANSPORT = 'ws-delta';
|
|
197
202
|
try {
|
|
198
203
|
const provider = new OpenAIOAuthProvider({});
|
|
199
204
|
provider.ensureAuth = async () => ({ access_token: 'fake-token' });
|
|
@@ -252,6 +257,8 @@ function assertOk(name, result, pattern = null) {
|
|
|
252
257
|
} finally {
|
|
253
258
|
if (prevTraceDisable == null) delete process.env.MIXDOG_AGENT_TRACE_DISABLE;
|
|
254
259
|
else process.env.MIXDOG_AGENT_TRACE_DISABLE = prevTraceDisable;
|
|
260
|
+
if (prevOaiTransport == null) delete process.env.MIXDOG_OAI_TRANSPORT;
|
|
261
|
+
else process.env.MIXDOG_OAI_TRANSPORT = prevOaiTransport;
|
|
255
262
|
}
|
|
256
263
|
}
|
|
257
264
|
|
|
@@ -485,6 +492,28 @@ const findOut = await executeBuiltinTool('find', {
|
|
|
485
492
|
}, root);
|
|
486
493
|
assertOk('find', findOut, /scripts[\\/]tool-smoke\.mjs/i);
|
|
487
494
|
|
|
495
|
+
// find query[] batch: fan-out must emit one section per query in caller order
|
|
496
|
+
// and share the broad enumeration sweep across queries (single-flight dedup).
|
|
497
|
+
const findBatchOut = await executeBuiltinTool('find', {
|
|
498
|
+
query: ['tool smoke', 'smoke'],
|
|
499
|
+
path: '.',
|
|
500
|
+
head_limit: 5,
|
|
501
|
+
}, root);
|
|
502
|
+
{
|
|
503
|
+
const s = String(findBatchOut);
|
|
504
|
+
const iA = s.indexOf('# find tool smoke');
|
|
505
|
+
const iB = s.indexOf('# find smoke');
|
|
506
|
+
if (iA < 0 || iB < 0) {
|
|
507
|
+
throw new Error(`find query[] must emit a section per query:\n${s.slice(0, 600)}`);
|
|
508
|
+
}
|
|
509
|
+
if (!(iA < iB)) {
|
|
510
|
+
throw new Error(`find query[] must preserve caller order:\n${s.slice(0, 600)}`);
|
|
511
|
+
}
|
|
512
|
+
if (!/scripts[\\/]tool-smoke\.mjs/i.test(s)) {
|
|
513
|
+
throw new Error(`find query[] sections must carry match bodies:\n${s.slice(0, 600)}`);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
488
517
|
const readOut = await executeBuiltinTool('read', {
|
|
489
518
|
path: 'scripts/smoke.mjs',
|
|
490
519
|
offset: 0,
|
|
@@ -743,13 +772,22 @@ const shellOut = await shellOutPromise;
|
|
|
743
772
|
assertOk('bash explicit shell/cwd', shellOut, /v\d+\.\d+\.\d+/);
|
|
744
773
|
|
|
745
774
|
const shellFailOut = await shellFailOutPromise;
|
|
746
|
-
if (!/^Error[\s:[]/.test(String(shellFailOut)) || !/\[exit code: 7\]/.test(String(shellFailOut))) {
|
|
747
|
-
throw new Error(`bash non-zero exit must be classified as Error:\n${shellFailOut}`);
|
|
775
|
+
if (!/^Error[\s:[]/.test(String(shellFailOut)) || !/\[shell-run-failed\]/.test(String(shellFailOut)) || !/\[exit code: 7\]/.test(String(shellFailOut))) {
|
|
776
|
+
throw new Error(`bash non-zero exit must be classified as shell-run-failed Error:\n${shellFailOut}`);
|
|
748
777
|
}
|
|
749
778
|
|
|
750
779
|
const shellTimeoutOut = await shellTimeoutOutPromise;
|
|
751
|
-
if (!/^Error[\s:[]/.test(String(shellTimeoutOut)) || !/\[timeout: 500ms\b/.test(String(shellTimeoutOut))) {
|
|
752
|
-
throw new Error(`bash timeout must be milliseconds and classified as Error:\n${shellTimeoutOut}`);
|
|
780
|
+
if (!/^Error[\s:[]/.test(String(shellTimeoutOut)) || !/\[shell-run-failed\]/.test(String(shellTimeoutOut)) || !/\[timeout: 500ms\b/.test(String(shellTimeoutOut))) {
|
|
781
|
+
throw new Error(`bash timeout must be milliseconds and classified as shell-run-failed Error:\n${shellTimeoutOut}`);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
const shellArgFailOut = await executeBuiltinTool('shell', {
|
|
785
|
+
command: '',
|
|
786
|
+
cwd: root,
|
|
787
|
+
shell: 'powershell',
|
|
788
|
+
}, root);
|
|
789
|
+
if (!/^Error[\s:[]/.test(String(shellArgFailOut)) || !/\[shell-tool-failed\]/.test(String(shellArgFailOut))) {
|
|
790
|
+
throw new Error(`shell tool/preflight failures must be classified as shell-tool-failed Error:\n${shellArgFailOut}`);
|
|
753
791
|
}
|
|
754
792
|
|
|
755
793
|
// Auto-promotion: a sync foreground command still running past the (soft)
|
|
@@ -1207,6 +1245,14 @@ const explorerPrompt = buildExplorerPrompt('where is <agent> & status?');
|
|
|
1207
1245
|
if (!explorerPrompt.includes('<agent>') || !explorerPrompt.includes('&') || /verdicts, ratings, or recommendations/.test(explorerPrompt) === false) {
|
|
1208
1246
|
throw new Error(`explorer prompt contract failed: ${explorerPrompt}`);
|
|
1209
1247
|
}
|
|
1248
|
+
if (
|
|
1249
|
+
!/STOP and answer NOW/.test(explorerPrompt)
|
|
1250
|
+
|| !/Turns 2-3 exist SOLELY as zero-hit recovery/.test(explorerPrompt)
|
|
1251
|
+
|| !/HARD max 3 tool turns/.test(explorerPrompt)
|
|
1252
|
+
|| !/turn 1\/3/.test(explorerPrompt)
|
|
1253
|
+
) {
|
|
1254
|
+
throw new Error(`explorer prompt must force immediate answer on a specific-token anchor while preserving the 3-turn cap: ${explorerPrompt}`);
|
|
1255
|
+
}
|
|
1210
1256
|
setInternalToolsProvider({
|
|
1211
1257
|
executor: async () => 'tool-smoke internal tool',
|
|
1212
1258
|
tools: [
|
|
@@ -1737,8 +1783,35 @@ if (/line\/context/i.test(JSON.stringify(readTool?.inputSchema || {}))) {
|
|
|
1737
1783
|
if (!/applyToCurrentSession = options\?\.applyToCurrentSession === true/.test(setRouteBlock)) {
|
|
1738
1784
|
throw new Error('setRoute must default applyToCurrentSession to false (model changes apply to the next session only)');
|
|
1739
1785
|
}
|
|
1740
|
-
if (!/
|
|
1741
|
-
|
|
1786
|
+
if (!/const applyLive = applyToCurrentSession \|\| currentSessionEmpty/.test(setRouteBlock)
|
|
1787
|
+
|| !/if \(!applyLive\)/.test(setRouteBlock)
|
|
1788
|
+
|| !/return getRoute\(\);/.test(setRouteBlock)) {
|
|
1789
|
+
throw new Error('setRoute must early-return before touching a non-empty live session when applyToCurrentSession is false');
|
|
1790
|
+
}
|
|
1791
|
+
// Empty current session must apply live so /model before the first chat
|
|
1792
|
+
// updates route + statusline at once, but compact summary anchors are route
|
|
1793
|
+
// history and must keep a compacted session next-session-only. Seeded system
|
|
1794
|
+
// or synthetic assistant/tool rows alone must NOT make the session non-empty.
|
|
1795
|
+
if (!/!hasRouteHistoryMessage\(session\.messages\)/.test(setRouteBlock)
|
|
1796
|
+
|| !/!hasRouteHistoryMessage\(session\.liveTurnMessages\)/.test(setRouteBlock)
|
|
1797
|
+
|| !/SUMMARY_PREFIX/.test(runtimeSrc)
|
|
1798
|
+
|| !/hasUserConversationMessage\(list\) \|\| list\.some\(isSummaryAnchorMessage\)/.test(runtimeSrc)
|
|
1799
|
+
|| !/function hasRouteHistoryMessage/.test(runtimeSrc)) {
|
|
1800
|
+
throw new Error('setRoute must apply live only to route-empty sessions and must treat compact summary anchors as non-empty route history');
|
|
1801
|
+
}
|
|
1802
|
+
if (!/createCurrentSession\('model-switch-empty'\)/.test(setRouteBlock)
|
|
1803
|
+
|| !/createCurrentSession\('model-switch-empty-drain'\)/.test(setRouteBlock)
|
|
1804
|
+
|| !/const emptySession = getSession\(\)/.test(setRouteBlock)
|
|
1805
|
+
|| !/cli-model-switch-empty/.test(setRouteBlock)
|
|
1806
|
+
|| !/pushTranscriptRebind\?\.\(\)/.test(setRouteBlock)
|
|
1807
|
+
|| !/invalidatePreSessionToolSurface\?\.\(\)/.test(setRouteBlock)) {
|
|
1808
|
+
throw new Error('setRoute must drain in-flight create then recreate/rebind empty live sessions so provider-specific BP1/tool surface is rebuilt for /model before first chat');
|
|
1809
|
+
}
|
|
1810
|
+
const sessionLifecycleSrc = readMjsSources('src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs');
|
|
1811
|
+
const updateSessionRouteBlock = sessionLifecycleSrc.match(/export function updateSessionRoute\(id, route = \{\}\) \{[\s\S]*?\n\}/)?.[0] || '';
|
|
1812
|
+
if (!/session\.promptCacheKey = providerCacheKey\(session\.provider\)/.test(updateSessionRouteBlock)
|
|
1813
|
+
|| !/session\.providerCacheOpts = buildSessionProviderCacheOpts\(session\.provider, session\.id, session\.agent\) \|\| null/.test(updateSessionRouteBlock)) {
|
|
1814
|
+
throw new Error('updateSessionRoute must refresh provider-scoped prompt cache fields when an empty live session changes provider/model');
|
|
1742
1815
|
}
|
|
1743
1816
|
const engineSrc = [readMjsSources('src/tui/engine.mjs'), readMjsSources('src/tui/engine')].join('\n');
|
|
1744
1817
|
if (/setRoute\(\{ model: m \}, \{ applyToCurrentSession: true \}\)/.test(engineSrc)) {
|
|
@@ -2074,13 +2147,13 @@ const grepPathDescription = grepTool?.inputSchema?.properties?.path?.description
|
|
|
2074
2147
|
const grepGlobDescription = grepTool?.inputSchema?.properties?.glob?.description || '';
|
|
2075
2148
|
const grepOutputModeDescription = grepTool?.inputSchema?.properties?.output_mode?.description || '';
|
|
2076
2149
|
const grepHeadLimitDescription = grepTool?.inputSchema?.properties?.head_limit?.description || '';
|
|
2077
|
-
if (!/Array = variants in one call/i.test(grepPatternDescription) || !/Verified file
|
|
2150
|
+
if (!/Array = variants in one call/i.test(grepPatternDescription) || !/Verified file\/dir/i.test(grepPathDescription)) {
|
|
2078
2151
|
throw new Error('grep schema must keep compact pattern/path guidance');
|
|
2079
2152
|
}
|
|
2080
|
-
if (!/verified
|
|
2153
|
+
if (!/verified scope/i.test(grepTool?.description || '') || !/Unknown path\/name.*find first/i.test(grepTool?.description || '')) {
|
|
2081
2154
|
throw new Error('grep description must require verified scopes and locator-first unknown paths');
|
|
2082
2155
|
}
|
|
2083
|
-
if (!/
|
|
2156
|
+
if (!/Glob filter/i.test(grepGlobDescription) || !/no guessed src\/\*\*/i.test(grepGlobDescription)) {
|
|
2084
2157
|
throw new Error('grep glob schema must describe scope narrowing');
|
|
2085
2158
|
}
|
|
2086
2159
|
if (!/files_with_matches\/count/i.test(grepOutputModeDescription) || !/content_with_context/i.test(grepOutputModeDescription)) {
|
|
@@ -2098,7 +2171,7 @@ const listTool = BUILTIN_TOOLS.find((tool) => tool.name === 'list');
|
|
|
2098
2171
|
if (!/exact glob from verified roots/i.test(globTool?.description || '')) {
|
|
2099
2172
|
throw new Error('glob description must route exact-pattern unknown paths before read/grep/list');
|
|
2100
2173
|
}
|
|
2101
|
-
if (!/
|
|
2174
|
+
if (!/Partial path\/name lookup/i.test(findTool?.description || '') || !/verify roots before grep\/glob/i.test(findTool?.description || '')) {
|
|
2102
2175
|
throw new Error('find description must advertise unverified path/name lookup and verified outputs');
|
|
2103
2176
|
}
|
|
2104
2177
|
if (!/List verified directories/i.test(listTool?.description || '') || !/Unknown dir.*find first/i.test(listTool?.description || '') || !/Verified directory/i.test(listTool?.inputSchema?.properties?.path?.description || '')) {
|
package/src/app.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs';
|
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { performance } from 'node:perf_hooks';
|
|
5
|
+
import { ensureProcessListenerHeadroom } from './runtime/shared/process-listener-headroom.mjs';
|
|
5
6
|
|
|
6
7
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
8
|
// --workflow is consumed (with its value) but ignored on the headless role
|
|
@@ -35,7 +36,7 @@ const BOOT_PROFILE_START = globalThis.__mixdogBootProfileStart || (globalThis.__
|
|
|
35
36
|
// which legitimately exceeds Node's default 10-listener warning threshold in a
|
|
36
37
|
// fully-loaded runtime. Raise the cap once at the CLI entry so a benign
|
|
37
38
|
// MaxListenersExceededWarning never leaks into the user's terminal.
|
|
38
|
-
|
|
39
|
+
ensureProcessListenerHeadroom(64);
|
|
39
40
|
|
|
40
41
|
function bootProfile(event, fields = {}) {
|
|
41
42
|
if (!BOOT_PROFILE_ENABLED) return;
|
|
@@ -51,6 +51,14 @@ or inconclusive, keep the CORE entry.
|
|
|
51
51
|
- `keep` — durable and already one short clause.
|
|
52
52
|
- `update` — durable but verbose or multi-sentence → rewrite as one ≤120-char clause.
|
|
53
53
|
- `merge` — duplicates another entry → fold into the survivor (same project pool).
|
|
54
|
+
- `reclassify` — the entry is filed under the WRONG project pool. Its subject
|
|
55
|
+
clearly belongs to a different pool shown among the entries under review: a
|
|
56
|
+
project-specific fact stored in COMMON belongs under that project, or a generic
|
|
57
|
+
cross-project rule mis-filed under one project belongs in COMMON. Name the
|
|
58
|
+
correct destination: `<id>|reclassify|<project_slug|common>`. The target must
|
|
59
|
+
be COMMON or a project pool that already appears on another entry above — never
|
|
60
|
+
invent a new pool. Reclassify only MOVES the entry (its text is unchanged and
|
|
61
|
+
nothing is deleted); use it only when the mis-scoping is unambiguous, else `keep`.
|
|
54
62
|
- `superseded` — a NEWER active core entry directly CONTRADICTS the value/state
|
|
55
63
|
this older entry asserts (same
|
|
56
64
|
subject, changed fact: a renamed thing, a moved location, a reversed
|
|
@@ -103,6 +111,7 @@ One line per entry id, any order:
|
|
|
103
111
|
<id>|keep
|
|
104
112
|
<id>|update|<element>|<summary>
|
|
105
113
|
<id>|merge|<target_id>|<source_ids_csv>
|
|
114
|
+
<id>|reclassify|<project_slug|common>
|
|
106
115
|
<id>|superseded|<newer_id>
|
|
107
116
|
<id>|delete|<reason>
|
|
108
117
|
```
|