mixdog 0.9.36 → 0.9.38

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.
Files changed (115) hide show
  1. package/package.json +3 -2
  2. package/scripts/abort-recovery-test.mjs +118 -0
  3. package/scripts/compact-smoke.mjs +7 -7
  4. package/scripts/dispatch-persist-recovery-test.mjs +141 -0
  5. package/scripts/explore-bench-tmp.mjs +19 -0
  6. package/scripts/explore-bench.mjs +101 -14
  7. package/scripts/explore-prompt-policy-test.mjs +31 -0
  8. package/scripts/ingest-pure-conversation-smoke.mjs +11 -11
  9. package/scripts/notify-completion-mirror-test.mjs +73 -0
  10. package/scripts/openai-ws-early-settle-test.mjs +176 -0
  11. package/scripts/output-style-smoke.mjs +17 -17
  12. package/scripts/provider-toolcall-test.mjs +333 -14
  13. package/scripts/recall-bench-cases.json +3 -3
  14. package/scripts/recall-bench.mjs +1 -1
  15. package/scripts/recall-quality-cases.json +4 -4
  16. package/scripts/recall-usecase-cases.json +2 -2
  17. package/scripts/session-bench.mjs +13 -13
  18. package/scripts/session-sweep.mjs +266 -0
  19. package/scripts/steering-drain-buckets-test.mjs +72 -11
  20. package/scripts/submit-commandbusy-race-test.mjs +114 -0
  21. package/scripts/tool-smoke.mjs +83 -10
  22. package/src/app.mjs +2 -1
  23. package/src/defaults/cycle3-review-prompt.md +9 -0
  24. package/src/defaults/skills/setup/SKILL.md +93 -293
  25. package/src/lib/rules-builder.cjs +3 -2
  26. package/src/output-styles/default.md +2 -2
  27. package/src/output-styles/extreme-minimal.md +1 -1
  28. package/src/output-styles/minimal.md +1 -1
  29. package/src/output-styles/simple.md +2 -3
  30. package/src/rules/agent/30-explorer.md +40 -14
  31. package/src/rules/lead/01-general.md +4 -0
  32. package/src/rules/shared/01-tool.md +7 -3
  33. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +15 -3
  34. package/src/runtime/agent/orchestrator/config.mjs +1 -1
  35. package/src/runtime/agent/orchestrator/dispatch-persist.mjs +31 -8
  36. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +13 -7
  37. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +6 -6
  38. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +6 -6
  39. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +35 -5
  40. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +27 -0
  41. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +36 -37
  42. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +2 -2
  43. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +72 -1
  44. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +26 -3
  45. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +12 -28
  46. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +18 -15
  47. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +5 -2
  48. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +119 -3
  49. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +113 -2
  50. package/src/runtime/agent/orchestrator/providers/registry.mjs +44 -5
  51. package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +78 -0
  52. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +57 -31
  53. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +8 -6
  54. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +28 -2
  55. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +1 -3
  56. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +15 -2
  57. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +30 -55
  58. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +2 -2
  59. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +24 -16
  60. package/src/runtime/agent/orchestrator/session/result-classification.mjs +2 -0
  61. package/src/runtime/agent/orchestrator/session/store.mjs +116 -21
  62. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +5 -2
  63. package/src/runtime/agent/orchestrator/stall-policy.mjs +55 -0
  64. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +35 -20
  65. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +9 -9
  66. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +77 -31
  67. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +25 -3
  68. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +9 -2
  69. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +73 -25
  70. package/src/runtime/agent/orchestrator/tools/builtin.mjs +2 -1
  71. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +17 -7
  72. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -12
  73. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +5 -0
  74. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +56 -6
  75. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
  76. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -0
  77. package/src/runtime/channels/lib/worker-main.mjs +5 -0
  78. package/src/runtime/memory/lib/core-memory-store.mjs +104 -0
  79. package/src/runtime/memory/lib/ko-morph.mjs +1 -1
  80. package/src/runtime/memory/lib/memory-cycle3.mjs +64 -4
  81. package/src/runtime/memory/lib/memory-text-utils.mjs +4 -4
  82. package/src/runtime/memory/lib/recall-format.mjs +3 -3
  83. package/src/runtime/shared/child-spawn-gate.mjs +15 -0
  84. package/src/runtime/shared/config.mjs +98 -12
  85. package/src/runtime/shared/process-listener-headroom.mjs +12 -0
  86. package/src/session-runtime/cwd-plugins.mjs +6 -3
  87. package/src/session-runtime/model-route-api.mjs +50 -2
  88. package/src/session-runtime/provider-auth-api.mjs +8 -1
  89. package/src/session-runtime/provider-models.mjs +3 -3
  90. package/src/session-runtime/resource-api.mjs +22 -0
  91. package/src/session-runtime/runtime-core.mjs +56 -10
  92. package/src/session-runtime/settings-api.mjs +11 -2
  93. package/src/session-runtime/warmup-schedulers.mjs +7 -1
  94. package/src/standalone/agent-tool.mjs +15 -9
  95. package/src/standalone/explore-tool.mjs +4 -1
  96. package/src/tui/App.jsx +6 -5
  97. package/src/tui/app/transcript-window.mjs +60 -10
  98. package/src/tui/app/use-transcript-window.mjs +2 -1
  99. package/src/tui/components/ContextPanel.jsx +4 -1
  100. package/src/tui/components/ToolExecution.jsx +15 -12
  101. package/src/tui/components/TranscriptItem.jsx +1 -1
  102. package/src/tui/components/tool-execution/surface-detail.mjs +8 -8
  103. package/src/tui/dist/index.mjs +482 -151
  104. package/src/tui/engine/agent-job-feed.mjs +36 -6
  105. package/src/tui/engine/notification-plan.mjs +3 -3
  106. package/src/tui/engine/queue-helpers.mjs +8 -0
  107. package/src/tui/engine/render-timing.mjs +104 -8
  108. package/src/tui/engine/session-api.mjs +58 -3
  109. package/src/tui/engine/session-flow.mjs +93 -29
  110. package/src/tui/engine/tool-card-results.mjs +28 -13
  111. package/src/tui/engine/turn.mjs +238 -157
  112. package/src/tui/engine.mjs +17 -8
  113. package/src/tui/index.jsx +10 -1
  114. package/src/workflows/default/WORKFLOW.md +8 -4
  115. package/src/workflows/solo/WORKFLOW.md +8 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.36",
