Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 17 additions & 15 deletions packages/opencode/src/tool/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,8 +444,6 @@ export const ShellTool = Tool.define(
let file = ""
let sink: ReturnType<typeof createWriteStream> | undefined
let cut = false
let expired = false
let aborted = false

const closeSink = Effect.fnUntraced(function* () {
const stream = sink
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand All @@ -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 } : {}),
},
Expand Down
37 changes: 37 additions & 0 deletions packages/opencode/test/cli/run/run-process.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) =>
Expand Down
28 changes: 28 additions & 0 deletions packages/opencode/test/tool/shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading