mixdog 0.9.41 → 0.9.44
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 +3 -2
- package/scripts/agent-tag-reuse-smoke.mjs +124 -10
- package/scripts/ansi-color-capability-test.mjs +90 -0
- 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/generate-runtime-manifest.mjs +39 -22
- package/scripts/grok-oauth-refresh-race-test.mjs +198 -0
- package/scripts/internal-comms-bench.mjs +0 -1
- package/scripts/internal-comms-smoke.mjs +38 -39
- package/scripts/internal-tools-normalization-test.mjs +52 -0
- package/scripts/maintenance-default-routes-test.mjs +164 -0
- package/scripts/max-output-recovery-test.mjs +49 -0
- package/scripts/mcp-client-normalization-test.mjs +45 -0
- package/scripts/openai-oauth-ws-1006-retry-test.mjs +123 -0
- package/scripts/provider-toolcall-test.mjs +23 -0
- package/scripts/result-classification-test.mjs +75 -0
- package/scripts/routing-corpus.mjs +1 -1
- package/scripts/shell-jobs-windows-hide-test.mjs +164 -0
- package/scripts/smoke.mjs +1 -1
- package/scripts/submit-commandbusy-race-test.mjs +99 -7
- package/scripts/tui-transcript-jitter-harness-entry.jsx +302 -0
- package/scripts/tui-transcript-jitter-harness.mjs +58 -0
- package/scripts/tui-transcript-perf-test.mjs +56 -1
- package/src/agents/reviewer/AGENT.md +5 -3
- package/src/rules/lead/lead-brief.md +5 -4
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +40 -3
- package/src/runtime/agent/orchestrator/config.mjs +29 -14
- 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/grok-oauth.mjs +46 -21
- package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +59 -2
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +5 -1
- 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/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -5
- package/src/runtime/memory/data/runtime-manifest.json +11 -11
- package/src/runtime/memory/lib/runtime-fetcher.mjs +1 -2
- 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 +267 -72
- package/src/standalone/explore-tool.mjs +17 -16
- package/src/tui/app/provider-setup-picker.mjs +1 -0
- package/src/tui/app/use-transcript-window.mjs +5 -1
- package/src/tui/components/StatusLine.jsx +11 -32
- package/src/tui/dist/index.mjs +257 -106
- 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/tui/index.jsx +3 -2
- package/src/tui/markdown/streaming-markdown.mjs +35 -9
- package/src/tui/statusline-ansi-bridge.mjs +17 -7
- package/src/ui/ansi.mjs +85 -5
- package/src/ui/statusline-agents.mjs +0 -8
- package/src/ui/statusline-format.mjs +7 -7
- 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
|
@@ -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
|
+
});
|
|
@@ -345,7 +345,7 @@ function buildCorpus(rows, { limit, sinceTs, agentFilter }) {
|
|
|
345
345
|
}
|
|
346
346
|
|
|
347
347
|
function scoreCorpus(cases) {
|
|
348
|
-
// A single
|
|
348
|
+
// A single health index: sum of flagged cases per KPI (lower is better).
|
|
349
349
|
const summary = {};
|
|
350
350
|
let flaggedCases = 0;
|
|
351
351
|
for (const k of FLAG_KEYS) summary[k] = cases.filter((c) => c.flags.includes(k)).length;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { readFileSync, rmSync, mkdtempSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import test from 'node:test';
|
|
7
|
+
import {
|
|
8
|
+
killShellJob,
|
|
9
|
+
startBackgroundShellJob,
|
|
10
|
+
waitForShellJob,
|
|
11
|
+
} from '../src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs';
|
|
12
|
+
import { shellJobDetailPath } from '../src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs';
|
|
13
|
+
|
|
14
|
+
function availablePowerShellHosts() {
|
|
15
|
+
return ['powershell.exe', 'pwsh.exe'].filter((host) => {
|
|
16
|
+
const result = spawnSync(host, ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$PSVersionTable.PSVersion.Major'], {
|
|
17
|
+
encoding: 'utf8',
|
|
18
|
+
windowsHide: true,
|
|
19
|
+
});
|
|
20
|
+
return !result.error && result.status === 0;
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const windowProbe = String.raw`
|
|
25
|
+
Add-Type @'
|
|
26
|
+
using System;
|
|
27
|
+
using System.Runtime.InteropServices;
|
|
28
|
+
using System.Text;
|
|
29
|
+
public delegate bool MixdogEnumWindowsProc(IntPtr hWnd, IntPtr lParam);
|
|
30
|
+
public static class MixdogWindowProbe {
|
|
31
|
+
[DllImport("user32.dll")] public static extern bool EnumWindows(MixdogEnumWindowsProc callback, IntPtr lParam);
|
|
32
|
+
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetClassName(IntPtr hWnd, StringBuilder text, int maxCount);
|
|
33
|
+
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
|
|
34
|
+
}
|
|
35
|
+
'@
|
|
36
|
+
$consoleWindowPids = @{}
|
|
37
|
+
$callback = [MixdogEnumWindowsProc] {
|
|
38
|
+
param([IntPtr] $hWnd, [IntPtr] $lParam)
|
|
39
|
+
$className = New-Object System.Text.StringBuilder 256
|
|
40
|
+
[void] [MixdogWindowProbe]::GetClassName($hWnd, $className, $className.Capacity)
|
|
41
|
+
if ($className.ToString() -eq 'ConsoleWindowClass') {
|
|
42
|
+
[uint32] $pid = 0
|
|
43
|
+
[void] [MixdogWindowProbe]::GetWindowThreadProcessId($hWnd, [ref] $pid)
|
|
44
|
+
$consoleWindowPids[[string] $pid] = $true
|
|
45
|
+
}
|
|
46
|
+
return $true
|
|
47
|
+
}
|
|
48
|
+
[void] [MixdogWindowProbe]::EnumWindows($callback, [IntPtr]::Zero)
|
|
49
|
+
Get-CimInstance Win32_Process | ForEach-Object {
|
|
50
|
+
Write-Output "P|$($_.ProcessId)|$($_.ParentProcessId)|$($_.Name)"
|
|
51
|
+
}
|
|
52
|
+
$consoleWindowPids.Keys | ForEach-Object { Write-Output "W|$_" }
|
|
53
|
+
`;
|
|
54
|
+
|
|
55
|
+
function processConsoleSnapshot(probeHost) {
|
|
56
|
+
const result = spawnSync(probeHost, ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', windowProbe], {
|
|
57
|
+
encoding: 'utf8',
|
|
58
|
+
windowsHide: true,
|
|
59
|
+
timeout: 10_000,
|
|
60
|
+
});
|
|
61
|
+
if (result.error || result.status !== 0) {
|
|
62
|
+
throw new Error(`Console window probe failed: ${result.error?.message || result.stderr || result.status}`);
|
|
63
|
+
}
|
|
64
|
+
const processes = new Map();
|
|
65
|
+
const consoleWindowPids = new Set();
|
|
66
|
+
for (const row of result.stdout.split(/\r?\n/)) {
|
|
67
|
+
const [kind, pid, parentPid, name] = row.split('|');
|
|
68
|
+
if (kind === 'P') processes.set(Number(pid), { parentPid: Number(parentPid), name });
|
|
69
|
+
if (kind === 'W') consoleWindowPids.add(Number(pid));
|
|
70
|
+
}
|
|
71
|
+
return { processes, consoleWindowPids };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function processTreePids(processes, rootPid) {
|
|
75
|
+
const tree = new Set([rootPid]);
|
|
76
|
+
let changed = true;
|
|
77
|
+
while (changed) {
|
|
78
|
+
changed = false;
|
|
79
|
+
for (const [pid, process] of processes) {
|
|
80
|
+
if (!tree.has(pid) && tree.has(process.parentPid)) {
|
|
81
|
+
tree.add(pid);
|
|
82
|
+
changed = true;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return tree;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function sleep(ms) {
|
|
90
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function removeJobArtifacts(detail) {
|
|
94
|
+
for (const file of [
|
|
95
|
+
shellJobDetailPath(detail.jobId),
|
|
96
|
+
detail.stdoutPath,
|
|
97
|
+
detail.stderrPath,
|
|
98
|
+
detail.exitPath,
|
|
99
|
+
detail.donePath,
|
|
100
|
+
`${detail.exitPath}.cmd.ps1`,
|
|
101
|
+
`${detail.exitPath}.user.ps1`,
|
|
102
|
+
]) {
|
|
103
|
+
try { rmSync(file, { force: true }); } catch {}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
test('Windows PowerShell shell jobs create no conhost or ConsoleWindowClass window', async (t) => {
|
|
108
|
+
if (process.platform !== 'win32') return t.skip('win32-only');
|
|
109
|
+
const hosts = availablePowerShellHosts();
|
|
110
|
+
if (hosts.length === 0) return t.skip('no PowerShell host installed');
|
|
111
|
+
const workDir = mkdtempSync(join(tmpdir(), 'mixdog-shell-job-window-'));
|
|
112
|
+
const probeHost = hosts[0];
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
for (const shell of hosts) {
|
|
116
|
+
const before = processConsoleSnapshot(probeHost);
|
|
117
|
+
const job = startBackgroundShellJob({
|
|
118
|
+
command: "Start-Sleep -Milliseconds 5000; Write-Output 'mixdog-window-probe-out'; [Console]::Error.WriteLine('mixdog-window-probe-err')",
|
|
119
|
+
timeoutMs: 10_000,
|
|
120
|
+
workDir,
|
|
121
|
+
mergeStderr: false,
|
|
122
|
+
spawnEnv: process.env,
|
|
123
|
+
shell,
|
|
124
|
+
shellType: 'powershell',
|
|
125
|
+
});
|
|
126
|
+
try {
|
|
127
|
+
const newConsoleHosts = new Set();
|
|
128
|
+
for (let i = 0; i < 8; i += 1) {
|
|
129
|
+
await sleep(100);
|
|
130
|
+
const snapshot = processConsoleSnapshot(probeHost);
|
|
131
|
+
const jobTree = processTreePids(snapshot.processes, job.pid);
|
|
132
|
+
// windowsHide may give the outer wrapper one hidden console
|
|
133
|
+
// (a direct child of job.pid). A second conhost belongs to
|
|
134
|
+
// Start-Process only when it is nested below that wrapper.
|
|
135
|
+
const isNestedJobConsole = (pid) => {
|
|
136
|
+
const process = snapshot.processes.get(pid);
|
|
137
|
+
return process && process.parentPid !== job.pid && jobTree.has(process.parentPid);
|
|
138
|
+
};
|
|
139
|
+
for (const [pid, process] of snapshot.processes) {
|
|
140
|
+
if (process.name?.toLowerCase() === 'conhost.exe'
|
|
141
|
+
&& !before.processes.has(pid) && isNestedJobConsole(pid)) {
|
|
142
|
+
newConsoleHosts.add(`${pid}|conhost.exe|parent=${process.parentPid}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
for (const pid of snapshot.consoleWindowPids) {
|
|
146
|
+
if (!before.consoleWindowPids.has(pid) && isNestedJobConsole(pid)) {
|
|
147
|
+
newConsoleHosts.add(`${pid}|ConsoleWindowClass`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const detail = await waitForShellJob(job.jobId, { timeoutMs: 10_000, pollMs: 50 });
|
|
152
|
+
assert.equal(detail?.exitCode, 0, `${shell} preserves the child exit code`);
|
|
153
|
+
assert.equal(readFileSync(job.stdoutPath, 'utf8').trim(), 'mixdog-window-probe-out', `${shell} preserves stdout redirection`);
|
|
154
|
+
assert.equal(readFileSync(job.stderrPath, 'utf8').trim(), 'mixdog-window-probe-err', `${shell} preserves stderr redirection`);
|
|
155
|
+
assert.deepEqual([...newConsoleHosts], [], `${shell} created a console host/window in its process tree: ${[...newConsoleHosts].join(', ')}`);
|
|
156
|
+
} finally {
|
|
157
|
+
killShellJob(job.jobId);
|
|
158
|
+
removeJobArtifacts(job);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
} finally {
|
|
162
|
+
rmSync(workDir, { recursive: true, force: true });
|
|
163
|
+
}
|
|
164
|
+
});
|
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);
|