mixdog 0.9.43 → 0.9.45
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/ansi-color-capability-test.mjs +90 -0
- package/scripts/generate-runtime-manifest.mjs +39 -22
- package/scripts/grok-oauth-refresh-race-test.mjs +198 -0
- package/scripts/internal-comms-smoke.mjs +24 -7
- package/scripts/maintenance-default-routes-test.mjs +164 -0
- package/scripts/openai-oauth-ws-1006-retry-test.mjs +123 -0
- package/scripts/routing-corpus.mjs +1 -1
- package/scripts/shell-jobs-windows-hide-test.mjs +164 -0
- package/scripts/tui-transcript-jitter-harness-entry.jsx +302 -0
- package/scripts/tui-transcript-jitter-harness.mjs +58 -0
- package/src/agents/reviewer/AGENT.md +5 -7
- 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/providers/grok-oauth.mjs +46 -21
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +5 -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/shared/update-checker.mjs +40 -10
- package/src/standalone/agent-tool.mjs +65 -50
- package/src/standalone/explore-tool.mjs +17 -16
- package/src/tui/App.jsx +3 -6
- 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 +7 -6
- package/src/tui/dist/index.mjs +225 -90
- package/src/tui/engine/turn.mjs +1 -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-format.mjs +7 -7
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Regression: OpenAI OAuth WS abnormal-close recovery before visible output.
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { OpenAIOAuthProvider } from '../src/runtime/agent/orchestrator/providers/openai-oauth.mjs';
|
|
5
|
+
import { sendViaWebSocket } from '../src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs';
|
|
6
|
+
|
|
7
|
+
function close1006() {
|
|
8
|
+
const err = new Error('WebSocket closed abnormally');
|
|
9
|
+
err.wsCloseCode = 1006;
|
|
10
|
+
return err;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function entry() {
|
|
14
|
+
return { socket: { close() {} } };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function wsArgs(overrides = {}) {
|
|
18
|
+
return {
|
|
19
|
+
auth: { access_token: 'test-token' },
|
|
20
|
+
body: { model: 'gpt-5.5', input: [{ role: 'user', content: 'retry me' }] },
|
|
21
|
+
poolKey: 'openai-oauth-ws-1006-test',
|
|
22
|
+
cacheKey: 'openai-oauth-ws-1006-test',
|
|
23
|
+
useModel: 'gpt-5.5',
|
|
24
|
+
_sendFrameFn: async () => {},
|
|
25
|
+
_sleepFn: async () => {},
|
|
26
|
+
...overrides,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test('pre-response 1006 opens a fresh WS and replays the same request', async () => {
|
|
31
|
+
const acquires = [];
|
|
32
|
+
const frames = [];
|
|
33
|
+
let streams = 0;
|
|
34
|
+
const result = await sendViaWebSocket(wsArgs({
|
|
35
|
+
_acquireWithRetryFn: async (opts) => {
|
|
36
|
+
acquires.push(opts.forceFresh);
|
|
37
|
+
return { entry: entry(), reused: false };
|
|
38
|
+
},
|
|
39
|
+
_sendFrameFn: async (_entry, frame) => { frames.push(frame); },
|
|
40
|
+
_streamFn: async () => {
|
|
41
|
+
streams += 1;
|
|
42
|
+
if (streams === 1) throw close1006();
|
|
43
|
+
return { content: 'recovered', model: 'gpt-5.5', toolCalls: [], usage: {}, closeSocket: true };
|
|
44
|
+
},
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
assert.equal(result.content, 'recovered');
|
|
48
|
+
assert.deepEqual(acquires, [false, true], 'retry must acquire a fresh socket');
|
|
49
|
+
assert.equal(frames.length, 2);
|
|
50
|
+
assert.deepEqual(frames[1].input, frames[0].input, 'retry must replay the same input');
|
|
51
|
+
assert.equal(result.__midstreamRetries, 1);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('exhausted pre-response 1006 retries reach the HTTP/SSE fallback', async () => {
|
|
55
|
+
const savedEnv = Object.fromEntries([
|
|
56
|
+
'MIXDOG_OAI_TRANSPORT',
|
|
57
|
+
'MIXDOG_OPENAI_HTTP_FALLBACK',
|
|
58
|
+
'MIXDOG_OPENAI_OAUTH_WS_WARMUP',
|
|
59
|
+
'MIXDOG_OPENAI_OAUTH_FAST_HTTP_FALLBACK',
|
|
60
|
+
'MIXDOG_AGENT_TRACE_DISABLE',
|
|
61
|
+
].map((name) => [name, process.env[name]]));
|
|
62
|
+
Object.assign(process.env, {
|
|
63
|
+
MIXDOG_OAI_TRANSPORT: 'auto',
|
|
64
|
+
MIXDOG_OPENAI_HTTP_FALLBACK: '1',
|
|
65
|
+
MIXDOG_OPENAI_OAUTH_WS_WARMUP: '0',
|
|
66
|
+
MIXDOG_OPENAI_OAUTH_FAST_HTTP_FALLBACK: '0',
|
|
67
|
+
MIXDOG_AGENT_TRACE_DISABLE: '1',
|
|
68
|
+
});
|
|
69
|
+
try {
|
|
70
|
+
const provider = new OpenAIOAuthProvider({});
|
|
71
|
+
provider.ensureAuth = async () => ({ access_token: 'test-token' });
|
|
72
|
+
let streamAttempts = 0;
|
|
73
|
+
let httpCalls = 0;
|
|
74
|
+
const result = await provider.send([], 'gpt-5.5', [], {
|
|
75
|
+
sessionId: 'openai-oauth-ws-1006-fallback-test',
|
|
76
|
+
_prebuiltBody: { model: 'gpt-5.5', input: [], prompt_cache_key: 'openai-oauth-ws-1006-fallback-test' },
|
|
77
|
+
_sendViaWebSocketFn: (args) => sendViaWebSocket({
|
|
78
|
+
...args,
|
|
79
|
+
_acquireWithRetryFn: async () => ({ entry: entry(), reused: false }),
|
|
80
|
+
_sendFrameFn: async () => {},
|
|
81
|
+
_streamFn: async () => {
|
|
82
|
+
streamAttempts += 1;
|
|
83
|
+
throw close1006();
|
|
84
|
+
},
|
|
85
|
+
_sleepFn: async () => {},
|
|
86
|
+
}),
|
|
87
|
+
_sendViaHttpSseFn: async () => {
|
|
88
|
+
httpCalls += 1;
|
|
89
|
+
return { content: 'http-recovered', toolCalls: [], usage: {} };
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
assert.equal(streamAttempts, 5, 'all bounded ws_1006 attempts must run before fallback');
|
|
94
|
+
assert.equal(httpCalls, 1);
|
|
95
|
+
assert.equal(result.content, 'http-recovered');
|
|
96
|
+
} finally {
|
|
97
|
+
for (const [name, value] of Object.entries(savedEnv)) {
|
|
98
|
+
if (value == null) delete process.env[name];
|
|
99
|
+
else process.env[name] = value;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('post-emission 1006 refuses replay after text or tool output', async () => {
|
|
105
|
+
for (const emitted of ['emittedText', 'emittedToolCall']) {
|
|
106
|
+
let acquires = 0;
|
|
107
|
+
await assert.rejects(
|
|
108
|
+
sendViaWebSocket(wsArgs({
|
|
109
|
+
poolKey: `openai-oauth-ws-1006-${emitted}-test`,
|
|
110
|
+
_acquireWithRetryFn: async () => {
|
|
111
|
+
acquires += 1;
|
|
112
|
+
return { entry: entry(), reused: false };
|
|
113
|
+
},
|
|
114
|
+
_streamFn: async ({ state }) => {
|
|
115
|
+
state[emitted] = true;
|
|
116
|
+
throw close1006();
|
|
117
|
+
},
|
|
118
|
+
})),
|
|
119
|
+
(err) => err.wsCloseCode === 1006 && err.unsafeToRetry === true,
|
|
120
|
+
);
|
|
121
|
+
assert.equal(acquires, 1, `${emitted} must prevent a replay`);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { PassThrough } from 'node:stream';
|
|
3
|
+
import { Box, measureElement, render } from 'ink';
|
|
4
|
+
import { Item } from '../src/tui/components/TranscriptItem.jsx';
|
|
5
|
+
import { useTranscriptWindow } from '../src/tui/app/use-transcript-window.mjs';
|
|
6
|
+
import {
|
|
7
|
+
streamingMeasuredRowsById,
|
|
8
|
+
} from '../src/tui/app/transcript-window.mjs';
|
|
9
|
+
import {
|
|
10
|
+
resetAllStreamingMarkdownStablePrefixes,
|
|
11
|
+
resetStreamingMarkdownStablePrefix,
|
|
12
|
+
resolveStreamingMarkdownParts,
|
|
13
|
+
} from '../src/tui/markdown/streaming-markdown.mjs';
|
|
14
|
+
|
|
15
|
+
const COLUMNS = 42;
|
|
16
|
+
const VIEW_ROWS = 8;
|
|
17
|
+
const INITIAL_SCROLL = 8;
|
|
18
|
+
const STREAM_ID = 'jitter-fence-tail';
|
|
19
|
+
const HISTORY = Array.from({ length: 8 }, (_, index) => ({
|
|
20
|
+
id: `history-${index}`,
|
|
21
|
+
kind: 'notice',
|
|
22
|
+
tone: 'plain',
|
|
23
|
+
text: `H${index} stable history row`,
|
|
24
|
+
}));
|
|
25
|
+
const SCRIPT = [
|
|
26
|
+
'Here is the script:',
|
|
27
|
+
'',
|
|
28
|
+
'```js',
|
|
29
|
+
'const first = `',
|
|
30
|
+
'alpha',
|
|
31
|
+
'`;',
|
|
32
|
+
'const second = `',
|
|
33
|
+
'beta',
|
|
34
|
+
'`;',
|
|
35
|
+
'console.log(first, second);',
|
|
36
|
+
'```',
|
|
37
|
+
'',
|
|
38
|
+
'Done.',
|
|
39
|
+
].join('\n');
|
|
40
|
+
|
|
41
|
+
const frames = [];
|
|
42
|
+
globalThis.__mixdogTailGrowthProbe = null;
|
|
43
|
+
let commit = 0;
|
|
44
|
+
const identity = (value) => value;
|
|
45
|
+
const noop = () => {};
|
|
46
|
+
|
|
47
|
+
function Harness({ text, step }) {
|
|
48
|
+
const [scrollOffset, setScrollOffset] = React.useState(INITIAL_SCROLL);
|
|
49
|
+
const [measuredRowsVersion, setMeasuredRowsVersion] = React.useState(0);
|
|
50
|
+
const transcriptAnchorRef = React.useRef(null);
|
|
51
|
+
const transcriptAnchorDirtyRef = React.useRef(true);
|
|
52
|
+
const scrollTargetRef = React.useRef(INITIAL_SCROLL);
|
|
53
|
+
const scrollPositionRef = React.useRef(INITIAL_SCROLL);
|
|
54
|
+
const maxScrollRowsRef = React.useRef(0);
|
|
55
|
+
const transcriptGeomRef = React.useRef({});
|
|
56
|
+
const followingRef = React.useRef(false);
|
|
57
|
+
const dragRef = React.useRef({ active: false, rect: null });
|
|
58
|
+
const transcriptViewportRef = React.useRef({ top: 0 });
|
|
59
|
+
const selectionLayoutRef = React.useRef(null);
|
|
60
|
+
const contentRef = React.useRef(null);
|
|
61
|
+
const tailRef = React.useRef(null);
|
|
62
|
+
const streamingTail = React.useMemo(() => ({
|
|
63
|
+
id: STREAM_ID,
|
|
64
|
+
kind: 'assistant',
|
|
65
|
+
text,
|
|
66
|
+
streaming: true,
|
|
67
|
+
}), [text]);
|
|
68
|
+
const transcriptItems = React.useMemo(() => [...HISTORY, streamingTail], [streamingTail]);
|
|
69
|
+
|
|
70
|
+
const preHookEntry = streamingMeasuredRowsById.get(STREAM_ID);
|
|
71
|
+
const preHookMeasuredRows = preHookEntry?.rows ?? null;
|
|
72
|
+
const preHookEstimateRows = preHookEntry?.estimateRows ?? null;
|
|
73
|
+
|
|
74
|
+
const {
|
|
75
|
+
transcriptWindow,
|
|
76
|
+
renderedTranscriptItems,
|
|
77
|
+
transcriptMeasureRef,
|
|
78
|
+
} = useTranscriptWindow({
|
|
79
|
+
items: HISTORY,
|
|
80
|
+
structureRevision: 1,
|
|
81
|
+
streamingTail,
|
|
82
|
+
themeEpoch: 0,
|
|
83
|
+
frameColumns: COLUMNS,
|
|
84
|
+
toolOutputExpanded: false,
|
|
85
|
+
transcriptContentHeight: VIEW_ROWS,
|
|
86
|
+
transcriptBottomSlackRows: 1,
|
|
87
|
+
transcriptGuardRows: 1,
|
|
88
|
+
floatingPanelRows: 0,
|
|
89
|
+
overlayHintRequested: false,
|
|
90
|
+
scrollOffset,
|
|
91
|
+
setScrollOffset,
|
|
92
|
+
transcriptAnchorRef,
|
|
93
|
+
transcriptAnchorDirtyRef,
|
|
94
|
+
scrollTargetRef,
|
|
95
|
+
scrollPositionRef,
|
|
96
|
+
maxScrollRowsRef,
|
|
97
|
+
transcriptGeomRef,
|
|
98
|
+
followingRef,
|
|
99
|
+
dragRef,
|
|
100
|
+
transcriptViewportRef,
|
|
101
|
+
selectionLayoutRef,
|
|
102
|
+
withSelectionClip: identity,
|
|
103
|
+
paintSelectionRect: noop,
|
|
104
|
+
stopSmoothScroll: noop,
|
|
105
|
+
measuredRowsVersion,
|
|
106
|
+
setMeasuredRowsVersion,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const growthProbe = globalThis.__mixdogTailGrowthProbe;
|
|
110
|
+
const tailHookRef = transcriptMeasureRef(streamingTail);
|
|
111
|
+
const combinedTailRef = React.useCallback((element) => {
|
|
112
|
+
tailHookRef?.(element);
|
|
113
|
+
tailRef.current = element;
|
|
114
|
+
}, [tailHookRef]);
|
|
115
|
+
|
|
116
|
+
React.useLayoutEffect(() => {
|
|
117
|
+
const geometry = transcriptGeomRef.current || {};
|
|
118
|
+
const prefix = geometry.prefixRows || [];
|
|
119
|
+
const physicalRows = measureElement(contentRef.current).height;
|
|
120
|
+
const tailYogaRows = measureElement(tailRef.current).height;
|
|
121
|
+
const renderScrollOffset = transcriptWindow.effectiveScrollOffset;
|
|
122
|
+
const visibleTopIndexed = transcriptWindow.totalRows - renderScrollOffset - VIEW_ROWS;
|
|
123
|
+
const visibleTopPhysical = physicalRows - renderScrollOffset - VIEW_ROWS;
|
|
124
|
+
frames.push({
|
|
125
|
+
commit: ++commit,
|
|
126
|
+
step,
|
|
127
|
+
char: text.at(-1) === '\n' ? '\\n' : (text.at(-1) || ''),
|
|
128
|
+
totalRows: transcriptWindow.totalRows,
|
|
129
|
+
renderScrollOffset,
|
|
130
|
+
visibleTopIndexed,
|
|
131
|
+
visibleTopPhysical,
|
|
132
|
+
physicalRows,
|
|
133
|
+
tailIndexedRows: prefix.length > 1 ? prefix.at(-1) - prefix.at(-2) : -1,
|
|
134
|
+
tailYogaRows,
|
|
135
|
+
mountedDelta: tailYogaRows - (prefix.length > 1 ? prefix.at(-1) - prefix.at(-2) : -1),
|
|
136
|
+
growthLive: growthProbe?.live ?? null,
|
|
137
|
+
growthBaseline: growthProbe?.baseline ?? null,
|
|
138
|
+
growthDelta: growthProbe?.delta ?? null,
|
|
139
|
+
suppressMeasured: geometry.suppressMeasuredRowHeights === true,
|
|
140
|
+
measuredRowsVersion,
|
|
141
|
+
preHookMeasuredRows,
|
|
142
|
+
preHookEstimateRows,
|
|
143
|
+
postHarvestMeasuredRows: streamingMeasuredRowsById.get(STREAM_ID)?.rows ?? null,
|
|
144
|
+
postHarvestEstimateRows: streamingMeasuredRowsById.get(STREAM_ID)?.estimateRows ?? null,
|
|
145
|
+
scrollTarget: scrollTargetRef.current,
|
|
146
|
+
following: followingRef.current,
|
|
147
|
+
anchor: transcriptAnchorRef.current?.id || '-',
|
|
148
|
+
});
|
|
149
|
+
}, [step, text, measuredRowsVersion, transcriptWindow.totalRows, transcriptWindow.effectiveScrollOffset,
|
|
150
|
+
transcriptAnchorRef, transcriptGeomRef]);
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<Box flexDirection="column" width={COLUMNS} height={VIEW_ROWS} overflow="hidden" justifyContent="flex-end">
|
|
154
|
+
<Box
|
|
155
|
+
ref={contentRef}
|
|
156
|
+
flexDirection="column"
|
|
157
|
+
width="100%"
|
|
158
|
+
flexShrink={0}
|
|
159
|
+
marginBottom={-transcriptWindow.effectiveScrollOffset}
|
|
160
|
+
>
|
|
161
|
+
{renderedTranscriptItems.map((item, index, all) => {
|
|
162
|
+
const hookRef = item.id === STREAM_ID ? combinedTailRef : transcriptMeasureRef(item);
|
|
163
|
+
return (
|
|
164
|
+
<Box key={item.id} ref={hookRef} flexDirection="column" flexShrink={0}>
|
|
165
|
+
<Item
|
|
166
|
+
item={item}
|
|
167
|
+
prevKind={index > 0 ? all[index - 1].kind : null}
|
|
168
|
+
columns={COLUMNS}
|
|
169
|
+
toolOutputExpanded={false}
|
|
170
|
+
/>
|
|
171
|
+
</Box>
|
|
172
|
+
);
|
|
173
|
+
})}
|
|
174
|
+
{transcriptWindow.bottomSpacerRows > 0
|
|
175
|
+
? <Box height={transcriptWindow.bottomSpacerRows} flexShrink={0} />
|
|
176
|
+
: null}
|
|
177
|
+
</Box>
|
|
178
|
+
</Box>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function fakeTty(columns, rows) {
|
|
183
|
+
const stream = new PassThrough();
|
|
184
|
+
stream.columns = columns;
|
|
185
|
+
stream.rows = rows;
|
|
186
|
+
stream.isTTY = true;
|
|
187
|
+
stream.getColorDepth = () => 1;
|
|
188
|
+
stream.hasColors = () => false;
|
|
189
|
+
stream.setRawMode = () => stream;
|
|
190
|
+
stream.on('data', () => {});
|
|
191
|
+
return stream;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function settle(instance) {
|
|
195
|
+
await instance.waitUntilRenderFlush();
|
|
196
|
+
await new Promise((resolve) => setTimeout(resolve, 2));
|
|
197
|
+
await instance.waitUntilRenderFlush();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function assertStreamingMarkdownPartsCache() {
|
|
201
|
+
const key = 'streaming-parts-cache-coverage';
|
|
202
|
+
const longText = 'Settled paragraph.\n\n```js\nconst value = 1;';
|
|
203
|
+
const initial = resolveStreamingMarkdownParts(longText, key);
|
|
204
|
+
const repeated = resolveStreamingMarkdownParts(`${longText}\n\n`, key);
|
|
205
|
+
if (repeated !== initial) {
|
|
206
|
+
throw new Error('normalized-equivalent stream text did not reuse its resolved parts');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const regressed = resolveStreamingMarkdownParts('plain text', key);
|
|
210
|
+
if (regressed === initial || regressed.stablePrefix || regressed.unstableSuffix !== 'plain text') {
|
|
211
|
+
throw new Error('text regression served a stale streaming-markdown split');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const recomputed = resolveStreamingMarkdownParts(longText, key);
|
|
215
|
+
if (recomputed === initial) {
|
|
216
|
+
throw new Error('text change did not evict the prior streaming-markdown snapshot');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const resetSeed = recomputed;
|
|
220
|
+
resetStreamingMarkdownStablePrefix(key);
|
|
221
|
+
if (resolveStreamingMarkdownParts(longText, key) === resetSeed) {
|
|
222
|
+
throw new Error('streaming-markdown reset did not clear its resolved-parts snapshot');
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
assertStreamingMarkdownPartsCache();
|
|
227
|
+
resetAllStreamingMarkdownStablePrefixes();
|
|
228
|
+
streamingMeasuredRowsById.delete(STREAM_ID);
|
|
229
|
+
const stdout = fakeTty(COLUMNS, VIEW_ROWS);
|
|
230
|
+
const stderr = fakeTty(COLUMNS, VIEW_ROWS);
|
|
231
|
+
const stdin = fakeTty(COLUMNS, VIEW_ROWS);
|
|
232
|
+
const instance = render(<Harness text={SCRIPT.slice(0, 1)} step={1} />, {
|
|
233
|
+
stdout,
|
|
234
|
+
stderr,
|
|
235
|
+
stdin,
|
|
236
|
+
interactive: true,
|
|
237
|
+
patchConsole: false,
|
|
238
|
+
exitOnCtrlC: false,
|
|
239
|
+
maxFps: 1000,
|
|
240
|
+
});
|
|
241
|
+
await settle(instance);
|
|
242
|
+
for (let step = 2; step <= SCRIPT.length; step++) {
|
|
243
|
+
instance.rerender(<Harness text={SCRIPT.slice(0, step)} step={step} />);
|
|
244
|
+
await settle(instance);
|
|
245
|
+
}
|
|
246
|
+
instance.unmount();
|
|
247
|
+
await instance.waitUntilExit();
|
|
248
|
+
instance.cleanup();
|
|
249
|
+
|
|
250
|
+
const byStep = new Map();
|
|
251
|
+
for (const frame of frames) {
|
|
252
|
+
const list = byStep.get(frame.step) || [];
|
|
253
|
+
list.push(frame);
|
|
254
|
+
byStep.set(frame.step, list);
|
|
255
|
+
}
|
|
256
|
+
const dips = [];
|
|
257
|
+
let previousSettled = null;
|
|
258
|
+
for (const [step, list] of [...byStep.entries()].sort((a, b) => a[0] - b[0])) {
|
|
259
|
+
const first = list[0];
|
|
260
|
+
const settled = list.at(-1);
|
|
261
|
+
if (previousSettled
|
|
262
|
+
&& first.visibleTopPhysical !== previousSettled.visibleTopPhysical
|
|
263
|
+
&& settled.visibleTopPhysical === previousSettled.visibleTopPhysical) {
|
|
264
|
+
dips.push({ previous: previousSettled, transient: first, corrected: settled });
|
|
265
|
+
}
|
|
266
|
+
previousSettled = settled;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const print = (label, frame) => {
|
|
270
|
+
console.log(`${label} c${frame.commit} step=${frame.step} char=${JSON.stringify(frame.char)}`
|
|
271
|
+
+ ` totalRows=${frame.totalRows} renderScrollOffset=${frame.renderScrollOffset}`
|
|
272
|
+
+ ` visibleTop=${frame.visibleTopPhysical} indexedTop=${frame.visibleTopIndexed}`
|
|
273
|
+
+ ` physicalRows=${frame.physicalRows} tail(index/yoga)=${frame.tailIndexedRows}/${frame.tailYogaRows}`
|
|
274
|
+
+ ` mountedDelta=${frame.mountedDelta} helper(live/base/delta)=${frame.growthLive}/${frame.growthBaseline}/${frame.growthDelta}`
|
|
275
|
+
+ ` suppressMeasured=${frame.suppressMeasured ? 1 : 0}`
|
|
276
|
+
+ ` measuredVersion=${frame.measuredRowsVersion}`
|
|
277
|
+
+ ` baseline(measured/estimate)=${frame.preHookMeasuredRows}/${frame.preHookEstimateRows}`
|
|
278
|
+
+ ` harvest(measured/estimate)=${frame.postHarvestMeasuredRows}/${frame.postHarvestEstimateRows}`
|
|
279
|
+
+ ` target=${frame.scrollTarget} following=${frame.following ? 1 : 0} anchor=${frame.anchor}`);
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
console.log(`# scrolled-up fenced-script frame repro columns=${COLUMNS} viewRows=${VIEW_ROWS} initialScroll=${INITIAL_SCROLL}`);
|
|
283
|
+
console.log(`# append-only characters=${SCRIPT.length} commits=${frames.length} dip-snap events=${dips.length}`);
|
|
284
|
+
for (const event of dips.slice(0, 4)) {
|
|
285
|
+
print('before ', event.previous);
|
|
286
|
+
print('transient', event.transient);
|
|
287
|
+
print('harvest ', event.corrected);
|
|
288
|
+
console.log('');
|
|
289
|
+
}
|
|
290
|
+
const suppressValues = new Set(frames.map((frame) => frame.suppressMeasured));
|
|
291
|
+
console.log(`# suppressMeasuredRowHeights values during repro: ${[...suppressValues].map(Number).join(',')}`);
|
|
292
|
+
if (dips.length > 0) {
|
|
293
|
+
throw new Error(`expected no visible-top dip/snap while fenced script streams; observed ${dips.length}`);
|
|
294
|
+
}
|
|
295
|
+
if (suppressValues.size !== 1 || !suppressValues.has(true)) {
|
|
296
|
+
throw new Error('repro unexpectedly toggled suppressMeasuredRowHeights');
|
|
297
|
+
}
|
|
298
|
+
console.log('tui-transcript-jitter-harness: ok');
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { build } from 'esbuild';
|
|
3
|
+
import { readFile, rm } from 'node:fs/promises';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
8
|
+
const entry = join(ROOT, 'scripts', 'tui-transcript-jitter-harness-entry.jsx');
|
|
9
|
+
const outfile = join(ROOT, 'scripts', '.tui-transcript-jitter-harness.tmp.mjs');
|
|
10
|
+
|
|
11
|
+
const inkAlias = {
|
|
12
|
+
name: 'mixdog-ink-alias',
|
|
13
|
+
setup(ctx) {
|
|
14
|
+
ctx.onResolve({ filter: /^ink$/ }, () => ({
|
|
15
|
+
path: '../vendor/ink/build/index.js',
|
|
16
|
+
external: true,
|
|
17
|
+
}));
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Build-only probe: records the exact delta seen by the production helper
|
|
22
|
+
// without adding another stateful estimator call from the harness.
|
|
23
|
+
const growthProbe = {
|
|
24
|
+
name: 'streaming-tail-growth-probe',
|
|
25
|
+
setup(ctx) {
|
|
26
|
+
ctx.onLoad({ filter: /transcript-window\.mjs$/ }, async (args) => {
|
|
27
|
+
let source = (await readFile(args.path, 'utf8')).replace(/\r\n/g, '\n');
|
|
28
|
+
const target = ' return { tailRows: idEntry.rows, delta };';
|
|
29
|
+
const replacement = ` globalThis.__mixdogTailGrowthProbe = { live, baseline, delta, tailRows: idEntry.rows };\n${target}`;
|
|
30
|
+
if (!source.includes(target)) return null;
|
|
31
|
+
source = source.replace(target, replacement);
|
|
32
|
+
return { contents: source, loader: 'js' };
|
|
33
|
+
});
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
await build({
|
|
39
|
+
entryPoints: [entry],
|
|
40
|
+
outfile,
|
|
41
|
+
bundle: true,
|
|
42
|
+
format: 'esm',
|
|
43
|
+
platform: 'node',
|
|
44
|
+
target: 'node22',
|
|
45
|
+
jsx: 'automatic',
|
|
46
|
+
packages: 'external',
|
|
47
|
+
plugins: [inkAlias, growthProbe],
|
|
48
|
+
banner: {
|
|
49
|
+
js: "import { createRequire as __mixdogCreateRequire } from 'node:module';\nconst require = __mixdogCreateRequire(import.meta.url);",
|
|
50
|
+
},
|
|
51
|
+
logLevel: 'silent',
|
|
52
|
+
});
|
|
53
|
+
await import(`${pathToFileURL(outfile).href}?run=${Date.now()}`);
|
|
54
|
+
} finally {
|
|
55
|
+
await rm(outfile, { force: true });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
@@ -6,13 +6,11 @@ permission: read
|
|
|
6
6
|
|
|
7
7
|
Independent regression/risk review agent.
|
|
8
8
|
|
|
9
|
-
Review the
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
available evidence with a critical lens, actively seeking errors, unsupported
|
|
15
|
-
assumptions, and counterexamples before confirming.
|
|
9
|
+
Review the diff and tests with independent judgment. Prioritize actionable
|
|
10
|
+
correctness, regression, security, and verification risks; inspect affected
|
|
11
|
+
boundaries. Do not reimplement the change or report non-risky nits. Independently
|
|
12
|
+
evaluate the final deliverable with a critical lens, actively seeking errors,
|
|
13
|
+
unsupported assumptions, and counterexamples before confirming.
|
|
16
14
|
Report findings first, severity-ordered, with one line per `file:line`. If clean,
|
|
17
15
|
say so in one line and include only material residual risk.
|
|
18
16
|
|