mixdog 0.9.45 → 0.9.46

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 (116) hide show
  1. package/package.json +5 -2
  2. package/scripts/agent-dispatch-abort-compose-test.mjs +31 -0
  3. package/scripts/agent-model-liveness-test.mjs +618 -0
  4. package/scripts/agent-trace-io-test.mjs +68 -4
  5. package/scripts/bench-run.mjs +30 -3
  6. package/scripts/compact-pressure-test.mjs +187 -4
  7. package/scripts/compact-prior-context-flatten-test.mjs +252 -0
  8. package/scripts/compact-trigger-migration-smoke.mjs +8 -6
  9. package/scripts/compacted-placeholder-scrub-test.mjs +20 -34
  10. package/scripts/context-mcp-metering-test.mjs +75 -0
  11. package/scripts/explore-prompt-policy-test.mjs +15 -16
  12. package/scripts/explore-timeout-cancel-test.mjs +345 -0
  13. package/scripts/headless-pristine-execution-test.mjs +614 -0
  14. package/scripts/internal-comms-smoke.mjs +15 -6
  15. package/scripts/live-worker-smoke.mjs +1 -1
  16. package/scripts/memory-core-input-test.mjs +137 -0
  17. package/scripts/memory-rule-contract-test.mjs +5 -5
  18. package/scripts/openai-oauth-refresh-race-test.mjs +120 -0
  19. package/scripts/openai-oauth-ws-1006-retry-test.mjs +26 -0
  20. package/scripts/parent-abort-link-test.mjs +22 -0
  21. package/scripts/provider-toolcall-test.mjs +22 -0
  22. package/scripts/reactive-compact-persist-smoke.mjs +8 -4
  23. package/scripts/session-bench.mjs +3 -70
  24. package/scripts/task-bench.mjs +0 -2
  25. package/scripts/terminal-bench-isolation-guards-test.mjs +102 -0
  26. package/scripts/tool-smoke.mjs +21 -21
  27. package/scripts/tool-tui-presentation-test.mjs +68 -0
  28. package/scripts/tui-transcript-jitter-harness-entry.jsx +91 -10
  29. package/src/app.mjs +28 -103
  30. package/src/cli.mjs +17 -13
  31. package/src/headless-command.mjs +139 -0
  32. package/src/headless-role.mjs +121 -10
  33. package/src/help.mjs +4 -1
  34. package/src/rules/agent/00-common.md +3 -3
  35. package/src/rules/agent/00-core.md +8 -9
  36. package/src/rules/agent/20-skip-protocol.md +2 -3
  37. package/src/rules/agent/30-explorer.md +50 -56
  38. package/src/rules/agent/40-cycle1-agent.md +10 -12
  39. package/src/rules/agent/41-cycle2-agent.md +12 -9
  40. package/src/rules/agent/42-cycle3-agent.md +4 -6
  41. package/src/rules/lead/01-general.md +5 -6
  42. package/src/rules/lead/02-channels.md +1 -1
  43. package/src/rules/lead/lead-brief.md +14 -17
  44. package/src/rules/lead/lead-tool.md +3 -3
  45. package/src/rules/shared/01-tool.md +41 -43
  46. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +46 -10
  47. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +44 -31
  48. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +18 -3
  49. package/src/runtime/agent/orchestrator/config.mjs +96 -30
  50. package/src/runtime/agent/orchestrator/context/collect.mjs +9 -0
  51. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +3 -0
  52. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +18 -5
  53. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +6 -6
  54. package/src/runtime/agent/orchestrator/providers/gemini.mjs +6 -6
  55. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +123 -3
  56. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +12 -3
  57. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +148 -30
  58. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +5 -7
  59. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +62 -14
  60. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +141 -19
  61. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +12 -4
  62. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +19 -1
  63. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +85 -17
  64. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +6 -8
  65. package/src/runtime/agent/orchestrator/providers/registry.mjs +47 -17
  66. package/src/runtime/agent/orchestrator/session/compact/summary.mjs +159 -20
  67. package/src/runtime/agent/orchestrator/session/context-utils.mjs +83 -10
  68. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +75 -7
  69. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +4 -374
  70. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +0 -75
  71. package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +0 -5
  72. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +20 -3
  73. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +32 -16
  74. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +5 -2
  75. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +86 -15
  76. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +30 -27
  77. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +1 -14
  78. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +21 -27
  79. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +1 -3
  80. package/src/runtime/agent/orchestrator/tools/builtin.mjs +1 -51
  81. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -4
  82. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +1 -1
  83. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +26 -0
  84. package/src/runtime/memory/index.mjs +0 -1
  85. package/src/runtime/memory/lib/core-memory-store.mjs +44 -24
  86. package/src/runtime/memory/lib/http-router.mjs +0 -193
  87. package/src/runtime/memory/lib/memory-action-handlers.mjs +37 -11
  88. package/src/runtime/memory/tool-defs.mjs +5 -6
  89. package/src/runtime/shared/config.mjs +11 -34
  90. package/src/runtime/shared/pristine-execution-contract.json +75 -0
  91. package/src/runtime/shared/pristine-execution.mjs +356 -0
  92. package/src/runtime/shared/provider-api-key.mjs +43 -0
  93. package/src/runtime/shared/provider-auth-binding.mjs +21 -0
  94. package/src/session-runtime/context-status.mjs +61 -13
  95. package/src/session-runtime/mcp-glue.mjs +29 -2
  96. package/src/session-runtime/plugin-mcp.mjs +7 -0
  97. package/src/session-runtime/resource-api.mjs +38 -5
  98. package/src/session-runtime/runtime-core.mjs +5 -1
  99. package/src/session-runtime/session-turn-api.mjs +14 -2
  100. package/src/session-runtime/settings-api.mjs +5 -0
  101. package/src/session-runtime/tool-catalog.mjs +13 -2
  102. package/src/session-runtime/tool-defs.mjs +1 -3
  103. package/src/standalone/agent-task-status.mjs +50 -11
  104. package/src/standalone/agent-tool/tool-def.mjs +1 -1
  105. package/src/standalone/explore-tool.mjs +257 -49
  106. package/src/standalone/seeds.mjs +1 -0
  107. package/src/tui/App.jsx +23 -10
  108. package/src/tui/app/use-transcript-scroll.mjs +4 -3
  109. package/src/tui/app/use-transcript-window.mjs +12 -21
  110. package/src/tui/components/ContextPanel.jsx +19 -25
  111. package/src/tui/dist/index.mjs +77 -65
  112. package/src/tui/engine/agent-envelope.mjs +16 -5
  113. package/src/tui/engine/labels.mjs +1 -1
  114. package/src/workflows/default/WORKFLOW.md +21 -51
  115. package/src/workflows/solo/WORKFLOW.md +12 -17
  116. package/src/workflows/solo-review/WORKFLOW.md +0 -47
@@ -55,8 +55,8 @@ function assert(condition, message) {
55
55
  `sub-boundary explicit limit should be preserved, got ${meta.autoCompactTokenLimit}`);
56
56
  }
57
57
 
58
- // 3) compactTriggerForSession: main/user (non-agent) recall-fasttrack keeps 5%
59
- // headroom (95% trigger); a truly-explicit
58
+ // 3) compactTriggerForSession: main/user (non-agent) recall-fasttrack keeps
59
+ // the default 5% headroom (95% trigger); a truly-explicit
60
60
  // sub-boundary autoCompactTokenLimit still wins. Agent-owned semantic
61
61
  // sessions keep the default 10% buffer (90%).
62
62
  {
@@ -71,14 +71,16 @@ function assert(condition, message) {
71
71
  assert(agentExplicit === 150000, `agent sub-boundary explicit limit should be the trigger, got ${agentExplicit}`);
72
72
  }
73
73
 
74
- // 4) A configured bufferPercent flows into the trigger for agent-owned sessions;
75
- // main/user sessions use their fixed 5% headroom.
74
+ // 4) Generic bufferPercent remains agent-only; main/user use their own
75
+ // configurable mainBufferPercent setting.
76
76
  {
77
77
  const boundary = 200000;
78
78
  const agentTrigger = compactTriggerForSession({ owner: 'agent', compaction: { bufferPercent: 5 } }, boundary);
79
79
  assert(agentTrigger === 190000, `agent bufferPercent 5 should yield trigger 190000, got ${agentTrigger}`);
80
80
  const userTrigger = compactTriggerForSession({ compaction: { bufferPercent: 5 } }, boundary);
81
- assert(userTrigger === 190000, `main/user should keep 5% headroom and trigger at 190000, got ${userTrigger}`);
81
+ assert(userTrigger === 190000, `main/user should ignore agent bufferPercent and trigger at 190000, got ${userTrigger}`);
82
+ const userConfigured = compactTriggerForSession({ compaction: { mainBufferPercent: 15 } }, boundary);
83
+ assert(userConfigured === 170000, `main/user mainBufferPercent 15 should trigger at 170000, got ${userConfigured}`);
82
84
  }
83
85
 
84
86
  // 5) Legacy/zero buffer telemetry migration is an agent-path concern (the
@@ -106,7 +108,7 @@ function assert(condition, message) {
106
108
  compaction: { boundaryTokens: boundary, triggerTokens: 180000, bufferTokens: 20000, bufferRatio: 0.1 },
107
109
  }, boundary);
108
110
  assert(userTelemetry === 190000,
109
- `main/user should ignore buffer telemetry and keep 5% headroom at 190000, got ${userTelemetry}`);
111
+ `main/user should ignore agent buffer telemetry and keep 5% headroom at 190000, got ${userTelemetry}`);
110
112
  }
111
113
 
112
114
  // 6) preserveBufferConfigFields copies only finite-positive percent/ratio fields.
@@ -1,14 +1,11 @@
1
1
  #!/usr/bin/env node
2
- // Regression test for the pre-send compacted-placeholder invariant: no
3
- // provider-visible assistant toolCall may ship a `[mixdog compacted …]`
4
- // placeholder body that the model could copy back as apply_patch input.
5
- // scrubCompactedPlaceholderToolCalls is the single enforcement point invoked
6
- // by repairTranscriptBeforeProviderSend right before provider.send.
2
+ // Regression tests for stable stored tool args: successful calls retain their
3
+ // self-explanatory compacted marker, while failed calls can restore full bodies.
7
4
  import test from 'node:test';
8
5
  import assert from 'node:assert/strict';
9
6
  import {
10
7
  compactToolCallsForHistory,
11
- scrubCompactedPlaceholderToolCalls,
8
+ restoreToolCallBodyForId,
12
9
  } from '../src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs';
13
10
  import { repairTranscriptBeforeProviderSend } from '../src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs';
14
11
 
@@ -26,52 +23,41 @@ test('compact leaves a placeholder patch body (precondition)', () => {
26
23
  assert.equal(msg.toolCalls[0].arguments.base_path, '/repo');
27
24
  });
28
25
 
