Merge remote-tracking branch 'origin/main' into nwparker/test-five-ssh-pane-input-under-flood

This commit is contained in:
Neil
2026-09-07 19:16:03 -07:00
65 changed files with 2253 additions and 407 deletions
+2 -2
View File
@@ -14,8 +14,8 @@
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@hono/node-server": "^1.19.14",
"hono": "^4.12.27",
"@hono/node-server": "^1.19.17",
"hono": "^4.13.7",
"zod": "^3.25.76"
},
"devDependencies": {
+2 -2
View File
@@ -16,8 +16,8 @@
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@hono/node-server": "^1.19.14",
"hono": "^4.12.27",
"@hono/node-server": "^1.19.17",
"hono": "^4.13.7",
"zod": "^3.25.76"
},
"devDependencies": {
+3 -3
View File
@@ -15,13 +15,13 @@
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@hono/node-server": "^1.19.14",
"@hono/node-server": "^1.19.17",
"@orca-cloud/relay-contract": "workspace:*",
"hono": "^4.12.27",
"hono": "^4.13.7",
"jose": "^6.1.3",
"pg": "^8.22.0",
"tweetnacl": "^1.0.3",
"ws": "^8.18.3",
"ws": "^8.21.3",
"zod": "^3.25.76"
},
"devDependencies": {
+32 -32
View File
@@ -24,14 +24,14 @@ importers:
apps/relay:
dependencies:
'@hono/node-server':
specifier: ^1.19.14
version: 1.19.14(hono@4.12.27)
specifier: ^1.19.17
version: 1.19.17(hono@4.13.7)
'@orca-cloud/relay-contract':
specifier: workspace:*
version: link:../../packages/relay-contract
hono:
specifier: ^4.12.27
version: 4.12.27
specifier: ^4.13.7
version: 4.13.7
jose:
specifier: ^6.1.3
version: 6.2.3
@@ -42,8 +42,8 @@ importers:
specifier: ^1.0.3
version: 1.0.3
ws:
specifier: ^8.18.3
version: 8.21.0
specifier: ^8.21.3
version: 8.21.3
zod:
specifier: ^3.25.76
version: 3.25.76
@@ -70,11 +70,11 @@ importers:
apps/relay-fence-broker:
dependencies:
'@hono/node-server':
specifier: ^1.19.14
version: 1.19.14(hono@4.12.27)
specifier: ^1.19.17
version: 1.19.17(hono@4.13.7)
hono:
specifier: ^4.12.27
version: 4.12.27
specifier: ^4.13.7
version: 4.13.7
zod:
specifier: ^3.25.76
version: 3.25.76
@@ -95,11 +95,11 @@ importers:
apps/relay-ops:
dependencies:
'@hono/node-server':
specifier: ^1.19.14
version: 1.19.14(hono@4.12.27)
specifier: ^1.19.17
version: 1.19.17(hono@4.13.7)
hono:
specifier: ^4.12.27
version: 4.12.27
specifier: ^4.13.7
version: 4.13.7
zod:
specifier: ^3.25.76
version: 3.25.76
@@ -300,8 +300,8 @@ packages:
cpu: [x64]
os: [win32]
'@hono/node-server@1.19.14':
resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==}
'@hono/node-server@1.19.17':
resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==}
engines: {node: '>=18.14.1'}
peerDependencies:
hono: ^4
@@ -507,8 +507,8 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
hono@4.12.27:
resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==}
hono@4.13.7:
resolution: {integrity: sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==}
engines: {node: '>=16.9.0'}
jose@6.2.3:
@@ -587,8 +587,8 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
nanoid@3.3.13:
resolution: {integrity: sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==}
nanoid@3.3.18:
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
@@ -640,8 +640,8 @@ packages:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
postcss@8.5.15:
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
postcss@8.5.28:
resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
engines: {node: ^10 || ^12 || >=14}
postgres-array@2.0.0:
@@ -805,8 +805,8 @@ packages:
engines: {node: '>=8'}
hasBin: true
ws@8.21.0:
resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
ws@8.21.3:
resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
@@ -920,9 +920,9 @@ snapshots:
'@esbuild/win32-x64@0.28.1':
optional: true
'@hono/node-server@1.19.14(hono@4.12.27)':
'@hono/node-server@1.19.17(hono@4.13.7)':
dependencies:
hono: 4.12.27
hono: 4.13.7
'@jridgewell/sourcemap-codec@1.5.5': {}
@@ -1109,7 +1109,7 @@ snapshots:
fsevents@2.3.3:
optional: true
hono@4.12.27: {}
hono@4.13.7: {}
jose@6.2.3: {}
@@ -1166,7 +1166,7 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
nanoid@3.3.13: {}
nanoid@3.3.18: {}
obug@2.1.3: {}
@@ -1211,9 +1211,9 @@ snapshots:
picomatch@4.0.4: {}
postcss@8.5.15:
postcss@8.5.28:
dependencies:
nanoid: 3.3.13
nanoid: 3.3.18
picocolors: 1.1.1
source-map-js: 1.2.1
@@ -1288,7 +1288,7 @@ snapshots:
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
postcss: 8.5.15
postcss: 8.5.28
rolldown: 1.0.3
tinyglobby: 0.2.17
optionalDependencies:
@@ -1329,7 +1329,7 @@ snapshots:
siginfo: 2.0.0
stackback: 0.0.2
ws@8.21.0: {}
ws@8.21.3: {}
xtend@4.0.2: {}
-5
View File
@@ -9,8 +9,3 @@ inline src/main/ssh/ssh-relay-deploy.ts
inline src/main/ssh/ssh-relay-session.ts
inline src/relay/pty-handler.ts
inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts
mobile-config app/h/*/files/*.tsx
mobile-config app/h/*/source-control/*.tsx
mobile-config app/index.tsx
mobile-config scripts/mock-server.ts
mobile-config src/transport/rpc-client.ts
File diff suppressed because one or more lines are too long
@@ -1104,31 +1104,124 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..d4d5106d8dd723eceed87310c193ccfe
}
}
diff --git a/src/common/SortedList.ts b/src/common/SortedList.ts
index 8a10076e3963e33b4a7d1e4602333eb3f4772dc9..df0761c35907ddc48eb102ba181b0dac8e61f00d 100644
index 8a10076e3963e33b4a7d1e4602333eb3f4772dc9..c6dcf18b762e3c56fe22e9c2d49b8e550d96f915 100644
--- a/src/common/SortedList.ts
+++ b/src/common/SortedList.ts
@@ -87,6 +87,24 @@ export class SortedList<T> {
if (key === undefined) {
return false;
@@ -22,7 +22,8 @@ export class SortedList<T> {
private readonly _flushInsertedTask: InstanceType<typeof IdleTaskQueue>;
private _isFlushingInserted = false;
- private readonly _deletedIndices: number[] = [];
+ private readonly _deletedIndices = new Set<number>();
+ private readonly _indicesByValue = new Map<T, number | number[]>();
private readonly _flushDeletedTask: InstanceType<typeof IdleTaskQueue>;
private _isFlushingDeleted = false;
@@ -36,10 +37,11 @@ export class SortedList<T> {
public clear(): void {
this._array.length = 0;
+ this._indicesByValue.clear();
this._insertedValues.length = 0;
this._flushInsertedTask.clear();
this._isFlushingInserted = false;
- this._deletedIndices.length = 0;
+ this._deletedIndices.clear();
this._flushDeletedTask.clear();
this._isFlushingDeleted = false;
}
@@ -69,6 +71,7 @@ export class SortedList<T> {
}
+ if (this._deleteAtKey(value, key)) {
+ return true;
this._array = newArray;
+ this._rebuildIdentityIndex();
this._insertedValues.length = 0;
}
@@ -78,54 +81,60 @@ export class SortedList<T> {
}
}
+ private _rebuildIdentityIndex(): void {
+ this._indicesByValue.clear();
+ // Reverse indices let duplicate identities remove their first occurrence in O(1).
+ for (let index = this._array.length - 1; index >= 0; index--) {
+ const value = this._array[index];
+ const indices = this._indicesByValue.get(value);
+ if (indices === undefined) {
+ this._indicesByValue.set(value, index);
+ } else if (typeof indices === 'number') {
+ this._indicesByValue.set(value, [indices, index]);
+ } else {
+ indices.push(index);
+ }
+ }
+ // A pending deletion whose key mutated after `delete()` (disposing a marker
+ // resets `line` to -1, and `line` is the sort key) leaves `_array` out of
+ // order, so the binary search above can miss a value that is present.
+ // Compacting those entries out restores the order; retry before reporting
+ // the value absent, else its `onDecorationRemoved` never fires and the
+ // decoration paints forever. Miss path only, so the common bulk delete
+ // keeps its O(log n) search and deferred-compaction batching.
+ if (this._deletedIndices.length === 0) {
+ return false;
+ }
+ this._flushCleanupDeleted();
+ return this._deleteAtKey(value, key);
+ }
+
+ private _deleteAtKey(value: T, key: number): boolean {
i = this._search(key);
if (i === -1) {
public delete(value: T): boolean {
this._flushCleanupInserted();
- if (this._array.length === 0) {
+ // Marker disposal mutates the sort key before removal; identity stays stable.
+ const indices = this._indicesByValue.get(value);
+ if (indices === undefined) {
return false;
}
- const key = this._getKey(value);
- if (key === undefined) {
+ const index = typeof indices === 'number' ? indices : indices.pop();
+ if (index === undefined) {
return false;
}
- i = this._search(key);
- if (i === -1) {
- return false;
+ if (typeof indices === 'number' || indices.length === 0) {
+ this._indicesByValue.delete(value);
}
- if (this._getKey(this._array[i]) !== key) {
- return false;
+ if (this._deletedIndices.size === 0) {
+ this._flushDeletedTask.enqueue(() => this._flushDeleted());
}
- do {
- if (this._array[i] === value) {
- if (this._deletedIndices.length === 0) {
- this._flushDeletedTask.enqueue(() => this._flushDeleted());
- }
- this._deletedIndices.push(i);
- return true;
- }
- } while (++i < this._array.length && this._getKey(this._array[i]) === key);
- return false;
+ this._deletedIndices.add(index);
+ return true;
}
private _flushDeleted(): void {
this._isFlushingDeleted = true;
- const sortedDeletedIndices = this._deletedIndices.sort((a, b) => a - b);
- let sortedDeletedIndicesIndex = 0;
- const newArray = new Array(this._array.length - sortedDeletedIndices.length);
+ const newArray = new Array(this._array.length - this._deletedIndices.size);
let newArrayIndex = 0;
for (let i = 0; i < this._array.length; i++) {
- if (sortedDeletedIndices[sortedDeletedIndicesIndex] === i) {
- sortedDeletedIndicesIndex++;
- } else {
+ if (!this._deletedIndices.has(i)) {
newArray[newArrayIndex++] = this._array[i];
}
}
this._array = newArray;
- this._deletedIndices.length = 0;
+ this._rebuildIdentityIndex();
+ this._deletedIndices.clear();
this._isFlushingDeleted = false;
}
private _flushCleanupDeleted(): void {
- if (!this._isFlushingDeleted && this._deletedIndices.length > 0) {
+ if (!this._isFlushingDeleted && this._deletedIndices.size > 0) {
this._flushDeletedTask.flush();
}
}
+8 -2
View File
@@ -1,4 +1,3 @@
import { readFileSync } from 'node:fs'
import process from 'node:process'
import { pathToFileURL } from 'node:url'
@@ -369,7 +368,14 @@ function matchesPrefix(file, prefixes) {
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const files = readFileSync(0, 'utf8').split('\n').filter(Boolean)
// Why streamed, not readFileSync(0): a single read of fd 0 throws EAGAIN once the writer
// outgrows the 64 KB pipe buffer, which a stale PR base.sha reaches easily.
let input = ''
process.stdin.setEncoding('utf8')
for await (const chunk of process.stdin) {
input += chunk
}
const files = input.split(/\r?\n/).filter(Boolean)
const classification = classifyPrJobs(files)
for (const [name, value] of Object.entries(classification)) {
process.stdout.write(`${name}=${value ? 'true' : 'false'}\n`)
+49 -1
View File
@@ -1,4 +1,4 @@
import { spawnSync } from 'node:child_process'
import { spawn, spawnSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
@@ -353,6 +353,54 @@ describe('per-job path classification', () => {
expect(result.stdout).toContain('package=false\n')
expect(result.stdout).toContain('test=true\n')
})
// A long-lived PR whose base.sha has gone stale diffs thousands of files, so the writer
// outruns one pipe buffer. A single fd-0 read then returns early, breaks the writer's pipe,
// and still exits 0 -- emitting no pairs at all, which silently skips every lane.
it('classifies a path that arrives after the first pipe buffer', async () => {
const filler = Array.from(
{ length: 12_000 },
(_, index) => `docs/reference/generated-placeholder-${index}.md`
)
const input = `${[...filler, 'config/patches/xterm-upstream.json'].join('\n')}\n`
expect(input.length).toBeGreaterThan(64 * 1024)
const child = spawn(process.execPath, ['config/scripts/pr-code-change-scope.mjs'], {
cwd: projectDir,
stdio: ['pipe', 'pipe', 'pipe']
})
let stdout = ''
let stderr = ''
let brokePipe = false
child.stdout.setEncoding('utf8')
child.stderr.setEncoding('utf8')
child.stdout.on('data', (chunk) => (stdout += chunk))
child.stderr.on('data', (chunk) => (stderr += chunk))
child.stdin.on('error', (error) => {
brokePipe ||= error.code === 'EPIPE'
})
const exitCode = await new Promise((resolvePromise) => {
child.on('close', resolvePromise)
let offset = 0
const step = () => {
if (offset >= input.length) {
child.stdin.end()
return
}
child.stdin.write(input.slice(offset, offset + 64 * 1024))
offset += 64 * 1024
setTimeout(step, 20)
}
step()
})
expect(stderr).not.toContain('EAGAIN')
expect(brokePipe).toBe(false)
expect(exitCode, stderr).toBe(0)
expect(stdout).toContain('should_run=true\n')
expect(stdout).toContain('xterm_patch_sync=true\n')
})
})
describe('PR Checks skip wiring', () => {
+8 -8
View File
@@ -2321,11 +2321,11 @@ packages:
bindings@1.5.0:
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
brace-expansion@1.1.16:
resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==}
brace-expansion@1.1.18:
resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
brace-expansion@5.0.8:
resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==}
brace-expansion@5.0.9:
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
engines: {node: 20 || >=22}
braces@3.0.3:
@@ -6854,12 +6854,12 @@ snapshots:
dependencies:
file-uri-to-path: 1.0.0
brace-expansion@1.1.16:
brace-expansion@1.1.18:
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
brace-expansion@5.0.8:
brace-expansion@5.0.9:
dependencies:
balanced-match: 4.0.4
@@ -8764,11 +8764,11 @@ snapshots:
minimatch@10.2.6:
dependencies:
brace-expansion: 5.0.8
brace-expansion: 5.0.9
minimatch@3.1.5:
dependencies:
brace-expansion: 1.1.16
brace-expansion: 1.1.18
minimist@1.2.8: {}
-30
View File
@@ -20,36 +20,6 @@
"rules": {
"max-lines": ["error", { "max": 379, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["app/h/*/source-control/*.tsx"],
"rules": {
"max-lines": ["error", { "max": 2152, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["app/index.tsx"],
"rules": {
"max-lines": ["error", { "max": 1422, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["src/transport/rpc-client.ts"],
"rules": {
"max-lines": ["error", { "max": 1074, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["scripts/mock-server.ts"],
"rules": {
"max-lines": ["error", { "max": 407, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["app/h/*/files/*.tsx"],
"rules": {
"max-lines": ["error", { "max": 402, "skipBlankLines": true, "skipComments": true }]
}
}
]
}
+9 -9
View File
@@ -3075,12 +3075,12 @@ packages:
'@vitest/utils@4.1.11':
resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==}
'@xmldom/xmldom@0.8.13':
resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==}
'@xmldom/xmldom@0.8.15':
resolution: {integrity: sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==}
engines: {node: '>=10.0.0'}
'@xmldom/xmldom@0.9.10':
resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==}
'@xmldom/xmldom@0.9.12':
resolution: {integrity: sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==}
engines: {node: '>=14.6'}
'@xterm/addon-unicode11@0.10.0-beta.300':
@@ -9072,13 +9072,13 @@ snapshots:
'@expo/plist@0.5.3':
dependencies:
'@xmldom/xmldom': 0.8.13
'@xmldom/xmldom': 0.8.15
base64-js: 1.5.1
xmlbuilder: 15.1.1
'@expo/plist@0.5.4':
dependencies:
'@xmldom/xmldom': 0.8.13
'@xmldom/xmldom': 0.8.15
base64-js: 1.5.1
xmlbuilder: 15.1.1
@@ -10574,9 +10574,9 @@ snapshots:
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
'@xmldom/xmldom@0.8.13': {}
'@xmldom/xmldom@0.8.15': {}
'@xmldom/xmldom@0.9.10': {}
'@xmldom/xmldom@0.9.12': {}
'@xterm/addon-unicode11@0.10.0-beta.300(@xterm/xterm@6.1.0-beta.303)':
dependencies:
@@ -14451,7 +14451,7 @@ snapshots:
plist@3.1.1:
dependencies:
'@xmldom/xmldom': 0.9.10
'@xmldom/xmldom': 0.9.12
base64-js: 1.5.1
xmlbuilder: 15.1.1
+3 -3
View File
@@ -176,7 +176,7 @@
"proper-lockfile": "4.1.2",
"psl": "1.15.0",
"qrcode": "^1.5.4",
"react-i18next": "^17.0.8",
"react-i18next": "17.0.13",
"serve-sim": "^0.1.40",
"sherpa-onnx": "1.12.37",
"ssh2": "^1.17.0",
@@ -235,8 +235,8 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dompurify": "^3.4.13",
"electron": "^43.4.1",
"dompurify": "3.4.14",
"electron": "43.6.0",
"electron-builder": "^26.15.3",
"electron-builder-squirrel-windows": "^26.15.3",
"electron-vite": "^5.0.0",
+110 -116
View File
@@ -106,7 +106,7 @@ settings:
excludeLinksFromLockfile: false
overrides:
monaco-editor>dompurify: 3.4.13
monaco-editor>dompurify: 3.4.14
patchedDependencies:
'@vscode/windows-process-tree@0.8.0': e66202cc623996d02040c93449eb9ae353fddadf426cb53202a59ee710ee6fe7
@@ -114,7 +114,7 @@ patchedDependencies:
'@xterm/addon-search@0.17.0-beta.300': eee5338dd2621ece46e79c61ec06766cd7fadaf79ffdb24e2a8ab68e97ef31f0
'@xterm/addon-serialize@0.15.0-beta.300': 851eac3d75e6d8c013b9f4c053e61d824b23965cb19ecc28e335e05059f3a294
'@xterm/addon-webgl@0.20.0-beta.299': 94687e89a0115e6e6aa102837f986debdc029c091527ee5eb4a4e17ceaf9473e
'@xterm/xterm@6.1.0-beta.303': 98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d
'@xterm/xterm@6.1.0-beta.303': 1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4
lint-staged@16.4.0: 7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673
node-pty@1.1.0: bac3a53fb15efc9b3b944fbe3c4718b5174a0b3bd6ead84e21975edad4bc6615
@@ -127,10 +127,10 @@ importers:
version: 0.3.251(@anthropic-ai/sdk@0.122.0(zod@4.5.4))(@modelcontextprotocol/sdk@1.30.0(supports-color@7.2.0)(zod@4.5.4))(zod@4.5.4)
'@electron-toolkit/preload':
specifier: ^3.0.2
version: 3.0.2(electron@43.4.1(supports-color@7.2.0))
version: 3.0.2(electron@43.6.0(supports-color@7.2.0))
'@electron-toolkit/utils':
specifier: ^4.0.0
version: 4.0.0(electron@43.4.1(supports-color@7.2.0))
version: 4.0.0(electron@43.6.0(supports-color@7.2.0))
'@floating-ui/dom':
specifier: 1.7.6
version: 1.7.6
@@ -142,7 +142,7 @@ importers:
version: 2.5.6
'@xterm/addon-serialize':
specifier: 0.15.0-beta.300
version: 0.15.0-beta.300(patch_hash=851eac3d75e6d8c013b9f4c053e61d824b23965cb19ecc28e335e05059f3a294)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))
version: 0.15.0-beta.300(patch_hash=851eac3d75e6d8c013b9f4c053e61d824b23965cb19ecc28e335e05059f3a294)(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4))
'@xterm/headless':
specifier: 6.1.0-beta.302
version: 6.1.0-beta.302
@@ -174,8 +174,8 @@ importers:
specifier: ^1.5.4
version: 1.5.4
react-i18next:
specifier: ^17.0.8
version: 17.0.8(i18next@26.3.1(typescript@7.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2)
specifier: 17.0.13
version: 17.0.13(i18next@26.3.1(typescript@7.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2)
serve-sim:
specifier: ^0.1.40
version: 0.1.40(typescript@7.0.2)
@@ -317,25 +317,25 @@ importers:
version: 5.2.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4))
'@xterm/addon-fit':
specifier: 0.12.0-beta.300
version: 0.12.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))
version: 0.12.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4))
'@xterm/addon-ligatures':
specifier: 0.11.0-beta.300
version: 0.11.0-beta.300(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))
version: 0.11.0-beta.300(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4))
'@xterm/addon-search':
specifier: 0.17.0-beta.300
version: 0.17.0-beta.300(patch_hash=eee5338dd2621ece46e79c61ec06766cd7fadaf79ffdb24e2a8ab68e97ef31f0)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))
version: 0.17.0-beta.300(patch_hash=eee5338dd2621ece46e79c61ec06766cd7fadaf79ffdb24e2a8ab68e97ef31f0)(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4))
'@xterm/addon-unicode11':
specifier: 0.10.0-beta.300
version: 0.10.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))
version: 0.10.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4))
'@xterm/addon-web-links':
specifier: 0.13.0-beta.300
version: 0.13.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))
version: 0.13.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4))
'@xterm/addon-webgl':
specifier: 0.20.0-beta.299
version: 0.20.0-beta.299(patch_hash=94687e89a0115e6e6aa102837f986debdc029c091527ee5eb4a4e17ceaf9473e)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))
version: 0.20.0-beta.299(patch_hash=94687e89a0115e6e6aa102837f986debdc029c091527ee5eb4a4e17ceaf9473e)(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4))
'@xterm/xterm':
specifier: 6.1.0-beta.303
version: 6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)
version: 6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -346,11 +346,11 @@ importers:
specifier: ^1.1.1
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
dompurify:
specifier: ^3.4.13
version: 3.4.13
specifier: 3.4.14
version: 3.4.14
electron:
specifier: ^43.4.1
version: 43.4.1(supports-color@7.2.0)
specifier: 43.6.0
version: 43.6.0(supports-color@7.2.0)
electron-builder:
specifier: ^26.15.3
version: 26.15.3(electron-builder-squirrel-windows@26.15.3)
@@ -3513,8 +3513,8 @@ packages:
'@vscode/windows-process-tree@0.8.0':
resolution: {integrity: sha512-TI+h2GRwX+igD/YYJMQQVAFcsCNSg7Te2yYxQpKMzwto5RsJ8d2KKgOeur/p/6sAOQwZvopiTDOClyTHEn9MhQ==}
'@xmldom/xmldom@0.8.13':
resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==}
'@xmldom/xmldom@0.8.15':
resolution: {integrity: sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==}
engines: {node: '>=10.0.0'}
'@xterm/addon-fit@0.12.0-beta.300':
@@ -3677,8 +3677,8 @@ packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
baseline-browser-mapping@2.10.27:
resolution: {integrity: sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==}
baseline-browser-mapping@2.11.21:
resolution: {integrity: sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==}
engines: {node: '>=6.0.0'}
hasBin: true
@@ -3715,8 +3715,8 @@ packages:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
browserslist@4.28.2:
resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==}
browserslist@4.28.9:
resolution: {integrity: sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
@@ -3779,8 +3779,8 @@ packages:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
caniuse-lite@1.0.30001791:
resolution: {integrity: sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==}
caniuse-lite@1.0.30001810:
resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==}
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
@@ -4261,8 +4261,8 @@ packages:
dom-accessibility-api@0.6.3:
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
dompurify@3.4.13:
resolution: {integrity: sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==}
dompurify@3.4.14:
resolution: {integrity: sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==}
dotenv-expand@11.0.7:
resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==}
@@ -4306,8 +4306,8 @@ packages:
electron-publish@26.15.3:
resolution: {integrity: sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==}
electron-to-chromium@1.5.351:
resolution: {integrity: sha512-9D7Iqx8RImSvCnOsj86rCH6eQjZFQoM04Jn6HnZVM0Nu/G58/gmKYQ1d12MZTbjQbQSTGI8nwEy07ErsA2slLA==}
electron-to-chromium@1.5.422:
resolution: {integrity: sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==}
electron-updater@6.8.9:
resolution: {integrity: sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==}
@@ -4327,8 +4327,8 @@ packages:
resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
engines: {node: '>=8.0.0'}
electron@43.4.1:
resolution: {integrity: sha512-5b+EuiwkgG5iRcsEL34rimgRpkYp15SsfZOa0pC5kXs0Tb82TH4n95rpQzTZa7yRCbA7tm0WoEbuBL6NaAhAcA==}
electron@43.6.0:
resolution: {integrity: sha512-DqVKYV+FXheMSLTxcMQ+NCo78BDgpnToSyIzXctlUtbP3lRGEuoo1P+C2n/90rJ7TvHgzP0bpP9fbbXxp4noIg==}
engines: {node: '>= 22.12.0'}
hasBin: true
@@ -4525,8 +4525,8 @@ packages:
fast-string-width@3.0.2:
resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==}
fast-uri@3.1.5:
resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==}
fast-uri@3.1.7:
resolution: {integrity: sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==}
fast-wrap-ansi@0.2.2:
resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==}
@@ -4794,8 +4794,8 @@ packages:
resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==}
engines: {node: '>=10'}
html-parse-stringify@3.0.1:
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
html-parse-stringify@4.0.1:
resolution: {integrity: sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==}
html-to-image@1.11.13:
resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
@@ -5563,8 +5563,8 @@ packages:
nan@2.26.2:
resolution: {integrity: sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==}
nanoid@3.3.17:
resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==}
nanoid@3.3.18:
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
@@ -5600,8 +5600,9 @@ packages:
node-pty@1.1.0:
resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==}
node-releases@2.0.38:
resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==}
node-releases@2.0.54:
resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==}
engines: {node: '>=18'}
nopt@9.0.0:
resolution: {integrity: sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==}
@@ -5856,8 +5857,8 @@ packages:
points-on-path@0.2.1:
resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==}
postcss-selector-parser@7.1.1:
resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==}
postcss-selector-parser@7.1.6:
resolution: {integrity: sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==}
engines: {node: '>=4'}
postcss@8.5.25:
@@ -5977,8 +5978,8 @@ packages:
engines: {node: '>=10.13.0'}
hasBin: true
qs@6.15.2:
resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==}
qs@6.16.0:
resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==}
engines: {node: '>=0.6'}
queue-microtask@1.2.3:
@@ -6029,14 +6030,14 @@ packages:
react:
optional: true
react-i18next@17.0.8:
resolution: {integrity: sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==}
react-i18next@17.0.13:
resolution: {integrity: sha512-Cc1PscmblIHA1kljTqDwrcVMI21ydgmUzw0UAeQBe7pAOgfuRLfzXze4EUBQoeDiICzFIXXhHFoZxuetNg5D0Q==}
peerDependencies:
i18next: '>= 26.2.0'
react: '>= 16.8.0'
react-dom: '*'
react-native: '*'
typescript: ^5 || ^6
typescript: ^5 || ^6 || ^7
peerDependenciesMeta:
react-dom:
optional: true
@@ -6392,8 +6393,8 @@ packages:
resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
engines: {node: '>= 0.4'}
side-channel@1.1.0:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
side-channel@1.1.1:
resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
engines: {node: '>= 0.4'}
siginfo@2.0.0:
@@ -6755,8 +6756,8 @@ packages:
unzipper@0.12.5:
resolution: {integrity: sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==}
update-browserslist-db@1.2.3:
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
update-browserslist-db@1.3.2:
resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==}
hasBin: true
peerDependencies:
browserslist: '>= 4.21.0'
@@ -6854,10 +6855,6 @@ packages:
jsdom:
optional: true
void-elements@3.1.0:
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
engines: {node: '>=0.10.0'}
vscode-oniguruma@2.0.1:
resolution: {integrity: sha512-poJU8iHIWnC3vgphJnrLZyI3YdqRlR27xzqDmpPXYzA93R4Gk8z7T6oqDzDoHjoikA2aS82crdXFkjELCdJsjQ==}
@@ -7119,7 +7116,7 @@ snapshots:
dependencies:
'@babel/compat-data': 7.29.7
'@babel/helper-validator-option': 7.29.7
browserslist: 4.28.2
browserslist: 4.28.9
lru-cache: 5.1.1
semver: 6.3.1
@@ -7332,17 +7329,17 @@ snapshots:
'@electron-internal/extract-zip@1.0.4': {}
'@electron-toolkit/preload@3.0.2(electron@43.4.1(supports-color@7.2.0))':
'@electron-toolkit/preload@3.0.2(electron@43.6.0(supports-color@7.2.0))':
dependencies:
electron: 43.4.1(supports-color@7.2.0)
electron: 43.6.0(supports-color@7.2.0)
'@electron-toolkit/tsconfig@2.0.0(@types/node@25.9.5)':
dependencies:
'@types/node': 25.9.5
'@electron-toolkit/utils@4.0.0(electron@43.4.1(supports-color@7.2.0))':
'@electron-toolkit/utils@4.0.0(electron@43.6.0(supports-color@7.2.0))':
dependencies:
electron: 43.4.1(supports-color@7.2.0)
electron: 43.6.0(supports-color@7.2.0)
'@electron/asar@3.4.1':
dependencies:
@@ -7739,7 +7736,7 @@ snapshots:
eventsource: 3.0.7
eventsource-parser: 3.0.8
express: 5.2.1(supports-color@7.2.0)
express-rate-limit: 8.5.2(express@5.2.1(supports-color@7.2.0))
express-rate-limit: 8.5.2(express@5.2.1)
hono: 4.13.0
jose: 6.2.3
json-schema-typed: 8.0.2
@@ -7761,7 +7758,7 @@ snapshots:
eventsource: 3.0.7
eventsource-parser: 3.0.8
express: 5.2.1(supports-color@7.2.0)
express-rate-limit: 8.5.2(express@5.2.1(supports-color@7.2.0))
express-rate-limit: 8.5.2(express@5.2.1)
hono: 4.13.0
jose: 6.2.3
json-schema-typed: 8.0.2
@@ -9826,41 +9823,41 @@ snapshots:
node-addon-api: 7.1.0
optional: true
'@xmldom/xmldom@0.8.13': {}
'@xmldom/xmldom@0.8.15': {}
'@xterm/addon-fit@0.12.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))':
'@xterm/addon-fit@0.12.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4))':
dependencies:
'@xterm/xterm': 6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)
'@xterm/xterm': 6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4)
'@xterm/addon-ligatures@0.11.0-beta.300(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))':
'@xterm/addon-ligatures@0.11.0-beta.300(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4))':
dependencies:
'@xterm/xterm': 6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)
'@xterm/xterm': 6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4)
lru-cache: 11.5.1
opentype.js: 2.0.0
'@xterm/addon-search@0.17.0-beta.300(patch_hash=eee5338dd2621ece46e79c61ec06766cd7fadaf79ffdb24e2a8ab68e97ef31f0)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))':
'@xterm/addon-search@0.17.0-beta.300(patch_hash=eee5338dd2621ece46e79c61ec06766cd7fadaf79ffdb24e2a8ab68e97ef31f0)(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4))':
dependencies:
'@xterm/xterm': 6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)
'@xterm/xterm': 6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4)
'@xterm/addon-serialize@0.15.0-beta.300(patch_hash=851eac3d75e6d8c013b9f4c053e61d824b23965cb19ecc28e335e05059f3a294)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))':
'@xterm/addon-serialize@0.15.0-beta.300(patch_hash=851eac3d75e6d8c013b9f4c053e61d824b23965cb19ecc28e335e05059f3a294)(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4))':
dependencies:
'@xterm/xterm': 6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)
'@xterm/xterm': 6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4)
'@xterm/addon-unicode11@0.10.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))':
'@xterm/addon-unicode11@0.10.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4))':
dependencies:
'@xterm/xterm': 6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)
'@xterm/xterm': 6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4)
'@xterm/addon-web-links@0.13.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))':
'@xterm/addon-web-links@0.13.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4))':
dependencies:
'@xterm/xterm': 6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)
'@xterm/xterm': 6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4)
'@xterm/addon-webgl@0.20.0-beta.299(patch_hash=94687e89a0115e6e6aa102837f986debdc029c091527ee5eb4a4e17ceaf9473e)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))':
'@xterm/addon-webgl@0.20.0-beta.299(patch_hash=94687e89a0115e6e6aa102837f986debdc029c091527ee5eb4a4e17ceaf9473e)(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4))':
dependencies:
'@xterm/xterm': 6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)
'@xterm/xterm': 6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4)
'@xterm/headless@6.1.0-beta.302': {}
'@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)': {}
'@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4)': {}
abbrev@4.0.0: {}
@@ -9882,7 +9879,7 @@ snapshots:
ajv@8.20.0:
dependencies:
fast-deep-equal: 3.1.3
fast-uri: 3.1.5
fast-uri: 3.1.7
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
@@ -9994,7 +9991,7 @@ snapshots:
base64-js@1.5.1: {}
baseline-browser-mapping@2.10.27: {}
baseline-browser-mapping@2.11.21: {}
bcrypt-pbkdf@1.0.2:
dependencies:
@@ -10014,7 +10011,7 @@ snapshots:
http-errors: 2.0.1
iconv-lite: 0.7.2
on-finished: 2.4.1
qs: 6.15.2
qs: 6.16.0
raw-body: 3.0.2
type-is: 2.1.0
transitivePeerDependencies:
@@ -10040,13 +10037,13 @@ snapshots:
dependencies:
fill-range: 7.1.1
browserslist@4.28.2:
browserslist@4.28.9:
dependencies:
baseline-browser-mapping: 2.10.27
caniuse-lite: 1.0.30001791
electron-to-chromium: 1.5.351
node-releases: 2.0.38
update-browserslist-db: 1.2.3(browserslist@4.28.2)
baseline-browser-mapping: 2.11.21
caniuse-lite: 1.0.30001810
electron-to-chromium: 1.5.422
node-releases: 2.0.54
update-browserslist-db: 1.3.2(browserslist@4.28.9)
buffer-from@1.1.2: {}
@@ -10119,7 +10116,7 @@ snapshots:
camelcase@5.3.1: {}
caniuse-lite@1.0.30001791: {}
caniuse-lite@1.0.30001810: {}
ccount@2.0.1: {}
@@ -10573,7 +10570,7 @@ snapshots:
dom-accessibility-api@0.6.3: {}
dompurify@3.4.13:
dompurify@3.4.14:
optionalDependencies:
'@types/trusted-types': 2.0.7
@@ -10647,7 +10644,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
electron-to-chromium@1.5.351: {}
electron-to-chromium@1.5.422: {}
electron-updater@6.8.9(supports-color@7.2.0):
dependencies:
@@ -10688,7 +10685,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
electron@43.4.1(supports-color@7.2.0):
electron@43.6.0(supports-color@7.2.0):
dependencies:
'@electron-internal/extract-zip': 1.0.4
'@electron/get': 5.0.0(supports-color@7.2.0)
@@ -10862,7 +10859,7 @@ snapshots:
exponential-backoff@3.1.3: {}
express-rate-limit@8.5.2(express@5.2.1(supports-color@7.2.0)):
express-rate-limit@8.5.2(express@5.2.1):
dependencies:
express: 5.2.1(supports-color@7.2.0)
ip-address: 10.4.0
@@ -10889,7 +10886,7 @@ snapshots:
once: 1.4.0
parseurl: 1.3.3
proxy-addr: 2.0.7
qs: 6.15.2
qs: 6.16.0
range-parser: 1.2.1
router: 2.2.0(supports-color@7.2.0)
send: 1.2.1(supports-color@7.2.0)
@@ -10922,7 +10919,7 @@ snapshots:
dependencies:
fast-string-truncated-width: 3.0.3
fast-uri@3.1.5: {}
fast-uri@3.1.7: {}
fast-wrap-ansi@0.2.2:
dependencies:
@@ -11299,9 +11296,7 @@ snapshots:
dependencies:
lru-cache: 6.0.0
html-parse-stringify@3.0.1:
dependencies:
void-elements: 3.1.0
html-parse-stringify@4.0.1: {}
html-to-image@1.11.13: {}
@@ -11361,7 +11356,7 @@ snapshots:
minimatch: 10.2.5
ora: 9.4.0
react: 19.2.8
react-i18next: 17.0.8(i18next@26.3.1(typescript@7.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2)
react-i18next: 17.0.13(i18next@26.3.1(typescript@7.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2)
yaml: 2.9.0
transitivePeerDependencies:
- '@swc/helpers'
@@ -11929,7 +11924,7 @@ snapshots:
d3-sankey: 0.12.3
dagre-d3-es: 7.0.14
dayjs: 1.11.23
dompurify: 3.4.13
dompurify: 3.4.14
es-toolkit: 1.46.1
fastdom: 1.0.12
katex: 0.16.47
@@ -12214,7 +12209,7 @@ snapshots:
monaco-editor@0.55.1:
dependencies:
dompurify: 3.4.13
dompurify: 3.4.14
marked: 14.0.0
ms@2.1.3: {}
@@ -12250,7 +12245,7 @@ snapshots:
nan@2.26.2:
optional: true
nanoid@3.3.17: {}
nanoid@3.3.18: {}
negotiator@1.0.0: {}
@@ -12289,7 +12284,7 @@ snapshots:
dependencies:
node-addon-api: 7.1.1
node-releases@2.0.38: {}
node-releases@2.0.54: {}
nopt@9.0.0:
dependencies:
@@ -12575,7 +12570,7 @@ snapshots:
plist@3.1.0:
dependencies:
'@xmldom/xmldom': 0.8.13
'@xmldom/xmldom': 0.8.15
base64-js: 1.5.1
xmlbuilder: 15.1.1
@@ -12590,14 +12585,14 @@ snapshots:
path-data-parser: 0.1.0
points-on-curve: 0.2.0
postcss-selector-parser@7.1.1:
postcss-selector-parser@7.1.6:
dependencies:
cssesc: 3.0.0
util-deprecate: 1.0.2
postcss@8.5.25:
dependencies:
nanoid: 3.3.17
nanoid: 3.3.18
picocolors: 1.1.1
source-map-js: 1.2.1
@@ -12743,9 +12738,10 @@ snapshots:
pngjs: 5.0.0
yargs: 15.4.1
qs@6.15.2:
qs@6.16.0:
dependencies:
side-channel: 1.1.0
es-define-property: 1.0.1
side-channel: 1.1.1
queue-microtask@1.2.3: {}
@@ -12840,10 +12836,10 @@ snapshots:
optionalDependencies:
react: 19.2.8
react-i18next@17.0.8(i18next@26.3.1(typescript@7.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2):
react-i18next@17.0.13(i18next@26.3.1(typescript@7.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2):
dependencies:
'@babel/runtime': 7.29.7
html-parse-stringify: 3.0.1
html-parse-stringify: 4.0.1
i18next: 26.3.1(typescript@7.0.2)
react: 19.2.8
use-sync-external-store: 1.6.0(react@19.2.8)
@@ -13222,7 +13218,7 @@ snapshots:
'@dotenvx/dotenvx': 1.66.0
'@modelcontextprotocol/sdk': 1.30.0(zod@3.25.76)
'@types/validate-npm-package-name': 4.0.2
browserslist: 4.28.2
browserslist: 4.28.9
commander: 14.0.3
cosmiconfig: 9.0.1(typescript@7.0.2)
dedent: 1.7.2
@@ -13236,7 +13232,7 @@ snapshots:
open: 11.0.0
ora: 8.2.0
postcss: 8.5.25
postcss-selector-parser: 7.1.1
postcss-selector-parser: 7.1.6
prompts: 2.4.2
recast: 0.23.11
stringify-object: 5.0.0
@@ -13296,7 +13292,7 @@ snapshots:
object-inspect: 1.13.4
side-channel-map: 1.0.1
side-channel@1.1.0:
side-channel@1.1.1:
dependencies:
es-errors: 1.3.0
object-inspect: 1.13.4
@@ -13668,9 +13664,9 @@ snapshots:
graceful-fs: 4.2.11
node-int64: 0.4.0
update-browserslist-db@1.2.3(browserslist@4.28.2):
update-browserslist-db@1.3.2(browserslist@4.28.9):
dependencies:
browserslist: 4.28.2
browserslist: 4.28.9
escalade: 3.2.0
picocolors: 1.1.1
@@ -13747,8 +13743,6 @@ snapshots:
transitivePeerDependencies:
- msw
void-elements@3.1.0: {}
vscode-oniguruma@2.0.1: {}
vscode-textmate@9.3.2: {}
+1 -1
View File
@@ -52,7 +52,7 @@ allowBuilds:
windows-native-registry: false
overrides:
monaco-editor>dompurify: 3.4.13
monaco-editor>dompurify: 3.4.14
patchedDependencies:
node-pty@1.1.0: config/patches/node-pty@1.1.0.patch
@@ -0,0 +1,61 @@
import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types'
import type { NativeChatBlock } from '../../shared/native-chat-types'
import { buildImageDataUri } from '../../shared/image-data-uri'
import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from '../native-chat/agent-session-journal/journal-payload-bounds'
import { readString } from './codex-item-field-readers'
import type { CodexThreadItem } from './codex-thread-item-identity'
// Leave room for operation text and the journal envelope beside inline image bytes.
const MAX_IMAGE_REFERENCE_BYTES = DEFAULT_JOURNAL_PAYLOAD_LIMITS.inlineHeadBytes / 2
function imagePath(item: CodexThreadItem, key: string): string | null {
const value = readString(item, key)
return value?.trim() && Buffer.byteLength(value, 'utf8') <= MAX_IMAGE_REFERENCE_BYTES
? value
: null
}
function generatedImageUrl(item: CodexThreadItem): string | null {
const result = readString(item, 'result')
if (!result || result.length > MAX_IMAGE_REFERENCE_BYTES) {
return null
}
const match = /^data:(image\/(?:png|jpeg|webp));base64,(.*)$/s.exec(result)
const base64 = (match?.[2] ?? result).replace(/\s/g, '')
if (!base64 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(base64)) {
return null
}
const url = buildImageDataUri(match?.[1] ?? 'image/png', base64)
return url && url.length <= MAX_IMAGE_REFERENCE_BYTES ? url : null
}
export function codexImageItemBody(item: CodexThreadItem): AgentJournalMessageItem {
let image: Extract<NativeChatBlock, { type: 'image-ref' }> | null = null
let text: string
if (item.type === 'imageView') {
const path = imagePath(item, 'path')
text = path ? 'Viewed image' : 'Image view: preview unavailable'
image = path ? { type: 'image-ref', path } : null
} else {
const status = readString(item, 'status')
if (item.failure || status === 'failed') {
text = 'Image generation failed'
} else if (status !== 'completed') {
text = status === 'inProgress' ? 'Generating image…' : 'Image generation: preview unavailable'
} else {
const path = imagePath(item, 'savedPath')
const url = path ? null : generatedImageUrl(item)
image = path
? { type: 'image-ref', path }
: url
? { type: 'image-ref', url, alt: 'Generated image' }
: null
text = image ? 'Generated image' : 'Image generated: preview unavailable'
}
}
return {
kind: 'message',
role: 'assistant',
blocks: [{ type: 'text', text }, ...(image ? [image] : [])]
}
}
@@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest'
import { codexItemBody, codexStreamingJournalItem } from './codex-structured-item-translation'
import { AgentJournalItemBodySchema } from '../../shared/agent-session-journal-schemas'
import { projectStructuredItemsToNativeChat } from '../../shared/structured-agent-session-projection'
describe('plan document translation', () => {
it('marks both complete documents and streaming snapshots', () => {
const item = { id: 'plan-1', type: 'plan', text: '# Plan\n\nReadable prose.' }
expect(codexItemBody(item)).toEqual({
kind: 'status',
text: item.text,
presentation: 'plan-document'
})
expect(codexStreamingJournalItem(item, '# Plan\n\nPartial').body).toEqual({
kind: 'status',
text: '# Plan\n\nPartial',
presentation: 'plan-document'
})
expect(codexItemBody({ id: 'plan-1', type: 'plan' })).toBeNull()
})
it('preserves the full existing reasoning body byte for byte', () => {
expect(
codexItemBody({ id: 'r', type: 'reasoning', summary: ['Thinking through the problem.'] })
).toEqual({
kind: 'status',
text: 'Thinking through the problem.'
})
expect(codexStreamingJournalItem({ id: 'r', type: 'reasoning' }, 'Thinking…')).toEqual({
body: { kind: 'status', text: 'Thinking…' },
handled: true
})
})
})
describe('image item translation', () => {
it.each(['/remote/work/image.png', 'C:\\work\\image.png'])(
'preserves the execution-host path %s and old-reader operation text',
(path) => {
expect(codexItemBody({ id: 'view', type: 'imageView', path })).toEqual({
kind: 'message',
role: 'assistant',
blocks: [
{ type: 'text', text: 'Viewed image' },
{ type: 'image-ref', path }
]
})
}
)
it('prefers saved paths over unbounded inline image data', () => {
const body = codexItemBody({
id: 'gen',
type: 'imageGeneration',
status: 'completed',
savedPath: '/remote/image.png',
result: 'A'.repeat(100_000)
})
expect(body).toEqual({
kind: 'message',
role: 'assistant',
blocks: [
{ type: 'text', text: 'Generated image' },
{ type: 'image-ref', path: '/remote/image.png' }
]
})
expect(AgentJournalItemBodySchema.safeParse(body).success).toBe(true)
const [message] = projectStructuredItemsToNativeChat([
{ itemId: 'gen', revision: 1, sequence: 1, observedAt: 1, body: body! }
])
expect(message?.blocks).toEqual(body?.kind === 'message' ? body.blocks : [])
})
it.each(['AAAA', 'data:image/png;base64,AAAA'])(
'maps bounded image data onto a meaningful existing image-ref: %s',
(result) => {
expect(
codexItemBody({ id: 'gen', type: 'imageGeneration', status: 'completed', result })
).toMatchObject({
kind: 'message',
role: 'assistant',
blocks: [
{ type: 'text', text: 'Generated image' },
{ type: 'image-ref', url: 'data:image/png;base64,AAAA', alt: 'Generated image' }
]
})
}
)
it.each(['A'.repeat(20_000), 'not valid image bytes', 'data:text/html;base64,AAAA', ''])(
'keeps unavailable results bounded and readable',
(result) => {
const body = codexItemBody({
id: 'gen',
type: 'imageGeneration',
status: 'completed',
result
})
expect(body).toEqual({
kind: 'message',
role: 'assistant',
blocks: [{ type: 'text', text: 'Image generated: preview unavailable' }]
})
}
)
it.each([
[{ status: 'inProgress' }, 'Generating image…'],
[{ status: 'failed' }, 'Image generation failed'],
[{ status: 'completed', failure: { type: 'usageLimitExceeded' } }, 'Image generation failed'],
[{ status: 'future-state' }, 'Image generation: preview unavailable']
])('does not invent completed output for %j', (fields, text) => {
expect(codexItemBody({ id: 'gen', type: 'imageGeneration', ...fields })).toEqual({
kind: 'message',
role: 'assistant',
blocks: [{ type: 'text', text }]
})
})
})
@@ -7,6 +7,7 @@ import {
DEFAULT_JOURNAL_PAYLOAD_LIMITS
} from '../native-chat/agent-session-journal/journal-payload-bounds'
import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame'
import { codexImageItemBody } from './codex-image-item-translation'
import { commandActionFacts } from './codex-command-action-class'
import {
readFirstString,
@@ -250,6 +251,23 @@ export function codexJournalItem(item: CodexThreadItem): CodexJournalItem {
if (item.type === 'webSearch') {
return webSearchItem(item)
}
if (item.type === 'imageView' || item.type === 'imageGeneration') {
return { body: codexImageItemBody(item), handled: true }
}
if (item.type === 'plan') {
const text = readTextContent(item, 'text')
return {
body:
text === null
? null
: {
kind: 'status',
text: boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text,
presentation: 'plan-document'
},
handled: true
}
}
if (item.type === 'reasoning' || item.type === 'plan') {
const text =
readTextContent(item, 'text') ??
@@ -298,6 +316,16 @@ export function codexStreamingJournalItem(item: CodexThreadItem, text: string):
handled: true
}
}
if (item.type === 'plan') {
return {
body: {
kind: 'status',
text: boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text,
presentation: 'plan-document'
},
handled: true
}
}
const bounded = boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS)
return { body: { kind: 'status', text: bounded.text }, handled: true }
}
@@ -0,0 +1,133 @@
import { describe, expect, it } from 'vitest'
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types'
import { projectStructuredItemsToNativeChat } from '../../shared/structured-agent-session-projection'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import {
createCodexJournalTranslator,
MAX_CODEX_GENERIC_TURN_BUCKETS
} from './codex-structured-journal-translation'
function setup() {
const rows = new Map<string, AgentJournalItemBody>()
let writes = 0
const sink: StructuredAgentSessionEventSink = {
appendItem: (identity, body) => {
writes += 1
rows.set(agentJournalItemKey(identity), body)
},
appendTombstone: (identity) => rows.delete(agentJournalItemKey(identity)),
publish: () => {}
}
const translator = createCodexJournalTranslator({ sink })
const send = (method: string, turnId = 'turn', threadId = 'thread') =>
translator.handle({
type: 'notification',
sessionId: 'session',
threadId,
method,
params: { turnId, item: { id: `compact-${turnId}`, type: 'contextCompaction' } }
})
return { rows, sink, translator, send, writes: () => writes }
}
describe('compaction provider generation compatibility', () => {
it.each([
['item/completed'],
['thread/compacted'],
['item/completed', 'thread/compacted'],
['thread/compacted', 'item/completed']
])('projects one readable divider for %j', (...methods) => {
const { rows, translator, send } = setup()
expect(send('item/started')).toEqual({ accepted: true })
expect(rows.size).toBe(0)
for (const method of methods) {
expect(send(method)).toEqual({ accepted: true })
}
const messages = projectStructuredItemsToNativeChat(
[...rows].map(([itemId, body], index) => ({
itemId,
body,
sequence: index + 1,
revision: 1,
observedAt: 1
}))
)
expect(messages).toHaveLength(1)
expect(messages[0]?.blocks).toEqual([
{ type: 'text', text: 'Context compacted', presentation: 'compaction' }
])
translator.dispose()
})
it('keeps the canonical item authoritative and scopes deduplication by thread and turn', () => {
const { rows, translator, send, writes } = setup()
send('thread/compacted')
send('item/completed')
expect(writes()).toBe(2)
send('thread/compacted')
send('item/completed')
expect(writes()).toBe(2)
send('item/completed', 'turn-2')
send('item/completed', 'turn', 'other-thread')
expect(rows.size).toBe(3)
translator.dispose()
})
it.each(['append', 'publish'])('does not suppress the retry after rejected %s', (stage) => {
const { rows, sink, translator, send } = setup()
let reject = true
let publishes = 0
sink.tryAppendItem = (identity, body) => {
if (stage === 'append' && reject) {
return { accepted: false, reason: 'backpressure' }
}
sink.appendItem(identity, body)
return { accepted: true }
}
sink.tryPublish = () => {
publishes += 1
return stage === 'publish' && reject
? { accepted: false, reason: 'backpressure' }
: { accepted: true }
}
expect(send('item/completed')).toEqual({ accepted: false, reason: 'backpressure' })
reject = false
expect(send('item/completed')).toEqual({ accepted: true })
expect(rows.size).toBe(1)
expect(publishes).toBe(stage === 'publish' ? 2 : 1)
translator.dispose()
})
it('bounds retained turns and keeps the same journal identity after eviction', () => {
const { rows, translator, send, writes } = setup()
send('item/completed', 'oldest')
for (let index = 0; index < MAX_CODEX_GENERIC_TURN_BUCKETS; index += 1) {
send('item/completed', `turn-${index}`)
}
const before = writes()
send('thread/compacted', 'oldest')
expect(writes()).toBe(before + 1)
expect(rows.size).toBe(MAX_CODEX_GENERIC_TURN_BUCKETS + 1)
translator.dispose()
})
it('restores canonical compaction history over a previously journaled legacy fallback', () => {
const { rows, sink, translator, send } = setup()
send('thread/compacted')
translator.dispose()
const restored = createCodexJournalTranslator({ sink })
expect(
restored.restoreThread('thread', {
turns: [{ id: 'turn', items: [{ id: 'renumbered', type: 'contextCompaction' }] }]
})
).toEqual({ accepted: true })
expect(rows.size).toBe(1)
expect([...rows.values()][0]).toEqual({
kind: 'status',
text: 'Context compacted',
presentation: 'compaction'
})
restored.dispose()
})
})
@@ -0,0 +1,66 @@
import { createHash } from 'node:crypto'
import { isCodexCompactionComplete } from '../native-chat/agent-session-wire/structured-session-compaction'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import {
CODEX_JOURNAL_ADMITTED,
type CodexJournalTranslationAdmission
} from './codex-structured-journal-contracts'
import { MAX_CODEX_GENERIC_TURN_BUCKETS } from './codex-structured-journal-limits'
import { appendCodexLifecycleItem, publishCodexLifecycle } from './codex-structured-journal-sink'
import { readCodexTurnId } from './codex-structured-thread-facts'
export class CodexJournalCompactions {
private readonly turns = new Map<string, 'item' | 'legacy'>()
constructor(
private readonly sink: StructuredAgentSessionEventSink,
private readonly activeTurn: (threadId: string) => string | null
) {}
handle(event: {
threadId: string
method: string
params: unknown
}): CodexJournalTranslationAdmission | null {
if (!isCodexCompactionComplete(event.method, event.params)) {
return null
}
const turnId = readCodexTurnId(event.params) ?? this.activeTurn(event.threadId)
if (!turnId) {
return null
}
// Collapse compactions within a thread/turn; the canonical item replaces its legacy fallback.
const key = createHash('sha256')
.update(JSON.stringify([event.threadId, turnId]))
.digest('hex')
const source = event.method === 'item/completed' ? 'item' : 'legacy'
const previous = this.turns.get(key)
if (previous === 'item' || previous === source) {
return CODEX_JOURNAL_ADMITTED
}
const admission = appendCodexLifecycleItem(
this.sink,
{ provider: 'orca', clientMessageId: `codex-compaction:${key}` },
{ kind: 'status', text: 'Context compacted', presentation: 'compaction' }
)
if (!admission.accepted) {
return admission
}
const published = publishCodexLifecycle(this.sink)
if (!published.accepted) {
return published
}
this.turns.set(key, source)
while (this.turns.size > MAX_CODEX_GENERIC_TURN_BUCKETS) {
const oldest = this.turns.keys().next().value
if (oldest !== undefined) {
this.turns.delete(oldest)
}
}
return CODEX_JOURNAL_ADMITTED
}
clear(): void {
this.turns.clear()
}
}
@@ -78,6 +78,9 @@ export class CodexJournalItems {
if (source === 'live' && item.type === 'userMessage') {
return { handled: true, admission: CODEX_JOURNAL_ADMITTED }
}
if (item.type === 'contextCompaction' && event.method === 'item/started') {
return { handled: true, admission: CODEX_JOURNAL_ADMITTED }
}
const translated = codexJournalItem(item)
const command = readCodexJournalString(item, 'command')
if (command) {
@@ -603,3 +603,68 @@ describe('codex journal translation', () => {
expect(tap.rows).toEqual([])
})
})
describe('notice journal pipeline', () => {
it('replaces a legacy compaction divider with its canonical item at the same journal key', () => {
const { translator, tap } = translatorWith()
translator.handle(notification('thread/compacted', { threadId: THREAD_ID, turnId: TURN_ID }))
translator.handle(
notification('item/completed', {
turnId: TURN_ID,
item: { id: 'compact', type: 'contextCompaction' }
})
)
expect(tap.rows).toHaveLength(2)
expect([...new Map(tap.rows.map((row) => [row.key, row.body])).values()]).toEqual([
expect.objectContaining({
kind: 'status',
text: 'Context compacted',
presentation: 'compaction'
})
])
translator.dispose()
})
it('preserves every notice after generic traffic reaches its cap', () => {
const { translator, tap, window } = translatorWith()
translator.handle(TURN_STARTED)
for (let index = 0; index < MAX_CODEX_GENERIC_ROWS_PER_TURN; index += 1) {
translator.handle(notification('future/notification', { value: index }))
}
for (const method of ['warning', 'guardianWarning', 'configWarning', 'deprecationNotice']) {
translator.handle(notification(method, { message: method, summary: method }))
}
window.fire()
expect(tap.rows.slice(-4).map((row) => row.body)).toEqual([
expect.objectContaining({ text: 'warning', tone: 'warning' }),
expect.objectContaining({ text: 'guardianWarning', tone: 'warning' }),
expect.objectContaining({ text: 'configWarning', tone: 'warning' }),
expect.objectContaining({ text: 'deprecationNotice', tone: 'notice' })
])
translator.dispose()
})
it('keeps the plan document marker during streamed updates and completion', () => {
const { translator, tap, window } = translatorWith()
translator.handle(
notification('item/started', {
turnId: TURN_ID,
item: { id: 'plan', type: 'plan', text: '' }
})
)
translator.handle(
notification('item/plan/delta', { turnId: TURN_ID, itemId: 'plan', delta: '# Plan' })
)
window.fire()
expect(tap.rows.at(-1)?.body).toMatchObject({ text: '# Plan', presentation: 'plan-document' })
translator.handle(
notification('item/completed', {
turnId: TURN_ID,
item: { id: 'plan', type: 'plan', text: '# Plan\n\nComplete' }
})
)
expect(tap.rows.at(-1)?.body).toMatchObject({
text: '# Plan\n\nComplete',
presentation: 'plan-document'
})
translator.dispose()
})
})
@@ -6,6 +6,7 @@ import {
import { CodexSubagentRoster } from './codex-subagent-roster'
import { readCodexThreadItem } from './codex-structured-item-translation'
import { CodexJournalGenericFrames } from './codex-structured-journal-generic-frames'
import { CodexJournalCompactions } from './codex-structured-journal-compactions'
import { CodexJournalItems } from './codex-structured-journal-items'
import { CodexJournalPrompts } from './codex-structured-journal-prompts'
import {
@@ -46,6 +47,9 @@ export function createCodexJournalTranslator(
deps: CodexJournalTranslatorDeps
): CodexJournalTranslator {
const activeTurns = new CodexJournalActiveTurns()
const compactions = new CodexJournalCompactions(deps.sink, (threadId) =>
activeTurns.current(threadId)
)
const genericFrames = new CodexJournalGenericFrames(deps, (threadId) =>
activeTurns.current(threadId)
)
@@ -94,6 +98,10 @@ export function createCodexJournalTranslator(
currentTurnIds: activeTurns.byThread,
ordinals: items.ordinals,
handleItem: (event) => {
const compaction = compactions.handle(event)
if (compaction) {
return compaction
}
const translated = items.handle(event, 'history')
return translated.handled
? translated.admission
@@ -135,6 +143,7 @@ export function createCodexJournalTranslator(
items.activeItems.clear()
prompts.pending.clear()
activeTurns.clear()
compactions.clear()
return CODEX_JOURNAL_ADMITTED
}
if (event.type === 'notification') {
@@ -168,6 +177,10 @@ export function createCodexJournalTranslator(
if (event.method === 'turn/started') {
return startTurn(event)
}
const compaction = compactions.handle(event)
if (compaction) {
return publishActivity(event, compaction)
}
if (event.method === 'turn/completed') {
return completeTurn(event)
}
@@ -223,17 +236,15 @@ export function createCodexJournalTranslator(
genericFrames.dispose()
subagents.dispose()
activeTurns.clear()
compactions.clear()
}
}
/** Settles the item a notification the transport refused to carry left
* mid-flight; null when the frame is not one. */
function settleOversizedNotification(event: {
sessionId: string
threadId: string
kind: string
payload: unknown
}): CodexJournalTranslationAdmission | null {
function settleOversizedNotification(
event: Extract<CodexStructuredSessionEvent, { type: 'provider-frame' }>
): CodexJournalTranslationAdmission | null {
return settleCodexOversizedNotificationFrame({
...event,
sink: deps.sink,
@@ -242,11 +253,9 @@ export function createCodexJournalTranslator(
})
}
function startTurn(event: {
sessionId: string
threadId: string
params: unknown
}): CodexJournalTranslationAdmission {
function startTurn(
event: Extract<CodexStructuredSessionEvent, { type: 'notification' }>
): CodexJournalTranslationAdmission {
const turnId = readCodexTurnId(event.params)
if (!turnId) {
return CODEX_JOURNAL_ADMITTED
@@ -104,11 +104,12 @@ describe('provider frame classification catalog', () => {
})
it('dispositions codex item-form frames, which the method catalog never matches', () => {
// `thread/compacted` is already chrome; its item form is the same event and
// must not leak `codex · item:contextCompaction` into the transcript.
expect(classifyProviderFrame('codex', 'item:contextCompaction', {})).toBe('status-chrome')
// Both provider generations reach the journal's compaction deduplication.
expect(classifyProviderFrame('codex', 'item:contextCompaction', {})).toBe(
'timeline-substantive'
)
expect(classifyProviderFrame('codex', 'notification:thread/compacted', {})).toBe(
'status-chrome'
'timeline-substantive'
)
// An item type nobody has dispositioned still falls through visibly.
expect(classifyProviderFrame('codex', 'item:futureThing', {})).toBe('timeline-substantive')
@@ -165,6 +166,21 @@ describe('provider frame classification catalog', () => {
})
})
describe('notice disposition boundaries', () => {
it.each(['warning', 'guardianWarning', 'deprecationNotice', 'configWarning'])(
'retains the error-surface cap exemption for %s',
(method) => {
expect(classifyProviderFrame('codex', `notification:${method}`, {})).toBe('error-surface')
}
)
it('does not change usage or rate-limit classifications', () => {
expect(classifyProviderFrame('codex', 'thread/tokenUsage/updated', {})).toBe('status-chrome')
expect(classifyProviderFrame('codex', 'account/rateLimits/updated', {})).toBe(
'suppressed-benign'
)
})
})
describe('codex subagent item disposition', () => {
it('keeps subagent lifecycle out of the transcript now that it renders as a roster row', () => {
expect(
@@ -66,7 +66,7 @@ export const PROVIDER_FRAME_CLASSIFICATIONS = {
'item/reasoning/summaryTextDelta': 'stream-into-item',
'item/reasoning/summaryPartAdded': 'stream-into-item',
'item/reasoning/textDelta': 'stream-into-item',
'thread/compacted': 'status-chrome',
'thread/compacted': 'timeline-substantive',
'model/rerouted': 'status-chrome',
'model/verification': 'status-chrome',
'turn/moderationMetadata': 'suppressed-benign',
@@ -196,9 +196,8 @@ function hasProviderError(payload: unknown): boolean {
* new item type cannot leak `codex · item:<type>` into the transcript. The
* notification catalog above is keyed by METHOD and never matches these. */
const CODEX_ITEM_CLASSIFICATIONS: Record<string, ProviderFrameClassification> = {
// The `thread/compacted` notification is already chrome; its item form is the
// same event and must not read as a mysterious opcode row.
contextCompaction: 'status-chrome',
// The journal coalesces this canonical completion with the legacy notification.
contextCompaction: 'timeline-substantive',
// Subagent lifecycle renders as the spawn-group roster row, so its raw items
// must not print a gray `codex · item:<type>` row beside it. The live
// notification path intercepts them before this catalog is reached;
@@ -11,6 +11,13 @@ function record(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' ? (value as Record<string, unknown>) : {}
}
export function isCodexCompactionComplete(method: string, params: unknown): boolean {
return (
method === 'thread/compacted' ||
(method === 'item/completed' && record(record(params).item).type === 'contextCompaction')
)
}
/** A receipt is not completion; keep listening through the provider's terminal frame. */
export class StructuredSessionCompaction {
private readonly pending = new Map<string, PendingCompaction>()
@@ -95,10 +102,7 @@ export class StructuredSessionCompaction {
if (method === 'turn/started' && typeof turn.id === 'string') {
pending.turnId = turn.id
}
if (
method === 'thread/compacted' ||
(method === 'item/completed' && record(params.item).type === 'contextCompaction')
) {
if (isCodexCompactionComplete(method, params)) {
pending.compacted = true
}
if (method === 'turn/completed' && turn.id === pending.turnId) {
@@ -235,3 +235,41 @@ describe('a failed provider dependency', () => {
).toBeNull()
})
})
describe('typed notice metadata', () => {
it('publishes readable compaction statuses for both provider forms', () => {
expect(
unhandledProviderFrameJournalItem('codex', 'notification:thread/compacted', {})
).toMatchObject({
classification: 'timeline-substantive',
body: { kind: 'status', text: 'Context compacted', presentation: 'compaction' }
})
expect(unhandledProviderFrameJournalItem('codex', 'item:contextCompaction', {})).toMatchObject({
body: { kind: 'status', text: 'Context compacted', presentation: 'compaction' }
})
})
it.each([
['warning', { message: 'Check this' }, 'warning', 'Check this'],
['guardianWarning', { message: 'Review required' }, 'warning', 'Review required'],
[
'configWarning',
{ summary: 'Invalid option', details: 'Remove the option' },
'warning',
'Invalid option\n\nRemove the option'
],
[
'deprecationNotice',
{ summary: 'Old option', details: 'Use its replacement' },
'notice',
'Old option\n\nUse its replacement'
],
['error', { error: { message: 'Connection failed' } }, 'error', 'Connection failed']
])('assigns the tone and readable text for %s', (method, payload, tone, text) => {
expect(
unhandledProviderFrameJournalItem('codex', `notification:${method}`, payload)
).toMatchObject({
classification: 'error-surface',
body: { kind: 'status', text, tone }
})
})
})
@@ -90,12 +90,38 @@ export function unhandledProviderFrameJournalItem(
// Why: the opcode alone ("codex · notification:warning") tells the user nothing
// and reads as protocol noise. Lead with the provider's own sentence when it has
// one; the raw frame stays behind the row's disclosure either way.
const message = readableProviderFrameText(payload)
const method = kind.startsWith('notification:') ? kind.slice('notification:'.length) : kind
const compaction =
provider === 'codex' && (method === 'thread/compacted' || method === 'item:contextCompaction')
const noticeTone =
provider === 'codex'
? method === 'deprecationNotice'
? 'notice'
: ['warning', 'guardianWarning', 'configWarning'].includes(method)
? 'warning'
: undefined
: undefined
const tone = noticeTone ?? (classification === 'error-surface' ? 'error' : undefined)
let message = readableProviderFrameText(payload)
if (
provider === 'codex' &&
(method === 'configWarning' || method === 'deprecationNotice') &&
typeof payload === 'object' &&
payload !== null
) {
const record = payload as Record<string, unknown>
message =
[record.summary, record.details]
.filter((part): part is string => typeof part === 'string' && part.trim().length > 0)
.join('\n\n') || message
}
const display = message ? boundInlineText(message, limits) : null
return {
body: {
kind: 'status',
text: display?.text ?? `${provider} · ${kind}`,
text: compaction ? 'Context compacted' : (display?.text ?? `${provider} · ${kind}`),
...(compaction ? { presentation: 'compaction' } : {}),
...(tone ? { tone } : {}),
providerFrame: { provider, kind, payload: bounded }
},
classification: classification === 'error-surface' ? 'error-surface' : 'timeline-substantive'
@@ -30,6 +30,7 @@ export function migrate(this: OrchestrationDb): void {
migrateV37.call(this, current)
migrateV38.call(this, current)
migrateV39.call(this, current)
this.createMailboxDeliveryIndexesIfPossible()
this.db.pragma(`user_version = ${SCHEMA_VERSION}`)
this.db.exec('COMMIT')
} catch (err) {
@@ -27,6 +27,13 @@ export function createMailboxDeliveryIndexesIfPossible(this: OrchestrationDb): v
ON messages(to_handle, sequence)
WHERE read = 0 AND pointer_enter_pending > 0;
`)
if (this.hasColumn('messages', 'pointer_pty_id')) {
// Working-title frames release one PTY's reservations, including already-read rows.
this.db.exec(`
CREATE INDEX IF NOT EXISTS idx_messages_pending_pointer_pty
ON messages(pointer_pty_id) WHERE pointer_enter_pending > 0;
`)
}
}
if (
@@ -0,0 +1,91 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import Database from '../../sqlite/sync-database'
import { OrchestrationDb } from './db'
function assertTargetedRelease(db: OrchestrationDb): void {
const prepare = vi.spyOn(db.db, 'prepare')
db.releasePendingMailboxPointerForPty('absent-pty')
const sql = prepare.mock.calls.find(([query]) => query.startsWith('UPDATE messages'))?.[0]
prepare.mockRestore()
expect(sql).toBeDefined()
const plan = db.db.prepare(`EXPLAIN QUERY PLAN ${sql}`).all(1, 'absent-pty') as {
detail: string
}[]
expect(plan.some(({ detail }) => detail.includes('SCAN messages'))).toBe(false)
expect(plan.some(({ detail }) => detail.includes('idx_messages_pending_pointer_pty'))).toBe(true)
}
describe('PTY mailbox reservation cleanup', () => {
it('uses a PTY lookup and preserves reservation settlement semantics', () => {
const db = new OrchestrationDb(':memory:')
try {
const seed = db.db.prepare(`INSERT INTO messages
(id, from_handle, to_handle, subject, read, pointer_enter_pending,
pointer_pty_id, pointer_process_incarnation, delivered_at)
VALUES (?, 'sender', 'recipient', 'test', ?, ?, ?, 'incarnation', ?)`)
db.db.exec('BEGIN')
for (let i = 0; i < 1000; i++) {
seed.run(`history-${i}`, 1, 0, null, null)
}
seed.run('reserved', 0, 1, 'target', '2026-01-01 00:00:00')
seed.run('written', 0, 2, 'target', null)
seed.run('entered', 0, 3, 'target', null)
seed.run('read', 1, 2, 'target', '2026-01-01 00:00:00')
seed.run('other', 0, 1, 'other-pty', null)
db.db.exec('COMMIT')
assertTargetedRelease(db)
db.releasePendingMailboxPointerForPty('target')
const read = (id: string) => db.db.prepare('SELECT * FROM messages WHERE id = ?').get(id)
for (const id of ['reserved', 'written', 'entered', 'read']) {
expect(read(id)).toMatchObject({
pointer_enter_pending: 0,
pointer_pty_id: null,
pointer_process_incarnation: null
})
}
expect(read('reserved')).toMatchObject({ delivered_at: null, read: 0 })
expect(read('written')).toMatchObject({ delivered_at: expect.any(String), read: 0 })
expect(read('entered')).toMatchObject({ delivered_at: expect.any(String), read: 0 })
expect(read('read')).toMatchObject({ delivered_at: '2026-01-01 00:00:00', read: 1 })
expect(read('other')).toMatchObject({ pointer_enter_pending: 1, pointer_pty_id: 'other-pty' })
expect(read('history-0')).toMatchObject({ read: 1, delivered_at: null })
} finally {
db.close()
}
})
it.each(['current', 'before-pointer-columns'])(
'installs the lookup on first open of an existing %s database',
(version) => {
const dir = mkdtempSync(join(tmpdir(), 'orca-pointer-release-'))
const path = join(dir, 'orchestration.db')
try {
new OrchestrationDb(path).close()
const raw = new Database(path)
try {
raw.exec('DROP INDEX idx_messages_pending_pointer_pty')
if (version === 'before-pointer-columns') {
raw.exec(`DROP INDEX idx_messages_pending_pointer_enter;
ALTER TABLE messages DROP COLUMN pointer_enter_pending;
ALTER TABLE messages DROP COLUMN pointer_pty_id;
ALTER TABLE messages DROP COLUMN pointer_process_incarnation;`)
raw.pragma('user_version = 32')
}
} finally {
raw.close()
}
const reopened = new OrchestrationDb(path)
try {
assertTargetedRelease(reopened)
} finally {
reopened.close()
}
} finally {
rmSync(dir, { recursive: true, force: true })
}
}
)
})
@@ -431,7 +431,9 @@ describe('OrchestrationDb version-skew migration', () => {
const raw = new Database(dbPath)
raw.exec(
'DROP INDEX IF EXISTS idx_messages_pending_pointer_enter; ALTER TABLE messages DROP COLUMN pointer_enter_pending;'
`DROP INDEX IF EXISTS idx_messages_pending_pointer_enter;
DROP INDEX IF EXISTS idx_messages_pending_pointer_pty;
ALTER TABLE messages DROP COLUMN pointer_enter_pending;`
)
raw.pragma('user_version = 33')
expect(resolveOrchestrationMigrationStartVersion(raw, 33, SCHEMA_VERSION)).toBe(6)
@@ -200,7 +200,7 @@ describe('NativeChatMessageList assistant messages', () => {
/>
)
const settledTool = screen.getByText('shell pnpm test')
const settledTool = screen.getByText('shell')
const activity = screen.getByText('Working…')
expect(activity.textContent).not.toBe(settledTool.textContent)
expect(activity).not.toHaveTextContent('shell')
@@ -243,7 +243,8 @@ describe('NativeChatMessageList assistant messages', () => {
/>
)
const settledTool = screen.getByText('shell pnpm test')
const settledTool = screen.getByText('shell')
expect(settledTool).toHaveTextContent('shell pnpm test')
expect(settledTool.closest('button')?.querySelector('.animate-pulse')).toBeNull()
expect(settledTool.closest('button')?.querySelector('.lucide-check')).toBeInTheDocument()
const activity = screen.getByText('Preparing the answer')
@@ -11,6 +11,7 @@ import {
import { isSubagentGroupBlock, type NativeChatMessage } from '../../../../shared/native-chat-types'
import { splitNativeChatBlocks } from './native-chat-tool-fold'
import { NativeChatToolRun } from './NativeChatToolRun'
import { NativeChatNoticeRow } from './NativeChatNoticeRow'
import { NativeChatMessageTimestamp } from './NativeChatMessageTimestamp'
import { nativeChatProseToMarkdown } from './native-chat-prose'
import {
@@ -94,6 +95,24 @@ export const MessageRow = memo(function MessageRow({
return null
}
const notice = isSystem
? message.blocks.find(
(block) =>
block.type === 'text' && (block.presentation !== undefined || block.tone !== undefined)
)
: undefined
if (notice?.type === 'text') {
return (
<div ref={rowRef}>
<NativeChatNoticeRow
block={notice}
onLinkClick={onLinkClick}
allowFileUriLinks={allowFileUriLinks}
/>
</div>
)
}
if (providerFrame) {
return (
<div ref={rowRef}>
@@ -0,0 +1,110 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AgentJournalItemBodySchema } from '../../../../shared/agent-session-journal-schemas'
import { projectStructuredItemsToNativeChat } from '../../../../shared/structured-agent-session-projection'
import type { AgentJournalStatusItem } from '../../../../shared/agent-session-journal-types'
import { MessageRow } from './NativeChatMessageRow'
afterEach(cleanup)
function renderStatus(body: AgentJournalStatusItem) {
const [message] = projectStructuredItemsToNativeChat([
{ itemId: 'notice', sequence: 1, revision: 1, observedAt: 1, body }
])
return render(
<MessageRow message={message!} expandSignal={false} onScrollMessageToTop={vi.fn()} />
)
}
describe('notice rows', () => {
it('renders compaction as a centered separator', () => {
renderStatus({ kind: 'status', text: 'Context compacted', presentation: 'compaction' })
expect(screen.getByRole('separator', { name: 'Context compacted' })).toHaveClass(
'text-muted-foreground'
)
expect(
screen.getByText('Context compacted').parentElement?.querySelectorAll('.bg-border')
).toHaveLength(2)
})
it.each([
['warning', 'text-[color:var(--warning,#f59e0b)]'],
['error', 'text-destructive'],
['notice', 'text-muted-foreground']
])('renders %s using its existing color treatment', (tone, className) => {
renderStatus({ kind: 'status', text: 'Readable notice', tone })
expect(screen.getByText('Readable notice').parentElement?.parentElement).toHaveClass(className)
})
it('renders a plan as readable markdown in the card primitive', () => {
renderStatus({
kind: 'status',
text: '# Steps\n\nA **readable** document.',
presentation: 'plan-document'
})
expect(screen.getByText('Plan').closest('[data-slot="card"]')).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Steps' })).toBeInTheDocument()
expect(screen.getByText('readable').tagName).toBe('STRONG')
expect(screen.getByText('readable').closest('[data-slot="card-content"]')).toHaveClass(
'text-sm',
'text-foreground'
)
})
it('shows provider notice text once while retaining its diagnostic disclosure', () => {
renderStatus({
kind: 'status',
text: 'Check the configuration',
tone: 'warning',
providerFrame: {
provider: 'codex',
kind: 'notification:warning',
payload: {
head: '{"message":"Check the configuration"}',
byteLength: 37,
digest: 'digest',
truncated: false
}
}
})
expect(screen.getAllByText('Check the configuration')).toHaveLength(1)
const disclosure = screen.getByText('Details').closest('details')
expect(disclosure?.querySelector('summary')).not.toHaveTextContent('Check the configuration')
expect(disclosure?.querySelector('pre')).toHaveTextContent('Check the configuration')
})
it('renders future presentation and tone values as untinted text', () => {
renderStatus({
kind: 'status',
text: 'Future readable text',
tone: 'future-tone',
presentation: 'future-presentation'
})
expect(screen.getByText('Future readable text').parentElement?.parentElement).toHaveClass(
'text-foreground'
)
expect(screen.getByText('Future readable text').parentElement?.querySelector('svg')).toBeNull()
})
})
describe('old-reader compatibility', () => {
// Derive the prior status shape without its new optional hints.
const statusSchema = AgentJournalItemBodySchema.options.find(
(schema): schema is (typeof AgentJournalItemBodySchema.options)[5] =>
schema.shape.kind.value === 'status'
)!
const oldStatusSchema = statusSchema.omit({ tone: true, presentation: true })
it.each([
{ presentation: 'compaction' },
{ presentation: 'plan-document' },
{ tone: 'warning' },
{ tone: 'error' },
{ tone: 'notice' },
{ tone: 'future-tone', presentation: 'future-presentation' }
])('accepts new metadata and still renders text with an old reader: %j', (metadata) => {
const body = { kind: 'status', text: 'Text survives version skew', ...metadata }
expect(AgentJournalItemBodySchema.safeParse(body).success).toBe(true)
const oldBody = oldStatusSchema.parse(body) as AgentJournalStatusItem
expect(oldBody).toEqual({ kind: 'status', text: body.text })
renderStatus(oldBody)
expect(screen.getByText(body.text)).toBeInTheDocument()
})
})
@@ -0,0 +1,86 @@
import { AlertCircle, AlertTriangle, Info } from 'lucide-react'
import CommentMarkdown, {
type CommentMarkdownLinkClickHandler
} from '@/components/sidebar/CommentMarkdown'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import type { NativeChatTextBlock } from '../../../../shared/native-chat-types'
import { ProviderFrameRow } from './NativeChatTranscriptChrome'
export function NativeChatNoticeRow({
block,
onLinkClick,
allowFileUriLinks = false
}: {
block: NativeChatTextBlock
onLinkClick?: CommentMarkdownLinkClickHandler
allowFileUriLinks?: boolean
}): React.JSX.Element {
if (block.presentation === 'compaction') {
const label = translate('components.native-chat.notices.compaction', 'Context compacted')
return (
<div
role="separator"
aria-label={label}
className="flex items-center gap-3 py-2 text-xs text-muted-foreground"
>
<span className="h-px flex-1 bg-border" />
<span>{label}</span>
<span className="h-px flex-1 bg-border" />
</div>
)
}
if (block.presentation === 'plan-document') {
return (
<Card className="gap-3 py-3 shadow-xs">
<CardHeader className="px-4">
<CardTitle className="text-sm">
{translate('components.native-chat.notices.plan', 'Plan')}
</CardTitle>
</CardHeader>
<CardContent className="px-4 text-sm leading-relaxed text-foreground">
<CommentMarkdown
content={block.text}
variant="document"
className="text-sm"
onLinkClick={onLinkClick}
allowFileUriLinks={allowFileUriLinks}
linkifyFilePaths={onLinkClick !== undefined}
/>
</CardContent>
</Card>
)
}
const tone = block.tone
const Icon =
tone === 'warning'
? AlertTriangle
: tone === 'error'
? AlertCircle
: tone === 'notice'
? Info
: null
return (
<div
className={cn(
'space-y-2 text-sm text-foreground',
Icon && 'rounded-md border border-border bg-muted/20 p-3',
tone === 'warning' && 'text-[color:var(--warning,#f59e0b)]',
tone === 'error' && 'text-destructive',
tone === 'notice' && 'text-muted-foreground'
)}
>
<div className="flex items-start gap-2">
{Icon ? <Icon aria-hidden="true" className="mt-0.5 size-4 shrink-0" /> : null}
<p className="min-w-0 whitespace-pre-wrap break-words">{block.text}</p>
</div>
{block.providerFrame ? (
<ProviderFrameRow
block={block}
summary={translate('components.native-chat.notices.details', 'Details')}
/>
) : null}
</div>
)
}
@@ -299,7 +299,7 @@ describe('NativeChatToolRun with a spawn group', () => {
)
expect(screen.getByText('Ran 1 subagent')).toBeInTheDocument()
expect(screen.queryByText('shell ls')).toBeNull()
expect(screen.queryByText('shell')).toBeNull()
})
it('renders the roster alongside the tool activity of its turn', () => {
@@ -313,6 +313,6 @@ describe('NativeChatToolRun with a spawn group', () => {
)
expect(screen.getByText('Ran 1 subagent')).toBeInTheDocument()
expect(screen.getByText('shell ls')).toBeInTheDocument()
expect(screen.getByText('shell').closest('button')).toHaveTextContent('shell ls')
})
})
@@ -70,7 +70,7 @@ describe('inline tool annotations', () => {
/>
)
expect(screen.queryByRole('link')).toBeNull()
fireEvent.click(screen.getByText('web_search').closest('button')!)
fireEvent.click(screen.getByText('web_search', { selector: 'code' }).closest('button')!)
const link = screen.getByRole('link', { name: /Reference docs/ })
expect(link.getAttribute('href')).toBe('https://example.com/docs')
expect(link.closest('button')).toBeNull()
@@ -112,7 +112,10 @@ it.each(['running', 'completed'] as const)(
expect(screen.getByText('My server')).toBeTruthy()
expect(screen.getByText('ns.tool')).toBeTruthy()
expect(screen.getByTitle(name)).toBeTruthy()
expect(document.querySelectorAll('.lucide-plug')).toHaveLength(2)
// Header glyph plus the row's. A settled header also names each member in a
// pill, which carries that member's own glyph — so three, all plug: the
// identity holds wherever it is drawn.
expect(document.querySelectorAll('.lucide-plug')).toHaveLength(state === 'completed' ? 3 : 2)
}
)
@@ -23,6 +23,16 @@ function leadingGlyphs(container: HTMLElement): (string | null)[] {
)
}
/** The run header — the first button in a run, above its member rows. Its
* members render as separate pills, so it has no single joined summary node. */
function runHeader(container: HTMLElement): HTMLElement {
const header = container.querySelector('button')
if (!header) {
throw new Error('run header did not render')
}
return header
}
describe('NativeChatToolRun', () => {
it('uses the shared clean label for a desktop tool row', () => {
const blocks: NativeChatBlock[] = [
@@ -244,6 +254,107 @@ describe('NativeChatToolRun', () => {
expect(writeClipboardText).toHaveBeenCalledWith(' ctx\n-was\n+now\n+tail')
})
describe('reading a batch as a group', () => {
const batch: NativeChatBlock[] = [
{
type: 'tool-call',
name: 'mcp__linear__list_issues',
input: { query: 'todo' },
state: 'completed',
mcpIdentity: { server: 'linear', tool: 'list_issues' }
},
{ type: 'tool-call', name: 'Bash', input: { command: 'ls -la' }, state: 'completed' },
{
type: 'tool-call',
name: 'tools/read',
input: { file_path: 'README.md' },
state: 'completed'
}
]
it('gives each member its own glyph-led segment instead of one joined string', () => {
const { container } = render(<NativeChatToolRun blocks={batch} expandSignal={false} />)
const pills = runHeader(container).querySelectorAll('[data-tool-run-member]')
expect([...pills].map((pill) => pill.textContent)).toEqual([
'mcp__linear__list_issues todo',
'Bash ls -la',
'tools/read README.md'
])
})
// The header still prints the raw identifier while the row beneath it prints
// the split MCP name. Pinned, not endorsed: reconciling the two changes what
// a tool is called, which is a naming decision rather than a layout one.
it('leaves the header naming a member differently from the row below it', () => {
const { container } = render(<NativeChatToolRun blocks={batch} expandSignal />)
expect(runHeader(container)).toHaveTextContent('mcp__linear__list_issues')
expect(screen.getByText('Linear')).toBeInTheDocument()
})
it('names each member with its own glyph, not the run-wide fallback', () => {
const { container } = render(<NativeChatToolRun blocks={batch} expandSignal={false} />)
const header = runHeader(container)
expect(header.querySelector('[data-tool-run-member] .lucide-plug')).toBeInTheDocument()
expect(
header.querySelector('[data-tool-run-member] .lucide-square-terminal')
).toBeInTheDocument()
// The run-wide glyph still reads generic, the categories being mixed.
expect(header.firstElementChild?.querySelector('.lucide-wrench')).toBeInTheDocument()
})
it('counts the members it could not show rather than ending mid-name', () => {
const wide: NativeChatBlock[] = [
...batch,
{ type: 'tool-call', name: 'Grep', input: { pattern: 'todo' }, state: 'completed' },
{ type: 'tool-call', name: 'Write', input: { file_path: 'a.ts' }, state: 'completed' }
]
const { container } = render(<NativeChatToolRun blocks={wide} expandSignal={false} />)
expect(runHeader(container)).toHaveTextContent('+2 more')
expect(runHeader(container).querySelectorAll('[data-tool-run-member]')).toHaveLength(3)
})
// A margin is invisible to a copied selection and to the accessible name, so
// the boundary needs a real space too — otherwise the header reads
// `ls -latools/read`.
it('separates members with real whitespace, not only a margin', () => {
const { container } = render(<NativeChatToolRun blocks={batch} expandSignal={false} />)
expect(runHeader(container).textContent).toBe(
'3\u00d7mcp__linear__list_issues todo Bash ls -la tools/read README.md'
)
})
it('leaves no remainder marker when every member is shown', () => {
const { container } = render(<NativeChatToolRun blocks={batch} expandSignal={false} />)
expect(runHeader(container)).not.toHaveTextContent('more')
})
it('indents opened members so the run has a visible end', () => {
const { container } = render(<NativeChatToolRun blocks={batch} expandSignal />)
const members = runHeader(container).parentElement?.querySelector('.pl-4')
expect(members).toBeInTheDocument()
expect(members?.querySelectorAll('button').length).toBe(batch.length)
})
it('falls back to the call count when a run names no tool', () => {
const { container } = render(
<NativeChatToolRun
blocks={[{ type: 'tool-call', name: ' ', input: {}, state: 'completed' }]}
expandSignal={false}
/>
)
expect(runHeader(container)).toHaveTextContent('1 tool call')
})
})
it('keeps a grouped active run to one stable row showing only the latest tool', () => {
const blocks: NativeChatBlock[] = [
{ type: 'tool-call', name: 'shell', input: { command: 'date' }, state: 'completed' },
@@ -293,7 +404,9 @@ describe('NativeChatToolRun', () => {
const runningBlocks: NativeChatBlock[] = [
{ type: 'tool-call', name: 'shell', input: { command: 'sleep 1' }, state: 'running' }
]
const { rerender } = render(<NativeChatToolRun blocks={runningBlocks} expandSignal={false} />)
const { rerender, container } = render(
<NativeChatToolRun blocks={runningBlocks} expandSignal={false} />
)
expect(screen.getByText('Running sleep 1')).toBeInTheDocument()
@@ -308,7 +421,7 @@ describe('NativeChatToolRun', () => {
)
expect(screen.queryByText('Running sleep 1')).toBeNull()
expect(screen.getByText('shell sleep 1')).toBeInTheDocument()
expect(runHeader(container)).toHaveTextContent('shell sleep 1')
})
it('never animates a settled tool row with its completion check', () => {
@@ -323,9 +436,10 @@ describe('NativeChatToolRun', () => {
/>
)
const settledRow = screen.getByText('shell pnpm test').closest('button')
expect(settledRow?.querySelector('.lucide-check')).toBeInTheDocument()
expect(settledRow?.querySelector('.animate-pulse')).toBeNull()
const settledRow = runHeader(container)
expect(settledRow).toHaveTextContent('shell pnpm test')
expect(settledRow.querySelector('.lucide-check')).toBeInTheDocument()
expect(settledRow.querySelector('.animate-pulse')).toBeNull()
expect(container.querySelector('.animate-pulse')).toBeNull()
})
@@ -348,7 +462,7 @@ describe('NativeChatToolRun', () => {
{ type: 'tool-result', output: 'exit 128', isError: true }
]
const { rerender } = render(
const { rerender, container } = render(
<NativeChatToolRun
blocks={blocks}
expandSignal={false}
@@ -369,7 +483,7 @@ describe('NativeChatToolRun', () => {
/>
)
expect(screen.getByText('shell git log -1')).toBeInTheDocument()
expect(runHeader(container)).toHaveTextContent('shell git log -1')
})
it('settles an orphaned running call when its turn lifecycle has ended', () => {
@@ -401,7 +515,7 @@ describe('NativeChatToolRun', () => {
const glyph = container.querySelector('.lucide-eye')
expect(glyph).toBeInTheDocument()
expect(glyph).toHaveAttribute('aria-hidden')
expect(screen.getByText('read')).toBeInTheDocument()
expect(screen.getByText('read', { selector: 'code' })).toBeInTheDocument()
})
it('holds one glyph for a category across running, completed, and failed', () => {
@@ -4,7 +4,7 @@ import {
NativeChatCommandMetadata,
NativeChatSearchResults
} from './NativeChatToolAnnotations'
import { useEffect, useMemo, useState } from 'react'
import { Fragment, useEffect, useMemo, useState } from 'react'
import { Check, ChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
@@ -26,8 +26,9 @@ import type { NativeChatEditFile } from '../../../../shared/native-chat-edit-mod
import {
countToolCalls,
createToolInputDisplay,
summarizeToolRun,
truncateToolDetail
toolRunSummaryMembers,
truncateToolDetail,
type ToolRunMember
} from './native-chat-tool-summary'
import {
NATIVE_CHAT_TOOL_ACTIVITY_COPY,
@@ -237,7 +238,22 @@ export function NativeChatToolRun({
.filter(isRenderableSubagentGroup)
.map((group) => <NativeChatSubagentRun key={group.groupId} block={group} />)
const callCount = countToolCalls(blocks) || blocks.length
const summary = summarizeToolRun(blocks)
// Members stay separate all the way to the markup: joining them into one
// string is what made a run read as a single call, because the separator also
// occurs inside tool names like `browser.open` and `tools/read`.
const summaryMembers = toolRunSummaryMembers(blocks)
const hiddenCallCount = Math.max(0, callCount - summaryMembers.length)
// Same content-signature keying the member rows below use: two identical calls
// in one run are distinguished by occurrence, never by list position.
const keyedSummaryMembers = ((): (ToolRunMember & { key: string })[] => {
const seen = new Map<string, number>()
return summaryMembers.map((member) => {
const signature = `${member.name}:${member.arg}`
const occurrence = seen.get(signature) ?? 0
seen.set(signature, occurrence + 1)
return { ...member, key: `${signature}:${occurrence}` }
})
})()
const latestActiveCall = structuredActivityUi
? selectActiveToolCall(blocks, { activeTurnIsWorking })
: null
@@ -251,7 +267,7 @@ export function NativeChatToolRun({
() => (open ? buildEditCards(blocks) : NO_EDIT_CARDS),
[open, blocks]
)
// Only the settled header reads this. It stands over `summary`, which speaks
// Only the settled header reads this. It stands over `summaryMembers`, which speaks
// for the run's first calls rather than its last, so a glyph taken from one
// call would assert a category the text beside it doesn't describe. A run that
// spans categories therefore heads with the generic tool glyph. The glyph is
@@ -329,9 +345,52 @@ export function NativeChatToolRun({
<span className="shrink-0 font-mono text-[11px] font-bold text-muted-foreground transition-colors group-hover:text-foreground/80">
{callCount}×
</span>
<span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground transition-colors group-hover:text-foreground/80">
{summary || fallbackLabel}
</span>
{summaryMembers.length > 0 ? (
<>
{/* Each member is led by its own category glyph, which is what marks
the boundary. A separator character cannot: `·` occurs inside
`browser.open` and `tools/read`. The list stays one line and
truncates as a whole rather than wrapping into a block — a
header that grows to three rows stops reading as a header. */}
<span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground transition-colors group-hover:text-foreground/80">
{keyedSummaryMembers.map((member, index) => (
<Fragment key={member.key}>
{/* A real space, not just the margin: a CSS gap is invisible to
a copied selection and to the button's accessible name, which
would otherwise run one member's argument into the next
member's name. The margin is trimmed to pay for its width. */}
{index > 0 ? ' ' : null}
<span data-tool-run-member className={cn(index > 0 && 'ml-2')}>
<NativeChatToolIcon
rowWord={member.name}
mcpIdentity={member.mcpIdentity}
className="mr-1 inline-flex size-3.5 align-middle"
/>
{member.name}
{member.arg ? (
<span className="text-muted-foreground/70">{` ${member.arg}`}</span>
) : null}
</span>
</Fragment>
))}
</span>
{hiddenCallCount > 0 ? (
/* Outside the truncating span, so the count of what is not shown
survives a list the pane is too narrow to print. */
<span className="shrink-0 font-mono text-[11px] text-muted-foreground transition-colors group-hover:text-foreground/80">
{translate(
'components.native-chat.tool.moreCalls',
NATIVE_CHAT_TOOL_ACTIVITY_COPY.moreCalls,
{ value0: hiddenCallCount }
)}
</span>
) : null}
</>
) : (
<span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground transition-colors group-hover:text-foreground/80">
{fallbackLabel}
</span>
)}
{/* Completion reads as a trailing mark so the leading glyph can stay fixed. */}
{structuredActivityUi ? (
<Check aria-hidden className="size-3 shrink-0 text-muted-foreground" />
@@ -346,7 +405,10 @@ export function NativeChatToolRun({
</button>
)}
{open ? (
<div className="mt-1">
// Members are indented under the header because nothing else marks the
// run's extent — flush rows are indistinguishable from the blocks after
// them, so the batch has no visible end.
<div className="mt-1 pl-4">
{(() => {
const seen = new Map<string, number>()
return blocks.map((block) => {
@@ -274,7 +274,13 @@ export function NativeChatAgentControls({
)
}
export function ProviderFrameRow({ block }: { block: NativeChatBlock }): React.JSX.Element | null {
export function ProviderFrameRow({
block,
summary
}: {
block: NativeChatBlock
summary?: string
}): React.JSX.Element | null {
if (block.type !== 'text' || !block.providerFrame) {
return null
}
@@ -284,7 +290,7 @@ export function ProviderFrameRow({ block }: { block: NativeChatBlock }): React.J
<summary className="flex cursor-pointer list-none items-center gap-2 rounded-md px-2 py-1 font-mono hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<span className="transition-transform group-open:rotate-90">›</span>
<span className="font-medium text-foreground">{frame.provider}</span>
<span className="truncate">{nativeChatProviderFrameSummary(block)}</span>
<span className="truncate">{summary ?? nativeChatProviderFrameSummary(block)}</span>
{frame.payload.truncated ? (
<span>
·{' '}
@@ -6,5 +6,7 @@ export {
summarizeToolInput,
summarizeToolRun,
toolFilePath,
toolRunSummaryMembers,
truncateToolDetail
} from '../../../../shared/native-chat-tool-summary'
export type { ToolRunMember } from '../../../../shared/native-chat-tool-summary'
@@ -236,7 +236,8 @@ const WorktreeList = React.memo(function WorktreeList({
useSidebarRevealRequests({
groupBy,
renderedSidebarRowKeys: rowModel.renderedSidebarRowKeys,
renderedWorktreeIdentities: selection.renderedWorktreeIdentities,
visibleWorktrees,
visibleFolderWorkspaces: visibleScope.visibleFolderWorkspacesForRows,
currentSidebarWorktreeId,
currentSidebarExecutionHostId: activeWorkspaceExecutionHostId,
worktreeMap,
@@ -82,7 +82,8 @@ beforeEach(() => {
args = {
groupBy: 'repo',
renderedSidebarRowKeys: new Set(),
renderedWorktreeIdentities: [],
visibleWorktrees: [],
visibleFolderWorkspaces: [],
currentSidebarWorktreeId: worktree.id,
currentSidebarExecutionHostId: 'ssh:dev',
worktreeMap: new Map([[worktree.id, worktree]]),
@@ -133,7 +134,7 @@ describe('revealing a filtered workspace', () => {
args = {
...args,
hasFilters: visible,
renderedWorktreeIdentities: visible ? ['ssh:dev|wt-1'] : []
visibleWorktrees: visible ? args.worktrees : []
}
await render()
await act(async () => requestScrollToCurrentWorkspaceReveal())
@@ -143,6 +144,41 @@ describe('revealing a filtered workspace', () => {
}
)
it.each(['ssh:dev', null] as const)(
'reveals a collapsed workspace that passes filters with active host %s',
async (executionHostId) => {
args = {
...args,
currentSidebarExecutionHostId: executionHostId,
visibleWorktrees: args.worktrees
}
await render()
await act(async () => requestScrollToCurrentWorkspaceReveal())
expect(document.querySelector('[role="dialog"]')).toBeNull()
expect(args.clearFilters).not.toHaveBeenCalled()
expect(state.revealWorktreeInSidebar).toHaveBeenCalledTimes(1)
}
)
it('does not let a visible same-id workspace on another host bypass confirmation', async () => {
args = { ...args, visibleWorktrees: [{ ...args.worktrees[0], hostId: 'local' }] }
await render()
await act(async () => requestScrollToCurrentWorkspaceReveal())
expect(document.querySelector('[role="dialog"]')).not.toBeNull()
expect(state.revealWorktreeInSidebar).not.toHaveBeenCalled()
await click('Keep filters')
})
it('preserves filters when the target becomes included while confirmation is open', async () => {
await render()
await act(async () => requestScrollToCurrentWorkspaceReveal())
args = { ...args, visibleWorktrees: args.worktrees }
await render()
await click('Clear filters and reveal')
expect(args.clearFilters).not.toHaveBeenCalled()
expect(state.revealWorktreeInSidebar).toHaveBeenCalledTimes(1)
})
it('does not apply a stale confirmation after switching workspaces', async () => {
await render()
await act(async () => requestScrollToCurrentWorkspaceReveal())
@@ -153,39 +189,47 @@ describe('revealing a filtered workspace', () => {
expect(state.revealWorktreeInSidebar).not.toHaveBeenCalled()
})
it('confirms filtered folder workspaces and preserves the rename request', async () => {
args = {
...args,
currentSidebarWorktreeId: folderWorkspaceKey('folder-1'),
currentSidebarExecutionHostId: null,
folderWorkspaces: [
{
id: 'folder-1',
projectGroupId: 'project-1',
name: 'Notes',
folderPath: '/notes',
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 1,
lastActivityAt: 1,
createdAt: 1,
updatedAt: 1
}
]
it.each([true, false])(
'reveals folder workspaces and preserves rename (filtered: %s)',
async (filtered) => {
args = {
...args,
currentSidebarWorktreeId: folderWorkspaceKey('folder-1'),
currentSidebarExecutionHostId: null,
folderWorkspaces: [
{
id: 'folder-1',
projectGroupId: 'project-1',
name: 'Notes',
folderPath: '/notes',
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 1,
lastActivityAt: 1,
createdAt: 1,
updatedAt: 1
}
]
}
args.visibleFolderWorkspaces = filtered ? [] : args.folderWorkspaces
await render()
await act(async () => requestScrollToCurrentWorkspaceRevealAndRename())
expect(args.clearFilters).not.toHaveBeenCalled()
if (filtered) {
await click('Clear filters and reveal')
expect(args.clearFilters).toHaveBeenCalledTimes(1)
} else {
expect(document.querySelector('[role="dialog"]')).toBeNull()
}
expect(state.revealWorktreeInSidebar).toHaveBeenCalledWith(folderWorkspaceKey('folder-1'), {
behavior: 'smooth',
highlight: true,
beginRename: true,
executionHostId: undefined
})
}
await render()
await act(async () => requestScrollToCurrentWorkspaceRevealAndRename())
expect(args.clearFilters).not.toHaveBeenCalled()
await click('Clear filters and reveal')
expect(args.clearFilters).toHaveBeenCalledTimes(1)
expect(state.revealWorktreeInSidebar).toHaveBeenCalledWith(folderWorkspaceKey('folder-1'), {
behavior: 'smooth',
highlight: true,
beginRename: true,
executionHostId: undefined
})
})
)
})
@@ -10,16 +10,30 @@ import {
import type { FolderWorkspace } from '../../../../../../shared/folder-workspace-types'
import type { Worktree } from '../../../../../../shared/worktree/types'
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import { composeWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity'
import { getWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity'
import { folderWorkspaceKey } from '../../../../../../shared/workspace-scope'
import type { WorktreeGroupBy } from '../grouping/row-types'
import { getKnownSidebarWorktreeById } from './folder-reveal'
function workspacePassesFilters(
worktree: Worktree,
worktrees: readonly Worktree[],
folderWorkspaces: readonly FolderWorkspace[]
): boolean {
const identity = getWorktreeHostIdentity(worktree)
return (
worktrees.some((candidate) => getWorktreeHostIdentity(candidate) === identity) ||
folderWorkspaces.some((workspace) => folderWorkspaceKey(workspace.id) === worktree.id)
)
}
// Turns a "show me the current workspace" request into whatever the sidebar must change
// first — grouping mode, active filters — before the viewport can scroll to it.
export function useSidebarRevealRequests(args: {
groupBy: WorktreeGroupBy
renderedSidebarRowKeys: ReadonlySet<string>
renderedWorktreeIdentities: readonly string[]
visibleWorktrees: readonly Worktree[]
visibleFolderWorkspaces: readonly FolderWorkspace[]
currentSidebarWorktreeId: string | null
currentSidebarExecutionHostId: ExecutionHostId | null
worktreeMap: Map<string, Worktree>
@@ -31,7 +45,8 @@ export function useSidebarRevealRequests(args: {
const {
groupBy,
renderedSidebarRowKeys,
renderedWorktreeIdentities,
visibleWorktrees,
visibleFolderWorkspaces,
currentSidebarWorktreeId,
currentSidebarExecutionHostId,
worktreeMap,
@@ -106,11 +121,11 @@ export function useSidebarRevealRequests(args: {
if (!activeWorktree || activeWorktree.isArchived) {
return
}
const currentIdentity = composeWorktreeHostIdentity(
currentSidebarExecutionHostId ?? undefined,
currentSidebarWorktreeId
)
if (hasFilters && !renderedWorktreeIdentities.includes(currentIdentity)) {
// Collapsed groups hide rows without excluding their workspaces from the filter results.
if (
hasFilters &&
!workspacePassesFilters(activeWorktree, visibleWorktrees, visibleFolderWorkspaces)
) {
if (confirmationPending.current) {
return
}
@@ -141,7 +156,14 @@ export function useSidebarRevealRequests(args: {
) {
return
}
if (latest.hasFilters && !latest.renderedWorktreeIdentities.includes(currentIdentity)) {
if (
latest.hasFilters &&
!workspacePassesFilters(
activeWorktree,
latest.visibleWorktrees,
latest.visibleFolderWorkspaces
)
) {
latest.clearFilters()
}
}
@@ -159,7 +181,8 @@ export function useSidebarRevealRequests(args: {
currentSidebarExecutionHostId,
folderWorkspaces,
revealSidebarRow,
renderedWorktreeIdentities,
visibleWorktrees,
visibleFolderWorkspaces,
revealWorktreeInSidebar,
worktreeMap,
worktrees
@@ -455,6 +455,83 @@ describe('connectPanePty', () => {
expect(transport.sendInput).toHaveBeenCalledWith('echo hi\r')
})
it('preserves classified user input during replay while suppressing synthetic replies', async () => {
const { connectPanePty } = await import('./pty-connection')
const pane = createPane(1)
const userInputListeners = new Set<() => void>()
Object.assign(pane.terminal, {
_core: {
coreService: {
onUserInput: (listener: () => void) => {
userInputListeners.add(listener)
return { dispose: () => userInputListeners.delete(listener) }
}
}
}
})
const transport = createMockTransport('ssh:ssh-1@@pty-1')
transportFactoryQueue.push(transport)
const deps = createDeps()
const deferred: (() => void)[] = []
Object.assign(deps, {
deferPtyInput: (_paneId: number, data: string, forward: (data: string) => void) => {
deferred.push(() => forward(data))
}
})
connectPanePty(pane as never, createManager(1, 1) as never, deps as never)
await flushAsyncTicks()
transport.sendInput.mockClear()
deps.replayingPanesRef.current.set(pane.id, 1)
for (const listener of userInputListeners) {
listener()
}
sendTerminalInputThroughPane(pane, 'input_under_flood\r')
sendTerminalInputThroughPane(pane, '\x1b[?1;2c')
// A click on replayed scrollback that still has mouse tracking armed is user input to xterm, but must not reach the shell.
for (const listener of userInputListeners) {
listener()
}
sendTerminalInputThroughPane(pane, '\x1b[<0;12;4M')
for (const forward of deferred.splice(0)) {
forward()
}
expect(transport.sendInput).toHaveBeenCalledExactlyOnceWith('input_under_flood\r')
// A wheel over a replayed alt-screen frame becomes cursor keys; the fresh shell must not recall history from them.
pane.terminal.buffer.active.type = 'alternate'
for (const listener of userInputListeners) {
listener()
}
sendTerminalInputThroughPane(pane, '\x1b[B')
for (const forward of deferred.splice(0)) {
forward()
}
expect(transport.sendInput).toHaveBeenCalledExactlyOnceWith('input_under_flood\r')
// The same bytes on the normal buffer can only be a keyboard arrow, which survives replay.
pane.terminal.buffer.active.type = 'normal'
for (const listener of userInputListeners) {
listener()
}
sendTerminalInputThroughPane(pane, '\x1b[B')
for (const forward of deferred.splice(0)) {
forward()
}
expect(transport.sendInput).toHaveBeenCalledTimes(2)
expect(transport.sendInput).toHaveBeenLastCalledWith('\x1b[B')
// Once the guard releases, the same mouse report is ordinary input again.
deps.replayingPanesRef.current.delete(pane.id)
for (const listener of userInputListeners) {
listener()
}
sendTerminalInputThroughPane(pane, '\x1b[<0;12;4M')
for (const forward of deferred.splice(0)) {
forward()
}
expect(transport.sendInput).toHaveBeenLastCalledWith('\x1b[<0;12;4M')
})
it('settles a queued startup only after the pane binds its spawned PTY', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-resume')
@@ -556,13 +556,12 @@ describe('connectPanePty', () => {
const manager = createManager(1)
const deps = createDeps()
const pane = createPane(2)
let userInputListener: (() => void) | null = null
const userInputDispose = vi.fn()
const userInputListeners = new Set<() => void>()
;(pane.terminal as unknown as { _core: unknown })._core = {
coreService: {
onUserInput: vi.fn((listener: () => void) => {
userInputListener = listener
return { dispose: userInputDispose }
userInputListeners.add(listener)
return { dispose: () => userInputListeners.delete(listener) }
})
}
}
@@ -571,7 +570,7 @@ describe('connectPanePty', () => {
dispose: () => void
}
await flushAsyncTicks()
expect(userInputListener).toBeTypeOf('function')
expect(userInputListeners.size).toBeGreaterThan(0)
;(mockStoreState.recordTerminalInput as ReturnType<typeof vi.fn>).mockClear()
// A focus-out report forwarded to the PTY must not count as activity.
@@ -581,11 +580,13 @@ describe('connectPanePty', () => {
expect(transport.sendInput).toHaveBeenCalledWith('\x1b[O')
// Real user input fires the core signal and records activity.
;(userInputListener as unknown as () => void)()
for (const listener of userInputListeners) {
listener()
}
expect(mockStoreState.recordTerminalInput).toHaveBeenCalledTimes(1)
binding.dispose()
expect(userInputDispose).toHaveBeenCalled()
expect(userInputListeners.size).toBe(0)
})
it('falls back to onData hibernation recording when the core user-input signal is unavailable', async () => {
@@ -1,4 +1,5 @@
import type { ManagedPaneInternal } from '@/lib/pane-manager/pane-manager-types'
import { subscribeToTerminalInputData } from '../terminal-user-input-signal'
import { installTerminalImeCompositionRoute } from '../terminal-ime-composition-route'
import { useAppStore } from '@/store'
import { isTerminalQueryReply } from '../../../../../shared/terminal-query-reply'
@@ -9,6 +10,7 @@ import { isPtyLocked } from '@/lib/pane-manager/mobile-driver-state'
import { getAppliedSizeReadE2eDelayMs } from '../pty-applied-size-read-e2e-delay'
import { createPtySizeReassertion } from '../pty-size-reassertion'
import { isPaneReplaying } from '../replay-guard'
import { isXtermMouseReport, isXtermWheelCursorKey } from '../terminal-pointer-input-sequences'
import { shouldDropQuarantinedTerminalInput } from '../terminal-input-quarantine'
import {
PANE_PTY_RESIZE_HOLD_FLUSH_EVENT,
@@ -24,15 +26,21 @@ import { isCodexPaneStale } from './codex-pane-stale'
import type { ConnectPanePtySession } from './connect-pane-pty-session'
export function installPtyInputForward(session: ConnectPanePtySession): void {
session.forwardPtyInput = (data: string): void => {
// Why: xterm auto-replies to embedded query sequences (DA1, DECRQM,
// OSC 10/11, focus, CPR) via onData. When we replay recorded PTY bytes
// into xterm for scrollback/cold-restore/snapshot, those queries would
// otherwise pipe replies into the freshly spawned shell as stray input
// ("?1;2c", "2026;2$y", OSC color fragments, ...). The replay sites
// engage the guard via replayIntoTerminal; here we drop everything
// xterm emits while the guard is active. See replay-guard.ts.
if (isPaneReplaying(session.deps.replayingPanesRef, session.pane.id)) {
session.forwardPtyInput = (data: string, wasUserInput = false): void => {
// Why: replaying recorded PTY bytes makes xterm auto-reply to embedded
// queries (DA1/DECRQM/OSC 10-11/CPR) via onData; those must not leak into
// the shell, but keystrokes typed mid-restore must survive. Pointer input
// stays dropped even though xterm flags it as user input: replayed bytes can
// leave mouse tracking armed until the guarded mode reset lands (a click
// would print SGR fragments on the fresh prompt), and a wheel over a
// replayed alt-screen frame becomes cursor keys that would recall history
// at that prompt once ?1049l lands. See replay-guard.ts.
if (
isPaneReplaying(session.deps.replayingPanesRef, session.pane.id) &&
(!wasUserInput ||
isXtermMouseReport(data) ||
(isXtermWheelCursorKey(data) && session.pane.terminal.buffer.active.type === 'alternate'))
) {
return
}
const currentPtyId = session.transport.getPtyId()
@@ -163,13 +171,21 @@ export function installPtyInputForward(session: ConnectPanePtySession): void {
session.requestRecoveryForUndeliverableInput()
}
}
session.onDataDisposable = session.pane.terminal.onData((data) => {
if (session.deps.deferPtyInput) {
session.deps.deferPtyInput(session.pane.id, data, session.forwardPtyInput)
return
// Why bind once: provenance must survive deferPtyInput's later callback, and
// this is the per-keystroke hot path, so no closure allocation per onData event.
const forwardUserInput = (data: string): void => session.forwardPtyInput(data, true)
const forwardUnclassifiedInput = (data: string): void => session.forwardPtyInput(data, false)
session.onDataDisposable = subscribeToTerminalInputData(
session.pane.terminal,
(data, wasUserInput) => {
const forward = wasUserInput ? forwardUserInput : forwardUnclassifiedInput
if (session.deps.deferPtyInput) {
session.deps.deferPtyInput(session.pane.id, data, forward)
return
}
forward(data)
}
session.forwardPtyInput(data)
})
)
session.imeCompositionRouteDisposable = installTerminalImeCompositionRoute({
terminalElement: session.pane.terminal.element,
terminal: session.pane.terminal,
@@ -12,7 +12,7 @@ import {
import { redactPtyIdForDiagnostics } from '../../../../shared/pty-delivery-diagnostics'
// Why this guard exists: xterm auto-replies to query sequences (DA1/DECRQM/OSC 10-11/CPR) via onData → shell stdin, so replaying recorded PTY bytes leaks stray replies onto the new shell's prompt.
// No wasUserInput flag distinguishes replay replies from real keystrokes, so a per-pane in-flight counter gates onData; bounded by xterm's parse completion (not a timer), only auto-replies from replayed bytes are dropped.
// The per-pane counter suppresses synthetic onData during replay parsing; xterm's user-input signal keeps real keystrokes flowing.
export type ReplayingPanesRef = React.RefObject<Map<number, number>>
@@ -3,6 +3,7 @@ import {
isTerminalLinkActionActivation,
isTerminalLinkDirectActivation
} from './terminal-link-activation'
import { isXtermMouseReport } from './terminal-pointer-input-sequences'
const CAPTURE_LISTENER_OPTIONS = { capture: true } as const
const MAX_DEFERRED_PTY_INPUT_FRAMES = 64
@@ -17,13 +18,6 @@ export type TerminalLinkPtyMouseSuppression = IDisposable & {
handlePtyInput: (data: string, forward: (data: string) => void) => void
}
function isXtermMouseReport(data: string): boolean {
return (
(data.startsWith('\x1b[M') && data.length === 6) ||
(data.startsWith('\x1b[<') && /^\d+;\d+;\d+[Mm]$/.test(data.slice(3)))
)
}
export function installTerminalLinkPtyMouseSuppression(
terminal: Terminal,
shouldSuppressMouseEvent: (event: MouseEvent) => boolean,
@@ -0,0 +1,15 @@
// Why: xterm flags pointer-derived bytes as user input alongside keystrokes; callers
// that must treat pointer input differently need to recognise it by shape.
/** True for an xterm mouse report (X10 `CSI M` or SGR `CSI <`): pointer input, never a keystroke. */
export function isXtermMouseReport(data: string): boolean {
return (
(data.startsWith('\x1b[M') && data.length === 6) ||
(data.startsWith('\x1b[<') && /^\d+;\d+;\d+[Mm]$/.test(data.slice(3)))
)
}
/** True for the bare cursor up/down xterm synthesises per wheel notch when the active buffer has no scrollback. */
export function isXtermWheelCursorKey(data: string): boolean {
return data === '\x1b[A' || data === '\x1b[B' || data === '\x1bOA' || data === '\x1bOB'
}
@@ -1,6 +1,9 @@
import { describe, expect, it, vi } from 'vitest'
import { Terminal } from '@xterm/xterm'
import { subscribeToTerminalUserInput } from './terminal-user-input-signal'
import {
subscribeToTerminalInputData,
subscribeToTerminalUserInput
} from './terminal-user-input-signal'
type CoreServiceAccess = {
_core: {
@@ -78,3 +81,26 @@ describe('subscribeToTerminalUserInput', () => {
expect(listener).not.toHaveBeenCalled()
})
})
describe('subscribeToTerminalInputData', () => {
it('classifies real xterm events independently and disposes both subscriptions', () => {
const terminal = new Terminal({ allowProposedApi: true })
const core = (terminal as unknown as CoreServiceAccess)._core.coreService
const listener = vi.fn()
const subscription = subscribeToTerminalInputData(terminal, listener)
core.triggerDataEvent('keyboard', true)
core.triggerDataEvent('\x1b[?1;2c')
core.triggerDataEvent('\x1b[200~paste\x1b[201~', true)
core.triggerDataEvent('\x1b[O', false)
expect(listener.mock.calls).toEqual([
['keyboard', true],
['\x1b[?1;2c', false],
['\x1b[200~paste\x1b[201~', true],
['\x1b[O', false]
])
subscription.dispose()
core.triggerDataEvent('after-dispose', true)
expect(listener).toHaveBeenCalledTimes(4)
terminal.dispose()
})
})
@@ -43,3 +43,25 @@ export function subscribeToTerminalUserInput(
return null
}
}
/** Preserve xterm's input provenance across deferred PTY forwarding. */
export function subscribeToTerminalInputData(
terminal: Terminal,
listener: (data: string, wasUserInput: boolean) => void
): { dispose: () => void } {
let pendingUserInput = false
const userInput = subscribeToTerminalUserInput(terminal, () => {
pendingUserInput = true
})
const dataInput = terminal.onData((data) => {
const wasUserInput = pendingUserInput
pendingUserInput = false
listener(data, wasUserInput)
})
return {
dispose: () => {
dataInput.dispose()
userInput?.dispose()
}
}
}
@@ -0,0 +1,205 @@
// @vitest-environment happy-dom
import { SearchAddon } from '@xterm/addon-search'
import { Terminal } from '@xterm/xterm'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
type Entry = { line: number; id: number }
type SortedList = {
insert(value: Entry): void
delete(value: Entry): boolean
clear(): void
values(): IterableIterator<Entry>
getKeyIterator(key: number): IterableIterator<Entry>
_getKey(value: Entry): number
}
type Decoration = { marker: { line: number } }
type Service = {
_decorations: SortedList
decorations: Iterable<Decoration>
onDecorationRemoved(callback: () => void): { dispose(): void }
}
const terminals: Terminal[] = []
function setup(scrollback = 1000): { terminal: Terminal; service: Service } {
const terminal = new Terminal({ allowProposedApi: true, scrollback, rows: 24 })
terminals.push(terminal)
const container = document.createElement('div')
document.body.append(container)
terminal.open(container)
const service = (terminal as unknown as { _core: { _decorationService: Service } })._core
._decorationService
return { terminal, service }
}
function list(): SortedList {
const { service } = setup()
const Constructor = service._decorations.constructor as new (
key: (entry: Entry) => number,
log: { warn(): void }
) => SortedList
return new Constructor((entry) => entry?.line, { warn() {} })
}
function write(terminal: Terminal, data: string): Promise<void> {
return new Promise((resolve) => terminal.write(data, resolve))
}
beforeEach(() => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
measureText: () => ({ width: 10 })
} as unknown as CanvasRenderingContext2D)
})
afterEach(() => {
for (const terminal of terminals.splice(0)) {
terminal.dispose()
}
vi.restoreAllMocks()
document.body.replaceChildren()
})
describe('scrollback decoration eviction (#10879)', () => {
it.each([250, 500, 1000])('removes %i disposed keys with linear lookup work', (count) => {
const sorted = list()
const entries = Array.from({ length: count }, (_, id) => ({ line: id, id }))
entries.forEach((entry) => sorted.insert(entry))
expect([...sorted.values()]).toEqual(entries)
const keyReads = vi.spyOn(sorted, '_getKey')
try {
for (const entry of entries) {
entry.line = -1
expect(sorted.delete(entry)).toBe(true)
}
expect([...sorted.values()]).toEqual([])
console.info(`evict ${count}: ${keyReads.mock.calls.length} key reads`)
expect(keyReads.mock.calls.length).toBeLessThanOrEqual(count * 4)
} finally {
sorted.clear()
}
})
it('keeps duplicate identities, live-key queries, reinsertion and snapshots correct', () => {
const sorted = list()
const a = { line: 10, id: 1 }
const b = { line: 20, id: 2 }
try {
sorted.insert(b)
sorted.insert(a)
sorted.insert(a)
const snapshot = sorted.values()
a.line -= 5
b.line -= 5
expect([...sorted.getKeyIterator(5)]).toEqual([a, a])
expect(sorted.delete(a)).toBe(true)
expect(sorted.delete(a)).toBe(true)
expect(sorted.delete(a)).toBe(false)
expect([...sorted.values()]).toEqual([b])
expect([...snapshot]).toEqual([a, a, b])
sorted.insert(a)
expect([...sorted.values()]).toEqual([a, b])
sorted.clear()
expect(sorted.delete(a)).toBe(false)
sorted.insert(b)
expect(sorted.delete(b)).toBe(true)
expect([...sorted.values()]).toEqual([])
} finally {
sorted.clear()
}
})
it('preserves duplicate counts through partial compaction and reinsertion', () => {
const sorted = list()
const entry = { line: 0, id: 0 }
try {
sorted.insert(entry)
sorted.insert(entry)
sorted.insert(entry)
expect(sorted.delete(entry)).toBe(true)
expect([...sorted.values()]).toEqual([entry, entry])
expect(sorted.delete(entry)).toBe(true)
expect([...sorted.values()]).toEqual([entry])
sorted.insert(entry)
expect(sorted.delete(entry)).toBe(true)
expect(sorted.delete(entry)).toBe(true)
expect(sorted.delete(entry)).toBe(false)
expect([...sorted.values()]).toEqual([])
} finally {
sorted.clear()
}
})
it('matches identity membership through mixed key mutation and compaction', () => {
const sorted = list()
let seed = 10879
const random = (): number => (seed = (seed * 1664525 + 1013904223) >>> 0) / 2 ** 32
const entries = Array.from({ length: 100 }, (_, id) => ({ line: id, id }))
const live = new Set<Entry>()
try {
for (let step = 0; step < 2000; step++) {
const entry = entries[Math.floor(random() * entries.length)]
if (random() < 0.45 && !live.has(entry)) {
entry.line = entry.id
sorted.insert(entry)
live.add(entry)
} else {
entry.line = -1
expect(sorted.delete(entry)).toBe(live.delete(entry))
}
if (step % 17 === 0) {
expect([...sorted.values()]).toEqual([...live].sort((a, b) => a.line - b.line))
}
}
} finally {
sorted.clear()
}
})
it('evicts real search highlights without changing marker events or retained output', async () => {
const { terminal, service } = setup()
const search = new SearchAddon()
terminal.loadAddon(search)
await write(terminal, 'needle\r\n'.repeat(1000))
expect(
search.findNext('needle', {
decorations: {
matchBackground: '#ffcc00',
matchOverviewRuler: '#ffcc00',
activeMatchColorOverviewRuler: '#ff9900'
}
})
).toBe(true)
const decorations = [...service.decorations]
expect(decorations.length).toBeGreaterThanOrEqual(1000)
const removed = vi.fn()
service.onDecorationRemoved(removed)
const marker = terminal.registerMarker(0)
expect(marker).toBeDefined()
const disposed = vi.fn(() => {
expect(marker?.isDisposed).toBe(true)
expect(marker?.line).toBe(-1)
})
marker?.onDispose(disposed)
const keyReads = vi.spyOn(service._decorations, '_getKey')
await write(terminal, 'plain\r\n'.repeat(2000))
expect(removed).toHaveBeenCalledTimes(decorations.length)
expect([...service.decorations]).toEqual([])
expect(disposed).toHaveBeenCalledOnce()
expect(terminal.buffer.active.length).toBe(1024)
expect(terminal.buffer.active.getLine(1000)?.translateToString(true)).toBe('plain')
expect(keyReads.mock.calls.length).toBeLessThanOrEqual(decorations.length * 4)
search.clearDecorations()
expect(
search.findNext('plain', {
decorations: {
matchBackground: '#ffcc00',
matchOverviewRuler: '#ffcc00',
activeMatchColorOverviewRuler: '#ff9900'
}
})
).toBe(true)
expect([...service.decorations].length).toBeGreaterThan(0)
search.clearDecorations()
expect([...service.decorations]).toEqual([])
})
})
+1
View File
@@ -2644,6 +2644,7 @@
"tool": {
"countN": "{{value0}} tool calls",
"countOne": "1 tool call",
"moreCalls": "+{{value0}} more",
"ranCommandManyToolsSummary": "Ran {{commandCount}} command and used {{toolCount}} tools",
"ranCommandOneToolSummary": "Ran {{commandCount}} command and used {{toolCount}} tool",
"ranCommandsManyToolsSummary": "Ran {{commandCount}} commands and used {{toolCount}} tools",
+6
View File
@@ -16968,6 +16968,11 @@
"empty": "No users found"
},
"native-chat": {
"notices": {
"compaction": "Context compacted",
"details": "Details",
"plan": "Plan"
},
"composer": {
"imageUnsupported": "Image paste is not supported for this agent.",
"send": "Send",
@@ -17039,6 +17044,7 @@
"result": "Result",
"countOne": "1 tool call",
"countN": "{{value0}} tool calls",
"moreCalls": "+{{value0}} more",
"runningPreview": "Running {{preview}}",
"runningCommand": "Running command",
"runningNamedPreview": "Running {{toolName}} {{preview}}",
@@ -190,6 +190,34 @@ describe('forward tolerance', () => {
})
})
describe('optional notice metadata', () => {
it.each([
{},
{ presentation: 'compaction' },
{ presentation: 'plan-document' },
{ tone: 'warning' },
{ tone: 'error' },
{ tone: 'notice' },
{ presentation: 'future-presentation', tone: 'future-tone' }
])('admits existing status and text kinds with %j', (metadata) => {
expect(
isAdmissibleAgentJournalItemBody({ kind: 'status', text: 'Readable fallback', ...metadata })
).toBe(true)
expect(
isAdmissibleAgentJournalItemBody({
kind: 'message',
role: 'system',
blocks: [{ type: 'text', text: 'Readable fallback', ...metadata }]
})
).toBe(true)
})
it.each([{ tone: false }, { presentation: {} }])('rejects malformed metadata: %j', (metadata) => {
expect(isAdmissibleAgentJournalItemBody({ kind: 'status', text: 'Text', ...metadata })).toBe(
false
)
})
})
describe('optional tool annotations', () => {
const body = { kind: 'tool-call', name: 'shell', input: null, state: 'completed' }
it('admits old rows and rows with optional annotations without a new kind', () => {
@@ -68,6 +68,8 @@ const Block = z.union([
z.object({
type: z.literal('text'),
text: z.string(),
presentation: z.string().optional(),
tone: z.string().optional(),
providerFrame: ProviderFrame.optional()
}),
// `input: undefined` loses its key under JSON.stringify, so a persisted
@@ -160,6 +162,8 @@ export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [
z.object({
kind: z.literal('status'),
text: z.string(),
presentation: z.string().optional(),
tone: z.string().optional(),
turnLifecycle: z.object({ turnId: z.string(), state: z.string().min(1) }).optional(),
providerFrame: ProviderFrame.optional()
})
@@ -148,6 +148,9 @@ export type AgentJournalQuestionItem = {
export type AgentJournalStatusItem = {
kind: 'status'
text: string
/** Optional display hints; unknown values retain the ordinary text fallback. */
presentation?: string
tone?: string
/** Durable root-turn lifecycle used by clients to expose cancellation only
* while the provider can still accept it. */
turnLifecycle?: { turnId: string; state: 'running' | 'completed' }
+2 -1
View File
@@ -13,7 +13,8 @@ export const NATIVE_CHAT_TOOL_ACTIVITY_COPY = {
runningNamedPreview: 'Running {{toolName}} {{preview}}',
runningNamed: 'Running {{toolName}}',
countOne: '1 tool call',
countN: '{{value0}} tool calls'
countN: '{{value0}} tool calls',
moreCalls: '+{{value0}} more'
} as const
/** Tools whose call is a shell command, so the row reads as terminal activity
@@ -9,6 +9,7 @@ import {
MAX_TOOL_DETAIL_LENGTH,
summarizeToolInput,
summarizeToolRun,
toolRunSummaryMembers,
toolFilePath,
truncateToolDetail
} from './native-chat-tool-summary'
@@ -254,6 +255,73 @@ describe('briefToolArg', () => {
})
})
describe('toolRunSummaryMembers', () => {
it('keeps each call separate so a boundary can be drawn between them', () => {
const blocks: NativeChatBlock[] = [
{ type: 'tool-call', name: 'Bash', input: { command: 'ls -la' } },
{ type: 'tool-call', name: 'tools/read', input: { file_path: 'README.md' } }
]
expect(toolRunSummaryMembers(blocks)).toEqual([
{ name: 'Bash', arg: 'ls -la', mcpIdentity: undefined },
{ name: 'tools/read', arg: 'README.md', mcpIdentity: undefined }
])
})
// `url` is a PRIMARY_ARG_KEY but not a BRIEF_ARG_KEY, so a call carrying only
// a url falls through to the bounded JSON preview and is cut at 28 chars —
// mid-token, brace unbalanced. Pinned as-is: the header renders whatever this
// returns, and changing the key list would move mobile's summary too.
it('still falls through to a clipped JSON preview for a url-only call', () => {
const blocks: NativeChatBlock[] = [
{ type: 'tool-call', name: 'browser.open', input: { url: 'https://example.com' } }
]
expect(toolRunSummaryMembers(blocks)[0]?.arg).toBe('{"url":"https://example.com"')
})
it('caps at the summary limit and skips nameless calls, as the joined string does', () => {
const blocks: NativeChatBlock[] = [
{ type: 'tool-call', name: ' ', input: {} },
{ type: 'tool-call', name: 'Bash', input: { command: 'ls' } },
{ type: 'tool-call', name: 'Read', input: { file_path: 'a.ts' } },
{ type: 'tool-call', name: 'Edit', input: { file_path: 'b.ts' } },
{ type: 'tool-call', name: 'Write', input: { file_path: 'c.ts' } }
]
const members = toolRunSummaryMembers(blocks)
expect(members.map((member) => member.name)).toEqual(['Bash', 'Read', 'Edit'])
// The joined string is derived from these, so the two can never disagree.
expect(summarizeToolRun(blocks)).toBe(
members.map((member) => `${member.name} ${member.arg}`).join(' · ')
)
})
it('carries provider MCP identity through, so a pill can draw the server glyph', () => {
const blocks: NativeChatBlock[] = [
{
type: 'tool-call',
name: 'mcp__linear__list_issues',
input: {},
mcpIdentity: { server: 'linear', tool: 'list_issues' }
}
]
expect(toolRunSummaryMembers(blocks)[0]?.mcpIdentity).toEqual({
server: 'linear',
tool: 'list_issues'
})
})
it('reports a blank argument rather than standing raw JSON in for one', () => {
const blocks: NativeChatBlock[] = [{ type: 'tool-call', name: 'Bash', input: { command: '' } }]
expect(toolRunSummaryMembers(blocks)).toEqual([
{ name: 'Bash', arg: '', mcpIdentity: undefined }
])
})
})
describe('summarizeToolRun', () => {
it('caps the run summary and skips nameless calls', () => {
const blocks: NativeChatBlock[] = [
+24 -6
View File
@@ -1,3 +1,4 @@
import type { NativeChatMcpIdentity } from './native-chat-tool-identity'
import { isToolCallBlock, type NativeChatBlock } from './native-chat-types'
const MAX_PREVIEW_LENGTH = 80
@@ -260,8 +261,20 @@ function summarizePrimaryToolArg(input: unknown): string | null {
return null
}
export function summarizeToolRun(blocks: readonly NativeChatBlock[]): string {
const parts: string[] = []
/** One named call in a run header, kept apart rather than pre-joined so a
* surface can draw the boundary between members itself. */
export type ToolRunMember = {
name: string
/** Brief argument, or '' when the call has none worth showing. */
arg: string
mcpIdentity?: NativeChatMcpIdentity
}
/** The run header's leading calls. Capped at the same limit the joined string
* has always used, so the two can never disagree about which calls speak for
* a run. */
export function toolRunSummaryMembers(blocks: readonly NativeChatBlock[]): ToolRunMember[] {
const members: ToolRunMember[] = []
for (const block of blocks) {
if (!isToolCallBlock(block)) {
continue
@@ -270,13 +283,18 @@ export function summarizeToolRun(blocks: readonly NativeChatBlock[]): string {
if (!name) {
continue
}
const detail = briefToolArg(block.input)
parts.push(detail ? `${name} ${detail}` : name)
if (parts.length >= MAX_TOOL_RUN_SUMMARY_PARTS) {
members.push({ name, arg: briefToolArg(block.input), mcpIdentity: block.mcpIdentity })
if (members.length >= MAX_TOOL_RUN_SUMMARY_PARTS) {
break
}
}
return parts.join(' · ')
return members
}
export function summarizeToolRun(blocks: readonly NativeChatBlock[]): string {
return toolRunSummaryMembers(blocks)
.map((member) => (member.arg ? `${member.name} ${member.arg}` : member.name))
.join(' · ')
}
export function countToolCalls(blocks: readonly NativeChatBlock[]): number {
+3
View File
@@ -32,6 +32,9 @@ export type NativeChatRole = (typeof NATIVE_CHAT_ROLES)[number]
export type NativeChatTextBlock = {
type: 'text'
text: string
/** Optional journal display hints; readers narrow only the values they know. */
presentation?: string
tone?: string
/** Optional structured detail for an otherwise ordinary fallback line. */
providerFrame?: {
provider: string
@@ -284,6 +284,29 @@ describe('structured agent session status projection', () => {
})
})
describe('notice projection for desktop and mobile consumers', () => {
it.each([
{ presentation: 'compaction' },
{ presentation: 'plan-document' },
{ tone: 'warning' },
{ tone: 'error' },
{ tone: 'notice' },
{ presentation: 'future-presentation', tone: 'future-tone' }
])('preserves readable text alongside optional metadata: %j', (metadata) => {
const projected = projectStructuredItemToNativeChat(
item('notice', 1, {
kind: 'status',
text: 'A readable document or notice',
...metadata
})
)
expect(projected).toMatchObject({
role: 'system',
blocks: [{ type: 'text', text: 'A readable document or notice', ...metadata }]
})
})
})
it('preserves optional tool annotations for desktop and mobile projection', () => {
const metadata = {
exitCode: 127,
@@ -113,6 +113,8 @@ function itemBlocks(item: AgentJournalRenderItem): {
{
type: 'text',
text: body.text,
...(body.presentation !== undefined ? { presentation: body.presentation } : {}),
...(body.tone !== undefined ? { tone: body.tone } : {}),
...(body.providerFrame ? { providerFrame: body.providerFrame } : {})
}
]