mirror of
https://github.com/stablyai/orca.git
synced 2026-10-04 00:02:21 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/c5-read-child-records
This commit is contained in:
@@ -9,9 +9,9 @@
|
||||
//
|
||||
// Those numbers are the `!useConptyDll` branch, which is the branch a RELAY runs. Every desktop
|
||||
// site that opens a terminal pane sets `useConptyDll: true` and takes the other branch, where
|
||||
// upstream already destroys the input socket. Two hidden rate-limit probes
|
||||
// (`src/main/rate-limits/claude-pty.ts`, `codex-pty-rate-limit-probe.ts`) do omit the option and so
|
||||
// do run this hunk, but no user-visible pane does. The divergence pinned below is about which
|
||||
// upstream already destroys the input socket. The hidden rate-limit probe
|
||||
// (`src/main/rate-limits/claude-pty.ts`) does omit the option and so does run this hunk, but no
|
||||
// user-visible pane does. The divergence pinned below is about which
|
||||
// branch each host runs for terminals -- not about a regression in the panes users open.
|
||||
import { createRequire } from 'node:module'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
|
||||
@@ -107,6 +107,7 @@
|
||||
"../src/main/claude/hook-settings.ts",
|
||||
"../src/main/claude/hook-service.ts",
|
||||
"../src/main/claude/statusline-script.ts",
|
||||
"../src/main/claude/windows-hook-files.ts",
|
||||
"../src/main/claude-accounts/keychain.ts",
|
||||
"../src/main/macos-keychain/generic-password.ts",
|
||||
"../src/main/codex/codex-app-server-capability-cache.ts",
|
||||
|
||||
@@ -274,7 +274,7 @@ _shorten the interpreter chain_ rather than to hide a window.
|
||||
#18875 is a worked example of that doctrine. The Claude Code lifecycle hook was
|
||||
registered as `powershell.exe -NoProfile -EncodedCommand <...>` whose entire
|
||||
decoded payload was a `Test-Path` and a call to `~/.orca/agent-hooks/claude-hook.cmd`.
|
||||
It now registers the script path itself (`<path> || echo {}`), so `bash ->
|
||||
It now registers the bare script path itself, with no shell operators, so `bash ->
|
||||
powershell -> cmd -> curl` became `bash -> cmd -> curl` and one
|
||||
`powershell.exe -EncodedCommand` per hook event — a first-class Defender alert
|
||||
title — leaves the tree. The reporting box fired ~6 900 of them in five days,
|
||||
@@ -287,24 +287,24 @@ concurrency, invoked as Claude Code invokes it. **No EDR verdict on either tree
|
||||
was measured**, so claim the removed `-EncodedCommand` spelling and the shorter
|
||||
chain, not a score. `cmd.exe` remains in the tree, spelled by MSYS's own `.cmd`
|
||||
spawn rather than by us — the doc's one "unavoidable for `.cmd`/`.bat`" case,
|
||||
carrying an absolute path and two literal tokens, with no caret escaping, no
|
||||
carrying only an absolute path, with no caret escaping, no
|
||||
encoding and no free text. The encoded launcher is still the shape for profile
|
||||
paths the shells cannot carry bare (a space, `%`, `^`, `&`, non-ASCII, a UNC
|
||||
profile) and for hosts where Git Bash is not resolvable, because PowerShell 5.1
|
||||
rejects `||` (measured: parse error, exit 1).
|
||||
profile).
|
||||
|
||||
That last clause is the standing assumption of this change, and it is worth
|
||||
stating plainly because it is **not** measured. `||` parses in Git Bash, cmd.exe
|
||||
and pwsh, but not in Windows PowerShell 5.1, so the direct shape is correct for
|
||||
any host that is one of the first three. Claude Code itself is a Git Bash host on
|
||||
native Windows. What no one here has verified is which host a _compat consumer_
|
||||
uses: cursor-agent and Devin import `~/.claude/settings.json` and run `command`
|
||||
through their own launcher (the managed `.cmd` carries a `DEVIN_PROJECT_DIR` skip
|
||||
for exactly that). If one of them spawns hook strings through Windows PowerShell
|
||||
5.1, its imported Claude events become a parse error with empty stdout, which is
|
||||
the fail-closed case #14818 exists to prevent. The encoded launcher had no such
|
||||
assumption — it was a `powershell.exe` invocation and therefore parsed anywhere.
|
||||
Before widening the direct shape to another agent, measure that consumer's host.
|
||||
The direct shape first shipped as `<path> || echo {}`, gated on Git Bash being
|
||||
resolvable. That gate guessed at a choice Claude Code makes on its own: it runs
|
||||
hooks under Windows PowerShell 5.1 when it picks PowerShell, and 5.1 rejects `||`
|
||||
(parse error, exit 1), so every hook failed (STA-8913). The registered command is
|
||||
now the bare path, which parses in Git Bash, cmd.exe, pwsh and PowerShell 5.1.
|
||||
The neutral `{}` for a missing payload lives in the `claude-hook.cmd` entry, which
|
||||
hands off to `claude-hook-impl.cmd` in the same `cmd.exe`; a deleted entry is an
|
||||
ordinary non-blocking hook error (never exit 2) until the next install rewrites it.
|
||||
Compat consumers such as cursor-agent and Devin import `~/.claude/settings.json`
|
||||
and run `command` through their own launcher (the payload carries a
|
||||
`DEVIN_PROJECT_DIR` skip for that); one that cannot start a `.cmd` at all still
|
||||
gets a launch failure. Before widening the direct shape to another agent, measure
|
||||
that consumer's host.
|
||||
|
||||
### Computer use: screen capture, synthetic input, runtime-compiled MSIL
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { retireLandedMobileNativeChatPending } from './mobile-native-chat-pending-retirement'
|
||||
|
||||
const boundary: NativeChatMessage = {
|
||||
id: 'boundary',
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text: 'before' }],
|
||||
timestamp: null,
|
||||
source: 'transcript'
|
||||
}
|
||||
|
||||
// An old host still sends Claude's raw paste envelope; the shared normalizer must match it.
|
||||
it('retires an echo against an old-host wrapped row and keeps the second identical send', () => {
|
||||
const text = 'one\ntwo'
|
||||
const echo: NativeChatMessage = {
|
||||
...boundary,
|
||||
id: 'user',
|
||||
role: 'user',
|
||||
blocks: [{ type: 'text', text: `<pasted_content id="a">\n${text}\n</pasted_content id="a">` }]
|
||||
}
|
||||
const reply: NativeChatMessage = { ...boundary, id: 'reply' }
|
||||
const entry = {
|
||||
id: 'one',
|
||||
text,
|
||||
expectedOccurrence: 1,
|
||||
baselineTailMessageId: 'boundary',
|
||||
baselineResolved: true
|
||||
}
|
||||
expect(
|
||||
retireLandedMobileNativeChatPending(
|
||||
[boundary, echo, reply],
|
||||
[entry, { ...entry, id: 'two', expectedOccurrence: 2 }],
|
||||
new Set()
|
||||
).map((item) => item.id)
|
||||
).toEqual(['two'])
|
||||
})
|
||||
@@ -78,6 +78,8 @@ export type TerminalDocumentState = {
|
||||
lastEmittedModes: TerminalDocumentModes
|
||||
/** `terminal-init`: whether the terminal has ever reached ready. */
|
||||
everReady: boolean
|
||||
/** `mouse-mode-decset-scan`: whether the encoding is proven (1006/1016 set or reset, RIS, or a live tracking enable). */
|
||||
mouseEncodingKnown: boolean
|
||||
/** `mouse-mode-decset-scan`: the tail of the last chunk, in case a DECSET straddles two writes. */
|
||||
mouseModeScanTail: string
|
||||
/** `mouse-mode-decset-scan`: the mouse tracking mode the TUI last asked for. */
|
||||
@@ -262,6 +264,7 @@ function createTerminalDocumentState(): TerminalDocumentState {
|
||||
sgrMousePixelsMode: false
|
||||
},
|
||||
everReady: false,
|
||||
mouseEncodingKnown: false,
|
||||
mouseModeScanTail: '',
|
||||
trackedMouseTrackingMode: 'none',
|
||||
sgrMouseMode: false,
|
||||
|
||||
@@ -94,6 +94,7 @@ export function handleMsg(scope: TerminalDocumentScope, msg: TerminalHostMessage
|
||||
scope.statusDotPendingSelector = false
|
||||
scope.afterDrainCallbacks = []
|
||||
scope.writesDraining = false
|
||||
scope.mouseEncodingKnown = false
|
||||
scope.mouseModeScanTail = ''
|
||||
scope.trackedMouseTrackingMode = 'none'
|
||||
scope.sgrMouseMode = false
|
||||
|
||||
@@ -24,7 +24,7 @@ export type TerminalMouseGesture = {
|
||||
// One report per transition, built with the same encoding ladder as
|
||||
// buildMouseClickInput: SGR pixels (1016) > SGR (1006) > default. Returns ''
|
||||
// when the mode does not report this transition (x10 has no release, only
|
||||
// drag/any report motion) or the cell is not encodable.
|
||||
// drag/any report motion), the encoding is unproven, or the cell is not encodable.
|
||||
export function buildMouseButtonReport(
|
||||
scope: TerminalDocumentScope,
|
||||
kind: string,
|
||||
@@ -32,7 +32,7 @@ export function buildMouseButtonReport(
|
||||
clientY: number
|
||||
) {
|
||||
const mouseTrackingMode = getMouseTrackingMode(scope)
|
||||
if (mouseTrackingMode === 'none') {
|
||||
if (mouseTrackingMode === 'none' || !scope.mouseEncodingKnown) {
|
||||
return ''
|
||||
}
|
||||
if (kind === 'motion' && mouseTrackingMode !== 'drag' && mouseTrackingMode !== 'any') {
|
||||
@@ -103,7 +103,8 @@ export function abandonMouseGesture(scope: TerminalDocumentScope) {
|
||||
|
||||
export function beginMouseDrag(scope: TerminalDocumentScope, gesture: TerminalMouseGesture) {
|
||||
gesture.moved = true
|
||||
if (getMouseTrackingMode(scope) !== 'none') {
|
||||
// Why: with no proven encoding the program would get no reports, so select locally instead.
|
||||
if (getMouseTrackingMode(scope) !== 'none' && scope.mouseEncodingKnown) {
|
||||
gesture.mode = 'tracking'
|
||||
gesture.lastCellKey = mouseReportCellKey(scope, gesture.startX, gesture.startY)
|
||||
const press = buildMouseButtonReport(scope, 'press', gesture.startX, gesture.startY)
|
||||
|
||||
@@ -61,6 +61,9 @@ export function buildMouseWheelSequence(
|
||||
clientX: number,
|
||||
clientY: number
|
||||
) {
|
||||
if (!scope.mouseEncodingKnown) {
|
||||
return ''
|
||||
}
|
||||
const cell = viewportToMouseReportCell(scope, clientX, clientY)
|
||||
if (!cell) {
|
||||
return ''
|
||||
@@ -105,7 +108,7 @@ export function buildMouseClickInput(
|
||||
clientY: number
|
||||
) {
|
||||
const mouseTrackingMode = getMouseTrackingMode(scope)
|
||||
if (!isClickMouseTrackingMode(mouseTrackingMode)) {
|
||||
if (!isClickMouseTrackingMode(mouseTrackingMode) || !scope.mouseEncodingKnown) {
|
||||
return ''
|
||||
}
|
||||
const cell = viewportToMouseReportCell(scope, clientX, clientY)
|
||||
@@ -168,8 +171,18 @@ export function isWheelMouseTrackingMode(mode: string) {
|
||||
return mode !== 'none' && mode !== 'x10'
|
||||
}
|
||||
|
||||
// Why: a replay can carry tracking without its encoding (?1006h/?1016h); a guessed
|
||||
// legacy report would type `ESC[M` bytes into the program, so scroll locally instead.
|
||||
export function isWheelEncodingUnproven(scope: TerminalDocumentScope, mode: string) {
|
||||
return isWheelMouseTrackingMode(mode) && !scope.mouseEncodingKnown
|
||||
}
|
||||
|
||||
export function shouldRouteScrollToTerminalInput(scope: TerminalDocumentScope) {
|
||||
return isWheelMouseTrackingMode(getMouseTrackingMode(scope)) || isAlternateBufferActive(scope)
|
||||
const mode = getMouseTrackingMode(scope)
|
||||
if (isWheelEncodingUnproven(scope, mode)) {
|
||||
return false
|
||||
}
|
||||
return isWheelMouseTrackingMode(mode) || isAlternateBufferActive(scope)
|
||||
}
|
||||
|
||||
export function buildMouseWheelScrollInput(
|
||||
@@ -221,6 +234,10 @@ export function routeScrollLines(
|
||||
}
|
||||
const mouseTrackingMode = getMouseTrackingMode(scope)
|
||||
const alternateBufferActive = isAlternateBufferActive(scope)
|
||||
if (isWheelEncodingUnproven(scope, mouseTrackingMode)) {
|
||||
scope.term.scrollLines(lines)
|
||||
return
|
||||
}
|
||||
if (isWheelMouseTrackingMode(mouseTrackingMode)) {
|
||||
// Why: xterm sends wheel events to mouse-aware TUIs before considering
|
||||
// scrollback, even if the app stays on the normal buffer.
|
||||
|
||||
@@ -22,7 +22,8 @@ export function normalizeInitialData(data: unknown) {
|
||||
return on > 0 ? data.slice(on) : data
|
||||
}
|
||||
|
||||
export function updateMouseModeFromData(scope: TerminalDocumentScope, data: unknown) {
|
||||
// `live`: bytes the program just wrote, as opposed to a snapshot replay.
|
||||
export function updateMouseModeFromData(scope: TerminalDocumentScope, data: unknown, live = false) {
|
||||
if (typeof data !== 'string' || data.length === 0) {
|
||||
return
|
||||
}
|
||||
@@ -35,6 +36,7 @@ export function updateMouseModeFromData(scope: TerminalDocumentScope, data: unkn
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = re.exec(input)) !== null) {
|
||||
if (match[0] === ESC + 'c') {
|
||||
scope.mouseEncodingKnown = true
|
||||
scope.trackedMouseTrackingMode = 'none'
|
||||
scope.sgrMouseMode = false
|
||||
scope.sgrMousePixelsMode = false
|
||||
@@ -50,6 +52,11 @@ export function updateMouseModeFromData(scope: TerminalDocumentScope, data: unkn
|
||||
if (!Number.isInteger(param)) {
|
||||
continue
|
||||
}
|
||||
// Why: a program enabling tracking live states its encoding in that same burst, or
|
||||
// means the default; only a replay can carry tracking without its encoding.
|
||||
if (live && enabled && (param === 9 || param === 1000 || param === 1002 || param === 1003)) {
|
||||
scope.mouseEncodingKnown = true
|
||||
}
|
||||
if (param === 9) {
|
||||
scope.trackedMouseTrackingMode = enabled ? 'x10' : 'none'
|
||||
}
|
||||
@@ -63,10 +70,12 @@ export function updateMouseModeFromData(scope: TerminalDocumentScope, data: unkn
|
||||
scope.trackedMouseTrackingMode = enabled ? 'any' : 'none'
|
||||
}
|
||||
if (param === 1006) {
|
||||
scope.mouseEncodingKnown = true
|
||||
scope.sgrMouseMode = enabled
|
||||
scope.sgrMousePixelsMode = false
|
||||
}
|
||||
if (param === 1016) {
|
||||
scope.mouseEncodingKnown = true
|
||||
scope.sgrMouseMode = false
|
||||
scope.sgrMousePixelsMode = enabled
|
||||
}
|
||||
|
||||
@@ -59,7 +59,12 @@ export function notifyTerminalSurfaceTap(
|
||||
notify(scope, { type: 'terminal-input', bytes: clickInput })
|
||||
}
|
||||
// Touch still needs native input focus after the TUI consumes its mouse click.
|
||||
if (focusKeyboard || !isClickMouseTrackingMode(getMouseTrackingMode(scope))) {
|
||||
// A click with an unproven encoding reached no program, so it acts as a plain tap.
|
||||
if (
|
||||
focusKeyboard ||
|
||||
!isClickMouseTrackingMode(getMouseTrackingMode(scope)) ||
|
||||
!scope.mouseEncodingKnown
|
||||
) {
|
||||
notify(scope, { type: 'terminal-tap' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +122,7 @@ export function init(
|
||||
scope.wheelAccumDeltaY = 0
|
||||
scope.mouseModeScanTail = ''
|
||||
scope.trackedMouseTrackingMode = 'none'
|
||||
scope.mouseEncodingKnown = false
|
||||
scope.sgrMouseMode = false
|
||||
scope.sgrMousePixelsMode = false
|
||||
scope.lastEmittedModes = {
|
||||
@@ -208,7 +209,7 @@ export function init(
|
||||
}
|
||||
|
||||
export function write(scope: TerminalDocumentScope, data: string) {
|
||||
updateMouseModeFromData(scope, data)
|
||||
updateMouseModeFromData(scope, data, true)
|
||||
enqueueWrite(scope, data)
|
||||
pumpWrites(scope, scope.terminalGeneration)
|
||||
// Why: first live data chunk after init may widen the buffer past
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ESC, useTerminalMouseWebViewHarness } from './terminal-webview-mouse-test-harness'
|
||||
|
||||
type DocumentMessage = {
|
||||
type: string
|
||||
data?: string
|
||||
cols?: number
|
||||
rows?: number
|
||||
initialData?: string
|
||||
}
|
||||
|
||||
function message(data: DocumentMessage) {
|
||||
window.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(data) }))
|
||||
}
|
||||
|
||||
function wheel(deltaY = 120) {
|
||||
document.getElementById('terminal-surface')!.dispatchEvent(
|
||||
new WheelEvent('wheel', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
deltaY,
|
||||
clientX: 40,
|
||||
clientY: 60
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function swipe() {
|
||||
const surface = document.getElementById('terminal-surface')!
|
||||
for (const [type, y] of [
|
||||
['touchstart', 240],
|
||||
['touchmove', 120],
|
||||
['touchend', 120]
|
||||
] as const) {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true })
|
||||
const touch = { identifier: 1, clientX: 40, clientY: y }
|
||||
Object.defineProperty(event, 'touches', { value: type === 'touchend' ? [] : [touch] })
|
||||
Object.defineProperty(event, 'changedTouches', { value: [touch] })
|
||||
surface.dispatchEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
function touchTap() {
|
||||
const surface = document.getElementById('terminal-surface')!
|
||||
for (const type of ['touchstart', 'touchend']) {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true })
|
||||
const touches = type === 'touchend' ? [] : [{ identifier: 0, clientX: 40, clientY: 60 }]
|
||||
Object.defineProperty(event, 'touches', {
|
||||
value: touches.map((touch) => ({ ...touch, target: surface }))
|
||||
})
|
||||
Object.defineProperty(event, 'target', { value: surface })
|
||||
document.dispatchEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
// What a desktop pane snapshot looks like: normal-buffer scrollback, the alternate screen, the
|
||||
// tracking modes the serializer writes, then the encoding the pane appends at the very end.
|
||||
const PANE_PREFIX = `scrollback\r\n${ESC}[?1049h${ESC}[H codex prompt${ESC}[?1003h`
|
||||
|
||||
describe('mobile mouse encoding proof', () => {
|
||||
const mouse = useTerminalMouseWebViewHarness()
|
||||
|
||||
function bootAltScreen(initialData: string, mode = 'any') {
|
||||
mouse.boot(initialData)
|
||||
mouse.showAlternateBuffer()
|
||||
mouse.activeTerminal().modes.mouseTrackingMode = mode
|
||||
mouse.clearPostedMessages()
|
||||
}
|
||||
|
||||
it('reports a swipe and a wheel as SGR from a pane snapshot that ends with ?1006h', () => {
|
||||
bootAltScreen(`${PANE_PREFIX}${ESC}[?1006h`)
|
||||
swipe()
|
||||
wheel(-120)
|
||||
const bytes = mouse.terminalInputBytes()
|
||||
expect(bytes).toContain(`${ESC}[<64;`)
|
||||
wheel()
|
||||
expect(mouse.terminalInputBytes()).toContain(`${ESC}[<65;`)
|
||||
expect(mouse.terminalInputBytes()).not.toContain(`${ESC}[M`)
|
||||
})
|
||||
|
||||
it('loses an encoding that sits before the alternate screen the phone replays from', () => {
|
||||
bootAltScreen(`${ESC}[?1006h${PANE_PREFIX}`)
|
||||
wheel()
|
||||
expect(mouse.terminalInputBytes()).toBe('')
|
||||
})
|
||||
|
||||
it('sends nothing for a wheel, a swipe or a tap while tracking has no proven encoding', () => {
|
||||
bootAltScreen(PANE_PREFIX)
|
||||
wheel()
|
||||
swipe()
|
||||
touchTap()
|
||||
expect(mouse.terminalInputBytes()).toBe('')
|
||||
expect(mouse.postedMessages().filter((posted) => posted.type === 'terminal-tap')).toHaveLength(
|
||||
1
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['SGR', '?1006h', '[<65;'],
|
||||
['pixel', '?1016h', '[<65;'],
|
||||
['explicit legacy', '?1006l', '[Ma']
|
||||
])('keeps %s encoding proven by a live write', (_name, mode, prefix) => {
|
||||
mouse.boot()
|
||||
mouse.activeTerminal().modes.mouseTrackingMode = 'any'
|
||||
message({ type: 'write', data: `${ESC}[${mode}${ESC}[?1003h` })
|
||||
wheel()
|
||||
expect(mouse.terminalInputBytes()).toContain(`${ESC}${prefix}`)
|
||||
})
|
||||
|
||||
it('reports legacy wheel input from a pane snapshot that states the default encoding', () => {
|
||||
bootAltScreen(`${PANE_PREFIX}${ESC}[?1006l`)
|
||||
wheel()
|
||||
expect(mouse.terminalInputBytes()).toContain(`${ESC}[Ma`)
|
||||
})
|
||||
|
||||
it('reports legacy wheel input for a program that enables tracking live without an encoding', () => {
|
||||
mouse.boot()
|
||||
message({ type: 'write', data: `${ESC}[?1049h${ESC}[?1000h` })
|
||||
mouse.showAlternateBuffer()
|
||||
mouse.activeTerminal().modes.mouseTrackingMode = 'vt200'
|
||||
wheel()
|
||||
expect(mouse.terminalInputBytes()).toContain(`${ESC}[Ma`)
|
||||
})
|
||||
|
||||
it('forgets encoding proof on the next snapshot', () => {
|
||||
mouse.boot()
|
||||
message({ type: 'write', data: `${ESC}[?1006l` })
|
||||
message({ type: 'init', cols: 40, rows: 24, initialData: `${ESC}[?1003h` })
|
||||
mouse.activeTerminal().modes.mouseTrackingMode = 'any'
|
||||
wheel()
|
||||
expect(mouse.terminalInputBytes()).toBe('')
|
||||
})
|
||||
|
||||
it('keeps arrow-key scrolling for a click-only (x10) program on the alternate screen', () => {
|
||||
bootAltScreen(`${ESC}[?1049h${ESC}[?9h`, 'x10')
|
||||
wheel(-120)
|
||||
swipe()
|
||||
const bytes = mouse.terminalInputBytes()
|
||||
expect(bytes).toContain(`${ESC}[B`)
|
||||
expect(bytes).toContain(`${ESC}[A`)
|
||||
expect(bytes).not.toContain(`${ESC}[M`)
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,7 @@ describe('terminal WebView external mouse click', () => {
|
||||
|
||||
it('reports a mouse click to a click-tracking TUI the way a touch tap does (#8818)', () => {
|
||||
mouse.boot()
|
||||
mouse.writeLegacyMouseEncoding()
|
||||
mouse.activeTerminal().modes.mouseTrackingMode = 'vt200'
|
||||
|
||||
mouse.mouseClick(40, 60)
|
||||
@@ -21,6 +22,18 @@ describe('terminal WebView external mouse click', () => {
|
||||
expect(mouse.postedMessages().filter((message) => message.type === 'terminal-tap')).toEqual([])
|
||||
})
|
||||
|
||||
it('sends no click report and focuses the keyboard while the encoding is unproven', () => {
|
||||
mouse.boot()
|
||||
mouse.activeTerminal().modes.mouseTrackingMode = 'vt200'
|
||||
|
||||
mouse.mouseClick(40, 60)
|
||||
|
||||
expect(mouse.terminalInputBytes()).toBe('')
|
||||
expect(
|
||||
mouse.postedMessages().filter((message) => message.type === 'terminal-tap')
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('dismisses an existing selection with a click without focusing the keyboard', () => {
|
||||
mouse.boot()
|
||||
mouse.mouseDrag(40, 60, 160, 90)
|
||||
|
||||
@@ -11,6 +11,7 @@ describe('terminal WebView external mouse drag', () => {
|
||||
|
||||
it('sends press, per-cell motion, and release for a drag-tracking mouse drag', () => {
|
||||
mouse.boot()
|
||||
mouse.writeLegacyMouseEncoding()
|
||||
mouse.activeTerminal().modes.mouseTrackingMode = 'drag'
|
||||
|
||||
mouse.mouseDrag(40, 60, 160, 60)
|
||||
@@ -29,6 +30,7 @@ describe('terminal WebView external mouse drag', () => {
|
||||
|
||||
it('does not report motion or release for an x10 click-only TUI drag', () => {
|
||||
mouse.boot()
|
||||
mouse.writeLegacyMouseEncoding()
|
||||
mouse.activeTerminal().modes.mouseTrackingMode = 'x10'
|
||||
|
||||
mouse.mouseDrag(40, 60, 160, 60)
|
||||
@@ -53,8 +55,20 @@ describe('terminal WebView external mouse drag', () => {
|
||||
expect(modes).toEqual([{ type: 'set-select-mode', enabled: true }])
|
||||
})
|
||||
|
||||
it('selects text instead of reporting a drag while the encoding is unproven', () => {
|
||||
mouse.boot(`${ESC}[?1002h`)
|
||||
mouse.activeTerminal().modes.mouseTrackingMode = 'drag'
|
||||
mouse.clearPostedMessages()
|
||||
|
||||
mouse.mouseDrag(40, 60, 160, 90)
|
||||
|
||||
expect(mouse.terminalInputBytes()).toBe('')
|
||||
expect(mouse.selectionSpy()).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('releases a tracked drag when the button state shows the pointerup was lost', () => {
|
||||
mouse.boot()
|
||||
mouse.writeLegacyMouseEncoding()
|
||||
mouse.activeTerminal().modes.mouseTrackingMode = 'drag'
|
||||
|
||||
mouse.dispatchPointer('pointerdown', { x: 40, y: 60, button: 0, buttons: 1 })
|
||||
@@ -74,6 +88,7 @@ describe('terminal WebView external mouse drag', () => {
|
||||
|
||||
it('releases a tracked drag when the pointer is cancelled mid-gesture', () => {
|
||||
mouse.boot()
|
||||
mouse.writeLegacyMouseEncoding()
|
||||
mouse.activeTerminal().modes.mouseTrackingMode = 'drag'
|
||||
|
||||
mouse.dispatchPointer('pointerdown', { x: 40, y: 60, button: 0, buttons: 1 })
|
||||
|
||||
@@ -150,12 +150,12 @@ export function useTerminalMouseWebViewHarness() {
|
||||
let select: Select
|
||||
let terminals: TerminalStub[]
|
||||
|
||||
function boot(): void {
|
||||
function boot(initialData = ''): void {
|
||||
document.body.innerHTML = bodyMarkup()
|
||||
runInThisContext(TERMINAL_DOCUMENT_SCRIPT)
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: JSON.stringify({ type: 'init', cols: 40, rows: 24, initialData: '' })
|
||||
data: JSON.stringify({ type: 'init', cols: 40, rows: 24, initialData })
|
||||
})
|
||||
)
|
||||
// Why: init commits the replacement surface on the next animation frame.
|
||||
@@ -164,6 +164,15 @@ export function useTerminalMouseWebViewHarness() {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: the document sends no mouse report until replayed bytes prove the encoding.
|
||||
function writeLegacyMouseEncoding(): void {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: JSON.stringify({ type: 'write', data: `${ESC}[?1006l` })
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function activeTerminal(): TerminalStub {
|
||||
const terminal = terminals.at(-1)
|
||||
if (!terminal) {
|
||||
@@ -231,6 +240,9 @@ export function useTerminalMouseWebViewHarness() {
|
||||
return {
|
||||
activeTerminal,
|
||||
boot,
|
||||
showAlternateBuffer: () => {
|
||||
buffer.type = 'alternate'
|
||||
},
|
||||
clearPostedMessages: () => postMessage.mockClear(),
|
||||
dispatchPointer,
|
||||
mouseClick,
|
||||
@@ -238,6 +250,7 @@ export function useTerminalMouseWebViewHarness() {
|
||||
postedMessages: () => postedMessages(postMessage),
|
||||
selectionSpy: () => select,
|
||||
terminalInputBytes: () => terminalInputBytes(postMessage),
|
||||
terminalSurface
|
||||
terminalSurface,
|
||||
writeLegacyMouseEncoding
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +148,11 @@ describe('terminal WebView tap routing', () => {
|
||||
it('focuses native input after reporting a touch tap to a mouse-tracking TUI', async () => {
|
||||
const { posted } = boot('interactive prompt', undefined, 'drag')
|
||||
await settle()
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: JSON.stringify({ type: 'write', data: '\u001b[?1006l' })
|
||||
})
|
||||
)
|
||||
|
||||
fireTouch('touchstart', [{ x: 20, y: tapY }])
|
||||
fireTouch('touchend', [])
|
||||
@@ -159,6 +164,17 @@ describe('terminal WebView tap routing', () => {
|
||||
).toEqual(['terminal-input', 'terminal-tap'])
|
||||
})
|
||||
|
||||
it('focuses native input without mouse bytes while the tracking encoding is unproven', async () => {
|
||||
const { posted } = boot('interactive prompt', undefined, 'drag')
|
||||
await settle()
|
||||
|
||||
fireTouch('touchstart', [{ x: 20, y: tapY }])
|
||||
fireTouch('touchend', [])
|
||||
|
||||
expect(posted.find((message) => message.type === 'terminal-input')).toBeUndefined()
|
||||
expect(posted.filter((message) => message.type === 'terminal-tap')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reports a non-mouse touch tap without terminal mouse bytes', async () => {
|
||||
const { posted } = boot('plain prompt')
|
||||
await settle()
|
||||
|
||||
@@ -218,6 +218,11 @@ describe('terminal WebView external pointer wheel scrolling', () => {
|
||||
throw new Error('terminal missing')
|
||||
}
|
||||
terminal.modes.mouseTrackingMode = 'any'
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: JSON.stringify({ type: 'write', data: `${ESC}[?1006l` })
|
||||
})
|
||||
)
|
||||
|
||||
dispatchWheel(CELL_HEIGHT)
|
||||
|
||||
|
||||
@@ -67,11 +67,7 @@ const buildersByAgent = new Map<string, CommandBuilders>([
|
||||
[
|
||||
'claude',
|
||||
{
|
||||
local: (path) =>
|
||||
[true, false].map(
|
||||
(gitBashAvailable) =>
|
||||
getManagedLifecycleHook(path, CLAUDE_HOOK_SETTINGS, { gitBashAvailable }).command
|
||||
),
|
||||
local: (path) => [getManagedLifecycleHook(path, CLAUDE_HOOK_SETTINGS).command],
|
||||
remote: (path) => [getClaudeRemoteCommand(path)]
|
||||
}
|
||||
],
|
||||
|
||||
@@ -69,4 +69,21 @@ describe('managed hook script refresh stays off the main thread', () => {
|
||||
|
||||
expect(syncCallsUnderHome()).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the Windows Claude entry and payload refresh off the main thread', async () => {
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
try {
|
||||
const hooksDir = join(state.home, '.orca', 'agent-hooks')
|
||||
await mkdir(hooksDir, { recursive: true })
|
||||
await writeFile(join(hooksDir, 'claude-hook.cmd'), 'stale', 'utf-8')
|
||||
state.syncCalls = []
|
||||
|
||||
await MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS.find(([agent]) => agent === 'claude')![1]()
|
||||
|
||||
expect(syncCallsUnderHome()).toEqual([])
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -40,6 +40,10 @@ const { homedirMock } = vi.hoisted(() => ({
|
||||
homedirMock: vi.fn<() => string>()
|
||||
}))
|
||||
|
||||
vi.mock('../codex/codex-hook-trust-grant', () => ({
|
||||
grantManagedCodexHookTrust: async () => ({ lane: 'fallback', reason: 'unsupported' })
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: () => '/tmp/orca-user-data'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { chmod, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { grantDirAclAsync, isPermissionError } from '../win32-utils'
|
||||
|
||||
@@ -28,7 +28,7 @@ async function readExistingScript(scriptPath: string): Promise<ExistingScript> {
|
||||
}
|
||||
}
|
||||
|
||||
async function scriptStillExists(scriptPath: string): Promise<boolean> {
|
||||
export async function scriptStillExists(scriptPath: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(scriptPath)
|
||||
return true
|
||||
@@ -61,12 +61,26 @@ async function writeScriptWithAclRetry(scriptPath: string, content: string): Pro
|
||||
export async function refreshManagedScriptIfPresent(
|
||||
scriptPath: string,
|
||||
content: string
|
||||
): Promise<boolean> {
|
||||
return writeManagedScriptAtomically(scriptPath, content, false)
|
||||
}
|
||||
|
||||
// Callers must establish ownership from a surviving managed script or registration first.
|
||||
export async function restoreManagedScript(scriptPath: string, content: string): Promise<void> {
|
||||
await mkdir(dirname(scriptPath), { recursive: true })
|
||||
await writeManagedScriptAtomically(scriptPath, content, true)
|
||||
}
|
||||
|
||||
async function writeManagedScriptAtomically(
|
||||
scriptPath: string,
|
||||
content: string,
|
||||
allowMissing: boolean
|
||||
): Promise<boolean> {
|
||||
const existing = await readExistingScript(scriptPath)
|
||||
if (!existing.exists) {
|
||||
if (!existing.exists && !allowMissing) {
|
||||
return false
|
||||
}
|
||||
if (existing.content === content) {
|
||||
if (existing.exists && existing.content === content) {
|
||||
if (process.platform !== 'win32') {
|
||||
await chmod(scriptPath, 0o755)
|
||||
}
|
||||
@@ -79,7 +93,7 @@ export async function refreshManagedScriptIfPresent(
|
||||
if (process.platform !== 'win32') {
|
||||
await chmod(tmpPath, 0o755)
|
||||
}
|
||||
if (!(await scriptStillExists(scriptPath))) {
|
||||
if (!allowMissing && !(await scriptStillExists(scriptPath))) {
|
||||
return false
|
||||
}
|
||||
await rename(tmpPath, scriptPath)
|
||||
|
||||
@@ -34,6 +34,10 @@ const { homedirMock } = vi.hoisted(() => ({
|
||||
homedirMock: vi.fn<() => string>()
|
||||
}))
|
||||
|
||||
vi.mock('../codex/codex-hook-trust-grant', () => ({
|
||||
grantManagedCodexHookTrust: async () => ({ lane: 'fallback', reason: 'unsupported' })
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: () => '/tmp/orca-user-data'
|
||||
@@ -296,7 +300,7 @@ describe('Windows managed hook stdin structure', () => {
|
||||
expect(script, `${fileName} no ORCA_* guard may route to the more.com drain`).not.toMatch(
|
||||
/ORCA_[A-Z_]+.*goto :?orca_agent_hook_drain_stdin/
|
||||
)
|
||||
// Why: the epilogue stays shared — claude-hook.cmd still jumps to it from the
|
||||
// Why: the epilogue stays shared — claude-hook-impl.cmd still jumps to it from the
|
||||
// Devin-imports-.claude skip, which now sits below these guards.
|
||||
expect(script, `${fileName} drain epilogue`).toContain(
|
||||
[
|
||||
@@ -310,7 +314,7 @@ describe('Windows managed hook stdin structure', () => {
|
||||
// Why (#11549): the Devin skip is the only remaining in-script jump to more.com, so it
|
||||
// must sit below the env guards — otherwise a Devin session outside an Orca pane still
|
||||
// parks there and strands the hook exactly like the pre-fix guards did.
|
||||
const claude = readFileSync(join(hooksDir, 'claude-hook.cmd'), 'utf8')
|
||||
const claude = readFileSync(join(hooksDir, 'claude-hook-impl.cmd'), 'utf8')
|
||||
expect(claude, 'claude devin guard present').toContain(
|
||||
'if not "%DEVIN_PROJECT_DIR%"=="" goto :orca_agent_hook_drain_stdin'
|
||||
)
|
||||
@@ -524,7 +528,7 @@ describe('Windows managed hook stdin structure', () => {
|
||||
// Why: the encoded launcher resolves %USERPROFILE% at run time, so redirecting it is
|
||||
// what makes the script vanish for that shape. The direct launcher (#18875) carries
|
||||
// an absolute path, so here it asserts only that a bogus profile changes nothing; its
|
||||
// missing-script fallback is covered live in windows-direct-cmd-hook-command.test.ts.
|
||||
// missing-entry failure (never exit 2) is covered live in windows-direct-cmd-hook-command.test.ts.
|
||||
name: 'missing managed script',
|
||||
env: hookEnvironment({ USERPROFILE: absentProfile })
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
// Why (#18875): the registered Windows Claude hook is now the script path itself, so this file
|
||||
// pins the two things that make that safe — the shape carries nothing MSYS or cmd.exe rewrites,
|
||||
// and it still answers with neutral JSON when the script is gone. The live legs run the string
|
||||
// through BOTH hosts Claude Code can pick, because the shape has to parse in either.
|
||||
// The registered command must not depend on the shell Claude selects.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { runProcess } from '../../shared/child-process/run-process'
|
||||
import { existsSync, mkdtempSync, readdirSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { removeTreeSync } from '../../shared/windows-transient-lock-removal'
|
||||
import { WINDOWS_CMD_SAFE_PATH } from './installer-utils'
|
||||
import { wrapWindowsDirectCmdHookCommand } from './windows-direct-cmd-hook-command'
|
||||
import { getWindowsClaudeHookEntry } from '../claude/windows-hook-files'
|
||||
import { getManagedScript } from '../claude/hook-script'
|
||||
import { getWindowsPowerShellExecutablePath } from './windows-powershell-hook-launcher'
|
||||
import { findGitBash } from './windows-git-bash-path.test-fixture'
|
||||
|
||||
const SAFE_PATH = 'C:\\Users\\alice\\.orca\\agent-hooks\\claude-hook.cmd'
|
||||
|
||||
describe('wrapWindowsDirectCmdHookCommand', () => {
|
||||
it('emits the script path with forward slashes and a neutral-JSON fallback', () => {
|
||||
it('emits an operator-free path that PowerShell 5.1 can parse', () => {
|
||||
expect(wrapWindowsDirectCmdHookCommand(SAFE_PATH)).toBe(
|
||||
'C:/Users/alice/.orca/agent-hooks/claude-hook.cmd || echo {}'
|
||||
'C:/Users/alice/.orca/agent-hooks/claude-hook.cmd'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -33,6 +33,7 @@ describe('wrapWindowsDirectCmdHookCommand', () => {
|
||||
// Why: `2>nul` writes a literal file named `nul` into the cwd under MSYS (measured), and no
|
||||
// stderr sink parses in both hosts. The missing-script line is left on stderr deliberately.
|
||||
expect(command).not.toContain('2>')
|
||||
expect(command).not.toMatch(/[|&;]/)
|
||||
})
|
||||
|
||||
it('declines any path the shells cannot carry bare', () => {
|
||||
@@ -53,91 +54,142 @@ describe('wrapWindowsDirectCmdHookCommand', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(process.platform !== 'win32')('direct hook command, run by both hook hosts', () => {
|
||||
// Why: the fixture throws when Git Bash is absent, and that is a skip here, not a failure —
|
||||
// a box without it never gets this command shape in the first place.
|
||||
const gitBash = ((): string | null => {
|
||||
try {
|
||||
return findGitBash()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
|
||||
function runInCmd(command: string, cwd: string): { stdout: string; status: number } {
|
||||
return runCapture('cmd.exe', ['/d', '/c', command], cwd)
|
||||
}
|
||||
|
||||
function runInBash(command: string, cwd: string): { stdout: string; status: number } {
|
||||
return runCapture(gitBash!, ['-c', command], cwd)
|
||||
}
|
||||
|
||||
function runCapture(file: string, args: string[], cwd: string) {
|
||||
try {
|
||||
const stdout = execFileSync(file, args, {
|
||||
cwd,
|
||||
input: '{"hook_event_name":"PreToolUse"}',
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
})
|
||||
return { stdout, status: 0 }
|
||||
} catch (error) {
|
||||
const failure = error as { stdout?: string; status?: number }
|
||||
return { stdout: failure.stdout ?? '', status: failure.status ?? 1 }
|
||||
}
|
||||
}
|
||||
|
||||
// Why: a runner whose TEMP sits under a profile with a space is the encoded-launcher case,
|
||||
// so these legs skip rather than assert a contract that shape never claimed.
|
||||
const tempIsCmdSafe = WINDOWS_CMD_SAFE_PATH.test(join(tmpdir(), 'orca-direct-hook-x', 'x.cmd'))
|
||||
const canRunLive = Boolean(gitBash) && tempIsCmdSafe
|
||||
|
||||
function withTempDir(run: (dir: string, scriptPath: string, command: string) => void): void {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-direct-hook-'))
|
||||
try {
|
||||
const scriptPath = join(dir, 'claude-hook.cmd')
|
||||
const command = wrapWindowsDirectCmdHookCommand(scriptPath)
|
||||
expect(command, 'precondition: temp path must be cmd-safe').not.toBeNull()
|
||||
run(dir, scriptPath, command!)
|
||||
} finally {
|
||||
// Why: cmd.exe/bash have just exited in this tree; a raw recursive rm throws EPERM on
|
||||
// Windows while their handles drain.
|
||||
removeTreeSync(dir)
|
||||
}
|
||||
}
|
||||
|
||||
it.skipIf(!canRunLive)('answers {} and exit 0 in both hosts when the script exists', () => {
|
||||
withTempDir((dir, scriptPath, command) => {
|
||||
writeFileSync(scriptPath, '@echo off\r\necho {}\r\nexit /b 0\r\n', 'utf8')
|
||||
for (const result of [runInCmd(command, dir), runInBash(command, dir)]) {
|
||||
expect(result.stdout.trim()).toBe('{}')
|
||||
expect(result.status).toBe(0)
|
||||
describe.skipIf(process.platform !== 'win32')(
|
||||
'direct hook command, run by Windows hook hosts',
|
||||
() => {
|
||||
// Git Bash is optional; cmd.exe and Windows PowerShell still exercise the command without it.
|
||||
const gitBash = ((): string | null => {
|
||||
try {
|
||||
return findGitBash()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
})
|
||||
})()
|
||||
|
||||
it.skipIf(!canRunLive)(
|
||||
'still answers {} and exit 0 in both hosts when the script is gone',
|
||||
() => {
|
||||
// Why: compat consumers require neutral JSON even with no managed script (#14818). The
|
||||
// encoded launcher did this with a Test-Path; `|| echo {}` does it with no interpreter.
|
||||
withTempDir((dir, scriptPath, command) => {
|
||||
expect(existsSync(scriptPath)).toBe(false)
|
||||
for (const result of [runInCmd(command, dir), runInBash(command, dir)]) {
|
||||
expect(result.stdout.trim()).toBe('{}')
|
||||
expect(result.status).toBe(0)
|
||||
}
|
||||
})
|
||||
async function runInHosts(command: string, cwd: string) {
|
||||
const hosts: [string, string[]][] = [
|
||||
['cmd.exe', ['/d', '/c', command]],
|
||||
[getWindowsPowerShellExecutablePath(), ['-NoProfile', '-Command', command]]
|
||||
]
|
||||
if (gitBash) {
|
||||
hosts.push([gitBash, ['-c', command]])
|
||||
}
|
||||
const pwsh = join(
|
||||
process.env.ProgramFiles ?? 'C:\\Program Files',
|
||||
'PowerShell',
|
||||
'7',
|
||||
'pwsh.exe'
|
||||
)
|
||||
if (existsSync(pwsh)) {
|
||||
hosts.push([pwsh, ['-NoProfile', '-Command', command]])
|
||||
}
|
||||
const results: Awaited<ReturnType<typeof runCapture>>[] = []
|
||||
for (const [file, args] of hosts) {
|
||||
results.push(await runCapture(file, args, cwd))
|
||||
}
|
||||
return results
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(!canRunLive)('leaves no stray `nul` file behind in the working directory', () => {
|
||||
// Why this is worth a test: adding `2>nul` to silence the missing-script line looks like
|
||||
// tidy-up, but under MSYS it creates a real file named `nul` in the cwd — which is the
|
||||
// user's repo. Measured on Windows 11. Keep stderr unredirected.
|
||||
withTempDir((dir, _scriptPath, command) => {
|
||||
runInBash(command, dir)
|
||||
expect(readdirSync(dir)).not.toContain('nul')
|
||||
})
|
||||
})
|
||||
})
|
||||
// Why async runProcess: runProcessSync cannot take a string stdin (it forces encoding 'buffer').
|
||||
async function runCapture(file: string, args: string[], cwd: string) {
|
||||
const result = await runProcess({
|
||||
program: file,
|
||||
args,
|
||||
cwd,
|
||||
// Why PATHEXT: without it Windows PowerShell 5.1 prints nothing and exits 0 for a .cmd path
|
||||
// (measured); every real hook host inherits it.
|
||||
env: {
|
||||
SystemRoot: process.env.SystemRoot,
|
||||
PATH: process.env.PATH,
|
||||
PATHEXT: process.env.PATHEXT,
|
||||
ComSpec: process.env.ComSpec,
|
||||
HOME: cwd,
|
||||
USERPROFILE: cwd
|
||||
},
|
||||
input: '{"hook_event_name":"PreToolUse"}',
|
||||
timeoutMs: 5_000
|
||||
})
|
||||
expect(result.timedOut, result.stderr).toBe(false)
|
||||
return { stdout: result.stdout, status: result.code, label: `${file}: ${result.stderr}` }
|
||||
}
|
||||
|
||||
// Why: a runner whose TEMP sits under a profile with a space is the encoded-launcher case,
|
||||
// so these legs skip rather than assert a contract that shape never claimed.
|
||||
const tempIsCmdSafe = WINDOWS_CMD_SAFE_PATH.test(join(tmpdir(), 'orca-direct-hook-x', 'x.cmd'))
|
||||
const canRunLive = tempIsCmdSafe
|
||||
|
||||
async function withTempDir(
|
||||
run: (dir: string, scriptPath: string, command: string) => Promise<void>
|
||||
): Promise<void> {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-direct-hook-'))
|
||||
try {
|
||||
const scriptPath = join(dir, 'claude-hook.cmd')
|
||||
const command = wrapWindowsDirectCmdHookCommand(scriptPath)
|
||||
expect(command, 'precondition: temp path must be cmd-safe').not.toBeNull()
|
||||
await run(dir, scriptPath, command!)
|
||||
} finally {
|
||||
// Why: cmd.exe/bash have just exited in this tree; a raw recursive rm throws EPERM on
|
||||
// Windows while their handles drain.
|
||||
removeTreeSync(dir)
|
||||
}
|
||||
}
|
||||
|
||||
it.skipIf(!canRunLive)(
|
||||
'answers {} and exit 0 in both hosts when the script exists',
|
||||
async () => {
|
||||
await withTempDir(async (dir, scriptPath, command) => {
|
||||
writeFileSync(scriptPath, '@echo off\r\necho {}\r\nexit /b 0\r\n', 'utf8')
|
||||
for (const result of await runInHosts(command, dir)) {
|
||||
expect(result.stdout.trim(), result.label).toBe('{}')
|
||||
expect(result.status, result.label).toBe(0)
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(!canRunLive)(
|
||||
'runs the generated pair and answers when its payload is missing',
|
||||
async () => {
|
||||
await withTempDir(async (dir, scriptPath, command) => {
|
||||
writeFileSync(scriptPath, getWindowsClaudeHookEntry(), 'utf8')
|
||||
for (const result of await runInHosts(command, dir)) {
|
||||
expect(result.stdout.trim(), result.label).toBe('{}')
|
||||
expect(result.status, result.label).toBe(0)
|
||||
}
|
||||
writeFileSync(join(dir, 'claude-hook-impl.cmd'), getManagedScript(), 'utf8')
|
||||
for (const result of await runInHosts(command, dir)) {
|
||||
expect(result.stdout.trim(), result.label).toBe('{}')
|
||||
expect(result.status, result.label).toBe(0)
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(!canRunLive)(
|
||||
'reports a non-blocking failure, never exit 2, when the entry is gone',
|
||||
async () => {
|
||||
await withTempDir(async (dir, scriptPath, command) => {
|
||||
expect(existsSync(scriptPath)).toBe(false)
|
||||
for (const result of await runInHosts(command, dir)) {
|
||||
expect(result.stdout.trim(), result.label).toBe('')
|
||||
expect(result.status, result.label).toBeGreaterThan(0)
|
||||
expect(result.status, result.label).not.toBe(2)
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(!canRunLive)(
|
||||
'leaves no stray `nul` file behind in the working directory',
|
||||
async () => {
|
||||
// Why this is worth a test: adding `2>nul` to silence the missing-script line looks like
|
||||
// tidy-up, but under MSYS it creates a real file named `nul` in the cwd — which is the
|
||||
// user's repo. Measured on Windows 11. Keep stderr unredirected.
|
||||
await withTempDir(async (dir, _scriptPath, command) => {
|
||||
await runInHosts(command, dir)
|
||||
expect(readdirSync(dir)).not.toContain('nul')
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,30 +1,13 @@
|
||||
import { WINDOWS_CMD_SAFE_PATH } from './installer-utils'
|
||||
|
||||
// Why: a drive-letter path only. WINDOWS_CMD_SAFE_PATH also admits a UNC profile, and
|
||||
// `//server/share/...` is not a command cmd.exe reliably starts.
|
||||
// UNC paths cannot be started consistently by all Windows hook hosts.
|
||||
const WINDOWS_DRIVE_LETTER_PATH = /^[A-Za-z]:\\/
|
||||
|
||||
/**
|
||||
* Shortest launcher for a managed Windows `.cmd` hook: the script path itself (#18875).
|
||||
*
|
||||
* The encoded PowerShell launcher spent a full interpreter start-up per hook event to reach a
|
||||
* script that exits at its first `ORCA_PANE_KEY` guard, and left a stdout-holding orphan behind
|
||||
* when the hook's timeout kill landed. Measurements and the EDR trade are in
|
||||
* `docs/reference/windows-edr-posture.md`.
|
||||
*
|
||||
* Returns null when the caller must keep the encoded launcher: a path either shell would mangle.
|
||||
*/
|
||||
/** A bare path works without guessing whether Claude selected Bash or PowerShell. */
|
||||
export function wrapWindowsDirectCmdHookCommand(scriptPath: string): string | null {
|
||||
if (!WINDOWS_CMD_SAFE_PATH.test(scriptPath) || !WINDOWS_DRIVE_LETTER_PATH.test(scriptPath)) {
|
||||
return null
|
||||
}
|
||||
// Why: forward slashes are the one separator both hosts read, and no token here is a switch
|
||||
// MSYS can rewrite — a literal `cmd.exe /d /c <path>` does not survive Git Bash (measured).
|
||||
const invocation = scriptPath.replaceAll('\\', '/')
|
||||
// Why: neutral JSON when the script is missing (#14818), with no interpreter to Test-Path with.
|
||||
// Valid in bash and cmd.exe; PowerShell 5.1 rejects `||`, which is what gates this on Git Bash.
|
||||
// It also fires when cmd.exe itself exits non-zero (a failing AutoRun), printing `{}` twice —
|
||||
// on that same box the encoded launcher exited 1 instead, so neither shape is clean there.
|
||||
// Stderr stays unredirected: `2>nul` writes a literal `nul` file into the cwd under MSYS.
|
||||
return `${invocation} || echo {}`
|
||||
// The script owns failure handling; PowerShell 5.1 cannot parse a shell-level `||`.
|
||||
return scriptPath.replaceAll('\\', '/')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { runProcess } from '../../shared/child-process/run-process'
|
||||
import { getWindowsManagedLifecycleHook } from '../claude/hook-settings'
|
||||
|
||||
const command = getWindowsManagedLifecycleHook(
|
||||
'C:\\Users\\alice\\.orca\\agent-hooks\\claude-hook.cmd'
|
||||
).command
|
||||
const input = '{"message":"café 日本語 & %PATH%", "hook_event_name":"Stop"}'
|
||||
|
||||
// A POSIX fixture at the exact registered spelling exercises tokenization, not Windows batch execution.
|
||||
describe.skipIf(process.platform === 'win32')('Windows command under available POSIX hosts', () => {
|
||||
// PowerShell parsing is covered by the Windows-only host legs in windows-direct-cmd-hook-command.test.ts.
|
||||
for (const shell of ['/bin/bash', '/bin/zsh']) {
|
||||
it.skipIf(!existsSync(shell))(
|
||||
`preserves invocation and reports a missing entry without exit 2: ${shell}`,
|
||||
async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'claude-command-host-'))
|
||||
const fixture = join(root, 'C:/Users/alice/.orca/agent-hooks/claude-hook.cmd')
|
||||
const run = () =>
|
||||
runProcess({
|
||||
program: '/usr/bin/env',
|
||||
args: ['-i', `HOME=${root}`, 'PATH=/usr/bin:/bin', shell, '-c', command],
|
||||
cwd: root,
|
||||
env: {},
|
||||
input,
|
||||
timeoutMs: 5_000
|
||||
})
|
||||
try {
|
||||
mkdirSync(dirname(fixture), { recursive: true })
|
||||
writeFileSync(fixture, '#!/bin/sh\nprintf "{}\\n"\ncat > payload.txt\nexit 0\n', {
|
||||
mode: 0o755
|
||||
})
|
||||
const present = await run()
|
||||
expect(present.code, present.stderr).toBe(0)
|
||||
expect(present.stdout.trim()).toBe('{}')
|
||||
expect(readFileSync(join(root, 'payload.txt'), 'utf8')).toBe(input)
|
||||
rmSync(fixture)
|
||||
const missing = await run()
|
||||
expect(missing.timedOut).toBe(false)
|
||||
expect(missing.code).toBeGreaterThan(0)
|
||||
expect(missing.code).not.toBe(2)
|
||||
expect(missing.stdout.trim()).toBe('')
|
||||
expect(missing.stderr).not.toBe('')
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -15,6 +15,10 @@ const { homedirMock } = vi.hoisted(() => ({
|
||||
homedirMock: vi.fn<() => string>()
|
||||
}))
|
||||
|
||||
vi.mock('../codex/codex-hook-trust-grant', () => ({
|
||||
grantManagedCodexHookTrust: async () => ({ lane: 'fallback', reason: 'unsupported' })
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: () => '/tmp/orca-user-data'
|
||||
|
||||
@@ -42,8 +42,7 @@ export function getWindowsPowerShellExecutablePath(): string {
|
||||
*
|
||||
* #18875 took that answer for the Claude lifecycle hook, which now registers the
|
||||
* managed `.cmd` path directly (`windows-direct-cmd-hook-command.ts`) and reaches
|
||||
* this launcher only when the profile path is not cmd-safe or Git Bash is not
|
||||
* resolvable. Every other caller still comes through here on every event.
|
||||
* this launcher only when the profile path is not cmd-safe. Every other caller still comes through here on every event.
|
||||
*/
|
||||
export const WINDOWS_POWERSHELL_HOOK_SWITCHES = '-NoProfile'
|
||||
|
||||
|
||||
@@ -16,6 +16,12 @@ import {
|
||||
buildWindowsHookStdinDrainEpilogue
|
||||
} from '../agent-hooks/hook-stdin-contract'
|
||||
|
||||
// Why: a backgrounded session runs in a daemon worker that inherited the dispatching
|
||||
// pane's env, so ORCA_PANE_KEY names a pane this session does not run in (#9236).
|
||||
// Why exit, not the drain label: the drain parks in more.com and a worker is outside
|
||||
// an Orca pane — the abandoned-stdin hang #11549 guards against.
|
||||
export const WINDOWS_CLAUDE_BACKGROUND_JOB_GUARD = 'if not "%CLAUDE_JOB_DIR%"=="" exit /b 0'
|
||||
|
||||
export function getManagedScript(
|
||||
target: 'local' | 'posix' = 'local',
|
||||
options: {
|
||||
@@ -36,11 +42,7 @@ export function getManagedScript(
|
||||
// Why (#11549): the env guards must outrank the Devin skip — the Devin skip parks in more.com,
|
||||
// and outside an Orca pane the caller can abandon stdin, so more.com never returns.
|
||||
...buildWindowsHookEnvironmentGuardLines(),
|
||||
// Why: a backgrounded session runs in a daemon worker that inherited the dispatching
|
||||
// pane's env, so ORCA_PANE_KEY names a pane this session does not run in (#9236).
|
||||
// Why exit, not the drain label: the drain parks in more.com and a worker is outside
|
||||
// an Orca pane — the abandoned-stdin hang #11549 guards against.
|
||||
'if not "%CLAUDE_JOB_DIR%"=="" exit /b 0',
|
||||
WINDOWS_CLAUDE_BACKGROUND_JOB_GUARD,
|
||||
...(options.skipWhenGrokImportsClaude ? buildWindowsGrokReplayGuardLines() : []),
|
||||
...(options.skipWhenDevinImportsClaude
|
||||
? [
|
||||
|
||||
@@ -15,8 +15,7 @@ vi.mock('electron', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
// Why: the installed hook shape depends on whether Git Bash is resolvable on the host, so the
|
||||
// install assertions below have to state which host they describe rather than inherit the box's.
|
||||
// Mock discovery so registration can be checked with Git Bash both present and absent.
|
||||
const { gitBashAvailableMock } = vi.hoisted(() => ({ gitBashAvailableMock: { value: true } }))
|
||||
vi.mock('../git-bash', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof GitBashModule>()),
|
||||
@@ -55,10 +54,10 @@ describe('getWindowsManagedLifecycleHook', () => {
|
||||
// Why this is the whole point: the encoded launcher spent a PowerShell start-up per hook
|
||||
// event (471ms vs 201ms measured) before the .cmd could reach its ORCA_PANE_KEY guard, and
|
||||
// its orphan outlived the hook's timeout kill still holding the stdout the agent reads.
|
||||
const hook = getWindowsManagedLifecycleHook(SAFE_SCRIPT_PATH, { gitBashAvailable: true })
|
||||
const hook = getWindowsManagedLifecycleHook(SAFE_SCRIPT_PATH)
|
||||
|
||||
expect(hook.args).toBeUndefined()
|
||||
expect(hook.command).toBe('C:/Users/alice/.orca/agent-hooks/claude-hook.cmd || echo {}')
|
||||
expect(hook.command).toBe('C:/Users/alice/.orca/agent-hooks/claude-hook.cmd')
|
||||
expect(hook.command).not.toMatch(/powershell|-EncodedCommand|conhost/i)
|
||||
// Why: Git Bash/MSYS mangles backslash paths and rewrites slash-prefixed switches.
|
||||
expect(hook.command).not.toMatch(/\\/)
|
||||
@@ -66,7 +65,7 @@ describe('getWindowsManagedLifecycleHook', () => {
|
||||
})
|
||||
|
||||
it('falls back to the encoded launcher when the profile path is not cmd-safe', () => {
|
||||
const hook = getWindowsManagedLifecycleHook(UNSAFE_SCRIPT_PATH, { gitBashAvailable: true })
|
||||
const hook = getWindowsManagedLifecycleHook(UNSAFE_SCRIPT_PATH)
|
||||
|
||||
expect(hook.args).toBeUndefined()
|
||||
expect(hook.command).toMatch(/\/powershell\.exe -NoProfile -EncodedCommand /)
|
||||
@@ -79,18 +78,21 @@ describe('getWindowsManagedLifecycleHook', () => {
|
||||
expect(decoded).toContain('.orca\\agent-hooks\\claude-hook.cmd')
|
||||
})
|
||||
|
||||
it('falls back to the encoded launcher when Git Bash is not resolvable', () => {
|
||||
// Why: without Git Bash, Claude Code hosts the hook in PowerShell, and PowerShell 5.1
|
||||
// rejects `||` as a statement separator (measured) — every event would be a parse error.
|
||||
const hook = getWindowsManagedLifecycleHook(SAFE_SCRIPT_PATH, { gitBashAvailable: false })
|
||||
|
||||
expect(hook.command).toMatch(/\/powershell\.exe -NoProfile -EncodedCommand /)
|
||||
it('does not consult Git Bash availability', () => {
|
||||
gitBashAvailableMock.value = false
|
||||
try {
|
||||
expect(getWindowsManagedLifecycleHook(SAFE_SCRIPT_PATH).command).toBe(
|
||||
'C:/Users/alice/.orca/agent-hooks/claude-hook.cmd'
|
||||
)
|
||||
} finally {
|
||||
gitBashAvailableMock.value = true
|
||||
}
|
||||
})
|
||||
|
||||
it('is still recognized as managed by createManagedCommandMatcher (#14825)', () => {
|
||||
for (const hook of [
|
||||
getWindowsManagedLifecycleHook(SAFE_SCRIPT_PATH, { gitBashAvailable: true }),
|
||||
getWindowsManagedLifecycleHook(SAFE_SCRIPT_PATH, { gitBashAvailable: false })
|
||||
getWindowsManagedLifecycleHook(SAFE_SCRIPT_PATH),
|
||||
getWindowsManagedLifecycleHook(UNSAFE_SCRIPT_PATH)
|
||||
]) {
|
||||
expect(isClaudeManagedCommand(hook.command)).toBe(true)
|
||||
}
|
||||
@@ -242,7 +244,7 @@ describe('ClaudeHookService.install', () => {
|
||||
// Why: POSIX resolves the profile at runtime (`${HOME-}`, STA-3348). Windows cannot —
|
||||
// no single token expands in both Git Bash and cmd.exe — so it registers the absolute
|
||||
// path, as Codex/Grok/Devin/Antigravity already do (#18875). A moved profile is caught
|
||||
// by getStatus's exact match and rewritten, and `|| echo {}` keeps a stale entry neutral.
|
||||
// by getStatus's exact match and rewritten; a deleted entry reports an ordinary hook error.
|
||||
if (process.platform !== 'win32') {
|
||||
expect(JSON.stringify(managedHook)).not.toContain(tmpHome.replaceAll('\\', '/'))
|
||||
}
|
||||
@@ -258,7 +260,12 @@ describe('ClaudeHookService.install', () => {
|
||||
true
|
||||
)
|
||||
const managedScript = readFileSync(
|
||||
join(tmpHome, '.orca', 'agent-hooks', CLAUDE_SCRIPT_FILE_NAME),
|
||||
join(
|
||||
tmpHome,
|
||||
'.orca',
|
||||
'agent-hooks',
|
||||
process.platform === 'win32' ? 'claude-hook-impl.cmd' : CLAUDE_SCRIPT_FILE_NAME
|
||||
),
|
||||
'utf-8'
|
||||
)
|
||||
expect(managedScript).toContain('DEVIN_PROJECT_DIR')
|
||||
@@ -506,7 +513,7 @@ describe('ClaudeHookService.install', () => {
|
||||
readFileSync(join(tmpHome, '.claude', 'settings.json'), 'utf-8')
|
||||
) as { hooks: Record<string, { hooks: TestHook[] }[]> }
|
||||
|
||||
const expected = `${scriptPath.replaceAll('\\', '/')} || echo {}`
|
||||
const expected = scriptPath.replaceAll('\\', '/')
|
||||
for (const { eventName } of CLAUDE_EVENTS) {
|
||||
const hook = settings.hooks[eventName]?.[0]?.hooks?.[0]
|
||||
expect(hook?.args, eventName).toBeUndefined()
|
||||
@@ -537,7 +544,9 @@ describe('ClaudeHookService.install', () => {
|
||||
try {
|
||||
const settingsPath = join(tmpHome, '.claude', 'settings.json')
|
||||
mkdirSync(join(tmpHome, '.claude'), { recursive: true })
|
||||
const stale = getWindowsManagedLifecycleHook(scriptPath, { gitBashAvailable: false })
|
||||
const stale = getWindowsManagedLifecycleHook(
|
||||
'C:\\Users\\%name%\\.orca\\agent-hooks\\claude-hook.cmd'
|
||||
)
|
||||
writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
@@ -605,9 +614,7 @@ describe('ClaudeHookService.install', () => {
|
||||
hooks: Record<string, { hooks: TestHook[] }[]>
|
||||
}
|
||||
expect(JSON.stringify(settings.hooks)).not.toContain('someone-else')
|
||||
expect(settings.hooks.PreToolUse[0].hooks[0].command).toBe(
|
||||
`${scriptPath.replaceAll('\\', '/')} || echo {}`
|
||||
)
|
||||
expect(settings.hooks.PreToolUse[0].hooks[0].command).toBe(scriptPath.replaceAll('\\', '/'))
|
||||
expect(new ClaudeHookService().getStatus().state).toBe('installed')
|
||||
} finally {
|
||||
vi.unstubAllEnvs()
|
||||
@@ -625,7 +632,7 @@ describe('ClaudeHookService.install', () => {
|
||||
try {
|
||||
expect(new ClaudeHookService().install().state).toBe('installed')
|
||||
const script = readFileSync(
|
||||
join(tmpHome, '.orca', 'agent-hooks', CLAUDE_SCRIPT_FILE_NAME),
|
||||
join(tmpHome, '.orca', 'agent-hooks', 'claude-hook-impl.cmd'),
|
||||
'utf-8'
|
||||
)
|
||||
expect(script).toContain('%SystemRoot%\\System32\\curl.exe')
|
||||
@@ -705,7 +712,10 @@ describe('backgrounded-session pane guard (#9236)', () => {
|
||||
vi.stubEnv('USERPROFILE', tmpHome)
|
||||
try {
|
||||
expect(new ClaudeHookService().install().state).toBe('installed')
|
||||
const script = readFileSync(join(tmpHome, '.orca', 'agent-hooks', 'claude-hook.cmd'), 'utf-8')
|
||||
const script = readFileSync(
|
||||
join(tmpHome, '.orca', 'agent-hooks', 'claude-hook-impl.cmd'),
|
||||
'utf-8'
|
||||
)
|
||||
const guard = script.split('\r\n').find((line) => line.includes('CLAUDE_JOB_DIR'))
|
||||
expect(guard).toBe('if not "%CLAUDE_JOB_DIR%"=="" exit /b 0')
|
||||
// Why: the drain parks in more.com, and a daemon worker is exactly the
|
||||
|
||||
@@ -16,6 +16,11 @@ import {
|
||||
} from '../agent-hooks/installer-utils-remote'
|
||||
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
|
||||
import { getManagedScript } from './hook-script'
|
||||
import {
|
||||
getWindowsClaudeHookFileStatus,
|
||||
installWindowsClaudeHookFiles,
|
||||
refreshWindowsClaudeHookFiles
|
||||
} from './windows-hook-files'
|
||||
|
||||
export { getManagedScript }
|
||||
import { getManagedStatusLineScript } from './statusline-script'
|
||||
@@ -72,6 +77,18 @@ export class ClaudeHookService {
|
||||
this.options = options
|
||||
}
|
||||
|
||||
private get usesWindowsEntry(): boolean {
|
||||
return process.platform === 'win32' && this.options.agent === 'claude'
|
||||
}
|
||||
|
||||
private managedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
return getManagedScript(target, {
|
||||
source: this.options.source,
|
||||
skipWhenDevinImportsClaude: this.options.agent === 'claude',
|
||||
skipWhenGrokImportsClaude: this.options.agent === 'claude'
|
||||
})
|
||||
}
|
||||
|
||||
// Why: Claude's settings loader rejects events newer than the running CLI, so its plan follows the
|
||||
// resolved version; OpenClaude and Qoder read their own settings files.
|
||||
private managedHookPlan(options: ClaudeHookInstallOptions): ClaudeManagedHookPlan {
|
||||
@@ -124,18 +141,16 @@ export class ClaudeHookService {
|
||||
state = 'partial'
|
||||
detail = `Managed hook missing for events: ${missing.join(', ')}`
|
||||
}
|
||||
return { agent: this.options.agent, state, configPath, managedHooksPresent, detail }
|
||||
const status = { agent: this.options.agent, state, configPath, managedHooksPresent, detail }
|
||||
return this.usesWindowsEntry ? getWindowsClaudeHookFileStatus(status, scriptPath) : status
|
||||
}
|
||||
|
||||
async refreshManagedScripts(): Promise<void> {
|
||||
await refreshManagedScriptIfPresent(
|
||||
getManagedScriptPath(this.options.settings),
|
||||
getManagedScript('local', {
|
||||
source: this.options.source,
|
||||
skipWhenDevinImportsClaude: this.options.agent === 'claude',
|
||||
skipWhenGrokImportsClaude: this.options.agent === 'claude'
|
||||
})
|
||||
)
|
||||
const scriptPath = getManagedScriptPath(this.options.settings)
|
||||
const payload = this.managedScript()
|
||||
await (this.usesWindowsEntry
|
||||
? refreshWindowsClaudeHookFiles(scriptPath, payload)
|
||||
: refreshManagedScriptIfPresent(scriptPath, payload))
|
||||
// Why: no agent gate — the statusline script only ever exists for claude, so presence is the gate.
|
||||
await refreshManagedScriptIfPresent(
|
||||
getStatusLineScriptPath(this.options.settings),
|
||||
@@ -165,14 +180,12 @@ export class ClaudeHookService {
|
||||
getManagedScriptFileName(this.options.settings),
|
||||
plan
|
||||
)
|
||||
writeManagedScript(
|
||||
scriptPath,
|
||||
getManagedScript('local', {
|
||||
source: this.options.source,
|
||||
skipWhenDevinImportsClaude: this.options.agent === 'claude',
|
||||
skipWhenGrokImportsClaude: this.options.agent === 'claude'
|
||||
})
|
||||
)
|
||||
const payload = this.managedScript()
|
||||
if (this.usesWindowsEntry) {
|
||||
installWindowsClaudeHookFiles(scriptPath, payload)
|
||||
} else {
|
||||
writeManagedScript(scriptPath, payload)
|
||||
}
|
||||
if (plan.statusLine === 'install') {
|
||||
nextConfig = this.installManagedStatusLine(nextConfig)
|
||||
} else if (plan.statusLine === 'retire') {
|
||||
@@ -257,15 +270,7 @@ export class ClaudeHookService {
|
||||
|
||||
// Why: write scripts before settings to avoid settings pointing to missing scripts.
|
||||
// Why: SSH scripts always use POSIX .sh paths, regardless of the local OS.
|
||||
await writeManagedScriptRemote(
|
||||
sftp,
|
||||
remoteScriptPath,
|
||||
getManagedScript('posix', {
|
||||
source: this.options.source,
|
||||
skipWhenDevinImportsClaude: this.options.agent === 'claude',
|
||||
skipWhenGrokImportsClaude: this.options.agent === 'claude'
|
||||
})
|
||||
)
|
||||
await writeManagedScriptRemote(sftp, remoteScriptPath, this.managedScript('posix'))
|
||||
// Why: no statusline install here — this path serves SSH remotes and WSL guests, whose relay hook
|
||||
// listener doesn't route /statusline/claude, and an SSH box's Claude login can be a different
|
||||
// account than the locally selected one, so its usage must not feed the local bar (live feed is host-local only).
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
import { quotePowerShellLiteral } from '../../shared/powershell-native-argument'
|
||||
import { wrapRuntimeHomeHookCommand } from '../agent-hooks/runtime-home-hook-command'
|
||||
import { wrapWindowsDirectCmdHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command'
|
||||
import { isGitBashAvailable } from '../git-bash'
|
||||
import type { ClaudeManagedHookPlan } from './claude-managed-hook-events'
|
||||
|
||||
export type ClaudeCompatibleHookSettings = {
|
||||
@@ -91,8 +90,7 @@ export function getManagedCommand(
|
||||
|
||||
export function getManagedLifecycleHook(
|
||||
scriptPath: string,
|
||||
settings = CLAUDE_HOOK_SETTINGS,
|
||||
options: WindowsManagedLifecycleHookOptions = {}
|
||||
settings = CLAUDE_HOOK_SETTINGS
|
||||
): HookCommandConfig {
|
||||
if (process.platform !== 'win32' || !settings.usesWindowsCompatLauncher) {
|
||||
return buildManagedCommandHook(getManagedCommand(scriptPath, { neutralJsonWhenMissing: true }))
|
||||
@@ -105,22 +103,12 @@ export function getManagedLifecycleHook(
|
||||
timeout: MANAGED_HOOK_TIMEOUT_SECONDS
|
||||
}
|
||||
}
|
||||
return getWindowsManagedLifecycleHook(scriptPath, options)
|
||||
return getWindowsManagedLifecycleHook(scriptPath)
|
||||
}
|
||||
|
||||
export type WindowsManagedLifecycleHookOptions = { gitBashAvailable?: boolean }
|
||||
|
||||
// Why: some Claude-compatible consumers ignore `args`, so the invocation must be self-contained.
|
||||
export function getWindowsManagedLifecycleHook(
|
||||
scriptPath: string,
|
||||
options: WindowsManagedLifecycleHookOptions = {}
|
||||
): HookCommandConfig {
|
||||
// Why (#18875): the encoded launcher cost a PowerShell start-up per hook event. Take the direct
|
||||
// path only where the host can parse `||` — Git Bash can, Windows PowerShell 5.1 cannot.
|
||||
const directCommand =
|
||||
(options.gitBashAvailable ?? isGitBashAvailable())
|
||||
? wrapWindowsDirectCmdHookCommand(scriptPath)
|
||||
: null
|
||||
// Some compatible consumers ignore args, so keep the invocation self-contained.
|
||||
export function getWindowsManagedLifecycleHook(scriptPath: string): HookCommandConfig {
|
||||
const directCommand = wrapWindowsDirectCmdHookCommand(scriptPath)
|
||||
if (directCommand) {
|
||||
return { type: 'command', command: directCommand, timeout: MANAGED_HOOK_TIMEOUT_SECONDS }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import type * as osModule from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import * as refresh from '../agent-hooks/managed-hook-script-refresh'
|
||||
import { readHooksJson, writeHooksJson } from '../agent-hooks/installer-utils'
|
||||
import { ClaudeHookService } from './hook-service'
|
||||
import { getWindowsManagedLifecycleHook } from './hook-settings'
|
||||
import { getWindowsClaudeHookEntry, getWindowsClaudeHookPayloadPath } from './windows-hook-files'
|
||||
import { codebuddyHookService } from '../codebuddy/hook-service'
|
||||
import { qoderHookService } from '../qoder/hook-service'
|
||||
import { openClaudeHookService } from '../openclaude/hook-service'
|
||||
|
||||
const { home } = vi.hoisted(() => ({ home: { path: '' } }))
|
||||
vi.mock('node:os', async (original) => ({
|
||||
...(await original<typeof osModule>()),
|
||||
homedir: () => home.path
|
||||
}))
|
||||
vi.mock('electron', () => ({ app: { getPath: () => home.path } }))
|
||||
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!
|
||||
let entry: string
|
||||
let payload: string
|
||||
let settings: string
|
||||
const service = new ClaudeHookService()
|
||||
|
||||
beforeEach(() => {
|
||||
home.path = mkdtempSync(join(tmpdir(), 'claude-windows-files-'))
|
||||
entry = join(home.path, '.orca', 'agent-hooks', 'claude-hook.cmd')
|
||||
payload = getWindowsClaudeHookPayloadPath(entry)
|
||||
settings = join(home.path, '.claude', 'settings.json')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
vi.restoreAllMocks()
|
||||
rmSync(home.path, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('Windows Claude hook files', () => {
|
||||
it('installs a delegating entry and a payload that answers before bounded delivery', () => {
|
||||
expect(service.install().state).toBe('installed')
|
||||
expect(readFileSync(entry, 'utf8')).toBe(getWindowsClaudeHookEntry())
|
||||
const body = readFileSync(payload, 'utf8')
|
||||
expect(body.indexOf('echo {}')).toBeLessThan(body.indexOf('curl.exe'))
|
||||
expect(body).toContain('--connect-timeout 0.5 --max-time 1.5')
|
||||
expect(body).toContain('--data-urlencode "payload@-" >nul 2>&1\r\nexit /b 0')
|
||||
expect(body).toContain('DEVIN_PROJECT_DIR')
|
||||
})
|
||||
|
||||
it('repairs a missing payload from the surviving entry without touching settings', async () => {
|
||||
service.install()
|
||||
rmSync(payload)
|
||||
expect(service.getStatus().state).toBe('partial')
|
||||
const configBefore = readFileSync(settings, 'utf8')
|
||||
await service.refreshManagedScripts()
|
||||
expect(service.getStatus().state).toBe('installed')
|
||||
expect(readFileSync(entry, 'utf8')).toBe(getWindowsClaudeHookEntry())
|
||||
expect(readFileSync(payload, 'utf8')).toContain('/hook/claude')
|
||||
expect(readFileSync(settings, 'utf8')).toBe(configBefore)
|
||||
})
|
||||
|
||||
it.each(['entry', 'both'])('leaves a missing %s to install()', async (missing) => {
|
||||
service.install()
|
||||
rmSync(entry)
|
||||
if (missing === 'both') {
|
||||
rmSync(payload)
|
||||
}
|
||||
await service.refreshManagedScripts()
|
||||
expect(existsSync(entry)).toBe(false)
|
||||
expect(service.getStatus().state).toBe('partial')
|
||||
expect(service.install().state).toBe('installed')
|
||||
expect(readFileSync(entry, 'utf8')).toBe(getWindowsClaudeHookEntry())
|
||||
expect(readFileSync(payload, 'utf8')).toContain('/hook/claude')
|
||||
})
|
||||
|
||||
it('leaves the old single-file entry intact when payload publication fails', async () => {
|
||||
mkdirSync(join(home.path, '.orca', 'agent-hooks'), { recursive: true })
|
||||
const oldEntry = '@echo off\r\necho {}\r\nexit /b 0\r\n'
|
||||
writeFileSync(entry, oldEntry)
|
||||
vi.spyOn(refresh, 'restoreManagedScript').mockRejectedValueOnce(new Error('disk full'))
|
||||
await expect(service.refreshManagedScripts()).rejects.toThrow('disk full')
|
||||
expect(readFileSync(entry, 'utf8')).toBe(oldEntry)
|
||||
expect(existsSync(payload)).toBe(false)
|
||||
})
|
||||
|
||||
it('publishes the payload before replacing a legacy entry', async () => {
|
||||
mkdirSync(join(home.path, '.orca', 'agent-hooks'), { recursive: true })
|
||||
writeFileSync(entry, 'legacy payload')
|
||||
const restore = refresh.restoreManagedScript
|
||||
const writes: string[] = []
|
||||
vi.spyOn(refresh, 'restoreManagedScript').mockImplementation(async (path, content) => {
|
||||
if (path === entry) {
|
||||
expect(readFileSync(payload, 'utf8')).toContain('/hook/claude')
|
||||
}
|
||||
writes.push(path)
|
||||
await restore(path, content)
|
||||
})
|
||||
await service.refreshManagedScripts()
|
||||
expect(writes).toEqual([payload, entry])
|
||||
expect(existsSync(settings)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps inert scripts on uninstall and never creates an entry from an orphan payload', async () => {
|
||||
service.install()
|
||||
service.remove()
|
||||
expect(JSON.stringify(readHooksJson(settings))).not.toContain('claude-hook.cmd')
|
||||
// Like every other agent's script: a session still holding the old settings keeps answering.
|
||||
expect(readFileSync(entry, 'utf8')).toBe(getWindowsClaudeHookEntry())
|
||||
rmSync(entry)
|
||||
writeFileSync(payload, 'orphan payload')
|
||||
await service.refreshManagedScripts()
|
||||
expect(existsSync(entry)).toBe(false)
|
||||
expect(readFileSync(payload, 'utf8')).toBe('orphan payload')
|
||||
})
|
||||
|
||||
it('migrates old command forms while preserving user hooks', () => {
|
||||
const encoded = getWindowsManagedLifecycleHook(
|
||||
'C:\\Users\\%name%\\.orca\\agent-hooks\\claude-hook.cmd'
|
||||
)
|
||||
writeHooksJson(settings, {
|
||||
hooks: {
|
||||
Stop: [{ hooks: [encoded, { type: 'command', command: 'echo user-hook' }] }],
|
||||
PreToolUse: [
|
||||
{
|
||||
hooks: [
|
||||
{ type: 'command', command: 'C:/old/.orca/agent-hooks/claude-hook.cmd || echo {}' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
service.install()
|
||||
const config = JSON.stringify(readHooksJson(settings))
|
||||
expect(config).not.toContain('|| echo {}')
|
||||
expect(config).not.toContain('C:/old/')
|
||||
expect(config).toContain('echo user-hook')
|
||||
expect(service.getStatus().state).toBe('installed')
|
||||
})
|
||||
|
||||
it('keeps compatible agents on their existing single-file scripts', async () => {
|
||||
for (const [name, compatible] of [
|
||||
['qoder', qoderHookService],
|
||||
['codebuddy', codebuddyHookService],
|
||||
['openclaude', openClaudeHookService]
|
||||
] as const) {
|
||||
expect(compatible.install().state).toBe('installed')
|
||||
await compatible.refreshManagedScripts()
|
||||
const path = join(home.path, '.orca', 'agent-hooks', `${name}-hook.cmd`)
|
||||
expect(readFileSync(path, 'utf8')).toContain(
|
||||
`/hook/${name === 'openclaude' ? 'claude' : name}`
|
||||
)
|
||||
expect(readFileSync(path, 'utf8')).not.toContain('claude-hook-impl.cmd')
|
||||
expect(existsSync(join(home.path, '.orca', 'agent-hooks', `${name}-hook-impl.cmd`))).toBe(
|
||||
false
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
|
||||
import { writeManagedScript } from '../agent-hooks/installer-utils'
|
||||
import { restoreManagedScript, scriptStillExists } from '../agent-hooks/managed-hook-script-refresh'
|
||||
import {
|
||||
buildWindowsHookEnvironmentGuardLines,
|
||||
buildWindowsHookStdinDrainEpilogue
|
||||
} from '../agent-hooks/hook-stdin-contract'
|
||||
import { WINDOWS_CLAUDE_BACKGROUND_JOB_GUARD } from './hook-script'
|
||||
|
||||
const PAYLOAD_FILE_NAME = 'claude-hook-impl.cmd'
|
||||
|
||||
export function getWindowsClaudeHookPayloadPath(entryPath: string): string {
|
||||
return join(dirname(entryPath), PAYLOAD_FILE_NAME)
|
||||
}
|
||||
|
||||
export function getWindowsClaudeHookFileStatus(
|
||||
status: AgentHookInstallStatus,
|
||||
entryPath: string
|
||||
): AgentHookInstallStatus {
|
||||
if (
|
||||
status.state === 'installed' &&
|
||||
(!existsSync(entryPath) || !existsSync(getWindowsClaudeHookPayloadPath(entryPath)))
|
||||
) {
|
||||
return { ...status, state: 'partial', detail: 'Managed Claude hook script is missing' }
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
export function installWindowsClaudeHookFiles(entryPath: string, payload: string): void {
|
||||
writeManagedScript(getWindowsClaudeHookPayloadPath(entryPath), payload)
|
||||
writeManagedScript(entryPath, getWindowsClaudeHookEntry())
|
||||
}
|
||||
|
||||
export function getWindowsClaudeHookEntry(): string {
|
||||
return [
|
||||
'@echo off',
|
||||
// Inherited delayed expansion would eat exclamation marks in the profile path.
|
||||
'setlocal DisableDelayedExpansion',
|
||||
`set "ORCA_CLAUDE_HOOK_IMPL=%~dp0${PAYLOAD_FILE_NAME}"`,
|
||||
'if not exist "%ORCA_CLAUDE_HOOK_IMPL%" goto :missing_impl',
|
||||
// Transfer control without CALL's second expansion of percent signs in the path.
|
||||
// A payload that exists but cannot start (locked, quarantined) falls through to the neutral reply.
|
||||
'"%ORCA_CLAUDE_HOOK_IMPL%"',
|
||||
':missing_impl',
|
||||
'echo {}',
|
||||
...buildWindowsHookEnvironmentGuardLines(),
|
||||
WINDOWS_CLAUDE_BACKGROUND_JOB_GUARD,
|
||||
...buildWindowsHookStdinDrainEpilogue(),
|
||||
''
|
||||
].join('\r\n')
|
||||
}
|
||||
|
||||
export async function refreshWindowsClaudeHookFiles(
|
||||
entryPath: string,
|
||||
payload: string
|
||||
): Promise<void> {
|
||||
// A surviving entry is Orca-owned; creating a missing one stays install()'s presence-gated job.
|
||||
if (!(await scriptStillExists(entryPath))) {
|
||||
return
|
||||
}
|
||||
// Publish the payload first so a failed migration leaves the previous single-file hook intact.
|
||||
await restoreManagedScript(getWindowsClaudeHookPayloadPath(entryPath), payload)
|
||||
await restoreManagedScript(entryPath, getWindowsClaudeHookEntry())
|
||||
}
|
||||
@@ -219,7 +219,7 @@ export class CodexAccountRegistration {
|
||||
target: CodexAccountSelectionTarget | undefined
|
||||
): void {
|
||||
// Why: quota probes against a cold per-account CODEX_HOME can take 10–25s
|
||||
// (RPC + PTY fallback) and queue behind an in-flight global usage refresh.
|
||||
// (RPC + HTTP fallback) and queue behind an in-flight global usage refresh.
|
||||
// The refresh synchronously flips usage to "fetching" before its first await,
|
||||
// so the switcher updates immediately; the probe itself must never block or
|
||||
// fail the already-durable account mutation.
|
||||
|
||||
@@ -32,7 +32,7 @@ describe('CodexAccountService config sync', () => {
|
||||
registerCodexAccountsTestHomes()
|
||||
|
||||
// Why: quota probes against a cold per-account CODEX_HOME can take 10–25s
|
||||
// (RPC + PTY fallback) and queue behind an in-flight global usage refresh;
|
||||
// (RPC + HTTP fallback) and queue behind an in-flight global usage refresh;
|
||||
// account mutations must never block on — or fail because of — that probe.
|
||||
describe('quota refresh decoupling', () => {
|
||||
function createAccountOneSettings(): GlobalSettings {
|
||||
|
||||
@@ -565,6 +565,16 @@ describe('HeadlessEmulator', () => {
|
||||
expect(snapshot.modes.sgrMouseMode).toBe(false)
|
||||
expect(snapshot.rehydrateSequences).toContain('\x1b[?1002h')
|
||||
expect(snapshot.rehydrateSequences).not.toContain('\x1b[?1006h')
|
||||
// Replay readers must not guess the encoding of an active tracking mode.
|
||||
expect(snapshot.rehydrateSequences).toContain('\x1b[?1006l')
|
||||
})
|
||||
|
||||
it('states no mouse encoding while nothing tracks the mouse', async () => {
|
||||
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
|
||||
|
||||
await emulator.write('\x1b[?1002h\x1b[?1002l')
|
||||
|
||||
expect(emulator.getSnapshot().rehydrateSequences).not.toContain('\x1b[?1006')
|
||||
})
|
||||
|
||||
it('tracks SGR-pixels mouse reporting as a separate active encoding', async () => {
|
||||
|
||||
@@ -66,7 +66,9 @@ const KNOWN_PREEXISTING_I2_FAILURES: Record<string, number> = {
|
||||
// background that the round trip does not restore to default. Verified as upstream, not a
|
||||
// regression, by replaying it against the previous build
|
||||
// (`build-serialize-addon-at-ref.mjs --ref origin/main`): I1 and I3 both hold.
|
||||
'dsh-tui-ready-no-key': 10
|
||||
'dsh-tui-ready-no-key': 10,
|
||||
// Hermes banner cells restore with an extra bold bit under the jitter schedule.
|
||||
'hermes-tui-ready': 2
|
||||
}
|
||||
|
||||
// Exact resize checkpoints and full GridDiff hashes from base 6835b9b4e3ea, not this branch.
|
||||
@@ -204,6 +206,12 @@ describe('serialize round trip over captured PTY transcripts', () => {
|
||||
console.log(`${transcript.name} ${JSON.stringify(counts)}`)
|
||||
}
|
||||
expect(blocking).toEqual([])
|
||||
if (SEEDS === 2 && transcript.name === 'hermes-tui-ready') {
|
||||
expect(failureSignatures).toEqual([
|
||||
'jitter/false/2/34:0db2561506ff28d2e83276b07bc8684541957f61e488b6f8081627e8cad80c63',
|
||||
'jitter/true/2/34:0db2561506ff28d2e83276b07bc8684541957f61e488b6f8081627e8cad80c63'
|
||||
])
|
||||
}
|
||||
if (SEEDS === 2 && transcript.name.startsWith('freebuff-')) {
|
||||
expect(failureSignatures).toEqual(FREEBUFF_BASELINE[transcript.name] ?? [])
|
||||
} else if (!OLD_ADDON_PATH && SEEDS === 2) {
|
||||
|
||||
@@ -19,7 +19,8 @@ export function buildRehydrateSequences(modes: TerminalModes): string {
|
||||
}
|
||||
// Why: mobile alt-screen scroll gestures need xterm's mouse mode restored
|
||||
// from cold snapshots; OpenCode/OpenTUI enables scrollable panes this way.
|
||||
switch (modes.mouseTracking ? (modes.mouseTrackingMode ?? 'vt200') : 'none') {
|
||||
const trackingMode = modes.mouseTracking ? (modes.mouseTrackingMode ?? 'vt200') : 'none'
|
||||
switch (trackingMode) {
|
||||
case 'x10':
|
||||
seqs.push('\x1b[?9h')
|
||||
break
|
||||
@@ -41,6 +42,9 @@ export function buildRehydrateSequences(modes: TerminalModes): string {
|
||||
seqs.push('\x1b[?1016h')
|
||||
} else if (modes.sgrMouseMode) {
|
||||
seqs.push('\x1b[?1006h')
|
||||
} else if (trackingMode !== 'none') {
|
||||
// Why: states the default encoding, so a replay reader never has to guess it.
|
||||
seqs.push('\x1b[?1006l')
|
||||
}
|
||||
return seqs.join('')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { decodeClaudeTranscriptLine } from './transcript-line-decoders-claude'
|
||||
import { normalizeNativeChatUserText } from '../../shared/native-chat-image-transcript-markers'
|
||||
|
||||
const prompt = 'Summarize the failing tests.\n\nThen propose a fix for each one.'
|
||||
const wrapped = `\n\n<pasted_content id="7e64">\n${prompt}\n</pasted_content id="7e64">\n`
|
||||
function decode(text: string, role = 'user', array = false) {
|
||||
return decodeClaudeTranscriptLine(
|
||||
JSON.stringify({
|
||||
type: role,
|
||||
uuid: 'user',
|
||||
message: { content: array ? [{ type: 'text', text }] : text }
|
||||
}),
|
||||
'fallback'
|
||||
)!
|
||||
}
|
||||
|
||||
describe('Claude whole-block paste envelope', () => {
|
||||
it.each([false, true])('decodes host user content (array=%s)', (array) => {
|
||||
expect(decode(wrapped, 'user', array).blocks).toEqual([{ type: 'text', text: prompt }])
|
||||
})
|
||||
it('accepts CRLF and a wrapper without ids', () => {
|
||||
expect(decode(`<pasted_content>\r\n${prompt}\r\n</pasted_content>`).blocks).toEqual([
|
||||
{ type: 'text', text: prompt }
|
||||
])
|
||||
})
|
||||
it('decodes a paste whose own text quotes differently identified or bare tags', () => {
|
||||
const quoted = `<pasted_content id="aa">\nx\n</pasted_content id="aa">\n</pasted_content>`
|
||||
expect(
|
||||
decode(`<pasted_content id="7e64">\n${quoted}\n</pasted_content id="7e64">`).blocks
|
||||
).toEqual([{ type: 'text', text: quoted }])
|
||||
})
|
||||
it.each([
|
||||
`Explain this:\n${wrapped}`,
|
||||
`<pasted_content id="7e64">\n<pasted_content id="7e64">\nx\n</pasted_content id="7e64">`,
|
||||
`<pasted_content>\n<pasted_content id="aa">\nx\n</pasted_content id="aa">\n</pasted_content>`,
|
||||
wrapped.replace('id="7e64">\n', 'id="other">\n'),
|
||||
wrapped.replace('</pasted_content id="7e64">', '</pasted_content>'),
|
||||
wrapped.replace('<pasted_content id="7e64">', '<pasted_content>'),
|
||||
`${wrapped}\n${wrapped}`
|
||||
])('preserves prose and nonmatching envelopes', (text) => {
|
||||
expect(decode(text).blocks).toEqual([{ type: 'text', text }])
|
||||
})
|
||||
it('leaves assistant text untouched', () => {
|
||||
expect(decode(wrapped, 'assistant').blocks).toEqual([{ type: 'text', text: wrapped }])
|
||||
})
|
||||
it('handles a large prompt in linear passes', () => {
|
||||
const text = 'line\n'.repeat(50_000)
|
||||
expect(
|
||||
normalizeNativeChatUserText(`<pasted_content id="a">\n${text}\n</pasted_content id="a">`)
|
||||
).toBe(text.trim().replace(/\s+/g, ' '))
|
||||
})
|
||||
})
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '../ai-vault/session-scanner-values'
|
||||
import { imageSourcePathFromText } from '../../shared/native-chat-image-transcript-markers'
|
||||
import { claudeContentBlocks } from './transcript-record-blocks'
|
||||
import { unwrapClaudePastedContentBlock } from '../../shared/claude-pasted-content'
|
||||
import { claudeInterruptedMessageId } from './transcript-turn-markers'
|
||||
|
||||
const MAX_EDIT_PATCH_HUNKS = 40
|
||||
@@ -124,7 +125,7 @@ export function decodeClaudeTranscriptLine(
|
||||
return {
|
||||
id: messageId ?? fallbackId,
|
||||
role: claudeMessageRole(role, blocks),
|
||||
blocks,
|
||||
blocks: role === 'user' ? blocks.map(unwrapClaudePastedContentBlock) : blocks,
|
||||
timestamp,
|
||||
source: 'transcript'
|
||||
}
|
||||
|
||||
@@ -80,16 +80,12 @@ describe('Qoder Windows hook shell', () => {
|
||||
it('uses the documented explicit shell without relying on Git Bash or another PowerShell hop', () => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
|
||||
try {
|
||||
const hook = getManagedLifecycleHook(
|
||||
'C:\\Users\\a b\\.orca\\agent-hooks\\qoder-hook.cmd',
|
||||
{
|
||||
configDirName: '.qoder',
|
||||
scriptBaseName: 'qoder-hook',
|
||||
usesWindowsCompatLauncher: true,
|
||||
windowsHookShell: 'powershell'
|
||||
},
|
||||
{ gitBashAvailable: true }
|
||||
)
|
||||
const hook = getManagedLifecycleHook('C:\\Users\\a b\\.orca\\agent-hooks\\qoder-hook.cmd', {
|
||||
configDirName: '.qoder',
|
||||
scriptBaseName: 'qoder-hook',
|
||||
usesWindowsCompatLauncher: true,
|
||||
windowsHookShell: 'powershell'
|
||||
})
|
||||
expect(hook.shell).toBe('powershell')
|
||||
expect(hook.command).toContain('$env:USERPROFILE')
|
||||
expect(hook.command).not.toMatch(/EncodedCommand|ExecutionPolicy|\|\|/)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { childSpawnMock, resolveCodexCommandMock, ptySpawnMock } = vi.hoisted(() => ({
|
||||
childSpawnMock: vi.fn(),
|
||||
@@ -26,10 +26,6 @@ vi.mock('./codex-auth-presence', () => ({
|
||||
|
||||
import { fetchCodexRateLimits } from './codex-fetcher'
|
||||
|
||||
function makeDisposable() {
|
||||
return { dispose: vi.fn() }
|
||||
}
|
||||
|
||||
function makeRpcChild() {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
stdout: EventEmitter
|
||||
@@ -60,9 +56,14 @@ describe('fetchCodexRateLimits auth errors', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
resolveCodexCommandMock.mockReturnValue('codex')
|
||||
vi.stubGlobal('fetch', vi.fn())
|
||||
})
|
||||
|
||||
it('returns Codex RPC auth refresh errors without masking them behind PTY fallback', async () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('returns Codex RPC auth refresh errors without masking them behind a fallback', async () => {
|
||||
const rpcChild = makeRpcChild()
|
||||
const authError =
|
||||
'Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again.'
|
||||
@@ -105,10 +106,11 @@ describe('fetchCodexRateLimits auth errors', () => {
|
||||
status: 'error',
|
||||
error: authError
|
||||
})
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
expect(ptySpawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns the app-server chatgpt-auth-required error without spawning the PTY probe', async () => {
|
||||
it('returns the app-server chatgpt-auth-required error without falling back', async () => {
|
||||
const rpcChild = makeRpcChild()
|
||||
const authError = 'chatgpt authentication required to read rate limits'
|
||||
|
||||
@@ -150,75 +152,7 @@ describe('fetchCodexRateLimits auth errors', () => {
|
||||
status: 'error',
|
||||
error: authError
|
||||
})
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
expect(ptySpawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves Codex PTY auth errors when the CLI exits before status is available', async () => {
|
||||
const ptyHandlers: { onData?: (data: string) => void; onExit?: () => void } = {}
|
||||
const authError =
|
||||
'Error loading configuration: Your authentication session could not be refreshed automatically.'
|
||||
|
||||
childSpawnMock.mockImplementation(() => {
|
||||
throw new Error('rpc unavailable')
|
||||
})
|
||||
ptySpawnMock.mockReturnValue({
|
||||
onData: vi.fn((callback) => {
|
||||
ptyHandlers.onData = callback
|
||||
return makeDisposable()
|
||||
}),
|
||||
onExit: vi.fn((callback) => {
|
||||
ptyHandlers.onExit = callback
|
||||
return makeDisposable()
|
||||
}),
|
||||
write: vi.fn(),
|
||||
kill: vi.fn()
|
||||
})
|
||||
|
||||
const resultPromise = fetchCodexRateLimits()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
ptyHandlers.onData?.(`${authError}\n`)
|
||||
ptyHandlers.onExit?.()
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
provider: 'codex',
|
||||
session: null,
|
||||
weekly: null,
|
||||
status: 'error',
|
||||
error: authError
|
||||
})
|
||||
})
|
||||
|
||||
it('stops a PTY probe when Codex renders its sign-in screen', async () => {
|
||||
const ptyHandlers: { onData?: (data: string) => void } = {}
|
||||
const ptyWrite = vi.fn()
|
||||
const ptyKill = vi.fn()
|
||||
|
||||
childSpawnMock.mockImplementation(() => {
|
||||
throw new Error('rpc unavailable')
|
||||
})
|
||||
ptySpawnMock.mockReturnValue({
|
||||
onData: vi.fn((callback) => {
|
||||
ptyHandlers.onData = callback
|
||||
return makeDisposable()
|
||||
}),
|
||||
onExit: vi.fn(() => makeDisposable()),
|
||||
write: ptyWrite,
|
||||
kill: ptyKill
|
||||
})
|
||||
|
||||
const resultPromise = fetchCodexRateLimits()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
ptyHandlers.onData?.('\u001b[2JSign in with ChatGPT\r\n')
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
provider: 'codex',
|
||||
session: null,
|
||||
weekly: null,
|
||||
status: 'error',
|
||||
error: 'Sign in with ChatGPT'
|
||||
})
|
||||
expect(ptyWrite).not.toHaveBeenCalled()
|
||||
expect(ptyKill).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -129,7 +129,7 @@ describe('fetchCodexRateLimits probe shutdown', () => {
|
||||
}
|
||||
})
|
||||
|
||||
const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false })
|
||||
const resultPromise = fetchCodexRateLimits()
|
||||
await vi.advanceTimersByTimeAsync(19_999)
|
||||
expect(rpcChild.kill).not.toHaveBeenCalled()
|
||||
|
||||
@@ -169,7 +169,7 @@ describe('fetchCodexRateLimits probe shutdown', () => {
|
||||
})
|
||||
|
||||
try {
|
||||
const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false })
|
||||
const resultPromise = fetchCodexRateLimits()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await vi.advanceTimersByTimeAsync(9_999)
|
||||
expect(rpcChild.kill).not.toHaveBeenCalled()
|
||||
@@ -200,12 +200,10 @@ describe('fetchCodexRateLimits probe shutdown', () => {
|
||||
})
|
||||
|
||||
const first = fetchCodexRateLimits({
|
||||
allowPtyFallback: false,
|
||||
codexHomePath: '/managed/home-a'
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
const second = fetchCodexRateLimits({
|
||||
allowPtyFallback: false,
|
||||
codexHomePath: '/managed/home-a'
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
@@ -241,13 +239,11 @@ describe('fetchCodexRateLimits probe shutdown', () => {
|
||||
|
||||
try {
|
||||
const first = fetchCodexRateLimits({
|
||||
allowPtyFallback: false,
|
||||
codexHomePath: '/managed/home-error'
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
firstChild.emit('error', new Error('stdio failed'))
|
||||
const second = fetchCodexRateLimits({
|
||||
allowPtyFallback: false,
|
||||
codexHomePath: '/managed/home-error'
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
@@ -308,14 +304,12 @@ describe('fetchCodexRateLimits probe shutdown', () => {
|
||||
|
||||
try {
|
||||
const first = fetchCodexRateLimits({
|
||||
allowPtyFallback: false,
|
||||
codexHomePath: home
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(firstChild.kill).toHaveBeenCalledWith('SIGTERM')
|
||||
|
||||
const second = fetchCodexRateLimits({
|
||||
allowPtyFallback: false,
|
||||
codexHomePath: home
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(4_999)
|
||||
@@ -357,13 +351,11 @@ describe('fetchCodexRateLimits probe shutdown', () => {
|
||||
|
||||
try {
|
||||
const first = fetchCodexRateLimits({
|
||||
allowPtyFallback: false,
|
||||
codexHomePath: '/managed/home-async-stdin-error'
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
firstChild.stdin.emit('error', Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }))
|
||||
const second = fetchCodexRateLimits({
|
||||
allowPtyFallback: false,
|
||||
codexHomePath: '/managed/home-async-stdin-error'
|
||||
})
|
||||
|
||||
|
||||
@@ -130,8 +130,7 @@ describe('Codex rate-limit process contract', () => {
|
||||
it('starts a read-only non-interactive app-server with the managed home', async () => {
|
||||
await expect(
|
||||
fetchCodexRateLimits({
|
||||
codexHomePath: process.env.ORCA_EXPECTED_CODEX_HOME,
|
||||
allowPtyFallback: false
|
||||
codexHomePath: process.env.ORCA_EXPECTED_CODEX_HOME
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
provider: 'codex',
|
||||
|
||||
@@ -1,300 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { childSpawnMock, resolveCodexCommandMock, ptySpawnMock } = vi.hoisted(() => ({
|
||||
childSpawnMock: vi.fn(),
|
||||
resolveCodexCommandMock: vi.fn(),
|
||||
ptySpawnMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
spawn: childSpawnMock
|
||||
}))
|
||||
|
||||
vi.mock('../codex-cli/command', () => ({
|
||||
resolveCodexCommand: resolveCodexCommandMock
|
||||
}))
|
||||
|
||||
vi.mock('node-pty', () => ({
|
||||
spawn: ptySpawnMock
|
||||
}))
|
||||
|
||||
// Auth gate is covered separately; these tests assume a signed-in Codex.
|
||||
vi.mock('./codex-auth-presence', () => ({
|
||||
probeCodexAuthPresence: vi.fn(() => 'present')
|
||||
}))
|
||||
|
||||
import { fetchCodexRateLimits } from './codex-fetcher'
|
||||
|
||||
function makeDisposable() {
|
||||
return { dispose: vi.fn() }
|
||||
}
|
||||
|
||||
describe('fetchCodexRateLimits PTY settle timers', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
resolveCodexCommandMock.mockReturnValue('codex')
|
||||
})
|
||||
|
||||
it('coalesces the PTY fallback status settle timer while output keeps streaming', async () => {
|
||||
const ptyHandlers: { onData?: (data: string) => void } = {}
|
||||
|
||||
childSpawnMock.mockImplementation(() => {
|
||||
throw new Error('rpc unavailable')
|
||||
})
|
||||
ptySpawnMock.mockReturnValue({
|
||||
onData: vi.fn((callback) => {
|
||||
ptyHandlers.onData = callback
|
||||
return makeDisposable()
|
||||
}),
|
||||
onExit: vi.fn(() => makeDisposable()),
|
||||
write: vi.fn(),
|
||||
kill: vi.fn()
|
||||
})
|
||||
|
||||
const resultPromise = fetchCodexRateLimits()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
const onPtyData = ptyHandlers.onData
|
||||
if (!onPtyData) {
|
||||
throw new Error('PTY data handler was not registered')
|
||||
}
|
||||
|
||||
onPtyData('>')
|
||||
// Pending: PTY timeout + the delayed /status Enter keypress.
|
||||
expect(vi.getTimerCount()).toBe(2)
|
||||
|
||||
onPtyData('5h limit: 17%\n')
|
||||
onPtyData('Weekly limit: 23%\n')
|
||||
onPtyData('still rendering\n')
|
||||
// One settle timer armed — not one per data chunk.
|
||||
expect(vi.getTimerCount()).toBe(3)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
session: { usedPercent: 17 },
|
||||
weekly: { usedPercent: 23 },
|
||||
status: 'ok'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the reset text on the weekly window for weekly-only plans', async () => {
|
||||
const ptyHandlers: { onData?: (data: string) => void } = {}
|
||||
|
||||
childSpawnMock.mockImplementation(() => {
|
||||
throw new Error('rpc unavailable')
|
||||
})
|
||||
ptySpawnMock.mockReturnValue({
|
||||
onData: vi.fn((callback) => {
|
||||
ptyHandlers.onData = callback
|
||||
return makeDisposable()
|
||||
}),
|
||||
onExit: vi.fn(() => makeDisposable()),
|
||||
write: vi.fn(),
|
||||
kill: vi.fn()
|
||||
})
|
||||
|
||||
const resultPromise = fetchCodexRateLimits()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
const onPtyData = ptyHandlers.onData
|
||||
if (!onPtyData) {
|
||||
throw new Error('PTY data handler was not registered')
|
||||
}
|
||||
|
||||
onPtyData('>')
|
||||
onPtyData('Weekly limit: 76%\nResets in 5d 23h\n')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
|
||||
const fiveDays23h = (5 * 24 + 23) * 60 * 60 * 1000
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
session: null,
|
||||
weekly: {
|
||||
usedPercent: 76,
|
||||
resetDescription: '5d 23h',
|
||||
resetsAt: Date.now() + fiveDays23h
|
||||
},
|
||||
status: 'ok'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps each window reset text on its own window for dual-window plans', async () => {
|
||||
const ptyHandlers: { onData?: (data: string) => void } = {}
|
||||
|
||||
childSpawnMock.mockImplementation(() => {
|
||||
throw new Error('rpc unavailable')
|
||||
})
|
||||
ptySpawnMock.mockReturnValue({
|
||||
onData: vi.fn((callback) => {
|
||||
ptyHandlers.onData = callback
|
||||
return makeDisposable()
|
||||
}),
|
||||
onExit: vi.fn(() => makeDisposable()),
|
||||
write: vi.fn(),
|
||||
kill: vi.fn()
|
||||
})
|
||||
|
||||
const resultPromise = fetchCodexRateLimits()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
const onPtyData = ptyHandlers.onData
|
||||
if (!onPtyData) {
|
||||
throw new Error('PTY data handler was not registered')
|
||||
}
|
||||
|
||||
onPtyData('>')
|
||||
onPtyData('5h limit: 17% (resets in 2h 30m)\nWeekly limit: 23% (resets in 5d 3h)\n')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
session: {
|
||||
usedPercent: 17,
|
||||
resetDescription: '2h 30m',
|
||||
resetsAt: Date.now() + (2 * 60 + 30) * 60 * 1000
|
||||
},
|
||||
weekly: {
|
||||
usedPercent: 23,
|
||||
resetDescription: '5d 3h',
|
||||
resetsAt: Date.now() + (5 * 24 + 3) * 60 * 60 * 1000
|
||||
},
|
||||
status: 'ok'
|
||||
})
|
||||
})
|
||||
|
||||
it('parses the framed codex 0.145 status panel via the /status nudge', async () => {
|
||||
const ptyHandlers: { onData?: (data: string) => void } = {}
|
||||
const write = vi.fn()
|
||||
|
||||
childSpawnMock.mockImplementation(() => {
|
||||
throw new Error('rpc unavailable')
|
||||
})
|
||||
ptySpawnMock.mockReturnValue({
|
||||
onData: vi.fn((callback) => {
|
||||
ptyHandlers.onData = callback
|
||||
return makeDisposable()
|
||||
}),
|
||||
onExit: vi.fn(() => makeDisposable()),
|
||||
write,
|
||||
kill: vi.fn()
|
||||
})
|
||||
|
||||
const resultPromise = fetchCodexRateLimits()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
const onPtyData = ptyHandlers.onData
|
||||
if (!onPtyData) {
|
||||
throw new Error('PTY data handler was not registered')
|
||||
}
|
||||
|
||||
// codex ≥0.145 shows a '›' composer with placeholder text, never a bare '>' prompt.
|
||||
onPtyData('›Summarize recent commits')
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(2500)
|
||||
expect(write).toHaveBeenCalledWith('/status')
|
||||
await vi.advanceTimersByTimeAsync(350)
|
||||
expect(write).toHaveBeenCalledWith('\r')
|
||||
|
||||
onPtyData(
|
||||
'│ Weekly limit: \x1b[?2026h\x1b[0 q[█████████░░░░░░░░░░░] 43% left\x1b[?2026l (resets 10:21 on 28 Jul) │\n' +
|
||||
'│ GPT-5.3-Codex-Spark Weekly limit: [████████████████████] 100% left (resets 17:40 on 29 Jul) │\n'
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
|
||||
const expectedReset = new Date(new Date().getFullYear(), 6, 28, 10, 21)
|
||||
if (expectedReset.getTime() <= Date.now()) {
|
||||
expectedReset.setFullYear(expectedReset.getFullYear() + 1)
|
||||
}
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
session: null,
|
||||
weekly: {
|
||||
usedPercent: 57,
|
||||
resetDescription: '10:21 on 28 Jul',
|
||||
resetsAt: expectedReset.getTime()
|
||||
},
|
||||
status: 'ok'
|
||||
})
|
||||
})
|
||||
|
||||
it('never selects a model-scoped weekly row even when it renders first', async () => {
|
||||
const ptyHandlers: { onData?: (data: string) => void } = {}
|
||||
|
||||
childSpawnMock.mockImplementation(() => {
|
||||
throw new Error('rpc unavailable')
|
||||
})
|
||||
ptySpawnMock.mockReturnValue({
|
||||
onData: vi.fn((callback) => {
|
||||
ptyHandlers.onData = callback
|
||||
return makeDisposable()
|
||||
}),
|
||||
onExit: vi.fn(() => makeDisposable()),
|
||||
write: vi.fn(),
|
||||
kill: vi.fn()
|
||||
})
|
||||
|
||||
const resultPromise = fetchCodexRateLimits()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
const onPtyData = ptyHandlers.onData
|
||||
if (!onPtyData) {
|
||||
throw new Error('PTY data handler was not registered')
|
||||
}
|
||||
|
||||
onPtyData('>')
|
||||
onPtyData(
|
||||
'│ GPT-5.3-Codex-Spark Weekly limit: [████████████████████] 100% left (resets 17:40 on 29 Jul) │\n' +
|
||||
'│ Weekly limit: [█████████░░░░░░░░░░░] 43% left (resets 10:21 on 28 Jul) │\n'
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
session: null,
|
||||
weekly: { usedPercent: 57, resetDescription: '10:21 on 28 Jul' },
|
||||
status: 'ok'
|
||||
})
|
||||
})
|
||||
|
||||
it('re-sends Enter once when the panel does not render after the first submit', async () => {
|
||||
const ptyHandlers: { onData?: (data: string) => void } = {}
|
||||
const write = vi.fn()
|
||||
|
||||
childSpawnMock.mockImplementation(() => {
|
||||
throw new Error('rpc unavailable')
|
||||
})
|
||||
ptySpawnMock.mockReturnValue({
|
||||
onData: vi.fn((callback) => {
|
||||
ptyHandlers.onData = callback
|
||||
return makeDisposable()
|
||||
}),
|
||||
onExit: vi.fn(() => makeDisposable()),
|
||||
write,
|
||||
kill: vi.fn()
|
||||
})
|
||||
|
||||
const resultPromise = fetchCodexRateLimits()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
const onPtyData = ptyHandlers.onData
|
||||
if (!onPtyData) {
|
||||
throw new Error('PTY data handler was not registered')
|
||||
}
|
||||
|
||||
onPtyData('>')
|
||||
await vi.advanceTimersByTimeAsync(350)
|
||||
expect(write.mock.calls.filter((call) => call[0] === '\r')).toHaveLength(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3000)
|
||||
expect(write.mock.calls.filter((call) => call[0] === '\r')).toHaveLength(2)
|
||||
|
||||
onPtyData('Weekly limit: 76%\nResets in 5d 23h\n')
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
session: null,
|
||||
weekly: { usedPercent: 76 },
|
||||
status: 'ok'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -62,7 +62,8 @@ let stubPath: string
|
||||
function runStub(stderr: string, exitCode: number): Promise<{ error: string | null }> {
|
||||
process.env.ORCA_STUB_CODEX_STDERR = stderr
|
||||
process.env.ORCA_STUB_CODEX_EXIT_CODE = String(exitCode)
|
||||
return fetchCodexRateLimits({ allowPtyFallback: false })
|
||||
// Why: a temp home with no auth.json keeps the HTTP fallback off the developer's real login.
|
||||
return fetchCodexRateLimits({ codexHomePath: tempRoot })
|
||||
}
|
||||
|
||||
describe('Codex RPC exit diagnostics', () => {
|
||||
|
||||
@@ -82,7 +82,7 @@ describe('codex rate-limit spawn runtime pairing', () => {
|
||||
const rpcChild = makeRpcChild()
|
||||
childSpawnMock.mockReturnValue(rpcChild)
|
||||
|
||||
const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false })
|
||||
const resultPromise = fetchCodexRateLimits()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
const spawnEnv = childSpawnMock.mock.calls[0]?.[2]?.env as NodeJS.ProcessEnv
|
||||
@@ -105,7 +105,7 @@ describe('codex rate-limit spawn runtime pairing', () => {
|
||||
const rpcChild = makeRpcChild()
|
||||
childSpawnMock.mockReturnValue(rpcChild)
|
||||
|
||||
const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false })
|
||||
const resultPromise = fetchCodexRateLimits()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
const spawnCommand = childSpawnMock.mock.calls[0]?.[0] as string
|
||||
@@ -127,7 +127,7 @@ describe('codex rate-limit spawn runtime pairing', () => {
|
||||
const rpcChild = makeRpcChild()
|
||||
childSpawnMock.mockReturnValue(rpcChild)
|
||||
|
||||
const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false })
|
||||
const resultPromise = fetchCodexRateLimits()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
const spawnEnv = childSpawnMock.mock.calls[0]?.[2]?.env as NodeJS.ProcessEnv
|
||||
|
||||
@@ -58,13 +58,8 @@ vi.mock('./codex-auth-presence', () => ({
|
||||
|
||||
import { fetchCodexRateLimits } from './codex-fetcher'
|
||||
import { probeCodexAuthPresence } from './codex-auth-presence'
|
||||
import { getActiveHiddenRateLimitPtyCount } from './hidden-pty-cleanup'
|
||||
import { CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS } from '../codex-cli/codex-read-only-app-server-args'
|
||||
|
||||
function makeDisposable() {
|
||||
return { dispose: vi.fn() }
|
||||
}
|
||||
|
||||
function makeRpcChild() {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
stdout: EventEmitter
|
||||
@@ -116,23 +111,24 @@ function respondToRpcRateLimitRead(
|
||||
})
|
||||
}
|
||||
|
||||
function makePtyTerm() {
|
||||
let dataHandler: ((data: string) => void) | null = null
|
||||
let exitHandler: (() => void) | null = null
|
||||
return {
|
||||
onData: vi.fn((callback: (data: string) => void) => {
|
||||
dataHandler = callback
|
||||
return makeDisposable()
|
||||
}),
|
||||
onExit: vi.fn((callback: () => void) => {
|
||||
exitHandler = callback
|
||||
return makeDisposable()
|
||||
}),
|
||||
write: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
emitData: (data: string) => dataHandler?.(data),
|
||||
emitExit: () => exitHandler?.()
|
||||
}
|
||||
function mockBackendUsage(): void {
|
||||
readFileMock.mockResolvedValue(
|
||||
JSON.stringify({ tokens: { access_token: 'access-token', account_id: 'account-id' } })
|
||||
)
|
||||
// A Response body reads once; usage and reset credits each fetch.
|
||||
vi.mocked(fetch).mockImplementation(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
plan_type: 'plus',
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 7, limit_window_seconds: 5 * 60 * 60 },
|
||||
secondary_window: { used_percent: 12, limit_window_seconds: 7 * 24 * 60 * 60 }
|
||||
},
|
||||
rate_limit_reset_credits: { available_count: 0, credits: [] }
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
describe('fetchCodexRateLimits', () => {
|
||||
@@ -168,9 +164,7 @@ describe('fetchCodexRateLimits', () => {
|
||||
it('does not let a quota probe steal an incomplete state-DB backfill lease', async () => {
|
||||
isBackfillPendingMock.mockReturnValue(true)
|
||||
|
||||
await expect(
|
||||
fetchCodexRateLimits({ codexHomePath: '/managed-home', allowPtyFallback: false })
|
||||
).resolves.toMatchObject({
|
||||
await expect(fetchCodexRateLimits({ codexHomePath: '/managed-home' })).resolves.toMatchObject({
|
||||
status: 'error',
|
||||
error: expect.stringContaining('session index')
|
||||
})
|
||||
@@ -219,38 +213,11 @@ describe('fetchCodexRateLimits', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('disposes node-pty listeners before killing the PTY fallback on timeout', async () => {
|
||||
const onDataDisposable = makeDisposable()
|
||||
const onExitDisposable = makeDisposable()
|
||||
const killMock = vi.fn()
|
||||
|
||||
childSpawnMock.mockImplementation(() => {
|
||||
throw new Error('rpc unavailable')
|
||||
})
|
||||
ptySpawnMock.mockReturnValue({
|
||||
onData: vi.fn(() => onDataDisposable),
|
||||
onExit: vi.fn(() => onExitDisposable),
|
||||
write: vi.fn(),
|
||||
kill: killMock
|
||||
})
|
||||
|
||||
const resultPromise = fetchCodexRateLimits()
|
||||
await vi.advanceTimersByTimeAsync(15_000)
|
||||
await resultPromise
|
||||
|
||||
expect(onDataDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
killMock.mock.invocationCallOrder[0]
|
||||
)
|
||||
expect(onExitDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
killMock.mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('spawns the RPC rate-limit reader in a bounded non-root cwd', async () => {
|
||||
const rpcChild = makeRpcChild()
|
||||
childSpawnMock.mockReturnValue(rpcChild)
|
||||
|
||||
const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false })
|
||||
const resultPromise = fetchCodexRateLimits()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
const spawnCwd = childSpawnMock.mock.calls[0]?.[2]?.cwd as string
|
||||
@@ -262,26 +229,7 @@ describe('fetchCodexRateLimits', () => {
|
||||
await resultPromise
|
||||
})
|
||||
|
||||
it('spawns the PTY fallback in a bounded non-root cwd', async () => {
|
||||
const term = makePtyTerm()
|
||||
childSpawnMock.mockImplementation(() => {
|
||||
throw new Error('rpc unavailable')
|
||||
})
|
||||
ptySpawnMock.mockReturnValue(term)
|
||||
|
||||
const resultPromise = fetchCodexRateLimits()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
const spawnCwd = ptySpawnMock.mock.calls[0]?.[2]?.cwd as string
|
||||
expect(spawnCwd).toContain('rate-limit-pty-cwd')
|
||||
expect(spawnCwd).not.toBe('/')
|
||||
expect(spawnCwd).not.toMatch(/^[A-Za-z]:\\?$/)
|
||||
|
||||
term.emitExit()
|
||||
await resultPromise
|
||||
})
|
||||
|
||||
it('kills the RPC child and skips PTY fallback when the fetch signal aborts', async () => {
|
||||
it('kills the RPC child and skips the HTTP fallback when the fetch signal aborts', async () => {
|
||||
const rpcChild = makeRpcChild()
|
||||
childSpawnMock.mockReturnValue(rpcChild)
|
||||
const controller = new AbortController()
|
||||
@@ -297,77 +245,40 @@ describe('fetchCodexRateLimits', () => {
|
||||
error: 'Rate-limit fetch aborted'
|
||||
})
|
||||
expect(rpcChild.kill).toHaveBeenCalledTimes(1)
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
expect(ptySpawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('kills and unregisters the PTY fallback when the fetch signal aborts', async () => {
|
||||
const term = makePtyTerm()
|
||||
childSpawnMock.mockImplementation(() => {
|
||||
throw new Error('rpc unavailable')
|
||||
})
|
||||
ptySpawnMock.mockReturnValue(term)
|
||||
const controller = new AbortController()
|
||||
const killMock = term.kill
|
||||
|
||||
const resultPromise = fetchCodexRateLimits({ signal: controller.signal })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(getActiveHiddenRateLimitPtyCount()).toBe(1)
|
||||
|
||||
controller.abort()
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
provider: 'codex',
|
||||
status: 'error',
|
||||
error: 'Rate-limit fetch aborted'
|
||||
})
|
||||
expect(killMock).toHaveBeenCalledTimes(1)
|
||||
expect(getActiveHiddenRateLimitPtyCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('falls back to the PTY status reader when RPC exits before returning usage', async () => {
|
||||
it('reads usage over HTTP, never through the Codex TUI, when RPC exits before returning usage', async () => {
|
||||
const rpcChild = makeRpcChild()
|
||||
const ptyHandlers: { onData?: (data: string) => void } = {}
|
||||
|
||||
childSpawnMock.mockReturnValue(rpcChild)
|
||||
ptySpawnMock.mockReturnValue({
|
||||
onData: vi.fn((callback) => {
|
||||
ptyHandlers.onData = callback
|
||||
return makeDisposable()
|
||||
}),
|
||||
onExit: vi.fn(() => makeDisposable()),
|
||||
write: vi.fn(),
|
||||
kill: vi.fn()
|
||||
})
|
||||
mockBackendUsage()
|
||||
|
||||
const resultPromise = fetchCodexRateLimits()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
rpcChild.emit('close')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(ptySpawnMock).toHaveBeenCalled()
|
||||
const onPtyData = ptyHandlers.onData
|
||||
if (!onPtyData) {
|
||||
throw new Error('PTY data handler was not registered')
|
||||
}
|
||||
onPtyData('>')
|
||||
onPtyData('5h limit: 7%\nWeekly limit: 12%\n')
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
provider: 'codex',
|
||||
session: { usedPercent: 7 },
|
||||
weekly: { usedPercent: 12 },
|
||||
session: { usedPercent: 7, windowMinutes: 300 },
|
||||
weekly: { usedPercent: 12, windowMinutes: 10080 },
|
||||
status: 'ok',
|
||||
error: null
|
||||
})
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'https://chatgpt.com/backend-api/wham/usage',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: 'Bearer access-token' })
|
||||
})
|
||||
)
|
||||
expect(ptySpawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not start the PTY fallback when disabled for background account previews', async () => {
|
||||
it('keeps the RPC error when the HTTP fallback has no usage to offer', async () => {
|
||||
const rpcChild = makeRpcChild()
|
||||
childSpawnMock.mockReturnValue(rpcChild)
|
||||
|
||||
const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false })
|
||||
const resultPromise = fetchCodexRateLimits()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
rpcChild.emit('close')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
@@ -379,6 +290,28 @@ describe('fetchCodexRateLimits', () => {
|
||||
status: 'error'
|
||||
})
|
||||
expect(rpcChild.stdin.listenerCount('error')).toBe(0)
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
expect(ptySpawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the RPC exit reason when the HTTP fallback is rejected', async () => {
|
||||
const rpcChild = makeRpcChild()
|
||||
childSpawnMock.mockReturnValue(rpcChild)
|
||||
mockBackendUsage()
|
||||
vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 401 }))
|
||||
|
||||
const resultPromise = fetchCodexRateLimits()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
rpcChild.emit('close', 1, null)
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
provider: 'codex',
|
||||
session: null,
|
||||
weekly: null,
|
||||
status: 'error',
|
||||
error: expect.stringContaining('exit code 1')
|
||||
})
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
expect(ptySpawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -386,7 +319,7 @@ describe('fetchCodexRateLimits', () => {
|
||||
const rpcChild = makeRpcChild()
|
||||
childSpawnMock.mockReturnValue(rpcChild)
|
||||
|
||||
const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false })
|
||||
const resultPromise = fetchCodexRateLimits()
|
||||
// Why: without an initialize response only the 30s boot deadline fires.
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
|
||||
@@ -810,27 +743,18 @@ describe('fetchCodexRateLimits', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('runs rate-limit PTY fallback through WSL when RPC cannot read usage', async () => {
|
||||
it('does not retry HTTP after RPC fails for a WSL home, since WSL already tried HTTP first', async () => {
|
||||
const originalPlatform = process.platform
|
||||
const originalCodexHome = process.env.CODEX_HOME
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'win32'
|
||||
})
|
||||
process.env.CODEX_HOME = 'C:\\Users\\alice\\.codex'
|
||||
|
||||
const rpcChild = makeRpcChild()
|
||||
const ptyHandlers: { onData?: (data: string) => void } = {}
|
||||
childSpawnMock.mockReturnValue(rpcChild)
|
||||
ptySpawnMock.mockReturnValue({
|
||||
onData: vi.fn((callback) => {
|
||||
ptyHandlers.onData = callback
|
||||
return makeDisposable()
|
||||
}),
|
||||
onExit: vi.fn(() => makeDisposable()),
|
||||
write: vi.fn(),
|
||||
kill: vi.fn()
|
||||
})
|
||||
readFileMock.mockResolvedValue(
|
||||
JSON.stringify({ tokens: { access_token: 'access-token', account_id: 'account-id' } })
|
||||
)
|
||||
vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 503 }))
|
||||
|
||||
try {
|
||||
const resultPromise = fetchCodexRateLimits({
|
||||
@@ -838,53 +762,12 @@ describe('fetchCodexRateLimits', () => {
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
rpcChild.emit('close')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
const [spawnFile, spawnArgs, spawnOptions] = ptySpawnMock.mock.calls[0]
|
||||
expect(spawnFile).toBe('wsl.exe')
|
||||
expect(spawnArgs.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--exec', 'sh', '-c'])
|
||||
const shellCommand = spawnArgs.at(-1) as string
|
||||
expect(shellCommand).toContain('_orca_wsl_shell=$(getent passwd')
|
||||
expect(shellCommand).toContain('bash|zsh|ksh|mksh|ash) exec "$_orca_wsl_shell" -ilc')
|
||||
expect(shellCommand).not.toContain('exec 3<&0')
|
||||
expect(shellCommand).not.toContain('exec </dev/null')
|
||||
expect(shellCommand).not.toContain('exec >/dev/null')
|
||||
expect(shellCommand).not.toContain('<&3 >&4 3<&- 4>&-')
|
||||
expect(shellCommand).toContain('mkdir -p "$orca_rate_limit_cwd"')
|
||||
expect(shellCommand).toContain('cd "$orca_rate_limit_cwd"')
|
||||
expect(shellCommand).toContain(
|
||||
"export CODEX_HOME='\\''/home/alice/.local/share/orca/account/home'\\''"
|
||||
)
|
||||
expect(shellCommand).toContain('exec codex ')
|
||||
expect(shellCommand).toContain('features.plugins=false')
|
||||
expect(shellCommand).not.toContain('_orca_codex')
|
||||
expect(shellCommand).not.toContain('wsl-codex-path')
|
||||
expect(spawnOptions).toEqual(
|
||||
expect.objectContaining({
|
||||
cwd: expect.stringContaining('rate-limit-pty-cwd'),
|
||||
env: expect.not.objectContaining({ CODEX_HOME: expect.anything() })
|
||||
})
|
||||
)
|
||||
|
||||
const onPtyData = ptyHandlers.onData
|
||||
if (!onPtyData) {
|
||||
throw new Error('PTY data handler was not registered')
|
||||
}
|
||||
onPtyData('>')
|
||||
onPtyData('5h limit: 17%\nWeekly limit: 23%\n')
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
session: { usedPercent: 17 },
|
||||
weekly: { usedPercent: 23 },
|
||||
status: 'ok'
|
||||
})
|
||||
await expect(resultPromise).resolves.toMatchObject({ status: 'error' })
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
expect(childSpawnMock).toHaveBeenCalledTimes(1)
|
||||
expect(ptySpawnMock).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
if (originalCodexHome === undefined) {
|
||||
delete process.env.CODEX_HOME
|
||||
} else {
|
||||
process.env.CODEX_HOME = originalCodexHome
|
||||
}
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: originalPlatform
|
||||
|
||||
@@ -2,10 +2,7 @@ import type { CodexRateLimitResetOutcome, ProviderRateLimits } from '../../share
|
||||
import { isCodexAuthError } from '../../shared/codex-auth-errors'
|
||||
import { buildWslExecArgs, buildWslLoginShellCommand } from '../../shared/wsl-login-shell-command'
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
import {
|
||||
CODEX_DISABLE_PLUGINS_ARGS,
|
||||
CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS
|
||||
} from '../codex-cli/codex-read-only-app-server-args'
|
||||
import { CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS } from '../codex-cli/codex-read-only-app-server-args'
|
||||
import { resolveCodexCommand } from '../codex-cli/command'
|
||||
// Why: import from the shared module, not the codex-cli re-export, so a test that
|
||||
// mocks '../codex-cli/command' does not have to restate this pure helper.
|
||||
@@ -16,9 +13,7 @@ import {
|
||||
} from '../codex-cli/codex-home-process-lock'
|
||||
import { isCodexStateDbBackfillPending } from '../codex/codex-state-db'
|
||||
import { startCodexStateDbBackfillRecoveryInBackground } from '../codex/codex-state-db-backfill-recovery'
|
||||
import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic'
|
||||
import { spawnProcess } from '../../shared/child-process/run-process'
|
||||
import { getCmdExePath } from '../win32-utils'
|
||||
import { probeCodexAuthPresence } from './codex-auth-presence'
|
||||
import {
|
||||
fetchCodexRateLimitsViaBackend,
|
||||
@@ -26,7 +21,6 @@ import {
|
||||
} from './codex-backend-usage-client'
|
||||
import type { CodexRateLimitFetchOptions } from './codex-rate-limit-fetch-options'
|
||||
import { abortedCodexRateLimitResult } from './codex-rate-limit-fetch-result'
|
||||
import { fetchCodexRateLimitsViaPty } from './codex-pty-rate-limit-probe'
|
||||
import { terminateCodexProbeChild } from './codex-probe-termination'
|
||||
import {
|
||||
consumeCodexRateLimitResetCreditFromBackend,
|
||||
@@ -47,17 +41,11 @@ const WSL_RPC_TIMEOUT_MS = 25_000
|
||||
const RPC_INIT_TIMEOUT_MS = 30_000
|
||||
const WSL_RPC_INIT_TIMEOUT_MS = 40_000
|
||||
|
||||
// Keep the PTY fallback aligned with the RPC probe: rate-limit collection does
|
||||
// not need marketplace/plugin startup, and those background clones can outlive
|
||||
// the short-lived probe process.
|
||||
const CODEX_RATE_LIMIT_PLUGIN_ARGS = CODEX_DISABLE_PLUGINS_ARGS
|
||||
|
||||
export type FetchCodexRateLimitsOptions = CodexRateLimitFetchOptions
|
||||
|
||||
function buildWslCodexCommand(
|
||||
codexHomePath: string,
|
||||
args: string[],
|
||||
isolateRpcStdio: boolean
|
||||
args: string[]
|
||||
): { command: string; args: string[] } | null {
|
||||
const wslInfo = parseWslUncPath(codexHomePath)
|
||||
if (process.platform !== 'win32' || !wslInfo) {
|
||||
@@ -67,15 +55,17 @@ function buildWslCodexCommand(
|
||||
...getHiddenRateLimitWslCwdSetupCommands(),
|
||||
`export CODEX_HOME=${quoteHiddenRateLimitShellValue(wslInfo.linuxPath)}`
|
||||
].join(' && ')
|
||||
const execSuffix = `${args.map(quoteHiddenRateLimitShellValue).join(' ')}${
|
||||
isolateRpcStdio ? ' <&3 >&4 3<&- 4>&-' : ''
|
||||
}`
|
||||
const execSuffix = `${args.map(quoteHiddenRateLimitShellValue).join(' ')} <&3 >&4 3<&- 4>&-`
|
||||
const loginShellCommand = buildWslLoginShellCommand(
|
||||
[setupCommands, `exec codex ${execSuffix}`].join(' && ')
|
||||
)
|
||||
const command = isolateRpcStdio
|
||||
? ['exec 3<&0', 'exec 4>&1', 'exec </dev/null', 'exec >/dev/null', loginShellCommand].join('\n')
|
||||
: loginShellCommand
|
||||
const command = [
|
||||
'exec 3<&0',
|
||||
'exec 4>&1',
|
||||
'exec </dev/null',
|
||||
'exec >/dev/null',
|
||||
loginShellCommand
|
||||
].join('\n')
|
||||
return {
|
||||
command: 'wsl.exe',
|
||||
args: buildWslExecArgs(wslInfo.distro, ['sh', '-c', command])
|
||||
@@ -106,7 +96,7 @@ async function fetchViaRpc(options?: CodexRateLimitFetchOptions): Promise<Provid
|
||||
}
|
||||
const codexArgs = [...CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS]
|
||||
const wslCodex = options?.codexHomePath
|
||||
? buildWslCodexCommand(options.codexHomePath, codexArgs, true)
|
||||
? buildWslCodexCommand(options.codexHomePath, codexArgs)
|
||||
: null
|
||||
const codexCommand = wslCodex ? 'codex' : resolveCodexCommand()
|
||||
// Why the bare CLI: spawnProcess resolves an npm `codex.cmd` shim past cmd.exe itself.
|
||||
@@ -130,28 +120,6 @@ async function fetchViaRpc(options?: CodexRateLimitFetchOptions): Promise<Provid
|
||||
})
|
||||
}
|
||||
|
||||
function resolvePtyCommand(options?: CodexRateLimitFetchOptions) {
|
||||
const wslCodex = options?.codexHomePath
|
||||
? buildWslCodexCommand(options.codexHomePath, [...CODEX_RATE_LIMIT_PLUGIN_ARGS], false)
|
||||
: null
|
||||
const codexCommand = wslCodex ? 'codex' : resolveCodexCommand()
|
||||
const isWin32 = process.platform === 'win32'
|
||||
return {
|
||||
command: wslCodex ? wslCodex.command : isWin32 ? getCmdExePath() : codexCommand,
|
||||
args: wslCodex
|
||||
? wslCodex.args
|
||||
: isWin32
|
||||
? ['/d', '/c', codexCommand, ...CODEX_RATE_LIMIT_PLUGIN_ARGS]
|
||||
: [...CODEX_RATE_LIMIT_PLUGIN_ARGS],
|
||||
cwd: resolveHiddenRateLimitPtyCwd(),
|
||||
env: withCliRuntimeOnPath(codexCommand, {
|
||||
...(wslCodex ? processEnvWithoutCodexHome() : process.env),
|
||||
TERM: 'xterm-256color',
|
||||
...(options?.codexHomePath && !wslCodex ? { CODEX_HOME: options.codexHomePath } : {})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function consumeCodexRateLimitResetCredit(options: {
|
||||
codexHomePath?: string | null
|
||||
idempotencyKey: string
|
||||
@@ -178,19 +146,19 @@ function codexUnavailable(error: string, status: 'error' | 'unavailable'): Provi
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWslBackend(
|
||||
options: CodexRateLimitFetchOptions
|
||||
async function fetchBackendUsage(
|
||||
options: CodexRateLimitFetchOptions | undefined
|
||||
): Promise<ProviderRateLimits | null> {
|
||||
try {
|
||||
const result = await fetchCodexRateLimitsViaBackend(fetchCodexUsage, options)
|
||||
if (options.signal?.aborted) {
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedCodexRateLimitResult()
|
||||
}
|
||||
return result
|
||||
? supplementCodexRateLimitResetCredits(result, fetchCodexResetCredits, options)
|
||||
: null
|
||||
} catch {
|
||||
return options.signal?.aborted ? abortedCodexRateLimitResult() : null
|
||||
return options?.signal?.aborted ? abortedCodexRateLimitResult() : null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,10 +186,11 @@ export async function fetchCodexRateLimits(
|
||||
)
|
||||
}
|
||||
|
||||
if (options?.codexHomePath && parseWslUncPath(options.codexHomePath)) {
|
||||
const backendResult = await fetchWslBackend(options)
|
||||
const isWslHome = Boolean(options?.codexHomePath && parseWslUncPath(options.codexHomePath))
|
||||
if (isWslHome) {
|
||||
const backendResult = await fetchBackendUsage(options)
|
||||
if (backendResult) {
|
||||
return options.signal?.aborted ? abortedCodexRateLimitResult() : backendResult
|
||||
return options?.signal?.aborted ? abortedCodexRateLimitResult() : backendResult
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,9 +202,12 @@ export async function fetchCodexRateLimits(
|
||||
)
|
||||
}
|
||||
|
||||
const homeLockKey = resolveCodexHomeProcessLockKey(options?.codexHomePath)
|
||||
let rpcFailure: ProviderRateLimits
|
||||
try {
|
||||
const rpcResult = await withCodexHomeProcessLock(homeLockKey, () => fetchViaRpc(options))
|
||||
const rpcResult = await withCodexHomeProcessLock(
|
||||
resolveCodexHomeProcessLockKey(options?.codexHomePath),
|
||||
() => fetchViaRpc(options)
|
||||
)
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedCodexRateLimitResult()
|
||||
}
|
||||
@@ -243,39 +215,24 @@ export async function fetchCodexRateLimits(
|
||||
const supplemented = await supplementBackendMetadata(rpcResult, options)
|
||||
return options?.signal?.aborted ? abortedCodexRateLimitResult() : supplemented
|
||||
}
|
||||
if (isCodexAuthError(rpcResult.error) || options?.allowPtyFallback === false) {
|
||||
if (isCodexAuthError(rpcResult.error)) {
|
||||
return rpcResult
|
||||
}
|
||||
rpcFailure = rpcResult
|
||||
} catch {
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedCodexRateLimitResult()
|
||||
}
|
||||
if (options?.allowPtyFallback === false) {
|
||||
return codexUnavailable('RPC failed', 'error')
|
||||
}
|
||||
rpcFailure = codexUnavailable('RPC failed', 'error')
|
||||
}
|
||||
|
||||
try {
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedCodexRateLimitResult()
|
||||
}
|
||||
const ptyResult = await withCodexHomeProcessLock(homeLockKey, () =>
|
||||
fetchCodexRateLimitsViaPty(() => resolvePtyCommand(options), options)
|
||||
)
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedCodexRateLimitResult()
|
||||
}
|
||||
const supplemented = await supplementBackendMetadata(ptyResult, options)
|
||||
return options?.signal?.aborted ? abortedCodexRateLimitResult() : supplemented
|
||||
} catch (error) {
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedCodexRateLimitResult()
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
const isNotInstalled = message.includes('ENOENT')
|
||||
return codexUnavailable(
|
||||
isNotInstalled ? 'Codex CLI not found' : withMacTailscaleDnsHint(message),
|
||||
isNotInstalled ? 'unavailable' : 'error'
|
||||
)
|
||||
if (isWslHome) {
|
||||
return rpcFailure
|
||||
}
|
||||
// Why: read usage over HTTP, never by driving the interactive Codex TUI — keystrokes sent there can accept startup dialogs such as "Update now" (#17415).
|
||||
const backendResult = await fetchBackendUsage(options)
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedCodexRateLimitResult()
|
||||
}
|
||||
return backendResult ?? rpcFailure
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { fetchCodexRateLimitsViaPty } from './codex-pty-rate-limit-probe'
|
||||
|
||||
describe('Codex PTY rate-limit probe cancellation', () => {
|
||||
it('does not resolve the process command after cancellation', async () => {
|
||||
const controller = new AbortController()
|
||||
const resolveCommand = vi.fn(() => ({
|
||||
command: 'codex',
|
||||
args: [],
|
||||
cwd: '.',
|
||||
env: {}
|
||||
}))
|
||||
controller.abort()
|
||||
|
||||
await expect(
|
||||
fetchCodexRateLimitsViaPty(resolveCommand, { signal: controller.signal })
|
||||
).resolves.toMatchObject({
|
||||
provider: 'codex',
|
||||
status: 'error',
|
||||
error: 'Rate-limit fetch aborted'
|
||||
})
|
||||
expect(resolveCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,236 +0,0 @@
|
||||
import type { ProviderRateLimits } from '../../shared/rate-limit-types'
|
||||
import { extractCodexAuthError } from '../../shared/codex-auth-errors'
|
||||
import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic'
|
||||
import { cleanupHiddenRateLimitPty, registerHiddenRateLimitPty } from './hidden-pty-cleanup'
|
||||
import type { CodexRateLimitFetchOptions } from './codex-rate-limit-fetch-options'
|
||||
import { abortedCodexRateLimitResult } from './codex-rate-limit-fetch-result'
|
||||
import {
|
||||
hasCodexPtyRateLimit,
|
||||
parseCodexPtyStatus,
|
||||
stripCodexPtyControlSequences
|
||||
} from './codex-pty-status-parser'
|
||||
|
||||
const PTY_TIMEOUT_MS = 15_000
|
||||
const PTY_STATUS_NUDGE_MS = 2_500
|
||||
const PTY_STATUS_ENTER_DELAY_MS = 350
|
||||
const PTY_STATUS_ENTER_RETRY_MS = 3_000
|
||||
const MAX_DIAGNOSTIC_OUTPUT_LENGTH = 100_000
|
||||
|
||||
export type CodexPtyRateLimitCommand = {
|
||||
command: string
|
||||
args: string[]
|
||||
cwd: string
|
||||
env: NodeJS.ProcessEnv
|
||||
}
|
||||
|
||||
export async function fetchCodexRateLimitsViaPty(
|
||||
resolveCommand: () => CodexPtyRateLimitCommand,
|
||||
options?: CodexRateLimitFetchOptions
|
||||
): Promise<ProviderRateLimits> {
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedCodexRateLimitResult()
|
||||
}
|
||||
const pty = await import('node-pty')
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedCodexRateLimitResult()
|
||||
}
|
||||
const command = resolveCommand()
|
||||
|
||||
return new Promise<ProviderRateLimits>((resolve) => {
|
||||
let output = ''
|
||||
let resolved = false
|
||||
let sentStatus = false
|
||||
let settleTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const term = pty.spawn(command.command, command.args, {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: command.cwd,
|
||||
env: command.env
|
||||
})
|
||||
const termDisposables: { dispose: () => void }[] = [registerHiddenRateLimitPty(term)]
|
||||
|
||||
let statusEnter: ReturnType<typeof setTimeout> | null = null
|
||||
let statusNudge: ReturnType<typeof setTimeout> | null = null
|
||||
function sendStatusCommand(): void {
|
||||
sentStatus = true
|
||||
if (statusNudge) {
|
||||
clearTimeout(statusNudge)
|
||||
statusNudge = null
|
||||
}
|
||||
term.write('/status')
|
||||
statusEnter = setTimeout(() => {
|
||||
statusEnter = null
|
||||
term.write('\r')
|
||||
statusEnter = setTimeout(() => {
|
||||
statusEnter = null
|
||||
if (!resolved && !settleTimer) {
|
||||
term.write('\r')
|
||||
}
|
||||
}, PTY_STATUS_ENTER_RETRY_MS)
|
||||
}, PTY_STATUS_ENTER_DELAY_MS)
|
||||
}
|
||||
|
||||
function armStatusNudge(): void {
|
||||
if (statusNudge || sentStatus || resolved) {
|
||||
return
|
||||
}
|
||||
statusNudge = setTimeout(() => {
|
||||
statusNudge = null
|
||||
if (!resolved && !sentStatus) {
|
||||
sendStatusCommand()
|
||||
}
|
||||
}, PTY_STATUS_NUDGE_MS)
|
||||
}
|
||||
termDisposables.push({
|
||||
dispose: () => {
|
||||
if (statusNudge) {
|
||||
clearTimeout(statusNudge)
|
||||
statusNudge = null
|
||||
}
|
||||
if (statusEnter) {
|
||||
clearTimeout(statusEnter)
|
||||
statusEnter = null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function clearSettleTimers(): void {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
timeout = null
|
||||
}
|
||||
if (settleTimer) {
|
||||
clearTimeout(settleTimer)
|
||||
settleTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function settleAborted(): void {
|
||||
if (resolved) {
|
||||
return
|
||||
}
|
||||
resolved = true
|
||||
clearSettleTimers()
|
||||
cleanupHiddenRateLimitPty(term, termDisposables, { kill: true })
|
||||
resolve(abortedCodexRateLimitResult())
|
||||
}
|
||||
|
||||
if (options?.signal) {
|
||||
if (options.signal.aborted) {
|
||||
settleAborted()
|
||||
return
|
||||
}
|
||||
options.signal.addEventListener('abort', settleAborted, { once: true })
|
||||
termDisposables.push({
|
||||
dispose: () => options.signal?.removeEventListener('abort', settleAborted)
|
||||
})
|
||||
}
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true
|
||||
clearSettleTimers()
|
||||
cleanupHiddenRateLimitPty(term, termDisposables, { kill: true })
|
||||
resolve({
|
||||
provider: 'codex',
|
||||
session: null,
|
||||
weekly: null,
|
||||
updatedAt: Date.now(),
|
||||
error: extractCodexAuthError(output) ?? withMacTailscaleDnsHint('PTY timeout', output),
|
||||
status: 'error'
|
||||
})
|
||||
}
|
||||
}, PTY_TIMEOUT_MS)
|
||||
|
||||
const onDataDisposable = term.onData((data) => {
|
||||
output += data
|
||||
if (output.length > MAX_DIAGNOSTIC_OUTPUT_LENGTH) {
|
||||
output = output.slice(-MAX_DIAGNOSTIC_OUTPUT_LENGTH)
|
||||
}
|
||||
|
||||
const authError = extractCodexAuthError(output)
|
||||
if (authError) {
|
||||
resolved = true
|
||||
clearSettleTimers()
|
||||
cleanupHiddenRateLimitPty(term, termDisposables, { kill: true })
|
||||
resolve({
|
||||
provider: 'codex',
|
||||
session: null,
|
||||
weekly: null,
|
||||
updatedAt: Date.now(),
|
||||
error: authError,
|
||||
status: 'error'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
armStatusNudge()
|
||||
if (!sentStatus && /[>›]\s*$/.test(data)) {
|
||||
sendStatusCommand()
|
||||
return
|
||||
}
|
||||
const probe = sentStatus && !settleTimer ? stripCodexPtyControlSequences(output) : null
|
||||
if (probe !== null && hasCodexPtyRateLimit(probe)) {
|
||||
settleTimer = setTimeout(() => {
|
||||
settleTimer = null
|
||||
if (resolved) {
|
||||
return
|
||||
}
|
||||
resolved = true
|
||||
clearSettleTimers()
|
||||
cleanupHiddenRateLimitPty(term, termDisposables, { kill: true })
|
||||
const clean = stripCodexPtyControlSequences(output)
|
||||
const { session, weekly } = parseCodexPtyStatus(clean)
|
||||
resolve({
|
||||
provider: 'codex',
|
||||
session,
|
||||
weekly,
|
||||
updatedAt: Date.now(),
|
||||
error:
|
||||
session || weekly
|
||||
? null
|
||||
: withMacTailscaleDnsHint('Failed to parse CLI output', clean),
|
||||
status: session || weekly ? 'ok' : 'error'
|
||||
})
|
||||
}, 500)
|
||||
}
|
||||
})
|
||||
if (onDataDisposable) {
|
||||
termDisposables.push(onDataDisposable)
|
||||
}
|
||||
|
||||
const onExitDisposable = term.onExit(() => {
|
||||
cleanupHiddenRateLimitPty(term, termDisposables, { kill: false })
|
||||
if (settleTimer) {
|
||||
clearTimeout(settleTimer)
|
||||
settleTimer = null
|
||||
}
|
||||
if (!resolved) {
|
||||
resolved = true
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
const clean = stripCodexPtyControlSequences(output)
|
||||
const { session, weekly } = parseCodexPtyStatus(clean)
|
||||
resolve({
|
||||
provider: 'codex',
|
||||
session,
|
||||
weekly,
|
||||
updatedAt: Date.now(),
|
||||
error:
|
||||
session || weekly
|
||||
? null
|
||||
: (extractCodexAuthError(clean) ??
|
||||
withMacTailscaleDnsHint('CLI exited before status was available', clean)),
|
||||
status: session || weekly ? 'ok' : 'error'
|
||||
})
|
||||
}
|
||||
})
|
||||
if (onExitDisposable) {
|
||||
termDisposables.push(onExitDisposable)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseCodexPtyStatus } from './codex-pty-status-parser'
|
||||
|
||||
describe('Codex PTY status parser', () => {
|
||||
it('uses the account row orientation and reset when a model-scoped weekly row renders first', () => {
|
||||
const result = parseCodexPtyStatus(
|
||||
'GPT-5.3-Codex-Spark Weekly limit: 100% left (resets in 1d 2h)\n' +
|
||||
'Weekly limit: 43% left (resets in 5d 3h)\n'
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
session: null,
|
||||
weekly: {
|
||||
usedPercent: 57,
|
||||
resetDescription: '5d 3h'
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,62 +0,0 @@
|
||||
import type { RateLimitWindow } from '../../shared/rate-limit-types'
|
||||
import { extractClaudePtyResetMetadata } from './claude-pty-reset-parser'
|
||||
|
||||
// Why: reject model-scoped rows regardless of row order in cursor-positioned output.
|
||||
const FIVE_HOUR_RE = /(?<![\w-][^\S\r\n]{0,4})5h\s+limit[^\d%\r\n]*(\d+)%(?:\s*(used|left))?/i
|
||||
const WEEKLY_RE = /(?<![\w-][^\S\r\n]{0,4})weekly\s+limit[^\d%\r\n]*(\d+)%(?:\s*(used|left))?/i
|
||||
const ANY_LIMIT_LABEL_RE = /(?:5h|weekly)\s+limit/i
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const PTY_CONTROL_SEQUENCE_RE = /\x1b\[[0-?]*[ -/]*[@-~]/g
|
||||
|
||||
export function stripCodexPtyControlSequences(output: string): string {
|
||||
return output.replace(PTY_CONTROL_SEQUENCE_RE, '')
|
||||
}
|
||||
|
||||
export function hasCodexPtyRateLimit(output: string): boolean {
|
||||
return FIVE_HOUR_RE.test(output) || WEEKLY_RE.test(output)
|
||||
}
|
||||
|
||||
function ptyUsedPercent(match: RegExpExecArray): number {
|
||||
const pct = Number.parseInt(match[1], 10)
|
||||
const oriented = match[2]?.toLowerCase() === 'left' ? 100 - pct : pct
|
||||
return Math.min(100, Math.max(0, oriented))
|
||||
}
|
||||
|
||||
export function parseCodexPtyStatus(output: string): {
|
||||
session: RateLimitWindow | null
|
||||
weekly: RateLimitWindow | null
|
||||
} {
|
||||
const fiveMatch = FIVE_HOUR_RE.exec(output)
|
||||
const weeklyMatch = WEEKLY_RE.exec(output)
|
||||
const lines = output.split(/\r\n|\n|\r/)
|
||||
const isLimitLabel = (line: string): boolean => ANY_LIMIT_LABEL_RE.test(line)
|
||||
const sessionReset = extractClaudePtyResetMetadata(
|
||||
lines,
|
||||
(line) => FIVE_HOUR_RE.test(line),
|
||||
isLimitLabel
|
||||
)
|
||||
const weeklyReset = extractClaudePtyResetMetadata(
|
||||
lines,
|
||||
(line) => WEEKLY_RE.test(line),
|
||||
isLimitLabel
|
||||
)
|
||||
|
||||
return {
|
||||
session: fiveMatch
|
||||
? {
|
||||
usedPercent: ptyUsedPercent(fiveMatch),
|
||||
windowMinutes: 300,
|
||||
resetsAt: sessionReset.resetsAt,
|
||||
resetDescription: sessionReset.resetDescription
|
||||
}
|
||||
: null,
|
||||
weekly: weeklyMatch
|
||||
? {
|
||||
usedPercent: ptyUsedPercent(weeklyMatch),
|
||||
windowMinutes: 10080,
|
||||
resetsAt: weeklyReset.resetsAt,
|
||||
resetDescription: weeklyReset.resetDescription
|
||||
}
|
||||
: null
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
export type CodexRateLimitFetchOptions = {
|
||||
codexHomePath?: string | null
|
||||
allowPtyFallback?: boolean
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
@@ -144,7 +144,6 @@ describe('RateLimitService', () => {
|
||||
expect(fetchCodexRateLimits).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
codexHomePath: wslCodexHome,
|
||||
allowPtyFallback: false,
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
)
|
||||
|
||||
@@ -127,7 +127,6 @@ export abstract class RateLimitServiceFetchTargets extends RateLimitServiceResul
|
||||
try {
|
||||
fresh = await fetchCodexRateLimits({
|
||||
codexHomePath,
|
||||
allowPtyFallback: this.shouldAllowCodexPtyFallback(),
|
||||
signal: controller.signal
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -172,11 +171,6 @@ export abstract class RateLimitServiceFetchTargets extends RateLimitServiceResul
|
||||
return { ...stateBeforeReset, codex: scopedCodex, codexTarget: target }
|
||||
}
|
||||
|
||||
protected shouldAllowCodexPtyFallback(): boolean {
|
||||
// Why: hidden PTY fallback can crash inside ConPTY on Windows; prefer RPC-only degradation there for background quota refresh.
|
||||
return process.platform !== 'win32'
|
||||
}
|
||||
|
||||
protected shouldAllowClaudePtyFallback(
|
||||
authPreparation: ClaudeRuntimeAuthPreparation | undefined
|
||||
): boolean {
|
||||
|
||||
@@ -183,7 +183,6 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ
|
||||
: (missingWslCodexHome ??
|
||||
fetchCodexRateLimits({
|
||||
codexHomePath,
|
||||
allowPtyFallback: this.shouldAllowCodexPtyFallback(),
|
||||
signal
|
||||
})),
|
||||
fetchGeminiRateLimits(geminiCliOAuthEnabled),
|
||||
|
||||
@@ -97,7 +97,7 @@ export abstract class RateLimitServiceInactiveAccounts extends RateLimitServiceP
|
||||
if (accounts.length === 0) {
|
||||
return
|
||||
}
|
||||
// Why: account switching can activate a previewed account while its RPC-only fetch is still in flight; ignore stale results.
|
||||
// Why: account switching can activate a previewed account while its usage fetch is still in flight; ignore stale results.
|
||||
const fetchGeneration = this.inactiveCodexAccountsGeneration
|
||||
const controller = this.beginFetchCycle()
|
||||
const signal = controller.signal
|
||||
@@ -143,10 +143,8 @@ export abstract class RateLimitServiceInactiveAccounts extends RateLimitServiceP
|
||||
this.pushToRenderer()
|
||||
try {
|
||||
// Why: point fetchCodexRateLimits at the managed home directly, avoiding materializing credentials into the shared runtime location.
|
||||
// Why: no PTY fallback — the switcher preview shouldn't spawn hidden PTYs per account (can crash ConPTY on Windows); RPC-only is enough.
|
||||
const fresh = await fetchCodexRateLimits({
|
||||
codexHomePath: home.managedHomePath,
|
||||
allowPtyFallback: false,
|
||||
signal
|
||||
})
|
||||
if (
|
||||
|
||||
@@ -40,7 +40,6 @@ export abstract class RateLimitServiceProviderCycles extends RateLimitServiceFul
|
||||
? Promise.resolve(missingWslCodexHome)
|
||||
: fetchCodexRateLimits({
|
||||
codexHomePath,
|
||||
allowPtyFallback: this.shouldAllowCodexPtyFallback(),
|
||||
signal
|
||||
})
|
||||
).catch((err): ProviderRateLimits => ({
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"capturedAt": "2026-09-23T21:11:11.946Z",
|
||||
"platform": "linux",
|
||||
"command": ["hermes", "--yolo", "--tui"],
|
||||
"cols": 120,
|
||||
"rows": 31,
|
||||
"note": "Hermes Agent v0.21.4 on headless Linux, ready without a submitted prompt; command path, model label, tool inventory, and session marker anonymized.",
|
||||
"exitCode": 0
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,128 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness'
|
||||
import { createHookListenerState } from '../../shared/agent-hook-listener/listener-state'
|
||||
import { normalizeHermesEvent } from '../../shared/agent-hook-listener/providers/hermes-events'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: { fromId: vi.fn(() => null) },
|
||||
webContents: { fromId: vi.fn(() => null) },
|
||||
ipcMain: { on: vi.fn(), removeListener: vi.fn() },
|
||||
app: { getPath: vi.fn(() => '/tmp') }
|
||||
}))
|
||||
|
||||
const captured = readFileSync(join(__dirname, '__fixtures__', 'hermes-tui-ready.txt'), 'utf8')
|
||||
|
||||
/** The real payload Orca's managed Hermes plugin produces for `eventName`, wired through the
|
||||
* same normalizer the hook relay uses — so a regression in the event mapping fails here too,
|
||||
* not only in the provider's own unit test. */
|
||||
const status = (eventName: string, payload: Record<string, unknown> = {}): string => {
|
||||
const parsed = normalizeHermesEvent(createHookListenerState(), eventName, '', 'pane-1', payload)
|
||||
expect(parsed, `hermes plugin event ${eventName} must normalize to a status`).not.toBeNull()
|
||||
return `\x1b]9999;${JSON.stringify(parsed)}\x07`
|
||||
}
|
||||
|
||||
const readyPane = async () =>
|
||||
createTranscriptPane({
|
||||
paneTitle: 'Hermes Agent',
|
||||
foregroundProcess: 'hermes',
|
||||
launchAgent: 'hermes',
|
||||
size: { cols: 120, rows: 31 },
|
||||
data: captured
|
||||
})
|
||||
|
||||
describe('Hermes TUI readiness from a captured PTY', () => {
|
||||
it('settles tui-idle on the session-boundary row a freshly launched Hermes emits', async () => {
|
||||
const { runtime, handle } = await readyPane()
|
||||
// What Orca's own managed plugin sends for `on_session_start`.
|
||||
runtime.onPtyData(
|
||||
TRANSCRIPT_PANE_PTY_ID,
|
||||
status('on_session_start', { session_id: 's1' }),
|
||||
Date.now()
|
||||
)
|
||||
|
||||
const read = vi.spyOn(runtime, 'readTerminal')
|
||||
await expect(
|
||||
runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_000 })
|
||||
).resolves.toMatchObject({ satisfied: true })
|
||||
// The boundary row is tier-1 evidence, so no pane's screen is serialized to settle it.
|
||||
expect(read.mock.calls.filter(([, options]) => options?.screen === true)).toEqual([])
|
||||
}, 5_000)
|
||||
|
||||
it('settles through a leaf handle whose retained tail never showed the ready screen', async () => {
|
||||
const { runtime } = await readyPane()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Access the real synced leaf and handle issuer for this route regression.
|
||||
const internals = runtime as unknown as {
|
||||
leaves: Map<string, { tailBuffer: string[]; tailPartialLine: string }>
|
||||
issueHandle: (leaf: unknown) => string
|
||||
}
|
||||
const leaf = internals.leaves.values().next().value
|
||||
expect(leaf).toBeDefined()
|
||||
expect([...leaf!.tailBuffer, leaf!.tailPartialLine].join('\n')).not.toMatch(/\bready\b/i)
|
||||
runtime.onPtyData(
|
||||
TRANSCRIPT_PANE_PTY_ID,
|
||||
status('on_session_start', { session_id: 's1' }),
|
||||
Date.now()
|
||||
)
|
||||
|
||||
await expect(
|
||||
runtime.waitForTerminal(internals.issueHandle(leaf), {
|
||||
condition: 'tui-idle',
|
||||
timeoutMs: 2_000
|
||||
})
|
||||
).resolves.toMatchObject({ satisfied: true })
|
||||
}, 5_000)
|
||||
|
||||
it('does not settle while a turn the user started is still running', async () => {
|
||||
const { runtime, handle } = await readyPane()
|
||||
runtime.onPtyData(
|
||||
TRANSCRIPT_PANE_PTY_ID,
|
||||
status('on_session_start', { session_id: 's1' }),
|
||||
Date.now()
|
||||
)
|
||||
// `pre_llm_call` — the first event that means a turn actually began.
|
||||
runtime.onPtyData(
|
||||
TRANSCRIPT_PANE_PTY_ID,
|
||||
status('pre_llm_call', { user_message: 'hi' }),
|
||||
Date.now()
|
||||
)
|
||||
|
||||
await expect(
|
||||
runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 550 })
|
||||
).rejects.toThrow('timeout')
|
||||
}, 3_000)
|
||||
|
||||
it('does not settle on a turn-end done row, only on a session boundary', async () => {
|
||||
const { runtime, handle } = await readyPane()
|
||||
// `post_llm_call` lands `done` without the boundary flag; the #6011 rule still applies.
|
||||
runtime.onPtyData(
|
||||
TRANSCRIPT_PANE_PTY_ID,
|
||||
status('post_llm_call', { session_id: 's1' }),
|
||||
Date.now()
|
||||
)
|
||||
|
||||
await expect(
|
||||
runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 550 })
|
||||
).rejects.toThrow('timeout')
|
||||
}, 3_000)
|
||||
|
||||
it('reports a visible blocker instead of readiness when Hermes asks for approval', async () => {
|
||||
const { runtime, handle } = await readyPane()
|
||||
runtime.onPtyData(
|
||||
TRANSCRIPT_PANE_PTY_ID,
|
||||
status('on_session_start', { session_id: 's1' }),
|
||||
Date.now()
|
||||
)
|
||||
// `pre_approval_request` supersedes the boundary row the session opened with.
|
||||
runtime.onPtyData(
|
||||
TRANSCRIPT_PANE_PTY_ID,
|
||||
status('pre_approval_request', { command: 'rm -rf build' }),
|
||||
Date.now()
|
||||
)
|
||||
|
||||
await expect(
|
||||
runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 550 })
|
||||
).rejects.toThrow('timeout')
|
||||
}, 3_000)
|
||||
})
|
||||
@@ -184,7 +184,11 @@ export class OrcaRuntimeWithMaybeHydrateHeadlessFromRenderer extends OrcaRuntime
|
||||
pty.launchAgent
|
||||
)
|
||||
if (payload) {
|
||||
pty.lastExplicitAgentStatus = { state: payload.state, updatedAt: Date.now() }
|
||||
pty.lastExplicitAgentStatus = {
|
||||
state: payload.state,
|
||||
updatedAt: Date.now(),
|
||||
sessionBoundary: payload.sessionBoundary
|
||||
}
|
||||
this.emitTerminalAgentStatusEvents(ptyId, {
|
||||
cleanData: '',
|
||||
payloads: [payload],
|
||||
|
||||
@@ -232,7 +232,8 @@ export class OrcaRuntimeWithOnPtyData extends OrcaRuntimeWithPreparePtyExecution
|
||||
if (ptyRecord) {
|
||||
ptyRecord.lastExplicitAgentStatus = {
|
||||
state: latestAgentStatus.state,
|
||||
updatedAt: Date.now()
|
||||
updatedAt: Date.now(),
|
||||
sessionBoundary: latestAgentStatus.sessionBoundary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,12 @@ export type RuntimePtyWorktreeRecord = RuntimeTerminalTailState & {
|
||||
/** Latest first-party state from the agent's own OSC 9999 status stream — what the
|
||||
* agent SAYS it is doing, as opposed to `lastAgentStatus`, which is inferred from its
|
||||
* OSC title. Optional: absent until a payload lands. */
|
||||
lastExplicitAgentStatus?: { state: AgentStatusState; updatedAt: number } | null
|
||||
lastExplicitAgentStatus?: {
|
||||
state: AgentStatusState
|
||||
updatedAt: number
|
||||
/** A `done` row that marks a new session owning the pane, not the end of a turn. */
|
||||
sessionBoundary?: boolean
|
||||
} | null
|
||||
lastAgentStatusStartedAtEpochMs: number | null
|
||||
lastAgentStatusRichInvalidatedAtEpochMs: number | null
|
||||
lastOscTitle: string | null
|
||||
|
||||
@@ -53,7 +53,11 @@ export type TuiIdleEvidenceRecord = {
|
||||
lastOscTitle?: string | null
|
||||
}
|
||||
|
||||
export type FirstPartyAgentStatus = { state: AgentStatusState; updatedAt: number } | null
|
||||
export type FirstPartyAgentStatus = {
|
||||
state: AgentStatusState
|
||||
updatedAt: number
|
||||
sessionBoundary?: boolean
|
||||
} | null
|
||||
|
||||
/** Tier 1: an idle marker the agent put in a title itself. */
|
||||
export function hasExplicitIdleTitle(
|
||||
@@ -88,13 +92,18 @@ export function hasExplicitIdleTitle(
|
||||
* Scoped rather than general: for agents whose hooks do report child turns, a `done` row
|
||||
* can arrive mid-turn, and settling on it is exactly the #6011 class this file exists to
|
||||
* prevent.
|
||||
*
|
||||
* The second lane is narrower and agent-agnostic: a `sessionBoundary` row does not claim a
|
||||
* turn ended, it claims a NEW SESSION owns the pane and is waiting for its first input. That
|
||||
* cannot arrive mid-turn by construction — the producers only set it for a startup/resume/
|
||||
* reset boundary — so it carries no #6011 risk for any agent that emits it.
|
||||
*/
|
||||
export function hasFreshDoneFirstPartyStatus(
|
||||
agent: TuiAgent | null | undefined,
|
||||
status: FirstPartyAgentStatus,
|
||||
staleAfterMs = AGENT_STATUS_STALE_AFTER_MS
|
||||
): boolean {
|
||||
if (agent !== 'dsh' || status?.state !== 'done') {
|
||||
if (status?.state !== 'done' || (agent !== 'dsh' && status.sessionBoundary !== true)) {
|
||||
return false
|
||||
}
|
||||
return Date.now() - status.updatedAt <= staleAfterMs
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readClipboardCopiedFilePaths } from './clipboard-copied-file-paths'
|
||||
|
||||
function clipboardWith(formats: Record<string, Buffer | string>) {
|
||||
return {
|
||||
readBuffer: (format: string): Buffer => {
|
||||
const value = formats[format]
|
||||
return typeof value === 'string' ? Buffer.from(value, 'utf8') : (value ?? Buffer.alloc(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function filenamesPlist(paths: string[]): string {
|
||||
const entries = paths.map((path) => `<string>${path}</string>`).join('')
|
||||
return `<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><array>${entries}</array></plist>`
|
||||
}
|
||||
|
||||
describe('readClipboardCopiedFilePaths', () => {
|
||||
it('lists every file Finder copied, decoding XML entities', () => {
|
||||
const clipboard = clipboardWith({
|
||||
NSFilenamesPboardType: filenamesPlist(['/Users/me/Q&A shot.png', '/Users/me/b.pdf']),
|
||||
'public.file-url': 'file:///Users/me/Q&A%20shot.png'
|
||||
})
|
||||
expect(readClipboardCopiedFilePaths(clipboard, 'darwin')).toEqual([
|
||||
'/Users/me/Q&A shot.png',
|
||||
'/Users/me/b.pdf'
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to the first file URL on macOS, but not a file-reference URL', () => {
|
||||
expect(
|
||||
readClipboardCopiedFilePaths(
|
||||
clipboardWith({ 'public.file-url': 'file:///Users/me/my%20shot.png' }),
|
||||
'darwin'
|
||||
)
|
||||
).toEqual(['/Users/me/my shot.png'])
|
||||
expect(
|
||||
readClipboardCopiedFilePaths(
|
||||
clipboardWith({ 'public.file-url': 'file:///.file/id=6571367.2773272' }),
|
||||
'darwin'
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('reads a Linux file manager uri-list and rejects non-file entries', () => {
|
||||
expect(
|
||||
readClipboardCopiedFilePaths(
|
||||
clipboardWith({
|
||||
'text/uri-list': '# copied\r\nfile:///home/me/a.png\r\nfile:///home/me/b%20c.txt\r\n'
|
||||
}),
|
||||
'linux'
|
||||
)
|
||||
).toEqual(['/home/me/a.png', '/home/me/b c.txt'])
|
||||
expect(
|
||||
readClipboardCopiedFilePaths(
|
||||
clipboardWith({ 'text/uri-list': 'file:///home/me/a.png\nhttps://example.com/x' }),
|
||||
'linux'
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('reads the single file Explorer copied and nothing when it copied several', () => {
|
||||
const shellItems = (count: number): Buffer => {
|
||||
const cida = Buffer.alloc(4 + 4 * (count + 1))
|
||||
cida.writeUInt32LE(count)
|
||||
return cida
|
||||
}
|
||||
const explorer = (count: number) =>
|
||||
clipboardWith({
|
||||
FileNameW: Buffer.from('C:\\Users\\me\\shot.png\0', 'utf16le'),
|
||||
'Shell IDList Array': shellItems(count)
|
||||
})
|
||||
expect(readClipboardCopiedFilePaths(explorer(1), 'win32')).toEqual(['C:\\Users\\me\\shot.png'])
|
||||
expect(readClipboardCopiedFilePaths(explorer(2), 'win32')).toEqual([])
|
||||
})
|
||||
|
||||
it('returns nothing for plain text, oversized lists, or a failing clipboard', () => {
|
||||
expect(readClipboardCopiedFilePaths(clipboardWith({}), 'darwin')).toEqual([])
|
||||
const huge = filenamesPlist(['/a'.padEnd(300 * 1024, 'a')])
|
||||
expect(
|
||||
readClipboardCopiedFilePaths(clipboardWith({ NSFilenamesPboardType: huge }), 'darwin')
|
||||
).toEqual([])
|
||||
const failing = {
|
||||
readBuffer: (): Buffer => {
|
||||
throw new Error('format unavailable')
|
||||
}
|
||||
}
|
||||
expect(readClipboardCopiedFilePaths(failing, 'linux')).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { readWindowsCopiedFilePath } from './clipboard-windows-image-file'
|
||||
|
||||
type ClipboardFormatReader = { readBuffer: (format: string) => Buffer }
|
||||
|
||||
const FILE_LIST_MAX_BYTES = 256 * 1024
|
||||
const XML_ENTITIES: Record<string, string> = {
|
||||
amp: '&',
|
||||
apos: "'",
|
||||
gt: '>',
|
||||
lt: '<',
|
||||
quot: '"'
|
||||
}
|
||||
|
||||
function readBoundedText(clipboard: ClipboardFormatReader, format: string): string {
|
||||
const buffer = clipboard.readBuffer(format)
|
||||
return buffer.byteLength <= FILE_LIST_MAX_BYTES ? buffer.toString('utf8') : ''
|
||||
}
|
||||
|
||||
function decodeXmlText(value: string): string {
|
||||
return value.replace(
|
||||
/&(?:#(\d+)|#x([0-9a-fA-F]+)|(amp|apos|gt|lt|quot));/g,
|
||||
(_entity, decimal: string | undefined, hex: string | undefined, name: string | undefined) =>
|
||||
decimal
|
||||
? String.fromCodePoint(Number(decimal))
|
||||
: hex
|
||||
? String.fromCodePoint(Number.parseInt(hex, 16))
|
||||
: XML_ENTITIES[name ?? '']
|
||||
)
|
||||
}
|
||||
|
||||
/** macOS/Linux file URLs; any other entry means this is not a file copy. */
|
||||
function filePathsFromUrls(urls: readonly string[]): string[] {
|
||||
const paths: string[] = []
|
||||
for (const url of urls) {
|
||||
// Finder can hand out file-reference URLs (/.file/id=…), which name no file.
|
||||
if (!url.startsWith('file://') || url.startsWith('file:///.file/id=')) {
|
||||
return []
|
||||
}
|
||||
paths.push(fileURLToPath(url, { windows: false }))
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
function readMacCopiedFilePaths(clipboard: ClipboardFormatReader): string[] {
|
||||
// Finder's legacy filenames plist lists every copied file; public.file-url holds only the first.
|
||||
const plist = readBoundedText(clipboard, 'NSFilenamesPboardType')
|
||||
const listed = Array.from(plist.matchAll(/<string>([^<]*)<\/string>/g), (match) =>
|
||||
decodeXmlText(match[1])
|
||||
)
|
||||
if (listed.length > 0) {
|
||||
return listed
|
||||
}
|
||||
const url = readBoundedText(clipboard, 'public.file-url').trim()
|
||||
return url ? filePathsFromUrls([url]) : []
|
||||
}
|
||||
|
||||
function readLinuxCopiedFilePaths(clipboard: ClipboardFormatReader): string[] {
|
||||
const urls = readBoundedText(clipboard, 'text/uri-list')
|
||||
.split(/\r\n|\r|\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !line.startsWith('#'))
|
||||
return filePathsFromUrls(urls)
|
||||
}
|
||||
|
||||
/**
|
||||
* Paths of the files a file manager copied, so a paste can tell the text that
|
||||
* labels them from prompt text. A list it cannot read in full comes back empty.
|
||||
*/
|
||||
export function readClipboardCopiedFilePaths(
|
||||
clipboard: ClipboardFormatReader,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): string[] {
|
||||
try {
|
||||
if (platform === 'darwin') {
|
||||
return readMacCopiedFilePaths(clipboard)
|
||||
}
|
||||
if (platform === 'win32') {
|
||||
const filePath = readWindowsCopiedFilePath({
|
||||
fileNameW: clipboard.readBuffer('FileNameW'),
|
||||
shellIdListArray: clipboard.readBuffer('Shell IDList Array')
|
||||
})
|
||||
return filePath ? [filePath] : []
|
||||
}
|
||||
return readLinuxCopiedFilePaths(clipboard)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,9 @@ describe('dashboard popout clipboard access', () => {
|
||||
await expect(handlers.get('clipboard:saveImageAsTempFile')?.(popoutEvent)).rejects.toThrow(
|
||||
'Unauthorized clipboard IPC sender'
|
||||
)
|
||||
expect(() => handlers.get('clipboard:readFilePaths')?.(popoutEvent)).toThrow(
|
||||
'Unauthorized clipboard IPC sender'
|
||||
)
|
||||
expect(() =>
|
||||
handlers.get('clipboard:writeFile')?.(popoutEvent, {
|
||||
filePath: '/tmp/copied-file.txt',
|
||||
|
||||
@@ -26,6 +26,7 @@ const {
|
||||
clipboardReadBufferMock,
|
||||
clipboardWriteTextMock,
|
||||
clipboardReadImageMock,
|
||||
clipboardAvailableFormatsMock,
|
||||
clipboardWriteImageMock,
|
||||
clipboardWriteBufferMock,
|
||||
nativeImageCreateFromBufferMock,
|
||||
@@ -62,6 +63,7 @@ const {
|
||||
clipboardReadBufferMock: vi.fn(),
|
||||
clipboardWriteTextMock: vi.fn(),
|
||||
clipboardReadImageMock: vi.fn(),
|
||||
clipboardAvailableFormatsMock: vi.fn(),
|
||||
clipboardWriteImageMock: vi.fn(),
|
||||
clipboardWriteBufferMock: vi.fn(),
|
||||
nativeImageCreateFromBufferMock: vi.fn(),
|
||||
@@ -109,6 +111,7 @@ vi.mock('electron', () => ({
|
||||
readBuffer: clipboardReadBufferMock,
|
||||
writeText: clipboardWriteTextMock,
|
||||
readImage: clipboardReadImageMock,
|
||||
availableFormats: clipboardAvailableFormatsMock,
|
||||
writeImage: clipboardWriteImageMock,
|
||||
writeBuffer: clipboardWriteBufferMock
|
||||
},
|
||||
@@ -552,6 +555,8 @@ describe('registerClipboardHandlers', () => {
|
||||
expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:writeFile')
|
||||
expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:saveImageAsTempFile')
|
||||
expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:readImageThumbnail')
|
||||
expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:hasImage')
|
||||
expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:readFilePaths')
|
||||
})
|
||||
|
||||
it('does not inspect FileNameW when an empty image clipboard is read outside Windows', async () => {
|
||||
@@ -874,4 +879,18 @@ describe('registerClipboardHandlers', () => {
|
||||
expect(nativeImageCreateFromBufferMock).toHaveBeenCalled()
|
||||
expect(clipboardWriteImageMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[['text/plain'], false],
|
||||
[['text/plain', 'image/png'], true]
|
||||
])('reports image presence for %j from the format list without decoding', (formats, expected) => {
|
||||
setTrustedClipboardRendererWebContentsId(17)
|
||||
clipboardAvailableFormatsMock.mockReturnValue(formats)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: registering handlers never reads the store for clipboard image presence.
|
||||
registerClipboardHandlers({} as never)
|
||||
const probe = getRegisteredHandlers().get('clipboard:hasImage')
|
||||
expect(probe?.(makeClipboardEvent())).toBe(expected)
|
||||
expect(clipboardReadImageMock).not.toHaveBeenCalled()
|
||||
expect(() => probe?.(makeClipboardEvent({ id: 42 }))).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
assertClipboardImageBase64LengthWithinLimit,
|
||||
assertClipboardImageByteLengthWithinLimit,
|
||||
assertClipboardImageDimensionsWithinLimit,
|
||||
clipboardFormatsIncludeImage,
|
||||
type ClipboardImageThumbnail
|
||||
} from '../../shared/clipboard-image'
|
||||
import {
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
} from './clipboard-remote-file-copy'
|
||||
import { saveClipboardImageBufferInRuntime } from './clipboard-runtime-image-upload'
|
||||
import { readWindowsClipboardImageFileAsPng } from './clipboard-windows-image-file'
|
||||
import { readClipboardCopiedFilePaths } from './clipboard-copied-file-paths'
|
||||
import { buildClipboardImageThumbnail } from './clipboard-image-thumbnail'
|
||||
import { writeClipboardTextAndVerify } from './clipboard-text-write-verify'
|
||||
import { isDashboardPopoutRenderer } from './dashboard-popout-window'
|
||||
@@ -96,6 +98,8 @@ export function registerClipboardHandlers(store: Store): void {
|
||||
ipcMain.removeHandler('clipboard:writeFile')
|
||||
ipcMain.removeHandler('clipboard:saveImageAsTempFile')
|
||||
ipcMain.removeHandler('clipboard:readImageThumbnail')
|
||||
ipcMain.removeHandler('clipboard:hasImage')
|
||||
ipcMain.removeHandler('clipboard:readFilePaths')
|
||||
|
||||
void cleanupExpiredRemoteClipboardFiles()
|
||||
scheduleLegacyRemoteClipboardFileCleanup()
|
||||
@@ -117,6 +121,15 @@ export function registerClipboardHandlers(store: Store): void {
|
||||
assertTrustedClipboardSender(event)
|
||||
return buildClipboardImageThumbnail(clipboard.readImage())
|
||||
})
|
||||
ipcMain.handle('clipboard:hasImage', (event): boolean => {
|
||||
assertTrustedClipboardSender(event)
|
||||
return clipboardFormatsIncludeImage(clipboard.availableFormats())
|
||||
})
|
||||
// Why: a file-manager copy also carries the files' names as text, which a paste must not type.
|
||||
ipcMain.handle('clipboard:readFilePaths', (event): string[] => {
|
||||
assertTrustedClipboardSender(event)
|
||||
return readClipboardCopiedFilePaths(clipboard)
|
||||
})
|
||||
// Why: terminals need to detect clipboard images to support tools like Claude
|
||||
// Code that accept image input via paste. Writes the clipboard image to a
|
||||
// temp file and returns the path, or null if the clipboard has no image.
|
||||
|
||||
@@ -13,7 +13,7 @@ type WindowsClipboardImageFileDeps = {
|
||||
openFile: (filePath: string) => Promise<ClipboardImageFileHandle>
|
||||
}
|
||||
|
||||
type WindowsClipboardImageFileFormats = {
|
||||
export type WindowsClipboardFileFormats = {
|
||||
fileNameW: Buffer
|
||||
shellIdListArray: Buffer
|
||||
}
|
||||
@@ -65,7 +65,7 @@ function decodeFileNameW(value: Buffer): string | null {
|
||||
if (!filePath || filePath.includes('\0') || !isFullyQualifiedWindowsPath(filePath)) {
|
||||
return null
|
||||
}
|
||||
return IMAGE_FILE_EXTENSION_SET.has(win32.extname(filePath).toLowerCase()) ? filePath : null
|
||||
return filePath
|
||||
}
|
||||
|
||||
function hasAtMostOneShellItem(value: Buffer): boolean {
|
||||
@@ -76,6 +76,14 @@ function hasAtMostOneShellItem(value: Buffer): boolean {
|
||||
return value.byteLength >= 12 && value.readUInt32LE(0) === 1
|
||||
}
|
||||
|
||||
/** The one file Explorer copied; null when it copied none or several. */
|
||||
export function readWindowsCopiedFilePath({
|
||||
fileNameW,
|
||||
shellIdListArray
|
||||
}: WindowsClipboardFileFormats): string | null {
|
||||
return hasAtMostOneShellItem(shellIdListArray) ? decodeFileNameW(fileNameW) : null
|
||||
}
|
||||
|
||||
function readPngDimensions(source: Buffer): { height: number; width: number } | null {
|
||||
if (
|
||||
source.byteLength < 24 ||
|
||||
@@ -153,14 +161,11 @@ async function readStableFile(
|
||||
}
|
||||
|
||||
export async function readWindowsClipboardImageFileAsPng(
|
||||
{ fileNameW, shellIdListArray }: WindowsClipboardImageFileFormats,
|
||||
formats: WindowsClipboardFileFormats,
|
||||
{ createImageFromBuffer, openFile }: WindowsClipboardImageFileDeps
|
||||
): Promise<Buffer | null> {
|
||||
if (!hasAtMostOneShellItem(shellIdListArray)) {
|
||||
return null
|
||||
}
|
||||
const filePath = decodeFileNameW(fileNameW)
|
||||
if (!filePath) {
|
||||
const filePath = readWindowsCopiedFilePath(formats)
|
||||
if (!filePath || !IMAGE_FILE_EXTENSION_SET.has(win32.extname(filePath).toLowerCase())) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,8 @@ export const uiClipboardAndWindowControlsApi = {
|
||||
connectionId?: string | null
|
||||
runtimeEnvironmentId?: string | null
|
||||
}): Promise<string | null> => ipcRenderer.invoke('clipboard:saveImageAsTempFile', args),
|
||||
clipboardHasImage: (): Promise<boolean> => ipcRenderer.invoke('clipboard:hasImage'),
|
||||
readClipboardFilePaths: (): Promise<string[]> => ipcRenderer.invoke('clipboard:readFilePaths'),
|
||||
readClipboardImageThumbnail: (): Promise<ClipboardImageThumbnail | null> =>
|
||||
ipcRenderer.invoke('clipboard:readImageThumbnail'),
|
||||
writeClipboardText: (text: string): Promise<void> =>
|
||||
|
||||
@@ -13,6 +13,9 @@ export type UiWindowApi = {
|
||||
connectionId?: string | null
|
||||
runtimeEnvironmentId?: string | null
|
||||
}) => Promise<string | null>
|
||||
clipboardHasImage: () => Promise<boolean | null>
|
||||
/** Paths of files a file manager copied; empty when there are none or the host cannot list them. */
|
||||
readClipboardFilePaths: () => Promise<string[]>
|
||||
readClipboardImageThumbnail: () => Promise<ClipboardImageThumbnail | null>
|
||||
writeClipboardText: (text: string) => Promise<void>
|
||||
writeTerminalClipboardText: (text: string) => Promise<void>
|
||||
|
||||
-16
@@ -11,20 +11,4 @@ describe('collectBrowserPageIds identity', () => {
|
||||
expect(collectBrowserPageIds([])).toBe(fromUndefined)
|
||||
expect(fromUndefined).toEqual([])
|
||||
})
|
||||
|
||||
it('still collects page ids, preferring pageIds over the active page', () => {
|
||||
const ids = collectBrowserPageIds([
|
||||
{ id: 'tab-1', pageIds: ['page-a', 'page-b'] },
|
||||
{ id: 'tab-2', activePageId: 'page-c' },
|
||||
{ id: 'tab-3' }
|
||||
])
|
||||
|
||||
expect(ids).toEqual(['page-a', 'page-b', 'page-c', 'tab-3'])
|
||||
})
|
||||
|
||||
it('falls back to the active page when pageIds is present but empty', () => {
|
||||
expect(collectBrowserPageIds([{ id: 'tab-1', pageIds: [], activePageId: 'page-a' }])).toEqual([
|
||||
'page-a'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { isEditableKeyboardTarget } from './browser-keyboard'
|
||||
|
||||
// Why: the old fakes passed a single joined selector to `closest`, so any
|
||||
@@ -35,25 +35,6 @@ describe('isEditableKeyboardTarget', () => {
|
||||
expect(isEditableKeyboardTarget(targetInside(html))).toBe(true)
|
||||
})
|
||||
|
||||
it('queries every editable host in one selector', () => {
|
||||
const closest = vi.fn((_selector: string) => null)
|
||||
isEditableKeyboardTarget({ isContentEditable: false, closest })
|
||||
|
||||
const selector = closest.mock.calls[0][0]
|
||||
const tokens = selector.split(',').map((part) => part.trim())
|
||||
expect(tokens).toEqual([
|
||||
'input',
|
||||
'textarea',
|
||||
'select',
|
||||
'[contenteditable=""]',
|
||||
'[contenteditable="true"]',
|
||||
'.monaco-editor',
|
||||
'.diff-editor',
|
||||
'.rich-markdown-editor',
|
||||
'.rich-markdown-editor-shell'
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to isContentEditable when no host selector matches', () => {
|
||||
expect(isEditableKeyboardTarget({ isContentEditable: true, closest: () => null })).toBe(true)
|
||||
})
|
||||
|
||||
-64
@@ -17,68 +17,4 @@ describe('getRemoteBrowserFrameStyle', () => {
|
||||
objectPosition: 'top left'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps correctly sized frames filling the viewport', () => {
|
||||
expect(
|
||||
getRemoteBrowserFrameStyle({
|
||||
imageWidth: 958,
|
||||
imageHeight: 609,
|
||||
deviceWidth: 958,
|
||||
deviceHeight: 609
|
||||
})
|
||||
).toEqual({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'fill',
|
||||
objectPosition: 'top left'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not crop high-DPI frames with a uniform device scale', () => {
|
||||
expect(
|
||||
getRemoteBrowserFrameStyle({
|
||||
imageWidth: 1998,
|
||||
imageHeight: 1218,
|
||||
deviceWidth: 999,
|
||||
deviceHeight: 609
|
||||
})
|
||||
).toEqual({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'fill',
|
||||
objectPosition: 'top left'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not crop slightly uneven high-DPI frames after navigation', () => {
|
||||
expect(
|
||||
getRemoteBrowserFrameStyle({
|
||||
imageWidth: 3278,
|
||||
imageHeight: 2070,
|
||||
deviceWidth: 999,
|
||||
deviceHeight: 609
|
||||
})
|
||||
).toEqual({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'fill',
|
||||
objectPosition: 'top left'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not shrink malformed frame metadata below the viewport', () => {
|
||||
expect(
|
||||
getRemoteBrowserFrameStyle({
|
||||
imageWidth: 10,
|
||||
imageHeight: 10,
|
||||
deviceWidth: 958,
|
||||
deviceHeight: 609
|
||||
})
|
||||
).toEqual({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'fill',
|
||||
objectPosition: 'top left'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,20 +34,6 @@ function trimEnd(s: string): string {
|
||||
return s.trimEnd()
|
||||
}
|
||||
|
||||
function shouldSyncPropIntoEditor(
|
||||
currentMarkdown: string,
|
||||
propContent: string,
|
||||
lastCommittedMarkdown: string
|
||||
): boolean {
|
||||
if (propContent === lastCommittedMarkdown) {
|
||||
return false
|
||||
}
|
||||
if (currentMarkdown === propContent) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates the onCreate flow: empty-list repair then getMarkdown().
|
||||
*/
|
||||
@@ -134,27 +120,6 @@ describe('document soft-break round-trip', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 3. Rich editor content sync must ignore its own mount-time round-trip
|
||||
// differences, but still accept genuine external file changes.
|
||||
// -----------------------------------------------------------------------
|
||||
describe('content sync gating', () => {
|
||||
it('does not re-sync on mount when only the normalized markdown differs', () => {
|
||||
const disk = 'Line one\nLine two'
|
||||
const normalizedMarkdown = simulateOnCreate(disk)
|
||||
|
||||
expect(shouldSyncPropIntoEditor(normalizedMarkdown, disk, disk)).toBe(false)
|
||||
})
|
||||
|
||||
it('does re-sync when disk content actually changes externally', () => {
|
||||
const oldDisk = 'Line one\nLine two'
|
||||
const newDisk = 'Line one\nLine two\nLine three'
|
||||
const normalizedCurrentMarkdown = simulateOnCreate(oldDisk)
|
||||
|
||||
expect(shouldSyncPropIntoEditor(normalizedCurrentMarkdown, newDisk, oldDisk)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 4. Actual user edits must still be detected as dirty.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -44,13 +44,4 @@ describe('shouldIgnoreMonacoContentChange', () => {
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not ignore a user edit that happens to match the saved prop content', () => {
|
||||
expect(
|
||||
shouldIgnoreMonacoContentChange({
|
||||
filePath: '/repo/seed.spec.ts',
|
||||
isApplyingProgrammaticContent: false
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -169,7 +169,7 @@ describe('useEditorPanelFileLoadRetry — owner-not-ready bounding (#6648)', ()
|
||||
expect(attemptsRef.current[file.id]).toBe(1)
|
||||
})
|
||||
|
||||
it('stops after the budget and shows a truthful terminal message, then Retry re-arms', () => {
|
||||
it('stops after the budget and shows a truthful terminal message', () => {
|
||||
const file = makeFile()
|
||||
const attemptsRef = { current: {} as Record<string, number> }
|
||||
// The owner never hydrates: every retry re-fails with owner-not-ready.
|
||||
@@ -227,10 +227,6 @@ describe('useEditorPanelFileLoadRetry — owner-not-ready bounding (#6648)', ()
|
||||
)
|
||||
})
|
||||
expect(loadFileContent.mock.calls.length).toBe(callsAfterTerminal)
|
||||
|
||||
// Retry (reloadContent) clears the attempt budget for a fresh start.
|
||||
delete attemptsRef.current[file.id]
|
||||
expect(attemptsRef.current[file.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('stops immediately once the read succeeds (no terminal message)', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { NativeChatComposerInput } from './native-chat-composer-input'
|
||||
import { forwardRef, useCallback, useImperativeHandle, useState } from 'react'
|
||||
import { forwardRef, useCallback, useState } from 'react'
|
||||
import { useAppStore } from '../../store'
|
||||
import { useNativeChatComposerInterrupt } from './use-native-chat-composer-interrupt'
|
||||
import { useNativeChatContextUsageSummary } from './use-native-chat-context-usage-summary'
|
||||
@@ -14,7 +14,7 @@ import { useNativeChatLaunchDraftAdoption } from './use-native-chat-launch-draft
|
||||
import { NativeChatComposerField } from './NativeChatComposerField'
|
||||
import type { NativeChatResolvedTarget } from './native-chat-composer-target'
|
||||
import { useNativeChatComposerAttachments } from './use-native-chat-composer-attachments'
|
||||
import { useNativeChatComposerPaste } from './use-native-chat-composer-paste'
|
||||
import { useNativeChatComposerHandle } from './use-native-chat-composer-handle'
|
||||
import { useNativeChatExternalAttachments } from './use-native-chat-external-attachments'
|
||||
import { useNativeChatComposerKeyDown } from './use-native-chat-composer-keydown'
|
||||
import { useNativeChatSendLifecycle } from './use-native-chat-send-lifecycle'
|
||||
@@ -25,7 +25,6 @@ import { useNativeChatSessionOptionCommand } from './use-native-chat-session-opt
|
||||
import { useNativeChatComposerCatalog } from './use-native-chat-composer-catalog'
|
||||
import { useNativeChatPickerState } from './use-native-chat-picker-state'
|
||||
import { useNativeChatPickerCommandDispatch } from './use-native-chat-picker-command-dispatch'
|
||||
import { useNativeChatTypedInsertion } from './use-native-chat-typed-insertion'
|
||||
import type {
|
||||
NativeChatComposerHandle,
|
||||
NativeChatComposerProps
|
||||
@@ -61,6 +60,7 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
|
||||
isWorking = false,
|
||||
onStop,
|
||||
onOptimisticSend,
|
||||
optimisticSendOutcome,
|
||||
onOptimisticSendCanceled,
|
||||
onSlashCommand,
|
||||
onSwitchToTerminal,
|
||||
@@ -189,16 +189,6 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
|
||||
? !hasPty || !onStop
|
||||
: disabled || hasPendingAttachment || (draft.trim() === '' && imageAttachments.length === 0)
|
||||
|
||||
const { insertTypedText, focus } = useNativeChatTypedInsertion({
|
||||
textareaRef,
|
||||
caret,
|
||||
draft,
|
||||
setDraft,
|
||||
setCaret,
|
||||
setHistory,
|
||||
setActiveSuggestion
|
||||
})
|
||||
|
||||
const { attachExternalPaths, resolveAttachmentOwner } = useNativeChatExternalAttachments({
|
||||
terminalTabId,
|
||||
structuredWorktreeId: structuredTransport?.worktreeId,
|
||||
@@ -207,26 +197,31 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
|
||||
setNotice
|
||||
})
|
||||
|
||||
const { handlePaste, pasteFromClipboard } = useNativeChatComposerPaste({
|
||||
const handlePasteEvent = useNativeChatComposerHandle(ref, {
|
||||
textareaRef,
|
||||
caret,
|
||||
draft,
|
||||
setDraft,
|
||||
setCaret,
|
||||
setHistory,
|
||||
setActiveSuggestion,
|
||||
targetKey: JSON.stringify([
|
||||
paneKey,
|
||||
targetPtyId,
|
||||
structuredTransport?.sessionId,
|
||||
structuredTransport?.worktreeId,
|
||||
structuredTransport?.runtimeEnvironmentId
|
||||
]),
|
||||
agent,
|
||||
disabled,
|
||||
caret,
|
||||
resolveAttachmentOwner,
|
||||
attachResolvedPaths,
|
||||
beginPendingImageAttachment,
|
||||
resolvePendingImageAttachment,
|
||||
dropPendingImageAttachment,
|
||||
insertTypedText,
|
||||
setCaret,
|
||||
setNotice
|
||||
})
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({ focus, insertTypedText, handlePasteEvent: handlePaste, pasteFromClipboard }),
|
||||
[focus, insertTypedText, handlePaste, pasteFromClipboard]
|
||||
)
|
||||
|
||||
const { pickAttachment } = useNativeChatFileAttachmentActions(paneKey, attachExternalPaths)
|
||||
const { toggleDictation, startHoldDictation, stopHoldDictation } =
|
||||
useNativeChatDictationActions({ textareaRef, setDictationPressed })
|
||||
@@ -277,6 +272,7 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
|
||||
resolveTarget,
|
||||
classifySend,
|
||||
onOptimisticSend,
|
||||
optimisticSendOutcome,
|
||||
onSlashCommand,
|
||||
sessionOptionsSurface: ptySessionOptionsSurface,
|
||||
terminalTabId,
|
||||
@@ -395,7 +391,7 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
|
||||
flushDraftAppends()
|
||||
attachments.flushPendingAttachments()
|
||||
}}
|
||||
onPaste={handlePaste}
|
||||
onPaste={handlePasteEvent}
|
||||
pickerListboxId={picker.listboxId}
|
||||
onChoosePickerItem={goalMode.interceptPick(completeItem)}
|
||||
goalMode={goalMode}
|
||||
|
||||
@@ -30,7 +30,11 @@ import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
|
||||
|
||||
/** What a user message says under it when it did not go through, with its own Retry when the
|
||||
* surface can send it again. */
|
||||
export type NativeChatDeliveryNotice = { text: string; onRetry?: () => void }
|
||||
export type NativeChatDeliveryNotice = {
|
||||
text: string
|
||||
onRetry?: () => void
|
||||
onDismiss?: () => void
|
||||
}
|
||||
|
||||
/** One message: its prose first, then a collapsible run folding all of the
|
||||
* turn's tool activity. Monochrome per STYLEGUIDE: user prompts read as a
|
||||
@@ -185,6 +189,11 @@ export const MessageRow = memo(function MessageRow({
|
||||
{deliveryNotice ? (
|
||||
<div className="flex max-w-[85%] items-center gap-2 text-[11px] text-destructive/80">
|
||||
<span className="min-w-0 break-words">{deliveryNotice.text}</span>
|
||||
{deliveryNotice.onDismiss ? (
|
||||
<Button type="button" variant="ghost" size="xs" onClick={deliveryNotice.onDismiss}>
|
||||
{translate('components.native-chat.dismissDeliveryNotice', 'Dismiss')}
|
||||
</Button>
|
||||
) : null}
|
||||
{deliveryNotice.onRetry ? (
|
||||
<Button type="button" variant="ghost" size="xs" onClick={deliveryNotice.onRetry}>
|
||||
<RotateCcw className="size-3" />
|
||||
|
||||
@@ -4,7 +4,10 @@ import { createRef } from 'react'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NativeChatPromptEditor } from './NativeChatPromptEditor'
|
||||
import type { NativeChatComposerInput } from './native-chat-composer-input'
|
||||
import {
|
||||
insertNativeChatPastedText,
|
||||
type NativeChatComposerInput
|
||||
} from './native-chat-composer-input'
|
||||
import { promptEditor } from './native-chat-prompt-editor.test-support'
|
||||
|
||||
afterEach(cleanup)
|
||||
@@ -143,3 +146,28 @@ describe('native chat skill editor', () => {
|
||||
expect(container.querySelector('[data-native-chat-skill]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('replaces the selection with literal pasted text as one undoable edit', async () => {
|
||||
const { input, editor } = setup('hello world')
|
||||
act(() => input.setSelectionRange(6, 11))
|
||||
await act(async () => input.insertText?.('안녕\n$literal'))
|
||||
expect(input.value).toBe('hello 안녕\n$literal')
|
||||
await act(async () => {
|
||||
editor.commands.undo()
|
||||
})
|
||||
expect(input.value).toBe('hello world')
|
||||
})
|
||||
|
||||
it('moves focus into the composer when a paste is routed from elsewhere', async () => {
|
||||
const { input, editor } = setup('hi')
|
||||
act(() => input.setSelectionRange(2, 2))
|
||||
const hiddenTerminal = document.createElement('textarea')
|
||||
document.body.append(hiddenTerminal)
|
||||
hiddenTerminal.focus()
|
||||
await act(async () => {
|
||||
insertNativeChatPastedText(input, ' there')
|
||||
})
|
||||
expect(input.value).toBe('hi there')
|
||||
expect(editor.view.dom.contains(document.activeElement)).toBe(true)
|
||||
hiddenTerminal.remove()
|
||||
})
|
||||
|
||||
@@ -134,6 +134,12 @@ export function NativeChatPromptEditor({
|
||||
() =>
|
||||
editor
|
||||
? {
|
||||
insertText: (text) => {
|
||||
const content = editor.schema.nodeFromJSON(promptTextContent(text))
|
||||
editor.view.dispatch(
|
||||
editor.state.tr.replaceSelection(new Slice(content.content, 1, 1))
|
||||
)
|
||||
},
|
||||
get value() {
|
||||
return promptTextMap(editor.state.doc).text
|
||||
},
|
||||
|
||||
@@ -18,16 +18,11 @@ import { shouldClearNativeChatWorkingSuppression } from './native-chat-working-s
|
||||
import { resolveNativeChatTerminalTurn } from './native-chat-terminal-turn'
|
||||
import { useNativeChatTerminalTurnTiming } from './use-native-chat-terminal-turn-timing'
|
||||
import {
|
||||
appendPendingSendCache,
|
||||
launchPromptAsMessage,
|
||||
pendingSendsAsMessages,
|
||||
nextNativeChatPendingSendId,
|
||||
prunePendingSends,
|
||||
readPendingSendCache,
|
||||
shouldPruneLaunchPrompt,
|
||||
writePendingSendCache,
|
||||
type NativeChatPendingSend
|
||||
shouldPruneLaunchPrompt
|
||||
} from './native-chat-pending'
|
||||
import { useNativeChatPendingDelivery } from './use-native-chat-pending-delivery'
|
||||
import {
|
||||
appendCommandMarkerCache,
|
||||
applyCommandMarkerBoundaries,
|
||||
@@ -39,11 +34,8 @@ import {
|
||||
deriveNativeChatStreamingText,
|
||||
nativeChatStreamingMessage
|
||||
} from '../../../../shared/native-chat-streaming'
|
||||
import {
|
||||
shouldFocusNativeChatComposerFromEditingKey,
|
||||
shouldFocusNativeChatPaneFromPointerTarget,
|
||||
shouldRedirectNativeChatTyping
|
||||
} from './native-chat-typing-redirect'
|
||||
import { shouldFocusNativeChatPaneFromPointerTarget } from './native-chat-typing-redirect'
|
||||
import { routeNativeChatRootKeyToInput } from './native-chat-root-key-routing'
|
||||
import {
|
||||
emptyNativeChatContextMenuActions,
|
||||
useNativeChatContextMenu
|
||||
@@ -161,35 +153,19 @@ export function NativeChatResolvedView({
|
||||
() => ({ paneKey, agent, sessionId }),
|
||||
[paneKey, agent, sessionId]
|
||||
)
|
||||
const pendingScope = useMemo(() => ({ paneKey, agent }), [paneKey, agent])
|
||||
const [pending, setPending] = useState<NativeChatPendingSend[]>(() =>
|
||||
readPendingSendCache(pendingScope)
|
||||
)
|
||||
const delivery = useNativeChatPendingDelivery({ paneKey, agent, messages: session.messages })
|
||||
const { pending, record, clear } = delivery
|
||||
// Slash commands aren't chat turns, so they get a small local "Ran /clear"
|
||||
// system line instead of a user bubble. Capped + cached per conversation.
|
||||
const [commandMarkers, setCommandMarkers] = useState<NativeChatCommandMarker[]>(() =>
|
||||
readCommandMarkerCache(commandMarkerScope)
|
||||
)
|
||||
// Reset the optimistic queue only when the pane/agent changes. A fresh launch
|
||||
// often learns its provider session id after the first send; clearing pending
|
||||
// on that transition briefly flashes the empty state before the transcript
|
||||
// user turn lands.
|
||||
useEffect(() => {
|
||||
setPending(readPendingSendCache(pendingScope))
|
||||
setWorkingInterrupted(false)
|
||||
}, [pendingScope])
|
||||
// Command markers are session-scoped because slash commands like /clear are
|
||||
// local feedback for a specific transcript boundary.
|
||||
useEffect(() => {
|
||||
setCommandMarkers(readCommandMarkerCache(commandMarkerScope))
|
||||
setWorkingInterrupted(false)
|
||||
}, [commandMarkerScope])
|
||||
// Prune echoes whose real user turn is now in the transcript.
|
||||
useEffect(() => {
|
||||
setPending((prev) =>
|
||||
writePendingSendCache(pendingScope, prunePendingSends(prev, session.messages))
|
||||
)
|
||||
}, [session.messages, pendingScope])
|
||||
useEffect(() => {
|
||||
if (!paneLaunchPrompt || !shouldPruneLaunchPrompt(paneLaunchPrompt, session.messages)) {
|
||||
return
|
||||
@@ -199,29 +175,9 @@ export function NativeChatResolvedView({
|
||||
const onOptimisticSend = useCallback(
|
||||
(text: string, imagePaths?: string[]) => {
|
||||
setWorkingInterrupted(false)
|
||||
const sentAt = Date.now()
|
||||
const boundary = session.messages.at(-1)
|
||||
const entry: NativeChatPendingSend = {
|
||||
id: nextNativeChatPendingSendId(sentAt),
|
||||
text,
|
||||
sentAt,
|
||||
afterMessageId: boundary?.id ?? null,
|
||||
afterMessageTimestamp: boundary?.timestamp ?? null,
|
||||
...(imagePaths ? { imagePaths } : {})
|
||||
}
|
||||
setPending(appendPendingSendCache(pendingScope, entry))
|
||||
return entry.id
|
||||
return record(text, imagePaths)
|
||||
},
|
||||
[pendingScope, session.messages]
|
||||
)
|
||||
const onOptimisticSendCanceled = useCallback(
|
||||
(pendingId: string) => {
|
||||
// Why: detach/interrupt cancels the delayed Enter, so its optimistic echo
|
||||
// must not come back from the pane cache as a prompt that was delivered.
|
||||
const next = readPendingSendCache(pendingScope).filter((entry) => entry.id !== pendingId)
|
||||
setPending(writePendingSendCache(pendingScope, next))
|
||||
},
|
||||
[pendingScope]
|
||||
[record]
|
||||
)
|
||||
const onSlashCommand = useCallback(
|
||||
(command: string) => {
|
||||
@@ -251,6 +207,14 @@ export function NativeChatResolvedView({
|
||||
paneLaunchPrompt?.failed ? launchPromptMessage?.id : null,
|
||||
sessionAfterCommandBoundaries.messages
|
||||
)
|
||||
// Why memoized: a fresh map each render would re-render every memoized transcript row.
|
||||
const deliveryNotices = useMemo(
|
||||
() =>
|
||||
delivery.notices.size === 0
|
||||
? launchPromptDeliveryNotices
|
||||
: new Map([...(launchPromptDeliveryNotices ?? []), ...delivery.notices]),
|
||||
[launchPromptDeliveryNotices, delivery.notices]
|
||||
)
|
||||
const promptCard = useNativeChatInteractivePromptCard({
|
||||
paneKey,
|
||||
messages: sessionAfterCommandBoundaries.messages,
|
||||
@@ -332,9 +296,9 @@ export function NativeChatResolvedView({
|
||||
// Why: Stop after a submitted turn drops the delayed-write handle once it
|
||||
// settles, so cancelPendingSends no longer sees the optimistic id. Clear
|
||||
// the echo cache here so a cancelled prompt cannot stick as a ghost bubble.
|
||||
setPending(writePendingSendCache(pendingScope, []))
|
||||
clear()
|
||||
interactiveSend.cancel()
|
||||
}, [interactiveSend, pendingScope])
|
||||
}, [interactiveSend, clear])
|
||||
const { onLinkClick, linkActionRequest, closeLinkActions } = useNativeChatLinkActions(
|
||||
fileLinkContext,
|
||||
rootRef,
|
||||
@@ -376,20 +340,7 @@ export function NativeChatResolvedView({
|
||||
}
|
||||
return
|
||||
}
|
||||
// Backspace/Delete outside an input focuses the composer (like typing)
|
||||
// but inserts nothing — let the now-focused field handle the keystroke.
|
||||
if (shouldFocusNativeChatComposerFromEditingKey(event)) {
|
||||
composerRef.current?.focus()
|
||||
return
|
||||
}
|
||||
if (!shouldRedirectNativeChatTyping(event)) {
|
||||
return
|
||||
}
|
||||
if (!composerRef.current?.insertTypedText(event.key)) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
routeNativeChatRootKeyToInput(event, composerRef.current, questionAnswerInputRef.current)
|
||||
}}
|
||||
onMouseUpCapture={contextMenu.onSelectionCapture}
|
||||
onKeyUpCapture={contextMenu.onSelectionCapture}
|
||||
@@ -414,7 +365,7 @@ export function NativeChatResolvedView({
|
||||
awaitingInput={awaitingInput}
|
||||
onLinkClick={onLinkClick}
|
||||
allowFileUriLinks={fileLinkContext !== null}
|
||||
deliveryNotices={launchPromptDeliveryNotices}
|
||||
deliveryNotices={deliveryNotices}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -442,7 +393,8 @@ export function NativeChatResolvedView({
|
||||
isWorking={isWorking}
|
||||
onStop={stopAgent}
|
||||
onOptimisticSend={onOptimisticSend}
|
||||
onOptimisticSendCanceled={onOptimisticSendCanceled}
|
||||
onOptimisticSendCanceled={delivery.cancel}
|
||||
optimisticSendOutcome={delivery}
|
||||
onSlashCommand={onSlashCommand}
|
||||
onSwitchToTerminal={onSwitchToTerminal}
|
||||
readTerminalScreen={readTerminalScreen}
|
||||
|
||||
+2
-1
@@ -268,7 +268,8 @@ export function createStructuredSessionMocks() {
|
||||
},
|
||||
insertTypedText: () => true,
|
||||
handlePasteEvent: mocks.handlePasteEvent,
|
||||
pasteFromClipboard: mocks.pasteFromClipboard
|
||||
pasteFromClipboard: mocks.pasteFromClipboard,
|
||||
contains: (node: Node | null) => fieldRef.current?.contains(node) === true
|
||||
}))
|
||||
return <textarea ref={fieldRef} data-testid="structured-composer" />
|
||||
})
|
||||
|
||||
+2
-1
@@ -167,7 +167,8 @@ vi.mock('./NativeChatComposer', () => ({
|
||||
},
|
||||
insertTypedText: () => true,
|
||||
handlePasteEvent: mocks.handlePasteEvent,
|
||||
pasteFromClipboard: mocks.pasteFromClipboard
|
||||
pasteFromClipboard: mocks.pasteFromClipboard,
|
||||
contains: (node: Node | null) => fieldRef.current?.contains(node) === true
|
||||
}))
|
||||
return <textarea ref={fieldRef} data-testid="structured-composer" />
|
||||
})
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/** Minimal shape shared by React's synthetic ClipboardEvent and the native DOM
|
||||
* ClipboardEvent — the pane-level listener delivers the native one. */
|
||||
export type ClipboardEventLike = {
|
||||
clipboardData: DataTransfer | null
|
||||
preventDefault: () => void
|
||||
defaultPrevented: boolean
|
||||
}
|
||||
|
||||
export function clipboardEventImageFile(event: ClipboardEventLike): File | null {
|
||||
const data = event.clipboardData
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
const item = Array.from(data.items).find((candidate) => candidate.type.startsWith('image/'))
|
||||
return item?.getAsFile() ?? null
|
||||
}
|
||||
|
||||
function lastPathSegment(path: string): string {
|
||||
return path.split(/[\\/]/).pop() ?? path
|
||||
}
|
||||
|
||||
/** A label names its file directly (Finder), or by path or file URL (Linux file managers). */
|
||||
function labelledFileName(line: string): string {
|
||||
if (line.startsWith('file://')) {
|
||||
try {
|
||||
return lastPathSegment(decodeURIComponent(line.replace(/^file:\/\/[^/]*/, '')))
|
||||
} catch {
|
||||
return line
|
||||
}
|
||||
}
|
||||
return /^(?:\/|[A-Za-z]:[\\/])/.test(line) ? lastPathSegment(line) : line
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the text only labels the copied files, one line per file: a file
|
||||
* manager puts that label beside the files, and it is not prompt text.
|
||||
* `files` are the copied files' names or paths.
|
||||
*/
|
||||
export function textOnlyLabelsCopiedFiles(text: string, files: readonly string[]): boolean {
|
||||
if (files.length === 0) {
|
||||
return false
|
||||
}
|
||||
const names = files.map(lastPathSegment).sort()
|
||||
const labels = text
|
||||
.trim()
|
||||
.split(/\r\n|\r|\n/)
|
||||
.map((line) => labelledFileName(line.trim()))
|
||||
.sort()
|
||||
return labels.length === names.length && labels.every((label, i) => label === names[i])
|
||||
}
|
||||
|
||||
/** The event's text/plain, unless it only labels the files being attached. */
|
||||
export function clipboardEventPromptText(
|
||||
event: ClipboardEventLike,
|
||||
attachingFile: boolean
|
||||
): string {
|
||||
const text = event.clipboardData?.getData('text/plain') ?? ''
|
||||
if (!attachingFile || !text) {
|
||||
return text
|
||||
}
|
||||
const names = Array.from(event.clipboardData?.files ?? [], (file) => file.name)
|
||||
return textOnlyLabelsCopiedFiles(text, names) ? '' : text
|
||||
}
|
||||
|
||||
/** The clipboard's text for a paste with no event, and whether it only labels copied files. */
|
||||
export async function readClipboardPasteText(
|
||||
maxBytes: number
|
||||
): Promise<{ text: string; labelsFiles: boolean }> {
|
||||
const [text, filePaths] = await Promise.all([
|
||||
window.api.ui.readClipboardText({ maxBytes }),
|
||||
// Bookkeeping only: without the file list the text is typed as-is.
|
||||
window.api.ui.readClipboardFilePaths().catch(() => [])
|
||||
])
|
||||
return { text, labelsFiles: text !== '' && textOnlyLabelsCopiedFiles(text, filePaths) }
|
||||
}
|
||||
@@ -104,8 +104,3 @@ export function commandMarkersAsMessages(
|
||||
source: 'scrape' as const
|
||||
}))
|
||||
}
|
||||
|
||||
/** True when a message id was minted for a slash-command marker. */
|
||||
export function isCommandMarkerId(id: string): boolean {
|
||||
return id.startsWith('command:')
|
||||
}
|
||||
|
||||
@@ -10,5 +10,19 @@ export type NativeChatComposerInput = Pick<
|
||||
| 'setSelectionRange'
|
||||
> & {
|
||||
contains?: (node: Node | null) => boolean
|
||||
insertText?: (text: string) => void
|
||||
insertSkill?: (from: number, to: number, token: string) => void
|
||||
}
|
||||
|
||||
export function insertNativeChatPastedText(
|
||||
input: NativeChatComposerInput | null,
|
||||
text: string
|
||||
): boolean {
|
||||
if (!input || input.disabled || !input.insertText) {
|
||||
return false
|
||||
}
|
||||
// A paste routed from a hidden terminal or a transcript click must leave the caret in the composer.
|
||||
input.focus()
|
||||
input.insertText(text)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -52,8 +52,9 @@ function Harness(props: HarnessProps): React.JSX.Element {
|
||||
},
|
||||
insertTypedText: () => true,
|
||||
handlePasteEvent: () => {},
|
||||
pasteFromClipboard: () => {}
|
||||
} as NativeChatComposerHandle
|
||||
pasteFromClipboard: () => {},
|
||||
contains: () => false
|
||||
}
|
||||
useNativeChatComposerRevealFocus({
|
||||
rootRef,
|
||||
composerRef,
|
||||
|
||||
@@ -38,6 +38,13 @@ export type NativeChatStructuredComposerTransport = {
|
||||
runtimeEnvironmentId: string | null
|
||||
}
|
||||
|
||||
export type NativeChatOptimisticSendOutcome = {
|
||||
/** The host refused the write: mark the echo "Message not sent". */
|
||||
reject: (pendingId: string) => void
|
||||
/** The write acknowledgment was lost: hold the echo, then flag it unconfirmed. */
|
||||
holdUnconfirmed: (pendingId: string) => void
|
||||
}
|
||||
|
||||
export type NativeChatComposerProps = {
|
||||
/** Tab hosting the agent; used to resolve the live ptyId + runtime settings. */
|
||||
terminalTabId: string
|
||||
@@ -54,6 +61,8 @@ export type NativeChatComposerProps = {
|
||||
onStop?: () => void
|
||||
/** Render an optimistic echo until the real transcript turn lands. */
|
||||
onOptimisticSend?: (text: string, imagePaths?: string[]) => string | undefined
|
||||
/** Settle an optimistic echo whose write was refused or never acknowledged. */
|
||||
optimisticSendOutcome?: NativeChatOptimisticSendOutcome
|
||||
/** Remove an optimistic echo when its delayed submit is canceled. */
|
||||
onOptimisticSendCanceled?: (pendingId: string) => void
|
||||
/** Record a dispatched slash command that does not create a chat turn. */
|
||||
@@ -92,4 +101,6 @@ export type NativeChatComposerHandle = {
|
||||
}) => void
|
||||
/** Pastes clipboard content when no DOM paste event is available. */
|
||||
pasteFromClipboard: () => void
|
||||
/** Whether a node is inside the composer's own input, not merely the chat pane. */
|
||||
contains: (node: Node | null) => boolean
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// Clearing the agent's unsubmitted input line before a chat send writes its body.
|
||||
import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection'
|
||||
import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft'
|
||||
import { AGENT_TUI_CLEAR_INPUT_MAX } from '../../../../shared/agent-tui-input-clear'
|
||||
|
||||
// Why: agent TUI composers treat Ctrl+U as kill-to-start-of-line. Chat sends
|
||||
// start from an empty line so a prior cancelled paste cannot glue onto the next
|
||||
// prompt. Not used on verified option commands — model-switch confirmation
|
||||
// observes the PTY and Ctrl+U can miss confirmation markers.
|
||||
//
|
||||
// One Ctrl+U only ever clears ONE logical line. When the line may hold an
|
||||
// injected multi-line launch draft, callers pass `clearInput` built by
|
||||
// buildAgentTuiClearInputForText — see agent-tui-input-clear.ts for the measured
|
||||
// 2N-1 law and the sequences that do NOT work.
|
||||
export const NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT = '\x15'
|
||||
|
||||
/** Gap before re-reading the agent's input line to confirm a clear landed. */
|
||||
export const NATIVE_CHAT_CLEAR_CONFIRM_MS = 140
|
||||
|
||||
export type NativeChatSendOptions = {
|
||||
/** The host refused a write; nothing after it was sent. */
|
||||
onWriteRejected?: () => void
|
||||
/** A write's acknowledgment was lost; it may or may not have landed. */
|
||||
onWriteUnconfirmed?: () => void
|
||||
/** Bytes that empty the agent's input line. Defaults to a single Ctrl+U. */
|
||||
clearInput?: string
|
||||
/**
|
||||
* Observed check that the input line is now empty.
|
||||
* Supplied only for launch-draft replacement; when it reports "not cleared"
|
||||
* the send widens to a maximal burst before writing the body rather than
|
||||
* pasting on top of residue.
|
||||
*/
|
||||
confirmCleared?: () => boolean
|
||||
}
|
||||
|
||||
type RuntimeSettings = ReturnType<typeof getSettingsForAgentTabRuntimeOwner>
|
||||
|
||||
export function clearUnsubmittedAgentInput(
|
||||
settings: RuntimeSettings,
|
||||
ptyId: string,
|
||||
options?: NativeChatSendOptions
|
||||
): void {
|
||||
sendRuntimePtyInput(
|
||||
settings,
|
||||
ptyId,
|
||||
options?.clearInput ?? NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
|
||||
'driving'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `writeBody` once the input line is clear. With no `confirmCleared` the
|
||||
* clear is a plain in-order write on the same byte stream, so the TUI consumes
|
||||
* it before the body and the body follows immediately. With one, we pause to
|
||||
* actually look at the agent's input line, and widen to a maximal burst when the
|
||||
* draft is still visible — the injected line count is only a lower bound on what
|
||||
* the buffer holds, since the user can type into the TUI directly.
|
||||
*/
|
||||
export function clearThenWrite(
|
||||
settings: RuntimeSettings,
|
||||
ptyId: string,
|
||||
options: NativeChatSendOptions | undefined,
|
||||
delay: (ms: number, fn: () => void) => void,
|
||||
writeBody: () => void
|
||||
): void {
|
||||
clearUnsubmittedAgentInput(settings, ptyId, options)
|
||||
const confirmCleared = options?.confirmCleared
|
||||
if (!confirmCleared) {
|
||||
writeBody()
|
||||
return
|
||||
}
|
||||
delay(NATIVE_CHAT_CLEAR_CONFIRM_MS, () => {
|
||||
let cleared = false
|
||||
try {
|
||||
cleared = confirmCleared()
|
||||
} catch {
|
||||
// An unreadable terminal is unconfirmed; the maximal clear remains safe.
|
||||
}
|
||||
if (!cleared) {
|
||||
sendRuntimePtyInput(settings, ptyId, AGENT_TUI_CLEAR_INPUT_MAX, 'driving')
|
||||
}
|
||||
writeBody()
|
||||
})
|
||||
}
|
||||
|
||||
/** Extra time a send needs when it stops to confirm the clear before the body. */
|
||||
export function clearConfirmDurationMs(options?: NativeChatSendOptions): number {
|
||||
return options?.confirmCleared ? NATIVE_CHAT_CLEAR_CONFIRM_MS : 0
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import { buildNativeChatRenderItems, orderNativeChatMessages } from './native-chat-message-grouping'
|
||||
import { orderNativeChatMessages } from './native-chat-message-grouping'
|
||||
import { NATIVE_CHAT_STREAMING_ID } from '../../../../shared/native-chat-streaming'
|
||||
|
||||
function msg(
|
||||
@@ -42,75 +42,3 @@ describe('orderNativeChatMessages', () => {
|
||||
expect(ordered.map((m) => m.id)).toEqual(['real-user', 'streaming', 'pending:abc'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildNativeChatRenderItems', () => {
|
||||
it('renders messages in order', () => {
|
||||
const items = buildNativeChatRenderItems([
|
||||
msg({ id: 'u', role: 'user', timestamp: 1, blocks: [{ type: 'text', text: 'hi' }] }),
|
||||
msg({ id: 'a', role: 'assistant', timestamp: 2, blocks: [{ type: 'text', text: 'hello' }] })
|
||||
])
|
||||
expect(items.map((i) => i.id)).toEqual(['u', 'a'])
|
||||
expect(items[0]?.kind).toBe('message')
|
||||
})
|
||||
|
||||
it('pairs a tool-call with its tool-result into one step', () => {
|
||||
const items = buildNativeChatRenderItems([
|
||||
msg({
|
||||
id: 'a',
|
||||
role: 'assistant',
|
||||
timestamp: 1,
|
||||
blocks: [{ type: 'tool-call', name: 'Bash', input: { cmd: 'ls' } }]
|
||||
}),
|
||||
msg({
|
||||
id: 't',
|
||||
role: 'tool',
|
||||
timestamp: 2,
|
||||
blocks: [{ type: 'tool-result', output: 'file.txt' }]
|
||||
})
|
||||
])
|
||||
const steps = items.filter((i) => i.kind === 'tool-step')
|
||||
expect(steps).toHaveLength(1)
|
||||
const step = steps[0]
|
||||
if (step?.kind !== 'tool-step') {
|
||||
throw new Error('expected tool-step')
|
||||
}
|
||||
expect(step.step.call.name).toBe('Bash')
|
||||
expect(step.step.result?.output).toBe('file.txt')
|
||||
})
|
||||
|
||||
it('leaves an unanswered tool-call in flight (result null)', () => {
|
||||
const items = buildNativeChatRenderItems([
|
||||
msg({
|
||||
id: 'a',
|
||||
role: 'assistant',
|
||||
timestamp: 1,
|
||||
blocks: [{ type: 'tool-call', name: 'Read', input: {} }]
|
||||
})
|
||||
])
|
||||
const step = items.find((i) => i.kind === 'tool-step')
|
||||
if (step?.kind !== 'tool-step') {
|
||||
throw new Error('expected tool-step')
|
||||
}
|
||||
expect(step.step.result).toBeNull()
|
||||
})
|
||||
|
||||
it('separates prose blocks from tool blocks in the same message', () => {
|
||||
const items = buildNativeChatRenderItems([
|
||||
msg({
|
||||
id: 'a',
|
||||
role: 'assistant',
|
||||
timestamp: 1,
|
||||
blocks: [
|
||||
{ type: 'text', text: 'running it' },
|
||||
{ type: 'tool-call', name: 'Bash', input: {} }
|
||||
]
|
||||
})
|
||||
])
|
||||
expect(items.map((i) => i.kind)).toEqual(['message', 'tool-step'])
|
||||
const message = items[0]
|
||||
if (message?.kind !== 'message') {
|
||||
throw new Error('expected message')
|
||||
}
|
||||
expect(message.blocks).toEqual([{ type: 'text', text: 'running it' }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,118 +1,12 @@
|
||||
// Pure grouping logic for the native chat message list. Kept out of the .tsx so
|
||||
// the pairing/ordering rules are unit-testable without rendering. Two jobs:
|
||||
// 1. Order messages stably (timestamp then id; null timestamps sort first as
|
||||
// the shared model documents) — the assembler already sorts, but the list
|
||||
// re-sorts defensively so a caller passing unordered fixtures still reads
|
||||
// correctly.
|
||||
// 2. Within an assistant turn, pair each tool-call block with the tool-result
|
||||
// that answers it so the view can render one collapsible step instead of
|
||||
// two disconnected rows.
|
||||
// Stable message ordering for the native chat message list: timestamp then id,
|
||||
// null timestamps first as the shared model documents.
|
||||
|
||||
import {
|
||||
isToolCallBlock,
|
||||
isToolResultBlock,
|
||||
type NativeChatBlock,
|
||||
type NativeChatMessage,
|
||||
type NativeChatToolCallBlock,
|
||||
type NativeChatToolResultBlock
|
||||
} from '../../../../shared/native-chat-types'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import { compareMessages } from './native-chat-session-assembler'
|
||||
|
||||
/** A tool-call block paired with the result that answered it, when one exists.
|
||||
* `result` is null while the call is still in flight (no result yet). */
|
||||
export type NativeChatToolStep = {
|
||||
call: NativeChatToolCallBlock
|
||||
result: NativeChatToolResultBlock | null
|
||||
}
|
||||
|
||||
/** One renderable item in the list: either a prose/role message carrying its
|
||||
* non-tool blocks, or a tool step (call + optional result). The view renders
|
||||
* each variant differently. */
|
||||
export type NativeChatRenderItem =
|
||||
| {
|
||||
kind: 'message'
|
||||
id: string
|
||||
message: NativeChatMessage
|
||||
/** The message's blocks minus tool-call/tool-result (those become steps). */
|
||||
blocks: NativeChatBlock[]
|
||||
}
|
||||
| {
|
||||
kind: 'tool-step'
|
||||
id: string
|
||||
/** Role of the message the call originated from (assistant/tool). */
|
||||
role: NativeChatMessage['role']
|
||||
timestamp: number | null
|
||||
step: NativeChatToolStep
|
||||
}
|
||||
|
||||
/** Order messages stably: null timestamps first (model rule), then ascending
|
||||
* timestamp, ties broken by id. Shares the assembler's comparator so both
|
||||
* paths order identically. */
|
||||
export function orderNativeChatMessages(messages: NativeChatMessage[]): NativeChatMessage[] {
|
||||
return [...messages].sort(compareMessages)
|
||||
}
|
||||
|
||||
/** Collect every tool-result across the whole conversation in document order so
|
||||
* a call can find its answer even when the result lands in a later message (the
|
||||
* common transcript shape: assistant emits the call, a following tool message
|
||||
* carries the result). Results carry no originating name in our model, so they
|
||||
* are handed out FIFO to calls. */
|
||||
function collectToolResults(messages: NativeChatMessage[]): NativeChatToolResultBlock[] {
|
||||
const results: NativeChatToolResultBlock[] = []
|
||||
for (const message of messages) {
|
||||
for (const block of message.blocks) {
|
||||
if (isToolResultBlock(block)) {
|
||||
results.push(block)
|
||||
}
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten ordered messages into render items, pairing tool calls with results.
|
||||
* Result pairing is FIFO across the conversation: tool results in our model
|
||||
* carry no back-reference to a call id, so we match the Nth call to the Nth
|
||||
* result in document order — the order both providers emit them. A call with no
|
||||
* remaining result renders as in-flight (`result: null`).
|
||||
*/
|
||||
export function buildNativeChatRenderItems(messages: NativeChatMessage[]): NativeChatRenderItem[] {
|
||||
const ordered = orderNativeChatMessages(messages)
|
||||
const resultQueue = collectToolResults(ordered)
|
||||
let resultCursor = 0
|
||||
|
||||
const items: NativeChatRenderItem[] = []
|
||||
for (const message of ordered) {
|
||||
const nonToolBlocks: NativeChatBlock[] = []
|
||||
const steps: NativeChatToolStep[] = []
|
||||
|
||||
for (const block of message.blocks) {
|
||||
if (isToolCallBlock(block)) {
|
||||
const result = resultQueue[resultCursor] ?? null
|
||||
if (result) {
|
||||
resultCursor += 1
|
||||
}
|
||||
steps.push({ call: block, result })
|
||||
} else if (isToolResultBlock(block)) {
|
||||
// Results are emitted as steps from the call side; skip standalone ones.
|
||||
continue
|
||||
} else {
|
||||
nonToolBlocks.push(block)
|
||||
}
|
||||
}
|
||||
|
||||
if (nonToolBlocks.length > 0) {
|
||||
items.push({ kind: 'message', id: message.id, message, blocks: nonToolBlocks })
|
||||
}
|
||||
for (const [index, step] of steps.entries()) {
|
||||
items.push({
|
||||
kind: 'tool-step',
|
||||
id: `${message.id}:tool:${index}`,
|
||||
role: message.role,
|
||||
timestamp: message.timestamp,
|
||||
step
|
||||
})
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
const io = vi.hoisted(() => ({ write: vi.fn(), verified: vi.fn() }))
|
||||
vi.mock('@/runtime/runtime-terminal-inspection', () => ({
|
||||
sendRuntimePtyInput: io.write,
|
||||
sendRuntimePtyInputVerified: io.verified
|
||||
}))
|
||||
import {
|
||||
sendNativeChatMessage,
|
||||
resetNativeChatPtySendQueuesForTests
|
||||
} from './native-chat-runtime-send'
|
||||
import { buildNativeChatPasteBytes, NATIVE_CHAT_SUBMIT } from './native-chat-send'
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
resetNativeChatPtySendQueuesForTests()
|
||||
io.write.mockReset().mockReturnValue(true)
|
||||
io.verified.mockReset().mockResolvedValue(true)
|
||||
})
|
||||
afterEach(() => {
|
||||
resetNativeChatPtySendQueuesForTests()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
it('observes a refused write, skips Enter, and releases the queue for the next user action', async () => {
|
||||
const rejected = vi.fn()
|
||||
io.verified.mockResolvedValueOnce(false)
|
||||
sendNativeChatMessage(null, 'pane', 'refused', { onWriteRejected: rejected })
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(rejected).toHaveBeenCalledOnce()
|
||||
expect(io.verified.mock.calls.map((call) => call[2])).toEqual([
|
||||
buildNativeChatPasteBytes('refused')
|
||||
])
|
||||
sendNativeChatMessage(null, 'pane', 'next', { onWriteRejected: rejected })
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(io.verified.mock.calls.map((call) => call[2])).toEqual([
|
||||
buildNativeChatPasteBytes('refused'),
|
||||
buildNativeChatPasteBytes('next'),
|
||||
NATIVE_CHAT_SUBMIT
|
||||
])
|
||||
})
|
||||
it('reports a lost acknowledgment once as unconfirmed, never as rejection, and still submits', async () => {
|
||||
const rejected = vi.fn()
|
||||
const unconfirmed = vi.fn()
|
||||
io.verified.mockRejectedValueOnce(new Error('lost acknowledgment'))
|
||||
io.verified.mockRejectedValueOnce(new Error('lost acknowledgment'))
|
||||
sendNativeChatMessage(null, 'pane', 'uncertain', {
|
||||
onWriteRejected: rejected,
|
||||
onWriteUnconfirmed: unconfirmed
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(120000)
|
||||
expect(rejected).not.toHaveBeenCalled()
|
||||
expect(unconfirmed).toHaveBeenCalledOnce()
|
||||
expect(io.verified.mock.calls.map((call) => call[2])).toEqual([
|
||||
buildNativeChatPasteBytes('uncertain'),
|
||||
NATIVE_CHAT_SUBMIT
|
||||
])
|
||||
})
|
||||
it('serializes rapid sends through their acknowledged Enter and preserves the paste delay', async () => {
|
||||
const rejected = vi.fn()
|
||||
sendNativeChatMessage(null, 'pane', 'one', { onWriteRejected: rejected })
|
||||
sendNativeChatMessage(null, 'pane', 'two', { onWriteRejected: rejected })
|
||||
await vi.advanceTimersByTimeAsync(499)
|
||||
expect(io.verified).toHaveBeenCalledOnce()
|
||||
await vi.advanceTimersByTimeAsync(501)
|
||||
expect(io.verified.mock.calls.map((call) => call[2])).toEqual([
|
||||
buildNativeChatPasteBytes('one'),
|
||||
NATIVE_CHAT_SUBMIT,
|
||||
buildNativeChatPasteBytes('two'),
|
||||
NATIVE_CHAT_SUBMIT
|
||||
])
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { sendRuntimePtyInputVerified } from '@/runtime/runtime-terminal-inspection'
|
||||
import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft'
|
||||
import { enqueueNativeChatPtySend } from './native-chat-pty-send-queue'
|
||||
import {
|
||||
clearConfirmDurationMs,
|
||||
clearThenWrite,
|
||||
clearUnsubmittedAgentInput,
|
||||
type NativeChatSendOptions
|
||||
} from './native-chat-input-clear'
|
||||
|
||||
/** Observe write refusals without mistaking transport success for provider acceptance. */
|
||||
export function sendNativeChatObservedWrites(
|
||||
settings: ReturnType<typeof getSettingsForAgentTabRuntimeOwner>,
|
||||
ptyId: string,
|
||||
writes: readonly { data: string; delayBeforeMs: number }[],
|
||||
options: NativeChatSendOptions
|
||||
) {
|
||||
return enqueueNativeChatPtySend(
|
||||
ptyId,
|
||||
writes.reduce((total, write) => total + write.delayBeforeMs, 0) +
|
||||
clearConfirmDurationMs(options),
|
||||
({ isCancelled, delay, markSubmitted }) => {
|
||||
let reportedUnconfirmed = false
|
||||
const writeAt = (index: number): void => {
|
||||
if (isCancelled()) {
|
||||
return
|
||||
}
|
||||
const write = writes[index]
|
||||
if (!write) {
|
||||
markSubmitted()
|
||||
return
|
||||
}
|
||||
const send = (): void => {
|
||||
if (isCancelled()) {
|
||||
return
|
||||
}
|
||||
void sendRuntimePtyInputVerified(settings, ptyId, write.data, 'driving')
|
||||
.then((accepted) => {
|
||||
if (isCancelled()) {
|
||||
return
|
||||
}
|
||||
if (!accepted) {
|
||||
options.onWriteRejected?.()
|
||||
markSubmitted()
|
||||
return
|
||||
}
|
||||
writeAt(index + 1)
|
||||
})
|
||||
// A lost acknowledgment is not a refusal: never re-send these bytes, but still submit
|
||||
// a body that may have landed, as the unobserved path does.
|
||||
.catch(() => {
|
||||
if (!reportedUnconfirmed) {
|
||||
reportedUnconfirmed = true
|
||||
options.onWriteUnconfirmed?.()
|
||||
}
|
||||
writeAt(index + 1)
|
||||
})
|
||||
}
|
||||
if (write.delayBeforeMs > 0) {
|
||||
delay(write.delayBeforeMs, send)
|
||||
} else {
|
||||
send()
|
||||
}
|
||||
}
|
||||
clearThenWrite(settings, ptyId, options, delay, () => writeAt(0))
|
||||
},
|
||||
{ onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId, options) }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { useRef, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { APP_MENU_PASTE_EVENT } from '@/lib/app-menu-paste'
|
||||
import { useNativeChatPasteBridge } from './use-native-chat-paste-bridge'
|
||||
import type { NativeChatComposerHandle } from './NativeChatComposer'
|
||||
import { NativeChatPaneCover } from '../terminal-pane/NativeChatPaneCover'
|
||||
import { registerTerminalPanePasteListeners } from '../terminal-pane/terminal-pane-paste-listeners'
|
||||
import type { TerminalPaneCloseController } from '../terminal-pane/use-terminal-pane-close-actions'
|
||||
import type { TerminalPanePasteExecution } from '../terminal-pane/terminal-pane-paste-execution'
|
||||
import {
|
||||
pasteTerminalPaneMenuClipboard,
|
||||
type TerminalPaneMenuPasteContext
|
||||
} from '../terminal-pane/terminal-pane-menu-paste'
|
||||
import type { ManagedPane } from '@/lib/pane-manager/pane-manager'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
readClipboardText: vi.fn(),
|
||||
terminalClipboard: vi.fn(),
|
||||
error: vi.fn()
|
||||
}))
|
||||
vi.mock('sonner', () => ({ toast: { error: mocks.error } }))
|
||||
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
|
||||
vi.mock('../terminal-pane/terminal-clipboard-paste', () => ({
|
||||
pasteTerminalClipboard: mocks.terminalClipboard
|
||||
}))
|
||||
vi.mock('../terminal-pane/terminal-clipboard-event-paste', () => ({
|
||||
isClipboardEventPasteRequired: () => false,
|
||||
firesNativePasteEvent: () => true,
|
||||
getClipboardEventText: (event: ClipboardEvent) => event.clipboardData?.getData('text/plain') ?? ''
|
||||
}))
|
||||
|
||||
const REFUSAL = "Can't paste — this chat isn't accepting input right now."
|
||||
|
||||
let dispose: (() => void) | undefined
|
||||
beforeEach(() => {
|
||||
mocks.readClipboardText.mockResolvedValue('menu text')
|
||||
mocks.terminalClipboard.mockResolvedValue({ status: 'empty' })
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { ui: { readClipboardText: mocks.readClipboardText } }
|
||||
})
|
||||
})
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
dispose?.()
|
||||
dispose = undefined
|
||||
document.body.replaceChildren()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
type FakePane = Pick<ManagedPane, 'id' | 'container' | 'terminal'> & {
|
||||
helper: HTMLTextAreaElement
|
||||
}
|
||||
|
||||
function terminalPane(id: number): FakePane {
|
||||
const container = document.createElement('div')
|
||||
container.className = 'pane'
|
||||
container.dataset.leafId = `leaf-${id}`
|
||||
const element = document.createElement('div')
|
||||
element.className = 'xterm'
|
||||
const helper = document.createElement('textarea')
|
||||
helper.className = 'xterm-helper-textarea'
|
||||
element.append(helper)
|
||||
container.append(element)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: cover code reads only element and focus.
|
||||
const terminal = { element, focus: () => helper.focus() } as unknown as ManagedPane['terminal']
|
||||
return { id, container, terminal, helper }
|
||||
}
|
||||
|
||||
function composerHandle(): NativeChatComposerHandle & { element: HTMLElement } {
|
||||
const element = document.createElement('div')
|
||||
element.contentEditable = 'true'
|
||||
// Production marks the composer field box and the pane-wide drop surface alike.
|
||||
element.dataset.composerScopeKey = 'pane'
|
||||
return {
|
||||
element,
|
||||
focus: vi.fn(() => {
|
||||
element.focus()
|
||||
return true
|
||||
}),
|
||||
insertTypedText: vi.fn(() => true),
|
||||
handlePasteEvent: vi.fn(),
|
||||
pasteFromClipboard: vi.fn(),
|
||||
contains: (node) => element.contains(node)
|
||||
}
|
||||
}
|
||||
|
||||
type ChatOptions = {
|
||||
composer?: NativeChatComposerHandle & { element: HTMLElement }
|
||||
answer?: HTMLInputElement
|
||||
answerRef?: { current: HTMLInputElement | null }
|
||||
}
|
||||
|
||||
function ChatRoot({
|
||||
composer,
|
||||
answer,
|
||||
answerRef: sharedAnswerRef
|
||||
}: ChatOptions): React.JSX.Element {
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const composerRef = useRef<NativeChatComposerHandle | null>(composer ?? null)
|
||||
const ownAnswerRef = useRef<HTMLInputElement | null>(answer ?? null)
|
||||
const answerRef = sharedAnswerRef ?? ownAnswerRef
|
||||
useNativeChatPasteBridge({ rootRef, composerRef, questionAnswerInputRef: answerRef })
|
||||
return (
|
||||
// Mirrors NativeChatPaneFileDropSurface, which publishes the composer scope pane-wide.
|
||||
<div data-composer-scope-key="pane" data-native-file-drop-target="composer">
|
||||
<div
|
||||
ref={(node) => {
|
||||
rootRef.current = node
|
||||
if (node && composer && !node.contains(composer.element)) {
|
||||
node.append(composer.element)
|
||||
}
|
||||
if (node && answer && !node.contains(answer)) {
|
||||
node.append(answer)
|
||||
}
|
||||
}}
|
||||
data-native-chat-root="true"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<p data-testid="transcript">transcript</p>
|
||||
<input data-testid="search" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Cover({
|
||||
pane,
|
||||
covered,
|
||||
children
|
||||
}: {
|
||||
pane: FakePane
|
||||
covered: boolean
|
||||
children?: ReactNode
|
||||
}): React.JSX.Element | null {
|
||||
return covered
|
||||
? createPortal(
|
||||
<NativeChatPaneCover pane={pane}>{children}</NativeChatPaneCover>,
|
||||
pane.container
|
||||
)
|
||||
: null
|
||||
}
|
||||
|
||||
function fixture(options: { platform?: NodeJS.Platform } = {}) {
|
||||
const container = document.createElement('div')
|
||||
const pane = terminalPane(1)
|
||||
const sibling = terminalPane(2)
|
||||
container.append(pane.container, sibling.container)
|
||||
const outside = document.createElement('input')
|
||||
document.body.append(container, outside)
|
||||
const pasteFromClipboard = vi.fn()
|
||||
const executePanePasteText = vi.fn()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the listeners use only these controller fields.
|
||||
const controller = {
|
||||
forceBracketedMultilineTextPaste: false,
|
||||
keybindings: {},
|
||||
worktreeId: 'workspace',
|
||||
managerRef: { current: { getActivePane: () => pane, getPanes: () => [pane, sibling] } },
|
||||
setTerminalError: vi.fn()
|
||||
} as unknown as TerminalPaneCloseController
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these are the two execution callbacks read by the listener.
|
||||
const execution = {
|
||||
pasteFromClipboard,
|
||||
executePanePasteText
|
||||
} as unknown as TerminalPanePasteExecution
|
||||
dispose = registerTerminalPanePasteListeners({
|
||||
container,
|
||||
controller,
|
||||
execution,
|
||||
isMac: options.platform === 'darwin',
|
||||
shortcutPlatform: options.platform ?? 'win32'
|
||||
})
|
||||
pane.helper.focus()
|
||||
const view = render(<Cover pane={pane} covered={false} />)
|
||||
const cover = (chat: ReactNode = null): void =>
|
||||
view.rerender(
|
||||
<Cover pane={pane} covered>
|
||||
{chat}
|
||||
</Cover>
|
||||
)
|
||||
const uncover = (): void => view.rerender(<Cover pane={pane} covered={false} />)
|
||||
const shell = (): HTMLElement => {
|
||||
const element = pane.container.querySelector<HTMLElement>('.native-chat-pane-shell')
|
||||
if (!element) {
|
||||
throw new Error('cover not mounted')
|
||||
}
|
||||
return element
|
||||
}
|
||||
return { pane, sibling, outside, pasteFromClipboard, cover, uncover, shell }
|
||||
}
|
||||
|
||||
function paste(target: Element, text = 'event text'): ClipboardEvent {
|
||||
const data = new DataTransfer()
|
||||
data.setData('text/plain', text)
|
||||
const event = new ClipboardEvent('paste', {
|
||||
clipboardData: data,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
target.dispatchEvent(event)
|
||||
return event
|
||||
}
|
||||
|
||||
function menuPane(pane: FakePane): ManagedPane {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a covered pane's menu paste reads only container and terminal.
|
||||
return pane as unknown as ManagedPane
|
||||
}
|
||||
|
||||
function menuContext(): TerminalPaneMenuPasteContext {
|
||||
return {
|
||||
managerRef: { current: null },
|
||||
paneTransportsRef: { current: new Map() },
|
||||
tabId: 'tab',
|
||||
worktreeId: 'workspace',
|
||||
forceBracketedMultilineTextPaste: false,
|
||||
onPasteError: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
describe('a chat cover owns focus over its terminal', () => {
|
||||
it('moves focus off the covered xterm and keeps it off', () => {
|
||||
const f = fixture()
|
||||
expect(document.activeElement).toBe(f.pane.helper)
|
||||
f.cover()
|
||||
expect(document.activeElement).toBe(f.shell())
|
||||
expect(f.pane.terminal.element?.inert).toBe(true)
|
||||
// Any of the unguarded terminal.focus() paths (focus-follows-mouse, pane menus) now no-ops.
|
||||
f.pane.terminal.focus()
|
||||
expect(document.activeElement).toBe(f.shell())
|
||||
})
|
||||
|
||||
it('leaves a split sibling focusable', () => {
|
||||
const f = fixture()
|
||||
f.cover()
|
||||
expect(f.sibling.terminal.element?.inert).toBe(false)
|
||||
f.sibling.terminal.focus()
|
||||
expect(document.activeElement).toBe(f.sibling.helper)
|
||||
})
|
||||
|
||||
it('does not pull focus from elsewhere when a chat opens', () => {
|
||||
const f = fixture()
|
||||
f.outside.focus()
|
||||
f.cover()
|
||||
expect(document.activeElement).toBe(f.outside)
|
||||
expect(f.pane.terminal.element?.inert).toBe(true)
|
||||
})
|
||||
|
||||
it('returns focus to the terminal when the chat that held it goes away', () => {
|
||||
const f = fixture()
|
||||
f.cover()
|
||||
f.uncover()
|
||||
expect(f.pane.terminal.element?.inert).toBe(false)
|
||||
expect(document.activeElement).toBe(f.pane.helper)
|
||||
})
|
||||
|
||||
it.each(['another control', 'nothing'] as const)(
|
||||
'does not pull focus into the terminal when %s had focus',
|
||||
(holder) => {
|
||||
const f = fixture()
|
||||
f.cover()
|
||||
if (holder === 'another control') {
|
||||
f.outside.focus()
|
||||
} else {
|
||||
f.shell().blur()
|
||||
}
|
||||
const focusedBefore = document.activeElement
|
||||
f.uncover()
|
||||
expect(f.pane.terminal.element?.inert).toBe(false)
|
||||
expect(document.activeElement).toBe(focusedBefore)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('paste inside a chat cover never reaches the terminal', () => {
|
||||
it.each(['darwin', 'win32', 'linux'] as const)(
|
||||
'refuses a %s paste before the chat has mounted any input',
|
||||
(platform) => {
|
||||
const f = fixture({ platform })
|
||||
f.cover()
|
||||
f.shell().dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'v',
|
||||
metaKey: platform === 'darwin',
|
||||
ctrlKey: platform !== 'darwin',
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
)
|
||||
expect(paste(f.shell()).defaultPrevented).toBe(true)
|
||||
expect(f.pasteFromClipboard).not.toHaveBeenCalled()
|
||||
expect(mocks.error).toHaveBeenCalledExactlyOnceWith(REFUSAL)
|
||||
}
|
||||
)
|
||||
|
||||
it('leaves the app-menu paste to the cover instead of the hidden terminal', () => {
|
||||
const f = fixture({ platform: 'darwin' })
|
||||
f.cover()
|
||||
const event = new Event(APP_MENU_PASTE_EVENT, { cancelable: true })
|
||||
window.dispatchEvent(event)
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
expect(mocks.terminalClipboard).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses when the chat shows no input, such as an approval card', () => {
|
||||
const f = fixture()
|
||||
f.cover(<ChatRoot />)
|
||||
const transcript = f.pane.container.querySelector('[data-testid="transcript"]')!
|
||||
expect(paste(transcript).defaultPrevented).toBe(true)
|
||||
expect(mocks.error).toHaveBeenCalledExactlyOnceWith(REFUSAL)
|
||||
expect(f.pasteFromClipboard).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hands a paste on the cover itself to the composer once the chat mounts', () => {
|
||||
const f = fixture()
|
||||
const composer = composerHandle()
|
||||
f.cover(<ChatRoot composer={composer} />)
|
||||
paste(f.shell())
|
||||
expect(composer.pasteFromClipboard).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.error).not.toHaveBeenCalled()
|
||||
expect(f.pasteFromClipboard).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('delivers transcript paste with its payload to the composer', () => {
|
||||
const f = fixture()
|
||||
const composer = composerHandle()
|
||||
f.cover(<ChatRoot composer={composer} />)
|
||||
const transcript = f.pane.container.querySelector('[data-testid="transcript"]')!
|
||||
const event = paste(transcript, '원문')
|
||||
expect(composer.handlePasteEvent).toHaveBeenCalledExactlyOnceWith(event)
|
||||
expect(mocks.readClipboardText).not.toHaveBeenCalled()
|
||||
expect(mocks.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps native paste in a text field inside the chat despite the pane-wide scope marker', () => {
|
||||
const f = fixture()
|
||||
const composer = composerHandle()
|
||||
f.cover(<ChatRoot composer={composer} />)
|
||||
const search = f.pane.container.querySelector<HTMLInputElement>('[data-testid="search"]')!
|
||||
expect(paste(search).defaultPrevented).toBe(false)
|
||||
search.focus()
|
||||
window.dispatchEvent(new Event(APP_MENU_PASTE_EVENT, { cancelable: true }))
|
||||
expect(composer.handlePasteEvent).not.toHaveBeenCalled()
|
||||
expect(composer.pasteFromClipboard).not.toHaveBeenCalled()
|
||||
expect(mocks.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('claims the app-menu paste while the composer input has focus', () => {
|
||||
const f = fixture({ platform: 'darwin' })
|
||||
const composer = composerHandle()
|
||||
f.cover(<ChatRoot composer={composer} />)
|
||||
composer.element.focus()
|
||||
window.dispatchEvent(new Event(APP_MENU_PASTE_EVENT, { cancelable: true }))
|
||||
expect(composer.pasteFromClipboard).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.terminalClipboard).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hands transcript paste to the question answer when the composer is absent', async () => {
|
||||
const f = fixture()
|
||||
const answer = document.createElement('input')
|
||||
f.cover(<ChatRoot answer={answer} />)
|
||||
const transcript = f.pane.container.querySelector('[data-testid="transcript"]')!
|
||||
await act(async () => {
|
||||
paste(transcript, 'answer')
|
||||
})
|
||||
expect(answer.value).toBe('answer')
|
||||
expect(mocks.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps terminal-only paste unchanged', () => {
|
||||
const f = fixture()
|
||||
paste(f.pane.helper)
|
||||
expect(f.pasteFromClipboard).toHaveBeenCalledExactlyOnceWith(f.pane, 'paste-event')
|
||||
window.dispatchEvent(new Event(APP_MENU_PASTE_EVENT, { cancelable: true }))
|
||||
expect(mocks.terminalClipboard).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal context-menu paste on a covered pane', () => {
|
||||
it('routes by its named pane to that pane’s chat', async () => {
|
||||
const f = fixture()
|
||||
const composer = composerHandle()
|
||||
f.cover(<ChatRoot composer={composer} />)
|
||||
await pasteTerminalPaneMenuClipboard(menuContext(), menuPane(f.pane), 'context-menu')
|
||||
expect(composer.pasteFromClipboard).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.terminalClipboard).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses instead of pasting into the terminal when the chat has no input', async () => {
|
||||
const f = fixture()
|
||||
f.cover()
|
||||
await pasteTerminalPaneMenuClipboard(menuContext(), menuPane(f.pane), 'right-click')
|
||||
expect(mocks.error).toHaveBeenCalledExactlyOnceWith(REFUSAL)
|
||||
expect(mocks.terminalClipboard).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not put a late read into a replacement question input', async () => {
|
||||
const f = fixture()
|
||||
const answer = document.createElement('input')
|
||||
const answerRef = { current: answer }
|
||||
f.cover(<ChatRoot answer={answer} answerRef={answerRef} />)
|
||||
let finish = (_text: string): void => {}
|
||||
mocks.readClipboardText.mockReturnValue(
|
||||
new Promise<string>((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
)
|
||||
await pasteTerminalPaneMenuClipboard(menuContext(), menuPane(f.pane), 'context-menu')
|
||||
answerRef.current = document.createElement('input')
|
||||
await act(async () => finish('old answer'))
|
||||
expect(answer.value).toBe('')
|
||||
expect(answerRef.current.value).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import { pendingSendsAsMessages, prunePendingSends } from './native-chat-pending'
|
||||
|
||||
const prompt = 'Summarize the failing tests.\n\nThen propose a fix for each one.'
|
||||
const wrapped = `\n\n<pasted_content id="7e64">\n${prompt}\n</pasted_content id="7e64">\n`
|
||||
function message(id: string, role: NativeChatMessage['role'], text: string): NativeChatMessage {
|
||||
return { id, role, source: 'transcript', timestamp: null, blocks: [{ type: 'text', text }] }
|
||||
}
|
||||
|
||||
describe('pending echoes against Claude pasted-content rows', () => {
|
||||
// A new host delivers the decoded prompt; an old host still delivers the raw envelope.
|
||||
it.each([
|
||||
['new host', prompt],
|
||||
['old host', wrapped]
|
||||
])('retires the echo with %s rows and keeps a second identical send', (_host, row) => {
|
||||
const history = [
|
||||
message('boundary', 'assistant', 'earlier'),
|
||||
message('user', 'user', row),
|
||||
message('reply', 'assistant', 'answer')
|
||||
]
|
||||
const pending = [{ id: 'p1', text: prompt, sentAt: 999_000, afterMessageId: 'boundary' }]
|
||||
expect(pendingSendsAsMessages(pending, history)).toEqual([])
|
||||
expect(prunePendingSends(pending, history)).toEqual([])
|
||||
expect(
|
||||
prunePendingSends([...pending, { ...pending[0]!, id: 'p2', matchingOccurrence: 2 }], history)
|
||||
).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
applyCommandMarkerBoundaries,
|
||||
clearCommandMarkerCacheForTests,
|
||||
commandMarkersAsMessages,
|
||||
isCommandMarkerId,
|
||||
readCommandMarkerCache
|
||||
} from './native-chat-command-marker'
|
||||
import { stripNoiseMessages } from './native-chat-noise'
|
||||
@@ -694,11 +693,6 @@ describe('commandMarkersAsMessages', () => {
|
||||
const markers = commandMarkersAsMessages([{ id: 'c1', command: '/compact', sentAt: 1 }])
|
||||
expect(stripNoiseMessages(markers)).toEqual(markers)
|
||||
})
|
||||
|
||||
it('isCommandMarkerId recognizes the prefix', () => {
|
||||
expect(isCommandMarkerId('command:c1')).toBe(true)
|
||||
expect(isCommandMarkerId('pending:p1')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('command marker cache', () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user