mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-21 13:03:31 +00:00
fix(core): preserve background shell status (#43663)
This commit is contained in:
parent
2970b7a6a8
commit
c33c9bf2b9
2 changed files with 105 additions and 44 deletions
|
|
@ -2,7 +2,6 @@ export * as ShellTool from "./shell.js"
|
|||
|
||||
import path from "path"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import type { Content } from "@opencode-ai/schema/tool"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Deferred, Effect, Schema, Scope } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
|
|
@ -72,10 +71,27 @@ const Output = Schema.Struct({
|
|||
|
||||
type Output = typeof Output.Type
|
||||
|
||||
const modelOutput = (output: Output): string | undefined => {
|
||||
if (output.status === "running") return BACKGROUND_INSTRUCTION
|
||||
if (output.timeout) return "Command timed out before completion."
|
||||
return `Command exited with code ${output.exit}.`
|
||||
const resultMessages = (output: Output) => {
|
||||
const notice = (() => {
|
||||
if (output.status === "running") return BACKGROUND_INSTRUCTION
|
||||
if (output.timeout) return "Command timed out before completion."
|
||||
if (output.exit !== undefined) return `Command exited with code ${output.exit}.`
|
||||
})()
|
||||
return [output.output, ...(notice ? [notice] : [])]
|
||||
}
|
||||
|
||||
const toolResult = (output: Output) => {
|
||||
return {
|
||||
output,
|
||||
content: resultMessages(output).map((text) => ({ type: "text" as const, text })),
|
||||
metadata: {
|
||||
status: output.status,
|
||||
truncated: output.truncated,
|
||||
...(output.exit !== undefined ? { exit: output.exit } : {}),
|
||||
...(output.shellID !== undefined ? { shellID: output.shellID } : {}),
|
||||
...(output.timeout !== undefined ? { timeout: output.timeout } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const Plugin = {
|
||||
|
|
@ -92,32 +108,50 @@ export const Plugin = {
|
|||
const notifyWhenDone = Effect.fn("ShellTool.notifyWhenDone")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
id: string,
|
||||
shellID: string,
|
||||
command: string,
|
||||
settled: Deferred.Deferred<Output>,
|
||||
) {
|
||||
yield* runtime.job.wait({ id: id }).pipe(
|
||||
Effect.flatMap((result) => {
|
||||
const state =
|
||||
result.info?.status === "completed"
|
||||
? "completed"
|
||||
: result.info?.status === "error"
|
||||
? "error"
|
||||
: result.info?.status === "cancelled"
|
||||
? "cancelled"
|
||||
: undefined
|
||||
if (state === undefined) return Effect.void
|
||||
const text =
|
||||
state === "completed"
|
||||
? (result.info!.output ?? "")
|
||||
Effect.flatMap((result) =>
|
||||
Effect.gen(function* () {
|
||||
const info = result.info
|
||||
if (!info) return
|
||||
const state =
|
||||
info.status === "completed"
|
||||
? "completed"
|
||||
: info.status === "error"
|
||||
? "error"
|
||||
: info.status === "cancelled"
|
||||
? "cancelled"
|
||||
: undefined
|
||||
if (state === undefined) return
|
||||
const output = state === "completed" ? yield* Deferred.await(settled) : undefined
|
||||
const text = output
|
||||
? resultMessages(output).join("\n\n")
|
||||
: state === "error"
|
||||
? (result.info!.error ?? "Command failed")
|
||||
? (info.error ?? "Command failed")
|
||||
: "Command cancelled"
|
||||
return runtime.session.synthetic({
|
||||
sessionID,
|
||||
text: `<shell id="${id}" state="${state}" command="${command}">\n${text}\n</shell>`,
|
||||
description: command,
|
||||
metadata: { source: "shell", jobID: id, state },
|
||||
})
|
||||
}),
|
||||
yield* runtime.session.synthetic({
|
||||
sessionID,
|
||||
text: `<shell id="${id}" state="${state}" command="${command}">\n${text}\n</shell>`,
|
||||
description: command,
|
||||
metadata: {
|
||||
source: "shell",
|
||||
jobID: id,
|
||||
shellID,
|
||||
state,
|
||||
...(output
|
||||
? {
|
||||
truncated: output.truncated,
|
||||
...(output.exit !== undefined ? { exit: output.exit } : {}),
|
||||
...(output.timeout !== undefined ? { timeout: output.timeout } : {}),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
})
|
||||
}),
|
||||
),
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
)
|
||||
})
|
||||
|
|
@ -268,7 +302,7 @@ export const Plugin = {
|
|||
|
||||
if (input.background === true) {
|
||||
yield* runtime.job.background(job.id)
|
||||
yield* notifyWhenDone(context.sessionID, context.id, info.command)
|
||||
yield* notifyWhenDone(context.sessionID, context.id, info.id, info.command, settled)
|
||||
return {
|
||||
output: BACKGROUND_STARTED,
|
||||
shellID: info.id,
|
||||
|
|
@ -282,7 +316,7 @@ export const Plugin = {
|
|||
.pipe(Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)))
|
||||
if (result?.type === "backgrounded") {
|
||||
yield* shell.timeout(info.id, 0)
|
||||
yield* notifyWhenDone(context.sessionID, context.id, info.command)
|
||||
yield* notifyWhenDone(context.sessionID, context.id, info.id, info.command, settled)
|
||||
return {
|
||||
output: BACKGROUND_STARTED,
|
||||
shellID: info.id,
|
||||
|
|
@ -296,22 +330,7 @@ export const Plugin = {
|
|||
|
||||
return yield* Deferred.await(settled)
|
||||
}).pipe(
|
||||
Effect.map((output) => {
|
||||
const content: Array<Content> = [{ type: "text", text: output.output }]
|
||||
const model = modelOutput(output)
|
||||
if (model) content.push({ type: "text", text: model })
|
||||
return {
|
||||
output,
|
||||
content,
|
||||
metadata: {
|
||||
status: output.status,
|
||||
truncated: output.truncated,
|
||||
...("exit" in output && output.exit !== undefined ? { exit: output.exit } : {}),
|
||||
...("shellID" in output && output.shellID !== undefined ? { shellID: output.shellID } : {}),
|
||||
...("timeout" in output && output.timeout !== undefined ? { timeout: output.timeout } : {}),
|
||||
},
|
||||
}
|
||||
}),
|
||||
Effect.map(toolResult),
|
||||
Effect.mapError(
|
||||
(error) => new ToolFailure({ message: `Unable to execute command: ${input.command}`, error }),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -760,10 +760,52 @@ describe("ShellTool", () => {
|
|||
expect((yield* shell.list()).map((info) => info.id)).toContain(id)
|
||||
expect((yield* shell.wait(id)).status).toBe("timeout")
|
||||
expect((yield* Fiber.join(admitted)).valueOrUndefined?.data.item.payload).toMatchObject({
|
||||
text: expect.stringContaining("Command timed out before completion."),
|
||||
description: idleCommand,
|
||||
metadata: {
|
||||
source: "shell",
|
||||
shellID,
|
||||
state: "completed",
|
||||
timeout: true,
|
||||
truncated: false,
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("preserves a background command's non-zero exit", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const admitted = yield* bus.subscribe(SessionEvent.InboxEnqueued).pipe(
|
||||
Stream.filter((event) => event.data.sessionID === sessionID && event.data.item.type === "synthetic"),
|
||||
Stream.runHead,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const settled = yield* executeTool(
|
||||
registry,
|
||||
call({ command: bodyExitCommand, background: true }, "call-background-nonzero"),
|
||||
)
|
||||
const shellID = settled.metadata?.shellID
|
||||
expect(typeof shellID).toBe("string")
|
||||
expect((yield* Fiber.join(admitted)).valueOrUndefined?.data.item.payload).toMatchObject({
|
||||
text: expect.stringContaining("Command exited with code 7."),
|
||||
description: bodyExitCommand,
|
||||
metadata: {
|
||||
source: "shell",
|
||||
jobID: "call-background-nonzero",
|
||||
shellID,
|
||||
state: "completed",
|
||||
exit: 7,
|
||||
truncated: false,
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue