mixdog 0.9.41 → 0.9.43

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 (48) hide show
  1. package/package.json +1 -1
  2. package/scripts/agent-tag-reuse-smoke.mjs +124 -10
  3. package/scripts/anthropic-oauth-refresh-race-test.mjs +59 -0
  4. package/scripts/arg-guard-test.mjs +16 -0
  5. package/scripts/compact-pressure-test.mjs +256 -1
  6. package/scripts/internal-comms-bench.mjs +0 -1
  7. package/scripts/internal-comms-smoke.mjs +14 -32
  8. package/scripts/internal-tools-normalization-test.mjs +52 -0
  9. package/scripts/max-output-recovery-test.mjs +49 -0
  10. package/scripts/mcp-client-normalization-test.mjs +45 -0
  11. package/scripts/provider-toolcall-test.mjs +23 -0
  12. package/scripts/result-classification-test.mjs +75 -0
  13. package/scripts/smoke.mjs +1 -1
  14. package/scripts/submit-commandbusy-race-test.mjs +99 -7
  15. package/scripts/tui-transcript-perf-test.mjs +56 -1
  16. package/src/agents/reviewer/AGENT.md +4 -0
  17. package/src/runtime/agent/orchestrator/internal-tools.mjs +16 -3
  18. package/src/runtime/agent/orchestrator/mcp/client.mjs +17 -1
  19. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +49 -6
  20. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +14 -0
  21. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +59 -2
  22. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +18 -0
  23. package/src/runtime/agent/orchestrator/session/context-utils.mjs +140 -81
  24. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +23 -5
  25. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +3 -0
  26. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +12 -3
  27. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +4 -2
  28. package/src/runtime/agent/orchestrator/session/result-classification.mjs +4 -1
  29. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +7 -2
  30. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +8 -6
  31. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +15 -3
  32. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +1 -1
  33. package/src/runtime/memory/tool-defs.mjs +4 -3
  34. package/src/runtime/shared/tool-surface.mjs +1 -2
  35. package/src/session-runtime/context-status.mjs +8 -2
  36. package/src/standalone/agent-tool/render.mjs +2 -0
  37. package/src/standalone/agent-tool.mjs +202 -22
  38. package/src/tui/components/StatusLine.jsx +4 -26
  39. package/src/tui/dist/index.mjs +46 -31
  40. package/src/tui/engine/session-api.mjs +3 -0
  41. package/src/tui/engine/session-flow.mjs +19 -8
  42. package/src/tui/engine/turn.mjs +24 -2
  43. package/src/tui/engine.mjs +0 -1
  44. package/src/ui/statusline-agents.mjs +0 -8
  45. package/src/ui/statusline.mjs +2 -4
  46. package/src/workflows/default/WORKFLOW.md +29 -20
  47. package/src/workflows/solo-review/WORKFLOW.md +47 -0
  48. package/src/workflows/bench/WORKFLOW.md +0 -60
