fix(server): surface real errors and child-process output in diagnostics
Provider diagnostics were stringifying Error objects through JSON.stringify,
which yields {} and hides the cause. Spawned children logged stderr to the
daemon log but never attached it to thrown errors, so the diagnostic showed
"Server: Unavailable ({})" with no clue why.
Generalize toDiagnosticErrorMessage to extract message, code, signal, stderr,
stdout, and recursive cause from any Error (including execFile-style errors),
and never return {} or empty. Buffer stderr/stdout in OpenCode startServer
and include them in startup-failure rejections. Stop swallowing errors in
resolveBinaryVersion. Drop the duplicate stringifyUnknownError helpers in
opencode-agent and pi-direct-agent and route every callsite through the
single canonical helper.
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { toDiagnosticErrorMessage } from "./diagnostic-utils.js";
|
||||
|
||||
describe("toDiagnosticErrorMessage", () => {
|
||||
test("returns message for plain Error", () => {
|
||||
expect(toDiagnosticErrorMessage(new Error("boom"))).toBe("boom");
|
||||
});
|
||||
|
||||
test("includes stderr, stdout, code, and signal from execFile-style Error", () => {
|
||||
const error = new Error("Command failed: opencode --version") as Error & {
|
||||
stderr: string;
|
||||
stdout: string;
|
||||
code: number;
|
||||
signal: string;
|
||||
};
|
||||
error.stderr = "permission denied\n";
|
||||
error.stdout = "partial output";
|
||||
error.code = 127;
|
||||
error.signal = "SIGTERM";
|
||||
|
||||
const message = toDiagnosticErrorMessage(error);
|
||||
expect(message).toContain("Command failed: opencode --version");
|
||||
expect(message).toContain("exit code: 127");
|
||||
expect(message).toContain("signal: SIGTERM");
|
||||
expect(message).toContain("stderr: permission denied");
|
||||
expect(message).toContain("stdout: partial output");
|
||||
});
|
||||
|
||||
test("preserves multi-line stderr on a real Error", () => {
|
||||
const error = new Error("Command failed") as Error & { stderr: string };
|
||||
error.stderr = "line one\nline two\nline three";
|
||||
const message = toDiagnosticErrorMessage(error);
|
||||
expect(message).toContain("stderr: line one\nline two\nline three");
|
||||
});
|
||||
|
||||
test("recursively formats Error cause", () => {
|
||||
const inner = new Error("inner failure") as Error & { stderr: string };
|
||||
inner.stderr = "inner stderr";
|
||||
const outer = new Error("outer failure", { cause: inner });
|
||||
const message = toDiagnosticErrorMessage(outer);
|
||||
expect(message).toContain("outer failure");
|
||||
expect(message).toContain("caused by: inner failure");
|
||||
expect(message).toContain("stderr: inner stderr");
|
||||
});
|
||||
|
||||
test("serializes plain objects rather than returning {}", () => {
|
||||
expect(toDiagnosticErrorMessage({ foo: "bar" })).toBe('{"foo":"bar"}');
|
||||
});
|
||||
|
||||
test("returns trimmed string when given a string", () => {
|
||||
expect(toDiagnosticErrorMessage("oops")).toBe("oops");
|
||||
expect(toDiagnosticErrorMessage(" spaced ")).toBe("spaced");
|
||||
});
|
||||
|
||||
test("returns Unknown error for null and undefined", () => {
|
||||
expect(toDiagnosticErrorMessage(null)).toBe("Unknown error");
|
||||
expect(toDiagnosticErrorMessage(undefined)).toBe("Unknown error");
|
||||
});
|
||||
|
||||
test("returns Unknown error for an Error with no message and no extras", () => {
|
||||
const error = new Error("");
|
||||
expect(toDiagnosticErrorMessage(error)).toBe("Unknown error");
|
||||
});
|
||||
|
||||
test("truncates very long stderr", () => {
|
||||
const long = "x".repeat(5000);
|
||||
const error = new Error("big") as Error & { stderr: string };
|
||||
error.stderr = long;
|
||||
const message = toDiagnosticErrorMessage(error);
|
||||
expect(message).toContain("…(truncated)");
|
||||
expect(message.length).toBeLessThan(long.length + 200);
|
||||
});
|
||||
|
||||
test("returns Unknown error for empty plain object after serialization fallback", () => {
|
||||
expect(toDiagnosticErrorMessage({})).toBe("Unknown error");
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@ export function formatProviderDiagnosticError(providerName: string, error: unkno
|
||||
return formatProviderDiagnostic(providerName, [
|
||||
{
|
||||
label: "Error",
|
||||
value: error instanceof Error ? error.message : String(error),
|
||||
value: toDiagnosticErrorMessage(error),
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -33,16 +33,97 @@ export function formatDiagnosticStatus(
|
||||
return formatAvailabilityStatus(available);
|
||||
}
|
||||
|
||||
export function toDiagnosticErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message;
|
||||
const DIAGNOSTIC_OUTPUT_CAP = 4096;
|
||||
|
||||
function truncateForDiagnostic(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length <= DIAGNOSTIC_OUTPUT_CAP) {
|
||||
return trimmed;
|
||||
}
|
||||
if (typeof error === "string" && error.trim().length > 0) {
|
||||
return error;
|
||||
return `${trimmed.slice(0, DIAGNOSTIC_OUTPUT_CAP)}…(truncated)`;
|
||||
}
|
||||
|
||||
function readStringProperty(error: object, key: string): string | undefined {
|
||||
const value = (error as Record<string, unknown>)[key];
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "bigint") {
|
||||
return String(value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readUnknownProperty(error: object, key: string): unknown {
|
||||
return (error as Record<string, unknown>)[key];
|
||||
}
|
||||
|
||||
function pushIfNonEmpty(sections: string[], label: string, value: string | undefined): void {
|
||||
if (value && value.trim().length > 0) {
|
||||
sections.push(`${label}: ${value.trim()}`);
|
||||
}
|
||||
}
|
||||
|
||||
function pushTruncatedIfNonEmpty(
|
||||
sections: string[],
|
||||
label: string,
|
||||
value: string | undefined,
|
||||
): void {
|
||||
if (value && value.trim().length > 0) {
|
||||
sections.push(`${label}: ${truncateForDiagnostic(value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function formatErrorDiagnostic(error: Error): string {
|
||||
const sections: string[] = [];
|
||||
if (error.message && error.message.trim().length > 0) {
|
||||
sections.push(error.message.trim());
|
||||
}
|
||||
pushIfNonEmpty(sections, "exit code", readStringProperty(error, "code"));
|
||||
pushIfNonEmpty(sections, "signal", readStringProperty(error, "signal"));
|
||||
pushTruncatedIfNonEmpty(sections, "stderr", readStringProperty(error, "stderr"));
|
||||
pushTruncatedIfNonEmpty(sections, "stdout", readStringProperty(error, "stdout"));
|
||||
const cause = readUnknownProperty(error, "cause");
|
||||
if (cause !== undefined && cause !== null) {
|
||||
const causeMessage = toDiagnosticErrorMessage(cause);
|
||||
if (causeMessage && causeMessage !== "Unknown error") {
|
||||
sections.push(`caused by: ${causeMessage}`);
|
||||
}
|
||||
}
|
||||
return sections.length > 0 ? sections.join("\n") : "Unknown error";
|
||||
}
|
||||
|
||||
function formatNonErrorDiagnostic(error: unknown): string {
|
||||
try {
|
||||
const serialized = JSON.stringify(error);
|
||||
if (serialized && serialized !== "{}" && serialized !== '""') {
|
||||
return serialized;
|
||||
}
|
||||
} catch {
|
||||
// fall through to String() below
|
||||
}
|
||||
|
||||
const stringified = String(error);
|
||||
if (stringified.length > 0 && stringified !== "[object Object]") {
|
||||
return stringified;
|
||||
}
|
||||
return "Unknown error";
|
||||
}
|
||||
|
||||
export function toDiagnosticErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return formatErrorDiagnostic(error);
|
||||
}
|
||||
if (typeof error === "string") {
|
||||
const trimmed = error.trim();
|
||||
return trimmed.length > 0 ? trimmed : "Unknown error";
|
||||
}
|
||||
if (error === null || error === undefined) {
|
||||
return "Unknown error";
|
||||
}
|
||||
return formatNonErrorDiagnostic(error);
|
||||
}
|
||||
|
||||
export async function resolveBinaryVersion(binaryPath: string): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await execCommand(binaryPath, ["--version"], {
|
||||
@@ -50,8 +131,8 @@ export async function resolveBinaryVersion(binaryPath: string): Promise<string>
|
||||
timeout: 5_000,
|
||||
});
|
||||
return stdout.trim() || "unknown";
|
||||
} catch {
|
||||
return "unknown";
|
||||
} catch (error) {
|
||||
return `error: ${toDiagnosticErrorMessage(error)}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -225,22 +225,6 @@ function toOpenCodeMcpConfig(config: McpServerConfig): OpenCodeMcpConfig {
|
||||
};
|
||||
}
|
||||
|
||||
function stringifyUnknownError(error: unknown): string {
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(error);
|
||||
} catch {
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTurnFailureError(error: unknown): string {
|
||||
const normalized = stringifyUnknownError(error).trim();
|
||||
return normalized.length > 0 ? normalized : "Unknown error";
|
||||
}
|
||||
|
||||
type TerminalTurnEvent = Extract<
|
||||
AgentStreamEvent,
|
||||
{ type: "turn_completed" | "turn_failed" | "turn_canceled" }
|
||||
@@ -251,7 +235,7 @@ function toTerminalTurnEvent(event: AgentStreamEvent): TerminalTurnEvent | null
|
||||
return {
|
||||
type: "turn_failed",
|
||||
provider: "opencode",
|
||||
error: normalizeTurnFailureError(event.error),
|
||||
error: toDiagnosticErrorMessage(event.error),
|
||||
};
|
||||
}
|
||||
if (event.type === "turn_completed" || event.type === "turn_canceled") {
|
||||
@@ -286,7 +270,7 @@ async function reconcileOpenCodeSessionClose(params: {
|
||||
logger.warn(
|
||||
{
|
||||
sessionId,
|
||||
error: normalizeTurnFailureError(response.error),
|
||||
error: toDiagnosticErrorMessage(response.error),
|
||||
},
|
||||
"Failed to abort OpenCode session during close",
|
||||
);
|
||||
@@ -295,7 +279,7 @@ async function reconcileOpenCodeSessionClose(params: {
|
||||
logger.warn(
|
||||
{
|
||||
sessionId,
|
||||
error: normalizeTurnFailureError(error),
|
||||
error: toDiagnosticErrorMessage(error),
|
||||
},
|
||||
"Failed to abort OpenCode session during close",
|
||||
);
|
||||
@@ -311,7 +295,7 @@ async function reconcileOpenCodeSessionClose(params: {
|
||||
logger.warn(
|
||||
{
|
||||
sessionId,
|
||||
error: normalizeTurnFailureError(response.error),
|
||||
error: toDiagnosticErrorMessage(response.error),
|
||||
},
|
||||
"Failed to archive OpenCode session during close",
|
||||
);
|
||||
@@ -320,7 +304,7 @@ async function reconcileOpenCodeSessionClose(params: {
|
||||
logger.warn(
|
||||
{
|
||||
sessionId,
|
||||
error: normalizeTurnFailureError(error),
|
||||
error: toDiagnosticErrorMessage(error),
|
||||
},
|
||||
"Failed to archive OpenCode session during close",
|
||||
);
|
||||
@@ -345,7 +329,7 @@ function isOpenCodeHeadersTimeoutFailure(error: unknown): boolean {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalized = stringifyUnknownError(current).trim().toLowerCase();
|
||||
const normalized = toDiagnosticErrorMessage(current).trim().toLowerCase();
|
||||
if (normalized) {
|
||||
diagnostics.add(normalized);
|
||||
}
|
||||
@@ -380,7 +364,7 @@ function isOpenCodeHeadersTimeoutFailure(error: unknown): boolean {
|
||||
}
|
||||
|
||||
function isAlreadyPresentMcpError(error: unknown): boolean {
|
||||
const normalized = stringifyUnknownError(error).toLowerCase();
|
||||
const normalized = toDiagnosticErrorMessage(error).toLowerCase();
|
||||
return MCP_ALREADY_PRESENT_ERROR_TOKENS.some((token) => normalized.includes(token));
|
||||
}
|
||||
|
||||
@@ -873,14 +857,37 @@ export class OpenCodeServerManager {
|
||||
);
|
||||
|
||||
let started = false;
|
||||
let stderrBuffer = "";
|
||||
let stdoutBuffer = "";
|
||||
const STARTUP_BUFFER_CAP = 8192;
|
||||
const appendCapped = (current: string, chunk: string): string => {
|
||||
if (current.length >= STARTUP_BUFFER_CAP) {
|
||||
return current;
|
||||
}
|
||||
const remaining = STARTUP_BUFFER_CAP - current.length;
|
||||
return current + chunk.slice(0, remaining);
|
||||
};
|
||||
const buildStartupErrorMessage = (headline: string): string => {
|
||||
const sections = [headline];
|
||||
const stderrTrimmed = stderrBuffer.trim();
|
||||
if (stderrTrimmed.length > 0) {
|
||||
sections.push(`stderr: ${stderrTrimmed}`);
|
||||
}
|
||||
const stdoutTrimmed = stdoutBuffer.trim();
|
||||
if (stdoutTrimmed.length > 0) {
|
||||
sections.push(`stdout: ${stdoutTrimmed}`);
|
||||
}
|
||||
return sections.join("\n");
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
if (!started) {
|
||||
reject(new Error("OpenCode server startup timeout"));
|
||||
reject(new Error(buildStartupErrorMessage("OpenCode server startup timeout")));
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
serverProcess.stdout?.on("data", (data: Buffer) => {
|
||||
const output = data.toString();
|
||||
stdoutBuffer = appendCapped(stdoutBuffer, output);
|
||||
if (output.includes("listening on") && !started) {
|
||||
started = true;
|
||||
clearTimeout(timeout);
|
||||
@@ -895,18 +902,21 @@ export class OpenCodeServerManager {
|
||||
});
|
||||
|
||||
serverProcess.stderr?.on("data", (data: Buffer) => {
|
||||
this.logger.error({ stderr: data.toString().trim() }, "OpenCode server stderr");
|
||||
const output = data.toString();
|
||||
stderrBuffer = appendCapped(stderrBuffer, output);
|
||||
this.logger.error({ stderr: output.trim() }, "OpenCode server stderr");
|
||||
});
|
||||
|
||||
serverProcess.on("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
const headline = error instanceof Error ? error.message : String(error);
|
||||
reject(new Error(buildStartupErrorMessage(headline)));
|
||||
});
|
||||
|
||||
serverProcess.on("exit", (code) => {
|
||||
if (!started) {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(`OpenCode server exited with code ${code}`));
|
||||
reject(new Error(buildStartupErrorMessage(`OpenCode server exited with code ${code}`)));
|
||||
}
|
||||
if (this.currentServer?.process === serverProcess) {
|
||||
this.currentServer = null;
|
||||
@@ -1195,7 +1205,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
const { url } = await this.serverManager.ensureRunning();
|
||||
serverStatus = `Running (${url})`;
|
||||
} catch (error) {
|
||||
serverStatus = `Unavailable (${normalizeTurnFailureError(error)})`;
|
||||
serverStatus = `Unavailable (${toDiagnosticErrorMessage(error)})`;
|
||||
}
|
||||
|
||||
if (available) {
|
||||
@@ -1504,7 +1514,7 @@ export function translateOpenCodeEvent(
|
||||
events.push({
|
||||
type: "turn_failed",
|
||||
provider: "opencode",
|
||||
error: normalizeTurnFailureError(event.properties.error),
|
||||
error: toDiagnosticErrorMessage(event.properties.error),
|
||||
});
|
||||
}
|
||||
break;
|
||||
@@ -1821,7 +1831,7 @@ function appendOpenCodeSessionStatus(
|
||||
events.push({
|
||||
type: "turn_failed",
|
||||
provider: "opencode",
|
||||
error: normalizeTurnFailureError(status.message),
|
||||
error: toDiagnosticErrorMessage(status.message),
|
||||
});
|
||||
}
|
||||
// "retry" and "busy" are transient — no terminal event.
|
||||
@@ -2054,7 +2064,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const errorMsg = normalizeTurnFailureError(response.error);
|
||||
const errorMsg = toDiagnosticErrorMessage(response.error);
|
||||
this.finishForegroundTurn(
|
||||
{ type: "turn_failed", provider: "opencode", error: errorMsg },
|
||||
turnId,
|
||||
@@ -2080,7 +2090,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
return;
|
||||
}
|
||||
this.finishForegroundTurn(
|
||||
{ type: "turn_failed", provider: "opencode", error: normalizeTurnFailureError(err) },
|
||||
{ type: "turn_failed", provider: "opencode", error: toDiagnosticErrorMessage(err) },
|
||||
turnId,
|
||||
);
|
||||
});
|
||||
@@ -2109,7 +2119,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
{
|
||||
type: "turn_failed",
|
||||
provider: "opencode",
|
||||
error: normalizeTurnFailureError(promptResponse.error),
|
||||
error: toDiagnosticErrorMessage(promptResponse.error),
|
||||
},
|
||||
turnId,
|
||||
);
|
||||
@@ -2121,7 +2131,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
{
|
||||
type: "turn_failed",
|
||||
provider: "opencode",
|
||||
error: normalizeTurnFailureError(error),
|
||||
error: toDiagnosticErrorMessage(error),
|
||||
},
|
||||
turnId,
|
||||
);
|
||||
@@ -2186,7 +2196,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
{
|
||||
type: "turn_failed",
|
||||
provider: "opencode",
|
||||
error: normalizeTurnFailureError(error),
|
||||
error: toDiagnosticErrorMessage(error),
|
||||
},
|
||||
turnId,
|
||||
);
|
||||
@@ -2585,7 +2595,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Failed to ${operation} OpenCode MCP server '${name}': ${stringifyUnknownError(error)}`,
|
||||
`Failed to ${operation} OpenCode MCP server '${name}': ${toDiagnosticErrorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -624,13 +624,6 @@ function mapToolDetail(toolCall: PiTrackedToolCall, result?: PiToolResult): Tool
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyUnknownError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return typeof error === "string" ? error : "Unknown Pi error";
|
||||
}
|
||||
|
||||
function parseModelReference(modelId: string | null): PiModelReference | null {
|
||||
if (!modelId) {
|
||||
return null;
|
||||
@@ -756,7 +749,7 @@ function isPiRequestAbortError(error: unknown): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
return /\brequest was aborted\b/i.test(stringifyUnknownError(error));
|
||||
return /\brequest was aborted\b/i.test(toDiagnosticErrorMessage(error));
|
||||
}
|
||||
|
||||
function resolveThinkingOptionId(
|
||||
@@ -1113,7 +1106,7 @@ export class PiDirectAgentSession implements AgentSession {
|
||||
type: "turn_canceled",
|
||||
provider: PI_PROVIDER,
|
||||
turnId: failedTurnId,
|
||||
reason: stringifyUnknownError(error),
|
||||
reason: toDiagnosticErrorMessage(error),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1121,7 +1114,7 @@ export class PiDirectAgentSession implements AgentSession {
|
||||
type: "turn_failed",
|
||||
provider: PI_PROVIDER,
|
||||
turnId: failedTurnId,
|
||||
error: stringifyUnknownError(error),
|
||||
error: toDiagnosticErrorMessage(error),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user