29
- test('scrub drops the placeholder patch key, keeps other args', () => {
30
- const msg = assistantWithCompactedPatch();
31
- scrubCompactedPlaceholderToolCalls([msg]);
32
- assert.equal('patch' in msg.toolCalls[0].arguments, false);
33
- assert.equal(msg.toolCalls[0].arguments.base_path, '/repo');
34
- });
35
-
36
- test('scrub is recursive across nested batch body args', () => {
26
+ test('nested body args retain their compacted markers', () => {
37
27
  const calls = [{
38
28
  id: 'call_2', name: 'edit',
39
29
  arguments: { edits: [{ path: 'a.js', old_string: BIG_PATCH, new_string: 'ok' }] },
40
30
  }];
41
31
  const msg = { role: 'assistant', content: '', toolCalls: compactToolCallsForHistory(calls) };
42
32
  assert.match(msg.toolCalls[0].arguments.edits[0].old_string, /^\[mixdog compacted /);
43
- scrubCompactedPlaceholderToolCalls([msg]);
44
33
  const edit = msg.toolCalls[0].arguments.edits[0];
45
- assert.equal('old_string' in edit, false);
46
34
  assert.equal(edit.path, 'a.js');
47
35
  assert.equal(edit.new_string, 'ok');
48
36
  });
49
37
 
50
- test('scrub leaves real (restored) patch bodies untouched', () => {
51
- const real = '*** Begin Patch\n@@\n-a\n+b\n*** End Patch';
52
- const msg = { role: 'assistant', content: '', toolCalls: [
53
- { id: 'call_3', name: 'apply_patch', arguments: { patch: real } },
54
- ] };
55
- scrubCompactedPlaceholderToolCalls([msg]);
56
- assert.equal(msg.toolCalls[0].arguments.patch, real);
57
- });
58
-
59
- test('scrub ignores non-assistant / non-toolCall messages', () => {
60
- const msgs = [
61
- { role: 'user', content: 'hi' },
62
- { role: 'tool', content: 'x', toolCallId: 'call_1' },
63
- { role: 'assistant', content: 'text only' },
64
- ];
65
- assert.doesNotThrow(() => scrubCompactedPlaceholderToolCalls(msgs));
38
+ test('failed-call restore replaces the marker with the full original body', () => {
39
+ const originalCalls = [{
40
+ id: 'call_3', name: 'apply_patch', arguments: { patch: BIG_PATCH, base_path: '/repo' },
41
+ }];
42
+ const msg = {
43
+ role: 'assistant',
44
+ content: '',
45
+ toolCalls: compactToolCallsForHistory(originalCalls),
46
+ };
47
+ restoreToolCallBodyForId(msg, originalCalls, 'call_3');
48
+ assert.equal(msg.toolCalls[0].arguments.patch, BIG_PATCH);
49
+ assert.equal(msg.toolCalls[0].arguments.base_path, '/repo');
66
50
  });
67
51
 
68
- test('repairTranscriptBeforeProviderSend enforces the invariant end-to-end', () => {
52
+ test('pre-send transcript repair does not mutate compacted tool-call args', () => {
69
53
  const msgs = [
70
54
  { role: 'user', content: 'do it' },
71
55
  assistantWithCompactedPatch('call_9'),
72
56
  { role: 'tool', content: 'applied', toolCallId: 'call_9' },
73
57
  ];
58
+ const argsBefore = structuredClone(msgs[1].toolCalls[0].arguments);
74
59
  repairTranscriptBeforeProviderSend(msgs, null);
75
60
  const asst = msgs.find((m) => m.role === 'assistant');
76
- assert.equal('patch' in asst.toolCalls[0].arguments, false);
61
+ assert.deepEqual(asst.toolCalls[0].arguments, argsBefore);
62
+ assert.match(asst.toolCalls[0].arguments.patch, /^\[mixdog compacted patch: \d+ chars, sha256:[a-f0-9]{16}\]$/);
77
63
  });
@@ -0,0 +1,75 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+
4
+ import {
5
+ createContextStatus,
6
+ requestSerializedToolsForContext,
7
+ } from '../src/session-runtime/context-status.mjs';
8
+ import { splitToolStatusCounts } from '../src/session-runtime/session-turn-api.mjs';
9
+ import { toolKind } from '../src/session-runtime/tool-catalog.mjs';
10
+
11
+ const schema = (description) => ({
12
+ type: 'object',
13
+ properties: { value: { type: 'string', description } },
14
+ });
15
+
16
+ test('context meters Anthropic deferred MCP schemas and invalidates on catalog changes', () => {
17
+ const read = { name: 'read', description: 'Read a file', inputSchema: schema('path') };
18
+ const mcp = { name: 'mcp__demo__lookup', description: 'MCP lookup', inputSchema: schema('mcp payload '.repeat(100)) };
19
+ const session = {
20
+ id: 'deferred-mcp-context',
21
+ provider: 'anthropic',
22
+ contextWindow: 100_000,
23
+ messages: [{ role: 'user', content: 'hello' }],
24
+ tools: [read],
25
+ deferredNativeTools: true,
26
+ deferredToolCatalog: [read, mcp],
27
+ compaction: {},
28
+ };
29
+ const route = { provider: 'openai', model: 'test' };
30
+ const { contextStatus } = createContextStatus({
31
+ getSession: () => session,
32
+ getRoute: () => route,
33
+ getCurrentCwd: () => '',
34
+ getMode: () => 'default',
35
+ });
36
+
37
+ assert.deepEqual(requestSerializedToolsForContext(session, 'openai'), [read]);
38
+ assert.equal(requestSerializedToolsForContext(session, 'anthropic').length, 2);
39
+ const before = contextStatus();
40
+ assert.equal(before.request.toolSchemaBreakdown.mcp.count, 1);
41
+ assert.ok(before.request.toolSchemaBreakdown.mcp.tokens > 0);
42
+
43
+ session.provider = 'openai';
44
+ const switched = contextStatus();
45
+ assert.notEqual(switched, before);
46
+ assert.equal(switched.request.toolSchemaBreakdown.mcp, undefined);
47
+ assert.ok(switched.request.toolSchemaTokens < before.request.toolSchemaTokens);
48
+
49
+ session.provider = 'anthropic';
50
+ session.deferredToolCatalog.push({
51
+ name: 'mcp__demo__second',
52
+ description: 'Second MCP tool',
53
+ inputSchema: schema('second payload '.repeat(100)),
54
+ });
55
+ const after = contextStatus();
56
+ assert.notEqual(after, before);
57
+ assert.equal(after.request.toolSchemaBreakdown.mcp.count, 2);
58
+ assert.ok(after.request.toolSchemaTokens > before.request.toolSchemaTokens);
59
+ assert.ok(after.request.reserveTokens > before.request.reserveTokens);
60
+ assert.ok(after.usedTokens > before.usedTokens);
61
+ });
62
+
63
+ test('tool status counts exclude MCP and skills while exposing MCP tool count', () => {
64
+ const rows = [
65
+ { name: 'read', kind: toolKind({ name: 'read' }), active: true },
66
+ { name: 'mcp__demo__lookup', kind: toolKind({ name: 'mcp__demo__lookup' }), active: false },
67
+ { name: 'Skill', kind: toolKind({ name: 'Skill' }), active: true },
68
+ ];
69
+ assert.deepEqual(splitToolStatusCounts(rows), {
70
+ count: 1,
71
+ activeCount: 1,
72
+ mcpToolCount: 1,
73
+ activeMcpToolCount: 0,
74
+ });
75
+ });
@@ -68,22 +68,21 @@ test('explorer locator policy retains its compact behavioral contract', () => {
68
68
  const rule = readFileSync(new URL('../src/rules/agent/30-explorer.md', import.meta.url), 'utf8');
69
69
  const policy = rule.replace(/\s+/g, ' ');
70
70
  const required = [
71
- /Locator only:[\s\S]*WHERE \(`path:line`\), never WHY[\s\S]*You ARE `explore`; never[\s\S]*call it/i,
72
- /ONLY grep\/find\/glob\/code_graph[\s\S]*`read`\/`list` are forbidden/i,
73
- /Turn 1 \(`turn 1\/3`\)[\s\S]*gathers all known facets in ONE message[\s\S]*Broad\/uncertain is Explorer input[\s\S]*split it into concrete facets[\s\S]*apply the shared one-route\/batch contract[\s\S]*concept facets use grep[\s\S]*single-tool turn is valid[\s\S]*follow-up is only for unresolved pre-anchor\/\s*zero-hit facets/i,
74
- /follow-up is only for unresolved pre-anchor\/\s*zero-hit facets under this budget/i,
75
- /broad searches use `output_mode:"files_with_matches"`[\s\S]*`content_with_context` plus `head_limit` only on a path returned THIS session/i,
76
- /Each pattern is one identifier or camel\/snake variant[\s\S]*spaces are only verbatim copied quoted\s*error\/log literals[\s\S]*Translate non-English queries to English identifiers first[\s\S]*non-ASCII only for quoted literals[\s\S]*Include concept synonyms[\s\S]*not prose phrases/i,
77
- /Scope is session cwd; omit `path` freely[\s\S]*only from an exact find-returned path \(turn-2 recovery earliest\)[\s\S]*never guess or\s*invent directories[\s\S]*`path:"\."` with guessed `src\/\*\*`[\s\S]*After zero hits, change TOKENS\/scope, not wording\/guessed paths/i,
78
- /anchor is any `path:line` with a query token\/synonym[\s\S]*code_graph hits[\s\S]*generic-only schema\/handler\/config\/resolver\/index\/error words are zero[\s\S]*Never re-locate an anchor[\s\S]*without `:line`[\s\S]*PRE-anchor/i,
79
- /After every result[\s\S]*specific-token anchor means STOP and answer NOW[\s\S]*pre-anchors count as zero[\s\S]*Never re-confirm\/upgrade an anchor/i,
80
- /sole legal follow-up[\s\S]*code-location query[\s\S]*only with pre-anchors[\s\S]*one scoped `content_with_context` grep with `head_limit` on those paths[\s\S]*If zero, remaining zero-anchor recovery turns are legal under the 3-turn budget but must change tokens\/scope[\s\S]*never a second minting hop, anchor upgrade, or fabricated\/estimated line/i,
81
- /at most 3 turns \(expect 1\)[\s\S]*every tool message `turn N\/3`[\s\S]*normally two messages[\s\S]*third\/extra tool call[\s\S]*unless turn 1 has zero anchors[\s\S]*must change tokens\/scope/i,
82
- /Single-hop exception[\s\S]*first matching entry\/definition anchors concept\/value\/\s*default[\s\S]*do not trace chains\/value-search[\s\S]*explicit flow or default-resolution query[\s\S]*entry anchor but not its resolved value[\s\S]*ONE resolving hop, then stop/i,
83
- /Answer in ≤3 lines[\s\S]*`path:line symbol — short reason`[\s\S]*Copy every cited `path:line` VERBATIM[\s\S]*THIS-session tool result[\s\S]*never estimate\/adjust\/recall/i,
84
- /Code-location answers require `:line` on every line[\s\S]*`EXPLORATION_FAILED`[\s\S]*never a bare filename or vague prose/i,
85
- /file\/dir-location queries[\s\S]*exact verified file\/dir path without `:line`[\s\S]*do not force a line\/failure/i,
86
- /Emit `EXPLORATION_FAILED` only after the budget is spent with zero anchors[\s\S]*prefer a weak anchor to a false miss/i,
71
+ /Return only WHERE \(`path:line`\), never WHY[\s\S]*You ARE `explore`; never call it/i,
72
+ /only grep\/find\/glob\/code_graph[\s\S]*`read` and `list` are forbidden/i,
73
+ /Turn 1 \(`turn 1\/3`\) is the whole search[\s\S]*Split broad\/uncertain input into every known facet[\s\S]*one batch under the shared one-route contract[\s\S]*upstream producer\/derivation layer[\s\S]*SAME batch[\s\S]*Follow-up turns batch every unresolved facet in parallel[\s\S]*single-tool turn is allowed only when exactly one pre-anchor\/zero-hit facet remains/i,
74
+ /broad grep use `output_mode:"files_with_matches"`[\s\S]*`content_with_context` with `head_limit` only on paths returned this session/i,
75
+ /Each pattern is one identifier, camel\/snake variant, or concept synonym[\s\S]*never a prose phrase[\s\S]*Spaces and non-ASCII are allowed only in verbatim quoted error\/log literals[\s\S]*Translate other non-English queries to English identifiers/i,
76
+ /Scope is session cwd[\s\S]*For unverified `src` paths, use `find` first[\s\S]*never guess or invent directories[\s\S]*`path:"\."` with guessed `src\/\*\*`[\s\S]*exact find-returned path[\s\S]*no earlier than turn 2[\s\S]*After zero hits, change tokens or scope, never wording or guessed paths/i,
77
+ /anchor is a `path:line` containing a query token or synonym[\s\S]*code_graph hit[\s\S]*Generic terms without query specificity are zero[\s\S]*Never re-locate, reconfirm, or upgrade an anchor[\s\S]*path without `:line` is a pre-anchor and counts as zero/i,
78
+ /After every result, stop and answer on any specific-token anchor[\s\S]*mark a weak anchor `\?`/i,
79
+ /code-location query left only with pre-anchors[\s\S]*sole anchor-minting follow-up[\s\S]*one scoped `content_with_context` grep with `head_limit`[\s\S]*If it returns zero[\s\S]*changed tokens or scope[\s\S]*Never make a second minting hop or fabricate\/estimate a line/i,
80
+ /at most 3 turns[\s\S]*label every tool message `turn N\/3`[\s\S]*normally use one batch and one answer[\s\S]*Turns 2–3 are allowed only when turn 1 has zero anchors/i,
81
+ /first matching entry\/definition anchors a concept, value, or default[\s\S]*never trace its chain[\s\S]*explicit flow or default-resolution query[\s\S]*entry anchor but no resolved value[\s\S]*turn 2 for one resolving hop/i,
82
+ /Answer in at most 3 lines[\s\S]*`path:line symbol — short reason`[\s\S]*Copy every cited `path:line` verbatim[\s\S]*tool result in this session[\s\S]*never estimate, adjust, or recall/i,
83
+ /Every code-location line requires `:line`[\s\S]*never return a bare filename or vague prose/i,
84
+ /file\/dir-location query may return an exact verified path without `:line`/i,
85
+ /Return `EXPLORATION_FAILED` only after spending the budget with zero anchors[\s\S]*prefer a weak anchor to a false miss/i,
87
86
  ];
88
87
  for (const behavior of required) assert.match(policy, behavior);
89
88
  assert.doesNotMatch(policy, /grep[^.]{0,120}\band\b[^.]{0,120}code_graph[^.]{0,120}\band\b[^.]{0,120}find/i);
@@ -0,0 +1,345 @@
1
+ #!/usr/bin/env node
2
+ // Regression test for explore-tool wall-clock timeout + cancellation cleanup:
3
+ // - the default hard timeout is 60s (was 10min) and the
4
+ // MIXDOG_EXPLORE_HARD_TIMEOUT_MS override (including 0 = disabled) is kept;
5
+ // - a parent cancellation aborts every child compute AbortSignal immediately;
6
+ // - the wall-clock hard timeout aborts the compute AbortSignal (not just the
7
+ // race), so wedged compute tears down instead of running detached;
8
+ // - a canceled/timed-out compute purges its poisoned cache entry so a later
9
+ // call never awaits a dead promise and empties into "no tool output".
10
+ import test from 'node:test';
11
+ import assert from 'node:assert/strict';
12
+ import { execFileSync } from 'node:child_process';
13
+ import { readFileSync } from 'node:fs';
14
+ import {
15
+ EXPLORE_COMPUTE_HARD_TIMEOUT_MS,
16
+ runExploreComputeWithAbort,
17
+ runExploreCached,
18
+ runExplore,
19
+ exploreStaggerDelay,
20
+ awaitExploreProviderReadyOrCancel,
21
+ ensureExploreProviderReady,
22
+ __exploreResultCacheForTest,
23
+ } from '../src/standalone/explore-tool.mjs';
24
+
25
+ const MODULE_URL = new URL('../src/standalone/explore-tool.mjs', import.meta.url).href;
26
+
27
+ // A compute stub that rejects the moment its AbortSignal fires (mirrors a real
28
+ // child dispatch tearing down on abort), and handles the already-aborted case.
29
+ function abortAwareCompute(record) {
30
+ return (signal) => new Promise((_resolve, reject) => {
31
+ record?.(signal);
32
+ if (signal.aborted) { reject(signal.reason || new Error('aborted')); return; }
33
+ signal.addEventListener('abort', () => reject(signal.reason || new Error('aborted')), { once: true });
34
+ });
35
+ }
36
+
37
+ function readHardTimeoutWithEnv(value) {
38
+ const code = `import(${JSON.stringify(MODULE_URL)}).then((m) => process.stdout.write(String(m.EXPLORE_COMPUTE_HARD_TIMEOUT_MS)))`;
39
+ return execFileSync(process.execPath, ['--input-type=module', '-e', code], {
40
+ env: { ...process.env, MIXDOG_EXPLORE_HARD_TIMEOUT_MS: value },
41
+ encoding: 'utf8',
42
+ }).trim();
43
+ }
44
+
45
+ // The production hard-timeout timer is unref()'d (must never keep the host
46
+ // process alive). In an otherwise-idle test that relies on that timer firing,
47
+ // hold a ref'd timer so the event loop stays alive until the assertion settles.
48
+ function keepEventLoopAlive(ms = 5_000) {
49
+ const t = setTimeout(() => {}, ms);
50
+ return () => clearTimeout(t);
51
+ }
52
+
53
+ test('default explore hard timeout is 60 seconds', () => {
54
+ // Only meaningful when the override is unset (the CI/default environment).
55
+ if (process.env.MIXDOG_EXPLORE_HARD_TIMEOUT_MS == null) {
56
+ assert.equal(EXPLORE_COMPUTE_HARD_TIMEOUT_MS, 60_000);
57
+ }
58
+ });
59
+
60
+ test('MIXDOG_EXPLORE_HARD_TIMEOUT_MS override is preserved (including 0 = disabled)', () => {
61
+ assert.equal(readHardTimeoutWithEnv('1234'), '1234');
62
+ assert.equal(readHardTimeoutWithEnv('0'), '0');
63
+ assert.equal(readHardTimeoutWithEnv('90000'), '90000');
64
+ });
65
+
66
+ test('hard timeout aborts the compute AbortSignal', async () => {
67
+ const release = keepEventLoopAlive();
68
+ try {
69
+ let received = null;
70
+ const p = runExploreComputeWithAbort((signal) => {
71
+ received = signal;
72
+ return new Promise(() => {}); // never settles on its own
73
+ }, null, 25);
74
+ await assert.rejects(p, /timed out/);
75
+ assert.ok(received, 'compute received a signal');
76
+ assert.equal(received.aborted, true);
77
+ } finally {
78
+ release();
79
+ }
80
+ });
81
+
82
+ test('parent cancellation aborts every child compute immediately', async () => {
83
+ const parent = new AbortController();
84
+ const seen = [];
85
+ const p1 = runExploreComputeWithAbort(abortAwareCompute((s) => seen.push(s)), parent.signal, 10_000);
86
+ const p2 = runExploreComputeWithAbort(abortAwareCompute((s) => seen.push(s)), parent.signal, 10_000);
87
+ parent.abort(new Error('user pressed ESC'));
88
+ await assert.rejects(p1);
89
+ await assert.rejects(p2);
90
+ assert.equal(seen.length, 2);
91
+ assert.ok(seen.every((s) => s.aborted), 'both child signals aborted');
92
+ });
93
+
94
+ test('an already-aborted parent signal aborts the compute up front', async () => {
95
+ const parent = new AbortController();
96
+ parent.abort(new Error('already canceled'));
97
+ let received = null;
98
+ const p = runExploreComputeWithAbort(abortAwareCompute((s) => { received = s; }), parent.signal, 10_000);
99
+ await assert.rejects(p);
100
+ assert.ok(received.aborted, 'compute signal was already aborted');
101
+ });
102
+
103
+ test('a canceled compute purges its poisoned cache entry', async () => {
104
+ // Force the result cache on so the in-flight promise is stored.
105
+ process.env.MIXDOG_EXPLORE_RESULT_CACHE = '1';
106
+ const cache = __exploreResultCacheForTest();
107
+ cache.clear();
108
+ const key = 'explore-cancel-poison-key';
109
+ const parent = new AbortController();
110
+ const promise = runExploreCached(key, abortAwareCompute(), parent.signal);
111
+ // Pending promise cached while the compute is in flight.
112
+ assert.equal(cache.has(key), true);
113
+ parent.abort(new Error('cancel'));
114
+ await assert.rejects(promise);
115
+ // The poisoned pending entry must be gone so a later call recomputes fresh
116
+ // instead of awaiting the dead promise.
117
+ assert.equal(cache.has(key), false);
118
+ });
119
+
120
+ test('a wedged never-resolving shared compute is purged by its real hard-timeout timer and recomputed', async () => {
121
+ process.env.MIXDOG_EXPLORE_RESULT_CACHE = '1';
122
+ const cache = __exploreResultCacheForTest();
123
+ cache.clear();
124
+ const key = 'shared-hard-timeout-wedged';
125
+ const seen = [];
126
+ let calls = 0;
127
+ const neverResolves = (signal) => { calls += 1; seen.push(signal); return new Promise(() => {}); };
128
+ const release = keepEventLoopAlive();
129
+ try {
130
+ await assert.rejects(runExploreCached(key, neverResolves, null, 25), /timed out/);
131
+ assert.equal(seen[0].aborted, true, 'the timer aborted the actual wedged compute');
132
+ assert.equal(cache.has(key), false, 'timed-out pending entry was purged');
133
+ assert.equal(await runExploreCached(key, () => { calls += 1; return Promise.resolve('fresh'); }, null, 100), 'fresh');
134
+ assert.equal(calls, 2, 'a later caller recomputes rather than reusing the rejected promise');
135
+ } finally {
136
+ release();
137
+ }
138
+ });
139
+
140
+ // A compute whose settlement the test controls, recording the AbortSignal it
141
+ // received so shared-compute teardown can be asserted.
142
+ function deferredCompute() {
143
+ const handle = { signal: null, resolve: null, reject: null };
144
+ handle.fn = (signal) => new Promise((resolve, reject) => {
145
+ handle.signal = signal;
146
+ handle.resolve = resolve;
147
+ handle.reject = reject;
148
+ });
149
+ return handle;
150
+ }
151
+
152
+ test('parent cancellation rejects immediately even for a non-cooperative compute', async () => {
153
+ // The compute IGNORES its AbortSignal (never settles on abort). Cancellation
154
+ // must still reject the returned promise right away — not hang until the
155
+ // wall-clock hard timeout.
156
+ const parent = new AbortController();
157
+ const p = runExploreComputeWithAbort(() => new Promise(() => {}), parent.signal, 10_000);
158
+ const t0 = Date.now();
159
+ parent.abort(new Error('user pressed ESC'));
160
+ await assert.rejects(p);
161
+ assert.ok(Date.now() - t0 < 500, 'rejected promptly, not after the hard timeout');
162
+ });
163
+
164
+ test('never-resolving compute is torn down by the hard timeout (real cleanup)', async () => {
165
+ const release = keepEventLoopAlive();
166
+ try {
167
+ let received = null;
168
+ const p = runExploreComputeWithAbort((signal) => {
169
+ received = signal;
170
+ return new Promise(() => {}); // never resolves, never observes abort
171
+ }, null, 30);
172
+ await assert.rejects(p, /timed out/);
173
+ assert.equal(received.aborted, true, 'compute signal aborted on timeout');
174
+ } finally {
175
+ release();
176
+ }
177
+ });
178
+
179
+ test('owner cancellation does not abort an unaffected later subscriber', async () => {
180
+ process.env.MIXDOG_EXPLORE_RESULT_CACHE = '1';
181
+ const cache = __exploreResultCacheForTest();
182
+ cache.clear();
183
+ const key = 'shared-owner-cancel';
184
+ const dc = deferredCompute();
185
+ const owner = new AbortController();
186
+ const later = new AbortController();
187
+ const ownerP = runExploreCached(key, dc.fn, owner.signal); // starts shared compute
188
+ const laterP = runExploreCached(key, dc.fn, later.signal); // subscribes to the same compute
189
+ owner.abort(new Error('owner ESC'));
190
+ await assert.rejects(ownerP);
191
+ // The shared compute must keep running for the still-active later subscriber.
192
+ assert.equal(dc.signal.aborted, false, 'shared compute not aborted by owner cancel');
193
+ assert.equal(cache.has(key), true);
194
+ dc.resolve('src/x.mjs:1 — shared hit');
195
+ assert.match(String(await laterP), /src\/x\.mjs:1/);
196
+ });
197
+
198
+ test('later subscriber cancellation releases itself without aborting the shared compute', async () => {
199
+ process.env.MIXDOG_EXPLORE_RESULT_CACHE = '1';
200
+ const cache = __exploreResultCacheForTest();
201
+ cache.clear();
202
+ const key = 'shared-later-cancel';
203
+ const dc = deferredCompute();
204
+ const owner = new AbortController();
205
+ const later = new AbortController();
206
+ const ownerP = runExploreCached(key, dc.fn, owner.signal);
207
+ const laterP = runExploreCached(key, dc.fn, later.signal);
208
+ later.abort(new Error('later ESC'));
209
+ await assert.rejects(laterP);
210
+ assert.equal(dc.signal.aborted, false, 'owner still waiting; compute alive');
211
+ assert.equal(cache.has(key), true);
212
+ dc.resolve('src/y.mjs:2 — owner result');
213
+ assert.match(String(await ownerP), /src\/y\.mjs:2/);
214
+ });
215
+
216
+ test('all subscribers canceling aborts the shared compute and purges the cache', async () => {
217
+ process.env.MIXDOG_EXPLORE_RESULT_CACHE = '1';
218
+ const cache = __exploreResultCacheForTest();
219
+ cache.clear();
220
+ const key = 'shared-all-cancel';
221
+ const dc = deferredCompute();
222
+ const a = new AbortController();
223
+ const b = new AbortController();
224
+ const pa = runExploreCached(key, dc.fn, a.signal);
225
+ const pb = runExploreCached(key, dc.fn, b.signal);
226
+ a.abort(new Error('a ESC'));
227
+ await assert.rejects(pa);
228
+ assert.equal(cache.has(key), true, 'b still subscribed; compute retained');
229
+ assert.equal(dc.signal.aborted, false);
230
+ b.abort(new Error('b ESC'));
231
+ await assert.rejects(pb);
232
+ assert.equal(dc.signal.aborted, true, 'last subscriber gone; shared compute torn down');
233
+ assert.equal(cache.has(key), false, 'purged so a future call recomputes');
234
+ });
235
+
236
+ test('a failed shared compute is purged so future calls recompute', async () => {
237
+ process.env.MIXDOG_EXPLORE_RESULT_CACHE = '1';
238
+ const cache = __exploreResultCacheForTest();
239
+ cache.clear();
240
+ const key = 'shared-recompute';
241
+ let calls = 0;
242
+ const compute = () => { calls += 1; return Promise.reject(new Error(`owner fail ${calls}`)); };
243
+ await assert.rejects(runExploreCached(key, compute, null));
244
+ assert.equal(cache.has(key), false);
245
+ await assert.rejects(runExploreCached(key, compute, null));
246
+ assert.equal(calls, 2, 'recomputed rather than deduping on the dead promise');
247
+ });
248
+
249
+ test('stagger delay is canceled before dispatch when the signal aborts', async () => {
250
+ const ctrl = new AbortController();
251
+ let dispatched = false;
252
+ const p = exploreStaggerDelay(10_000, ctrl.signal).then(() => { dispatched = true; return 'DISPATCHED'; });
253
+ ctrl.abort(new Error('cancel during stagger'));
254
+ await assert.rejects(p);
255
+ assert.equal(dispatched, false, 'child dispatch skipped after the stagger was canceled');
256
+ });
257
+
258
+ test('runtime-core forwards the caller signal into runExplore (plumbing)', () => {
259
+ const src = readFileSync(new URL('../src/session-runtime/runtime-core.mjs', import.meta.url), 'utf8');
260
+ const at = src.indexOf("name === 'explore'");
261
+ assert.ok(at >= 0, 'explore branch present in runtime-core');
262
+ const branch = src.slice(at, at + 1000);
263
+ assert.match(branch, /return await runExplore\(/);
264
+ assert.match(branch, /signal:\s*callerCtx\?\.signal\s*\|\|\s*session\?\.controller\?\.signal/);
265
+ });
266
+
267
+ test('runExplore short-circuits an already-canceled call without dispatching', async () => {
268
+ const ctrl = new AbortController();
269
+ ctrl.abort(new Error('canceled up front'));
270
+ const res = await runExplore({ query: 'anything at all' }, { signal: ctrl.signal });
271
+ assert.equal(res.isError, true);
272
+ assert.match(res.content?.[0]?.text || '', /cancel/i);
273
+ });
274
+
275
+ test('explore provider warmup is canceled immediately when the caller aborts mid-init', async () => {
276
+ const ctrl = new AbortController();
277
+ const ready = new Promise(() => {}); // warmup wedged (never resolves)
278
+ const t0 = Date.now();
279
+ const p = awaitExploreProviderReadyOrCancel(ready, ctrl.signal);
280
+ ctrl.abort(new Error('ESC during warmup'));
281
+ const canceled = await p;
282
+ assert.equal(canceled, true, 'reported canceled without waiting for warmup');
283
+ assert.ok(Date.now() - t0 < 500, 'returned promptly, did not block on warmup');
284
+ });
285
+
286
+ test('explore provider warmup aborts the real pending initializer work', async () => {
287
+ const ctrl = new AbortController();
288
+ let initializerAborted = false;
289
+ const pendingInit = (_providers, { signal }) => new Promise((_resolve, reject) => {
290
+ signal.addEventListener('abort', () => {
291
+ initializerAborted = true;
292
+ reject(signal.reason);
293
+ }, { once: true });
294
+ });
295
+ const ready = ensureExploreProviderReady({ providers: { fake: { enabled: true } } }, { provider: 'fake' }, ctrl.signal, pendingInit);
296
+ const waited = awaitExploreProviderReadyOrCancel(ready, ctrl.signal);
297
+ ctrl.abort(new Error('ESC during provider init'));
298
+ assert.equal(await waited, true);
299
+ assert.equal(initializerAborted, true, 'AbortSignal reached the pending initializer');
300
+ });
301
+
302
+ test('explore provider warmup resolves to not-canceled when the caller stays active', async () => {
303
+ const canceled = await awaitExploreProviderReadyOrCancel(Promise.resolve(), new AbortController().signal);
304
+ assert.equal(canceled, false, 'ready to dispatch');
305
+ });
306
+
307
+ test('explore provider warmup short-circuits an already-aborted caller before awaiting init', async () => {
308
+ const ctrl = new AbortController();
309
+ ctrl.abort(new Error('already canceled'));
310
+ let awaited = false;
311
+ const ready = Promise.resolve().then(() => { awaited = true; });
312
+ const canceled = await awaitExploreProviderReadyOrCancel(ready, ctrl.signal);
313
+ assert.equal(canceled, true, 'canceled without blocking on the warmup promise');
314
+ });
315
+
316
+ test('explore provider warmup propagates a genuine init failure (not a cancel)', async () => {
317
+ const ctrl = new AbortController(); // never aborted
318
+ const ready = Promise.reject(new Error('provider init failed'));
319
+ await assert.rejects(awaitExploreProviderReadyOrCancel(ready, ctrl.signal), /init failed/);
320
+ });
321
+
322
+ test('a retired shared compute success write is identity-guarded (no stale overwrite)', async () => {
323
+ process.env.MIXDOG_EXPLORE_RESULT_CACHE = '1';
324
+ const cache = __exploreResultCacheForTest();
325
+ cache.clear();
326
+ const key = 'identity-guard-success';
327
+ const dc = deferredCompute();
328
+ const p = runExploreCached(key, dc.fn, null);
329
+ p.catch(() => {});
330
+ // The compute executor runs on a microtask; let it install dc.resolve.
331
+ await new Promise((r) => setTimeout(r, 0));
332
+ const oldEntry = cache.get(key);
333
+ assert.ok(oldEntry?.promise, 'pending entry stored');
334
+ assert.equal(typeof dc.resolve, 'function', 'compute executor ran');
335
+ // Simulate the entry having been retired + replaced by a fresh one (e.g. a
336
+ // TTL eviction/recompute) WITHOUT touching the old in-flight compute.
337
+ const freshEntry = { ts: Date.now(), value: undefined, sentinel: true };
338
+ cache.set(key, freshEntry);
339
+ // The OLD compute now resolves successfully — its success handler must NOT
340
+ // overwrite the fresh entry (identity guard on every eventual write).
341
+ dc.resolve('src/stale.mjs:1 — retired winner');
342
+ await p;
343
+ await new Promise((r) => setTimeout(r, 0)); // flush the success microtask
344
+ assert.equal(cache.get(key), freshEntry, 'retired success write was identity-guarded');
345
+ });