3
+ "version": "0.9.38",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -46,7 +46,7 @@
46
46
  "smoke:tools": "node scripts/tool-smoke.mjs",
47
47
  "smoke:patch": "node scripts/apply-patch-edit-smoke.mjs",
48
48
  "smoke:output": "node scripts/output-style-smoke.mjs",
49
- "smoke:tui": "node scripts/tui-render-smoke.mjs",
49
+ "smoke:tui": "node scripts/tui-render-smoke.mjs && npm run test:tui-queue",
50
50
  "smoke:freevars": "node scripts/freevar-smoke.mjs",
51
51
  "smoke:logguard": "node scripts/log-writer-guard-smoke.mjs",
52
52
  "test:toolcall": "node --test scripts/toolcall-args-test.mjs",
@@ -55,6 +55,7 @@
55
55
  "test:placeholder": "node --test scripts/compacted-placeholder-scrub-test.mjs",
56
56
  "test:providers": "node --test scripts/provider-toolcall-test.mjs",
57
57
  "test:atomiclock": "node --test scripts/atomic-lock-tryonce-test.mjs",
58
+ "test:tui-queue": "node --test scripts/submit-commandbusy-race-test.mjs scripts/steering-drain-buckets-test.mjs scripts/abort-recovery-test.mjs",
58
59
  "test:rebindtail": "node --test scripts/forwarder-rebind-tail-test.mjs",
59
60
  "failures": "node scripts/tool-failures.mjs",
