mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix: restore OMP commit messages and chat names (#34644)
* fix: deliver OMP generation prompts through owned files * test: preserve the OMP contract suite entry point * Preserve complete OMP generation prompts and retry owned cleanup * Load OMP cleanup fixture module outside test timeout * Deliver WSL generation prompts inside the execution guest * Cover deadline expiry before WSL generation authorization
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { wslDistroForCommand } from '../../../shared/wsl-paths'
|
||||
export { wslDistroForCommand } from '../../../shared/wsl-paths'
|
||||
import { waitForPromiseWithSignal } from '../../../shared/abort-signal-reason'
|
||||
import { withTimeout } from '../../../shared/promise-timeout-fallback'
|
||||
import { parseWslPath } from '../../wsl'
|
||||
import { isWslDirectGitReadCommand } from '../wsl-direct-git-read-commands'
|
||||
import {
|
||||
disableWslGitReadEnvironment,
|
||||
@@ -20,14 +21,6 @@ function wslGitArgs(args: string[], options: GitExecOptions): string[] {
|
||||
return withWslWindowsCheckoutGitConfig(args, options.cwd, options.wslDistro)
|
||||
}
|
||||
|
||||
/** The WSL distro a Git command with this cwd runs in, or null when it runs on the host. */
|
||||
export function wslDistroForCommand(cwd: string | undefined, override?: string): string | null {
|
||||
if (process.platform !== 'win32') {
|
||||
return null
|
||||
}
|
||||
return (cwd ? parseWslPath(cwd)?.distro : undefined) ?? override ?? null
|
||||
}
|
||||
|
||||
export function resolveGitCommand(
|
||||
args: string[],
|
||||
options: GitExecOptions,
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process'
|
||||
import { recordSubprocessSpawn } from '../../diagnostics/main-thread-churn-probe'
|
||||
import { resolveCommand } from './wsl-command-resolution'
|
||||
import {
|
||||
commandLineLength,
|
||||
MAX_COMMAND_LINE_CHARS,
|
||||
WindowsCommandLineBudgetError
|
||||
} from '../../../shared/windows-command-line-budget'
|
||||
import { resolveCommand, withWslPreparation } from './wsl-command-resolution'
|
||||
|
||||
export function wslAwareSpawn(
|
||||
command: string,
|
||||
@@ -11,15 +16,28 @@ export function wslAwareSpawn(
|
||||
useWslLoginShell?: boolean
|
||||
wslShellCommand?: string
|
||||
cwdFailureExitCode?: number
|
||||
preparation?: { script: string; args: string[] }
|
||||
}
|
||||
): ChildProcess {
|
||||
const { wslDistro, useWslLoginShell, wslShellCommand, cwdFailureExitCode, ...spawnOptions } =
|
||||
options
|
||||
const resolved = resolveCommand(command, args, options.cwd, wslDistro, {
|
||||
const {
|
||||
wslDistro,
|
||||
useWslLoginShell,
|
||||
wslShellCommand,
|
||||
cwdFailureExitCode,
|
||||
preparation,
|
||||
...spawnOptions
|
||||
} = options
|
||||
let resolved = resolveCommand(command, args, options.cwd, wslDistro, {
|
||||
useWslLoginShell,
|
||||
wslShellCommand,
|
||||
...(cwdFailureExitCode === undefined ? {} : { cwdFailureExitCode })
|
||||
})
|
||||
if (preparation && resolved.wsl) {
|
||||
resolved = withWslPreparation(resolved, preparation)
|
||||
if (commandLineLength([resolved.binary, ...resolved.args]) > MAX_COMMAND_LINE_CHARS) {
|
||||
throw new WindowsCommandLineBudgetError()
|
||||
}
|
||||
}
|
||||
const spawnStartedAt = performance.now()
|
||||
const child = spawn(resolved.binary, resolved.args, {
|
||||
...spawnOptions,
|
||||
|
||||
@@ -208,3 +208,23 @@ function withWslProcessGroupTermination(
|
||||
termination
|
||||
}
|
||||
}
|
||||
|
||||
/** Prefix only the opt-in preparation; retain the resolver's original guest argv intact. */
|
||||
export function withWslPreparation(
|
||||
command: ResolvedCommand,
|
||||
preparation: { script: string; args: string[] }
|
||||
): ResolvedCommand {
|
||||
const execIndex = command.args.indexOf('--exec')
|
||||
return {
|
||||
...command,
|
||||
args: [
|
||||
...command.args.slice(0, execIndex + 1),
|
||||
'/bin/sh',
|
||||
'-c',
|
||||
preparation.script,
|
||||
'orca-generation',
|
||||
...preparation.args,
|
||||
...command.args.slice(execIndex + 1)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { encodePosixPrintfRecords } from '../../shared/posix-printf-encoding'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
@@ -20,6 +21,21 @@ function shimDir(tools: Record<string, string>): string {
|
||||
return bin
|
||||
}
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'keeps incremental printf records byte-bounded and lossless at escape/codepoint boundaries',
|
||||
() => {
|
||||
const controls = Array.from({ length: 128 }, (_, index) => String.fromCharCode(index)).join('')
|
||||
const text = `${'x'.repeat(1023)}🌊${controls}\\c\\012\uFEFF'!%$\`\ud800`.repeat(4)
|
||||
const chunks = [...encodePosixPrintfRecords(text)]
|
||||
expect(chunks.every((chunk) => Buffer.byteLength(chunk) <= 1024)).toBe(true)
|
||||
expect(chunks.every((chunk) => !chunk.includes('\n') && !chunk.includes('\0'))).toBe(true)
|
||||
const result = spawnSync('/bin/sh', ['-c', 'printf "%b" "$@"', 'fixture', ...chunks])
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toEqual(Buffer.from(text))
|
||||
expect([...encodePosixPrintfRecords('')]).toEqual([''])
|
||||
}
|
||||
)
|
||||
|
||||
describe('wrapRemoteCommandForPosixShell', () => {
|
||||
it('emits one physical line without requiring a remote decoder binary', () => {
|
||||
const wrapped = wrapRemoteCommandForPosixShell("printf 'a\\n'\nprintf 'b\\n'")
|
||||
|
||||
@@ -1,34 +1,8 @@
|
||||
import { encodePosixPrintfRecords } from '../../shared/posix-printf-encoding'
|
||||
import { gzipSync } from 'node:zlib'
|
||||
import { quotePosixShell } from '../../shared/shell-argument-quoting'
|
||||
|
||||
const REMOTE_COMMAND_CHUNK_MAX_BYTES = 1_024
|
||||
const REMOTE_COMMAND_COMPRESSION_THRESHOLD_BYTES = 8_000
|
||||
const REMOTE_COMMAND_PRINTF_ESCAPED_BYTES = new Set([0x21, 0x27, 0x5c])
|
||||
|
||||
function encodeRemoteCommandForPrintf(command: string): string[] {
|
||||
const chunks: string[] = []
|
||||
let chunk = ''
|
||||
let chunkBytes = 0
|
||||
for (const character of command) {
|
||||
const codePoint = character.codePointAt(0)!
|
||||
const isSafePrintableAscii =
|
||||
codePoint >= 0x20 && codePoint <= 0x7e && !REMOTE_COMMAND_PRINTF_ESCAPED_BYTES.has(codePoint)
|
||||
const encodedCharacter =
|
||||
codePoint > 0x7f || isSafePrintableAscii
|
||||
? character
|
||||
: `\\0${codePoint.toString(8).padStart(3, '0')}`
|
||||
const encodedBytes = codePoint > 0x7f ? Buffer.byteLength(character) : encodedCharacter.length
|
||||
if (chunkBytes + encodedBytes > REMOTE_COMMAND_CHUNK_MAX_BYTES) {
|
||||
chunks.push(chunk)
|
||||
chunk = ''
|
||||
chunkBytes = 0
|
||||
}
|
||||
chunk += encodedCharacter
|
||||
chunkBytes += encodedBytes
|
||||
}
|
||||
chunks.push(chunk)
|
||||
return chunks
|
||||
}
|
||||
|
||||
/** The compressed wrapper's exit when the host has no base64 or gzip; nothing ran. */
|
||||
export const REMOTE_COMMAND_DECODER_UNAVAILABLE_EXIT = 125
|
||||
@@ -39,7 +13,7 @@ export function wrapRemoteCommandForPosixShell(
|
||||
options: { compress?: boolean } = {}
|
||||
): string {
|
||||
// csh/tcsh split multiline exec strings; rebuild bounded printf chunks without consuming relay stdin.
|
||||
const encodedChunks = encodeRemoteCommandForPrintf(command)
|
||||
const encodedChunks = [...encodePosixPrintfRecords(command)]
|
||||
const runDecoded = 'decoded=${decoded%_}; exec /bin/sh -c "$decoded"'
|
||||
const decodeAndRun = `decoded=$(printf %b "$@" && printf _) || exit $?; ${runDecoded}`
|
||||
const chunkArguments = encodedChunks.map(quotePosixShell).join(' ')
|
||||
@@ -53,7 +27,7 @@ export function wrapRemoteCommandForPosixShell(
|
||||
|
||||
// Compress long scripts before gateways can truncate the exec request.
|
||||
const compressed = gzipSync(Buffer.from(command), { level: 9 }).toString('base64')
|
||||
const compressedArguments = encodeRemoteCommandForPrintf(compressed)
|
||||
const compressedArguments = [...encodePosixPrintfRecords(compressed)]
|
||||
.map(quotePosixShell)
|
||||
.join(' ')
|
||||
// Preserve payload stdin and execute only after gzip validation. Missing decoders consume nothing, permitting plain retry; macOS before 13 needs base64 -D.
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { wslDistroForCommand } from '../../shared/wsl-paths'
|
||||
import { spawnProcess } from '@orca/process-host'
|
||||
import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution'
|
||||
import { resolveCliCommand } from '../codex-cli/command'
|
||||
import { createProviderSpawnSpec } from '../provider-process/provider-process-supervisor'
|
||||
import { guardSupervisedProviderGroup } from '../provider-process/provider-group-orphan-guard'
|
||||
import { SOURCE_CONTROL_KILL_SITE } from './source-control-local-process'
|
||||
import { SOURCE_CONTROL_KILL_SITE } from './source-control-agent-termination'
|
||||
import { wslAwareSpawn } from '../git/runner'
|
||||
import { guestNativeSpawnEnv } from '../ipc/wsl-guest-windows-drive-path'
|
||||
import type {
|
||||
@@ -41,8 +42,11 @@ function buildWslLauncherEnv(explicitEnv: NodeJS.ProcessEnv | undefined): NodeJS
|
||||
}
|
||||
|
||||
export const spawnSourceControlAgent: SpawnSourceControlAgent = (input) => {
|
||||
const wslDistro = input.preparation
|
||||
? wslDistroForCommand(input.cwd, input.wslDistro)
|
||||
: input.wslDistro
|
||||
const spawnEnv = input.env ?? process.env
|
||||
if (process.platform === 'win32' && input.wslDistro) {
|
||||
if (process.platform === 'win32' && wslDistro) {
|
||||
// Apply command assignments after guest login-shell setup, never to the Windows launcher.
|
||||
const assignments = Object.entries(input.commandEnv ?? {}).map(
|
||||
([key, value]) => `${key}=${value}`
|
||||
@@ -56,8 +60,9 @@ export const spawnSourceControlAgent: SpawnSourceControlAgent = (input) => {
|
||||
env: buildWslLauncherEnv(input.env),
|
||||
stdio: [input.stdinMode, 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
wslDistro: input.wslDistro,
|
||||
useWslLoginShell: true
|
||||
wslDistro,
|
||||
useWslLoginShell: true,
|
||||
...(input.preparation ? { preparation: input.preparation } : {})
|
||||
}
|
||||
) as SpawnedSourceControlAgentProcess
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { stopSupervisedChildProcess } from '../provider-process/supervised-child-process-stop'
|
||||
import { terminateWindowsProcessTree } from '../windows-process-tree-kill'
|
||||
import type { SpawnedSourceControlAgentProcess } from './source-control-text-generation-types'
|
||||
|
||||
export const SOURCE_CONTROL_KILL_SITE = 'source-control-text-generation'
|
||||
|
||||
export async function killSourceControlAgentProcess(
|
||||
child: SpawnedSourceControlAgentProcess
|
||||
): Promise<void> {
|
||||
const pid = child.pid
|
||||
if (!pid) {
|
||||
return
|
||||
}
|
||||
if (child.supervised) {
|
||||
await stopSupervisedChildProcess(child, { site: SOURCE_CONTROL_KILL_SITE })
|
||||
return
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
// Tree-walk refusal must not release the home lock having killed nothing; root handle kill cannot hit a recycled PID.
|
||||
await terminateWindowsProcessTree(pid, { site: SOURCE_CONTROL_KILL_SITE })
|
||||
}
|
||||
try {
|
||||
child.kill('SIGKILL')
|
||||
} catch {
|
||||
// The process may exit between the PID check and kill.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { CommitMessagePlan } from '../../shared/commit-message-plan'
|
||||
import { commandLineLength, MAX_COMMAND_LINE_CHARS } from '../../shared/windows-command-line-budget'
|
||||
|
||||
// Linux caps each argv entry in bytes (128KiB on 4KiB pages), before total argv capacity; reserve headroom for single-prompt arguments.
|
||||
const LINUX_SINGLE_ARGUMENT_BYTE_BUDGET = 120 * 1024
|
||||
/** Whether the plan's argv plus reserve fits this host's limit: UTF-16 units on Windows, bytes per argument on Linux. */
|
||||
export function fitsLocalCommandLine(plan: CommitMessagePlan, reserve = 0): boolean {
|
||||
if (process.platform === 'win32') {
|
||||
return commandLineLength([plan.binary, ...plan.args]) + reserve <= MAX_COMMAND_LINE_CHARS
|
||||
}
|
||||
if (process.platform === 'linux') {
|
||||
return plan.args.every(
|
||||
(arg) => Buffer.byteLength(arg, 'utf8') + reserve <= LINUX_SINGLE_ARGUMENT_BYTE_BUDGET
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function argumentBudgetFailure(plan: CommitMessagePlan): string | null {
|
||||
if (fitsLocalCommandLine(plan)) {
|
||||
return null
|
||||
}
|
||||
return process.platform === 'win32'
|
||||
? `${plan.label} prompt is too large for the Windows command line. Stage fewer changes and try again.`
|
||||
: `${plan.label} prompt is too large to pass as a single command-line argument. Stage fewer changes and try again.`
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { wslDistroForCommand } from '../../shared/wsl-paths'
|
||||
import { mergeCommandEnvironment } from '../../shared/command-environment'
|
||||
import type { CommitMessagePlan } from '../../shared/commit-message-plan'
|
||||
import {
|
||||
@@ -27,12 +28,16 @@ export function runLocalPlanForAgent(input: {
|
||||
spawnAgent: SpawnSourceControlAgent
|
||||
shouldExecute?: () => boolean
|
||||
}): Promise<InternalTextGenerationResult> {
|
||||
const wslDistro = input.plan.promptFile
|
||||
? (wslDistroForCommand(input.target.cwd, input.target.wslDistro) ?? undefined)
|
||||
: input.target.wslDistro
|
||||
const target = {
|
||||
...input.target,
|
||||
wslDistro,
|
||||
env: mergeCommandEnvironment(
|
||||
input.target.env,
|
||||
input.plan.env,
|
||||
input.target.wslDistro ? 'linux' : process.platform
|
||||
wslDistro ? 'linux' : process.platform
|
||||
)
|
||||
}
|
||||
const start = (): LocalProcessExecution<InternalTextGenerationResult> => {
|
||||
@@ -45,10 +50,10 @@ export function runLocalPlanForAgent(input: {
|
||||
return runLocalSourceControlPlan({
|
||||
plan: input.plan,
|
||||
cwd: input.target.cwd,
|
||||
env: input.target.wslDistro ? input.target.env : target.env,
|
||||
env: wslDistro ? input.target.env : target.env,
|
||||
emptyResultName: input.emptyResultName,
|
||||
operation: input.operation,
|
||||
wslDistro: input.target.wslDistro,
|
||||
wslDistro,
|
||||
spawnAgent: input.spawnAgent
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { killSourceControlAgentProcess } from './source-control-agent-termination'
|
||||
import { wslDistroForCommand } from '../../shared/wsl-paths'
|
||||
import { prepareWslPrompt, stageLocalPromptFiles } from './source-control-prompt-files'
|
||||
import { producePromptRecords } from './source-control-prompt-producer'
|
||||
import { argumentBudgetFailure } from './source-control-command-line-budget'
|
||||
export { fitsLocalCommandLine } from './source-control-command-line-budget'
|
||||
import { removeTree } from '../../shared/windows-transient-lock-removal'
|
||||
import { WindowsCmdLineBreakError } from '@orca/process-host'
|
||||
import type { CommitMessagePlan } from '../../shared/commit-message-plan'
|
||||
import { commandLineLength, MAX_COMMAND_LINE_CHARS } from '../../shared/windows-command-line-budget'
|
||||
import { WindowsCommandLineBudgetError } from '../../shared/windows-command-line-budget'
|
||||
import { supervisedProviderSpawnFailure } from '../provider-process/provider-spawn-failure-report'
|
||||
import { stopSupervisedChildProcess } from '../provider-process/supervised-child-process-stop'
|
||||
import { terminateWindowsProcessTree } from '../windows-process-tree-kill'
|
||||
import {
|
||||
finalizeFromAgentOutput,
|
||||
userFacingUnsafeWindowsBatchArgs
|
||||
@@ -21,77 +25,43 @@ import type {
|
||||
InternalTextGenerationResult,
|
||||
LocalProcessExecution,
|
||||
SpawnedSourceControlAgentProcess,
|
||||
SpawnSourceControlAgent,
|
||||
TextGenerationOperation
|
||||
LocalSourceControlPlanInput
|
||||
} from './source-control-text-generation-types'
|
||||
|
||||
export const SOURCE_CONTROL_KILL_SITE = 'source-control-text-generation'
|
||||
export * from './source-control-agent-termination'
|
||||
|
||||
export async function killSourceControlAgentProcess(
|
||||
child: SpawnedSourceControlAgentProcess
|
||||
): Promise<void> {
|
||||
const pid = child.pid
|
||||
if (!pid) {
|
||||
return
|
||||
}
|
||||
if (child.supervised) {
|
||||
await stopSupervisedChildProcess(child, { site: SOURCE_CONTROL_KILL_SITE })
|
||||
return
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
// Tree-walk refusal must not release the home lock having killed nothing; root handle kill cannot hit a recycled PID.
|
||||
await terminateWindowsProcessTree(pid, { site: SOURCE_CONTROL_KILL_SITE })
|
||||
}
|
||||
try {
|
||||
child.kill('SIGKILL')
|
||||
} catch {
|
||||
// The process may exit between the PID check and kill.
|
||||
}
|
||||
}
|
||||
|
||||
// Linux caps each argv entry in bytes (128KiB on 4KiB pages), before total argv capacity; reserve headroom for single-prompt arguments.
|
||||
const LINUX_SINGLE_ARGUMENT_BYTE_BUDGET = 120 * 1024
|
||||
|
||||
/** Whether the plan's argv plus reserve fits this host's limit: UTF-16 units on Windows, bytes per argument on Linux. */
|
||||
export function fitsLocalCommandLine(plan: CommitMessagePlan, reserve = 0): boolean {
|
||||
if (process.platform === 'win32') {
|
||||
return commandLineLength([plan.binary, ...plan.args]) + reserve <= MAX_COMMAND_LINE_CHARS
|
||||
}
|
||||
if (process.platform === 'linux') {
|
||||
return plan.args.every(
|
||||
(arg) => Buffer.byteLength(arg, 'utf8') + reserve <= LINUX_SINGLE_ARGUMENT_BYTE_BUDGET
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function argumentBudgetFailure(plan: CommitMessagePlan): string | null {
|
||||
if (fitsLocalCommandLine(plan)) {
|
||||
return null
|
||||
}
|
||||
return process.platform === 'win32'
|
||||
? `${plan.label} prompt is too large for the Windows command line. Stage fewer changes and try again.`
|
||||
: `${plan.label} prompt is too large to pass as a single command-line argument. Stage fewer changes and try again.`
|
||||
}
|
||||
|
||||
export function runLocalSourceControlPlan(input: {
|
||||
plan: CommitMessagePlan
|
||||
cwd: string
|
||||
env: NodeJS.ProcessEnv | undefined
|
||||
emptyResultName: string
|
||||
operation: TextGenerationOperation
|
||||
wslDistro?: string
|
||||
spawnAgent: SpawnSourceControlAgent
|
||||
}): LocalProcessExecution<InternalTextGenerationResult> {
|
||||
const { plan, cwd, operation } = input
|
||||
export function runLocalSourceControlPlan(
|
||||
input: LocalSourceControlPlanInput
|
||||
): LocalProcessExecution<InternalTextGenerationResult> {
|
||||
let { plan } = input
|
||||
const { cwd, operation } = input
|
||||
const distro = plan.promptFile ? wslDistroForCommand(cwd, input.wslDistro) : null
|
||||
const guest = distro ? prepareWslPrompt(plan, distro) : undefined
|
||||
let closed = false
|
||||
let promptDirectory: string | undefined
|
||||
let markProcessClosed!: () => void
|
||||
const processClosed = new Promise<void>((resolve) => {
|
||||
markProcessClosed = resolve
|
||||
markProcessClosed = () => {
|
||||
closed = true
|
||||
if (promptDirectory) {
|
||||
const directory = promptDirectory
|
||||
promptDirectory = undefined
|
||||
void removeTree(directory).catch((error: unknown) => {
|
||||
console.error('[commit-message] Failed to remove generation prompt:', error)
|
||||
})
|
||||
}
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
const couldNotStart = `${plan.label} could not be started. Check the agent command in Settings and try again.`
|
||||
const result = new Promise<InternalTextGenerationResult>((resolve) => {
|
||||
let child: SpawnedSourceControlAgentProcess
|
||||
try {
|
||||
plan =
|
||||
guest?.plan ??
|
||||
stageLocalPromptFiles(plan, (directory) => {
|
||||
promptDirectory = directory
|
||||
})
|
||||
// Reject oversized argv before opaque E2BIG spawn failure so users can act on the prompt-size limit.
|
||||
const budgetFailure = argumentBudgetFailure(plan)
|
||||
if (budgetFailure) {
|
||||
@@ -105,12 +75,20 @@ export function runLocalSourceControlPlan(input: {
|
||||
cwd,
|
||||
env: input.env,
|
||||
commandEnv: plan.env,
|
||||
wslDistro: input.wslDistro,
|
||||
wslDistro: distro ?? input.wslDistro,
|
||||
...(guest ? { preparation: guest.preparation } : {}),
|
||||
stdinMode: 'pipe',
|
||||
useCwdForNative: true
|
||||
})
|
||||
} catch (error) {
|
||||
markProcessClosed()
|
||||
if (error instanceof WindowsCommandLineBudgetError) {
|
||||
resolve({
|
||||
success: false,
|
||||
error: `${plan.label} prompt is too large for the Windows command line. Stage fewer changes and try again.`
|
||||
})
|
||||
return
|
||||
}
|
||||
if (error instanceof WindowsCmdLineBreakError) {
|
||||
resolve({
|
||||
success: false,
|
||||
@@ -123,6 +101,8 @@ export function runLocalSourceControlPlan(input: {
|
||||
return
|
||||
}
|
||||
|
||||
const stop = new AbortController()
|
||||
const deadline = Date.now() + SOURCE_CONTROL_GENERATION_TIMEOUT_MS
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let stdoutBytes = 0
|
||||
@@ -135,7 +115,10 @@ export function runLocalSourceControlPlan(input: {
|
||||
let terminal = false
|
||||
let terminationComplete: Promise<void> | null = null
|
||||
let detachChildListeners = (): void => {}
|
||||
const active = (): boolean =>
|
||||
!settled && !terminal && !stop.signal.aborted && Date.now() < deadline
|
||||
const startTermination = (): void => {
|
||||
stop.abort()
|
||||
terminationComplete ??= killSourceControlAgentProcess(child).catch((error: unknown) => {
|
||||
console.error('[commit-message] Local generator termination failed:', error)
|
||||
})
|
||||
@@ -150,6 +133,7 @@ export function runLocalSourceControlPlan(input: {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
stop.abort()
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
@@ -187,6 +171,9 @@ export function runLocalSourceControlPlan(input: {
|
||||
stdout += chunk.toString('utf-8')
|
||||
}
|
||||
const onStderrData = (chunk: Buffer): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
stderrBytes += chunk.byteLength
|
||||
if (stderrBytes > MAX_SOURCE_CONTROL_AGENT_OUTPUT_BYTES) {
|
||||
outputLimitExceeded = true
|
||||
@@ -216,6 +203,7 @@ export function runLocalSourceControlPlan(input: {
|
||||
}
|
||||
const onClose = (code: number | null): void => {
|
||||
terminal = true
|
||||
stop.abort()
|
||||
child.off?.('close', onClose)
|
||||
markClosedAfterTermination()
|
||||
if (canceledByUser) {
|
||||
@@ -255,12 +243,47 @@ export function runLocalSourceControlPlan(input: {
|
||||
})
|
||||
)
|
||||
}
|
||||
child.stdin?.on?.('error', (error: Error) => {
|
||||
if (active()) {
|
||||
startTermination()
|
||||
onError(error)
|
||||
}
|
||||
})
|
||||
child.stdin?.on?.('close', () => {
|
||||
if (guest && active() && !child.stdin?.writableEnded) {
|
||||
startTermination()
|
||||
onError(new Error('Generation input closed.'))
|
||||
}
|
||||
})
|
||||
child.stdout?.on('data', onStdoutData)
|
||||
child.stderr?.on('data', onStderrData)
|
||||
if (guest) {
|
||||
child.stderr?.on('data', (chunk: Buffer) => {
|
||||
const proved = guest.observe(chunk, onStderrData)
|
||||
if (proved) {
|
||||
promptDirectory = guest.cleanupPath
|
||||
if (closed) {
|
||||
markProcessClosed()
|
||||
}
|
||||
if (active() && child.stdin) {
|
||||
void producePromptRecords(child.stdin, guest.records(), stop.signal, active).catch(
|
||||
(error: unknown) => {
|
||||
if (active()) {
|
||||
startTermination()
|
||||
onError(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
child.stderr?.on('data', onStderrData)
|
||||
}
|
||||
child.on('error', onError)
|
||||
child.on('close', onClose)
|
||||
child.on('exit', () => {
|
||||
terminal = true
|
||||
stop.abort()
|
||||
markClosedAfterTermination()
|
||||
})
|
||||
detachChildListeners = () => {
|
||||
@@ -268,7 +291,11 @@ export function runLocalSourceControlPlan(input: {
|
||||
child.stderr?.off?.('data', onStderrData)
|
||||
}
|
||||
try {
|
||||
child.stdin?.end(plan.stdinPayload ?? undefined)
|
||||
if (!guest) {
|
||||
child.stdin?.end(plan.stdinPayload ?? undefined)
|
||||
} else if (!child.stdin) {
|
||||
throw new Error('Generation input is unavailable.')
|
||||
}
|
||||
} catch (error) {
|
||||
startTermination()
|
||||
onError(error instanceof Error ? error : new Error(String(error)))
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
import { spawnProcess } from '@orca/process-host'
|
||||
import * as agentSpecs from '../../shared/commit-message-agent-spec'
|
||||
import type { ResolvedSourceControlAiGenerationParams } from '../../shared/source-control-ai'
|
||||
import type { SpawnSourceControlAgent } from './source-control-text-generation-types'
|
||||
import {
|
||||
generateCommitMessage,
|
||||
generatePullRequestFields,
|
||||
generateBranchName
|
||||
} from './source-control-text-generation-requests'
|
||||
import { generateConversationName } from './conversation-name-generation-request'
|
||||
import { generateJiraIssueSummaryFromContext } from './jira-issue-summary-text-generation'
|
||||
|
||||
import { existsSync, mkdtempSync, readFileSync } from 'node:fs'
|
||||
import { rm } from 'node:fs/promises'
|
||||
import type * as FsPromises from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { planCommitMessageGeneration } from '../../shared/commit-message-plan'
|
||||
import { cancelLocalGeneration } from './source-control-generation-lanes'
|
||||
import { SOURCE_CONTROL_GENERATION_TIMEOUT_MS } from './source-control-generation-limits'
|
||||
import { runLocalSourceControlPlan } from './source-control-local-process'
|
||||
import {
|
||||
createMockDiscoveryChild,
|
||||
withPlatform
|
||||
} from './commit-message-text-generation-test-harness'
|
||||
|
||||
vi.mock('../windows-process-tree-kill', () => ({
|
||||
terminateWindowsProcessTree: vi.fn(async () => true)
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof FsPromises>()
|
||||
return { ...actual, rm: vi.fn(actual.rm) }
|
||||
})
|
||||
|
||||
const actualFs = await vi.importActual<typeof FsPromises>('node:fs/promises')
|
||||
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
// OMP 17.0.5 recorded stdin-only execution: exit 0, empty stdout/stderr, no submitted prompt.
|
||||
const recordedPrompt = `Name this chat from the user's first request.
|
||||
Write a short, specific title of at most 48 characters.
|
||||
Output only the title, with no heading, quotes, or explanation.
|
||||
|
||||
First request:
|
||||
Trace a lost lighthouse signal.
|
||||
|
||||
Additional instructions:
|
||||
Return Lighthouse Signal Trace.
|
||||
`
|
||||
|
||||
it.each(['success', 'failure', 'cancel', 'timeout', 'spawn failure'] as const)(
|
||||
'delivers the recorded OMP prompt without stdin and removes the owned file after %s',
|
||||
async (outcome) => {
|
||||
vi.useFakeTimers()
|
||||
const prompt = recordedPrompt + 'x'.repeat(200_000)
|
||||
const planned = planCommitMessageGeneration({ agentId: 'omp', model: 'default' }, prompt)
|
||||
if (!planned.ok) {
|
||||
throw new Error(planned.error)
|
||||
}
|
||||
const child = createMockDiscoveryChild()
|
||||
let promptPath = ''
|
||||
const execution = withPlatform('win32', () =>
|
||||
runLocalSourceControlPlan({
|
||||
plan: planned.plan,
|
||||
cwd: '/omp-file-test',
|
||||
env: undefined,
|
||||
operation: 'conversation-name',
|
||||
emptyResultName: 'chat name',
|
||||
spawnAgent: ({ args }) => {
|
||||
promptPath = args[planned.plan.promptFile?.argIndex ?? -1]?.slice(1) ?? ''
|
||||
expect(new TextDecoder().decode(readFileSync(promptPath))).toBe(prompt)
|
||||
expect(args.join(' ').length).toBeLessThan(1000)
|
||||
if (outcome === 'spawn failure') {
|
||||
throw new Error('recorded spawn failure')
|
||||
}
|
||||
return child
|
||||
}
|
||||
})
|
||||
)
|
||||
if (outcome === 'success') {
|
||||
// Like the recorded binary, this child never reads stdin; only the explicit file submits input.
|
||||
child.stdout.emit('data', Buffer.from('Lighthouse Signal Trace'))
|
||||
} else if (outcome === 'cancel') {
|
||||
cancelLocalGeneration('conversation-name', '/omp-file-test')
|
||||
} else if (outcome === 'timeout') {
|
||||
await vi.advanceTimersByTimeAsync(SOURCE_CONTROL_GENERATION_TIMEOUT_MS)
|
||||
}
|
||||
if (outcome !== 'spawn failure') {
|
||||
expect(child.stdin.end).toHaveBeenCalledWith(undefined)
|
||||
expect(existsSync(promptPath)).toBe(true)
|
||||
child.emit('close', outcome === 'failure' ? 1 : 0)
|
||||
}
|
||||
const result = await execution.result
|
||||
await execution.processClosed
|
||||
expect(result.success).toBe(outcome === 'success')
|
||||
await vi.waitFor(() => expect(existsSync(dirname(promptPath))).toBe(false))
|
||||
}
|
||||
)
|
||||
|
||||
// Pinned OMP17 cli/file-processor.ts:16,49-54,119-122: per-file cap, ordered text decoding.
|
||||
const attachmentReader = String.raw`
|
||||
const fs = require('node:fs');
|
||||
const args = process.argv.slice(1);
|
||||
const paths = args.filter(arg => arg.startsWith('@')).map(arg => arg.slice(1));
|
||||
const chunks = paths.map(path => fs.readFileSync(path));
|
||||
const isImage = bytes => ['GIF87a', 'GIF89a'].includes(bytes.subarray(0, 6).toString()) ||
|
||||
(bytes.subarray(0, 4).toString() === 'RIFF' && bytes.subarray(8, 12).toString() === 'WEBP');
|
||||
const fragments = chunks.map(bytes => bytes.length > 5 * 1024 * 1024
|
||||
? '(skipped: too large)' : isImage(bytes) ? '(image instead of text)' : new TextDecoder().decode(bytes));
|
||||
let stdin = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => { stdin += chunk; });
|
||||
process.stdin.on('end', () => {
|
||||
fs.writeFileSync(process.env.OMP_RECEIPT, JSON.stringify({
|
||||
paths, sizes: chunks.map(bytes => bytes.length), content: paths.length ? fragments.join('') : stdin, stdin,
|
||||
message: fragments.map((text, index) => '<file name="' + paths[index] + '">\n' + text + '\n</file>\n').join('') + args.at(-1)
|
||||
}));
|
||||
process.stdout.write(process.env.OMP_OUTPUT);
|
||||
if (process.env.OMP_EXIT === '7') process.stderr.write('Fixture provider unavailable');
|
||||
process.exitCode = Number(process.env.OMP_EXIT);
|
||||
});
|
||||
`
|
||||
|
||||
const contractParams = {
|
||||
agentId: 'omp',
|
||||
model: 'generated/model',
|
||||
thinkingLevel: 'low',
|
||||
agentCommandOverride: 'OMP_RECIPE_ENV=kept node /configured/prefix/omp.js --model prefix/model',
|
||||
agentArgs: '--model recipe/model --thinking high -- recipe-message',
|
||||
commandInputTemplate: 'Saved recipe instruction\n{basePrompt}'
|
||||
} as const
|
||||
const contractTarget = { kind: 'local', cwd: tmpdir() } as const
|
||||
const commitContext = {
|
||||
branch: 'feature',
|
||||
stagedSummary: 'signal.ts | 1 +',
|
||||
stagedPatch: '+ Trace lighthouse signal'
|
||||
}
|
||||
|
||||
type Consumer = {
|
||||
label: string
|
||||
run(
|
||||
params: ResolvedSourceControlAiGenerationParams,
|
||||
spawnAgent: SpawnSourceControlAgent
|
||||
): Promise<unknown>
|
||||
output: string
|
||||
expected: object
|
||||
}
|
||||
const consumers: Consumer[] = [
|
||||
{
|
||||
label: 'commit',
|
||||
run: (params, spawnAgent) =>
|
||||
generateCommitMessage({ context: commitContext, params, target: contractTarget, spawnAgent }),
|
||||
output: 'Trace lighthouse signal.\n\nPreserve request bytes\n',
|
||||
expected: { message: 'Trace lighthouse signal\n\nPreserve request bytes' }
|
||||
},
|
||||
{
|
||||
label: 'PR fields',
|
||||
run: (params, spawnAgent) =>
|
||||
generatePullRequestFields({
|
||||
context: {
|
||||
branch: 'feature',
|
||||
base: 'main',
|
||||
branchChangedByPreparation: true,
|
||||
currentTitle: '',
|
||||
currentBody: '',
|
||||
currentDraft: false,
|
||||
commitSummary: 'Trace lighthouse signal',
|
||||
changeSummary: 'signal.ts',
|
||||
patch: '+ Trace lighthouse signal'
|
||||
},
|
||||
params,
|
||||
target: contractTarget,
|
||||
spawnAgent
|
||||
}),
|
||||
output: JSON.stringify({
|
||||
base: 'main',
|
||||
title: 'Trace lighthouse signal.',
|
||||
body: 'Preserve request bytes\n',
|
||||
draft: true
|
||||
}),
|
||||
expected: {
|
||||
fields: {
|
||||
base: 'main',
|
||||
title: 'Trace lighthouse signal',
|
||||
body: 'Preserve request bytes',
|
||||
draft: true
|
||||
},
|
||||
branchChangedByPreparation: true
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'branch slug / folder title',
|
||||
run: (params, spawnAgent) =>
|
||||
generateBranchName({
|
||||
context: { firstPrompt: 'Trace lighthouse signal' },
|
||||
params,
|
||||
target: contractTarget,
|
||||
spawnAgent
|
||||
}),
|
||||
output: 'Trace Lighthouse Signal!',
|
||||
expected: { slug: 'trace-lighthouse-signal' }
|
||||
},
|
||||
{
|
||||
label: 'conversation name',
|
||||
run: (params, spawnAgent) =>
|
||||
generateConversationName({
|
||||
context: { firstPrompt: 'Trace lighthouse signal' },
|
||||
params,
|
||||
target: contractTarget,
|
||||
spawnAgent
|
||||
}),
|
||||
output: '# Title: "Trace lighthouse signal"\nextra commentary',
|
||||
expected: { name: 'Trace lighthouse signal' }
|
||||
},
|
||||
{
|
||||
label: 'Jira summary',
|
||||
run: (params, spawnAgent) =>
|
||||
generateJiraIssueSummaryFromContext(
|
||||
{ description: 'Trace lighthouse signal' },
|
||||
params,
|
||||
contractTarget,
|
||||
spawnAgent
|
||||
),
|
||||
output: '"Trace lighthouse signal"\nextra commentary',
|
||||
expected: { summary: 'Trace lighthouse signal' }
|
||||
}
|
||||
]
|
||||
|
||||
type Receipt = { paths: string[]; sizes: number[]; content: string; stdin: string; message: string }
|
||||
async function withReader<T>(
|
||||
output: string,
|
||||
fail: boolean,
|
||||
run: (spawnAgent: SpawnSourceControlAgent) => Promise<T>
|
||||
) {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-omp-contract-'))
|
||||
const receiptPath = join(directory, 'receipt.json')
|
||||
try {
|
||||
const result = await run((input) => {
|
||||
expect(input.binary).toBe('node')
|
||||
expect(input.args.slice(0, 1)).toEqual(['/configured/prefix/omp.js'])
|
||||
expect(input.args).toContain('recipe/model')
|
||||
expect(input.args).not.toContain('prefix/model')
|
||||
expect(input.args).not.toContain('generated/model')
|
||||
expect(input.args).toContain('high')
|
||||
expect(input.args).not.toContain('low')
|
||||
expect(input.args.slice(-2)).toEqual(['--', 'recipe-message'])
|
||||
expect(input.commandEnv).toEqual({ OMP_RECIPE_ENV: 'kept' })
|
||||
return spawnProcess({
|
||||
program: process.execPath,
|
||||
args: ['-e', attachmentReader, '--', ...input.args],
|
||||
cwd: input.cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
OMP_RECEIPT: receiptPath,
|
||||
OMP_OUTPUT: output,
|
||||
OMP_EXIT: fail ? '7' : '0'
|
||||
},
|
||||
stdio: 'pipe'
|
||||
})
|
||||
})
|
||||
const receipt: Receipt = JSON.parse(readFileSync(receiptPath, 'utf8'))
|
||||
await vi.waitFor(() => {
|
||||
for (const path of receipt.paths) {
|
||||
expect(existsSync(dirname(path))).toBe(false)
|
||||
}
|
||||
})
|
||||
return { result, receipt }
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
it.each(consumers)(
|
||||
'preserves $label output and failure classification across stdin/file transport',
|
||||
async (consumer) => {
|
||||
const spec = agentSpecs.getCommitMessageAgentSpec('omp')
|
||||
if (!spec) {
|
||||
throw new Error('Missing OMP spec')
|
||||
}
|
||||
for (const fail of [false, true]) {
|
||||
const delivered = await withReader(consumer.output, fail, (spawn) =>
|
||||
consumer.run(contractParams, spawn)
|
||||
)
|
||||
expect(delivered.receipt.paths).toHaveLength(1)
|
||||
expect(delivered.receipt.stdin).toBe('')
|
||||
expect(delivered.receipt.message.endsWith('recipe-message')).toBe(true)
|
||||
expect(delivered.receipt.content).toContain('Trace lighthouse signal')
|
||||
expect(delivered.receipt.content.includes('Saved recipe instruction')).toBe(
|
||||
consumer.label !== 'Jira summary'
|
||||
)
|
||||
if (fail) {
|
||||
expect(delivered.result).toMatchObject({
|
||||
success: false,
|
||||
error: expect.stringContaining('OMP CLI command failed with code 7')
|
||||
})
|
||||
} else {
|
||||
expect(delivered.result).toMatchObject({
|
||||
success: true,
|
||||
agentLabel: 'OMP',
|
||||
...consumer.expected
|
||||
})
|
||||
}
|
||||
const legacy = vi.spyOn(agentSpecs, 'getCommitMessageAgentSpec').mockReturnValue({
|
||||
...spec,
|
||||
promptDelivery: 'stdin',
|
||||
buildArgs: (params) => spec.buildArgs(params).slice(0, -1)
|
||||
})
|
||||
try {
|
||||
const previous = await withReader(consumer.output, fail, (spawn) =>
|
||||
consumer.run(contractParams, spawn)
|
||||
)
|
||||
expect(previous.receipt.paths).toHaveLength(0)
|
||||
expect(previous.receipt.content).toBe(delivered.receipt.content)
|
||||
expect(previous.result).toEqual(delivered.result)
|
||||
} finally {
|
||||
legacy.mockRestore()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['below', 'at', 'above', 'multibyte', 'BOM run', 'GIF', 'RIFF', 'internal magic'] as const)(
|
||||
'delivers complete text recipe content: %s',
|
||||
async (boundary) => {
|
||||
const cap = 5 * 1024 * 1024
|
||||
const payloadCap = cap - 3
|
||||
const patches = {
|
||||
below: () => 'x'.repeat(payloadCap - 1),
|
||||
at: () => 'x'.repeat(payloadCap),
|
||||
above: () => 'x'.repeat(cap + 1),
|
||||
multibyte: () =>
|
||||
`${'x'.repeat(payloadCap - 1)}🌊界é${'界'.repeat(Math.ceil(cap / 3))}\uFEFFEND`,
|
||||
'BOM run': () => `${'\uFEFF'.repeat(Math.ceil(cap / 3))}Preserve every BOM`,
|
||||
GIF: () => 'GIF89a is the header this change handles. Preserve this as text.',
|
||||
RIFF: () => 'RIFFxxxxWEBP is the signature this change handles. Preserve this as text.',
|
||||
'internal magic': () => `${'x'.repeat(payloadCap)}GIF89a preserve this boundary as text.`
|
||||
}
|
||||
const patch = patches[boundary]()
|
||||
const { result, receipt } = await withReader(
|
||||
'Preserve the complete request',
|
||||
false,
|
||||
(spawnAgent) =>
|
||||
generateCommitMessage({
|
||||
context: { ...commitContext, stagedPatch: patch },
|
||||
params: { ...contractParams, commandInputTemplate: '{stagedPatch}' },
|
||||
target: contractTarget,
|
||||
spawnAgent
|
||||
})
|
||||
)
|
||||
expect(result).toMatchObject({ success: true, message: 'Preserve the complete request' })
|
||||
expect(receipt.content).toBe(patch)
|
||||
expect(receipt.sizes.every((size) => size <= cap)).toBe(true)
|
||||
expect(receipt.paths).toHaveLength(
|
||||
boundary === 'multibyte'
|
||||
? 3
|
||||
: ['above', 'BOM run', 'internal magic'].includes(boundary)
|
||||
? 2
|
||||
: 1
|
||||
)
|
||||
if (boundary === 'at') {
|
||||
expect(receipt.sizes).toEqual([cap])
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['success', 'spawn failure', 'permanent failure'] as const)(
|
||||
'settles independently of bounded async cleanup after %s',
|
||||
async (outcome) => {
|
||||
const originalPlatform = process.platform
|
||||
const rmMock = vi.mocked(rm)
|
||||
const errors = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const locked = Object.assign(new Error('EPERM: prompt handle held'), { code: 'EPERM' })
|
||||
let directory = ''
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
rmMock.mockClear()
|
||||
rmMock.mockImplementationOnce(async () => {
|
||||
throw locked
|
||||
})
|
||||
if (outcome === 'permanent failure') {
|
||||
rmMock.mockRejectedValue(locked)
|
||||
}
|
||||
try {
|
||||
const planned = planCommitMessageGeneration(
|
||||
{ agentId: 'omp', model: 'default' },
|
||||
recordedPrompt
|
||||
)
|
||||
if (!planned.ok) {
|
||||
throw new Error(planned.error)
|
||||
}
|
||||
const child = createMockDiscoveryChild()
|
||||
const execution = runLocalSourceControlPlan({
|
||||
plan: planned.plan,
|
||||
cwd: '/omp-cleanup',
|
||||
env: undefined,
|
||||
operation: 'conversation-name',
|
||||
emptyResultName: 'chat name',
|
||||
spawnAgent: ({ args }) => {
|
||||
directory = dirname(args[planned.plan.promptFile?.argIndex ?? -1].slice(1))
|
||||
if (outcome === 'spawn failure') {
|
||||
throw new Error('Fixture spawn failure')
|
||||
}
|
||||
return child
|
||||
}
|
||||
})
|
||||
if (outcome !== 'spawn failure') {
|
||||
child.stdout.emit('data', Buffer.from('Lighthouse Signal Trace'))
|
||||
child.emit('close', 0)
|
||||
}
|
||||
expect((await execution.result).success).toBe(outcome !== 'spawn failure')
|
||||
await execution.processClosed
|
||||
expect(rmMock).toHaveBeenCalledTimes(1)
|
||||
expect(existsSync(directory)).toBe(true)
|
||||
if (outcome === 'permanent failure') {
|
||||
await vi.waitFor(
|
||||
() =>
|
||||
expect(errors).toHaveBeenCalledWith(
|
||||
'[commit-message] Failed to remove generation prompt:',
|
||||
locked
|
||||
),
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
expect(rmMock).toHaveBeenCalledTimes(5)
|
||||
expect(errors).toHaveBeenCalledTimes(1)
|
||||
expect(existsSync(directory)).toBe(true)
|
||||
} else {
|
||||
await vi.waitFor(() => expect(existsSync(directory)).toBe(false), { timeout: 2000 })
|
||||
expect(rmMock).toHaveBeenCalledTimes(2)
|
||||
}
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
rmMock.mockImplementation(actualFs.rm)
|
||||
if (directory) {
|
||||
await actualFs.rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
errors.mockRestore()
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,140 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { getUtf8ChunkEndIndex } from '../../shared/utf8-byte-limits'
|
||||
import { encodePosixPrintfRecords } from '../../shared/posix-printf-encoding'
|
||||
import { toWindowsWslUncPath } from '../../shared/wsl-paths'
|
||||
import type { CommitMessagePlan } from '../../shared/commit-message-plan'
|
||||
|
||||
// Reserve the transport signature under the native text attachment cap.
|
||||
const PAYLOAD_BYTES = 5 * 1024 * 1024 - 3
|
||||
|
||||
export function* promptFileContents(content: string): Generator<string> {
|
||||
for (let start = 0; start < content.length;) {
|
||||
const end = getUtf8ChunkEndIndex(content, start, PAYLOAD_BYTES)
|
||||
yield `\uFEFF${content.slice(start, end)}`
|
||||
start = end
|
||||
}
|
||||
}
|
||||
|
||||
export function stageLocalPromptFiles(
|
||||
plan: CommitMessagePlan,
|
||||
own: (directory: string) => void
|
||||
): CommitMessagePlan {
|
||||
if (!plan.promptFile) {
|
||||
return plan
|
||||
}
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-generation-'))
|
||||
own(directory)
|
||||
const attachments: string[] = []
|
||||
for (const content of promptFileContents(plan.promptFile.content)) {
|
||||
const path = join(directory, `prompt-${attachments.length + 1}.txt`)
|
||||
writeFileSync(path, content, { mode: 0o600 })
|
||||
attachments.push(`@${path}`)
|
||||
}
|
||||
const args = [...plan.args]
|
||||
args.splice(plan.promptFile.argIndex, 1, ...attachments)
|
||||
return { ...plan, args }
|
||||
}
|
||||
|
||||
// Preparation precedes profiles; only a proved empty allocation may roll back before ACK.
|
||||
export const WSL_PROMPT_PREPARATION = `
|
||||
nonce=$1; count=$2; shift 2
|
||||
dir=/tmp/orca-generation-$nonce
|
||||
old_umask=$(umask)
|
||||
umask 077
|
||||
mkdir "$dir" || exit 1
|
||||
trap 'rmdir "$dir" 2>/dev/null' EXIT
|
||||
printf '\\nORCA_GENERATION_READY:%s\\n' "$nonce" >&2 || exit 1
|
||||
IFS= read -r record && [ "$record" = "A$nonce" ] || exit 1
|
||||
trap - EXIT
|
||||
index=1
|
||||
while [ "$index" -le "$count" ]; do
|
||||
file=$dir/prompt-$index.txt
|
||||
: > "$file" || exit 1
|
||||
while :; do
|
||||
IFS= read -r record || exit 1
|
||||
case "$record" in
|
||||
D*) printf '%b' "\${record#D}" >> "$file" || exit 1 ;;
|
||||
*) [ "$record" = "F$nonce:$index" ] || exit 1; break ;;
|
||||
esac
|
||||
done
|
||||
index=$((index + 1))
|
||||
done
|
||||
IFS= read -r record && [ "$record" = "X$nonce" ] || exit 1
|
||||
umask "$old_umask"
|
||||
exec "$@" </dev/null
|
||||
`
|
||||
|
||||
export function prepareWslPrompt(plan: CommitMessagePlan, distro: string) {
|
||||
const nonce = randomUUID()
|
||||
const directory = `/tmp/orca-generation-${nonce}`
|
||||
const attachments: string[] = []
|
||||
for (const _content of promptFileContents(plan.promptFile?.content ?? '')) {
|
||||
attachments.push(`@${directory}/prompt-${attachments.length + 1}.txt`)
|
||||
}
|
||||
const args = [...plan.args]
|
||||
args.splice(plan.promptFile?.argIndex ?? 0, 1, ...attachments)
|
||||
const proof = `ORCA_GENERATION_READY:${nonce}`
|
||||
let partial = ''
|
||||
let oversized = false
|
||||
let proved = false
|
||||
return {
|
||||
plan: { ...plan, args },
|
||||
preparation: { script: WSL_PROMPT_PREPARATION, args: [nonce, String(attachments.length)] },
|
||||
cleanupPath: toWindowsWslUncPath(directory, distro),
|
||||
observe(chunk: Buffer, diagnostic?: (bytes: Buffer) => void): boolean {
|
||||
let firstProof = false
|
||||
let passthrough = 0
|
||||
for (let index = 0; index < chunk.length; index++) {
|
||||
const byte = chunk[index]
|
||||
if (oversized) {
|
||||
const end = chunk.indexOf(10, index)
|
||||
if (end === -1) {
|
||||
diagnostic?.(chunk.subarray(passthrough))
|
||||
return firstProof
|
||||
}
|
||||
diagnostic?.(chunk.subarray(passthrough, end + 1))
|
||||
index = end
|
||||
oversized = false
|
||||
} else if (byte === 10) {
|
||||
if (partial === proof) {
|
||||
if (!proved) {
|
||||
proved = true
|
||||
firstProof = true
|
||||
}
|
||||
} else {
|
||||
diagnostic?.(Buffer.from(`${partial}\n`, 'latin1'))
|
||||
}
|
||||
partial = ''
|
||||
} else if (partial.length < proof.length && byte === proof.charCodeAt(partial.length)) {
|
||||
partial += String.fromCharCode(byte)
|
||||
} else {
|
||||
if (partial) {
|
||||
diagnostic?.(Buffer.from(partial, 'latin1'))
|
||||
}
|
||||
partial = ''
|
||||
oversized = true
|
||||
passthrough = index
|
||||
index--
|
||||
}
|
||||
if (!oversized) {
|
||||
passthrough = index + 1
|
||||
}
|
||||
}
|
||||
return firstProof
|
||||
},
|
||||
*records(): Generator<string> {
|
||||
yield `A${nonce}\n`
|
||||
let index = 0
|
||||
for (const content of promptFileContents(plan.promptFile?.content ?? '')) {
|
||||
for (const encoded of encodePosixPrintfRecords(content)) {
|
||||
yield `D${encoded}\n`
|
||||
}
|
||||
yield `F${nonce}:${++index}\n`
|
||||
}
|
||||
yield `X${nonce}\n`
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { setImmediate as yieldToEvents } from 'node:timers/promises'
|
||||
import type { Writable } from 'node:stream'
|
||||
|
||||
/** One bounded record at a time; a stopped attempt also releases an outstanding drain wait. */
|
||||
export async function producePromptRecords(
|
||||
stdin: Writable,
|
||||
records: Iterable<string>,
|
||||
signal: AbortSignal,
|
||||
active: () => boolean
|
||||
): Promise<void> {
|
||||
let batch = 0
|
||||
for (const record of records) {
|
||||
if (!active()) {
|
||||
return
|
||||
}
|
||||
if (!stdin.write(record)) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const done = (): void => {
|
||||
stdin.off('drain', done)
|
||||
stdin.off('error', done)
|
||||
stdin.off('close', done)
|
||||
signal.removeEventListener('abort', done)
|
||||
resolve()
|
||||
}
|
||||
stdin.once('drain', done)
|
||||
stdin.once('error', done)
|
||||
stdin.once('close', done)
|
||||
signal.addEventListener('abort', done, { once: true })
|
||||
if (!active() || stdin.destroyed || stdin.writableEnded) {
|
||||
done()
|
||||
}
|
||||
})
|
||||
if (!active() || stdin.destroyed || stdin.writableEnded) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (++batch === 16) {
|
||||
batch = 0
|
||||
await yieldToEvents()
|
||||
if (!active()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if (active()) {
|
||||
stdin.end()
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,7 @@ export type SourceControlAgentSpawnInput = {
|
||||
wslDistro?: string
|
||||
// WSL applies these in the guest; native callers already merge them into env.
|
||||
commandEnv?: Record<string, string>
|
||||
preparation?: { script: string; args: string[] }
|
||||
stdinMode: 'ignore' | 'pipe'
|
||||
useCwdForNative: boolean
|
||||
}
|
||||
@@ -115,3 +116,13 @@ export type SourceControlAgentSpawnInput = {
|
||||
export type SpawnSourceControlAgent = (
|
||||
input: SourceControlAgentSpawnInput
|
||||
) => SpawnedSourceControlAgentProcess
|
||||
|
||||
export type LocalSourceControlPlanInput = {
|
||||
plan: CommitMessagePlan
|
||||
cwd: string
|
||||
env: NodeJS.ProcessEnv | undefined
|
||||
emptyResultName: string
|
||||
operation: TextGenerationOperation
|
||||
wslDistro?: string
|
||||
spawnAgent: SpawnSourceControlAgent
|
||||
}
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import { spawnProcess } from '@orca/process-host'
|
||||
import type * as ChildProcess from 'node:child_process'
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, realpathSync } from 'node:fs'
|
||||
import { rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it, vi, afterEach } from 'vitest'
|
||||
import { quotePosixShell } from '../../shared/shell-argument-quoting'
|
||||
import { commandLineLength, MAX_COMMAND_LINE_CHARS } from '../../shared/windows-command-line-budget'
|
||||
import { prepareWslPrompt } from './source-control-prompt-files'
|
||||
import { producePromptRecords } from './source-control-prompt-producer'
|
||||
import { planCommitMessageGeneration } from '../../shared/commit-message-plan'
|
||||
import {
|
||||
withPlatform,
|
||||
createMockDiscoveryChild
|
||||
} from './commit-message-text-generation-test-harness'
|
||||
import { spawnSourceControlAgent } from './source-control-agent-launch'
|
||||
import { runLocalPlanForAgent } from './source-control-local-generation'
|
||||
import { resolveCommand } from '../git/command-runner/wsl-command-resolution'
|
||||
|
||||
const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() }))
|
||||
vi.mock('node:child_process', async (original) => {
|
||||
const actual = await original<typeof ChildProcess>()
|
||||
spawnMock.mockImplementation(actual.spawn)
|
||||
return { ...actual, spawn: spawnMock }
|
||||
})
|
||||
vi.mock('../wsl-background-distro-gate', () => ({ noteWslDistroLaunched: vi.fn() }))
|
||||
const nativeSpawn = (await vi.importActual<typeof ChildProcess>('node:child_process')).spawn
|
||||
afterEach(() => {
|
||||
spawnMock.mockReset().mockImplementation(nativeSpawn)
|
||||
})
|
||||
const repo = '\\\\wsl.localhost\\Actual\\home\\dev\\repo'
|
||||
function planned(agentId: 'omp' | 'codex' = 'omp') {
|
||||
const result = planCommitMessageGeneration(
|
||||
{
|
||||
agentId,
|
||||
model: 'default',
|
||||
agentCommandOverride: 'lower=recipe LOWER=UPPER omp --model prefix/model',
|
||||
agentArgs: '--thinking high --mode text @user.txt -- recipe-message'
|
||||
},
|
||||
'request'
|
||||
)
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
return result.plan
|
||||
}
|
||||
|
||||
it.each([undefined, 'Wrong', 'Actual'])(
|
||||
'routes UNC before hint %s with Linux assignments retained for OMP only',
|
||||
async (hint) => {
|
||||
const request = planned()
|
||||
const child = createMockDiscoveryChild()
|
||||
let guestPath = ''
|
||||
const result = withPlatform('win32', () =>
|
||||
runLocalPlanForAgent({
|
||||
agentId: 'omp',
|
||||
plan: request,
|
||||
target: {
|
||||
kind: 'local',
|
||||
cwd: repo,
|
||||
wslDistro: hint,
|
||||
env: { lower: 'base', LOWER: 'BASE' }
|
||||
},
|
||||
operation: 'conversation-name',
|
||||
emptyResultName: 'name',
|
||||
spawnAgent: (input) => {
|
||||
expect(input.wslDistro).toBe('Actual')
|
||||
expect(input.env).toEqual({ lower: 'base', LOWER: 'BASE' })
|
||||
expect(input.commandEnv).toEqual({ lower: 'recipe', LOWER: 'UPPER' })
|
||||
expect(input.preparation).toBeDefined()
|
||||
guestPath = input.args.find((arg) => arg.startsWith('@/tmp/')) ?? ''
|
||||
queueMicrotask(() => {
|
||||
child.stdout.emit('data', Buffer.from('Useful name'))
|
||||
child.emit('close', 0)
|
||||
})
|
||||
return child
|
||||
}
|
||||
})
|
||||
)
|
||||
expect(await result).toMatchObject({ success: true })
|
||||
expect(guestPath).toContain('@/tmp/orca-generation-')
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves ordinary hinted WSL and unrelated provider no-hint native routing', async () => {
|
||||
for (const agentId of ['omp', 'codex'] as const) {
|
||||
const request = planned(agentId)
|
||||
const child = createMockDiscoveryChild()
|
||||
const result = withPlatform('win32', () =>
|
||||
runLocalPlanForAgent({
|
||||
agentId: 'custom',
|
||||
plan: request,
|
||||
target: {
|
||||
kind: 'local',
|
||||
cwd: agentId === 'omp' ? 'C:\\repo' : repo,
|
||||
...(agentId === 'omp' ? { wslDistro: 'Hint' } : {})
|
||||
},
|
||||
operation: 'conversation-name',
|
||||
emptyResultName: 'name',
|
||||
spawnAgent: (input) => {
|
||||
expect(input.wslDistro).toBe(agentId === 'omp' ? 'Hint' : undefined)
|
||||
expect(!!input.preparation).toBe(agentId === 'omp')
|
||||
queueMicrotask(() => {
|
||||
child.stdout.emit('data', Buffer.from('Useful name'))
|
||||
child.emit('close', 0)
|
||||
})
|
||||
return child
|
||||
}
|
||||
})
|
||||
)
|
||||
expect(await result).toMatchObject({ success: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('launches the original resolved wrapper once, preserving prefix, recipe, translation and assignment timing', () => {
|
||||
const request = prepareWslPrompt(planned(), 'Actual')
|
||||
spawnMock.mockReturnValueOnce(createMockDiscoveryChild())
|
||||
withPlatform('win32', () => {
|
||||
const assignments = ['lower=recipe', 'LOWER=UPPER']
|
||||
const original = resolveCommand(
|
||||
'/usr/bin/env',
|
||||
[...assignments, planned().binary, ...request.plan.args],
|
||||
repo,
|
||||
'Wrong',
|
||||
{ useWslLoginShell: true }
|
||||
)
|
||||
spawnSourceControlAgent({
|
||||
binary: planned().binary,
|
||||
args: request.plan.args,
|
||||
cwd: repo,
|
||||
commandEnv: planned().env,
|
||||
wslDistro: 'Actual',
|
||||
preparation: request.preparation,
|
||||
stdinMode: 'pipe',
|
||||
useCwdForNative: true
|
||||
})
|
||||
const [binary, args] = spawnMock.mock.calls.at(-1)!
|
||||
expect(binary).toBe('wsl.exe')
|
||||
expect(args.slice(0, 3)).toEqual(['-d', 'Actual', '--exec'])
|
||||
expect(args.slice(3, 7)).toEqual([
|
||||
'/bin/sh',
|
||||
'-c',
|
||||
request.preparation.script,
|
||||
'orca-generation'
|
||||
])
|
||||
expect(args.slice(9)).toEqual(original.args.slice(3))
|
||||
expect(original.args.at(-1)).toContain('/home/dev/repo')
|
||||
expect(original.args.at(-1)).toContain('lower=recipe')
|
||||
expect(args.join(' ')).toContain('@user.txt')
|
||||
})
|
||||
})
|
||||
|
||||
it('checks the exact composed Windows argv at the inclusive boundary before spawning', () => {
|
||||
const request = prepareWslPrompt(planned(), 'Actual')
|
||||
const launch = (padding: number) =>
|
||||
withPlatform('win32', () =>
|
||||
spawnSourceControlAgent({
|
||||
binary: 'omp',
|
||||
args: [...request.plan.args, 'x'.repeat(padding)],
|
||||
cwd: repo,
|
||||
wslDistro: 'Actual',
|
||||
preparation: request.preparation,
|
||||
stdinMode: 'pipe',
|
||||
useCwdForNative: true
|
||||
})
|
||||
)
|
||||
spawnMock.mockReturnValue(createMockDiscoveryChild())
|
||||
launch(0)
|
||||
const [binary, args] = spawnMock.mock.calls.at(-1)!
|
||||
const slack = MAX_COMMAND_LINE_CHARS - commandLineLength([binary, ...args])
|
||||
const padding = Math.floor(slack / 3)
|
||||
launch(padding)
|
||||
const [atBinary, atArgs] = spawnMock.mock.calls.at(-1)!
|
||||
expect(commandLineLength([atBinary, ...atArgs])).toBeLessThanOrEqual(MAX_COMMAND_LINE_CHARS)
|
||||
expect(commandLineLength([atBinary, ...atArgs])).toBeGreaterThan(MAX_COMMAND_LINE_CHARS - 3)
|
||||
const count = spawnMock.mock.calls.length
|
||||
expect(() => launch(padding + 1)).toThrow()
|
||||
expect(spawnMock).toHaveBeenCalledTimes(count)
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'prepares before hostile profiles and preserves login invocation/cwd/env (assigned=%s)',
|
||||
async (assigned) => {
|
||||
const directory = realpathSync(mkdtempSync(join(tmpdir(), 'orca-c3-profiles-')))
|
||||
const repoPath = join(directory, 'repo')
|
||||
const startup = join(directory, 'startup')
|
||||
const changed = join(directory, 'profile-cwd')
|
||||
const reportPath = join(directory, 'report.json')
|
||||
if (!assigned) {
|
||||
mkdirSync(repoPath)
|
||||
}
|
||||
mkdirSync(startup)
|
||||
mkdirSync(changed)
|
||||
const shell = join(directory, 'bash')
|
||||
const profile = join(directory, 'rc')
|
||||
writeFileSync(
|
||||
shell,
|
||||
`#!/bin/sh\nexec /bin/bash --noprofile --rcfile ${quotePosixShell(profile)} -ic "$2"\n`,
|
||||
{ mode: 0o700 }
|
||||
)
|
||||
writeFileSync(
|
||||
join(directory, 'getent'),
|
||||
`#!/bin/sh\nprintf '%s\\n' ${quotePosixShell(`user:x:1:1:User:${directory}:${shell}`)}\n`,
|
||||
{ mode: 0o700 }
|
||||
)
|
||||
writeFileSync(
|
||||
join(directory, '.profile'),
|
||||
`export PATH=${quotePosixShell(directory)}:"$PATH"\n`
|
||||
)
|
||||
writeFileSync(
|
||||
profile,
|
||||
`
|
||||
export STARTUP_CWD="$PWD"
|
||||
export PROFILE_LOWER="$lower"
|
||||
if IFS= read -r stolen; then export STOLEN=yes; else export STOLEN=no; fi
|
||||
alias mkdir='mkdir -pv'
|
||||
alias printf='printf CORRUPTED'
|
||||
set -C
|
||||
command mkdir -p ${quotePosixShell(repoPath)}
|
||||
cd ${quotePosixShell(changed)}
|
||||
omp() { ${quotePosixShell(process.execPath)} ${quotePosixShell(join(directory, 'reader.cjs'))} "$@"; }
|
||||
`
|
||||
)
|
||||
writeFileSync(
|
||||
join(directory, 'reader.cjs'),
|
||||
String.raw`
|
||||
const fs=require('node:fs'); const path=process.argv.find(arg=>arg.startsWith('@/tmp/')).slice(1);
|
||||
fs.writeFileSync(process.env.REPORT, JSON.stringify({startup:process.env.STARTUP_CWD,cwd:process.cwd(),stolen:process.env.STOLEN,profileLower:process.env.PROFILE_LOWER,lower:process.env.lower,content:new TextDecoder().decode(fs.readFileSync(path)),stdin:fs.readFileSync(0).length,mask:process.umask()}));
|
||||
`
|
||||
)
|
||||
const request = prepareWslPrompt(planned(), 'Actual')
|
||||
// Use the actual resolved native cwd and original wrapper, without starting a guest on this Mac.
|
||||
const original = withPlatform('win32', () =>
|
||||
resolveCommand(
|
||||
assigned ? '/usr/bin/env' : 'omp',
|
||||
assigned
|
||||
? [
|
||||
'lower=recipe',
|
||||
'LOWER=UPPER',
|
||||
process.execPath,
|
||||
join(directory, 'reader.cjs'),
|
||||
...request.plan.args
|
||||
]
|
||||
: request.plan.args,
|
||||
`\\\\wsl.localhost\\Actual${repoPath.replaceAll('/', '\\')}`,
|
||||
undefined,
|
||||
{ useWslLoginShell: true }
|
||||
)
|
||||
)
|
||||
const child = spawnProcess({
|
||||
program: '/bin/sh',
|
||||
args: [
|
||||
'-c',
|
||||
request.preparation.script,
|
||||
'fixture',
|
||||
...request.preparation.args,
|
||||
...original.args.slice(3)
|
||||
],
|
||||
cwd: startup,
|
||||
env: { ...process.env, HOME: directory, SHELL: shell, REPORT: reportPath, lower: 'base' },
|
||||
stdio: 'pipe'
|
||||
})
|
||||
child.stdin.on('error', () => {})
|
||||
const stop = new AbortController()
|
||||
const closed = new Promise<number | null>((resolve, reject) => {
|
||||
child.on('close', resolve)
|
||||
child.on('error', reject)
|
||||
})
|
||||
child.stderr.on('data', (chunk) => {
|
||||
if (request.observe(chunk)) {
|
||||
void producePromptRecords(
|
||||
child.stdin,
|
||||
request.records(),
|
||||
stop.signal,
|
||||
() => !stop.signal.aborted
|
||||
)
|
||||
}
|
||||
})
|
||||
try {
|
||||
expect(await closed).toBe(0)
|
||||
const receipt = JSON.parse(readFileSync(reportPath, 'utf8'))
|
||||
expect(receipt).toMatchObject({
|
||||
startup,
|
||||
cwd: repoPath,
|
||||
stolen: 'no',
|
||||
profileLower: 'base',
|
||||
lower: assigned ? 'recipe' : 'base',
|
||||
content: 'request',
|
||||
stdin: 0
|
||||
})
|
||||
expect(receipt.mask).toBe(process.umask())
|
||||
} finally {
|
||||
stop.abort()
|
||||
await rm(`/tmp/orca-generation-${request.preparation.args[0]}`, {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,532 @@
|
||||
import { spawnProcess } from '@orca/process-host'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { rm } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { Writable } from 'node:stream'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { planCommitMessageGeneration } from '../../shared/commit-message-plan'
|
||||
import { prepareWslPrompt } from './source-control-prompt-files'
|
||||
import { producePromptRecords } from './source-control-prompt-producer'
|
||||
import { runLocalSourceControlPlan } from './source-control-local-process'
|
||||
import { cancelLocalGeneration } from './source-control-generation-lanes'
|
||||
import {
|
||||
MAX_SOURCE_CONTROL_AGENT_OUTPUT_BYTES,
|
||||
SOURCE_CONTROL_GENERATION_TIMEOUT_MS
|
||||
} from './source-control-generation-limits'
|
||||
import {
|
||||
withPlatform,
|
||||
createMockDiscoveryChild
|
||||
} from './commit-message-text-generation-test-harness'
|
||||
import type { SourceControlAgentSpawnInput } from './source-control-text-generation-types'
|
||||
import type * as Removal from '../../shared/windows-transient-lock-removal'
|
||||
|
||||
const { removeTreeMock } = vi.hoisted(() => ({ removeTreeMock: vi.fn(async () => {}) }))
|
||||
vi.mock('../../shared/windows-transient-lock-removal', async (original) => ({
|
||||
...(await original<typeof Removal>()),
|
||||
removeTree: removeTreeMock
|
||||
}))
|
||||
vi.mock('../windows-process-tree-kill', () => ({
|
||||
terminateWindowsProcessTree: vi.fn(async () => true)
|
||||
}))
|
||||
vi.mock('../wsl-background-distro-gate', () => ({ noteWslDistroLaunched: vi.fn() }))
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
removeTreeMock.mockReset()
|
||||
removeTreeMock.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
const cwd = '\\\\wsl.localhost\\Actual\\home\\dev\\repo'
|
||||
function plan(content = 'sensitive request') {
|
||||
const planned = planCommitMessageGeneration({ agentId: 'omp', model: 'default' }, content)
|
||||
if (!planned.ok) {
|
||||
throw new Error(planned.error)
|
||||
}
|
||||
return planned.plan
|
||||
}
|
||||
function start(child = createMockDiscoveryChild(), content?: string) {
|
||||
let launch: SourceControlAgentSpawnInput | undefined
|
||||
const execution = withPlatform('win32', () =>
|
||||
runLocalSourceControlPlan({
|
||||
plan: plan(content),
|
||||
cwd,
|
||||
wslDistro: 'Wrong',
|
||||
env: undefined,
|
||||
operation: 'conversation-name',
|
||||
emptyResultName: 'name',
|
||||
spawnAgent: (input) => {
|
||||
launch = input
|
||||
return child
|
||||
}
|
||||
})
|
||||
)
|
||||
const nonce = launch?.preparation?.args[0]
|
||||
if (!nonce) {
|
||||
throw new Error('Expected preparation')
|
||||
}
|
||||
return { child, launch, nonce, execution, proof: `ORCA_GENERATION_READY:${nonce}\n` }
|
||||
}
|
||||
function close(child: ReturnType<typeof createMockDiscoveryChild>, code = 0) {
|
||||
child.stdout.emit('data', Buffer.from('Lighthouse Signal Trace'))
|
||||
child.emit('exit', code)
|
||||
child.emit('close', code)
|
||||
}
|
||||
|
||||
it('waits for fragmented exact proof, streams once and cleans only the actual guest after terminal', async () => {
|
||||
const { child, launch, nonce, execution, proof } = start()
|
||||
const writes: string[] = []
|
||||
child.stdin.on('data', (chunk) => writes.push(chunk.toString()))
|
||||
expect(launch?.wslDistro).toBe('Actual')
|
||||
expect(launch?.args).toContain(`@/tmp/orca-generation-${nonce}/prompt-1.txt`)
|
||||
expect(child.stdin.end).not.toHaveBeenCalled()
|
||||
child.stderr.emit('data', Buffer.from(`${proof.trim()}WRONG\n${'x'.repeat(100_000)}\n`))
|
||||
child.stderr.emit('data', Buffer.from(proof.slice(0, 17)))
|
||||
expect(writes).toEqual([])
|
||||
child.stderr.emit('data', Buffer.from(proof.slice(17)))
|
||||
child.stderr.emit('data', Buffer.from(proof))
|
||||
expect(writes.join('')).toBe(`A${nonce}\nDsensitive request\nF${nonce}:1\nX${nonce}\n`)
|
||||
expect(removeTreeMock).not.toHaveBeenCalled()
|
||||
close(child)
|
||||
expect(await execution.result).toMatchObject({ success: true })
|
||||
await execution.processClosed
|
||||
expect(removeTreeMock).toHaveBeenCalledExactlyOnceWith(
|
||||
`\\\\wsl.localhost\\Actual\\tmp\\orca-generation-${nonce}`
|
||||
)
|
||||
child.stderr.emit('data', Buffer.from(proof))
|
||||
expect(removeTreeMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each(['cancel', 'deadline', 'exit', 'overflow', 'stdin-error', 'stdin-close'] as const)(
|
||||
'stops pending input on %s, and late proof only cleans without reopening result',
|
||||
async (route) => {
|
||||
vi.useFakeTimers()
|
||||
const { child, execution, proof } = start()
|
||||
const writes = vi.spyOn(child.stdin, 'write')
|
||||
if (route === 'cancel') {
|
||||
cancelLocalGeneration('conversation-name', cwd)
|
||||
}
|
||||
if (route === 'deadline') {
|
||||
await vi.advanceTimersByTimeAsync(SOURCE_CONTROL_GENERATION_TIMEOUT_MS)
|
||||
}
|
||||
if (route === 'exit') {
|
||||
close(child)
|
||||
}
|
||||
if (route === 'overflow') {
|
||||
child.stdout.emit('data', Buffer.alloc(MAX_SOURCE_CONTROL_AGENT_OUTPUT_BYTES + 1))
|
||||
}
|
||||
if (route === 'stdin-error') {
|
||||
child.stdin.emit('error', new Error('EPIPE'))
|
||||
}
|
||||
if (route === 'stdin-close') {
|
||||
child.stdin.emit('close')
|
||||
}
|
||||
child.stderr.emit('data', Buffer.from(proof))
|
||||
expect(writes).not.toHaveBeenCalled()
|
||||
close(child)
|
||||
await execution.result
|
||||
await execution.processClosed
|
||||
expect(removeTreeMock).toHaveBeenCalledTimes(1)
|
||||
child.stdin.emit('error', new Error('late EPIPE'))
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['cancel', 'deadline', 'exit', 'error', 'close', 'overflow'] as const)(
|
||||
'releases a never-draining producer on %s without final authorization',
|
||||
async (route) => {
|
||||
vi.useFakeTimers()
|
||||
const { child, execution, proof, nonce } = start(undefined, 'x'.repeat(50_000))
|
||||
const write = vi.spyOn(child.stdin, 'write').mockReturnValue(false)
|
||||
child.stderr.emit('data', Buffer.from(proof))
|
||||
expect(write).toHaveBeenCalledExactlyOnceWith(`A${nonce}\n`)
|
||||
if (route === 'cancel') {
|
||||
cancelLocalGeneration('conversation-name', cwd)
|
||||
}
|
||||
if (route === 'deadline') {
|
||||
await vi.advanceTimersByTimeAsync(SOURCE_CONTROL_GENERATION_TIMEOUT_MS)
|
||||
}
|
||||
if (route === 'exit') {
|
||||
child.emit('exit', 1)
|
||||
}
|
||||
if (route === 'error') {
|
||||
child.stdin.emit('error', new Error('EPIPE'))
|
||||
}
|
||||
if (route === 'close') {
|
||||
child.stdin.emit('close')
|
||||
}
|
||||
if (route === 'overflow') {
|
||||
child.stderr.emit('data', Buffer.alloc(MAX_SOURCE_CONTROL_AGENT_OUTPUT_BYTES + 1))
|
||||
}
|
||||
await Promise.resolve()
|
||||
child.stdin.emit('drain')
|
||||
close(child, 1)
|
||||
await execution.result
|
||||
await execution.processClosed
|
||||
expect(write).toHaveBeenCalledTimes(1)
|
||||
expect(child.stdin.listenerCount('drain')).toBe(0)
|
||||
expect(removeTreeMock).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['missing-proof', 'spawn', 'no-pid', 'missing-stdin'] as const)(
|
||||
'settles %s without invented ownership',
|
||||
async (route) => {
|
||||
vi.useFakeTimers()
|
||||
const child = createMockDiscoveryChild()
|
||||
if (route === 'missing-stdin') {
|
||||
Object.defineProperty(child, 'stdin', { value: null })
|
||||
}
|
||||
if (route === 'no-pid') {
|
||||
Object.defineProperty(child, 'pid', { value: undefined })
|
||||
}
|
||||
const execution = withPlatform('win32', () =>
|
||||
runLocalSourceControlPlan({
|
||||
plan: plan(),
|
||||
cwd,
|
||||
env: undefined,
|
||||
operation: 'conversation-name',
|
||||
emptyResultName: 'name',
|
||||
spawnAgent: () => {
|
||||
if (route === 'spawn') {
|
||||
throw new Error('spawn denied')
|
||||
}
|
||||
return child
|
||||
}
|
||||
})
|
||||
)
|
||||
if (route === 'no-pid') {
|
||||
child.emit('error', Object.assign(new Error('not found'), { code: 'ENOENT' }))
|
||||
}
|
||||
if (route === 'missing-proof') {
|
||||
await vi.advanceTimersByTimeAsync(SOURCE_CONTROL_GENERATION_TIMEOUT_MS)
|
||||
}
|
||||
if (route !== 'spawn' && route !== 'no-pid') {
|
||||
close(child, 1)
|
||||
}
|
||||
expect(await execution.result).toMatchObject({ success: false })
|
||||
await execution.processClosed
|
||||
expect(removeTreeMock).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['pending', 'rejected'] as const)(
|
||||
'never gates either promise on %s cleanup, even proof after processClosed',
|
||||
async (outcome) => {
|
||||
removeTreeMock.mockImplementation(() =>
|
||||
outcome === 'pending' ? new Promise(() => {}) : Promise.reject(new Error('share unavailable'))
|
||||
)
|
||||
const log = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
try {
|
||||
const { child, execution, proof } = start()
|
||||
close(child)
|
||||
expect(await execution.result).toMatchObject({ success: true })
|
||||
await execution.processClosed
|
||||
child.stderr.emit('data', Buffer.from(proof))
|
||||
child.stderr.emit('data', Buffer.from(proof))
|
||||
expect(removeTreeMock).toHaveBeenCalledTimes(1)
|
||||
expect(child.stdin.end).not.toHaveBeenCalled()
|
||||
await Promise.resolve()
|
||||
} finally {
|
||||
log.mockRestore()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const tricky = `${Array.from({ length: 128 }, (_, i) => String.fromCharCode(i)).join('')}\\c\\012\r\n'!%$\`🌊界\ud800`
|
||||
|
||||
async function shellFixture(
|
||||
content: string,
|
||||
controls?: (records: string[], nonce: string) => string[],
|
||||
setup?: (directory: string) => void,
|
||||
onProof?: (directory: string) => void
|
||||
) {
|
||||
const guest = prepareWslPrompt(plan(content), 'Actual')
|
||||
const [nonce] = guest.preparation.args
|
||||
const directory = `/tmp/orca-generation-${nonce}`
|
||||
setup?.(directory)
|
||||
const child = spawnProcess({
|
||||
program: '/bin/sh',
|
||||
args: [
|
||||
'-c',
|
||||
guest.preparation.script,
|
||||
'fixture',
|
||||
...guest.preparation.args,
|
||||
process.execPath,
|
||||
'-e',
|
||||
String.raw`
|
||||
const fs = require('node:fs');
|
||||
const paths = process.argv.slice(1).map(arg => arg.slice(1));
|
||||
const files = paths.map(path => fs.readFileSync(path));
|
||||
process.stdout.write(JSON.stringify({ content: Buffer.concat(files.map(file => Buffer.from(new TextDecoder().decode(file)))).toString('base64'),
|
||||
sizes: files.map(file => file.length), modes: paths.map(path => fs.statSync(path).mode & 511), directory: fs.statSync(process.env.FIXTURE_DIRECTORY).mode & 511,
|
||||
eof: require('node:fs').readFileSync(0).length }));
|
||||
`,
|
||||
'--',
|
||||
...guest.plan.args.filter((arg) => arg.startsWith('@'))
|
||||
],
|
||||
stdio: 'pipe',
|
||||
env: { ...process.env, FIXTURE_DIRECTORY: directory }
|
||||
})
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
child.stdout.on('data', (bytes) => {
|
||||
stdout += bytes
|
||||
})
|
||||
const finished = new Promise<number | null>((resolve, reject) => {
|
||||
child.once('close', resolve)
|
||||
child.once('error', reject)
|
||||
})
|
||||
child.stdin.on('error', () => {})
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += chunk
|
||||
if (guest.observe(chunk)) {
|
||||
onProof?.(directory)
|
||||
const records = [...guest.records()]
|
||||
child.stdin.end((controls ? controls(records, nonce) : records).join(''))
|
||||
}
|
||||
})
|
||||
try {
|
||||
const code = await finished
|
||||
return {
|
||||
code,
|
||||
stdout,
|
||||
stderr,
|
||||
directory,
|
||||
existed: existsSync(directory),
|
||||
sentinel: existsSync(join(directory, 'sentinel'))
|
||||
? readFileSync(join(directory, 'sentinel'), 'utf8')
|
||||
: null
|
||||
}
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
it.each(['', tricky.repeat(12), `${'x'.repeat(5 * 1024 * 1024 - 4)}🌊GIF89a${tricky}`])(
|
||||
'materializes exact native text with private modes across encoding and file boundaries',
|
||||
async (content) => {
|
||||
const result = await shellFixture(content)
|
||||
expect(result.code).toBe(0)
|
||||
const receipt = JSON.parse(result.stdout)
|
||||
expect(Buffer.from(receipt.content, 'base64').toString()).toBe(Buffer.from(content).toString())
|
||||
expect(receipt.modes.every((mode: number) => mode === 0o600)).toBe(true)
|
||||
expect(receipt.directory).toBe(0o700)
|
||||
expect(receipt.eof).toBe(0)
|
||||
expect(receipt.sizes.every((size: number) => size <= 5 * 1024 * 1024)).toBe(true)
|
||||
expect(result.existed).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
'collision',
|
||||
'partial-data',
|
||||
'wrong-end',
|
||||
'missing-end',
|
||||
'partial-execute',
|
||||
'extra-data',
|
||||
'wrong-ack'
|
||||
] as const)('does not execute on %s, and never adopts/deletes a collision', async (route) => {
|
||||
const result = await shellFixture(
|
||||
'private data',
|
||||
(records, nonce) => {
|
||||
if (route === 'partial-data') {
|
||||
return [`A${nonce}\nDpartial`]
|
||||
}
|
||||
if (route === 'wrong-end') {
|
||||
return records.map((record) => (record.startsWith('F') ? `F${nonce}:2\n` : record))
|
||||
}
|
||||
if (route === 'missing-end') {
|
||||
return records.filter((record) => !record.startsWith('F'))
|
||||
}
|
||||
if (route === 'partial-execute') {
|
||||
return records.map((record) => (record.startsWith('X') ? record.trim() : record))
|
||||
}
|
||||
if (route === 'extra-data') {
|
||||
return [...records.slice(0, -1), 'Dextra\n', ...records.slice(-1)]
|
||||
}
|
||||
if (route === 'wrong-ack') {
|
||||
return ['Ano\n']
|
||||
}
|
||||
return records
|
||||
},
|
||||
(directory) => {
|
||||
if (route === 'collision') {
|
||||
mkdirSync(directory)
|
||||
writeFileSync(join(directory, 'sentinel'), 'untouched')
|
||||
}
|
||||
}
|
||||
)
|
||||
expect(result.code).not.toBe(0)
|
||||
expect(result.stdout).toBe('')
|
||||
if (route === 'collision') {
|
||||
expect(result.stderr).not.toContain('ORCA_GENERATION_READY:')
|
||||
expect(result.sentinel).toBe('untouched')
|
||||
}
|
||||
if (route === 'wrong-ack') {
|
||||
expect(result.existed).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'bounds real producer work at N and 10N (backpressure=%s)',
|
||||
async (backpressure) => {
|
||||
const results: number[] = []
|
||||
for (const size of [20_000, 200_000]) {
|
||||
const guest = prepareWslPrompt(
|
||||
plan(tricky.repeat(Math.ceil(size / tricky.length)).slice(0, size)),
|
||||
'Actual'
|
||||
)
|
||||
let writes = 0
|
||||
let maxRecord = 0
|
||||
let pending = 0
|
||||
let maxPending = 0
|
||||
let maxQueued = 0
|
||||
const stream = new Writable({
|
||||
highWaterMark: 2048,
|
||||
write(chunk, _encoding, callback) {
|
||||
writes++
|
||||
maxRecord = Math.max(maxRecord, chunk.length)
|
||||
maxPending = Math.max(maxPending, ++pending)
|
||||
const complete = () => {
|
||||
maxQueued = Math.max(maxQueued, stream.writableLength)
|
||||
pending--
|
||||
callback()
|
||||
}
|
||||
if (backpressure) {
|
||||
setImmediate(complete)
|
||||
} else {
|
||||
complete()
|
||||
}
|
||||
}
|
||||
})
|
||||
let yieldedAt = 0
|
||||
setImmediate(() => {
|
||||
yieldedAt = writes
|
||||
})
|
||||
const finished = new Promise<void>((resolve) => stream.once('finish', resolve))
|
||||
await producePromptRecords(stream, guest.records(), new AbortController().signal, () => true)
|
||||
await finished
|
||||
expect(maxQueued).toBeLessThanOrEqual(2048 + 1026)
|
||||
expect(yieldedAt).toBeGreaterThan(0)
|
||||
expect(yieldedAt).toBeLessThan(writes)
|
||||
expect(maxRecord).toBeLessThanOrEqual(1026)
|
||||
expect(maxPending).toBe(1)
|
||||
results.push(writes)
|
||||
}
|
||||
expect(results[1]).toBeLessThanOrEqual(results[0] * 10 + 10)
|
||||
expect(results[1]).toBeGreaterThan(results[0] * 8)
|
||||
}
|
||||
)
|
||||
|
||||
it('fails a real post-proof storage redirect without running a partial request', async () => {
|
||||
const result = await shellFixture('private data', undefined, undefined, (directory) =>
|
||||
mkdirSync(join(directory, 'prompt-1.txt'))
|
||||
)
|
||||
expect(result.code).not.toBe(0)
|
||||
expect(result.stdout).toBe('')
|
||||
expect(result.existed).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ boundary: 'data', stopping: 'cancel' },
|
||||
{ boundary: 'final', stopping: 'cancel' },
|
||||
{ boundary: 'data', stopping: 'deadline' },
|
||||
{ boundary: 'final', stopping: 'deadline' }
|
||||
] as const)(
|
||||
'does not authorize after $stopping at $boundary write continuation',
|
||||
async ({ boundary, stopping }) => {
|
||||
vi.useFakeTimers()
|
||||
const { child, execution, proof, nonce } = start()
|
||||
const writes: string[] = []
|
||||
vi.spyOn(child.stdin, 'write').mockImplementation((chunk) => {
|
||||
const record = String(chunk)
|
||||
writes.push(record)
|
||||
if (record.startsWith(boundary === 'data' ? 'D' : 'F')) {
|
||||
if (stopping === 'cancel') {
|
||||
cancelLocalGeneration('conversation-name', cwd)
|
||||
} else {
|
||||
vi.advanceTimersByTime(SOURCE_CONTROL_GENERATION_TIMEOUT_MS)
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
child.stderr.emit('data', Buffer.from(proof))
|
||||
close(child)
|
||||
expect(await execution.result).toMatchObject(
|
||||
stopping === 'cancel'
|
||||
? { success: false, canceled: true }
|
||||
: { success: false, error: 'Generation timed out after 60s.' }
|
||||
)
|
||||
await execution.processClosed
|
||||
expect(writes).not.toContain(`X${nonce}\n`)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['success', 'nonzero', 'empty', 'storage', 'pipe-close'] as const)(
|
||||
'runs the production attempt through real private shell preparation and %s',
|
||||
async (outcome) => {
|
||||
let directory = ''
|
||||
let actualPipeError = false
|
||||
const execution = withPlatform('win32', () =>
|
||||
runLocalSourceControlPlan({
|
||||
plan: plan(tricky.repeat(40)),
|
||||
cwd,
|
||||
env: undefined,
|
||||
operation: 'conversation-name',
|
||||
emptyResultName: 'name',
|
||||
spawnAgent: (input) => {
|
||||
const preparation = input.preparation
|
||||
if (!preparation) {
|
||||
throw new Error('Missing preparation')
|
||||
}
|
||||
directory = `/tmp/orca-generation-${preparation.args[0]}`
|
||||
const argv =
|
||||
outcome === 'pipe-close'
|
||||
? [
|
||||
'-c',
|
||||
`exec 0<&-; printf 'ORCA_GENERATION_READY:%s\\n' "$1" >&2; sleep 0.1`,
|
||||
'fixture',
|
||||
preparation.args[0]
|
||||
]
|
||||
: [
|
||||
'-c',
|
||||
preparation.script,
|
||||
'fixture',
|
||||
...preparation.args,
|
||||
'/bin/sh',
|
||||
'-c',
|
||||
outcome === 'empty'
|
||||
? ':'
|
||||
: `printf '%s' 'Useful name'; exit ${outcome === 'nonzero' ? 7 : 0}`
|
||||
]
|
||||
const child = withPlatform('darwin', () =>
|
||||
spawnProcess({ program: '/bin/sh', args: argv, stdio: 'pipe' })
|
||||
)
|
||||
child.stdin.on('error', (error) => {
|
||||
actualPipeError ||= 'code' in error && error.code === 'EPIPE'
|
||||
})
|
||||
if (outcome === 'storage') {
|
||||
child.stderr.once('data', () => mkdirSync(join(directory, 'prompt-1.txt')))
|
||||
}
|
||||
return child
|
||||
}
|
||||
})
|
||||
)
|
||||
try {
|
||||
const result = await execution.result
|
||||
await execution.processClosed
|
||||
expect(result.success).toBe(outcome === 'success')
|
||||
expect(removeTreeMock).toHaveBeenCalledTimes(1)
|
||||
if (outcome === 'pipe-close') {
|
||||
expect(actualPipeError).toBe(true)
|
||||
}
|
||||
if (outcome === 'success') {
|
||||
const file = readFileSync(join(directory, 'prompt-1.txt'))
|
||||
expect(new TextDecoder().decode(file)).toBe(Buffer.from(tricky.repeat(40)).toString())
|
||||
}
|
||||
} finally {
|
||||
if (directory) {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -15,6 +15,16 @@ describe('planSourceControlCommitMessageGeneration', () => {
|
||||
).toEqual({ ok: false, error: 'Command input is empty.' })
|
||||
})
|
||||
|
||||
it('describes OMP file delivery in dry-run plans', () => {
|
||||
const result = planSourceControlCommitMessageGeneration({
|
||||
agentId: 'omp',
|
||||
model: 'gpt-5.5',
|
||||
thinkingLevel: 'low'
|
||||
})
|
||||
|
||||
expect(result.ok && result.delivery).toContain('temporary file attachment')
|
||||
})
|
||||
|
||||
it('expands linkedIssue when validating commit and pull-request recipes', () => {
|
||||
for (const actionId of ['commitMessage', 'pullRequest'] as const) {
|
||||
// Why: `{prompt}` puts the rendered template in argv, so commandLabel shows it.
|
||||
|
||||
@@ -75,8 +75,9 @@ export function planSourceControlTextGeneration(
|
||||
if (!planned.ok) {
|
||||
return { ok: false, error: planned.error }
|
||||
}
|
||||
const delivery =
|
||||
planned.plan.stdinPayload === null
|
||||
const delivery = planned.plan.promptFile
|
||||
? 'Prompt is delivered as a temporary file attachment.'
|
||||
: planned.plan.stdinPayload === null
|
||||
? 'Prompt is delivered as command arguments.'
|
||||
: 'Prompt is piped to the agent over stdin.'
|
||||
return {
|
||||
|
||||
@@ -44,8 +44,8 @@ export type CommitMessageAgentSpec = {
|
||||
id: TuiAgent
|
||||
label: string
|
||||
binary: string
|
||||
/** Where the prompt is delivered. Large diffs go via stdin to avoid argv limits. */
|
||||
promptDelivery: 'argv' | 'stdin'
|
||||
/** Transport for the prompt; stdin and file delivery avoid argv limits. */
|
||||
promptDelivery: 'argv' | 'stdin' | 'file'
|
||||
buildArgs: (params: {
|
||||
prompt: string
|
||||
model: string
|
||||
@@ -94,8 +94,8 @@ export const COMMIT_MESSAGE_AGENT_SPECS: Partial<Record<TuiAgent, CommitMessageA
|
||||
id: 'omp',
|
||||
label: 'OMP',
|
||||
binary: 'omp',
|
||||
promptDelivery: 'stdin',
|
||||
buildArgs: ({ model, thinkingLevel }) => [
|
||||
promptDelivery: 'file',
|
||||
buildArgs: ({ prompt, model, thinkingLevel }) => [
|
||||
'--print',
|
||||
'--no-session',
|
||||
'--no-tools',
|
||||
@@ -105,7 +105,8 @@ export const COMMIT_MESSAGE_AGENT_SPECS: Partial<Record<TuiAgent, CommitMessageA
|
||||
'--mode',
|
||||
'text',
|
||||
...(model && model !== 'default' ? ['--model', model] : []),
|
||||
...(thinkingLevel ? ['--thinking', thinkingLevel] : [])
|
||||
...(thinkingLevel ? ['--thinking', thinkingLevel] : []),
|
||||
prompt
|
||||
],
|
||||
singletonOptions: [['--model'], ['--thinking']],
|
||||
modelSource: 'dynamic',
|
||||
|
||||
@@ -18,13 +18,13 @@ describe('OMP Source Control AI', () => {
|
||||
})
|
||||
}
|
||||
)
|
||||
it('plans stdin delivery with tools and extensions disabled', () => {
|
||||
it('plans file delivery with tools and extensions disabled', () => {
|
||||
const spec = getCommitMessageAgentSpec('omp')
|
||||
expect(spec?.promptDelivery).toBe('stdin')
|
||||
expect(spec?.promptDelivery).toBe('file')
|
||||
if (!spec) {
|
||||
throw new Error('Missing OMP spec')
|
||||
}
|
||||
const args = spec.buildArgs({ prompt: 'large diff', model: 'default' })
|
||||
const args = spec.buildArgs({ prompt: '@{prompt-file}', model: 'default' })
|
||||
expect(args).toEqual([
|
||||
'--print',
|
||||
'--no-session',
|
||||
@@ -33,11 +33,23 @@ describe('OMP Source Control AI', () => {
|
||||
'--no-skills',
|
||||
'--no-rules',
|
||||
'--mode',
|
||||
'text'
|
||||
'text',
|
||||
'@{prompt-file}'
|
||||
])
|
||||
expect(
|
||||
spec?.buildArgs({ prompt: '', model: 'provider/exact-model', thinkingLevel: 'low' })
|
||||
).toEqual([...args, '--model', 'provider/exact-model', '--thinking', 'low'])
|
||||
spec?.buildArgs({
|
||||
prompt: '@{prompt-file}',
|
||||
model: 'provider/exact-model',
|
||||
thinkingLevel: 'low'
|
||||
})
|
||||
).toEqual([
|
||||
...args.slice(0, -1),
|
||||
'--model',
|
||||
'provider/exact-model',
|
||||
'--thinking',
|
||||
'low',
|
||||
'@{prompt-file}'
|
||||
])
|
||||
})
|
||||
it('discovers provider-qualified models through the existing OMP JSON parser', () => {
|
||||
const discovery = getCommitMessageAgentSpec('omp')?.modelDiscovery
|
||||
@@ -58,8 +70,10 @@ describe('OMP Source Control AI', () => {
|
||||
{
|
||||
agentId: 'omp',
|
||||
model: 'provider/model',
|
||||
thinkingLevel: 'medium',
|
||||
backslash,
|
||||
agentArgs: '--model provider/override'
|
||||
agentCommandOverride: 'OMP_ENV=value omp --model provider/prefix --thinking high',
|
||||
agentArgs: '--model provider/override --thinking low --verbose --'
|
||||
},
|
||||
prompt
|
||||
)
|
||||
@@ -67,7 +81,16 @@ describe('OMP Source Control AI', () => {
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
expect(result.plan.stdinPayload).toBe(prompt)
|
||||
expect(result.plan.stdinPayload).toBeNull()
|
||||
expect(result.plan.promptFile).toEqual({
|
||||
content: prompt,
|
||||
argIndex: result.plan.args.indexOf('@{prompt-file}')
|
||||
})
|
||||
expect(result.plan.env).toEqual({ OMP_ENV: 'value' })
|
||||
expect(result.plan.args.slice(-3)).toEqual(['@{prompt-file}', '--verbose', '--'])
|
||||
expect(result.plan.args.filter((arg) => arg === '--thinking')).toHaveLength(1)
|
||||
expect(result.plan.args).toContain('low')
|
||||
expect(result.plan.args).not.toContain('high')
|
||||
expect(result.plan.args).not.toContain(prompt)
|
||||
expect(result.plan.args.filter((arg) => arg === '--model')).toHaveLength(1)
|
||||
expect(result.plan.args).toContain('provider/override')
|
||||
|
||||
@@ -36,6 +36,8 @@ export type CommitMessagePlan = {
|
||||
args: string[]
|
||||
/** Non-null when the prompt should be piped via stdin. */
|
||||
stdinPayload: string | null
|
||||
/** Execution-host-owned prompt attachment, materialized only when starting the process. */
|
||||
promptFile?: { content: string; argIndex: number }
|
||||
/** Human-readable label used in error prefixes (e.g. "Claude failed: ..."). */
|
||||
label: string
|
||||
/** Leading command assignments, applied on the execution host. */
|
||||
@@ -137,7 +139,7 @@ function applySingletonOptionOverrides(args: {
|
||||
function insertAdditionalAgentArgs(args: {
|
||||
baseArgs: string[]
|
||||
agentArgs: string[]
|
||||
promptDelivery: 'argv' | 'stdin'
|
||||
promptDelivery: 'argv' | 'stdin' | 'file'
|
||||
prompt: string
|
||||
}): string[] {
|
||||
if (!args.agentArgs.length) {
|
||||
@@ -221,7 +223,8 @@ export function planCommitMessageGeneration(
|
||||
}
|
||||
}
|
||||
|
||||
const argvPrompt = spec.promptDelivery === 'argv' ? prompt : ''
|
||||
const argvPrompt =
|
||||
spec.promptDelivery === 'file' ? '@{prompt-file}' : spec.promptDelivery === 'argv' ? prompt : ''
|
||||
const agentArgs = planAdditionalAgentArgs(input.agentArgs, input.backslash)
|
||||
if (!agentArgs.ok) {
|
||||
return agentArgs
|
||||
@@ -287,6 +290,14 @@ export function planCommitMessageGeneration(
|
||||
: spec.promptDelivery === 'stdin'
|
||||
? prompt
|
||||
: null,
|
||||
...(spec.promptDelivery === 'file'
|
||||
? {
|
||||
promptFile: {
|
||||
content: prompt,
|
||||
argIndex: merged.prefixArgs.length + merged.generatedArgs.length - 1
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
label: spec.label,
|
||||
...(formatOption?.value === 'json' ? { outputFormat: 'opencode-json' as const } : {}),
|
||||
...(kimiFormatOption?.value === 'stream-json'
|
||||
|
||||
@@ -64,7 +64,11 @@ for (const hostKey of ['local', 'ssh:omp-host']) {
|
||||
expect(resolved.value.params.model).toBe(selectedModel ?? 'default')
|
||||
const args = spec.buildArgs({ prompt: '', model: resolved.value.params.model })
|
||||
if (selectedModel === 'provider/exact-model') {
|
||||
expect(args.slice(-2)).toEqual(['--model', 'provider/exact-model'])
|
||||
const modelIndex = args.indexOf('--model')
|
||||
expect(args.slice(modelIndex, modelIndex + 2)).toEqual([
|
||||
'--model',
|
||||
'provider/exact-model'
|
||||
])
|
||||
} else {
|
||||
expect(args).not.toContain('--model')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
export const POSIX_PRINTF_RECORD_MAX_BYTES = 1_024
|
||||
const PRINTF_ESCAPED_BYTES = new Set([0x21, 0x27, 0x5c])
|
||||
|
||||
export function* encodePosixPrintfRecords(command: string): Generator<string> {
|
||||
let chunk = ''
|
||||
let chunkBytes = 0
|
||||
for (const character of command) {
|
||||
const codePoint = character.codePointAt(0)!
|
||||
const isSafePrintableAscii =
|
||||
codePoint >= 0x20 && codePoint <= 0x7e && !PRINTF_ESCAPED_BYTES.has(codePoint)
|
||||
const encodedCharacter =
|
||||
codePoint > 0x7f || isSafePrintableAscii
|
||||
? character
|
||||
: `\\0${codePoint.toString(8).padStart(3, '0')}`
|
||||
const encodedBytes = codePoint > 0x7f ? Buffer.byteLength(character) : encodedCharacter.length
|
||||
if (chunkBytes + encodedBytes > POSIX_PRINTF_RECORD_MAX_BYTES) {
|
||||
yield chunk
|
||||
chunk = ''
|
||||
chunkBytes = 0
|
||||
}
|
||||
chunk += encodedCharacter
|
||||
chunkBytes += encodedBytes
|
||||
}
|
||||
yield chunk
|
||||
}
|
||||
@@ -46,3 +46,5 @@ function countEscapes(arg: string): number {
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
export class WindowsCommandLineBudgetError extends Error {}
|
||||
|
||||
@@ -162,3 +162,11 @@ export function uncRouteKey(cwd: string): string | null {
|
||||
const server = cwd.slice(2).split(/[\\/]/, 1)[0]
|
||||
return `unc:${server.toLowerCase()}`
|
||||
}
|
||||
|
||||
/** Match command routing: a native UNC cwd takes precedence over a distro hint. */
|
||||
export function wslDistroForCommand(cwd: string | undefined, override?: string): string | null {
|
||||
if (process.platform !== 'win32') {
|
||||
return null
|
||||
}
|
||||
return (cwd ? parseWslUncPath(cwd)?.distro : undefined) ?? override ?? null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user