mixdog 0.9.39 → 0.9.41

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 (59) hide show
  1. package/package.json +2 -1
  2. package/scripts/agent-terminal-reap-test.mjs +6 -6
  3. package/scripts/anthropic-oauth-refresh-race-test.mjs +338 -0
  4. package/scripts/compact-pressure-test.mjs +128 -0
  5. package/scripts/compact-trigger-migration-smoke.mjs +8 -8
  6. package/scripts/execution-resume-esc-integration-test.mjs +4 -2
  7. package/scripts/internal-comms-smoke.mjs +66 -25
  8. package/scripts/max-output-recovery-persist-test.mjs +81 -0
  9. package/scripts/max-output-recovery-test.mjs +222 -0
  10. package/scripts/provider-toolcall-test.mjs +32 -0
  11. package/scripts/steering-drain-buckets-test.mjs +140 -5
  12. package/scripts/tui-transcript-perf-test.mjs +279 -0
  13. package/src/agents/reviewer/AGENT.md +4 -0
  14. package/src/rules/lead/lead-brief.md +11 -3
  15. package/src/rules/lead/lead-tool.md +0 -2
  16. package/src/rules/shared/01-tool.md +4 -0
  17. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +20 -2
  18. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +7 -0
  19. package/src/runtime/agent/orchestrator/context/collect.mjs +7 -3
  20. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +144 -7
  21. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +15 -2
  22. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +2 -2
  23. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +57 -25
  24. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +54 -12
  25. package/src/runtime/agent/orchestrator/session/context-utils.mjs +4 -3
  26. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +106 -8
  27. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +12 -1
  28. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +22 -7
  29. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +26 -1
  30. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +17 -1
  31. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +26 -0
  32. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +31 -4
  33. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +2 -0
  34. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +11 -2
  35. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +45 -0
  36. package/src/runtime/agent/orchestrator/session/store.mjs +9 -1
  37. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +43 -1
  38. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +49 -34
  39. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
  40. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -10
  41. package/src/runtime/shared/buffered-appender.mjs +13 -2
  42. package/src/session-runtime/config-helpers.mjs +6 -6
  43. package/src/session-runtime/lifecycle-api.mjs +4 -0
  44. package/src/session-runtime/runtime-core.mjs +1 -0
  45. package/src/session-runtime/session-turn-api.mjs +12 -0
  46. package/src/standalone/agent-tool.mjs +8 -1
  47. package/src/tui/App.jsx +2 -1
  48. package/src/tui/app/transcript-window.mjs +9 -10
  49. package/src/tui/app/use-transcript-window.mjs +38 -56
  50. package/src/tui/dist/index.mjs +354 -163
  51. package/src/tui/engine/agent-job-feed.mjs +76 -6
  52. package/src/tui/engine/session-api.mjs +7 -1
  53. package/src/tui/engine/session-flow.mjs +3 -1
  54. package/src/tui/engine/tool-card-results.mjs +10 -5
  55. package/src/tui/engine/turn.mjs +96 -36
  56. package/src/tui/engine.mjs +136 -37
  57. package/src/tui/index.jsx +2 -2
  58. package/src/workflows/bench/WORKFLOW.md +51 -27
  59. package/src/workflows/default/WORKFLOW.md +29 -24
@@ -63,14 +63,23 @@ const leadTool = readSrc('rules', 'lead', 'lead-tool.md');
63
63
  const core = readSrc('rules', 'agent', '00-core.md');
64
64
  const common = readSrc('rules', 'agent', '00-common.md');
65
65
  const skip = readSrc('rules', 'agent', '20-skip-protocol.md');
66
- const BRIEF_FIELDS = ['Goal:', 'Anchors:', 'Allow/Forbid:', 'Deliver:', 'Verify:'];
66
+ const OPTIONAL_BRIEF_FIELDS = ['Anchors:', 'Allow/Forbid:', 'Deliver:', 'Verify:'];
67
67
  const TOKEN_PRINCIPLE = /minimum (?:characters|chars), maximum (?:information|info)/i;
68
68
  function requireAll(text, label, patterns) {
69
69
  for (const pattern of patterns) assert(pattern.test(normalize(text).toLowerCase()), `${label}: missing ${pattern}`);
70
70
  }
