Skip to content

Commit 529a6ed

Browse files
committed
.
1 parent 20c3461 commit 529a6ed

6 files changed

Lines changed: 28 additions & 157 deletions

File tree

packages/opencode/src/acp/agent.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1550,9 +1550,11 @@ export class Agent implements ACPAgent {
15501550

15511551
function toToolKind(toolName: string): ToolKind {
15521552
const tool = toolName.toLocaleLowerCase()
1553-
if (tool === ShellToolID.id) return "execute"
15541553

15551554
switch (tool) {
1555+
case ShellToolID.id:
1556+
return "execute"
1557+
15561558
case "webfetch":
15571559
return "fetch"
15581560

@@ -1577,7 +1579,6 @@ function toToolKind(toolName: string): ToolKind {
15771579

15781580
function toLocations(toolName: string, input: Record<string, any>): { path: string }[] {
15791581
const tool = toolName.toLocaleLowerCase()
1580-
if (tool === ShellToolID.id) return []
15811582

15821583
switch (tool) {
15831584
case "read":
@@ -1587,6 +1588,8 @@ function toLocations(toolName: string, input: Record<string, any>): { path: stri
15871588
case "glob":
15881589
case "grep":
15891590
return input["path"] ? [{ path: input["path"] }] : []
1591+
case ShellToolID.id:
1592+
return []
15901593
default:
15911594
return []
15921595
}

packages/opencode/src/session/llm.ts

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -206,8 +206,6 @@ const live: Layer.Layer<
206206
input.model.providerID.toLowerCase().includes("litellm") ||
207207
input.model.api.id.toLowerCase().includes("litellm")
208208

209-
const repair = (toolName: string) => repairToolName(toolName, tools)
210-
211209
// LiteLLM/Bedrock rejects requests where the message history contains tool
212210
// calls but no tools param is present. When there are no active tools (e.g.
213211
// during compaction), inject a stub tool to satisfy the validation requirement.
@@ -241,7 +239,7 @@ const live: Layer.Layer<
241239
workflowModel.sessionID = input.sessionID
242240
workflowModel.systemPrompt = system.join("\n")
243241
workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => {
244-
const t = tools[repair(toolName) ?? toolName]
242+
const t = tools[toolName]
245243
if (!t || !t.execute) {
246244
return { result: "", error: `Unknown tool: ${toolName}` }
247245
}
@@ -339,15 +337,15 @@ const live: Layer.Layer<
339337
})
340338
},
341339
async experimental_repairToolCall(failed) {
342-
const repaired = repair(failed.toolCall.toolName)
343-
if (repaired && repaired !== failed.toolCall.toolName) {
340+
const lower = failed.toolCall.toolName.toLowerCase()
341+
if (lower !== failed.toolCall.toolName && tools[lower]) {
344342
l.info("repairing tool call", {
345343
tool: failed.toolCall.toolName,
346-
repaired,
344+
repaired: lower,
347345
})
348346
return {
349347
...failed.toolCall,
350-
toolName: repaired,
348+
toolName: lower,
351349
}
352350
}
353351
return {
@@ -445,22 +443,12 @@ export const defaultLayer = Layer.suspend(() =>
445443
),
446444
)
447445

448-
export function repairToolName(toolName: string, tools: Record<string, Tool>) {
449-
const next = toolName.toLowerCase()
450-
if (!tools[next]) return
451-
return next
452-
}
453-
454446
function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "permission" | "user">) {
455447
const disabled = Permission.disabled(
456448
Object.keys(input.tools),
457449
Permission.merge(input.agent.permission, input.permission ?? []),
458450
)
459-
return Record.filter(input.tools, (_, k) => {
460-
const userTool = input.user.tools?.[k]
461-
if (userTool !== undefined) return userTool !== false && !disabled.has(k)
462-
return !disabled.has(k)
463-
})
451+
return Record.filter(input.tools, (_, k) => input.user.tools?.[k] !== false && !disabled.has(k))
464452
}
465453

466454
// Check if messages contain any tool-call content

packages/opencode/src/tool/shell.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ import * as Truncate from "./truncate"
1919
import { Plugin } from "@/plugin"
2020
import { ChildProcess } from "effect/unstable/process"
2121
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
22-
import { ShellArity } from "./shell/arity"
2322
import { ShellPrompt, type Parameters } from "./shell/prompt"
23+
import { BashArity } from "@/permission/arity"
2424

2525
export { Parameters } from "./shell/prompt"
2626

@@ -381,7 +381,7 @@ export const ShellTool = Tool.define(
381381

382382
if (tokens.length && (!cmd || !CWD.has(cmd))) {
383383
scan.patterns.add(source(node))
384-
scan.always.add(ShellArity.prefix(tokens, shellKind).join(" ") + " *")
384+
scan.always.add(BashArity.prefix(tokens).join(" ") + " *")
385385
}
386386
}
387387

packages/opencode/src/tool/shell/arity.ts

Lines changed: 0 additions & 8 deletions
This file was deleted.
Lines changed: 14 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,33 @@
11
import { test, expect } from "bun:test"
2-
import { ShellArity } from "../../src/tool/shell/arity"
2+
import { BashArity } from "../../src/permission/arity"
33

44
test("arity 1 - unknown commands default to first token", () => {
5-
expect(ShellArity.prefix(["unknown", "command", "subcommand"], "bash")).toEqual(["unknown"])
6-
expect(ShellArity.prefix(["touch", "foo.txt"], "bash")).toEqual(["touch"])
5+
expect(BashArity.prefix(["unknown", "command", "subcommand"])).toEqual(["unknown"])
6+
expect(BashArity.prefix(["touch", "foo.txt"])).toEqual(["touch"])
77
})
88

99
test("arity 2 - two token commands", () => {
10-
expect(ShellArity.prefix(["git", "checkout", "main"], "bash")).toEqual(["git", "checkout"])
11-
expect(ShellArity.prefix(["docker", "run", "nginx"], "bash")).toEqual(["docker", "run"])
10+
expect(BashArity.prefix(["git", "checkout", "main"])).toEqual(["git", "checkout"])
11+
expect(BashArity.prefix(["docker", "run", "nginx"])).toEqual(["docker", "run"])
1212
})
1313

1414
test("arity 3 - three token commands", () => {
15-
expect(ShellArity.prefix(["aws", "s3", "ls", "my-bucket"], "bash")).toEqual(["aws", "s3", "ls"])
16-
expect(ShellArity.prefix(["npm", "run", "dev", "script"], "bash")).toEqual(["npm", "run", "dev"])
15+
expect(BashArity.prefix(["aws", "s3", "ls", "my-bucket"])).toEqual(["aws", "s3", "ls"])
16+
expect(BashArity.prefix(["npm", "run", "dev", "script"])).toEqual(["npm", "run", "dev"])
1717
})
1818

1919
test("longest match wins - nested prefixes", () => {
20-
expect(ShellArity.prefix(["docker", "compose", "up", "service"], "bash")).toEqual(["docker", "compose", "up"])
21-
expect(ShellArity.prefix(["consul", "kv", "get", "config"], "bash")).toEqual(["consul", "kv", "get"])
20+
expect(BashArity.prefix(["docker", "compose", "up", "service"])).toEqual(["docker", "compose", "up"])
21+
expect(BashArity.prefix(["consul", "kv", "get", "config"])).toEqual(["consul", "kv", "get"])
2222
})
2323

2424
test("exact length matches", () => {
25-
expect(ShellArity.prefix(["git", "checkout"], "bash")).toEqual(["git", "checkout"])
26-
expect(ShellArity.prefix(["npm", "run", "dev"], "bash")).toEqual(["npm", "run", "dev"])
25+
expect(BashArity.prefix(["git", "checkout"])).toEqual(["git", "checkout"])
26+
expect(BashArity.prefix(["npm", "run", "dev"])).toEqual(["npm", "run", "dev"])
2727
})
2828

2929
test("edge cases", () => {
30-
expect(ShellArity.prefix([], "bash")).toEqual([])
31-
expect(ShellArity.prefix(["single"], "bash")).toEqual(["single"])
32-
expect(ShellArity.prefix(["git"], "bash")).toEqual(["git"])
33-
})
34-
35-
test("powershell verb-noun structures", () => {
36-
expect(ShellArity.prefix(["Get-Content", "file.txt"], "pwsh")).toEqual(["Get-Content"])
37-
expect(ShellArity.prefix(["Remove-Item", "-Recurse", "dir"], "powershell")).toEqual(["Remove-Item"])
38-
expect(ShellArity.prefix(["git", "checkout", "main"], "pwsh")).toEqual(["git", "checkout"])
39-
expect(ShellArity.prefix(["redis-cli", "ping"], "pwsh")).toEqual(["redis-cli", "ping"])
30+
expect(BashArity.prefix([])).toEqual([])
31+
expect(BashArity.prefix(["single"])).toEqual(["single"])
32+
expect(BashArity.prefix(["git"])).toEqual(["git"])
4033
})

packages/opencode/test/session/llm.test.ts

Lines changed: 1 addition & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"
22
import path from "path"
3-
import { tool, type ModelMessage, type Tool } from "ai"
3+
import { tool, type ModelMessage } from "ai"
44
import { Cause, Effect, Exit, Stream } from "effect"
55
import z from "zod"
66
import { makeRuntime } from "../../src/effect/run-service"
@@ -119,17 +119,6 @@ describe("session.llm.hasToolCalls", () => {
119119
})
120120
})
121121

122-
describe("session.llm.repairToolName", () => {
123-
test("normalizes bash casing when available", () => {
124-
expect(LLM.repairToolName("bash", { bash: {} as Tool })).toBe("bash")
125-
expect(LLM.repairToolName("BASH", { bash: {} as Tool })).toBe("bash")
126-
})
127-
128-
test("returns undefined when normalized tool is unavailable", () => {
129-
expect(LLM.repairToolName("bash", { read: {} as Tool })).toBeUndefined()
130-
})
131-
})
132-
133122
type Capture = {
134123
url: URL
135124
headers: Headers
@@ -572,100 +561,6 @@ describe("session.llm.stream", () => {
572561
})
573562
})
574563

575-
test("disables bash when user message uses bash override", async () => {
576-
const server = state.server
577-
if (!server) {
578-
throw new Error("Server not initialized")
579-
}
580-
581-
const providerID = "alibaba"
582-
const modelID = "qwen-plus"
583-
const fixture = await loadFixture(providerID, modelID)
584-
const model = fixture.model
585-
586-
const request = waitRequest(
587-
"/chat/completions",
588-
new Response(createChatStream("Hello"), {
589-
status: 200,
590-
headers: { "Content-Type": "text/event-stream" },
591-
}),
592-
)
593-
594-
await using tmp = await tmpdir({
595-
init: async (dir) => {
596-
await Bun.write(
597-
path.join(dir, "opencode.json"),
598-
JSON.stringify({
599-
$schema: "https://opencode.ai/config.json",
600-
enabled_providers: [providerID],
601-
provider: {
602-
[providerID]: {
603-
options: {
604-
apiKey: "test-key",
605-
baseURL: `${server.url.origin}/v1`,
606-
},
607-
},
608-
},
609-
}),
610-
)
611-
},
612-
})
613-
614-
await Instance.provide({
615-
directory: tmp.path,
616-
fn: async () => {
617-
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id))
618-
const sessionID = SessionID.make("session-test-legacy-bash-tools")
619-
const agent = {
620-
name: "test",
621-
mode: "primary",
622-
options: {},
623-
permission: [],
624-
} satisfies Agent.Info
625-
626-
const user = {
627-
id: MessageID.make("user-legacy-bash-tools"),
628-
sessionID,
629-
role: "user",
630-
time: { created: Date.now() },
631-
agent: agent.name,
632-
model: { providerID: ProviderID.make(providerID), modelID: resolved.id },
633-
tools: { bash: false },
634-
} satisfies MessageV2.User
635-
636-
await drain({
637-
user,
638-
sessionID,
639-
model: resolved,
640-
agent,
641-
system: ["You are a helpful assistant."],
642-
messages: [{ role: "user", content: "Hello" }],
643-
tools: {
644-
bash: tool({
645-
description: "Run a shell command",
646-
inputSchema: z.object({ command: z.string() }),
647-
execute: async () => ({ output: "" }),
648-
}),
649-
read: tool({
650-
description: "Read a file",
651-
inputSchema: z.object({ filePath: z.string() }),
652-
execute: async () => ({ output: "" }),
653-
}),
654-
},
655-
})
656-
657-
const capture = await request
658-
const names =
659-
(capture.body.tools as Array<{ function?: { name?: string } }> | undefined)?.flatMap((item) =>
660-
item.function?.name ? [item.function.name] : [],
661-
) ?? []
662-
663-
expect(names).not.toContain("bash")
664-
expect(names).toContain("read")
665-
},
666-
})
667-
})
668-
669564
test("sends responses API payload for OpenAI models", async () => {
670565
const server = state.server
671566
if (!server) {

0 commit comments

Comments
 (0)