Apply PR #20039: feat: bash->shell tool + pwsh/powershell/cmd/bash specific tool definitions so agents work better
This commit is contained in:
commit
af8ea50ab2
60 changed files with 885 additions and 347 deletions
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
|
|
|
|||
|
|
@ -651,7 +651,7 @@ describe("session.compaction.prune", () => {
|
|||
sessionID: info.id,
|
||||
type: "tool",
|
||||
callID: crypto.randomUUID(),
|
||||
tool: "bash",
|
||||
tool: "shell",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: {},
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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]" },
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
@ -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,
|
||||
})
|
||||
}),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue