mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-08 10:10:58 +00:00
Apply PR #20039: feat: bash->shell tool + pwsh/powershell/cmd/bash specific tool definitions so agents work better
This commit is contained in:
commit
718f8ba1e4
57 changed files with 877 additions and 338 deletions
|
|
@ -14,8 +14,9 @@ export function SessionPermissionDock(props: {
|
|||
|
||||
const toolDescription = () => {
|
||||
const key = `settings.permissions.tool.${props.request.permission}.description`
|
||||
const fallback = props.request.permission === "shell" ? "settings.permissions.tool.bash.description" : key
|
||||
const value = language.t(key as Parameters<typeof language.t>[0])
|
||||
if (value === key) return ""
|
||||
if (value === key) return fallback === key ? "" : language.t(fallback as Parameters<typeof language.t>[0])
|
||||
return value
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ import { LoadAPIKeyError } from "ai"
|
|||
import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import { applyPatch } from "diff"
|
||||
import { InstallationVersion } from "@/installation/version"
|
||||
import { ShellToolID } from "@/tool/shell/id"
|
||||
|
||||
type ModeOption = { id: string; name: string; description?: string }
|
||||
type ModelOption = { modelId: string; name: string }
|
||||
|
|
@ -144,7 +145,7 @@ export class Agent implements ACPAgent {
|
|||
private sessionManager: ACPSessionManager
|
||||
private eventAbort = new AbortController()
|
||||
private eventStarted = false
|
||||
private bashSnapshots = new Map<string, string>()
|
||||
private shellSnapshots = new Map<string, string>()
|
||||
private toolStarts = new Set<string>()
|
||||
private permissionQueues = new Map<string, Promise<void>>()
|
||||
private permissionOptions: PermissionOption[] = [
|
||||
|
|
@ -283,16 +284,16 @@ export class Agent implements ACPAgent {
|
|||
|
||||
switch (part.state.status) {
|
||||
case "pending":
|
||||
this.bashSnapshots.delete(part.callID)
|
||||
this.shellSnapshots.delete(part.callID)
|
||||
return
|
||||
|
||||
case "running":
|
||||
const output = this.bashOutput(part)
|
||||
const output = this.shellOutput(part)
|
||||
const content: ToolCallContent[] = []
|
||||
if (output) {
|
||||
const hash = Hash.fast(output)
|
||||
if (part.tool === "bash") {
|
||||
if (this.bashSnapshots.get(part.callID) === hash) {
|
||||
if (ShellToolID.normalize(part.tool) === ShellToolID.id) {
|
||||
if (this.shellSnapshots.get(part.callID) === hash) {
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
|
|
@ -311,7 +312,7 @@ export class Agent implements ACPAgent {
|
|||
})
|
||||
return
|
||||
}
|
||||
this.bashSnapshots.set(part.callID, hash)
|
||||
this.shellSnapshots.set(part.callID, hash)
|
||||
}
|
||||
content.push({
|
||||
type: "content",
|
||||
|
|
@ -342,7 +343,7 @@ export class Agent implements ACPAgent {
|
|||
|
||||
case "completed": {
|
||||
this.toolStarts.delete(part.callID)
|
||||
this.bashSnapshots.delete(part.callID)
|
||||
this.shellSnapshots.delete(part.callID)
|
||||
const kind = toToolKind(part.tool)
|
||||
const content: ToolCallContent[] = [
|
||||
{
|
||||
|
|
@ -423,7 +424,7 @@ export class Agent implements ACPAgent {
|
|||
}
|
||||
case "error":
|
||||
this.toolStarts.delete(part.callID)
|
||||
this.bashSnapshots.delete(part.callID)
|
||||
this.shellSnapshots.delete(part.callID)
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
|
|
@ -837,10 +838,10 @@ export class Agent implements ACPAgent {
|
|||
await this.toolStart(sessionId, part)
|
||||
switch (part.state.status) {
|
||||
case "pending":
|
||||
this.bashSnapshots.delete(part.callID)
|
||||
this.shellSnapshots.delete(part.callID)
|
||||
break
|
||||
case "running":
|
||||
const output = this.bashOutput(part)
|
||||
const output = this.shellOutput(part)
|
||||
const runningContent: ToolCallContent[] = []
|
||||
if (output) {
|
||||
runningContent.push({
|
||||
|
|
@ -871,7 +872,7 @@ export class Agent implements ACPAgent {
|
|||
break
|
||||
case "completed":
|
||||
this.toolStarts.delete(part.callID)
|
||||
this.bashSnapshots.delete(part.callID)
|
||||
this.shellSnapshots.delete(part.callID)
|
||||
const kind = toToolKind(part.tool)
|
||||
const content: ToolCallContent[] = [
|
||||
{
|
||||
|
|
@ -951,7 +952,7 @@ export class Agent implements ACPAgent {
|
|||
break
|
||||
case "error":
|
||||
this.toolStarts.delete(part.callID)
|
||||
this.bashSnapshots.delete(part.callID)
|
||||
this.shellSnapshots.delete(part.callID)
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
|
|
@ -1105,8 +1106,8 @@ export class Agent implements ACPAgent {
|
|||
}
|
||||
}
|
||||
|
||||
private bashOutput(part: ToolPart) {
|
||||
if (part.tool !== "bash") return
|
||||
private shellOutput(part: ToolPart) {
|
||||
if (ShellToolID.normalize(part.tool) !== ShellToolID.id) return
|
||||
if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return
|
||||
const output = part.state.metadata["output"]
|
||||
if (typeof output !== "string") return
|
||||
|
|
@ -1549,9 +1550,9 @@ export class Agent implements ACPAgent {
|
|||
|
||||
function toToolKind(toolName: string): ToolKind {
|
||||
const tool = toolName.toLocaleLowerCase()
|
||||
if (ShellToolID.normalize(tool) === ShellToolID.id) return "execute"
|
||||
|
||||
switch (tool) {
|
||||
case "bash":
|
||||
return "execute"
|
||||
case "webfetch":
|
||||
return "fetch"
|
||||
|
||||
|
|
@ -1576,6 +1577,8 @@ function toToolKind(toolName: string): ToolKind {
|
|||
|
||||
function toLocations(toolName: string, input: Record<string, any>): { path: string }[] {
|
||||
const tool = toolName.toLocaleLowerCase()
|
||||
if (ShellToolID.normalize(tool) === ShellToolID.id) return []
|
||||
|
||||
switch (tool) {
|
||||
case "read":
|
||||
case "edit":
|
||||
|
|
@ -1584,8 +1587,6 @@ function toLocations(toolName: string, input: Record<string, any>): { path: stri
|
|||
case "glob":
|
||||
case "grep":
|
||||
return input["path"] ? [{ path: input["path"] }] : []
|
||||
case "bash":
|
||||
return []
|
||||
default:
|
||||
return []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ export const layer = Layer.effect(
|
|||
grep: "allow",
|
||||
glob: "allow",
|
||||
list: "allow",
|
||||
bash: "allow",
|
||||
shell: "allow",
|
||||
webfetch: "allow",
|
||||
websearch: "allow",
|
||||
codesearch: "allow",
|
||||
|
|
|
|||
|
|
@ -9,10 +9,10 @@ Guidelines:
|
|||
- Use Glob for broad file pattern matching
|
||||
- Use Grep for searching file contents with regex
|
||||
- Use Read when you know the specific file path you need to read
|
||||
- Use Bash for file operations like copying, moving, or listing directory contents
|
||||
- Use Shell for file operations like copying, moving, or listing directory contents
|
||||
- Adapt your search approach based on the thoroughness level specified by the caller
|
||||
- Return file paths as absolute paths in your final response
|
||||
- For clear communication, avoid using emojis
|
||||
- Do not create any files, or run bash commands that modify the user's system state in any way
|
||||
- Do not create any files, or run shell commands that modify the user's system state in any way
|
||||
|
||||
Complete the user's search request efficiently and report your findings clearly.
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ Your output must be:
|
|||
<rules>
|
||||
- you MUST use the same language as the user message you are summarizing
|
||||
- Title must be grammatically correct and read naturally - no word salad
|
||||
- Never include tool names in the title (e.g. "read tool", "bash tool", "edit tool")
|
||||
- Never include tool names in the title (e.g. "read tool", "shell tool", "edit tool")
|
||||
- Focus on the main topic or question the user needs to retrieve
|
||||
- Vary your phrasing - avoid repetitive patterns like always starting with "Analyzing"
|
||||
- When a file is mentioned, focus on WHAT the user wants to do WITH the file, not just that they shared it
|
||||
|
|
|
|||
|
|
@ -10,12 +10,12 @@ import fs from "fs/promises"
|
|||
import { Filesystem } from "../../util"
|
||||
import matter from "gray-matter"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { ShellToolID } from "../../tool/shell/id"
|
||||
import { EOL } from "os"
|
||||
import type { Argv } from "yargs"
|
||||
|
||||
type AgentMode = "all" | "primary" | "subagent"
|
||||
|
||||
const AVAILABLE_TOOLS = ["bash", "read", "write", "edit", "glob", "grep", "webfetch", "task", "todowrite"]
|
||||
const AVAILABLE_TOOLS = ["shell", "read", "write", "edit", "glob", "grep", "webfetch", "task", "todowrite"]
|
||||
|
||||
const AgentCreateCommand = cmd({
|
||||
command: "create",
|
||||
|
|
@ -123,7 +123,17 @@ const AgentCreateCommand = cmd({
|
|||
// Select tools
|
||||
let selectedTools: string[]
|
||||
if (cliTools !== undefined) {
|
||||
selectedTools = cliTools ? cliTools.split(",").map((t) => t.trim()) : AVAILABLE_TOOLS
|
||||
selectedTools = cliTools
|
||||
? [
|
||||
...new Set(
|
||||
cliTools
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.map(ShellToolID.normalize)
|
||||
.filter(Boolean),
|
||||
),
|
||||
]
|
||||
: AVAILABLE_TOOLS
|
||||
} else {
|
||||
const result = await prompts.multiselect({
|
||||
message: "Select tools to enable (Space to toggle)",
|
||||
|
|
|
|||
|
|
@ -879,7 +879,7 @@ export const GithubRunCommand = cmd({
|
|||
function subscribeSessionEvents() {
|
||||
const TOOL: Record<string, [string, string]> = {
|
||||
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
|
||||
bash: ["Bash", UI.Style.TEXT_DANGER_BOLD],
|
||||
shell: ["Shell", UI.Style.TEXT_DANGER_BOLD],
|
||||
edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD],
|
||||
glob: ["Glob", UI.Style.TEXT_INFO_BOLD],
|
||||
grep: ["Grep", UI.Style.TEXT_INFO_BOLD],
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ import { CodeSearchTool } from "../../tool/codesearch"
|
|||
import { WebSearchTool } from "../../tool/websearch"
|
||||
import { TaskTool } from "../../tool/task"
|
||||
import { SkillTool } from "../../tool/skill"
|
||||
import { BashTool } from "../../tool/bash"
|
||||
import { ShellTool } from "../../tool/shell"
|
||||
import { ShellToolID } from "../../tool/shell/id"
|
||||
import { TodoWriteTool } from "../../tool/todo"
|
||||
import { Locale } from "../../util"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
|
|
@ -183,7 +184,7 @@ function skill(info: ToolProps<typeof SkillTool>) {
|
|||
})
|
||||
}
|
||||
|
||||
function bash(info: ToolProps<typeof BashTool>) {
|
||||
function shell(info: ToolProps<typeof ShellTool>) {
|
||||
const output = info.part.state.status === "completed" ? info.part.state.output?.trim() : undefined
|
||||
block(
|
||||
{
|
||||
|
|
@ -413,7 +414,7 @@ export const RunCommand = cmd({
|
|||
async function execute(sdk: OpencodeClient) {
|
||||
function tool(part: ToolPart) {
|
||||
try {
|
||||
if (part.tool === "bash") return bash(props<typeof BashTool>(part))
|
||||
if (ShellToolID.normalize(part.tool) === ShellToolID.id) return shell(props<typeof ShellTool>(part))
|
||||
if (part.tool === "glob") return glob(props<typeof GlobTool>(part))
|
||||
if (part.tool === "grep") return grep(props<typeof GrepTool>(part))
|
||||
if (part.tool === "read") return read(props<typeof ReadTool>(part))
|
||||
|
|
|
|||
|
|
@ -92,8 +92,8 @@ const TIPS = [
|
|||
"Use {highlight}$ARGUMENTS{/highlight}, {highlight}$1{/highlight}, {highlight}$2{/highlight} in custom commands for dynamic input",
|
||||
"Use backticks in commands to inject shell output (e.g., {highlight}`git status`{/highlight})",
|
||||
"Add {highlight}.md{/highlight} files to {highlight}.opencode/agent/{/highlight} for specialized AI personas",
|
||||
"Configure per-agent permissions for {highlight}edit{/highlight}, {highlight}bash{/highlight}, and {highlight}webfetch{/highlight} tools",
|
||||
'Use patterns like {highlight}"git *": "allow"{/highlight} for granular bash permissions',
|
||||
"Configure per-agent permissions for {highlight}edit{/highlight}, {highlight}shell{/highlight}, and {highlight}webfetch{/highlight} tools",
|
||||
'Use patterns like {highlight}"git *": "allow"{/highlight} for granular shell permissions',
|
||||
'Set {highlight}"rm -rf *": "deny"{/highlight} to block destructive commands',
|
||||
'Configure {highlight}"git push": "ask"{/highlight} to require approval before pushing',
|
||||
"OpenCode auto-formats files using prettier, gofmt, ruff, and more",
|
||||
|
|
@ -127,7 +127,7 @@ const TIPS = [
|
|||
"Use {highlight}instructions{/highlight} in config to load additional rules files",
|
||||
"Set agent {highlight}temperature{/highlight} from 0.0 (focused) to 1.0 (creative)",
|
||||
"Configure {highlight}steps{/highlight} to limit agentic iterations per request",
|
||||
'Set {highlight}"tools": {"bash": false}{/highlight} to disable specific tools',
|
||||
'Set {highlight}"tools": {"shell": false}{/highlight} to disable specific tools',
|
||||
'Set {highlight}"mcp_*": false{/highlight} to disable all tools from an MCP server',
|
||||
"Override global tool settings per agent configuration",
|
||||
'Set {highlight}"share": "auto"{/highlight} to automatically share all sessions',
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@ import { Locale } from "@/util"
|
|||
import type { Tool } from "@/tool"
|
||||
import type { ReadTool } from "@/tool/read"
|
||||
import type { WriteTool } from "@/tool/write"
|
||||
import { BashTool } from "@/tool/bash"
|
||||
import { ShellTool } from "@/tool/shell"
|
||||
import { ShellToolID } from "@/tool/shell/id"
|
||||
import type { GlobTool } from "@/tool/glob"
|
||||
import { TodoWriteTool } from "@/tool/todo"
|
||||
import type { GrepTool } from "@/tool/grep"
|
||||
|
|
@ -1550,8 +1551,8 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
|
|||
return (
|
||||
<Show when={!shouldHide()}>
|
||||
<Switch>
|
||||
<Match when={props.part.tool === "bash"}>
|
||||
<Bash {...toolprops} />
|
||||
<Match when={ShellToolID.normalize(props.part.tool) === ShellToolID.id}>
|
||||
<Shell {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "glob"}>
|
||||
<Glob {...toolprops} />
|
||||
|
|
@ -1785,7 +1786,7 @@ function BlockTool(props: {
|
|||
)
|
||||
}
|
||||
|
||||
function Bash(props: ToolProps<typeof BashTool>) {
|
||||
function Shell(props: ToolProps<typeof ShellTool>) {
|
||||
const { theme } = useTheme()
|
||||
const sync = useSync()
|
||||
const isRunning = createMemo(() => props.part.state.status === "running")
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
|
|||
import { Keybind } from "@/util"
|
||||
import { Locale } from "@/util"
|
||||
import { Global } from "@/global"
|
||||
import { ShellToolID } from "@/tool/shell/id"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { useTuiConfig } from "../../context/tui-config"
|
||||
|
|
@ -287,7 +288,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
|
|||
}
|
||||
}
|
||||
|
||||
if (permission === "bash") {
|
||||
if (ShellToolID.normalize(permission) === ShellToolID.id) {
|
||||
const title =
|
||||
typeof data.description === "string" && data.description ? data.description : "Shell command"
|
||||
const command = typeof data.command === "string" ? data.command : ""
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { InvalidError } from "./error"
|
|||
import * as ConfigMarkdown from "./markdown"
|
||||
import { ConfigModelID } from "./model-id"
|
||||
import { ConfigPermission } from "./permission"
|
||||
import { ShellToolID } from "@/tool/shell/id"
|
||||
|
||||
const log = Log.create({ service: "config" })
|
||||
|
||||
|
|
@ -86,10 +87,14 @@ const normalize = (agent: z.infer<typeof Info>) => {
|
|||
const permission: ConfigPermission.Info = {}
|
||||
for (const [tool, enabled] of Object.entries(agent.tools ?? {})) {
|
||||
const action = enabled ? "allow" : "deny"
|
||||
if (tool === "write" || tool === "edit" || tool === "patch") {
|
||||
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
|
||||
permission.edit = action
|
||||
continue
|
||||
}
|
||||
if (ShellToolID.normalize(tool) === ShellToolID.id) {
|
||||
permission.shell = action
|
||||
continue
|
||||
}
|
||||
permission[tool] = action
|
||||
}
|
||||
globalThis.Object.assign(permission, agent.permission)
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import { ConfigServer } from "./server"
|
|||
import { ConfigSkills } from "./skills"
|
||||
import { ConfigVariable } from "./variable"
|
||||
import { Npm } from "@/npm"
|
||||
import { ShellToolID } from "@/tool/shell/id"
|
||||
|
||||
const log = Log.create({ service: "config" })
|
||||
|
||||
|
|
@ -661,10 +662,14 @@ export const layer = Layer.effect(
|
|||
const perms: Record<string, ConfigPermission.Action> = {}
|
||||
for (const [tool, enabled] of Object.entries(result.tools)) {
|
||||
const action: ConfigPermission.Action = enabled ? "allow" : "deny"
|
||||
if (tool === "write" || tool === "edit" || tool === "patch") {
|
||||
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
|
||||
perms.edit = action
|
||||
continue
|
||||
}
|
||||
if (ShellToolID.normalize(tool) === ShellToolID.id) {
|
||||
perms.shell = action
|
||||
continue
|
||||
}
|
||||
perms[tool] = action
|
||||
}
|
||||
result.permission = mergeDeep(perms, result.permission ?? {})
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ const InputObject = Schema.StructWithRest(
|
|||
glob: Schema.optional(Rule),
|
||||
grep: Schema.optional(Rule),
|
||||
list: Schema.optional(Rule),
|
||||
bash: Schema.optional(Rule),
|
||||
shell: Schema.optional(Rule),
|
||||
task: Schema.optional(Rule),
|
||||
external_directory: Schema.optional(Rule),
|
||||
todowrite: Schema.optional(Action),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Wildcard } from "@/util"
|
||||
import { ShellToolID } from "@/tool/shell/id"
|
||||
|
||||
type Rule = {
|
||||
permission: string
|
||||
|
|
@ -7,9 +8,10 @@ type Rule = {
|
|||
}
|
||||
|
||||
export function evaluate(permission: string, pattern: string, ...rulesets: Rule[][]): Rule {
|
||||
const next = ShellToolID.normalize(permission)
|
||||
const rules = rulesets.flat()
|
||||
const match = rules.findLast(
|
||||
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
|
||||
(rule) => Wildcard.match(next, ShellToolID.normalize(rule.permission)) && Wildcard.match(pattern, rule.pattern),
|
||||
)
|
||||
return match ?? { action: "ask", permission, pattern: "*" }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { Deferred, Effect, Layer, Schema, Context } from "effect"
|
|||
import os from "os"
|
||||
import { evaluate as evalRule } from "./evaluate"
|
||||
import { PermissionID } from "./schema"
|
||||
import { ShellToolID } from "@/tool/shell/id"
|
||||
|
||||
const log = Log.create({ service: "permission" })
|
||||
|
||||
|
|
@ -185,7 +186,9 @@ export const layer = Layer.effect(
|
|||
log.info("evaluated", { permission: request.permission, pattern, action: rule })
|
||||
if (rule.action === "deny") {
|
||||
return yield* new DeniedError({
|
||||
ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
|
||||
ruleset: ruleset.filter((rule) =>
|
||||
Wildcard.match(ShellToolID.normalize(request.permission), ShellToolID.normalize(rule.permission)),
|
||||
),
|
||||
})
|
||||
}
|
||||
if (rule.action === "allow") continue
|
||||
|
|
@ -290,12 +293,13 @@ function expand(pattern: string): string {
|
|||
export function fromConfig(permission: ConfigPermission.Info) {
|
||||
const ruleset: Ruleset = []
|
||||
for (const [key, value] of Object.entries(permission)) {
|
||||
const permission = ShellToolID.normalize(key)
|
||||
if (typeof value === "string") {
|
||||
ruleset.push({ permission: key, action: value, pattern: "*" })
|
||||
ruleset.push({ permission, action: value, pattern: "*" })
|
||||
continue
|
||||
}
|
||||
ruleset.push(
|
||||
...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action })),
|
||||
...Object.entries(value).map(([pattern, action]) => ({ permission, pattern: expand(pattern), action })),
|
||||
)
|
||||
}
|
||||
return ruleset
|
||||
|
|
@ -310,8 +314,8 @@ const EDIT_TOOLS = ["edit", "write", "apply_patch"]
|
|||
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
|
||||
const result = new Set<string>()
|
||||
for (const tool of tools) {
|
||||
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
|
||||
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
|
||||
const permission = EDIT_TOOLS.includes(tool) ? "edit" : ShellToolID.normalize(tool)
|
||||
const rule = ruleset.findLast((rule) => Wildcard.match(permission, ShellToolID.normalize(rule.permission)))
|
||||
if (!rule) continue
|
||||
if (rule.pattern === "*" && rule.action === "deny") result.add(tool)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { Auth } from "@/auth"
|
|||
import { Installation } from "@/installation"
|
||||
import { InstallationVersion } from "@/installation/version"
|
||||
import { EffectBridge } from "@/effect"
|
||||
import { ShellToolID } from "@/tool/shell/id"
|
||||
import * as Option from "effect/Option"
|
||||
import * as OtelTracer from "@effect/opentelemetry/Tracer"
|
||||
|
||||
|
|
@ -206,6 +207,8 @@ const live: Layer.Layer<
|
|||
input.model.providerID.toLowerCase().includes("litellm") ||
|
||||
input.model.api.id.toLowerCase().includes("litellm")
|
||||
|
||||
const repair = (toolName: string) => repairToolName(toolName, tools)
|
||||
|
||||
// LiteLLM/Bedrock rejects requests where the message history contains tool
|
||||
// calls but no tools param is present. When there are no active tools (e.g.
|
||||
// during compaction), inject a stub tool to satisfy the validation requirement.
|
||||
|
|
@ -239,7 +242,7 @@ const live: Layer.Layer<
|
|||
workflowModel.sessionID = input.sessionID
|
||||
workflowModel.systemPrompt = system.join("\n")
|
||||
workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => {
|
||||
const t = tools[toolName]
|
||||
const t = tools[repair(toolName) ?? toolName]
|
||||
if (!t || !t.execute) {
|
||||
return { result: "", error: `Unknown tool: ${toolName}` }
|
||||
}
|
||||
|
|
@ -337,15 +340,15 @@ const live: Layer.Layer<
|
|||
})
|
||||
},
|
||||
async experimental_repairToolCall(failed) {
|
||||
const lower = failed.toolCall.toolName.toLowerCase()
|
||||
if (lower !== failed.toolCall.toolName && tools[lower]) {
|
||||
const repaired = repair(failed.toolCall.toolName)
|
||||
if (repaired && repaired !== failed.toolCall.toolName) {
|
||||
l.info("repairing tool call", {
|
||||
tool: failed.toolCall.toolName,
|
||||
repaired: lower,
|
||||
repaired,
|
||||
})
|
||||
return {
|
||||
...failed.toolCall,
|
||||
toolName: lower,
|
||||
toolName: repaired,
|
||||
}
|
||||
}
|
||||
return {
|
||||
|
|
@ -442,12 +445,23 @@ export const defaultLayer = Layer.suspend(() =>
|
|||
),
|
||||
)
|
||||
|
||||
export function repairToolName(toolName: string, tools: Record<string, Tool>) {
|
||||
const next = ShellToolID.normalize(toolName.toLowerCase())
|
||||
if (!tools[next]) return
|
||||
return next
|
||||
}
|
||||
|
||||
function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "permission" | "user">) {
|
||||
const disabled = Permission.disabled(
|
||||
Object.keys(input.tools),
|
||||
Permission.merge(input.agent.permission, input.permission ?? []),
|
||||
)
|
||||
return Record.filter(input.tools, (_, k) => input.user.tools?.[k] !== false && !disabled.has(k))
|
||||
return Record.filter(input.tools, (_, k) => {
|
||||
const userTool = input.user.tools?.[k]
|
||||
if (userTool !== undefined) return userTool !== false && !disabled.has(k)
|
||||
if (k === ShellToolID.id && input.user.tools?.[ShellToolID.legacy] === false) return false
|
||||
return !disabled.has(k)
|
||||
})
|
||||
}
|
||||
|
||||
// Check if messages contain any tool-call content
|
||||
|
|
|
|||
|
|
@ -672,7 +672,7 @@ const part = (row: typeof PartTable.$inferSelect) =>
|
|||
id: row.id,
|
||||
sessionID: row.session_id,
|
||||
messageID: row.message_id,
|
||||
}) as Part
|
||||
} as Part)
|
||||
|
||||
const older = (row: Cursor) =>
|
||||
or(lt(MessageTable.time_created, row.time), and(eq(MessageTable.time_created, row.time), lt(MessageTable.id, row.id)))
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import { Permission } from "@/permission"
|
|||
import { SessionStatus } from "./status"
|
||||
import { LLM } from "./llm"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { ShellToolID } from "@/tool/shell/id"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Truncate } from "@/tool"
|
||||
import { decodeDataUrl } from "@/util/data-url"
|
||||
|
|
@ -773,7 +774,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||
id: PartID.ascending(),
|
||||
messageID: msg.id,
|
||||
sessionID: input.sessionID,
|
||||
tool: "bash",
|
||||
tool: ShellToolID.id,
|
||||
callID: ulid(),
|
||||
state: {
|
||||
status: "running",
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ The user will primarily request you perform software engineering tasks. This inc
|
|||
- When WebFetch returns a message about a redirect to a different host, you should immediately make a new WebFetch request with the redirect URL provided in the response.
|
||||
- You can call multiple tools in a single response. If you intend to call multiple tools and there are no dependencies between them, make all independent tool calls in parallel. Maximize use of parallel tool calls where possible to increase efficiency. However, if some tool calls depend on previous calls to inform dependent values, do NOT call these tools in parallel and instead call them sequentially. For instance, if one operation must complete before another starts, run these operations sequentially instead. Never use placeholders or guess missing parameters in tool calls.
|
||||
- If the user specifies that they want you to run tools "in parallel", you MUST send a single message with multiple tool use content blocks. For example, if you need to launch multiple agents in parallel, send a single message with multiple Task tool calls.
|
||||
- Use specialized tools instead of bash commands when possible, as this provides a better user experience. For file operations, use dedicated tools: Read for reading files instead of cat/head/tail, Edit for editing instead of sed/awk, and Write for creating files instead of cat with heredoc or echo redirection. Reserve bash tools exclusively for actual system commands and terminal operations that require shell execution. NEVER use bash echo or other command-line tools to communicate thoughts, explanations, or instructions to the user. Output all communication directly in your response text instead.
|
||||
- Use specialized tools instead of shell commands when possible, as this provides a better user experience. For file operations, use dedicated tools: Read for reading files instead of cat/head/tail, Edit for editing instead of sed/awk, and Write for creating files instead of cat with heredoc or echo redirection. Reserve the shell tool exclusively for actual system commands and terminal operations that require shell execution. NEVER use shell echo or other command-line tools to communicate thoughts, explanations, or instructions to the user. Output all communication directly in your response text instead.
|
||||
- VERY IMPORTANT: When exploring the codebase to gather context or to answer a question that is not a needle query for a specific file/class/function, it is CRITICAL that you use the Task tool instead of running search commands directly.
|
||||
<example>
|
||||
user: Where are errors from the client handled?
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ If the user asks for help or wants to give feedback inform them of the following
|
|||
When the user directly asks about opencode (eg 'can opencode do...', 'does opencode have...') or asks in second person (eg 'are you able...', 'can you do...'), first use the WebFetch tool to gather information to answer the question from opencode docs at https://opencode.ai
|
||||
|
||||
# Tone and style
|
||||
You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
|
||||
You should be concise, direct, and to the point. When you run a non-trivial shell command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
|
||||
Remember that your output will be displayed on a command line interface. Your responses can use GitHub-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
|
||||
Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session.
|
||||
If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences.
|
||||
|
|
@ -89,7 +89,7 @@ NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTAN
|
|||
|
||||
# Tool usage policy
|
||||
- When doing file search, prefer to use the Task tool in order to reduce context usage.
|
||||
- You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. When making multiple bash tool calls, you MUST send a single message with multiple tools calls to run the calls in parallel. For example, if you need to run "git status" and "git diff", send a single message with two tool calls to run the calls in parallel.
|
||||
- You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. When making multiple shell tool calls, you MUST send a single message with multiple tools calls to run the calls in parallel. For example, if you need to run "git status" and "git diff", send a single message with two tool calls to run the calls in parallel.
|
||||
|
||||
You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.
|
||||
|
||||
|
|
|
|||
|
|
@ -19,18 +19,18 @@ You are opencode, an interactive CLI agent specializing in software engineering
|
|||
When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this sequence:
|
||||
1. **Understand:** Think about the user's request and the relevant codebase context. Use 'grep' and 'glob' search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions. Use 'read' to understand context and validate any assumptions you may have.
|
||||
2. **Plan:** Build a coherent and grounded (based on the understanding in step 1) plan for how you intend to resolve the user's task. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process. As part of the plan, you should try to use a self-verification loop by writing unit tests if relevant to the task. Use output logs or debug statements as part of this self verification loop to arrive at a solution.
|
||||
3. **Implement:** Use the available tools (e.g., 'edit', 'write' 'bash' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates').
|
||||
3. **Implement:** Use the available tools (e.g., 'edit', 'write' 'shell' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates').
|
||||
4. **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands.
|
||||
5. **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to.
|
||||
|
||||
## New Applications
|
||||
|
||||
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write', 'edit' and 'bash'.
|
||||
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write', 'edit' and 'shell'.
|
||||
|
||||
1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions.
|
||||
2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern, and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner.
|
||||
3. **User Approval:** Obtain user approval for the proposed plan.
|
||||
4. **Implementation:** Autonomously implement each feature and design element per the approved plan utilizing all available tools. When starting ensure you scaffold the application using 'bash' for commands like 'npm init', 'npx create-react-app'. Aim for full scope completion. Proactively create or source necessary placeholder assets (e.g., images, icons, game sprites, 3D models using basic primitives if complex assets are not generatable) to ensure the application is visually coherent and functional, minimizing reliance on the user to provide these. If the model can generate simple assets (e.g., a uniformly colored square sprite, a simple 3D cube), it should do so. Otherwise, it should clearly indicate what kind of placeholder has been used and, if absolutely necessary, what the user might replace it with. Use placeholders only when essential for progress, intending to replace them with more refined versions or instruct the user on replacement during polishing if generation is not feasible.
|
||||
4. **Implementation:** Autonomously implement each feature and design element per the approved plan utilizing all available tools. When starting ensure you scaffold the application using 'shell' for commands like 'npm init', 'npx create-react-app'. Aim for full scope completion. Proactively create or source necessary placeholder assets (e.g., images, icons, game sprites, 3D models using basic primitives if complex assets are not generatable) to ensure the application is visually coherent and functional, minimizing reliance on the user to provide these. If the model can generate simple assets (e.g., a uniformly colored square sprite, a simple 3D cube), it should do so. Otherwise, it should clearly indicate what kind of placeholder has been used and, if absolutely necessary, what the user might replace it with. Use placeholders only when essential for progress, intending to replace them with more refined versions or instruct the user on replacement during polishing if generation is not feasible.
|
||||
5. **Verify:** Review work against the original request, the approved plan. Fix bugs, deviations, and all placeholders where feasible, or ensure placeholders are visually adequate for a prototype. Ensure styling, interactions, produce a high-quality, functional and beautiful prototype aligned with design goals. Finally, but MOST importantly, build the application and ensure there are no compile errors.
|
||||
6. **Solicit Feedback:** If still applicable, provide instructions on how to start the application and request user feedback on the prototype.
|
||||
|
||||
|
|
@ -46,13 +46,13 @@ When requested to perform tasks like fixing bugs, adding features, refactoring,
|
|||
- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly (1-2 sentences) without excessive justification. Offer alternatives if appropriate.
|
||||
|
||||
## Security and Safety Rules
|
||||
- **Explain Critical Commands:** Before executing commands with 'bash' that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
|
||||
- **Explain Critical Commands:** Before executing commands with 'shell' that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **File Paths:** Always use absolute paths when referring to files with tools like 'read' or 'write'. Relative paths are not supported. You must provide an absolute path.
|
||||
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
|
||||
- **Command Execution:** Use the 'bash' tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
- **Command Execution:** Use the 'shell' tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
- **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user.
|
||||
- **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user.
|
||||
- **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward.
|
||||
|
|
@ -79,7 +79,7 @@ model: [tool_call: ls for path '/path/to/project']
|
|||
|
||||
<example>
|
||||
user: start the server implemented in server.js
|
||||
model: [tool_call: bash for 'node server.js &' because it must run in the background]
|
||||
model: [tool_call: shell for 'node server.js &' because it must run in the background]
|
||||
</example>
|
||||
|
||||
<example>
|
||||
|
|
@ -106,7 +106,7 @@ user: Yes
|
|||
model:
|
||||
[tool_call: write or edit to apply the refactoring to 'src/auth.py']
|
||||
Refactoring complete. Running verification...
|
||||
[tool_call: bash for 'ruff check src/auth.py && pytest']
|
||||
[tool_call: shell for 'ruff check src/auth.py && pytest']
|
||||
(After verification passes)
|
||||
All checks passed. This is a stable checkpoint.
|
||||
|
||||
|
|
@ -125,7 +125,7 @@ Now I'll look for existing or related test files to understand current testing c
|
|||
(After reviewing existing tests and the file content)
|
||||
[tool_call: write to create /path/to/someFile.test.ts with the test code]
|
||||
I've written the tests. Now I'll run the project's test command to verify them.
|
||||
[tool_call: bash for 'npm run test']
|
||||
[tool_call: shell for 'npm run test']
|
||||
</example>
|
||||
|
||||
<example>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ You are OpenCode, You and the user share the same workspace and collaborate to a
|
|||
You are a deeply pragmatic, effective software engineer. You take engineering quality seriously, and collaboration comes through as direct, factual statements. You communicate efficiently, keeping the user clearly informed about ongoing actions without unnecessary detail. You build context by examining the codebase first without making assumptions or jumping to conclusions. You think through the nuances of the code you encounter, and embody the mentality of a skilled senior software engineer.
|
||||
|
||||
- When searching for text or files, prefer using Glob and Grep tools (they are powered by `rg`)
|
||||
- Parallelize tool calls whenever possible - especially file reads. Use `multi_tool_use.parallel` to parallelize tool calls and only this. Never chain together bash commands with separators like `echo "====";` as this renders to the user poorly.
|
||||
- Parallelize tool calls whenever possible - especially file reads. Use `multi_tool_use.parallel` to parallelize tool calls and only this. Never chain together shell commands with separators like `echo "====";` as this renders to the user poorly.
|
||||
|
||||
## Editing Approach
|
||||
|
||||
|
|
|
|||
|
|
@ -30,8 +30,8 @@ When building something from scratch, you should:
|
|||
Always use tools to implement your code changes:
|
||||
|
||||
- Use `write`/`edit` to create or modify source files. Code that only appears in your text response is NOT saved to the file system and will not take effect.
|
||||
- Use `bash` to run and test your code after writing it.
|
||||
- Iterate: if tests fail, read the error, fix the code with `write`/`edit`, and re-test with `bash`.
|
||||
- Use `shell` to run and test your code after writing it.
|
||||
- Iterate: if tests fail, read the error, fix the code with `write`/`edit`, and re-test with `shell`.
|
||||
|
||||
When working on an existing codebase, you should:
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
CRITICAL: Plan mode ACTIVE - you are in READ-ONLY phase. STRICTLY FORBIDDEN:
|
||||
ANY file edits, modifications, or system changes. Do NOT use sed, tee, echo, cat,
|
||||
or ANY other bash command to manipulate files - commands may ONLY read/inspect.
|
||||
or ANY other shell command to manipulate files - commands may ONLY read/inspect.
|
||||
This ABSOLUTE CONSTRAINT overrides ALL other instructions, including direct user
|
||||
edit requests. You may ONLY observe, analyze, and plan. Any modification attempt
|
||||
is a critical violation. ZERO exceptions.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
You are opencode, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
|
||||
|
||||
# Tone and style
|
||||
You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
|
||||
You should be concise, direct, and to the point. When you run a non-trivial shell command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
|
||||
Remember that your output will be displayed on a command line interface. Your responses can use GitHub-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
|
||||
Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session.
|
||||
If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { PlanExitTool } from "./plan"
|
||||
import { Session } from "../session"
|
||||
import { QuestionTool } from "./question"
|
||||
import { BashTool } from "./bash"
|
||||
import { ShellTool } from "./shell"
|
||||
import { EditTool } from "./edit"
|
||||
import { GlobTool } from "./glob"
|
||||
import { GrepTool } from "./grep"
|
||||
|
|
@ -107,7 +107,7 @@ export const layer: Layer.Layer<
|
|||
const plan = yield* PlanExitTool
|
||||
const webfetch = yield* WebFetchTool
|
||||
const websearch = yield* WebSearchTool
|
||||
const bash = yield* BashTool
|
||||
const shell = yield* ShellTool
|
||||
const codesearch = yield* CodeSearchTool
|
||||
const globtool = yield* GlobTool
|
||||
const writetool = yield* WriteTool
|
||||
|
|
@ -188,7 +188,7 @@ export const layer: Layer.Layer<
|
|||
|
||||
const tool = yield* Effect.all({
|
||||
invalid: Tool.init(invalid),
|
||||
bash: Tool.init(bash),
|
||||
shell: Tool.init(shell),
|
||||
read: Tool.init(read),
|
||||
glob: Tool.init(globtool),
|
||||
grep: Tool.init(greptool),
|
||||
|
|
@ -211,7 +211,7 @@ export const layer: Layer.Layer<
|
|||
builtin: [
|
||||
tool.invalid,
|
||||
...(questionEnabled ? [tool.question] : []),
|
||||
tool.bash,
|
||||
tool.shell,
|
||||
tool.read,
|
||||
tool.glob,
|
||||
tool.grep,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { Schema } from "effect"
|
||||
import { Effect, Stream } from "effect"
|
||||
import os from "os"
|
||||
import { createWriteStream } from "node:fs"
|
||||
import * as Tool from "./tool"
|
||||
import path from "path"
|
||||
import DESCRIPTION from "./bash.txt"
|
||||
import { Log } from "../util"
|
||||
import { Instance } from "../project/instance"
|
||||
import { lazy } from "@/util/lazy"
|
||||
|
|
@ -13,13 +12,16 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
|||
import { fileURLToPath } from "url"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { ShellKind, ShellToolID } from "./shell/id"
|
||||
|
||||
import { BashArity } from "@/permission/arity"
|
||||
import * as Truncate from "./truncate"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import { ShellArity } from "./shell/arity"
|
||||
import { ShellPrompt, type Parameters } from "./shell/prompt"
|
||||
|
||||
export { Parameters } from "./shell/prompt"
|
||||
|
||||
const MAX_METADATA_LENGTH = 30_000
|
||||
const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000
|
||||
|
|
@ -50,18 +52,6 @@ const FILES = new Set([
|
|||
const FLAGS = new Set(["-destination", "-literalpath", "-path"])
|
||||
const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"])
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
command: Schema.String.annotate({ description: "The command to execute" }),
|
||||
timeout: Schema.optional(Schema.Number).annotate({ description: "Optional timeout in milliseconds" }),
|
||||
workdir: Schema.optional(Schema.String).annotate({
|
||||
description: `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`,
|
||||
}),
|
||||
description: Schema.String.annotate({
|
||||
description:
|
||||
"Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'",
|
||||
}),
|
||||
})
|
||||
|
||||
type Part = {
|
||||
type: string
|
||||
text: string
|
||||
|
|
@ -78,7 +68,7 @@ type Chunk = {
|
|||
size: number
|
||||
}
|
||||
|
||||
export const log = Log.create({ service: "bash-tool" })
|
||||
export const log = Log.create({ service: "shell-tool" })
|
||||
|
||||
const resolveWasm = (asset: string) => {
|
||||
if (asset.startsWith("file://")) return fileURLToPath(asset)
|
||||
|
|
@ -248,13 +238,13 @@ function tail(text: string, maxLines: number, maxBytes: number) {
|
|||
}
|
||||
}
|
||||
|
||||
const parse = Effect.fn("BashTool.parse")(function* (command: string, ps: boolean) {
|
||||
const parse = Effect.fn("ShellTool.parse")(function* (command: string, ps: boolean) {
|
||||
const tree = yield* Effect.promise(() => parser().then((p) => (ps ? p.ps : p.bash).parse(command)))
|
||||
if (!tree) throw new Error("Failed to parse command")
|
||||
return tree.rootNode
|
||||
})
|
||||
|
||||
const ask = Effect.fn("BashTool.ask")(function* (ctx: Tool.Context, scan: Scan) {
|
||||
const ask = Effect.fn("ShellTool.ask")(function* (ctx: Tool.Context, scan: Scan) {
|
||||
if (scan.dirs.size > 0) {
|
||||
const globs = Array.from(scan.dirs).map((dir) => {
|
||||
if (process.platform === "win32") return AppFileSystem.normalizePathPattern(path.join(dir, "*"))
|
||||
|
|
@ -270,7 +260,7 @@ const ask = Effect.fn("BashTool.ask")(function* (ctx: Tool.Context, scan: Scan)
|
|||
|
||||
if (scan.patterns.size === 0) return
|
||||
yield* ctx.ask({
|
||||
permission: "bash",
|
||||
permission: ShellToolID.id,
|
||||
patterns: Array.from(scan.patterns),
|
||||
always: Array.from(scan.always),
|
||||
metadata: {},
|
||||
|
|
@ -323,16 +313,15 @@ const parser = lazy(async () => {
|
|||
return { bash, ps }
|
||||
})
|
||||
|
||||
// TODO: we may wanna rename this tool so it works better on other shells
|
||||
export const BashTool = Tool.define(
|
||||
"bash",
|
||||
export const ShellTool = Tool.define(
|
||||
ShellToolID.id,
|
||||
Effect.gen(function* () {
|
||||
const spawner = yield* ChildProcessSpawner
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const trunc = yield* Truncate.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
|
||||
const cygpath = Effect.fn("BashTool.cygpath")(function* (shell: string, text: string) {
|
||||
const cygpath = Effect.fn("ShellTool.cygpath")(function* (shell: string, text: string) {
|
||||
const lines = yield* spawner
|
||||
.lines(ChildProcess.make(shell, ["-lc", 'cygpath -w -- "$1"', "_", text]))
|
||||
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
||||
|
|
@ -341,7 +330,7 @@ export const BashTool = Tool.define(
|
|||
return AppFileSystem.normalizePath(file)
|
||||
})
|
||||
|
||||
const resolvePath = Effect.fn("BashTool.resolvePath")(function* (text: string, root: string, shell: string) {
|
||||
const resolvePath = Effect.fn("ShellTool.resolvePath")(function* (text: string, root: string, shell: string) {
|
||||
if (process.platform === "win32") {
|
||||
if (Shell.posix(shell) && text.startsWith("/") && AppFileSystem.windowsPath(text) === text) {
|
||||
const file = yield* cygpath(shell, text)
|
||||
|
|
@ -352,7 +341,7 @@ export const BashTool = Tool.define(
|
|||
return path.resolve(root, text)
|
||||
})
|
||||
|
||||
const argPath = Effect.fn("BashTool.argPath")(function* (arg: string, cwd: string, ps: boolean, shell: string) {
|
||||
const argPath = Effect.fn("ShellTool.argPath")(function* (arg: string, cwd: string, ps: boolean, shell: string) {
|
||||
const text = ps ? expand(arg, cwd, shell) : home(unquote(arg))
|
||||
const file = text && prefix(text)
|
||||
if (!file || dynamic(file, ps)) return
|
||||
|
|
@ -361,12 +350,13 @@ export const BashTool = Tool.define(
|
|||
return yield* resolvePath(next, cwd, shell)
|
||||
})
|
||||
|
||||
const collect = Effect.fn("BashTool.collect")(function* (root: Node, cwd: string, ps: boolean, shell: string) {
|
||||
const collect = Effect.fn("ShellTool.collect")(function* (root: Node, cwd: string, ps: boolean, shell: string) {
|
||||
const scan: Scan = {
|
||||
dirs: new Set<string>(),
|
||||
patterns: new Set<string>(),
|
||||
always: new Set<string>(),
|
||||
}
|
||||
const shellKind = ShellKind.from(Shell.name(shell))
|
||||
|
||||
for (const node of commands(root)) {
|
||||
const command = parts(node)
|
||||
|
|
@ -385,14 +375,14 @@ export const BashTool = Tool.define(
|
|||
|
||||
if (tokens.length && (!cmd || !CWD.has(cmd))) {
|
||||
scan.patterns.add(source(node))
|
||||
scan.always.add(BashArity.prefix(tokens).join(" ") + " *")
|
||||
scan.always.add(ShellArity.prefix(tokens, shellKind).join(" ") + " *")
|
||||
}
|
||||
}
|
||||
|
||||
return scan
|
||||
})
|
||||
|
||||
const shellEnv = Effect.fn("BashTool.shellEnv")(function* (ctx: Tool.Context, cwd: string) {
|
||||
const shellEnv = Effect.fn("ShellTool.shellEnv")(function* (ctx: Tool.Context, cwd: string) {
|
||||
const extra = yield* plugin.trigger(
|
||||
"shell.env",
|
||||
{ cwd, sessionID: ctx.sessionID, callID: ctx.callID },
|
||||
|
|
@ -404,7 +394,7 @@ export const BashTool = Tool.define(
|
|||
}
|
||||
})
|
||||
|
||||
const run = Effect.fn("BashTool.run")(function* (
|
||||
const run = Effect.fn("ShellTool.run")(function* (
|
||||
input: {
|
||||
shell: string
|
||||
name: string
|
||||
|
|
@ -519,7 +509,7 @@ export const BashTool = Tool.define(
|
|||
const meta: string[] = []
|
||||
if (expired) {
|
||||
meta.push(
|
||||
`bash tool terminated command after exceeding timeout ${input.timeout} ms. If this command is expected to take longer and is not waiting for interactive input, retry with a larger timeout value in milliseconds.`,
|
||||
`shell tool terminated command after exceeding timeout ${input.timeout} ms. If this command is expected to take longer and is not waiting for interactive input, retry with a larger timeout value in milliseconds.`,
|
||||
)
|
||||
}
|
||||
if (aborted) meta.push("User aborted the command")
|
||||
|
|
@ -538,7 +528,7 @@ export const BashTool = Tool.define(
|
|||
}
|
||||
|
||||
if (meta.length > 0) {
|
||||
output += "\n\n<bash_metadata>\n" + meta.join("\n") + "\n</bash_metadata>"
|
||||
output += "\n\n<shell_metadata>\n" + meta.join("\n") + "\n</shell_metadata>"
|
||||
}
|
||||
if (sink) {
|
||||
const stream = sink
|
||||
|
|
@ -568,23 +558,14 @@ export const BashTool = Tool.define(
|
|||
Effect.gen(function* () {
|
||||
const shell = Shell.acceptable()
|
||||
const name = Shell.name(shell)
|
||||
const chain =
|
||||
name === "powershell"
|
||||
? "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success."
|
||||
: "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead."
|
||||
log.info("bash tool using shell", { shell })
|
||||
|
||||
const limits = yield* trunc.limits()
|
||||
const prompt = ShellPrompt.render(name, process.platform, limits)
|
||||
log.info("shell tool using shell", { shell })
|
||||
|
||||
return {
|
||||
description: DESCRIPTION.replaceAll("${directory}", Instance.directory)
|
||||
.replaceAll("${os}", process.platform)
|
||||
.replaceAll("${shell}", name)
|
||||
.replaceAll("${chaining}", chain)
|
||||
.replaceAll("${maxLines}", String(limits.maxLines))
|
||||
.replaceAll("${maxBytes}", String(limits.maxBytes)),
|
||||
parameters: Parameters,
|
||||
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
|
||||
description: prompt.description,
|
||||
parameters: prompt.parameters,
|
||||
execute: (params: Parameters, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
const cwd = params.workdir
|
||||
? yield* resolvePath(params.workdir, Instance.directory, shell)
|
||||
8
packages/opencode/src/tool/shell/arity.ts
Normal file
8
packages/opencode/src/tool/shell/arity.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { BashArity } from "@/permission/arity"
|
||||
import type { ShellKind } from "./id"
|
||||
|
||||
export namespace ShellArity {
|
||||
export function prefix(tokens: string[], _shellType: ShellKind.ID) {
|
||||
return BashArity.prefix(tokens)
|
||||
}
|
||||
}
|
||||
33
packages/opencode/src/tool/shell/id.ts
Normal file
33
packages/opencode/src/tool/shell/id.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
export namespace ShellKind {
|
||||
export const ids = ["bash", "pwsh", "powershell"] as const
|
||||
export type ID = (typeof ids)[number]
|
||||
|
||||
const kind = new Set<string>(ids)
|
||||
const ps = new Set<string>(["pwsh", "powershell"])
|
||||
|
||||
export function has(value: string): value is ID {
|
||||
return kind.has(value)
|
||||
}
|
||||
|
||||
export function from(value: string): ID {
|
||||
return has(value) ? value : "bash"
|
||||
}
|
||||
|
||||
export function powershell(value: string) {
|
||||
return ps.has(value)
|
||||
}
|
||||
}
|
||||
|
||||
export namespace ShellToolID {
|
||||
export const id = "shell"
|
||||
export const legacy = "bash"
|
||||
export type ID = typeof id
|
||||
|
||||
export function has(value: string): value is ID {
|
||||
return value === id
|
||||
}
|
||||
|
||||
export function normalize(value: string) {
|
||||
return value === legacy ? id : value
|
||||
}
|
||||
}
|
||||
296
packages/opencode/src/tool/shell/prompt.ts
Normal file
296
packages/opencode/src/tool/shell/prompt.ts
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
import { Schema } from "effect"
|
||||
import DESCRIPTION from "./shell.txt"
|
||||
|
||||
const PS = new Set(["powershell", "pwsh"])
|
||||
const CMD = new Set(["cmd"])
|
||||
|
||||
const descriptions = {
|
||||
bash:
|
||||
"Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'",
|
||||
powershell:
|
||||
'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: Get-ChildItem -LiteralPath "."\nOutput: Lists current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: New-Item -ItemType Directory -Path "tmp"\nOutput: Creates directory tmp',
|
||||
cmd:
|
||||
'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: dir\nOutput: Lists current directory\n\nInput: if exist "package.json" type "package.json"\nOutput: Prints package.json when it exists\n\nInput: mkdir tmp\nOutput: Creates directory tmp',
|
||||
}
|
||||
|
||||
export type Limits = {
|
||||
maxLines: number
|
||||
maxBytes: number
|
||||
}
|
||||
|
||||
export function parameterSchema(description: string) {
|
||||
return Schema.Struct({
|
||||
command: Schema.String.annotate({ description: "The command to execute" }),
|
||||
timeout: Schema.optional(Schema.Number).annotate({ description: "Optional timeout in milliseconds" }),
|
||||
workdir: Schema.optional(Schema.String).annotate({
|
||||
description: `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`,
|
||||
}),
|
||||
description: Schema.String.annotate({ description }),
|
||||
})
|
||||
}
|
||||
|
||||
export const Parameters = parameterSchema(descriptions.bash)
|
||||
export type Parameters = Schema.Schema.Type<typeof Parameters>
|
||||
|
||||
function renderPrompt(template: string, values: Record<string, string>) {
|
||||
return template.replace(/\$\{(\w+)\}/g, (_, key: string) => {
|
||||
const value = values[key]
|
||||
if (value === undefined) throw new Error(`Missing shell prompt value: ${key}`)
|
||||
return value
|
||||
})
|
||||
}
|
||||
|
||||
function shellDisplayName(name: string) {
|
||||
if (name === "pwsh") return "PowerShell (7+)"
|
||||
if (name === "powershell") return "Windows PowerShell (5.1)"
|
||||
if (name === "cmd") return "cmd.exe"
|
||||
return name
|
||||
}
|
||||
|
||||
function powershellNotes(name: string) {
|
||||
if (name === "pwsh") {
|
||||
return `# PowerShell (7+) shell notes
|
||||
- This cross-platform shell supports pipeline chain operators (\`&&\` and \`||\`).
|
||||
- Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings.
|
||||
- Prefer full cmdlet names like \`Get-ChildItem\`, \`Set-Content\`, \`Remove-Item\`, and \`New-Item\` over aliases.
|
||||
- Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions.
|
||||
- To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`.
|
||||
- Escape special characters with the PowerShell backtick character.`
|
||||
}
|
||||
if (name === "powershell") {
|
||||
return `# Windows PowerShell (5.1) shell notes
|
||||
- Use \`cmd1; if ($?) { cmd2 }\` to chain dependent commands.
|
||||
- Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings.
|
||||
- Prefer full cmdlet names like \`Get-ChildItem\`, \`Set-Content\`, \`Remove-Item\`, and \`New-Item\` over aliases.
|
||||
- Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions.
|
||||
- To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`.
|
||||
- Escape special characters with the PowerShell backtick character.`
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
function chainGuidance(name: string) {
|
||||
if (name === "powershell") {
|
||||
return "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell (5.1) does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success."
|
||||
}
|
||||
if (PS.has(name)) {
|
||||
return "If the commands depend on each other and must run sequentially, use a single Shell call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like New-Item before Copy-Item, Write before Shell for git operations, or git add before git commit), run these operations sequentially instead."
|
||||
}
|
||||
if (CMD.has(name)) {
|
||||
return "If the commands depend on each other and must run sequentially, use a single Shell call with `&&` to chain them together (e.g., `mkdir out && dir out`). For instance, if one operation must complete before another starts, run these operations sequentially instead."
|
||||
}
|
||||
return "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead."
|
||||
}
|
||||
|
||||
function bashCommandSection(chain: string, limits: Limits) {
|
||||
return `Before executing the command, please follow these steps:
|
||||
|
||||
1. Directory Verification:
|
||||
- If the command will create new directories or files, first use \`ls\` to verify the parent directory exists and is the correct location
|
||||
- For example, before running "mkdir foo/bar", first use \`ls foo\` to check that "foo" exists and is the intended parent directory
|
||||
|
||||
2. Command Execution:
|
||||
- Always quote file paths that contain spaces with double quotes (e.g., rm "path with spaces/file.txt")
|
||||
- Examples of proper quoting:
|
||||
- mkdir "/Users/name/My Documents" (correct)
|
||||
- mkdir /Users/name/My Documents (incorrect - will fail)
|
||||
- python "/path/with spaces/script.py" (correct)
|
||||
- python /path/with spaces/script.py (incorrect - will fail)
|
||||
- After ensuring proper quoting, execute the command.
|
||||
- Capture the output of the command.
|
||||
|
||||
Usage notes:
|
||||
- The command argument is required.
|
||||
- You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes).
|
||||
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
|
||||
- If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`head\`, \`tail\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching.
|
||||
|
||||
- Avoid using Bash with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
|
||||
- File search: Use Glob (NOT find or ls)
|
||||
- Content search: Use Grep (NOT grep or rg)
|
||||
- Read files: Use Read (NOT cat/head/tail)
|
||||
- Edit files: Use Edit (NOT sed/awk)
|
||||
- Write files: Use Write (NOT echo >/cat <<EOF)
|
||||
- Communication: Output text directly (NOT echo/printf)
|
||||
- When issuing multiple commands:
|
||||
- If the commands are independent and can run in parallel, make multiple Shell tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two Shell tool calls in parallel.
|
||||
- ${chain}
|
||||
- Use ';' only when you need to run commands sequentially but don't care if earlier commands fail
|
||||
- DO NOT use newlines to separate commands (newlines are ok in quoted strings)
|
||||
- AVOID using \`cd <directory> && <command>\`. Use the \`workdir\` parameter to change directories instead.
|
||||
<good-example>
|
||||
Use workdir="/foo/bar" with command: pytest tests
|
||||
</good-example>
|
||||
<bad-example>
|
||||
cd /foo/bar && pytest tests
|
||||
</bad-example>`
|
||||
}
|
||||
|
||||
function powershellCommandSection(name: string, chain: string, pathSep: string, limits: Limits) {
|
||||
return `${powershellNotes(name)}
|
||||
|
||||
Before executing the command, please follow these steps:
|
||||
|
||||
1. Directory Verification:
|
||||
- If the command will create new directories or files, first use \`Test-Path -LiteralPath <parent>\` to verify the parent directory exists and is the correct location
|
||||
- For example, before creating \`foo${pathSep}bar\`, first use \`Test-Path -LiteralPath "foo"\` to check that \`foo\` exists and is the intended parent directory
|
||||
|
||||
2. Command Execution:
|
||||
- Always quote file paths that contain spaces with double quotes (e.g., Remove-Item -LiteralPath "path with spaces${pathSep}file.txt")
|
||||
- Examples of proper quoting:
|
||||
- New-Item -ItemType Directory -Path "My Documents" (correct)
|
||||
- New-Item -ItemType Directory -Path My Documents (incorrect - path is split)
|
||||
- & "path with spaces${pathSep}script.ps1" (correct)
|
||||
- path with spaces${pathSep}script.ps1 (incorrect - path is split and not invoked)
|
||||
- After ensuring proper quoting, execute the command.
|
||||
- Capture the output of the command.
|
||||
|
||||
Usage notes:
|
||||
- The command argument is required.
|
||||
- You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes).
|
||||
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
|
||||
- If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`Select-Object -First\`, \`Select-Object -Last\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching.
|
||||
|
||||
- Avoid using Shell with PowerShell file/content cmdlets unless explicitly instructed or when these cmdlets are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
|
||||
- File search: Use Glob (NOT Get-ChildItem)
|
||||
- Content search: Use Grep (NOT Select-String)
|
||||
- Read files: Use Read (NOT Get-Content)
|
||||
- Edit files: Use Edit (NOT Set-Content)
|
||||
- Write files: Use Write (NOT Set-Content/Out-File or here-strings)
|
||||
- Communication: Output text directly (NOT Write-Output/Write-Host)
|
||||
- When issuing multiple commands:
|
||||
- If the commands are independent and can run in parallel, make multiple Shell tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two Shell tool calls in parallel.
|
||||
- ${chain}
|
||||
- Use \`;\` only when you need to run commands sequentially but don't care if earlier commands fail
|
||||
- DO NOT use newlines to separate commands (newlines are ok in quoted strings)
|
||||
- AVOID changing directories inside the command. Use the \`workdir\` parameter to change directories instead.
|
||||
<good-example>
|
||||
Use workdir="project${pathSep}subdir" with command: pytest tests
|
||||
</good-example>
|
||||
<bad-example>
|
||||
${name === "powershell" ? `Set-Location -LiteralPath "project${pathSep}subdir"; if ($?) { pytest tests }` : `Set-Location -LiteralPath "project${pathSep}subdir" && pytest tests`}
|
||||
</bad-example>`
|
||||
}
|
||||
|
||||
function cmdCommandSection(chain: string, limits: Limits) {
|
||||
return `# cmd.exe shell notes
|
||||
- Use double quotes for paths with spaces.
|
||||
- Use %VAR% for environment variables.
|
||||
- Use \`if exist\` for existence checks.
|
||||
- Use \`call\` when invoking batch files from another batch-style command.
|
||||
|
||||
Before executing the command, please follow these steps:
|
||||
|
||||
1. Directory Verification:
|
||||
- If the command will create new directories or files, first use \`if exist\` to verify the parent directory exists and is the correct location
|
||||
- For example, before creating \`foo\\bar\`, first use \`if exist "foo\\" dir "foo"\` to check that \`foo\` exists and is the intended parent directory
|
||||
|
||||
2. Command Execution:
|
||||
- Always quote file paths that contain spaces with double quotes (e.g., del "path with spaces\\file.txt")
|
||||
- Examples of proper quoting:
|
||||
- mkdir "My Documents" (correct)
|
||||
- mkdir My Documents (incorrect - path is split)
|
||||
- call "path with spaces\\script.bat" (correct)
|
||||
- path with spaces\\script.bat (incorrect - path is split and not invoked correctly)
|
||||
- After ensuring proper quoting, execute the command.
|
||||
- Capture the output of the command.
|
||||
|
||||
Usage notes:
|
||||
- The command argument is required.
|
||||
- You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes).
|
||||
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
|
||||
- If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`more\` or other pagination commands to limit output; the full output will already be captured to a file for more precise searching.
|
||||
|
||||
- Avoid using Shell with cmd.exe file/content commands unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
|
||||
- File search: Use Glob (NOT dir /s)
|
||||
- Content search: Use Grep (NOT findstr)
|
||||
- Read files: Use Read (NOT type)
|
||||
- Edit files: Use Edit (NOT copy)
|
||||
- Write files: Use Write (NOT echo > file)
|
||||
- Communication: Output text directly (NOT echo)
|
||||
- When issuing multiple commands:
|
||||
- If the commands are independent and can run in parallel, make multiple Shell tool calls in a single message. For example, if you need to run "dir" and "where cmd", send a single message with two Shell tool calls in parallel.
|
||||
- ${chain}
|
||||
- Use \`&\` only when you need to run commands sequentially but don't care if earlier commands fail
|
||||
- DO NOT use newlines to separate commands (newlines are ok in quoted strings)
|
||||
- AVOID changing directories inside the command. Use the \`workdir\` parameter to change directories instead.
|
||||
<good-example>
|
||||
Use workdir="project\\subdir" with command: dir
|
||||
</good-example>
|
||||
<bad-example>
|
||||
cd /d "project\\subdir" && dir
|
||||
</bad-example>`
|
||||
}
|
||||
|
||||
function profile(name: string, platform: NodeJS.Platform, limits: Limits) {
|
||||
const isPowerShell = PS.has(name)
|
||||
const chain = chainGuidance(name)
|
||||
if (CMD.has(name)) {
|
||||
return {
|
||||
intro: `Executes a given ${shellDisplayName(name)} command with optional timeout, ensuring proper handling and security measures.`,
|
||||
workdirSection:
|
||||
"All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID changing directories inside the command - use `workdir` instead.",
|
||||
commandSection: cmdCommandSection(chain, limits),
|
||||
gitCommands: "git commands",
|
||||
toolName: "Shell",
|
||||
gitCommandRestriction: "git commands",
|
||||
createPrInstruction: "Create PR using a temporary body file so cmd.exe quoting stays simple.",
|
||||
createPrExample: `(\n echo ## Summary\n echo - ^<1-3 bullet points^>\n) > pr-body.txt\ngh pr create --title "the pr title" --body-file pr-body.txt`,
|
||||
parameterDescription: descriptions.cmd,
|
||||
}
|
||||
}
|
||||
if (isPowerShell) {
|
||||
return {
|
||||
intro: `Executes a given ${shellDisplayName(name)} command with optional timeout, ensuring proper handling and security measures.`,
|
||||
workdirSection:
|
||||
"All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID changing directories inside the command - use `workdir` instead.",
|
||||
commandSection: powershellCommandSection(name, chain, platform === "win32" ? "\\" : "/", limits),
|
||||
gitCommands: "git commands",
|
||||
toolName: "Shell",
|
||||
gitCommandRestriction: "git commands",
|
||||
createPrInstruction: "Create PR using gh pr create with a PowerShell here-string to pass the body correctly.",
|
||||
createPrExample: `gh pr create --title "the pr title" --body @'
|
||||
## Summary
|
||||
- <1-3 bullet points>
|
||||
'@`,
|
||||
parameterDescription: descriptions.powershell,
|
||||
}
|
||||
}
|
||||
return {
|
||||
intro:
|
||||
"Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.",
|
||||
workdirSection:
|
||||
"All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd <directory> && <command>` patterns - use `workdir` instead.",
|
||||
commandSection: bashCommandSection(chain, limits),
|
||||
gitCommands: "bash commands",
|
||||
toolName: "Shell",
|
||||
gitCommandRestriction: "git bash commands",
|
||||
createPrInstruction:
|
||||
"Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.",
|
||||
createPrExample: `gh pr create --title "the pr title" --body "$(cat <<'EOF'
|
||||
## Summary
|
||||
<1-3 bullet points>`,
|
||||
parameterDescription: descriptions.bash,
|
||||
}
|
||||
}
|
||||
|
||||
export function render(name: string, platform: NodeJS.Platform, limits: Limits) {
|
||||
const selected = profile(name, platform, limits)
|
||||
return {
|
||||
description: renderPrompt(DESCRIPTION, {
|
||||
intro: selected.intro,
|
||||
os: platform,
|
||||
shell: name,
|
||||
workdirSection: selected.workdirSection,
|
||||
commandSection: selected.commandSection,
|
||||
gitCommands: selected.gitCommands,
|
||||
toolName: selected.toolName,
|
||||
gitCommandRestriction: selected.gitCommandRestriction,
|
||||
createPrInstruction: selected.createPrInstruction,
|
||||
createPrExample: selected.createPrExample,
|
||||
}),
|
||||
parameters: parameterSchema(selected.parameterDescription),
|
||||
}
|
||||
}
|
||||
|
||||
export * as ShellPrompt from "./prompt"
|
||||
|
|
@ -1,52 +1,12 @@
|
|||
Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.
|
||||
${intro}
|
||||
|
||||
Be aware: OS: ${os}, Shell: ${shell}
|
||||
|
||||
All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd <directory> && <command>` patterns - use `workdir` instead.
|
||||
${workdirSection}
|
||||
|
||||
IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead.
|
||||
|
||||
Before executing the command, please follow these steps:
|
||||
|
||||
1. Directory Verification:
|
||||
- If the command will create new directories or files, first use `ls` to verify the parent directory exists and is the correct location
|
||||
- For example, before running "mkdir foo/bar", first use `ls foo` to check that "foo" exists and is the intended parent directory
|
||||
|
||||
2. Command Execution:
|
||||
- Always quote file paths that contain spaces with double quotes (e.g., rm "path with spaces/file.txt")
|
||||
- Examples of proper quoting:
|
||||
- mkdir "/Users/name/My Documents" (correct)
|
||||
- mkdir /Users/name/My Documents (incorrect - will fail)
|
||||
- python "/path/with spaces/script.py" (correct)
|
||||
- python /path/with spaces/script.py (incorrect - will fail)
|
||||
- After ensuring proper quoting, execute the command.
|
||||
- Capture the output of the command.
|
||||
|
||||
Usage notes:
|
||||
- The command argument is required.
|
||||
- You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes).
|
||||
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
|
||||
- If the output exceeds ${maxLines} lines or ${maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use `head`, `tail`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching.
|
||||
|
||||
- Avoid using Bash with the `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
|
||||
- File search: Use Glob (NOT find or ls)
|
||||
- Content search: Use Grep (NOT grep or rg)
|
||||
- Read files: Use Read (NOT cat/head/tail)
|
||||
- Edit files: Use Edit (NOT sed/awk)
|
||||
- Write files: Use Write (NOT echo >/cat <<EOF)
|
||||
- Communication: Output text directly (NOT echo/printf)
|
||||
- When issuing multiple commands:
|
||||
- If the commands are independent and can run in parallel, make multiple Bash tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two Bash tool calls in parallel.
|
||||
- ${chaining}
|
||||
- Use ';' only when you need to run commands sequentially but don't care if earlier commands fail
|
||||
- DO NOT use newlines to separate commands (newlines are ok in quoted strings)
|
||||
- AVOID using `cd <directory> && <command>`. Use the `workdir` parameter to change directories instead.
|
||||
<good-example>
|
||||
Use workdir="/foo/bar" with command: pytest tests
|
||||
</good-example>
|
||||
<bad-example>
|
||||
cd /foo/bar && pytest tests
|
||||
</bad-example>
|
||||
${commandSection}
|
||||
|
||||
# Committing changes with git
|
||||
|
||||
|
|
@ -65,7 +25,7 @@ Git Safety Protocol:
|
|||
- CRITICAL: If you already pushed to remote, NEVER amend unless user explicitly requests it (requires force push)
|
||||
- NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
|
||||
|
||||
1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel, each using the Bash tool:
|
||||
1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following ${gitCommands} in parallel, each using the ${toolName} tool:
|
||||
- Run a git status command to see all untracked files.
|
||||
- Run a git diff command to see both staged and unstaged changes that will be committed.
|
||||
- Run a git log command to see recent commit messages, so that you can follow this repository's commit message style.
|
||||
|
|
@ -82,18 +42,18 @@ Git Safety Protocol:
|
|||
4. If the commit fails due to pre-commit hook, fix the issue and create a NEW commit (see amend rules above)
|
||||
|
||||
Important notes:
|
||||
- NEVER run additional commands to read or explore code, besides git bash commands
|
||||
- NEVER run additional commands to read or explore code, besides ${gitCommandRestriction}
|
||||
- NEVER use the TodoWrite or Task tools
|
||||
- DO NOT push to the remote repository unless the user explicitly asks you to do so
|
||||
- IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported.
|
||||
- If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit
|
||||
|
||||
# Creating pull requests
|
||||
Use the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a GitHub URL use the gh command to get the information needed.
|
||||
Use the gh command via the ${toolName} tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a GitHub URL use the gh command to get the information needed.
|
||||
|
||||
IMPORTANT: When the user asks you to create a pull request, follow these steps carefully:
|
||||
|
||||
1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel using the Bash tool, in order to understand the current state of the branch since it diverged from the main branch:
|
||||
1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following ${gitCommands} in parallel using the ${toolName} tool, in order to understand the current state of the branch since it diverged from the main branch:
|
||||
- Run a git status command to see all untracked files
|
||||
- Run a git diff command to see both staged and unstaged changes that will be committed
|
||||
- Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote
|
||||
|
|
@ -102,11 +62,9 @@ IMPORTANT: When the user asks you to create a pull request, follow these steps c
|
|||
3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands in parallel:
|
||||
- Create new branch if needed
|
||||
- Push to remote with -u flag if needed
|
||||
- Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.
|
||||
- ${createPrInstruction}
|
||||
<example>
|
||||
gh pr create --title "the pr title" --body "$(cat <<'EOF'
|
||||
## Summary
|
||||
<1-3 bullet points>
|
||||
${createPrExample}
|
||||
</example>
|
||||
|
||||
Important:
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import * as Tool from "./tool"
|
||||
import DESCRIPTION from "./task.txt"
|
||||
import { ShellToolID } from "./shell/id"
|
||||
import { Session } from "../session"
|
||||
import { SessionID, MessageID } from "../session/schema"
|
||||
import { MessageV2 } from "../session/message-v2"
|
||||
|
|
@ -39,6 +40,7 @@ export const TaskTool = Tool.define(
|
|||
ctx: Tool.Context,
|
||||
) {
|
||||
const cfg = yield* config.get()
|
||||
const primaryTools = (cfg.experimental?.primary_tools ?? []).map(ShellToolID.normalize)
|
||||
|
||||
if (!ctx.extra?.bypassAgentCheck) {
|
||||
yield* ctx.ask({
|
||||
|
|
@ -88,11 +90,11 @@ export const TaskTool = Tool.define(
|
|||
action: "deny" as const,
|
||||
},
|
||||
]),
|
||||
...(cfg.experimental?.primary_tools?.map((item) => ({
|
||||
...primaryTools.map((item) => ({
|
||||
pattern: "*",
|
||||
action: "allow" as const,
|
||||
permission: item,
|
||||
})) ?? []),
|
||||
})),
|
||||
],
|
||||
}))
|
||||
|
||||
|
|
@ -139,7 +141,7 @@ export const TaskTool = Tool.define(
|
|||
tools: {
|
||||
...(canTodo ? {} : { todowrite: false }),
|
||||
...(canTask ? {} : { task: false }),
|
||||
...Object.fromEntries((cfg.experimental?.primary_tools ?? []).map((item) => [item, false])),
|
||||
...Object.fromEntries(primaryTools.map((item) => [item, false])),
|
||||
},
|
||||
parts,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -1224,7 +1224,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",
|
||||
})
|
||||
},
|
||||
|
|
@ -1255,7 +1255,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",
|
||||
})
|
||||
},
|
||||
|
|
@ -1453,7 +1453,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",
|
||||
|
|
@ -1489,7 +1489,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",
|
||||
})
|
||||
},
|
||||
})
|
||||
|
|
@ -1540,6 +1540,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",
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// MCP config merging tests
|
||||
|
||||
test("project config can override MCP server enabled status", async () => {
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
|
|
@ -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" },
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
},
|
||||
],
|
||||
|
|
@ -950,7 +950,7 @@ describe("session.message-v2.toModelMessage", () => {
|
|||
...basePart(assistantID, "a1"),
|
||||
type: "tool",
|
||||
callID: "call-pending",
|
||||
tool: "bash",
|
||||
tool: "shell",
|
||||
state: {
|
||||
status: "pending",
|
||||
input: { cmd: "ls" },
|
||||
|
|
@ -985,7 +985,7 @@ describe("session.message-v2.toModelMessage", () => {
|
|||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call-pending",
|
||||
toolName: "bash",
|
||||
toolName: "shell",
|
||||
input: { cmd: "ls" },
|
||||
providerExecuted: undefined,
|
||||
},
|
||||
|
|
@ -1004,7 +1004,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 { Log } from "../../src/util"
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ import { Effect, Layer, ManagedRuntime } from "effect"
|
|||
import os from "os"
|
||||
import path from "path"
|
||||
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 "../../src/util"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
|
@ -26,9 +27,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(""),
|
||||
|
|
@ -133,12 +136,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(
|
||||
{
|
||||
|
|
@ -155,13 +160,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(
|
||||
|
|
@ -173,18 +178,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(
|
||||
|
|
@ -196,7 +201,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")
|
||||
},
|
||||
|
|
@ -210,7 +215,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(
|
||||
|
|
@ -221,7 +226,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")
|
||||
|
|
@ -232,11 +237,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/*"
|
||||
|
|
@ -272,7 +309,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(
|
||||
|
|
@ -285,7 +322,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()
|
||||
|
|
@ -305,7 +342,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(
|
||||
|
|
@ -335,7 +372,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(
|
||||
|
|
@ -348,7 +385,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()
|
||||
|
|
@ -367,7 +404,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(
|
||||
|
|
@ -397,7 +434,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(
|
||||
|
|
@ -492,7 +529,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(/[\\/]+$/, "")
|
||||
|
|
@ -632,7 +669,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!, "*")),
|
||||
|
|
@ -651,7 +688,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(
|
||||
|
|
@ -662,7 +699,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 *")
|
||||
|
|
@ -911,12 +948,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(
|
||||
|
|
@ -927,7 +964,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()
|
||||
},
|
||||
})
|
||||
|
|
@ -938,7 +975,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(
|
||||
|
|
@ -949,7 +986,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")
|
||||
},
|
||||
|
|
@ -964,7 +1001,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 *")
|
||||
},
|
||||
|
|
@ -972,12 +1009,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(
|
||||
|
|
@ -1011,7 +1048,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(
|
||||
{
|
||||
|
|
@ -1023,7 +1060,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")
|
||||
},
|
||||
})
|
||||
|
|
@ -1033,7 +1070,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(
|
||||
{
|
||||
|
|
@ -1054,7 +1091,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(
|
||||
{
|
||||
|
|
@ -1099,12 +1136,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(
|
||||
|
|
@ -1126,7 +1163,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(
|
||||
|
|
@ -1148,7 +1185,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(
|
||||
{
|
||||
|
|
@ -1168,7 +1205,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,
|
||||
})
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1214,7 +1214,7 @@ export type PermissionConfig =
|
|||
glob?: PermissionRuleConfig
|
||||
grep?: PermissionRuleConfig
|
||||
list?: PermissionRuleConfig
|
||||
bash?: PermissionRuleConfig
|
||||
shell?: PermissionRuleConfig
|
||||
task?: PermissionRuleConfig
|
||||
external_directory?: PermissionRuleConfig
|
||||
todowrite?: PermissionActionConfig
|
||||
|
|
|
|||
|
|
@ -271,6 +271,8 @@ export type ToolInfo = {
|
|||
subtitle?: string
|
||||
}
|
||||
|
||||
const SHELL = new Set(["shell", "bash"])
|
||||
|
||||
function agentTitle(i18n: UiI18n, type?: string) {
|
||||
if (!type) return i18n.t("ui.tool.agent.default")
|
||||
return i18n.t("ui.tool.agent", { type })
|
||||
|
|
@ -319,6 +321,14 @@ function taskAgent(
|
|||
|
||||
export function getToolInfo(tool: string, input: any = {}): ToolInfo {
|
||||
const i18n = useI18n()
|
||||
if (SHELL.has(tool)) {
|
||||
return {
|
||||
icon: "console",
|
||||
title: i18n.t("ui.tool.shell"),
|
||||
subtitle: input.description,
|
||||
}
|
||||
}
|
||||
|
||||
switch (tool) {
|
||||
case "read":
|
||||
return {
|
||||
|
|
@ -373,12 +383,6 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo {
|
|||
subtitle: input.description,
|
||||
}
|
||||
}
|
||||
case "bash":
|
||||
return {
|
||||
icon: "console",
|
||||
title: i18n.t("ui.tool.shell"),
|
||||
subtitle: input.description,
|
||||
}
|
||||
case "edit":
|
||||
return {
|
||||
icon: "code-lines",
|
||||
|
|
@ -582,7 +586,7 @@ function renderable(part: PartType, showReasoningSummaries = true) {
|
|||
}
|
||||
|
||||
function toolDefaultOpen(tool: string, shell = false, edit = false) {
|
||||
if (tool === "bash") return shell
|
||||
if (SHELL.has(tool)) return shell
|
||||
if (tool === "edit" || tool === "write" || tool === "apply_patch") return edit
|
||||
}
|
||||
|
||||
|
|
@ -1254,6 +1258,7 @@ export function registerTool(input: { name: string; render?: ToolComponent }) {
|
|||
}
|
||||
|
||||
export function getTool(name: string) {
|
||||
if (name === "bash") return state.shell?.render
|
||||
return state[name]?.render
|
||||
}
|
||||
|
||||
|
|
@ -1818,7 +1823,7 @@ ToolRegistry.register({
|
|||
})
|
||||
|
||||
ToolRegistry.register({
|
||||
name: "bash",
|
||||
name: "shell",
|
||||
render(props) {
|
||||
const i18n = useI18n()
|
||||
const pending = () => props.status === "pending" || props.status === "running"
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ Interactive playground for animating the Shell tool subtitle ("submessage") in t
|
|||
|
||||
### Production component path
|
||||
- Trigger layout: \`packages/ui/src/components/basic-tool.tsx\`
|
||||
- Bash tool subtitle source: \`packages/ui/src/components/message-part.tsx\` (tool: \`bash\`, \`trigger.subtitle\`)
|
||||
- Shell tool subtitle source: \`packages/ui/src/components/message-part.tsx\` (tool: \`shell\`, \`trigger.subtitle\`)
|
||||
|
||||
### What this playground tunes
|
||||
- Width reveal (spring-driven pixel width via \`useSpring\`)
|
||||
|
|
|
|||
|
|
@ -314,8 +314,8 @@ const TOOL_SAMPLES = {
|
|||
title: "Found 2 matches",
|
||||
metadata: {},
|
||||
},
|
||||
bash: {
|
||||
tool: "bash",
|
||||
shell: {
|
||||
tool: "shell",
|
||||
input: { command: "bun test --filter session", description: "Run session tests" },
|
||||
output:
|
||||
"bun test v1.3.13\n\n✓ session-turn.test.tsx (3 tests) 45ms\n✓ message-part.test.tsx (7 tests) 120ms\n\nTest Suites: 2 passed, 2 total\nTests: 10 passed, 10 total\nTime: 0.89s",
|
||||
|
|
@ -1333,7 +1333,7 @@ function Playground() {
|
|||
toolPart(TOOL_SAMPLES.glob),
|
||||
toolPart(TOOL_SAMPLES.grep),
|
||||
toolPart(TOOL_SAMPLES.edit),
|
||||
toolPart(TOOL_SAMPLES.bash),
|
||||
toolPart(TOOL_SAMPLES.shell),
|
||||
textPart(MARKDOWN_SAMPLES.mixed),
|
||||
])
|
||||
}
|
||||
|
|
@ -1356,7 +1356,7 @@ function Playground() {
|
|||
toolPart(TOOL_SAMPLES.glob),
|
||||
toolPart(TOOL_SAMPLES.grep),
|
||||
toolPart(TOOL_SAMPLES.edit),
|
||||
toolPart(TOOL_SAMPLES.bash),
|
||||
toolPart(TOOL_SAMPLES.shell),
|
||||
textPart(MARKDOWN_SAMPLES.blockquote),
|
||||
])
|
||||
addContextGroupTurn()
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ const docs = `### Overview
|
|||
Tool call failure summary styled like a tool trigger.
|
||||
|
||||
### API
|
||||
- Required: \`tool\` (tool id, e.g. apply_patch, bash)
|
||||
- Required: \`tool\` (tool id, e.g. apply_patch, shell)
|
||||
- Required: \`error\` (error string)
|
||||
|
||||
### Behavior
|
||||
|
|
@ -19,8 +19,8 @@ const samples = [
|
|||
"apply_patch verification failed: Failed to find expected lines in /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/session-turn.tsx",
|
||||
},
|
||||
{
|
||||
tool: "bash",
|
||||
error: "bash Command failed: exit code 1: bun test --watch",
|
||||
tool: "shell",
|
||||
error: "shell Command failed: exit code 1: bun test --watch",
|
||||
},
|
||||
{
|
||||
tool: "read",
|
||||
|
|
@ -72,7 +72,7 @@ export default {
|
|||
argTypes: {
|
||||
tool: {
|
||||
control: "select",
|
||||
options: ["apply_patch", "bash", "read", "glob", "grep", "webfetch", "websearch", "codesearch", "question"],
|
||||
options: ["apply_patch", "shell", "read", "glob", "grep", "webfetch", "websearch", "codesearch", "question"],
|
||||
},
|
||||
error: {
|
||||
control: "text",
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
|
|||
websearch: "ui.tool.websearch",
|
||||
codesearch: "ui.tool.codesearch",
|
||||
bash: "ui.tool.shell",
|
||||
shell: "ui.tool.shell",
|
||||
apply_patch: "ui.tool.patch",
|
||||
question: "ui.tool.questions",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import type { Diagnostic } from "vscode-languageserver-types"
|
|||
import styles from "./part.module.css"
|
||||
|
||||
const MIN_DURATION = 2000
|
||||
const SHELL = new Set(["shell", "bash"])
|
||||
|
||||
export interface PartProps {
|
||||
index: number
|
||||
|
|
@ -90,7 +91,7 @@ export function Part(props: PartProps) {
|
|||
<Match when={props.part.type === "tool" && props.part.tool === "todowrite"}>
|
||||
<IconQueueList width={18} height={18} />
|
||||
</Match>
|
||||
<Match when={props.part.type === "tool" && props.part.tool === "bash"}>
|
||||
<Match when={props.part.type === "tool" && SHELL.has(props.part.tool)}>
|
||||
<IconCommandLine width={18} height={18} />
|
||||
</Match>
|
||||
<Match when={props.part.type === "tool" && props.part.tool === "edit"}>
|
||||
|
|
@ -240,7 +241,7 @@ export function Part(props: PartProps) {
|
|||
state={props.part.state}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.part.tool === "bash"}>
|
||||
<Match when={SHELL.has(props.part.tool)}>
|
||||
<BashTool
|
||||
id={props.part.id}
|
||||
tool={props.part.tool}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue