mixdog 0.9.41 → 0.9.44

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 (73) hide show
  1. package/package.json +3 -2
  2. package/scripts/agent-tag-reuse-smoke.mjs +124 -10
  3. package/scripts/ansi-color-capability-test.mjs +90 -0
  4. package/scripts/anthropic-oauth-refresh-race-test.mjs +59 -0
  5. package/scripts/arg-guard-test.mjs +16 -0
  6. package/scripts/compact-pressure-test.mjs +256 -1
  7. package/scripts/generate-runtime-manifest.mjs +39 -22
  8. package/scripts/grok-oauth-refresh-race-test.mjs +198 -0
  9. package/scripts/internal-comms-bench.mjs +0 -1
  10. package/scripts/internal-comms-smoke.mjs +38 -39
  11. package/scripts/internal-tools-normalization-test.mjs +52 -0
  12. package/scripts/maintenance-default-routes-test.mjs +164 -0
  13. package/scripts/max-output-recovery-test.mjs +49 -0
  14. package/scripts/mcp-client-normalization-test.mjs +45 -0
  15. package/scripts/openai-oauth-ws-1006-retry-test.mjs +123 -0
  16. package/scripts/provider-toolcall-test.mjs +23 -0
  17. package/scripts/result-classification-test.mjs +75 -0
  18. package/scripts/routing-corpus.mjs +1 -1
  19. package/scripts/shell-jobs-windows-hide-test.mjs +164 -0
  20. package/scripts/smoke.mjs +1 -1
  21. package/scripts/submit-commandbusy-race-test.mjs +99 -7
  22. package/scripts/tui-transcript-jitter-harness-entry.jsx +302 -0
  23. package/scripts/tui-transcript-jitter-harness.mjs +58 -0
  24. package/scripts/tui-transcript-perf-test.mjs +56 -1
  25. package/src/agents/reviewer/AGENT.md +5 -3
  26. package/src/rules/lead/lead-brief.md +5 -4
  27. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +40 -3
  28. package/src/runtime/agent/orchestrator/config.mjs +29 -14
  29. package/src/runtime/agent/orchestrator/internal-tools.mjs +16 -3
  30. package/src/runtime/agent/orchestrator/mcp/client.mjs +17 -1
  31. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +49 -6
  32. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +14 -0
  33. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +46 -21
  34. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +59 -2
  35. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +5 -1
  36. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +18 -0
  37. package/src/runtime/agent/orchestrator/session/context-utils.mjs +140 -81
  38. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +23 -5
  39. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +3 -0
  40. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +12 -3
  41. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +4 -2
  42. package/src/runtime/agent/orchestrator/session/result-classification.mjs +4 -1
  43. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +7 -2
  44. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +8 -6
  45. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +15 -3
  46. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +1 -1
  47. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -5
  48. package/src/runtime/memory/data/runtime-manifest.json +11 -11
  49. package/src/runtime/memory/lib/runtime-fetcher.mjs +1 -2
  50. package/src/runtime/memory/tool-defs.mjs +4 -3
  51. package/src/runtime/shared/tool-surface.mjs +1 -2
  52. package/src/session-runtime/context-status.mjs +8 -2
  53. package/src/standalone/agent-tool/render.mjs +2 -0
  54. package/src/standalone/agent-tool.mjs +267 -72
  55. package/src/standalone/explore-tool.mjs +17 -16
  56. package/src/tui/app/provider-setup-picker.mjs +1 -0
  57. package/src/tui/app/use-transcript-window.mjs +5 -1
  58. package/src/tui/components/StatusLine.jsx +11 -32
  59. package/src/tui/dist/index.mjs +257 -106
  60. package/src/tui/engine/session-api.mjs +3 -0
  61. package/src/tui/engine/session-flow.mjs +19 -8
  62. package/src/tui/engine/turn.mjs +24 -2
  63. package/src/tui/engine.mjs +0 -1
  64. package/src/tui/index.jsx +3 -2
  65. package/src/tui/markdown/streaming-markdown.mjs +35 -9
  66. package/src/tui/statusline-ansi-bridge.mjs +17 -7
  67. package/src/ui/ansi.mjs +85 -5
  68. package/src/ui/statusline-agents.mjs +0 -8
  69. package/src/ui/statusline-format.mjs +7 -7
  70. package/src/ui/statusline.mjs +2 -4
  71. package/src/workflows/default/WORKFLOW.md +29 -20
  72. package/src/workflows/solo-review/WORKFLOW.md +47 -0
  73. package/src/workflows/bench/WORKFLOW.md +0 -60
@@ -57,13 +57,12 @@ function frontmatter(text, label) {
57
57
  const workflow = readSrc('workflows', 'default', 'WORKFLOW.md');
58
58
  const leadBrief = readSrc('rules', 'lead', 'lead-brief.md');
59
59
  const solo = readSrc('workflows', 'solo', 'WORKFLOW.md');
60
- const bench = readSrc('workflows', 'bench', 'WORKFLOW.md');
61
60
  const general = readSrc('rules', 'lead', '01-general.md');
62
61
  const leadTool = readSrc('rules', 'lead', 'lead-tool.md');
63
62
  const core = readSrc('rules', 'agent', '00-core.md');
64
63
  const common = readSrc('rules', 'agent', '00-common.md');
65
64
  const skip = readSrc('rules', 'agent', '20-skip-protocol.md');
66
- const OPTIONAL_BRIEF_FIELDS = ['Anchors:', 'Allow/Forbid:', 'Deliver:', 'Verify:'];
65
+ const OPTIONAL_BRIEF_FIELDS = ['Anchors:', 'Allow/Forbid:', 'Deliver:'];
67
66
  const TOKEN_PRINCIPLE = /minimum (?:characters|chars), maximum (?:information|info)/i;
68
67
  function requireAll(text, label, patterns) {
69
68
  for (const pattern of patterns) assert(pattern.test(normalize(text).toLowerCase()), `${label}: missing ${pattern}`);
@@ -72,16 +71,21 @@ assert(TOKEN_PRINCIPLE.test(normalize(leadBrief)), 'lead-brief.md: brief must st
72
71
  assert(leadBrief.includes('Task:'), 'lead-brief.md: brief must require labeled field Task:');
73
72
  assert(!leadBrief.includes('Goal:'), 'lead-brief.md: Goal: must be replaced by Task:');
74
73
  for (const field of OPTIONAL_BRIEF_FIELDS) assert(leadBrief.includes(field), `lead-brief.md: brief missing optional labeled field ${field}`);
74
+ assert(!leadBrief.includes('Verify:'), 'lead-brief.md: must not prescribe Verify:');
75
+ assert(
76
+ !/copy a prior role(?:'s)? brief|worker\s*(?:->|→)\s*debugger\s*(?:->|→)\s*reviewer|\blead\s*\/\s*worker\b/i.test(leadBrief),
77
+ 'lead-brief.md: must not restore cross-role Task copying or lineage',
78
+ );
75
79
  requireAll(leadBrief, 'Lead brief task', [
76
80
  /`task:` is mandatory and lossless/, /intent/, /required outcomes/, /negative outcomes/,
77
81
  /completion\/stop boundary/, /all other fields are optional task-specific deltas/,
78
82
  /preserve user-supplied exact targets and exact replacements\/outputs in `task:`/,
79
83
  /never infer exactness from a task name, file count, or perceived difficulty/,
80
- /exact same `task:` across worker -> debugger -> reviewer/,
81
- /never summarize, rewrite, or replace it/,
84
+ /each role independently constructs a role-appropriate, lossless `task:` from/,
85
+ /the original request and official spec\/test acceptance criteria/,
82
86
  ]);
83
87
  assert(/role-known|already (?:owns|knows)|wasted cost|wasted/i.test(normalize(leadBrief)), 'lead-brief.md: brief must ban restating known rules/background as cost');
84
- assert(/spec\/test beats its summary/i.test(normalize(leadBrief)), 'lead-brief.md: spec/test must beat summary');
88
+ assert(/original request and official spec\/test acceptance criteria/i.test(normalize(leadBrief)), 'lead-brief.md: original request and official acceptance criteria must beat summary');
85
89
  requireAll(leadBrief, 'Lead brief lifecycle', [
86
90
  /full brief only for fresh spawn\/`respawned: true`/, /live follow-ups are delta/,
87
91
  /dead-tag send is cold: re-supply anchors/, /never `send` mid-run/,
@@ -110,6 +114,15 @@ const roles = {
110
114
  'reviewer/AGENT.md': readSrc('agents', 'reviewer', 'AGENT.md'),
111
115
  'debugger/AGENT.md': readSrc('agents', 'debugger', 'AGENT.md'),
112
116
  };
117
+ const reviewerRole = roles['reviewer/AGENT.md'];
118
+ assert(
119
+ !/construct the review\s*`task:` independently/i.test(reviewerRole),
120
+ 'reviewer/AGENT.md: must not restore Reviewer-specific Task construction',
121
+ );
122
+ assert(
123
+ !/treat prior reports as unverified claims rather than evidence/i.test(reviewerRole),
124
+ 'reviewer/AGENT.md: must not restore prior-report framing',
125
+ );
113
126
 
114
127
  // Semantic contracts deliberately avoid prose snapshots.
115
128
  function snapshot(actual, expected, label) {
@@ -121,21 +134,6 @@ requireAll(workflow, 'Default approval', [
121
134
  /initial\/additional\/changed requests reset planning/, /scope change needs a revised plan and fresh approval/,
122
135
  /no edits, state mutation, or delegation/,
123
136
  ]);
124
- function routingReviewBlocks(text, label) {
125
- const source = String(text).replace(/\r\n/g, '\n');
126
- return {
127
- routing: block(source, 'Lead orchestrates and verifies.', '1. Plan:', `${label} routing policy`),
128
- review: block(source, '3. Review:', '\n4.', `${label} review policy`),
129
- };
130
- }
131
- const defaultPolicy = routingReviewBlocks(workflow, 'Default');
132
- const benchPolicy = routingReviewBlocks(bench, 'Bench');
133
- for (const section of ['routing', 'review']) {
134
- assert(
135
- defaultPolicy[section] === benchPolicy[section],
136
- `Default/Bench ${section} policy must be identical after normalization`,
137
- );
138
- }
139
137
  const ROUTING_REVIEW_POLICY = [
140
138
  /lead-direct work is allowed only for pure read\/analysis, git\/configuration/,
141
139
  /user explicitly supplies both the exact target and exact replacement\/output/,
@@ -154,13 +152,11 @@ const ROUTING_REVIEW_POLICY = [
154
152
  /loop fix -> re-verify \(same reviewer \+ lead re-check\) until clean/,
155
153
  /exempt mutations still require shell self-verification/,
156
154
  ];
157
- for (const [label, text] of [['Default', workflow], ['Bench', bench]]) {
158
- requireAll(text, `${label} routing/review parity`, ROUTING_REVIEW_POLICY);
159
- assert(
160
- !/(high clarity|low structural complexity|immediate 1-step|genuinely simple)/i.test(text),
161
- `${label}: heuristic routing/review language must not return`,
162
- );
163
- }
155
+ requireAll(workflow, 'Default routing/review policy', ROUTING_REVIEW_POLICY);
156
+ assert(
157
+ !/(high clarity|low structural complexity|immediate 1-step|genuinely simple)/i.test(workflow),
158
+ 'Default: heuristic routing/review language must not return',
159
+ );
164
160
  requireAll(workflow, 'Default lifecycle', [
165
161
  /draft before any implementation/, /all ready scopes in one turn, with no count cap/,
166
162
  /real dependency, overlapping write, or inseparable coupling/,
@@ -181,19 +177,15 @@ requireAll(solo, 'Solo lifecycle', [
181
177
  /interim updates are in-progress, never conclusions/,
182
178
  /issue-free feedback/,
183
179
  ]);
184
- requireAll(bench, 'Bench lifecycle', [
185
- /never ask questions, propose plans for approval, or end the turn waiting/,
186
- /verified complete or provably blocked/, /all ready scopes in one turn, with no count cap/,
187
- /after async spawn, end the turn/, /original live session/,
188
- /hard blocked/, /outcome and evidence/,
189
- ]);
190
180
  requireAll(leadTool, 'Lead tools', [
191
181
  /write-role agents self-verify/, /cross-scope verification.*benches.*all git/,
192
- /workflow permits delegation/, /no-delegation workflow.*controls/,
193
182
  ]);
194
183
  requireAll(general, 'General safety', [
195
184
  /identify as mixdog\/current coding agent/, /destructive\/hard-to-reverse action needs explicit confirmation/,
196
- /never push, build, deploy without explicit user request/, /implementation approval is not deploy approval/,
185
+ ]);
186
+ requireAll(workflow, 'Default ship safety', [
187
+ /build\/deploy\/commit\/push require an explicit user request after issue-free feedback/,
188
+ /implementation approval alone is insufficient/,
197
189
  ]);
198
190
  requireAll(skip, 'Silent skip', [
199
191
  /webhook-handler/, /scheduler-task/, /label-only, duplicate\/dedup, no action needed\/report/,
@@ -230,11 +222,18 @@ Self-verify each checkpoint and the final slice with shell (targeted test/build)
230
222
  'reviewer/AGENT.md': `# Reviewer
231
223
  Independent regression/risk review agent.
232
224
 
233
- Review the approved intent, diff, and tests with independent judgment. Prioritize
234
- actionable correctness, regression, security, and verification risks; inspect
235
- affected boundaries. Do not reimplement the change or report non-risky nits.
225
+ Review the diff and tests with independent judgment. Prioritize actionable
226
+ correctness, regression, security, and verification risks; inspect affected
227
+ boundaries. Do not reimplement the change or report non-risky nits. Independently
228
+ evaluate the final deliverable with a critical lens, actively seeking errors,
229
+ unsupported assumptions, and counterexamples before confirming.
230
+
236
231
  Report findings first, severity-ordered, with one line per \`file:line\`. If clean,
237
- say so in one line and include only material residual risk.`,
232
+ say so in one line and include only material residual risk.
233
+
234
+ When the work comes with stated criteria or reference material for judging
235
+ it, verify against those as given — substituting your own interpretation or
236
+ a self-built check is a verification risk to report.`,
238
237
  'debugger/AGENT.md': `# Debugger
239
238
  Root-cause analysis agent.
240
239
 
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ import test from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import {
5
+ executeInternalTool,
6
+ setInternalToolsProvider,
7
+ } from '../src/runtime/agent/orchestrator/internal-tools.mjs';
8
+ import { classifyResultKind } from '../src/runtime/agent/orchestrator/session/result-classification.mjs';
9
+
10
+ async function normalizeToolResult(result) {
11
+ setInternalToolsProvider({
12
+ tools: [{ name: 'normalization_test' }],
13
+ executor: async () => result,
14
+ });
15
+ return executeInternalTool('normalization_test', {});
16
+ }
17
+
18
+ test('already-prefixed handler errors keep one canonical Error: prefix', async () => {
19
+ const result = await normalizeToolResult({
20
+ content: [{ type: 'text', text: 'Error: web search failed' }],
21
+ isError: true,
22
+ });
23
+ assert.equal(result, 'Error: web search failed');
24
+ assert.equal(classifyResultKind(result), 'error');
25
+ });
26
+
27
+ test('ordinary handler error text receives the canonical Error: prefix', async () => {
28
+ const result = await normalizeToolResult({
29
+ content: [{ type: 'text', text: 'core add: project_id required' }],
30
+ isError: true,
31
+ });
32
+ assert.equal(result, 'Error: core add: project_id required');
33
+ assert.equal(classifyResultKind(result), 'error');
34
+ });
35
+
36
+ test('successful empty output stays normal', async () => {
37
+ const result = await normalizeToolResult({
38
+ content: [{ type: 'text', text: '' }],
39
+ isError: false,
40
+ });
41
+ assert.equal(result, '');
42
+ assert.equal(classifyResultKind(result), 'normal');
43
+ });
44
+
45
+ test('isError:false output stays normal', async () => {
46
+ const result = await normalizeToolResult({
47
+ content: [{ type: 'text', text: 'search complete' }],
48
+ isError: false,
49
+ });
50
+ assert.equal(result, 'search complete');
51
+ assert.equal(classifyResultKind(result), 'normal');
52
+ });
@@ -0,0 +1,164 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { spawnSync } from 'node:child_process';
7
+ import { DEFAULT_MAINTENANCE } from '../src/runtime/agent/orchestrator/config.mjs';
8
+ import { resolveMaintenanceRoute } from '../src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs';
9
+ import { resolveAgentSpawnPreset } from '../src/standalone/agent-tool.mjs';
10
+ import { exploreResultCacheKey, resolveExploreRoute } from '../src/standalone/explore-tool.mjs';
11
+
12
+ const main = { id: 'main', name: 'MAIN', type: 'agent', provider: 'main-provider', model: 'main-model', tools: 'full' };
13
+ const configWithMain = (overrides = {}) => ({
14
+ default: 'main',
15
+ presets: [main],
16
+ maintenance: { ...DEFAULT_MAINTENANCE },
17
+ ...overrides,
18
+ });
19
+ const normalizedRoute = (route, provider = route.provider || 'anthropic-oauth') => ({
20
+ provider,
21
+ model: route.model,
22
+ effort: undefined,
23
+ fast: false,
24
+ });
25
+
26
+ test('empty Explore and Maintainer routes inherit Main in hidden and public dispatch', () => {
27
+ assert.equal(DEFAULT_MAINTENANCE.explore, undefined);
28
+ assert.equal(DEFAULT_MAINTENANCE.memory, undefined);
29
+ assert.deepEqual(DEFAULT_MAINTENANCE.scheduler, DEFAULT_MAINTENANCE.webhook);
30
+ assert.equal(DEFAULT_MAINTENANCE.scheduler.provider, 'anthropic-oauth');
31
+ assert.match(DEFAULT_MAINTENANCE.scheduler.model, /haiku/i);
32
+
33
+ const config = configWithMain();
34
+ assert.equal(resolveMaintenanceRoute({ agent: 'explorer', config }), 'main');
35
+ assert.equal(resolveMaintenanceRoute({ agent: 'cycle1-agent', config }), 'main');
36
+ assert.equal(resolveMaintenanceRoute({ agent: 'scheduler-task', config }), DEFAULT_MAINTENANCE.scheduler);
37
+ assert.equal(resolveAgentSpawnPreset(config, { agent: 'explore' }).preset, main);
38
+ assert.equal(resolveAgentSpawnPreset(config, { agent: 'maintainer' }).preset, main);
39
+ });
40
+
41
+ test('public agent, workflow, and maintenance routes override Main inheritance', () => {
42
+ const exploreAgent = { provider: 'agent-provider', model: 'agent-model' };
43
+ const maintainerWorkflow = { provider: 'workflow-provider', model: 'workflow-model' };
44
+ const exploreMaintenance = { provider: 'maintenance-provider', model: 'maintenance-model' };
45
+ assert.deepEqual(
46
+ resolveAgentSpawnPreset(configWithMain({ agents: { explore: exploreAgent } }), { agent: 'explore' }).preset,
47
+ { id: 'agent-explore', name: 'AGENT EXPLORE', type: 'agent', ...exploreAgent, effort: undefined, fast: false, tools: 'full' },
48
+ );
49
+ assert.deepEqual(
50
+ resolveAgentSpawnPreset(configWithMain({
51
+ workflowRoutes: { memory: maintainerWorkflow },
52
+ maintenance: { ...DEFAULT_MAINTENANCE, memory: { provider: 'ignored-provider', model: 'ignored-model' } },
53
+ }), { agent: 'maintainer' }).preset,
54
+ { id: 'agent-maintainer', name: 'AGENT MAINTAINER', type: 'agent', ...maintainerWorkflow, effort: undefined, fast: false, tools: 'full' },
55
+ );
56
+ const config = configWithMain({
57
+ maintenance: {
58
+ ...DEFAULT_MAINTENANCE,
59
+ explore: exploreMaintenance,
60
+ },
61
+ });
62
+ assert.deepEqual(resolveMaintenanceRoute({ agent: 'explorer', config }), normalizedRoute(exploreMaintenance));
63
+ assert.deepEqual(
64
+ resolveAgentSpawnPreset(config, { agent: 'explore' }).preset,
65
+ { id: 'agent-explore', name: 'AGENT EXPLORE', type: 'agent', ...exploreMaintenance, effort: undefined, fast: false, tools: 'full' },
66
+ );
67
+ });
68
+
69
+ test('hidden Explore and Maintainer routes use public precedence, including the legacy maintainer alias', () => {
70
+ const agentRoute = { provider: 'agent-provider', model: 'agent-model' };
71
+ const workflowRoute = { provider: 'workflow-provider', model: 'workflow-model' };
72
+ const maintenanceRoute = { provider: 'maintenance-provider', model: 'maintenance-model' };
73
+ assert.deepEqual(resolveMaintenanceRoute({
74
+ agent: 'explorer',
75
+ config: configWithMain({
76
+ agents: { explore: agentRoute },
77
+ workflowRoutes: { explorer: workflowRoute },
78
+ maintenance: { ...DEFAULT_MAINTENANCE, explore: maintenanceRoute },
79
+ }),
80
+ }), normalizedRoute(agentRoute));
81
+ assert.deepEqual(resolveMaintenanceRoute({
82
+ agent: 'cycle1-agent',
83
+ config: configWithMain({
84
+ workflowRoutes: { memory: workflowRoute },
85
+ maintenance: { ...DEFAULT_MAINTENANCE, memory: maintenanceRoute },
86
+ }),
87
+ }), normalizedRoute(workflowRoute));
88
+ assert.deepEqual(resolveMaintenanceRoute({
89
+ agent: 'cycle2-agent',
90
+ config: configWithMain({
91
+ maintenance: { ...DEFAULT_MAINTENANCE, memory: maintenanceRoute },
92
+ }),
93
+ }), normalizedRoute(maintenanceRoute));
94
+ assert.deepEqual(resolveMaintenanceRoute({
95
+ agent: 'cycle3-agent',
96
+ config: configWithMain({ agents: { maintenance: agentRoute } }),
97
+ }), normalizedRoute(agentRoute));
98
+ });
99
+
100
+ test('Explore cache identity follows the effective route when Main changes', () => {
101
+ const first = configWithMain();
102
+ const secondMain = { ...main, model: 'replacement-main-model' };
103
+ const second = configWithMain({ presets: [secondMain] });
104
+ const query = 'locate the route';
105
+ const cwd = '/tmp/project';
106
+ assert.deepEqual(resolveExploreRoute(first), main);
107
+ assert.deepEqual(resolveExploreRoute(second), secondMain);
108
+ assert.notEqual(
109
+ exploreResultCacheKey({ cwd, route: resolveExploreRoute(first), query }),
110
+ exploreResultCacheKey({ cwd, route: resolveExploreRoute(second), query }),
111
+ );
112
+ });
113
+
114
+ test('hidden precedence normalizes model-only routes with the public provider fallback', () => {
115
+ const modelOnly = { model: 'model-only' };
116
+ assert.deepEqual(
117
+ resolveMaintenanceRoute({ agent: 'explorer', config: configWithMain({ defaultProvider: 'openai', agents: { explore: modelOnly } }) }),
118
+ normalizedRoute(modelOnly, 'openai'),
119
+ );
120
+ assert.deepEqual(
121
+ resolveMaintenanceRoute({ agent: 'cycle1-agent', config: configWithMain({ defaultProvider: 'gemini', agents: { maintenance: modelOnly } }) }),
122
+ normalizedRoute(modelOnly, 'gemini'),
123
+ );
124
+ assert.deepEqual(
125
+ resolveMaintenanceRoute({ agent: 'explorer', config: configWithMain({ defaultProvider: 'xai', workflowRoutes: { explorer: modelOnly } }) }),
126
+ normalizedRoute(modelOnly, 'xai'),
127
+ );
128
+ assert.deepEqual(
129
+ resolveMaintenanceRoute({ agent: 'cycle2-agent', config: configWithMain({ maintenance: { ...DEFAULT_MAINTENANCE, memory: modelOnly } }) }),
130
+ normalizedRoute(modelOnly),
131
+ );
132
+ });
133
+
134
+ test('loadConfig preserves persisted model-only Explore and Maintainer routes with the configured provider', () => {
135
+ const dataDir = mkdtempSync(join(tmpdir(), 'mixdog-maintenance-config-'));
136
+ writeFileSync(join(dataDir, 'mixdog-config.json'), JSON.stringify({
137
+ agent: {
138
+ defaultProvider: 'openai',
139
+ maintenance: {
140
+ explore: { model: 'gpt-test' },
141
+ memory: { model: 'gpt-maintainer-test' },
142
+ },
143
+ },
144
+ }));
145
+ const runner = [
146
+ "import { loadConfig } from './src/runtime/agent/orchestrator/config.mjs';",
147
+ "console.log(JSON.stringify(loadConfig({ secrets: false }).maintenance));",
148
+ ].join(' ');
149
+ const result = spawnSync(process.execPath, ['--input-type=module', '--eval', runner], {
150
+ cwd: process.cwd(),
151
+ env: { ...process.env, MIXDOG_DATA_DIR: dataDir },
152
+ encoding: 'utf8',
153
+ });
154
+ assert.equal(result.status, 0, result.stderr);
155
+ const maintenance = JSON.parse(result.stdout);
156
+ assert.deepEqual(maintenance.explore, { provider: 'openai', model: 'gpt-test' });
157
+ assert.deepEqual(maintenance.memory, { provider: 'openai', model: 'gpt-maintainer-test' });
158
+ });
159
+
160
+ test('config maintenance migration has no standalone provider runtime dependency', () => {
161
+ const source = readFileSync(new URL('../src/runtime/agent/orchestrator/config.mjs', import.meta.url), 'utf8');
162
+ assert.doesNotMatch(source, /standalone\/provider-admin/);
163
+ assert.match(source, /CONFIG_PROVIDER_IDS/);
164
+ });
@@ -3,6 +3,7 @@ import test from 'node:test';
3
3
  import assert from 'node:assert/strict';
4
4
 
5
5
  import { agentLoop } from '../src/runtime/agent/orchestrator/session/agent-loop.mjs';
6
+ import { HIDDEN_AGENT_NAMES } from '../src/runtime/agent/orchestrator/session/loop/hidden-agents.mjs';
6
7
 
7
8
  function queuedProvider(responses, streamed = []) {
8
9
  const sent = [];
@@ -35,6 +36,7 @@ function queuedProvider(responses, streamed = []) {
35
36
  async function run(provider, messages = [{ role: 'user', content: 'answer fully' }], options = {}) {
36
37
  return agentLoop(provider, messages, 'fake-model', [], options.onToolCall, process.cwd(), {
37
38
  onTextDelta: options.onTextDelta,
39
+ session: options.session,
38
40
  });
39
41
  }
40
42
 
@@ -126,6 +128,53 @@ test('pause_turn and Gemini OTHER preserve prior non-empty semantics and do not
126
128
  }
127
129
  });
128
130
 
131
+ test('empty safety refusal gets one context-changing retry, then terminates as refusal', async () => {
132
+ const provider = queuedProvider([
133
+ { content: '', stopReason: 'refusal', stopDetails: { category: 'safety' } },
134
+ { content: '', stopReason: 'refusal', stopDetails: { category: 'safety' } },
135
+ ]);
136
+ const messages = [{ role: 'user', content: 'answer fully' }];
137
+
138
+ const result = await run(provider, messages);
139
+
140
+ assert.equal(provider.sent.length, 2);
141
+ assert.equal(result.terminationReason, 'refusal');
142
+ assert.equal(messages.filter((message) => message?.meta?.source === 'refusal-recovery').length, 1);
143
+ assert.match(provider.sent[1].at(-1).content, /safety classifier/);
144
+ assert.match(provider.sent[1].at(-1).content, /within policy/);
145
+ });
146
+
147
+ test('one refusal retry can recover to a normal answer', async () => {
148
+ const provider = queuedProvider([
149
+ { content: '', stopReason: 'refusal' },
150
+ { content: 'compliant answer', stopReason: 'end_turn' },
151
+ ]);
152
+
153
+ const result = await run(provider);
154
+
155
+ assert.equal(provider.sent.length, 2);
156
+ assert.equal(result.content, 'compliant answer');
157
+ assert.equal(result.terminationReason, undefined);
158
+ });
159
+
160
+ test('hidden-agent empty refusal gets one retry, then terminates as refusal', async () => {
161
+ const hiddenAgent = HIDDEN_AGENT_NAMES.values().next().value;
162
+ assert.ok(hiddenAgent, 'expected at least one configured hidden agent');
163
+ const provider = queuedProvider([
164
+ { content: '', stopReason: 'refusal' },
165
+ { content: '', stopReason: 'refusal' },
166
+ ]);
167
+ const messages = [{ role: 'user', content: 'complete the assigned output' }];
168
+
169
+ const result = await run(provider, messages, { session: { agent: hiddenAgent } });
170
+
171
+ assert.equal(provider.sent.length, 2);
172
+ assert.equal(result.terminationReason, 'refusal');
173
+ assert.equal(messages.filter((message) => message?.meta?.source === 'refusal-recovery').length, 1);
174
+ assert.match(provider.sent[1].at(-1).content, /assigned output within policy/);
175
+ assert.doesNotMatch(provider.sent[1].at(-1).content, /answer the user/i);
176
+ });
177
+
129
178
  test('non-output ProviderIncompleteError remains an error', async () => {
130
179
  const other = Object.assign(new Error('Gemini response incomplete: finishReason=OTHER'), {
131
180
  code: 'PROVIDER_INCOMPLETE',
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ import test from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { normalizeMcpToolResult } from '../src/runtime/agent/orchestrator/mcp/client.mjs';
5
+ import { classifyResultKind } from '../src/runtime/agent/orchestrator/session/result-classification.mjs';
6
+ import { normalizeToolEnvelope } from '../src/runtime/agent/orchestrator/session/tool-envelope.mjs';
7
+
8
+ test('isError:true plain MCP text receives one canonical Error: prefix', () => {
9
+ const result = normalizeMcpToolResult({
10
+ content: [{ type: 'text', text: 'connection refused' }],
11
+ isError: true,
12
+ });
13
+ assert.equal(result, 'Error: connection refused');
14
+ assert.equal(classifyResultKind(result), 'error');
15
+ });
16
+
17
+ test('isError:true already-prefixed MCP text is unchanged', () => {
18
+ const result = normalizeMcpToolResult({
19
+ content: [{ type: 'text', text: 'Error: connection refused' }],
20
+ isError: true,
21
+ });
22
+ assert.equal(result, 'Error: connection refused');
23
+ });
24
+
25
+ test('absent or false isError leaves MCP text untouched', () => {
26
+ for (const isError of [undefined, false]) {
27
+ const result = normalizeMcpToolResult({
28
+ content: [{ type: 'text', text: 'search complete' }],
29
+ ...(isError === undefined ? {} : { isError }),
30
+ });
31
+ assert.equal(result, 'search complete');
32
+ assert.equal(classifyResultKind(result), 'normal');
33
+ }
34
+ });
35
+
36
+ test('isError:false Error:-leading MCP text stays untouched and normal', () => {
37
+ const returned = normalizeMcpToolResult({
38
+ content: [{ type: 'text', text: 'Error: quoted search result' }],
39
+ isError: false,
40
+ });
41
+ const normalized = normalizeToolEnvelope(returned);
42
+ assert.equal(normalized.result, 'Error: quoted search result');
43
+ assert.equal(normalized.explicitSuccess, true);
44
+ assert.equal(classifyResultKind(normalized.result, normalized.explicitSuccess), 'normal');
45
+ });
@@ -0,0 +1,123 @@
1
+ // Regression: OpenAI OAuth WS abnormal-close recovery before visible output.
2
+ import test from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { OpenAIOAuthProvider } from '../src/runtime/agent/orchestrator/providers/openai-oauth.mjs';
5
+ import { sendViaWebSocket } from '../src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs';
6
+
7
+ function close1006() {
8
+ const err = new Error('WebSocket closed abnormally');
9
+ err.wsCloseCode = 1006;
10
+ return err;
11
+ }
12
+
13
+ function entry() {
14
+ return { socket: { close() {} } };
15
+ }
16
+
17
+ function wsArgs(overrides = {}) {
18
+ return {
19
+ auth: { access_token: 'test-token' },
20
+ body: { model: 'gpt-5.5', input: [{ role: 'user', content: 'retry me' }] },
21
+ poolKey: 'openai-oauth-ws-1006-test',
22
+ cacheKey: 'openai-oauth-ws-1006-test',
23
+ useModel: 'gpt-5.5',
24
+ _sendFrameFn: async () => {},
25
+ _sleepFn: async () => {},
26
+ ...overrides,
27
+ };
28
+ }
29
+
30
+ test('pre-response 1006 opens a fresh WS and replays the same request', async () => {
31
+ const acquires = [];
32
+ const frames = [];
33
+ let streams = 0;
34
+ const result = await sendViaWebSocket(wsArgs({
35
+ _acquireWithRetryFn: async (opts) => {
36
+ acquires.push(opts.forceFresh);
37
+ return { entry: entry(), reused: false };
38
+ },
39
+ _sendFrameFn: async (_entry, frame) => { frames.push(frame); },
40
+ _streamFn: async () => {
41
+ streams += 1;
42
+ if (streams === 1) throw close1006();
43
+ return { content: 'recovered', model: 'gpt-5.5', toolCalls: [], usage: {}, closeSocket: true };
44
+ },
45
+ }));
46
+
47
+ assert.equal(result.content, 'recovered');
48
+ assert.deepEqual(acquires, [false, true], 'retry must acquire a fresh socket');
49
+ assert.equal(frames.length, 2);
50
+ assert.deepEqual(frames[1].input, frames[0].input, 'retry must replay the same input');
51
+ assert.equal(result.__midstreamRetries, 1);
52
+ });
53
+
54
+ test('exhausted pre-response 1006 retries reach the HTTP/SSE fallback', async () => {
55
+ const savedEnv = Object.fromEntries([
56
+ 'MIXDOG_OAI_TRANSPORT',
57
+ 'MIXDOG_OPENAI_HTTP_FALLBACK',
58
+ 'MIXDOG_OPENAI_OAUTH_WS_WARMUP',
59
+ 'MIXDOG_OPENAI_OAUTH_FAST_HTTP_FALLBACK',
60
+ 'MIXDOG_AGENT_TRACE_DISABLE',
61
+ ].map((name) => [name, process.env[name]]));
62
+ Object.assign(process.env, {
63
+ MIXDOG_OAI_TRANSPORT: 'auto',
64
+ MIXDOG_OPENAI_HTTP_FALLBACK: '1',
65
+ MIXDOG_OPENAI_OAUTH_WS_WARMUP: '0',
66
+ MIXDOG_OPENAI_OAUTH_FAST_HTTP_FALLBACK: '0',
67
+ MIXDOG_AGENT_TRACE_DISABLE: '1',
68
+ });
69
+ try {
70
+ const provider = new OpenAIOAuthProvider({});
71
+ provider.ensureAuth = async () => ({ access_token: 'test-token' });
72
+ let streamAttempts = 0;
73
+ let httpCalls = 0;
74
+ const result = await provider.send([], 'gpt-5.5', [], {
75
+ sessionId: 'openai-oauth-ws-1006-fallback-test',
76
+ _prebuiltBody: { model: 'gpt-5.5', input: [], prompt_cache_key: 'openai-oauth-ws-1006-fallback-test' },
77
+ _sendViaWebSocketFn: (args) => sendViaWebSocket({
78
+ ...args,
79
+ _acquireWithRetryFn: async () => ({ entry: entry(), reused: false }),
80
+ _sendFrameFn: async () => {},
81
+ _streamFn: async () => {
82
+ streamAttempts += 1;
83
+ throw close1006();
84
+ },
85
+ _sleepFn: async () => {},
86
+ }),
87
+ _sendViaHttpSseFn: async () => {
88
+ httpCalls += 1;
89
+ return { content: 'http-recovered', toolCalls: [], usage: {} };
90
+ },
91
+ });
92
+
93
+ assert.equal(streamAttempts, 5, 'all bounded ws_1006 attempts must run before fallback');
94
+ assert.equal(httpCalls, 1);
95
+ assert.equal(result.content, 'http-recovered');
96
+ } finally {
97
+ for (const [name, value] of Object.entries(savedEnv)) {
98
+ if (value == null) delete process.env[name];
99
+ else process.env[name] = value;
100
+ }
101
+ }
102
+ });
103
+
104
+ test('post-emission 1006 refuses replay after text or tool output', async () => {
105
+ for (const emitted of ['emittedText', 'emittedToolCall']) {
106
+ let acquires = 0;
107
+ await assert.rejects(
108
+ sendViaWebSocket(wsArgs({
109
+ poolKey: `openai-oauth-ws-1006-${emitted}-test`,
110
+ _acquireWithRetryFn: async () => {
111
+ acquires += 1;
112
+ return { entry: entry(), reused: false };
113
+ },
114
+ _streamFn: async ({ state }) => {
115
+ state[emitted] = true;
116
+ throw close1006();
117
+ },
118
+ })),
119
+ (err) => err.wsCloseCode === 1006 && err.unsafeToRetry === true,
120
+ );
121
+ assert.equal(acquires, 1, `${emitted} must prevent a replay`);
122
+ }
123
+ });
@@ -95,6 +95,29 @@ function anthropicSseResponse(events) {
95
95
  };
96
96
  }
97
97
 
98
+ test('anthropic SSE exposes refusal stop details and category metadata', async () => {
99
+ const result = await anthropicParseSSEStream(
100
+ anthropicSseResponse([
101
+ { type: 'message_start', message: { model: 'claude-fable-5', usage: { input_tokens: 1 } } },
102
+ {
103
+ type: 'message_delta',
104
+ delta: {
105
+ stop_reason: 'refusal',
106
+ stop_details: { classifier: 'safety' },
107
+ category: 'policy',
108
+ },
109
+ usage: { output_tokens: 0 },
110
+ },
111
+ { type: 'message_stop' },
112
+ ]),
113
+ null, () => {}, () => {}, () => {}, {}, null,
114
+ );
115
+
116
+ assert.equal(result.stopReason, 'refusal');
117
+ assert.deepEqual(result.stopDetails, { classifier: 'safety', category: 'policy' });
118
+ assert.equal(result.content, '');
119
+ });
120
+
98
121
  function compatResponsesEventStream(events) {
99
122
  return {
100
123
  async *[Symbol.asyncIterator]() {