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,383 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {
|
|
4
|
+
estimateMessagesTokens,
|
|
5
|
+
estimateRequestReserveTokens,
|
|
6
|
+
estimateToolSchemaTokens,
|
|
7
|
+
estimateTokens,
|
|
8
|
+
IMAGE_VISUAL_TOKEN_ALLOWANCE,
|
|
9
|
+
summarizeContextMessages,
|
|
10
|
+
} from '../src/runtime/agent/orchestrator/session/context-utils.mjs';
|
|
11
|
+
import { createContextStatus } from '../src/session-runtime/context-status.mjs';
|
|
12
|
+
import {
|
|
13
|
+
compactionTelemetryPressureTokens,
|
|
14
|
+
recordProviderContextBaseline,
|
|
15
|
+
resolveCompactionPressureTokens,
|
|
16
|
+
rememberCompactTelemetry,
|
|
17
|
+
resolveWorkerCompactPolicy,
|
|
18
|
+
shouldCompactForSession,
|
|
19
|
+
} from '../src/runtime/agent/orchestrator/session/loop/compact-policy.mjs';
|
|
20
|
+
|
|
21
|
+
function policyFor(session) {
|
|
22
|
+
return resolveWorkerCompactPolicy(session, []);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test('Lead/main auto-compaction triggers at 95% of the effective boundary', () => {
|
|
26
|
+
const leadPolicy = policyFor({ contextWindow: 100_000, compaction: {} });
|
|
27
|
+
assert.equal(leadPolicy.triggerTokens, 95_000);
|
|
28
|
+
assert.equal(leadPolicy.bufferTokens, 5_000);
|
|
29
|
+
|
|
30
|
+
const noReserve = { ...leadPolicy, reserveTokens: 0 };
|
|
31
|
+
assert.equal(shouldCompactForSession(94_999, noReserve), false);
|
|
32
|
+
assert.equal(shouldCompactForSession(95_000, noReserve), true);
|
|
33
|
+
|
|
34
|
+
const agentPolicy = policyFor({ owner: 'agent', contextWindow: 100_000, compaction: {} });
|
|
35
|
+
assert.equal(agentPolicy.triggerTokens, 90_000, 'agent default 10% headroom must remain unchanged');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('image payload bytes do not inflate live gauge or fallback compaction estimates', () => {
|
|
39
|
+
const text = 'Please describe the attached image.';
|
|
40
|
+
const imageData = 'A'.repeat(400_000);
|
|
41
|
+
const imagePart = { type: 'image', data: imageData, mimeType: 'image/png' };
|
|
42
|
+
const messages = [{
|
|
43
|
+
role: 'user',
|
|
44
|
+
content: [{ type: 'text', text }, imagePart],
|
|
45
|
+
}];
|
|
46
|
+
const plainTextEstimate = estimateTokens(text) + 4;
|
|
47
|
+
const fallbackEstimate = estimateMessagesTokens(messages);
|
|
48
|
+
const summaryEstimate = summarizeContextMessages(messages).estimatedTokens;
|
|
49
|
+
|
|
50
|
+
assert.equal(estimateMessagesTokens([{ role: 'user', content: text }]), plainTextEstimate,
|
|
51
|
+
'ordinary string estimates must remain unchanged');
|
|
52
|
+
assert.equal(fallbackEstimate, plainTextEstimate + IMAGE_VISUAL_TOKEN_ALLOWANCE,
|
|
53
|
+
'fallback compaction estimate must count text plus a visual image allowance');
|
|
54
|
+
const largerPayloadEstimate = estimateMessagesTokens([{
|
|
55
|
+
role: 'user',
|
|
56
|
+
content: [{ type: 'text', text }, { ...imagePart, data: imageData.repeat(2) }],
|
|
57
|
+
}]);
|
|
58
|
+
assert.equal(largerPayloadEstimate, fallbackEstimate,
|
|
59
|
+
'raw image byte length must not affect the visual allowance');
|
|
60
|
+
assert.equal(summaryEstimate, fallbackEstimate,
|
|
61
|
+
'display and fallback compaction must share the image-aware message estimate');
|
|
62
|
+
|
|
63
|
+
const session = {
|
|
64
|
+
id: 'image-context-test',
|
|
65
|
+
provider: 'openai',
|
|
66
|
+
contextWindow: 100_000,
|
|
67
|
+
rawContextWindow: 100_000,
|
|
68
|
+
compactBoundaryTokens: 100_000,
|
|
69
|
+
messages: [],
|
|
70
|
+
liveTurnMessages: messages,
|
|
71
|
+
tools: [],
|
|
72
|
+
compaction: {},
|
|
73
|
+
};
|
|
74
|
+
const { contextStatus } = createContextStatus({
|
|
75
|
+
getSession: () => session,
|
|
76
|
+
getRoute: () => ({ provider: 'openai', model: 'test-model' }),
|
|
77
|
+
getCurrentCwd: () => '',
|
|
78
|
+
getMode: () => 'default',
|
|
79
|
+
});
|
|
80
|
+
const status = contextStatus();
|
|
81
|
+
assert.ok(status.usedTokens < status.compaction.triggerTokens,
|
|
82
|
+
`image-aware live gauge must remain below compaction threshold, got ${status.usedTokens}`);
|
|
83
|
+
assert.ok(status.usedTokens < status.contextWindow,
|
|
84
|
+
`raw base64 must not pin the live gauge at 100%, got ${status.usedTokens}/${status.contextWindow}`);
|
|
85
|
+
|
|
86
|
+
recordProviderContextBaseline(session, messages, { inputTokens: 80_000, outputTokens: 0 });
|
|
87
|
+
const policy = { ...policyFor(session), reserveTokens: 0 };
|
|
88
|
+
assert.equal(
|
|
89
|
+
shouldCompactForSession(fallbackEstimate, policy, { messages, sessionRef: session }),
|
|
90
|
+
false,
|
|
91
|
+
'provider-backed pressure below threshold must remain below threshold',
|
|
92
|
+
);
|
|
93
|
+
assert.equal(imagePart.data, imageData,
|
|
94
|
+
'estimating live context must not sanitize or remove image content');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('dense ASCII, encoded, and minified payloads do not retain the prose chars/4 estimate', () => {
|
|
98
|
+
for (const text of [
|
|
99
|
+
'A1b2C3d4E5f6G7h8'.repeat(100),
|
|
100
|
+
'%7B%22path%22%3A%22very%2Flong%2Fencoded%2Fvalue%22%7D'.repeat(40),
|
|
101
|
+
JSON.stringify({ rows: Array.from({ length: 100 }, (_, i) => ({ id: i, value: `item_${i}_abcdef` })) }),
|
|
102
|
+
]) {
|
|
103
|
+
assert.ok(estimateTokens(text) >= Math.ceil(text.length * 0.7),
|
|
104
|
+
`dense ASCII estimate must be conservative (${estimateTokens(text)}/${text.length})`);
|
|
105
|
+
}
|
|
106
|
+
const shortJsonl = Array.from({ length: 200 }, (_, i) => `{"i":${i}}`).join('\n');
|
|
107
|
+
assert.ok(estimateTokens(shortJsonl) >= shortJsonl.replace(/\s/g, '').length * 0.7,
|
|
108
|
+
'short JSONL records must receive the structured multiline floor');
|
|
109
|
+
const spacedShortIdentifiers = 'A1b2C3d4E5 F6G7H8I9J0 '.repeat(100);
|
|
110
|
+
assert.ok(estimateTokens(spacedShortIdentifiers) >= spacedShortIdentifiers.length * 0.7,
|
|
111
|
+
'space-separated encoded identifiers below the long-run threshold must receive the dense floor');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('assistantBlocks and reasoningItems count provider-visible data but not image bytes', () => {
|
|
115
|
+
const base = { role: 'assistant', content: '' };
|
|
116
|
+
const assistantBlocks = [
|
|
117
|
+
{ type: 'text', text: 'native assistant replay' },
|
|
118
|
+
{ type: 'tool_use', id: 'call_native', name: 'read', input: '{"path":"x"}' },
|
|
119
|
+
{ type: 'image', data: 'Z'.repeat(300_000), mimeType: 'image/png' },
|
|
120
|
+
];
|
|
121
|
+
const reasoningItems = [{ type: 'reasoning', encrypted_content: 'enc_'.repeat(2_000) }];
|
|
122
|
+
const estimated = estimateMessagesTokens([{ ...base, assistantBlocks, reasoningItems }]);
|
|
123
|
+
const noNativeReplay = estimateMessagesTokens([base]);
|
|
124
|
+
assert.ok(estimated > noNativeReplay + IMAGE_VISUAL_TOKEN_ALLOWANCE,
|
|
125
|
+
'native assistant blocks and opaque reasoning must contribute tokens');
|
|
126
|
+
assert.equal(
|
|
127
|
+
estimateMessagesTokens([{ ...base, assistantBlocks: assistantBlocks.map(block => (
|
|
128
|
+
block.type === 'image' ? { ...block, data: 'Z'.repeat(600_000) } : block
|
|
129
|
+
)), reasoningItems }]),
|
|
130
|
+
estimated,
|
|
131
|
+
'native replay image bytes must not be tokenized',
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('image allowance handles identity, detail, dimensions, and multiple accepted forms', () => {
|
|
136
|
+
const low = { type: 'image_url', image_url: { url: 'https://example.test/a.png', detail: 'low' } };
|
|
137
|
+
const tiled = {
|
|
138
|
+
type: 'input_image',
|
|
139
|
+
image_url: { url: 'https://example.test/b.png', detail: 'high' },
|
|
140
|
+
dimensions: { width: 2_048, height: 1_024 },
|
|
141
|
+
};
|
|
142
|
+
const inline = { inlineData: { data: 'A'.repeat(1_000), mimeType: 'image/png' }, width: 512, height: 512 };
|
|
143
|
+
const lowEstimate = estimateMessagesTokens([{ role: 'user', content: [low] }]);
|
|
144
|
+
const tiledEstimate = estimateMessagesTokens([{ role: 'user', content: [tiled] }]);
|
|
145
|
+
const multiEstimate = estimateMessagesTokens([{ role: 'user', content: [low, tiled, inline] }]);
|
|
146
|
+
assert.ok(tiledEstimate > lowEstimate, 'high-detail multi-tile image must reserve more than low detail');
|
|
147
|
+
assert.ok(lowEstimate >= IMAGE_VISUAL_TOKEN_ALLOWANCE + 4,
|
|
148
|
+
'unverified low-detail metadata must not reduce the conservative image fallback');
|
|
149
|
+
const unverifiedSmallDimensions = estimateMessagesTokens([{
|
|
150
|
+
role: 'user',
|
|
151
|
+
content: [{ type: 'image', data: 'A'.repeat(1_000), width: 1, height: 1, detail: 'low' }],
|
|
152
|
+
}]);
|
|
153
|
+
assert.ok(unverifiedSmallDimensions >= IMAGE_VISUAL_TOKEN_ALLOWANCE + 4,
|
|
154
|
+
'unverified tiny dimensions must not reduce the conservative image fallback');
|
|
155
|
+
assert.ok(multiEstimate > tiledEstimate + lowEstimate, 'every accepted image form must add visual allowance');
|
|
156
|
+
|
|
157
|
+
const session = { provider: 'openai', model: 'm', tools: [], contextWindow: 100_000, compaction: {} };
|
|
158
|
+
const messages = [{ role: 'user', content: [{ type: 'image', data: 'A'.repeat(1_000), mimeType: 'image/png' }] }];
|
|
159
|
+
const policy = policyFor(session);
|
|
160
|
+
recordProviderContextBaseline(session, messages, { inputTokens: 80_000 }, { sendTools: [] });
|
|
161
|
+
messages[0].content[0].data = 'B'.repeat(1_000);
|
|
162
|
+
assert.equal(
|
|
163
|
+
resolveCompactionPressureTokens(estimateMessagesTokens(messages), policy, { messages, sessionRef: session }),
|
|
164
|
+
estimateMessagesTokens(messages) + policy.reserveTokens,
|
|
165
|
+
'same-size image replacement must invalidate the measured prefix',
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('tool reserve and status cache detect nested schema mutation', () => {
|
|
170
|
+
const tools = [{ name: 'read', parameters: { type: 'object', properties: { path: { type: 'string' } } } }];
|
|
171
|
+
const beforeSchema = estimateToolSchemaTokens(tools);
|
|
172
|
+
const beforeReserve = estimateRequestReserveTokens(tools);
|
|
173
|
+
tools[0].parameters.properties.path.description = 'dense_nested_schema_description_'.repeat(200);
|
|
174
|
+
assert.ok(estimateToolSchemaTokens(tools) > beforeSchema);
|
|
175
|
+
assert.ok(estimateRequestReserveTokens(tools) > beforeReserve);
|
|
176
|
+
|
|
177
|
+
const session = { id: 'tool-cache', contextWindow: 100_000, messages: [{ role: 'user', content: 'x' }], tools, compaction: {} };
|
|
178
|
+
const { contextStatus } = createContextStatus({
|
|
179
|
+
getSession: () => session,
|
|
180
|
+
getRoute: () => ({ provider: 'openai', model: 'm' }),
|
|
181
|
+
getCurrentCwd: () => '',
|
|
182
|
+
getMode: () => 'default',
|
|
183
|
+
});
|
|
184
|
+
const statusBefore = contextStatus();
|
|
185
|
+
tools[0].parameters.properties.path.description += 'more_'.repeat(500);
|
|
186
|
+
const statusAfter = contextStatus();
|
|
187
|
+
assert.notEqual(statusAfter, statusBefore);
|
|
188
|
+
assert.ok(statusAfter.request.toolSchemaTokens > statusBefore.request.toolSchemaTokens);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('native replay fingerprint detects encrypted reasoning and assistant metadata mutation', () => {
|
|
192
|
+
const message = {
|
|
193
|
+
role: 'assistant',
|
|
194
|
+
content: '',
|
|
195
|
+
reasoningItems: [{ type: 'reasoning', encrypted_content: 'short' }],
|
|
196
|
+
assistantBlocks: [{ type: 'tool_use', id: 'c1', name: 'read', input: { path: 'a' }, cache_control: { type: 'ephemeral' } }],
|
|
197
|
+
};
|
|
198
|
+
const messages = [message];
|
|
199
|
+
const first = summarizeContextMessages(messages).estimatedTokens;
|
|
200
|
+
message.reasoningItems[0].encrypted_content = 'encrypted_'.repeat(1_000);
|
|
201
|
+
const second = summarizeContextMessages(messages).estimatedTokens;
|
|
202
|
+
assert.ok(second > first, 'in-place encrypted_content mutation must invalidate the message memo');
|
|
203
|
+
message.assistantBlocks[0].cache_control.mode = 'metadata_'.repeat(1_000);
|
|
204
|
+
const third = summarizeContextMessages(messages).estimatedTokens;
|
|
205
|
+
assert.ok(third > second, 'in-place assistant-block metadata mutation must invalidate the message memo');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('provider baseline pressure includes only the configured extra reserve', () => {
|
|
209
|
+
const session = { provider: 'openai', model: 'm', tools: [], contextWindow: 100_000, compaction: {} };
|
|
210
|
+
const messages = [{ role: 'user', content: 'baseline' }];
|
|
211
|
+
recordProviderContextBaseline(session, messages, { inputTokens: 80_000, outputTokens: 0 });
|
|
212
|
+
const policy = { ...policyFor(session), reserveTokens: 7_512, requestReserveTokens: 512, configuredReserveTokens: 7_000 };
|
|
213
|
+
assert.equal(resolveCompactionPressureTokens(1, policy, { messages, sessionRef: session }), 87_000);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('provider baselines fingerprint actual sendTools and reject provider, model, tool-schema, and prefix changes', () => {
|
|
217
|
+
const make = () => ({
|
|
218
|
+
provider: 'openai',
|
|
219
|
+
model: 'm1',
|
|
220
|
+
tools: [{ name: 'read', parameters: { type: 'object' } }],
|
|
221
|
+
contextWindow: 100_000,
|
|
222
|
+
compaction: {},
|
|
223
|
+
});
|
|
224
|
+
const fallbackForMutation = (mutate) => {
|
|
225
|
+
const session = make();
|
|
226
|
+
const messages = [{ role: 'user', content: 'original prefix' }];
|
|
227
|
+
recordProviderContextBaseline(session, messages, { inputTokens: 80_000, outputTokens: 0 }, { sendTools: session.tools });
|
|
228
|
+
mutate(session, messages);
|
|
229
|
+
const policy = { ...resolveWorkerCompactPolicy(session, session.tools), reserveTokens: 0 };
|
|
230
|
+
return {
|
|
231
|
+
pressure: resolveCompactionPressureTokens(estimateMessagesTokens(messages), policy, { messages, sessionRef: session }),
|
|
232
|
+
fallback: estimateMessagesTokens(messages),
|
|
233
|
+
};
|
|
234
|
+
};
|
|
235
|
+
for (const mutate of [
|
|
236
|
+
session => { session.provider = 'anthropic'; },
|
|
237
|
+
session => { session.model = 'm2'; },
|
|
238
|
+
session => { session.tools[0].parameters.properties = { path: { type: 'string' } }; },
|
|
239
|
+
(_session, messages) => { messages[0].content = 'mutated earlier prefix'; },
|
|
240
|
+
]) {
|
|
241
|
+
const result = fallbackForMutation(mutate);
|
|
242
|
+
assert.equal(result.pressure, result.fallback);
|
|
243
|
+
}
|
|
244
|
+
const session = make();
|
|
245
|
+
const messages = [{ role: 'user', content: 'forced tool request' }];
|
|
246
|
+
const actualSendTools = [session.tools[0]];
|
|
247
|
+
const matchingPolicy = resolveWorkerCompactPolicy(session, actualSendTools);
|
|
248
|
+
recordProviderContextBaseline(session, messages, { inputTokens: 80_000 }, { sendTools: actualSendTools });
|
|
249
|
+
session.tools = [{ name: 'different-session-tool', parameters: { type: 'object' } }];
|
|
250
|
+
assert.equal(
|
|
251
|
+
resolveCompactionPressureTokens(1, matchingPolicy, { messages, sessionRef: session }),
|
|
252
|
+
80_000,
|
|
253
|
+
'baseline must remain aligned to actual sendTools rather than mutable sessionRef.tools',
|
|
254
|
+
);
|
|
255
|
+
const changedSendPolicy = resolveWorkerCompactPolicy(session, session.tools);
|
|
256
|
+
assert.notEqual(
|
|
257
|
+
resolveCompactionPressureTokens(1, changedSendPolicy, { messages, sessionRef: session }),
|
|
258
|
+
80_000,
|
|
259
|
+
'a different next-send schema must invalidate the baseline',
|
|
260
|
+
);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('context status cache notices mutation of an earlier message', () => {
|
|
264
|
+
const session = {
|
|
265
|
+
id: 'status-mutation',
|
|
266
|
+
contextWindow: 100_000,
|
|
267
|
+
messages: [{ role: 'user', content: 'short' }, { role: 'assistant', content: 'tail' }],
|
|
268
|
+
tools: [],
|
|
269
|
+
compaction: {},
|
|
270
|
+
};
|
|
271
|
+
const { contextStatus } = createContextStatus({
|
|
272
|
+
getSession: () => session,
|
|
273
|
+
getRoute: () => ({ provider: 'openai', model: 'm' }),
|
|
274
|
+
getCurrentCwd: () => '',
|
|
275
|
+
getMode: () => 'default',
|
|
276
|
+
});
|
|
277
|
+
const before = contextStatus();
|
|
278
|
+
session.messages[0].content = 'dense_earlier_message_'.repeat(1_000);
|
|
279
|
+
const after = contextStatus();
|
|
280
|
+
assert.notEqual(after, before);
|
|
281
|
+
assert.ok(after.usedTokens > before.usedTokens);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test('provider callback usage counts assistant output once and estimates only later tool results', () => {
|
|
285
|
+
const session = { provider: 'anthropic', contextWindow: 100_000, compaction: {} };
|
|
286
|
+
const policy = { ...policyFor(session), reserveTokens: 0 };
|
|
287
|
+
const messages = [{ role: 'user', content: 'production-shaped tool request' }];
|
|
288
|
+
const onProviderUsage = d => recordProviderContextBaseline(session, messages, {
|
|
289
|
+
inputTokens: d.deltaInput,
|
|
290
|
+
outputTokens: d.deltaOutput,
|
|
291
|
+
promptTokens: d.deltaPrompt,
|
|
292
|
+
cachedTokens: d.deltaCachedRead,
|
|
293
|
+
cacheWriteTokens: d.deltaCacheWrite,
|
|
294
|
+
}, { boundary: 'request' });
|
|
295
|
+
assert.equal(onProviderUsage({
|
|
296
|
+
source: 'provider_send',
|
|
297
|
+
deltaInput: 5_000,
|
|
298
|
+
deltaOutput: 800,
|
|
299
|
+
deltaPrompt: 0,
|
|
300
|
+
deltaCachedRead: 88_000,
|
|
301
|
+
deltaCacheWrite: 1_000,
|
|
302
|
+
}), true);
|
|
303
|
+
messages.push({
|
|
304
|
+
role: 'assistant',
|
|
305
|
+
content: 'Calling the requested tool.',
|
|
306
|
+
reasoningItems: [{ type: 'reasoning', encrypted_content: 'opaque-provider-reasoning' }],
|
|
307
|
+
toolCalls: [{ id: 'call_1', name: 'read', arguments: '{"path":"large.txt"}' }],
|
|
308
|
+
});
|
|
309
|
+
const laterToolResult = { role: 'tool', toolCallId: 'call_1', content: 'x'.repeat(4_000) };
|
|
310
|
+
messages.push(laterToolResult);
|
|
311
|
+
|
|
312
|
+
const wholeEstimate = estimateMessagesTokens(messages);
|
|
313
|
+
assert.ok(wholeEstimate < policy.triggerTokens, 'fixture must reproduce local estimator undercount');
|
|
314
|
+
const pressure = compactionTelemetryPressureTokens(wholeEstimate, policy, { messages, sessionRef: session });
|
|
315
|
+
const expectedPressure = 94_800 + estimateMessagesTokens([laterToolResult]);
|
|
316
|
+
assert.equal(pressure, expectedPressure, 'assistant output/reasoning must stay in actual usage, not be estimated again');
|
|
317
|
+
assert.ok(pressure >= 95_000, `actual usage plus later tool growth should cross trigger, got ${pressure}`);
|
|
318
|
+
assert.equal(shouldCompactForSession(wholeEstimate, policy, {
|
|
319
|
+
messages,
|
|
320
|
+
sessionRef: session,
|
|
321
|
+
pressureTokens: pressure,
|
|
322
|
+
}), true);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test('thinking-only continuation without assistant replay excludes provider output and estimates the nudge', () => {
|
|
326
|
+
const session = { provider: 'anthropic', contextWindow: 100_000, compaction: {} };
|
|
327
|
+
const policy = { ...policyFor(session), reserveTokens: 0 };
|
|
328
|
+
const messages = [{ role: 'user', content: 'request that returns thinking but no replayable assistant message' }];
|
|
329
|
+
recordProviderContextBaseline(session, messages, {
|
|
330
|
+
inputTokens: 5_000,
|
|
331
|
+
outputTokens: 2_000,
|
|
332
|
+
cachedTokens: 88_000,
|
|
333
|
+
cacheWriteTokens: 1_000,
|
|
334
|
+
}, { boundary: 'request' });
|
|
335
|
+
const nudge = {
|
|
336
|
+
role: 'user',
|
|
337
|
+
content: '[mixdog-runtime] Previous response was empty. Continue with a final answer or tool call.',
|
|
338
|
+
};
|
|
339
|
+
messages.push(nudge);
|
|
340
|
+
|
|
341
|
+
const wholeEstimate = estimateMessagesTokens(messages);
|
|
342
|
+
const pressure = compactionTelemetryPressureTokens(wholeEstimate, policy, { messages, sessionRef: session });
|
|
343
|
+
const expectedPressure = 94_000 + estimateMessagesTokens([nudge]);
|
|
344
|
+
assert.equal(pressure, expectedPressure, 'unreplayed output must be removed while the later nudge is estimated');
|
|
345
|
+
assert.equal(shouldCompactForSession(wholeEstimate, policy, {
|
|
346
|
+
messages,
|
|
347
|
+
sessionRef: session,
|
|
348
|
+
pressureTokens: pressure,
|
|
349
|
+
}), false, 'unreplayed thinking output must not cause an early compact');
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test('successful compact invalidates stale usage and cannot immediately compact again', () => {
|
|
353
|
+
const session = { provider: 'openai', contextWindow: 100_000, compaction: {} };
|
|
354
|
+
const policy = { ...policyFor(session), reserveTokens: 0 };
|
|
355
|
+
const before = [{ role: 'user', content: 'old context' }];
|
|
356
|
+
recordProviderContextBaseline(session, before, { inputTokens: 99_000, outputTokens: 500 });
|
|
357
|
+
assert.equal(shouldCompactForSession(estimateMessagesTokens(before), policy, {
|
|
358
|
+
messages: before,
|
|
359
|
+
sessionRef: session,
|
|
360
|
+
}), true);
|
|
361
|
+
|
|
362
|
+
rememberCompactTelemetry(session, policy, {
|
|
363
|
+
compactChanged: true,
|
|
364
|
+
beforeTokens: 99_500,
|
|
365
|
+
afterTokens: 20,
|
|
366
|
+
pressureTokens: 99_500,
|
|
367
|
+
});
|
|
368
|
+
const compacted = [{ role: 'user', content: 'short summary' }];
|
|
369
|
+
const compactedEstimate = estimateMessagesTokens(compacted);
|
|
370
|
+
assert.equal(session.contextPressureBaselineTokens, null);
|
|
371
|
+
assert.equal(session.lastContextTokensStaleAfterCompact, true);
|
|
372
|
+
assert.equal(shouldCompactForSession(compactedEstimate, policy, {
|
|
373
|
+
messages: compacted,
|
|
374
|
+
sessionRef: session,
|
|
375
|
+
}), false, 'stale pre-compact usage must not trigger a consecutive compact');
|
|
376
|
+
|
|
377
|
+
recordProviderContextBaseline(session, compacted, { inputTokens: 10_000, outputTokens: 100 });
|
|
378
|
+
assert.equal(session.lastContextTokensStaleAfterCompact, false);
|
|
379
|
+
assert.equal(shouldCompactForSession(compactedEstimate, policy, {
|
|
380
|
+
messages: compacted,
|
|
381
|
+
sessionRef: session,
|
|
382
|
+
}), false, 'fresh post-compact usage may be reused safely');
|
|
383
|
+
});
|
|
@@ -55,14 +55,14 @@ function assert(condition, message) {
|
|
|
55
55
|
`sub-boundary explicit limit should be preserved, got ${meta.autoCompactTokenLimit}`);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
// 3) compactTriggerForSession: main/user (non-agent) recall-fasttrack
|
|
59
|
-
//
|
|
58
|
+
// 3) compactTriggerForSession: main/user (non-agent) recall-fasttrack keeps 5%
|
|
59
|
+
// headroom (95% trigger); a truly-explicit
|
|
60
60
|
// sub-boundary autoCompactTokenLimit still wins. Agent-owned semantic
|
|
61
61
|
// sessions keep the default 10% buffer (90%).
|
|
62
62
|
{
|
|
63
63
|
const boundary = 200000;
|
|
64
64
|
const legacy = compactTriggerForSession({ autoCompactTokenLimit: boundary, compaction: {} }, boundary);
|
|
65
|
-
assert(legacy ===
|
|
65
|
+
assert(legacy === 190000, `main/user boundary-equal limit should yield 95% trigger 190000, got ${legacy}`);
|
|
66
66
|
const explicit = compactTriggerForSession({ autoCompactTokenLimit: 150000, compaction: {} }, boundary);
|
|
67
67
|
assert(explicit === 150000, `sub-boundary explicit limit should be the trigger, got ${explicit}`);
|
|
68
68
|
const agent = compactTriggerForSession({ owner: 'agent', autoCompactTokenLimit: boundary, compaction: {} }, boundary);
|
|
@@ -72,19 +72,19 @@ function assert(condition, message) {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
// 4) A configured bufferPercent flows into the trigger for agent-owned sessions;
|
|
75
|
-
// main/user sessions
|
|
75
|
+
// main/user sessions use their fixed 5% headroom.
|
|
76
76
|
{
|
|
77
77
|
const boundary = 200000;
|
|
78
78
|
const agentTrigger = compactTriggerForSession({ owner: 'agent', compaction: { bufferPercent: 5 } }, boundary);
|
|
79
79
|
assert(agentTrigger === 190000, `agent bufferPercent 5 should yield trigger 190000, got ${agentTrigger}`);
|
|
80
80
|
const userTrigger = compactTriggerForSession({ compaction: { bufferPercent: 5 } }, boundary);
|
|
81
|
-
assert(userTrigger ===
|
|
81
|
+
assert(userTrigger === 190000, `main/user should keep 5% headroom and trigger at 190000, got ${userTrigger}`);
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
// 5) Legacy/zero buffer telemetry migration is an agent-path concern (the
|
|
85
85
|
// default-buffer sanitizer). Agent-owned sessions reapply the current 10%
|
|
86
86
|
// default (trigger 180000); explicit bufferTokens still lowers the agent
|
|
87
|
-
// trigger. Main/user sessions ignore all of it and compact at
|
|
87
|
+
// trigger. Main/user sessions ignore all of it and compact at 95%.
|
|
88
88
|
{
|
|
89
89
|
const boundary = 200000;
|
|
90
90
|
const agentLegacy = compactTriggerForSession({
|
|
@@ -105,8 +105,8 @@ function assert(condition, message) {
|
|
|
105
105
|
const userTelemetry = compactTriggerForSession({
|
|
106
106
|
compaction: { boundaryTokens: boundary, triggerTokens: 180000, bufferTokens: 20000, bufferRatio: 0.1 },
|
|
107
107
|
}, boundary);
|
|
108
|
-
assert(userTelemetry ===
|
|
109
|
-
`main/user should ignore buffer telemetry and
|
|
108
|
+
assert(userTelemetry === 190000,
|
|
109
|
+
`main/user should ignore buffer telemetry and keep 5% headroom at 190000, got ${userTelemetry}`);
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
// 6) preserveBufferConfigFields copies only finite-positive percent/ratio fields.
|
|
@@ -57,20 +57,28 @@ 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');
|
|
64
63
|
const common = readSrc('rules', 'agent', '00-common.md');
|
|
65
64
|
const skip = readSrc('rules', 'agent', '20-skip-protocol.md');
|
|
66
|
-
const
|
|
65
|
+
const OPTIONAL_BRIEF_FIELDS = ['Anchors:', 'Allow/Forbid:', 'Deliver:', 'Verify:'];
|
|
67
66
|
const TOKEN_PRINCIPLE = /minimum (?:characters|chars), maximum (?:information|info)/i;
|
|
68
67
|
function requireAll(text, label, patterns) {
|
|
69
68
|
for (const pattern of patterns) assert(pattern.test(normalize(text).toLowerCase()), `${label}: missing ${pattern}`);
|
|
70
69
|
}
|
|
71
70
|
assert(TOKEN_PRINCIPLE.test(normalize(leadBrief)), 'lead-brief.md: brief must state min-char/max-info principle');
|
|
72
|
-
|
|
73
|
-
assert(leadBrief.includes('
|
|
71
|
+
assert(leadBrief.includes('Task:'), 'lead-brief.md: brief must require labeled field Task:');
|
|
72
|
+
assert(!leadBrief.includes('Goal:'), 'lead-brief.md: Goal: must be replaced by Task:');
|
|
73
|
+
for (const field of OPTIONAL_BRIEF_FIELDS) assert(leadBrief.includes(field), `lead-brief.md: brief missing optional labeled field ${field}`);
|
|
74
|
+
requireAll(leadBrief, 'Lead brief task', [
|
|
75
|
+
/`task:` is mandatory and lossless/, /intent/, /required outcomes/, /negative outcomes/,
|
|
76
|
+
/completion\/stop boundary/, /all other fields are optional task-specific deltas/,
|
|
77
|
+
/preserve user-supplied exact targets and exact replacements\/outputs in `task:`/,
|
|
78
|
+
/never infer exactness from a task name, file count, or perceived difficulty/,
|
|
79
|
+
/exact same `task:` across worker -> debugger -> reviewer/,
|
|
80
|
+
/never summarize, rewrite, or replace it/,
|
|
81
|
+
]);
|
|
74
82
|
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
83
|
assert(/spec\/test beats its summary/i.test(normalize(leadBrief)), 'lead-brief.md: spec/test must beat summary');
|
|
76
84
|
requireAll(leadBrief, 'Lead brief lifecycle', [
|
|
@@ -80,7 +88,7 @@ requireAll(leadBrief, 'Lead brief lifecycle', [
|
|
|
80
88
|
/agent communication is english/,
|
|
81
89
|
]);
|
|
82
90
|
assert(/lead brief contract/i.test(normalize(workflow)), 'WORKFLOW.md: must defer to the lead brief contract');
|
|
83
|
-
assert(!
|
|
91
|
+
assert(!OPTIONAL_BRIEF_FIELDS.every((field) => workflow.includes(field)), 'WORKFLOW.md: must not duplicate the optional brief field list');
|
|
84
92
|
|
|
85
93
|
assert(/fragments/i.test(core), '00-core: handoff must require fragments');
|
|
86
94
|
assert(/file:line/i.test(core), '00-core: handoff must anchor evidence to file:line');
|
|
@@ -112,19 +120,35 @@ requireAll(workflow, 'Default approval', [
|
|
|
112
120
|
/initial\/additional\/changed requests reset planning/, /scope change needs a revised plan and fresh approval/,
|
|
113
121
|
/no edits, state mutation, or delegation/,
|
|
114
122
|
]);
|
|
115
|
-
|
|
116
|
-
/
|
|
117
|
-
/
|
|
118
|
-
/
|
|
119
|
-
/
|
|
120
|
-
/
|
|
121
|
-
|
|
123
|
+
const ROUTING_REVIEW_POLICY = [
|
|
124
|
+
/lead-direct work is allowed only for pure read\/analysis, git\/configuration/,
|
|
125
|
+
/user explicitly supplies both the exact target and exact replacement\/output/,
|
|
126
|
+
/never infer an exemption from a task name, file count, or perceived difficulty/,
|
|
127
|
+
/every other implementation, reverse engineering, debugging application, or artifact generation delegates to the matching agent/,
|
|
128
|
+
/debugger owns diagnosis and reverse engineering/,
|
|
129
|
+
/worker applies an established bounded change or fully specified artifact/,
|
|
130
|
+
/heavy worker owns implementation that must establish the change through investigation or staged delivery/,
|
|
131
|
+
/applying a debugger result is implementation, not diagnosis/,
|
|
132
|
+
/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/,
|
|
133
|
+
/every non-exempt mutation or artifact/,
|
|
134
|
+
/worker, heavy worker, debugger, or lead/,
|
|
135
|
+
/one reviewer .*lead integration\/cross-scope verification in parallel/,
|
|
136
|
+
/debugger analysis cannot substitute for implementation review/,
|
|
137
|
+
/applying a debugger result triggers the same reviewer \+ lead verification/,
|
|
138
|
+
/loop fix -> re-verify \(same reviewer \+ lead re-check\) until clean/,
|
|
139
|
+
/exempt mutations still require shell self-verification/,
|
|
140
|
+
];
|
|
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
|
+
);
|
|
122
146
|
requireAll(workflow, 'Default lifecycle', [
|
|
123
|
-
/draft before any implementation/, /
|
|
124
|
-
/
|
|
125
|
-
/after async spawn, end the turn/,
|
|
126
|
-
/
|
|
127
|
-
/original live session/,
|
|
147
|
+
/draft before any implementation/, /all ready scopes in one turn, with no count cap/,
|
|
148
|
+
/real dependency, overlapping write, or inseparable coupling/,
|
|
149
|
+
/after async spawn, end the turn/,
|
|
150
|
+
/high-risk scopes add distinct lenses/,
|
|
151
|
+
/original live session/,
|
|
128
152
|
/bug surviving 2\+ fix cycles/, /agent reports relay.*as in-progress, never conclusions/,
|
|
129
153
|
/final \(not interim\) report/, /never forward raw agent output/,
|
|
130
154
|
/explicit user request after issue-free feedback/, /outcome\/direction change, pause and re-consult/,
|
|
@@ -137,24 +161,17 @@ requireAll(solo, 'Solo lifecycle', [
|
|
|
137
161
|
/scope change needs a revised plan and fresh approval/,
|
|
138
162
|
/checks\/fixes directly until clean or reports a blocker/, /final \(not interim\) report/,
|
|
139
163
|
/interim updates are in-progress, never conclusions/,
|
|
140
|
-
/issue-free
|
|
141
|
-
]);
|
|
142
|
-
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/,
|
|
164
|
+
/issue-free feedback/,
|
|
150
165
|
]);
|
|
151
166
|
requireAll(leadTool, 'Lead tools', [
|
|
152
167
|
/write-role agents self-verify/, /cross-scope verification.*benches.*all git/,
|
|
153
|
-
/workflow permits delegation/, /no-delegation workflow.*controls/,
|
|
154
168
|
]);
|
|
155
169
|
requireAll(general, 'General safety', [
|
|
156
170
|
/identify as mixdog\/current coding agent/, /destructive\/hard-to-reverse action needs explicit confirmation/,
|
|
157
|
-
|
|
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/,
|
|
158
175
|
]);
|
|
159
176
|
requireAll(skip, 'Silent skip', [
|
|
160
177
|
/webhook-handler/, /scheduler-task/, /label-only, duplicate\/dedup, no action needed\/report/,
|
|
@@ -195,7 +212,11 @@ Review the approved intent, diff, and tests with independent judgment. Prioritiz
|
|
|
195
212
|
actionable correctness, regression, security, and verification risks; inspect
|
|
196
213
|
affected boundaries. Do not reimplement the change or report non-risky nits.
|
|
197
214
|
Report findings first, severity-ordered, with one line per \`file:line\`. If clean,
|
|
198
|
-
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.`,
|
|
199
220
|
'debugger/AGENT.md': `# Debugger
|
|
200
221
|
Root-cause analysis agent.
|
|
201
222
|
|
|
@@ -220,7 +241,9 @@ const dataDir = mkdtempSync(join(tmpdir(), 'mixdog-internal-comms-smoke-'));
|
|
|
220
241
|
try {
|
|
221
242
|
const leadRules = rulesBuilder.buildInjectionContent({ PLUGIN_ROOT: join(root, 'src'), DATA_DIR: dataDir });
|
|
222
243
|
assert(TOKEN_PRINCIPLE.test(normalize(leadRules)), 'injected Lead rules must carry the brief token principle');
|
|
223
|
-
|
|
244
|
+
assert(leadRules.includes('Task:'), 'injected Lead rules missing mandatory brief field Task:');
|
|
245
|
+
assert(!leadRules.includes('Goal:'), 'injected Lead rules must not retain Goal:');
|
|
246
|
+
for (const field of OPTIONAL_BRIEF_FIELDS) assert(leadRules.includes(field), `injected Lead rules missing optional brief field ${field}`);
|
|
224
247
|
} finally {
|
|
225
248
|
rmSync(dataDir, { recursive: true, force: true });
|
|
226
249
|
}
|
|
@@ -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
|
+
});
|