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.
Files changed (66) hide show
  1. package/package.json +2 -1
  2. package/scripts/agent-tag-reuse-smoke.mjs +124 -10
  3. package/scripts/anthropic-oauth-refresh-race-test.mjs +397 -0
  4. package/scripts/arg-guard-test.mjs +16 -0
  5. package/scripts/compact-pressure-test.mjs +383 -0
  6. package/scripts/compact-trigger-migration-smoke.mjs +8 -8
  7. package/scripts/internal-comms-bench.mjs +0 -1
  8. package/scripts/internal-comms-smoke.mjs +54 -31
  9. package/scripts/internal-tools-normalization-test.mjs +52 -0
  10. package/scripts/max-output-recovery-persist-test.mjs +81 -0
  11. package/scripts/max-output-recovery-test.mjs +271 -0
  12. package/scripts/mcp-client-normalization-test.mjs +45 -0
  13. package/scripts/provider-toolcall-test.mjs +55 -0
  14. package/scripts/result-classification-test.mjs +75 -0
  15. package/scripts/smoke.mjs +1 -1
  16. package/scripts/submit-commandbusy-race-test.mjs +99 -7
  17. package/scripts/tui-transcript-perf-test.mjs +56 -1
  18. package/src/agents/reviewer/AGENT.md +8 -0
  19. package/src/rules/lead/lead-brief.md +11 -3
  20. package/src/rules/lead/lead-tool.md +0 -2
  21. package/src/rules/shared/01-tool.md +4 -0
  22. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +20 -2
  23. package/src/runtime/agent/orchestrator/context/collect.mjs +7 -3
  24. package/src/runtime/agent/orchestrator/internal-tools.mjs +16 -3
  25. package/src/runtime/agent/orchestrator/mcp/client.mjs +17 -1
  26. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +193 -13
  27. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +15 -2
  28. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +14 -0
  29. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +2 -2
  30. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +59 -2
  31. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +57 -25
  32. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +72 -12
  33. package/src/runtime/agent/orchestrator/session/context-utils.mjs +144 -84
  34. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +124 -8
  35. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +12 -1
  36. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +25 -7
  37. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +12 -3
  38. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +28 -1
  39. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -1
  40. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +31 -4
  41. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +11 -2
  42. package/src/runtime/agent/orchestrator/session/result-classification.mjs +4 -1
  43. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +45 -0
  44. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +7 -2
  45. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +8 -6
  46. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +15 -3
  47. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +22 -21
  48. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
  49. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +1 -1
  50. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -10
  51. package/src/runtime/memory/tool-defs.mjs +4 -3
  52. package/src/runtime/shared/tool-surface.mjs +1 -2
  53. package/src/session-runtime/context-status.mjs +8 -2
  54. package/src/standalone/agent-tool/render.mjs +2 -0
  55. package/src/standalone/agent-tool.mjs +202 -22
  56. package/src/tui/components/StatusLine.jsx +4 -26
  57. package/src/tui/dist/index.mjs +46 -31
  58. package/src/tui/engine/session-api.mjs +3 -0
  59. package/src/tui/engine/session-flow.mjs +19 -8
  60. package/src/tui/engine/turn.mjs +24 -2
  61. package/src/tui/engine.mjs +0 -1
  62. package/src/ui/statusline-agents.mjs +0 -8
  63. package/src/ui/statusline.mjs +2 -4
  64. package/src/workflows/default/WORKFLOW.md +38 -24
  65. package/src/workflows/solo-review/WORKFLOW.md +47 -0
  66. 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 compacts
59
- // on the boundary itself (100%, no early-trigger buffer); a truly-explicit
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 === 200000, `main/user boundary-equal limit should yield 100% trigger 200000, got ${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 ignore the buffer and stay on the boundary (100%).
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 === 200000, `main/user should ignore bufferPercent and stay at 100% 200000, got ${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 100%.
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 === 200000,
109
- `main/user should ignore buffer telemetry and stay at 100% 200000, got ${userTelemetry}`);
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.
@@ -38,7 +38,6 @@ const RULE_FILES = [
38
38
  'agents/debugger/AGENT.md',
39
39
  'workflows/default/WORKFLOW.md',
40
40
  'workflows/solo/WORKFLOW.md',
41
- 'workflows/bench/WORKFLOW.md',
42
41
  'rules/lead/01-general.md',
43
42
  'rules/lead/02-channels.md',
44
43
  'rules/lead/lead-tool.md',
@@ -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 BRIEF_FIELDS = ['Goal:', 'Anchors:', 'Allow/Forbid:', 'Deliver:', 'Verify:'];
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
- for (const field of BRIEF_FIELDS) assert(leadBrief.includes(field), `lead-brief.md: brief missing labeled field ${field}`);
73
- assert(leadBrief.includes('Stop:'), 'lead-brief.md: brief must add Stop: for heavy-worker bound');
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(!BRIEF_FIELDS.every((field) => workflow.includes(field)), 'WORKFLOW.md: must not duplicate the full brief field list');
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
- requireAll(workflow, 'Default routing', [
116
- /delegate by default/, /only coordinates, does git, or an obvious 1-edit\/ 1-check change/,
117
- /implementation\/research\/debugging to its matching agent/, /indicators, not automatic categories/,
118
- /worker: bounded established path, low local risk\/coupling\/verification, clear local check/,
119
- /heavy worker: high risk\/coupling\/verification \(including any high-risk scope\), or coupled staged work needing coordinated verification/,
120
- /reviewer verifies an implementation/, /debugger handles requested debugging or root cause after a failed fix/,
121
- ]);
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/, /parallel gain exceeds coordination\/merge cost/,
124
- /all in one turn, with no count cap/, /real dependency, overlapping write, or inseparable coupling/,
125
- /after async spawn, end the turn/, /every delegated implementation gets one reviewer/,
126
- /lead integration\/cross-scope verification in parallel/, /high-risk scopes add distinct lenses/,
127
- /original live session/, /loop fix -> re-verify \(same reviewer \+ lead re-check\) until clean/,
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 user feedback/,
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
- /never push, build, deploy without explicit user request/, /implementation approval is not deploy approval/,
171
+ ]);
172
+ requireAll(workflow, 'Default ship safety', [
173
+ /build\/deploy\/commit\/push require an explicit user request after issue-free feedback/,
174
+ /implementation approval alone is insufficient/,
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
- for (const field of BRIEF_FIELDS) assert(leadRules.includes(field), `injected Lead rules missing brief field ${field}`);
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
+ });