71
71
  assert(TOKEN_PRINCIPLE.test(normalize(leadBrief)), 'lead-brief.md: brief must state min-char/max-info principle');
72
- for (const field of BRIEF_FIELDS) assert(leadBrief.includes(field), `lead-brief.md: brief missing labeled field ${field}`);
73
- assert(leadBrief.includes('Stop:'), 'lead-brief.md: brief must add Stop: for heavy-worker bound');
72
+ assert(leadBrief.includes('Task:'), 'lead-brief.md: brief must require labeled field Task:');
73
+ assert(!leadBrief.includes('Goal:'), 'lead-brief.md: Goal: must be replaced by Task:');
74
+ for (const field of OPTIONAL_BRIEF_FIELDS) assert(leadBrief.includes(field), `lead-brief.md: brief missing optional labeled field ${field}`);
75
+ requireAll(leadBrief, 'Lead brief task', [
76
+ /`task:` is mandatory and lossless/, /intent/, /required outcomes/, /negative outcomes/,
77
+ /completion\/stop boundary/, /all other fields are optional task-specific deltas/,
78
+ /preserve user-supplied exact targets and exact replacements\/outputs in `task:`/,
79
+ /never infer exactness from a task name, file count, or perceived difficulty/,
80
+ /exact same `task:` across worker -> debugger -> reviewer/,
81
+ /never summarize, rewrite, or replace it/,
82
+ ]);
74
83
  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');
75
84
  assert(/spec\/test beats its summary/i.test(normalize(leadBrief)), 'lead-brief.md: spec/test must beat summary');
76
85
  requireAll(leadBrief, 'Lead brief lifecycle', [
@@ -80,7 +89,7 @@ requireAll(leadBrief, 'Lead brief lifecycle', [
80
89
  /agent communication is english/,
81
90
  ]);
82
91
  assert(/lead brief contract/i.test(normalize(workflow)), 'WORKFLOW.md: must defer to the lead brief contract');
83
- assert(!BRIEF_FIELDS.every((field) => workflow.includes(field)), 'WORKFLOW.md: must not duplicate the full brief field list');
92
+ assert(!OPTIONAL_BRIEF_FIELDS.every((field) => workflow.includes(field)), 'WORKFLOW.md: must not duplicate the optional brief field list');
84
93
 
85
94
  assert(/fragments/i.test(core), '00-core: handoff must require fragments');
86
95
  assert(/file:line/i.test(core), '00-core: handoff must anchor evidence to file:line');
@@ -112,19 +121,52 @@ requireAll(workflow, 'Default approval', [
112
121
  /initial\/additional\/changed requests reset planning/, /scope change needs a revised plan and fresh approval/,
113
122
  /no edits, state mutation, or delegation/,
114
123
  ]);
115
- requireAll(workflow, 'Default routing', [
116
- /delegate by default/, /only coordinates, does git, or an obvious 1-edit\/ 1-check change/,
117
- /implementation\/research\/debugging to its matching agent/, /indicators, not automatic categories/,
118
- /worker: bounded established path, low local risk\/coupling\/verification, clear local check/,
119
- /heavy worker: high risk\/coupling\/verification \(including any high-risk scope\), or coupled staged work needing coordinated verification/,
120
- /reviewer verifies an implementation/, /debugger handles requested debugging or root cause after a failed fix/,
121
- ]);
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
+ const ROUTING_REVIEW_POLICY = [
140
+ /lead-direct work is allowed only for pure read\/analysis, git\/configuration/,
141
+ /user explicitly supplies both the exact target and exact replacement\/output/,
142
+ /never infer an exemption from a task name, file count, or perceived difficulty/,
143
+ /every other implementation, reverse engineering, debugging application, or artifact generation delegates to the matching agent/,
144
+ /debugger owns diagnosis and reverse engineering/,
145
+ /worker applies an established bounded change or fully specified artifact/,
146
+ /heavy worker owns implementation that must establish the change through investigation or staged delivery/,
147
+ /applying a debugger result is implementation, not diagnosis/,
148
+ /review is exempt only for pure read\/analysis with no edit, artifact, or state mutation; git\/configuration; or a request where the user explicitly supplies both the exact target and exact replacement\/output\. every non-exempt mutation or artifact/,
149
+ /every non-exempt mutation or artifact/,
150
+ /worker, heavy worker, debugger, or lead/,
151
+ /one reviewer .*lead integration\/cross-scope verification in parallel/,
152
+ /debugger analysis cannot substitute for implementation review/,
153
+ /applying a debugger result triggers the same reviewer \+ lead verification/,
154
+ /loop fix -> re-verify \(same reviewer \+ lead re-check\) until clean/,
155
+ /exempt mutations still require shell self-verification/,
156
+ ];
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
+ }
122
164
  requireAll(workflow, 'Default lifecycle', [
123
- /draft before any implementation/, /parallel gain exceeds coordination\/merge cost/,
124
- /all in one turn, with no count cap/, /real dependency, overlapping write, or inseparable coupling/,
125
- /after async spawn, end the turn/, /every delegated implementation gets one reviewer/,
126
- /lead integration\/cross-scope verification in parallel/, /high-risk scopes add distinct lenses/,
127
- /original live session/, /loop fix -> re-verify \(same reviewer \+ lead re-check\) until clean/,
165
+ /draft before any implementation/, /all ready scopes in one turn, with no count cap/,
166
+ /real dependency, overlapping write, or inseparable coupling/,
167
+ /after async spawn, end the turn/,
168
+ /high-risk scopes add distinct lenses/,
169
+ /original live session/,
128
170
  /bug surviving 2\+ fix cycles/, /agent reports relay.*as in-progress, never conclusions/,
129
171
  /final \(not interim\) report/, /never forward raw agent output/,
130
172
  /explicit user request after issue-free feedback/, /outcome\/direction change, pause and re-consult/,
@@ -137,16 +179,13 @@ requireAll(solo, 'Solo lifecycle', [
137
179
  /scope change needs a revised plan and fresh approval/,
138
180
  /checks\/fixes directly until clean or reports a blocker/, /final \(not interim\) report/,
139
181
  /interim updates are in-progress, never conclusions/,
140
- /issue-free user feedback/,
182
+ /issue-free feedback/,
141
183
  ]);
142
184
  requireAll(bench, 'Bench lifecycle', [
143
- /never wait for approval or ask questions/, /verified complete or provably blocked/,
144
- /maximum independent scopes/, /spawning every scope in one turn/,
145
- /build\/test-green gate/, /no polling, guessing, or dependent work/,
146
- /one reviewer per implementation scope/, /never deferred\/batched/,
147
- /fact-check agent responses and cross-check implementation\/review yourself before acting/,
148
- /return fixes to the original scope/, /loop verify -> fix -> re-verify until clean/,
149
- /skip only simple, low-risk review/, /hard blocked/, /outcome and evidence/,
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/,
150
189
  ]);
151
190
  requireAll(leadTool, 'Lead tools', [
152
191
  /write-role agents self-verify/, /cross-scope verification.*benches.*all git/,
@@ -220,7 +259,9 @@ const dataDir = mkdtempSync(join(tmpdir(), 'mixdog-internal-comms-smoke-'));
220
259
  try {
221
260
  const leadRules = rulesBuilder.buildInjectionContent({ PLUGIN_ROOT: join(root, 'src'), DATA_DIR: dataDir });
222
261
  assert(TOKEN_PRINCIPLE.test(normalize(leadRules)), 'injected Lead rules must carry the brief token principle');
223
- for (const field of BRIEF_FIELDS) assert(leadRules.includes(field), `injected Lead rules missing brief field ${field}`);
262
+ assert(leadRules.includes('Task:'), 'injected Lead rules missing mandatory brief field Task:');
263
+ assert(!leadRules.includes('Goal:'), 'injected Lead rules must not retain Goal:');
264
+ for (const field of OPTIONAL_BRIEF_FIELDS) assert(leadRules.includes(field), `injected Lead rules missing optional brief field ${field}`);
224
265
  } finally {
225
266
  rmSync(dataDir, { recursive: true, force: true });
226
267
  }
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+ import test from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { mkdtempSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { tmpdir } from 'node:os';
7
+
8
+ process.env.MIXDOG_AGENT_TRACE_DISABLE = '1';
9
+ process.env.MIXDOG_DATA_DIR = mkdtempSync(join(tmpdir(), 'mixdog-max-output-persist-'));
10
+
11
+ test('askSession persists recovery history without duplicating the returned aggregate', async () => {
12
+ const { initProviders, getProvider } = await import('../src/runtime/agent/orchestrator/providers/registry.mjs');
13
+ await initProviders({ gemini: { enabled: true, apiKey: 'test-only' } });
14
+ const provider = getProvider('gemini');
15
+ const responses = [
16
+ {
17
+ content: 'persisted partial ',
18
+ stopReason: 'MAX_TOKENS',
19
+ truncated: true,
20
+ usage: { inputTokens: 4, outputTokens: 3, cachedTokens: 1, promptTokens: 4 },
21
+ },
22
+ {
23
+ content: 'terminal segment',
24
+ stopReason: 'end_turn',
25
+ usage: { inputTokens: 2, outputTokens: 2, cachedTokens: 0, promptTokens: 2 },
26
+ },
27
+ ];
28
+ let sendIndex = 0;
29
+ const streamed = [];
30
+ provider.send = async (_messages, _model, _tools, opts = {}) => {
31
+ const response = responses[sendIndex++];
32
+ assert.ok(response, `unexpected provider send ${sendIndex}`);
33
+ opts.onTextDelta?.(response.content);
34
+ streamed.push(response.content);
35
+ return response;
36
+ };
37
+
38
+ const { createSession, askSession, getSession } = await import('../src/runtime/agent/orchestrator/session/manager.mjs');
39
+ const session = createSession({
40
+ provider: 'gemini',
41
+ model: 'gemini-test',
42
+ tools: [],
43
+ cwd: process.cwd(),
44
+ skipAgentRules: true,
45
+ skipSkills: true,
46
+ compaction: { auto: false },
47
+ });
48
+
49
+ const result = await askSession(
50
+ session.id,
51
+ 'produce a long answer',
52
+ null,
53
+ null,
54
+ process.cwd(),
55
+ null,
56
+ { onTextDelta: () => {} },
57
+ );
58
+ const persisted = getSession(session.id);
59
+ const partialRows = persisted.messages.filter((message) => message.role === 'assistant' && message.content === 'persisted partial ');
60
+ const terminalRows = persisted.messages.filter((message) => message.role === 'assistant' && message.content === 'terminal segment');
61
+ const aggregateRows = persisted.messages.filter((message) => message.role === 'assistant' && message.content === result.content);
62
+
63
+ assert.equal(result.content, 'persisted partial terminal segment');
64
+ assert.equal(streamed.join(''), result.content, 'streamed TUI text matches the aggregate without duplication');
65
+ assert.equal(partialRows.length, 1);
66
+ assert.equal(terminalRows.length, 1);
67
+ assert.equal(aggregateRows.length, 0, 'aggregate is returned but is not persisted over the recovery chain');
68
+ assert.equal(
69
+ persisted.messages.filter((message) => message?.meta?.source === 'max-output-recovery').length,
70
+ 1,
71
+ );
72
+ assert.deepEqual(
73
+ {
74
+ inputTokens: result.usage.inputTokens,
75
+ outputTokens: result.usage.outputTokens,
76
+ cachedTokens: result.usage.cachedTokens,
77
+ promptTokens: result.usage.promptTokens,
78
+ },
79
+ { inputTokens: 6, outputTokens: 5, cachedTokens: 1, promptTokens: 6 },
80
+ );
81
+ });
@@ -0,0 +1,222 @@
1
+ #!/usr/bin/env node
2
+ import test from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+
5
+ import { agentLoop } from '../src/runtime/agent/orchestrator/session/agent-loop.mjs';
6
+
7
+ function queuedProvider(responses, streamed = []) {
8
+ const sent = [];
9
+ let index = 0;
10
+ return {
11
+ sent,
12
+ async send(messages, _model, _tools, opts = {}) {
13
+ sent.push(structuredClone(messages));
14
+ const response = responses[index++];
15
+ assert.ok(response, `unexpected provider send ${index}`);
16
+ if (response instanceof Error) {
17
+ if (typeof response.partialContent === 'string') {
18
+ opts.onTextDelta?.(response.partialContent);
19
+ streamed.push(response.partialContent);
20
+ }
21
+ throw response;
22
+ }
23
+ if (typeof response.content === 'string') {
24
+ opts.onTextDelta?.(response.content);
25
+ streamed.push(response.content);
26
+ }
27
+ return {
28
+ usage: { inputTokens: 1, outputTokens: 1 },
29
+ ...response,
30
+ };
31
+ },
32
+ };
33
+ }
34
+
35
+ async function run(provider, messages = [{ role: 'user', content: 'answer fully' }], options = {}) {
36
+ return agentLoop(provider, messages, 'fake-model', [], options.onToolCall, process.cwd(), {
37
+ onTextDelta: options.onTextDelta,
38
+ });
39
+ }
40
+
41
+ function persistedAssistantText(messages, result) {
42
+ const terminal = typeof result.historyContent === 'string' ? result.historyContent : result.content;
43
+ return [
44
+ ...messages.filter((message) => message.role === 'assistant').map((message) => message.content),
45
+ terminal,
46
+ ].join('');
47
+ }
48
+
49
+ test('one max-output continuation resumes from preserved partial and returns one complete text', async () => {
50
+ const streamed = [];
51
+ const provider = queuedProvider([
52
+ { content: 'alpha ', stopReason: 'length', truncated: true },
53
+ { content: 'omega', stopReason: 'end_turn' },
54
+ ], streamed);
55
+ const messages = [{ role: 'user', content: 'answer fully' }];
56
+
57
+ const result = await run(provider, messages, { onTextDelta: () => {} });
58
+
59
+ assert.equal(provider.sent.length, 2);
60
+ assert.equal(result.content, 'alpha omega');
61
+ assert.equal(streamed.join(''), result.content);
62
+ assert.equal(result.historyContent, 'omega');
63
+ assert.equal(result.maxOutputRecoveryAttempts, 1);
64
+ assert.equal(result.terminationReason, undefined);
65
+ assert.deepEqual(provider.sent[1].map((message) => message.role), ['user', 'assistant', 'user']);
66
+ assert.equal(provider.sent[1][1].content, 'alpha ');
67
+ assert.match(provider.sent[1][2].content, /Resume directly/);
68
+ assert.match(provider.sent[1][2].content, /no apology, no recap/i);
69
+ assert.equal(persistedAssistantText(messages, result), result.content);
70
+ assert.equal(messages.some((message) => message.role === 'assistant' && message.content === result.content), false);
71
+ });
72
+
73
+ test('Gemini MAX_TOKENS ProviderIncompleteError enters the same bounded recovery and aggregates usage', async () => {
74
+ const streamed = [];
75
+ const geminiIncomplete = Object.assign(new Error('Gemini response incomplete: finishReason=MAX_TOKENS'), {
76
+ name: 'ProviderIncompleteError',
77
+ code: 'PROVIDER_INCOMPLETE',
78
+ providerIncomplete: true,
79
+ finishReason: 'MAX_TOKENS',
80
+ partialContent: 'gemini ',
81
+ partialToolCalls: undefined,
82
+ model: 'gemini-test',
83
+ rawUsage: {
84
+ promptTokenCount: 10,
85
+ candidatesTokenCount: 4,
86
+ thoughtsTokenCount: 1,
87
+ cachedContentTokenCount: 3,
88
+ },
89
+ });
90
+ const provider = queuedProvider([
91
+ geminiIncomplete,
92
+ {
93
+ content: 'complete',
94
+ stopReason: 'end_turn',
95
+ usage: { inputTokens: 1, outputTokens: 2, cachedTokens: 1, promptTokens: 1 },
96
+ },
97
+ ], streamed);
98
+ const messages = [{ role: 'user', content: 'answer fully' }];
99
+
100
+ const result = await run(provider, messages, { onTextDelta: () => {} });
101
+
102
+ assert.equal(provider.sent.length, 2);
103
+ assert.equal(result.content, 'gemini complete');
104
+ assert.equal(streamed.join(''), result.content, 'TUI-facing deltas and final aggregate contain each segment once');
105
+ assert.deepEqual(
106
+ {
107
+ inputTokens: result.usage.inputTokens,
108
+ outputTokens: result.usage.outputTokens,
109
+ cachedTokens: result.usage.cachedTokens,
110
+ promptTokens: result.usage.promptTokens,
111
+ },
112
+ { inputTokens: 11, outputTokens: 7, cachedTokens: 4, promptTokens: 11 },
113
+ );
114
+ assert.equal(result.lastTurnUsage.outputTokens, 2);
115
+ assert.equal(provider.sent[1][1].content, 'gemini ');
116
+ });
117
+
118
+ test('pause_turn and Gemini OTHER preserve prior non-empty semantics and do not recover', async () => {
119
+ for (const stopReason of ['pause_turn', 'OTHER']) {
120
+ const provider = queuedProvider([{ content: `partial-${stopReason}`, stopReason }]);
121
+ const result = await run(provider);
122
+ assert.equal(provider.sent.length, 1, stopReason);
123
+ assert.equal(result.content, `partial-${stopReason}`, stopReason);
124
+ assert.equal(result.terminationReason, undefined, stopReason);
125
+ assert.equal(Object.hasOwn(result, 'historyContent'), false, stopReason);
126
+ }
127
+ });
128
+
129
+ test('non-output ProviderIncompleteError remains an error', async () => {
130
+ const other = Object.assign(new Error('Gemini response incomplete: finishReason=OTHER'), {
131
+ code: 'PROVIDER_INCOMPLETE',
132
+ providerIncomplete: true,
133
+ finishReason: 'OTHER',
134
+ partialContent: 'unsafe partial',
135
+ partialToolCalls: undefined,
136
+ });
137
+ await assert.rejects(run(queuedProvider([other])), (error) => error === other);
138
+ });
139
+
140
+ test('adaptive-thinking replay state is preserved on the partial assistant history turn', async () => {
141
+ const thinkingBlocks = [{ type: 'thinking', thinking: 'signed thought', signature: 'sig-123' }];
142
+ const reasoningItems = [{ type: 'reasoning', encrypted_content: 'enc-1' }];
143
+ const provider = queuedProvider([
144
+ {
145
+ content: 'first ',
146
+ stopReason: 'max_tokens',
147
+ truncated: true,
148
+ thinkingBlocks,
149
+ reasoningItems,
150
+ reasoningContent: 'display thought',
151
+ },
152
+ { content: 'second', stopReason: 'end_turn' },
153
+ ]);
154
+
155
+ const result = await run(provider);
156
+
157
+ assert.equal(result.content, 'first second');
158
+ assert.deepEqual(provider.sent[1][1].thinkingBlocks, thinkingBlocks);
159
+ assert.deepEqual(provider.sent[1][1].reasoningItems, reasoningItems);
160
+ assert.equal(provider.sent[1][1].reasoningContent, 'display thought');
161
+ });
162
+
163
+ test('max-output recovery stops after three continuations and surfaces explicit truncation', async () => {
164
+ const provider = queuedProvider([
165
+ { content: 'A', stopReason: 'max_tokens', truncated: true },
166
+ { content: 'B', stopReason: 'max_tokens', truncated: true },
167
+ { content: 'C', stopReason: 'max_tokens', truncated: true },
168
+ { content: 'D', stopReason: 'max_tokens', truncated: true },
169
+ ]);
170
+ const messages = [{ role: 'user', content: 'answer fully' }];
171
+
172
+ const result = await run(provider, messages);
173
+
174
+ assert.equal(provider.sent.length, 4);
175
+ assert.equal(result.maxOutputRecoveryAttempts, 3);
176
+ assert.equal(result.terminationReason, 'truncated');
177
+ assert.match(result.content, /^ABCD/);
178
+ assert.match(result.content, /remained truncated after 3 continuation attempts/);
179
+ assert.equal(messages.filter((message) => message?.meta?.source === 'max-output-recovery').length, 3);
180
+ assert.equal(messages.filter((message) => message.role === 'assistant').length, 3);
181
+ assert.equal(persistedAssistantText(messages, result), result.content);
182
+ });
183
+
184
+ test('clean end_turn is unchanged and does not enter recovery', async () => {
185
+ const provider = queuedProvider([
186
+ { content: 'clean answer', stopReason: 'end_turn' },
187
+ ]);
188
+ const messages = [{ role: 'user', content: 'answer fully' }];
189
+
190
+ const result = await run(provider, messages);
191
+
192
+ assert.equal(provider.sent.length, 1);
193
+ assert.equal(result.content, 'clean answer');
194
+ assert.equal(result.terminationReason, undefined);
195
+ assert.equal(Object.hasOwn(result, 'historyContent'), false);
196
+ assert.deepEqual(messages, [{ role: 'user', content: 'answer fully' }]);
197
+ });
198
+
199
+ test('tool-call turns keep the normal tool execution path', async () => {
200
+ const provider = queuedProvider([
201
+ {
202
+ content: '',
203
+ stopReason: 'tool_use',
204
+ toolCalls: [{ id: 'call-1', name: 'unknown_test_tool', arguments: {} }],
205
+ },
206
+ { content: 'done', stopReason: 'end_turn' },
207
+ ]);
208
+ const batches = [];
209
+ const messages = [{ role: 'user', content: 'use a tool' }];
210
+
211
+ const result = await run(provider, messages, {
212
+ onToolCall: (_iteration, calls) => batches.push(calls),
213
+ });
214
+
215
+ assert.equal(provider.sent.length, 2);
216
+ assert.equal(result.content, 'done');
217
+ assert.equal(result.toolCallsTotal, 1);
218
+ assert.equal(result.terminationReason, undefined);
219
+ assert.equal(Object.hasOwn(result, 'historyContent'), false);
220
+ assert.equal(batches.length, 1);
221
+ assert.deepEqual(messages.map((message) => message.role), ['user', 'assistant', 'tool']);
222
+ });
@@ -52,6 +52,7 @@ import {
52
52
  } from '../src/runtime/agent/orchestrator/providers/gemini-cache.mjs';
53
53
  import { parseSSEStream as anthropicParseSSEStream } from '../src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs';
54
54
  import { _buildRequestBodyForCacheSmoke } from '../src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs';
55
+ import { _toAnthropicMessagesForTest } from '../src/runtime/agent/orchestrator/providers/anthropic.mjs';
55
56
  import {
56
57
  EFFORT_BETA_HEADER,
57
58
  LEGACY_EFFORT_BUDGET,
@@ -773,6 +774,37 @@ test('anthropic(-oauth): redacted_thinking round-trips exactly as {type,data} (n
773
774
  ]);
774
775
  });
775
776
 
777
+ test('anthropic API-key and OAuth lower plain signed thinkingBlocks before text without tool calls', () => {
778
+ const thinkingBlocks = [
779
+ { type: 'thinking', thinking: 'resume state', signature: 'sig-recovery-1' },
780
+ { type: 'redacted_thinking', data: 'ENCRYPTED_RECOVERY_STATE' },
781
+ ];
782
+ const history = [
783
+ { role: 'user', content: 'write the answer' },
784
+ { role: 'assistant', content: 'partial answer', thinkingBlocks },
785
+ { role: 'user', content: 'resume directly' },
786
+ ];
787
+ const expectedAssistantContent = [
788
+ ...thinkingBlocks,
789
+ { type: 'text', text: 'partial answer' },
790
+ ];
791
+
792
+ const apiKeyMessages = _toAnthropicMessagesForTest(history);
793
+ const oauthMessages = _buildRequestBodyForCacheSmoke(
794
+ history,
795
+ 'claude-sonnet-4-6',
796
+ [],
797
+ {},
798
+ ).messages;
799
+
800
+ for (const lowered of [apiKeyMessages, oauthMessages]) {
801
+ const assistant = lowered.find((message) => message.role === 'assistant');
802
+ assert.ok(assistant, 'plain recovery assistant turn must survive lowering');
803
+ assert.deepEqual(assistant.content, expectedAssistantContent);
804
+ assert.equal(assistant.content.some((block) => block.type === 'tool_use'), false);
805
+ }
806
+ });
807
+
776
808
  test('anthropic effort: legacy claude-3-7-sonnet gets NO adaptive thinking / effort beta', () => {
777
809
  const model = 'claude-3-7-sonnet-20250219';
778
810
  assert.equal(modelSupportsEffort(model), false);