mixdog 0.9.35 → 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.
Files changed (105) 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/explore-bench-tmp.mjs +19 -0
  5. package/scripts/explore-bench.mjs +36 -6
  6. package/scripts/explore-prompt-policy-test.mjs +27 -0
  7. package/scripts/ingest-pure-conversation-smoke.mjs +11 -11
  8. package/scripts/openai-ws-early-settle-test.mjs +176 -0
  9. package/scripts/output-style-smoke.mjs +17 -17
  10. package/scripts/provider-toolcall-test.mjs +333 -14
  11. package/scripts/recall-bench-cases.json +3 -3
  12. package/scripts/recall-bench.mjs +1 -1
  13. package/scripts/recall-quality-cases.json +4 -4
  14. package/scripts/recall-usecase-cases.json +2 -2
  15. package/scripts/session-bench.mjs +13 -13
  16. package/scripts/steering-drain-buckets-test.mjs +72 -11
  17. package/scripts/submit-commandbusy-race-test.mjs +114 -0
  18. package/scripts/tool-smoke.mjs +83 -10
  19. package/src/app.mjs +2 -1
  20. package/src/defaults/cycle3-review-prompt.md +9 -0
  21. package/src/defaults/skills/setup/SKILL.md +93 -293
  22. package/src/lib/rules-builder.cjs +3 -2
  23. package/src/output-styles/default.md +2 -2
  24. package/src/output-styles/extreme-minimal.md +1 -1
  25. package/src/output-styles/minimal.md +1 -1
  26. package/src/output-styles/simple.md +2 -3
  27. package/src/rules/agent/30-explorer.md +15 -7
  28. package/src/rules/lead/01-general.md +4 -0
  29. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +15 -3
  30. package/src/runtime/agent/orchestrator/config.mjs +1 -1
  31. package/src/runtime/agent/orchestrator/mcp/client.mjs +7 -7
  32. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +6 -6
  33. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +6 -6
  34. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +35 -5
  35. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +27 -0
  36. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +36 -37
  37. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +2 -2
  38. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +72 -1
  39. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +26 -3
  40. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +12 -28
  41. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +18 -15
  42. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +4 -2
  43. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +113 -2
  44. package/src/runtime/agent/orchestrator/providers/registry.mjs +44 -5
  45. package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +78 -0
  46. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +57 -31
  47. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +8 -6
  48. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +28 -2
  49. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +1 -3
  50. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +15 -2
  51. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +26 -17
  52. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +2 -2
  53. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +24 -16
  54. package/src/runtime/agent/orchestrator/session/result-classification.mjs +2 -0
  55. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +5 -2
  56. package/src/runtime/agent/orchestrator/stall-policy.mjs +18 -0
  57. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +35 -20
  58. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -7
  59. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +77 -31
  60. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +25 -3
  61. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +9 -2
  62. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +73 -25
  63. package/src/runtime/agent/orchestrator/tools/builtin.mjs +2 -1
  64. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +17 -7
  65. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -12
  66. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +5 -0
  67. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +56 -6
  68. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -0
  69. package/src/runtime/channels/lib/worker-main.mjs +5 -0
  70. package/src/runtime/memory/lib/core-memory-store.mjs +104 -0
  71. package/src/runtime/memory/lib/ko-morph.mjs +1 -1
  72. package/src/runtime/memory/lib/memory-cycle3.mjs +64 -4
  73. package/src/runtime/memory/lib/memory-text-utils.mjs +4 -4
  74. package/src/runtime/memory/lib/recall-format.mjs +3 -3
  75. package/src/runtime/shared/child-spawn-gate.mjs +15 -0
  76. package/src/runtime/shared/config.mjs +98 -12
  77. package/src/runtime/shared/process-listener-headroom.mjs +12 -0
  78. package/src/session-runtime/cwd-plugins.mjs +6 -3
  79. package/src/session-runtime/model-route-api.mjs +50 -2
  80. package/src/session-runtime/provider-auth-api.mjs +8 -1
  81. package/src/session-runtime/resource-api.mjs +22 -0
  82. package/src/session-runtime/runtime-core.mjs +13 -2
  83. package/src/session-runtime/settings-api.mjs +11 -2
  84. package/src/standalone/agent-tool.mjs +15 -9
  85. package/src/standalone/explore-tool.mjs +4 -1
  86. package/src/tui/App.jsx +6 -5
  87. package/src/tui/app/transcript-window.mjs +60 -10
  88. package/src/tui/app/use-transcript-window.mjs +2 -1
  89. package/src/tui/components/ContextPanel.jsx +4 -1
  90. package/src/tui/components/ToolExecution.jsx +15 -12
  91. package/src/tui/components/TranscriptItem.jsx +1 -1
  92. package/src/tui/components/tool-execution/surface-detail.mjs +8 -8
  93. package/src/tui/dist/index.mjs +473 -155
  94. package/src/tui/engine/agent-job-feed.mjs +26 -6
  95. package/src/tui/engine/context-state.mjs +13 -4
  96. package/src/tui/engine/notification-plan.mjs +3 -3
  97. package/src/tui/engine/render-timing.mjs +104 -8
  98. package/src/tui/engine/session-api.mjs +58 -3
  99. package/src/tui/engine/session-flow.mjs +78 -28
  100. package/src/tui/engine/tool-card-results.mjs +28 -13
  101. package/src/tui/engine/turn.mjs +232 -156
  102. package/src/tui/engine.mjs +17 -8
  103. package/src/tui/index.jsx +10 -1
  104. package/src/workflows/default/WORKFLOW.md +8 -4
  105. 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.35",
3
+ "version": "0.9.37",
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, {
@@ -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);
@@ -13,6 +13,12 @@ import { runExplore } from '../src/standalone/explore-tool.mjs';
13
13
  const ROUND = process.argv[2] || 'r0';
14
14
  const CWD = 'C:/Project/mixdog';
15
15
 
16
+ // High-fanout timing attribution: emit lightweight per-iteration send_ms
17
+ // loop rows (no verbose payload estimate) so we can split LLM send time
18
+ // from tool time under the parallel round below. Trace-only; no behavior
19
+ // or provider change.
20
+ if (!process.env.MIXDOG_AGENT_TRACE_TIMING) process.env.MIXDOG_AGENT_TRACE_TIMING = '1';
21
+
16
22
  // Representative locator queries with ground truth: expect = substrings, at
17
23
  // least one must appear in the anchor output (quality hit). expectFail = the
18
24
  // correct answer is EXPLORATION_FAILED (miss discipline).
@@ -51,12 +57,12 @@ const QUERIES = [
51
57
  expect: ['agent-dispatch'],
52
58
  },
53
59
  {
54
- q: '에이전트 세션의 최대 루프 반복 횟수는 어디서 정해져?', // korean concept query
60
+ 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
61
  expect: ['agent-loop-policy'],
56
62
  },
57
63
  {
58
64
  q: 'how does a fixed agent slot map to a workflow slot (explore/explorer)',
59
- expect: ['mixdog-session-runtime', 'internal-agents'],
65
+ expect: ['session-runtime/workflow', 'FIXED_AGENT_SLOTS'],
60
66
  },
61
67
  {
62
68
  q: 'where is the mixdog config json file path resolved (data dir)',
@@ -81,7 +87,9 @@ function readTraceSince(ts) {
81
87
  if (!line) continue;
82
88
  try {
83
89
  const r = JSON.parse(line);
84
- if (r.ts >= ts && r.agent === 'explorer') rows.push(r);
90
+ // Keep every row since ts; explorer-session attribution happens at
91
+ // aggregation time.
92
+ if (r.ts >= ts) rows.push(r);
85
93
  } catch { /* skip */ }
86
94
  }
87
95
  return rows;
@@ -103,11 +111,15 @@ const results = await Promise.all(QUERIES.map(async ({ q, expect, expectFail })
103
111
  return { q: q.slice(0, 48), ms: Date.now() - qt0, anchors, failed, hit, bytes: text.length };
104
112
  }));
