mixdog 0.9.40 → 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.
- package/package.json +2 -1
- package/scripts/agent-tag-reuse-smoke.mjs +124 -10
- package/scripts/anthropic-oauth-refresh-race-test.mjs +397 -0
- package/scripts/arg-guard-test.mjs +16 -0
- package/scripts/compact-pressure-test.mjs +383 -0
- package/scripts/compact-trigger-migration-smoke.mjs +8 -8
- package/scripts/internal-comms-bench.mjs +0 -1
- package/scripts/internal-comms-smoke.mjs +54 -31
- package/scripts/internal-tools-normalization-test.mjs +52 -0
- package/scripts/max-output-recovery-persist-test.mjs +81 -0
- package/scripts/max-output-recovery-test.mjs +271 -0
- package/scripts/mcp-client-normalization-test.mjs +45 -0
- package/scripts/provider-toolcall-test.mjs +55 -0
- package/scripts/result-classification-test.mjs +75 -0
- package/scripts/smoke.mjs +1 -1
- package/scripts/submit-commandbusy-race-test.mjs +99 -7
- package/scripts/tui-transcript-perf-test.mjs +56 -1
- package/src/agents/reviewer/AGENT.md +8 -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/context/collect.mjs +7 -3
- package/src/runtime/agent/orchestrator/internal-tools.mjs +16 -3
- package/src/runtime/agent/orchestrator/mcp/client.mjs +17 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +193 -13
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +15 -2
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +14 -0
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +59 -2
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +57 -25
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +72 -12
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +144 -84
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +124 -8
- package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +12 -1
- package/src/runtime/agent/orchestrator/session/loop/termination.mjs +25 -7
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +12 -3
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +28 -1
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -1
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +31 -4
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +11 -2
- package/src/runtime/agent/orchestrator/session/result-classification.mjs +4 -1
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +45 -0
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +7 -2
- package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +8 -6
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +15 -3
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +22 -21
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -10
- package/src/runtime/memory/tool-defs.mjs +4 -3
- package/src/runtime/shared/tool-surface.mjs +1 -2
- package/src/session-runtime/context-status.mjs +8 -2
- package/src/standalone/agent-tool/render.mjs +2 -0
- package/src/standalone/agent-tool.mjs +202 -22
- package/src/tui/components/StatusLine.jsx +4 -26
- package/src/tui/dist/index.mjs +46 -31
- package/src/tui/engine/session-api.mjs +3 -0
- package/src/tui/engine/session-flow.mjs +19 -8
- package/src/tui/engine/turn.mjs +24 -2
- package/src/tui/engine.mjs +0 -1
- package/src/ui/statusline-agents.mjs +0 -8
- package/src/ui/statusline.mjs +2 -4
- package/src/workflows/default/WORKFLOW.md +38 -24
- package/src/workflows/solo-review/WORKFLOW.md +47 -0
- package/src/workflows/bench/WORKFLOW.md +0 -36
|
@@ -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,271 @@
|
|
|
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
|
+
import { HIDDEN_AGENT_NAMES } from '../src/runtime/agent/orchestrator/session/loop/hidden-agents.mjs';
|
|
7
|
+
|
|
8
|
+
function queuedProvider(responses, streamed = []) {
|
|
9
|
+
const sent = [];
|
|
10
|
+
let index = 0;
|
|
11
|
+
return {
|
|
12
|
+
sent,
|
|
13
|
+
async send(messages, _model, _tools, opts = {}) {
|
|
14
|
+
sent.push(structuredClone(messages));
|
|
15
|
+
const response = responses[index++];
|
|
16
|
+
assert.ok(response, `unexpected provider send ${index}`);
|
|
17
|
+
if (response instanceof Error) {
|
|
18
|
+
if (typeof response.partialContent === 'string') {
|
|
19
|
+
opts.onTextDelta?.(response.partialContent);
|
|
20
|
+
streamed.push(response.partialContent);
|
|
21
|
+
}
|
|
22
|
+
throw response;
|
|
23
|
+
}
|
|
24
|
+
if (typeof response.content === 'string') {
|
|
25
|
+
opts.onTextDelta?.(response.content);
|
|
26
|
+
streamed.push(response.content);
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
usage: { inputTokens: 1, outputTokens: 1 },
|
|
30
|
+
...response,
|
|
31
|
+
};
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function run(provider, messages = [{ role: 'user', content: 'answer fully' }], options = {}) {
|
|
37
|
+
return agentLoop(provider, messages, 'fake-model', [], options.onToolCall, process.cwd(), {
|
|
38
|
+
onTextDelta: options.onTextDelta,
|
|
39
|
+
session: options.session,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function persistedAssistantText(messages, result) {
|
|
44
|
+
const terminal = typeof result.historyContent === 'string' ? result.historyContent : result.content;
|
|
45
|
+
return [
|
|
46
|
+
...messages.filter((message) => message.role === 'assistant').map((message) => message.content),
|
|
47
|
+
terminal,
|
|
48
|
+
].join('');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test('one max-output continuation resumes from preserved partial and returns one complete text', async () => {
|
|
52
|
+
const streamed = [];
|
|
53
|
+
const provider = queuedProvider([
|
|
54
|
+
{ content: 'alpha ', stopReason: 'length', truncated: true },
|
|
55
|
+
{ content: 'omega', stopReason: 'end_turn' },
|
|
56
|
+
], streamed);
|
|
57
|
+
const messages = [{ role: 'user', content: 'answer fully' }];
|
|
58
|
+
|
|
59
|
+
const result = await run(provider, messages, { onTextDelta: () => {} });
|
|
60
|
+
|
|
61
|
+
assert.equal(provider.sent.length, 2);
|
|
62
|
+
assert.equal(result.content, 'alpha omega');
|
|
63
|
+
assert.equal(streamed.join(''), result.content);
|
|
64
|
+
assert.equal(result.historyContent, 'omega');
|
|
65
|
+
assert.equal(result.maxOutputRecoveryAttempts, 1);
|
|
66
|
+
assert.equal(result.terminationReason, undefined);
|
|
67
|
+
assert.deepEqual(provider.sent[1].map((message) => message.role), ['user', 'assistant', 'user']);
|
|
68
|
+
assert.equal(provider.sent[1][1].content, 'alpha ');
|
|
69
|
+
assert.match(provider.sent[1][2].content, /Resume directly/);
|
|
70
|
+
assert.match(provider.sent[1][2].content, /no apology, no recap/i);
|
|
71
|
+
assert.equal(persistedAssistantText(messages, result), result.content);
|
|
72
|
+
assert.equal(messages.some((message) => message.role === 'assistant' && message.content === result.content), false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('Gemini MAX_TOKENS ProviderIncompleteError enters the same bounded recovery and aggregates usage', async () => {
|
|
76
|
+
const streamed = [];
|
|
77
|
+
const geminiIncomplete = Object.assign(new Error('Gemini response incomplete: finishReason=MAX_TOKENS'), {
|
|
78
|
+
name: 'ProviderIncompleteError',
|
|
79
|
+
code: 'PROVIDER_INCOMPLETE',
|
|
80
|
+
providerIncomplete: true,
|
|
81
|
+
finishReason: 'MAX_TOKENS',
|
|
82
|
+
partialContent: 'gemini ',
|
|
83
|
+
partialToolCalls: undefined,
|
|
84
|
+
model: 'gemini-test',
|
|
85
|
+
rawUsage: {
|
|
86
|
+
promptTokenCount: 10,
|
|
87
|
+
candidatesTokenCount: 4,
|
|
88
|
+
thoughtsTokenCount: 1,
|
|
89
|
+
cachedContentTokenCount: 3,
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
const provider = queuedProvider([
|
|
93
|
+
geminiIncomplete,
|
|
94
|
+
{
|
|
95
|
+
content: 'complete',
|
|
96
|
+
stopReason: 'end_turn',
|
|
97
|
+
usage: { inputTokens: 1, outputTokens: 2, cachedTokens: 1, promptTokens: 1 },
|
|
98
|
+
},
|
|
99
|
+
], streamed);
|
|
100
|
+
const messages = [{ role: 'user', content: 'answer fully' }];
|
|
101
|
+
|
|
102
|
+
const result = await run(provider, messages, { onTextDelta: () => {} });
|
|
103
|
+
|
|
104
|
+
assert.equal(provider.sent.length, 2);
|
|
105
|
+
assert.equal(result.content, 'gemini complete');
|
|
106
|
+
assert.equal(streamed.join(''), result.content, 'TUI-facing deltas and final aggregate contain each segment once');
|
|
107
|
+
assert.deepEqual(
|
|
108
|
+
{
|
|
109
|
+
inputTokens: result.usage.inputTokens,
|
|
110
|
+
outputTokens: result.usage.outputTokens,
|
|
111
|
+
cachedTokens: result.usage.cachedTokens,
|
|
112
|
+
promptTokens: result.usage.promptTokens,
|
|
113
|
+
},
|
|
114
|
+
{ inputTokens: 11, outputTokens: 7, cachedTokens: 4, promptTokens: 11 },
|
|
115
|
+
);
|
|
116
|
+
assert.equal(result.lastTurnUsage.outputTokens, 2);
|
|
117
|
+
assert.equal(provider.sent[1][1].content, 'gemini ');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('pause_turn and Gemini OTHER preserve prior non-empty semantics and do not recover', async () => {
|
|
121
|
+
for (const stopReason of ['pause_turn', 'OTHER']) {
|
|
122
|
+
const provider = queuedProvider([{ content: `partial-${stopReason}`, stopReason }]);
|
|
123
|
+
const result = await run(provider);
|
|
124
|
+
assert.equal(provider.sent.length, 1, stopReason);
|
|
125
|
+
assert.equal(result.content, `partial-${stopReason}`, stopReason);
|
|
126
|
+
assert.equal(result.terminationReason, undefined, stopReason);
|
|
127
|
+
assert.equal(Object.hasOwn(result, 'historyContent'), false, stopReason);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
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
|
+
|
|
178
|
+
test('non-output ProviderIncompleteError remains an error', async () => {
|
|
179
|
+
const other = Object.assign(new Error('Gemini response incomplete: finishReason=OTHER'), {
|
|
180
|
+
code: 'PROVIDER_INCOMPLETE',
|
|
181
|
+
providerIncomplete: true,
|
|
182
|
+
finishReason: 'OTHER',
|
|
183
|
+
partialContent: 'unsafe partial',
|
|
184
|
+
partialToolCalls: undefined,
|
|
185
|
+
});
|
|
186
|
+
await assert.rejects(run(queuedProvider([other])), (error) => error === other);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('adaptive-thinking replay state is preserved on the partial assistant history turn', async () => {
|
|
190
|
+
const thinkingBlocks = [{ type: 'thinking', thinking: 'signed thought', signature: 'sig-123' }];
|
|
191
|
+
const reasoningItems = [{ type: 'reasoning', encrypted_content: 'enc-1' }];
|
|
192
|
+
const provider = queuedProvider([
|
|
193
|
+
{
|
|
194
|
+
content: 'first ',
|
|
195
|
+
stopReason: 'max_tokens',
|
|
196
|
+
truncated: true,
|
|
197
|
+
thinkingBlocks,
|
|
198
|
+
reasoningItems,
|
|
199
|
+
reasoningContent: 'display thought',
|
|
200
|
+
},
|
|
201
|
+
{ content: 'second', stopReason: 'end_turn' },
|
|
202
|
+
]);
|
|
203
|
+
|
|
204
|
+
const result = await run(provider);
|
|
205
|
+
|
|
206
|
+
assert.equal(result.content, 'first second');
|
|
207
|
+
assert.deepEqual(provider.sent[1][1].thinkingBlocks, thinkingBlocks);
|
|
208
|
+
assert.deepEqual(provider.sent[1][1].reasoningItems, reasoningItems);
|
|
209
|
+
assert.equal(provider.sent[1][1].reasoningContent, 'display thought');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('max-output recovery stops after three continuations and surfaces explicit truncation', async () => {
|
|
213
|
+
const provider = queuedProvider([
|
|
214
|
+
{ content: 'A', stopReason: 'max_tokens', truncated: true },
|
|
215
|
+
{ content: 'B', stopReason: 'max_tokens', truncated: true },
|
|
216
|
+
{ content: 'C', stopReason: 'max_tokens', truncated: true },
|
|
217
|
+
{ content: 'D', stopReason: 'max_tokens', truncated: true },
|
|
218
|
+
]);
|
|
219
|
+
const messages = [{ role: 'user', content: 'answer fully' }];
|
|
220
|
+
|
|
221
|
+
const result = await run(provider, messages);
|
|
222
|
+
|
|
223
|
+
assert.equal(provider.sent.length, 4);
|
|
224
|
+
assert.equal(result.maxOutputRecoveryAttempts, 3);
|
|
225
|
+
assert.equal(result.terminationReason, 'truncated');
|
|
226
|
+
assert.match(result.content, /^ABCD/);
|
|
227
|
+
assert.match(result.content, /remained truncated after 3 continuation attempts/);
|
|
228
|
+
assert.equal(messages.filter((message) => message?.meta?.source === 'max-output-recovery').length, 3);
|
|
229
|
+
assert.equal(messages.filter((message) => message.role === 'assistant').length, 3);
|
|
230
|
+
assert.equal(persistedAssistantText(messages, result), result.content);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('clean end_turn is unchanged and does not enter recovery', async () => {
|
|
234
|
+
const provider = queuedProvider([
|
|
235
|
+
{ content: 'clean answer', stopReason: 'end_turn' },
|
|
236
|
+
]);
|
|
237
|
+
const messages = [{ role: 'user', content: 'answer fully' }];
|
|
238
|
+
|
|
239
|
+
const result = await run(provider, messages);
|
|
240
|
+
|
|
241
|
+
assert.equal(provider.sent.length, 1);
|
|
242
|
+
assert.equal(result.content, 'clean answer');
|
|
243
|
+
assert.equal(result.terminationReason, undefined);
|
|
244
|
+
assert.equal(Object.hasOwn(result, 'historyContent'), false);
|
|
245
|
+
assert.deepEqual(messages, [{ role: 'user', content: 'answer fully' }]);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('tool-call turns keep the normal tool execution path', async () => {
|
|
249
|
+
const provider = queuedProvider([
|
|
250
|
+
{
|
|
251
|
+
content: '',
|
|
252
|
+
stopReason: 'tool_use',
|
|
253
|
+
toolCalls: [{ id: 'call-1', name: 'unknown_test_tool', arguments: {} }],
|
|
254
|
+
},
|
|
255
|
+
{ content: 'done', stopReason: 'end_turn' },
|
|
256
|
+
]);
|
|
257
|
+
const batches = [];
|
|
258
|
+
const messages = [{ role: 'user', content: 'use a tool' }];
|
|
259
|
+
|
|
260
|
+
const result = await run(provider, messages, {
|
|
261
|
+
onToolCall: (_iteration, calls) => batches.push(calls),
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
assert.equal(provider.sent.length, 2);
|
|
265
|
+
assert.equal(result.content, 'done');
|
|
266
|
+
assert.equal(result.toolCallsTotal, 1);
|
|
267
|
+
assert.equal(result.terminationReason, undefined);
|
|
268
|
+
assert.equal(Object.hasOwn(result, 'historyContent'), false);
|
|
269
|
+
assert.equal(batches.length, 1);
|
|
270
|
+
assert.deepEqual(messages.map((message) => message.role), ['user', 'assistant', 'tool']);
|
|
271
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -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,
|
|
@@ -94,6 +95,29 @@ function anthropicSseResponse(events) {
|
|
|
94
95
|
};
|
|
95
96
|
}
|
|
96
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
|
+
|
|
97
121
|
function compatResponsesEventStream(events) {
|
|
98
122
|
return {
|
|
99
123
|
async *[Symbol.asyncIterator]() {
|
|
@@ -773,6 +797,37 @@ test('anthropic(-oauth): redacted_thinking round-trips exactly as {type,data} (n
|
|
|
773
797
|
]);
|
|
774
798
|
});
|
|
775
799
|
|
|
800
|
+
test('anthropic API-key and OAuth lower plain signed thinkingBlocks before text without tool calls', () => {
|
|
801
|
+
const thinkingBlocks = [
|
|
802
|
+
{ type: 'thinking', thinking: 'resume state', signature: 'sig-recovery-1' },
|
|
803
|
+
{ type: 'redacted_thinking', data: 'ENCRYPTED_RECOVERY_STATE' },
|
|
804
|
+
];
|
|
805
|
+
const history = [
|
|
806
|
+
{ role: 'user', content: 'write the answer' },
|
|
807
|
+
{ role: 'assistant', content: 'partial answer', thinkingBlocks },
|
|
808
|
+
{ role: 'user', content: 'resume directly' },
|
|
809
|
+
];
|
|
810
|
+
const expectedAssistantContent = [
|
|
811
|
+
...thinkingBlocks,
|
|
812
|
+
{ type: 'text', text: 'partial answer' },
|
|
813
|
+
];
|
|
814
|
+
|
|
815
|
+
const apiKeyMessages = _toAnthropicMessagesForTest(history);
|
|
816
|
+
const oauthMessages = _buildRequestBodyForCacheSmoke(
|
|
817
|
+
history,
|
|
818
|
+
'claude-sonnet-4-6',
|
|
819
|
+
[],
|
|
820
|
+
{},
|
|
821
|
+
).messages;
|
|
822
|
+
|
|
823
|
+
for (const lowered of [apiKeyMessages, oauthMessages]) {
|
|
824
|
+
const assistant = lowered.find((message) => message.role === 'assistant');
|
|
825
|
+
assert.ok(assistant, 'plain recovery assistant turn must survive lowering');
|
|
826
|
+
assert.deepEqual(assistant.content, expectedAssistantContent);
|
|
827
|
+
assert.equal(assistant.content.some((block) => block.type === 'tool_use'), false);
|
|
828
|
+
}
|
|
829
|
+
});
|
|
830
|
+
|
|
776
831
|
test('anthropic effort: legacy claude-3-7-sonnet gets NO adaptive thinking / effort beta', () => {
|
|
777
832
|
const model = 'claude-3-7-sonnet-20250219';
|
|
778
833
|
assert.equal(modelSupportsEffort(model), false);
|
|
@@ -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') ||
|
|
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', `
|