Apply PR #20039: feat: bash->shell tool + pwsh/powershell/cmd/bash specific tool definitions so agents work better

This commit is contained in:
opencode-agent[bot] 2026-04-27 20:46:10 +00:00
commit af8ea50ab2
60 changed files with 885 additions and 347 deletions

View file

@ -431,7 +431,7 @@ describe("acp.agent event subscription", () => {
properties: {
id: "perm_1",
sessionID: sessionA,
permission: "bash",
permission: "shell",
patterns: ["*"],
metadata: {},
always: [],
@ -490,7 +490,7 @@ describe("acp.agent event subscription", () => {
properties: {
id: "perm_a",
sessionID: sessionA,
permission: "bash",
permission: "shell",
patterns: ["*"],
metadata: {},
always: [],
@ -549,7 +549,7 @@ describe("acp.agent event subscription", () => {
controller.push(
toolEvent(sessionId, cwd, {
callID: "call_1",
tool: "bash",
tool: "shell",
status: "running",
input,
metadata: { output },
@ -581,7 +581,7 @@ describe("acp.agent event subscription", () => {
controller.push(
toolEvent(sessionId, cwd, {
callID: "call_bash",
tool: "bash",
tool: "shell",
status: "running",
input: { command: "echo hi", description: "run command" },
metadata: { output: "hi\n" },
@ -635,7 +635,7 @@ describe("acp.agent event subscription", () => {
{
type: "tool",
callID: "call_1",
tool: "bash",
tool: "shell",
state: {
status: "running",
input,
@ -652,7 +652,7 @@ describe("acp.agent event subscription", () => {
controller.push(
toolEvent(sessionId, cwd, {
callID: "call_1",
tool: "bash",
tool: "shell",
status: "running",
input,
metadata: { output: "hi\nthere\n" },
@ -686,7 +686,7 @@ describe("acp.agent event subscription", () => {
controller.push(
toolEvent(sessionId, cwd, {
callID: "call_1",
tool: "bash",
tool: "shell",
status: "running",
input,
metadata: { output: "a" },
@ -695,7 +695,7 @@ describe("acp.agent event subscription", () => {
controller.push(
toolEvent(sessionId, cwd, {
callID: "call_1",
tool: "bash",
tool: "shell",
status: "pending",
input,
raw: '{"command":"echo hello"}',
@ -704,7 +704,7 @@ describe("acp.agent event subscription", () => {
controller.push(
toolEvent(sessionId, cwd, {
callID: "call_1",
tool: "bash",
tool: "shell",
status: "running",
input,
metadata: { output: "a" },

View file

@ -172,7 +172,7 @@ describe("transcript", () => {
messageID: "msg_123",
type: "tool",
callID: "call_1",
tool: "bash",
tool: "shell",
state: {
status: "completed",
input: { command: "ls" },
@ -183,7 +183,7 @@ describe("transcript", () => {
},
}
const result = formatPart(part, options)
expect(result).toContain("**Tool: bash**")
expect(result).toContain("**Tool: shell**")
expect(result).toContain("**Input:**")
expect(result).toContain('"command": "ls"')
expect(result).toContain("**Output:**")
@ -197,7 +197,7 @@ describe("transcript", () => {
messageID: "msg_123",
type: "tool",
callID: "call_1",
tool: "bash",
tool: "shell",
state: {
status: "completed",
input: { command: "echo '```hello```'" },
@ -209,7 +209,7 @@ describe("transcript", () => {
}
const result = formatPart(part, options)
// The tool header should not be inside a code block
expect(result).toStartWith("**Tool: bash**\n")
expect(result).toStartWith("**Tool: shell**\n")
// Input and output should each be in their own code blocks
expect(result).toContain("**Input:**\n```json")
expect(result).toContain("**Output:**\n```\n```hello```\n```")
@ -222,7 +222,7 @@ describe("transcript", () => {
messageID: "msg_123",
type: "tool",
callID: "call_1",
tool: "bash",
tool: "shell",
state: {
status: "completed",
input: { command: "ls" },
@ -233,7 +233,7 @@ describe("transcript", () => {
},
}
const result = formatPart(part, { ...options, toolDetails: false })
expect(result).toContain("**Tool: bash**")
expect(result).toContain("**Tool: shell**")
expect(result).not.toContain("**Input:**")
expect(result).not.toContain("**Output:**")
})
@ -245,7 +245,7 @@ describe("transcript", () => {
messageID: "msg_123",
type: "tool",
callID: "call_1",
tool: "bash",
tool: "shell",
state: {
status: "error",
input: { command: "invalid" },

View file

@ -1353,7 +1353,7 @@ test("migrates legacy tools config to permissions - allow", async () => {
fn: async () => {
const config = await load()
expect(config.agent?.["test"]?.permission).toEqual({
bash: "allow",
shell: "allow",
read: "allow",
})
},
@ -1384,7 +1384,7 @@ test("migrates legacy tools config to permissions - deny", async () => {
fn: async () => {
const config = await load()
expect(config.agent?.["test"]?.permission).toEqual({
bash: "deny",
shell: "deny",
webfetch: "deny",
})
},
@ -1582,7 +1582,7 @@ test("migrates mixed legacy tools config", async () => {
fn: async () => {
const config = await load()
expect(config.agent?.["test"]?.permission).toEqual({
bash: "allow",
shell: "allow",
edit: "allow",
read: "deny",
webfetch: "allow",
@ -1618,7 +1618,7 @@ test("merges legacy tools with existing permission config", async () => {
const config = await load()
expect(config.agent?.["test"]?.permission).toEqual({
glob: "allow",
bash: "allow",
shell: "allow",
})
},
})
@ -1669,6 +1669,34 @@ test("permission config preserves user key order", async () => {
})
})
test("permission config preserves shell and legacy bash order", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
permission: {
shell: "deny",
bash: "allow",
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
expect(Object.keys(config.permission!)).toEqual(["shell", "bash"])
expect(config.permission).toEqual({
shell: "deny",
bash: "allow",
})
},
})
})
test("Effect config parser preserves permission order while rejecting unknown top-level keys", () => {
const config = ConfigParse.effectSchema(
Config.Info,

View file

@ -1,33 +1,40 @@
import { test, expect } from "bun:test"
import { BashArity } from "../../src/permission/arity"
import { ShellArity } from "../../src/tool/shell/arity"
test("arity 1 - unknown commands default to first token", () => {
expect(BashArity.prefix(["unknown", "command", "subcommand"])).toEqual(["unknown"])
expect(BashArity.prefix(["touch", "foo.txt"])).toEqual(["touch"])
expect(ShellArity.prefix(["unknown", "command", "subcommand"], "bash")).toEqual(["unknown"])
expect(ShellArity.prefix(["touch", "foo.txt"], "bash")).toEqual(["touch"])
})
test("arity 2 - two token commands", () => {
expect(BashArity.prefix(["git", "checkout", "main"])).toEqual(["git", "checkout"])
expect(BashArity.prefix(["docker", "run", "nginx"])).toEqual(["docker", "run"])
expect(ShellArity.prefix(["git", "checkout", "main"], "bash")).toEqual(["git", "checkout"])
expect(ShellArity.prefix(["docker", "run", "nginx"], "bash")).toEqual(["docker", "run"])
})
test("arity 3 - three token commands", () => {
expect(BashArity.prefix(["aws", "s3", "ls", "my-bucket"])).toEqual(["aws", "s3", "ls"])
expect(BashArity.prefix(["npm", "run", "dev", "script"])).toEqual(["npm", "run", "dev"])
expect(ShellArity.prefix(["aws", "s3", "ls", "my-bucket"], "bash")).toEqual(["aws", "s3", "ls"])
expect(ShellArity.prefix(["npm", "run", "dev", "script"], "bash")).toEqual(["npm", "run", "dev"])
})
test("longest match wins - nested prefixes", () => {
expect(BashArity.prefix(["docker", "compose", "up", "service"])).toEqual(["docker", "compose", "up"])
expect(BashArity.prefix(["consul", "kv", "get", "config"])).toEqual(["consul", "kv", "get"])
expect(ShellArity.prefix(["docker", "compose", "up", "service"], "bash")).toEqual(["docker", "compose", "up"])
expect(ShellArity.prefix(["consul", "kv", "get", "config"], "bash")).toEqual(["consul", "kv", "get"])
})
test("exact length matches", () => {
expect(BashArity.prefix(["git", "checkout"])).toEqual(["git", "checkout"])
expect(BashArity.prefix(["npm", "run", "dev"])).toEqual(["npm", "run", "dev"])
expect(ShellArity.prefix(["git", "checkout"], "bash")).toEqual(["git", "checkout"])
expect(ShellArity.prefix(["npm", "run", "dev"], "bash")).toEqual(["npm", "run", "dev"])
})
test("edge cases", () => {
expect(BashArity.prefix([])).toEqual([])
expect(BashArity.prefix(["single"])).toEqual(["single"])
expect(BashArity.prefix(["git"])).toEqual(["git"])
expect(ShellArity.prefix([], "bash")).toEqual([])
expect(ShellArity.prefix(["single"], "bash")).toEqual(["single"])
expect(ShellArity.prefix(["git"], "bash")).toEqual(["git"])
})
test("powershell verb-noun structures", () => {
expect(ShellArity.prefix(["Get-Content", "file.txt"], "pwsh")).toEqual(["Get-Content"])
expect(ShellArity.prefix(["Remove-Item", "-Recurse", "dir"], "powershell")).toEqual(["Remove-Item"])
expect(ShellArity.prefix(["git", "checkout", "main"], "pwsh")).toEqual(["git", "checkout"])
expect(ShellArity.prefix(["redis-cli", "ping"], "pwsh")).toEqual(["redis-cli", "ping"])
})

View file

@ -78,14 +78,14 @@ function withProvided(dir: string) {
test("fromConfig - string value becomes wildcard rule", () => {
const result = Permission.fromConfig({ bash: "allow" })
expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }])
expect(result).toEqual([{ permission: "shell", pattern: "*", action: "allow" }])
})
test("fromConfig - object value converts to rules array", () => {
const result = Permission.fromConfig({ bash: { "*": "allow", rm: "deny" } })
expect(result).toEqual([
{ permission: "bash", pattern: "*", action: "allow" },
{ permission: "bash", pattern: "rm", action: "deny" },
{ permission: "shell", pattern: "*", action: "allow" },
{ permission: "shell", pattern: "rm", action: "deny" },
])
})
@ -96,13 +96,35 @@ test("fromConfig - mixed string and object values", () => {
webfetch: "ask",
})
expect(result).toEqual([
{ permission: "bash", pattern: "*", action: "allow" },
{ permission: "bash", pattern: "rm", action: "deny" },
{ permission: "shell", pattern: "*", action: "allow" },
{ permission: "shell", pattern: "rm", action: "deny" },
{ permission: "edit", pattern: "*", action: "allow" },
{ permission: "webfetch", pattern: "*", action: "ask" },
])
})
test("fromConfig - shell and legacy bash normalize to shell in key order", () => {
const result = Permission.fromConfig({
shell: "deny",
bash: "allow",
})
expect(result).toEqual([
{ permission: "shell", pattern: "*", action: "deny" },
{ permission: "shell", pattern: "*", action: "allow" },
])
expect(Permission.evaluate("bash", "ls", result).action).toBe("allow")
expect(Permission.evaluate("shell", "ls", result).action).toBe("allow")
})
test("fromConfig - legacy bash rules coexist with canonical shell rules", () => {
const result = Permission.fromConfig({
shell: { "rm *": "deny" },
bash: { "*": "allow", "rm *": "ask" },
})
expect(Permission.evaluate("shell", "rm foo", result).action).toBe("ask")
expect(Permission.evaluate("bash", "rm foo", result).action).toBe("ask")
})
test("fromConfig - empty object", () => {
const result = Permission.fromConfig({})
expect(result).toEqual([])
@ -136,8 +158,8 @@ test("fromConfig - preserves top-level config key order", () => {
const wildcardFirst = Permission.fromConfig({ "*": "deny", bash: "allow" })
const specificFirst = Permission.fromConfig({ bash: "allow", "*": "deny" })
expect(wildcardFirst.map((r) => r.permission)).toEqual(["*", "bash"])
expect(specificFirst.map((r) => r.permission)).toEqual(["bash", "*"])
expect(wildcardFirst.map((r) => r.permission)).toEqual(["*", "shell"])
expect(specificFirst.map((r) => r.permission)).toEqual(["shell", "*"])
expect(Permission.evaluate("bash", "ls", wildcardFirst).action).toBe("allow")
expect(Permission.evaluate("bash", "ls", specificFirst).action).toBe("deny")
@ -156,7 +178,7 @@ test("fromConfig - top-level ordering is not sorted by wildcard specificity", ()
edit: "deny",
"mcp_*": "allow",
})
expect(ruleset.map((r) => r.permission)).toEqual(["bash", "*", "edit", "mcp_*"])
expect(ruleset.map((r) => r.permission)).toEqual(["shell", "*", "edit", "mcp_*"])
})
test("fromConfig - sub-pattern insertion order inside a tool key is preserved", () => {
@ -282,6 +304,11 @@ test("evaluate - exact pattern match", () => {
expect(result.action).toBe("deny")
})
test("evaluate - shell matches legacy bash rules", () => {
const result = Permission.evaluate("shell", "rm", [{ permission: "bash", pattern: "rm", action: "deny" }])
expect(result.action).toBe("deny")
})
test("evaluate - wildcard pattern match", () => {
const result = Permission.evaluate("bash", "rm", [{ permission: "bash", pattern: "*", action: "allow" }])
expect(result.action).toBe("allow")

View file

@ -865,7 +865,7 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => {
{
type: "tool-call",
toolCallId: "test",
toolName: "bash",
toolName: "shell",
input: { command: "echo hello" },
},
],
@ -916,7 +916,7 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => {
{
type: "tool-call",
toolCallId: "test",
toolName: "bash",
toolName: "shell",
input: { command: "echo hello" },
},
])
@ -1193,7 +1193,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
role: "assistant",
content: [
{ type: "text", text: "" },
{ type: "tool-call", toolCallId: "123", toolName: "bash", input: { command: "ls" } },
{ type: "tool-call", toolCallId: "123", toolName: "shell", input: { command: "ls" } },
],
},
] as any[]
@ -1205,7 +1205,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
expect(result[0].content[0]).toEqual({
type: "tool-call",
toolCallId: "123",
toolName: "bash",
toolName: "shell",
input: { command: "ls" },
})
})

View file

@ -94,14 +94,14 @@ describe("experimental HttpApi", () => {
expect(toolList.status).toBe(200)
expect(await toolList.json()).toContainEqual(
expect.objectContaining({
id: "bash",
id: "shell",
description: expect.any(String),
parameters: expect.any(Object),
}),
)
expect(toolIDs.status).toBe(200)
expect(await toolIDs.json()).toContain("bash")
expect(await toolIDs.json()).toContain("shell")
expect(worktrees.status).toBe(200)
expect(await worktrees.json()).toEqual([])

View file

@ -651,7 +651,7 @@ describe("session.compaction.prune", () => {
sessionID: info.id,
type: "tool",
callID: crypto.randomUUID(),
tool: "bash",
tool: "shell",
state: {
status: "completed",
input: {},

View file

@ -1,6 +1,6 @@
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"
import path from "path"
import { tool, type ModelMessage } from "ai"
import { tool, type ModelMessage, type Tool } from "ai"
import { Cause, Effect, Exit, Stream } from "effect"
import z from "zod"
import { makeRuntime } from "../../src/effect/run-service"
@ -63,7 +63,7 @@ describe("session.llm.hasToolCalls", () => {
{
type: "tool-call",
toolCallId: "call-123",
toolName: "bash",
toolName: "shell",
},
],
},
@ -79,7 +79,7 @@ describe("session.llm.hasToolCalls", () => {
{
type: "tool-result",
toolCallId: "call-123",
toolName: "bash",
toolName: "shell",
},
],
},
@ -119,6 +119,17 @@ describe("session.llm.hasToolCalls", () => {
})
})
describe("session.llm.repairToolName", () => {
test("normalizes legacy bash alias to shell when available", () => {
expect(LLM.repairToolName("bash", { shell: {} as Tool })).toBe("shell")
expect(LLM.repairToolName("BASH", { shell: {} as Tool })).toBe("shell")
})
test("returns undefined when normalized tool is unavailable", () => {
expect(LLM.repairToolName("bash", { read: {} as Tool })).toBeUndefined()
})
})
type Capture = {
url: URL
headers: Headers
@ -561,6 +572,100 @@ describe("session.llm.stream", () => {
})
})
test("disables shell when user message uses legacy bash override", async () => {
const server = state.server
if (!server) {
throw new Error("Server not initialized")
}
const providerID = "alibaba"
const modelID = "qwen-plus"
const fixture = await loadFixture(providerID, modelID)
const model = fixture.model
const request = waitRequest(
"/chat/completions",
new Response(createChatStream("Hello"), {
status: 200,
headers: { "Content-Type": "text/event-stream" },
}),
)
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
enabled_providers: [providerID],
provider: {
[providerID]: {
options: {
apiKey: "test-key",
baseURL: `${server.url.origin}/v1`,
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id))
const sessionID = SessionID.make("session-test-legacy-bash-tools")
const agent = {
name: "test",
mode: "primary",
options: {},
permission: [],
} satisfies Agent.Info
const user = {
id: MessageID.make("user-legacy-bash-tools"),
sessionID,
role: "user",
time: { created: Date.now() },
agent: agent.name,
model: { providerID: ProviderID.make(providerID), modelID: resolved.id },
tools: { bash: false },
} satisfies MessageV2.User
await drain({
user,
sessionID,
model: resolved,
agent,
system: ["You are a helpful assistant."],
messages: [{ role: "user", content: "Hello" }],
tools: {
shell: tool({
description: "Run a shell command",
inputSchema: z.object({ command: z.string() }),
execute: async () => ({ output: "" }),
}),
read: tool({
description: "Read a file",
inputSchema: z.object({ filePath: z.string() }),
execute: async () => ({ output: "" }),
}),
},
})
const capture = await request
const names =
(capture.body.tools as Array<{ function?: { name?: string } }> | undefined)?.flatMap((item) =>
item.function?.name ? [item.function.name] : [],
) ?? []
expect(names).not.toContain("shell")
expect(names).toContain("read")
},
})
})
test("sends responses API payload for OpenAI models", async () => {
const server = state.server
if (!server) {

View file

@ -296,7 +296,7 @@ describe("session.message-v2.toModelMessage", () => {
...basePart(assistantID, "a2"),
type: "tool",
callID: "call-1",
tool: "bash",
tool: "shell",
state: {
status: "completed",
input: { cmd: "ls" },
@ -332,7 +332,7 @@ describe("session.message-v2.toModelMessage", () => {
{
type: "tool-call",
toolCallId: "call-1",
toolName: "bash",
toolName: "shell",
input: { cmd: "ls" },
providerExecuted: undefined,
providerOptions: { openai: { tool: "meta" } },
@ -345,7 +345,7 @@ describe("session.message-v2.toModelMessage", () => {
{
type: "tool-result",
toolCallId: "call-1",
toolName: "bash",
toolName: "shell",
output: {
type: "content",
value: [
@ -471,7 +471,7 @@ describe("session.message-v2.toModelMessage", () => {
...basePart(assistantID, "a2"),
type: "tool",
callID: "call-1",
tool: "bash",
tool: "shell",
state: {
status: "completed",
input: { cmd: "ls" },
@ -498,7 +498,7 @@ describe("session.message-v2.toModelMessage", () => {
{
type: "tool-call",
toolCallId: "call-1",
toolName: "bash",
toolName: "shell",
input: { cmd: "ls" },
providerExecuted: undefined,
},
@ -510,7 +510,7 @@ describe("session.message-v2.toModelMessage", () => {
{
type: "tool-result",
toolCallId: "call-1",
toolName: "bash",
toolName: "shell",
output: { type: "text", value: "ok" },
},
],
@ -540,7 +540,7 @@ describe("session.message-v2.toModelMessage", () => {
...basePart(assistantID, "a1"),
type: "tool",
callID: "call-1",
tool: "bash",
tool: "shell",
state: {
status: "completed",
input: { cmd: "ls" },
@ -565,7 +565,7 @@ describe("session.message-v2.toModelMessage", () => {
{
type: "tool-call",
toolCallId: "call-1",
toolName: "bash",
toolName: "shell",
input: { cmd: "ls" },
providerExecuted: undefined,
},
@ -577,7 +577,7 @@ describe("session.message-v2.toModelMessage", () => {
{
type: "tool-result",
toolCallId: "call-1",
toolName: "bash",
toolName: "shell",
output: { type: "text", value: "[Old tool result content cleared]" },
},
],
@ -607,12 +607,12 @@ describe("session.message-v2.toModelMessage", () => {
...basePart(assistantID, "a1"),
type: "tool",
callID: "call-1",
tool: "bash",
tool: "shell",
state: {
status: "completed",
input: { cmd: "ls" },
output: "abcdefghij",
title: "Bash",
title: "Shell",
metadata: {},
time: { start: 0, end: 1 },
},
@ -632,7 +632,7 @@ describe("session.message-v2.toModelMessage", () => {
{
type: "tool-call",
toolCallId: "call-1",
toolName: "bash",
toolName: "shell",
input: { cmd: "ls" },
providerExecuted: undefined,
},
@ -644,7 +644,7 @@ describe("session.message-v2.toModelMessage", () => {
{
type: "tool-result",
toolCallId: "call-1",
toolName: "bash",
toolName: "shell",
output: {
type: "text",
value: "abcd\n[Tool output truncated for compaction: omitted 6 chars]",
@ -677,7 +677,7 @@ describe("session.message-v2.toModelMessage", () => {
...basePart(assistantID, "a1"),
type: "tool",
callID: "call-1",
tool: "bash",
tool: "shell",
state: {
status: "error",
input: { cmd: "ls" },
@ -702,7 +702,7 @@ describe("session.message-v2.toModelMessage", () => {
{
type: "tool-call",
toolCallId: "call-1",
toolName: "bash",
toolName: "shell",
input: { cmd: "ls" },
providerExecuted: undefined,
providerOptions: { openai: { tool: "meta" } },
@ -715,7 +715,7 @@ describe("session.message-v2.toModelMessage", () => {
{
type: "tool-result",
toolCallId: "call-1",
toolName: "bash",
toolName: "shell",
output: { type: "error-text", value: "nope" },
providerOptions: { openai: { tool: "meta" } },
},
@ -732,9 +732,9 @@ describe("session.message-v2.toModelMessage", () => {
"12179",
"4575",
"",
"<bash_metadata>",
"<shell_metadata>",
"User aborted the command",
"</bash_metadata>",
"</shell_metadata>",
].join("\n")
const input: MessageV2.WithParts[] = [
@ -755,7 +755,7 @@ describe("session.message-v2.toModelMessage", () => {
...basePart(assistantID, "a1"),
type: "tool",
callID: "call-1",
tool: "bash",
tool: "shell",
state: {
status: "error",
input: { command: "for i in {1..20}; do print -- $RANDOM; sleep 1; done" },
@ -779,7 +779,7 @@ describe("session.message-v2.toModelMessage", () => {
{
type: "tool-call",
toolCallId: "call-1",
toolName: "bash",
toolName: "shell",
input: { command: "for i in {1..20}; do print -- $RANDOM; sleep 1; done" },
providerExecuted: undefined,
},
@ -791,7 +791,7 @@ describe("session.message-v2.toModelMessage", () => {
{
type: "tool-result",
toolCallId: "call-1",
toolName: "bash",
toolName: "shell",
output: { type: "text", value: output },
},
],
@ -1023,7 +1023,7 @@ describe("session.message-v2.toModelMessage", () => {
...basePart(assistantID, "a1"),
type: "tool",
callID: "call-pending",
tool: "bash",
tool: "shell",
state: {
status: "pending",
input: { cmd: "ls" },
@ -1058,7 +1058,7 @@ describe("session.message-v2.toModelMessage", () => {
{
type: "tool-call",
toolCallId: "call-pending",
toolName: "bash",
toolName: "shell",
input: { cmd: "ls" },
providerExecuted: undefined,
},
@ -1077,7 +1077,7 @@ describe("session.message-v2.toModelMessage", () => {
{
type: "tool-result",
toolCallId: "call-pending",
toolName: "bash",
toolName: "shell",
output: { type: "error-text", value: "[Tool execution was interrupted]" },
},
{

View file

@ -650,7 +650,7 @@ it.live("session.processor effect tests mark pending tools as aborted on cleanup
Effect.gen(function* () {
const { processors, session, provider } = yield* boot()
yield* llm.toolHang("bash", { cmd: "pwd" })
yield* llm.toolHang("shell", { cmd: "pwd" })
const chat = yield* session.create({})
const parent = yield* user(chat.id, "tool abort")

View file

@ -73,7 +73,7 @@ const tool = Effect.fn("test.tool")(function* (sessionID: SessionID, messageID:
messageID,
sessionID,
type: "tool" as const,
tool: "bash",
tool: "shell",
callID: "call-1",
state: {
status: "completed" as const,

View file

@ -414,13 +414,13 @@ describe("session-entry-stepper", () => {
(callID, title, input, output, metadata, attachments, parts) => {
const next = run(
[
SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }),
SessionEvent.Tool.Input.Started.create({ callID, name: "shell", timestamp: time(1) }),
...parts.map((x, i) =>
SessionEvent.Tool.Input.Delta.create({ callID, delta: x, timestamp: time(i + 2) }),
),
SessionEvent.Tool.Called.create({
callID,
tool: "bash",
tool: "shell",
input,
provider: { executed: true },
timestamp: time(parts.length + 2),
@ -459,10 +459,10 @@ describe("session-entry-stepper", () => {
FastCheck.property(word, dict, word, maybe(dict), (callID, input, error, metadata) => {
const next = run(
[
SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }),
SessionEvent.Tool.Input.Started.create({ callID, name: "shell", timestamp: time(1) }),
SessionEvent.Tool.Called.create({
callID,
tool: "bash",
tool: "shell",
input,
provider: { executed: true },
timestamp: time(2),
@ -496,7 +496,7 @@ describe("session-entry-stepper", () => {
FastCheck.property(word, word, (callID, title) => {
const next = run(
[
SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }),
SessionEvent.Tool.Input.Started.create({ callID, name: "shell", timestamp: time(1) }),
SessionEvent.Tool.Success.create({
callID,
title,
@ -691,10 +691,10 @@ describe("session-entry-stepper", () => {
SessionEvent.Reasoning.Started.create({ timestamp: time(2) }),
...reason.map((x, i) => SessionEvent.Reasoning.Delta.create({ delta: x, timestamp: time(i + 3) })),
SessionEvent.Reasoning.Ended.create({ text: end, timestamp: time(reason.length + 3) }),
SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(reason.length + 4) }),
SessionEvent.Tool.Input.Started.create({ callID, name: "shell", timestamp: time(reason.length + 4) }),
SessionEvent.Tool.Called.create({
callID,
tool: "bash",
tool: "shell",
input,
provider: { executed: true },
timestamp: time(reason.length + 5),
@ -771,10 +771,10 @@ describe("session-entry-stepper", () => {
FastCheck.property(dict, dict, word, word, (a, b, title, error) => {
const next = run(
[
SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }),
SessionEvent.Tool.Input.Started.create({ callID: "a", name: "shell", timestamp: time(1) }),
SessionEvent.Tool.Called.create({
callID: "a",
tool: "bash",
tool: "shell",
input: a,
provider: { executed: true },
timestamp: time(2),
@ -789,7 +789,7 @@ describe("session-entry-stepper", () => {
SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(4) }),
SessionEvent.Tool.Called.create({
callID: "b",
tool: "bash",
tool: "shell",
input: b,
provider: { executed: true },
timestamp: time(5),
@ -827,13 +827,13 @@ describe("session-entry-stepper", () => {
FastCheck.property(dict, dict, word, word, text, text, (a, b, titleA, titleB, deltaA, deltaB) => {
const next = run(
[
SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }),
SessionEvent.Tool.Input.Started.create({ callID: "a", name: "shell", timestamp: time(1) }),
SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(2) }),
SessionEvent.Tool.Input.Delta.create({ callID: "a", delta: deltaA, timestamp: time(3) }),
SessionEvent.Tool.Input.Delta.create({ callID: "b", delta: deltaB, timestamp: time(4) }),
SessionEvent.Tool.Called.create({
callID: "a",
tool: "bash",
tool: "shell",
input: a,
provider: { executed: true },
timestamp: time(5),

View file

@ -23,6 +23,7 @@ import { SessionRevert } from "../../src/session/revert"
import { SessionSummary } from "../../src/session/summary"
import { MessageV2 } from "../../src/session/message-v2"
import * as Log from "@opencode-ai/core/util/log"
import { ShellToolID } from "../../src/tool/shell/id"
import { provideTmpdirServer } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { TestLLMServer } from "../lib/llm-server"
@ -198,13 +199,15 @@ it.live("tool execution produces non-empty session diff (snapshot race)", () =>
permission: [{ permission: "*", pattern: "*", action: "allow" }],
})
// Use bash tool (always registered) to create a file
const shell = ShellToolID.id
// Use the active shell tool to create a file
const command = `echo 'snapshot race test content' > ${path.join(dir, "race-test.txt")}`
yield* llm.toolMatch((hit) => JSON.stringify(hit.body).includes("create the file"), "bash", {
yield* llm.toolMatch((hit) => JSON.stringify(hit.body).includes("create the file"), shell, {
command,
description: "create test file",
})
yield* llm.textMatch((hit) => JSON.stringify(hit.body).includes("bash"), "done")
yield* llm.textMatch((hit) => JSON.stringify(hit.body).includes(shell), "done")
// Seed user message
yield* prompt.prompt({
@ -232,7 +235,7 @@ it.live("tool execution produces non-empty session diff (snapshot race)", () =>
const allMsgs = yield* MessageV2.filterCompactedEffect(session.id)
const tool = allMsgs
.flatMap((m) => m.parts)
.find((p): p is MessageV2.ToolPart => p.type === "tool" && p.tool === "bash")
.find((p): p is MessageV2.ToolPart => p.type === "tool" && p.tool === shell)
expect(tool?.state.status).toBe("completed")
// Poll for diff — summarize() is fire-and-forget
@ -246,4 +249,5 @@ it.live("tool execution produces non-empty session diff (snapshot race)", () =>
}),
{ git: true, config: providerCfg },
),
20_000,
)

View file

@ -16,7 +16,7 @@ exports[`tool parameters JSON Schema (wire shape) apply_patch 1`] = `
}
`;
exports[`tool parameters JSON Schema (wire shape) bash 1`] = `
exports[`tool parameters JSON Schema (wire shape) shell 1`] = `
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {

View file

@ -10,7 +10,6 @@ import { toJsonSchema } from "../../src/util/effect-zod"
// byte-identical regardless of whether a tool has migrated from zod to Schema.
import { Parameters as ApplyPatch } from "../../src/tool/apply_patch"
import { Parameters as Bash } from "../../src/tool/bash"
import { Parameters as CodeSearch } from "../../src/tool/codesearch"
import { Parameters as Edit } from "../../src/tool/edit"
import { Parameters as Glob } from "../../src/tool/glob"
@ -20,6 +19,7 @@ import { Parameters as Lsp } from "../../src/tool/lsp"
import { Parameters as Plan } from "../../src/tool/plan"
import { Parameters as Question } from "../../src/tool/question"
import { Parameters as Read } from "../../src/tool/read"
import { Parameters as Shell } from "../../src/tool/shell"
import { Parameters as Skill } from "../../src/tool/skill"
import { Parameters as Task } from "../../src/tool/task"
import { Parameters as Todo } from "../../src/tool/todo"
@ -36,7 +36,7 @@ const accepts = (schema: Schema.Decoder<unknown>, input: unknown): boolean =>
describe("tool parameters", () => {
describe("JSON Schema (wire shape)", () => {
test("apply_patch", () => expect(toJsonSchema(ApplyPatch)).toMatchSnapshot())
test("bash", () => expect(toJsonSchema(Bash)).toMatchSnapshot())
test("shell", () => expect(toJsonSchema(Shell)).toMatchSnapshot())
test("codesearch", () => expect(toJsonSchema(CodeSearch)).toMatchSnapshot())
test("edit", () => expect(toJsonSchema(Edit)).toMatchSnapshot())
test("glob", () => expect(toJsonSchema(Glob)).toMatchSnapshot())
@ -68,20 +68,20 @@ describe("tool parameters", () => {
})
})
describe("bash", () => {
describe("shell", () => {
test("accepts minimum: command + description", () => {
expect(parse(Bash, { command: "ls", description: "list" })).toEqual({ command: "ls", description: "list" })
expect(parse(Shell, { command: "ls", description: "list" })).toEqual({ command: "ls", description: "list" })
})
test("accepts optional timeout + workdir", () => {
const parsed = parse(Bash, { command: "ls", description: "list", timeout: 5000, workdir: "/tmp" })
const parsed = parse(Shell, { command: "ls", description: "list", timeout: 5000, workdir: "/tmp" })
expect(parsed.timeout).toBe(5000)
expect(parsed.workdir).toBe("/tmp")
})
test("rejects missing description (required by zod)", () => {
expect(accepts(Bash, { command: "ls" })).toBe(false)
test("rejects missing description", () => {
expect(accepts(Shell, { command: "ls" })).toBe(false)
})
test("rejects missing command", () => {
expect(accepts(Bash, { description: "list" })).toBe(false)
expect(accepts(Shell, { description: "list" })).toBe(false)
})
})

View file

@ -4,7 +4,8 @@ import os from "os"
import path from "path"
import { Config } from "@/config/config"
import { Shell } from "../../src/shell/shell"
import { BashTool } from "../../src/tool/bash"
import { ShellToolID } from "../../src/tool/shell/id"
import { ShellTool } from "../../src/tool/shell"
import { Instance } from "../../src/project/instance"
import { Filesystem } from "@/util/filesystem"
import { tmpdir } from "../fixture/fixture"
@ -28,9 +29,11 @@ const runtime = ManagedRuntime.make(
)
function initBash() {
return runtime.runPromise(BashTool.pipe(Effect.flatMap((info) => info.init())))
return runtime.runPromise(ShellTool.pipe(Effect.flatMap((info) => info.init())))
}
const initShell = initBash
const ctx = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""),
@ -135,12 +138,14 @@ const mustTruncate = (result: {
)
}
describe("tool.bash", () => {
const expectedPermission = ShellToolID.id
describe("tool.shell", () => {
each("basic", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const bash = await initBash()
const bash = await initShell()
const result = await Effect.runPromise(
bash.execute(
{
@ -184,13 +189,13 @@ describe("tool.bash", () => {
})
})
describe("tool.bash permissions", () => {
each("asks for bash permission with correct pattern", async () => {
describe("tool.shell permissions", () => {
each("asks for shell permission with correct pattern", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bash = await initBash()
const bash = await initShell()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await Effect.runPromise(
bash.execute(
@ -202,18 +207,18 @@ describe("tool.bash permissions", () => {
),
)
expect(requests.length).toBe(1)
expect(requests[0].permission).toBe("bash")
expect(requests[0].permission).toBe(expectedPermission)
expect(requests[0].patterns).toContain("echo hello")
},
})
})
each("asks for bash permission with multiple commands", async () => {
each("asks for shell permission with multiple commands", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bash = await initBash()
const bash = await initShell()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await Effect.runPromise(
bash.execute(
@ -225,7 +230,7 @@ describe("tool.bash permissions", () => {
),
)
expect(requests.length).toBe(1)
expect(requests[0].permission).toBe("bash")
expect(requests[0].permission).toBe(expectedPermission)
expect(requests[0].patterns).toContain("echo foo")
expect(requests[0].patterns).toContain("echo bar")
},
@ -239,7 +244,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const bash = await initBash()
const bash = await initShell()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await Effect.runPromise(
bash.execute(
@ -250,7 +255,7 @@ describe("tool.bash permissions", () => {
capture(requests),
),
)
const bashReq = requests.find((r) => r.permission === "bash")
const bashReq = requests.find((r) => r.permission === expectedPermission)
expect(bashReq).toBeDefined()
expect(bashReq!.patterns).toContain("Write-Host foo")
expect(bashReq!.patterns).toContain("Write-Host bar")
@ -261,11 +266,43 @@ describe("tool.bash permissions", () => {
)
}
for (const item of ps) {
test(
`uses PowerShell cmdlet prefixes for always-allow prompts [${item.label}]`,
withShell(item, async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bash = await initShell()
const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect(
Effect.runPromise(
bash.execute(
{
command: "Remove-Item -Recurse tmp",
description: "Remove a temp directory",
},
capture(requests, err),
),
),
).rejects.toThrow(err.message)
const bashReq = requests.find((r) => r.permission === expectedPermission)
expect(bashReq).toBeDefined()
expect(bashReq!.always).toContain("Remove-Item *")
expect(bashReq!.always).not.toContain("Remove-Item -Recurse *")
},
})
}),
)
}
each("asks for external_directory permission for wildcard external paths", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const bash = await initBash()
const bash = await initShell()
const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const file = process.platform === "win32" ? `${process.env.WINDIR!.replaceAll("\\", "/")}/*` : "/etc/*"
@ -301,7 +338,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const bash = await initBash()
const bash = await initShell()
const file = path.join(outerTmp.path, "outside.txt").replaceAll("\\", "/")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await Effect.runPromise(
@ -314,7 +351,7 @@ describe("tool.bash permissions", () => {
),
)
const extDirReq = requests.find((r) => r.permission === "external_directory")
const bashReq = requests.find((r) => r.permission === "bash")
const bashReq = requests.find((r) => r.permission === expectedPermission)
expect(extDirReq).toBeDefined()
expect(extDirReq!.patterns).toContain(glob(path.join(outerTmp.path, "*")))
expect(bashReq).toBeDefined()
@ -334,7 +371,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const bash = await initBash()
const bash = await initShell()
const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect(
@ -364,7 +401,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const bash = await initBash()
const bash = await initShell()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const file = `${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`
await Effect.runPromise(
@ -377,7 +414,7 @@ describe("tool.bash permissions", () => {
),
)
const extDirReq = requests.find((r) => r.permission === "external_directory")
const bashReq = requests.find((r) => r.permission === "bash")
const bashReq = requests.find((r) => r.permission === expectedPermission)
expect(extDirReq).toBeDefined()
expect(extDirReq!.patterns).toContain(glob(path.join(process.env.WINDIR!, "*")))
expect(bashReq).toBeDefined()
@ -396,7 +433,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bash = await initBash()
const bash = await initShell()
const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect(
@ -426,7 +463,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const bash = await initBash()
const bash = await initShell()
const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect(
@ -521,7 +558,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const bash = await initBash()
const bash = await initShell()
const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const root = path.parse(process.env.WINDIR!).root.replace(/[\\/]+$/, "")
@ -661,7 +698,7 @@ describe("tool.bash permissions", () => {
),
)
const extDirReq = requests.find((r) => r.permission === "external_directory")
const bashReq = requests.find((r) => r.permission === "bash")
const bashReq = requests.find((r) => r.permission === expectedPermission)
expect(extDirReq).toBeDefined()
expect(extDirReq!.patterns).toContain(
Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")),
@ -680,7 +717,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const bash = await initBash()
const bash = await initShell()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await Effect.runPromise(
bash.execute(
@ -691,7 +728,7 @@ describe("tool.bash permissions", () => {
capture(requests),
),
)
const bashReq = requests.find((r) => r.permission === "bash")
const bashReq = requests.find((r) => r.permission === expectedPermission)
expect(bashReq).toBeDefined()
expect(bashReq!.patterns).not.toContain("a * 3")
expect(bashReq!.always).not.toContain("a *")
@ -940,12 +977,12 @@ describe("tool.bash permissions", () => {
})
})
each("does not ask for bash permission when command is cd only", async () => {
each("does not ask for shell permission when command is cd only", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bash = await initBash()
const bash = await initShell()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await Effect.runPromise(
bash.execute(
@ -956,7 +993,7 @@ describe("tool.bash permissions", () => {
capture(requests),
),
)
const bashReq = requests.find((r) => r.permission === "bash")
const bashReq = requests.find((r) => r.permission === expectedPermission)
expect(bashReq).toBeUndefined()
},
})
@ -967,7 +1004,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bash = await initBash()
const bash = await initShell()
const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect(
@ -978,7 +1015,7 @@ describe("tool.bash permissions", () => {
),
),
).rejects.toThrow(err.message)
const bashReq = requests.find((r) => r.permission === "bash")
const bashReq = requests.find((r) => r.permission === expectedPermission)
expect(bashReq).toBeDefined()
expect(bashReq!.patterns).toContain("echo test > output.txt")
},
@ -993,7 +1030,7 @@ describe("tool.bash permissions", () => {
const bash = await initBash()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await Effect.runPromise(bash.execute({ command: "ls -la", description: "List" }, capture(requests)))
const bashReq = requests.find((r) => r.permission === "bash")
const bashReq = requests.find((r) => r.permission === expectedPermission)
expect(bashReq).toBeDefined()
expect(bashReq!.always[0]).toBe("ls *")
},
@ -1001,12 +1038,12 @@ describe("tool.bash permissions", () => {
})
})
describe("tool.bash abort", () => {
describe("tool.shell abort", () => {
test("preserves output when aborted", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const bash = await initBash()
const bash = await initShell()
const controller = new AbortController()
const collected: string[] = []
const res = await Effect.runPromise(
@ -1040,7 +1077,7 @@ describe("tool.bash abort", () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const bash = await initBash()
const bash = await initShell()
const result = await Effect.runPromise(
bash.execute(
{
@ -1052,7 +1089,7 @@ describe("tool.bash abort", () => {
),
)
expect(result.output).toContain("started")
expect(result.output).toContain("bash tool terminated command after exceeding timeout")
expect(result.output).toContain("shell tool terminated command after exceeding timeout")
expect(result.output).toContain("retry with a larger timeout value in milliseconds")
},
})
@ -1062,7 +1099,7 @@ describe("tool.bash abort", () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const bash = await initBash()
const bash = await initShell()
const result = await Effect.runPromise(
bash.execute(
{
@ -1083,7 +1120,7 @@ describe("tool.bash abort", () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const bash = await initBash()
const bash = await initShell()
const result = await Effect.runPromise(
bash.execute(
{
@ -1128,12 +1165,12 @@ describe("tool.bash abort", () => {
})
})
describe("tool.bash truncation", () => {
describe("tool.shell truncation", () => {
test("truncates output exceeding line limit", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const bash = await initBash()
const bash = await initShell()
const lineCount = Truncate.MAX_LINES + 500
const result = await Effect.runPromise(
bash.execute(
@ -1155,7 +1192,7 @@ describe("tool.bash truncation", () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const bash = await initBash()
const bash = await initShell()
const byteCount = Truncate.MAX_BYTES + 10000
const result = await Effect.runPromise(
bash.execute(
@ -1177,7 +1214,7 @@ describe("tool.bash truncation", () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const bash = await initBash()
const bash = await initShell()
const result = await Effect.runPromise(
bash.execute(
{
@ -1197,7 +1234,7 @@ describe("tool.bash truncation", () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const bash = await initBash()
const bash = await initShell()
const lineCount = Truncate.MAX_LINES + 100
const result = await Effect.runPromise(
bash.execute(

View file

@ -351,7 +351,7 @@ describe("tool.task", () => {
action: "deny",
},
{
permission: "bash",
permission: "shell",
pattern: "*",
action: "allow",
},
@ -363,7 +363,7 @@ describe("tool.task", () => {
])
expect(seen?.tools).toEqual({
todowrite: false,
bash: false,
shell: false,
read: false,
})
}),