diff --git a/src/agents/headless-json.ts b/src/agents/headless-json.ts new file mode 100644 index 0000000..f653f86 --- /dev/null +++ b/src/agents/headless-json.ts @@ -0,0 +1,614 @@ +import type { HeadlessBackend } from "./headless.js"; + +type JsonRecord = Record; + +const textItemTypes = new Set(["text", "input_text", "output_text"]); +const skippedItemTypes = new Set([ + "thinking", + "reasoning", + "redacted_thinking", + "tool_use", + "tool_result", + "toolcall", + "function_call", + "function_call_output", +]); +const readToolNames = new Set(["read", "read_file", "open", "view", "cat"]); +const searchToolNames = new Set(["grep", "rg", "ripgrep", "search", "search_files", "codebase_search", "file_search"]); +const runToolNames = new Set(["bash", "shell", "run", "exec", "execute", "command", "run_command", "exec_command", "terminal"]); +const writeToolNames = new Set(["write", "write_file", "create_file"]); +const editToolNames = new Set(["edit", "edit_file", "replace", "patch", "apply_patch", "str_replace_editor"]); +const toolRecordTypes = new Set([ + "tool_use", + "tool", + "toolcall", + "tool_call", + "function_call", + "functioncall", + "function", +]); +const pathFields = ["path", "file_path", "filepath", "filePath", "relative_path"]; +const queryFields = ["query", "pattern", "regex", "term", "search", "needle", "q", "expression"]; +const commandFields = ["command", "cmd", "shell_command", "shellCommand", "script", "code"]; +const toolNameFields = ["name", "tool_name", "toolName", "function", "function_name", "tool"]; +const toolArgumentFields = ["arguments", "args", "input", "params", "parameters", "input_json", "payload"]; + +export interface ToolProgressEvent { + readonly operation: "read" | "search" | "run" | "write" | "edit" | "tool"; + readonly text: string; +} + +export class HeadlessJsonStream { + private buffer = ""; + private readonly records: unknown[] = []; + private readonly rawLines: string[] = []; + + push(text: string): unknown[] { + this.buffer += text; + const parsed: unknown[] = []; + + while (true) { + const newlineIndex = this.buffer.search(/\r?\n/); + if (newlineIndex === -1) { + break; + } + + const rawLine = this.buffer.slice(0, newlineIndex); + const newlineLength = this.buffer[newlineIndex] === "\r" && this.buffer[newlineIndex + 1] === "\n" ? 2 : 1; + this.buffer = this.buffer.slice(newlineIndex + newlineLength); + const value = parseJsonLine(rawLine); + if (value === undefined) { + continue; + } + + this.records.push(value); + this.rawLines.push(rawLine.trim()); + parsed.push(value); + } + + return parsed; + } + + finish(): unknown[] { + const rawLine = this.buffer; + this.buffer = ""; + const value = parseJsonLine(rawLine); + if (value === undefined) { + return []; + } + + this.records.push(value); + this.rawLines.push(rawLine.trim()); + return [value]; + } + + trace(): string { + return this.rawLines.length > 0 ? `${this.rawLines.join("\n")}\n` : ""; + } + + finalAnswer(backend: HeadlessBackend | undefined): string { + if (backend === "gemini") { + const geminiDeltaAnswer = finalGeminiDeltaAnswer(this.records); + if (geminiDeltaAnswer) { + return geminiDeltaAnswer; + } + } + + const candidates = this.records.flatMap((record) => collectCandidates(record, backend)); + return candidates.at(-1)?.trim() ?? ""; + } + + usage(): unknown { + for (let index = this.records.length - 1; index >= 0; index -= 1) { + const usage = extractUsage(this.records[index]); + if (usage !== undefined) { + return usage; + } + } + + return undefined; + } +} + +export function extractFileReadPaths(value: unknown): string[] { + return extractToolProgressEvents(value) + .filter((event) => event.operation === "read") + .map((event) => event.text.replace(/^read /, "")); +} + +export function extractToolProgressEvents(value: unknown): ToolProgressEvent[] { + const events: ToolProgressEvent[] = []; + const seen = new Set(); + + const visit = (item: unknown): void => { + if (Array.isArray(item)) { + for (const child of item) { + visit(child); + } + return; + } + + const record = asRecord(item); + if (Object.keys(record).length === 0 || seen.has(record)) { + return; + } + + seen.add(record); + const event = progressEventFromToolRecord(record); + if (event) { + events.push(event); + return; + } + + for (const child of Object.values(record)) { + visit(child); + } + }; + + visit(value); + return events; +} + +function parseJsonLine(rawLine: string): unknown | undefined { + const trimmed = rawLine.trim(); + if (!trimmed) { + return undefined; + } + + try { + return JSON.parse(trimmed) as unknown; + } catch { + return undefined; + } +} + +function asRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? value as JsonRecord : {}; +} + +function asArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function asString(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function asNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function normalizeRole(value: unknown): string { + const role = asString(value).trim().toLowerCase(); + return role === "model" || role === "gemini" ? "assistant" : role; +} + +function extractText(value: unknown): string[] { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed ? [trimmed] : []; + } + + if (Array.isArray(value)) { + return value.flatMap((item) => extractText(item)); + } + + const record = asRecord(value); + if (Object.keys(record).length === 0) { + return []; + } + + const itemType = asString(record.type).trim().toLowerCase(); + if (skippedItemTypes.has(itemType)) { + return []; + } + + if (textItemTypes.has(itemType)) { + return extractText(record.text); + } + + const directText = asString(record.text).trim(); + if (directText) { + return [directText]; + } + + const parts = extractText(record.parts); + if (parts.length > 0) { + return parts; + } + + const content = extractText(record.content); + if (content.length > 0) { + return content; + } + + const nestedMessage = extractText(asRecord(record.message).content); + return nestedMessage.length > 0 ? nestedMessage : []; +} + +function joinText(value: unknown): string { + return extractText(value).join("\n").trim(); +} + +function roleFromRecord(record: JsonRecord): string { + const directRole = normalizeRole(record.role); + if (directRole) { + return directRole; + } + + const messageRole = normalizeRole(asRecord(record.message).role); + if (messageRole) { + return messageRole; + } + + const type = asString(record.type).trim().toLowerCase(); + return type === "assistant" || type === "model" || type === "gemini" ? "assistant" : ""; +} + +function candidateFromRecord(record: JsonRecord, backend: HeadlessBackend | undefined): string { + const rowType = asString(record.type).trim().toLowerCase(); + const payload = asRecord(record.payload); + if (rowType === "response_item" && Object.keys(payload).length > 0) { + return candidateFromRecord(payload, backend); + } + + const item = asRecord(record.item); + if (rowType.startsWith("item.") && Object.keys(item).length > 0) { + return candidateFromRecord(item, backend); + } + + if (rowType === "agent_message") { + const text = asString(record.text).trim(); + if (text) { + return text; + } + } + + if (backend === "opencode" && rowType === "text") { + const text = joinText(record.part || record.text); + if (text) { + return text; + } + } + + const message = asRecord(record.message); + if (Object.keys(message).length > 0) { + const messageRole = roleFromRecord(message) || roleFromRecord(record); + if (messageRole === "assistant") { + return joinText(message.content || message.parts || message.text || message); + } + } + + const role = roleFromRecord(record); + if (role === "assistant" && !skippedItemTypes.has(rowType)) { + const contentText = joinText(record.content || record.parts || record.text); + if (contentText) { + return contentText; + } + } + + if (backend === "gemini" && (rowType === "model" || rowType === "gemini")) { + const contentText = joinText(record.content || record.parts || record.text); + if (contentText) { + return contentText; + } + } + + if ((backend === "codex" || backend === undefined) && role === "assistant" && rowType === "message") { + const contentText = joinText(record.content); + if (contentText) { + return contentText; + } + } + + for (const field of ["result", "response", "final_message", "finalMessage", "final_answer", "finalAnswer", "output"]) { + const text = asString(record[field]).trim(); + if ( + text && + (role === "assistant" || + rowType === "result" || + rowType === "final" || + rowType === "assistant" || + (backend === "gemini" && field === "response")) + ) { + return text; + } + } + + return ""; +} + +function finalGeminiDeltaAnswer(records: readonly unknown[]): string { + const segments: string[] = []; + let currentSegment = ""; + + const flushSegment = (): void => { + const trimmed = currentSegment.trim(); + if (trimmed) { + segments.push(trimmed); + } + currentSegment = ""; + }; + + for (const value of records) { + const record = asRecord(value); + if (Object.keys(record).length === 0) { + continue; + } + + if (isGeminiAssistantDelta(record)) { + currentSegment += extractRawText(record.content || record.parts || record.text); + continue; + } + + if (isGeminiToolBoundary(record)) { + flushSegment(); + } + } + + flushSegment(); + return segments.at(-1) ?? ""; +} + +function isGeminiAssistantDelta(record: JsonRecord): boolean { + return Boolean(record.delta) && roleFromRecord(record) === "assistant"; +} + +function isGeminiToolBoundary(record: JsonRecord): boolean { + const rowType = asString(record.type).trim().toLowerCase(); + return rowType === "tool_use" || + rowType === "tool_result" || + rowType === "function_call" || + rowType === "function_response" || + normalizeRole(record.role) === "tool"; +} + +function extractRawText(value: unknown): string { + if (typeof value === "string") { + return value; + } + + if (Array.isArray(value)) { + return value.map((item) => extractRawText(item)).join(""); + } + + const record = asRecord(value); + if (Object.keys(record).length === 0) { + return ""; + } + + return extractRawText(record.text || record.content || record.parts); +} + +function collectCandidates(value: unknown, backend: HeadlessBackend | undefined): string[] { + if (Array.isArray(value)) { + return value.flatMap((item) => collectCandidates(item, backend)); + } + + const record = asRecord(value); + if (Object.keys(record).length === 0) { + return []; + } + + const candidates: string[] = []; + const candidate = candidateFromRecord(record, backend); + if (candidate) { + candidates.push(candidate); + } + + const response = asRecord(record.response); + for (const geminiCandidate of asArray(response.candidates)) { + const contentText = joinText(asRecord(geminiCandidate).content); + if (contentText) { + candidates.push(contentText); + } + } + + for (const message of asArray(record.messages)) { + candidates.push(...collectCandidates(message, backend)); + } + + return candidates; +} + +function flattenRecords(value: unknown): JsonRecord[] { + if (Array.isArray(value)) { + return value.flatMap((item) => flattenRecords(item)); + } + + const record = asRecord(value); + return Object.keys(record).length > 0 ? [record] : []; +} + +function progressEventFromToolRecord(record: JsonRecord): ToolProgressEvent | null { + const recordType = asString(record.type).trim().toLowerCase(); + if (recordType === "command_execution") { + const command = sanitizeSummary(record.command); + return command ? { operation: "run", text: `run ${command}` } : { operation: "tool", text: "tool command_execution" }; + } + + const toolName = toolNameFromRecord(record); + if (!toolName || !isToolRecord(record, toolName)) { + return null; + } + + const args = toolArgsFromRecord(record); + if (!readToolNames.has(toolName)) { + if (searchToolNames.has(toolName)) { + const query = valueFromFields(args, queryFields); + return query ? { operation: "search", text: `search ${query}` } : { operation: "tool", text: `tool ${toolName}` }; + } + + if (runToolNames.has(toolName)) { + const command = commandFromArgs(args); + return command ? { operation: "run", text: `run ${command}` } : { operation: "tool", text: `tool ${toolName}` }; + } + + if (writeToolNames.has(toolName)) { + const path = pathFromArgs(args); + return path ? { operation: "write", text: `write ${path}` } : { operation: "tool", text: `tool ${toolName}` }; + } + + if (editToolNames.has(toolName)) { + const path = pathFromArgs(args); + return path ? { operation: "edit", text: `edit ${path}` } : { operation: "tool", text: `tool ${toolName}` }; + } + + return { operation: "tool", text: `tool ${toolName}` }; + } + + const path = pathFromArgs(args); + return path ? { operation: "read", text: `read ${path}` } : { operation: "tool", text: `tool ${toolName}` }; +} + +function isToolRecord(record: JsonRecord, toolName: string): boolean { + const type = asString(record.type).trim().toLowerCase(); + if (toolRecordTypes.has(type)) { + return true; + } + + if (asRecord(record.function).name !== undefined || asRecord(record.functionCall).name !== undefined) { + return true; + } + + if (toolName && Object.keys(toolArgsFromRecord(record)).length > 0) { + return true; + } + + return false; +} + +function toolNameFromRecord(record: JsonRecord): string { + for (const field of toolNameFields) { + const name = asString(record[field]).trim().toLowerCase(); + if (name) { + return name; + } + } + + const functionRecord = asRecord(record.function); + const functionName = asString(functionRecord.name).trim().toLowerCase(); + if (functionName) { + return functionName; + } + + const functionCallRecord = asRecord(record.functionCall); + return asString(functionCallRecord.name).trim().toLowerCase(); +} + +function toolArgsFromRecord(record: JsonRecord): JsonRecord { + for (const field of toolArgumentFields) { + const value = record[field]; + const parsed = typeof value === "string" ? parseJsonLine(value) : value; + const args = asRecord(parsed); + if (Object.keys(args).length > 0) { + return args; + } + } + + const functionRecord = asRecord(record.function); + const functionArgs = typeof functionRecord.arguments === "string" + ? parseJsonLine(functionRecord.arguments) + : functionRecord.arguments; + const functionArgsRecord = asRecord(functionArgs); + if (Object.keys(functionArgsRecord).length > 0) { + return functionArgsRecord; + } + + const functionCallRecord = asRecord(record.functionCall); + const functionCallArgs = typeof functionCallRecord.args === "string" + ? parseJsonLine(functionCallRecord.args) + : functionCallRecord.args; + return asRecord(functionCallArgs); +} + +function isConfidentPath(path: string): boolean { + return path.length > 0 && !path.includes("\n") && !path.includes("\0") && !/^\s*-/.test(path); +} + +function pathFromArgs(args: JsonRecord): string { + for (const field of pathFields) { + const path = sanitizeSummary(args[field]); + if (isConfidentPath(path)) { + return path; + } + } + + return ""; +} + +function commandFromArgs(args: JsonRecord): string { + for (const field of commandFields) { + const command = sanitizeSummary(args[field]); + if (command) { + return command; + } + } + + return ""; +} + +function valueFromFields(args: JsonRecord, fields: readonly string[]): string { + for (const field of fields) { + const value = sanitizeSummary(args[field]); + if (value) { + return value; + } + } + + return ""; +} + +function sanitizeSummary(value: unknown): string { + const raw = Array.isArray(value) && value.every((item) => typeof item === "string") + ? value.join(" ") + : asString(value); + const summary = raw.replace(/\s+/g, " ").trim(); + return summary.length > 120 ? `${summary.slice(0, 117)}...` : summary; +} + +function extractUsage(value: unknown): unknown { + for (const record of flattenRecords(value)) { + const directUsage = normalizeUsage(record.usage); + if (directUsage !== undefined) { + return directUsage; + } + + const messageUsage = normalizeUsage(asRecord(record.message).usage); + if (messageUsage !== undefined) { + return messageUsage; + } + } + + return undefined; +} + +function normalizeUsage(value: unknown): unknown { + const usage = asRecord(value); + if (Object.keys(usage).length === 0) { + return undefined; + } + + const normalized: Record = {}; + assignNumber(normalized, "inputTokens", usage.inputTokens ?? usage.input_tokens ?? usage.input); + assignNumber(normalized, "cacheReadTokens", usage.cacheReadTokens ?? usage.cache_read_input_tokens ?? usage.cached_input_tokens ?? usage.cacheRead); + assignNumber(normalized, "cacheWriteTokens", usage.cacheWriteTokens ?? usage.cache_creation_input_tokens ?? usage.cacheWrite); + assignNumber(normalized, "outputTokens", usage.outputTokens ?? usage.output_tokens ?? usage.output); + assignNumber(normalized, "reasoningOutputTokens", usage.reasoningOutputTokens ?? usage.reasoning_output_tokens); + assignNumber(normalized, "totalTokens", usage.totalTokens ?? usage.total_tokens); + + for (const field of ["provider", "model", "pricingStatus", "cost"]) { + if (usage[field] !== undefined) { + normalized[field] = usage[field]; + } + } + + return Object.keys(normalized).length > 0 ? normalized : usage; +} + +function assignNumber(target: Record, field: string, value: unknown): void { + const number = asNumber(value); + if (number !== undefined) { + target[field] = number; + } +} diff --git a/src/agents/headless.ts b/src/agents/headless.ts index cf4c7ba..8803f31 100644 --- a/src/agents/headless.ts +++ b/src/agents/headless.ts @@ -2,6 +2,7 @@ import { spawn } from "node:child_process"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; +import { extractToolProgressEvents, HeadlessJsonStream } from "./headless-json.js"; import type { Agent, AgentEvent, AgentRequest } from "../types.js"; export type HeadlessBackend = "codex" | "claude" | "cursor" | "gemini" | "opencode" | "pi"; @@ -58,8 +59,8 @@ function headlessCommand( const args = [ ...(usesNpx ? ["-y", "@roberttlange/headless"] : []), ...(backend ? [backend] : []), - ...(req.debug ? ["--debug"] : []), - ...(req.usage ? ["--usage"] : []), + ...(req.debug ? ["--debug"] : ["--json"]), + ...(req.debug && req.usage ? ["--usage"] : []), ...(req.reasoningEffort ? ["--reasoning-effort", req.reasoningEffort] : []), "--allow", "read-only", @@ -83,6 +84,7 @@ async function* streamHeadless( ): AsyncIterable { type QueueEvent = | { readonly type: "agent_trace"; readonly text: string } + | { readonly type: "progress"; readonly message: string } | { readonly type: "close"; readonly exitCode: number }; const queue: QueueEvent[] = []; @@ -115,6 +117,8 @@ async function* streamHeadless( let settled = false; let pendingDebugTrace = ""; let reachedFinalMessage = false; + const jsonStream = new HeadlessJsonStream(); + const reportedToolSummaries = new Set(); try { child = spawn(command.command, command.args, { cwd: req.workspacePath, @@ -132,6 +136,25 @@ async function* streamHeadless( pushQueue({ type: "agent_trace", text: chunk }); } }; + const pushProgress = (message: string): void => { + if (!req.debug) { + pushQueue({ type: "progress", message }); + } + }; + const handleJsonRecords = (records: unknown[]): void => { + for (const record of records) { + for (const event of extractToolProgressEvents(record)) { + if (shouldDeduplicateToolSummary(event.operation) && reportedToolSummaries.has(event.text)) { + continue; + } + + if (shouldDeduplicateToolSummary(event.operation)) { + reportedToolSummaries.add(event.text); + } + pushProgress(event.text); + } + } + }; const flushDebugTrace = (): void => { if (!req.debug || reachedFinalMessage || !pendingDebugTrace) { return; @@ -163,6 +186,8 @@ async function* streamHeadless( stdout += text; if (req.debug) { pushDebugTrace(text); + } else { + handleJsonRecords(jsonStream.push(text)); } }); child.stderr?.on("data", (chunk) => { @@ -173,7 +198,15 @@ async function* streamHeadless( spawnErrorMessage = error.message; finish(127); }); - child.on("close", (code) => finish(code ?? 1)); + child.on("close", (code) => { + if (!req.debug) { + handleJsonRecords(jsonStream.finish()); + pushProgress("agent finished"); + } + finish(code ?? 1); + }); + + pushProgress("agent started"); let exitCode = 1; while (true) { @@ -182,6 +215,10 @@ async function* streamHeadless( yield event; continue; } + if (event.type === "progress") { + yield event; + continue; + } exitCode = event.exitCode; break; @@ -214,7 +251,13 @@ async function* streamHeadless( const output = splitHeadlessDebugOutput(stdout); yield { type: "text", text: output.answer || stdout }; } else { - yield { type: "text", text: stdout }; + const finalAnswer = jsonStream.finalAnswer(backend); + const usage = req.usage ? jsonStream.usage() : undefined; + const text = finalAnswer || (jsonStream.trace() ? "" : stdout); + yield { + type: "text", + text: usage === undefined ? text : `${text.trimEnd()}\n${JSON.stringify({ usage })}\n`, + }; } } yield { type: "done", exitCode }; @@ -273,6 +316,10 @@ function killProcessGroup(pid: number | undefined, signal: NodeJS.Signals): void } } +function shouldDeduplicateToolSummary(operation: string): boolean { + return operation === "read" || operation === "search" || operation === "run"; +} + function splitHeadlessDebugOutput(stdout: string): { readonly trace: string; readonly answer: string; diff --git a/src/cli/index.ts b/src/cli/index.ts index 977ac73..8a9d543 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,8 +1,13 @@ +import { spawn } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; import { helpText, packageVersion, exitCodes } from "./constants.js"; import { ConfigError, loadConfig } from "./config.js"; import { parseInvocation, UsageError, type ParsedInvocation } from "./args.js"; import { collectContext } from "../collectors/context.js"; import { defaultLimits } from "../collectors/limits.js"; +import { ProgressFormatter } from "./progress.js"; import { AgentError, HeadlessAgent, type HeadlessBackend } from "../agents/headless.js"; import { NoneAgent } from "../agents/none.js"; import { buildAgentPrompt } from "../agents/prompt.js"; @@ -96,6 +101,15 @@ async function runQuestion( emitDiagnostic: DiagnosticWriter, ): Promise { const trace = new Trace(); + const headlessProgressEnabled = invocation.config.agent !== "none" && !invocation.config.debug; + const progress = new ProgressFormatter({ + label: headlessProgressEnabled ? await progressLabel(invocation) : progressLabelFromConfig(invocation), + }); + const emitProgress = (message: string): void => emitDiagnostic(progress.format(message)); + if (headlessProgressEnabled) { + emitProgress(`resolving ${invocation.command}`); + } + const locateStartedAt = performance.now(); const located = await locateExecutable(invocation.command); trace.record({ @@ -138,6 +152,10 @@ async function runQuestion( details: { key: cacheKey }, }); + if (headlessProgressEnabled) { + emitProgress("collecting context"); + } + const bundle = cachedBundle ?? await collectContext(resolution, { ...defaultLimits, maxFiles: invocation.config.maxFiles, @@ -168,6 +186,9 @@ async function runQuestion( try { staged = await stageWorkspace(bundle, { question: invocation.question }); + if (headlessProgressEnabled && invocation.config.keepWorkspace) { + emitProgress(`staged workspace ${staged.path}`); + } await cache.storeWorkspace(cacheKey, staged.path); await cache.evict(512); const prompt = buildAgentPrompt({ @@ -195,7 +216,7 @@ async function runQuestion( debug: invocation.config.debug, usage: invocation.config.usage, reasoningEffort: invocation.config.reasoningEffort, - }), invocation.config.debug ? emitDiagnostic : undefined); + }), headlessProgressEnabled || invocation.config.debug ? emitDiagnostic : undefined, progress); await releaseStaged(); const parsedAnswer = splitUsageAnswer(answer.text, invocation.config.usage); @@ -244,6 +265,143 @@ function headlessBackend(agent: ParsedInvocation["config"]["agent"]): HeadlessBa return agent; } +async function progressLabel(invocation: ParsedInvocation): Promise { + if (invocation.config.agent !== "auto") { + return progressLabelFromConfig(invocation); + } + + const resolved = await resolveHeadlessAutoIdentity(invocation); + return progressLabelFromParts({ + agent: resolved.agent ?? invocation.config.agent, + model: resolved.model ?? headlessModel(invocation.config.headlessExtraFlags), + reasoningEffort: invocation.config.reasoningEffort ?? resolved.reasoningEffort, + }); +} + +function progressLabelFromConfig(invocation: ParsedInvocation): string { + return progressLabelFromParts({ + agent: invocation.config.agent, + model: headlessModel(invocation.config.headlessExtraFlags), + reasoningEffort: invocation.config.reasoningEffort, + }); +} + +function progressLabelFromParts(parts: { + readonly agent: string; + readonly model: string; + readonly reasoningEffort?: string; +}): string { + return `ask[${[ + parts.agent, + parts.model, + parts.reasoningEffort ?? "default", + ].map(sanitizeProgressLabelPart).join("-")}]`; +} + +async function resolveHeadlessAutoIdentity(invocation: ParsedInvocation): Promise<{ + readonly agent?: string; + readonly model?: string; + readonly reasoningEffort?: string; +}> { + const usesNpx = !invocation.config.headlessPath; + const command = invocation.config.headlessPath || "npx"; + const args = [ + ...(usesNpx ? ["-y", "@roberttlange/headless"] : []), + "--print-command", + ...(invocation.config.reasoningEffort ? ["--reasoning-effort", invocation.config.reasoningEffort] : []), + "--allow", + "read-only", + "--work-dir", + process.cwd(), + "--prompt", + "identity", + ...invocation.config.headlessExtraFlags, + ]; + + const resolved = parseHeadlessPrintCommand(await runHeadlessPrintCommand(command, args)); + return { + ...resolved, + reasoningEffort: resolved.reasoningEffort ?? await configuredReasoningEffort(resolved.agent), + }; +} + +function runHeadlessPrintCommand(command: string, args: readonly string[]): Promise { + return new Promise((resolve) => { + const child = spawn(command, args, { + cwd: process.cwd(), + shell: false, + stdio: ["ignore", "pipe", "ignore"], + }); + let stdout = ""; + let settled = false; + const settle = (output: string): void => { + if (settled) { + return; + } + + settled = true; + clearTimeout(timeout); + resolve(output); + }; + const timeout = setTimeout(() => { + child.kill("SIGTERM"); + settle(""); + }, 2_000); + timeout.unref(); + + child.stdout?.on("data", (chunk) => { + stdout += chunk.toString("utf8"); + }); + child.on("error", () => settle("")); + child.on("close", (code) => settle(code === 0 ? stdout : "")); + }); +} + +function parseHeadlessPrintCommand(command: string): { + readonly agent?: string; + readonly model?: string; + readonly reasoningEffort?: string; +} { + const agentMatch = command.match(/(?:^|\|\s*)(codex|claude|cursor|gemini|opencode|pi)\b/); + const modelMatch = command.match(/(?:^|\s)--model(?:=|\s+)(?:"([^"]+)"|'([^']+)'|(\S+))/); + const reasoningMatch = command.match(/model_reasoning_effort\s*=\s*\\?["']?([A-Za-z0-9_-]+)/); + return { + agent: agentMatch?.[1], + model: modelMatch?.[1] ?? modelMatch?.[2] ?? modelMatch?.[3], + reasoningEffort: reasoningMatch?.[1], + }; +} + +async function configuredReasoningEffort(agent: string | undefined): Promise { + if (agent !== "codex") { + return undefined; + } + + const home = process.env.HOME || homedir(); + const config = await readFile(join(home, ".codex", "config.toml"), "utf8").catch(() => ""); + return config.match(/^\s*model_reasoning_effort\s*=\s*["']?([A-Za-z0-9_-]+)/m)?.[1]; +} + +function headlessModel(flags: readonly string[]): string { + for (let index = 0; index < flags.length; index += 1) { + const flag = flags[index]; + if (flag === "--model") { + return flags[index + 1] ?? "default"; + } + + if (flag.startsWith("--model=")) { + return flag.slice("--model=".length) || "default"; + } + } + + return "default"; +} + +function sanitizeProgressLabelPart(value: string): string { + const normalized = value.replace(/[^A-Za-z0-9_.-]+/g, "_").replace(/^_+|_+$/g, ""); + return normalized || "default"; +} + function splitUsageAnswer(answer: string, usageEnabled: boolean): { readonly text: string; readonly usage?: unknown; @@ -300,7 +458,8 @@ function withBufferedDiagnostics(result: RunResult, diagnostics: string): RunRes async function collectAgentAnswer( events: AsyncIterable, - emitAgentTrace?: DiagnosticWriter, + emitAgentDiagnostic?: DiagnosticWriter, + progress?: ProgressFormatter, ): Promise<{ readonly text: string; readonly trace: string; @@ -318,15 +477,18 @@ async function collectAgentAnswer( } if (event.type === "agent_trace") { trace += event.text; - if (emitAgentTrace) { + if (emitAgentDiagnostic) { if (!traceOpen) { - emitAgentTrace("----- ask agent trace -----\n"); + emitAgentDiagnostic("----- ask agent trace -----\n"); traceOpen = true; } - emitAgentTrace(event.text); + emitAgentDiagnostic(event.text); lastTraceEndedWithNewline = event.text.endsWith("\n"); } } + if (event.type === "progress" && emitAgentDiagnostic) { + emitAgentDiagnostic((progress ?? new ProgressFormatter()).format(event.message)); + } if (event.type === "error" && event.fatal) { text += event.message; exitCode = 1; @@ -336,8 +498,8 @@ async function collectAgentAnswer( } } - if (traceOpen && emitAgentTrace) { - emitAgentTrace(`${lastTraceEndedWithNewline ? "" : "\n"}----- end ask agent trace -----\n\n`); + if (traceOpen && emitAgentDiagnostic) { + emitAgentDiagnostic(`${lastTraceEndedWithNewline ? "" : "\n"}----- end ask agent trace -----\n\n`); } return { text, trace, exitCode }; diff --git a/src/cli/progress.ts b/src/cli/progress.ts new file mode 100644 index 0000000..ff7a0e9 --- /dev/null +++ b/src/cli/progress.ts @@ -0,0 +1,68 @@ +interface ProgressClock { + readonly label?: string; + readonly now?: () => Date; + readonly monotonicNow?: () => number; + readonly monotonicStartedAt?: number; + readonly color?: boolean; +} + +interface StderrLike { + readonly isTTY?: boolean; +} + +const ansi = { + cyan: "\x1b[36m", + dim: "\x1b[2m", + reset: "\x1b[0m", +}; + +export class ProgressFormatter { + private readonly label: string; + private readonly now: () => Date; + private readonly monotonicNow: () => number; + private readonly monotonicStartedAt: number; + private readonly color: boolean; + + constructor(options: ProgressClock = {}) { + this.label = options.label ?? "ask"; + this.now = options.now ?? (() => new Date()); + this.monotonicNow = options.monotonicNow ?? (() => performance.now()); + this.monotonicStartedAt = options.monotonicStartedAt ?? this.monotonicNow(); + this.color = options.color ?? shouldColorProgress(process.stderr, process.env); + } + + format(message: string): string { + const timestamp = formatClockTime(this.now()); + const elapsedSeconds = Math.max(0, this.monotonicNow() - this.monotonicStartedAt) / 1_000; + const prefix = `[${timestamp} +${elapsedSeconds.toFixed(1)}s] ${this.label}:`; + if (!this.color) { + return `${prefix} ${message}\n`; + } + + return `${ansi.dim}[${timestamp} +${elapsedSeconds.toFixed(1)}s]${ansi.reset} ${ansi.cyan}${this.label}:${ansi.reset} ${message}\n`; + } +} + +export function shouldColorProgress( + stderr: StderrLike = process.stderr, + env: Record = process.env, +): boolean { + const forceColor = env.FORCE_COLOR; + if (forceColor !== undefined) { + return forceColor !== "" && forceColor !== "0" && forceColor.toLowerCase() !== "false"; + } + + if (env.NO_COLOR !== undefined) { + return false; + } + + return Boolean(stderr.isTTY); +} + +function formatClockTime(date: Date): string { + return [ + date.getHours(), + date.getMinutes(), + date.getSeconds(), + ].map((part) => String(part).padStart(2, "0")).join(":"); +} diff --git a/src/types.ts b/src/types.ts index 4e1683d..1ada340 100644 --- a/src/types.ts +++ b/src/types.ts @@ -85,6 +85,7 @@ export interface AgentRequest { export type AgentEvent = | { readonly type: "text"; readonly text: string } | { readonly type: "agent_trace"; readonly text: string } + | { readonly type: "progress"; readonly message: string } | { readonly type: "citation"; readonly path: string; diff --git a/src/workspace/stage.ts b/src/workspace/stage.ts index a66f079..67a0c25 100644 --- a/src/workspace/stage.ts +++ b/src/workspace/stage.ts @@ -268,30 +268,81 @@ async function removeWorkspace(path: string): Promise { async function acquireWorkspaceLock(lockPath: string): Promise<() => Promise> { const startedAt = Date.now(); - const staleLockMs = 60_000; + const legacyLockMs = 2_000; + const ownerPath = join(lockPath, "owner.json"); await mkdir(dirname(lockPath), { recursive: true }); while (true) { try { await mkdir(lockPath, { recursive: false }); + await writeFile(ownerPath, `${JSON.stringify({ pid: process.pid, startedAt })}\n`).catch(() => undefined); return async () => { await rm(lockPath, { recursive: true, force: true }); }; } catch (error) { + if (!isErrnoException(error) || error.code !== "EEXIST") { + throw error; + } + const lockStat = await stat(lockPath).catch(() => null); - if (lockStat && Date.now() - lockStat.mtimeMs > staleLockMs) { + if (lockStat && await shouldRemoveWorkspaceLock(lockPath, Date.now() - lockStat.mtimeMs, legacyLockMs)) { await rm(lockPath, { recursive: true, force: true }).catch(() => undefined); continue; } if (Date.now() - startedAt > 20_000) { - throw error; + throw new Error(`timed out waiting for workspace lock: ${lockPath}`); } await new Promise((resolve) => setTimeout(resolve, 25)); } } } +async function shouldRemoveWorkspaceLock( + lockPath: string, + ageMs: number, + legacyLockMs: number, +): Promise { + const owner = await readWorkspaceLockOwner(join(lockPath, "owner.json")); + if (!owner) { + return ageMs > legacyLockMs; + } + + return !isProcessAlive(owner.pid); +} + +async function readWorkspaceLockOwner(path: string): Promise<{ readonly pid: number } | null> { + const raw = await readFile(path, "utf8").catch(() => ""); + if (!raw) { + return null; + } + + try { + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + + const pid = (parsed as { readonly pid?: unknown }).pid; + return typeof pid === "number" && Number.isSafeInteger(pid) && pid > 0 ? { pid } : null; + } catch { + return null; + } +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function isErrnoException(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} + async function chmodWritable(path: string): Promise { const fileStat = await stat(path); if (fileStat.isDirectory()) { diff --git a/tests/agent.test.js b/tests/agent.test.js index c68d392..43a977f 100644 --- a/tests/agent.test.js +++ b/tests/agent.test.js @@ -1,11 +1,12 @@ import assert from "node:assert/strict"; -import { chmod, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { delimiter, join } from "node:path"; +import { join } from "node:path"; import test from "node:test"; import { run } from "../dist/cli/index.js"; import { buildAgentPrompt } from "../dist/agents/prompt.js"; +import { delay, eventually, jsonl, withEnv, withFakeHeadless, withFakeNpx } from "./helpers/fake-headless.js"; test("prompt builder includes untrusted-data and no-network rules", () => { const prompt = buildAgentPrompt({ @@ -43,6 +44,7 @@ test("none agent human output prints workspace and ASK_CONTEXT", async () => { assert.match(result.stdout, /Workspace: /); assert.match(result.stdout, /# ask context/); assert.match(result.stdout, /Package: fixture-cli-npm 0\.1\.0/); + assert.doesNotMatch(result.stderr ?? "", /^ask:/m); }); test("verbose prints exact agent prompt to stderr", async () => { @@ -136,15 +138,16 @@ test("default agent invokes Headless without an explicit backend", async () => { }); const capture = JSON.parse(await readFile(capturePath, "utf8")); - assert.deepEqual(capture.argv.slice(0, 8), [ + assert.deepEqual(capture.argv.slice(0, 9), [ "-y", "@roberttlange/headless", + "--json", "--allow", "read-only", "--work-dir", - capture.argv[5], + capture.argv[6], "--prompt-file", - capture.argv[7], + capture.argv[8], ]); assert.match(capture.cwd, /ask-/); assert.match(capture.promptFile, /Question:\s+How do I enable json output\?/); @@ -160,10 +163,10 @@ test("explicit coding agent is passed to Headless", async () => { const capture = JSON.parse(await readFile(capturePath, "utf8")); assert.deepEqual(capture.argv.slice(0, 3), ["-y", "@roberttlange/headless", "claude"]); - assert.deepEqual(capture.argv.slice(3, 7), ["--allow", "read-only", "--work-dir", capture.argv[6]]); + assert.deepEqual(capture.argv.slice(3, 8), ["--json", "--allow", "read-only", "--work-dir", capture.argv[7]]); }); -test("Headless receives reasoning effort and usage flags", async () => { +test("Headless receives reasoning effort and omits usage flag in JSON trace mode", async () => { const { capturePath } = await withFakeNpx(async () => { const result = await run([ "--agent", @@ -178,10 +181,14 @@ test("Headless receives reasoning effort and usage flags", async () => { assert.equal(result.exitCode, 0); assert.match(result.stdout, /headless answer/); assert.match(result.stdout, /"usage"/); - }, { stdout: 'headless answer\n{"usage":{"totalTokens":42}}\n' }); + }, { stdout: jsonl([ + { type: "agent_message", text: "headless answer" }, + { type: "turn.completed", usage: { totalTokens: 42 } }, + ]) }); const capture = JSON.parse(await readFile(capturePath, "utf8")); - assert.ok(capture.argv.includes("--usage")); + assert.ok(capture.argv.includes("--json")); + assert.ok(!capture.argv.includes("--usage")); assert.deepEqual(capture.argv.slice(capture.argv.indexOf("--reasoning-effort"), capture.argv.indexOf("--reasoning-effort") + 2), [ "--reasoning-effort", "high", @@ -203,7 +210,10 @@ test("JSON usage output is structured outside the answer", async () => { const parsed = JSON.parse(result.stdout); assert.equal(parsed.answer, "headless answer"); assert.deepEqual(parsed.usage, { totalTokens: 42 }); - }, { stdout: 'headless answer\n{"usage":{"totalTokens":42}}\n' }); + }, { stdout: jsonl([ + { type: "agent_message", text: "headless answer" }, + { type: "turn.completed", usage: { totalTokens: 42 } }, + ]) }); }); test("Headless path and extra flags come from config", async () => { @@ -234,7 +244,7 @@ test("Headless path and extra flags come from config", async () => { const capture = JSON.parse(await readFile(capturePath, "utf8")); assert.equal(capture.command, "headless-local"); - assert.deepEqual(capture.argv.slice(0, 5), ["codex", "--allow", "read-only", "--work-dir", capture.argv[4]]); + assert.deepEqual(capture.argv.slice(0, 6), ["codex", "--json", "--allow", "read-only", "--work-dir", capture.argv[5]]); assert.deepEqual(capture.argv.slice(-2), ["--model", "gpt-5.5"]); }); @@ -350,113 +360,3 @@ test("Headless timeout terminates descendant processes", async () => { await rm(temp, { force: true, recursive: true }); } }); - -async function withFakeNpx(callback, options = {}) { - return withFakeHeadless("npx", callback, options); -} - -async function withFakeHeadless(command, callback, options = {}) { - const temp = await mkdtemp(join(tmpdir(), "ask-fake-headless-")); - const capturePath = join(temp, "capture.json"); - const commandPath = join(temp, command); - await writeFile(commandPath, `#!/usr/bin/env node -const { readFileSync, writeFileSync } = require("node:fs"); -const { spawn } = require("node:child_process"); -const argv = process.argv.slice(2); -const promptFileIndex = argv.indexOf("--prompt-file"); -writeFileSync(process.env.ASK_NPX_CAPTURE, JSON.stringify({ - command: process.argv[1].split("/").pop(), - argv, - cwd: process.cwd(), - promptFile: promptFileIndex === -1 ? "" : readFileSync(argv[promptFileIndex + 1], "utf8") -})); -if (process.env.ASK_NPX_STDERR) { - process.stderr.write(process.env.ASK_NPX_STDERR); -} -if (process.env.ASK_NPX_GRANDCHILD_MARKER) { - const child = spawn(process.execPath, [ - "-e", - "setTimeout(() => require('node:fs').writeFileSync(process.env.ASK_NPX_GRANDCHILD_MARKER, 'alive'), Number(process.env.ASK_NPX_GRANDCHILD_DELAY_MS || '1500'))", - ], { env: process.env, stdio: "ignore" }); - child.unref(); -} -process.stdout.write(process.env.ASK_NPX_STDOUT || "headless answer\\n"); -const exitCode = Number(process.env.ASK_NPX_EXIT || "0"); -const sleepMs = Number(process.env.ASK_NPX_SLEEP_MS || "0"); -if (sleepMs > 0) { - setTimeout(() => process.exit(exitCode), sleepMs); -} else { - process.exit(exitCode); -} -`); - await chmod(commandPath, 0o755); - - const previous = { - PATH: process.env.PATH, - ASK_NPX_CAPTURE: process.env.ASK_NPX_CAPTURE, - ASK_NPX_EXIT: process.env.ASK_NPX_EXIT, - ASK_NPX_STDERR: process.env.ASK_NPX_STDERR, - ASK_NPX_STDOUT: process.env.ASK_NPX_STDOUT, - ASK_NPX_SLEEP_MS: process.env.ASK_NPX_SLEEP_MS, - ASK_NPX_GRANDCHILD_MARKER: process.env.ASK_NPX_GRANDCHILD_MARKER, - ASK_NPX_GRANDCHILD_DELAY_MS: process.env.ASK_NPX_GRANDCHILD_DELAY_MS, - }; - - try { - return await withEnv({ - PATH: `${temp}${delimiter}${process.env.PATH ?? ""}`, - ASK_NPX_CAPTURE: capturePath, - ASK_NPX_EXIT: options.exitCode === undefined ? undefined : String(options.exitCode), - ASK_NPX_STDERR: options.stderr, - ASK_NPX_STDOUT: options.stdout, - ASK_NPX_SLEEP_MS: options.sleepMs === undefined ? undefined : String(options.sleepMs), - ASK_NPX_GRANDCHILD_MARKER: options.grandchildMarker, - ASK_NPX_GRANDCHILD_DELAY_MS: options.grandchildDelayMs === undefined ? undefined : String(options.grandchildDelayMs), - }, async () => ({ capturePath, result: await callback() })); - } finally { - restoreEnv(previous); - } -} - -async function withEnv(values, callback) { - const previous = Object.fromEntries(Object.keys(values).map((key) => [key, process.env[key]])); - for (const [key, value] of Object.entries(values)) { - setOptionalEnv(key, value); - } - - try { - return await callback(); - } finally { - restoreEnv(previous); - } -} - -function restoreEnv(previous) { - for (const [key, value] of Object.entries(previous)) { - setOptionalEnv(key, value); - } -} - -function setOptionalEnv(key, value) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } -} - -async function eventually(predicate) { - const startedAt = Date.now(); - while (Date.now() - startedAt < 2_000) { - if (predicate()) { - return; - } - await new Promise((resolve) => setTimeout(resolve, 10)); - } - - assert.fail("condition was not met before timeout"); -} - -async function delay(ms) { - await new Promise((resolve) => setTimeout(resolve, ms)); -} diff --git a/tests/headless-progress.test.js b/tests/headless-progress.test.js new file mode 100644 index 0000000..775f68b --- /dev/null +++ b/tests/headless-progress.test.js @@ -0,0 +1,432 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { run } from "../dist/cli/index.js"; +import { ProgressFormatter, shouldColorProgress } from "../dist/cli/progress.js"; +import { extractToolProgressEvents, HeadlessJsonStream } from "../dist/agents/headless-json.js"; +import { eventually, jsonl, withEnv, withFakeNpx } from "./helpers/fake-headless.js"; + +test("progress formatter adds local timestamp and elapsed time", () => { + const formatter = new ProgressFormatter({ + label: "ask[codex-default-high]", + color: false, + now: () => new Date(2026, 3, 30, 12, 34, 56), + monotonicNow: () => 1_234, + monotonicStartedAt: 0, + }); + + assert.equal(formatter.format("agent started"), "[12:34:56 +1.2s] ask[codex-default-high]: agent started\n"); +}); + +test("progress color follows TTY and environment gates", async () => { + assert.equal(shouldColorProgress({ isTTY: false }, {}), false); + assert.equal(shouldColorProgress({ isTTY: true }, {}), true); + assert.equal(shouldColorProgress({ isTTY: true }, { NO_COLOR: "1" }), false); + assert.equal(shouldColorProgress({ isTTY: false }, { FORCE_COLOR: "1" }), true); + assert.equal(shouldColorProgress({ isTTY: true }, { FORCE_COLOR: "0" }), false); + + await withEnv({ FORCE_COLOR: "1", NO_COLOR: undefined }, async () => { + const formatter = new ProgressFormatter({ + label: "ask[codex-default-default]", + now: () => new Date(2026, 3, 30, 12, 34, 56), + monotonicNow: () => 0, + monotonicStartedAt: 0, + }); + assert.match(formatter.format("agent started"), /\x1b\[/); + }); +}); + +test("Headless progress diagnostics stream before completion", async () => { + const diagnostics = []; + let completed = false; + + await withFakeNpx(async () => { + const runPromise = run([ + "--agent", + "codex", + "fixture-cli-npm", + "How do I enable json output?", + ], (text) => diagnostics.push(text)).finally(() => { + completed = true; + }); + + await eventually(() => diagnostics.join("").includes("ask[codex-default-default]: agent started") && !completed, 10_000); + const result = await runPromise; + + assert.equal(result.exitCode, 0); + assert.match(result.stdout, /headless answer/); + }, { + stdout: jsonl([{ type: "agent_message", text: "headless answer" }]), + sleepMs: 5_000, + }); + + const output = diagnostics.join(""); + assert.match(output, /ask\[codex-default-default\]: resolving fixture-cli-npm/); + assert.match(output, /ask\[codex-default-default\]: collecting context/); + assert.match(output, /ask\[codex-default-default\]: agent started/); + assert.match(output, /ask\[codex-default-default\]: agent finished/); +}); + +test("Headless progress label includes agent model and reasoning", async () => { + const temp = await mkdtemp(join(tmpdir(), "ask-progress-label-")); + const askConfigDir = join(temp, ".ask"); + await mkdir(askConfigDir); + await writeFile( + join(askConfigDir, "config.toml"), + [ + "[agents.headless]", + "extraFlags = [\"--model\", \"sonnet\"]", + "", + ].join("\n"), + ); + + const diagnostics = []; + await withFakeNpx(async () => { + const result = await withEnv({ HOME: temp }, () => run([ + "--agent", + "claude", + "--reasoning-effort", + "high", + "fixture-cli-npm", + "How do I enable json output?", + ], (text) => diagnostics.push(text))); + + assert.equal(result.exitCode, 0); + }, { + stdout: jsonl([{ type: "agent_message", text: "headless answer" }]), + }); + + assert.match(diagnostics.join(""), /ask\[claude-sonnet-high\]: agent started/); +}); + +test("Headless progress label resolves auto agent through print command", async () => { + const diagnostics = []; + await withFakeNpx(async () => { + const result = await run([ + "--reasoning-effort", + "high", + "fixture-cli-npm", + "How do I enable json output?", + ], (text) => diagnostics.push(text)); + + assert.equal(result.exitCode, 0); + }, { + printCommand: "printf %s prompt | codex --model gpt-5.5 --json -", + stdout: jsonl([{ type: "agent_message", text: "headless answer" }]), + }); + + assert.match(diagnostics.join(""), /ask\[codex-gpt-5.5-high\]: agent started/); + assert.doesNotMatch(diagnostics.join(""), /ask\[auto-default-high\]/); +}); + +test("Headless progress label reads Codex configured reasoning for auto agent", async () => { + const temp = await mkdtemp(join(tmpdir(), "ask-codex-reasoning-")); + const codexConfigDir = join(temp, ".codex"); + await mkdir(codexConfigDir); + await writeFile(join(codexConfigDir, "config.toml"), 'model_reasoning_effort = "high"\n'); + + const diagnostics = []; + await withFakeNpx(async () => { + const result = await withEnv({ HOME: temp }, () => run([ + "fixture-cli-npm", + "How do I enable json output?", + ], (text) => diagnostics.push(text))); + + assert.equal(result.exitCode, 0); + }, { + printCommand: "printf %s prompt | codex --model gpt-5.5 --json -", + stdout: jsonl([{ type: "agent_message", text: "headless answer" }]), + }); + + assert.match(diagnostics.join(""), /ask\[codex-gpt-5.5-high\]: agent started/); + assert.doesNotMatch(diagnostics.join(""), /ask\[codex-gpt-5.5-default\]/); +}); + +test("Headless JSONL parser handles split chunks and non-JSON warnings", async () => { + const chunks = [ + "warning: noisy provider banner\n{\"type\":\"agent_", + "message\",\"text\":\"split answer\"}\nnot json\n", + ]; + + await withFakeNpx(async () => { + const result = await run(["--agent", "codex", "fixture-cli-npm", "How do I enable json output?"]); + + assert.equal(result.exitCode, 0); + assert.match(result.stdout, /split answer/); + assert.doesNotMatch(result.stdout, /warning: noisy/); + }, { stdoutChunks: chunks, stderr: "stderr warning\n" }); +}); + +test("Headless progress reports Codex-style file reads once", async () => { + const diagnostics = []; + await withFakeNpx(async () => { + const result = await run([ + "--agent", + "codex", + "fixture-cli-npm", + "How do I enable json output?", + ], (text) => diagnostics.push(text)); + + assert.equal(result.exitCode, 0); + }, { + stdout: jsonl([ + { + type: "item.completed", + item: { + type: "function_call", + name: "read_file", + arguments: JSON.stringify({ path: "package/README.md" }), + }, + }, + { + type: "response_item", + payload: { + type: "function_call", + name: "read_file", + arguments: { path: "package/README.md" }, + }, + }, + { type: "agent_message", text: "headless answer" }, + ]), + }); + + assert.equal((diagnostics.join("").match(/ask\[codex-default-default\]: read package\/README\.md/g) ?? []).length, 1); +}); + +test("Headless progress reports real Codex command execution records", async () => { + const diagnostics = []; + await withFakeNpx(async () => { + const result = await run([ + "--agent", + "codex", + "fixture-cli-npm", + "How do I enable json output?", + ], (text) => diagnostics.push(text)); + + assert.equal(result.exitCode, 0); + }, { + stdout: jsonl([ + { type: "thread.started", thread_id: "thread_1" }, + { type: "turn.started" }, + { + type: "item.started", + item: { + id: "item_1", + type: "command_execution", + command: "/bin/zsh -lc pwd", + aggregated_output: "", + exit_code: null, + status: "in_progress", + }, + }, + { + type: "item.completed", + item: { + id: "item_1", + type: "command_execution", + command: "/bin/zsh -lc pwd", + aggregated_output: "/Users/rob/Dropbox/projects/ask-cli\n", + exit_code: 0, + status: "completed", + }, + }, + { type: "item.completed", item: { id: "item_2", type: "agent_message", text: "headless answer" } }, + ]), + }); + + const output = diagnostics.join(""); + assert.equal((output.match(/ask\[codex-default-default\]: run \/bin\/zsh -lc pwd/g) ?? []).length, 1); + assert.doesNotMatch(output, /aggregated_output|Dropbox\/projects/); +}); + +test("Headless progress reports Claude-style file reads", async () => { + const diagnostics = []; + await withFakeNpx(async () => { + const result = await run([ + "--agent", + "claude", + "fixture-cli-npm", + "How do I enable json output?", + ], (text) => diagnostics.push(text)); + + assert.equal(result.exitCode, 0); + }, { + stdout: jsonl([ + { + type: "content_block_start", + content_block: { + type: "tool_use", + name: "open", + input: { file_path: "ASK_CONTEXT.md" }, + }, + }, + { type: "result", result: "headless answer" }, + ]), + }); + + assert.match(diagnostics.join(""), /ask\[claude-default-default\]: read ASK_CONTEXT\.md/); +}); + +test("Headless progress extractor summarizes provider tool calls", () => { + const records = [ + { + provider: "codex", + record: { + type: "item.completed", + item: { type: "command_execution", command: "npm test -- --runInBand", aggregated_output: "secret result" }, + }, + expected: "run npm test -- --runInBand", + }, + { + provider: "claude", + record: { + type: "content_block_start", + content_block: { type: "tool_use", name: "Grep", input: { pattern: "ProgressFormatter" } }, + }, + expected: "search ProgressFormatter", + }, + { + provider: "cursor", + record: { + type: "toolcall", + toolName: "read_file", + args: { filePath: "src/cli/index.ts" }, + }, + expected: "read src/cli/index.ts", + }, + { + provider: "gemini", + record: { + type: "message", + message: { + role: "model", + parts: [{ functionCall: { name: "run_command", args: { cmd: ["npm", "test"] } } }], + }, + }, + expected: "run npm test", + }, + { + provider: "opencode", + record: { + type: "part", + part: { type: "tool", tool: "search", input: { query: "headless progress" } }, + }, + expected: "search headless progress", + }, + { + provider: "pi", + record: { + type: "payload", + payload: { type: "tool_use", name: "inspect_symbols", input: { symbol: "HeadlessJsonStream" } }, + }, + expected: "tool inspect_symbols", + }, + ]; + + for (const { provider, record, expected } of records) { + assert.deepEqual( + extractToolProgressEvents(record).map((event) => event.text), + [expected], + provider, + ); + } +}); + +test("Headless progress reports run search edit and unknown tool summaries once where deduped", async () => { + const diagnostics = []; + await withFakeNpx(async () => { + const result = await run([ + "--agent", + "gemini", + "fixture-cli-npm", + "How do I enable json output?", + ], (text) => diagnostics.push(text)); + + assert.equal(result.exitCode, 0); + }, { + stdout: jsonl([ + { + type: "message", + message: { + role: "model", + parts: [ + { functionCall: { name: "run_command", args: { command: "npm test" } } }, + { functionCall: { name: "run_command", args: { command: "npm test" } } }, + { functionCall: { name: "grep", args: { pattern: "json output" } } }, + { functionCall: { name: "edit", args: { path: "src/cli/index.ts" } } }, + { functionCall: { name: "inspect_symbols", args: { symbol: "runQuestion" } } }, + ], + }, + }, + { type: "agent_message", text: "headless answer" }, + ]), + }); + + const output = diagnostics.join(""); + assert.equal((output.match(/ask\[gemini-default-default\]: run npm test/g) ?? []).length, 1); + assert.match(output, /ask\[gemini-default-default\]: search json output/); + assert.match(output, /ask\[gemini-default-default\]: edit src\/cli\/index\.ts/); + assert.match(output, /ask\[gemini-default-default\]: tool inspect_symbols/); +}); + +test("Headless parser joins Gemini final delta message chunks after tool use", () => { + const stream = new HeadlessJsonStream(); + stream.push(jsonl([ + { type: "message", role: "assistant", content: "I will inspect", delta: true }, + { type: "message", role: "assistant", content: " the files first.", delta: true }, + { type: "tool_use", tool_name: "read_file", parameters: { file_path: "ASK_CONTEXT.md" } }, + { type: "tool_result", tool_id: "tool_1", status: "success", output: "not answer text" }, + { type: "message", role: "assistant", content: "Lint rules are configured in ", delta: true }, + { type: "message", role: "assistant", content: "`pyproject.toml", delta: true }, + { type: "message", role: "assistant", content: ":328`.", delta: true }, + { type: "result", status: "success", stats: { total_tokens: 328 } }, + ])); + + assert.equal(stream.finalAnswer("gemini"), "Lint rules are configured in `pyproject.toml:328`."); +}); + +test("Headless progress remains stderr-only for ask JSON output", async () => { + const diagnostics = []; + await withFakeNpx(async () => { + const result = await run([ + "--json", + "--agent", + "codex", + "fixture-cli-npm", + "How do I enable json output?", + ], (text) => diagnostics.push(text)); + + assert.equal(result.exitCode, 0); + assert.equal(JSON.parse(result.stdout).answer, "headless answer"); + assert.doesNotMatch(result.stdout, /ask\[/); + }); + + assert.match(diagnostics.join(""), /ask\[codex-default-default\]: agent started/); +}); + +test("agent none and debug mode do not emit progress diagnostics", async () => { + const none = await run(["--agent", "none", "fixture-cli-npm", "How do I enable json output?"]); + assert.doesNotMatch(none.stderr ?? "", /ask\[/); + + const diagnostics = []; + await withFakeNpx(async () => { + const result = await run([ + "--debug", + "--agent", + "codex", + "fixture-cli-npm", + "How do I enable json output?", + ], (text) => diagnostics.push(text)); + + assert.equal(result.exitCode, 0); + }, { + stdout: "raw trace\n--- final message ---\ndebug answer\n", + }); + + const output = diagnostics.join(""); + assert.match(output, /----- ask agent trace -----/); + assert.doesNotMatch(output, /\[[0-9]{2}:[0-9]{2}:[0-9]{2} \+[0-9.]+s\] ask\[/); +}); diff --git a/tests/helpers/fake-headless.js b/tests/helpers/fake-headless.js new file mode 100644 index 0000000..d0befa9 --- /dev/null +++ b/tests/helpers/fake-headless.js @@ -0,0 +1,146 @@ +import assert from "node:assert/strict"; +import { chmod, mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { delimiter, join } from "node:path"; + +export async function withFakeNpx(callback, options = {}) { + return withFakeHeadless("npx", callback, options); +} + +export async function withFakeHeadless(command, callback, options = {}) { + const temp = await mkdtemp(join(tmpdir(), "ask-fake-headless-")); + const capturePath = join(temp, "capture.json"); + const commandPath = join(temp, command); + await writeFile(commandPath, `#!/usr/bin/env node +const { readFileSync, writeFileSync } = require("node:fs"); +const { spawn } = require("node:child_process"); +const argv = process.argv.slice(2); +const promptFileIndex = argv.indexOf("--prompt-file"); +writeFileSync(process.env.ASK_NPX_CAPTURE, JSON.stringify({ + command: process.argv[1].split("/").pop(), + argv, + cwd: process.cwd(), + promptFile: promptFileIndex === -1 ? "" : readFileSync(argv[promptFileIndex + 1], "utf8") +})); +if (process.env.ASK_NPX_STDERR) { + process.stderr.write(process.env.ASK_NPX_STDERR); +} +if (process.env.ASK_NPX_GRANDCHILD_MARKER) { + const child = spawn(process.execPath, [ + "-e", + "setTimeout(() => require('node:fs').writeFileSync(process.env.ASK_NPX_GRANDCHILD_MARKER, 'alive'), Number(process.env.ASK_NPX_GRANDCHILD_DELAY_MS || '1500'))", + ], { env: process.env, stdio: "ignore" }); + child.unref(); +} +const stdoutChunks = process.env.ASK_NPX_STDOUT_CHUNKS ? JSON.parse(process.env.ASK_NPX_STDOUT_CHUNKS) : null; +const stdoutText = process.env.ASK_NPX_STDOUT || "{\\"type\\":\\"agent_message\\",\\"text\\":\\"headless answer\\"}\\n"; +const exitCode = Number(process.env.ASK_NPX_EXIT || "0"); +const sleepMs = Number(process.env.ASK_NPX_SLEEP_MS || "0"); +if (argv.includes("--print-command")) { + process.stdout.write(process.env.ASK_NPX_PRINT_COMMAND || ""); + process.exit(exitCode); +} +if (stdoutChunks) { + let index = 0; + const writeNext = () => { + if (index >= stdoutChunks.length) { + if (sleepMs > 0) { + setTimeout(() => process.exit(exitCode), sleepMs); + } else { + process.exit(exitCode); + } + return; + } + process.stdout.write(stdoutChunks[index]); + index += 1; + setTimeout(writeNext, 20); + }; + writeNext(); +} else { + process.stdout.write(stdoutText); + if (sleepMs > 0) { + setTimeout(() => process.exit(exitCode), sleepMs); + } else { + process.exit(exitCode); + } +} +`); + await chmod(commandPath, 0o755); + + const previous = { + PATH: process.env.PATH, + ASK_NPX_CAPTURE: process.env.ASK_NPX_CAPTURE, + ASK_NPX_EXIT: process.env.ASK_NPX_EXIT, + ASK_NPX_STDERR: process.env.ASK_NPX_STDERR, + ASK_NPX_STDOUT: process.env.ASK_NPX_STDOUT, + ASK_NPX_STDOUT_CHUNKS: process.env.ASK_NPX_STDOUT_CHUNKS, + ASK_NPX_PRINT_COMMAND: process.env.ASK_NPX_PRINT_COMMAND, + ASK_NPX_SLEEP_MS: process.env.ASK_NPX_SLEEP_MS, + ASK_NPX_GRANDCHILD_MARKER: process.env.ASK_NPX_GRANDCHILD_MARKER, + ASK_NPX_GRANDCHILD_DELAY_MS: process.env.ASK_NPX_GRANDCHILD_DELAY_MS, + }; + + try { + return await withEnv({ + PATH: `${temp}${delimiter}${process.env.PATH ?? ""}`, + ASK_NPX_CAPTURE: capturePath, + ASK_NPX_EXIT: options.exitCode === undefined ? undefined : String(options.exitCode), + ASK_NPX_STDERR: options.stderr, + ASK_NPX_STDOUT: options.stdout, + ASK_NPX_STDOUT_CHUNKS: options.stdoutChunks === undefined ? undefined : JSON.stringify(options.stdoutChunks), + ASK_NPX_PRINT_COMMAND: options.printCommand, + ASK_NPX_SLEEP_MS: options.sleepMs === undefined ? undefined : String(options.sleepMs), + ASK_NPX_GRANDCHILD_MARKER: options.grandchildMarker, + ASK_NPX_GRANDCHILD_DELAY_MS: options.grandchildDelayMs === undefined ? undefined : String(options.grandchildDelayMs), + }, async () => ({ capturePath, result: await callback() })); + } finally { + restoreEnv(previous); + } +} + +export async function withEnv(values, callback) { + const previous = Object.fromEntries(Object.keys(values).map((key) => [key, process.env[key]])); + for (const [key, value] of Object.entries(values)) { + setOptionalEnv(key, value); + } + + try { + return await callback(); + } finally { + restoreEnv(previous); + } +} + +export function jsonl(records) { + return `${records.map((record) => JSON.stringify(record)).join("\n")}\n`; +} + +export async function eventually(predicate, timeoutMs = 5_000) { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if (predicate()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + assert.fail("condition was not met before timeout"); +} + +export async function delay(ms) { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +function restoreEnv(previous) { + for (const [key, value] of Object.entries(previous)) { + setOptionalEnv(key, value); + } +} + +function setOptionalEnv(key, value) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } +} diff --git a/tests/workspace.test.js b/tests/workspace.test.js index 0f2ad9c..6363781 100644 --- a/tests/workspace.test.js +++ b/tests/workspace.test.js @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { access, lstat, mkdir, mkdtemp, readFile, stat, symlink, writeFile } from "node:fs/promises"; +import { access, lstat, mkdir, mkdtemp, readFile, rm, stat, symlink, utimes, writeFile } from "node:fs/promises"; import { constants as fsConstants } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -68,6 +68,71 @@ test("workspace staging creates the shared temp root before locking", async () = } }); +test("workspace staging removes stale lock directories", async () => { + const bundle = await npmFixtureBundle(); + const staged = await stageWorkspace(bundle, { question: "find path" }); + const lockPath = `${staged.path}.lock`; + await staged.cleanup(); + + await mkdir(lockPath, { recursive: true }); + const oldDate = new Date(Date.now() - 120_000); + await utimes(lockPath, oldDate, oldDate); + + const restaged = await stageWorkspace(bundle, { question: "stale lock?" }); + try { + await access(restaged.path, fsConstants.F_OK); + } finally { + await restaged.cleanup(); + await rm(lockPath, { recursive: true, force: true }); + } +}); + +test("workspace staging preserves stale-looking locks owned by a live process", async () => { + const bundle = await npmFixtureBundle(); + const staged = await stageWorkspace(bundle, { question: "live lock?" }); + const lockPath = `${staged.path}.lock`; + const oldDate = new Date(Date.now() - 120_000); + await utimes(lockPath, oldDate, oldDate); + + let restaged; + const restagePromise = stageWorkspace(bundle, { question: "wait for live lock?" }).then((value) => { + restaged = value; + return value; + }); + const firstResult = await Promise.race([ + restagePromise.then(() => "resolved"), + delay(250).then(() => "waiting"), + ]); + + try { + assert.equal(firstResult, "waiting"); + } finally { + await staged.cleanup(); + } + + restaged = await restagePromise; + await restaged.cleanup(); +}); + +test("workspace staging removes orphaned legacy lock directories quickly", async () => { + const bundle = await npmFixtureBundle(); + const staged = await stageWorkspace(bundle, { question: "find path" }); + const lockPath = `${staged.path}.lock`; + await staged.cleanup(); + + await mkdir(lockPath, { recursive: true }); + const oldDate = new Date(Date.now() - 5_000); + await utimes(lockPath, oldDate, oldDate); + + const restaged = await stageWorkspace(bundle, { question: "orphan lock?" }); + try { + await access(restaged.path, fsConstants.F_OK); + } finally { + await restaged.cleanup(); + await rm(lockPath, { recursive: true, force: true }); + } +}); + test("workspace skips symlink escapes", async () => { const temp = await mkdtemp(join(tmpdir(), "ask-workspace-")); const packageRoot = join(temp, "pkg"); @@ -230,3 +295,7 @@ function restoreEnv(name, value) { process.env[name] = value; } + +async function delay(ms) { + await new Promise((resolve) => setTimeout(resolve, ms)); +}