diff --git a/src/tui/tui-local-shell.test.ts b/src/tui/tui-local-shell.test.ts index d951e9c595c5..5413cbfbda4d 100644 --- a/src/tui/tui-local-shell.test.ts +++ b/src/tui/tui-local-shell.test.ts @@ -1,5 +1,7 @@ // Verifies local shell process handling for TUI local mode. +import { spawn } from "node:child_process"; import { EventEmitter } from "node:events"; +import { join } from "node:path"; import type { OverlayHandle } from "@earendil-works/pi-tui"; import { describe, expect, it, vi } from "vitest"; import { createLocalShellRunner } from "./tui-local-shell.js"; @@ -242,6 +244,28 @@ describe("createLocalShellRunner", () => { ); }); + it("finishes a failed child before reporting the next local command", async () => { + const harness = createShellHarness({ + spawnCommand: spawn, + getCwd: vi + .fn(() => process.cwd()) + .mockReturnValueOnce(join(process.cwd(), ".missing-openclaw-local-shell-directory")), + }); + + const failedRun = harness.runLocalShellLine("!echo first"); + harness.getLastSelector()?.onSelect?.({ value: "yes", label: "Yes" }); + await failedRun; + await harness.runLocalShellLine("!echo second"); + + expect(harness.messages.filter((message) => message.startsWith("[local]"))).toEqual([ + "[local] $ echo first", + expect.stringContaining("[local] error: "), + "[local] $ echo second", + "[local] second", + "[local] exit 0", + ]); + }); + it("does not crash when stdout or stderr emit an error event", async () => { const stdout = new EventEmitter(); const stderr = new EventEmitter(); diff --git a/src/tui/tui-local-shell.ts b/src/tui/tui-local-shell.ts index 2fc98df4d6e0..7269337f8ea1 100644 --- a/src/tui/tui-local-shell.ts +++ b/src/tui/tui-local-shell.ts @@ -131,9 +131,10 @@ export function createLocalShellRunner(deps: LocalShellDeps) { let stdout = ""; let stderr = ""; + let error: Error | undefined; const stdoutDecoder = new StringDecoder("utf8"); const stderrDecoder = new StringDecoder("utf8"); - // Output pipes may fail independently; child close/error remains authoritative. + // Pipe errors are incidental; close owns completion after any recorded spawn error. const ignoreOutputStreamError = () => {}; child.stdout.on("error", ignoreOutputStreamError); child.stderr.on("error", ignoreOutputStreamError); @@ -160,15 +161,14 @@ export function createLocalShellRunner(deps: LocalShellDeps) { deps.chatLog.addSystem(`[local] ${lineLocal}`); } } - deps.chatLog.addSystem(`[local] exit ${code ?? "?"}${signal ? ` (signal ${signal})` : ""}`); + const status = error ? `error: ${formatTuiErrorMessage(error)}` : `exit ${code ?? "?"}`; + deps.chatLog.addSystem(`[local] ${status}${signal ? ` (signal ${signal})` : ""}`); deps.tui.requestRender(); resolve(); }); child.on("error", (err) => { - deps.chatLog.addSystem(`[local] error: ${formatTuiErrorMessage(err)}`); - deps.tui.requestRender(); - resolve(); + error = err; }); }); };