refactor(shell): use Effect ChildProcess for shell command execution (#20494)
This commit is contained in:
@@ -2,7 +2,6 @@ import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { sessionIDFromUrl } from "../actions"
|
import { sessionIDFromUrl } from "../actions"
|
||||||
import { promptSelector } from "../selectors"
|
import { promptSelector } from "../selectors"
|
||||||
import { createSdk } from "../utils"
|
|
||||||
|
|
||||||
const isBash = (part: unknown): part is ToolPart => {
|
const isBash = (part: unknown): part is ToolPart => {
|
||||||
if (!part || typeof part !== "object") return false
|
if (!part || typeof part !== "object") return false
|
||||||
@@ -14,10 +13,9 @@ const isBash = (part: unknown): part is ToolPart => {
|
|||||||
test("shell mode runs a command in the project directory", async ({ page, withProject }) => {
|
test("shell mode runs a command in the project directory", async ({ page, withProject }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
await withProject(async ({ directory, gotoSession, trackSession }) => {
|
await withProject(async ({ directory, gotoSession, trackSession, sdk }) => {
|
||||||
const sdk = createSdk(directory)
|
|
||||||
const prompt = page.locator(promptSelector)
|
const prompt = page.locator(promptSelector)
|
||||||
const cmd = process.platform === "win32" ? "dir" : "ls"
|
const cmd = process.platform === "win32" ? "dir" : "command ls"
|
||||||
|
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
await prompt.click()
|
await prompt.click()
|
||||||
|
|||||||
@@ -386,9 +386,17 @@ export const make = Effect.gen(function* () {
|
|||||||
if (code !== 0 && Predicate.isNotNull(code)) return yield* Effect.ignore(kill(killGroup))
|
if (code !== 0 && Predicate.isNotNull(code)) return yield* Effect.ignore(kill(killGroup))
|
||||||
return yield* Effect.void
|
return yield* Effect.void
|
||||||
}
|
}
|
||||||
return yield* kill((command, proc, signal) =>
|
const send = (s: NodeJS.Signals) =>
|
||||||
Effect.catch(killGroup(command, proc, signal), () => killOne(command, proc, signal)),
|
Effect.catch(killGroup(command, proc, s), () => killOne(command, proc, s))
|
||||||
).pipe(Effect.andThen(Deferred.await(signal)), Effect.ignore)
|
const sig = command.options.killSignal ?? "SIGTERM"
|
||||||
|
const attempt = send(sig).pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid)
|
||||||
|
const escalated = command.options.forceKillAfter
|
||||||
|
? Effect.timeoutOrElse(attempt, {
|
||||||
|
duration: command.options.forceKillAfter,
|
||||||
|
orElse: () => send("SIGKILL").pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid),
|
||||||
|
})
|
||||||
|
: attempt
|
||||||
|
return yield* Effect.ignore(escalated)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -413,14 +421,17 @@ export const make = Effect.gen(function* () {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
kill: (opts?: ChildProcess.KillOptions) =>
|
kill: (opts?: ChildProcess.KillOptions) => {
|
||||||
timeout(
|
const sig = opts?.killSignal ?? "SIGTERM"
|
||||||
proc,
|
const send = (s: NodeJS.Signals) =>
|
||||||
command,
|
Effect.catch(killGroup(command, proc, s), () => killOne(command, proc, s))
|
||||||
opts,
|
const attempt = send(sig).pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid)
|
||||||
)((command, proc, signal) =>
|
if (!opts?.forceKillAfter) return attempt
|
||||||
Effect.catch(killGroup(command, proc, signal), () => killOne(command, proc, signal)),
|
return Effect.timeoutOrElse(attempt, {
|
||||||
).pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid),
|
duration: opts.forceKillAfter,
|
||||||
|
orElse: () => send("SIGKILL").pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid),
|
||||||
|
})
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
case "PipedCommand": {
|
case "PipedCommand": {
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ import { ReadTool } from "../tool/read"
|
|||||||
import { FileTime } from "../file/time"
|
import { FileTime } from "../file/time"
|
||||||
import { Flag } from "../flag/flag"
|
import { Flag } from "../flag/flag"
|
||||||
import { ulid } from "ulid"
|
import { ulid } from "ulid"
|
||||||
import { spawn } from "child_process"
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||||
|
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||||
|
import * as Stream from "effect/Stream"
|
||||||
import { Command } from "../command"
|
import { Command } from "../command"
|
||||||
import { pathToFileURL, fileURLToPath } from "url"
|
import { pathToFileURL, fileURLToPath } from "url"
|
||||||
import { ConfigMarkdown } from "../config/markdown"
|
import { ConfigMarkdown } from "../config/markdown"
|
||||||
@@ -96,6 +98,7 @@ export namespace SessionPrompt {
|
|||||||
const filetime = yield* FileTime.Service
|
const filetime = yield* FileTime.Service
|
||||||
const registry = yield* ToolRegistry.Service
|
const registry = yield* ToolRegistry.Service
|
||||||
const truncate = yield* Truncate.Service
|
const truncate = yield* Truncate.Service
|
||||||
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||||
const scope = yield* Scope.Scope
|
const scope = yield* Scope.Scope
|
||||||
|
|
||||||
const state = yield* InstanceState.make(
|
const state = yield* InstanceState.make(
|
||||||
@@ -809,22 +812,26 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
fish: { args: ["-c", input.command] },
|
fish: { args: ["-c", input.command] },
|
||||||
zsh: {
|
zsh: {
|
||||||
args: [
|
args: [
|
||||||
"-c",
|
|
||||||
"-l",
|
"-l",
|
||||||
|
"-c",
|
||||||
`
|
`
|
||||||
|
__oc_cwd=$PWD
|
||||||
[[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true
|
[[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true
|
||||||
[[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true
|
[[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true
|
||||||
|
cd "$__oc_cwd"
|
||||||
eval ${JSON.stringify(input.command)}
|
eval ${JSON.stringify(input.command)}
|
||||||
`,
|
`,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
bash: {
|
bash: {
|
||||||
args: [
|
args: [
|
||||||
"-c",
|
|
||||||
"-l",
|
"-l",
|
||||||
|
"-c",
|
||||||
`
|
`
|
||||||
|
__oc_cwd=$PWD
|
||||||
shopt -s expand_aliases
|
shopt -s expand_aliases
|
||||||
[[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true
|
[[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true
|
||||||
|
cd "$__oc_cwd"
|
||||||
eval ${JSON.stringify(input.command)}
|
eval ${JSON.stringify(input.command)}
|
||||||
`,
|
`,
|
||||||
],
|
],
|
||||||
@@ -832,7 +839,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
cmd: { args: ["/c", input.command] },
|
cmd: { args: ["/c", input.command] },
|
||||||
powershell: { args: ["-NoProfile", "-Command", input.command] },
|
powershell: { args: ["-NoProfile", "-Command", input.command] },
|
||||||
pwsh: { args: ["-NoProfile", "-Command", input.command] },
|
pwsh: { args: ["-NoProfile", "-Command", input.command] },
|
||||||
"": { args: ["-c", `${input.command}`] },
|
"": { args: ["-c", input.command] },
|
||||||
}
|
}
|
||||||
|
|
||||||
const args = (invocations[shellName] ?? invocations[""]).args
|
const args = (invocations[shellName] ?? invocations[""]).args
|
||||||
@@ -842,51 +849,20 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
{ cwd, sessionID: input.sessionID, callID: part.callID },
|
{ cwd, sessionID: input.sessionID, callID: part.callID },
|
||||||
{ env: {} },
|
{ env: {} },
|
||||||
)
|
)
|
||||||
const proc = yield* Effect.sync(() =>
|
|
||||||
spawn(sh, args, {
|
const cmd = ChildProcess.make(sh, args, {
|
||||||
cwd,
|
cwd,
|
||||||
detached: process.platform !== "win32",
|
extendEnv: true,
|
||||||
windowsHide: process.platform === "win32",
|
env: { ...shellEnv.env, TERM: "dumb" },
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdin: "ignore",
|
||||||
env: {
|
forceKillAfter: "3 seconds",
|
||||||
...process.env,
|
})
|
||||||
...shellEnv.env,
|
|
||||||
TERM: "dumb",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
let output = ""
|
let output = ""
|
||||||
const write = () => {
|
|
||||||
if (part.state.status !== "running") return
|
|
||||||
part.state.metadata = { output, description: "" }
|
|
||||||
void Effect.runFork(sessions.updatePart(part))
|
|
||||||
}
|
|
||||||
|
|
||||||
proc.stdout?.on("data", (chunk) => {
|
|
||||||
output += chunk.toString()
|
|
||||||
write()
|
|
||||||
})
|
|
||||||
proc.stderr?.on("data", (chunk) => {
|
|
||||||
output += chunk.toString()
|
|
||||||
write()
|
|
||||||
})
|
|
||||||
|
|
||||||
let aborted = false
|
let aborted = false
|
||||||
let exited = false
|
|
||||||
let finished = false
|
|
||||||
const kill = Effect.promise(() => Shell.killTree(proc, { exited: () => exited }))
|
|
||||||
|
|
||||||
const abortHandler = () => {
|
|
||||||
if (aborted) return
|
|
||||||
aborted = true
|
|
||||||
void Effect.runFork(kill)
|
|
||||||
}
|
|
||||||
|
|
||||||
const finish = Effect.uninterruptible(
|
const finish = Effect.uninterruptible(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (finished) return
|
|
||||||
finished = true
|
|
||||||
if (aborted) {
|
if (aborted) {
|
||||||
output += "\n\n" + ["<metadata>", "User aborted the command", "</metadata>"].join("\n")
|
output += "\n\n" + ["<metadata>", "User aborted the command", "</metadata>"].join("\n")
|
||||||
}
|
}
|
||||||
@@ -908,20 +884,26 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const exit = yield* Effect.promise(() => {
|
const exit = yield* Effect.gen(function* () {
|
||||||
signal.addEventListener("abort", abortHandler, { once: true })
|
const handle = yield* spawner.spawn(cmd)
|
||||||
if (signal.aborted) abortHandler()
|
yield* Stream.runForEach(Stream.decodeText(handle.all), (chunk) =>
|
||||||
return new Promise<void>((resolve) => {
|
Effect.sync(() => {
|
||||||
const close = () => {
|
output += chunk
|
||||||
exited = true
|
if (part.state.status === "running") {
|
||||||
proc.off("close", close)
|
part.state.metadata = { output, description: "" }
|
||||||
resolve()
|
void Effect.runFork(sessions.updatePart(part))
|
||||||
}
|
}
|
||||||
proc.once("close", close)
|
}),
|
||||||
})
|
)
|
||||||
|
yield* handle.exitCode
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.onInterrupt(() => Effect.sync(abortHandler)),
|
Effect.scoped,
|
||||||
Effect.ensuring(Effect.sync(() => signal.removeEventListener("abort", abortHandler))),
|
Effect.onInterrupt(() =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
aborted = true
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Effect.orDie,
|
||||||
Effect.ensuring(finish),
|
Effect.ensuring(finish),
|
||||||
Effect.exit,
|
Effect.exit,
|
||||||
)
|
)
|
||||||
@@ -1735,6 +1717,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
Layer.provide(Session.defaultLayer),
|
Layer.provide(Session.defaultLayer),
|
||||||
Layer.provide(Agent.defaultLayer),
|
Layer.provide(Agent.defaultLayer),
|
||||||
Layer.provide(Bus.layer),
|
Layer.provide(Bus.layer),
|
||||||
|
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NodeFileSystem } from "@effect/platform-node"
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
import { expect, spyOn } from "bun:test"
|
import { expect, spyOn } from "bun:test"
|
||||||
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
|
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
|
||||||
|
import path from "path"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import type { Agent } from "../../src/agent/agent"
|
import type { Agent } from "../../src/agent/agent"
|
||||||
import { Agent as AgentSvc } from "../../src/agent/agent"
|
import { Agent as AgentSvc } from "../../src/agent/agent"
|
||||||
@@ -887,6 +888,79 @@ unix("shell captures stdout and stderr in completed tool output", () =>
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
unix("shell completes a fast command on the preferred shell", () =>
|
||||||
|
provideTmpdirInstance(
|
||||||
|
(dir) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const { prompt, chat } = yield* boot()
|
||||||
|
const result = yield* prompt.shell({
|
||||||
|
sessionID: chat.id,
|
||||||
|
agent: "build",
|
||||||
|
command: "pwd",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.info.role).toBe("assistant")
|
||||||
|
const tool = completedTool(result.parts)
|
||||||
|
if (!tool) return
|
||||||
|
|
||||||
|
expect(tool.state.input.command).toBe("pwd")
|
||||||
|
expect(tool.state.output).toContain(dir)
|
||||||
|
expect(tool.state.metadata.output).toContain(dir)
|
||||||
|
yield* prompt.assertNotBusy(chat.id)
|
||||||
|
}),
|
||||||
|
{ git: true, config: cfg },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
unix("shell lists files from the project directory", () =>
|
||||||
|
provideTmpdirInstance(
|
||||||
|
(dir) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const { prompt, chat } = yield* boot()
|
||||||
|
yield* Effect.promise(() => Bun.write(path.join(dir, "README.md"), "# e2e\n"))
|
||||||
|
|
||||||
|
const result = yield* prompt.shell({
|
||||||
|
sessionID: chat.id,
|
||||||
|
agent: "build",
|
||||||
|
command: "command ls",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.info.role).toBe("assistant")
|
||||||
|
const tool = completedTool(result.parts)
|
||||||
|
if (!tool) return
|
||||||
|
|
||||||
|
expect(tool.state.input.command).toBe("command ls")
|
||||||
|
expect(tool.state.output).toContain("README.md")
|
||||||
|
expect(tool.state.metadata.output).toContain("README.md")
|
||||||
|
yield* prompt.assertNotBusy(chat.id)
|
||||||
|
}),
|
||||||
|
{ git: true, config: cfg },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
unix("shell captures stderr from a failing command", () =>
|
||||||
|
provideTmpdirInstance(
|
||||||
|
(dir) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const { prompt, chat } = yield* boot()
|
||||||
|
const result = yield* prompt.shell({
|
||||||
|
sessionID: chat.id,
|
||||||
|
agent: "build",
|
||||||
|
command: "command -v __nonexistent_cmd_e2e__ || echo 'not found' >&2; exit 1",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.info.role).toBe("assistant")
|
||||||
|
const tool = completedTool(result.parts)
|
||||||
|
if (!tool) return
|
||||||
|
|
||||||
|
expect(tool.state.output).toContain("not found")
|
||||||
|
expect(tool.state.metadata.output).toContain("not found")
|
||||||
|
yield* prompt.assertNotBusy(chat.id)
|
||||||
|
}),
|
||||||
|
{ git: true, config: cfg },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
unix(
|
unix(
|
||||||
"shell updates running metadata before process exit",
|
"shell updates running metadata before process exit",
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
Reference in New Issue
Block a user