diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index 1e4423e01774..1674695938ae 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -444,8 +444,6 @@ export const ShellTool = Tool.define( let file = "" let sink: ReturnType | undefined let cut = false - let expired = false - let aborted = false const closeSink = Effect.fnUntraced(function* () { const stream = sink @@ -478,7 +476,7 @@ export const ShellTool = Tool.define( }, }) - const code: number | null = yield* Effect.scoped( + const exit = yield* Effect.scoped( Effect.gen(function* () { yield* Effect.addFinalizer(closeSink) const handle = yield* spawner.spawn(cmd(input.shell, input.command, input.cwd, input.env)) @@ -540,31 +538,35 @@ export const ShellTool = Tool.define( const timeout = Effect.sleep(`${input.timeout + 100} millis`) const exit = yield* Effect.raceAll([ - handle.exitCode.pipe(Effect.map((code) => ({ kind: "exit" as const, code }))), + // Signal termination fails exitCode; raceAll only settles on success. + handle.exitCode.pipe( + Effect.match({ + onSuccess: (code: number) => ({ kind: "exit" as const, code }), + onFailure: (error) => ({ kind: "error" as const, code: null, error }), + }), + ), abort.pipe(Effect.map(() => ({ kind: "abort" as const, code: null }))), timeout.pipe(Effect.map(() => ({ kind: "timeout" as const, code: null }))), ]) - if (exit.kind === "abort") { - aborted = true - yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie) - } - if (exit.kind === "timeout") { - expired = true + if (exit.kind === "abort" || exit.kind === "timeout") { yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie) } - return exit.kind === "exit" ? exit.code : null + return exit }), ).pipe(Effect.orDie) const meta: string[] = [] - if (expired) { + if (exit.kind === "timeout") { meta.push( `shell tool terminated command after exceeding timeout ${input.timeout} ms. If this command is expected to take longer and is not waiting for interactive input, retry with a larger timeout value in milliseconds.`, ) } - if (aborted) meta.push("User aborted the command") + if (exit.kind === "abort") meta.push("User aborted the command") + if (exit.kind === "error") { + meta.push(exit.error.cause instanceof Error ? exit.error.cause.message : exit.error.message) + } const raw = list.map((item) => item.text).join("") const end = tail(raw, limits.maxLines, limits.maxBytes) if (end.cut) cut = true @@ -585,8 +587,8 @@ export const ShellTool = Tool.define( return { title: input.command, metadata: { - output: last || preview(output), - exit: code, + output: exit.kind === "error" ? preview(output) : last || preview(output), + exit: exit.code, truncated: cut, ...(cut && file ? { outputPath: file } : {}), }, diff --git a/packages/opencode/test/cli/run/run-process.test.ts b/packages/opencode/test/cli/run/run-process.test.ts index c6ccdc80cd41..fdb8ad5ed166 100644 --- a/packages/opencode/test/cli/run/run-process.test.ts +++ b/packages/opencode/test/cli/run/run-process.test.ts @@ -214,6 +214,43 @@ describe("opencode run (non-interactive subprocess)", () => { 60_000, ) + if (process.platform !== "win32") { + cliIt.live( + "--format json preserves signal termination output and continues", + ({ llm, opencode }) => + Effect.gen(function* () { + yield* llm.push( + reply().tool("bash", { + command: "printf 'before-signal\\n'; kill -TERM $$", + description: "Terminate the tool shell with SIGTERM", + timeout: 10_000, + }), + ) + yield* llm.text("continued after signal") + + const result = yield* opencode.run("exercise signal termination", { + format: "json", + permission: { bash: "allow" }, + }) + + opencode.expectExit(result, 0) + const events = opencode.parseJsonEvents(result.stdout) + expect(events.find((event) => event.type === "tool_use")?.part).toMatchObject({ + tool: "bash", + state: { + status: "completed", + output: expect.stringMatching(/before-signal[\s\S]*SIGTERM/), + metadata: { exit: null, output: expect.stringContaining("SIGTERM") }, + }, + }) + expect(result.stdout).toContain("continued after signal") + expect(result.stdout).not.toContain("ChildProcess.kill") + expect(result.stdout).not.toContain("exceeding timeout") + }), + 60_000, + ) + } + cliIt.concurrent( "--format json records an unknown stream finish and continuation", ({ llm, opencode }) => diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index 02c31ad81531..951de1ec88ee 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -1092,6 +1092,34 @@ describe("tool.shell abort", () => { ) } + if (process.platform !== "win32") { + for (const signal of ["TERM", "SEGV", "KILL"]) { + it.live(`reports SIG${signal} without waiting for the command timeout`, () => + runIn( + projectRoot, + Effect.gen(function* () { + const result = yield* run({ + command: `ulimit -c 0; printf 'before-stdout\\n'; printf 'before-stderr\\n' >&2; kill -${signal} $$`, + timeout: 10_000, + }).pipe( + Effect.timeoutOrElse({ + duration: "3 seconds", + orElse: () => Effect.die(new Error("Signal termination was not reported promptly")), + }), + ) + expect(result.output).toContain("before-stdout") + expect(result.output).toContain("before-stderr") + expect(result.output).toContain(`SIG${signal}`) + expect(result.metadata.output).toBe(result.output) + expect(result.output).not.toContain("exceeding timeout") + expect(result.output).not.toContain("User aborted") + expect(result.metadata.exit).toBeNull() + }), + ), + ) + } + } + it.live("returns non-zero exit code", () => runIn( projectRoot,