mixdog 0.9.45 → 0.9.47

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 (123) 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/defaults/cycle3-review-prompt.md +10 -4
  32. package/src/defaults/memory-promote-prompt.md +4 -3
  33. package/src/headless-command.mjs +139 -0
  34. package/src/headless-role.mjs +121 -10
  35. package/src/help.mjs +4 -1
  36. package/src/rules/agent/00-common.md +3 -3
  37. package/src/rules/agent/00-core.md +8 -9
  38. package/src/rules/agent/20-skip-protocol.md +2 -3
  39. package/src/rules/agent/30-explorer.md +50 -56
  40. package/src/rules/agent/40-cycle1-agent.md +10 -12
  41. package/src/rules/agent/41-cycle2-agent.md +12 -9
  42. package/src/rules/agent/42-cycle3-agent.md +4 -6
  43. package/src/rules/lead/01-general.md +5 -6
  44. package/src/rules/lead/02-channels.md +1 -1
  45. package/src/rules/lead/lead-brief.md +14 -17
  46. package/src/rules/lead/lead-tool.md +3 -3
  47. package/src/rules/shared/01-tool.md +41 -43
  48. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +46 -10
  49. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +44 -31
  50. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +18 -3
  51. package/src/runtime/agent/orchestrator/config.mjs +96 -30
  52. package/src/runtime/agent/orchestrator/context/collect.mjs +9 -0
  53. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +3 -0
  54. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +18 -5
  55. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +6 -6
  56. package/src/runtime/agent/orchestrator/providers/gemini.mjs +6 -6
  57. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +123 -3
  58. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +12 -3
  59. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +148 -30
  60. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +5 -7
  61. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +62 -14
  62. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +141 -19
  63. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +12 -4
  64. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +19 -1
  65. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +85 -17
  66. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +6 -8
  67. package/src/runtime/agent/orchestrator/providers/registry.mjs +47 -17
  68. package/src/runtime/agent/orchestrator/session/compact/summary.mjs +159 -20
  69. package/src/runtime/agent/orchestrator/session/context-utils.mjs +83 -10
  70. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +75 -7
  71. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +4 -374
  72. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +0 -75
  73. package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +0 -5
  74. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +20 -3
  75. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +32 -16
  76. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +5 -2
  77. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +86 -15
  78. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +30 -27
  79. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +1 -14
  80. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +21 -27
  81. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +1 -3
  82. package/src/runtime/agent/orchestrator/tools/builtin.mjs +1 -51
  83. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -4
  84. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +1 -1
  85. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +26 -0
  86. package/src/runtime/memory/index.mjs +0 -1
  87. package/src/runtime/memory/lib/core-memory-store.mjs +44 -24
  88. package/src/runtime/memory/lib/http-router.mjs +0 -193
  89. package/src/runtime/memory/lib/memory-action-handlers.mjs +37 -11
  90. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +13 -5
  91. package/src/runtime/memory/lib/memory-cycle2.mjs +8 -5
  92. package/src/runtime/memory/lib/memory-cycle3.mjs +41 -6
  93. package/src/runtime/memory/lib/query-handlers.mjs +49 -6
  94. package/src/runtime/memory/lib/recall-format.mjs +21 -6
  95. package/src/runtime/memory/tool-defs.mjs +5 -6
  96. package/src/runtime/shared/config.mjs +11 -34
  97. package/src/runtime/shared/pristine-execution-contract.json +75 -0
  98. package/src/runtime/shared/pristine-execution.mjs +356 -0
  99. package/src/runtime/shared/provider-api-key.mjs +43 -0
  100. package/src/runtime/shared/provider-auth-binding.mjs +21 -0
  101. package/src/session-runtime/context-status.mjs +61 -13
  102. package/src/session-runtime/mcp-glue.mjs +29 -2
  103. package/src/session-runtime/plugin-mcp.mjs +7 -0
  104. package/src/session-runtime/resource-api.mjs +38 -5
  105. package/src/session-runtime/runtime-core.mjs +5 -1
  106. package/src/session-runtime/session-turn-api.mjs +14 -2
  107. package/src/session-runtime/settings-api.mjs +5 -0
  108. package/src/session-runtime/tool-catalog.mjs +13 -2
  109. package/src/session-runtime/tool-defs.mjs +1 -3
  110. package/src/standalone/agent-task-status.mjs +50 -11
  111. package/src/standalone/agent-tool/tool-def.mjs +1 -1
  112. package/src/standalone/explore-tool.mjs +257 -49
  113. package/src/standalone/seeds.mjs +1 -0
  114. package/src/tui/App.jsx +23 -10
  115. package/src/tui/app/use-transcript-scroll.mjs +4 -3
  116. package/src/tui/app/use-transcript-window.mjs +12 -21
  117. package/src/tui/components/ContextPanel.jsx +19 -25
  118. package/src/tui/dist/index.mjs +77 -65
  119. package/src/tui/engine/agent-envelope.mjs +16 -5
  120. package/src/tui/engine/labels.mjs +1 -1
  121. package/src/workflows/default/WORKFLOW.md +21 -51
  122. package/src/workflows/solo/WORKFLOW.md +12 -17
  123. package/src/workflows/solo-review/WORKFLOW.md +0 -47
@@ -0,0 +1,137 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { normalizeCoreInput } from '../src/runtime/memory/lib/core-memory-store.mjs'
7
+ import { createMemoryActionHandlers } from '../src/runtime/memory/lib/memory-action-handlers.mjs'
8
+
9
+ test('core content aliases summary and derives a bounded element', () => {
10
+ const content = 'A durable preference that callers should receive concise answers.'
11
+ const input = normalizeCoreInput({ content, category: 'preference' }, {
12
+ requireElement: true,
13
+ requireSummary: true,
14
+ requireCategory: true,
15
+ })
16
+ assert.equal(input.summary, content)
17
+ assert.equal(input.element, content.slice(0, 40))
18
+ assert.deepEqual(input.errors, [])
19
+ })
20
+
21
+ test('core add reports every invalid field and cwd project hint together', async () => {
22
+ const cwd = await mkdtemp(join(tmpdir(), 'mixdog-core-input-'))
23
+ await mkdir(join(cwd, '.mixdog'))
24
+ await writeFile(join(cwd, '.mixdog', 'project.id'), 'owner/repo\n')
25
+ try {
26
+ const { handleMemoryAction } = createMemoryActionHandlers({
27
+ getDb: () => ({}),
28
+ dataDir: cwd,
29
+ readMainConfig: () => ({}),
30
+ })
31
+ const result = await handleMemoryAction({
32
+ action: 'core',
33
+ op: 'add',
34
+ cwd,
35
+ element: 'x'.repeat(41),
36
+ summary: 'y'.repeat(101),
37
+ category: 'unknown',
38
+ })
39
+ assert.equal(result.isError, true)
40
+ assert.equal(
41
+ result.text,
42
+ 'core add: project_id required — pass "common" for COMMON pool, or project slug like "owner/repo" for scoped pool (cwd suggests "owner/repo"); element too long (41/40 chars, remove 1); summary too long (101/100 chars, remove 1); invalid category "unknown". Valid: rule, constraint, decision, fact, goal, preference, task, issue',
43
+ )
44
+ } finally {
45
+ await rm(cwd, { recursive: true, force: true })
46
+ }
47
+ })
48
+
49
+ test('core add rejects blank project_id in the batched error', async () => {
50
+ const { handleMemoryAction } = createMemoryActionHandlers({
51
+ getDb: () => ({}),
52
+ dataDir: tmpdir(),
53
+ readMainConfig: () => ({}),
54
+ })
55
+ const result = await handleMemoryAction({
56
+ action: 'core',
57
+ op: 'add',
58
+ project_id: ' ',
59
+ element: 'x'.repeat(41),
60
+ summary: 'y'.repeat(101),
61
+ category: 'unknown',
62
+ })
63
+ assert.equal(result.isError, true)
64
+ assert.match(result.text, /^core add: project_id required —/)
65
+ assert.match(result.text, /element too long \(41\/40 chars, remove 1\)/)
66
+ assert.match(result.text, /summary too long \(101\/100 chars, remove 1\)/)
67
+ assert.match(result.text, /invalid category "unknown"/)
68
+ })
69
+
70
+ test('core add folds project_id "*" into the batched error', async () => {
71
+ const { handleMemoryAction } = createMemoryActionHandlers({
72
+ getDb: () => ({}),
73
+ dataDir: tmpdir(),
74
+ readMainConfig: () => ({}),
75
+ })
76
+ const result = await handleMemoryAction({
77
+ action: 'core',
78
+ op: 'add',
79
+ project_id: '*',
80
+ element: 'x'.repeat(41),
81
+ summary: 'y'.repeat(101),
82
+ category: 'unknown',
83
+ })
84
+ assert.equal(result.isError, true)
85
+ assert.match(result.text, /^core add: project_id "\*" only valid for op="list"; element too long/)
86
+ assert.match(result.text, /summary too long \(101\/100 chars, remove 1\)/)
87
+ assert.match(result.text, /invalid category "unknown"/)
88
+ })
89
+
90
+ test('core add suppresses malformed cwd project hints', async () => {
91
+ const cwd = await mkdtemp(join(tmpdir(), 'mixdog-core-input-'))
92
+ await mkdir(join(cwd, '.mixdog'))
93
+ await writeFile(join(cwd, '.mixdog', 'project.id'), '/absolute/path\n')
94
+ try {
95
+ const { handleMemoryAction } = createMemoryActionHandlers({
96
+ getDb: () => ({}),
97
+ dataDir: cwd,
98
+ readMainConfig: () => ({}),
99
+ })
100
+ const result = await handleMemoryAction({
101
+ action: 'core',
102
+ op: 'add',
103
+ cwd,
104
+ element: 'durable preference',
105
+ summary: 'Callers prefer concise answers.',
106
+ category: 'preference',
107
+ })
108
+ assert.equal(result.text, 'core add: project_id required — pass "common" for COMMON pool, or project slug like "owner/repo" for scoped pool')
109
+ } finally {
110
+ await rm(cwd, { recursive: true, force: true })
111
+ }
112
+ })
113
+
114
+ test('core edit by id succeeds without project_id', async () => {
115
+ let call
116
+ const { handleMemoryAction } = createMemoryActionHandlers({
117
+ getDb: () => ({}),
118
+ dataDir: tmpdir(),
119
+ readMainConfig: () => ({}),
120
+ editCoreImpl: async (dataDir, id, patch) => {
121
+ call = { dataDir, id, patch }
122
+ return { id: Number(id), element: patch.element, summary: patch.summary, category: patch.category }
123
+ },
124
+ })
125
+ const result = await handleMemoryAction({
126
+ action: 'core',
127
+ op: 'edit',
128
+ id: 7,
129
+ element: 'reply style',
130
+ summary: 'Use concise answers.',
131
+ category: 'preference',
132
+ })
133
+ assert.equal(result.isError, undefined)
134
+ assert.equal(result.text, 'core edited (id=7): [preference] reply style — Use concise answers.')
135
+ assert.equal(call.id, 7)
136
+ assert.equal(call.patch.project_id, undefined)
137
+ })
@@ -48,8 +48,8 @@ test('cycle2 preserves essential taxonomy, phases, rejects, formats, and fields'
48
48
  'Anything unclear or outside these concepts is `archived`',
49
49
  '`phase1_new_chunks` → `active` if clearly essential, otherwise `archived`',
50
50
  '`phase2_reevaluate` → `active` to promote, otherwise `archived`',
51
- '`phase3_active_review` requires every-row verdict: default `archived`, or `active`, `update`, `merge`',
52
- 'silence is not keep', 'work narratives', 'static facts without behavior/user value',
51
+ '`phase3_active_review` requires an `archived`, `active`, `update`, or `merge` verdict for every row',
52
+ 'defaults to `archived`', 'never treats silence as keep', 'work narratives', 'static facts without behavior/user value',
53
53
  'rule-system meta', 'resolved bug/fix logs', 'rule-file duplicates',
54
54
  'single-run measurements/counts/versions', 'session-scoped or in-progress decisions',
55
55
  '<id>|<verb>', '<id>|update|<element>|<summary>',
@@ -59,7 +59,7 @@ test('cycle2 preserves essential taxonomy, phases, rejects, formats, and fields'
59
59
  'complete sentences in input language', 'preserve important specifics verbatim',
60
60
  'omit actor/meta filler', '`rule > constraint > decision > fact > goal > preference > task > issue`',
61
61
  'Replace literal `|` with `/`', 'fields contain no newlines',
62
- 'For phase 3, emit one verdict per input row', 'start with a digit',
62
+ 'Start every verdict with a digit',
63
63
  ])
