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.
Files changed (73) hide show
  1. package/package.json +3 -2
  2. package/scripts/agent-tag-reuse-smoke.mjs +124 -10
  3. package/scripts/ansi-color-capability-test.mjs +90 -0
  4. package/scripts/anthropic-oauth-refresh-race-test.mjs +59 -0
  5. package/scripts/arg-guard-test.mjs +16 -0
  6. package/scripts/compact-pressure-test.mjs +256 -1
  7. package/scripts/generate-runtime-manifest.mjs +39 -22
  8. package/scripts/grok-oauth-refresh-race-test.mjs +198 -0
  9. package/scripts/internal-comms-bench.mjs +0 -1
  10. package/scripts/internal-comms-smoke.mjs +38 -39
  11. package/scripts/internal-tools-normalization-test.mjs +52 -0
  12. package/scripts/maintenance-default-routes-test.mjs +164 -0
  13. package/scripts/max-output-recovery-test.mjs +49 -0
  14. package/scripts/mcp-client-normalization-test.mjs +45 -0
  15. package/scripts/openai-oauth-ws-1006-retry-test.mjs +123 -0
  16. package/scripts/provider-toolcall-test.mjs +23 -0
  17. package/scripts/result-classification-test.mjs +75 -0
  18. package/scripts/routing-corpus.mjs +1 -1
  19. package/scripts/shell-jobs-windows-hide-test.mjs +164 -0
  20. package/scripts/smoke.mjs +1 -1
  21. package/scripts/submit-commandbusy-race-test.mjs +99 -7
  22. package/scripts/tui-transcript-jitter-harness-entry.jsx +302 -0
  23. package/scripts/tui-transcript-jitter-harness.mjs +58 -0
  24. package/scripts/tui-transcript-perf-test.mjs +56 -1
  25. package/src/agents/reviewer/AGENT.md +5 -3
  26. package/src/rules/lead/lead-brief.md +5 -4
  27. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +40 -3
  28. package/src/runtime/agent/orchestrator/config.mjs +29 -14
  29. package/src/runtime/agent/orchestrator/internal-tools.mjs +16 -3
  30. package/src/runtime/agent/orchestrator/mcp/client.mjs +17 -1
  31. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +49 -6
  32. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +14 -0
  33. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +46 -21
  34. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +59 -2
  35. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +5 -1
  36. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +18 -0
  37. package/src/runtime/agent/orchestrator/session/context-utils.mjs +140 -81
  38. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +23 -5
  39. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +3 -0
  40. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +12 -3
  41. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +4 -2
  42. package/src/runtime/agent/orchestrator/session/result-classification.mjs +4 -1
  43. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +7 -2
  44. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +8 -6
  45. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +15 -3
  46. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +1 -1
  47. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -5
  48. package/src/runtime/memory/data/runtime-manifest.json +11 -11
  49. package/src/runtime/memory/lib/runtime-fetcher.mjs +1 -2
  50. package/src/runtime/memory/tool-defs.mjs +4 -3
  51. package/src/runtime/shared/tool-surface.mjs +1 -2
  52. package/src/session-runtime/context-status.mjs +8 -2
  53. package/src/standalone/agent-tool/render.mjs +2 -0
  54. package/src/standalone/agent-tool.mjs +267 -72
  55. package/src/standalone/explore-tool.mjs +17 -16
  56. package/src/tui/app/provider-setup-picker.mjs +1 -0
  57. package/src/tui/app/use-transcript-window.mjs +5 -1
  58. package/src/tui/components/StatusLine.jsx +11 -32
  59. package/src/tui/dist/index.mjs +257 -106
  60. package/src/tui/engine/session-api.mjs +3 -0
  61. package/src/tui/engine/session-flow.mjs +19 -8
  62. package/src/tui/engine/turn.mjs +24 -2
  63. package/src/tui/engine.mjs +0 -1
  64. package/src/tui/index.jsx +3 -2
  65. package/src/tui/markdown/streaming-markdown.mjs +35 -9
  66. package/src/tui/statusline-ansi-bridge.mjs +17 -7
  67. package/src/ui/ansi.mjs +85 -5
  68. package/src/ui/statusline-agents.mjs +0 -8
  69. package/src/ui/statusline-format.mjs +7 -7
  70. package/src/ui/statusline.mjs +2 -4
  71. package/src/workflows/default/WORKFLOW.md +29 -20
  72. package/src/workflows/solo-review/WORKFLOW.md +47 -0
  73. 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健全 index: sum of flagged cases per KPI (lower is better).
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') || !line.includes('bench-agent')) throw new Error('statusline must render live agent task state: ' + JSON.stringify(line));
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({ autoClearBeforeSubmit } = {}) {
10
+ function makeEngine({
11
+ autoClearBeforeSubmit,
12
+ autoClearEnabled = false,
13
+ runtimeClear = async () => true,
14
+ } = {}) {
11
15
  let seq = 0;
12
16
  const executed = [];
13
- let state = { queued: [], busy: false, commandBusy: false };
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: { id: null, consumePendingSessionReset: () => null },
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) => { executed.push(text); },
38
- autoClearState: () => ({ enabled: false }),
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 () => 'ok',
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);