105
113
 
106
- // Attribute tool calls per explorer session spawned during this run.
114
+ // Identify explorer sessions from trace rows, then attribute both tool calls
115
+ // and LLM send time per session.
107
116
  const rows = readTraceSince(t0);
117
+ const explorerSessions = new Set(
118
+ rows.filter((r) => r.agent === 'explorer').map((r) => r.session_id)
119
+ );
108
120
  const bySession = new Map();
109
121
  for (const r of rows) {
110
- if (r.kind !== 'tool') continue;
122
+ if (r.kind !== 'tool' || !explorerSessions.has(r.session_id)) continue;
111
123
  const s = bySession.get(r.session_id) || { calls: 0, tools: [] };
112
124
  s.calls++; s.tools.push(r.tool_name);
113
125
  bySession.set(r.session_id, s);
@@ -115,6 +127,22 @@ for (const r of rows) {
115
127
  const callCounts = [...bySession.values()].map((s) => s.calls).sort((a, b) => a - b);
116
128
  const p50 = callCounts[Math.floor(callCounts.length / 2)] ?? 0;
117
129
 
130
+ // LLM send-time attribution from loop rows (kind='loop', send_ms), emitted
131
+ // under MIXDOG_AGENT_TRACE_TIMING. Under the parallel round, sends overlap
132
+ // wall time, so send/wall > 1 quantifies provider contention.
133
+ const sendBySession = new Map();
134
+ for (const r of rows) {
135
+ if (r.kind !== 'loop' || !explorerSessions.has(r.session_id)) continue;
136
+ const cur = sendBySession.get(r.session_id) || { sends: 0, ms: 0 };
137
+ cur.sends++; cur.ms += Number(r.send_ms) || 0;
138
+ sendBySession.set(r.session_id, cur);
139
+ }
140
+ const sendMsPer = [...sendBySession.values()].map((s) => s.ms).sort((a, b) => a - b);
141
+ const sendTotal = sendMsPer.reduce((a, b) => a + b, 0);
142
+ const sendP50 = sendMsPer[Math.floor(sendMsPer.length / 2)] ?? 0;
143
+ const sendMax = sendMsPer[sendMsPer.length - 1] ?? 0;
144
+ const sendCount = [...sendBySession.values()].reduce((a, s) => a + s.sends, 0);
145
+
118
146
  console.log(`\n=== explore-bench round=${ROUND} ===`);
119
147
  for (const r of results) {
120
148
  console.log(` [${String(r.ms).padStart(6)}ms] ${r.hit ? 'HIT ' : 'MISS'} anchors=${r.anchors} failed=${r.failed} bytes=${r.bytes} ${r.q}`);
@@ -122,4 +150,6 @@ for (const r of results) {
122
150
  console.log(` quality: ${results.filter((r) => r.hit).length}/${results.length} hits`);
123
151
  console.log(` sessions=${bySession.size} toolcalls p50=${p50} max=${callCounts[callCounts.length - 1] ?? 0} total=${callCounts.reduce((a, b) => a + b, 0)}`);
124
152
  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`);
153
+ const wallMs = Date.now() - t0;
154
+ console.log(` llm-send: sessions=${sendBySession.size} sends=${sendCount} ms/session p50=${sendP50} max=${sendMax} total=${sendTotal}`);
155
+ console.log(` wall total=${(wallMs / 1000).toFixed(1)}s send/wall=${wallMs > 0 ? (sendTotal / wallMs).toFixed(2) : '0.00'}x`);
@@ -0,0 +1,27 @@
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
+ assert.match(byName.grep.description, /Unknown path\/name → find first/i);
17
+ assert.match(byName.grep.description, /no path "\." \+ guessed src\/\*\*/i);
18
+ assert.match(byName.glob.description, /Unknown root\/name → find first/i);
19
+ assert.match(byName.find.description, /verify roots before grep\/glob/i);
20
+ });
21
+
22
+ test('explorer turn-1 contract includes batched find for unknown broad targets', () => {
23
+ const rule = readFileSync(new URL('../src/rules/agent/30-explorer.md', import.meta.url), 'utf8');
24
+ assert.match(rule, /Turn 1 is the whole search in ONE message/i);
25
+ assert.match(rule, /unknown\/broad\s+targets\)\s+find `query\[\]`/i);
26
+ assert.match(rule, /path\/name fragments from multiple tokens/i);
27
+ });
@@ -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' }
@@ -0,0 +1,176 @@
1
+ // Regression: OpenAI OAuth WS early tool-call settle.
2
+ //
3
+ // A tool-call response can be fully formed (function_call_arguments.done +
4
+ // output_item.done) yet the server never emits the terminal
5
+ // response.completed/response.done frame. Before the fix _streamResponse
6
+ // coasted on that silence until a stall error (gated out of the loop via
7
+ // pendingToolUse) or the 30-min agent watchdog, and -- because the socket was
8
+ // still pooled -- the missing terminal frame later leaked onto the NEXT request
9
+ // as orphan bytes (http_status=0 wedge). The fix resolves early with the
10
+ // captured tool call and flags `closeSocket` so the pool discards the socket.
11
+ import test from 'node:test';
12
+ import assert from 'node:assert/strict';
13
+ import { EventEmitter } from 'node:events';
14
+ import { _streamResponse } from '../src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs';
15
+
16
+ // Minimal fake WS: matches the .on/.off/.close/.ping surface _streamResponse
17
+ // uses. close() is a no-op here -- the early-settle path sets done=true before
18
+ // calling it, so the real closeHandler would short-circuit anyway.
19
+ class FakeSocket extends EventEmitter {
20
+ constructor() {
21
+ super();
22
+ this.readyState = 1;
23
+ this.closed = null;
24
+ }
25
+ close(code, reason) { this.closed = { code, reason }; }
26
+ ping() {}
27
+ feed(events) { for (const e of events) this.emit('message', JSON.stringify(e)); }
28
+ }
29
+
30
+ const FAST_TIMEOUTS = { interChunkMs: 40, preResponseCreatedMs: 5000, firstMeaningfulMs: 5000 };
31
+
32
+ const TOOL_CALL_EVENTS = [
33
+ { type: 'response.created', response: { id: 'resp_1', model: 'gpt-5.5' } },
34
+ { type: 'response.output_item.added', item: { type: 'function_call', id: 'fc_1', name: 'explore', call_id: 'call_1' } },
35
+ { type: 'response.function_call_arguments.delta', item_id: 'fc_1', delta: '{"query"' },
36
+ { type: 'response.function_call_arguments.done', item_id: 'fc_1', arguments: '{"query":"x"}', call_id: 'call_1', name: 'explore' },
37
+ { type: 'response.output_item.done', item: { type: 'function_call', id: 'fc_1', name: 'explore', call_id: 'call_1', arguments: '{"query":"x"}' } },
38
+ ];
39
+
40
+ test('ws early settle: complete tool call resolves without response.completed and flags closeSocket', async () => {
41
+ const socket = new FakeSocket();
42
+ const state = {};
43
+ const emitted = [];
44
+ const p = _streamResponse({
45
+ entry: { socket },
46
+ state,
47
+ onToolCall: (c) => emitted.push(c),
48
+ _timeouts: FAST_TIMEOUTS,
49
+ });
50
+ socket.feed(TOOL_CALL_EVENTS);
51
+ const result = await p;
52
+ assert.equal(result.closeSocket, true, 'socket must be dropped, not pooled');
53
+ assert.ok(Array.isArray(result.toolCalls) && result.toolCalls.length === 1);
54
+ assert.equal(result.toolCalls[0].id, 'call_1');
55
+ assert.equal(result.toolCalls[0].name, 'explore');
56
+ assert.deepEqual(result.toolCalls[0].arguments, { query: 'x' });
57
+ assert.equal(emitted.length, 1, 'tool call dispatched exactly once');
58
+ assert.equal(state.wsEarlySettle, 'inter_chunk');
59
+ assert.ok(socket.closed, 'socket.close() invoked so it is never reused');
60
+ });
61
+
62
+ test('ws early settle: normal response.completed path keeps the socket (no closeSocket)', async () => {
63
+ const socket = new FakeSocket();
64
+ const p = _streamResponse({ entry: { socket }, state: {}, _timeouts: FAST_TIMEOUTS });
65
+ socket.feed([
66
+ ...TOOL_CALL_EVENTS,
67
+ {
68
+ type: 'response.completed',
69
+ response: {
70
+ id: 'resp_1',
71
+ model: 'gpt-5.5',
72
+ output: [{ type: 'function_call', id: 'fc_1', name: 'explore', call_id: 'call_1', arguments: '{"query":"x"}' }],
73
+ },
74
+ },
75
+ ]);
76
+ const result = await p;
77
+ assert.equal(result.closeSocket, undefined, 'normal completion must not mark closeSocket');
78
+ assert.equal(result.toolCalls.length, 1);
79
+ });
80
+
81
+ test('ws early settle: deferred salvage still pending does NOT early-settle (fails as stall)', async () => {
82
+ const socket = new FakeSocket();
83
+ const p = _streamResponse({ entry: { socket }, state: {}, _timeouts: FAST_TIMEOUTS });
84
+ // No output_item.added -> pendingCalls empty; args.done carries no call_id/name
85
+ // -> a deferred placeholder that only response.completed could salvage.
86
+ socket.feed([
87
+ { type: 'response.created', response: { id: 'resp_1', model: 'gpt-5.5' } },
88
+ { type: 'response.function_call_arguments.delta', item_id: 'fc_1', delta: '{"query"' },
89
+ { type: 'response.function_call_arguments.done', item_id: 'fc_1', arguments: '{"query":"x"}' },
90
+ ]);
91
+ await assert.rejects(p, /inter-chunk inactivity/);
92
+ });
93
+
94
+ test('ws early settle: complete tool then partial second tool does NOT early-settle', async () => {
95
+ const socket = new FakeSocket();
96
+ const p = _streamResponse({ entry: { socket }, state: {}, _timeouts: FAST_TIMEOUTS });
97
+ socket.feed([
98
+ ...TOOL_CALL_EVENTS,
99
+ { type: 'response.output_item.added', item: { type: 'function_call', id: 'fc_2', name: 'grep', call_id: 'call_2' } },
100
+ { type: 'response.function_call_arguments.delta', item_id: 'fc_2', delta: '{"pattern"' },
101
+ ]);
102
+ await assert.rejects(p, /inter-chunk inactivity/);
103
+ assert.equal(socket.closed?.reason, 'inter_chunk_timeout', 'partial second tool must use stall path, not early settle');
104
+ });
105
+
106
+ test('ws early settle: complete tool_search_call clears in-flight and can early-settle', async () => {
107
+ const socket = new FakeSocket();
108
+ const state = {};
109
+ const p = _streamResponse({ entry: { socket }, state, _timeouts: FAST_TIMEOUTS });
110
+ socket.feed([
111
+ { type: 'response.created', response: { id: 'resp_1', model: 'gpt-5.5' } },
112
+ { type: 'response.output_item.added', item: { type: 'tool_search_call', id: 'ts_1' } },
113
+ { type: 'response.output_item.done', item: { type: 'tool_search_call', id: 'ts_1', arguments: '{"query":"fetch"}' } },
114
+ ]);
115
+ const result = await p;
116
+ assert.equal(result.closeSocket, true);
117
+ assert.equal(result.toolCalls[0].id, 'ts_1');
118
+ assert.equal(result.toolCalls[0].name, 'load_tool');
119
+ assert.deepEqual(result.toolCalls[0].arguments, { query: 'fetch' });
120
+ assert.equal(state.wsEarlySettle, 'inter_chunk');
121
+ });
122
+
123
+ test('ws early settle: complete tool_search_call plus partial custom tool does NOT early-settle', async () => {
124
+ const socket = new FakeSocket();
125
+ const p = _streamResponse({ entry: { socket }, state: {}, _timeouts: FAST_TIMEOUTS });
126
+ socket.feed([
127
+ { type: 'response.created', response: { id: 'resp_1', model: 'gpt-5.5' } },
128
+ { type: 'response.output_item.added', item: { type: 'tool_search_call', id: 'ts_1' } },
129
+ { type: 'response.output_item.done', item: { type: 'tool_search_call', id: 'ts_1', arguments: '{"query":"fetch"}' } },
130
+ { type: 'response.output_item.added', item: { type: 'custom_tool_call', id: 'ct_2', call_id: 'call_custom_2', name: 'apply_patch' } },
131
+ { type: 'response.custom_tool_call_input.delta', item_id: 'ct_2', delta: '*** Begin Patch' },
132
+ ]);
133
+ await assert.rejects(p, /inter-chunk inactivity/);
134
+ assert.equal(socket.closed?.reason, 'inter_chunk_timeout', 'partial custom tool must keep stall path');
135
+ });
136
+
137
+ test('ws early settle: output_item.done salvages deferred function_call and can early-settle', async () => {
138
+ const socket = new FakeSocket();
139
+ const p = _streamResponse({ entry: { socket }, state: {}, _timeouts: FAST_TIMEOUTS });
140
+ socket.feed([
141
+ { type: 'response.created', response: { id: 'resp_1', model: 'gpt-5.5' } },
142
+ { type: 'response.function_call_arguments.delta', item_id: 'fc_salvage', delta: '{"query"' },
143
+ { type: 'response.function_call_arguments.done', item_id: 'fc_salvage', arguments: '{"query":"x"}' },
144
+ { type: 'response.output_item.done', item: { type: 'function_call', id: 'fc_salvage', name: 'explore', call_id: 'call_salvage', arguments: '{"query":"x"}' } },
145
+ ]);
146
+ const result = await p;
147
+ assert.equal(result.closeSocket, true);
148
+ assert.equal(result.toolCalls[0].id, 'call_salvage');
149
+ assert.equal(result.toolCalls[0].name, 'explore');
150
+ assert.deepEqual(result.toolCalls[0].arguments, { query: 'x' });
151
+ });
152
+
153
+ test('ws early settle: call_id-only tool_search done clears id-tracked active item', async () => {
154
+ const socket = new FakeSocket();
155
+ const p = _streamResponse({ entry: { socket }, state: {}, _timeouts: FAST_TIMEOUTS });
156
+ socket.feed([
157
+ { type: 'response.created', response: { id: 'resp_1', model: 'gpt-5.5' } },
158
+ { type: 'response.output_item.added', item: { type: 'tool_search_call', id: 'ts_added', call_id: 'ts_call' } },
159
+ { type: 'response.output_item.done', item: { type: 'tool_search_call', call_id: 'ts_call', arguments: '{"query":"fetch"}' } },
160
+ ]);
161
+ const result = await p;
162
+ assert.equal(result.closeSocket, true);
163
+ assert.equal(result.toolCalls[0].id, 'ts_call');
164
+ });
165
+
166
+ test('ws early settle: function args.done without item.done does NOT early-settle', async () => {
167
+ const socket = new FakeSocket();
168
+ const p = _streamResponse({ entry: { socket }, state: {}, _timeouts: FAST_TIMEOUTS });
169
+ socket.feed([
170
+ { type: 'response.created', response: { id: 'resp_1', model: 'gpt-5.5' } },
171
+ { type: 'response.output_item.added', item: { type: 'function_call', id: 'fc_empty', name: 'explore', call_id: 'call_empty' } },
172
+ { type: 'response.function_call_arguments.done', item_id: 'fc_empty', arguments: '{}', call_id: 'call_empty', name: 'explore' },
173
+ ]);
174
+ await assert.rejects(p, /inter-chunk inactivity/);
175
+ assert.equal(socket.closed?.reason, 'inter_chunk_timeout', 'function item must stay active until output_item.done');
176
+ });