mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 08:02:31 +00:00
* Surface the Pi CLI's real error when branch auto-naming and commit generation fail Pi failures (missing provider credentials, HTTP 4xx/5xx, connection errors) previously collapsed to the generic 'Pi CLI command failed with code 1.' because extractAgentErrorMessage only recognized Error:-prefixed lines. Add two stderr-only extraction passes for pi's failure formats and narrow the unix-path redactor so pi's /login remedy token survives redaction. Fixes the OP variant of STA-1492 (#7808). * Replace per-CLI failure parsing with sanitized raw output excerpts Every agent CLI formats errors differently, and the parsing passes only ever covered the vendors someone had already debugged (the pi passes fixed pi and nothing else). Show the output itself instead: a positional excerpt (first two non-empty lines plus the last one) of stderr, falling back to stdout when stderr is silent, path-redacted and capped as before. Exit-0 runs with empty stdout now stay 'returned an empty <result>' instead of misreporting a command failure. The sanitizer also strips Cf characters (bidi overrides) now that provider-controlled bodies flow through verbatim, and OSC sequences are stripped alongside CSI. * Keep the full CLI output of failed branch-name generation for on-demand viewing The persisted rename-failed badge carries only a sanitized excerpt (it syncs to paired clients), so the complete diagnosis was previously buried in the main-process log. Failed generations now capture their full stdout/stderr (bounded to 64 KiB per stream, head and tail) into a main-memory-only store keyed by worktree — never persisted or synced. The rename-failed dialog fetches it on demand and shows it in place of the excerpt, ANSI/control/bidi-stripped; after a restart or on paired web clients it falls back to the excerpt. Renderer-bound generation results explicitly drop the capture so IPC payload shapes are unchanged. * Cover the rename-failed dialog's full-output fetch and excerpt fallback * Split the folder-workspace title rename flow into its own module first-work-branch-rename.ts sat at the max-lines ceiling; the folder title flow is a self-contained concept and main's concurrent additions to this file pushed the CI merge ref over the limit. * Harden the failure-output capture and dialog against review findings - Flatten bounded captures so V8 sliced strings no longer retain the multi-megabyte parent stream in the capture map (128 MiB -> ~4 MiB in a 32-entry probe). - Require an OSC terminator and stop its char class at newlines so an unterminated/boundary-truncated sequence can no longer swallow the omission marker and diagnostic tail. - Exclude stdout from the persisted branch-name failure detail (it can echo the prompt into synced metadata); the full local-only capture still keeps it for the dialog. - Refetch and reset the dialog's full output when the persisted error changes so an open dialog never shows or copies a stale run. - Report signal-terminated generators (null exit code) as 'was terminated before exiting' instead of 'code null'. * Redact JSON-escaped Windows drive paths without breaking scheme URLs Provider JSON bodies double backslashes (C:\\Users\\name), which slipped past the drive-letter redaction into the persisted, client-synced failure excerpt. Allow repeated backslashes only — a URL's :// must stay single so remedy links survive. * Fix branch rename failure IPC re-registration --------- Co-authored-by: Brennan Benson <brennanbenson@Brennans-MacBook-Pro.local>
198 lines
6.7 KiB
TypeScript
198 lines
6.7 KiB
TypeScript
/** Strips noise around the agent's output: surrounding whitespace, a single
|
|
* enclosing fenced code block, and lone "Generating…" preamble lines some
|
|
* CLIs print before the real answer. */
|
|
export function cleanGeneratedCommitMessage(raw: string): string {
|
|
// Why: agent output can include very large generated bodies; normalize and
|
|
// unwrap by scanning boundaries instead of building newline-sized arrays.
|
|
let text = normalizeGeneratedCommitMessageLineFeeds(raw).trim()
|
|
|
|
// Why: real commit messages never start with an ellipsis or the word
|
|
// "Generating"/"Thinking" — those leak from CLIs that print a status line
|
|
// before the actual response.
|
|
const firstNewline = text.indexOf('\n')
|
|
if (firstNewline !== -1) {
|
|
const firstLine = text.slice(0, firstNewline)
|
|
if (/^(generating|thinking)\b/i.test(firstLine) || /^[.…]+$/.test(firstLine.trim())) {
|
|
text = text.slice(firstNewline + 1).trim()
|
|
}
|
|
}
|
|
|
|
const fenced = findEnclosingCommitMessageFenceBody(text)
|
|
if (fenced !== null) {
|
|
text = fenced.trim()
|
|
}
|
|
|
|
// Why: some CLIs format a one-shot answer as a list item even when the
|
|
// prompt asks for raw text; a Git subject should not carry that marker.
|
|
text = text.replace(/^(\s*)(?:[-*•●]\s+|\d+[.)]\s+)/, '$1').trim()
|
|
|
|
return text
|
|
}
|
|
|
|
function normalizeGeneratedCommitMessageLineFeeds(value: string): string {
|
|
let crlfStart = value.indexOf('\r\n')
|
|
if (crlfStart === -1) {
|
|
return value
|
|
}
|
|
|
|
let normalized = value.slice(0, crlfStart)
|
|
let chunkStart = crlfStart + 2
|
|
normalized += '\n'
|
|
crlfStart = value.indexOf('\r\n', chunkStart)
|
|
|
|
while (crlfStart !== -1) {
|
|
normalized += value.slice(chunkStart, crlfStart)
|
|
normalized += '\n'
|
|
chunkStart = crlfStart + 2
|
|
crlfStart = value.indexOf('\r\n', chunkStart)
|
|
}
|
|
|
|
return `${normalized}${value.slice(chunkStart)}`
|
|
}
|
|
|
|
function findEnclosingCommitMessageFenceBody(text: string): string | null {
|
|
if (!text.startsWith('```')) {
|
|
return null
|
|
}
|
|
|
|
let headerEnd = 3
|
|
while (headerEnd < text.length && text.charCodeAt(headerEnd) !== 10) {
|
|
if (!isCommitFenceInfoCharacter(text.charCodeAt(headerEnd))) {
|
|
return null
|
|
}
|
|
headerEnd++
|
|
}
|
|
|
|
if (headerEnd >= text.length) {
|
|
return null
|
|
}
|
|
|
|
const closingFenceStart = text.length - 3
|
|
if (closingFenceStart <= headerEnd || !text.endsWith('```')) {
|
|
return null
|
|
}
|
|
if (text.charCodeAt(closingFenceStart - 1) !== 10) {
|
|
return null
|
|
}
|
|
|
|
return text.slice(headerEnd + 1, closingFenceStart - 1)
|
|
}
|
|
|
|
function isCommitFenceInfoCharacter(code: number): boolean {
|
|
return (
|
|
(code >= 48 && code <= 57) ||
|
|
(code >= 65 && code <= 90) ||
|
|
(code >= 97 && code <= 122) ||
|
|
code === 45 ||
|
|
code === 95
|
|
)
|
|
}
|
|
|
|
export function stripAnsiControlSequences(value: string): string {
|
|
const esc = String.fromCharCode(27)
|
|
const bel = String.fromCharCode(7)
|
|
// CSI (colors/cursor) and OSC (titles/hyperlinks) both appear in raw CLI
|
|
// failure output once it is shown verbatim instead of parsed.
|
|
return value.replace(
|
|
new RegExp(
|
|
`${esc}(?:\\[[0-?]*[ -/]*[@-~]|\\][^${bel}${esc}\\r\\n]*(?:${bel}|${esc}\\\\))`,
|
|
'g'
|
|
),
|
|
''
|
|
)
|
|
}
|
|
|
|
function stripAnsiIfPresent(value: string): string {
|
|
return value.includes(String.fromCharCode(27)) ? stripAnsiControlSequences(value) : value
|
|
}
|
|
|
|
// Only the two ends of the output are read, like glancing at the first and
|
|
// last lines of a long log.
|
|
const FAILURE_EXCERPT_SCAN_WINDOW = 8192
|
|
const FAILURE_EXCERPT_HEAD_LINE_COUNT = 2
|
|
// Why: when both ends are shown, the tail gets the larger budget because most
|
|
// CLIs print the operative error last; the head budget covers CLIs that
|
|
// front-load it. A lone excerpt keeps the whole toast/persistence budget.
|
|
const FAILURE_EXCERPT_HEAD_BUDGET = 100
|
|
const FAILURE_EXCERPT_TAIL_BUDGET = 130
|
|
const FAILURE_EXCERPT_SINGLE_BUDGET = 240
|
|
|
|
// Why: agent CLIs share no error format, and per-CLI parsing rots every time a
|
|
// vendor rewords a message. Orca deliberately does NOT interpret failure
|
|
// output — it excerpts it positionally (first lines plus last line) so every
|
|
// CLI's real failure text reaches the user. Callers must still sanitize the
|
|
// excerpt before display or persistence.
|
|
export function excerptAgentFailureOutput(stdout: string, stderr: string): string | null {
|
|
// stderr is where CLIs put diagnostics; stdout is the fallback for the ones
|
|
// that report failures inline (and often echoes the prompt, so it never
|
|
// overrides a non-blank stderr).
|
|
const source = /\S/.test(stderr) ? stderr : stdout
|
|
if (!/\S/.test(source)) {
|
|
return null
|
|
}
|
|
|
|
if (source.length <= FAILURE_EXCERPT_SCAN_WINDOW) {
|
|
const lines = collectExcerptLines(source, Number.POSITIVE_INFINITY)
|
|
if (lines.length === 0) {
|
|
return null
|
|
}
|
|
if (lines.length <= FAILURE_EXCERPT_HEAD_LINE_COUNT + 1) {
|
|
return truncateExcerptPart(lines.join(' '), FAILURE_EXCERPT_SINGLE_BUDGET)
|
|
}
|
|
return composeTwoEndExcerpt(
|
|
lines.slice(0, FAILURE_EXCERPT_HEAD_LINE_COUNT),
|
|
lines.at(-1) ?? null
|
|
)
|
|
}
|
|
|
|
const headLines = collectExcerptLines(
|
|
source.slice(0, FAILURE_EXCERPT_SCAN_WINDOW),
|
|
FAILURE_EXCERPT_HEAD_LINE_COUNT
|
|
)
|
|
const tailLine =
|
|
collectExcerptLinesFromEnd(source.slice(source.length - FAILURE_EXCERPT_SCAN_WINDOW), 1)[0] ??
|
|
null
|
|
if (headLines.length === 0) {
|
|
return tailLine ? truncateExcerptPart(tailLine, FAILURE_EXCERPT_SINGLE_BUDGET) : null
|
|
}
|
|
return composeTwoEndExcerpt(headLines, tailLine)
|
|
}
|
|
|
|
function composeTwoEndExcerpt(headLines: string[], tailLine: string | null): string {
|
|
const headPart = truncateExcerptPart(headLines.join(' '), FAILURE_EXCERPT_HEAD_BUDGET)
|
|
// Repeated lines (spinner/retry frames) would otherwise show twice.
|
|
if (tailLine === null || headLines.includes(tailLine)) {
|
|
return headPart
|
|
}
|
|
return `${headPart} … ${truncateExcerptPart(tailLine, FAILURE_EXCERPT_TAIL_BUDGET)}`
|
|
}
|
|
|
|
function truncateExcerptPart(value: string, budget: number): string {
|
|
return value.length > budget ? `${value.slice(0, budget).trimEnd()}…` : value
|
|
}
|
|
|
|
function collectExcerptLines(text: string, max: number): string[] {
|
|
// Bare `\r` is a boundary too: progress bars redraw with carriage returns.
|
|
const lines = text.split(/\r\n|\r|\n/)
|
|
const collected: string[] = []
|
|
for (let index = 0; index < lines.length && collected.length < max; index += 1) {
|
|
const line = stripAnsiIfPresent(lines[index]).trim()
|
|
if (line.length > 0) {
|
|
collected.push(line)
|
|
}
|
|
}
|
|
return collected
|
|
}
|
|
|
|
function collectExcerptLinesFromEnd(text: string, max: number): string[] {
|
|
const lines = text.split(/\r\n|\r|\n/)
|
|
const collected: string[] = []
|
|
for (let index = lines.length - 1; index >= 0 && collected.length < max; index -= 1) {
|
|
const line = stripAnsiIfPresent(lines[index]).trim()
|
|
if (line.length > 0) {
|
|
collected.push(line)
|
|
}
|
|
}
|
|
return collected
|
|
}
|