mixdog 0.9.41 → 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 +1 -1
- package/scripts/agent-tag-reuse-smoke.mjs +124 -10
- package/scripts/anthropic-oauth-refresh-race-test.mjs +59 -0
- package/scripts/arg-guard-test.mjs +16 -0
- package/scripts/compact-pressure-test.mjs +256 -1
- package/scripts/internal-comms-bench.mjs +0 -1
- package/scripts/internal-comms-smoke.mjs +14 -32
- package/scripts/internal-tools-normalization-test.mjs +52 -0
- package/scripts/max-output-recovery-test.mjs +49 -0
- package/scripts/mcp-client-normalization-test.mjs +45 -0
- package/scripts/provider-toolcall-test.mjs +23 -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 +4 -0
- 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 +49 -6
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +14 -0
- package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +59 -2
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +18 -0
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +140 -81
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +23 -5
- package/src/runtime/agent/orchestrator/session/loop/termination.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +12 -3
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +4 -2
- package/src/runtime/agent/orchestrator/session/result-classification.mjs +4 -1
- 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/shell-analysis.mjs +1 -1
- 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 +29 -20
- package/src/workflows/solo-review/WORKFLOW.md +47 -0
- package/src/workflows/bench/WORKFLOW.md +0 -60
|
@@ -57,7 +57,6 @@ 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');
|
|
@@ -121,21 +120,6 @@ requireAll(workflow, 'Default approval', [
|
|
|
121
120
|
/initial\/additional\/changed requests reset planning/, /scope change needs a revised plan and fresh approval/,
|
|
122
121
|
/no edits, state mutation, or delegation/,
|
|
123
122
|
]);
|
|
124
|
-
function routingReviewBlocks(text, label) {
|
|
125
|
-
const source = String(text).replace(/\r\n/g, '\n');
|
|
126
|
-
return {
|
|
127
|
-
routing: block(source, 'Lead orchestrates and verifies.', '1. Plan:', `${label} routing policy`),
|
|
128
|
-
review: block(source, '3. Review:', '\n4.', `${label} review policy`),
|
|
129
|
-
};
|
|
130
|
-
}
|
|
131
|
-
const defaultPolicy = routingReviewBlocks(workflow, 'Default');
|
|
132
|
-
const benchPolicy = routingReviewBlocks(bench, 'Bench');
|
|
133
|
-
for (const section of ['routing', 'review']) {
|
|
134
|
-
assert(
|
|
135
|
-
defaultPolicy[section] === benchPolicy[section],
|
|
136
|
-
`Default/Bench ${section} policy must be identical after normalization`,
|
|
137
|
-
);
|
|
138
|
-
}
|
|
139
123
|
const ROUTING_REVIEW_POLICY = [
|
|
140
124
|
/lead-direct work is allowed only for pure read\/analysis, git\/configuration/,
|
|
141
125
|
/user explicitly supplies both the exact target and exact replacement\/output/,
|
|
@@ -154,13 +138,11 @@ const ROUTING_REVIEW_POLICY = [
|
|
|
154
138
|
/loop fix -> re-verify \(same reviewer \+ lead re-check\) until clean/,
|
|
155
139
|
/exempt mutations still require shell self-verification/,
|
|
156
140
|
];
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
);
|
|
163
|
-
}
|
|
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
|
+
);
|
|
164
146
|
requireAll(workflow, 'Default lifecycle', [
|
|
165
147
|
/draft before any implementation/, /all ready scopes in one turn, with no count cap/,
|
|
166
148
|
/real dependency, overlapping write, or inseparable coupling/,
|
|
@@ -181,19 +163,15 @@ requireAll(solo, 'Solo lifecycle', [
|
|
|
181
163
|
/interim updates are in-progress, never conclusions/,
|
|
182
164
|
/issue-free feedback/,
|
|
183
165
|
]);
|
|
184
|
-
requireAll(bench, 'Bench lifecycle', [
|
|
185
|
-
/never ask questions, propose plans for approval, or end the turn waiting/,
|
|
186
|
-
/verified complete or provably blocked/, /all ready scopes in one turn, with no count cap/,
|
|
187
|
-
/after async spawn, end the turn/, /original live session/,
|
|
188
|
-
/hard blocked/, /outcome and evidence/,
|
|
189
|
-
]);
|
|
190
166
|
requireAll(leadTool, 'Lead tools', [
|
|
191
167
|
/write-role agents self-verify/, /cross-scope verification.*benches.*all git/,
|
|
192
|
-
/workflow permits delegation/, /no-delegation workflow.*controls/,
|
|
193
168
|
]);
|
|
194
169
|
requireAll(general, 'General safety', [
|
|
195
170
|
/identify as mixdog\/current coding agent/, /destructive\/hard-to-reverse action needs explicit confirmation/,
|
|
196
|
-
|
|
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/,
|
|
197
175
|
]);
|
|
198
176
|
requireAll(skip, 'Silent skip', [
|
|
199
177
|
/webhook-handler/, /scheduler-task/, /label-only, duplicate\/dedup, no action needed\/report/,
|
|
@@ -234,7 +212,11 @@ Review the approved intent, diff, and tests with independent judgment. Prioritiz
|
|
|
234
212
|
actionable correctness, regression, security, and verification risks; inspect
|
|
235
213
|
affected boundaries. Do not reimplement the change or report non-risky nits.
|
|
236
214
|
Report findings first, severity-ordered, with one line per \`file:line\`. If clean,
|
|
237
|
-
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.`,
|
|
238
220
|
'debugger/AGENT.md': `# Debugger
|
|
239
221
|
Root-cause analysis agent.
|
|
240
222
|
|
|
@@ -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
|
+
});
|
|
@@ -3,6 +3,7 @@ import test from 'node:test';
|
|
|
3
3
|
import assert from 'node:assert/strict';
|
|
4
4
|
|
|
5
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';
|
|
6
7
|
|
|
7
8
|
function queuedProvider(responses, streamed = []) {
|
|
8
9
|
const sent = [];
|
|
@@ -35,6 +36,7 @@ function queuedProvider(responses, streamed = []) {
|
|
|
35
36
|
async function run(provider, messages = [{ role: 'user', content: 'answer fully' }], options = {}) {
|
|
36
37
|
return agentLoop(provider, messages, 'fake-model', [], options.onToolCall, process.cwd(), {
|
|
37
38
|
onTextDelta: options.onTextDelta,
|
|
39
|
+
session: options.session,
|
|
38
40
|
});
|
|
39
41
|
}
|
|
40
42
|
|
|
@@ -126,6 +128,53 @@ test('pause_turn and Gemini OTHER preserve prior non-empty semantics and do not
|
|
|
126
128
|
}
|
|
127
129
|
});
|
|
128
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
|
+
|
|
129
178
|
test('non-output ProviderIncompleteError remains an error', async () => {
|
|
130
179
|
const other = Object.assign(new Error('Gemini response incomplete: finishReason=OTHER'), {
|
|
131
180
|
code: 'PROVIDER_INCOMPLETE',
|
|
@@ -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
|
+
});
|
|
@@ -95,6 +95,29 @@ function anthropicSseResponse(events) {
|
|
|
95
95
|
};
|
|
96
96
|
}
|
|
97
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
|
+
|
|
98
121
|
function compatResponsesEventStream(events) {
|
|
99
122
|
return {
|
|
100
123
|
async *[Symbol.asyncIterator]() {
|
|
@@ -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', `
|
|
@@ -7,12 +7,39 @@ import { createEngineApiA } from '../src/tui/engine/session-api.mjs';
|
|
|
7
7
|
// command (commandBusy) or an auto-clear. Wires the real session-flow queue
|
|
8
8
|
// (enqueue/drain) + the real submit() against a minimal bag whose set() mirrors
|
|
9
9
|
// engine.mjs's commandBusy-release drain kick.
|
|
10
|
-
function makeEngine({
|
|
10
|
+
function makeEngine({
|
|
11
|
+
autoClearBeforeSubmit,
|
|
12
|
+
autoClearEnabled = false,
|
|
13
|
+
runtimeClear = async () => true,
|
|
14
|
+
} = {}) {
|
|
11
15
|
let seq = 0;
|
|
12
16
|
const executed = [];
|
|
13
|
-
|
|
17
|
+
const providerRequests = [];
|
|
18
|
+
let resolveProviderDispatch;
|
|
19
|
+
const providerDispatch = new Promise((resolve) => { resolveProviderDispatch = resolve; });
|
|
20
|
+
let state = {
|
|
21
|
+
items: [{ kind: 'user', id: 'existing', text: 'existing prompt' }],
|
|
22
|
+
queued: [],
|
|
23
|
+
busy: false,
|
|
24
|
+
commandBusy: false,
|
|
25
|
+
};
|
|
14
26
|
const bag = {
|
|
15
|
-
runtime: {
|
|
27
|
+
runtime: {
|
|
28
|
+
id: 'session_1',
|
|
29
|
+
session: {
|
|
30
|
+
id: 'session_1',
|
|
31
|
+
messages: [{ role: 'user', content: 'existing prompt' }],
|
|
32
|
+
lastUsedAt: Date.now() - 1000,
|
|
33
|
+
},
|
|
34
|
+
getCompactionSettings: () => ({}),
|
|
35
|
+
clear: runtimeClear,
|
|
36
|
+
consumePendingSessionReset: () => null,
|
|
37
|
+
ask: async (text) => {
|
|
38
|
+
providerRequests.push(text);
|
|
39
|
+
resolveProviderDispatch(text);
|
|
40
|
+
return { result: { content: 'ok' }, session: bag.runtime.session };
|
|
41
|
+
},
|
|
42
|
+
},
|
|
16
43
|
nextId: () => `id_${++seq}`,
|
|
17
44
|
tuiDebug: () => {},
|
|
18
45
|
flags: {},
|
|
@@ -30,12 +57,15 @@ function makeEngine({ autoClearBeforeSubmit } = {}) {
|
|
|
30
57
|
if (released) queueMicrotask(() => { void bag.drain?.(); });
|
|
31
58
|
return true;
|
|
32
59
|
},
|
|
33
|
-
pushItem: () => {},
|
|
60
|
+
pushItem: (item) => { state = { ...state, items: [...state.items, item] }; },
|
|
34
61
|
patchItem: () => {},
|
|
35
62
|
replaceItems: (x) => x,
|
|
36
63
|
pushNotice: () => {},
|
|
37
|
-
pushUserOrSyntheticItem: (text) => {
|
|
38
|
-
|
|
64
|
+
pushUserOrSyntheticItem: (text, id) => {
|
|
65
|
+
executed.push(text);
|
|
66
|
+
state = { ...state, items: [...state.items, { kind: 'user', id, text }] };
|
|
67
|
+
},
|
|
68
|
+
autoClearState: () => ({ enabled: autoClearEnabled, idleMs: 0, minContextPercent: 0 }),
|
|
39
69
|
agentStatusState: () => ({}),
|
|
40
70
|
routeState: () => ({}),
|
|
41
71
|
syncContextStats: () => {},
|
|
@@ -44,7 +74,10 @@ function makeEngine({ autoClearBeforeSubmit } = {}) {
|
|
|
44
74
|
requeueEntriesFront: () => {},
|
|
45
75
|
resetStatsAndSyncContext: () => {},
|
|
46
76
|
flushDeferredExecutionPendingResumeKick: () => {},
|
|
47
|
-
runTurn: async () =>
|
|
77
|
+
runTurn: async (text) => {
|
|
78
|
+
await bag.runtime.ask(text);
|
|
79
|
+
return 'ok';
|
|
80
|
+
},
|
|
48
81
|
};
|
|
49
82
|
Object.assign(bag, createSessionFlow(bag));
|
|
50
83
|
if (autoClearBeforeSubmit) bag.autoClearBeforeSubmit = autoClearBeforeSubmit;
|
|
@@ -53,6 +86,14 @@ function makeEngine({ autoClearBeforeSubmit } = {}) {
|
|
|
53
86
|
api,
|
|
54
87
|
bag,
|
|
55
88
|
getExecuted: () => executed,
|
|
89
|
+
getProviderRequests: () => providerRequests,
|
|
90
|
+
waitForProviderDispatch: (timeoutMs = 250) => new Promise((resolve, reject) => {
|
|
91
|
+
const timer = setTimeout(() => reject(new Error(`provider dispatch timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
92
|
+
providerDispatch.then((text) => {
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
resolve(text);
|
|
95
|
+
}, reject);
|
|
96
|
+
}),
|
|
56
97
|
getState: () => bag.getState(),
|
|
57
98
|
mutateState: (mutator) => { mutator(state); },
|
|
58
99
|
};
|
|
@@ -107,6 +148,57 @@ test('submit still enqueues when autoClearBeforeSubmit rejects', async () => {
|
|
|
107
148
|
assert.deepEqual(getExecuted(), ['survives rejection'], 'rejected auto-clear must not lose the prompt');
|
|
108
149
|
});
|
|
109
150
|
|
|
151
|
+
test('runtime clear false skips auto-clear and sends the first post-failure prompt to the provider', async () => {
|
|
152
|
+
const {
|
|
153
|
+
api, bag, getExecuted, getProviderRequests, getState,
|
|
154
|
+
} = makeEngine({ autoClearEnabled: true, runtimeClear: async () => false });
|
|
155
|
+
|
|
156
|
+
assert.equal(api.submit('first after failed auto-clear'), true);
|
|
157
|
+
await tick(); await tick();
|
|
158
|
+
|
|
159
|
+
assert.deepEqual(getExecuted(), ['first after failed auto-clear'], 'user card remains visible');
|
|
160
|
+
assert.deepEqual(getProviderRequests(), ['first after failed auto-clear'], 'first post-failure input reaches provider');
|
|
161
|
+
assert.equal(getState().items.some((item) => item.label === 'Auto-clear skipped'), true);
|
|
162
|
+
assert.equal(getState().items.some((item) => item.label === 'Auto-clear complete'), false);
|
|
163
|
+
assert.equal(getState().items.some((item) => item.id === 'existing'), true, 'failed clear preserves the existing session UI');
|
|
164
|
+
assert.equal(bag.runtime.session.messages[0].content, 'existing prompt', 'failed clear preserves the runtime session');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('late runtime clear false keeps the UI skipped and sends the first post-timeout prompt', async () => {
|
|
168
|
+
let resolveClear;
|
|
169
|
+
let harness;
|
|
170
|
+
const autoClearBeforeSubmit = () => harness.bag.performSessionClear({
|
|
171
|
+
verb: 'Auto-clearing idle conversation',
|
|
172
|
+
doneLabel: 'Auto-clear complete',
|
|
173
|
+
skipLabel: 'Auto-clear skipped',
|
|
174
|
+
surface: 'auto-clear',
|
|
175
|
+
useCompaction: true,
|
|
176
|
+
compactTimeoutMs: 5,
|
|
177
|
+
});
|
|
178
|
+
harness = makeEngine({
|
|
179
|
+
autoClearBeforeSubmit,
|
|
180
|
+
runtimeClear: () => new Promise((resolve) => { resolveClear = resolve; }),
|
|
181
|
+
});
|
|
182
|
+
harness.bag.runtime.getCompactionSettings = () => ({ compactType: 'summary' });
|
|
183
|
+
|
|
184
|
+
assert.equal(harness.api.submit('first after timed-out auto-clear'), true);
|
|
185
|
+
await harness.waitForProviderDispatch();
|
|
186
|
+
assert.equal(typeof resolveClear, 'function', 'compacting clear started');
|
|
187
|
+
assert.deepEqual(
|
|
188
|
+
harness.getProviderRequests(),
|
|
189
|
+
['first after timed-out auto-clear'],
|
|
190
|
+
'first post-timeout input reaches provider before late clear settles',
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
resolveClear(false);
|
|
194
|
+
await tick(); await tick();
|
|
195
|
+
|
|
196
|
+
assert.equal(harness.getState().items.some((item) => item.label === 'Auto-clear skipped'), true);
|
|
197
|
+
assert.equal(harness.getState().items.some((item) => item.label === 'Auto-clear complete'), false);
|
|
198
|
+
assert.equal(harness.getState().items.some((item) => item.id === 'existing'), true, 'late false preserves the existing session UI');
|
|
199
|
+
assert.equal(harness.bag.runtime.session.messages[0].content, 'existing prompt', 'late false preserves the runtime session');
|
|
200
|
+
});
|
|
201
|
+
|
|
110
202
|
test('empty and whitespace submits are still rejected', () => {
|
|
111
203
|
const { api, bag } = makeEngine();
|
|
112
204
|
assert.equal(api.submit(' '), false);
|
|
@@ -8,7 +8,7 @@ import { buildTranscriptRowIndexIncremental } from '../src/tui/app/transcript-wi
|
|
|
8
8
|
|
|
9
9
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
10
10
|
|
|
11
|
-
function makeTurnHarness(ask) {
|
|
11
|
+
function makeTurnHarness(ask, stateOverrides = {}) {
|
|
12
12
|
let seq = 0;
|
|
13
13
|
let state = {
|
|
14
14
|
items: [],
|
|
@@ -18,6 +18,7 @@ function makeTurnHarness(ask) {
|
|
|
18
18
|
busy: false,
|
|
19
19
|
spinner: null,
|
|
20
20
|
thinking: null,
|
|
21
|
+
...stateOverrides,
|
|
21
22
|
};
|
|
22
23
|
const itemIndexById = new Map();
|
|
23
24
|
const set = (patch) => { state = { ...state, ...patch }; return true; };
|
|
@@ -43,6 +44,15 @@ function makeTurnHarness(ask) {
|
|
|
43
44
|
const clearStreamingTail = (id = null) => {
|
|
44
45
|
if (id == null || state.streamingTail?.id === id) set({ streamingTail: null });
|
|
45
46
|
};
|
|
47
|
+
const replaceItems = (items, options = {}) => {
|
|
48
|
+
state = replaceEngineItemsState({
|
|
49
|
+
state,
|
|
50
|
+
items,
|
|
51
|
+
itemIndexById,
|
|
52
|
+
preserveStreamingTail: options.preserveStreamingTail === true,
|
|
53
|
+
});
|
|
54
|
+
return items;
|
|
55
|
+
};
|
|
46
56
|
const bag = {
|
|
47
57
|
runtime: { id: null, toolMode: 'auto', ask, abort: () => true },
|
|
48
58
|
nextId: () => `id_${++seq}`,
|
|
@@ -55,6 +65,7 @@ function makeTurnHarness(ask) {
|
|
|
55
65
|
set,
|
|
56
66
|
pushItem,
|
|
57
67
|
patchItem,
|
|
68
|
+
replaceItems,
|
|
58
69
|
updateStreamingTail,
|
|
59
70
|
settleStreamingTail,
|
|
60
71
|
clearStreamingTail,
|
|
@@ -77,6 +88,50 @@ function makeTurnHarness(ask) {
|
|
|
77
88
|
return { runTurn: createRunTurn(bag), getState: () => state };
|
|
78
89
|
}
|
|
79
90
|
|
|
91
|
+
test('successful mid-turn compact trims history but preserves live turn references', async () => {
|
|
92
|
+
let preservedTail = false;
|
|
93
|
+
const harness = makeTurnHarness(async (_text, options) => {
|
|
94
|
+
options.onTextDelta('before compact\n');
|
|
95
|
+
await wait(30);
|
|
96
|
+
options.onCompactEvent({ status: 'compacted', trigger: 'reactive' });
|
|
97
|
+
preservedTail = harness.getState().streamingTail?.text === 'before compact\n';
|
|
98
|
+
options.onCompactEvent({ status: 'compacted', trigger: 'reactive' });
|
|
99
|
+
options.onTextDelta('after compact\n');
|
|
100
|
+
return { result: { content: 'before compact\nafter compact\n' }, session: { messages: [] } };
|
|
101
|
+
}, {
|
|
102
|
+
items: [
|
|
103
|
+
{ id: 'old', kind: 'assistant', text: 'old history' },
|
|
104
|
+
{ id: 'current-user', kind: 'user', text: 'go' },
|
|
105
|
+
],
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
assert.equal(await harness.runTurn('go', { submittedIds: ['current-user'] }), 'done');
|
|
109
|
+
assert.equal(preservedTail, true);
|
|
110
|
+
assert.equal(harness.getState().items.some((item) => item.id === 'old'), false);
|
|
111
|
+
assert.equal(harness.getState().items.some((item) => item.id === 'current-user'), true);
|
|
112
|
+
assert.equal(
|
|
113
|
+
harness.getState().items.filter((item) => item.label === 'Compact complete (overflow recovery)').length,
|
|
114
|
+
2,
|
|
115
|
+
);
|
|
116
|
+
assert.equal(
|
|
117
|
+
harness.getState().items.find((item) => item.kind === 'assistant')?.text,
|
|
118
|
+
'before compact\nafter compact\n',
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('failed mid-turn compact leaves prior transcript items untouched', async () => {
|
|
123
|
+
const harness = makeTurnHarness(async (_text, options) => {
|
|
124
|
+
options.onCompactEvent({ status: 'failed' });
|
|
125
|
+
return { result: { content: '' }, session: { messages: [] } };
|
|
126
|
+
}, {
|
|
127
|
+
items: [{ id: 'old', kind: 'assistant', text: 'old history' }],
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
assert.equal(await harness.runTurn('go'), 'done');
|
|
131
|
+
assert.equal(harness.getState().items.some((item) => item.id === 'old'), true);
|
|
132
|
+
assert.equal(harness.getState().items.some((item) => item.label === 'Compact failed'), true);
|
|
133
|
+
});
|
|
134
|
+
|
|
80
135
|
test('stream flush keeps settled items identity and finalize appends one assistant', async () => {
|
|
81
136
|
let identityStable = false;
|
|
82
137
|
const harness = makeTurnHarness(async (_text, options) => {
|
|
@@ -9,6 +9,10 @@ Independent regression/risk review agent.
|
|
|
9
9
|
Review the approved intent, diff, and tests with independent judgment. Prioritize
|
|
10
10
|
actionable correctness, regression, security, and verification risks; inspect
|
|
11
11
|
affected boundaries. Do not reimplement the change or report non-risky nits.
|
|
12
|
+
Treat implementer and Lead reports as claims to test rather than evidence;
|
|
13
|
+
independently evaluate the final deliverable against the original request and
|
|
14
|
+
available evidence with a critical lens, actively seeking errors, unsupported
|
|
15
|
+
assumptions, and counterexamples before confirming.
|
|
12
16
|
Report findings first, severity-ordered, with one line per `file:line`. If clean,
|
|
13
17
|
say so in one line and include only material residual risk.
|
|
14
18
|
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
* no-tool role guard) — not a permission check. No gating is needed here.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { isToolEnvelope, makeToolEnvelope } from './session/tool-envelope.mjs';
|
|
18
|
+
import { isToolEnvelope, makeToolEnvelope, normalizeToolEnvelope } from './session/tool-envelope.mjs';
|
|
19
|
+
import { classifyResultKind } from './session/result-classification.mjs';
|
|
19
20
|
|
|
20
21
|
let _executor = null;
|
|
21
22
|
let _tools = [];
|
|
@@ -62,12 +63,24 @@ function _normalize(result) {
|
|
|
62
63
|
// JSON.stringify below and the loop would see the stringified envelope as
|
|
63
64
|
// the tool_result (body inlined + duplicated, no newMessages).
|
|
64
65
|
if (isToolEnvelope(result)) {
|
|
65
|
-
|
|
66
|
+
const normalized = normalizeToolEnvelope(_normalize(result.result));
|
|
67
|
+
return makeToolEnvelope(normalized.result, result.newMessages, {
|
|
68
|
+
explicitSuccess: normalized.explicitSuccess || result.explicitSuccess === true,
|
|
69
|
+
});
|
|
66
70
|
}
|
|
67
71
|
if (result && typeof result === 'object' && Array.isArray(result.content)) {
|
|
68
|
-
|
|
72
|
+
const text = result.content
|
|
69
73
|
.map((c) => (c?.type === 'text' ? c.text || '' : JSON.stringify(c)))
|
|
70
74
|
.join('\n');
|
|
75
|
+
// Preserve MCP-style handler outcome metadata across the object→string
|
|
76
|
+
// boundary. Explicit failures retain the canonical Error: convention;
|
|
77
|
+
// explicit successes use a transient envelope so legitimate output
|
|
78
|
+
// beginning with Error: is not mistaken for a failed execution.
|
|
79
|
+
if (result.isError === true) return !text.startsWith('Error:') ? `Error: ${text}` : text;
|
|
80
|
+
if (result.isError === false && classifyResultKind(text) === 'error') {
|
|
81
|
+
return makeToolEnvelope(text, [], { explicitSuccess: true });
|
|
82
|
+
}
|
|
83
|
+
return text;
|
|
71
84
|
}
|
|
72
85
|
if (typeof result === 'string') return result;
|
|
73
86
|
return JSON.stringify(result);
|