60
61
  "trace:llm": "node scripts/llm-trace-summary.mjs",
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+ // Regression: bounded manual-abort recovery. Esc calls runtime.abort(), which
3
+ // normally rejects the in-flight runtime.ask() so the turn's finally clears
4
+ // busy. If that unwind is STARVED (provider abort never settles after a
5
+ // post-tool fetch stall), busy must not stay true forever — a short grace timer
6
+ // hard-releases the store and re-kicks drain. A normal abort that settles in
7
+ // time must NOT be masked by the recovery.
8
+ import test from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import { createSessionFlow } from '../src/tui/engine/session-flow.mjs';
11
+ import { createEngineApiA } from '../src/tui/engine/session-api.mjs';
12
+
13
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
14
+
15
+ // Minimal engine bag exercising only the abort() recovery path. `abortSettles`
16
+ // models whether runtime.abort() actually unwinds the turn (clears busy) — the
17
+ // starved case leaves busy=true so the recovery timer must fire.
18
+ function makeEngine({ abortSettles = false, recoveryMs = 30 } = {}) {
19
+ let seq = 0;
20
+ const notices = [];
21
+ let drainCount = 0;
22
+ let state = { items: [], queued: [], busy: false, commandBusy: false, spinner: null, thinking: null, lastTurn: null };
23
+ const bag = {
24
+ runtime: {
25
+ id: null,
26
+ consumePendingSessionReset: () => null,
27
+ abort: () => {
28
+ if (abortSettles) bag.set({ busy: false, spinner: null, thinking: null, lastTurn: null });
29
+ return true;
30
+ },
31
+ },
32
+ nextId: () => `id_${++seq}`,
33
+ tuiDebug: () => {},
34
+ flags: { leadTurnEpoch: 1, disposed: false, draining: false, activePromptRestore: null, manualAbortRecoveryMs: recoveryMs },
35
+ pending: [],
36
+ listeners: new Set(),
37
+ getState: () => state,
38
+ set: (patch) => {
39
+ if (!patch || typeof patch !== 'object') return false;
40
+ state = { ...state, ...patch };
41
+ return true;
42
+ },
43
+ pushItem: () => {},
44
+ patchItem: () => {},
45
+ replaceItems: (x) => x,
46
+ pushNotice: (text, level) => { notices.push({ text, level }); },
47
+ pushUserOrSyntheticItem: () => {},
48
+ autoClearState: () => ({ enabled: false }),
49
+ agentStatusState: () => ({}),
50
+ routeState: () => ({}),
51
+ syncContextStats: () => {},
52
+ denyAllToolApprovals: () => {},
53
+ updateAgentJobCard: () => {},
54
+ requeueEntriesFront: () => {},
55
+ resetStatsAndSyncContext: () => {},
56
+ flushDeferredExecutionPendingResumeKick: () => {},
57
+ drain: async () => { drainCount += 1; },
58
+ runTurn: async () => 'ok',
59
+ };
60
+ Object.assign(bag, createSessionFlow(bag));
61
+ bag.drain = async () => { drainCount += 1; };
62
+ const api = createEngineApiA(bag);
63
+ return { api, bag, getNotices: () => notices, getDrainCount: () => drainCount };
64
+ }
65
+
66
+ test('starved abort → bounded recovery hard-releases busy and re-kicks drain', async () => {
67
+ const { api, bag, getNotices, getDrainCount } = makeEngine({ abortSettles: false, recoveryMs: 25 });
68
+ bag.set({ busy: true, spinner: { active: true } });
69
+ bag.pending.push({ kind: 'prompt', text: 'queued next' });
70
+ const res = api.abort();
71
+ assert.equal(res.aborted, true, 'abort dispatched to runtime');
72
+ assert.equal(bag.getState().busy, true, 'still busy immediately after abort (unwind pending)');
73
+ await wait(60);
74
+ assert.equal(bag.getState().busy, false, 'recovery timer force-releases busy');
75
+ assert.equal(bag.getState().spinner, null, 'spinner cleared on recovery');
76
+ assert.equal(bag.flags.leadTurnEpoch, 2, 'epoch bumped so stuck turn finally becomes a no-op');
77
+ assert.equal(getDrainCount() >= 1, true, 'drain re-kicked so queued prompt runs');
78
+ assert.equal(getNotices().some((n) => /did not settle/i.test(n.text)), true, 'user told input was restored');
79
+ });
80
+
81
+ test('starved abort abandons old drain owner before re-kicking drain', async () => {
82
+ const { api, bag, getDrainCount } = makeEngine({ abortSettles: false, recoveryMs: 25 });
83
+ bag.flags.draining = true;
84
+ bag.flags.drainEpoch = 10;
85
+ bag.set({ busy: true, spinner: { active: true } });
86
+ bag.pending.push({ kind: 'prompt', text: 'queued next' });
87
+
88
+ api.abort();
89
+ await wait(60);
90
+
91
+ assert.equal(bag.getState().busy, false);
92
+ assert.equal(bag.flags.draining, false, 'stuck drain lock was released only after epoch abandonment');
93
+ assert.equal(bag.flags.drainEpoch > 10, true, 'old drain owner invalidated');
94
+ assert.equal(getDrainCount() >= 1, true, 'new drain kick requested for pending work');
95
+ });
96
+
97
+ test('abort that settles in time is NOT masked by recovery', async () => {
98
+ const { api, bag, getNotices } = makeEngine({ abortSettles: true, recoveryMs: 25 });
99
+ bag.set({ busy: true, spinner: { active: true } });
100
+ const res = api.abort();
101
+ assert.equal(res.aborted, true);
102
+ assert.equal(bag.getState().busy, false, 'settled abort cleared busy synchronously');
103
+ const epochAfter = bag.flags.leadTurnEpoch;
104
+ await wait(60);
105
+ assert.equal(bag.flags.leadTurnEpoch, epochAfter, 'recovery no-ops (no epoch bump) when busy already cleared');
106
+ assert.equal(getNotices().some((n) => /did not settle/i.test(n.text)), false, 'no spurious recovery notice');
107
+ });
108
+
109
+ test('recovery no-ops if a newer turn already owns the store', async () => {
110
+ const { api, bag } = makeEngine({ abortSettles: false, recoveryMs: 25 });
111
+ bag.set({ busy: true });
112
+ api.abort();
113
+ bag.flags.leadTurnEpoch = 5;
114
+ bag.set({ busy: true, spinner: { active: true, verb: 'new turn' } });
115
+ await wait(60);
116
+ assert.equal(bag.flags.leadTurnEpoch, 5, 'recovery must not touch a newer turn epoch');
117
+ assert.equal(bag.getState().busy, true, 'newer turn stays busy — not force-released');
118
+ });
@@ -858,7 +858,7 @@ function chars4(text) {
858
858
  return Math.ceil(String(text ?? '').length / 4);
859
859
  }
860
860
 
861
- const koHeavy = '한국어 컴팩션 경계 테스트 '.repeat(200);
861
+ const koHeavy = '\uD55C\uAD6D\uC5B4 \uCEF4\uD329\uC158 \uACBD\uACC4 \uD14C\uC2A4\uD2B8 '.repeat(200);
862
862
  const koEst = estimateMessagesTokens([{ role: 'user', content: koHeavy }]);
863
863
  assert(
864
864
  koEst > chars4(koHeavy) * 2,
@@ -892,8 +892,8 @@ assert(
892
892
  // also use the conservative estimator (>= chars/4 of the serialized JSON).
893
893
  const toolSchema = [{
894
894
  name: 'apply_patch',
895
- description: '패치를 파일에 적용 (한국어 설명) — applies a patch',
896
- parameters: { type: 'object', properties: { patch: { type: 'string', description: '패치 본문 텍스트' } } },
895
+ description: '\uD328\uCE58\uB97C \uD30C\uC77C\uC5D0 \uC801\uC6A9 (\uD55C\uAD6D\uC5B4 \uC124\uBA85) — applies a patch',
896
+ parameters: { type: 'object', properties: { patch: { type: 'string', description: '\uD328\uCE58 \uBCF8\uBB38 \uD14D\uC2A4\uD2B8' } } },
897
897
  }];
898
898
  const toolSchemaEst = estimateToolSchemaTokens(toolSchema);
899
899
  assert(
@@ -904,7 +904,7 @@ assert(
904
904
  // Strict-fit smoke: a Korean/CJK-heavy newest turn far larger than the preserve
905
905
  // budget must be summarized/truncated safely — never preserved verbatim — and
906
906
  // the final estimated tokens must stay within budget.
907
- const KO_HUGE_SENTINEL = 'KO_HUGE_SENTINEL_' + '압축경계테스트'.repeat(20_000);
907
+ const KO_HUGE_SENTINEL = 'KO_HUGE_SENTINEL_' + '\uC555\uCD95\uACBD\uACC4\uD14C\uC2A4\uD2B8'.repeat(20_000);
908
908
  let strictFitPrompt = '';
909
909
  const strictFitProvider = {
910
910
  name: 'strict-fit-cjk-smoke',
@@ -915,9 +915,9 @@ const strictFitProvider = {
915
915
  };
916
916
  const strictFitMessages = [
917
917
  { role: 'system', content: 'system rules stay mandatory' },
918
- { role: 'user', content: '이전 작은 요청' },
919
- { role: 'assistant', content: '이전 작은 응답' },
920
- { role: 'user', content: `최신 거대한 한국어 요청 ${KO_HUGE_SENTINEL}` },
918
+ { role: 'user', content: '\uC774\uC804 \uC791\uC740 \uC694\uCCAD' },
919
+ { role: 'assistant', content: '\uC774\uC804 \uC791\uC740 \uC751\uB2F5' },
920
+ { role: 'user', content: `\uCD5C\uC2E0 \uAC70\uB300\uD55C \uD55C\uAD6D\uC5B4 \uC694\uCCAD ${KO_HUGE_SENTINEL}` },
921
921
  ];
922
922
  const STRICT_FIT_BUDGET = 6_000;
923
923
  const strictFitResult = await semanticCompactMessages(strictFitProvider, strictFitMessages, 'fake-model', STRICT_FIT_BUDGET, {
@@ -0,0 +1,141 @@
1
+ // Regression tests for recoverPending recovery bugs:
2
+ // (a) an entry must survive when notifyFn resolves false (not delivered) and
3
+ // only be removed after a confirmed (truthy) ack.
4
+ // (b) a scoped recovery that matches purely on clientHostPid must NOT stamp
5
+ // the reconnecting filter session's id onto another session's abort — it
6
+ // delivers to the true owner session, and leaves the entry persisted when
7
+ // there is no owner session to target.
8
+ import test from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import { mkdtempSync, rmSync, readFileSync } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+ import {
14
+ addPending,
15
+ recoverPending,
16
+ } from '../src/runtime/agent/orchestrator/dispatch-persist.mjs';
17
+
18
+ const FILE = 'pending-dispatches.json';
19
+
20
+ function readMap(dir) {
21
+ try {
22
+ const raw = readFileSync(join(dir, FILE), 'utf8');
23
+ return raw.trim() ? JSON.parse(raw) : {};
24
+ } catch { return {}; }
25
+ }
26
+
27
+ async function waitFor(fn, { timeout = 3000, step = 20 } = {}) {
28
+ const deadline = Date.now() + timeout;
29
+ for (;;) {
30
+ if (fn()) return true;
31
+ if (Date.now() > deadline) return false;
32
+ await new Promise((r) => setTimeout(r, step));
33
+ }
34
+ }
35
+
36
+ function tmp() {
37
+ const dir = mkdtempSync(join(tmpdir(), 'dpersist-'));
38
+ return dir;
39
+ }
40
+
41
+ test('(a) only explicit false/0 keeps entry; undefined/void resolve deletes', async () => {
42
+ const dir = tmp();
43
+ try {
44
+ addPending(dir, 'h-a', 'recall', ['q'], 'sid-owner', 1111);
45
+ await waitFor(() => 'h-a' in readMap(dir));
46
+
47
+ // Explicit false → undelivered, entry MUST survive for retry.
48
+ recoverPending(dir, () => Promise.resolve(false), {});
49
+ await new Promise((r) => setTimeout(r, 200));
50
+ assert.ok('h-a' in readMap(dir), 'entry removed despite notifyFn=false');
51
+
52
+ // Explicit 0 → also undelivered, entry survives.
53
+ recoverPending(dir, () => Promise.resolve(0), {});
54
+ await new Promise((r) => setTimeout(r, 200));
55
+ assert.ok('h-a' in readMap(dir), 'entry removed despite notifyFn=0');
56
+
57
+ // undefined/void resolve from a delivered notifyFn → entry removed.
58
+ recoverPending(dir, () => Promise.resolve(undefined), {});
59
+ const gone = await waitFor(() => !('h-a' in readMap(dir)));
60
+ assert.ok(gone, 'entry not removed after undefined (delivered) resolve');
61
+ } finally {
62
+ rmSync(dir, { recursive: true, force: true });
63
+ }
64
+ });
65
+
66
+ test('(b) hostPid-only match delivers to true owner session, never the filter session', async () => {
67
+ const dir = tmp();
68
+ try {
69
+ addPending(dir, 'h-b', 'recall', ['q'], 'owner-A', 4242);
70
+ await waitFor(() => 'h-b' in readMap(dir));
71
+
72
+ let seen = null;
73
+ // Reconnect as a DIFFERENT session that only shares the host pid.
74
+ recoverPending(dir, (content, meta) => { seen = meta; return Promise.resolve(true); }, {
75
+ sessionId: 'session-B',
76
+ clientHostPid: 4242,
77
+ });
78
+ await waitFor(() => seen != null);
79
+ assert.equal(seen.caller_session_id, 'owner-A', 'stamped filter session instead of true owner');
80
+ assert.notEqual(seen.caller_session_id, 'session-B');
81
+ } finally {
82
+ rmSync(dir, { recursive: true, force: true });
83
+ }
84
+ });
85
+
86
+ test('(b) hostPid-only match with no owner session is left persisted, not injected', async () => {
87
+ const dir = tmp();
88
+ try {
89
+ addPending(dir, 'h-c', 'recall', ['q'], null, 5353);
90
+ await waitFor(() => 'h-c' in readMap(dir));
91
+
92
+ let called = false;
93
+ recoverPending(dir, () => { called = true; return Promise.resolve(true); }, {
94
+ sessionId: 'session-C',
95
+ clientHostPid: 5353,
96
+ });
97
+ await new Promise((r) => setTimeout(r, 250));
98
+ assert.equal(called, false, 'notifyFn fired for an entry with no owner session');
99
+ assert.ok('h-c' in readMap(dir), 'entry with no owner session was not left persisted');
100
+ } finally {
101
+ rmSync(dir, { recursive: true, force: true });
102
+ }
103
+ });
104
+
105
+ test('owner-session match still stamps the reconnecting session id', async () => {
106
+ const dir = tmp();
107
+ try {
108
+ addPending(dir, 'h-d', 'recall', ['q'], 'prior-sid', 6464);
109
+ await waitFor(() => 'h-d' in readMap(dir));
110
+
111
+ let seen = null;
112
+ recoverPending(dir, (content, meta) => { seen = meta; return Promise.resolve(true); }, {
113
+ sessionId: 'new-sid',
114
+ priorSessionId: 'prior-sid',
115
+ clientHostPid: 6464,
116
+ });
117
+ await waitFor(() => seen != null);
118
+ assert.equal(seen.caller_session_id, 'new-sid', 'owner match should stamp reconnecting session id');
119
+ } finally {
120
+ rmSync(dir, { recursive: true, force: true });
121
+ }
122
+ });
123
+
124
+ test('priorSessionId match with no current sessionId keeps the entry owner cid', async () => {
125
+ const dir = tmp();
126
+ try {
127
+ addPending(dir, 'h-e', 'recall', ['q'], 'prior-sid', 7575);
128
+ await waitFor(() => 'h-e' in readMap(dir));
129
+
130
+ let seen = null;
131
+ // priorSessionId matches the owner but no current sessionId is supplied →
132
+ // filterSid is null; must fall back to the entry's known owner cid.
133
+ recoverPending(dir, (content, meta) => { seen = meta; return Promise.resolve(true); }, {
134
+ priorSessionId: 'prior-sid',
135
+ });
136
+ await waitFor(() => seen != null);
137
+ assert.equal(seen.caller_session_id, 'prior-sid', 'dropped known owner cid when sessionId absent');
138
+ } finally {
139
+ rmSync(dir, { recursive: true, force: true });
140
+ }
141
+ });
@@ -1,4 +1,9 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
1
4
  import { runExplore } from '../src/standalone/explore-tool.mjs';
5
+ // Attribute LLM send time (not just wall) for this high-fanout batch.
6
+ process.env.MIXDOG_AGENT_TRACE_TIMING = '1';
2
7
  const queries = [
3
8
  'standalone explore tool implementation entry point',
4
9
  'V4A patch format parsing/conversion implementation',
@@ -14,4 +19,18 @@ const res = await runExplore({ query: queries, cwd: 'C:/Project/mixdog' }, { cal
14
19
  console.log('BATCH_ELAPSED_MS', Date.now() - t0);
15
20
  const txt = res.content?.[0]?.text || '';
16
21
  console.log('FAILED_COUNT', (txt.match(/EXPLORATION_FAILED/g) || []).length);
22
+ // Sum send_ms from loop rows emitted since t0 → LLM time vs the wall above.
23
+ const tracePath = process.env.MIXDOG_AGENT_TRACE_PATH
24
+ || join(process.env.MIXDOG_DATA_DIR || join(process.env.MIXDOG_HOME || join(homedir(), '.mixdog'), 'data'), 'history', 'agent-trace.jsonl');
25
+ let sendMs = 0, sends = 0; const sessions = new Set();
26
+ if (existsSync(tracePath)) {
27
+ for (const line of readFileSync(tracePath, 'utf8').split('\n')) {
28
+ if (!line) continue;
29
+ try {
30
+ const r = JSON.parse(line);
31
+ if (r.ts >= t0 && r.kind === 'loop' && r.agent === 'explorer') { sendMs += Number(r.send_ms) || 0; sends++; sessions.add(r.session_id); }
32
+ } catch { /* skip */ }
33
+ }
34
+ }
35
+ console.log('LLM_SEND_MS_TOTAL', sendMs, 'sends', sends, 'sessions', sessions.size);
17
36
  process.exit(0);
@@ -6,13 +6,47 @@
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
+ function checkAnchorLine(line, ctxTokens = []) {
24
+ const m = line.match(/([A-Za-z0-9_.\/\\-]+):(\d+)/);
25
+ if (!m) return null; // no parseable path:line
26
+ const p = m[1];
27
+ const ln = Number(m[2]);
28
+ const full = isAbsolute(p) ? p : join(CWD, p);
29
+ if (!existsSync(full)) return false;
30
+ try {
31
+ const fileLines = readFileSync(full, 'utf8').split('\n');
32
+ if (!(ln >= 1 && ln <= fileLines.length)) return false;
33
+ // Fabricated-line guard: the cited line's ±2 window must contain a
34
+ // query/expected token, so a plausible-but-wrong line number fails.
35
+ if (ctxTokens.length) {
36
+ // ±2 lines around the 1-based cited line (indices ln-3 .. ln+1 inclusive).
37
+ const win = fileLines.slice(Math.max(0, ln - 3), ln + 2).join('\n').toLowerCase();
38
+ if (!ctxTokens.some((t) => win.includes(t))) return false;
39
+ }
40
+ return true;
41
+ } catch { return false; }
42
+ }
43
+
44
+ // High-fanout timing attribution: emit lightweight per-iteration send_ms
45
+ // loop rows (no verbose payload estimate) so we can split LLM send time
46
+ // from tool time under the parallel round below. Trace-only; no behavior
47
+ // or provider change.
48
+ if (!process.env.MIXDOG_AGENT_TRACE_TIMING) process.env.MIXDOG_AGENT_TRACE_TIMING = '1';
49
+
16
50
  // Representative locator queries with ground truth: expect = substrings, at
17
51
  // least one must appear in the anchor output (quality hit). expectFail = the
18
52
  // correct answer is EXPLORATION_FAILED (miss discipline).
@@ -51,17 +85,24 @@ const QUERIES = [
51
85
  expect: ['agent-dispatch'],
52
86
  },
53
87
  {
54
- q: '에이전트 세션의 최대 루프 반복 횟수는 어디서 정해져?', // korean concept query
88
+ q: '\uC5D0\uC774\uC804\uD2B8 \uC138\uC158\uC758 \uCD5C\uB300 \uB8E8\uD504 \uBC18\uBCF5 \uD69F\uC218\uB294 \uC5B4\uB514\uC11C \uC815\uD574\uC838?', // korean concept query
55
89
  expect: ['agent-loop-policy'],
56
90
  },
57
91
  {
58
92
  q: 'how does a fixed agent slot map to a workflow slot (explore/explorer)',
59
- expect: ['mixdog-session-runtime', 'internal-agents'],
93
+ expect: ['session-runtime/workflow', 'FIXED_AGENT_SLOTS'],
60
94
  },
61
95
  {
62
96
  q: 'where is the mixdog config json file path resolved (data dir)',
63
97
  expect: ['config', 'plugin-paths'],
64
98
  },
99
+ {
100
+ // path-only class: the correct answer is a verified file/dir location on
101
+ // disk; a bare path (no :line) is an allowed HIT for this query.
102
+ q: 'where does mixdog store background shell job stdout logs on disk',
103
+ expect: ['shell-jobs', 'shell-job-paths', 'getShellJobsDir', 'shellJobStdout'],
104
+ pathOnly: true,
105
+ },
65
106
  {
66
107
  q: 'GraphQL schema stitching resolver implementation', // not in this repo
67
108
  expectFail: true,
@@ -81,7 +122,9 @@ function readTraceSince(ts) {
81
122
  if (!line) continue;
82
123
  try {
83
124
  const r = JSON.parse(line);
84
- if (r.ts >= ts && r.agent === 'explorer') rows.push(r);
125
+ // Keep every row since ts; explorer-session attribution happens at
126
+ // aggregation time.
127
+ if (r.ts >= ts) rows.push(r);
85
128
  } catch { /* skip */ }
86
129
  }
87
130
  return rows;
@@ -90,24 +133,49 @@ function readTraceSince(ts) {
90
133
  const t0 = Date.now();
91
134
  // Parallel round: all queries fire at once. Per-session trace attribution is
92
135
  // by session_id so counts stay correct; per-query ms includes queueing.
93
- const results = await Promise.all(QUERIES.map(async ({ q, expect, expectFail }) => {
136
+ const results = await Promise.all(QUERIES.map(async ({ q, expect, expectFail, pathOnly }) => {
94
137
  const qt0 = Date.now();
95
138
  const res = await runExplore({ query: q, cwd: CWD }, { callerCwd: CWD });
96
139
  const text = res?.content?.[0]?.text || '';
97
- const anchors = text.split('\n').filter((l) => /:\d+/.test(l)).length;
140
+ const aLines = anchorLinesOf(text);
141
+ const anchors = aLines.length;
98
142
  const failed = /EXPLORATION_FAILED/.test(text);
99
- const lower = text.toLowerCase();
143
+ // Context tokens for the fabricated-line guard: expanded expected + query
144
+ // words, length>=4 to drop filler.
145
+ const ctxTokens = [
146
+ ...(expect || []).flatMap((e) => e.toLowerCase().split(/[^a-z0-9]+/)),
147
+ ...q.toLowerCase().split(/[^a-z0-9]+/),
148
+ ].filter((t) => t.length >= 4);
149
+ // Spot-check up to 2 anchors per query for file/line validity + line context.
150
+ let badAnchors = 0;
151
+ for (const l of aLines.slice(0, 2)) {
152
+ if (checkAnchorLine(l, ctxTokens) === false) badAnchors++;
153
+ }
154
+ const allLines = text.split('\n').filter((l) => l.trim());
155
+ // Strict hit: expected token must appear ON an anchor line (>=1 anchor). For
156
+ // the path-only class, an expected token on any verified path line HITs even
157
+ // without :line. expectFail wants EXPLORATION_FAILED with zero anchors.
100
158
  const hit = expectFail
101
159
  ? (failed && anchors === 0)
102
- : (!failed && (expect || []).some((e) => lower.includes(e.toLowerCase())));
103
- return { q: q.slice(0, 48), ms: Date.now() - qt0, anchors, failed, hit, bytes: text.length };
160
+ : pathOnly
161
+ ? (!failed && (expect || []).some(
162
+ (e) => allLines.some((l) => l.toLowerCase().includes(e.toLowerCase())),
163
+ ))
164
+ : (!failed && anchors > 0 && (expect || []).some(
165
+ (e) => aLines.some((l) => l.toLowerCase().includes(e.toLowerCase())),
166
+ ));
167
+ return { q: q.slice(0, 48), ms: Date.now() - qt0, anchors, failed, hit, badAnchors, bytes: text.length };
104
168
  }));
105
169
 
106
- // Attribute tool calls per explorer session spawned during this run.
170
+ // Identify explorer sessions from trace rows, then attribute both tool calls
171
+ // and LLM send time per session.
107
172
  const rows = readTraceSince(t0);
173
+ const explorerSessions = new Set(
174
+ rows.filter((r) => r.agent === 'explorer').map((r) => r.session_id)
175
+ );
108
176
  const bySession = new Map();
109
177
  for (const r of rows) {
110
- if (r.kind !== 'tool') continue;
178
+ if (r.kind !== 'tool' || !explorerSessions.has(r.session_id)) continue;
111
179
  const s = bySession.get(r.session_id) || { calls: 0, tools: [] };
112
180
  s.calls++; s.tools.push(r.tool_name);
113
181
  bySession.set(r.session_id, s);
@@ -115,11 +183,30 @@ for (const r of rows) {
115
183
  const callCounts = [...bySession.values()].map((s) => s.calls).sort((a, b) => a - b);
116
184
  const p50 = callCounts[Math.floor(callCounts.length / 2)] ?? 0;
117
185
 
186
+ // LLM send-time attribution from loop rows (kind='loop', send_ms), emitted
187
+ // under MIXDOG_AGENT_TRACE_TIMING. Under the parallel round, sends overlap
188
+ // wall time, so send/wall > 1 quantifies provider contention.
189
+ const sendBySession = new Map();
190
+ for (const r of rows) {
191
+ if (r.kind !== 'loop' || !explorerSessions.has(r.session_id)) continue;
192
+ const cur = sendBySession.get(r.session_id) || { sends: 0, ms: 0 };
193
+ cur.sends++; cur.ms += Number(r.send_ms) || 0;
194
+ sendBySession.set(r.session_id, cur);
195
+ }
196
+ const sendMsPer = [...sendBySession.values()].map((s) => s.ms).sort((a, b) => a - b);
197
+ const sendTotal = sendMsPer.reduce((a, b) => a + b, 0);
198
+ const sendP50 = sendMsPer[Math.floor(sendMsPer.length / 2)] ?? 0;
199
+ const sendMax = sendMsPer[sendMsPer.length - 1] ?? 0;
200
+ const sendCount = [...sendBySession.values()].reduce((a, s) => a + s.sends, 0);
201
+
118
202
  console.log(`\n=== explore-bench round=${ROUND} ===`);
119
203
  for (const r of results) {
120
- console.log(` [${String(r.ms).padStart(6)}ms] ${r.hit ? 'HIT ' : 'MISS'} anchors=${r.anchors} failed=${r.failed} bytes=${r.bytes} ${r.q}`);
204
+ 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}`);
121
205
  }
122
- console.log(` quality: ${results.filter((r) => r.hit).length}/${results.length} hits`);
206
+ const badTotal = results.reduce((a, r) => a + r.badAnchors, 0);
207
+ console.log(` quality: ${results.filter((r) => r.hit).length}/${results.length} hits (strict) bad-anchors=${badTotal}`);
123
208
  console.log(` sessions=${bySession.size} toolcalls p50=${p50} max=${callCounts[callCounts.length - 1] ?? 0} total=${callCounts.reduce((a, b) => a + b, 0)}`);
124
209
  for (const [id, s] of bySession) console.log(` ${id.slice(-8)}: ${s.tools.join(',')}`);
125
- console.log(` wall total=${((Date.now() - t0) / 1000).toFixed(1)}s`);
210
+ const wallMs = Date.now() - t0;
211
+ console.log(` llm-send: sessions=${sendBySession.size} sends=${sendCount} ms/session p50=${sendP50} max=${sendMax} total=${sendTotal}`);
212
+ console.log(` wall total=${(wallMs / 1000).toFixed(1)}s send/wall=${wallMs > 0 ? (sendTotal / wallMs).toFixed(2) : '0.00'}x`);
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+ import test from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { readFileSync } from 'node:fs';
5
+ import { buildExplorerPrompt } from '../src/standalone/explore-tool.mjs';
6
+ import { BUILTIN_TOOLS } from '../src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs';
7
+
8
+ test('explore per-query prompt leaves path policy to tool schemas', () => {
9
+ const prompt = buildExplorerPrompt('display model usage show usage model_usage provider_usage session cache usage state');
10
+ assert.doesNotMatch(prompt, /Path policy:/i);
11
+ assert.doesNotMatch(prompt, /run find for that fragment before any grep\/glob/i);
12
+ });
13
+
14
+ test('grep glob find tool schemas carry unverified path policy', () => {
15
+ const byName = Object.fromEntries(BUILTIN_TOOLS.map((tool) => [tool.name, tool]));
16
+ // Conditional find-first: only genuinely guessed fragments route through
17
+ // find, and in the SAME turn — project root itself is a verified scope.
18
+ assert.match(byName.grep.description, /project root counts as verified/i);
19
+ assert.match(byName.grep.description, /guessed path fragment → find first, same turn/i);
20
+ assert.match(byName.grep.description, /no path "\." \+ guessed src\/\*\*/i);
21
+ assert.match(byName.glob.description, /project root is verified/i);
22
+ assert.match(byName.glob.description, /Guessed root\/name → find first, same turn/i);
23
+ assert.match(byName.find.description, /verify roots before grep\/glob/i);
24
+ });
25
+
26
+ test('explorer turn-1 contract includes batched find for unknown broad targets', () => {
27
+ const rule = readFileSync(new URL('../src/rules/agent/30-explorer.md', import.meta.url), 'utf8');
28
+ assert.match(rule, /Turn 1 is the whole search in ONE message/i);
29
+ assert.match(rule, /unknown\/broad\s+targets\)\s+find `query\[\]`/i);
30
+ assert.match(rule, /path\/name fragments from multiple tokens/i);
31
+ });
@@ -46,7 +46,7 @@ const prefixedUser = {
46
46
  `${sessionBlock}\n\n` +
47
47
  '# Additional context\nsome context body line one\nline two\n\n' +
48
48
  '# Prefetch\nprefetched file snippet\n\n' +
49
- '# Task\n실제 질문',
49
+ '# Task\n\uC2E4\uC81C \uC9C8\uBB38',
50
50
  }
51
51
  // (iii) Reference files synthetic row.
52
52
  const referenceRow = { role: 'user', content: 'Reference files:\n- a.mjs\n- b.mjs' }
@@ -76,8 +76,8 @@ const unquotedCompletionRow = {
76
76
  'Result:\nbackground task\ntask_id: task_456\nstatus: completed',
77
77
  }
78
78
  // (vii) normal human + normal assistant.
79
- const normalUser = { role: 'user', content: '안녕' }
80
- const normalAsst = { role: 'assistant', content: ' 안녕하세요' }
79
+ const normalUser = { role: 'user', content: '\uC548\uB155' }
80
+ const normalAsst = { role: 'assistant', content: '\uB124 \uC548\uB155\uD558\uC138\uC694' }
81
81
 
82
82
  const before = {
83
83
  asstWithTool: sessionMessageContent(asstWithTool),
@@ -111,7 +111,7 @@ assert(after.asstWithTool && !after.asstWithTool.includes('[tool_call'), 'ingest
111
111
  assert(after.asstWithTool.includes('Here is my plan for the refactor.'), 'assistant prose must survive')
112
112
 
113
113
  // (ii) prefixed user collapses to exactly the human prompt.
114
- assert(after.prefixedUser === '실제 질문', `prefixed user must shape to "실제 질문" (got: ${JSON.stringify(after.prefixedUser)})`)
114
+ assert(after.prefixedUser === '\uC2E4\uC81C \uC9C8\uBB38', `prefixed user must shape to "\uC2E4\uC81C \uC9C8\uBB38" (got: ${JSON.stringify(after.prefixedUser)})`)
115
115
  assert(!/# Session|Cwd:|Model:|Additional context|Prefetch|# Task/.test(after.prefixedUser), 'no prefix residue may remain')
116
116
 
117
117
  // (iii)-(vi) synthetic rows excluded.
@@ -123,8 +123,8 @@ assert(after.completionWrapperRow === null, 'model-visible tool-completion wrapp
123
123
  assert(after.unquotedCompletionRow === null, 'unquoted tool-completion wrapper row (real DB shape) must be excluded')
124
124
 
125
125
  // (vii) normal conversation survives intact.
126
- assert(after.normalUser === '안녕', 'normal human text must survive intact')
127
- assert(after.normalAsst === ' 안녕하세요', 'normal assistant text must survive intact')
126
+ assert(after.normalUser === '\uC548\uB155', 'normal human text must survive intact')
127
+ assert(after.normalAsst === '\uB124 \uC548\uB155\uD558\uC138\uC694', 'normal assistant text must survive intact')
128
128
 
129
129
  // Zero-loss guard: a human message that merely MENTIONS the markers mid-text is
130
130
  // NOT stripped (anchors only fire on the leading manager-produced prefix).
@@ -137,18 +137,18 @@ assert(ingestRow(humanMentionsMarkers) === 'please write a # Task section and a
137
137
  // buildSessionStartBlock emits (`# Session` then only Cwd/Model/Workflow lines).
138
138
  // Assert on the SHAPER directly so the check isolates the strip rule from
139
139
  // cleanMemoryText's normal markdown-header cleaning.
140
- const humanSessionDoc = { role: 'user', content: '# Session\n프로젝트 회의록입니다\n다음 안건' }
140
+ const humanSessionDoc = { role: 'user', content: '# Session\n\uD504\uB85C\uC81D\uD2B8 \uD68C\uC758\uB85D\uC785\uB2C8\uB2E4\n\uB2E4\uC74C \uC548\uAC74' }
141
141
  assert(
142
- sessionMessageContentForIngest(humanSessionDoc) === '# Session\n프로젝트 회의록입니다\n다음 안건',
142
+ sessionMessageContentForIngest(humanSessionDoc) === '# Session\n\uD504\uB85C\uC81D\uD2B8 \uD68C\uC758\uB85D\uC785\uB2C8\uB2E4\n\uB2E4\uC74C \uC548\uAC74',
143
143
  'human doc starting with `# Session` heading must be preserved verbatim (not wiped)',
144
144
  )
145
145
  // And through the full ingest row it must still carry the human words (the lone
146
146
  // `# ` heading marker is stripped by normal markdown cleaning — not data loss).
147
- assert(/프로젝트 회의록입니다/.test(ingestRow(humanSessionDoc) || ''), 'human session-doc words must survive ingest')
147
+ assert(/\uD504\uB85C\uC81D\uD2B8 \uD68C\uC758\uB85D\uC785\uB2C8\uB2E4/.test(ingestRow(humanSessionDoc) || ''), 'human session-doc words must survive ingest')
148
148
 
149
149
  // Injected real session block (Cwd/Model) still strips to the human prompt.
150
- const injectedCwdModel = { role: 'user', content: '# Session\nCwd: /x\nModel: Y\n\n실제질문' }
151
- assert(sessionMessageContentForIngest(injectedCwdModel) === '실제질문', 'injected Cwd/Model session block must strip to the human prompt')
150
+ const injectedCwdModel = { role: 'user', content: '# Session\nCwd: /x\nModel: Y\n\n\uC2E4\uC81C\uC9C8\uBB38' }
151
+ assert(sessionMessageContentForIngest(injectedCwdModel) === '\uC2E4\uC81C\uC9C8\uBB38', 'injected Cwd/Model session block must strip to the human prompt')
152
152
 
153
153
  // Injected real session block incl. Workflow line still strips.
154
154
  const injectedWorkflow = { role: 'user', content: '# Session\nCwd: /x\nModel: Y\nWorkflow: Default\n\nq' }