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.
- package/package.json +3 -2
- package/scripts/agent-tag-reuse-smoke.mjs +124 -10
- package/scripts/ansi-color-capability-test.mjs +90 -0
- package/scripts/anthropic-oauth-refresh-race-test.mjs +59 -0
- package/scripts/arg-guard-test.mjs +16 -0
- package/scripts/compact-pressure-test.mjs +256 -1
- package/scripts/generate-runtime-manifest.mjs +39 -22
- package/scripts/grok-oauth-refresh-race-test.mjs +198 -0
- package/scripts/internal-comms-bench.mjs +0 -1
- package/scripts/internal-comms-smoke.mjs +38 -39
- package/scripts/internal-tools-normalization-test.mjs +52 -0
- package/scripts/maintenance-default-routes-test.mjs +164 -0
- package/scripts/max-output-recovery-test.mjs +49 -0
- package/scripts/mcp-client-normalization-test.mjs +45 -0
- package/scripts/openai-oauth-ws-1006-retry-test.mjs +123 -0
- package/scripts/provider-toolcall-test.mjs +23 -0
- package/scripts/result-classification-test.mjs +75 -0
- package/scripts/routing-corpus.mjs +1 -1
- package/scripts/shell-jobs-windows-hide-test.mjs +164 -0
- package/scripts/smoke.mjs +1 -1
- package/scripts/submit-commandbusy-race-test.mjs +99 -7
- package/scripts/tui-transcript-jitter-harness-entry.jsx +302 -0
- package/scripts/tui-transcript-jitter-harness.mjs +58 -0
- package/scripts/tui-transcript-perf-test.mjs +56 -1
- package/src/agents/reviewer/AGENT.md +5 -3
- package/src/rules/lead/lead-brief.md +5 -4
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +40 -3
- package/src/runtime/agent/orchestrator/config.mjs +29 -14
- package/src/runtime/agent/orchestrator/internal-tools.mjs +16 -3
- package/src/runtime/agent/orchestrator/mcp/client.mjs +17 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +49 -6
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +14 -0
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +46 -21
- package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +59 -2
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +5 -1
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +18 -0
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +140 -81
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +23 -5
- package/src/runtime/agent/orchestrator/session/loop/termination.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +12 -3
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +4 -2
- package/src/runtime/agent/orchestrator/session/result-classification.mjs +4 -1
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +7 -2
- package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +8 -6
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +15 -3
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -5
- package/src/runtime/memory/data/runtime-manifest.json +11 -11
- package/src/runtime/memory/lib/runtime-fetcher.mjs +1 -2
- package/src/runtime/memory/tool-defs.mjs +4 -3
- package/src/runtime/shared/tool-surface.mjs +1 -2
- package/src/session-runtime/context-status.mjs +8 -2
- package/src/standalone/agent-tool/render.mjs +2 -0
- package/src/standalone/agent-tool.mjs +267 -72
- package/src/standalone/explore-tool.mjs +17 -16
- package/src/tui/app/provider-setup-picker.mjs +1 -0
- package/src/tui/app/use-transcript-window.mjs +5 -1
- package/src/tui/components/StatusLine.jsx +11 -32
- package/src/tui/dist/index.mjs +257 -106
- package/src/tui/engine/session-api.mjs +3 -0
- package/src/tui/engine/session-flow.mjs +19 -8
- package/src/tui/engine/turn.mjs +24 -2
- package/src/tui/engine.mjs +0 -1
- package/src/tui/index.jsx +3 -2
- package/src/tui/markdown/streaming-markdown.mjs +35 -9
- package/src/tui/statusline-ansi-bridge.mjs +17 -7
- package/src/ui/ansi.mjs +85 -5
- package/src/ui/statusline-agents.mjs +0 -8
- package/src/ui/statusline-format.mjs +7 -7
- package/src/ui/statusline.mjs +2 -4
- package/src/workflows/default/WORKFLOW.md +29 -20
- package/src/workflows/solo-review/WORKFLOW.md +47 -0
- 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:'
|
|
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
|
-
/
|
|
81
|
-
/
|
|
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
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
|
234
|
-
|
|
235
|
-
|
|
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]() {
|