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
@@ -0,0 +1,302 @@
1
+ import React from 'react';
2
+ import { PassThrough } from 'node:stream';
3
+ import { Box, measureElement, render } from 'ink';
4
+ import { Item } from '../src/tui/components/TranscriptItem.jsx';
5
+ import { useTranscriptWindow } from '../src/tui/app/use-transcript-window.mjs';
6
+ import {
7
+ streamingMeasuredRowsById,
8
+ } from '../src/tui/app/transcript-window.mjs';
9
+ import {
10
+ resetAllStreamingMarkdownStablePrefixes,
11
+ resetStreamingMarkdownStablePrefix,
12
+ resolveStreamingMarkdownParts,
13
+ } from '../src/tui/markdown/streaming-markdown.mjs';
14
+
15
+ const COLUMNS = 42;
16
+ const VIEW_ROWS = 8;
17
+ const INITIAL_SCROLL = 8;
18
+ const STREAM_ID = 'jitter-fence-tail';
19
+ const HISTORY = Array.from({ length: 8 }, (_, index) => ({
20
+ id: `history-${index}`,
21
+ kind: 'notice',
22
+ tone: 'plain',
23
+ text: `H${index} stable history row`,
24
+ }));
25
+ const SCRIPT = [
26
+ 'Here is the script:',
27
+ '',
28
+ '```js',
29
+ 'const first = `',
30
+ 'alpha',
31
+ '`;',
32
+ 'const second = `',
33
+ 'beta',
34
+ '`;',
35
+ 'console.log(first, second);',
36
+ '```',
37
+ '',
38
+ 'Done.',
39
+ ].join('\n');
40
+
41
+ const frames = [];
42
+ globalThis.__mixdogTailGrowthProbe = null;
43
+ let commit = 0;
44
+ const identity = (value) => value;
45
+ const noop = () => {};
46
+
47
+ function Harness({ text, step }) {
48
+ const [scrollOffset, setScrollOffset] = React.useState(INITIAL_SCROLL);
49
+ const [measuredRowsVersion, setMeasuredRowsVersion] = React.useState(0);
50
+ const transcriptAnchorRef = React.useRef(null);
51
+ const transcriptAnchorDirtyRef = React.useRef(true);
52
+ const scrollTargetRef = React.useRef(INITIAL_SCROLL);
53
+ const scrollPositionRef = React.useRef(INITIAL_SCROLL);
54
+ const maxScrollRowsRef = React.useRef(0);
55
+ const transcriptGeomRef = React.useRef({});
56
+ const followingRef = React.useRef(false);
57
+ const dragRef = React.useRef({ active: false, rect: null });
58
+ const transcriptViewportRef = React.useRef({ top: 0 });
59
+ const selectionLayoutRef = React.useRef(null);
60
+ const contentRef = React.useRef(null);
61
+ const tailRef = React.useRef(null);
62
+ const streamingTail = React.useMemo(() => ({
63
+ id: STREAM_ID,
64
+ kind: 'assistant',
65
+ text,
66
+ streaming: true,
67
+ }), [text]);
68
+ const transcriptItems = React.useMemo(() => [...HISTORY, streamingTail], [streamingTail]);
69
+
70
+ const preHookEntry = streamingMeasuredRowsById.get(STREAM_ID);
71
+ const preHookMeasuredRows = preHookEntry?.rows ?? null;
72
+ const preHookEstimateRows = preHookEntry?.estimateRows ?? null;
73
+
74
+ const {
75
+ transcriptWindow,
76
+ renderedTranscriptItems,
77
+ transcriptMeasureRef,
78
+ } = useTranscriptWindow({
79
+ items: HISTORY,
80
+ structureRevision: 1,
81
+ streamingTail,
82
+ themeEpoch: 0,
83
+ frameColumns: COLUMNS,
84
+ toolOutputExpanded: false,
85
+ transcriptContentHeight: VIEW_ROWS,
86
+ transcriptBottomSlackRows: 1,
87
+ transcriptGuardRows: 1,
88
+ floatingPanelRows: 0,
89
+ overlayHintRequested: false,
90
+ scrollOffset,
91
+ setScrollOffset,
92
+ transcriptAnchorRef,
93
+ transcriptAnchorDirtyRef,
94
+ scrollTargetRef,
95
+ scrollPositionRef,
96
+ maxScrollRowsRef,
97
+ transcriptGeomRef,
98
+ followingRef,
99
+ dragRef,
100
+ transcriptViewportRef,
101
+ selectionLayoutRef,
102
+ withSelectionClip: identity,
103
+ paintSelectionRect: noop,
104
+ stopSmoothScroll: noop,
105
+ measuredRowsVersion,
106
+ setMeasuredRowsVersion,
107
+ });
108
+
109
+ const growthProbe = globalThis.__mixdogTailGrowthProbe;
110
+ const tailHookRef = transcriptMeasureRef(streamingTail);
111
+ const combinedTailRef = React.useCallback((element) => {
112
+ tailHookRef?.(element);
113
+ tailRef.current = element;
114
+ }, [tailHookRef]);
115
+
116
+ React.useLayoutEffect(() => {
117
+ const geometry = transcriptGeomRef.current || {};
118
+ const prefix = geometry.prefixRows || [];
119
+ const physicalRows = measureElement(contentRef.current).height;
120
+ const tailYogaRows = measureElement(tailRef.current).height;
121
+ const renderScrollOffset = transcriptWindow.effectiveScrollOffset;
122
+ const visibleTopIndexed = transcriptWindow.totalRows - renderScrollOffset - VIEW_ROWS;
123
+ const visibleTopPhysical = physicalRows - renderScrollOffset - VIEW_ROWS;
124
+ frames.push({
125
+ commit: ++commit,
126
+ step,
127
+ char: text.at(-1) === '\n' ? '\\n' : (text.at(-1) || ''),
128
+ totalRows: transcriptWindow.totalRows,
129
+ renderScrollOffset,
130
+ visibleTopIndexed,
131
+ visibleTopPhysical,
132
+ physicalRows,
133
+ tailIndexedRows: prefix.length > 1 ? prefix.at(-1) - prefix.at(-2) : -1,
134
+ tailYogaRows,
135
+ mountedDelta: tailYogaRows - (prefix.length > 1 ? prefix.at(-1) - prefix.at(-2) : -1),
136
+ growthLive: growthProbe?.live ?? null,
137
+ growthBaseline: growthProbe?.baseline ?? null,
138
+ growthDelta: growthProbe?.delta ?? null,
139
+ suppressMeasured: geometry.suppressMeasuredRowHeights === true,
140
+ measuredRowsVersion,
141
+ preHookMeasuredRows,
142
+ preHookEstimateRows,
143
+ postHarvestMeasuredRows: streamingMeasuredRowsById.get(STREAM_ID)?.rows ?? null,
144
+ postHarvestEstimateRows: streamingMeasuredRowsById.get(STREAM_ID)?.estimateRows ?? null,
145
+ scrollTarget: scrollTargetRef.current,
146
+ following: followingRef.current,
147
+ anchor: transcriptAnchorRef.current?.id || '-',
148
+ });
149
+ }, [step, text, measuredRowsVersion, transcriptWindow.totalRows, transcriptWindow.effectiveScrollOffset,
150
+ transcriptAnchorRef, transcriptGeomRef]);
151
+
152
+ return (
153
+ <Box flexDirection="column" width={COLUMNS} height={VIEW_ROWS} overflow="hidden" justifyContent="flex-end">
154
+ <Box
155
+ ref={contentRef}
156
+ flexDirection="column"
157
+ width="100%"
158
+ flexShrink={0}
159
+ marginBottom={-transcriptWindow.effectiveScrollOffset}
160
+ >
161
+ {renderedTranscriptItems.map((item, index, all) => {
162
+ const hookRef = item.id === STREAM_ID ? combinedTailRef : transcriptMeasureRef(item);
163
+ return (
164
+ <Box key={item.id} ref={hookRef} flexDirection="column" flexShrink={0}>
165
+ <Item
166
+ item={item}
167
+ prevKind={index > 0 ? all[index - 1].kind : null}
168
+ columns={COLUMNS}
169
+ toolOutputExpanded={false}
170
+ />
171
+ </Box>
172
+ );
173
+ })}
174
+ {transcriptWindow.bottomSpacerRows > 0
175
+ ? <Box height={transcriptWindow.bottomSpacerRows} flexShrink={0} />
176
+ : null}
177
+ </Box>
178
+ </Box>
179
+ );
180
+ }
181
+
182
+ function fakeTty(columns, rows) {
183
+ const stream = new PassThrough();
184
+ stream.columns = columns;
185
+ stream.rows = rows;
186
+ stream.isTTY = true;
187
+ stream.getColorDepth = () => 1;
188
+ stream.hasColors = () => false;
189
+ stream.setRawMode = () => stream;
190
+ stream.on('data', () => {});
191
+ return stream;
192
+ }
193
+
194
+ async function settle(instance) {
195
+ await instance.waitUntilRenderFlush();
196
+ await new Promise((resolve) => setTimeout(resolve, 2));
197
+ await instance.waitUntilRenderFlush();
198
+ }
199
+
200
+ function assertStreamingMarkdownPartsCache() {
201
+ const key = 'streaming-parts-cache-coverage';
202
+ const longText = 'Settled paragraph.\n\n```js\nconst value = 1;';
203
+ const initial = resolveStreamingMarkdownParts(longText, key);
204
+ const repeated = resolveStreamingMarkdownParts(`${longText}\n\n`, key);
205
+ if (repeated !== initial) {
206
+ throw new Error('normalized-equivalent stream text did not reuse its resolved parts');
207
+ }
208
+
209
+ const regressed = resolveStreamingMarkdownParts('plain text', key);
210
+ if (regressed === initial || regressed.stablePrefix || regressed.unstableSuffix !== 'plain text') {
211
+ throw new Error('text regression served a stale streaming-markdown split');
212
+ }
213
+
214
+ const recomputed = resolveStreamingMarkdownParts(longText, key);
215
+ if (recomputed === initial) {
216
+ throw new Error('text change did not evict the prior streaming-markdown snapshot');
217
+ }
218
+
219
+ const resetSeed = recomputed;
220
+ resetStreamingMarkdownStablePrefix(key);
221
+ if (resolveStreamingMarkdownParts(longText, key) === resetSeed) {
222
+ throw new Error('streaming-markdown reset did not clear its resolved-parts snapshot');
223
+ }
224
+ }
225
+
226
+ assertStreamingMarkdownPartsCache();
227
+ resetAllStreamingMarkdownStablePrefixes();
228
+ streamingMeasuredRowsById.delete(STREAM_ID);
229
+ const stdout = fakeTty(COLUMNS, VIEW_ROWS);
230
+ const stderr = fakeTty(COLUMNS, VIEW_ROWS);
231
+ const stdin = fakeTty(COLUMNS, VIEW_ROWS);
232
+ const instance = render(<Harness text={SCRIPT.slice(0, 1)} step={1} />, {
233
+ stdout,
234
+ stderr,
235
+ stdin,
236
+ interactive: true,
237
+ patchConsole: false,
238
+ exitOnCtrlC: false,
239
+ maxFps: 1000,
240
+ });
241
+ await settle(instance);
242
+ for (let step = 2; step <= SCRIPT.length; step++) {
243
+ instance.rerender(<Harness text={SCRIPT.slice(0, step)} step={step} />);
244
+ await settle(instance);
245
+ }
246
+ instance.unmount();
247
+ await instance.waitUntilExit();
248
+ instance.cleanup();
249
+
250
+ const byStep = new Map();
251
+ for (const frame of frames) {
252
+ const list = byStep.get(frame.step) || [];
253
+ list.push(frame);
254
+ byStep.set(frame.step, list);
255
+ }
256
+ const dips = [];
257
+ let previousSettled = null;
258
+ for (const [step, list] of [...byStep.entries()].sort((a, b) => a[0] - b[0])) {
259
+ const first = list[0];
260
+ const settled = list.at(-1);
261
+ if (previousSettled
262
+ && first.visibleTopPhysical !== previousSettled.visibleTopPhysical
263
+ && settled.visibleTopPhysical === previousSettled.visibleTopPhysical) {
264
+ dips.push({ previous: previousSettled, transient: first, corrected: settled });
265
+ }
266
+ previousSettled = settled;
267
+ }
268
+
269
+ const print = (label, frame) => {
270
+ console.log(`${label} c${frame.commit} step=${frame.step} char=${JSON.stringify(frame.char)}`
271
+ + ` totalRows=${frame.totalRows} renderScrollOffset=${frame.renderScrollOffset}`
272
+ + ` visibleTop=${frame.visibleTopPhysical} indexedTop=${frame.visibleTopIndexed}`
273
+ + ` physicalRows=${frame.physicalRows} tail(index/yoga)=${frame.tailIndexedRows}/${frame.tailYogaRows}`
274
+ + ` mountedDelta=${frame.mountedDelta} helper(live/base/delta)=${frame.growthLive}/${frame.growthBaseline}/${frame.growthDelta}`
275
+ + ` suppressMeasured=${frame.suppressMeasured ? 1 : 0}`
276
+ + ` measuredVersion=${frame.measuredRowsVersion}`
277
+ + ` baseline(measured/estimate)=${frame.preHookMeasuredRows}/${frame.preHookEstimateRows}`
278
+ + ` harvest(measured/estimate)=${frame.postHarvestMeasuredRows}/${frame.postHarvestEstimateRows}`
279
+ + ` target=${frame.scrollTarget} following=${frame.following ? 1 : 0} anchor=${frame.anchor}`);
280
+ };
281
+
282
+ console.log(`# scrolled-up fenced-script frame repro columns=${COLUMNS} viewRows=${VIEW_ROWS} initialScroll=${INITIAL_SCROLL}`);
283
+ console.log(`# append-only characters=${SCRIPT.length} commits=${frames.length} dip-snap events=${dips.length}`);
284
+ for (const event of dips.slice(0, 4)) {
285
+ print('before ', event.previous);
286
+ print('transient', event.transient);
287
+ print('harvest ', event.corrected);
288
+ console.log('');
289
+ }
290
+ const suppressValues = new Set(frames.map((frame) => frame.suppressMeasured));
291
+ console.log(`# suppressMeasuredRowHeights values during repro: ${[...suppressValues].map(Number).join(',')}`);
292
+ if (dips.length > 0) {
293
+ throw new Error(`expected no visible-top dip/snap while fenced script streams; observed ${dips.length}`);
294
+ }
295
+ if (suppressValues.size !== 1 || !suppressValues.has(true)) {
296
+ throw new Error('repro unexpectedly toggled suppressMeasuredRowHeights');
297
+ }
298
+ console.log('tui-transcript-jitter-harness: ok');
299
+
300
+
301
+
302
+
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ import { build } from 'esbuild';
3
+ import { readFile, rm } from 'node:fs/promises';
4
+ import { dirname, join } from 'node:path';
5
+ import { fileURLToPath, pathToFileURL } from 'node:url';
6
+
7
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
8
+ const entry = join(ROOT, 'scripts', 'tui-transcript-jitter-harness-entry.jsx');
9
+ const outfile = join(ROOT, 'scripts', '.tui-transcript-jitter-harness.tmp.mjs');
10
+
11
+ const inkAlias = {
12
+ name: 'mixdog-ink-alias',
13
+ setup(ctx) {
14
+ ctx.onResolve({ filter: /^ink$/ }, () => ({
15
+ path: '../vendor/ink/build/index.js',
16
+ external: true,
17
+ }));
18
+ },
19
+ };
20
+
21
+ // Build-only probe: records the exact delta seen by the production helper
22
+ // without adding another stateful estimator call from the harness.
23
+ const growthProbe = {
24
+ name: 'streaming-tail-growth-probe',
25
+ setup(ctx) {
26
+ ctx.onLoad({ filter: /transcript-window\.mjs$/ }, async (args) => {
27
+ let source = (await readFile(args.path, 'utf8')).replace(/\r\n/g, '\n');
28
+ const target = ' return { tailRows: idEntry.rows, delta };';
29
+ const replacement = ` globalThis.__mixdogTailGrowthProbe = { live, baseline, delta, tailRows: idEntry.rows };\n${target}`;
30
+ if (!source.includes(target)) return null;
31
+ source = source.replace(target, replacement);
32
+ return { contents: source, loader: 'js' };
33
+ });
34
+ },
35
+ };
36
+
37
+ try {
38
+ await build({
39
+ entryPoints: [entry],
40
+ outfile,
41
+ bundle: true,
42
+ format: 'esm',
43
+ platform: 'node',
44
+ target: 'node22',
45
+ jsx: 'automatic',
46
+ packages: 'external',
47
+ plugins: [inkAlias, growthProbe],
48
+ banner: {
49
+ js: "import { createRequire as __mixdogCreateRequire } from 'node:module';\nconst require = __mixdogCreateRequire(import.meta.url);",
50
+ },
51
+ logLevel: 'silent',
52
+ });
53
+ await import(`${pathToFileURL(outfile).href}?run=${Date.now()}`);
54
+ } finally {
55
+ await rm(outfile, { force: true });
56
+ }
57
+
58
+
@@ -8,7 +8,7 @@ import { buildTranscriptRowIndexIncremental } from '../src/tui/app/transcript-wi
8
8
 
9
9
  const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
10
10
 
11
- function makeTurnHarness(ask) {
11
+ function makeTurnHarness(ask, stateOverrides = {}) {
12
12
  let seq = 0;
13
13
  let state = {
14
14
  items: [],
@@ -18,6 +18,7 @@ function makeTurnHarness(ask) {
18
18
  busy: false,
19
19
  spinner: null,
20
20
  thinking: null,
21
+ ...stateOverrides,
21
22
  };
22
23
  const itemIndexById = new Map();
23
24
  const set = (patch) => { state = { ...state, ...patch }; return true; };
@@ -43,6 +44,15 @@ function makeTurnHarness(ask) {
43
44
  const clearStreamingTail = (id = null) => {
44
45
  if (id == null || state.streamingTail?.id === id) set({ streamingTail: null });
45
46
  };
47
+ const replaceItems = (items, options = {}) => {
48
+ state = replaceEngineItemsState({
49
+ state,
50
+ items,
51
+ itemIndexById,
52
+ preserveStreamingTail: options.preserveStreamingTail === true,
53
+ });
54
+ return items;
55
+ };
46
56
  const bag = {
47
57
  runtime: { id: null, toolMode: 'auto', ask, abort: () => true },
48
58
  nextId: () => `id_${++seq}`,
@@ -55,6 +65,7 @@ function makeTurnHarness(ask) {
55
65
  set,
56
66
  pushItem,
57
67
  patchItem,
68
+ replaceItems,
58
69
  updateStreamingTail,
59
70
  settleStreamingTail,
60
71
  clearStreamingTail,
@@ -77,6 +88,50 @@ function makeTurnHarness(ask) {
77
88
  return { runTurn: createRunTurn(bag), getState: () => state };
78
89
  }
79
90
 
91
+ test('successful mid-turn compact trims history but preserves live turn references', async () => {
92
+ let preservedTail = false;
93
+ const harness = makeTurnHarness(async (_text, options) => {
94
+ options.onTextDelta('before compact\n');
95
+ await wait(30);
96
+ options.onCompactEvent({ status: 'compacted', trigger: 'reactive' });
97
+ preservedTail = harness.getState().streamingTail?.text === 'before compact\n';
98
+ options.onCompactEvent({ status: 'compacted', trigger: 'reactive' });
99
+ options.onTextDelta('after compact\n');
100
+ return { result: { content: 'before compact\nafter compact\n' }, session: { messages: [] } };
101
+ }, {
102
+ items: [
103
+ { id: 'old', kind: 'assistant', text: 'old history' },
104
+ { id: 'current-user', kind: 'user', text: 'go' },
105
+ ],
106
+ });
107
+
108
+ assert.equal(await harness.runTurn('go', { submittedIds: ['current-user'] }), 'done');
109
+ assert.equal(preservedTail, true);
110
+ assert.equal(harness.getState().items.some((item) => item.id === 'old'), false);
111
+ assert.equal(harness.getState().items.some((item) => item.id === 'current-user'), true);
112
+ assert.equal(
113
+ harness.getState().items.filter((item) => item.label === 'Compact complete (overflow recovery)').length,
114
+ 2,
115
+ );
116
+ assert.equal(
117
+ harness.getState().items.find((item) => item.kind === 'assistant')?.text,
118
+ 'before compact\nafter compact\n',
119
+ );
120
+ });
121
+
122
+ test('failed mid-turn compact leaves prior transcript items untouched', async () => {
123
+ const harness = makeTurnHarness(async (_text, options) => {
124
+ options.onCompactEvent({ status: 'failed' });
125
+ return { result: { content: '' }, session: { messages: [] } };
126
+ }, {
127
+ items: [{ id: 'old', kind: 'assistant', text: 'old history' }],
128
+ });
129
+
130
+ assert.equal(await harness.runTurn('go'), 'done');
131
+ assert.equal(harness.getState().items.some((item) => item.id === 'old'), true);
132
+ assert.equal(harness.getState().items.some((item) => item.label === 'Compact failed'), true);
133
+ });
134
+
80
135
  test('stream flush keeps settled items identity and finalize appends one assistant', async () => {
81
136
  let identityStable = false;
82
137
  const harness = makeTurnHarness(async (_text, options) => {
@@ -6,9 +6,11 @@ permission: read
6
6
 
7
7
  Independent regression/risk review agent.
8
8
 
9
- Review the approved intent, diff, and tests with independent judgment. Prioritize
10
- actionable correctness, regression, security, and verification risks; inspect
11
- affected boundaries. Do not reimplement the change or report non-risky nits.
9
+ Review the diff and tests with independent judgment. Prioritize actionable
10
+ correctness, regression, security, and verification risks; inspect affected
11
+ boundaries. Do not reimplement the change or report non-risky nits. Independently
12
+ evaluate the final deliverable with a critical lens, actively seeking errors,
13
+ unsupported assumptions, and counterexamples before confirming.
12
14
  Report findings first, severity-ordered, with one line per `file:line`. If clean,
13
15
  say so in one line and include only material residual risk.
14
16
 
@@ -8,12 +8,13 @@
8
8
  `Task:`. Never infer exactness from a task name, file count, or perceived
9
9
  difficulty.
10
10
  - All other fields are optional task-specific deltas: `Anchors:`
11
- `Allow/Forbid:` `Deliver:` `Verify:`. Omit any that add no information.
11
+ `Allow/Forbid:` `Deliver:`. Omit any that add no information.
12
12
  - Anchors: `file:line` + one-line conclusion; never log/code bodies. Specify
13
13
  outcome, not method unless required. `Deliver:` gives shape/size, never a long
14
- handoff. Referenced spec/test beats its summary.
15
- - Preserve the exact same `Task:` across Worker -> Debugger -> Reviewer. Never
16
- summarize, rewrite, or replace it when handing the scope to the next role.
14
+ handoff. The original request and official spec/test acceptance criteria beat
15
+ their brief summary.
16
+ - Each role independently constructs a role-appropriate, lossless `Task:` from
17
+ the original request and official spec/test acceptance criteria.
17
18
  - Full brief only for fresh spawn/`respawned: true`; live follow-ups are delta.
18
19
  Dead-tag send is cold: re-supply anchors.
19
20
  - Never `send` mid-run; batch one follow-up after completion; interrupt only to
@@ -24,6 +24,7 @@
24
24
  import { loadConfig } from '../config.mjs';
25
25
  import { resolveRuntimeSpec } from '../config.mjs';
26
26
  import { getHiddenAgent, resolveAgentSessionPermission } from '../internal-agents.mjs';
27
+ import { isKnownProvider } from '../../../../standalone/provider-admin.mjs';
27
28
  import { prepareAgentSession } from './session-builder.mjs';
28
29
  import {
29
30
  askSession,
@@ -163,9 +164,30 @@ export function resolveHiddenRoleSchemaAllowedTools(hidden) {
163
164
  * against config.presets for backward compatibility.
164
165
  * - null — unresolved.
165
166
  *
166
- * Hidden roles read their slot from `maint[maintKey || slot]`; the cycle1/2/3
167
- * agents share one knob via the `maintKey: 'memory'` override.
167
+ * Explore and memory hidden roles mirror public spawning precedence:
168
+ * `agents.<role>` (including legacy `agents.maintenance`) workflow route
169
+ * maintenance route → Main. The cycle1/2/3 agents share the memory knob via
170
+ * their `maintKey: 'memory'` override. Scheduler and webhook are unchanged.
168
171
  */
172
+ const DEFAULT_AGENT_ROUTE_PROVIDER = 'anthropic-oauth';
173
+
174
+ function normalizeMaintenanceCandidate(candidate, config) {
175
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return candidate || null;
176
+ const configuredProvider = String(config?.defaultProvider || '').trim();
177
+ const fallbackProvider = isKnownProvider(configuredProvider)
178
+ ? configuredProvider
179
+ : DEFAULT_AGENT_ROUTE_PROVIDER;
180
+ const provider = String(candidate.provider || fallbackProvider).trim();
181
+ const model = String(candidate.model || '').trim();
182
+ if (!provider || !model) return null;
183
+ return {
184
+ provider,
185
+ model,
186
+ effort: String(candidate.effort || '').trim() || undefined,
187
+ fast: candidate.fast === true,
188
+ };
189
+ }
190
+
169
191
  export function resolveMaintenanceRoute({ preset, optsPreset, agent, config: cfgIn = null }) {
170
192
  if (preset) return preset;
171
193
  if (optsPreset) return optsPreset;
@@ -175,7 +197,22 @@ export function resolveMaintenanceRoute({ preset, optsPreset, agent, config: cfg
175
197
  try {
176
198
  const config = cfgIn || loadConfig({ secrets: false });
177
199
  const maint = config?.maintenance || {};
178
- return maint[hidden.maintKey || hidden.slot] ?? null;
200
+ const key = hidden.maintKey || hidden.slot;
201
+ const role = key === 'explore' ? 'explore' : (key === 'memory' ? 'maintainer' : '');
202
+ const workflowSlot = key === 'explore' ? 'explorer' : (key === 'memory' ? 'memory' : '');
203
+ if (!role) return maint[key] ?? null;
204
+ const candidates = [
205
+ role ? config?.agents?.[role] : null,
206
+ key === 'memory' ? config?.agents?.maintenance : null,
207
+ workflowSlot ? config?.workflowRoutes?.[workflowSlot] : null,
208
+ maint[key],
209
+ role ? config?.default : null,
210
+ ];
211
+ for (const candidate of candidates) {
212
+ const route = normalizeMaintenanceCandidate(candidate, config);
213
+ if (route) return route;
214
+ }
215
+ return null;
179
216
  } catch { return null; }
180
217
  }
181
218
  return null;
@@ -7,6 +7,20 @@ import {
7
7
  hasGrokOAuthCredentials,
8
8
  } from './providers/oauth-credential-probes.mjs';
9
9
 
10
+ // Keep config loading free of standalone provider-admin and its provider
11
+ // runtimes. These identifiers mirror that module's static catalog, assembled
12
+ // solely from the lightweight config/provider constants already imported here.
13
+ const CONFIG_PROVIDER_IDS = new Set([
14
+ ...Object.keys(AGENT_PROVIDER_ENV),
15
+ ...Object.keys(OPENAI_COMPAT_PRESETS),
16
+ 'openai-oauth',
17
+ 'anthropic-oauth',
18
+ 'grok-oauth',
19
+ ]);
20
+ function isConfiguredProviderId(provider) {
21
+ return CONFIG_PROVIDER_IDS.has(String(provider || '').trim());
22
+ }
23
+
10
24
  // Thin wrapper around resolvePluginData so callers in this orchestrator tree
11
25
  // can import a single helper without reaching into shared/.
12
26
  export function getPluginData() {
@@ -18,15 +32,12 @@ export function getPluginData() {
18
32
  // Canonical maintenance defaults. Single source of truth — imported by
19
33
  // llm/index.mjs and setup-server.mjs so UI/runtime cannot drift from config.
20
34
  //
21
- // Every hidden maintenance slot carries a CONCRETE preset here, so
22
- // resolvePresetName() (agent-dispatch) always resolves a model directly from
23
- // `maint[slot]` no shared `defaultPreset` fallback is needed or used.
24
- // Memory cycles + Lead helper fan-out (explore/cycle1/cycle2/cycle3) and
25
- // entry-driven dispatch (scheduler/webhook) all default to `haiku`. The three
26
- // memory cycles (chunker / re-scorer / core reviewer) share ONE `memory`
27
- // preset knob — the cycle agents stay separate (cycle1/2/3-agent, distinct
28
- // slots and invokedBy) but resolve their model from `maint.memory` via the
29
- // `maintKey` override on their hidden-role entries.
35
+ // Explore and Maintainer start without a route so they dynamically inherit the
36
+ // Main route. Explicit `maintenance.explore` / `maintenance.memory` choices
37
+ // remain supported. The memory cycles (chunker / re-scorer / core reviewer)
38
+ // share the optional `memory` preset knob the cycle agents stay separate
39
+ // (cycle1/2/3-agent, distinct slots and invokedBy) but resolve their model from
40
+ // `maint.memory` via the `maintKey` override on their hidden-role entries.
30
41
  // scheduler/webhook still let a per-entry config.json model win first (the
31
42
  // caller passes it explicitly via opts.preset); the haiku default below only
32
43
  // applies when an entry omits its own model.
@@ -129,8 +140,6 @@ const _HAIKU_ROUTE = Object.freeze({
129
140
  model: resolveAnthropicFamilyModel('haiku'),
130
141
  });
131
142
  export const DEFAULT_MAINTENANCE = Object.freeze({
132
- explore: { ..._HAIKU_ROUTE },
133
- memory: { ..._HAIKU_ROUTE },
134
143
  scheduler: { ..._HAIKU_ROUTE },
135
144
  webhook: { ..._HAIKU_ROUTE },
136
145
  });
@@ -201,12 +210,18 @@ function normalizeSearchRoute(route) {
201
210
  // is resolved against the config.presets array (the legacy lookup) and rewritten
202
211
  // to a route. Unresolvable strings are dropped so the DEFAULT_MAINTENANCE route
203
212
  // fills the slot. `presets` is the normalized preset array for legacy lookup.
204
- function migrateMaintenanceRoutes(rawMaint, presets) {
213
+ function migrateMaintenanceRoutes(rawMaint, presets, defaultProvider) {
205
214
  const out = {};
206
215
  const list = Array.isArray(presets) ? presets : [];
216
+ const configuredProvider = String(defaultProvider || '').trim();
217
+ const fallbackProvider = isConfiguredProviderId(configuredProvider)
218
+ ? configuredProvider
219
+ : 'anthropic-oauth';
207
220
  for (const [slot, value] of Object.entries(rawMaint || {})) {
208
221
  if (value && typeof value === 'object' && !Array.isArray(value)) {
209
- const provider = normalizeAgentProviderId(value.provider);
222
+ const provider = normalizeAgentProviderId(
223
+ value.provider || ((slot === 'explore' || slot === 'memory') ? fallbackProvider : ''),
224
+ );
210
225
  const model = String(value.model || '').trim();
211
226
  if (provider && model) {
212
227
  const route = { provider, model };
@@ -437,7 +452,7 @@ export function loadConfig(options = {}) {
437
452
  delete workflowRoutes.search;
438
453
  // Migrate legacy preset-name maintenance slots to direct routes,
439
454
  // then overlay onto the route-shaped defaults.
440
- const migratedMaint = migrateMaintenanceRoutes(rawMaint, normalizedPresets);
455
+ const migratedMaint = migrateMaintenanceRoutes(rawMaint, normalizedPresets, raw.defaultProvider);
441
456
  return {
442
457
  providers: mergedProviders,
443
458
  mcpServers,
@@ -15,7 +15,8 @@
15
15
  * no-tool role guard) — not a permission check. No gating is needed here.
16
16
  */
17
17
 
18
- import { isToolEnvelope, makeToolEnvelope } from './session/tool-envelope.mjs';
18
+ import { isToolEnvelope, makeToolEnvelope, normalizeToolEnvelope } from './session/tool-envelope.mjs';
19
+ import { classifyResultKind } from './session/result-classification.mjs';
19
20
 
20
21
  let _executor = null;
21
22
  let _tools = [];
@@ -62,12 +63,24 @@ function _normalize(result) {
62
63
  // JSON.stringify below and the loop would see the stringified envelope as
63
64
  // the tool_result (body inlined + duplicated, no newMessages).
64
65
  if (isToolEnvelope(result)) {
65
- return makeToolEnvelope(_normalize(result.result), result.newMessages);
66
+ const normalized = normalizeToolEnvelope(_normalize(result.result));
67
+ return makeToolEnvelope(normalized.result, result.newMessages, {
68
+ explicitSuccess: normalized.explicitSuccess || result.explicitSuccess === true,
69
+ });
66
70
  }
67
71
  if (result && typeof result === 'object' && Array.isArray(result.content)) {
68
- return result.content
72
+ const text = result.content
69
73
  .map((c) => (c?.type === 'text' ? c.text || '' : JSON.stringify(c)))
70
74
  .join('\n');
75
+ // Preserve MCP-style handler outcome metadata across the object→string
76
+ // boundary. Explicit failures retain the canonical Error: convention;
77
+ // explicit successes use a transient envelope so legitimate output
78
+ // beginning with Error: is not mistaken for a failed execution.
79
+ if (result.isError === true) return !text.startsWith('Error:') ? `Error: ${text}` : text;
80
+ if (result.isError === false && classifyResultKind(text) === 'error') {
81
+ return makeToolEnvelope(text, [], { explicitSuccess: true });
82
+ }
83
+ return text;
71
84
  }
72
85
  if (typeof result === 'string') return result;
73
86
  return JSON.stringify(result);