64
64
  })
65
65
 
@@ -70,7 +70,7 @@ test('cycle3 preserves durable-event verdicts, formats, and exceptions', () => {
70
70
  'rules, preferences, identity, goals, and current system/structure descriptions—not a log',
71
71
  'Each entry is one short clause (≤120 chars)',
72
72
  'Current rule/preference/live structure = durable',
73
- 'past event (shipped version, measured value, made fix) = not durable', 'When unsure, keep',
73
+ 'past event = not durable', 'When unsure, keep',
74
74
  '`keep`: durable, already one short clause',
75
75
  '`update`: durable but verbose/multi-sentence; compress to one ≤120-char clause',
76
76
  '`merge`: duplicate; fold into its survivor in the same project pool',
@@ -81,7 +81,7 @@ test('cycle3 preserves durable-event verdicts, formats, and exceptions', () => {
81
81
  'IDs match input rows; never invent them', 'summary is one ≤120-char clause',
82
82
  '`element` is short', 'retains `target_id`, absorbs sources, and stays within one `project_id`',
83
83
  'Replace literal `|` with `/`', 'fields contain no newlines',
84
- 'Emit a verdict for every input row', 'start with a digit',
84
+ 'Emit a digit-starting verdict for every input row',
85
85
  ])
86
86
  })
87
87
 
@@ -0,0 +1,120 @@
1
+ import test, { after } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {
4
+ existsSync,
5
+ mkdirSync,
6
+ mkdtempSync,
7
+ readFileSync,
8
+ readdirSync,
9
+ rmSync,
10
+ writeFileSync,
11
+ } from 'node:fs';
12
+ import { spawn } from 'node:child_process';
13
+ import { join } from 'node:path';
14
+ import { tmpdir } from 'node:os';
15
+
16
+ const root = mkdtempSync(join(tmpdir(), 'mixdog-openai-race-'));
17
+ const tokenPath = join(root, 'openai-oauth.json');
18
+ const readyDir = join(root, 'ready');
19
+ const exchangeDir = join(root, 'exchanges');
20
+ const resultDir = join(root, 'results');
21
+ const startPath = join(root, 'start');
22
+ const moduleUrl = new URL(
23
+ '../src/runtime/agent/orchestrator/providers/openai-oauth.mjs',
24
+ import.meta.url,
25
+ ).href;
26
+
27
+ after(() => rmSync(root, { recursive: true, force: true }));
28
+
29
+ function waitForExit(child) {
30
+ return new Promise((resolve, reject) => {
31
+ let stderr = '';
32
+ child.stderr.setEncoding('utf8');
33
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
34
+ child.on('error', reject);
35
+ child.on('exit', (code) => {
36
+ if (code === 0) resolve();
37
+ else reject(new Error(`OpenAI contention child exited ${code}: ${stderr}`));
38
+ });
39
+ });
40
+ }
41
+
42
+ test('contending OpenAI OAuth processes share one reread/exchange/write lock', async () => {
43
+ mkdirSync(readyDir);
44
+ mkdirSync(exchangeDir);
45
+ mkdirSync(resultDir);
46
+ writeFileSync(tokenPath, JSON.stringify({
47
+ access_token: 'fixture-old-access',
48
+ refresh_token: 'fixture-old-refresh',
49
+ expires_at: Date.now() + 1_000,
50
+ }));
51
+
52
+ const childSource = `
53
+ import { existsSync, writeFileSync } from 'node:fs';
54
+ const { OpenAIOAuthProvider } = await import(${JSON.stringify(moduleUrl)});
55
+ const provider = Object.create(OpenAIOAuthProvider.prototype);
56
+ provider.config = {};
57
+ provider.tokens = {
58
+ ...JSON.parse(await (await import('node:fs/promises')).readFile(process.env.TOKEN_PATH, 'utf8')),
59
+ _mtimeMs: (await (await import('node:fs/promises')).stat(process.env.TOKEN_PATH)).mtimeMs
60
+ };
61
+ provider._lastDiskScan = provider.tokens._mtimeMs;
62
+ provider._refreshFallbackUntil = 0;
63
+ writeFileSync(process.env.READY_PATH, 'ready');
64
+ while (!existsSync(process.env.START_PATH)) await new Promise((resolve) => setTimeout(resolve, 10));
65
+ globalThis.fetch = async () => {
66
+ const owner = String(process.pid);
67
+ writeFileSync(process.env.EXCHANGE_DIR + '/' + owner, 'exchange', { flag: 'wx' });
68
+ await new Promise((resolve) => setTimeout(resolve, 75));
69
+ return {
70
+ ok: true,
71
+ status: 200,
72
+ async json() {
73
+ return {
74
+ access_token: 'fixture-new-access-' + owner,
75
+ refresh_token: 'fixture-new-refresh-' + owner,
76
+ expires_in: 600
77
+ };
78
+ }
79
+ };
80
+ };
81
+ const tokens = await provider.ensureAuth();
82
+ writeFileSync(process.env.RESULT_PATH, tokens.access_token);
83
+ `;
84
+
85
+ const exits = [];
86
+ for (let index = 0; index < 10; index += 1) {
87
+ const child = spawn(process.execPath, ['--input-type=module', '--eval', childSource], {
88
+ env: {
89
+ ...process.env,
90
+ MIXDOG_DATA_DIR: root,
91
+ OPENAI_OAUTH_CREDENTIALS_PATH: tokenPath,
92
+ TOKEN_PATH: tokenPath,
93
+ READY_PATH: join(readyDir, String(index)),
94
+ START_PATH: startPath,
95
+ EXCHANGE_DIR: exchangeDir,
96
+ RESULT_PATH: join(resultDir, String(index)),
97
+ },
98
+ stdio: ['ignore', 'ignore', 'pipe'],
99
+ });
100
+ exits.push(waitForExit(child));
101
+ }
102
+
103
+ const deadline = Date.now() + 30_000;
104
+ while (readdirSync(readyDir).length < 10) {
105
+ if (Date.now() >= deadline) throw new Error('OpenAI contention children did not become ready');
106
+ await new Promise((resolve) => setTimeout(resolve, 20));
107
+ }
108
+ writeFileSync(startPath, 'start');
109
+ await Promise.all(exits);
110
+
111
+ const exchanges = readdirSync(exchangeDir);
112
+ assert.equal(exchanges.length, 1);
113
+ const winner = exchanges[0];
114
+ const expected = `fixture-new-access-${winner}`;
115
+ assert.equal(JSON.parse(readFileSync(tokenPath, 'utf8')).access_token, expected);
116
+ for (const result of readdirSync(resultDir)) {
117
+ assert.equal(readFileSync(join(resultDir, result), 'utf8'), expected);
118
+ }
119
+ assert.equal(existsSync(`${tokenPath}.refresh.lock`), false);
120
+ });
@@ -51,6 +51,32 @@ test('pre-response 1006 opens a fresh WS and replays the same request', async ()
51
51
  assert.equal(result.__midstreamRetries, 1);
52
52
  });
53
53
 
54
+ test('successful iteration emits one compact send-spans row', async () => {
55
+ const rows = [];
56
+ const result = await sendViaWebSocket(wsArgs({
57
+ _sendSpanTraceFn: (row) => rows.push(row),
58
+ _acquireWithRetryFn: async (opts) => {
59
+ opts.onRetry?.({ classifier: 'timeout' });
60
+ opts.onRetry?.({ classifier: 'timeout' });
61
+ return { entry: entry(), reused: true };
62
+ },
63
+ _streamFn: async ({ state }) => {
64
+ state.sendSpan.firstEventMs += 7;
65
+ state.sendSpan.preResponseCreatedMs += 9;
66
+ return { content: 'ok', model: 'gpt-5.5', toolCalls: [], usage: {}, closeSocket: true };
67
+ },
68
+ }));
69
+ assert.equal(result.content, 'ok');
70
+ assert.equal(rows.length, 1);
71
+ assert.equal(rows[0].kind, 'send_spans');
72
+ assert.equal(rows[0].payload.acquire_mode, 'reused');
73
+ assert.equal(rows[0].payload.acquire_attempts, 1);
74
+ assert.equal(rows[0].payload.handshake_retries, 2);
75
+ assert.equal(rows[0].payload.first_event_ms, 7);
76
+ assert.equal(rows[0].payload.pre_response_created_ms, 9);
77
+ assert.equal('body' in rows[0].payload, false);
78
+ });
79
+
54
80
  test('exhausted pre-response 1006 retries reach the HTTP/SSE fallback', async () => {
55
81
  const savedEnv = Object.fromEntries([
56
82
  'MIXDOG_OAI_TRANSPORT',
@@ -25,6 +25,28 @@ test('markSessionError drops parent AbortSignal listener but keeps runtime entry
25
25
  assert.equal(childAborted, false);
26
26
  });
27
27
 
28
+ test('an already/early-aborted parent signal is retained and re-cascades onto a swapped controller', () => {
29
+ const sessionId = `parent-abort-early-${Date.now()}`;
30
+ const parent = new AbortController();
31
+ parent.abort(new Error('canceled before dispatch'));
32
+ linkParentSignalToSession(sessionId, parent.signal);
33
+ const runtime = getSessionRuntime(sessionId);
34
+ // The controller present at link time was aborted up front...
35
+ assert.equal(runtime.controller.signal.aborted, true);
36
+ // ...and the parent signal is RETAINED (listener null: nothing left to fire)
37
+ // so askSession's fresh-controller swap can detect + re-cascade the early abort.
38
+ assert.ok(runtime.parentAbortLink?.signal === parent.signal);
39
+ assert.equal(runtime.parentAbortLink.listener, null);
40
+ // Simulate askSession swapping in a fresh (non-aborted) controller, then
41
+ // re-linking the retained parent signal: the early abort must re-cascade so
42
+ // provider computation actually aborts instead of running detached.
43
+ const linked = runtime.parentAbortLink.signal;
44
+ runtime.controller = null;
45
+ linkParentSignalToSession(sessionId, linked);
46
+ assert.equal(runtime.controller.signal.aborted, true,
47
+ 'early parent abort re-cascaded onto the freshly swapped controller');
48
+ });
49
+
28
50
  test('markSessionCancelled drops parent AbortSignal listener but keeps runtime entry', () => {
29
51
  const sessionId = `parent-abort-cancel-${Date.now()}`;
30
52
  const parent = new AbortController();
@@ -36,6 +36,7 @@ import {
36
36
  } from '../src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs';
37
37
  import {
38
38
  _cacheObservationForTest,
39
+ _cacheContinuityResetReasonForTest,
39
40
  } from '../src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs';
40
41
  import {
41
42
  _withCodexWsClientMetadata,
@@ -1679,6 +1680,27 @@ test('openai oauth ws cache observation detects warm zero and partial retreats',
1679
1680
  });
1680
1681
  assert.equal(healthy.actualMiss, false);
1681
1682
  assert.equal(healthy.missReason, null);
1683
+
1684
+ const compacted = _cacheObservationForTest({
1685
+ entry: { promptCacheMaxCachedTokens: 255_488 },
1686
+ result: { usage: { inputTokens: 21_066, promptTokens: 21_066, cachedTokens: 8_704 } },
1687
+ continuityResetReason: 'input_prefix_mismatch',
1688
+ });
1689
+ assert.equal(compacted.actualMiss, false, 'intentional prompt rewrite must reset the old high-water');
1690
+ assert.equal(compacted.wasWarm, false);
1691
+
1692
+ const previousInput = [{ type: 'message', role: 'user', content: 'old long transcript' }];
1693
+ const rewrittenBody = { model: 'gpt-5.5', input: [{ type: 'message', role: 'user', content: 'compact summary' }] };
1694
+ assert.equal(_cacheContinuityResetReasonForTest({
1695
+ mode: 'full',
1696
+ deltaReason: 'full_default',
1697
+ entry: {
1698
+ lastResponseId: 'resp_old',
1699
+ lastRequestSansInput: _stableStringify(_sansInput(rewrittenBody)),
1700
+ lastRequestInput: previousInput,
1701
+ },
1702
+ body: rewrittenBody,
1703
+ }), 'input_prefix_mismatch', 'ws-full must detect prompt rewrites hidden by full_default');
1682
1704
  });
1683
1705
 
1684
1706
  // === 10. OpenAI transport-policy switch (MIXDOG_OAI_TRANSPORT) ==============
@@ -83,6 +83,9 @@ const session = {
83
83
  contextWindow: 12_000,
84
84
  rawContextWindow: 12_000,
85
85
  compactBoundaryTokens: 12_000,
86
+ // Keep this fixture on the reactive-overflow path; main/user now compacts at
87
+ // 75% by default, while this explicit sub-boundary limit remains authoritative.
88
+ autoCompactTokenLimit: 11_500,
86
89
  compaction: { auto: true, semantic: true, type: 1, compactType: 1, lastStage: 'compacting' },
87
90
  cwd: process.cwd(),
88
91
  sessionStartMetaInjected: true,
@@ -107,9 +110,9 @@ try {
107
110
  // exact "memory pipeline broken" condition the fail-safe must cover. Assert the
108
111
  // fail-safe: recall-fasttrack aborts rather than dropping head behind a false
109
112
  // "Full history is in memory" notice, so no context is silently lost and the
110
- // overflow is surfaced instead of being masked by an empty summary shell.
111
- assert(threw, 'askSession should surface overflow error');
112
- assert(thrownCode === 'AGENT_CONTEXT_OVERFLOW', `expected AGENT_CONTEXT_OVERFLOW, got ${thrownCode}`);
113
+ // compaction failure is surfaced instead of being masked by an empty summary shell.
114
+ assert(threw, 'askSession should surface compaction failure');
115
+ assert(thrownCode === 'AGENT_COMPACT_FAILED', `expected AGENT_COMPACT_FAILED, got ${thrownCode}`);
113
116
  // Recall-fasttrack aborted (memory failed), so the reactive retry never produced
114
117
  // a smaller transcript to re-send: exactly one failing main send, no LLM compact.
115
118
  assert(mainSendCount === 1, `expected 1 main send (reactive retry aborted by fail-safe), got ${mainSendCount}`);
@@ -135,6 +138,7 @@ assert(
135
138
  'current user turn should remain in persisted transcript',
136
139
  );
137
140
  assert(reloaded.compaction?.lastStage === 'overflow_failed', `compaction lastStage should be overflow_failed, got ${reloaded.compaction?.lastStage}`);
138
- assert(reloaded.providerState === undefined, 'providerState should clear after reactive compact abort');
141
+ assert(reloaded.providerState?.xaiResponses?.previousResponseId === 'stale-after-compact',
142
+ 'providerState should remain when a failed reactive compact leaves the transcript unchanged');
139
143
 
140
144
  process.stdout.write('reactive-compact-persist smoke passed ✓\n');
@@ -52,6 +52,9 @@ const opts = {
52
52
  };
53
53
 
54
54
  function defaultTracePath() {
55
+ if (process.env.MIXDOG_AGENT_TRACE_PATH) {
56
+ return resolve(process.env.MIXDOG_AGENT_TRACE_PATH);
57
+ }
55
58
  const data = process.env.MIXDOG_DATA_DIR || resolvePluginData() || resolve(homedir(), '.mixdog', 'data');
56
59
  return resolve(data, 'history', 'agent-trace.jsonl');
57
60
  }
@@ -129,32 +132,6 @@ function toolArgsHash(row) {
129
132
  return field(row, 'tool_args_hash') || hashValue(args);
130
133
  }
131
134
 
132
- function grepPatternTerms(pattern) {
133
- const raw = Array.isArray(pattern) ? pattern.join('|') : String(pattern || '');
134
- return [...new Set(raw
135
- .split(/[|,\s()"'`[\]{}.*+?^$\\:-]+/g)
136
- .map((s) => s.trim().toLowerCase())
137
- .filter((s) => s.length >= 4))];
138
- }
139
-
140
- function normalizePathForCompare(pathValue) {
141
- return String(pathValue || '.')
142
- .replace(/\\/g, '/')
143
- .replace(/^([A-Za-z]):/, (_, d) => d.toLowerCase() + ':')
144
- .replace(/\/+/g, '/')
145
- .replace(/\/$/, '') || '.';
146
- }
147
-
148
- function grepSweepKey(row) {
149
- const args = toolArgs(row) || {};
150
- const path = normalizePathForCompare(String(args.path || '.'));
151
- const glob = Array.isArray(args.glob) ? args.glob.join(',') : String(args.glob || '');
152
- const terms = grepPatternTerms(args.pattern).slice(0, 3).join('|');
153
- return `${path}::${glob}::${terms}`;
154
- }
155
-
156
- const GREP_SWEEP_MIN_CALLS = 20;
157
- const GREP_SWEEP_MIN_UNIQUE = 16;
158
135
  const READONLY_TOOL_NAMES = new Set(['read', 'grep', 'glob', 'list', 'find', 'code_graph', 'recall', 'explore', 'search', 'web_fetch']);
159
136
  const READONLY_STALL_MIN_RUN = 8;
160
137
  const READONLY_ROLE_AGENTS = new Set(['reviewer', 'explore', 'explorer', 'debugger']);
@@ -691,37 +668,6 @@ function buildToolDiagnostics(rows, failureRows = []) {
691
668
  }
692
669
  readFragmentation.sort((a, b) => b.count - a.count);
693
670
 
694
- const grepSweeps = [];
695
- for (const [sid, srows] of groupBy(tools.filter((r) => field(r, 'tool_name') === 'grep'), sessionId).entries()) {
696
- const sorted = [...srows].sort((a, b) => Number(a.ts || 0) - Number(b.ts || 0));
697
- let cluster = [];
698
- const flush = () => {
699
- if (cluster.length < GREP_SWEEP_MIN_CALLS) { cluster = []; return; }
700
- const unique = new Set(cluster.map(grepSweepKey));
701
- const spanMs = Number(cluster[cluster.length - 1].ts || 0) - Number(cluster[0].ts || 0);
702
- if (unique.size >= GREP_SWEEP_MIN_UNIQUE && spanMs <= 10 * 60_000) {
703
- const pathCounts = countBy(cluster, (r) => normalizePathForCompare(String((toolArgs(r) || {}).path || '.'))).slice(0, 3);
704
- const patterns = cluster.slice(0, 5).map((r) => compactText(String((toolArgs(r) || {}).pattern || ''), 70));
705
- grepSweeps.push({
706
- session_id: sid,
707
- count: cluster.length,
708
- unique_queries: unique.size,
709
- span_ms: spanMs,
710
- paths: pathCounts.map(([path, count]) => ({ path, count })),
711
- examples: patterns,
712
- });
713
- }
714
- cluster = [];
715
- };
716
- for (const row of sorted) {
717
- const prev = cluster[cluster.length - 1];
718
- if (!prev || Number(row.ts || 0) - Number(prev.ts || 0) <= 45_000) cluster.push(row);
719
- else { flush(); cluster = [row]; }
720
- }
721
- flush();
722
- }
723
- grepSweeps.sort((a, b) => b.count - a.count || b.unique_queries - a.unique_queries);
724
-
725
671
  const singleToolBatches = rows.filter((r) => r.kind === 'batch' && Number(field(r, 'tool_call_count')) === 1)
726
672
  .sort((a, b) => Number(a.ts || 0) - Number(b.ts || 0));
727
673
  let missedParallelism = 0;
@@ -875,7 +821,6 @@ function buildToolDiagnostics(rows, failureRows = []) {
875
821
  failed_repeats: failedRepeats.slice(0, 20),
876
822
  broad_results: broadResults.slice(0, 20),
877
823
  read_fragmentation: readFragmentation.slice(0, 20),
878
- grep_sweeps: grepSweeps.slice(0, 20),
879
824
  missed_parallelism_heuristic: {
880
825
  consecutive_single_tool_batches: missedParallelism,
881
826
  single_tool_batches: singleToolBatches.length,
@@ -1268,10 +1213,6 @@ function buildIssues(routeGroups, cache, tools) {
1268
1213
  for (const frag of tools.read_fragmentation.slice(0, 5)) {
1269
1214
  issues.push({ severity: 'low', type: 'read_fragmentation', message: `read fragmentation x${frag.count} within ${frag.line_span} lines: ${frag.path}` });
1270
1215
  }
1271
- for (const sweep of (tools.grep_sweeps || []).slice(0, 3)) {
1272
- const scope = sweep.paths?.[0]?.path || '-';
1273
- issues.push({ severity: 'low', type: 'grep_sweep', message: `grep sweep x${sweep.count}/${sweep.unique_queries} over ${fmtMs(sweep.span_ms)} in ${scope}`, session_id: sweep.session_id });
1274
- }
1275
1216
  if (tools.missed_parallelism_heuristic.consecutive_single_tool_batches >= 3) {
1276
1217
  issues.push({ severity: 'low', type: 'missed_parallelism', message: `${tools.missed_parallelism_heuristic.consecutive_single_tool_batches} close consecutive single-tool batches` });
1277
1218
  }
@@ -1651,14 +1592,6 @@ function renderText(report) {
1651
1592
  lines.push('read fragmentation:');
1652
1593
  for (const f of report.tools.read_fragmentation.slice(0, 10)) lines.push(`- x${f.count} span=${f.line_span} lines: ${f.path}`);
1653
1594
  }
1654
- if (report.tools.grep_sweeps?.length) {
1655
- lines.push('grep sweeps:');
1656
- for (const s of report.tools.grep_sweeps.slice(0, 8)) {
1657
- const scope = s.paths?.map((p) => `${p.count}×${p.path}`).join(', ') || '-';
1658
- lines.push(`- x${s.count}/${s.unique_queries} span=${fmtMs(s.span_ms)} scope=${scope}`);
1659
- if (s.examples?.length) lines.push(` e.g. ${s.examples.join(' | ')}`);
1660
- }
1661
- }
1662
1595
  if (report.tools.sequential_tool_clusters?.length) {
1663
1596
  lines.push('sequential single-tool clusters:');
1664
1597
  for (const c of report.tools.sequential_tool_clusters.slice(0, 8)) {
@@ -55,7 +55,6 @@ function scorecard(report) {
55
55
  const issues = Array.isArray(report.issues) ? report.issues : [];
56
56
  const antipatterns =
57
57
  (tools.read_fragmentation?.length || 0) +
58
- (tools.grep_sweeps?.length || 0) +
59
58
  (tools.sequential_tool_clusters?.length || 0) +
60
59
  (tools.duplicates?.length || 0) +
61
60
  (tools.failed_repeats?.length || 0);
@@ -94,7 +93,6 @@ function scorecard(report) {
94
93
  issues: issues.length,
95
94
  issue_types: uniq(issues.map((i) => i?.type)),
96
95
  read_fragmentation_paths: uniq((tools.read_fragmentation || []).map((f) => f?.path)),
97
- grep_sweep_count: (tools.grep_sweeps || []).length,
98
96
  };
99
97
  }
100
98
 
@@ -0,0 +1,102 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readFileSync,
7
+ rmSync,
8
+ writeFileSync,
9
+ } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ process.env.MIXDOG_DISABLE_MCP = '1';
15
+ process.env.MIXDOG_DISABLE_SKILLS = '1';
16
+
17
+ const {
18
+ buildSkillManifest,
19
+ buildSkillToolDefs,
20
+ collectSkills,
21
+ collectSkillsCached,
22
+ loadSkillResource,
23
+ } = await import('../src/runtime/agent/orchestrator/context/collect.mjs');
24
+ const { seedBundledSkills } = await import('../src/standalone/seeds.mjs');
25
+ const { createMcpGlue } = await import('../src/session-runtime/mcp-glue.mjs');
26
+
27
+ test('skills guard suppresses discovery, bundled seeding, prompt, and tool exposure', () => {
28
+ const root = mkdtempSync(join(tmpdir(), 'mixdog-tb-skill-guard-'));
29
+ try {
30
+ const bundled = join(root, 'defaults', 'skills', 'bundled');
31
+ const project = join(root, 'project', '.mixdog', 'skills', 'project');
32
+ const data = join(root, 'data');
33
+ for (const dir of [bundled, project]) mkdirSync(dir, { recursive: true });
34
+ writeFileSync(join(bundled, 'SKILL.md'), '---\nname: bundled\n---\nsecret');
35
+ writeFileSync(join(project, 'SKILL.md'), '---\nname: project\n---\nsecret');
36
+
37
+ seedBundledSkills({ rootDir: root, dataDir: data });
38
+ assert.equal(readFileSync(join(bundled, 'SKILL.md'), 'utf8').includes('secret'), true);
39
+ assert.deepEqual(collectSkills(join(root, 'project')), []);
40
+ assert.deepEqual(collectSkillsCached(join(root, 'project')), []);
41
+ assert.equal(loadSkillResource('project', join(root, 'project')), null);
42
+ assert.equal(buildSkillManifest([{ name: 'injected', description: 'secret' }]), '');
43
+ assert.deepEqual(
44
+ buildSkillToolDefs([{ name: 'injected' }], { ownerIsAgentSession: true }),
45
+ [],
46
+ );
47
+ const runtimeCore = readFileSync(
48
+ fileURLToPath(new URL('../src/session-runtime/runtime-core.mjs', import.meta.url)),
49
+ 'utf8',
50
+ );
51
+ assert.match(
52
+ runtimeCore,
53
+ /envFlag\('MIXDOG_DISABLE_SKILLS'\) \? \[\] : \[SKILL_TOOL\]/,
54
+ );
55
+ assert.throws(
56
+ () => readFileSync(join(data, 'skills', 'bundled', 'SKILL.md'), 'utf8'),
57
+ /ENOENT/,
58
+ );
59
+ } finally {
60
+ rmSync(root, { recursive: true, force: true });
61
+ }
62
+ });
63
+
64
+ test('MCP guard ignores config and project sources and disconnects without connecting', async () => {
65
+ const root = mkdtempSync(join(tmpdir(), 'mixdog-tb-mcp-guard-'));
66
+ try {
67
+ writeFileSync(
68
+ join(root, '.mcp.json'),
69
+ JSON.stringify({ mcpServers: { project: { command: 'personal-project' } } }),
70
+ );
71
+ const calls = [];
72
+ const glue = createMcpGlue({
73
+ mcpClient: {
74
+ getMcpServerStatus: () => [{ name: 'stale', connected: true }],
75
+ connectMcpServers: async () => calls.push('connect'),
76
+ disconnectAll: async () => calls.push('disconnect-all'),
77
+ disconnectMcpServer: async (name) => calls.push(`disconnect:${name}`),
78
+ },
79
+ getConfig: () => ({
80
+ mcpServers: { configured: { command: 'personal-config' } },
81
+ }),
82
+ getCurrentCwd: () => root,
83
+ state: {
84
+ mcpFailures: [{ name: 'personal', msg: 'secret' }],
85
+ mcpConnectGeneration: 0,
86
+ mcpConnectInFlight: null,
87
+ },
88
+ });
89
+
90
+ assert.deepEqual(glue.resolveEffectiveMcpServers(), { servers: {}, sources: {} });
91
+ assert.deepEqual(glue.mcpStatus(), {
92
+ servers: [],
93
+ configuredCount: 0,
94
+ connectedCount: 0,
95
+ failedCount: 0,
96
+ });
97
+ await glue.connectConfiguredMcp({ reset: true });
98
+ assert.deepEqual(calls, ['disconnect-all']);
99
+ } finally {
100
+ rmSync(root, { recursive: true, force: true });
101
+ }
102
+ });