Compare commits

...
Author SHA1 Message Date
Aiden Cline f484949568 fix(core): restore shell tool fallback 2026-08-21 00:49:59 -05:00
4 changed files with 48 additions and 4 deletions
+7 -3
View File
@@ -54,7 +54,7 @@ type Active = {
export interface Interface {
readonly name: () => Effect.Effect<string>
readonly create: <E = never, R = never>(
input: Shell.CreateInput,
input: CreateInput,
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
) => Effect.Effect<Shell.Info, E | AppProcess.AppProcessError, R>
// Currently running commands only; exited shells are retained for get/output but excluded here.
@@ -69,6 +69,10 @@ export interface Interface {
readonly remove: (id: Shell.ID) => Effect.Effect<void, NotFoundError>
}
type CreateInput = Shell.CreateInput & {
readonly shell?: string
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Shell") {}
export const cleanup = Effect.fn("Shell.cleanup")(function* () {
@@ -218,7 +222,7 @@ const layer = () =>
})
const create = Effect.fn("Shell.create")(function* <E = never, R = never>(
input: Shell.CreateInput,
input: CreateInput,
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
) {
const sessionID = input.metadata?.sessionID
@@ -230,7 +234,7 @@ const layer = () =>
command: input.command,
cwd: input.cwd ?? location.directory,
timeout: input.timeout,
shell: yield* shell.preferred(),
shell: input.shell ?? (yield* shell.preferred()),
env: {
...(sessionEnvironment ?? process.env),
TERM: "xterm-256color",
+2
View File
@@ -43,6 +43,7 @@ export type Draft = {
export interface Interface extends State.Transformable<Draft> {
readonly preferred: () => Effect.Effect<string>
readonly acceptable: () => Effect.Effect<string>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ShellSelect") {}
@@ -217,6 +218,7 @@ const layer = (options?: Options) =>
transform: state.transform,
reload: state.reload,
preferred: () => Effect.sync(() => preferred(state.get().shell, options, global.bin)),
acceptable: () => Effect.sync(() => acceptable(state.get().shell, options, global.bin)),
})
}),
)
+4 -1
View File
@@ -13,6 +13,7 @@ import { NonNegativeInt } from "../../schema.js"
import { SessionSchema } from "../../session/schema.js"
import { Shell } from "../../shell.js"
import { ShellParse } from "../../shell/parse.js"
import { ShellSelect } from "../../shell/select.js"
import { ToolOutput } from "../../tool-output.js"
export const name = "shell"
@@ -102,6 +103,7 @@ export const Plugin = {
const environment = yield* Environment.Service
const mutation = yield* LocationMutation.Service
const shell = yield* Shell.Service
const shellSelect = yield* ShellSelect.Service
const permission = yield* Permission.Service
const config = yield* Config.Service
@@ -179,6 +181,7 @@ export const Plugin = {
cwd: input.workdir,
timeout,
metadata: { sessionID: context.sessionID },
shell: yield* shellSelect.acceptable(),
},
(invocation) =>
Effect.gen(function* () {
@@ -343,7 +346,7 @@ export const Plugin = {
Effect.gen(function* () {
const tool = event.tools[name]
if (!tool) return
tool.description = description(yield* shell.name())
tool.description = description(ShellSelect.name(yield* shellSelect.acceptable()))
}),
)
}),
+35
View File
@@ -32,6 +32,7 @@ import { Permission } from "@opencode-ai/core/permission"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Shell } from "@opencode-ai/core/shell"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
import { ShellTool } from "@opencode-ai/core/tool/plugin/shell"
import { ToolOutput } from "@opencode-ai/core/tool-output"
@@ -136,6 +137,7 @@ const shellPluginSupervisor = makeLocationNode({
Permission.node,
PluginRuntime.node,
Shell.node,
ShellSelect.node,
Tool.node,
],
})
@@ -167,6 +169,7 @@ const call = (input: typeof ShellTool.Input.Type, id = "call-shell") => ({
})
const isWindows = process.platform === "win32"
const terminalOnlyIt = isWindows ? productionIt.live.skip : productionIt.live
const cwdCommand = isWindows ? "(Get-Location).Path; Start-Sleep -Milliseconds 100" : "pwd"
const helloCommand = isWindows ? "[Console]::Out.Write('hello'); Start-Sleep -Milliseconds 100" : "printf hello"
const stderrCommand = isWindows
@@ -214,6 +217,38 @@ const withSession = <A, E, R>(directory: string, body: (registry: Tool.Interface
})
describe("ShellTool", () => {
terminalOnlyIt("falls back from a terminal-only shell", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const shellSelect = yield* ShellSelect.Service
const shell = yield* Shell.Service
const configured = path.join(tmp.path, "fish")
const preferred = yield* shellSelect.preferred()
yield* Effect.promise(() => fs.symlink(preferred, configured))
yield* shellSelect.transform((draft) => draft.configure(configured))
const acceptable = yield* shellSelect.acceptable()
expect(yield* shellSelect.preferred()).toBe(configured)
expect(acceptable).not.toBe(configured)
const progress: Tool.Metadata[] = []
yield* executeTool(registry, {
...call({ command: "printf tool" }),
progress: (update) => Effect.sync(() => progress.push(update)),
})
const shellID = progress[0]?.shellID
if (typeof shellID !== "string") yield* Effect.die(new Error("Missing shell ID"))
expect((yield* shell.get(ShellSchema.ID.make(shellID))).shell).toBe(acceptable)
}),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
productionIt.live(
"registers and returns real successful output from the active Location",
() =>