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.
- package/package.json +2 -1
- package/scripts/agent-terminal-reap-test.mjs +6 -6
- package/scripts/anthropic-oauth-refresh-race-test.mjs +338 -0
- package/scripts/compact-pressure-test.mjs +128 -0
- package/scripts/compact-trigger-migration-smoke.mjs +8 -8
- package/scripts/execution-resume-esc-integration-test.mjs +4 -2
- package/scripts/internal-comms-smoke.mjs +66 -25
- package/scripts/max-output-recovery-persist-test.mjs +81 -0
- package/scripts/max-output-recovery-test.mjs +222 -0
- package/scripts/provider-toolcall-test.mjs +32 -0
- package/scripts/steering-drain-buckets-test.mjs +140 -5
- package/scripts/tui-transcript-perf-test.mjs +279 -0
- package/src/agents/reviewer/AGENT.md +4 -0
- package/src/rules/lead/lead-brief.md +11 -3
- package/src/rules/lead/lead-tool.md +0 -2
- package/src/rules/shared/01-tool.md +4 -0
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +20 -2
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +7 -0
- package/src/runtime/agent/orchestrator/context/collect.mjs +7 -3
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +144 -7
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +15 -2
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +57 -25
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +54 -12
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +4 -3
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +106 -8
- package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +12 -1
- package/src/runtime/agent/orchestrator/session/loop/termination.mjs +22 -7
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +26 -1
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +17 -1
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +26 -0
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +31 -4
- package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +11 -2
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +45 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +9 -1
- package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +43 -1
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +49 -34
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -10
- package/src/runtime/shared/buffered-appender.mjs +13 -2
- package/src/session-runtime/config-helpers.mjs +6 -6
- package/src/session-runtime/lifecycle-api.mjs +4 -0
- package/src/session-runtime/runtime-core.mjs +1 -0
- package/src/session-runtime/session-turn-api.mjs +12 -0
- package/src/standalone/agent-tool.mjs +8 -1
- package/src/tui/App.jsx +2 -1
- package/src/tui/app/transcript-window.mjs +9 -10
- package/src/tui/app/use-transcript-window.mjs +38 -56
- package/src/tui/dist/index.mjs +354 -163
- package/src/tui/engine/agent-job-feed.mjs +76 -6
- package/src/tui/engine/session-api.mjs +7 -1
- package/src/tui/engine/session-flow.mjs +3 -1
- package/src/tui/engine/tool-card-results.mjs +10 -5
- package/src/tui/engine/turn.mjs +96 -36
- package/src/tui/engine.mjs +136 -37
- package/src/tui/index.jsx +2 -2
- package/src/workflows/bench/WORKFLOW.md +51 -27
- 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
|
|
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
|
-
|
|
73
|
-
assert(leadBrief.includes('
|
|
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(!
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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/, /
|
|
124
|
-
/
|
|
125
|
-
/after async spawn, end the turn/,
|
|
126
|
-
/
|
|
127
|
-
/original live session/,
|
|
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
|
|
182
|
+
/issue-free feedback/,
|
|
141
183
|
]);
|
|
142
184
|
requireAll(bench, 'Bench lifecycle', [
|
|
143
|
-
/never
|
|
144
|
-
/
|
|
145
|
-
/
|
|
146
|
-
/
|
|
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
|
-
|
|
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);
|