@@ -57,7 +57,6 @@ 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');
@@ -121,21 +120,6 @@ requireAll(workflow, 'Default approval', [
121
120
  /initial\/additional\/changed requests reset planning/, /scope change needs a revised plan and fresh approval/,
122
121
  /no edits, state mutation, or delegation/,
123
122
  ]);
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
123
  const ROUTING_REVIEW_POLICY = [
140
124
  /lead-direct work is allowed only for pure read\/analysis, git\/configuration/,
141
125
  /user explicitly supplies both the exact target and exact replacement\/output/,
@@ -154,13 +138,11 @@ const ROUTING_REVIEW_POLICY = [
154
138
  /loop fix -> re-verify \(same reviewer \+ lead re-check\) until clean/,
155
139
  /exempt mutations still require shell self-verification/,
156
140
  ];
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
- }
141
+ requireAll(workflow, 'Default routing/review policy', ROUTING_REVIEW_POLICY);
142
+ assert(
143
+ !/(high clarity|low structural complexity|immediate 1-step|genuinely simple)/i.test(workflow),
144
+ 'Default: heuristic routing/review language must not return',
145
+ );
164
146
  requireAll(workflow, 'Default lifecycle', [
165
147
  /draft before any implementation/, /all ready scopes in one turn, with no count cap/,
166
148
  /real dependency, overlapping write, or inseparable coupling/,
@@ -181,19 +163,15 @@ requireAll(solo, 'Solo lifecycle', [
181
163
  /interim updates are in-progress, never conclusions/,
182
164
  /issue-free feedback/,
183
165
  ]);
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
166
  requireAll(leadTool, 'Lead tools', [
191
167
  /write-role agents self-verify/, /cross-scope verification.*benches.*all git/,
192
- /workflow permits delegation/, /no-delegation workflow.*controls/,
193
168
  ]);
194
169
  requireAll(general, 'General safety', [
195
170
  /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/,
171
+ ]);
172
+ requireAll(workflow, 'Default ship safety', [
173
+ /build\/deploy\/commit\/push require an explicit user request after issue-free feedback/,
174
+ /implementation approval alone is insufficient/,
197
175
  ]);
198
176
  requireAll(skip, 'Silent skip', [
199
177
  /webhook-handler/, /scheduler-task/, /label-only, duplicate\/dedup, no action needed\/report/,
@@ -234,7 +212,11 @@ Review the approved intent, diff, and tests with independent judgment. Prioritiz
234
212
  actionable correctness, regression, security, and verification risks; inspect
235
213
  affected boundaries. Do not reimplement the change or report non-risky nits.
236
214
  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.`,
215
+ say so in one line and include only material residual risk.
216
+
217
+ When the work comes with stated criteria or reference material for judging
218
+ it, verify against those as given — substituting your own interpretation or
219
+ a self-built check is a verification risk to report.`,
238
220
  'debugger/AGENT.md': `# Debugger
239
221
  Root-cause analysis agent.
240
222
 
@@ -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
+ });
@@ -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
+ });
@@ -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]() {
@@ -0,0 +1,75 @@
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
+ import { normalizeToolEnvelope } from '../src/runtime/agent/orchestrator/session/tool-envelope.mjs';
10
+ import { executeTool } from '../src/runtime/agent/orchestrator/session/loop/tool-exec.mjs';
11
+
12
+ async function classifyHandlerResult(handlerResult) {
13
+ setInternalToolsProvider({
14
+ tools: [{ name: 'result_classification_test' }],
15
+ executor: async () => handlerResult,
16
+ });
17
+ const returned = await executeInternalTool('result_classification_test', {});
18
+ const normalized = normalizeToolEnvelope(returned);
19
+ return classifyResultKind(normalized.result, normalized.explicitSuccess);
20
+ }
21
+
22
+ test('explicit success suppresses Error: text-prefix classification', async () => {
23
+ assert.equal(await classifyHandlerResult({
24
+ content: [{ type: 'text', text: 'Error: quoted log output' }],
25
+ isError: false,
26
+ }), 'normal');
27
+ });
28
+
29
+ test('explicit isError:true remains an error', async () => {
30
+ assert.equal(await classifyHandlerResult({
31
+ content: [{ type: 'text', text: 'handler failed' }],
32
+ isError: true,
33
+ }), 'error');
34
+ });
35
+
36
+ test('flag-less Error: text remains an error', async () => {
37
+ assert.equal(await classifyHandlerResult({
38
+ content: [{ type: 'text', text: 'Error: prefix-only failure' }],
39
+ }), 'error');
40
+ });
41
+
42
+ test('flag-less normal text remains normal', async () => {
43
+ assert.equal(await classifyHandlerResult({
44
+ content: [{ type: 'text', text: 'ordinary output' }],
45
+ }), 'normal');
46
+ });
47
+
48
+ test('afterToolHook receives unwrapped explicit-success text', async () => {
49
+ setInternalToolsProvider({
50
+ tools: [{ name: 'result_classification_test' }],
51
+ executor: async () => ({
52
+ content: [{ type: 'text', text: 'Error: quoted hook payload' }],
53
+ isError: false,
54
+ }),
55
+ });
56
+ let hookResult;
57
+ const returned = await executeTool(
58
+ 'result_classification_test',
59
+ {},
60
+ process.cwd(),
61
+ 'result-classification-test-session',
62
+ {
63
+ afterToolHook: async (input) => {
64
+ hookResult = input.result;
65
+ },
66
+ },
67
+ { toolCallId: 'result-classification-hook-call' },
68
+ );
69
+ assert.equal(hookResult, 'Error: quoted hook payload');
70
+ assert.equal(typeof hookResult, 'string');
71
+
72
+ const normalized = normalizeToolEnvelope(returned);
73
+ assert.equal(normalized.result, hookResult);
74
+ assert.equal(normalized.explicitSuccess, true);
75
+ });
package/scripts/smoke.mjs CHANGED
@@ -95,7 +95,7 @@ runNode(['--input-type=module', '-e', `
95
95
  stats: { currentContextTokens: 0 },
96
96
  agentJobs: [{ task_id: 'task_statusline_smoke', status: 'running', tag: 'bench-agent', startedAt: new Date().toISOString() }],
97
97
  });
98
- if (!line.includes('Running') || !line.includes('bench-agent')) throw new Error('statusline must render live agent task state: ' + JSON.stringify(line));
98
+ if (!line.includes('Running 1 Agent') || line.includes('bench-agent')) throw new Error('statusline must render live agent count without task tags: ' + JSON.stringify(line));
99
99
  `], 'statusline live agent task smoke', { env: isolatedStatuslineEnv });
100
100
 
101
101
  runNode(['--input-type=module', '-e', `
@@ -7,12 +7,39 @@ import { createEngineApiA } from '../src/tui/engine/session-api.mjs';
7
7
  // command (commandBusy) or an auto-clear. Wires the real session-flow queue
8
8
  // (enqueue/drain) + the real submit() against a minimal bag whose set() mirrors
9
9
  // engine.mjs's commandBusy-release drain kick.
10
- function makeEngine({ autoClearBeforeSubmit } = {}) {
10
+ function makeEngine({
11
+ autoClearBeforeSubmit,
12
+ autoClearEnabled = false,
13
+ runtimeClear = async () => true,
14
+ } = {}) {
11
15
  let seq = 0;
12
16
  const executed = [];
13
- let state = { queued: [], busy: false, commandBusy: false };
17
+ const providerRequests = [];
18
+ let resolveProviderDispatch;
19
+ const providerDispatch = new Promise((resolve) => { resolveProviderDispatch = resolve; });
20
+ let state = {
21
+ items: [{ kind: 'user', id: 'existing', text: 'existing prompt' }],
22
+ queued: [],
23
+ busy: false,
24
+ commandBusy: false,
25
+ };
14
26
  const bag = {
15
- runtime: { id: null, consumePendingSessionReset: () => null },
27
+ runtime: {
28
+ id: 'session_1',
29
+ session: {
30
+ id: 'session_1',
31
+ messages: [{ role: 'user', content: 'existing prompt' }],
32
+ lastUsedAt: Date.now() - 1000,
33
+ },
34
+ getCompactionSettings: () => ({}),
35
+ clear: runtimeClear,
36
+ consumePendingSessionReset: () => null,
37
+ ask: async (text) => {
38
+ providerRequests.push(text);
39
+ resolveProviderDispatch(text);
40
+ return { result: { content: 'ok' }, session: bag.runtime.session };
41
+ },
42
+ },
16
43
  nextId: () => `id_${++seq}`,
17
44
  tuiDebug: () => {},
18
45
  flags: {},
@@ -30,12 +57,15 @@ function makeEngine({ autoClearBeforeSubmit } = {}) {
30
57
  if (released) queueMicrotask(() => { void bag.drain?.(); });
31
58
  return true;
32
59
  },
33
- pushItem: () => {},
60
+ pushItem: (item) => { state = { ...state, items: [...state.items, item] }; },
34
61
  patchItem: () => {},
35
62
  replaceItems: (x) => x,
36
63
  pushNotice: () => {},
37
- pushUserOrSyntheticItem: (text) => { executed.push(text); },
38
- autoClearState: () => ({ enabled: false }),
64
+ pushUserOrSyntheticItem: (text, id) => {
65
+ executed.push(text);
66
+ state = { ...state, items: [...state.items, { kind: 'user', id, text }] };
67
+ },
68
+ autoClearState: () => ({ enabled: autoClearEnabled, idleMs: 0, minContextPercent: 0 }),
39
69
  agentStatusState: () => ({}),
40
70
  routeState: () => ({}),
41
71
  syncContextStats: () => {},
@@ -44,7 +74,10 @@ function makeEngine({ autoClearBeforeSubmit } = {}) {
44
74
  requeueEntriesFront: () => {},
45
75
  resetStatsAndSyncContext: () => {},
46
76
  flushDeferredExecutionPendingResumeKick: () => {},
47
- runTurn: async () => 'ok',
77
+ runTurn: async (text) => {
78
+ await bag.runtime.ask(text);
79
+ return 'ok';
80
+ },
48
81
  };
49
82
  Object.assign(bag, createSessionFlow(bag));
50
83
  if (autoClearBeforeSubmit) bag.autoClearBeforeSubmit = autoClearBeforeSubmit;
@@ -53,6 +86,14 @@ function makeEngine({ autoClearBeforeSubmit } = {}) {
53
86
  api,
54
87
  bag,
55
88
  getExecuted: () => executed,
89
+ getProviderRequests: () => providerRequests,
90
+ waitForProviderDispatch: (timeoutMs = 250) => new Promise((resolve, reject) => {
91
+ const timer = setTimeout(() => reject(new Error(`provider dispatch timed out after ${timeoutMs}ms`)), timeoutMs);
92
+ providerDispatch.then((text) => {
93
+ clearTimeout(timer);
94
+ resolve(text);
95
+ }, reject);
96
+ }),
56
97
  getState: () => bag.getState(),
57
98
  mutateState: (mutator) => { mutator(state); },
58
99
  };
@@ -107,6 +148,57 @@ test('submit still enqueues when autoClearBeforeSubmit rejects', async () => {
107
148
  assert.deepEqual(getExecuted(), ['survives rejection'], 'rejected auto-clear must not lose the prompt');
108
149
  });
109
150
 
151
+ test('runtime clear false skips auto-clear and sends the first post-failure prompt to the provider', async () => {
152
+ const {
153
+ api, bag, getExecuted, getProviderRequests, getState,
154
+ } = makeEngine({ autoClearEnabled: true, runtimeClear: async () => false });
155
+
156
+ assert.equal(api.submit('first after failed auto-clear'), true);
157
+ await tick(); await tick();
158
+
159
+ assert.deepEqual(getExecuted(), ['first after failed auto-clear'], 'user card remains visible');
160
+ assert.deepEqual(getProviderRequests(), ['first after failed auto-clear'], 'first post-failure input reaches provider');
161
+ assert.equal(getState().items.some((item) => item.label === 'Auto-clear skipped'), true);
162
+ assert.equal(getState().items.some((item) => item.label === 'Auto-clear complete'), false);
163
+ assert.equal(getState().items.some((item) => item.id === 'existing'), true, 'failed clear preserves the existing session UI');
164
+ assert.equal(bag.runtime.session.messages[0].content, 'existing prompt', 'failed clear preserves the runtime session');
165
+ });
166
+
167
+ test('late runtime clear false keeps the UI skipped and sends the first post-timeout prompt', async () => {
168
+ let resolveClear;
169
+ let harness;
170
+ const autoClearBeforeSubmit = () => harness.bag.performSessionClear({
171
+ verb: 'Auto-clearing idle conversation',
172
+ doneLabel: 'Auto-clear complete',
173
+ skipLabel: 'Auto-clear skipped',
174
+ surface: 'auto-clear',
175
+ useCompaction: true,
176
+ compactTimeoutMs: 5,
177
+ });
178
+ harness = makeEngine({
179
+ autoClearBeforeSubmit,
180
+ runtimeClear: () => new Promise((resolve) => { resolveClear = resolve; }),
181
+ });
182
+ harness.bag.runtime.getCompactionSettings = () => ({ compactType: 'summary' });
183
+
184
+ assert.equal(harness.api.submit('first after timed-out auto-clear'), true);
185
+ await harness.waitForProviderDispatch();
186
+ assert.equal(typeof resolveClear, 'function', 'compacting clear started');
187
+ assert.deepEqual(
188
+ harness.getProviderRequests(),
189
+ ['first after timed-out auto-clear'],
190
+ 'first post-timeout input reaches provider before late clear settles',
191
+ );
192
+
193
+ resolveClear(false);
194
+ await tick(); await tick();
195
+
196
+ assert.equal(harness.getState().items.some((item) => item.label === 'Auto-clear skipped'), true);
197
+ assert.equal(harness.getState().items.some((item) => item.label === 'Auto-clear complete'), false);
198
+ assert.equal(harness.getState().items.some((item) => item.id === 'existing'), true, 'late false preserves the existing session UI');
199
+ assert.equal(harness.bag.runtime.session.messages[0].content, 'existing prompt', 'late false preserves the runtime session');
200
+ });
201
+
110
202
  test('empty and whitespace submits are still rejected', () => {
111
203
  const { api, bag } = makeEngine();
112
204
  assert.equal(api.submit(' '), false);
@@ -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) => {
@@ -9,6 +9,10 @@ Independent regression/risk review agent.
9
9
  Review the approved intent, diff, and tests with independent judgment. Prioritize
10
10
  actionable correctness, regression, security, and verification risks; inspect
11
11
  affected boundaries. Do not reimplement the change or report non-risky nits.
12
+ Treat implementer and Lead reports as claims to test rather than evidence;
13
+ independently evaluate the final deliverable against the original request and
14
+ available evidence with a critical lens, actively seeking errors, unsupported
15
+ assumptions, and counterexamples before confirming.
12
16
  Report findings first, severity-ordered, with one line per `file:line`. If clean,
13
17
  say so in one line and include only material residual risk.
14
18
 
@@ -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);