From d3983c8821856a7981a965df3ff6e88508291df5 Mon Sep 17 00:00:00 2001 From: wsp Date: Sat, 29 Aug 2026 09:52:56 +0800 Subject: [PATCH 1/3] refactor(agent): remove legacy command tools Remove Bash, Git, and TerminalControl from tool registration, agent defaults, deferred catalogs, prompts, and capability projections. - Route shell and local Git operations through ExecCommand - Keep terminal lifecycle control in ControlHub - Remove dedicated FlowChat cards and migrate shared process styles - Update ACP normalization, usage classification, skills, docs, and tests - Preserve legacy transcript sanitization for upgrade compatibility - Remove the obsolete computer-use refactor plan BREAKING CHANGE: Bash, Git, and TerminalControl are no longer registered Agent tools. Use ExecCommand and ControlHub terminal actions instead. --- .../agent-sdk-product-architecture.md | 2 +- docs/development/ui-testids-CN.md | 6 +- docs/development/ui-testids.md | 4 +- .../session-runtime-usage-report-design.md | 17 +- .../capabilities.json | 143 +- .../capabilities/feature.git.md | 16 +- .../technical/product-control-open-audit.json | 2 +- .../technical/tauri-command-map.json | 2 +- docs/plans/computer-use-refactor-plan.md | 230 -- docs/plans/core-decomposition-completed.md | 2 +- .../rules/source/forbidden-rules.mjs | 57 - .../rules/source/required-rules.mjs | 48 +- scripts/core-boundaries/self-test.mjs | 6 - scripts/i18n-governance-baseline.json | 6 +- scripts/theme-css-var-contract.mjs | 3 +- scripts/theme-visual-governance-contract.json | 2 +- src/apps/desktop/src/api/agentic_api.rs | 12 +- src/apps/desktop/src/api/tool_api.rs | 4 +- .../prompts/agents/code_review.md | 7 +- .../prompts/agents/general_purpose_agent.md | 4 +- .../prompts/agents/review_fixer_agent.md | 1 - .../agent-content/prompts/agents/team_mode.md | 2 +- .../prompts/insights/suggestions.md | 4 +- .../prompts/memories/phase2_system.md | 2 +- .../builtin_skills/agent-browser/SKILL.md | 2 +- .../core/builtin_skills/find-skills/SKILL.md | 2 +- .../builtin_skills/gstack-autoplan/SKILL.md | 12 +- .../gstack-design-consultation/SKILL.md | 2 +- .../gstack-design-review/SKILL.md | 2 +- .../gstack-office-hours/SKILL.md | 2 +- .../gstack-plan-design-review/SKILL.md | 2 +- .../builtin_skills/gstack-review/SKILL.md | 4 +- .../core/builtin_skills/gstack-ship/SKILL.md | 4 +- .../agentic/agents/definitions/modes/claw.rs | 1 - .../agents/definitions/modes/cowork.rs | 1 - .../agents/definitions/review/review_fixer.rs | 2 - .../definitions/review/review_specialists.rs | 2 +- .../assembly/core/src/agentic/agents/mod.rs | 1 - .../src/agentic/coordination/coordinator.rs | 6 +- .../execution/edit_constraint_guard.rs | 4 +- .../src/agentic/execution/execution_engine.rs | 22 +- .../src/agentic/execution/round_executor.rs | 6 +- .../core/src/agentic/memories/transcript.rs | 4 +- .../core/src/agentic/persistence/manager.rs | 2 +- .../src/agentic/session/session_manager.rs | 4 +- .../tools/implementations/bash_tool.rs | 1692 -------------- .../tools/implementations/control_hub_tool.rs | 74 +- .../agentic/tools/implementations/git_tool.rs | 1988 ----------------- .../src/agentic/tools/implementations/mod.rs | 8 - .../implementations/review_platform_tool.rs | 2 +- .../implementations/terminal_control_tool.rs | 245 -- .../agentic/tools/pipeline/tool_pipeline.rs | 14 +- .../core/src/agentic/tools/product_runtime.rs | 4 +- .../agentic/tools/product_runtime/catalog.rs | 8 +- .../product_runtime/loaded_spec_state.rs | 14 +- .../tools/product_runtime/materialization.rs | 3 - .../core/src/agentic/tools/registry.rs | 3 - .../src/agentic/tools/tool_context_runtime.rs | 28 +- .../src/agentic/tools/tool_result_storage.rs | 86 +- .../core/src/service/session_usage/service.rs | 22 +- .../core/src/service_agent_runtime.rs | 4 +- .../generated/product-control-catalog.json | 143 +- .../agent-runtime/src/evidence_ledger.rs | 12 +- .../custom_subagent_contracts.rs | 6 +- .../agent-stream/src/tool_call_accumulator.rs | 191 +- .../runtime-services/src/backend_events.rs | 2 +- .../execution/tool-contracts/src/framework.rs | 11 +- .../tool-contracts/tests/tool_contracts.rs | 8 +- .../execution/tool-execution/src/context.rs | 2 +- .../execution/tool-execution/src/shell/mod.rs | 369 +-- .../tool-execution/tests/tool_io_contracts.rs | 125 +- .../execution/tool-provider-groups/src/lib.rs | 5 +- .../interfaces/acp/src/client/stream.rs | 4 +- .../acp/src/client/tool_card_bridge/mod.rs | 32 +- .../src/client/tool_card_bridge/tool_name.rs | 16 +- .../client/tool_card_bridge/tool_params.rs | 12 +- .../client/tool_card_bridge/tool_result.rs | 13 +- .../interfaces/acp/src/runtime/events.rs | 18 +- .../src/session_usage/classifier.rs | 21 +- .../docs/STREAMING_OUTPUT_COLLECTION.md | 8 +- .../src/shell/scripts/shellIntegration.ps1 | 3 +- .../interactive-capabilities/catalog.json | 76 +- .../generated/interactive-capabilities.json | 143 +- .../components/subagentEditorUtils.test.ts | 8 +- .../startupPerformanceContract.test.ts | 6 - .../component-library/components/registry.tsx | 142 -- .../dispatch/DispatchJobObserver.test.ts | 2 +- .../src/flow_chat/components/FlowToolCard.tsx | 2 +- .../modern/virtualItemHeightEstimators.ts | 2 +- .../usage/SessionUsageComponents.test.tsx | 4 +- .../flow-chat-manager/ToolEventModule.ts | 2 - .../src/flow_chat/store/FlowChatStore.test.ts | 2 +- .../store/modernFlowChatStore.test.ts | 4 +- .../tool-cards/GetToolSpecCard.test.tsx | 6 +- .../tool-cards/GitToolDisplay.test.tsx | 133 -- .../flow_chat/tool-cards/GitToolDisplay.tsx | 191 -- src/web-ui/src/flow_chat/tool-cards/README.md | 2 +- .../tool-cards/TerminalControlDisplay.tsx | 61 - .../flow_chat/tool-cards/TerminalToolCard.tsx | 442 ---- .../src/flow_chat/tool-cards/index.test.ts | 11 +- src/web-ui/src/flow_chat/tool-cards/index.ts | 12 - .../tool-cards/terminalToolCardState.test.ts | 136 -- .../tool-cards/terminalToolCardState.ts | 185 -- .../flow_chat/tool-cards/toolCardMetadata.ts | 40 - .../api/generated/productControl.ts | 2 +- src/web-ui/src/locales/en-US/flow-chat.json | 55 - src/web-ui/src/locales/zh-CN/flow-chat.json | 55 - src/web-ui/src/locales/zh-TW/flow-chat.json | 55 - .../services/reviewSubagentCapabilities.ts | 2 +- .../shared/services/tool-execution-service.ts | 24 - src/web-ui/src/shared/types/tool-display.ts | 9 - 111 files changed, 638 insertions(+), 7041 deletions(-) delete mode 100644 docs/plans/computer-use-refactor-plan.md delete mode 100644 src/crates/assembly/core/src/agentic/tools/implementations/bash_tool.rs delete mode 100644 src/crates/assembly/core/src/agentic/tools/implementations/git_tool.rs delete mode 100644 src/crates/assembly/core/src/agentic/tools/implementations/terminal_control_tool.rs delete mode 100644 src/web-ui/src/flow_chat/tool-cards/GitToolDisplay.test.tsx delete mode 100644 src/web-ui/src/flow_chat/tool-cards/GitToolDisplay.tsx delete mode 100644 src/web-ui/src/flow_chat/tool-cards/TerminalControlDisplay.tsx delete mode 100644 src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx delete mode 100644 src/web-ui/src/flow_chat/tool-cards/terminalToolCardState.test.ts delete mode 100644 src/web-ui/src/flow_chat/tool-cards/terminalToolCardState.ts diff --git a/docs/architecture/agent-sdk-product-architecture.md b/docs/architecture/agent-sdk-product-architecture.md index 4e6024a2c4..935f2732cf 100644 --- a/docs/architecture/agent-sdk-product-architecture.md +++ b/docs/architecture/agent-sdk-product-architecture.md @@ -341,7 +341,7 @@ await using client = await AgentClient.start({ cwd }); // 常见的一次 Query;返回可迭代、可取消、可关闭的句柄。 await using query = await client.query({ prompt: "Find and fix the failing test", - allowedTools: ["Read", "Edit", "Bash"], + allowedTools: ["Read", "Edit", "ExecCommand"], }); for await (const message of query) { // Message / Event / Result diff --git a/docs/development/ui-testids-CN.md b/docs/development/ui-testids-CN.md index f5e88175ab..76daf6822c 100644 --- a/docs/development/ui-testids-CN.md +++ b/docs/development/ui-testids-CN.md @@ -209,8 +209,8 @@ | Chat shell 命令文本 | `chat-shell-command-text` | Shell 命令文本节点。 | | Chat shell 命令输出 | `chat-shell-command-output` | Shell 命令 stdout/stderr 或实时输出区域。 | | Chat shell 命令退出码 | `chat-shell-command-exit-code` | 退出码节点。包含 `data-exit-code` 和 `data-status`。 | -| Chat shell 工具卡片 | `chat-shell-tool-card` | Bash 的外层 FlowToolCard wrapper。包含 `data-tool-name` 和 `data-tool-card-id`。 | -| Chat shell 工具打开面板按钮 | `chat-shell-tool-open-panel` | 存在 terminal session 时,从 Bash ToolCard 打开关联终端面板。 | +| Chat shell 工具卡片 | `chat-shell-tool-card` | ExecCommand 的外层 FlowToolCard wrapper。包含 `data-tool-name` 和 `data-tool-card-id`。 | +| Chat shell 工具打开面板按钮 | `chat-shell-tool-open-panel` | 存在 terminal session 时,从 ExecCommand 卡片打开关联终端面板。 | | Chat browser 工具卡片 | `chat-browser-tool-card` | WebFetch 的外层 FlowToolCard wrapper。包含 `data-tool-name` 和 `data-tool-card-id`。 | | Chat 文件变更卡片 | `chat-file-change-card` | 文件操作卡片根节点。包含 `data-status`、`data-action`、`data-path` 和 `data-expanded`。 | | Chat 文件变更展开按钮 | `chat-file-change-toggle` | 文件操作卡片的展开/收起点击目标。 | @@ -299,7 +299,7 @@ 说明: -- 独立 xterm 终端没有结构化的逐命令历史 DOM。测试应使用 `shell-command-output` 断言终端渲染输出,使用 `chat-shell-command-*` 断言结构化 Bash ToolCard。 +- 独立 xterm 终端没有结构化的逐命令历史 DOM。测试应使用 `shell-command-output` 断言终端渲染输出,使用 `chat-shell-command-*` 断言结构化 ExecCommand 卡片。 - `shell-command-copy` 当前未暴露,因为活动终端复制能力基于选择/右键上下文菜单,并不是稳定可见按钮。 ## Browser Panel diff --git a/docs/development/ui-testids.md b/docs/development/ui-testids.md index 343a6ff5a3..37c1945f04 100644 --- a/docs/development/ui-testids.md +++ b/docs/development/ui-testids.md @@ -208,7 +208,7 @@ Avoid adding IDs to these surfaces unless there is a clear automated workflow. | Chat shell command text | `chat-shell-command-text` | Shell command text node. | | Chat shell command output | `chat-shell-command-output` | Shell command stdout/stderr or live output area. | | Chat shell command exit code | `chat-shell-command-exit-code` | Exit code node. Includes `data-exit-code` and `data-status`. | -| Chat shell tool card | `chat-shell-tool-card` | Outer FlowToolCard wrapper for Bash. Includes `data-tool-name` and `data-tool-card-id`. | +| Chat shell tool card | `chat-shell-tool-card` | Outer FlowToolCard wrapper for ExecCommand. Includes `data-tool-name` and `data-tool-card-id`. | | Chat shell tool open panel | `chat-shell-tool-open-panel` | Opens the associated terminal panel when a terminal session is available. | | Chat browser tool card | `chat-browser-tool-card` | Outer FlowToolCard wrapper for WebFetch. Includes `data-tool-name` and `data-tool-card-id`. | | Chat file change card | `chat-file-change-card` | File operation card root. Includes `data-status`, `data-action`, `data-path`, and `data-expanded`. | @@ -299,7 +299,7 @@ Avoid adding IDs to these surfaces unless there is a clear automated workflow. Notes: -- The standalone xterm terminal does not expose a structured per-command history DOM. Tests should use `shell-command-output` for rendered terminal output and `chat-shell-command-*` for structured Bash ToolCard assertions. +- The standalone xterm terminal does not expose a structured per-command history DOM. Tests should use `shell-command-output` for rendered terminal output and `chat-shell-command-*` for structured ExecCommand card assertions. - `shell-command-copy` is not currently exposed because the active terminal copy action is context-menu/selection driven rather than a stable visible button. ## Browser Panel diff --git a/docs/features/session-runtime-usage-report-design.md b/docs/features/session-runtime-usage-report-design.md index 2c423febd9..404150ee48 100644 --- a/docs/features/session-runtime-usage-report-design.md +++ b/docs/features/session-runtime-usage-report-design.md @@ -107,8 +107,7 @@ Models gpt-5.4: 8 req, 183,420 input, 21,908 output Tools -Bash: 14 calls, 2m 31s, 2 errors -Git: 5 calls, 42s +ExecCommand: 19 calls, 3m 13s, 2 errors Write/Edit: 7 calls, 1m 08s ``` @@ -151,9 +150,9 @@ Current implemented Markdown shape: | gpt-5.4 | 8 | 183,420 | 21,908 | 205,328 | ### Slowest Work -1. Bash `pnpm run build:web` - 1m 42s +1. ExecCommand `pnpm run build:web` - 1m 42s 2. Context compression - 28s -3. Git `fetch origin main` - 19s +3. ExecCommand `git fetch origin main` - 19s ``` The detailed visual report exists alongside the Markdown snapshot. It uses the structured DTO when present and falls back to the Markdown snapshot for historical/local-only reports. @@ -316,7 +315,7 @@ Required change: Missing: -- Git can happen through the dedicated Git tool or through Bash/terminal commands. +- Git now runs through ExecCommand; persisted legacy sessions may still contain dedicated Git or Bash calls. Required change: @@ -1384,8 +1383,8 @@ Steps: 1. Persist or emit queue wait, preflight, confirmation wait, execution, and total duration for completed tools. 2. Include best-effort total duration for failed and cancelled tools. 3. Add a report-only classifier for tool categories. -4. Classify dedicated Git tool calls as `git`. -5. Classify terminal calls as `git` only when the normalized command clearly invokes Git. +4. Classify legacy dedicated Git tool calls as `git`. +5. Classify ExecCommand and legacy terminal calls as `git` only when the normalized command clearly invokes Git. 6. Classify file operations by tool name and snapshot operation metadata. 7. When SubagentScheduler or budget governance events exist, consume their queued/running/retry/backoff summaries as runtime facts instead of inferring them from tool names. @@ -1407,8 +1406,8 @@ Risks and mitigations: Verification: -- Unit tests for Git tool classification. -- Unit tests for Bash Git command classification and false positives. +- Unit tests for legacy Git tool classification. +- Unit tests for ExecCommand Git command classification and false positives. - Existing tool lifecycle tests still pass. ### Task 10: File-change report integration diff --git a/docs/interactive-capabilities/capabilities.json b/docs/interactive-capabilities/capabilities.json index 51f1404efb..b8d3348b1b 100644 --- a/docs/interactive-capabilities/capabilities.json +++ b/docs/interactive-capabilities/capabilities.json @@ -4,7 +4,7 @@ "title": "BitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "5a170c4fb33ea5bb4fe1d839ff0d405db53b82e586aead0891babafbb59b40aa", + "digest": "6094ecf9b8e3d14c0783f7f0595e3d5e007362c9d89f5655c05e7307e9caa489", "ownerDigest": "a6a8c5337fb877e641b66f5774740d5213caed4800fe9b006eb53632f7eb1c2d", "searchAcceptance": [ { @@ -3381,7 +3381,7 @@ "additionalProperties": true }, "delegateTools": [ - "Git" + "ExecCommand" ], "presentationTarget": { "kind": "scene", @@ -3426,7 +3426,7 @@ "additionalProperties": true }, "delegateTools": [ - "Git" + "ExecCommand" ], "presentationTarget": { "kind": "scene", @@ -3471,7 +3471,7 @@ "additionalProperties": true }, "delegateTools": [ - "Git" + "ExecCommand" ], "presentationTarget": { "kind": "scene", @@ -3516,7 +3516,7 @@ "additionalProperties": true }, "delegateTools": [ - "Git" + "ExecCommand" ], "presentationTarget": { "kind": "scene", @@ -3561,7 +3561,7 @@ "additionalProperties": true }, "delegateTools": [ - "Git" + "ExecCommand" ], "presentationTarget": { "kind": "scene", @@ -3606,7 +3606,7 @@ "additionalProperties": true }, "delegateTools": [ - "Git" + "ExecCommand" ], "presentationTarget": { "kind": "scene", @@ -3651,7 +3651,7 @@ "additionalProperties": true }, "delegateTools": [ - "Git" + "ExecCommand" ], "presentationTarget": { "kind": "scene", @@ -3696,7 +3696,7 @@ "additionalProperties": true }, "delegateTools": [ - "Git" + "ExecCommand" ], "presentationTarget": { "kind": "scene", @@ -19959,15 +19959,15 @@ "control": { "kind": "delegate", "tools": [ - "Git" + "ExecCommand" ], "workflowZh": [ - "若 Git 是延迟工具,先用 GetToolSpec 加载其契约,再按返回的 operation/args 结构调用", - "使用 Git 的 status 与 diff 检查当前工作树、暂存区和目标文件差异" + "在当前工作区通过 ExecCommand 运行 git status 与 git diff", + "检查当前工作树、暂存区和目标文件差异" ], "workflowEn": [ - "If Git is deferred, load its contract with GetToolSpec first, then call it with the returned operation/args shape", - "Use Git status and diff to inspect the working tree, index, and selected file changes" + "Run git status and git diff through ExecCommand in the current workspace", + "Inspect the working tree, index, and selected file changes" ] } }, @@ -19978,15 +19978,15 @@ "control": { "kind": "delegate", "tools": [ - "Git" + "ExecCommand" ], "workflowZh": [ - "若 Git 是延迟工具,先用 GetToolSpec 加载其契约,再按返回的 operation/args 结构调用", - "使用 Git 的 add、restore 或 reset 完成暂存、取消暂存和选定提交重置;危险参数仍经过权限与安全检查" + "通过 ExecCommand 运行 git add、git restore 或 git reset", + "完成暂存、取消暂存和选定提交重置;危险参数仍经过权限与安全检查" ], "workflowEn": [ - "If Git is deferred, load its contract with GetToolSpec first, then call it with the returned operation/args shape", - "Use Git add, restore, or reset to stage, unstage, or reset to a selected commit; risky arguments still pass permission and safety checks" + "Run git add, git restore, or git reset through ExecCommand", + "Stage, unstage, or reset to a selected commit; risky arguments still pass permission and safety checks" ] } }, @@ -19997,15 +19997,15 @@ "control": { "kind": "delegate", "tools": [ - "Git" + "ExecCommand" ], "workflowZh": [ - "若 Git 是延迟工具,先用 GetToolSpec 加载其契约,再按返回的 operation/args 结构调用", - "先检查 status/diff,再用 Git commit 创建经用户确认的提交" + "通过 ExecCommand 先运行 git status 与 git diff", + "再运行 git commit 创建经用户确认的提交" ], "workflowEn": [ - "If Git is deferred, load its contract with GetToolSpec first, then call it with the returned operation/args shape", - "Inspect status/diff first, then create the user-approved commit with Git commit" + "Run git status and git diff through ExecCommand first", + "Then run git commit to create the user-approved commit" ] } }, @@ -20016,15 +20016,15 @@ "control": { "kind": "delegate", "tools": [ - "Git" + "ExecCommand" ], "workflowZh": [ - "若 Git 是延迟工具,先用 GetToolSpec 加载其契约,再按返回的 operation/args 结构调用", - "使用 Git pull 或 push 同步远端;强制推送等高风险参数仍会被拦截或要求确认" + "通过 ExecCommand 运行 git pull 或 git push 同步远端", + "强制推送等高风险参数仍会被拦截或要求确认" ], "workflowEn": [ - "If Git is deferred, load its contract with GetToolSpec first, then call it with the returned operation/args shape", - "Use Git pull or push to synchronize remotes; high-risk arguments such as force push remain blocked or require confirmation" + "Run git pull or git push through ExecCommand to synchronize remotes", + "High-risk arguments such as force push remain blocked or require confirmation" ] } }, @@ -20035,15 +20035,15 @@ "control": { "kind": "delegate", "tools": [ - "Git" + "ExecCommand" ], "workflowZh": [ - "若 Git 是延迟工具,先用 GetToolSpec 加载其契约,再按返回的 operation/args 结构调用", - "使用 Git branch 查看、创建或删除分支,使用 switch/checkout 切换分支" + "通过 ExecCommand 运行 git branch 查看、创建或删除分支", + "运行 git switch 或 git checkout 切换分支" ], "workflowEn": [ - "If Git is deferred, load its contract with GetToolSpec first, then call it with the returned operation/args shape", - "Use Git branch to list, create, or delete branches and switch/checkout to change branches" + "Run git branch through ExecCommand to list, create, or delete branches", + "Run git switch or git checkout to change branches" ] } }, @@ -20054,14 +20054,14 @@ "control": { "kind": "delegate", "tools": [ - "Git" + "ExecCommand" ], "workflowZh": [ - "若 Git 是延迟工具,先用 GetToolSpec 加载其契约,再用 log/show 读取提交图谱与历史", + "通过 ExecCommand 运行 git log 或 git show 读取提交图谱与历史", "界面的最近访问记录仍由 Git 场景保存;Agent 读取历史不会伪装成修改该 UI 状态" ], "workflowEn": [ - "If Git is deferred, load its contract with GetToolSpec, then inspect the commit graph and history with log or show", + "Run git log or git show through ExecCommand to inspect the commit graph and history", "Recent-history UI state remains owned by the Git surface; reading history does not pretend to mutate that presentation state" ] } @@ -20073,15 +20073,15 @@ "control": { "kind": "delegate", "tools": [ - "Git" + "ExecCommand" ], "workflowZh": [ - "若 Git 是延迟工具,先用 GetToolSpec 加载其契约,再按返回的 operation/args 结构调用", - "使用 Git show 解析 revision 并读取指定提交中的对象或文件内容" + "通过 ExecCommand 运行 git show", + "解析 revision 并读取指定提交中的对象或文件内容" ], "workflowEn": [ - "If Git is deferred, load its contract with GetToolSpec first, then call it with the returned operation/args shape", - "Use Git show to resolve a revision and inspect objects or file content from a selected commit" + "Run git show through ExecCommand", + "Resolve a revision and inspect objects or file content from a selected commit" ] } }, @@ -20092,15 +20092,15 @@ "control": { "kind": "delegate", "tools": [ - "Git" + "ExecCommand" ], "workflowZh": [ - "若 Git 是延迟工具,先用 GetToolSpec 加载其契约,再按返回的 operation/args 结构调用", - "使用 Git cherry-pick 启动,并通过 --continue 或 --abort 继续或中止流程" + "通过 ExecCommand 运行 git cherry-pick", + "使用 --continue 或 --abort 继续或中止流程" ], "workflowEn": [ - "If Git is deferred, load its contract with GetToolSpec first, then call it with the returned operation/args shape", - "Use Git cherry-pick to start, then --continue or --abort to continue or cancel the operation" + "Run git cherry-pick through ExecCommand", + "Use --continue or --abort to continue or cancel the operation" ] } }, @@ -20188,26 +20188,39 @@ "带我查看当前代码改动", "Open the Git panel", "Show me the current code changes", - "若 Git 是延迟工具,先用 GetToolSpec 加载其契约,再按返回的 operation/args 结构调用", - "使用 Git 的 status 与 diff 检查当前工作树、暂存区和目标文件差异", - "If Git is deferred, load its contract with GetToolSpec first, then call it with the returned operation/args shape", - "Use Git status and diff to inspect the working tree, index, and selected file changes", - "使用 Git 的 add、restore 或 reset 完成暂存、取消暂存和选定提交重置;危险参数仍经过权限与安全检查", - "Use Git add, restore, or reset to stage, unstage, or reset to a selected commit; risky arguments still pass permission and safety checks", - "先检查 status/diff,再用 Git commit 创建经用户确认的提交", - "Inspect status/diff first, then create the user-approved commit with Git commit", - "使用 Git pull 或 push 同步远端;强制推送等高风险参数仍会被拦截或要求确认", - "Use Git pull or push to synchronize remotes; high-risk arguments such as force push remain blocked or require confirmation", - "使用 Git branch 查看、创建或删除分支,使用 switch/checkout 切换分支", - "Use Git branch to list, create, or delete branches and switch/checkout to change branches", - "若 Git 是延迟工具,先用 GetToolSpec 加载其契约,再用 log/show 读取提交图谱与历史", + "ExecCommand", + "在当前工作区通过 ExecCommand 运行 git status 与 git diff", + "检查当前工作树、暂存区和目标文件差异", + "Run git status and git diff through ExecCommand in the current workspace", + "Inspect the working tree, index, and selected file changes", + "通过 ExecCommand 运行 git add、git restore 或 git reset", + "完成暂存、取消暂存和选定提交重置;危险参数仍经过权限与安全检查", + "Run git add, git restore, or git reset through ExecCommand", + "Stage, unstage, or reset to a selected commit; risky arguments still pass permission and safety checks", + "通过 ExecCommand 先运行 git status 与 git diff", + "再运行 git commit 创建经用户确认的提交", + "Run git status and git diff through ExecCommand first", + "Then run git commit to create the user-approved commit", + "通过 ExecCommand 运行 git pull 或 git push 同步远端", + "强制推送等高风险参数仍会被拦截或要求确认", + "Run git pull or git push through ExecCommand to synchronize remotes", + "High-risk arguments such as force push remain blocked or require confirmation", + "通过 ExecCommand 运行 git branch 查看、创建或删除分支", + "运行 git switch 或 git checkout 切换分支", + "Run git branch through ExecCommand to list, create, or delete branches", + "Run git switch or git checkout to change branches", + "通过 ExecCommand 运行 git log 或 git show 读取提交图谱与历史", "界面的最近访问记录仍由 Git 场景保存;Agent 读取历史不会伪装成修改该 UI 状态", - "If Git is deferred, load its contract with GetToolSpec, then inspect the commit graph and history with log or show", + "Run git log or git show through ExecCommand to inspect the commit graph and history", "Recent-history UI state remains owned by the Git surface; reading history does not pretend to mutate that presentation state", - "使用 Git show 解析 revision 并读取指定提交中的对象或文件内容", - "Use Git show to resolve a revision and inspect objects or file content from a selected commit", - "使用 Git cherry-pick 启动,并通过 --continue 或 --abort 继续或中止流程", - "Use Git cherry-pick to start, then --continue or --abort to continue or cancel the operation" + "通过 ExecCommand 运行 git show", + "解析 revision 并读取指定提交中的对象或文件内容", + "Run git show through ExecCommand", + "Resolve a revision and inspect objects or file content from a selected commit", + "通过 ExecCommand 运行 git cherry-pick", + "使用 --continue 或 --abort 继续或中止流程", + "Run git cherry-pick through ExecCommand", + "Use --continue or --abort to continue or cancel the operation" ], "docsUrl": "https://playbook.openbitfun.com/capabilities/feature.git/" }, diff --git a/docs/interactive-capabilities/capabilities/feature.git.md b/docs/interactive-capabilities/capabilities/feature.git.md index 3bffbead34..b1fea904bf 100644 --- a/docs/interactive-capabilities/capabilities/feature.git.md +++ b/docs/interactive-capabilities/capabilities/feature.git.md @@ -17,21 +17,21 @@ Inspect changes, branches, and commits; stage, commit, merge, cherry-pick, and w ## 完整功能清单 / Everything included -- **由专用 Agent 工具控制 / Delegated Agent tool** · `Git` · 查看仓库状态、修改文件和逐文件 Diff +- **由专用 Agent 工具控制 / Delegated Agent tool** · `ExecCommand` · 查看仓库状态、修改文件和逐文件 Diff - Inspect repository status, changed files, and per-file diffs -- **由专用 Agent 工具控制 / Delegated Agent tool** · `Git` · 暂存文件、取消暂存并重置到指定提交 +- **由专用 Agent 工具控制 / Delegated Agent tool** · `ExecCommand` · 暂存文件、取消暂存并重置到指定提交 - Stage files, unstage changes, and reset to a selected commit -- **由专用 Agent 工具控制 / Delegated Agent tool** · `Git` · 预览或生成提交信息并创建提交 +- **由专用 Agent 工具控制 / Delegated Agent tool** · `ExecCommand` · 预览或生成提交信息并创建提交 - Preview or generate a commit message and create the commit -- **由专用 Agent 工具控制 / Delegated Agent tool** · `Git` · 从远端 Pull 并向远端 Push +- **由专用 Agent 工具控制 / Delegated Agent tool** · `ExecCommand` · 从远端 Pull 并向远端 Push - Pull from and push to remotes -- **由专用 Agent 工具控制 / Delegated Agent tool** · `Git` · 查看、创建、切换和删除本地分支 +- **由专用 Agent 工具控制 / Delegated Agent tool** · `ExecCommand` · 查看、创建、切换和删除本地分支 - Browse, create, check out, and delete local branches -- **由专用 Agent 工具控制 / Delegated Agent tool** · `Git` · 浏览提交图谱与仓库历史并保存最近访问记录 +- **由专用 Agent 工具控制 / Delegated Agent tool** · `ExecCommand` · 浏览提交图谱与仓库历史并保存最近访问记录 - Browse the commit graph and repository history, including recent-history persistence -- **由专用 Agent 工具控制 / Delegated Agent tool** · `Git` · 解析 Revision 并查看任意提交中的文件内容 +- **由专用 Agent 工具控制 / Delegated Agent tool** · `ExecCommand` · 解析 Revision 并查看任意提交中的文件内容 - Resolve revisions and inspect file content from any commit -- **由专用 Agent 工具控制 / Delegated Agent tool** · `Git` · 执行、继续或中止 Cherry-pick +- **由专用 Agent 工具控制 / Delegated Agent tool** · `ExecCommand` · 执行、继续或中止 Cherry-pick - Start, continue, or abort a cherry-pick - **Agent 可定位入口,需交互完成 / Agent opens; interaction required** · 管理仓库信任与 Worktree 的创建、绑定、提升、重建和删除 - Manage repository trust and create, bind, promote, recreate, or remove worktrees diff --git a/docs/interactive-capabilities/technical/product-control-open-audit.json b/docs/interactive-capabilities/technical/product-control-open-audit.json index 0d7de3a69d..8b9425bd0c 100644 --- a/docs/interactive-capabilities/technical/product-control-open-audit.json +++ b/docs/interactive-capabilities/technical/product-control-open-audit.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "5a170c4fb33ea5bb4fe1d839ff0d405db53b82e586aead0891babafbb59b40aa", + "catalogDigest": "6094ecf9b8e3d14c0783f7f0595e3d5e007362c9d89f5655c05e7307e9caa489", "count": 207, "reasonCounts": { "externalAuth": 4, diff --git a/docs/interactive-capabilities/technical/tauri-command-map.json b/docs/interactive-capabilities/technical/tauri-command-map.json index 788174037c..8019cedf46 100644 --- a/docs/interactive-capabilities/technical/tauri-command-map.json +++ b/docs/interactive-capabilities/technical/tauri-command-map.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "5a170c4fb33ea5bb4fe1d839ff0d405db53b82e586aead0891babafbb59b40aa", + "catalogDigest": "6094ecf9b8e3d14c0783f7f0595e3d5e007362c9d89f5655c05e7307e9caa489", "commandCount": 651, "coverage": { "commandCount": 651, diff --git a/docs/plans/computer-use-refactor-plan.md b/docs/plans/computer-use-refactor-plan.md deleted file mode 100644 index 694514d804..0000000000 --- a/docs/plans/computer-use-refactor-plan.md +++ /dev/null @@ -1,230 +0,0 @@ -# BitFun Computer Use / 浏览器控制能力重构方案 - -> 范围:`src/crates/assembly/core` 的 ComputerUse / ControlHub / browser_control / web 工具族、`src/apps/desktop/src/computer_use/*` 执行层、`src/crates/execution` 契约层、web-ui 配置与模式面。 -> 依据:三份内部代码排查 + cua / Codex CLI / Anthropic computer-use-demo / browser-use / playwright-mcp & stagehand 五份标杆调研。所有文件路径均来自排查实证。 - ---- - -## 1. 现状诊断 - -### 1.1 重叠控制路径盘点 - -**"控制/使用浏览器"共 7 条并存路径:** - -| # | 路径 | 入口 | 状态 | -|---|------|------|------| -| 1 | ControlHub `domain=browser`(自研 Rust CDP,~40 action,~4900 行) | `control_hub_tool.rs` + `browser_control/*` | 最深实现,恒开 | -| 2 | agent-browser 内置技能(vercel-labs npm CLI,自带完整 CDP 栈 + 同名 `@eN` ref) | `builtin_skills/agent-browser/SKILL.md` | agentic/Claw/coding 默认开启 | -| 3 | ComputerUse 桌面视觉/AX 路径(可物理操作浏览器窗口) | `computer_use_tool.rs` | guard 是死代码,实际不设防 | -| 4 | ComputerUse `open_url`/`open_file`(OS 默认浏览器,不可控) | `computer_use_actions.rs:1512` | 活 | -| 5 | ControlHub `browser.open_builtin`(内置面板,纯展示、agent 读不回) | `EventHandlerModule.ts:2174` | 单向 | -| 6 | 内容读取族:WebFetch / browser.fetch / read_article / get_html / get_text / evaluate | `web/fetch.rs`、`browser_control/actions.rs` | 4-5 条重叠,无路由指导 | -| 7 | 用户自配 MCP(如 Playwright MCP) | `mcp_tools.rs` | 潜在 | - -**ComputerUse 内部"点击一个目标"共 7 种方言:** `locate→mouse_move→click`、`click_element`、`click_target`、`move_to_text→click`、`app_click{6 种寻址}`、`interactive_click(i)`、`visual_click(i)`;叠加 **4 套坐标系**(image px / native px / global logical / normalized 0-1000)。 - -### 1.2 核心耦合点 - -1. **`computer_use_tool.rs` ↔ `computer_use_actions.rs` 互相调用成环**(open_app 三跳绕环;未来漏配 dispatcher 列表即无限递归),且 ComputerUse 借用 `control_hub/errors.rs` 的封套,`domain='desktop'` 泄漏——两套错误形状在同一工具内混用。 -2. **ComputerUseHost 是 60+ 方法胖 trait**(`tools/computer_use_host.rs`,560 行),把截图裁剪、OCR 信任、点击守卫状态机、Set-of-Mark、Codex 风格 app_* 全部塞进一个接口,执行层(`src/apps/desktop/src/computer_use/*`,~1.9 万行)与工具层强耦合。 -3. **桌面 host 是进程级单例**(`lib.rs:1615`)+ `APP_LOOP_TRACKER` 全局静态,会话状态跨 session 污染。 -4. **三层能力开关互不咬合**:cargo feature 是装饰(`tool-provider-groups` 的 `enabled_feature_groups()` 无消费者)、`core.integration` 把 7 组能力绑死、ControlHub `is_enabled()` 硬编码 true。 -5. **三份 LOCAL_ONLY deny 表手工同步已漂移**(FE `peer-device-adapter.ts` / desktop `peer_host_invoke.rs` / cli `peer_host/deny.rs`),与 `remote_workspace_policy.rs` 的 LocalOnly 声明是两套独立"本地性"语义。 -6. **概念身份不收敛**:ComputerUse 同时是模式(FE CORE_AGENT_IDS)、restricted SubAgent(`agent-runtime/src/agents.rs:96`)、工具名(`toolGroups.ts:78`),文案三种称呼。 - -### 1.3 问题清单(合并去重,按 severity 排序) - -#### Critical - -| # | 问题 | 关键文件 | -|---|------|---------| -| C1 | **双浏览器自动化栈并存,prompt 指导互相矛盾**:SKILL.md 说优先 agent-browser,claw_mode.md/computer_use_mode.md 说用 ControlHub;两套 `@eN` ref 空间、两个浏览器实例、登录态互不可见 | `builtin_skills/agent-browser/SKILL.md`、`control_hub_tool.rs`、`agents/prompts/claw_mode.md`、`skills/policy.rs` | -| C2 | **浏览器/桌面边界 guard 是不可达死代码**:`desktop_action_targets_browser` 只在 `handle_desktop` 落空分支被调,而它想拦的 click/type_text 等在 `call_impl` 内联处理,边界只剩提示词 | `computer_use_actions.rs`、`computer_use_tool.rs` | -| C3 | **单一 god-tool:40 action、7 条点击方言、4 套坐标系、5KB 手写描述 ×2 份**,参数命名互不一致(`text_contains`/`target_text`/`text_query`/`ocr_text.needle`) | `computer_use_tool.rs`(2181 行)、`computer_use_actions.rs`(1648 行) | -| C4 | **headless 模式是假的且危险**:无 headless 启动实现,与 default 共用 9222 端口;可能附着用户真实登录浏览器却标为 "Headless test browser" | `control_hub_tool.rs:539-575,603`、`services-integrations/src/browser_control/launcher.rs` | -| C5 | **关闭 Computer use 后 ControlHub 仍可完全控制用户真实浏览器**:`is_enabled()` 恒 true、不申报 permission intent、无全局规则可 deny,文案却承诺"关闭后任何模式都不启用" | `control_hub_tool.rs:1925`、`GlobalPermissionRulesDialog.tsx`、`locales/en-US/settings/session-config.json` | -| C6 | **Firefox/Safari 用户被双向锁死**:guard 拒绝桌面输入并指向 ControlHub,而 BrowserKind 只支持 Chromium 系,CDP connect 必败 | `computer_use_actions.rs:121-150`、`launcher.rs:25-32`、`computer_use_mode.md` | - -#### Major - -| # | 问题 | 关键文件 | -|---|------|---------| -| M1 | 模型可见文案引用已删除的幽灵工具(ComputerUseMouseStep/MousePrecise/MouseClick),诱导调用不存在的工具 | `tool-contracts/src/computer_use.rs:1214,1584`、`tool-contracts/src/framework.rs:2269-2279`、`computer_use_tool.rs:992`、`computer_use_host.rs` | -| M2 | 桌面 host 会话状态进程级单例 + `APP_LOOP_TRACKER` 全局静态,跨 session 污染守卫/截图缓存/循环检测 | `desktop/src/lib.rs:1615`、`desktop_host/mod.rs`、`computer_use_actions.rs:30` | -| M3 | 大量死代码:input/result shim、verification/RetryStrategy 未接线、`handle_system('open_app')` 不可达、`enabled_feature_groups()` 无消费者 | `computer_use_input.rs`、`computer_use_result.rs`、`computer_use_verification.rs`、`tool-execution/src/computer_use.rs`、`tool-provider-groups/src/lib.rs` | -| M4 | tool ↔ actions 调用环 + ControlHub 错误封套泄漏,模型看到两种失败形状 | `computer_use_tool.rs`、`computer_use_actions.rs`、`control_hub/errors.rs` | -| M5 | 每次截图无条件写入 `/.bitfun/computer_use_debug/`,无门控、无轮转,隐私风险 | `computer_use_tool.rs`(try_save_screenshot_for_debug) | -| M6 | 未对接任何 provider 原生 computer-use 形态(Anthropic computer_20250124/OpenAI computer-use-preview),模型无法复用训练先验;多模态回传仅限两类 converter | `computer_use_tool.rs`、`tool-execution/src/context.rs` | -| M7 | text-only 门控不一致:schema 仍暴露 Set-of-Mark 纯视觉 action,`handle_desktop_ax` 附图不检查多模态能力 | `computer_use_tool.rs:249`、`computer_use_actions.rs:648-838` | -| M8 | `frame`/`frame_main` 是死功能(active_frame 无读者);同源 iframe 内点击坐标缺 offset 修正,点错位置 | `control_hub_tool.rs:1657-1698`、`browser_control/actions.rs:552-568` | -| M9 | `control_hub_tool.rs` 2668 行 god file,错误分类靠 `to_lowercase().contains(...)` 字符串猜测反推 ErrorCode | `control_hub_tool.rs`、`browser_control/actions.rs` | -| M10 | 内容读取 4-5 条路径无选择指导,`browser.fetch` 带用户登录态发任意请求却与 WebFetch 无差异化约束 | `web/fetch.rs`、`agentic_mode.md` | -| M11 | TerminalControl 与 ControlHub terminal 域双入口,session id 发现口径各说各话 | `terminal_control_tool.rs`、`control_hub_tool.rs:1717-1815` | -| M12 | `open_builtin` 内置面板单向不可观察,模型易误以为可继续 snapshot | `EventHandlerModule.ts:2174`、`control_hub_tool.rs` | -| M13 | Peer 模式混合机器语义:SessionConfig 裸调 Tauri invoke 打本机,configManager 写远端——权限弹窗弹在控制端、工具跑在 peer 端 | `SessionConfig.tsx:165-205,593,644-695`、`peer-device-adapter.ts`、`peer_host_invoke.rs` | -| M14 | 三份 deny 表漂移(speech_* 只在 FE、CLI 缺项),`browser_control_*` 不在任何 deny 表——控制器可静默在 peer 主机启动浏览器 | `peer-device-adapter.ts`、`peer_host_invoke.rs`、`cli/src/peer_host/deny.rs`、`remote_workspace_policy.rs` | -| M15 | ComputerUse 模式禁用门禁只在 FE 下拉生效,slash 命令 `/ComputerUse` 不拦截,后端不校验,工具静默缺失无解释 | `ChatInput.tsx:2440,4130,4977`、`AgentsScene.tsx:704` | - -#### Minor(合并列举) - -- 结果 JSON 三重字段别名(image_jpeg_width/image_width/display_width_px…);scroll 的 `scroll_x/scroll_y` 绕过 `ensure_global_xy_on_display` 边界守卫;num_clicks 循环模拟双击不用 CGEvent click_state(`computer_use_tool.rs:1581`、`tool-contracts/src/computer_use.rs`)。 -- `analyze_image_tool.rs` / `view_image_tool.rs` 整段复制 ResolvedImagePath/读取逻辑,三条图片链路无统一选择指引。 -- 陈旧注释指向不存在的 `claw_mode.md`(应为 computer_use_mode.md)、ControlHub 域口吻残留、loop 警告用已删除的 `desktop.screenshot` 语法;Linux 后端仅 141 行空壳但 schema 不裁剪。 -- cdp 方法白名单是摆设(evaluate 全权可绕);`--remote-debugging-port=9222` hint 教用户裸暴露登录态。 -- `ai.computer_use_enabled` 订阅逻辑三处复制且初始默认值矛盾(true vs false,首帧误导);`SessionConfig.tsx` 1775 行双 variant 互相污染;`AIFeaturesConfig.tsx` 死组件;`computer_use_open_system_settings` Windows 分支 UI 不可达;FE AIConfig 类型漂移;模型无视觉能力时无降级提示。 - ---- - -## 2. 标杆对比 - -| 维度 | BitFun 现状 | cua | Codex CLI | Anthropic demo | browser-use | playwright-mcp / stagehand | 差距结论 | -|------|------------|-----|-----------|----------------|-------------|---------------------------|---------| -| **动作空间** | 40 action 自造方言,7 条点击路径 | OpenAI+Anthropic 动作并集,按 tag 分发 | 工具极少(shell/apply_patch/view_image),GUI 委托 MCP | 日期版本化 enum,10-17 个动作,服务端定义 schema | ~20 个结构化动作,index 为句柄 | 每域一文件的声明式小工具 | **决策面失控**:应收敛到标准动作集 + 版本化 enum | -| **provider 原生形态** | 无,自造 5KB 描述现学 | 模型 regex 注册表,边缘转换到原生 computer_20251124 / computer-use-preview | — | 原生 Anthropic-defined tool,客户端零 schema | 能力门控换 schema | — | **放弃训练先验是执行质量差的直接原因** | -| **坐标处理** | 4 套坐标系并存,scroll 绕过校验 | per-screenshot scale factor 追踪 + reset | — | `scale_coordinates(source,x,y)` 单函数双向 | 截图尺寸→viewport 换算,坐标是门控降级 | ref 免坐标 | **需要唯一的双向缩放模块** | -| **浏览器交互范式** | CDP JS 注入 + `@eN` 属性写入,两套 ref 栈打架 | pixel + BrowserTool 页级动词 | 委托 MCP | — | **a11y 三树合并 + index 句柄**(成功率来源) | **aria snapshot + ref**,坐标隔离在 vision capability | **语义引用优先,坐标降级** 是业界共识 | -| **观察闭环** | 动作后需另调 screenshot;augment_result 附零散字段 | 执行器烘焙 post-action 截图 | — | 动作后 2s settle + 自动截图 | 动作即回灌新状态 + diff `*` 标注 | Response 聚合器:快照+tab diff+事件一并回传 | **"动作即观察"缺失,回合数浪费** | -| **工具契约/注册** | 手写双份 JSON schema + 测试防漂移 | Protocol + 注册表 | **spec 与 runtime 同 trait 对象**、ToolExposure 四态、每回合 spec_plan 组装 | ToolGroup{version,tools,beta_flag} 注册表 | 装饰器 + schema 自动派生 + 域名过滤 | Tool{capability,kind,zod,handle} + filteredTools | **schema/实现分离导致漂移;应 spec-runtime 同体** | -| **分层** | 工具层直连 60+ 方法胖 trait,执行层在 Tauri 进程内 | Provider ⊥ Interface ⊥ Handler 三层正交 | 契约 crate ⊥ 编排 ⊥ 风险编排 ⊥ 沙箱 crate | UI/loop/dispatch/tool/executor 五层 | Agent/Registry/Session/DOM 四层 | tools/mcp/backend 三层 | **BitFun 缺清晰层界,横切关注点全内联** | -| **会话状态** | 进程级单例 Mutex + 全局静态 | per-Computer 实例 | per-turn 组装 + 会话级审批缓存 | per-session 对象 | per-BrowserSession | per-context | **必须 per-session 键控** | -| **错误模型** | 两套封套混用 + 字符串猜 ErrorCode | 结构化 tool-error item,永不 abort | 稳定 ErrorCode + 审批 key | ToolError→is_error tool_result 唯一转换点 | 一切异常→ActionResult(error) 回灌 | 可恢复错误 + 恢复指令("Try new snapshot") | **需要唯一异常边界 + 稳定 code** | -| **安全/审批** | guard 死代码、ControlHub 恒开无 intent、9222 裸端口 hint | safety_checks 透传(含 TODO) | 审批 key 化缓存 + 沙箱升级阶梯 + 网络审批独立流 | Docker 沙箱 + prompt injection 分类器承接 | 敏感数据 `` 占位 + 域名白名单 | allowed/blockedOrigins 网络层强制 + element 描述供审批 UI | **安全边界应在 Rust 核心强制,不在 prompt** | -| **截图/上下文管理** | JPEG 无条件落盘 + 全量回传 | ImageRetentionCallback、trajectory 落盘可 replay | 输出截断一等策略 | 按块修剪保护 prompt cache | 干净截图 + 人用高亮分离 | 大输出写文件 + outputMaxSize | **无 retention 策略,落盘无门控** | - ---- - -## 3. 目标架构 - -### 3.1 分层设计 - -``` -┌────────────────────────────────────────────────────────────────┐ -│ L3 模式与配置面 │ -│ · 双独立开关: ai.computer_use_enabled (桌面) 与 │ -│ ai.browser_control_enabled (浏览器, 默认开) 互不牵连 │ -│ · permission intents: computer_use + browser_control │ -│ · 每回合工具组装 (仿 codex spec_plan): 按模型能力/平台/远程裁剪 │ -│ · deny 表单一真源 (Rust 导出 + contract test 三端对齐) │ -├────────────────────────────────────────────────────────────────┤ -│ L2 工具面 (模型可见) │ -│ · Desktop: Anthropic 原生 computer 形态 (Claude) / │ -│ 标准化自定义 fallback (其他模型);辅助定位工具独立 │ -│ · Browser: 单一栈, snapshot@ref 交互, 坐标为门控降级 │ -│ · Response 聚合器: 动作即观察 │ -│ · 统一 ToolResult / 稳定 ErrorCode / 唯一异常边界 │ -├────────────────────────────────────────────────────────────────┤ -│ L1 执行后端层 (Surface traits) │ -│ · DesktopSurface: screenshot/click/type/key/scroll/drag/ │ -│ ax_snapshot/window_ops (per-session 状态) │ -│ · BrowserSurface: CDP snapshot/resolve_ref/click/fill/ │ -│ navigate/fetch/events (session registry 保留) │ -│ · 坐标策略唯一模块: scale(Api↔Physical) + DPI 折算 │ -├────────────────────────────────────────────────────────────────┤ -│ L0 契约层 (独立 crate, 不依赖 Session) │ -│ · Action enum (serde tag, 版本化, 对齐 Anthropic 动作集) │ -│ · ToolResult{output,error,image,system} / TargetRef │ -│ · ToolSpec 与执行绑定同一 trait 对象 (spec-runtime 同体) │ -└────────────────────────────────────────────────────────────────┘ -``` - -### 3.2 关键决策及理由 - -**决策 1:桌面控制走"视觉坐标为主干 + AX 为辅助定位",对 Claude 映射 Anthropic 原生 computer 工具形态。** -- 理由:桌面没有普适的 DOM;Anthropic computer_20250124/20251124 动作集(screenshot/left_click/type/key/scroll/zoom…)是模型训练过的先验,cua 与 Anthropic demo 证明按原生形态声明(display_width_px = 实际发送截图尺寸)可直接消除"从 5KB 描述现学"的质量损失(对应 M6)。 -- 现有的 AX(`windows_ax_ui`/`macos_ax_ui`)、OCR、Set-of-Mark 能力**不删除,降级为补充**:合并为一个 `desktop_snapshot`(UIA/AX 树序列化为带 ref 的文本,服务 text-only 模型与精确定位)+ 一个 `desktop_find`(文本检索),不再作为并列的 7 条点击方言。40 action 收敛为:观察(screenshot/snapshot/get_app_state)、定位(单一 `target` 对象语法:`{ref} | {text} | {x,y}`,内部按 AX→OCR→coords 阶梯解析,复用现有 click_target 解析器)、动作(click/type/key/scroll/drag/wait)、系统(open_app/open_url/clipboard),约 15 个。 -- 坐标系收敛为 2 套:模型空间(= 发送截图尺寸)与物理空间,唯一双向函数(仿 demo 的 `scale_coordinates`),Windows per-monitor DPI 折算进同一变换;删除 normalized 0-1000 与 "Ignored…host rejects" 参数。 - -**决策 2:浏览器控制走 accessibility-tree/DOM 引用(snapshot@ref),视觉坐标仅作能力门控降级。** -- 理由:browser-use(69k stars)与 playwright-mcp 的一致结论——有可枚举语义树就用索引:确定性、可校验、不受截图缩放/DPI 影响;两家的成功率投资都在语义树提取(三树合并/paint-order 过滤),不在视觉 grounding。现有 ControlHub snapshot 已有 `@eN` ref 机制,方向正确,需修 iframe 坐标(M8)并把交互参数统一为 `{element: 人类可读描述, target: ref|selector}` 双通道(描述供审批 UI)。 - -**决策 3:浏览器自动化栈二选一——保留 ControlHub Rust CDP 栈为唯一路径,agent-browser 技能降为默认关闭的 opt-in(对应 C1)。** -- 理由:ControlHub 栈在自己进程内、可被权限系统/审批/deny 表统一管辖、与 web-ui 事件打通;agent-browser 是外部 npm CLI + 独立 Chromium + 独立 auth vault,无法纳入 BitFun 的权限与 Peer 策略,且两套 `@eN` ref 并存是模型出错最大源头。`skills/policy.rs` 的 `resolve_builtin_default_enabled` 全模式改 false。 - -**决策 4:浏览器/桌面边界 guard 真正执行,且区分 CDP 可控与不可控浏览器(对应 C2/C6)。** -- 把 `desktop_action_targets_browser` 移到统一 dispatcher 的输入类动作入口前;前台是 **Chromium 系(CDP 可控)** 时拒绝并指向 browser 工具;前台是 **Firefox/Safari** 时放行桌面控制(走视觉坐标路径),消除双向锁死。 - -**决策 5:会话状态 per-session 键控。** `DesktopComputerUseHost` 的 `ComputerUseSessionMutableState` 改为 `HashMap`(或经 ToolUseContext 注入 per-session 包装),删除 `APP_LOOP_TRACKER` 全局静态(M2)。 - -**决策 6:唯一异常边界 + 稳定错误形状。** 定义 `ComputerToolError`(带稳定 code + 给模型的恢复指令,如 "ref stale, take a new snapshot"),dispatcher 是唯一 catch 点,转 `is_error` tool result;ComputerUse 停用 ControlHub 封套;`browser_control/actions.rs` 直接产生带 code 的错误,删除 `map_dispatch_error` 字符串猜测(M4/M9)。 - -**决策 7:动作即观察。** 执行器在每个 mutating 动作后:settle 延迟(桌面固定/浏览器等 network idle)→ 自动截图或快照 diff → 打包进同一 result(仿 playwright-mcp Response 聚合器 + cua post-action screenshot)。配套截图 retention(只留最近 N 张,按块修剪保护 prompt cache)。 - -**决策 8:能力开关收敛为两个独立的真实门控。** 删除装饰性 cargo feature 层;桌面控制与浏览器控制是两个独立能力:`ai.computer_use_enabled` 只门控 ComputerUse 桌面工具(现状已如此);新增独立的 `ai.browser_control_enabled`(默认开)门控 ControlHub browser 域,关闭 computer use 不影响浏览器控制(产品决策确认,2026-07-26)。ControlHub 实现真实 `is_enabled()` 服从后者,并按 DeliveryProfile/远程会话裁剪;新增 `browser_control` permission intent 进后端枚举与 `GlobalPermissionRulesDialog.tsx`(对应 C5 的可管辖性诉求)。 - ---- - -## 4. 重构路线图 - -每阶段可独立合并、可编译可测;前 3 阶段不改变模型可见行为面(除删除幽灵引用),从阶段 4 起改变工具面需 A/B 验证。 - -### 阶段 0:止血(~1 周,即速赢清单,见 §5) - -### 阶段 1:死代码清理 + 打断环 + 错误统一(~1 周) - -- **删除**:`computer_use_input.rs`、`computer_use_result.rs`、`computer_use_verification.rs`、`tool-execution/src/computer_use.rs` 中未接线的 `RetryStrategy`/`detect_visual_change`、`handle_system('open_app')` 分支、`ComputerUseHost::get_action_history`、`tool-provider-groups` 的 `enabled_feature_groups()` 装饰层、`AIFeaturesConfig.tsx`、`computer_use_actions.rs:1277` 空注释段。 -- **打断环**:新建 `computer_use/dispatch.rs`,`call_impl` 与 `handle_desktop`/`handle_system` 全部单向汇入;删除 `handle_desktop` 尾部反向 new `ComputerUseTool` 的 fallback。 -- **错误统一**:ComputerUse 全面切换到自有错误类型(稳定 code),移除对 `control_hub/errors.rs` 的依赖;把 `ComputerUseActions` 的 system_* 测试从 `control_hub_tests`(`control_hub_tool.rs:2487-2648`)移回所属文件。 -- 风险:低(删的都是零引用代码)。验证:`cargo build` 全工作区 + 现有 4 个 schema 防漂移单测 + grep 确认零引用。 - -### 阶段 2:动作空间收敛(~2 周) - -- **文件**:`computer_use_tool.rs`、`computer_use_actions.rs`、`computer_use_locate.rs`、`tool-contracts/src/computer_use.rs`。 -- 40 action → ~15:`click_element`/`move_to_text`/`locate` 退化为 `click_target` 统一解析器的内部实现并从 schema 移除;`app_click` 六种寻址与 `interactive_click`/`visual_click` 合并进单一 `target` 语法;`delta_x`/`dx` 等双收参数、三重结果别名(保留 `image_*` 与 `native_*` 各一组)清理。 -- **坐标模块**:新建 `tool-contracts/src/computer_use/coords.rs`,唯一 `scale(source, x, y)` 双向函数 + DPI;scroll 的 `scroll_x/scroll_y` 补 `ensure_global_xy_on_display`;删除 normalized 0-1000。 -- **text-only 门控统一**:text-only schema 移除 interactive/visual view action;`handle_desktop_ax` 附件统一走 `require_multimodal_tool_output_for_screenshot` 同款检查。 -- **边界 guard 落地**(决策 4):guard 移入 dispatcher 输入动作入口,Firefox/Safari 放行。 -- 风险:中——模型行为面变化。验证:保留旧 action 名为 alias 一个版本期(deserialize 兼容 + deprecation 警告);用现有 ComputerUse 子代理跑固定任务集(打开 app、点击、输入、滚动)录 trajectory 对比回合数与成功率。 - -### 阶段 3:会话状态 per-session + host trait 瘦身(~1-2 周) - -- **文件**:`src/apps/desktop/src/lib.rs:1615`、`desktop_host/mod.rs`、`computer_use_actions.rs:30`、`computer_use_host.rs`、`api/computer_use_api.rs`。 -- `DesktopComputerUseHost` 状态按 session key 键控;删除 `APP_LOOP_TRACKER` 静态(循环检测并入 per-session optimizer);Tauri 命令与管线共享同一实例边界定义。 -- `ComputerUseHost` 60+ 方法按 §3.1 拆为 `DesktopSurface`(输入/截图/窗口)+ `AxProvider`(快照/定位)+ `OcrProvider`,工具层只依赖窄接口。 -- 风险:中(并发路径)。验证:新增两会话并发单测(守卫/循环检测互不干扰);macOS/Windows 手测。 - -### 阶段 4:provider 原生形态映射(~2 周) - -- **文件**:`computer_use_tool.rs`、`tool-execution/src/context.rs`、provider converter 层。 -- 新建版本注册表(仿 demo `groups.py`):Claude 模型 → `computer_20250124`/`computer_20251124` + beta header,工具声明 `display_width_px/height` = 实际截图尺寸,默认 `enable_zoom`;非 Claude 模型沿用阶段 2 收敛后的自定义 schema;describe_screen 文本降级保留。 -- 截图 retention(最近 N 张、按块修剪)进上下文管理器。 -- 风险:中高——converter 改动影响所有多模态回传。验证:Anthropic 直连 + OpenAI 兼容两条链路的集成测试;同任务集对比原生形态 vs 自定义形态成功率(预期显著提升)。 - -### 阶段 5:浏览器栈收敛(~2-3 周) - -- **文件**:`skills/policy.rs`(agent-browser 全模式默认 false)、`control_hub_tool.rs`、`browser_control/actions.rs`、`launcher.rs`、`claw_mode.md`/`computer_use_mode.md`/`agentic_mode.md`。 -- 修 iframe:`element_center` 累加 `frameElement.getBoundingClientRect()` 偏移;删除死功能 `frame`/`frame_main`。 -- headless 修复:`launch_with_cdp_opts` 实现真 headless 启动(独立端口 9223+、独立 user-data-dir),connect 校验 `/json/version` Headless 标识;绝不与 default 共用 9222。 -- 结构化错误:`actions.rs` 直接返回 ErrorCode,删 `map_dispatch_error`;`control_hub_tool.rs` 按域拆文件。 -- 合并 TerminalControl 双入口(保留 ControlHub terminal 域,注销独立注册);`open_builtin` 返回值明示"面板不可观察"或补 URL/标题回传事件。 -- **路由指导集中成一份**注入所有相关 prompt:WebFetch(无登录态读文)→ browser.read_article/fetch(登录态读)→ browser connect/snapshot(交互)→ ComputerUse(非 CDP 浏览器/桌面)→ open_builtin(给用户看)。 -- 风险:中——agent-browser 用户回退路径需公告;headless 改动影响现有连接流程。验证:Chromium/Edge/Brave 连接矩阵测试 + iframe 点击回归页面 + prompt 一致性 grep 测试。 - -### 阶段 6:配置/权限/Peer 面(~2 周) - -- **文件**:`control_hub_tool.rs`(真实 `is_enabled`)、`GlobalPermissionRulesDialog.tsx` + 后端 intent 枚举(新增 `browser_control`)、`session-config.json` 文案修正、`SessionConfig.tsx`(拆 personalization/permissions 两组件、状态命令走传输适配层或标注本机/远端)、`peer-device-adapter.ts`/`peer_host_invoke.rs`/`cli/peer_host/deny.rs`(Rust 单一真源导出 + contract test,`browser_control_*` 补 deny)、`ChatInput.tsx`/`AgentsScene.tsx`(抽 `useComputerUseEnabled()` hook,slash 路径补门禁,门禁移后端 `get_available_modes`)、`agents.rs`/`agentVisibility.ts`(统一 ComputerUse 身份与命名)。 -- 风险:低中。验证:deny 表 contract test 三端对齐;Peer 场景手测开关/权限弹窗归属;关闭 computer use 后确认 ControlHub browser 域**不受影响**(两开关独立),关闭 browser_control 后确认 ControlHub browser 域禁用且 ComputerUse 不受影响。 - ---- - -## 5. 速赢清单(一周内,高价值小改动) - -1. **清除幽灵工具名**(M1,半天):`tool-contracts/src/computer_use.rs:1214,1584`、`framework.rs:2269-2279`、`computer_use_tool.rs:992`、`computer_use_host.rs` doc 中的 ComputerUseMouseStep/MousePrecise/MouseClick 全替换为现行 action 名。直接消除"模型调用不存在工具"的失败循环。 -2. **截图落盘加门控**(M5,半天):`try_save_screenshot_for_debug` 改为 debug 配置开关(默认关)+ 数量/天数轮转,`.bitfun/computer_use_debug` 进默认 gitignore。 -3. **headless 误标止血**(C4,1 天):在真 headless 实现前,`control_hub_tool.rs:539-603` 的 headless connect 至少校验 `/json/version` 是否含 Headless,否则报错而非标 "Headless test browser";hint 改为引导 BitFun 托管 profile(`launch_with_cdp_opts` 已支持 `managed_profile_root`)而非教用户裸开 9222。 -4. **边界 guard 最小落地**(C2/C6,1 天):`desktop_action_targets_browser` 调用点移入 `call_impl` 的 click/type/key/scroll/drag 分发前;`is_probably_browser_app` 关键词表移除 firefox/safari。 -5. **slash 门禁补齐**(M15,半天):`ChatInput.tsx` 的 `selectSlashCommandMode`(L4130-4137)与 SlashModeItem 列表复用 `modeDisabled` 检查。 -6. **文案矛盾统一**(C1 部分,半天):`claw_mode.md`/`computer_use_mode.md`/agent-browser SKILL.md local_patch 三处路由指令统一为一个口径(过渡期先统一说 ControlHub);修正 `computer_use_actions.rs:26` 的 `claw_mode.md` 错误引用。 -7. **text-only schema 裁剪**(M7,半天):`input_schema_text_only`(`computer_use_tool.rs:249`)移除 build_interactive_view/interactive_click/build_visual_mark_view/visual_click。 -8. **scroll 坐标守卫**(半天):`computer_use_tool.rs:1581-1586` 的 `scroll_x/scroll_y` 补 `ensure_global_xy_on_display` 校验。 -9. **删除四个零引用死文件**(半天):`computer_use_input.rs`、`computer_use_result.rs`、`computer_use_verification.rs`、`AIFeaturesConfig.tsx`。 -10. **`useComputerUseEnabled()` hook**(半天):统一 `ChatInput.tsx:872`/`AgentsScene.tsx:245`/`SessionConfig.tsx` 三处复制的订阅逻辑,初始值统一 false,消除首帧误导。 -11. **文案过度承诺修正**(C5 部分,半天):`session-config.json` 的 enableDesc 在 ControlHub 真实门控落地前,先如实说明"浏览器控制(ControlHub)不受此开关约束"。 - ---- - -### 附:预期收益 - -- 模型决策面从 40 action / 7 点击方言 → ~15 action / 1 条定位语法;Claude 直接吃训练先验(阶段 4 是执行质量的最大单点收益)。 -- 浏览器控制从 7 条路径 → 1 条主路径(snapshot@ref)+ 明确降级阶梯,两套 `@eN` 冲突消失。 -- 安全面从"提示词约束 + 恒开工具"→ 双 intent 权限 + 真实开关 + deny 表单一真源。 -- 代码量预计净删 6-8 千行(死代码 + 重复方言 + 双份 schema),`control_hub_tool.rs` 与 `computer_use_tool.rs` 两个 2000+ 行 god file 拆解为按域模块。 \ No newline at end of file diff --git a/docs/plans/core-decomposition-completed.md b/docs/plans/core-decomposition-completed.md index 3bcdfd2a3e..daf8588475 100644 --- a/docs/plans/core-decomposition-completed.md +++ b/docs/plans/core-decomposition-completed.md @@ -32,7 +32,7 @@ - `services-integrations` 已承接 remote-connect primitives、wire command routing / response assembly、remote chat image metadata / display helper projection、remote image lifecycle attachment mapping、LAN IP/URL 探测、ngrok 进程/tunnel lifecycle、mobile-web relay upload manifest / incremental upload / fallback upload、IM bot provider-neutral config / persistence / file auto-push / locale / menu / state / command parsing、Weixin provider client、workspace search concrete owner、remote SSH/SFTP/PTY owner、Remote SSH disabled runtime surface、Remote SSH workspace/session identity helper、remote workspace-search disabled surface、DeepResearch report IO / display-map sidecar、MiniApp host dispatch / storage / worker / import IO、announcement remote fetch/cache、browser CDP endpoint HTTP probing / page creation、WebFetch / WebSearch concrete HTTP provider、review-platform provider service / token store / HTTP transport / Git provider integration,以及 MCP server registry、connection pool、catalog cache、reconnect retry state、runtime-only config overlay、local command resolution helper、lifecycle status policy 和 MCP OAuth credential vault / store / authorization bootstrap;core 仍保留 persisted turn adapter 和 MCP auth 的产品 data-dir 注入、授权入口、错误映射与 deprecated 兼容 wrapper。 - `relay-service` 已承接 room/device 状态、account/sync 存储、asset store 和 HTTP/WebSocket router;standalone app 保留 bind、环境配置、静态 fallback、进程生命周期和管理 CLI,embedded 入口复用同一 router。embedded 的 bind、静态 fallback 和任务生命周期已由窄 `EmbeddedRelayHost` 端口迁至 Desktop,assembly 只保留产品启停顺序和失败补偿。 - `tool-contracts` 已承接 provider-neutral tool DTO、manifest/catalog/admission/result presentation、Computer Use DTO/input parser/screenshot payload、confirmation facts、truncation recovery presentation、runtime restriction policy、provider-entry materialization、materialized tool snapshot、provider identity、permission/effect filter、cancellation contract 和 stale-call guard;core 只保留 Computer Use 旧 public path re-export / compatibility shim、产品 Tool trait 适配与产品执行入口。 -- `tool-execution` 已承接 local / remote IO helper、Bash shell helper、batching plan、retry policy、state counting、tool state event payload shaping / result redaction、cancellation-state/token-store policy、background exec output capture、ExecCommand provider-neutral 呈现 / 输入默认值 / 结果 shape / shell metadata / shell argv / remote shell probe / remote env snapshot 解析、cache 与 capture policy / lifecycle facts / control facts / completion shape、prompt-safe tool context facts / custom-data materialization、Computer Use loop detection / screenshot hash / verification / retry policy、WebFetch readable extraction / fallback / title / format facts、WebSearch Exa text result parsing,以及 File tool 的 provider-neutral 结果展示、写入 mode/status/line-count 规则、Edit guardrail 分类和 Delete success 文本;core 只保留 ToolResult 包装、权限、checkpoint、runtime handles、host adapter 调用、read-state adapter、remote FS 调用、Web tool network provider 调用和旧工具入口。 +- `tool-execution` 已承接 local / remote IO helper、shell execution helper、batching plan、retry policy、state counting、tool state event payload shaping / result redaction、cancellation-state/token-store policy、background exec output capture、ExecCommand provider-neutral 呈现 / 输入默认值 / 结果 shape / shell metadata / shell argv / remote shell probe / remote env snapshot 解析、cache 与 capture policy / lifecycle facts / control facts / completion shape、prompt-safe tool context facts / custom-data materialization、Computer Use loop detection / screenshot hash / verification / retry policy、WebFetch readable extraction / fallback / title / format facts、WebSearch Exa text result parsing,以及 File tool 的 provider-neutral 结果展示、写入 mode/status/line-count 规则、Edit guardrail 分类和 Delete success 文本;core 只保留 ToolResult 包装、权限、checkpoint、runtime handles、host adapter 调用、read-state adapter、remote FS 调用和 Web tool network provider 调用。 - `runtime-ports` / `terminal-core` / `services-integrations` 已承接 ExecCommand 会话执行端口和 concrete provider:`TerminalPort` 暴露本地命令执行、stdin 写入、会话控制和生命周期事件边界,`RemoteExecPort` 暴露远端 SSH 命令执行、bounded one-shot command、stdin、会话控制和生命周期事件边界;`TerminalRuntimePort` 复用原本地 `ExecProcessManager` 行为,`RemoteExecRuntimePort` 复用原 remote exec manager 与旧 SSH one-shot 行为,当前 desktop / CLI 产品入口和保留 server bootstrap 初始化路径通过 `CoreRuntimeServicesProvider` 构造 provider 并显式注入 `ConversationCoordinator` / 执行上下文 / `ToolRuntimeHandles`;core `ExecCommand` / `WriteStdin` / `ExecControl` 只消费端口,不再直接调用全局本地或远端进程 manager。 - `agent-runtime` 已承接 scheduler/background delivery 纯决策、dialog lifecycle port contracts、runtime event queue/router、session management/cancellation port contracts、session/config/summary facts、persisted session state sidecar / processing-state sanitization、session state facts / event-label projection、session state manager / event emission owner、dialog-turn id / stats facts、side-question runtime-only tracking、thread-goal facts、context profile / model capability policy、prompt markup / prompt / prompt-cache facts 与持久化写入决策、remote file delivery prompt facts、turn skill/agent snapshot DTO/diff/render/store、file-read session state / prior-read guardrail / freshness 决策、session evidence ledger 与 compression-contract projection、dialog-turn cancellation token store、tool confirmation gate / wait channel state、user-question wait channel state、custom agent / mode / subagent schema、默认值、discovery/loading、markdown IO、validation、review 工具过滤、skill catalog/root specs、mode policy、selection/shadow/mode-info 规则、assistant payload rendering、post-call hook routing、DeepReview provider-neutral policy/queue/retry/diagnostics shaping 与 queue event payload shaping,并建立不暴露 `bitfun-core` / `product-full` / concrete manager 的内部 SDK facade。DeepResearch citation renumber 与 report post-process gate 已迁入 `agent-workflows`;SDK facade 继续支持注入 fake runtime services、tool registry、harness registry、hook registry 和 agent registry。 - `harness` 已建立 descriptor、route plan 和 legacy provider registry。 diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index 6007b5ad51..dd7f7068b5 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -906,16 +906,6 @@ export const forbiddenContentRules = [ }, ], }, - { - path: 'src/crates/assembly/core/src/agentic/tools/implementations/bash_tool.rs', - patterns: [ - { - regex: /\bscheduler\s*\.\s*deliver_background_result\b/, - message: - 'Bash background delivery must flow through AgentRuntime lifecycle delivery port, not direct DialogScheduler', - }, - ], - }, { path: 'src/crates/assembly/core/src/agentic/coordination/coordinator.rs', patterns: [ @@ -3931,53 +3921,6 @@ export const forbiddenContentRules = [ }, ], }, - { - path: 'src/crates/assembly/core/src/agentic/tools/implementations/bash_tool.rs', - reason: - 'BashTool must stay as terminal/session/checkpoint glue and must not re-own reusable shell execution helpers', - patterns: [ - { - regex: /\bconst\s+MAX_OUTPUT_LENGTH\b/, - message: 'Bash output rendering budget is owned by tool-runtime::shell', - }, - { - regex: /\bconst\s+BANNED_COMMANDS\b/, - message: 'Bash banned-command policy is owned by tool-runtime::shell', - }, - { - regex: /\bfn\s+detect_osascript_keystroke_non_ascii\b/, - message: 'Bash osascript keystroke guard is owned by tool-runtime::shell', - }, - { - regex: /\bfn\s+detect_osascript_im_app\b/, - message: 'Bash IM AppleScript guard is owned by tool-runtime::shell', - }, - { - regex: /\bfn\s+truncate_output_preserving_tail\b/, - message: 'Bash output truncation is owned by tool-runtime::shell', - }, - { - regex: /\bfn\s+command_for_working_directory\b/, - message: 'Bash working-directory command wrapping is owned by tool-runtime::shell', - }, - { - regex: /\bfn\s+render_result\b/, - message: 'Bash local result rendering is owned by tool-runtime::shell', - }, - { - regex: /\bfn\s+render_remote_result\b/, - message: 'Bash remote result rendering is owned by tool-runtime::shell', - }, - { - regex: /\bfn\s+format_background_command_delivery_text\b/, - message: 'Bash background-result delivery text is owned by tool-runtime::shell', - }, - { - regex: /\bfn\s+format_background_command_error_text\b/, - message: 'Bash background-result error text is owned by tool-runtime::shell', - }, - ], - }, { path: 'src/apps/server/src/bootstrap.rs', reason: diff --git a/scripts/core-boundaries/rules/source/required-rules.mjs b/scripts/core-boundaries/rules/source/required-rules.mjs index 7b239f3b05..fba41a7c70 100644 --- a/scripts/core-boundaries/rules/source/required-rules.mjs +++ b/scripts/core-boundaries/rules/source/required-rules.mjs @@ -3393,39 +3393,11 @@ export const requiredContentRules = [ { path: 'src/crates/execution/tool-execution/src/shell/mod.rs', reason: - 'tool-runtime must own reusable Bash shell execution policy, rendering, and background-result text helpers', + 'tool-runtime must own the shared noninteractive terminal environment used by agent-managed sessions', patterns: [ { - regex: /\bpub fn banned_shell_command\b/, - message: 'missing Bash banned-command policy owner', - }, - { - regex: /\bpub fn detect_osascript_keystroke_non_ascii\b/, - message: 'missing Bash osascript keystroke guard owner', - }, - { - regex: /\bpub fn detect_osascript_im_app\b/, - message: 'missing Bash IM AppleScript guard owner', - }, - { - regex: /\bpub fn command_for_working_directory\b/, - message: 'missing Bash working-directory command wrapper owner', - }, - { - regex: /\bpub fn bash_noninteractive_env\b/, - message: 'missing Bash noninteractive environment owner', - }, - { - regex: /\bpub fn render_local_shell_result\b/, - message: 'missing local shell result rendering owner', - }, - { - regex: /\bpub fn render_remote_shell_result\b/, - message: 'missing remote shell result rendering owner', - }, - { - regex: /\bpub fn format_background_command_delivery_text\b/, - message: 'missing background command delivery text owner', + regex: /\bpub fn noninteractive_terminal_env\b/, + message: 'missing noninteractive terminal environment owner', }, ], }, @@ -3510,19 +3482,11 @@ export const requiredContentRules = [ { path: 'src/crates/execution/tool-execution/tests/tool_io_contracts.rs', reason: - 'tool-runtime shell owner must keep focused behavior-equivalence contracts for Bash execution helpers', + 'tool-runtime shell owner must keep the agent-managed terminal environment contract covered', patterns: [ { - regex: /\bbash_shell_owner_preserves_command_wrapping_and_env\b/, - message: 'missing Bash command/env owner regression', - }, - { - regex: /\bbash_shell_owner_preserves_guard_and_result_rendering\b/, - message: 'missing Bash guard/rendering owner regression', - }, - { - regex: /\bbash_shell_owner_preserves_background_delivery_texts\b/, - message: 'missing Bash background-result text owner regression', + regex: /\bnoninteractive_terminal_env_preserves_agent_session_contract\b/, + message: 'missing noninteractive terminal environment regression', }, ], }, diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 283d675637..3179a8e229 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -2298,12 +2298,6 @@ export function runManifestParserSelfTest({ throw new Error(`CronTool boundary rule must forbid direct coordinator ${contract}`); } } - const bashToolRuleText = forbiddenRuleTextForPath( - 'src/crates/assembly/core/src/agentic/tools/implementations/bash_tool.rs', - ); - if (!bashToolRuleText.includes('scheduler') || !bashToolRuleText.includes('deliver_background_result')) { - throw new Error('Bash boundary rule must forbid direct scheduler background delivery'); - } const coordinatorRuleText = forbiddenRuleTextForPath( 'src/crates/assembly/core/src/agentic/coordination/coordinator.rs', ); diff --git a/scripts/i18n-governance-baseline.json b/scripts/i18n-governance-baseline.json index bb98b1d5aa..42bc6f1705 100644 --- a/scripts/i18n-governance-baseline.json +++ b/scripts/i18n-governance-baseline.json @@ -6,13 +6,13 @@ "maxTotal": 0 }, "sharedTermDuplicates": { - "maxTotal": 171, + "maxTotal": 168, "bySurface": { "core": 15, "installer": 0, "mobile-web": 0, "relay-static-homepage": 0, - "web-ui": 156 + "web-ui": 153 }, "bySharedKey": { "agents.claw": 3, @@ -32,7 +32,7 @@ "product.name": 0, "statuses.cancelled": 27, "statuses.done": 24, - "statuses.failed": 42, + "statuses.failed": 39, "statuses.loading": 5, "statuses.running": 10, "tools.edit": 33, diff --git a/scripts/theme-css-var-contract.mjs b/scripts/theme-css-var-contract.mjs index 90019a46bd..068ffb2882 100644 --- a/scripts/theme-css-var-contract.mjs +++ b/scripts/theme-css-var-contract.mjs @@ -136,7 +136,6 @@ export const COLOR_DOMAIN_RULES = [ label: 'Terminal', pathParts: [ 'tools/terminal', - 'flow_chat/tool-cards/TerminalToolCard', 'app/components/panels/TerminalEditModal', ], }, @@ -232,7 +231,7 @@ export const COLOR_DOMAIN_CONTRACTS = [ }, { key: 'terminal', - owner: 'src/web-ui/src/tools/terminal; src/web-ui/src/flow_chat/tool-cards/TerminalToolCard', + owner: 'src/web-ui/src/tools/terminal; src/web-ui/src/app/components/panels/TerminalEditModal', reason: 'Terminal colors include ANSI and terminal surface roles that must stay compatible with shell output semantics.', mergePolicy: 'Keep ANSI roles independent even when values resemble app semantic colors.', }, diff --git a/scripts/theme-visual-governance-contract.json b/scripts/theme-visual-governance-contract.json index 436bdec56d..4f51d97b1a 100644 --- a/scripts/theme-visual-governance-contract.json +++ b/scripts/theme-visual-governance-contract.json @@ -100,7 +100,7 @@ }, { "key": "terminal", - "owner": "src/web-ui/src/tools/terminal; src/web-ui/src/flow_chat/tool-cards/TerminalToolCard", + "owner": "src/web-ui/src/tools/terminal; src/web-ui/src/app/components/panels/TerminalEditModal", "platforms": ["desktop-webview", "web"], "formFactors": ["desktop", "narrow"], "themes": ["dark", "light", "system"], diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index 0b0ce92f85..0153b5d170 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -4607,7 +4607,11 @@ mod tests { text_items: vec![], tool_items: vec![ tool_item("Read", json!({ "content": "abc" }), Some("assistant")), - tool_item("Bash", json!({ "output": "x".repeat(20) }), Some("short")), + tool_item( + "ExecCommand", + json!({ "output": "x".repeat(20) }), + Some("short"), + ), ], thinking_items: vec![], start_time: 1, @@ -4645,7 +4649,7 @@ mod tests { assert_eq!(stats.result_for_assistant_chars, 14); assert_eq!(stats.largest_raw_result_chars, 20); assert_eq!(stats.top_raw_results.len(), 2); - assert_eq!(stats.top_raw_results[0].tool_name, "Bash"); + assert_eq!(stats.top_raw_results[0].tool_name, "ExecCommand"); assert_eq!(stats.top_raw_results[0].raw_result_string_chars, 20); assert_eq!(stats.top_raw_results[0].result_for_assistant_chars, 5); assert_eq!(stats.top_raw_results[1].tool_name, "Read"); @@ -4691,7 +4695,7 @@ mod tests { timestamp: 1, text_items: vec![], tool_items: vec![tool_item( - "Bash", + "ExecCommand", json!({ "output": "visible output" }), Some("assistant-only payload"), )], @@ -4758,7 +4762,7 @@ mod tests { timestamp: 1, text_items: vec![], tool_items: vec![tool_item( - "Bash", + "ExecCommand", json!({ "output": large_output, "exit_code": 0 }), Some("assistant-only payload"), )], diff --git a/src/apps/desktop/src/api/tool_api.rs b/src/apps/desktop/src/api/tool_api.rs index 8c8b281972..abe75f158e 100644 --- a/src/apps/desktop/src/api/tool_api.rs +++ b/src/apps/desktop/src/api/tool_api.rs @@ -7,13 +7,13 @@ use std::path::PathBuf; use tauri::State; use bitfun_agent_runtime::sdk::AgentUserAnswersRequest; +use bitfun_core::agentic::tools::product_runtime::{build_tool_info, ToolInfoDto}; use bitfun_core::agentic::{ tools::framework::ToolUseContext, tools::{get_all_tools, get_readonly_tools}, workspace::{local_workspace_services, remote_workspace_services}, WorkspaceBinding, }; -use bitfun_core::agentic::tools::product_runtime::{build_tool_info, ToolInfoDto}; use bitfun_core::product_runtime::CoreRuntimeServicesProvider; use bitfun_core::service::remote_ssh::workspace_state::{ get_remote_workspace_manager, lookup_remote_connection, workspace_session_identity, @@ -170,7 +170,7 @@ fn write_file_path(input: &serde_json::Value) -> Option<&str> { fn tool_requires_workspace_path(tool_name: &str, input: &serde_json::Value) -> bool { match tool_name { - "Bash" => true, + "ExecCommand" => true, "Glob" | "Grep" => input.get("path").is_none() || is_relative_path(input.get("path")), "Write" => write_file_path(input).map_or_else( || input.get("payload").is_some(), diff --git a/src/crates/assembly/agent-content/prompts/agents/code_review.md b/src/crates/assembly/agent-content/prompts/agents/code_review.md index 2ff4a8ca6e..8d2e75695b 100644 --- a/src/crates/assembly/agent-content/prompts/agents/code_review.md +++ b/src/crates/assembly/agent-content/prompts/agents/code_review.md @@ -35,10 +35,9 @@ Use `cursor` only with the same prepared file and the value returned by the prev Never call GetFileDiff with an empty object. After `invalid_arguments`, correct the arguments once; do not repeat unchanged input. 2. Use `Read`, `Grep`, `Glob`, and `LS` to verify definitions, callers, contracts, and tests. -3. If a work packet authorizes `Git`, use it only for supplemental history or context. Never use it to replace, widen, or reinterpret a prepared target. -4. Trace user-visible behavior and cross-module effects before assigning severity. -5. Treat partial, unknown, or stale target evidence as a coverage limitation, never as a clean result. -6. Call `submit_code_review` once with findings ordered by severity. +3. Trace user-visible behavior and cross-module effects before assigning severity. +4. Treat partial, unknown, or stale target evidence as a coverage limitation, never as a clean result. +5. Call `submit_code_review` once with findings ordered by severity. Use precise new-file line numbers. State scope or evidence limitations. If no actionable issue is confirmed, say so and identify residual verification gaps. diff --git a/src/crates/assembly/agent-content/prompts/agents/general_purpose_agent.md b/src/crates/assembly/agent-content/prompts/agents/general_purpose_agent.md index bd0b7bafd4..0156f2bfef 100644 --- a/src/crates/assembly/agent-content/prompts/agents/general_purpose_agent.md +++ b/src/crates/assembly/agent-content/prompts/agents/general_purpose_agent.md @@ -4,12 +4,12 @@ You are a general-purpose agent for BitFun, a desktop AI IDE and agent runtime. ## When to use this agent -Use this agent **only** when the task requires file modifications, shell commands, or other write operations. For read-only exploration and research, prefer the `Explore` subagent instead. This agent has write capabilities (Write, Edit, Delete, Bash) and cannot run in parallel with other write-capable agents for safety reasons. +Use this agent **only** when the task requires file modifications, shell commands, or other write operations. For read-only exploration and research, prefer the `Explore` subagent instead. This agent has write capabilities (Write, Edit, Delete, ExecCommand) and cannot run in parallel with other write-capable agents for safety reasons. ## Strengths - Implementing features, fixing bugs, and refactoring code -- Running build, test, and validation commands via Bash +- Running build, test, and validation commands via ExecCommand - Searching for code, configurations, and patterns across large codebases - Analyzing multiple files to understand system architecture - Performing multi-step research tasks that may require edits diff --git a/src/crates/assembly/agent-content/prompts/agents/review_fixer_agent.md b/src/crates/assembly/agent-content/prompts/agents/review_fixer_agent.md index f09adaeda1..3fb9eacc08 100644 --- a/src/crates/assembly/agent-content/prompts/agents/review_fixer_agent.md +++ b/src/crates/assembly/agent-content/prompts/agents/review_fixer_agent.md @@ -25,7 +25,6 @@ You may investigate, edit files, and run local verification: - `Write` - `ExecCommand` - `TodoWrite` -- `Git` Do not commit, push, or perform destructive cleanup. Leave the workspace in a reviewable state. diff --git a/src/crates/assembly/agent-content/prompts/agents/team_mode.md b/src/crates/assembly/agent-content/prompts/agents/team_mode.md index 30b1ce5b4e..66716d5ac8 100644 --- a/src/crates/assembly/agent-content/prompts/agents/team_mode.md +++ b/src/crates/assembly/agent-content/prompts/agents/team_mode.md @@ -206,7 +206,7 @@ Team Mode is a **virtual team**, not a single specialist running serially. Paral **Concurrency safety:** - `Skill`, `Read`, `Grep`, `Glob`, `WebSearch`, `WebFetch`, and read-only `Task` calls are concurrency-safe and will run in parallel inside one batch. -- `Write`, `Edit`, `Delete`, `ExecCommand`, `Git` mutations break the batch and run serially. Do **not** mix them into a fan-out batch. +- `Write`, `Edit`, `Delete`, and `ExecCommand` mutations break the batch and run serially. Do **not** mix them into a fan-out batch. # Review Synthesis Template diff --git a/src/crates/assembly/agent-content/prompts/insights/suggestions.md b/src/crates/assembly/agent-content/prompts/insights/suggestions.md index 2bb14faf97..d26d58b7c3 100644 --- a/src/crates/assembly/agent-content/prompts/insights/suggestions.md +++ b/src/crates/assembly/agent-content/prompts/insights/suggestions.md @@ -61,12 +61,12 @@ Analyze this BitFun usage data and suggest improvements. - Tip: Use the Plan skill for complex tasks, then ask Agentic to implement the resulting plan. 5. **CLI Exec (Headless)**: Run BitFun non-interactively from scripts and CI/CD pipelines. - - How to use: `bitfun exec "fix lint errors" --tools "Edit,Read,Bash"` + - How to use: `bitfun exec "fix lint errors" --tools "Edit,Read,ExecCommand"` - Good for: CI/CD integration, batch code fixes, automated reviews - CI/CD examples: ```bash # Pre-commit hook: auto-fix lint errors - bitfun exec "fix all lint errors in staged files" --tools "Edit,Read,Bash" + bitfun exec "fix all lint errors in staged files" --tools "Edit,Read,ExecCommand" # PR review bot bitfun exec "review changes in this PR for security issues" --tools "Read,Grep,Glob" diff --git a/src/crates/assembly/agent-content/prompts/memories/phase2_system.md b/src/crates/assembly/agent-content/prompts/memories/phase2_system.md index 8835701226..09f25690b1 100644 --- a/src/crates/assembly/agent-content/prompts/memories/phase2_system.md +++ b/src/crates/assembly/agent-content/prompts/memories/phase2_system.md @@ -724,7 +724,7 @@ SKILL.md frontmatter (YAML between --- markers): - argument-hint: optional; e.g. "[branch]" or "[path] [mode]" - disable-model-invocation: true for workflows with side effects (push/deploy/delete/etc.) - user-invocable: false for background/reference-only skills -- allowed-tools: optional; list what the skill needs (e.g., Read, Grep, Glob, Bash) +- allowed-tools: optional; list what the skill needs (e.g., Read, Grep, Glob, ExecCommand) - context / agent / model: optional; use only when truly needed (e.g., context: fork) SKILL.md content expectations: diff --git a/src/crates/assembly/core/builtin_skills/agent-browser/SKILL.md b/src/crates/assembly/core/builtin_skills/agent-browser/SKILL.md index b029cbb399..bae5e0bc98 100644 --- a/src/crates/assembly/core/builtin_skills/agent-browser/SKILL.md +++ b/src/crates/assembly/core/builtin_skills/agent-browser/SKILL.md @@ -1,7 +1,7 @@ --- name: agent-browser description: Browser automation CLI for AI agents. Use when the user needs to interact with websites, including navigating pages, filling forms, clicking buttons, taking screenshots, extracting data, testing web apps, or automating any browser task. Triggers include requests to "open a website", "fill out a form", "click a button", "take a screenshot", "scrape data from a page", "test this web app", "login to a site", "automate browser actions", or any task requiring programmatic web interaction. Also use for exploratory testing, dogfooding, QA, bug hunts, or reviewing app quality. Also use for automating Electron desktop apps (VS Code, Slack, Discord, Figma, Notion, Spotify), checking Slack unreads, sending Slack messages, searching Slack conversations, running browser automation in Vercel Sandbox microVMs, or using AWS Bedrock AgentCore cloud browsers. Prefer BitFun's ControlHub browser domain for web automation when it is available; use agent-browser only when ControlHub is unavailable or for supported Electron automation, and use BitFun ComputerUse for native desktop UI that agent-browser cannot reach. -allowed-tools: Bash(agent-browser:*), Bash(npx agent-browser:*) +allowed-tools: ExecCommand(agent-browser:*), ExecCommand(npx agent-browser:*) hidden: true --- diff --git a/src/crates/assembly/core/builtin_skills/find-skills/SKILL.md b/src/crates/assembly/core/builtin_skills/find-skills/SKILL.md index 61da8aa0e8..d80c6d0ef7 100644 --- a/src/crates/assembly/core/builtin_skills/find-skills/SKILL.md +++ b/src/crates/assembly/core/builtin_skills/find-skills/SKILL.md @@ -2,7 +2,7 @@ name: find-skills description: Discover and install reusable agent skills when users ask for capabilities, workflows, or domain-specific help that may already exist as an installable skill. description_zh: 当用户询问能力、工作流或领域化需求时,帮助发现并安装可复用的技能,而不是从零实现。 -allowed-tools: Bash(npx -y skills:*), Bash(npx skills:*), Bash(skills:*) +allowed-tools: ExecCommand(npx -y skills:*), ExecCommand(npx skills:*), ExecCommand(skills:*) --- # Find and Install Skills diff --git a/src/crates/assembly/core/builtin_skills/gstack-autoplan/SKILL.md b/src/crates/assembly/core/builtin_skills/gstack-autoplan/SKILL.md index 37680f81fc..0c2fc769c0 100644 --- a/src/crates/assembly/core/builtin_skills/gstack-autoplan/SKILL.md +++ b/src/crates/assembly/core/builtin_skills/gstack-autoplan/SKILL.md @@ -227,10 +227,10 @@ Override: every AskUserQuestion → auto-decide using the 6 principles. - All 10 review sections: run fully, auto-decide each issue, log every decision. - Dual voices: always run BOTH independent subagent AND outside-voice sub-agent if available (P6). Run them sequentially in foreground. First the independent subagent (Task tool, - foreground — do NOT use run_in_background), then outside-voice sub-agent (Bash). Both must + foreground — do NOT use run_in_background), then outside-voice sub-agent (ExecCommand). Both must complete before building the consensus table. - **outside-voice sub-agent CEO voice** (via Bash): + **outside-voice sub-agent CEO voice** (via ExecCommand): ```bash _REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" >&2; exit 1; } Use the BitFun Task tool to dispatch this prompt to a suitable independent read-only outside-voice sub-agent. @@ -278,7 +278,7 @@ Step 0 (0A-0F) — run each sub-step and produce: - 0F: Mode selection confirmation Step 0.5 (Dual Voices): Run independent subagent (foreground Task tool) first, then -outside-voice sub-agent (Bash). Present outside-voice sub-agent output under CODEX SAYS (CEO — strategy challenge) +outside-voice sub-agent (ExecCommand). Present outside-voice sub-agent output under CODEX SAYS (CEO — strategy challenge) header. Present subagent output under INDEPENDENT SUBAGENT (CEO — strategic independence) header. Produce CEO consensus table: @@ -341,7 +341,7 @@ Override: every AskUserQuestion → auto-decide using the 6 principles. - Design system alignment: auto-fix if DESIGN.md exists and fix is obvious - Dual voices: always run BOTH independent subagent AND outside-voice sub-agent if available (P6). - **outside-voice sub-agent design voice** (via Bash): + **outside-voice sub-agent design voice** (via ExecCommand): ```bash _REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" >&2; exit 1; } Use the BitFun Task tool to dispatch this prompt to a suitable independent read-only outside-voice sub-agent. @@ -416,7 +416,7 @@ Override: every AskUserQuestion → auto-decide using the 6 principles. - Scope challenge: never reduce (P2) - Dual voices: always run BOTH independent subagent AND outside-voice sub-agent if available (P6). - **outside-voice sub-agent eng voice** (via Bash): + **outside-voice sub-agent eng voice** (via ExecCommand): ```bash _REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" >&2; exit 1; } Use the BitFun Task tool to dispatch this prompt to a suitable independent read-only outside-voice sub-agent. @@ -531,7 +531,7 @@ Log: "Phase 3.5 skipped — no developer-facing scope detected." - DX taste decisions (e.g., opinionated defaults vs flexibility): mark TASTE DECISION - Dual voices: always run BOTH independent subagent AND outside-voice sub-agent if available (P6). - **outside-voice sub-agent DX voice** (via Bash): + **outside-voice sub-agent DX voice** (via ExecCommand): ```bash _REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" >&2; exit 1; } Use the BitFun Task tool to dispatch this prompt to a suitable independent read-only outside-voice sub-agent. diff --git a/src/crates/assembly/core/builtin_skills/gstack-design-consultation/SKILL.md b/src/crates/assembly/core/builtin_skills/gstack-design-consultation/SKILL.md index 94eca024af..787b4c3606 100644 --- a/src/crates/assembly/core/builtin_skills/gstack-design-consultation/SKILL.md +++ b/src/crates/assembly/core/builtin_skills/gstack-design-consultation/SKILL.md @@ -165,7 +165,7 @@ which codex 2>/dev/null && echo "CODEX_AVAILABLE" || echo "CODEX_NOT_AVAILABLE" **If a suitable BitFun outside-voice or review sub-agent is available**, launch both voices simultaneously: -1. **outside-voice sub-agent design voice** (via Bash): +1. **outside-voice sub-agent design voice** (via ExecCommand): ```bash TMPERR_DESIGN=$(mktemp /tmp/codex-design-XXXXXXXX) _REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" >&2; exit 1; } diff --git a/src/crates/assembly/core/builtin_skills/gstack-design-review/SKILL.md b/src/crates/assembly/core/builtin_skills/gstack-design-review/SKILL.md index 1accef1802..0de7fdc904 100644 --- a/src/crates/assembly/core/builtin_skills/gstack-design-review/SKILL.md +++ b/src/crates/assembly/core/builtin_skills/gstack-design-review/SKILL.md @@ -693,7 +693,7 @@ which codex 2>/dev/null && echo "CODEX_AVAILABLE" || echo "CODEX_NOT_AVAILABLE" **If a suitable BitFun outside-voice or review sub-agent is available**, launch both voices simultaneously: -1. **outside-voice sub-agent design voice** (via Bash): +1. **outside-voice sub-agent design voice** (via ExecCommand): ```bash TMPERR_DESIGN=$(mktemp /tmp/codex-design-XXXXXXXX) _REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" >&2; exit 1; } diff --git a/src/crates/assembly/core/builtin_skills/gstack-office-hours/SKILL.md b/src/crates/assembly/core/builtin_skills/gstack-office-hours/SKILL.md index 5ccdf0b682..cf97686267 100644 --- a/src/crates/assembly/core/builtin_skills/gstack-office-hours/SKILL.md +++ b/src/crates/assembly/core/builtin_skills/gstack-office-hours/SKILL.md @@ -634,7 +634,7 @@ If a suitable BitFun outside-voice or review sub-agent is available, use AskUser If user chooses A, launch both voices simultaneously: -1. **outside-voice sub-agent** (via Bash, `model_reasoning_effort="medium"`): +1. **outside-voice sub-agent** (via ExecCommand, `model_reasoning_effort="medium"`): ```bash TMPERR_SKETCH=$(mktemp /tmp/codex-sketch-XXXXXXXX) _REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" >&2; exit 1; } diff --git a/src/crates/assembly/core/builtin_skills/gstack-plan-design-review/SKILL.md b/src/crates/assembly/core/builtin_skills/gstack-plan-design-review/SKILL.md index 9b8e0cf38d..ef7047bfb1 100644 --- a/src/crates/assembly/core/builtin_skills/gstack-plan-design-review/SKILL.md +++ b/src/crates/assembly/core/builtin_skills/gstack-plan-design-review/SKILL.md @@ -344,7 +344,7 @@ which codex 2>/dev/null && echo "CODEX_AVAILABLE" || echo "CODEX_NOT_AVAILABLE" **If a suitable BitFun outside-voice or review sub-agent is available**, launch both voices simultaneously: -1. **outside-voice sub-agent design voice** (via Bash): +1. **outside-voice sub-agent design voice** (via ExecCommand): ```bash TMPERR_DESIGN=$(mktemp /tmp/codex-design-XXXXXXXX) _REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" >&2; exit 1; } diff --git a/src/crates/assembly/core/builtin_skills/gstack-review/SKILL.md b/src/crates/assembly/core/builtin_skills/gstack-review/SKILL.md index 15d539dd63..fe801d0474 100644 --- a/src/crates/assembly/core/builtin_skills/gstack-review/SKILL.md +++ b/src/crates/assembly/core/builtin_skills/gstack-review/SKILL.md @@ -721,7 +721,7 @@ _REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" Use the BitFun Task tool to dispatch this prompt to a suitable independent read-only outside-voice sub-agent. ``` -Set the Bash tool's `timeout` parameter to `300000` (5 minutes). Do NOT use the `timeout` shell command — it doesn't exist on macOS. After the command completes, read stderr: +Set ExecCommand's `yield_time_ms` parameter to `300000` (5 minutes). Do NOT use the `timeout` shell command — it doesn't exist on macOS. After the command completes, read stderr: ```bash cat "$TMPERR_ADV" ``` @@ -750,7 +750,7 @@ cd "$_REPO_ROOT" Use the BitFun Task tool to dispatch a suitable independent read-only structured review sub-agent over the diff. ``` -Set the Bash tool's `timeout` parameter to `300000` (5 minutes). Do NOT use the `timeout` shell command — it doesn't exist on macOS. Present output under `CODEX SAYS (code review):` header. +Set ExecCommand's `yield_time_ms` parameter to `300000` (5 minutes). Do NOT use the `timeout` shell command — it doesn't exist on macOS. Present output under `CODEX SAYS (code review):` header. Check for `[P1]` markers: found → `GATE: FAIL`, not found → `GATE: PASS`. If GATE is FAIL, use AskUserQuestion: diff --git a/src/crates/assembly/core/builtin_skills/gstack-ship/SKILL.md b/src/crates/assembly/core/builtin_skills/gstack-ship/SKILL.md index 7a4e500a59..146951c388 100644 --- a/src/crates/assembly/core/builtin_skills/gstack-ship/SKILL.md +++ b/src/crates/assembly/core/builtin_skills/gstack-ship/SKILL.md @@ -1455,7 +1455,7 @@ _REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" Use the BitFun Task tool to dispatch this prompt to a suitable independent read-only outside-voice sub-agent. ``` -Set the Bash tool's `timeout` parameter to `300000` (5 minutes). Do NOT use the `timeout` shell command — it doesn't exist on macOS. After the command completes, read stderr: +Set ExecCommand's `yield_time_ms` parameter to `300000` (5 minutes). Do NOT use the `timeout` shell command — it doesn't exist on macOS. After the command completes, read stderr: ```bash cat "$TMPERR_ADV" ``` @@ -1484,7 +1484,7 @@ cd "$_REPO_ROOT" Use the BitFun Task tool to dispatch a suitable independent read-only structured review sub-agent over the diff. ``` -Set the Bash tool's `timeout` parameter to `300000` (5 minutes). Do NOT use the `timeout` shell command — it doesn't exist on macOS. Present output under `CODEX SAYS (code review):` header. +Set ExecCommand's `yield_time_ms` parameter to `300000` (5 minutes). Do NOT use the `timeout` shell command — it doesn't exist on macOS. Present output under `CODEX SAYS (code review):` header. Check for `[P1]` markers: found → `GATE: FAIL`, not found → `GATE: PASS`. If GATE is FAIL, use AskUserQuestion: diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs index d65cf8a18d..223279750b 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs @@ -40,7 +40,6 @@ impl ClawMode { "create_goal".to_string(), "update_goal".to_string(), "Skill".to_string(), - "Git".to_string(), "SessionControl".to_string(), "SessionMessage".to_string(), "SessionHistory".to_string(), diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs index 9bd635d115..3f0f1345c6 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs @@ -49,7 +49,6 @@ impl CoworkMode { "Delete".to_string(), // Utilities "GetFileDiff".to_string(), - "Git".to_string(), "ExecCommand".to_string(), "WriteStdin".to_string(), "ExecControl".to_string(), diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/review/review_fixer.rs b/src/crates/assembly/core/src/agentic/agents/definitions/review/review_fixer.rs index 8e90ecc666..9e68a7e770 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/review/review_fixer.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/review/review_fixer.rs @@ -17,7 +17,6 @@ impl ReviewFixerAgent { pub fn new() -> Self { let mut tool_exposure_overrides = AgentToolPolicyOverrides::default(); tool_exposure_overrides.insert("GetFileDiff".to_string(), ToolExposure::Direct); - tool_exposure_overrides.insert("Git".to_string(), ToolExposure::Direct); Self { default_tools: vec![ "Read".to_string(), @@ -31,7 +30,6 @@ impl ReviewFixerAgent { "WriteStdin".to_string(), "ExecControl".to_string(), "TodoWrite".to_string(), - "Git".to_string(), ], tool_exposure_overrides, } diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/review/review_specialists.rs b/src/crates/assembly/core/src/agentic/agents/definitions/review/review_specialists.rs index e6f2d099b9..7d0c1f30eb 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/review/review_specialists.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/review/review_specialists.rs @@ -50,7 +50,7 @@ mod tests { ); assert!(agent.is_readonly()); assert!(agent.default_tools().contains(&"GetFileDiff".to_string())); - assert!(!agent.default_tools().contains(&"Git".to_string())); + assert!(!agent.default_tools().contains(&"ExecCommand".to_string())); } } } diff --git a/src/crates/assembly/core/src/agentic/agents/mod.rs b/src/crates/assembly/core/src/agentic/agents/mod.rs index 8b40de112c..8d67291843 100644 --- a/src/crates/assembly/core/src/agentic/agents/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/mod.rs @@ -148,7 +148,6 @@ pub fn shared_coding_mode_tools() -> Vec { "GenerativeUI".to_string(), "Skill".to_string(), "AskUserQuestion".to_string(), - "Git".to_string(), "ReviewPlatform".to_string(), "ControlHub".to_string(), // Pairs with ControlHub: its `wait` sends anything repeating, or diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 7df78f497d..3a8e5961ac 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -15858,7 +15858,7 @@ mod tests { #[test] fn transient_session_runtime_restrictions_deny_out_of_band_session_tools() { let mut base = crate::agentic::tools::ToolRuntimeRestrictions::default(); - base.denied_tool_names.insert("Bash".to_string()); + base.denied_tool_names.insert("ExecCommand".to_string()); let transient = runtime_tool_restrictions_for_session_lifetime(base.clone(), true); for tool_name in [ @@ -15873,7 +15873,7 @@ mod tests { "{tool_name} must not cross a connection-scoped Session boundary" ); } - assert!(!transient.is_tool_allowed("Bash")); + assert!(!transient.is_tool_allowed("ExecCommand")); assert!(transient.is_tool_allowed("Read")); let durable = runtime_tool_restrictions_for_session_lifetime(base, false); @@ -15886,7 +15886,7 @@ mod tests { ] { assert!(durable.is_tool_allowed(tool_name)); } - assert!(!durable.is_tool_allowed("Bash")); + assert!(!durable.is_tool_allowed("ExecCommand")); } #[test] diff --git a/src/crates/assembly/core/src/agentic/execution/edit_constraint_guard.rs b/src/crates/assembly/core/src/agentic/execution/edit_constraint_guard.rs index d3ba4aca25..a8286e5b78 100644 --- a/src/crates/assembly/core/src/agentic/execution/edit_constraint_guard.rs +++ b/src/crates/assembly/core/src/agentic/execution/edit_constraint_guard.rs @@ -1171,7 +1171,7 @@ pub fn check_bash_command(context: &ToolUseContext, command: &str) -> Option Option", "deny_unresolved_target", diff --git a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs index 502ec2f172..4b036fcf06 100644 --- a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs @@ -451,7 +451,7 @@ impl ContextHealthSnapshot { return None; }; - if !matches!(tool_name.as_str(), "Bash" | "Git") { + if tool_name != "ExecCommand" { return None; } @@ -6286,7 +6286,7 @@ mod tests { parameters: json!({}), }, ToolDefinition { - name: "Bash".to_string(), + name: "ExecCommand".to_string(), description: String::new(), parameters: json!({}), }, @@ -6294,7 +6294,7 @@ mod tests { assert_eq!( ExecutionEngine::finalize_tool_names(Some(&tools)), - vec!["Read".to_string(), "Bash".to_string()] + vec!["Read".to_string(), "ExecCommand".to_string()] ); } @@ -6322,11 +6322,11 @@ mod tests { let restrictions = ExecutionEngine::finalize_runtime_tool_restrictions( &context, - &["Read".to_string(), "Bash".to_string()], + &["Read".to_string(), "ExecCommand".to_string()], ); assert!(restrictions.denied_tool_names.contains("Read")); - assert!(restrictions.denied_tool_names.contains("Bash")); + assert!(restrictions.denied_tool_names.contains("ExecCommand")); assert_eq!( restrictions.denied_tool_messages.get("Read"), Some(&ExecutionEngine::FINALIZE_TOOL_DENIED_MESSAGE.to_string()) @@ -6551,9 +6551,9 @@ mod tests { #[test] fn context_health_snapshot_scores_repeated_tool_signatures() { let signatures = vec![ - r#"Bash:{"command":"cargo test"}"#.to_string(), - r#"Bash:{"command":"cargo test"}"#.to_string(), - r#"Bash:{"command":"cargo test"}"#.to_string(), + r#"ExecCommand:{"cmd":"cargo test"}"#.to_string(), + r#"ExecCommand:{"cmd":"cargo test"}"#.to_string(), + r#"ExecCommand:{"cmd":"cargo test"}"#.to_string(), ]; let snapshot = @@ -6569,9 +6569,9 @@ mod tests { #[test] fn context_health_snapshot_counts_consecutive_failed_commands() { let messages = vec![ - command_result("Bash", true, Some(0)), - command_result("Bash", false, Some(1)), - command_result("Git", false, Some(128)), + command_result("ExecCommand", true, Some(0)), + command_result("ExecCommand", false, Some(1)), + command_result("ExecCommand", false, Some(128)), ]; let snapshot = ContextHealthSnapshot::from_runtime_observations(0.44, 0, 2, &[], &messages); diff --git a/src/crates/assembly/core/src/agentic/execution/round_executor.rs b/src/crates/assembly/core/src/agentic/execution/round_executor.rs index 7b5389b668..2fab0bdad9 100644 --- a/src/crates/assembly/core/src/agentic/execution/round_executor.rs +++ b/src/crates/assembly/core/src/agentic/execution/round_executor.rs @@ -2011,7 +2011,7 @@ mod tests { hidden_text_blocks: Vec::new(), tool_calls: vec![ToolCall { tool_id: "tool-1".to_string(), - tool_name: "Bash".to_string(), + tool_name: "ExecCommand".to_string(), arguments: json!({}), raw_arguments: Some("{\"command\":".to_string()), is_error: true, @@ -2070,7 +2070,7 @@ mod tests { trace.tool_calls, Some(json!([{ "tool_id": "tool-1", - "tool_name": "Bash", + "tool_name": "ExecCommand", "arguments": {}, "raw_arguments": "{\"command\":", "is_error": true, @@ -2088,7 +2088,7 @@ mod tests { None, &[ToolCall { tool_id: "tool-1".to_string(), - tool_name: "Bash".to_string(), + tool_name: "ExecCommand".to_string(), arguments: json!({}), raw_arguments: Some("{\"command\":".to_string()), is_error: true, diff --git a/src/crates/assembly/core/src/agentic/memories/transcript.rs b/src/crates/assembly/core/src/agentic/memories/transcript.rs index 4af57100f0..a30a3ae879 100644 --- a/src/crates/assembly/core/src/agentic/memories/transcript.rs +++ b/src/crates/assembly/core/src/agentic/memories/transcript.rs @@ -487,11 +487,11 @@ mod tests { tool_name: GET_TOOL_SPEC_TOOL_NAME.to_string(), tool_call: ToolCallData { id: "call_1".to_string(), - input: json!({ "tool_name": "Git" }), + input: json!({ "tool_name": "Worktree" }), }, tool_result: Some(ToolResultData { result: json!({ - "name": "Git", + "name": "Worktree", "description": "full schema definition", "input_schema": { "type": "object", diff --git a/src/crates/assembly/core/src/agentic/persistence/manager.rs b/src/crates/assembly/core/src/agentic/persistence/manager.rs index 272f7d5fce..beb49b737e 100644 --- a/src/crates/assembly/core/src/agentic/persistence/manager.rs +++ b/src/crates/assembly/core/src/agentic/persistence/manager.rs @@ -6517,7 +6517,7 @@ mod tests { Message::assistant("hello".to_string()), Message::tool_result(ToolResult { tool_id: "tool-1".to_string(), - tool_name: "Bash".to_string(), + tool_name: "ExecCommand".to_string(), effective_tool_name: None, result: serde_json::json!({ "output": "x".repeat(40) }), result_for_assistant: Some("assistant summary".to_string()), diff --git a/src/crates/assembly/core/src/agentic/session/session_manager.rs b/src/crates/assembly/core/src/agentic/session/session_manager.rs index c76271448c..d21d1caeb2 100644 --- a/src/crates/assembly/core/src/agentic/session/session_manager.rs +++ b/src/crates/assembly/core/src/agentic/session/session_manager.rs @@ -14947,10 +14947,10 @@ mod tests { text_items: vec![], tool_items: vec![ToolItemData { id: "tool-1".to_string(), - tool_name: "Bash".to_string(), + tool_name: "ExecCommand".to_string(), tool_call: ToolCallData { id: "call-1".to_string(), - input: json!({ "command": "printf output" }), + input: json!({ "cmd": "printf output" }), }, tool_result: Some(ToolResultData { result: json!({ diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/bash_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/bash_tool.rs deleted file mode 100644 index ca300fb923..0000000000 --- a/src/crates/assembly/core/src/agentic/tools/implementations/bash_tool.rs +++ /dev/null @@ -1,1692 +0,0 @@ -use crate::agentic::tools::framework::{ - PermissionIntent, Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, -}; -use crate::agentic::workspace::WorkspaceCommandOptions; -use crate::infrastructure::events::event_system::get_global_event_system; -use crate::infrastructure::events::event_system::BackendEvent::{ - ToolExecutionProgress, ToolTerminalReady, -}; -use crate::service::config::global::get_global_config_service; -use crate::service_agent_runtime::CoreServiceAgentRuntime; -use crate::util::elapsed_ms_u64; -use crate::util::errors::{BitFunError, BitFunResult}; -use crate::util::types::event::{ToolExecutionProgressInfo, ToolTerminalReadyInfo}; -use async_trait::async_trait; -use bitfun_runtime_ports::AgentBackgroundResultRequest; -use futures::StreamExt; -use log::{debug, error, info}; -use serde_json::{json, Value}; -use std::path::Path; -use std::time::{Duration, Instant}; -use terminal_core::session::SessionSource; -use terminal_core::shell::{ShellDetector, ShellType}; -use terminal_core::{ - CommandCompletionReason, CommandStreamEvent, ExecuteCommandRequest, SignalRequest, TerminalApi, - TerminalBindingOptions, TerminalSessionBinding, -}; -use tokio::io::AsyncWriteExt; -use tool_runtime::shell::{ - banned_shell_command, bash_noninteractive_env, command_for_working_directory, - detect_osascript_im_app, detect_osascript_keystroke_non_ascii, - format_background_command_delivery_text, format_background_command_display_text, - format_background_command_error_display_text, format_background_command_error_text, - render_local_shell_result, render_remote_shell_result, BackgroundCommandDeliveryTextRequest, - BackgroundCommandErrorTextRequest, BackgroundCommandStatusFacts, LocalShellResultRenderRequest, - RemoteShellResultRenderRequest, BASH_INTERRUPT_OUTPUT_DRAIN_MS, BASH_RESULT_MAX_OUTPUT_LENGTH, -}; - -/// Result of shell resolution for bash tool -struct ResolvedShell { - /// Shell type to use (None means use system default) - shell_type: Option, - /// Display name for the shell (for tool description) - display_name: String, -} - -fn json_object_metadata(value: Value) -> serde_json::Map { - match value { - Value::Object(map) => map, - _ => serde_json::Map::new(), - } -} - -struct BackgroundBashResultDelivery { - parent_session_id: String, - parent_agent_type: String, - parent_workspace_path: Option, - parent_remote_connection_id: Option, - parent_remote_ssh_host: Option, - delivery_text: String, - display_text: String, - metadata: serde_json::Map, - terminal_session_id: String, - failure_context: &'static str, -} - -async fn deliver_background_bash_result(delivery: BackgroundBashResultDelivery) { - let BackgroundBashResultDelivery { - parent_session_id, - parent_agent_type, - parent_workspace_path, - parent_remote_connection_id, - parent_remote_ssh_host, - delivery_text, - display_text, - metadata, - terminal_session_id, - failure_context, - } = delivery; - let runtime = match CoreServiceAgentRuntime::global_agent_runtime_with_lifecycle_delivery() { - Ok(runtime) => runtime, - Err(error) => { - error!( - "Agent runtime lifecycle delivery is not available; background Bash {} dropped: session_id={}, terminal_session_id={}, error={}", - failure_context, parent_session_id, terminal_session_id, error - ); - return; - } - }; - - if let Err(error) = runtime - .deliver_background_result(AgentBackgroundResultRequest { - session_id: parent_session_id.clone(), - agent_type: parent_agent_type, - workspace_path: parent_workspace_path, - remote_connection_id: parent_remote_connection_id, - remote_ssh_host: parent_remote_ssh_host, - content: delivery_text, - display_content: Some(display_text), - metadata, - }) - .await - { - error!( - "Failed to deliver background Bash {}: session_id={}, terminal_session_id={}, error={}", - failure_context, - parent_session_id, - terminal_session_id, - CoreServiceAgentRuntime::runtime_error_message(error) - ); - } -} - -/// Bash tool -pub struct BashTool; - -impl Default for BashTool { - fn default() -> Self { - Self::new() - } -} - -impl BashTool { - pub fn new() -> Self { - Self - } - - fn resolve_working_directory( - input: &Value, - context: &ToolUseContext, - ) -> BitFunResult> { - let Some(raw_dir) = input.get("working_directory").and_then(|v| v.as_str()) else { - return Ok(None); - }; - let trimmed = raw_dir.trim(); - if trimmed.is_empty() { - return Ok(context.workspace.as_ref().map(|w| w.root_path_string())); - } - context.resolve_workspace_tool_path(trimmed).map(Some) - } - - /// Build environment variables that suppress interactive behaviors - /// (pagers, editors, prompts) so agent-driven commands never block. - pub fn noninteractive_env() -> std::collections::HashMap { - bash_noninteractive_env() - } - - /// Resolve shell configuration for bash tool. - /// If configured shell doesn't support integration, falls back to system default. - async fn resolve_shell() -> ResolvedShell { - // Try configured shell first, fall back to system default - Self::try_configured_shell() - .await - .unwrap_or_else(Self::system_default_shell) - } - - /// Try to get a valid configured shell that supports integration. - async fn try_configured_shell() -> Option { - let config_service = get_global_config_service().await.ok()?; - let shell_str: String = config_service - .get_config::(Some("terminal.default_shell")) - .await - .ok() - .filter(|s| !s.is_empty())?; - - let parsed = ShellType::from_executable(&shell_str); - if parsed.supports_integration() { - Some(ResolvedShell { - shell_type: Some(parsed.clone()), - display_name: parsed.name().to_string(), - }) - } else { - debug!( - "Configured shell '{}' does not support integration, using system default", - shell_str - ); - None - } - } - - /// Get system default shell configuration. - fn system_default_shell() -> ResolvedShell { - let detected = ShellDetector::get_default_shell(); - ResolvedShell { - shell_type: None, - display_name: detected.display_name, - } - } - - fn emit_terminal_ready_event(tool_use_id: &str, terminal_session_id: &str) { - let event = ToolTerminalReady(ToolTerminalReadyInfo { - tool_use_id: tool_use_id.to_string(), - terminal_session_id: terminal_session_id.to_string(), - timestamp: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(), - }); - - let event_system = get_global_event_system(); - tokio::spawn(async move { - let _ = event_system.emit(event).await; - }); - } - - fn cancellation_requested(context: &ToolUseContext) -> bool { - context - .cancellation_token() - .is_some_and(|token| token.is_cancelled()) - } - - fn cancellation_error(stage: &str) -> BitFunError { - BitFunError::cancelled(format!("Bash tool execution cancelled {}", stage)) - } - - fn background_output_file_reference( - context: &ToolUseContext, - chat_session_id: &str, - tool_use_id: &str, - output_file_path: &Path, - ) -> String { - context - .build_session_runtime_artifact_reference( - chat_session_id, - &format!("tool-results/{}.txt", tool_use_id), - ) - .unwrap_or_else(|_| output_file_path.display().to_string()) - } -} - -#[async_trait] -impl Tool for BashTool { - fn name(&self) -> &str { - "Bash" - } - - async fn description(&self) -> BitFunResult { - let shell_info = Self::resolve_shell().await.display_name; - - Ok(format!( - r#"Executes a given command in a persistent shell session with optional timeout, ensuring proper handling and security measures. - -Shell Environment: {shell_info} - -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., cd "path with spaces/file.txt") - - Examples of proper quoting: - - cd "My Documents" (correct) - - cd My Documents (incorrect - will fail) - - python "scripts/with spaces/script.py" (correct) - - python scripts/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 and MUST be a single-line command. - - DO NOT use multiline commands or HEREDOC syntax (e.g., <` tag identifying the terminal session. The persistent shell session ID remains constant throughout the entire conversation; background sessions each have their own unique ID. - - The output may include the command echo and/or the shell prompt prefix (for example, a printed `PS` or `$` prompt line). Do not treat these as part of the command's actual result. - - Avoid interactive commands that may block waiting for user input or open a pager/editor. Prefer non-interactive variants and explicit flags. For example, use `git --no-pager diff` instead of `git diff`, and avoid commands that prompt for confirmation unless the User explicitly asks for them. - - - Prefer specialized tools for workspace file operations: Glob for file discovery, Grep for content search, Read for reading, Edit for modifying, Write for creating, and Delete for deletion. Prefer the Git tool for Git subcommands such as status, diff, log, add, commit, branch, checkout, pull, and push. When Git appears in the Deferred Tool Listing, load its schema with GetToolSpec and execute it through CallDeferredTool; otherwise call Git directly. Use Bash for commands that genuinely need a shell, such as build/test/package CLIs, process control, scripts, and environment checks. Never use shell output only to communicate with the user. - - When issuing multiple commands: - - If the commands are independent and can run in parallel, make multiple tool calls in a single message. For Git inspection, prefer parallel Git tool calls such as `{{"operation":"status"}}` and `{{"operation":"diff","args":"--stat"}}` instead of Bash. - - 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. - - 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) - - Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of `cd`. You may use `cd` if the User explicitly requests it. - - pytest /foo/bar/tests - - - cd /foo/bar && pytest tests - "# - )) - } - - fn short_description(&self) -> String { - "Run commands in the persistent shell session.".to_string() - } - - async fn description_with_context( - &self, - context: Option<&ToolUseContext>, - ) -> BitFunResult { - let mut base = self.description().await?; - if context.map(|c| c.is_remote()).unwrap_or(false) { - base = format!( - r#"**Remote workspace:** Commands run on the **SSH server** in a shell whose initial working directory is the **remote workspace root** (same as running a terminal on that machine). The shell name shown below may reflect your **local** BitFun settings; the actual interpreter on the server is typically `sh`/`bash`. Use **Unix** syntax and POSIX paths — not PowerShell or Windows paths. - -{base}"#, - base = base - ); - } - if !context.map(|c| c.is_remote()).unwrap_or(false) { - base.push_str( - "\n\n**Desktop automation:** Prefer this tool for actions achievable from the **workspace shell** (build, test, git, scripts, CLIs). On **macOS**, `open -a \"AppName\"` can launch or foreground an app. Use the dedicated `ComputerUse` tool or agent for desktop UI perception/control such as screenshots, OCR, mouse, keyboard, app state, clipboard, and OS-level interactions.", - ); - } - Ok(base) - } - - fn input_schema(&self) -> Value { - json!({ - "type": "object", - "properties": { - "command": { - "type": "string", - "description": "The command to execute" - }, - "timeout_ms": { - "type": "number", - "description": "Optional timeout in milliseconds (default 120000, max 600000). Ignored when run_in_background is true." - }, - "run_in_background": { - "type": "boolean", - "description": "If true, runs the command in a new dedicated background terminal session and returns immediately. The final completion result is delivered back automatically when the command finishes, and the full output is saved to a session runtime file instead of being injected into chat. Useful for long-running processes like dev servers or file watchers. timeout_ms is ignored when this is true." - }, - "working_directory": { - "type": "string", - "description": "Optional directory to run the command in. Use a workspace-relative path or an absolute path inside the current workspace. Omit to reuse the persistent terminal's current directory." - }, - "description": { - "type": "string", - "description": "Clear, concise description of what this command does in 5-10 words, in active voice. Examples:\nInput: ls\nOutput: List files in current directory\n\nInput: git status\nOutput: Show working tree status\n\nInput: npm install\nOutput: Install package dependencies\n\nInput: mkdir foo\nOutput: Create directory 'foo'" - } - }, - "required": ["command"], - "additionalProperties": false - }) - } - - fn is_readonly(&self) -> bool { - false - } - - fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool { - false - } - - fn permission_intents( - &self, - input: &Value, - _context: &ToolUseContext, - ) -> BitFunResult> { - let command = input - .get("command") - .and_then(Value::as_str) - .map(str::trim) - .filter(|command| !command.is_empty()) - .ok_or_else(|| BitFunError::validation("command is required".to_string()))?; - Ok(vec![PermissionIntent::new( - "bash", - vec![command.to_string()], - )]) - } - - async fn validate_input( - &self, - input: &Value, - context: Option<&ToolUseContext>, - ) -> ValidationResult { - let command = input.get("command").and_then(|v| v.as_str()); - let run_in_background = input - .get("run_in_background") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - - if let Some(cmd) = command { - if let Some(base_cmd) = banned_shell_command(cmd) { - return ValidationResult { - result: false, - message: Some(format!( - "Command '{}' is not allowed for security reasons", - base_cmd - )), - error_code: Some(403), - meta: None, - }; - } - - // Reject `osascript ... keystroke ""` — fundamentally - // broken: AppleScript's `keystroke` sends raw key codes, not - // Unicode, so CJK / emoji becomes garbage like "AAA…" in the - // target app. This is exactly the WeChat-search-box failure - // mode users keep hitting. Redirect to the canonical path. - if let Some(literal) = detect_osascript_keystroke_non_ascii(cmd) { - let preview: String = literal.chars().take(40).collect(); - return ValidationResult { - result: false, - message: Some(format!( - "Refused: `osascript ... keystroke \"{}…\"` cannot type non-ASCII text — \ - AppleScript's `keystroke` sends raw key codes, not Unicode, so CJK / \ - emoji / accented text comes out as garbage in the target app (e.g. \ - the WeChat search box receives `AAA…` instead of `{}`). \n\n\ - Use ControlHub instead:\n\ - 1. `system.open_app {{ app_name: \"\" }}` to focus the app\n\ - 2. (optional) `desktop.key_chord {{ keys: [\"command\",\"f\"] }}` to focus search\n\ - 3. `desktop.paste {{ text: \"\", submit: true }}` — pastes via \ - system clipboard, works for ANY language.\n\n\ - For sending an IM message specifically, run the `im_send_message` \ - playbook — it's the same 3-step flow pre-packaged.", - preview, preview - )), - error_code: Some(400), - meta: None, - }; - } - - // Soft-block `osascript` driving chat / IM apps. These flows are - // a constant source of frustration: no return value to verify, - // brittle UI scripting, no CJK support via keystroke, and the - // alternative (`system.open_app` + `desktop.paste` / - // `im_send_message` playbook) is faster AND more reliable. - if let Some(app) = detect_osascript_im_app(cmd) { - return ValidationResult { - result: false, - message: Some(format!( - "Refused: driving {app} via `osascript` / AppleScript GUI scripting is unreliable \ - (no CJK support in keystroke, no return value, easy to deadlock). \n\n\ - Use the canonical IM-send recipe instead — same 3 deterministic calls:\n\ - 1. `ControlHub domain:\"system\" action:\"open_app\" {{ app_name:\"{app}\" }}`\n\ - 2. `ControlHub domain:\"desktop\" action:\"key_chord\" {{ keys:[\"command\",\"f\"] }}`\n\ - 3. `ControlHub domain:\"desktop\" action:\"paste\" {{ text:\"\", submit:true }}`\n\ - 4. `ControlHub domain:\"desktop\" action:\"paste\" {{ text:\"\", submit:true }}`\n\n\ - Or run the prepackaged `im_send_message` playbook with \ - `{{ app_name, contact, message }}`. For Slack/Lark where Return inserts \ - a newline, pass `submit_keys:[\"command\",\"return\"]`." - )), - error_code: Some(400), - meta: None, - }; - } - } else { - return ValidationResult { - result: false, - message: Some("command is required".to_string()), - error_code: Some(400), - meta: None, - }; - } - - let Some(context) = context else { - return ValidationResult { - result: false, - message: Some("tool context is required for Bash tool".to_string()), - error_code: Some(400), - meta: None, - }; - }; - - if context.session_id.as_deref().unwrap_or_default().is_empty() { - return ValidationResult { - result: false, - message: Some("session_id is required for Bash tool".to_string()), - error_code: Some(400), - meta: None, - }; - } - - if context.workspace_root().is_none() { - return ValidationResult { - result: false, - message: Some("workspace_path is required for Bash tool".to_string()), - error_code: Some(400), - meta: None, - }; - } - - if let Some(rejection) = - crate::agentic::execution::edit_constraint_guard::check_bash_command( - context, - command.unwrap_or_default(), - ) - { - return rejection; - } - - // Resolve and enforce remote workspace containment here, but leave existence checks to - // the shell's `cd`. A separate filesystem stat is racy locally and unnecessarily couples - // remote command execution to the SFTP channel. - match Self::resolve_working_directory(input, context) { - Ok(_) => {} - Err(err) => { - return ValidationResult { - result: false, - message: Some(err.to_string()), - error_code: Some(400), - meta: None, - }; - } - } - - // Warn if timeout_ms is set alongside run_in_background - if run_in_background && input.get("timeout_ms").is_some() { - return ValidationResult { - result: true, - message: Some( - "Note: timeout_ms is ignored when run_in_background is true".to_string(), - ), - error_code: None, - meta: None, - }; - } - - ValidationResult { - result: true, - message: None, - error_code: None, - meta: None, - } - } - - async fn validate_input_rewrite_invariants( - &self, - input: &Value, - context: Option<&ToolUseContext>, - ) -> ValidationResult { - let Some((context, command)) = context.zip(input.get("command").and_then(Value::as_str)) - else { - return ValidationResult::default(); - }; - crate::agentic::execution::edit_constraint_guard::check_bash_command(context, command) - .unwrap_or_default() - } - - fn render_tool_use_message(&self, input: &Value, _options: &ToolRenderOptions) -> String { - if let Some(command) = input.get("command").and_then(|v| v.as_str()) { - // Clean up any command that uses the quoted HEREDOC pattern - if command.contains("\"$(cat <<'EOF'") { - // Simple regex-like parsing for HEREDOC - if let Some(start) = command.find("\"$(cat <<'EOF'\n") { - if let Some(end) = command.find("\nEOF\n)") { - let prefix = &command[..start]; - let content_start = start + "\"$(cat <<'EOF'\n".len(); - let content = &command[content_start..end]; - return format!("{} \"{}\"", prefix.trim(), content.trim()); - } - } - } - command.to_string() - } else { - "Executing command".to_string() - } - } - - async fn call_impl( - &self, - _input: &Value, - _context: &ToolUseContext, - ) -> BitFunResult> { - Err(BitFunError::tool( - "Bash tool call_impl should not be called".to_string(), - )) - } - - async fn call(&self, input: &Value, context: &ToolUseContext) -> BitFunResult> { - let start_time = Instant::now(); - - // Get command parameter - let command_str = input - .get("command") - .and_then(|v| v.as_str()) - .ok_or_else(|| BitFunError::tool("command is required".to_string()))?; - let requested_working_directory = Self::resolve_working_directory(input, context)?; - - if command_needs_light_checkpoint(command_str) { - context - .record_light_checkpoint("Bash", command_str, Vec::new()) - .await?; - } - - // Remote workspace: execute via injected workspace shell - if context.is_remote() { - let Some(ws_shell) = context.ws_shell() else { - return Err(BitFunError::tool( - "Remote workspace shell is unavailable; refusing to run Bash locally for a remote session.".to_string(), - )); - }; - - info!( - "Executing command on remote workspace via SSH: {}", - command_str - ); - let remote_command = - command_for_working_directory(command_str, requested_working_directory.as_deref()); - - let timeout_ms = input - .get("timeout_ms") - .and_then(|v| v.as_u64()) - .unwrap_or(120_000); - - let exec_result = ws_shell - .exec_with_options( - &remote_command, - WorkspaceCommandOptions { - timeout_ms: Some(timeout_ms), - cancellation_token: context.cancellation_token().cloned(), - }, - ) - .await - .map_err(|e| { - BitFunError::tool(format!("Remote command execution failed: {}", e)) - })?; - - let output = exec_result.combined_output(); - - let execution_time_ms = elapsed_ms_u64(start_time); - let working_directory = context - .workspace_root() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_default(); - let working_directory = requested_working_directory.unwrap_or(working_directory); - let result_for_assistant = render_remote_shell_result(RemoteShellResultRenderRequest { - working_directory: &working_directory, - stdout: &exec_result.stdout, - stderr: &exec_result.stderr, - interrupted: exec_result.interrupted, - timed_out: exec_result.timed_out, - exit_code: exec_result.exit_code, - }); - - let result = ToolResult::Result { - data: json!({ - "success": exec_result.exit_code == 0, - "command": command_str, - "stdout": exec_result.stdout, - "stderr": exec_result.stderr, - "output": output, - "exit_code": exec_result.exit_code, - "interrupted": exec_result.interrupted, - "timed_out": exec_result.timed_out, - "working_directory": working_directory, - "execution_time_ms": execution_time_ms, - "duration_ms": execution_time_ms, - "is_remote": true - }), - result_for_assistant: Some(result_for_assistant), - image_attachments: None, - }; - return Ok(vec![result]); - } - - let run_in_background = input - .get("run_in_background") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - - // Get session_id (for binding terminal session) - let chat_session_id = context - .session_id - .as_ref() - .ok_or_else(|| BitFunError::tool("session_id is required for Bash tool".to_string()))?; - - // Get tool call ID (for sending progress events) - let tool_use_id = context - .tool_call_id - .clone() - .unwrap_or_else(|| format!("bash_{}", uuid::Uuid::new_v4())); - - // 1. Get Terminal API - let terminal_api = TerminalApi::from_singleton() - .map_err(|e| BitFunError::tool(format!("Terminal not initialized: {}", e)))?; - - // 2. Resolve shell type - let shell_type = Self::resolve_shell().await.shell_type; - - let binding = terminal_api.session_manager().binding(); - let workspace_path = context - .workspace_root() - .ok_or_else(|| { - BitFunError::tool("workspace_path is required for Bash tool".to_string()) - })? - .to_string_lossy() - .to_string(); - - if run_in_background { - if Self::cancellation_requested(context) { - return Err(Self::cancellation_error( - "before creating background session", - )); - } - - // For background commands, inherit CWD from an already-running primary session - // if one exists; otherwise fall back to workspace path. This avoids forcing a - // primary session to be created just to read its working directory. - let initial_cwd = if let Some(requested_dir) = requested_working_directory.as_ref() { - requested_dir.clone() - } else if let Some(existing_id) = binding.get(chat_session_id) { - terminal_api - .get_session(&existing_id) - .await - .map(|s| s.cwd) - .unwrap_or_else(|_| workspace_path.clone()) - } else { - workspace_path.clone() - }; - - return self - .call_background( - command_str, - chat_session_id, - &initial_cwd, - context, - shell_type, - &binding, - start_time, - ) - .await; - } - - // 3. Foreground: get or create the primary terminal session - let terminal_ready_started_at = Instant::now(); - let primary_session_id = binding - .get_or_create( - chat_session_id, - TerminalBindingOptions { - working_directory: Some(workspace_path.clone()), - session_id: Some(chat_session_id.to_string()), - session_name: Some(format!( - "Chat-{}", - &chat_session_id[..8.min(chat_session_id.len())] - )), - shell_type: shell_type.clone(), - env: Some(Self::noninteractive_env()), - source: Some(SessionSource::Agent), - ..Default::default() - }, - ) - .await - .map_err(|e| BitFunError::tool(format!("Failed to create Terminal session: {}", e)))?; - let terminal_ready_ms = elapsed_ms_u64(terminal_ready_started_at); - - Self::emit_terminal_ready_event(&tool_use_id, &primary_session_id); - - // Get actual working directory from primary session - let primary_cwd = terminal_api - .get_session(&primary_session_id) - .await - .map(|s| s.cwd) - .unwrap_or_else(|_| workspace_path.clone()); - let execution_working_directory = requested_working_directory - .as_ref() - .cloned() - .unwrap_or_else(|| primary_cwd.clone()); - let command_to_execute = - command_for_working_directory(command_str, requested_working_directory.as_deref()); - - // --- Foreground execution --- - - let tool_name = self.name().to_string(); - - const DEFAULT_TIMEOUT_MS: u64 = 120_000; - const MAX_TIMEOUT_MS: u64 = 600_000; - let timeout_ms = Some( - input - .get("timeout_ms") - .and_then(|v| v.as_u64()) - .unwrap_or(DEFAULT_TIMEOUT_MS) - .min(MAX_TIMEOUT_MS), - ); - - debug!( - "Bash tool executing command: {}, session_id: {}, tool_id: {}", - command_to_execute, chat_session_id, tool_use_id - ); - - // 4. Create streaming execution request - let request = ExecuteCommandRequest { - session_id: primary_session_id.clone(), - command: command_to_execute, - timeout_ms, - prevent_history: Some(true), - }; - - // 5. Execute command and handle streaming output - let mut stream = terminal_api.execute_command_stream(request); - let mut accumulated_output = String::new(); - let mut final_exit_code: Option = None; - let mut was_interrupted = false; - let mut timed_out = false; - let mut final_shell_state: Option = None; - let mut command_started_after_ms: Option = None; - let mut completion_reason_label = "stream_end".to_string(); - let mut interrupt_drain_deadline: Option = None; - let command_stream_started_at = Instant::now(); - - // Get event system for sending progress - let event_system = get_global_event_system(); - - loop { - let next_event = if let Some(deadline) = interrupt_drain_deadline { - let now = tokio::time::Instant::now(); - if now >= deadline { - break; - } - - match tokio::time::timeout_at(deadline, stream.next()).await { - Ok(event) => event, - Err(_) => break, - } - } else { - stream.next().await - }; - - let Some(event) = next_event else { - break; - }; - - // Check cancellation request - if let Some(token) = context.cancellation_token() { - if token.is_cancelled() && !was_interrupted { - debug!("Bash tool received cancellation request, sending interrupt signal, tool_id: {}", tool_use_id); - was_interrupted = true; - interrupt_drain_deadline = Some( - tokio::time::Instant::now() - + Duration::from_millis(BASH_INTERRUPT_OUTPUT_DRAIN_MS), - ); - - let _ = terminal_api - .signal(SignalRequest { - session_id: primary_session_id.clone(), - signal: "SIGINT".to_string(), - }) - .await; - - #[cfg(windows)] - { - final_exit_code = Some(-1073741510); - } - #[cfg(not(windows))] - { - final_exit_code = Some(130); - } - } - } - - match event { - CommandStreamEvent::Started { command_id } => { - command_started_after_ms = Some(elapsed_ms_u64(command_stream_started_at)); - debug!("Bash command started execution, command_id: {}", command_id); - } - CommandStreamEvent::Output { data } => { - accumulated_output.push_str(&data); - - let progress_event = ToolExecutionProgress(ToolExecutionProgressInfo { - tool_use_id: tool_use_id.clone(), - tool_name: tool_name.clone(), - progress_message: data, - percentage: None, - timestamp: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(), - }); - - let event_system_clone = event_system.clone(); - tokio::spawn(async move { - let _ = event_system_clone.emit(progress_event).await; - }); - } - CommandStreamEvent::Completed { - exit_code, - total_output, - completion_reason, - shell_state, - } => { - debug!( - "Bash command completed, exit_code: {:?}, tool_id: {}", - exit_code, tool_use_id - ); - final_exit_code = exit_code.or(final_exit_code); - timed_out = completion_reason == CommandCompletionReason::TimedOut; - completion_reason_label = format!("{:?}", completion_reason); - - if !timed_out && matches!(exit_code, Some(130) | Some(-1073741510)) { - was_interrupted = true; - } - - if !total_output.is_empty() { - accumulated_output = total_output; - } - - // Capture post-command terminal state for the AI agent - if shell_state.is_some() { - final_shell_state = shell_state; - } - break; - } - CommandStreamEvent::Error { message } => { - error!( - "Bash command execution error: {}, tool_id: {}", - message, tool_use_id - ); - return Err(BitFunError::tool(format!( - "Command execution error: {}", - message - ))); - } - } - } - - // 6. Build result - let execution_time_ms = elapsed_ms_u64(start_time); - let command_stream_ms = elapsed_ms_u64(command_stream_started_at); - info!( - "Bash command completed: tool_id={}, terminal_session_id={}, duration_ms={}, terminal_ready_ms={}, command_started_after_ms={:?}, command_stream_ms={}, output_bytes={}, exit_code={:?}, interrupted={}, timed_out={}, completion_reason={}", - tool_use_id, - primary_session_id, - execution_time_ms, - terminal_ready_ms, - command_started_after_ms, - command_stream_ms, - accumulated_output.len(), - final_exit_code, - was_interrupted, - timed_out, - completion_reason_label - ); - - let result_data = json!({ - "success": final_exit_code.unwrap_or(-1) == 0, - "command": command_str, - "output": accumulated_output, - "exit_code": final_exit_code, - "interrupted": was_interrupted, - "timed_out": timed_out, - "working_directory": execution_working_directory, - "execution_time_ms": execution_time_ms, - "terminal_session_id": primary_session_id, - }); - - let result_for_assistant = render_local_shell_result(LocalShellResultRenderRequest { - terminal_session_id: &primary_session_id, - working_directory: &execution_working_directory, - output_text: &accumulated_output, - interrupted: was_interrupted, - timed_out, - exit_code: final_exit_code.unwrap_or(-1), - shell_state: final_shell_state.as_deref(), - }); - - Ok(vec![ToolResult::Result { - data: result_data, - result_for_assistant: Some(result_for_assistant), - image_attachments: None, - }]) - } -} - -fn command_needs_light_checkpoint(command: &str) -> bool { - let command = command.trim().to_ascii_lowercase(); - let mutating_prefixes = [ - "rm ", - "rmdir ", - "del ", - "erase ", - "move ", - "mv ", - "cp ", - "git reset", - "git clean", - "git checkout", - "git switch", - "git merge", - "git rebase", - "git pull", - "git stash", - "git commit", - "cargo fmt", - "cargo fix", - "rustfmt", - "prettier --write", - ]; - - mutating_prefixes - .iter() - .any(|prefix| command.starts_with(prefix)) - || command.contains(" --fix") - || command.contains(" > ") - || command.contains(" >> ") -} - -impl BashTool { - fn background_output_file_path( - context: &ToolUseContext, - chat_session_id: &str, - tool_use_id: &str, - ) -> Option { - context - .current_workspace_session_tool_result_path( - chat_session_id, - &format!("{}.txt", tool_use_id), - ) - .ok() - } - - /// Execute a command in a new background terminal session. - /// Returns immediately with the new session ID. - #[allow(clippy::too_many_arguments)] - async fn call_background( - &self, - command_str: &str, - chat_session_id: &str, - initial_cwd: &str, - context: &ToolUseContext, - shell_type: Option, - binding: &TerminalSessionBinding, - start_time: Instant, - ) -> BitFunResult> { - debug!( - "Bash tool starting background command: {}, owner: {}", - command_str, chat_session_id - ); - - if Self::cancellation_requested(context) { - return Err(Self::cancellation_error( - "before creating background terminal", - )); - } - - // Create a dedicated background terminal session sharing the primary session's cwd - let bg_session_id = binding - .create_background_session( - chat_session_id, - TerminalBindingOptions { - working_directory: Some(initial_cwd.to_string()), - session_id: None, - session_name: None, - shell_type, - env: Some(Self::noninteractive_env()), - source: Some(SessionSource::Agent), - ..Default::default() - }, - ) - .await - .map_err(|e| { - BitFunError::tool(format!( - "Failed to create background terminal session: {}", - e - )) - })?; - - let tool_use_id = context - .tool_call_id - .clone() - .unwrap_or_else(|| format!("bash_{}", uuid::Uuid::new_v4())); - Self::emit_terminal_ready_event(&tool_use_id, &bg_session_id); - - if Self::cancellation_requested(context) { - let terminal_api = TerminalApi::from_singleton() - .map_err(|e| BitFunError::tool(format!("Terminal not initialized: {}", e)))?; - let _ = terminal_api - .close_session(terminal_core::CloseSessionRequest { - session_id: bg_session_id.clone(), - immediate: Some(true), - }) - .await; - return Err(Self::cancellation_error( - "before sending background command", - )); - } - - // Store background output under the session-scoped runtime tool-results tree: - // local: ~/.bitfun/projects//sessions//tool-results/.txt - // remote: ~/.bitfun/remote_ssh///sessions//tool-results/.txt - let output_file_path = - Self::background_output_file_path(context, chat_session_id, &tool_use_id).ok_or_else( - || { - BitFunError::tool( - "Failed to prepare a background output file for Bash tool".to_string(), - ) - }, - )?; - if let Some(parent) = output_file_path.parent() { - tokio::fs::create_dir_all(parent).await.map_err(|e| { - BitFunError::tool(format!( - "Failed to create background output directory: {}", - e - )) - })?; - } - let output_file = tokio::fs::OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .open(&output_file_path) - .await - .map_err(|e| { - BitFunError::tool(format!("Failed to open background output file: {}", e)) - })?; - let output_file_reference = Self::background_output_file_reference( - context, - chat_session_id, - &tool_use_id, - &output_file_path, - ); - - debug!( - "Background command started, session_id: {}, owner: {}", - bg_session_id, chat_session_id - ); - - let parent_session_id = chat_session_id.to_string(); - let parent_agent_type = context - .agent_type - .clone() - .unwrap_or_else(|| "Agentic".to_string()); - let parent_workspace_path = context - .workspace_root() - .map(|path| path.to_string_lossy().to_string()); - let parent_remote_connection_id = context - .workspace - .as_ref() - .and_then(|workspace| workspace.connection_id().map(ToOwned::to_owned)); - let parent_remote_ssh_host = context - .workspace - .as_ref() - .filter(|workspace| workspace.is_remote()) - .map(|workspace| workspace.session_identity.hostname.clone()) - .filter(|value| !value.trim().is_empty()); - let command = command_str.to_string(); - let working_directory = initial_cwd.to_string(); - let terminal_session_id = bg_session_id.clone(); - let output_file_reference_for_task = output_file_reference.clone(); - let tool_use_id_for_task = tool_use_id.clone(); - - tokio::spawn(async move { - let mut writer = tokio::io::BufWriter::new(output_file); - let mut output_persist_error: Option = None; - let mut saw_output_event = false; - let mut saw_completion = false; - let mut delivery_sent = false; - - let terminal_api = match TerminalApi::from_singleton() { - Ok(api) => api, - Err(error) => { - error!( - "Background Bash command could not access terminal singleton: session_id={}, error={}", - terminal_session_id, error - ); - return; - } - }; - - let mut stream = terminal_api.execute_command_stream(ExecuteCommandRequest { - session_id: terminal_session_id.clone(), - command: command.clone(), - timeout_ms: None, - prevent_history: Some(true), - }); - - while let Some(event) = stream.next().await { - match event { - CommandStreamEvent::Started { command_id } => { - debug!( - "Background Bash command started execution, session_id={}, command_id={}", - terminal_session_id, command_id - ); - } - CommandStreamEvent::Output { data } => { - saw_output_event = true; - if output_persist_error.is_none() { - if let Err(error) = writer.write_all(data.as_bytes()).await { - output_persist_error = Some(error.to_string()); - error!( - "Failed to write background Bash output: session_id={}, error={}", - terminal_session_id, error - ); - } else if let Err(error) = writer.flush().await { - output_persist_error = Some(error.to_string()); - error!( - "Failed to flush background Bash output: session_id={}, error={}", - terminal_session_id, error - ); - } - } - } - CommandStreamEvent::Completed { - exit_code, - total_output, - completion_reason, - shell_state: _, - } => { - saw_completion = true; - - if !saw_output_event - && !total_output.is_empty() - && output_persist_error.is_none() - { - if let Err(error) = writer.write_all(total_output.as_bytes()).await { - output_persist_error = Some(error.to_string()); - error!( - "Failed to persist background Bash completion output: session_id={}, error={}", - terminal_session_id, error - ); - } else if let Err(error) = writer.flush().await { - output_persist_error = Some(error.to_string()); - error!( - "Failed to flush background Bash completion output: session_id={}, error={}", - terminal_session_id, error - ); - } - } - - let timed_out = completion_reason == CommandCompletionReason::TimedOut; - let interrupted = - !timed_out && matches!(exit_code, Some(130) | Some(-1073741510)); - let status = BackgroundCommandStatusFacts { - exit_code, - timed_out, - interrupted, - }; - let delivery_text = format_background_command_delivery_text( - BackgroundCommandDeliveryTextRequest { - command: &command, - terminal_session_id: &terminal_session_id, - working_directory: &working_directory, - status, - output_file_reference: &output_file_reference_for_task, - output_persist_error: output_persist_error.as_deref(), - }, - ); - let display_text = - format_background_command_display_text(BackgroundCommandStatusFacts { - exit_code, - timed_out, - interrupted, - }); - let metadata = json!({ - "kind": "background_result", - "sourceKind": "bash_command", - "toolName": "Bash", - "toolCallId": tool_use_id_for_task.clone(), - "terminalSessionId": terminal_session_id.clone(), - "command": command.clone(), - "workingDirectory": working_directory.clone(), - "outputFile": output_file_reference_for_task.clone(), - }); - - deliver_background_bash_result(BackgroundBashResultDelivery { - parent_session_id: parent_session_id.clone(), - parent_agent_type: parent_agent_type.clone(), - parent_workspace_path: parent_workspace_path.clone(), - parent_remote_connection_id: parent_remote_connection_id.clone(), - parent_remote_ssh_host: parent_remote_ssh_host.clone(), - delivery_text, - display_text, - metadata: json_object_metadata(metadata), - terminal_session_id: terminal_session_id.clone(), - failure_context: "result", - }) - .await; - delivery_sent = true; - break; - } - CommandStreamEvent::Error { message } => { - let delivery_text = format_background_command_error_text( - BackgroundCommandErrorTextRequest { - command: &command, - terminal_session_id: &terminal_session_id, - working_directory: &working_directory, - output_file_reference: &output_file_reference_for_task, - error: &message, - output_persist_error: output_persist_error.as_deref(), - }, - ); - let display_text = format_background_command_error_display_text(); - let metadata = json!({ - "kind": "background_result", - "sourceKind": "bash_command", - "toolName": "Bash", - "toolCallId": tool_use_id_for_task.clone(), - "terminalSessionId": terminal_session_id.clone(), - "command": command.clone(), - "workingDirectory": working_directory.clone(), - "outputFile": output_file_reference_for_task.clone(), - "error": message.clone(), - }); - - deliver_background_bash_result(BackgroundBashResultDelivery { - parent_session_id: parent_session_id.clone(), - parent_agent_type: parent_agent_type.clone(), - parent_workspace_path: parent_workspace_path.clone(), - parent_remote_connection_id: parent_remote_connection_id.clone(), - parent_remote_ssh_host: parent_remote_ssh_host.clone(), - delivery_text, - display_text, - metadata: json_object_metadata(metadata), - terminal_session_id: terminal_session_id.clone(), - failure_context: "error result", - }) - .await; - delivery_sent = true; - break; - } - } - } - - if !saw_completion && !delivery_sent { - let delivery_text = - format_background_command_error_text(BackgroundCommandErrorTextRequest { - command: &command, - terminal_session_id: &terminal_session_id, - working_directory: &working_directory, - output_file_reference: &output_file_reference_for_task, - error: "Background Bash command stream ended without a completion event.", - output_persist_error: output_persist_error.as_deref(), - }); - let display_text = format_background_command_error_display_text(); - let metadata = json!({ - "kind": "background_result", - "sourceKind": "bash_command", - "toolName": "Bash", - "toolCallId": tool_use_id_for_task, - "terminalSessionId": terminal_session_id.clone(), - "command": command.clone(), - "workingDirectory": working_directory.clone(), - "outputFile": output_file_reference_for_task.clone(), - "error": "stream_ended_without_completion", - }); - - deliver_background_bash_result(BackgroundBashResultDelivery { - parent_session_id: parent_session_id.clone(), - parent_agent_type: parent_agent_type.clone(), - parent_workspace_path: parent_workspace_path.clone(), - parent_remote_connection_id: parent_remote_connection_id.clone(), - parent_remote_ssh_host: parent_remote_ssh_host.clone(), - delivery_text, - display_text, - metadata: json_object_metadata(metadata), - terminal_session_id: terminal_session_id.clone(), - failure_context: "stream-end result", - }) - .await; - } - }); - - let execution_time_ms = elapsed_ms_u64(start_time); - let output_file_note = format!("\nFull output will be saved to: {}", output_file_reference); - - let result_data = json!({ - "success": true, - "command": command_str, - "output": format!("Command started in background terminal session.{}", output_file_note), - "exit_code": null, - "interrupted": false, - "working_directory": initial_cwd, - "execution_time_ms": execution_time_ms, - "terminal_session_id": bg_session_id, - "output_file": output_file_reference, - "run_in_background": true, - }); - - let result_for_assistant = format!( - "Command started in background terminal session (id: {}). Working directory: {}.{} Its final result will be delivered back automatically when it finishes. Do not poll for status updates. If your current path is blocked on this result and there is no other useful local work to do, it is fine to end the current turn.", - bg_session_id, initial_cwd, output_file_note - ); - - Ok(vec![ToolResult::Result { - data: result_data, - result_for_assistant: Some(result_for_assistant), - image_attachments: None, - }]) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::agentic::tools::ToolRuntimeRestrictions; - use crate::agentic::workspace::WorkspaceBinding; - use crate::service::remote_ssh::workspace_state::workspace_session_identity; - use std::collections::HashMap; - use std::path::PathBuf; - - fn remote_tool_context_without_filesystem(root: &str) -> ToolUseContext { - let session_identity = - workspace_session_identity(root, Some("conn-1"), Some("remote-host")) - .expect("remote session identity should build"); - ToolUseContext { - tool_call_id: None, - agent_type: Some("agentic".to_string()), - session_id: Some("session-1".to_string()), - dialog_turn_id: None, - workspace: Some(WorkspaceBinding::new_remote( - None, - PathBuf::from(root), - "conn-1".to_string(), - "Remote Host".to_string(), - session_identity, - )), - loaded_deferred_tool_specs: Vec::new(), - primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), - custom_data: HashMap::new(), - computer_use_host: None, - runtime_tool_restrictions: ToolRuntimeRestrictions::default(), - runtime_handles: bitfun_runtime_ports::ToolRuntimeHandles::default(), - } - } - - #[tokio::test] - async fn remote_workdir_validation_does_not_require_filesystem_service() { - let context = remote_tool_context_without_filesystem("/home/me/project"); - - let validation = BashTool::new() - .validate_input( - &json!({ - "command": "pwd", - "working_directory": "crates/core" - }), - Some(&context), - ) - .await; - - assert!( - validation.result, - "validation failed: {:?}", - validation.message - ); - assert!( - context.ws_fs().is_none(), - "the regression context has no SFTP service" - ); - } - - #[test] - fn checkpoint_detection_flags_mutating_bash_commands() { - assert!(command_needs_light_checkpoint("cargo fmt")); - assert!(command_needs_light_checkpoint("pnpm lint --fix")); - assert!(command_needs_light_checkpoint("rm -rf target/tmp")); - assert!(!command_needs_light_checkpoint("cargo test")); - assert!(!command_needs_light_checkpoint("git status")); - } - - #[test] - fn truncate_output_preserving_tail_keeps_end_of_output() { - let input = "BEGIN-".to_string() + &"x".repeat(120) + "-IMPORTANT-END"; - - let truncated = tool_runtime::shell::truncate_output_preserving_tail(&input, 80); - - assert!(truncated.contains("tail preserved")); - assert!(truncated.ends_with("IMPORTANT-END")); - assert!(!truncated.contains("BEGIN-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")); - assert!(truncated.chars().count() <= 80); - } - - #[test] - fn detect_osascript_keystroke_non_ascii_flags_cjk_keystroke() { - let cmd = r#"osascript -e 'tell application "System Events" to keystroke "尉怡青"'"#; - let hit = detect_osascript_keystroke_non_ascii(cmd).expect("should flag CJK keystroke"); - assert!(hit.contains("尉怡青")); - } - - #[test] - fn detect_osascript_keystroke_non_ascii_flags_emoji_keystroke() { - let cmd = r#"osascript -e 'tell application "System Events" to keystroke "hi 👋"'"#; - assert!(detect_osascript_keystroke_non_ascii(cmd).is_some()); - } - - #[test] - fn detect_osascript_keystroke_non_ascii_passes_pure_ascii() { - let cmd = r#"osascript -e 'tell application "System Events" to keystroke "hello"'"#; - assert!(detect_osascript_keystroke_non_ascii(cmd).is_none()); - } - - #[test] - fn detect_osascript_keystroke_non_ascii_passes_non_osascript() { - let cmd = r#"echo "尉怡青""#; - assert!(detect_osascript_keystroke_non_ascii(cmd).is_none()); - } - - #[test] - fn detect_osascript_im_app_flags_wechat() { - let cmd = r#"osascript -e 'tell application "WeChat" to activate'"#; - assert_eq!(detect_osascript_im_app(cmd), Some("WeChat")); - } - - #[test] - fn detect_osascript_im_app_flags_weixin_chinese() { - let cmd = r#"osascript -e 'tell application "微信" to activate'"#; - assert_eq!(detect_osascript_im_app(cmd), Some("微信")); - } - - #[test] - fn detect_osascript_im_app_passes_non_im() { - let cmd = r#"osascript -e 'tell application "Finder" to activate'"#; - assert!(detect_osascript_im_app(cmd).is_none()); - } - - #[test] - fn render_result_marks_truncated_output_and_keeps_tail() { - let long_output = "prefix\n".to_string() - + &"y".repeat(BASH_RESULT_MAX_OUTPUT_LENGTH + 100) - + "\nfinal-error"; - - let rendered = render_local_shell_result(LocalShellResultRenderRequest { - terminal_session_id: "session-1", - working_directory: "/repo", - output_text: &long_output, - interrupted: false, - timed_out: false, - exit_code: 1, - shell_state: None, - }); - - assert!(rendered.contains("")); - assert!(rendered.contains("tail preserved")); - assert!(rendered.contains("final-error")); - assert!(rendered.contains("1")); - } - - #[test] - fn render_remote_result_keeps_stdout_and_stderr_separate() { - let rendered = render_remote_shell_result(RemoteShellResultRenderRequest { - working_directory: "/repo", - stdout: "stdout text", - stderr: "stderr text", - interrupted: false, - timed_out: false, - exit_code: 2, - }); - - assert!(rendered.contains("true")); - assert!(rendered.contains("2")); - assert!(rendered.contains("stdout text")); - assert!(rendered.contains("stderr text")); - assert!(!rendered.contains("")); - } - - #[test] - fn render_remote_result_uses_shared_budget_with_stderr_priority() { - let long_stdout = "prefix\n".to_string() - + &"x".repeat(BASH_RESULT_MAX_OUTPUT_LENGTH + 100) - + "\nstdout-tail"; - let long_stderr = "prefix\n".to_string() - + &"z".repeat(BASH_RESULT_MAX_OUTPUT_LENGTH / 2) - + "\nstderr-tail"; - - let rendered = render_remote_shell_result(RemoteShellResultRenderRequest { - working_directory: "/repo", - stdout: &long_stdout, - stderr: &long_stderr, - interrupted: false, - timed_out: false, - exit_code: 1, - }); - - assert!(rendered.contains("")); - assert!(rendered.contains("stdout-tail")); - assert!(!rendered.contains("")); - assert!(rendered.contains("stderr-tail")); - } - - #[test] - fn render_remote_result_gives_all_budget_to_oversized_stderr() { - let long_stderr = "prefix\n".to_string() - + &"z".repeat(BASH_RESULT_MAX_OUTPUT_LENGTH + 100) - + "\nremote-final-error"; - - let rendered = render_remote_shell_result(RemoteShellResultRenderRequest { - working_directory: "/repo", - stdout: "stdout text", - stderr: &long_stderr, - interrupted: false, - timed_out: false, - exit_code: 1, - }); - - assert!(rendered.contains("")); - assert!(rendered.contains("no budget remaining")); - assert!(rendered.contains("")); - assert!(rendered.contains("tail preserved")); - assert!(rendered.contains("remote-final-error")); - } - - #[test] - fn input_schema_accepts_working_directory() { - let tool = BashTool::new(); - let schema = tool.input_schema(); - - assert!(schema["properties"].get("working_directory").is_some()); - assert_eq!(schema["additionalProperties"], false); - } - - #[test] - fn command_is_prefixed_with_quoted_working_directory_when_requested() { - let command = - command_for_working_directory("pnpm install", Some("/Users/example/My Project")); - - assert_eq!(command, "cd '/Users/example/My Project' && pnpm install"); - } - - #[test] - fn command_prefix_escapes_single_quotes_in_working_directory() { - let command = command_for_working_directory("pwd", Some("/tmp/it's fine")); - - assert_eq!(command, "cd '/tmp/it'\\''s fine' && pwd"); - } - - #[test] - fn command_result_includes_working_directory_for_model() { - let rendered = render_local_shell_result(LocalShellResultRenderRequest { - terminal_session_id: "session-1", - working_directory: "/private/tmp", - output_text: "ERR_PNPM_NO_PKG_MANIFEST No package.json found in /private/tmp", - interrupted: false, - timed_out: false, - exit_code: 1, - shell_state: None, - }); - - assert!(rendered.contains("1")); - assert!(rendered.contains("/private/tmp")); - assert!(rendered.contains("ERR_PNPM_NO_PKG_MANIFEST")); - } - - #[test] - fn background_delivery_text_points_to_saved_output_file() { - let rendered = - format_background_command_delivery_text(BackgroundCommandDeliveryTextRequest { - command: "pnpm test", - terminal_session_id: "bg-session-1", - working_directory: "/repo", - status: BackgroundCommandStatusFacts { - exit_code: Some(0), - timed_out: false, - interrupted: false, - }, - output_file_reference: "/runtime/sessions/session/tool-results/bash_123.txt", - output_persist_error: None, - }); - - assert!(rendered.contains("Background Bash command completed successfully.")); - assert!(rendered.contains("status=\"completed\"")); - assert!(rendered.contains("terminal_session_id=\"bg-session-1\"")); - assert!(rendered.contains( - "Full output was saved to: /runtime/sessions/session/tool-results/bash_123.txt" - )); - } - - #[test] - fn background_display_text_is_concise() { - assert_eq!( - format_background_command_display_text(BackgroundCommandStatusFacts { - exit_code: Some(0), - timed_out: false, - interrupted: false, - }), - "Background Bash command completed successfully." - ); - assert_eq!( - format_background_command_display_text(BackgroundCommandStatusFacts { - exit_code: Some(1), - timed_out: false, - interrupted: false, - }), - "Background Bash command completed with a non-zero exit code." - ); - assert_eq!( - format_background_command_display_text(BackgroundCommandStatusFacts { - exit_code: None, - timed_out: true, - interrupted: false, - }), - "Background Bash command timed out." - ); - assert_eq!( - format_background_command_display_text(BackgroundCommandStatusFacts { - exit_code: Some(130), - timed_out: false, - interrupted: true, - }), - "Background Bash command was interrupted." - ); - assert_eq!( - format_background_command_error_display_text(), - "Background Bash command failed before producing a final completion result." - ); - } -} diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/control_hub_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/control_hub_tool.rs index c1c156b5a4..837557186b 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/control_hub_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/control_hub_tool.rs @@ -2772,11 +2772,11 @@ Branch on `ok` and `error.code`, not on English messages. &self, action: &str, params: &Value, - context: &ToolUseContext, + _context: &ToolUseContext, ) -> BitFunResult> { // Phase 4: enumerate live terminal sessions so the model can resolve // a `terminal_session_id` *before* attempting `kill` / `interrupt`. - // Previously this required digging through earlier `Bash` results. + // Previously this required digging through earlier command results. if action == "list_sessions" { let api = crate::service::terminal::api::TerminalApi::from_singleton() .map_err(|e| BitFunError::tool(format!("TerminalApi unavailable: {}", e)))?; @@ -2805,7 +2805,7 @@ Branch on `ok` and `error.code`, not on English messages. // UX shortcut: when there is exactly one live terminal session, // make `terminal_session_id` optional. The 95th-percentile flow is - // "Bash launched a long-running command, please interrupt it" and + // "A long-running command needs to be interrupted" and // the user has no other terminals open — forcing a `list_sessions` // round-trip just to copy the only id back wastes a turn. let resolved_id: String = match params.get("terminal_session_id").and_then(|v| v.as_str()) { @@ -2836,7 +2836,7 @@ Branch on `ok` and `error.code`, not on English messages. "No live terminal sessions to target", ) .with_hint( - "Use the Bash tool to start a command, then this action becomes meaningful", + "Use ExecCommand to start a command, then this action becomes meaningful", ), )); } else { @@ -2858,14 +2858,66 @@ Branch on `ok` and `error.code`, not on English messages. } }; - let mut input = params.clone(); - if let Value::Object(ref mut map) = input { - map.insert("action".to_string(), json!(action)); - map.insert("terminal_session_id".to_string(), json!(resolved_id)); - } + let terminal_api = crate::service::terminal::TerminalApi::from_singleton() + .map_err(|error| BitFunError::tool(format!("Terminal not initialized: {error}")))?; + + match action { + "interrupt" => { + terminal_api + .signal(crate::service::terminal::SignalRequest { + session_id: resolved_id.clone(), + signal: "SIGINT".to_string(), + }) + .await + .map_err(|error| { + BitFunError::tool(format!("Failed to interrupt terminal session: {error}")) + })?; - let tool = super::terminal_control_tool::TerminalControlTool::new(); - tool.call_impl(&input, context).await + Ok(vec![ToolResult::ok( + json!({ + "success": true, + "terminal_session_id": resolved_id, + "action": "interrupt", + }), + Some("Sent interrupt (SIGINT) to the terminal session.".to_string()), + )]) + } + "kill" => { + let binding = terminal_api.session_manager().binding(); + let is_primary = binding + .get(&resolved_id) + .map(|bound_id| bound_id == resolved_id) + .unwrap_or(false); + + if is_primary { + binding.remove(&resolved_id).await.map_err(|error| { + BitFunError::tool(format!("Failed to close terminal session: {error}")) + })?; + } else { + terminal_api + .close_session(crate::service::terminal::CloseSessionRequest { + session_id: resolved_id.clone(), + immediate: Some(true), + }) + .await + .map_err(|error| { + BitFunError::tool(format!("Failed to close terminal session: {error}")) + })?; + } + + Ok(vec![ToolResult::ok( + json!({ + "success": true, + "terminal_session_id": resolved_id, + "action": "kill", + }), + Some("Closed the terminal session.".to_string()), + )]) + } + _ => Err(BitFunError::tool(format!( + "Unknown terminal action: '{action}'. Must be 'kill' or 'interrupt'." + ))), + } } } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/git_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/git_tool.rs deleted file mode 100644 index 7ef810813e..0000000000 --- a/src/crates/assembly/core/src/agentic/tools/implementations/git_tool.rs +++ /dev/null @@ -1,1988 +0,0 @@ -//! Git tool implementation - reuses GitService implementation -//! -//! Provides safe and convenient Git command execution functionality, reuses underlying GitService - -use crate::agentic::tools::framework::{ - PermissionIntent, Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, - ValidationResult, -}; -use crate::service::git::{ - execute_git_command, execute_git_command_raw, GitAddParams, GitCommitParams, GitDiffParams, - GitPullParams, GitPushParams, GitService, -}; -use crate::util::elapsed_ms_u64; -use crate::util::errors::{BitFunError, BitFunResult}; -use async_trait::async_trait; -use log::debug; -use serde_json::{json, Map, Value}; - -// --------------------------------------------------------------------------- -// Constants for git diff argument parsing -// --------------------------------------------------------------------------- - -/// Separator between refs and file paths in git diff commands. -const GIT_DIFF_FILE_SEPARATOR: &str = " -- "; - -/// Two-dot range separator (symmetric difference). -const RANGE_TWO_DOT: &str = ".."; - -/// Three-dot range separator (merge base). -const RANGE_THREE_DOT: &str = "..."; - -/// Known diff flags that should be excluded when extracting commit refs. -const DIFF_FLAGS: &[&str] = &["--staged", "--cached", "--stat"]; - -/// Prefix for short flags (e.g. `-p`, `-U5`). -const SHORT_FLAG_PREFIX: &str = "-"; - -/// Allowed Git operation types -const ALLOWED_OPERATIONS: &[&str] = &[ - "status", // View working tree status - "diff", // View differences - "log", // View commit history - "add", // Add files to staging area - "commit", // Commit changes - "branch", // Branch operations - "checkout", // Switch branches - "switch", // Switch branches (new syntax) - "pull", // Pull remote changes - "push", // Push to remote - "fetch", // Fetch remote updates - "merge", // Merge branches - "rebase", // Rebase operations - "stash", // Stash changes - "reset", // Reset changes - "restore", // Restore files - "show", // Show objects - "tag", // Tag operations - "remote", // Remote repository operations - "clone", // Clone repository - "init", // Initialize repository - "blame", // View file history - "cherry-pick", // Cherry-pick commits - "rev-parse", // Parse references - "describe", // Describe version - "shortlog", // Short log - "clean", // Clean working directory - "var", // Read Git author/committer identity and other Git variables -]; - -/// Dangerous Git operations (require special warning) -const DANGEROUS_OPERATIONS: &[&str] = &["push --force", "reset --hard", "clean -fd", "rebase"]; - -/// Parsed result of a `git diff` args string. -#[derive(Debug, PartialEq, Default)] -struct ParsedDiffArgs { - staged: bool, - stat: bool, - source: Option, - target: Option, - files: Option>, -} - -/// Parsed result of a `git commit` args string. -#[derive(Debug, PartialEq, Default)] -struct ParsedCommitArgs { - message_parts: Vec, - amend: bool, - all: bool, - no_verify: bool, -} - -/// Execution plan for checkout/switch derived from the raw args. -#[derive(Debug, PartialEq)] -enum CheckoutPlan { - /// Switch to an existing branch through GitService. - Checkout { branch: String }, - /// Create a branch (optionally from a start point) and switch to it. - Create { - branch: String, - start_point: Option, - }, - /// Run the original command through the real git CLI so less common - /// shapes (detach, orphan, track, force-create, `-`) stay faithful. - Passthrough, -} - -/// Git tool -pub struct GitTool; - -impl GitTool { - pub fn new() -> Self { - Self - } - - fn strip_command_wrapping(raw: &str) -> &str { - let trimmed = raw.trim(); - let Some(stripped) = trimmed - .strip_prefix("```") - .and_then(|value| value.strip_suffix("```")) - else { - return trimmed.trim_matches('`').trim(); - }; - - let stripped = stripped.trim(); - if let Some((first_line, rest)) = stripped.split_once('\n') { - if first_line - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') - { - return rest.trim(); - } - } - - stripped - } - - /// Parse shell-style git text such as `git status` or `git diff --staged`. - fn parse_git_command_text(text: &str) -> Option { - let trimmed = Self::strip_command_wrapping(text); - let command = trimmed - .strip_prefix("git ") - .map(str::trim) - .unwrap_or(trimmed); - let mut parts = command.splitn(2, char::is_whitespace); - let operation = parts.next()?.trim(); - if operation.is_empty() - || !operation - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') - { - return None; - } - - let args = parts.next().map(str::trim).filter(|args| !args.is_empty()); - let mut value = json!({ "operation": operation }); - if let Some(args) = args { - value["args"] = json!(args); - } - Some(value) - } - - fn split_leading_operation(args: &str) -> Option<(String, String)> { - let args = args - .trim() - .strip_prefix("git ") - .map(str::trim) - .unwrap_or(args.trim()); - let mut parts = args.splitn(2, char::is_whitespace); - let operation = parts.next()?.trim(); - if !ALLOWED_OPERATIONS.contains(&operation) { - return None; - } - - let rest = parts.next().unwrap_or("").trim().to_string(); - Some((operation.to_string(), rest)) - } - - fn infer_operation_from_flag_args(args: &str) -> Option<&'static str> { - let tokens: Vec<&str> = args.split_whitespace().collect(); - if tokens.is_empty() { - return None; - } - - let has_log_flag = tokens.iter().any(|token| { - matches!( - *token, - "--since" - | "--until" - | "--after" - | "--before" - | "--oneline" - | "--grep" - | "--author" - | "--decorate" - | "--walk-reflogs" - ) || token.starts_with("--since=") - || token.starts_with("--until=") - || token.starts_with("--after=") - || token.starts_with("--before=") - }); - if has_log_flag { - return Some("log"); - } - - let has_diff_flag = tokens.iter().any(|token| { - matches!( - *token, - "--staged" | "--cached" | "--stat" | "--numstat" | "--name-only" | "--name-status" - ) - }); - if has_diff_flag { - return Some("diff"); - } - - None - } - - fn preserve_git_input_metadata(parsed: &mut Value, source: &Map) { - let Some(parsed_obj) = parsed.as_object_mut() else { - return; - }; - for key in ["working_directory", "timeout"] { - if let Some(value) = source.get(key) { - parsed_obj - .entry(key.to_string()) - .or_insert_with(|| value.clone()); - } - } - } - - /// Coerce common malformed Git tool inputs into `{ operation, args? }`. - pub(crate) fn normalize_git_input(input: Value) -> Value { - if let Some(text) = input.as_str() { - return Self::parse_git_command_text(text).unwrap_or(input); - } - - let Some(source) = input.as_object() else { - return input; - }; - - if source - .get("operation") - .and_then(|value| value.as_str()) - .is_some_and(|operation| !operation.is_empty()) - { - return input; - } - - for key in ["command", "cmd"] { - if let Some(text) = source.get(key).and_then(|value| value.as_str()) { - if let Some(mut parsed) = Self::parse_git_command_text(text) { - Self::preserve_git_input_metadata(&mut parsed, source); - return parsed; - } - } - } - - if let Some(args) = source.get("args").and_then(|value| value.as_str()) { - if let Some((operation, rest)) = Self::split_leading_operation(args) { - let mut parsed = json!({ "operation": operation }); - if !rest.is_empty() { - parsed["args"] = json!(rest); - } - Self::preserve_git_input_metadata(&mut parsed, source); - return parsed; - } - - if let Some(operation) = Self::infer_operation_from_flag_args(args) { - let mut parsed = json!({ - "operation": operation, - "args": args.trim(), - }); - Self::preserve_git_input_metadata(&mut parsed, source); - return parsed; - } - } - - input - } - - /// Check if operation is dangerous - fn is_dangerous_operation(operation: &str, args: &str) -> bool { - let full_cmd = format!("{} {}", operation, args); - DANGEROUS_OPERATIONS - .iter() - .any(|&danger| full_cmd.contains(danger)) - } - - fn sh_quote(s: &str) -> String { - format!("'{}'", s.replace('\'', "'\\''")) - } - - /// Split an args string into shell-like tokens, honoring single and double - /// quotes so quoted values (commit messages, paths with spaces) survive. - fn tokenize_args(args: &str) -> Vec { - let mut tokens = Vec::new(); - let mut current = String::new(); - let mut quote: Option = None; - let mut token_started = false; - for ch in args.chars() { - match quote { - Some(active) => { - if ch == active { - quote = None; - } else { - current.push(ch); - } - } - None => { - if ch == '\'' || ch == '"' { - quote = Some(ch); - token_started = true; - } else if ch.is_whitespace() { - if token_started { - tokens.push(std::mem::take(&mut current)); - token_started = false; - } - } else { - current.push(ch); - token_started = true; - } - } - } - } - if token_started { - tokens.push(current); - } - tokens - } - - /// True when a token exactly matches a flag. Long flags also match their - /// `--flag=value` form. Substrings inside other words never count, so - /// `-b` no longer fires on branch names like `feat/my-bitfun-x`. - fn token_matches_flag(token: &str, short: Option<&str>, long: Option<&str>) -> bool { - if let Some(long_flag) = long { - if token == long_flag || token.starts_with(&format!("{}=", long_flag)) { - return true; - } - } - if let Some(short_flag) = short { - if token == short_flag { - return true; - } - } - false - } - - fn tokens_contain_flag(tokens: &[String], short: Option<&str>, long: Option<&str>) -> bool { - tokens - .iter() - .any(|token| Self::token_matches_flag(token, short, long)) - } - - /// Positional tokens (non-flag arguments) in original order. - fn positional_tokens(tokens: &[String]) -> Vec<&str> { - tokens - .iter() - .filter(|token| !token.starts_with(SHORT_FLAG_PREFIX)) - .map(String::as_str) - .collect() - } - - /// Decide how a checkout/switch invocation should be executed. - /// - /// Only the plain shapes are mapped onto GitService; anything else falls - /// through to the real git CLI so behavior stays faithful. - fn plan_checkout(operation: &str, args: &str) -> CheckoutPlan { - let tokens = Self::tokenize_args(args); - let is_switch = operation == "switch"; - let create = if is_switch { - Self::tokens_contain_flag(&tokens, Some("-c"), Some("--create")) - } else { - Self::tokens_contain_flag(&tokens, Some("-b"), None) - }; - let force_create = if is_switch { - Self::tokens_contain_flag(&tokens, Some("-C"), Some("--force-create")) - } else { - Self::tokens_contain_flag(&tokens, Some("-B"), None) - }; - let is_handled_flag = |token: &str| { - if is_switch { - token == "-c" || token == "--create" - } else { - token == "-b" - } - }; - let has_other_flags = tokens - .iter() - .any(|token| token.starts_with(SHORT_FLAG_PREFIX) && !is_handled_flag(token)); - if force_create || has_other_flags { - return CheckoutPlan::Passthrough; - } - - let positionals = Self::positional_tokens(&tokens); - let Some(branch) = positionals.first() else { - return CheckoutPlan::Passthrough; - }; - if create { - CheckoutPlan::Create { - branch: branch.to_string(), - start_point: positionals.get(1).map(|value| value.to_string()), - } - } else { - CheckoutPlan::Checkout { - branch: branch.to_string(), - } - } - } - - /// Parse a `git commit` args string. - /// - /// Multiple `-m`/`--message` parts are kept in order (git joins them with - /// blank lines); `-a`/`--amend`/`--no-verify` only match whole tokens, so - /// message text like "handle -a flag" no longer flips behavior. - fn parse_commit_args(args: &str) -> ParsedCommitArgs { - let tokens = Self::tokenize_args(args); - let mut message_parts: Vec = Vec::new(); - let mut index = 0; - while index < tokens.len() { - let token = &tokens[index]; - if token == "-m" || token == "--message" { - if let Some(value) = tokens.get(index + 1) { - message_parts.push(value.clone()); - index += 2; - continue; - } - } else if let Some(value) = token.strip_prefix("--message=") { - message_parts.push(value.to_string()); - } - index += 1; - } - - ParsedCommitArgs { - message_parts, - amend: Self::tokens_contain_flag(&tokens, None, Some("--amend")), - all: Self::tokens_contain_flag(&tokens, Some("-a"), Some("--all")), - no_verify: Self::tokens_contain_flag(&tokens, Some("-n"), Some("--no-verify")), - } - } - - fn log_args_have_max_count(tokens: &[String]) -> bool { - tokens.iter().any(|token| { - token == "-n" - || token == "--max-count" - || token.starts_with("--max-count=") - || token.strip_prefix("-n").is_some_and(|value| { - !value.is_empty() && value.chars().all(|ch| ch.is_ascii_digit()) - }) - || token.len() > 1 - && token.starts_with(SHORT_FLAG_PREFIX) - && !token.starts_with("--") - && token[1..].chars().all(|ch| ch.is_ascii_digit()) - }) - } - - /// Build native `git log` arguments without reinterpreting or dropping - /// standard Git flags. Keep the historical 50-commit safety bound only - /// when the caller did not provide an explicit count. - fn build_log_cli_args(args: &str) -> Vec { - let mut tokens = Self::tokenize_args(args); - if !Self::log_args_have_max_count(&tokens) { - tokens.insert(0, "--max-count=50".to_string()); - } - tokens - } - - /// Resolve repository root: workspace root or a path resolved with the same rules as file tools - /// (POSIX on remote SSH). - fn get_repo_path( - working_directory: Option<&str>, - context: &ToolUseContext, - ) -> BitFunResult { - if let Some(dir) = working_directory { - let trimmed = dir.trim(); - if trimmed.is_empty() { - return context - .workspace - .as_ref() - .map(|w| w.root_path_string()) - .ok_or_else(|| BitFunError::tool("No workspace path available".to_string())); - } - context.resolve_workspace_tool_path(trimmed) - } else { - context - .workspace - .as_ref() - .map(|w| w.root_path_string()) - .ok_or_else(|| BitFunError::tool("No workspace path available".to_string())) - } - } - - /// Run `git` on the remote host over SSH (same environment as native CLI on the server). - async fn execute_remote_git_cli( - repo_path: &str, - operation: &str, - args: Option<&str>, - context: &ToolUseContext, - ) -> BitFunResult { - let shell = context.ws_shell().ok_or_else(|| { - BitFunError::tool("Remote Git requires workspace shell (SSH)".to_string()) - })?; - - let arg_tokens = if operation == "log" { - Self::build_log_cli_args(args.unwrap_or("")) - } else { - Self::tokenize_args(args.unwrap_or("")) - }; - let quoted_args = arg_tokens - .iter() - .map(|arg| Self::sh_quote(arg)) - .collect::>() - .join(" "); - let cmd = format!( - "git --no-pager -C {} {}{}", - Self::sh_quote(repo_path), - operation, - if quoted_args.is_empty() { - String::new() - } else { - format!(" {quoted_args}") - } - ); - - let (stdout, stderr, exit_code) = shell - .exec(&cmd, Some(180_000)) - .await - .map_err(|e| BitFunError::tool(format!("Remote git failed: {}", e)))?; - - Ok(json!({ - "success": exit_code == 0, - "exit_code": exit_code, - "stdout": stdout, - "stderr": stderr, - "command": cmd, - "remote_execution": true, - })) - } - - /// Execute status operation using GitService - async fn execute_status(repo_path: &str) -> BitFunResult { - let status = GitService::get_status(repo_path) - .await - .map_err(|e| BitFunError::tool(format!("Git status failed: {}", e)))?; - - // Build output text - let mut output_lines = vec![]; - output_lines.push(format!("On branch {}", status.current_branch)); - - if status.ahead > 0 || status.behind > 0 { - output_lines.push(format!( - "Your branch is {} ahead, {} behind", - status.ahead, status.behind - )); - } - - if !status.staged.is_empty() { - output_lines.push("\nChanges to be committed:".to_string()); - for file in &status.staged { - output_lines.push(format!(" {}: {}", file.status, file.path)); - } - } - - if !status.unstaged.is_empty() { - output_lines.push("\nChanges not staged for commit:".to_string()); - for file in &status.unstaged { - output_lines.push(format!(" {}: {}", file.status, file.path)); - } - } - - if !status.untracked.is_empty() { - output_lines.push("\nUntracked files:".to_string()); - for file in &status.untracked { - output_lines.push(format!(" {}", file)); - } - } - - if status.staged.is_empty() && status.unstaged.is_empty() && status.untracked.is_empty() { - output_lines.push("nothing to commit, working tree clean".to_string()); - } - - Ok(json!({ - "success": true, - "exit_code": 0, - "stdout": output_lines.join("\n"), - "stderr": "", - "data": status - })) - } - - /// Parse a `git diff` args string into structured [`ParsedDiffArgs`]. - /// - /// Supported patterns: - /// - `HEAD~7..HEAD --stat` → source=HEAD~7, target=HEAD, stat=true - /// - `HEAD --stat -- src/foo.rs` → source=HEAD, stat=true, files=[src/foo.rs] - /// - `--staged` → staged=true - /// - `origin/main...HEAD` → source=origin/main, target=HEAD (three-dot) - fn parse_diff_args(args_str: &str) -> ParsedDiffArgs { - let flag_tokens = Self::tokenize_args(args_str); - let mut result = ParsedDiffArgs { - staged: Self::tokens_contain_flag(&flag_tokens, None, Some("--staged")) - || Self::tokens_contain_flag(&flag_tokens, None, Some("--cached")), - stat: Self::tokens_contain_flag(&flag_tokens, None, Some("--stat")), - ..Default::default() - }; - - // Split on " -- " to separate options/refs from file paths - let (refs_part, files_part) = if let Some(sep_pos) = args_str.find(GIT_DIFF_FILE_SEPARATOR) - { - let refs = args_str[..sep_pos].trim(); - let files = args_str[sep_pos + GIT_DIFF_FILE_SEPARATOR.len()..].trim(); - (refs, Some(files)) - } else if let Some(stripped) = args_str.strip_prefix("-- ") { - // Handle "-- file1 file2" (no leading space before --) - ("", Some(stripped.trim())) - } else { - (args_str.trim(), None) - }; - - // Extract non-flag tokens from refs_part as commit references - let ref_tokens: Vec<&str> = refs_part - .split_whitespace() - .filter(|token| { - !DIFF_FLAGS.iter().any(|flag| token == flag) - && !token.starts_with(SHORT_FLAG_PREFIX) - }) - .collect(); - - let refs_text = if ref_tokens.len() == 1 { - ref_tokens[0] - } else if ref_tokens.len() >= 2 { - // Re-join multi-token refs so spaces inside refs are preserved - &ref_tokens.join(" ") - } else { - "" - }; - - if !refs_text.is_empty() { - let (src, tgt) = Self::split_range(refs_text); - result.source = src; - result.target = tgt; - } - - result.files = files_part.map(|fp| { - fp.split_whitespace() - .map(|s| s.to_string()) - .collect::>() - }); - - result - } - - /// Split a ref expression on the first `..` or `...` range separator. - /// - /// Returns `(Some(source), Some(target))` when both sides are non-empty, - /// otherwise falls back to treating the whole text as a single source. - fn split_range(text: &str) -> (Option, Option) { - let (sep_len, pos) = if let Some(p) = text.find(RANGE_THREE_DOT) { - (RANGE_THREE_DOT.len(), p) - } else if let Some(p) = text.find(RANGE_TWO_DOT) { - (RANGE_TWO_DOT.len(), p) - } else { - return (Some(text.to_string()), None); - }; - - let src = text[..pos].trim(); - let tgt = text[pos + sep_len..].trim(); - - match (src.is_empty(), tgt.is_empty()) { - (false, false) => (Some(src.to_string()), Some(tgt.to_string())), - (false, true) => (Some(src.to_string()), None), - (true, false) => (None, Some(tgt.to_string())), - (true, true) => (None, None), - } - } - - /// Execute diff operation using GitService - async fn execute_diff(repo_path: &str, args: Option<&str>) -> BitFunResult { - let parsed = Self::parse_diff_args(args.unwrap_or("")); - - let params = GitDiffParams { - staged: Some(parsed.staged), - stat: Some(parsed.stat), - source: parsed.source, - target: parsed.target, - files: parsed.files, - review_safe: None, - }; - - let diff_output = GitService::get_diff(repo_path, ¶ms) - .await - .map_err(|e| BitFunError::tool(format!("Git diff failed: {}", e)))?; - - // When there are no differences, git diff returns exit code 0 with an - // empty stdout. Return a friendly message so the model (and user) see - // a clear "no changes" indication instead of a bare empty string. - let stdout = if diff_output.trim().is_empty() { - "No differences found.".to_string() - } else { - diff_output - }; - - Ok(json!({ - "success": true, - "exit_code": 0, - "stdout": stdout, - "stderr": "" - })) - } - - /// Execute log through native Git so every accepted argument keeps its - /// documented Git meaning. In particular, `--since`/`--until` are - /// approxidate filters rather than commit refs, and flags such as `--all`, - /// `--author-date-order`, `--format`, and `--date` are not discarded. - async fn execute_log(repo_path: &str, args: Option<&str>) -> BitFunResult { - let log_args = Self::build_log_cli_args(args.unwrap_or("")); - let mut command_args = Vec::with_capacity(log_args.len() + 1); - command_args.push("log".to_string()); - command_args.extend(log_args); - let command_arg_refs = command_args.iter().map(String::as_str).collect::>(); - let raw = execute_git_command_raw(repo_path, &command_arg_refs) - .await - .map_err(|e| BitFunError::tool(format!("Git log failed: {e}")))?; - let rendered_args = command_args[1..] - .iter() - .map(|arg| Self::sh_quote(arg)) - .collect::>() - .join(" "); - - Ok(json!({ - "success": raw.exit_code == 0, - "exit_code": raw.exit_code, - "stdout": raw.stdout, - "stderr": raw.stderr, - "command": format!("git log {rendered_args}") - })) - } - - /// Execute add operation using GitService - async fn execute_add(repo_path: &str, args: Option<&str>) -> BitFunResult { - let args_str = args.unwrap_or("."); - let tokens = Self::tokenize_args(args_str); - let all = Self::tokens_contain_flag(&tokens, Some("-A"), Some("--all")); - let update = Self::tokens_contain_flag(&tokens, Some("-u"), Some("--update")); - - let files: Vec = if all || update { - vec![] - } else { - Self::positional_tokens(&tokens) - .iter() - .map(|value| value.to_string()) - .collect() - }; - - let params = GitAddParams { - files, - all: Some(all), - update: Some(update), - }; - - let result = GitService::add_files(repo_path, params) - .await - .map_err(|e| BitFunError::tool(format!("Git add failed: {}", e)))?; - - Ok(json!({ - "success": result.success, - "exit_code": if result.success { 0 } else { 1 }, - "stdout": result.output.unwrap_or_default(), - "stderr": result.error.unwrap_or_default(), - "execution_time_ms": result.duration - })) - } - - /// Execute commit operation using GitService - async fn execute_commit(repo_path: &str, args: Option<&str>) -> BitFunResult { - let parsed = Self::parse_commit_args(args.unwrap_or("")); - - if parsed.message_parts.is_empty() { - return Err(BitFunError::tool( - "Commit message is required (-m \"message\")".to_string(), - )); - } - - let params = GitCommitParams { - message: parsed.message_parts.join("\n\n"), - amend: Some(parsed.amend), - all: Some(parsed.all), - no_verify: Some(parsed.no_verify), - author: None, - }; - - let result = GitService::commit(repo_path, params) - .await - .map_err(|e| BitFunError::tool(format!("Git commit failed: {}", e)))?; - - Ok(json!({ - "success": result.success, - "exit_code": if result.success { 0 } else { 1 }, - "stdout": result.output.unwrap_or_default(), - "stderr": result.error.unwrap_or_default(), - "execution_time_ms": result.duration - })) - } - - /// Execute push operation using GitService - async fn execute_push(repo_path: &str, args: Option<&str>) -> BitFunResult { - let tokens = Self::tokenize_args(args.unwrap_or("")); - let parts = Self::positional_tokens(&tokens); - - let params = GitPushParams { - remote: parts.first().map(|s| s.to_string()), - branch: parts.get(1).map(|s| s.to_string()), - force: Some(Self::tokens_contain_flag( - &tokens, - Some("-f"), - Some("--force"), - )), - set_upstream: Some(Self::tokens_contain_flag( - &tokens, - Some("-u"), - Some("--set-upstream"), - )), - }; - - let result = GitService::push(repo_path, params) - .await - .map_err(|e| BitFunError::tool(format!("Git push failed: {}", e)))?; - - Ok(json!({ - "success": result.success, - "exit_code": if result.success { 0 } else { 1 }, - "stdout": result.output.unwrap_or_default(), - "stderr": result.error.unwrap_or_default(), - "execution_time_ms": result.duration - })) - } - - /// Execute pull operation using GitService - async fn execute_pull(repo_path: &str, args: Option<&str>) -> BitFunResult { - let tokens = Self::tokenize_args(args.unwrap_or("")); - let parts = Self::positional_tokens(&tokens); - - let params = GitPullParams { - remote: parts.first().map(|s| s.to_string()), - branch: parts.get(1).map(|s| s.to_string()), - rebase: Some(Self::tokens_contain_flag(&tokens, None, Some("--rebase"))), - }; - - let result = GitService::pull(repo_path, params) - .await - .map_err(|e| BitFunError::tool(format!("Git pull failed: {}", e)))?; - - Ok(json!({ - "success": result.success, - "exit_code": if result.success { 0 } else { 1 }, - "stdout": result.output.unwrap_or_default(), - "stderr": result.error.unwrap_or_default(), - "execution_time_ms": result.duration - })) - } - - /// Execute checkout/switch operation using GitService - async fn execute_checkout( - repo_path: &str, - operation: &str, - args: Option<&str>, - ) -> BitFunResult { - let args_str = args.unwrap_or("").trim(); - - let plan = Self::plan_checkout(operation, args_str); - if plan == CheckoutPlan::Passthrough { - return Self::execute_generic(repo_path, operation, Some(args_str)).await; - } - - let result = match &plan { - CheckoutPlan::Checkout { branch } => { - GitService::checkout_branch(repo_path, branch).await - } - CheckoutPlan::Create { - branch, - start_point, - } => GitService::create_branch(repo_path, branch, start_point.as_deref()).await, - CheckoutPlan::Passthrough => unreachable!("passthrough returned above"), - } - .map_err(|e| BitFunError::tool(format!("Git checkout failed: {}", e)))?; - - Ok(json!({ - "success": result.success, - "exit_code": if result.success { 0 } else { 1 }, - "stdout": result.output.unwrap_or_default(), - "stderr": result.error.unwrap_or_default(), - "execution_time_ms": result.duration - })) - } - - /// Execute branch operation using GitService - async fn execute_branch(repo_path: &str, args: Option<&str>) -> BitFunResult { - let args_str = args.unwrap_or(""); - let tokens = Self::tokenize_args(args_str); - - // Check if it's a list branches operation - let is_list = args_str.trim().is_empty() - || Self::tokens_contain_flag(&tokens, Some("-l"), Some("--list")) - || Self::tokens_contain_flag(&tokens, Some("-a"), Some("--all")) - || Self::tokens_contain_flag(&tokens, Some("-r"), Some("--remotes")); - - if is_list { - let include_remote = Self::tokens_contain_flag(&tokens, Some("-a"), Some("--all")) - || Self::tokens_contain_flag(&tokens, Some("-r"), Some("--remotes")); - let branches = GitService::get_branches(repo_path, include_remote) - .await - .map_err(|e| BitFunError::tool(format!("Git branch failed: {}", e)))?; - - let output: Vec = branches - .iter() - .map(|b| { - if b.current { - format!("* {}", b.name) - } else { - format!(" {}", b.name) - } - }) - .collect(); - - Ok(json!({ - "success": true, - "exit_code": 0, - "stdout": output.join("\n"), - "stderr": "", - "data": branches - })) - } else if Self::tokens_contain_flag(&tokens, Some("-d"), Some("--delete")) - || Self::tokens_contain_flag(&tokens, Some("-D"), None) - { - // Delete branch - let force = Self::tokens_contain_flag(&tokens, Some("-D"), None) - || Self::tokens_contain_flag(&tokens, None, Some("--force")); - let branch_name = Self::positional_tokens(&tokens) - .first() - .copied() - .ok_or_else(|| { - BitFunError::tool("Branch name is required for deletion".to_string()) - })?; - - let result = GitService::delete_branch(repo_path, branch_name, force) - .await - .map_err(|e| BitFunError::tool(format!("Git branch delete failed: {}", e)))?; - - Ok(json!({ - "success": result.success, - "exit_code": if result.success { 0 } else { 1 }, - "stdout": result.output.unwrap_or_default(), - "stderr": result.error.unwrap_or_default() - })) - } else { - // Create new branch (without switching) - use original command - let mut cmd_args: Vec<&str> = vec!["branch"]; - for token in &tokens { - cmd_args.push(token.as_str()); - } - - let output = execute_git_command(repo_path, &cmd_args) - .await - .map_err(|e| BitFunError::tool(format!("Git branch failed: {}", e)))?; - - Ok(json!({ - "success": true, - "exit_code": 0, - "stdout": output, - "stderr": "" - })) - } - } - - /// Execute other Git operations using generic command - async fn execute_generic( - repo_path: &str, - operation: &str, - args: Option<&str>, - ) -> BitFunResult { - let tokens = Self::tokenize_args(args.unwrap_or("")); - let mut cmd_args: Vec<&str> = vec![operation]; - for token in &tokens { - cmd_args.push(token.as_str()); - } - - let start_time = std::time::Instant::now(); - - // Use raw execution so we can distinguish git diff exit code 1 (has differences) - // from actual errors. - match execute_git_command_raw(repo_path, &cmd_args).await { - Ok(raw) => { - let duration = elapsed_ms_u64(start_time); - - // git diff returns exit code 1 when there are differences, which is not an error. - // Other commands may also use exit code 1 for non-error conditions (e.g. grep with no matches). - // We treat exit code 0 and exit code 1 with non-empty stdout as success, - // but exit code >1 or exit code 1 with empty stdout and non-empty stderr as failure. - let is_diff_like = operation == "diff"; - let success = raw.exit_code == 0 - || is_diff_like && raw.exit_code == 1 && !raw.stdout.is_empty(); - - Ok(json!({ - "success": success, - "exit_code": raw.exit_code, - "stdout": raw.stdout, - "stderr": raw.stderr, - "execution_time_ms": duration - })) - } - Err(e) => { - let duration = elapsed_ms_u64(start_time); - Ok(json!({ - "success": false, - "exit_code": -1, - "stdout": "", - "stderr": e.to_string(), - "execution_time_ms": duration - })) - } - } - } -} - -#[async_trait] -impl Tool for GitTool { - fn name(&self) -> &str { - "Git" - } - - async fn description(&self) -> BitFunResult { - Ok(r#"Executes Git commands for version control operations. - -This tool provides a safe and convenient way to execute Git commands. It supports common Git operations like status, diff, log, add, commit, branch, checkout, pull, push, and more. - -If this definition was returned by `GetToolSpec`, execute it through `CallDeferredTool` with `tool_name` set to `Git` and put the arguments matching the schema below inside `args`. If Git is directly exposed in the available tool list, call it directly instead. - -## Supported Operations - -- **status**: Show working tree status -- **diff**: Show changes between commits, commit and working tree, etc. -- **log**: Show commit logs -- **add**: Add file contents to the index -- **commit**: Record changes to the repository -- **branch**: List, create, or delete branches -- **checkout/switch**: Switch branches or restore working tree files -- **pull**: Fetch from and integrate with another repository or a local branch -- **push**: Update remote refs along with associated objects -- **fetch**: Download objects and refs from another repository -- **merge**: Join two or more development histories together -- **rebase**: Reapply commits on top of another base tip -- **stash**: Stash the changes in a dirty working directory away -- **reset**: Reset current HEAD to the specified state -- **restore**: Restore working tree files -- **show**: Show various types of objects -- **tag**: Create, list, delete or verify a tag object -- **remote**: Manage set of tracked repositories -- **clone**: Clone a repository into a new directory -- **init**: Create an empty Git repository -- **blame**: Show what revision and author last modified each line -- **cherry-pick**: Apply the changes introduced by some existing commits -- **var**: Read Git identity and other Git variables - -## Usage Examples - -1. Check status: - ```json - {"operation": "status"} - ``` - -2. View diff of staged changes: - ```json - {"operation": "diff", "args": "--staged"} - ``` - -3. View recent commits: - ```json - {"operation": "log", "args": "--oneline -10"} - ``` - -4. Add files: - ```json - {"operation": "add", "args": "."} - ``` - -5. Commit with message: - ```json - {"operation": "commit", "args": "-m \"Your commit message\""} - ``` - -6. Create a new branch: - ```json - {"operation": "branch", "args": "feature/new-feature"} - ``` - -7. Switch to a branch: - ```json - {"operation": "switch", "args": "main"} - ``` - -8. Inspect work authored today: - ```json - {"operation": "var", "args": "GIT_AUTHOR_IDENT"} - {"operation": "status"} - {"operation": "log", "args": "--since=midnight --date=iso-local --format=\"%h%x09%ad%x09%an%x09%ae%x09%s\" --stat -50"} - ``` - -## Important: Input Shape - -- **Preferred format:** always send a JSON object with top-level `operation` plus optional `args`. -- `operation` is the bare Git subcommand (`status`, `diff`, `log`, `add`, `commit`, ...). -- `args` contains only flags, refs, paths, or commit-message text for that subcommand. -- **Do NOT repeat the subcommand in `args`.** Example: `{"operation": "diff", "args": "HEAD~2..HEAD --stat"}` — not `{"operation": "diff", "args": "diff HEAD~2..HEAD --stat"}`. -- `log` uses native Git argument semantics. Approxidates such as `--since=midnight`, `--since=today`, `--after="1 day ago"`, and matching `--until`/`--before` forms are date filters, not revision names. -- `log` adds a 50-commit safety bound only when no `-n`/`--max-count` option is provided. -- Prefer this tool over Bash for Git subcommands when `Git` is available. Bash is still fine for shell pipelines, hooks, or commands that combine Git with other tools. -- Common shell-style mistakes (`"git status"`, `{"command": "git status"}`, or `{"args": "log --oneline -10"}`) are auto-normalized when possible, but the canonical `{operation, args?}` shape above is more reliable. - -## Time-Scoped Inspection - -- For requests such as "what did I change today?", check both the working tree (`status`) and commits since local midnight on the current branch. Use `var GIT_AUTHOR_IDENT` when authorship matters, then filter or interpret the log using the verified identity. -- Prefer relative Git dates such as `--since=midnight`; do not guess the current calendar date. -- Do not add `--all` unless the user asks for activity across branches or refs. `--all` can include other authors, merged histories, and duplicate logical work. -- Keep uncommitted changes, authored commits, merged commits, and upstream changes distinct. A clean working tree only means there are no current uncommitted changes. -- Do not report a user's commit count or cumulative diff from an unfiltered multi-author/all-ref range. If a range includes merges or upstream work, qualify the scope explicitly. - -## Safety Notes - -- This tool validates operations to ensure only allowed Git commands are executed -- Dangerous operations (like `push --force`, `reset --hard`) will show warnings -- Never run `git config` to modify user settings -- Always verify changes before committing - - Use `--dry-run` for push/pull operations when unsure - -## Remote SSH - -When the workspace is opened over Remote SSH, Git runs on the **server** (see tool description context at runtime). - -## Commit Message Guidelines - -When creating commits, use this format for the commit message: -- Start with a concise summary, preferably 50 characters or less -- Leave a blank line after the summary when adding a body -- Add a body only when it helps explain the rationale, scope, or verification -- Do not add generated-by or co-author footers unless the user or repository convention asks for them"#.to_string()) - } - - async fn description_with_context( - &self, - context: Option<&ToolUseContext>, - ) -> BitFunResult { - let mut base = self.description().await?; - if context.map(|c| c.is_remote()).unwrap_or(false) { - base.push_str( - "\n\n**Remote workspace:** Commands execute on the **SSH host** via `git -C …`, using the same repository and Git install as a native terminal on that server (equivalent to Claude Code / CLI on the remote machine). Paths are POSIX paths on the server.", - ); - } - Ok(base) - } - - fn short_description(&self) -> String { - "Inspect and operate on the Git repository; load with GetToolSpec before deferred execution.".to_string() - } - - fn default_exposure(&self) -> ToolExposure { - ToolExposure::Deferred - } - - fn input_schema(&self) -> Value { - json!({ - "type": "object", - "properties": { - "operation": { - "type": "string", - "description": "Git subcommand to run. Use the bare subcommand only, such as \"status\", \"diff\", \"log\", \"add\", or \"commit\". Do not prefix with \"git\" and do not put the subcommand in args.", - "enum": ALLOWED_OPERATIONS - }, - "args": { - "type": "string", - "description": "Optional extra arguments for the selected operation: flags, refs, commit messages, or file paths. Examples: \"--staged\", \"--oneline --since=midnight -10\", \"-m \\\"message\\\"\", or \"-- src/file.rs\". Git log arguments retain native Git semantics, including relative date filters. Do not include \"git\" or repeat the operation/subcommand here." - }, - "working_directory": { - "type": "string", - "description": "Optional directory to run the Git command in. Omit to use the current workspace. If provided, use a workspace-relative path or an absolute path inside the current workspace; never use placeholder paths such as /workspace." - } - }, - "required": ["operation"], - "additionalProperties": false - }) - } - - fn is_readonly(&self) -> bool { - false - } - - fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool { - false - } - - fn permission_intents( - &self, - input: &Value, - _context: &ToolUseContext, - ) -> BitFunResult> { - let normalized = Self::normalize_git_input(input.clone()); - let operation = normalized - .get("operation") - .and_then(Value::as_str) - .ok_or_else(|| BitFunError::validation("operation is required".to_string()))?; - let args = normalized - .get("args") - .and_then(Value::as_str) - .map(str::trim) - .filter(|args| !args.is_empty()); - let resource = match args { - Some(args) => format!("git {operation} {args}"), - None => format!("git {operation}"), - }; - Ok(vec![PermissionIntent::new("git", vec![resource])]) - } - - async fn validate_input( - &self, - input: &Value, - context: Option<&ToolUseContext>, - ) -> ValidationResult { - let input = &Self::normalize_git_input(input.clone()); - - // Validate operation parameter - let operation = match input.get("operation").and_then(|v| v.as_str()) { - Some(op) => op, - None => { - return ValidationResult { - result: false, - message: Some( - "Could not determine Git operation. Send {\"operation\":\"status\"} (preferred) or a repairable shell-style payload such as {\"command\":\"git status\"} or {\"args\":\"log --oneline -10\"}." - .to_string(), - ), - error_code: Some(400), - meta: None, - }; - } - }; - - // Check if operation is allowed - if !ALLOWED_OPERATIONS.contains(&operation) { - return ValidationResult { - result: false, - message: Some(format!( - "Operation '{}' is not allowed. Allowed operations: {}", - operation, - ALLOWED_OPERATIONS.join(", ") - )), - error_code: Some(403), - meta: None, - }; - } - - // Get arguments (if any) - let args = input.get("args").and_then(|v| v.as_str()).unwrap_or(""); - - if let Some(context) = context { - if let Some(rejection) = - crate::agentic::execution::edit_constraint_guard::check_git_command( - context, operation, args, - ) - { - return rejection; - } - } - - // Security check: prohibit interactive operations. Match whole tokens - // only so text like "fix-ui" or a quoted message cannot false-trip. - let arg_tokens = Self::tokenize_args(args); - if Self::tokens_contain_flag(&arg_tokens, Some("-i"), Some("--interactive")) { - return ValidationResult { - result: false, - message: Some("Interactive mode (-i) is not supported".to_string()), - error_code: Some(400), - meta: None, - }; - } - - // Check if operation is dangerous, add warning message - if Self::is_dangerous_operation(operation, args) { - return ValidationResult { - result: true, - message: Some(format!( - "Warning: This is a potentially dangerous operation: git {} {}", - operation, args - )), - error_code: None, - meta: Some(json!({ "warning": "dangerous_operation" })), - }; - } - - ValidationResult { - result: true, - message: None, - error_code: None, - meta: None, - } - } - - fn render_tool_use_message(&self, input: &Value, _options: &ToolRenderOptions) -> String { - let operation = input - .get("operation") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - let args = input.get("args").and_then(|v| v.as_str()).unwrap_or(""); - - if args.is_empty() { - format!("git {}", operation) - } else { - format!("git {} {}", operation, args) - } - } - - fn render_result_for_assistant(&self, output: &Value) -> String { - let stdout = output - .get("stdout") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim(); - let stderr = output - .get("stderr") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim(); - let exit_code = output - .get("exit_code") - .and_then(|v| v.as_i64()) - .unwrap_or(0) as i32; - let command = output.get("command").and_then(|v| v.as_str()).unwrap_or(""); - - let mut result_parts = Vec::new(); - - // Command execution information - if !command.is_empty() { - result_parts.push(format!("$ {}", command)); - } - - // Main output content - if !stdout.is_empty() { - result_parts.push(stdout.to_string()); - } - - // Error output - if !stderr.is_empty() { - result_parts.push(stderr.to_string()); - } - - // Exit status - if exit_code != 0 { - result_parts.push(format!("\n[Exit code: {} - command failed]", exit_code)); - } - - if result_parts.is_empty() { - "(no output)".to_string() - } else { - result_parts.join("\n") - } - } - - async fn call_impl( - &self, - input: &Value, - context: &ToolUseContext, - ) -> BitFunResult> { - let input = &Self::normalize_git_input(input.clone()); - - let operation = input - .get("operation") - .and_then(|v| v.as_str()) - .ok_or_else(|| BitFunError::tool("operation is required".to_string()))?; - - let args = input.get("args").and_then(|v| v.as_str()); - - // Tolerance: strip a leading operation name from args if the model - // mistakenly includes it (e.g. "diff HEAD~2..HEAD --stat" when - // operation is already "diff"). This prevents commands like - // "git diff diff HEAD~2..HEAD --stat". - let args = args.map(|a| { - let trimmed = a.trim(); - let prefix = format!("{} ", operation); - if trimmed.starts_with(&prefix) { - &trimmed[prefix.len()..] - } else { - trimmed - } - }); - - let working_directory = input.get("working_directory").and_then(|v| v.as_str()); - - // Get repository path - let repo_path = Self::get_repo_path(working_directory, context)?; - - debug!( - "Git tool executing operation: {} in repository: {}, args: {}", - operation, - repo_path, - args.unwrap_or("") - ); - - if git_operation_needs_light_checkpoint(operation, args) { - context - .record_light_checkpoint( - "Git", - &format!("git {} {}", operation, args.unwrap_or("").trim()), - Vec::new(), - ) - .await?; - } - - let start_time = std::time::Instant::now(); - - // Remote SSH workspace: run git on the server (not libgit2 on the PC). - let result = if context.is_remote() { - Self::execute_remote_git_cli(&repo_path, operation, args, context).await? - } else { - match operation { - "status" => Self::execute_status(&repo_path).await?, - "diff" => Self::execute_diff(&repo_path, args).await?, - "log" => Self::execute_log(&repo_path, args).await?, - "add" => Self::execute_add(&repo_path, args).await?, - "commit" => Self::execute_commit(&repo_path, args).await?, - "push" => Self::execute_push(&repo_path, args).await?, - "pull" => Self::execute_pull(&repo_path, args).await?, - "checkout" | "switch" => { - Self::execute_checkout(&repo_path, operation, args).await? - } - "branch" => Self::execute_branch(&repo_path, args).await?, - _ => Self::execute_generic(&repo_path, operation, args).await?, - } - }; - - let duration = start_time.elapsed(); - debug!( - "Git tool command completed, operation: {}, duration: {}ms", - operation, - duration.as_millis() - ); - - // Add execution time and command information - let mut result_with_meta = result.clone(); - if let Some(obj) = result_with_meta.as_object_mut() { - obj.insert( - "execution_time_ms".to_string(), - json!(duration.as_millis() as u64), - ); - if !context.is_remote() { - obj.entry("command".to_string()) - .or_insert_with(|| json!(format!("git {} {}", operation, args.unwrap_or("")))); - } - obj.insert("operation".to_string(), json!(operation)); - obj.insert("working_directory".to_string(), json!(repo_path)); - } - - // Build result for assistant - let result_for_assistant = self.render_result_for_assistant(&result_with_meta); - - Ok(vec![ToolResult::Result { - data: result_with_meta, - result_for_assistant: Some(result_for_assistant), - image_attachments: None, - }]) - } -} - -fn git_operation_needs_light_checkpoint(operation: &str, args: Option<&str>) -> bool { - match operation { - "add" | "commit" | "pull" | "checkout" | "switch" | "merge" | "rebase" | "stash" - | "reset" | "restore" | "clean" | "cherry-pick" => true, - "branch" => args.is_some_and(|value| !value.trim().is_empty()), - _ => false, - } -} - -impl Default for GitTool { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use crate::agentic::tools::framework::Tool; - - use super::{git_operation_needs_light_checkpoint, CheckoutPlan, GitTool, ParsedDiffArgs}; - use serde_json::json; - use std::{fs, path::Path}; - - fn git(root: &Path, args: &[&str], commit_date: Option<&str>) { - let mut command = crate::util::create_test_command("git"); - command.current_dir(root).args(args); - if let Some(commit_date) = commit_date { - command - .env("GIT_AUTHOR_DATE", commit_date) - .env("GIT_COMMITTER_DATE", commit_date); - } - let output = command.output().expect("git should be available for tests"); - assert!( - output.status.success(), - "git {:?} failed: {}", - args, - String::from_utf8_lossy(&output.stderr) - ); - } - - fn commit_file(root: &Path, contents: &str, message: &str, commit_date: &str) { - fs::write(root.join("tracked.txt"), contents).expect("fixture should be written"); - git(root, &["add", "--", "tracked.txt"], None); - git( - root, - &[ - "-c", - "user.name=BitFun Tests", - "-c", - "user.email=bitfun@example.com", - "commit", - "-m", - message, - ], - Some(commit_date), - ); - } - - #[test] - fn tokenize_args_respects_quotes() { - assert_eq!( - GitTool::tokenize_args("-m \"hello world\" -m 'second line' plain"), - vec!["-m", "hello world", "-m", "second line", "plain"] - ); - assert_eq!( - GitTool::tokenize_args("-m \"multi\nline message\""), - vec!["-m", "multi\nline message"] - ); - assert!(GitTool::tokenize_args("").is_empty()); - } - - #[test] - fn flag_matching_ignores_substrings_inside_words() { - let tokens = GitTool::tokenize_args("-c feat/my-bitfun-pages-manager upstream/main"); - assert!(!GitTool::tokens_contain_flag(&tokens, Some("-b"), None)); - assert!(GitTool::tokens_contain_flag(&tokens, Some("-c"), None)); - - let tokens = GitTool::tokenize_args("--force-with-lease origin feat/x-f-y"); - assert!(!GitTool::tokens_contain_flag( - &tokens, - Some("-f"), - Some("--force") - )); - } - - #[test] - fn plan_checkout_switch_create_with_start_point() { - assert_eq!( - GitTool::plan_checkout("switch", "-c feat/my-bitfun-pages-manager upstream/main"), - CheckoutPlan::Create { - branch: "feat/my-bitfun-pages-manager".to_string(), - start_point: Some("upstream/main".to_string()), - } - ); - } - - #[test] - fn plan_checkout_checkout_create_and_plain_switch() { - assert_eq!( - GitTool::plan_checkout("checkout", "-b feature/new upstream/main"), - CheckoutPlan::Create { - branch: "feature/new".to_string(), - start_point: Some("upstream/main".to_string()), - } - ); - assert_eq!( - GitTool::plan_checkout("checkout", "main"), - CheckoutPlan::Checkout { - branch: "main".to_string() - } - ); - assert_eq!( - GitTool::plan_checkout("switch", "main"), - CheckoutPlan::Checkout { - branch: "main".to_string() - } - ); - } - - #[test] - fn plan_checkout_passthrough_for_less_common_shapes() { - assert_eq!( - GitTool::plan_checkout("switch", "--detach HEAD~1"), - CheckoutPlan::Passthrough - ); - assert_eq!( - GitTool::plan_checkout("switch", "-C rebuilt"), - CheckoutPlan::Passthrough - ); - assert_eq!( - GitTool::plan_checkout("checkout", "-B rebuilt HEAD"), - CheckoutPlan::Passthrough - ); - assert_eq!( - GitTool::plan_checkout("checkout", "-"), - CheckoutPlan::Passthrough - ); - assert_eq!( - GitTool::plan_checkout("checkout", "--orphan fresh"), - CheckoutPlan::Passthrough - ); - assert_eq!( - GitTool::plan_checkout("switch", ""), - CheckoutPlan::Passthrough - ); - } - - #[test] - fn parse_commit_args_collects_messages_and_flags() { - let parsed = GitTool::parse_commit_args("-m \"fix: handle -a flag\" --amend"); - assert_eq!( - parsed.message_parts, - vec!["fix: handle -a flag".to_string()] - ); - assert!(parsed.amend); - assert!(!parsed.all); - - let parsed = GitTool::parse_commit_args("-m \"first\" -m 'second' -a"); - assert_eq!( - parsed.message_parts, - vec!["first".to_string(), "second".to_string()] - ); - assert!(parsed.all); - - let parsed = GitTool::parse_commit_args("--message=title --no-verify"); - assert_eq!(parsed.message_parts, vec!["title".to_string()]); - assert!(parsed.no_verify); - - let parsed = GitTool::parse_commit_args("--amend"); - assert!(parsed.message_parts.is_empty()); - } - - #[test] - fn build_log_cli_args_preserves_native_flags_and_adds_only_a_default_bound() { - assert_eq!( - GitTool::build_log_cli_args("--oneline -10"), - vec!["--oneline", "-10"] - ); - assert_eq!( - GitTool::build_log_cli_args("-n 25 --since=midnight --author-date-order --all"), - vec![ - "-n", - "25", - "--since=midnight", - "--author-date-order", - "--all" - ] - ); - assert_eq!( - GitTool::build_log_cli_args("--max-count=7 --until \"1 day ago\""), - vec!["--max-count=7", "--until", "1 day ago"] - ); - assert_eq!( - GitTool::build_log_cli_args("--after=2026-05-02 --oneline"), - vec!["--max-count=50", "--after=2026-05-02", "--oneline"] - ); - assert_eq!(GitTool::build_log_cli_args("-n5"), vec!["-n5"]); - } - - #[tokio::test] - async fn execute_log_applies_date_filters_instead_of_treating_them_as_refs() { - let directory = tempfile::tempdir().expect("temporary repository should be created"); - git(directory.path(), &["init"], None); - commit_file( - directory.path(), - "old\n", - "old commit", - "2020-01-01T00:00:00Z", - ); - commit_file( - directory.path(), - "new\n", - "new commit", - "2030-01-01T00:00:00Z", - ); - - let output = GitTool::execute_log( - directory.path().to_str().expect("UTF-8 path"), - Some("--after=2025-01-01 --before=2035-01-01 --author-date-order --all --format=%s"), - ) - .await - .expect("git log should execute"); - - assert_eq!(output["success"], true); - assert_eq!(output["stdout"], "new commit\n"); - assert!(output["command"] - .as_str() - .expect("command should be present") - .contains("'--after=2025-01-01'")); - } - - #[tokio::test] - async fn validate_input_flags_interactive_only_as_whole_token() { - let tool = GitTool::new(); - let allowed = tool - .validate_input( - &json!({"operation": "commit", "args": "-m \"fix-ui: adjust spacing\""}), - None, - ) - .await; - assert!(allowed.result); - - let blocked = tool - .validate_input(&json!({"operation": "rebase", "args": "-i HEAD~3"}), None) - .await; - assert!(!blocked.result); - } - - #[test] - fn parsed_diff_args_default_is_empty_and_unset() { - assert_eq!( - ParsedDiffArgs::default(), - ParsedDiffArgs { - staged: false, - stat: false, - source: None, - target: None, - files: None, - } - ); - } - - #[tokio::test] - async fn git_schema_requires_explicit_operation_instead_of_args_only() { - let tool = GitTool::new(); - let schema = tool.input_schema(); - assert_eq!(schema["additionalProperties"], false); - assert_eq!(schema["required"], json!(["operation"])); - assert!(schema["properties"]["operation"]["description"] - .as_str() - .unwrap() - .contains("Do not prefix with \"git\"")); - assert!(schema["properties"]["args"]["description"] - .as_str() - .unwrap() - .contains("Do not include \"git\" or repeat the operation")); - assert!(schema["properties"]["args"]["description"] - .as_str() - .unwrap() - .contains("relative date filters")); - let description = tool.description().await.expect("description should render"); - assert!(description.contains("do not guess the current calendar date")); - assert!(description.contains("Do not add `--all`")); - - let validation = tool - .validate_input(&json!({"args": "--since=\"2026-05-02\" --oneline"}), None) - .await; - assert!(validation.result); - - let validation = tool - .validate_input(&json!({"args": "log --oneline -10"}), None) - .await; - assert!(validation.result); - - let validation = tool - .validate_input(&json!({"command": "git status"}), None) - .await; - assert!(validation.result); - - let validation = tool.validate_input(&json!("git diff --staged"), None).await; - assert!(validation.result); - - let validation = tool.validate_input(&json!({"args": "--stat"}), None).await; - assert!(validation.result); - } - - #[test] - fn normalize_git_input_repairs_common_malformed_payloads() { - assert_eq!( - GitTool::normalize_git_input(json!("git status")), - json!({"operation": "status"}) - ); - assert_eq!( - GitTool::normalize_git_input(json!({"command": "git diff --staged"})), - json!({"operation": "diff", "args": "--staged"}) - ); - assert_eq!( - GitTool::normalize_git_input(json!({"args": "log --oneline -10"})), - json!({"operation": "log", "args": "--oneline -10"}) - ); - assert_eq!( - GitTool::normalize_git_input(json!({"args": "--since=\"2026-05-02\" --oneline"})), - json!({ - "operation": "log", - "args": "--since=\"2026-05-02\" --oneline" - }) - ); - assert_eq!( - GitTool::normalize_git_input(json!({"operation": "status"})), - json!({"operation": "status"}) - ); - } - - #[test] - fn checkpoint_detection_flags_mutating_git_operations() { - assert!(git_operation_needs_light_checkpoint( - "checkout", - Some("main") - )); - assert!(git_operation_needs_light_checkpoint( - "reset", - Some("--hard HEAD") - )); - assert!(git_operation_needs_light_checkpoint( - "branch", - Some("-D old") - )); - assert!(!git_operation_needs_light_checkpoint("status", None)); - assert!(!git_operation_needs_light_checkpoint( - "diff", - Some("-- src/lib.rs") - )); - assert!(!git_operation_needs_light_checkpoint("branch", None)); - } - - #[test] - fn parse_diff_args_empty() { - let r = GitTool::parse_diff_args(""); - assert_eq!( - r, - ParsedDiffArgs { - staged: false, - stat: false, - source: None, - target: None, - files: None, - } - ); - } - - #[test] - fn parse_diff_args_staged_only() { - let r = GitTool::parse_diff_args("--staged"); - assert_eq!( - r, - ParsedDiffArgs { - staged: true, - stat: false, - source: None, - target: None, - files: None, - } - ); - } - - #[test] - fn parse_diff_args_cached_and_stat() { - let r = GitTool::parse_diff_args("--cached --stat"); - assert_eq!( - r, - ParsedDiffArgs { - staged: true, - stat: true, - source: None, - target: None, - files: None, - } - ); - } - - #[test] - fn parse_diff_args_single_ref() { - let r = GitTool::parse_diff_args("HEAD"); - assert_eq!( - r, - ParsedDiffArgs { - staged: false, - stat: false, - source: Some("HEAD".to_string()), - target: None, - files: None, - } - ); - } - - #[test] - fn parse_diff_args_single_ref_with_stat() { - let r = GitTool::parse_diff_args("HEAD --stat"); - assert_eq!( - r, - ParsedDiffArgs { - staged: false, - stat: true, - source: Some("HEAD".to_string()), - target: None, - files: None, - } - ); - } - - #[test] - fn parse_diff_args_range_two_dot() { - let r = GitTool::parse_diff_args("HEAD~7..HEAD --stat"); - assert_eq!( - r, - ParsedDiffArgs { - staged: false, - stat: true, - source: Some("HEAD~7".to_string()), - target: Some("HEAD".to_string()), - files: None, - } - ); - } - - #[test] - fn parse_diff_args_range_three_dot() { - let r = GitTool::parse_diff_args("origin/main...HEAD"); - assert_eq!( - r, - ParsedDiffArgs { - staged: false, - stat: false, - source: Some("origin/main".to_string()), - target: Some("HEAD".to_string()), - files: None, - } - ); - } - - #[test] - fn parse_diff_args_range_with_files() { - let r = GitTool::parse_diff_args("HEAD~7..HEAD --stat -- src/foo.rs src/bar.rs"); - assert_eq!( - r, - ParsedDiffArgs { - staged: false, - stat: true, - source: Some("HEAD~7".to_string()), - target: Some("HEAD".to_string()), - files: Some(vec!["src/foo.rs".to_string(), "src/bar.rs".to_string()]), - } - ); - } - - #[test] - fn parse_diff_args_single_ref_with_files() { - let r = GitTool::parse_diff_args("HEAD -- src/foo.rs"); - assert_eq!( - r, - ParsedDiffArgs { - staged: false, - stat: false, - source: Some("HEAD".to_string()), - target: None, - files: Some(vec!["src/foo.rs".to_string()]), - } - ); - } - - #[test] - fn parse_diff_args_files_only() { - let r = GitTool::parse_diff_args("-- -- src/foo.rs"); - assert_eq!( - r, - ParsedDiffArgs { - staged: false, - stat: false, - source: None, - target: None, - files: Some(vec!["src/foo.rs".to_string()]), - } - ); - } - - #[test] - fn parse_diff_args_multi_token_range() { - let r = GitTool::parse_diff_args("feature/foo..main"); - assert_eq!( - r, - ParsedDiffArgs { - staged: false, - stat: false, - source: Some("feature/foo".to_string()), - target: Some("main".to_string()), - files: None, - } - ); - } -} diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs b/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs index 6fa029f33c..ccd7221deb 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs @@ -8,7 +8,6 @@ pub mod analyze_image_tool; #[cfg(feature = "tools-miniapp")] pub mod appearance_publish_tool; pub mod ask_user_question_tool; -pub mod bash_tool; pub mod bitfun_control_tool; #[cfg(feature = "tools-canvas")] pub mod canvas_tools; @@ -37,8 +36,6 @@ pub mod generative_ui_tool; #[cfg(feature = "tools-git")] pub mod get_file_diff_tool; pub mod get_time_tool; -#[cfg(feature = "tools-git")] -pub mod git_tool; pub mod glob_tool; pub mod grep_tool; pub mod list_models_tool; @@ -67,7 +64,6 @@ pub mod session_message_tool; pub mod skill_tool; pub mod skills; pub mod task; -pub mod terminal_control_tool; pub mod thread_goal_tools; pub mod todo_write_tool; pub mod util; @@ -87,7 +83,6 @@ pub use analyze_image_tool::AnalyzeImageTool; #[cfg(feature = "tools-miniapp")] pub use appearance_publish_tool::PublishAppearanceTool; pub use ask_user_question_tool::AskUserQuestionTool; -pub use bash_tool::BashTool; pub use bitfun_control_tool::BitFunControlTool; #[cfg(feature = "tools-canvas")] pub use canvas_tools::{CreateCanvasTool, PatchCanvasTool, ReadCanvasTool, UpdateCanvasTool}; @@ -110,8 +105,6 @@ pub use generative_ui_tool::GenerativeUITool; #[cfg(feature = "tools-git")] pub use get_file_diff_tool::GetFileDiffTool; pub use get_time_tool::GetTimeTool; -#[cfg(feature = "tools-git")] -pub use git_tool::GitTool; pub use glob_tool::GlobTool; pub use grep_tool::GrepTool; pub use list_models_tool::ListModelsTool; @@ -143,7 +136,6 @@ pub use skill_tool::SkillTool; pub use task::{ AgentInterruptTool, AgentSendInputTool, AgentSpawnTool, LaunchReviewAgentTool, TaskTool, }; -pub use terminal_control_tool::TerminalControlTool; pub use thread_goal_tools::{CreateGoalTool, GetGoalTool, UpdateGoalTool}; pub use todo_write_tool::TodoWriteTool; #[cfg(feature = "tools-image-analysis")] diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/review_platform_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/review_platform_tool.rs index 690c8b30bb..104df77f96 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/review_platform_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/review_platform_tool.rs @@ -240,7 +240,7 @@ impl Tool for ReviewPlatformTool { async fn description(&self) -> BitFunResult { Ok(r#"Read and operate on hosted pull requests / merge requests. -Use this for remote review-platform operations such as discovering remotes, loading the workspace PR snapshot, counting pull requests, listing pull requests, opening full or paginated pull request detail, loading CI logs, creating a pull request, replying to review threads, submitting a comment review, approving, revoking approval, requesting changes, or resolving a review thread. Use the Git tool for local repository state and branch/commit/push operations. +Use this for remote review-platform operations such as discovering remotes, loading the workspace PR snapshot, counting pull requests, listing pull requests, opening full or paginated pull request detail, loading CI logs, creating a pull request, replying to review threads, submitting a comment review, approving, revoking approval, requesting changes, or resolving a review thread. Use ExecCommand for local repository state and branch/commit/push operations. GitHub authentication is owned by the local `gh` CLI and must never use token actions. Authentication-token actions are only for GitLab and GitCode when the user explicitly provides a token or asks to clear a stored token. Never guess or expose token values. diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/terminal_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/terminal_control_tool.rs deleted file mode 100644 index 30c1b78e89..0000000000 --- a/src/crates/assembly/core/src/agentic/tools/implementations/terminal_control_tool.rs +++ /dev/null @@ -1,245 +0,0 @@ -use crate::agentic::tools::framework::{ - Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, -}; -use crate::util::errors::{BitFunError, BitFunResult}; -use async_trait::async_trait; -use log::debug; -use serde_json::{json, Value}; -use terminal_core::{CloseSessionRequest, SignalRequest, TerminalApi}; - -/// TerminalControl tool - kill or interrupt a terminal session -pub struct TerminalControlTool; - -impl Default for TerminalControlTool { - fn default() -> Self { - Self::new() - } -} - -impl TerminalControlTool { - pub fn new() -> Self { - Self - } -} - -#[async_trait] -impl Tool for TerminalControlTool { - fn name(&self) -> &str { - "TerminalControl" - } - - async fn description(&self) -> BitFunResult { - Ok(r#"Control a terminal session by performing a kill or interrupt action. - -Actions: -- "kill": Permanently close a terminal session. When to use: - 1. Clean up terminals that are no longer needed (e.g., after stopping a server or when a long-running task completes). - 2. Close the persistent shell used by BashTool - if BashTool output appears clearly abnormal (e.g., garbled output, stuck prompts, corrupted shell state), use this to forcefully close the persistent shell. The next BashTool invocation will automatically create a fresh shell session. -- "interrupt": Cancel the currently running process without closing the session. - -The terminal_session_id is returned inside ... tags in BashTool results."# - .to_string()) - } - - fn short_description(&self) -> String { - "Interrupt or close a managed terminal session.".to_string() - } - - fn default_exposure(&self) -> ToolExposure { - ToolExposure::Deferred - } - - fn input_schema(&self) -> Value { - json!({ - "type": "object", - "properties": { - "terminal_session_id": { - "type": "string", - "description": "The ID of the terminal session to control." - }, - "action": { - "type": "string", - "enum": ["kill", "interrupt"], - "description": "The action to perform: 'kill' closes the session permanently; 'interrupt' cancels the running process." - } - }, - "required": ["terminal_session_id", "action"], - "additionalProperties": false - }) - } - - fn is_readonly(&self) -> bool { - false - } - - fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool { - true - } - - async fn is_available_in_context(&self, context: Option<&ToolUseContext>) -> bool { - !context.map(|ctx| ctx.is_remote()).unwrap_or(false) - } - - async fn validate_input( - &self, - input: &Value, - _context: Option<&ToolUseContext>, - ) -> ValidationResult { - if input - .get("terminal_session_id") - .and_then(|v| v.as_str()) - .is_none() - { - return ValidationResult { - result: false, - message: Some("terminal_session_id is required".to_string()), - error_code: Some(400), - meta: None, - }; - } - match input.get("action").and_then(|v| v.as_str()) { - Some("kill") | Some("interrupt") => {} - _ => { - return ValidationResult { - result: false, - message: Some("action must be one of: \"kill\", \"interrupt\"".to_string()), - error_code: Some(400), - meta: None, - }; - } - } - ValidationResult { - result: true, - message: None, - error_code: None, - meta: None, - } - } - - fn render_tool_use_message(&self, input: &Value, _options: &ToolRenderOptions) -> String { - let terminal_session_id = input - .get("terminal_session_id") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - let action = input - .get("action") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - match action { - "kill" => format!("Kill terminal session: {}", terminal_session_id), - "interrupt" => format!("Interrupt terminal session: {}", terminal_session_id), - _ => format!("Control terminal session: {}", terminal_session_id), - } - } - - async fn call_impl( - &self, - input: &Value, - _context: &ToolUseContext, - ) -> BitFunResult> { - let terminal_session_id = input - .get("terminal_session_id") - .and_then(|v| v.as_str()) - .ok_or_else(|| BitFunError::tool("terminal_session_id is required".to_string()))?; - - let action = input - .get("action") - .and_then(|v| v.as_str()) - .ok_or_else(|| BitFunError::tool("action is required".to_string()))?; - - let terminal_api = TerminalApi::from_singleton() - .map_err(|e| BitFunError::tool(format!("Terminal not initialized: {}", e)))?; - - match action { - "interrupt" => { - debug!( - "TerminalControl: sending SIGINT to session {}", - terminal_session_id - ); - - terminal_api - .signal(SignalRequest { - session_id: terminal_session_id.to_string(), - signal: "SIGINT".to_string(), - }) - .await - .map_err(|e| { - BitFunError::tool(format!("Failed to interrupt terminal session: {}", e)) - })?; - - Ok(vec![ToolResult::Result { - data: json!({ - "success": true, - "terminal_session_id": terminal_session_id, - "action": "interrupt", - }), - result_for_assistant: Some(format!( - "Sent interrupt (SIGINT) to terminal session '{}'.", - terminal_session_id - )), - image_attachments: None, - }]) - } - - "kill" => { - // Determine if this is a primary (persistent) session by checking the binding. - // For primary sessions, owner_id == terminal_session_id, so - // binding.get(terminal_session_id) returns Some(terminal_session_id) - // when the session is primary. - let binding = terminal_api.session_manager().binding(); - let is_primary = binding - .get(terminal_session_id) - .map(|bound_id| bound_id == terminal_session_id) - .unwrap_or(false); - - debug!( - "TerminalControl: killing session {}, is_primary={}", - terminal_session_id, is_primary - ); - - if is_primary { - binding.remove(terminal_session_id).await.map_err(|e| { - BitFunError::tool(format!("Failed to close terminal session: {}", e)) - })?; - } else { - terminal_api - .close_session(CloseSessionRequest { - session_id: terminal_session_id.to_string(), - immediate: Some(true), - }) - .await - .map_err(|e| { - BitFunError::tool(format!("Failed to close terminal session: {}", e)) - })?; - } - - let result_for_assistant = if is_primary { - format!( - "Terminal session '{}' has been killed. The next Bash tool call will automatically create a new persistent shell session.", - terminal_session_id - ) - } else { - format!( - "Background terminal session '{}' has been killed.", - terminal_session_id - ) - }; - - Ok(vec![ToolResult::Result { - data: json!({ - "success": true, - "terminal_session_id": terminal_session_id, - "action": "kill", - }), - result_for_assistant: Some(result_for_assistant), - image_attachments: None, - }]) - } - - _ => Err(BitFunError::tool(format!( - "Unknown action: '{}'. Must be 'kill' or 'interrupt'.", - action - ))), - } - } -} diff --git a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs index 7e8e0a21b9..4c4176a9aa 100644 --- a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs +++ b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs @@ -4698,7 +4698,7 @@ mod tests { #[test] fn error_result_preserves_full_raw_arguments_for_unparseable_calls() { - let mut task = test_tool_task("tool_1", "Git"); + let mut task = test_tool_task("tool_1", "Worktree"); task.tool_call.arguments = json!({}); task.tool_call.is_error = true; let raw_arguments = format!("{{\"operation\":\"{}", "log".repeat(512)); @@ -4730,7 +4730,7 @@ mod tests { #[test] fn error_result_omits_arguments_for_parsed_validation_errors() { - let mut task = test_tool_task("tool_1", "Git"); + let mut task = test_tool_task("tool_1", "Worktree"); task.tool_call.raw_arguments = Some(r#"{\"operation\":\"log\"}"#.to_string()); let result = build_error_execution_result( @@ -5055,8 +5055,8 @@ mod tests { image_attachments: None, }, "tool_1", - "Bash", - "Bash", + "ExecCommand", + "ExecCommand", ); let assistant_text = result.result_for_assistant.unwrap_or_default(); @@ -5130,7 +5130,7 @@ mod tests { task.context.loaded_deferred_tool_specs = vec![loaded_spec("WebFetch", 0)]; task.context.runtime_tool_restrictions = ToolRuntimeRestrictions { allowed_tool_names: ["WebFetch"].into_iter().map(str::to_string).collect(), - denied_tool_names: ["Bash"].into_iter().map(str::to_string).collect(), + denied_tool_names: ["ExecCommand"].into_iter().map(str::to_string).collect(), denied_tool_messages: Default::default(), path_policy: Default::default(), miniapp_context_scope: None, @@ -5150,7 +5150,9 @@ mod tests { assert!(context .runtime_tool_restrictions .is_tool_allowed("WebFetch")); - assert!(!context.runtime_tool_restrictions.is_tool_allowed("Bash")); + assert!(!context + .runtime_tool_restrictions + .is_tool_allowed("ExecCommand")); assert_eq!(context.custom_data["turn_index"], json!(7)); assert!(!context.custom_data.contains_key("primary_model_provider")); assert!(!context diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime.rs index fd1678389c..8a967aafe7 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime.rs @@ -25,13 +25,13 @@ use snapshot::ProductSnapshotToolWrapper; use std::sync::Arc; pub use call_deferred_tool::CallDeferredTool; +pub use catalog::{build_all_tools_info, build_tool_info, ToolInfoDto}; pub(crate) use catalog::{ product_get_tool_spec_runtime, resolve_product_get_tool_spec_results, resolve_product_readonly_enabled_tools, resolve_product_resolved_tool_manifest, resolve_product_resolved_visible_tools, ProductGetToolSpecRuntime, ProductToolCatalogProvider, }; pub use catalog::{ResolvedToolManifest, ResolvedVisibleTools}; -pub use catalog::{build_all_tools_info, build_tool_info, ToolInfoDto}; pub use get_tool_spec_tool::GetToolSpecTool; pub(crate) use loaded_spec_state::collect_product_loaded_deferred_tool_specs; @@ -291,7 +291,7 @@ mod baseline_tests { "CreateCanvas", "WebSearch", "ListMCPResources", - "Git", + "Worktree", "ComputerUse", ] { assert!( diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs index eee9121b6d..e754eba01c 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs @@ -1199,7 +1199,7 @@ mod tests { "Read".to_string(), "WebFetch".to_string(), "GetFileDiff".to_string(), - "Git".to_string(), + "Worktree".to_string(), ]; let manifest = resolve_product_resolved_tool_manifest( @@ -1215,7 +1215,7 @@ mod tests { "Read".to_string(), "WebFetch".to_string(), "GetFileDiff".to_string(), - "Git".to_string(), + "Worktree".to_string(), GET_TOOL_SPEC_TOOL_NAME.to_string(), "CallDeferredTool".to_string(), ], @@ -1226,7 +1226,7 @@ mod tests { vec![ "GetFileDiff".to_string(), "WebFetch".to_string(), - "Git".to_string() + "Worktree".to_string() ], "deferred loaded-spec list must follow product registry snapshot order" ); @@ -1240,7 +1240,7 @@ mod tests { "prompt-visible definitions must keep the current discovery insertion and policy order stable" ); - for tool_name in ["GetFileDiff", "WebFetch", "Git"] { + for tool_name in ["GetFileDiff", "WebFetch", "Worktree"] { assert!( !manifest .tool_definitions diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/loaded_spec_state.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/loaded_spec_state.rs index e5a2463ed9..58b60a566e 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime/loaded_spec_state.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/loaded_spec_state.rs @@ -166,7 +166,7 @@ mod tests { tool_name: "GetToolSpec".to_string(), effective_tool_name: None, result: json!({ - "tool_name": "Git", + "tool_name": "Worktree", "catalog_generation": 42, }), result_for_assistant: None, @@ -229,13 +229,13 @@ mod tests { &[ "WebFetch".to_string(), "GetFileDiff".to_string(), - "Git".to_string(), + "Worktree".to_string(), ], ); assert_eq!( loaded_specs, - vec![loaded_spec("Git"), loaded_spec("WebFetch")] + vec![loaded_spec("Worktree"), loaded_spec("WebFetch")] ); } @@ -247,7 +247,7 @@ mod tests { tool_name: "GetToolSpec".to_string(), effective_tool_name: None, result: json!({ - "tool_name": "Git", + "tool_name": "Worktree", "catalog_generation": 42, }), result_for_assistant: None, @@ -255,11 +255,11 @@ mod tests { duration_ms: Some(1), image_attachments: None, })], - &["Git".to_string(), "WebFetch".to_string()], + &["Worktree".to_string(), "WebFetch".to_string()], ); - assert!(state.is_loaded("Git")); + assert!(state.is_loaded("Worktree")); assert!(!state.is_loaded("WebFetch")); - assert_eq!(state.into_loaded_specs(), vec![loaded_spec("Git")]); + assert_eq!(state.into_loaded_specs(), vec![loaded_spec("Worktree")]); } } diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs index 16b278afc2..3374cbd188 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs @@ -66,7 +66,6 @@ const PRODUCT_TOOL_REGISTRATION_ORDER: &[&str] = &[ "ListMCPPrompts", "GetMCPPrompt", "GenerativeUI", - "Git", "Worktree", "ReviewPlatform", "InitMiniApp", @@ -171,8 +170,6 @@ impl StaticToolProviderFactory for ProductConcreteToolFactory { #[cfg(feature = "tools-miniapp")] "GenerativeUI" => Some(Arc::new(GenerativeUITool::new())), #[cfg(feature = "tools-git")] - "Git" => Some(Arc::new(GitTool::new())), - #[cfg(feature = "tools-git")] "Worktree" => Some(Arc::new(WorktreeTool::new())), #[cfg(feature = "tools-git")] "ReviewPlatform" => Some(Arc::new(ReviewPlatformTool::new())), diff --git a/src/crates/assembly/core/src/agentic/tools/registry.rs b/src/crates/assembly/core/src/agentic/tools/registry.rs index 21b4fde67c..75b1dd7c81 100644 --- a/src/crates/assembly/core/src/agentic/tools/registry.rs +++ b/src/crates/assembly/core/src/agentic/tools/registry.rs @@ -604,7 +604,6 @@ mod tests { "ListMCPPrompts", "GetMCPPrompt", "GenerativeUI", - "Git", "Worktree", "ReviewPlatform", "InitMiniApp", @@ -778,7 +777,6 @@ mod tests { assert!(registry.is_tool_deferred("GetFileDiff")); assert!(registry.is_tool_deferred("ListModels")); assert!(!registry.is_tool_deferred("GetToolSpec")); - assert!(registry.is_tool_deferred("Git")); assert!(registry.is_tool_deferred("Worktree")); assert!(registry.is_tool_deferred("ReviewPlatform")); assert!(!registry.is_tool_deferred("InitMiniApp")); @@ -809,7 +807,6 @@ mod tests { "ListMCPPrompts", "GetMCPPrompt", "GenerativeUI", - "Git", "Worktree", "ReviewPlatform", "ControlHub", diff --git a/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs b/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs index cc1015c31f..43fd727d90 100644 --- a/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs +++ b/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs @@ -95,8 +95,8 @@ impl ToolUseContext { self.workspace.as_ref().map(|binding| binding.root_path()) } - /// Main project root used by project-scoped orchestration tools. File, - /// terminal, and Git tools must continue to use [`Self::workspace_root`]. + /// Main project root used by project-scoped orchestration tools. File and + /// command tools must continue to use [`Self::workspace_root`]. pub fn project_workspace_root(&self) -> Option<&Path> { self.workspace .as_ref() @@ -599,8 +599,8 @@ impl ToolUseContext { )?; // Remote SSH workspaces stay contained to the opened project tree. Local desktop - // sessions may use any host path the OS user can access (Bash already has the same - // reach); optional `path_policy` roots still apply via `enforce_path_operation`. + // sessions may use any host path the OS user can access (ExecCommand already has the + // same reach); optional `path_policy` roots still apply via `enforce_path_operation`. if self.is_remote() && !is_remote_posix_path_within_root(&resolved_path, &workspace_root_owned) { @@ -846,7 +846,7 @@ mod context_facts_tests { computer_use_host: None, runtime_tool_restrictions: ToolRuntimeRestrictions { allowed_tool_names: BTreeSet::from(["Read".to_string()]), - denied_tool_names: BTreeSet::from(["Bash".to_string()]), + denied_tool_names: BTreeSet::from(["ExecCommand".to_string()]), denied_tool_messages: Default::default(), path_policy: Default::default(), miniapp_context_scope: None, @@ -863,7 +863,9 @@ mod context_facts_tests { assert_eq!(facts.workspace_kind, Some(ToolWorkspaceKind::Local)); assert_eq!(facts.workspace_root.as_deref(), Some("/repo/project")); assert!(facts.runtime_tool_restrictions.is_tool_allowed("Read")); - assert!(!facts.runtime_tool_restrictions.is_tool_allowed("Bash")); + assert!(!facts + .runtime_tool_restrictions + .is_tool_allowed("ExecCommand")); let value = serde_json::to_value(&facts).expect("serialize context facts"); assert!(value.get("unlockedCollapsedTools").is_none()); @@ -886,13 +888,13 @@ mod context_facts_tests { session_id: Some("session-runtime".to_string()), dialog_turn_id: Some("turn-runtime".to_string()), workspace: Some(WorkspaceBinding::new(None, PathBuf::from("/repo/runtime"))), - loaded_deferred_tool_specs: vec![loaded_spec("WebFetch"), loaded_spec("Git")], + loaded_deferred_tool_specs: vec![loaded_spec("WebFetch"), loaded_spec("Worktree")], primary_model_facts: PrimaryModelFacts::default(), custom_data, computer_use_host: None, runtime_tool_restrictions: ToolRuntimeRestrictions { allowed_tool_names: BTreeSet::from(["Read".to_string(), "GetToolSpec".to_string()]), - denied_tool_names: BTreeSet::from(["Bash".to_string()]), + denied_tool_names: BTreeSet::from(["ExecCommand".to_string()]), denied_tool_messages: Default::default(), path_policy: Default::default(), miniapp_context_scope: None, @@ -912,7 +914,9 @@ mod context_facts_tests { assert!(facts .runtime_tool_restrictions .is_tool_allowed("GetToolSpec")); - assert!(!facts.runtime_tool_restrictions.is_tool_allowed("Bash")); + assert!(!facts + .runtime_tool_restrictions + .is_tool_allowed("ExecCommand")); let value = serde_json::to_value(&facts).expect("serialize runtime context facts"); for runtime_only_field in [ @@ -1594,7 +1598,7 @@ mod task_context_tests { allowed_tools: vec!["WebFetch".to_string()], runtime_tool_restrictions: ToolRuntimeRestrictions { allowed_tool_names: BTreeSet::from(["WebFetch".to_string()]), - denied_tool_names: BTreeSet::from(["Bash".to_string()]), + denied_tool_names: BTreeSet::from(["ExecCommand".to_string()]), denied_tool_messages: Default::default(), path_policy: Default::default(), miniapp_context_scope: None, @@ -1626,7 +1630,9 @@ mod task_context_tests { assert!(context .runtime_tool_restrictions .is_tool_allowed("WebFetch")); - assert!(!context.runtime_tool_restrictions.is_tool_allowed("Bash")); + assert!(!context + .runtime_tool_restrictions + .is_tool_allowed("ExecCommand")); assert_eq!(context.custom_data["turn_index"], json!(7)); assert_eq!(context.primary_model_facts().model_id, "primary-model"); assert_eq!(context.primary_model_facts().api_format, "openai"); diff --git a/src/crates/assembly/core/src/agentic/tools/tool_result_storage.rs b/src/crates/assembly/core/src/agentic/tools/tool_result_storage.rs index 90ebd5a89f..c860d66a52 100644 --- a/src/crates/assembly/core/src/agentic/tools/tool_result_storage.rs +++ b/src/crates/assembly/core/src/agentic/tools/tool_result_storage.rs @@ -23,8 +23,6 @@ use std::path::Path; pub(crate) const READ_MAX_TOOL_RESULT_CHARS: usize = 72_000; const READ_TOOL_NAME: &str = "Read"; -const BASH_TOOL_NAME: &str = "Bash"; -const SHELL_MAX_TOOL_RESULT_CHARS: usize = 30_000; fn effective_tool_name(result: &ToolResult) -> &str { result @@ -56,16 +54,11 @@ pub(crate) async fn maybe_persist_large_tool_result_for_tool( let per_tool_limit = effective_per_tool_limit(effective_tool_name, policy); let visible_chars = result_visible_content(&result).chars().count(); - let content_override = - content_override_if_oversized(&result, effective_tool_name, per_tool_limit); - if visible_chars <= per_tool_limit - && content_override.is_none() - && !json_result_is_oversized(&result, per_tool_limit) - { + if visible_chars <= per_tool_limit && !json_result_is_oversized(&result, per_tool_limit) { return result; } - match persist_and_render_replacement(&result, context, policy, content_override).await { + match persist_and_render_replacement(&result, context, policy, None).await { Ok(replacement) => { result.result_for_assistant = Some(replacement); result @@ -280,27 +273,10 @@ fn serialize_tool_result_content(result: &ToolResult) -> BitFunResult<(String, b fn effective_per_tool_limit(tool_name: &str, policy: ToolResultStoragePolicy) -> usize { match tool_name { READ_TOOL_NAME => READ_MAX_TOOL_RESULT_CHARS, - BASH_TOOL_NAME => SHELL_MAX_TOOL_RESULT_CHARS, _ => policy.per_tool_limit_chars, } } -fn content_override_if_oversized( - result: &ToolResult, - effective_tool_name: &str, - limit: usize, -) -> Option { - if effective_tool_name != BASH_TOOL_NAME { - return None; - } - - let output = result - .result - .get("output") - .and_then(|value| value.as_str())?; - (output.chars().count() > limit).then(|| output.to_string()) -} - fn json_result_is_oversized(result: &ToolResult, limit: usize) -> bool { if result.result_for_assistant.is_some() { return false; @@ -416,33 +392,13 @@ mod tests { } } - fn bash_result(tool_id: &str, output: String, result_for_assistant: String) -> ToolResult { - ToolResult { - tool_id: tool_id.to_string(), - tool_name: "Bash".to_string(), - effective_tool_name: None, - result: json!({ - "success": false, - "output": output, - "exit_code": 1, - "timed_out": false, - "working_directory": "/repo", - "terminal_session_id": "term_1" - }), - result_for_assistant: Some(result_for_assistant), - is_error: false, - duration_ms: None, - image_attachments: None, - } - } - #[tokio::test] async fn single_large_result_persists_and_replaces_assistant_text() { let root = temp_workspace("single"); let context = test_context(root.clone()); let result = tool_result( "tool/one", - "Bash", + "WebFetch", "x".repeat(DEFAULT_MAX_TOOL_RESULT_CHARS + 1), ); @@ -555,45 +511,15 @@ mod tests { let _ = std::fs::remove_dir_all(root); } - #[tokio::test] - async fn bash_full_output_persists_even_when_assistant_text_is_already_truncated() { - let root = temp_workspace("bash"); - let context = test_context(root.clone()); - let full_output = format!( - "{}\nfinal-error", - "x".repeat(SHELL_MAX_TOOL_RESULT_CHARS + 1) - ); - let result = bash_result( - "bash_1", - full_output.clone(), - "tail only".to_string(), - ); - - let processed = maybe_persist_large_tool_result(result, &context).await; - let assistant = processed.result_for_assistant.unwrap_or_default(); - - assert!(assistant.starts_with(PERSISTED_OUTPUT_TAG)); - assert!(assistant.contains("exit_code: 1")); - assert!(assistant.contains("working_directory: /repo")); - assert!(assistant.contains("Line count: 2")); - let output_path = context - .current_workspace_session_tool_result_path("session_1", "bash_1.txt") - .expect("tool result path"); - let saved = std::fs::read_to_string(output_path).expect("saved output"); - assert_eq!(saved, full_output); - - let _ = std::fs::remove_dir_all(root); - } - #[tokio::test] async fn round_budget_persists_largest_results_including_read() { let root = temp_workspace("round"); let context = test_context(root.clone()); let read = tool_result("read_1", "Read", "a".repeat(170_000)); let medium = tool_result("medium_1", "WebFetch", "b".repeat(60_000)); - let bash = tool_result("bash_1", "Bash", "c".repeat(30_000)); + let small = tool_result("small_1", "OtherTool", "c".repeat(30_000)); - let processed = apply_round_tool_result_budget(vec![read, medium, bash], &context).await; + let processed = apply_round_tool_result_budget(vec![read, medium, small], &context).await; assert!(processed[0] .result_for_assistant @@ -616,7 +542,7 @@ mod tests { .expect("session tool-results dir"); assert!(session_dir.join("read_1.txt").exists()); assert!(!session_dir.join("medium_1.txt").exists()); - assert!(!session_dir.join("bash_1.txt").exists()); + assert!(!session_dir.join("small_1.txt").exists()); let _ = std::fs::remove_dir_all(root); } diff --git a/src/crates/assembly/core/src/service/session_usage/service.rs b/src/crates/assembly/core/src/service/session_usage/service.rs index 9deff3f6da..0c6575f64c 100644 --- a/src/crates/assembly/core/src/service/session_usage/service.rs +++ b/src/crates/assembly/core/src/service/session_usage/service.rs @@ -1864,8 +1864,8 @@ mod tests { incomplete.model_rounds[0].end_time = Some(181_000); incomplete.model_rounds[0].duration_ms = Some(59_000); incomplete.model_rounds[0].tool_items = vec![test_tool_item_with_input( - "slow-bash", - "Bash", + "slow-command", + "ExecCommand", Some(true), 120_000, serde_json::json!({ @@ -2381,7 +2381,7 @@ mod tests { let request = test_request(None); let mut slow = test_tool_item_with_input( "tool-slow", - "Bash", + "ExecCommand", Some(false), 95_000, serde_json::json!({ @@ -2461,7 +2461,7 @@ mod tests { let request = test_request(None); let slow_command = test_tool_item_with_input( "tool-secret-command", - "Bash", + "ExecCommand", Some(true), 95_000, serde_json::json!({ @@ -2645,7 +2645,13 @@ mod tests { 100, "D:/workspace/bitfun/src/main.rs", ), - test_tool_item("tool-2", "Bash", Some(false), 120, "D:/workspace/bitfun"), + test_tool_item( + "tool-2", + "ExecCommand", + Some(false), + 120, + "D:/workspace/bitfun", + ), ], ); failed_turn.status = TurnStatus::Error; @@ -2663,7 +2669,11 @@ mod tests { .iter() .map(|example| (example.label.as_str(), example.count)) .collect::>(), - vec![("Model/runtime turn errors", 1), ("Bash", 1), ("Write", 1),] + vec![ + ("Model/runtime turn errors", 1), + ("ExecCommand", 1), + ("Write", 1), + ] ); } diff --git a/src/crates/assembly/core/src/service_agent_runtime.rs b/src/crates/assembly/core/src/service_agent_runtime.rs index 6f8f3bd0ee..e0477700fd 100644 --- a/src/crates/assembly/core/src/service_agent_runtime.rs +++ b/src/crates/assembly/core/src/service_agent_runtime.rs @@ -2409,9 +2409,7 @@ impl RemoteDialogRuntimeHost for CoreRemoteDialogRuntimeHost<'_> { working_directory: workspace, session_id: Some(sid.clone()), session_name: Some(name), - env: Some( - crate::agentic::tools::implementations::bash_tool::BashTool::noninteractive_env(), - ), + env: Some(tool_runtime::shell::noninteractive_terminal_env()), source: Some(SessionSource::Agent), ..Default::default() }, diff --git a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json index 0f4af3bf6f..c4515c6860 100644 --- a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json +++ b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json @@ -4,7 +4,7 @@ "title": "BitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "5a170c4fb33ea5bb4fe1d839ff0d405db53b82e586aead0891babafbb59b40aa", + "digest": "6094ecf9b8e3d14c0783f7f0595e3d5e007362c9d89f5655c05e7307e9caa489", "ownerDigest": "a6a8c5337fb877e641b66f5774740d5213caed4800fe9b006eb53632f7eb1c2d", "searchAcceptance": [ { @@ -3381,7 +3381,7 @@ "additionalProperties": true }, "delegateTools": [ - "Git" + "ExecCommand" ], "presentationTarget": { "kind": "scene", @@ -3426,7 +3426,7 @@ "additionalProperties": true }, "delegateTools": [ - "Git" + "ExecCommand" ], "presentationTarget": { "kind": "scene", @@ -3471,7 +3471,7 @@ "additionalProperties": true }, "delegateTools": [ - "Git" + "ExecCommand" ], "presentationTarget": { "kind": "scene", @@ -3516,7 +3516,7 @@ "additionalProperties": true }, "delegateTools": [ - "Git" + "ExecCommand" ], "presentationTarget": { "kind": "scene", @@ -3561,7 +3561,7 @@ "additionalProperties": true }, "delegateTools": [ - "Git" + "ExecCommand" ], "presentationTarget": { "kind": "scene", @@ -3606,7 +3606,7 @@ "additionalProperties": true }, "delegateTools": [ - "Git" + "ExecCommand" ], "presentationTarget": { "kind": "scene", @@ -3651,7 +3651,7 @@ "additionalProperties": true }, "delegateTools": [ - "Git" + "ExecCommand" ], "presentationTarget": { "kind": "scene", @@ -3696,7 +3696,7 @@ "additionalProperties": true }, "delegateTools": [ - "Git" + "ExecCommand" ], "presentationTarget": { "kind": "scene", @@ -19975,15 +19975,15 @@ "control": { "kind": "delegate", "tools": [ - "Git" + "ExecCommand" ], "workflowZh": [ - "若 Git 是延迟工具,先用 GetToolSpec 加载其契约,再按返回的 operation/args 结构调用", - "使用 Git 的 status 与 diff 检查当前工作树、暂存区和目标文件差异" + "在当前工作区通过 ExecCommand 运行 git status 与 git diff", + "检查当前工作树、暂存区和目标文件差异" ], "workflowEn": [ - "If Git is deferred, load its contract with GetToolSpec first, then call it with the returned operation/args shape", - "Use Git status and diff to inspect the working tree, index, and selected file changes" + "Run git status and git diff through ExecCommand in the current workspace", + "Inspect the working tree, index, and selected file changes" ] } }, @@ -19994,15 +19994,15 @@ "control": { "kind": "delegate", "tools": [ - "Git" + "ExecCommand" ], "workflowZh": [ - "若 Git 是延迟工具,先用 GetToolSpec 加载其契约,再按返回的 operation/args 结构调用", - "使用 Git 的 add、restore 或 reset 完成暂存、取消暂存和选定提交重置;危险参数仍经过权限与安全检查" + "通过 ExecCommand 运行 git add、git restore 或 git reset", + "完成暂存、取消暂存和选定提交重置;危险参数仍经过权限与安全检查" ], "workflowEn": [ - "If Git is deferred, load its contract with GetToolSpec first, then call it with the returned operation/args shape", - "Use Git add, restore, or reset to stage, unstage, or reset to a selected commit; risky arguments still pass permission and safety checks" + "Run git add, git restore, or git reset through ExecCommand", + "Stage, unstage, or reset to a selected commit; risky arguments still pass permission and safety checks" ] } }, @@ -20013,15 +20013,15 @@ "control": { "kind": "delegate", "tools": [ - "Git" + "ExecCommand" ], "workflowZh": [ - "若 Git 是延迟工具,先用 GetToolSpec 加载其契约,再按返回的 operation/args 结构调用", - "先检查 status/diff,再用 Git commit 创建经用户确认的提交" + "通过 ExecCommand 先运行 git status 与 git diff", + "再运行 git commit 创建经用户确认的提交" ], "workflowEn": [ - "If Git is deferred, load its contract with GetToolSpec first, then call it with the returned operation/args shape", - "Inspect status/diff first, then create the user-approved commit with Git commit" + "Run git status and git diff through ExecCommand first", + "Then run git commit to create the user-approved commit" ] } }, @@ -20032,15 +20032,15 @@ "control": { "kind": "delegate", "tools": [ - "Git" + "ExecCommand" ], "workflowZh": [ - "若 Git 是延迟工具,先用 GetToolSpec 加载其契约,再按返回的 operation/args 结构调用", - "使用 Git pull 或 push 同步远端;强制推送等高风险参数仍会被拦截或要求确认" + "通过 ExecCommand 运行 git pull 或 git push 同步远端", + "强制推送等高风险参数仍会被拦截或要求确认" ], "workflowEn": [ - "If Git is deferred, load its contract with GetToolSpec first, then call it with the returned operation/args shape", - "Use Git pull or push to synchronize remotes; high-risk arguments such as force push remain blocked or require confirmation" + "Run git pull or git push through ExecCommand to synchronize remotes", + "High-risk arguments such as force push remain blocked or require confirmation" ] } }, @@ -20051,15 +20051,15 @@ "control": { "kind": "delegate", "tools": [ - "Git" + "ExecCommand" ], "workflowZh": [ - "若 Git 是延迟工具,先用 GetToolSpec 加载其契约,再按返回的 operation/args 结构调用", - "使用 Git branch 查看、创建或删除分支,使用 switch/checkout 切换分支" + "通过 ExecCommand 运行 git branch 查看、创建或删除分支", + "运行 git switch 或 git checkout 切换分支" ], "workflowEn": [ - "If Git is deferred, load its contract with GetToolSpec first, then call it with the returned operation/args shape", - "Use Git branch to list, create, or delete branches and switch/checkout to change branches" + "Run git branch through ExecCommand to list, create, or delete branches", + "Run git switch or git checkout to change branches" ] } }, @@ -20070,14 +20070,14 @@ "control": { "kind": "delegate", "tools": [ - "Git" + "ExecCommand" ], "workflowZh": [ - "若 Git 是延迟工具,先用 GetToolSpec 加载其契约,再用 log/show 读取提交图谱与历史", + "通过 ExecCommand 运行 git log 或 git show 读取提交图谱与历史", "界面的最近访问记录仍由 Git 场景保存;Agent 读取历史不会伪装成修改该 UI 状态" ], "workflowEn": [ - "If Git is deferred, load its contract with GetToolSpec, then inspect the commit graph and history with log or show", + "Run git log or git show through ExecCommand to inspect the commit graph and history", "Recent-history UI state remains owned by the Git surface; reading history does not pretend to mutate that presentation state" ] } @@ -20089,15 +20089,15 @@ "control": { "kind": "delegate", "tools": [ - "Git" + "ExecCommand" ], "workflowZh": [ - "若 Git 是延迟工具,先用 GetToolSpec 加载其契约,再按返回的 operation/args 结构调用", - "使用 Git show 解析 revision 并读取指定提交中的对象或文件内容" + "通过 ExecCommand 运行 git show", + "解析 revision 并读取指定提交中的对象或文件内容" ], "workflowEn": [ - "If Git is deferred, load its contract with GetToolSpec first, then call it with the returned operation/args shape", - "Use Git show to resolve a revision and inspect objects or file content from a selected commit" + "Run git show through ExecCommand", + "Resolve a revision and inspect objects or file content from a selected commit" ] } }, @@ -20108,15 +20108,15 @@ "control": { "kind": "delegate", "tools": [ - "Git" + "ExecCommand" ], "workflowZh": [ - "若 Git 是延迟工具,先用 GetToolSpec 加载其契约,再按返回的 operation/args 结构调用", - "使用 Git cherry-pick 启动,并通过 --continue 或 --abort 继续或中止流程" + "通过 ExecCommand 运行 git cherry-pick", + "使用 --continue 或 --abort 继续或中止流程" ], "workflowEn": [ - "If Git is deferred, load its contract with GetToolSpec first, then call it with the returned operation/args shape", - "Use Git cherry-pick to start, then --continue or --abort to continue or cancel the operation" + "Run git cherry-pick through ExecCommand", + "Use --continue or --abort to continue or cancel the operation" ] } }, @@ -20204,26 +20204,39 @@ "带我查看当前代码改动", "Open the Git panel", "Show me the current code changes", - "若 Git 是延迟工具,先用 GetToolSpec 加载其契约,再按返回的 operation/args 结构调用", - "使用 Git 的 status 与 diff 检查当前工作树、暂存区和目标文件差异", - "If Git is deferred, load its contract with GetToolSpec first, then call it with the returned operation/args shape", - "Use Git status and diff to inspect the working tree, index, and selected file changes", - "使用 Git 的 add、restore 或 reset 完成暂存、取消暂存和选定提交重置;危险参数仍经过权限与安全检查", - "Use Git add, restore, or reset to stage, unstage, or reset to a selected commit; risky arguments still pass permission and safety checks", - "先检查 status/diff,再用 Git commit 创建经用户确认的提交", - "Inspect status/diff first, then create the user-approved commit with Git commit", - "使用 Git pull 或 push 同步远端;强制推送等高风险参数仍会被拦截或要求确认", - "Use Git pull or push to synchronize remotes; high-risk arguments such as force push remain blocked or require confirmation", - "使用 Git branch 查看、创建或删除分支,使用 switch/checkout 切换分支", - "Use Git branch to list, create, or delete branches and switch/checkout to change branches", - "若 Git 是延迟工具,先用 GetToolSpec 加载其契约,再用 log/show 读取提交图谱与历史", + "ExecCommand", + "在当前工作区通过 ExecCommand 运行 git status 与 git diff", + "检查当前工作树、暂存区和目标文件差异", + "Run git status and git diff through ExecCommand in the current workspace", + "Inspect the working tree, index, and selected file changes", + "通过 ExecCommand 运行 git add、git restore 或 git reset", + "完成暂存、取消暂存和选定提交重置;危险参数仍经过权限与安全检查", + "Run git add, git restore, or git reset through ExecCommand", + "Stage, unstage, or reset to a selected commit; risky arguments still pass permission and safety checks", + "通过 ExecCommand 先运行 git status 与 git diff", + "再运行 git commit 创建经用户确认的提交", + "Run git status and git diff through ExecCommand first", + "Then run git commit to create the user-approved commit", + "通过 ExecCommand 运行 git pull 或 git push 同步远端", + "强制推送等高风险参数仍会被拦截或要求确认", + "Run git pull or git push through ExecCommand to synchronize remotes", + "High-risk arguments such as force push remain blocked or require confirmation", + "通过 ExecCommand 运行 git branch 查看、创建或删除分支", + "运行 git switch 或 git checkout 切换分支", + "Run git branch through ExecCommand to list, create, or delete branches", + "Run git switch or git checkout to change branches", + "通过 ExecCommand 运行 git log 或 git show 读取提交图谱与历史", "界面的最近访问记录仍由 Git 场景保存;Agent 读取历史不会伪装成修改该 UI 状态", - "If Git is deferred, load its contract with GetToolSpec, then inspect the commit graph and history with log or show", + "Run git log or git show through ExecCommand to inspect the commit graph and history", "Recent-history UI state remains owned by the Git surface; reading history does not pretend to mutate that presentation state", - "使用 Git show 解析 revision 并读取指定提交中的对象或文件内容", - "Use Git show to resolve a revision and inspect objects or file content from a selected commit", - "使用 Git cherry-pick 启动,并通过 --continue 或 --abort 继续或中止流程", - "Use Git cherry-pick to start, then --continue or --abort to continue or cancel the operation" + "通过 ExecCommand 运行 git show", + "解析 revision 并读取指定提交中的对象或文件内容", + "Run git show through ExecCommand", + "Resolve a revision and inspect objects or file content from a selected commit", + "通过 ExecCommand 运行 git cherry-pick", + "使用 --continue 或 --abort 继续或中止流程", + "Run git cherry-pick through ExecCommand", + "Use --continue or --abort to continue or cancel the operation" ], "docsUrl": "https://playbook.openbitfun.com/capabilities/feature.git/" }, diff --git a/src/crates/execution/agent-runtime/src/evidence_ledger.rs b/src/crates/execution/agent-runtime/src/evidence_ledger.rs index 55eddb3bc6..d3455d5730 100644 --- a/src/crates/execution/agent-runtime/src/evidence_ledger.rs +++ b/src/crates/execution/agent-runtime/src/evidence_ledger.rs @@ -626,7 +626,7 @@ mod tests { let event = EvidenceLedgerEvent::new( "session-a", "turn-a", - "Bash", + "ExecCommand", EvidenceLedgerTargetKind::Command, "cargo test", EvidenceLedgerEventStatus::Succeeded, @@ -652,7 +652,7 @@ mod tests { let turn_a = EvidenceLedgerEvent::new( "session-a", "turn-a", - "Bash", + "ExecCommand", EvidenceLedgerTargetKind::Command, "cargo test", EvidenceLedgerEventStatus::Succeeded, @@ -661,7 +661,7 @@ mod tests { let turn_b = EvidenceLedgerEvent::new( "session-a", "turn-b", - "Bash", + "ExecCommand", EvidenceLedgerTargetKind::Command, "cargo check", EvidenceLedgerEventStatus::Failed, @@ -730,7 +730,7 @@ mod tests { mismatched.events.push(EvidenceLedgerEvent::new( "session-b", "turn-a", - "Bash", + "ExecCommand", EvidenceLedgerTargetKind::Command, "cargo test", EvidenceLedgerEventStatus::Succeeded, @@ -750,7 +750,7 @@ mod tests { "event_id": "event-a", "session_id": "session-a", "turn_id": "turn-a", - "tool_name": "Bash", + "tool_name": "ExecCommand", "target_kind": "command", "target": "cargo test", "status": "succeeded", @@ -953,7 +953,7 @@ mod tests { EvidenceLedgerEvent::new( "session-a", "turn-a", - "Bash", + "ExecCommand", EvidenceLedgerTargetKind::Command, "cargo test", EvidenceLedgerEventStatus::Failed, diff --git a/src/crates/execution/agent-runtime/tests/agent_definition_contracts/custom_subagent_contracts.rs b/src/crates/execution/agent-runtime/tests/agent_definition_contracts/custom_subagent_contracts.rs index 840f9df49e..cb1af07760 100644 --- a/src/crates/execution/agent-runtime/tests/agent_definition_contracts/custom_subagent_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/agent_definition_contracts/custom_subagent_contracts.rs @@ -67,11 +67,11 @@ fn custom_subagent_defaults_match_existing_front_matter_contract() { #[test] fn custom_subagent_tool_front_matter_keeps_existing_comma_format() { - let tools = custom_subagent_tools_from_front_matter(Some("Read, Grep, Bash")); - assert_eq!(tools, ["Read", "Grep", "Bash"]); + let tools = custom_subagent_tools_from_front_matter(Some("Read, Grep, ExecCommand")); + assert_eq!(tools, ["Read", "Grep", "ExecCommand"]); assert_eq!( custom_subagent_tools_to_front_matter(&tools), - Some("Read, Grep, Bash".to_string()) + Some("Read, Grep, ExecCommand".to_string()) ); } diff --git a/src/crates/execution/agent-stream/src/tool_call_accumulator.rs b/src/crates/execution/agent-stream/src/tool_call_accumulator.rs index 0c3fd748cb..ecd8bc4d6e 100644 --- a/src/crates/execution/agent-stream/src/tool_call_accumulator.rs +++ b/src/crates/execution/agent-stream/src/tool_call_accumulator.rs @@ -145,7 +145,7 @@ pub struct PendingToolCalls { /// Tools where a repaired truncation can be passed to schema validation. /// Write requires its path/content separator before execution, so a truncation /// inside the path is rejected while a truncation in the content can still -/// write the safe prefix. For everything else (Bash, Edit, Task, ...) we surface +/// write the safe prefix. For everything else (ExecCommand, Edit, Task, ...) we surface /// the truncation as an error: a partial shell command or a partial /// `old_string`/`new_string` for Edit can change semantics destructively. pub fn is_write_like_tool_name(tool_name: &str) -> bool { @@ -245,94 +245,22 @@ fn repair_truncated_json(raw: &str) -> Option { } impl PendingToolCall { - fn strip_argument_wrapping(raw_arguments: &str) -> &str { - let trimmed = raw_arguments.trim(); - let Some(stripped) = trimmed - .strip_prefix("```") - .and_then(|value| value.strip_suffix("```")) - else { - return trimmed.trim_matches('`').trim(); - }; - - let stripped = stripped.trim(); - if let Some((first_line, rest)) = stripped.split_once('\n') { - if first_line - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') - { - return rest.trim(); - } - } - - stripped - } - - /// Best-effort repair for Git tool calls whose arguments came back as a raw - /// shell-style command (e.g. `git status`, `"git diff --staged"`). - fn parse_git_command_arguments(raw_arguments: &str) -> Option { - let trimmed = Self::strip_argument_wrapping(raw_arguments); - let command = trimmed - .strip_prefix("git ") - .map(str::trim) - .unwrap_or(trimmed); - let mut parts = command.splitn(2, char::is_whitespace); - let operation = parts.next()?.trim(); - if operation.is_empty() - || !operation - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') - { - return None; - } - - let args = parts.next().map(str::trim).filter(|args| !args.is_empty()); - let mut value = json!({ "operation": operation }); - if let Some(args) = args { - value["args"] = json!(args); - } - Some(value) - } - - fn normalize_git_tool_arguments(arguments: Value) -> Value { - if let Value::String(raw) = &arguments { - if let Some(repaired) = Self::parse_git_command_arguments(raw) { - warn!("Git tool call arguments repaired from JSON string command"); - return repaired; - } - } - arguments - } - fn parse_arguments( - tool_name: &str, + _tool_name: &str, raw_arguments: &str, ) -> Result { match serde_json::from_str::(raw_arguments) { - Ok(arguments) => { - if tool_name == "Git" { - Ok(Self::normalize_git_tool_arguments(arguments)) - } else { - Ok(arguments) - } - } - Err(primary_error) => { - if tool_name == "Git" { - if let Some(arguments) = Self::parse_git_command_arguments(raw_arguments) { - warn!("Git tool call arguments repaired from raw command"); - return Ok(arguments); - } - } - Err(ToolArgumentParseError { - message: primary_error.to_string(), - is_eof: primary_error.is_eof(), - category: match primary_error.classify() { - serde_json::error::Category::Io => "io", - serde_json::error::Category::Syntax => "syntax", - serde_json::error::Category::Data => "data", - serde_json::error::Category::Eof => "eof", - }, - }) - } + Ok(arguments) => Ok(arguments), + Err(primary_error) => Err(ToolArgumentParseError { + message: primary_error.to_string(), + is_eof: primary_error.is_eof(), + category: match primary_error.classify() { + serde_json::error::Category::Io => "io", + serde_json::error::Category::Syntax => "syntax", + serde_json::error::Category::Data => "data", + serde_json::error::Category::Eof => "eof", + }, + }), } } @@ -672,87 +600,10 @@ mod tests { .is_some_and(|error| error.contains("EOF"))); } - #[test] - fn repairs_git_raw_command_arguments() { - let mut pending = PendingToolCall::default(); - pending.start_new("call_1".to_string(), Some("Git".to_string())); - pending.append_arguments("git status"); - - let finalized = pending - .finalize(ToolCallBoundary::FinishReason) - .expect("finalized tool"); - - assert_eq!(finalized.raw_arguments, "git status"); - assert_eq!(finalized.arguments, json!({"operation": "status"})); - assert!(!finalized.is_error); - } - - #[test] - fn repairs_git_json_string_command_arguments() { - let mut pending = PendingToolCall::default(); - pending.start_new("call_1".to_string(), Some("Git".to_string())); - pending.append_arguments("\"git diff --staged\""); - - let finalized = pending - .finalize(ToolCallBoundary::FinishReason) - .expect("finalized tool"); - - assert_eq!( - finalized.arguments, - json!({"operation": "diff", "args": "--staged"}) - ); - assert!(!finalized.is_error); - } - - #[test] - fn git_args_only_object_is_left_for_tool_schema_diagnostic() { - let mut pending = PendingToolCall::default(); - pending.start_new("call_1".to_string(), Some("Git".to_string())); - pending.append_arguments("{\"args\": \"--since=\\\"2026-05-02\\\" --oneline\"}"); - - let finalized = pending - .finalize(ToolCallBoundary::FinishReason) - .expect("finalized tool"); - - assert_eq!( - finalized.arguments, - json!({"args": "--since=\"2026-05-02\" --oneline"}) - ); - assert!(!finalized.is_error); - } - - #[test] - fn git_duplicate_subcommand_in_args_is_left_for_tool_schema_diagnostic() { - let mut pending = PendingToolCall::default(); - pending.start_new("call_1".to_string(), Some("Git".to_string())); - pending.append_arguments("{\"args\": \"log --oneline -10\"}"); - - let finalized = pending - .finalize(ToolCallBoundary::FinishReason) - .expect("finalized tool"); - - assert_eq!(finalized.arguments, json!({"args": "log --oneline -10"})); - assert!(!finalized.is_error); - } - - #[test] - fn does_not_infer_git_operation_from_ambiguous_args_only_object() { - let mut pending = PendingToolCall::default(); - pending.start_new("call_1".to_string(), Some("Git".to_string())); - pending.append_arguments("{\"args\": \"--stat\"}"); - - let finalized = pending - .finalize(ToolCallBoundary::FinishReason) - .expect("finalized tool"); - - assert_eq!(finalized.arguments, json!({"args": "--stat"})); - assert!(!finalized.is_error); - } - #[test] fn raw_string_arguments_for_single_field_tools_stay_invalid_json() { let cases = [ - ("Bash", "pnpm test"), + ("ExecCommand", "pnpm test"), ("Skill", "openai-docs"), ("Read", "src/main.rs"), ("GetFileDiff", "src/lib.rs"), @@ -782,7 +633,7 @@ mod tests { #[test] fn incomplete_json_object_for_single_field_tools_stays_invalid() { let mut pending = PendingToolCall::default(); - pending.start_new("call_1".to_string(), Some("Bash".to_string())); + pending.start_new("call_1".to_string(), Some("ExecCommand".to_string())); pending.append_arguments( "{\"command\": \"git log --since=\\\"2026-05-02\\\" --oneline --stat", ); @@ -798,7 +649,7 @@ mod tests { #[test] fn does_not_wrap_incomplete_json_object_as_raw_string_argument() { let mut pending = PendingToolCall::default(); - pending.start_new("call_1".to_string(), Some("Bash".to_string())); + pending.start_new("call_1".to_string(), Some("ExecCommand".to_string())); pending.append_arguments("{\"command\": "); let finalized = pending @@ -828,7 +679,7 @@ mod tests { #[test] fn does_not_repair_object_without_key_value_payload() { let mut pending = PendingToolCall::default(); - pending.start_new("call_1".to_string(), Some("Bash".to_string())); + pending.start_new("call_1".to_string(), Some("ExecCommand".to_string())); pending.append_arguments("{"); let finalized = pending @@ -842,7 +693,7 @@ mod tests { #[test] fn does_not_execute_truncated_incomplete_json_object() { let mut pending = PendingToolCall::default(); - pending.start_new("call_1".to_string(), Some("Bash".to_string())); + pending.start_new("call_1".to_string(), Some("ExecCommand".to_string())); pending.append_arguments("{\"command\": \"git log --since=\\\"2026-05-02\\\" --on"); let finalized = pending @@ -856,7 +707,7 @@ mod tests { #[test] fn json_string_arguments_for_single_field_tools_are_schema_errors_not_rewritten() { let mut pending = PendingToolCall::default(); - pending.start_new("call_1".to_string(), Some("Bash".to_string())); + pending.start_new("call_1".to_string(), Some("ExecCommand".to_string())); pending.append_arguments("\"git status\""); let finalized = pending @@ -870,7 +721,7 @@ mod tests { #[test] fn fenced_raw_arguments_for_single_field_tools_stay_invalid_json() { let mut pending = PendingToolCall::default(); - pending.start_new("call_1".to_string(), Some("Bash".to_string())); + pending.start_new("call_1".to_string(), Some("ExecCommand".to_string())); pending.append_arguments("```bash\npnpm run lint:web\n```"); let finalized = pending @@ -1159,7 +1010,7 @@ mod tests { fn non_write_truncated_command_still_errors_without_repair_provenance() { let raw = r#"{"command": "git log --since=\"2026-05-02\" --on"#; let mut pending = PendingToolCall::default(); - pending.start_new("call_1".to_string(), Some("Bash".to_string())); + pending.start_new("call_1".to_string(), Some("ExecCommand".to_string())); pending.append_arguments(raw); let finalized = pending diff --git a/src/crates/execution/runtime-services/src/backend_events.rs b/src/crates/execution/runtime-services/src/backend_events.rs index 365eaeea9a..d986ffbb71 100644 --- a/src/crates/execution/runtime-services/src/backend_events.rs +++ b/src/crates/execution/runtime-services/src/backend_events.rs @@ -197,7 +197,7 @@ mod tests { fn progress() -> ToolExecutionProgressInfo { ToolExecutionProgressInfo { tool_use_id: "tool-1".to_string(), - tool_name: "Bash".to_string(), + tool_name: "ExecCommand".to_string(), progress_message: "running".to_string(), percentage: Some(50.0), timestamp: 1, diff --git a/src/crates/execution/tool-contracts/src/framework.rs b/src/crates/execution/tool-contracts/src/framework.rs index ca3bff387b..84dd6fb508 100644 --- a/src/crates/execution/tool-contracts/src/framework.rs +++ b/src/crates/execution/tool-contracts/src/framework.rs @@ -358,7 +358,7 @@ pub fn get_tool_spec_input_schema() -> Value { "properties": { "tool_name": { "type": "string", - "description": "Exact deferred tool name to load, using the tool's canonical casing from the catalog (for example, \"Git\"). Do not pass a command such as \"git status\" or an operation such as \"status\" here." + "description": "Exact deferred tool name to load, using the tool's canonical casing from the catalog (for example, \"WebFetch\"). Do not pass a URL or an operation name here." } } }) @@ -639,8 +639,7 @@ fn escape_get_tool_spec_xml_text(value: &str) -> String { pub fn tool_manifest_sort_rank(tool_name: &str) -> usize { match tool_name { "Task" => 1, - "Bash" => 2, - "TerminalControl" => 3, + "ExecCommand" => 2, "Glob" => 4, "Grep" => 5, "Read" => 6, @@ -2672,7 +2671,7 @@ mod tests { fn get_tool_spec_catalog_description_keeps_builtin_summaries_optional() { let description = build_get_tool_spec_catalog_description(&[ GetToolSpecDeferredToolSummary { - name: "Git".to_string(), + name: "Worktree".to_string(), short_description: Some("Inspect repository state.".to_string()), }, GetToolSpecDeferredToolSummary { @@ -2682,9 +2681,9 @@ mod tests { ]) .expect("catalog description"); - assert!(description.contains("- Git")); + assert!(description.contains("- Worktree")); assert!(description.contains("- WebFetch")); - assert!(description.contains("- Git: Inspect repository state.")); + assert!(description.contains("- Worktree: Inspect repository state.")); assert!(!description.contains("Fetch a URL.")); } diff --git a/src/crates/execution/tool-contracts/tests/tool_contracts.rs b/src/crates/execution/tool-contracts/tests/tool_contracts.rs index a5e6198034..cae4f24dab 100644 --- a/src/crates/execution/tool-contracts/tests/tool_contracts.rs +++ b/src/crates/execution/tool-contracts/tests/tool_contracts.rs @@ -1940,9 +1940,9 @@ fn prompt_visible_manifest_builder_omits_deferred_tools_from_provider_manifest() json!({ "type": "object", "properties": { "path": { "type": "string" } } }), )), PromptVisibleToolManifestItem::Direct(ToolManifestDefinition::new( - "Bash", + "ExecCommand", "Run shell commands.", - json!({ "type": "object", "properties": { "command": { "type": "string" } } }), + json!({ "type": "object", "properties": { "cmd": { "type": "string" } } }), )), ]); @@ -1951,11 +1951,11 @@ fn prompt_visible_manifest_builder_omits_deferred_tools_from_provider_manifest() .iter() .map(|definition| definition.name.as_str()) .collect::>(), - vec!["Bash", "Read"] + vec!["ExecCommand", "Read"] ); assert_eq!(definitions[0].description, "Run shell commands."); assert_eq!( - definitions[0].parameters["properties"]["command"]["type"], + definitions[0].parameters["properties"]["cmd"]["type"], json!("string") ); } diff --git a/src/crates/execution/tool-execution/src/context.rs b/src/crates/execution/tool-execution/src/context.rs index 6f3f5f938a..b028c72da4 100644 --- a/src/crates/execution/tool-execution/src/context.rs +++ b/src/crates/execution/tool-execution/src/context.rs @@ -262,7 +262,7 @@ mod tests { workspace_root: Some("/home/user/project".to_string()), runtime_tool_restrictions: ToolRuntimeRestrictions { allowed_tool_names: BTreeSet::from(["Read".to_string()]), - denied_tool_names: BTreeSet::from(["Bash".to_string()]), + denied_tool_names: BTreeSet::from(["ExecCommand".to_string()]), denied_tool_messages: Default::default(), path_policy: Default::default(), miniapp_context_scope: None, diff --git a/src/crates/execution/tool-execution/src/shell/mod.rs b/src/crates/execution/tool-execution/src/shell/mod.rs index bb307130a8..348ba52940 100644 --- a/src/crates/execution/tool-execution/src/shell/mod.rs +++ b/src/crates/execution/tool-execution/src/shell/mod.rs @@ -1,104 +1,6 @@ use std::collections::HashMap; -use crate::util::ansi_cleaner::strip_ansi; -use crate::util::string::shell_single_quote; - -pub const BASH_RESULT_MAX_OUTPUT_LENGTH: usize = 30_000; -pub const BASH_INTERRUPT_OUTPUT_DRAIN_MS: u64 = 500; - -const BANNED_COMMANDS: &[&str] = &[ - "alias", - // "curl", - // "curlie", - // "wget", - // "axel", - // "aria2c", - // "nc", - // "telnet", - // "lynx", - // "w3m", - // "links", - // "httpie", - // "xh", - // "http-prompt", - // "chrome", - // "firefox", - // "safari", -]; - -pub fn banned_shell_command(cmd: &str) -> Option<&str> { - let base_cmd = cmd.split_whitespace().next()?; - let base_cmd_lc = base_cmd.to_lowercase(); - BANNED_COMMANDS - .iter() - .any(|banned| base_cmd_lc == *banned) - .then_some(base_cmd) -} - -pub fn detect_osascript_keystroke_non_ascii(cmd: &str) -> Option { - if !cmd.contains("osascript") { - return None; - } - - let bytes = cmd.as_bytes(); - let needle = b"keystroke"; - let mut i = 0usize; - while i + needle.len() < bytes.len() { - if &bytes[i..i + needle.len()] == needle { - let mut j = i + needle.len(); - while j < bytes.len() && bytes[j] != b'"' { - j += 1; - } - if j >= bytes.len() { - break; - } - let start = j + 1; - let mut end = start; - while end < bytes.len() && bytes[end] != b'"' { - end += 1; - } - if end > bytes.len() { - break; - } - let literal = &cmd[start..end.min(cmd.len())]; - if !literal.is_ascii() { - return Some(literal.to_string()); - } - i = end + 1; - } else { - i += 1; - } - } - None -} - -pub fn detect_osascript_im_app(cmd: &str) -> Option<&'static str> { - if !cmd.contains("osascript") { - return None; - } - const IM_APPS: &[&str] = &[ - "WeChat", "微信", "iMessage", "Messages", "Slack", "Lark", "飞书", "Telegram", "DingTalk", - "钉钉", "QQ", "Discord", "Teams", "Whatsapp", "WhatsApp", - ]; - let cmd_lc = cmd.to_lowercase(); - for app in IM_APPS { - let app_lc = app.to_lowercase(); - if cmd.contains(app) || cmd_lc.contains(&app_lc) { - return Some(*app); - } - } - None -} - -pub fn command_for_working_directory(command: &str, working_directory: Option<&str>) -> String { - working_directory - .map(str::trim) - .filter(|dir| !dir.is_empty()) - .map(|dir| format!("cd {} && {}", shell_single_quote(dir), command)) - .unwrap_or_else(|| command.to_string()) -} - -pub fn bash_noninteractive_env() -> HashMap { +pub fn noninteractive_terminal_env() -> HashMap { let mut env = HashMap::new(); env.insert("BITFUN_NONINTERACTIVE".to_string(), "1".to_string()); env.insert("GIT_PAGER".to_string(), "cat".to_string()); @@ -107,272 +9,3 @@ pub fn bash_noninteractive_env() -> HashMap { env.insert("GIT_EDITOR".to_string(), "true".to_string()); env } - -pub fn truncate_output_preserving_tail(s: &str, max_chars: usize) -> String { - let chars: Vec = s.chars().collect(); - if chars.len() <= max_chars { - return s.to_string(); - } - - let tail_bias = max_chars.saturating_mul(4) / 5; - let separator = "\n... [truncated, middle omitted, tail preserved] ...\n"; - let separator_len = separator.chars().count(); - - if separator_len >= max_chars { - return chars[chars.len() - max_chars..].iter().collect(); - } - - let content_budget = max_chars - separator_len; - let tail_len = tail_bias.min(content_budget); - let head_len = content_budget.saturating_sub(tail_len); - - let head: String = chars[..head_len].iter().collect(); - let tail: String = chars[chars.len() - tail_len..].iter().collect(); - - format!("{head}{separator}{tail}") -} - -pub struct LocalShellResultRenderRequest<'a> { - pub terminal_session_id: &'a str, - pub working_directory: &'a str, - pub output_text: &'a str, - pub interrupted: bool, - pub timed_out: bool, - pub exit_code: i32, - pub shell_state: Option<&'a str>, -} - -pub fn render_local_shell_result(request: LocalShellResultRenderRequest<'_>) -> String { - let mut result_string = String::new(); - - result_string.push_str(&format!("{}", request.exit_code)); - if !request.working_directory.is_empty() { - result_string.push_str(&format!( - "{}", - request.working_directory - )); - } - - if let Some(output_block) = - render_output_block_with_limit("output", request.output_text, BASH_RESULT_MAX_OUTPUT_LENGTH) - { - result_string.push_str(&output_block); - } - - if let Some(state) = request.shell_state { - let cleaned_state = strip_ansi(state); - result_string.push_str(&format!("{}", cleaned_state)); - } - - if request.timed_out { - result_string.push_str( - "Command timed out before completion. Partial output, if any, is included above.", - ); - } else if request.interrupted { - result_string.push_str( - "Command was canceled by the user. ASK THE USER what they would like to do next." - ); - } - - result_string.push_str(&format!( - "{}", - request.terminal_session_id - )); - - result_string -} - -pub fn render_output_block_with_limit( - tag: &str, - output_text: &str, - max_chars: usize, -) -> Option { - if output_text.is_empty() { - return None; - } - - let cleaned_output = strip_ansi(output_text); - let output_len = cleaned_output.chars().count(); - if max_chars == 0 { - Some(format!( - "<{tag} truncated=\"true\">... [truncated, no budget remaining] ..." - )) - } else if output_len > max_chars { - let truncated = truncate_output_preserving_tail(&cleaned_output, max_chars); - Some(format!("<{tag} truncated=\"true\">{}", truncated)) - } else { - Some(format!("<{tag}>{}", cleaned_output)) - } -} - -pub fn remote_stream_budgets(stdout: &str, stderr: &str) -> (usize, usize) { - let stdout_len = strip_ansi(stdout).chars().count(); - let stderr_len = strip_ansi(stderr).chars().count(); - - if stderr_len >= BASH_RESULT_MAX_OUTPUT_LENGTH { - return (0, BASH_RESULT_MAX_OUTPUT_LENGTH); - } - - let stderr_budget = stderr_len; - let stdout_budget = BASH_RESULT_MAX_OUTPUT_LENGTH.saturating_sub(stderr_budget); - (stdout_budget.min(stdout_len), stderr_budget) -} - -pub struct RemoteShellResultRenderRequest<'a> { - pub working_directory: &'a str, - pub stdout: &'a str, - pub stderr: &'a str, - pub interrupted: bool, - pub timed_out: bool, - pub exit_code: i32, -} - -pub fn render_remote_shell_result(request: RemoteShellResultRenderRequest<'_>) -> String { - let mut result_string = String::new(); - result_string.push_str("true"); - result_string.push_str(&format!("{}", request.exit_code)); - if !request.working_directory.is_empty() { - result_string.push_str(&format!( - "{}", - request.working_directory - )); - } - - let (stdout_budget, stderr_budget) = remote_stream_budgets(request.stdout, request.stderr); - - if let Some(stdout_block) = - render_output_block_with_limit("stdout", request.stdout, stdout_budget) - { - result_string.push_str(&stdout_block); - } - if let Some(stderr_block) = - render_output_block_with_limit("stderr", request.stderr, stderr_budget) - { - result_string.push_str(&stderr_block); - } - - if request.timed_out { - result_string.push_str( - "Command timed out before completion. Partial stdout/stderr, if any, is included above.", - ); - } else if request.interrupted { - result_string.push_str( - "Command was canceled before completion. ASK THE USER what they would like to do next.", - ); - } - - result_string -} - -pub struct BackgroundCommandStatusFacts { - pub exit_code: Option, - pub timed_out: bool, - pub interrupted: bool, -} - -pub struct BackgroundCommandDeliveryTextRequest<'a> { - pub command: &'a str, - pub terminal_session_id: &'a str, - pub working_directory: &'a str, - pub status: BackgroundCommandStatusFacts, - pub output_file_reference: &'a str, - pub output_persist_error: Option<&'a str>, -} - -pub fn format_background_command_delivery_text( - request: BackgroundCommandDeliveryTextRequest<'_>, -) -> String { - let (status, summary) = if request.status.timed_out { - ("timeout", "Background Bash command timed out.") - } else if request.status.interrupted { - ("interrupted", "Background Bash command was interrupted.") - } else if request.status.exit_code == Some(0) { - ( - "completed", - "Background Bash command completed successfully.", - ) - } else { - ( - "failed", - "Background Bash command completed with a non-zero exit code.", - ) - }; - let exit_code_attr = request - .status - .exit_code - .map(|code| format!(" exit_code=\"{}\"", code)) - .unwrap_or_default(); - let persistence_line = request.output_persist_error.map_or_else( - || { - format!( - "Full output was saved to: {}", - request.output_file_reference - ) - }, - |error| { - format!( - "Output persistence encountered an error while writing {}: {}", - request.output_file_reference, error - ) - }, - ); - - format!( - "{summary}\n\nCommand: {command}\nWorking directory: {working_directory}\n{persistence_line}\n", - terminal_session_id = request.terminal_session_id, - command = request.command, - working_directory = request.working_directory, - ) -} - -pub fn format_background_command_display_text(status: BackgroundCommandStatusFacts) -> String { - if status.timed_out { - "Background Bash command timed out.".to_string() - } else if status.interrupted { - "Background Bash command was interrupted.".to_string() - } else if status.exit_code == Some(0) { - "Background Bash command completed successfully.".to_string() - } else { - "Background Bash command completed with a non-zero exit code.".to_string() - } -} - -pub struct BackgroundCommandErrorTextRequest<'a> { - pub command: &'a str, - pub terminal_session_id: &'a str, - pub working_directory: &'a str, - pub output_file_reference: &'a str, - pub error: &'a str, - pub output_persist_error: Option<&'a str>, -} - -pub fn format_background_command_error_text( - request: BackgroundCommandErrorTextRequest<'_>, -) -> String { - let persistence_line = request.output_persist_error.map_or_else( - || { - format!( - "Any captured output was saved to: {}", - request.output_file_reference - ) - }, - |persist_error| { - format!( - "Output persistence encountered an error while writing {}: {}", - request.output_file_reference, persist_error - ) - }, - ); - - format!( - "Background Bash command failed before producing a final completion result.\n\nCommand: {command}\nWorking directory: {working_directory}\n{persistence_line}\nError: {error}\n", - terminal_session_id = request.terminal_session_id, - command = request.command, - working_directory = request.working_directory, - error = request.error, - ) -} - -pub fn format_background_command_error_display_text() -> String { - "Background Bash command failed before producing a final completion result.".to_string() -} diff --git a/src/crates/execution/tool-execution/tests/tool_io_contracts.rs b/src/crates/execution/tool-execution/tests/tool_io_contracts.rs index aca6b76376..705324b924 100644 --- a/src/crates/execution/tool-execution/tests/tool_io_contracts.rs +++ b/src/crates/execution/tool-execution/tests/tool_io_contracts.rs @@ -17,16 +17,7 @@ use tool_runtime::search::grep_search::{ apply_offset_and_limit, build_remote_grep_command, count_remote_grep_matches, relativize_result_text, render_remote_grep_result_text, OutputMode, RemoteGrepCommandRequest, }; -use tool_runtime::shell::{ - banned_shell_command, bash_noninteractive_env, command_for_working_directory, - detect_osascript_im_app, detect_osascript_keystroke_non_ascii, - format_background_command_delivery_text, format_background_command_display_text, - format_background_command_error_display_text, format_background_command_error_text, - render_local_shell_result, render_output_block_with_limit, render_remote_shell_result, - BackgroundCommandDeliveryTextRequest, BackgroundCommandErrorTextRequest, - BackgroundCommandStatusFacts, LocalShellResultRenderRequest, RemoteShellResultRenderRequest, - BASH_RESULT_MAX_OUTPUT_LENGTH, -}; +use tool_runtime::shell::noninteractive_terminal_env; use tool_runtime::util::string::shell_single_quote; fn make_temp_dir(name: &str) -> PathBuf { @@ -337,15 +328,8 @@ fn shell_single_quote_preserves_existing_remote_escape_style() { } #[test] -fn bash_shell_owner_preserves_command_wrapping_and_env() { - assert_eq!( - command_for_working_directory("pnpm test", Some(" C:/repo/a'b ")), - "cd 'C:/repo/a'\\''b' && pnpm test" - ); - assert_eq!(command_for_working_directory("pwd", Some(" ")), "pwd"); - assert_eq!(command_for_working_directory("pwd", None), "pwd"); - - let env = bash_noninteractive_env(); +fn noninteractive_terminal_env_preserves_agent_session_contract() { + let env = noninteractive_terminal_env(); assert_eq!( env.get("BITFUN_NONINTERACTIVE").map(String::as_str), Some("1") @@ -357,109 +341,6 @@ fn bash_shell_owner_preserves_command_wrapping_and_env() { Some("0") ); assert_eq!(env.get("GIT_EDITOR").map(String::as_str), Some("true")); - - assert_eq!(banned_shell_command("alias ll='ls -la'"), Some("alias")); - assert_eq!(banned_shell_command(" git status "), None); -} - -#[test] -fn bash_shell_owner_preserves_guard_and_result_rendering() { - assert_eq!( - detect_osascript_keystroke_non_ascii( - r#"osascript -e 'tell app "System Events" to keystroke "你好"'"# - ), - Some("你好".to_string()) - ); - assert_eq!( - detect_osascript_im_app(r#"osascript -e 'tell application "Slack" to activate'"#), - Some("Slack") - ); - assert_eq!( - detect_osascript_im_app(r#"osascript -e 'tell application "微信" to activate'"#), - Some("微信") - ); - - let local = render_local_shell_result(LocalShellResultRenderRequest { - terminal_session_id: "term-1", - working_directory: "C:/repo", - output_text: "\u{1b}[31mhello\u{1b}[0m", - interrupted: true, - timed_out: false, - exit_code: 130, - shell_state: Some("\u{1b}[32mPS C:/repo>\u{1b}[0m"), - }); - assert!(local.contains("130")); - assert!(local.contains("C:/repo")); - assert!(local.contains("hello")); - assert!(local.contains("PS C:/repo>")); - assert!(local.contains("")); - assert!(local.contains("term-1")); - - let truncated = - render_output_block_with_limit("stdout", "abcdef", 4).expect("output block should render"); - assert!(truncated.contains("truncated=\"true\"")); - assert!(truncated.ends_with(">cdef")); - - let remote = render_remote_shell_result(RemoteShellResultRenderRequest { - working_directory: "/repo", - stdout: "ok", - stderr: "err", - interrupted: false, - timed_out: true, - exit_code: 124, - }); - assert!(remote.contains("true")); - assert!(remote.contains("ok")); - assert!(remote.contains("err")); - assert!(remote.contains("")); - assert_eq!(BASH_RESULT_MAX_OUTPUT_LENGTH, 30_000); -} - -#[test] -fn bash_shell_owner_preserves_background_delivery_texts() { - let delivery = format_background_command_delivery_text(BackgroundCommandDeliveryTextRequest { - command: "pnpm dev", - terminal_session_id: "term-bg", - working_directory: "C:/repo", - status: BackgroundCommandStatusFacts { - exit_code: Some(0), - timed_out: false, - interrupted: false, - }, - output_file_reference: "artifact://tool-results/bg.txt", - output_persist_error: None, - }); - assert!(delivery.starts_with("Background Bash command completed successfully.")); - assert!(delivery.contains( - "" - )); - assert!(delivery.contains("Full output was saved to: artifact://tool-results/bg.txt")); - - assert_eq!( - format_background_command_display_text(BackgroundCommandStatusFacts { - exit_code: None, - timed_out: true, - interrupted: false, - }), - "Background Bash command timed out." - ); - - let error = format_background_command_error_text(BackgroundCommandErrorTextRequest { - command: "pnpm dev", - terminal_session_id: "term-bg", - working_directory: "C:/repo", - output_file_reference: "artifact://tool-results/bg.txt", - error: "boom", - output_persist_error: Some("disk full"), - }); - assert!(error - .starts_with("Background Bash command failed before producing a final completion result.")); - assert!(error.contains("Output persistence encountered an error")); - assert!(error.contains("Error: boom")); - assert_eq!( - format_background_command_error_display_text(), - "Background Bash command failed before producing a final completion result." - ); } #[test] diff --git a/src/crates/execution/tool-provider-groups/src/lib.rs b/src/crates/execution/tool-provider-groups/src/lib.rs index 99e2aa753c..ba737c57ea 100644 --- a/src/crates/execution/tool-provider-groups/src/lib.rs +++ b/src/crates/execution/tool-provider-groups/src/lib.rs @@ -91,7 +91,7 @@ pub fn tool_feature_group(tool_name: &str) -> Option { | "WriteStdin" | "ExecControl" | "GetTime" | "ListModels" => { Some(ToolPackFeatureGroup::Basic) } - "Git" | "Worktree" | "ReviewPlatform" | "GetFileDiff" => Some(ToolPackFeatureGroup::Git), + "Worktree" | "ReviewPlatform" | "GetFileDiff" => Some(ToolPackFeatureGroup::Git), "ListMCPResources" | "ReadMCPResource" | "ListMCPPrompts" | "GetMCPPrompt" => { Some(ToolPackFeatureGroup::Mcp) } @@ -222,7 +222,7 @@ const PRODUCT_TOOL_PROVIDER_GROUP_PLAN: &[ToolProviderGroupPlan] = &[ ToolProviderGroupPlan { provider_id: "core.git", feature_groups: CORE_GIT_FEATURE_GROUPS, - tool_names: &["GetFileDiff", "Git", "Worktree", "ReviewPlatform"], + tool_names: &["GetFileDiff", "Worktree", "ReviewPlatform"], }, ToolProviderGroupPlan { provider_id: "core.web", @@ -545,7 +545,6 @@ mod tests { "Cron", "PortForward", "GetFileDiff", - "Git", "Worktree", "ReviewPlatform", "WebSearch", diff --git a/src/crates/interfaces/acp/src/client/stream.rs b/src/crates/interfaces/acp/src/client/stream.rs index 520ed57d94..dc15d9ba06 100644 --- a/src/crates/interfaces/acp/src/client/stream.rs +++ b/src/crates/interfaces/acp/src/client/stream.rs @@ -461,8 +461,8 @@ mod tests { fn tool_event(id: &str) -> AcpClientStreamEvent { AcpClientStreamEvent::ToolEvent(ToolEventData::Started { - identity: bitfun_events::ToolEventIdentity::direct(id, "Bash"), - params: json!({ "command": "echo ok" }), + identity: bitfun_events::ToolEventIdentity::direct(id, "ExecCommand"), + params: json!({ "cmd": "echo ok" }), timeout_seconds: None, }) } diff --git a/src/crates/interfaces/acp/src/client/tool_card_bridge/mod.rs b/src/crates/interfaces/acp/src/client/tool_card_bridge/mod.rs index 2eb357a450..41da64a524 100644 --- a/src/crates/interfaces/acp/src/client/tool_card_bridge/mod.rs +++ b/src/crates/interfaces/acp/src/client/tool_card_bridge/mod.rs @@ -34,28 +34,28 @@ mod tests { use serde_json::json; #[test] - fn normalizes_execute_tools_to_bash_card() { + fn normalizes_execute_tools_to_exec_command_card() { let input = json!({ "command": "pnpm test" }); assert_eq!( acp_tool_name("Run shell command", Some(&input), Some(&ToolKind::Execute)), - "Bash" + "ExecCommand" ); - let params = normalize_tool_params("Bash", json!({ "cmd": "ls -la" })); - assert_eq!(params["command"], "ls -la"); + let params = normalize_tool_params("ExecCommand", json!({ "command": "ls -la" })); + assert_eq!(params["cmd"], "ls -la"); } #[test] - fn normalizes_bash_command_arrays_to_display_string() { + fn normalizes_exec_command_arrays_to_display_string() { let params = normalize_tool_params( - "Bash", + "ExecCommand", json!({ "command": ["/bin/zsh", "-lc", "sed -n '1,120p' src/lib.rs"], "cwd": "/tmp/project" }), ); - assert_eq!(params["command"], "/bin/zsh -lc sed -n '1,120p' src/lib.rs"); + assert_eq!(params["cmd"], "/bin/zsh -lc sed -n '1,120p' src/lib.rs"); assert_eq!(params["cwd"], "/tmp/project"); } @@ -145,10 +145,10 @@ mod tests { "Read" ); - let bash_input = json!({ "command": "cargo test" }); + let command_input = json!({ "command": "cargo test" }); assert_eq!( - acp_tool_name("bash", Some(&bash_input), Some(&ToolKind::Execute)), - "Bash" + acp_tool_name("shell", Some(&command_input), Some(&ToolKind::Execute)), + "ExecCommand" ); // An unknown harness tool keeps its own name rather than being forced @@ -166,7 +166,7 @@ mod tests { #[test] fn splits_the_str_replace_editor_family_by_its_command() { // One tool name, several operations. Without the split every call would - // land on Bash, because the `command` key looks like a shell call. + // land on ExecCommand, because the `command` key looks like a shell call. let edit_input = json!({ "command": "str_replace", "path": "/repo/src/lib.rs", @@ -218,7 +218,7 @@ mod tests { fn code_mode_gets_its_own_card_rather_than_an_empty_terminal() { // DeepSeek Harness's PTC preset answers every step with one `run_code` // call whose argument is a TypeScript program. As an Execute kind with - // no `command`, it used to land on the Bash card and render blank. + // no `command`, it used to land on the command card and render blank. let input = json!({ "code": "const files = await tools.bash({ command: \"ls\" });", "description": "List the project root", @@ -246,7 +246,7 @@ mod tests { "print(1)" ); - // A shell call that happens to carry a `code` argument is still Bash: + // A shell call that happens to carry a `code` argument is still ExecCommand: // the command is what ran. assert_eq!( acp_tool_name( @@ -254,7 +254,7 @@ mod tests { Some(&json!({ "command": "echo hi", "code": "unused" })), Some(&ToolKind::Execute) ), - "Bash" + "ExecCommand" ); } @@ -264,11 +264,11 @@ mod tests { // rawInput shape keeps deciding these. assert_eq!( acp_tool_name( - "Run Bash", + "Run shell", Some(&json!({ "command": "ls" })), Some(&ToolKind::Execute) ), - "Bash" + "ExecCommand" ); assert_eq!( acp_tool_name( diff --git a/src/crates/interfaces/acp/src/client/tool_card_bridge/tool_name.rs b/src/crates/interfaces/acp/src/client/tool_card_bridge/tool_name.rs index db51570d8f..54adc830fa 100644 --- a/src/crates/interfaces/acp/src/client/tool_card_bridge/tool_name.rs +++ b/src/crates/interfaces/acp/src/client/tool_card_bridge/tool_name.rs @@ -21,7 +21,7 @@ pub(super) fn acp_tool_name( // rawInput is the tool arguments (`{todos}`, `{command}`, …) with no name // key at all, so `todo_write` would otherwise reach the heuristics below and // match their "write" substring as a Write card. The match is whole-string, - // so a descriptive title like "Run Bash" still falls through. + // so a descriptive title like "Run shell" still falls through. let trimmed_title = title.trim(); let from_title = normalize_known_tool_alias(trimmed_title); if from_title != trimmed_title || is_native_tool_name(&from_title) { @@ -36,7 +36,7 @@ pub(super) fn acp_tool_name( /// /// The name alone cannot answer which card to draw: every view would open an /// Edit card. Worse, the generic path never gets that far — the `command` key -/// reads as a shell call and the whole family lands on Bash. DeepSeek Harness's +/// reads as a shell call and the whole family lands on ExecCommand. DeepSeek Harness's /// minimal preset ships exactly this tool as one of its two. fn editor_family_tool_name(name: &str, raw_input: Option<&serde_json::Value>) -> Option { if !matches!( @@ -108,7 +108,7 @@ fn normalize_tool_name( return "RunCode".to_string(); } if has_any_key(input, &["command", "cmd"]) { - return "Bash".to_string(); + return "ExecCommand".to_string(); } if has_any_key( input, @@ -180,7 +180,7 @@ fn normalize_tool_name( "run command", ], ) { - return "Bash".to_string(); + return "ExecCommand".to_string(); } if contains_any(&haystack, &["list", "directory", "folder", "ls"]) { return "LS".to_string(); @@ -216,7 +216,7 @@ fn normalize_tool_name( Some(ToolKind::Delete) => "Delete".to_string(), Some(ToolKind::Move) => "Edit".to_string(), Some(ToolKind::Search) => "Grep".to_string(), - Some(ToolKind::Execute) => "Bash".to_string(), + Some(ToolKind::Execute) => "ExecCommand".to_string(), Some(ToolKind::Fetch) => "WebSearch".to_string(), Some(ToolKind::Think) | Some(ToolKind::SwitchMode) | Some(ToolKind::Other) | Some(_) => { fallback_tool_name(candidate, title) @@ -244,7 +244,9 @@ fn normalize_known_tool_alias(name: &str) -> String { "ls" | "list" | "list_dir" | "list_directory" | "readdir" => "LS".to_string(), "grep" | "rg" | "search" | "text_search" => "Grep".to_string(), "glob" | "find" | "file_search" => "Glob".to_string(), - "bash" | "sh" | "shell" | "terminal" | "command" | "cmd" | "execute" => "Bash".to_string(), + "bash" | "sh" | "shell" | "terminal" | "command" | "cmd" | "execute" => { + "ExecCommand".to_string() + } "write" | "write_file" | "create" => "Write".to_string(), "edit" | "patch" | "replace" | "update" => "Edit".to_string(), "delete" | "remove" | "rm" => "Delete".to_string(), @@ -265,7 +267,7 @@ fn is_native_tool_name(name: &str) -> bool { | "LS" | "Grep" | "Glob" - | "Bash" + | "ExecCommand" | "RunCode" | "TodoWrite" | "WebSearch" diff --git a/src/crates/interfaces/acp/src/client/tool_card_bridge/tool_params.rs b/src/crates/interfaces/acp/src/client/tool_card_bridge/tool_params.rs index 02eff11552..57f032bb78 100644 --- a/src/crates/interfaces/acp/src/client/tool_card_bridge/tool_params.rs +++ b/src/crates/interfaces/acp/src/client/tool_card_bridge/tool_params.rs @@ -8,15 +8,15 @@ pub(super) fn normalize_tool_params( let mut normalized = object.clone(); match tool_name { - "Bash" => { - if !normalized.contains_key("command") { - if let Some(value) = normalized.get("cmd").cloned() { - normalized.insert("command".to_string(), value); + "ExecCommand" => { + if !normalized.contains_key("cmd") { + if let Some(value) = normalized.get("command").cloned() { + normalized.insert("cmd".to_string(), value); } } - if let Some(value) = normalized.get("command").cloned() { + if let Some(value) = normalized.get("cmd").cloned() { normalized.insert( - "command".to_string(), + "cmd".to_string(), serde_json::Value::String(command_value_to_display_text(&value)), ); } diff --git a/src/crates/interfaces/acp/src/client/tool_card_bridge/tool_result.rs b/src/crates/interfaces/acp/src/client/tool_card_bridge/tool_result.rs index 713dd88e9b..babd79a8b9 100644 --- a/src/crates/interfaces/acp/src/client/tool_card_bridge/tool_result.rs +++ b/src/crates/interfaces/acp/src/client/tool_card_bridge/tool_result.rs @@ -19,7 +19,7 @@ pub(super) fn normalize_tool_result(tool_name: &str, result: Value) -> Value { match tool_name { "Grep" => normalize_grep_result(result), "Glob" => normalize_glob_result(result), - "Bash" | "RunCode" => normalize_output_result(result), + "ExecCommand" | "RunCode" => normalize_output_result(result), _ => result, } } @@ -328,19 +328,20 @@ mod tests { #[test] fn lifts_command_and_program_output_to_the_field_those_cards_read() { - let bash = normalize_tool_result("Bash", harness_result("total 0\ndrwxr-xr-x 4 user")); - assert_eq!(bash["output"], "total 0\ndrwxr-xr-x 4 user"); + let command = + normalize_tool_result("ExecCommand", harness_result("total 0\ndrwxr-xr-x 4 user")); + assert_eq!(command["output"], "total 0\ndrwxr-xr-x 4 user"); // The envelope stays intact for the raw view. - assert_eq!(bash["role"], "user"); + assert_eq!(command["role"], "user"); let code = normalize_tool_result("RunCode", harness_result("42")); assert_eq!(code["output"], "42"); // An agent that already speaks the card's shape is left alone. let native = json!({ "output": "done", "exit_code": 0 }); - assert_eq!(normalize_tool_result("Bash", native.clone()), native); + assert_eq!(normalize_tool_result("ExecCommand", native.clone()), native); let piped = json!({ "stdout": "done", "stderr": "" }); - assert_eq!(normalize_tool_result("Bash", piped.clone()), piped); + assert_eq!(normalize_tool_result("ExecCommand", piped.clone()), piped); // Nothing to lift: a result with no text is untouched rather than // given an empty output that reads as "the command printed nothing". diff --git a/src/crates/interfaces/acp/src/runtime/events.rs b/src/crates/interfaces/acp/src/runtime/events.rs index 35bab00709..a53e74df0f 100644 --- a/src/crates/interfaces/acp/src/runtime/events.rs +++ b/src/crates/interfaces/acp/src/runtime/events.rs @@ -563,7 +563,7 @@ mod tests { fn completed_event_maps_to_completed_update_with_output() { let mut seen = HashSet::new(); let event = ToolEventData::Completed { - identity: identity("Bash"), + identity: identity("ExecCommand"), result: serde_json::json!({ "stdout": "ok" }), result_for_assistant: Some("done".to_string()), image_attachments: None, @@ -1010,7 +1010,7 @@ mod tests { fn replay_without_result_defaults_to_in_progress() { // No status, no interruption reason: the stored state is indeterminate, // so the replayed card stays InProgress (matches live streaming shape). - let item = replay_tool_item("tool-1", "Bash", None, None); + let item = replay_tool_item("tool-1", "ExecCommand", None, None); let update = replay_update(&item); assert_eq!(update.fields.status, Some(ToolCallStatus::InProgress)); assert!(update.fields.raw_output.is_none()); @@ -1019,7 +1019,7 @@ mod tests { #[test] fn replay_with_running_status_stays_in_progress() { - let item = replay_tool_item("tool-1", "Bash", Some("running"), None); + let item = replay_tool_item("tool-1", "ExecCommand", Some("running"), None); let update = replay_update(&item); assert_eq!(update.fields.status, Some(ToolCallStatus::InProgress)); } @@ -1029,14 +1029,14 @@ mod tests { // `build_model_rounds_from_messages` stamps `completed` on tool items // whose results live in separate tool_result messages; that is not a // terminal-without-result signal, so we must not flip it to Failed. - let item = replay_tool_item("tool-1", "Bash", Some("completed"), None); + let item = replay_tool_item("tool-1", "ExecCommand", Some("completed"), None); let update = replay_update(&item); assert_eq!(update.fields.status, Some(ToolCallStatus::InProgress)); } #[test] fn replay_with_interruption_reason_settles_to_failed() { - let item = replay_tool_item("tool-1", "Bash", None, Some("cancelled")); + let item = replay_tool_item("tool-1", "ExecCommand", None, Some("cancelled")); let update = replay_update(&item); assert_eq!(update.fields.status, Some(ToolCallStatus::Failed)); assert_eq!( @@ -1056,7 +1056,7 @@ mod tests { #[test] fn replay_with_cancelled_status_settles_to_failed() { - let item = replay_tool_item("tool-1", "Bash", Some("cancelled"), None); + let item = replay_tool_item("tool-1", "ExecCommand", Some("cancelled"), None); let update = replay_update(&item); assert_eq!(update.fields.status, Some(ToolCallStatus::Failed)); assert_eq!( @@ -1067,7 +1067,7 @@ mod tests { #[test] fn replay_with_error_status_settles_to_failed() { - let item = replay_tool_item("tool-1", "Bash", Some("error"), None); + let item = replay_tool_item("tool-1", "ExecCommand", Some("error"), None); let update = replay_update(&item); assert_eq!(update.fields.status, Some(ToolCallStatus::Failed)); assert_eq!( @@ -1078,7 +1078,7 @@ mod tests { #[test] fn replay_interruption_reason_takes_precedence_over_running_status() { - let item = replay_tool_item("tool-1", "Bash", Some("running"), Some("aborted")); + let item = replay_tool_item("tool-1", "ExecCommand", Some("running"), Some("aborted")); let update = replay_update(&item); assert_eq!(update.fields.status, Some(ToolCallStatus::Failed)); assert_eq!( @@ -1089,7 +1089,7 @@ mod tests { #[test] fn replay_with_blank_interruption_reason_falls_back_to_status() { - let item = replay_tool_item("tool-1", "Bash", Some("cancelled"), Some(" ")); + let item = replay_tool_item("tool-1", "ExecCommand", Some("cancelled"), Some(" ")); let update = replay_update(&item); assert_eq!(update.fields.status, Some(ToolCallStatus::Failed)); assert_eq!( diff --git a/src/crates/services/services-core/src/session_usage/classifier.rs b/src/crates/services/services-core/src/session_usage/classifier.rs index d9831adaa8..6366baad02 100644 --- a/src/crates/services/services-core/src/session_usage/classifier.rs +++ b/src/crates/services/services-core/src/session_usage/classifier.rs @@ -37,7 +37,14 @@ fn is_file_tool(tool_name: &str) -> bool { fn is_shell_tool(tool_name: &str) -> bool { matches!( tool_name, - "shell" | "terminal" | "run_command" | "execute_command" | "bash" | "powershell" + "shell" + | "terminal" + | "run_command" + | "execute_command" + | "execcommand" + | "exec_command" + | "bash" + | "powershell" ) } @@ -70,7 +77,7 @@ mod tests { use super::*; #[test] - fn classify_dedicated_git_tool_as_git() { + fn classify_legacy_dedicated_git_tool_as_git() { assert_eq!( classify_tool_usage("git_status", None), UsageToolCategory::Git @@ -87,6 +94,16 @@ mod tests { ); } + #[test] + fn classify_exec_command_git_executable_as_git() { + let input = serde_json::json!({ "cmd": "git status --short" }); + + assert_eq!( + classify_tool_usage("ExecCommand", Some(&input)), + UsageToolCategory::Git + ); + } + #[test] fn do_not_classify_command_containing_git_text_as_git() { let input = serde_json::json!({ "command": "echo git status" }); diff --git a/src/crates/services/terminal/docs/STREAMING_OUTPUT_COLLECTION.md b/src/crates/services/terminal/docs/STREAMING_OUTPUT_COLLECTION.md index 6ab1549460..3c3acd17d9 100644 --- a/src/crates/services/terminal/docs/STREAMING_OUTPUT_COLLECTION.md +++ b/src/crates/services/terminal/docs/STREAMING_OUTPUT_COLLECTION.md @@ -10,8 +10,8 @@ Output collection involves two independent layers: ``` ┌──────────────────────────────────────────────────────────────┐ -│ bash_tool.rs │ -│ Consumes CommandStream, accumulates output for tool result │ +│ Terminal command client │ +│ Consumes CommandStream and accumulates command output │ └────────────────────────┬─────────────────────────────────────┘ │ CommandStream (mpsc channel) │ Started / Output / Completed / Error @@ -179,14 +179,14 @@ can still be collected before the stream completes as `TimedOut`. ## Interaction Between Layers -A typical bash tool execution timeline: +A typical streamed terminal command timeline: ``` Time PTY Data Stream integration.rs manager.rs ───── ───────────────────────── ───────────────────────── ────────────────── 0ms 633;A state → Prompt 2ms 633;B state → Input - 4ms (bash_tool writes cmd+\n) + 4ms (caller writes cmd+\n) 6ms 633;E;ls record command text 8ms 633;C state → Executing poll: Executing output_buffer.clear() diff --git a/src/crates/services/terminal/src/shell/scripts/shellIntegration.ps1 b/src/crates/services/terminal/src/shell/scripts/shellIntegration.ps1 index d190eb6529..75387bfe56 100644 --- a/src/crates/services/terminal/src/shell/scripts/shellIntegration.ps1 +++ b/src/crates/services/terminal/src/shell/scripts/shellIntegration.ps1 @@ -175,7 +175,7 @@ if (Get-Module -Name PSReadLine) { [Console]::Write("$([char]0x1b)]633;P;ContinuationPrompt=$(__Terminal-Escape-Value $Global:__TerminalState.ContinuationPrompt)`a") } - # For programmatic terminals (bash_tool), disable PSReadLine inline + # For programmatic terminal sessions, disable PSReadLine inline # prediction to prevent ConPTY rendering interference. ConPTY's async # renderer can flush prediction rendering (cursor repositioning, partial # text fragments) AFTER the 633;C marker, polluting captured output. @@ -183,4 +183,3 @@ if (Get-Module -Name PSReadLine) { try { Set-PSReadLineOption -PredictionSource None } catch {} } } - diff --git a/src/shared/interactive-capabilities/catalog.json b/src/shared/interactive-capabilities/catalog.json index 01230a6f78..e7d6f16e2f 100644 --- a/src/shared/interactive-capabilities/catalog.json +++ b/src/shared/interactive-capabilities/catalog.json @@ -1788,15 +1788,15 @@ "control": { "kind": "delegate", "tools": [ - "Git" + "ExecCommand" ], "workflowZh": [ - "若 Git 是延迟工具,先用 GetToolSpec 加载其契约,再按返回的 operation/args 结构调用", - "使用 Git 的 status 与 diff 检查当前工作树、暂存区和目标文件差异" + "在当前工作区通过 ExecCommand 运行 git status 与 git diff", + "检查当前工作树、暂存区和目标文件差异" ], "workflowEn": [ - "If Git is deferred, load its contract with GetToolSpec first, then call it with the returned operation/args shape", - "Use Git status and diff to inspect the working tree, index, and selected file changes" + "Run git status and git diff through ExecCommand in the current workspace", + "Inspect the working tree, index, and selected file changes" ] }, "evidence": [ @@ -1815,15 +1815,15 @@ "control": { "kind": "delegate", "tools": [ - "Git" + "ExecCommand" ], "workflowZh": [ - "若 Git 是延迟工具,先用 GetToolSpec 加载其契约,再按返回的 operation/args 结构调用", - "使用 Git 的 add、restore 或 reset 完成暂存、取消暂存和选定提交重置;危险参数仍经过权限与安全检查" + "通过 ExecCommand 运行 git add、git restore 或 git reset", + "完成暂存、取消暂存和选定提交重置;危险参数仍经过权限与安全检查" ], "workflowEn": [ - "If Git is deferred, load its contract with GetToolSpec first, then call it with the returned operation/args shape", - "Use Git add, restore, or reset to stage, unstage, or reset to a selected commit; risky arguments still pass permission and safety checks" + "Run git add, git restore, or git reset through ExecCommand", + "Stage, unstage, or reset to a selected commit; risky arguments still pass permission and safety checks" ] }, "evidence": [ @@ -1839,15 +1839,15 @@ "control": { "kind": "delegate", "tools": [ - "Git" + "ExecCommand" ], "workflowZh": [ - "若 Git 是延迟工具,先用 GetToolSpec 加载其契约,再按返回的 operation/args 结构调用", - "先检查 status/diff,再用 Git commit 创建经用户确认的提交" + "通过 ExecCommand 先运行 git status 与 git diff", + "再运行 git commit 创建经用户确认的提交" ], "workflowEn": [ - "If Git is deferred, load its contract with GetToolSpec first, then call it with the returned operation/args shape", - "Inspect status/diff first, then create the user-approved commit with Git commit" + "Run git status and git diff through ExecCommand first", + "Then run git commit to create the user-approved commit" ] }, "evidence": [ @@ -1863,15 +1863,15 @@ "control": { "kind": "delegate", "tools": [ - "Git" + "ExecCommand" ], "workflowZh": [ - "若 Git 是延迟工具,先用 GetToolSpec 加载其契约,再按返回的 operation/args 结构调用", - "使用 Git pull 或 push 同步远端;强制推送等高风险参数仍会被拦截或要求确认" + "通过 ExecCommand 运行 git pull 或 git push 同步远端", + "强制推送等高风险参数仍会被拦截或要求确认" ], "workflowEn": [ - "If Git is deferred, load its contract with GetToolSpec first, then call it with the returned operation/args shape", - "Use Git pull or push to synchronize remotes; high-risk arguments such as force push remain blocked or require confirmation" + "Run git pull or git push through ExecCommand to synchronize remotes", + "High-risk arguments such as force push remain blocked or require confirmation" ] }, "evidence": [ @@ -1886,15 +1886,15 @@ "control": { "kind": "delegate", "tools": [ - "Git" + "ExecCommand" ], "workflowZh": [ - "若 Git 是延迟工具,先用 GetToolSpec 加载其契约,再按返回的 operation/args 结构调用", - "使用 Git branch 查看、创建或删除分支,使用 switch/checkout 切换分支" + "通过 ExecCommand 运行 git branch 查看、创建或删除分支", + "运行 git switch 或 git checkout 切换分支" ], "workflowEn": [ - "If Git is deferred, load its contract with GetToolSpec first, then call it with the returned operation/args shape", - "Use Git branch to list, create, or delete branches and switch/checkout to change branches" + "Run git branch through ExecCommand to list, create, or delete branches", + "Run git switch or git checkout to change branches" ] }, "evidence": [ @@ -1912,14 +1912,14 @@ "control": { "kind": "delegate", "tools": [ - "Git" + "ExecCommand" ], "workflowZh": [ - "若 Git 是延迟工具,先用 GetToolSpec 加载其契约,再用 log/show 读取提交图谱与历史", + "通过 ExecCommand 运行 git log 或 git show 读取提交图谱与历史", "界面的最近访问记录仍由 Git 场景保存;Agent 读取历史不会伪装成修改该 UI 状态" ], "workflowEn": [ - "If Git is deferred, load its contract with GetToolSpec, then inspect the commit graph and history with log or show", + "Run git log or git show through ExecCommand to inspect the commit graph and history", "Recent-history UI state remains owned by the Git surface; reading history does not pretend to mutate that presentation state" ] }, @@ -1937,15 +1937,15 @@ "control": { "kind": "delegate", "tools": [ - "Git" + "ExecCommand" ], "workflowZh": [ - "若 Git 是延迟工具,先用 GetToolSpec 加载其契约,再按返回的 operation/args 结构调用", - "使用 Git show 解析 revision 并读取指定提交中的对象或文件内容" + "通过 ExecCommand 运行 git show", + "解析 revision 并读取指定提交中的对象或文件内容" ], "workflowEn": [ - "If Git is deferred, load its contract with GetToolSpec first, then call it with the returned operation/args shape", - "Use Git show to resolve a revision and inspect objects or file content from a selected commit" + "Run git show through ExecCommand", + "Resolve a revision and inspect objects or file content from a selected commit" ] }, "evidence": [ @@ -1960,15 +1960,15 @@ "control": { "kind": "delegate", "tools": [ - "Git" + "ExecCommand" ], "workflowZh": [ - "若 Git 是延迟工具,先用 GetToolSpec 加载其契约,再按返回的 operation/args 结构调用", - "使用 Git cherry-pick 启动,并通过 --continue 或 --abort 继续或中止流程" + "通过 ExecCommand 运行 git cherry-pick", + "使用 --continue 或 --abort 继续或中止流程" ], "workflowEn": [ - "If Git is deferred, load its contract with GetToolSpec first, then call it with the returned operation/args shape", - "Use Git cherry-pick to start, then --continue or --abort to continue or cancel the operation" + "Run git cherry-pick through ExecCommand", + "Use --continue or --abort to continue or cancel the operation" ] }, "evidence": [ diff --git a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json index 51f1404efb..b8d3348b1b 100644 --- a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json +++ b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json @@ -4,7 +4,7 @@ "title": "BitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "5a170c4fb33ea5bb4fe1d839ff0d405db53b82e586aead0891babafbb59b40aa", + "digest": "6094ecf9b8e3d14c0783f7f0595e3d5e007362c9d89f5655c05e7307e9caa489", "ownerDigest": "a6a8c5337fb877e641b66f5774740d5213caed4800fe9b006eb53632f7eb1c2d", "searchAcceptance": [ { @@ -3381,7 +3381,7 @@ "additionalProperties": true }, "delegateTools": [ - "Git" + "ExecCommand" ], "presentationTarget": { "kind": "scene", @@ -3426,7 +3426,7 @@ "additionalProperties": true }, "delegateTools": [ - "Git" + "ExecCommand" ], "presentationTarget": { "kind": "scene", @@ -3471,7 +3471,7 @@ "additionalProperties": true }, "delegateTools": [ - "Git" + "ExecCommand" ], "presentationTarget": { "kind": "scene", @@ -3516,7 +3516,7 @@ "additionalProperties": true }, "delegateTools": [ - "Git" + "ExecCommand" ], "presentationTarget": { "kind": "scene", @@ -3561,7 +3561,7 @@ "additionalProperties": true }, "delegateTools": [ - "Git" + "ExecCommand" ], "presentationTarget": { "kind": "scene", @@ -3606,7 +3606,7 @@ "additionalProperties": true }, "delegateTools": [ - "Git" + "ExecCommand" ], "presentationTarget": { "kind": "scene", @@ -3651,7 +3651,7 @@ "additionalProperties": true }, "delegateTools": [ - "Git" + "ExecCommand" ], "presentationTarget": { "kind": "scene", @@ -3696,7 +3696,7 @@ "additionalProperties": true }, "delegateTools": [ - "Git" + "ExecCommand" ], "presentationTarget": { "kind": "scene", @@ -19959,15 +19959,15 @@ "control": { "kind": "delegate", "tools": [ - "Git" + "ExecCommand" ], "workflowZh": [ - "若 Git 是延迟工具,先用 GetToolSpec 加载其契约,再按返回的 operation/args 结构调用", - "使用 Git 的 status 与 diff 检查当前工作树、暂存区和目标文件差异" + "在当前工作区通过 ExecCommand 运行 git status 与 git diff", + "检查当前工作树、暂存区和目标文件差异" ], "workflowEn": [ - "If Git is deferred, load its contract with GetToolSpec first, then call it with the returned operation/args shape", - "Use Git status and diff to inspect the working tree, index, and selected file changes" + "Run git status and git diff through ExecCommand in the current workspace", + "Inspect the working tree, index, and selected file changes" ] } }, @@ -19978,15 +19978,15 @@ "control": { "kind": "delegate", "tools": [ - "Git" + "ExecCommand" ], "workflowZh": [ - "若 Git 是延迟工具,先用 GetToolSpec 加载其契约,再按返回的 operation/args 结构调用", - "使用 Git 的 add、restore 或 reset 完成暂存、取消暂存和选定提交重置;危险参数仍经过权限与安全检查" + "通过 ExecCommand 运行 git add、git restore 或 git reset", + "完成暂存、取消暂存和选定提交重置;危险参数仍经过权限与安全检查" ], "workflowEn": [ - "If Git is deferred, load its contract with GetToolSpec first, then call it with the returned operation/args shape", - "Use Git add, restore, or reset to stage, unstage, or reset to a selected commit; risky arguments still pass permission and safety checks" + "Run git add, git restore, or git reset through ExecCommand", + "Stage, unstage, or reset to a selected commit; risky arguments still pass permission and safety checks" ] } }, @@ -19997,15 +19997,15 @@ "control": { "kind": "delegate", "tools": [ - "Git" + "ExecCommand" ], "workflowZh": [ - "若 Git 是延迟工具,先用 GetToolSpec 加载其契约,再按返回的 operation/args 结构调用", - "先检查 status/diff,再用 Git commit 创建经用户确认的提交" + "通过 ExecCommand 先运行 git status 与 git diff", + "再运行 git commit 创建经用户确认的提交" ], "workflowEn": [ - "If Git is deferred, load its contract with GetToolSpec first, then call it with the returned operation/args shape", - "Inspect status/diff first, then create the user-approved commit with Git commit" + "Run git status and git diff through ExecCommand first", + "Then run git commit to create the user-approved commit" ] } }, @@ -20016,15 +20016,15 @@ "control": { "kind": "delegate", "tools": [ - "Git" + "ExecCommand" ], "workflowZh": [ - "若 Git 是延迟工具,先用 GetToolSpec 加载其契约,再按返回的 operation/args 结构调用", - "使用 Git pull 或 push 同步远端;强制推送等高风险参数仍会被拦截或要求确认" + "通过 ExecCommand 运行 git pull 或 git push 同步远端", + "强制推送等高风险参数仍会被拦截或要求确认" ], "workflowEn": [ - "If Git is deferred, load its contract with GetToolSpec first, then call it with the returned operation/args shape", - "Use Git pull or push to synchronize remotes; high-risk arguments such as force push remain blocked or require confirmation" + "Run git pull or git push through ExecCommand to synchronize remotes", + "High-risk arguments such as force push remain blocked or require confirmation" ] } }, @@ -20035,15 +20035,15 @@ "control": { "kind": "delegate", "tools": [ - "Git" + "ExecCommand" ], "workflowZh": [ - "若 Git 是延迟工具,先用 GetToolSpec 加载其契约,再按返回的 operation/args 结构调用", - "使用 Git branch 查看、创建或删除分支,使用 switch/checkout 切换分支" + "通过 ExecCommand 运行 git branch 查看、创建或删除分支", + "运行 git switch 或 git checkout 切换分支" ], "workflowEn": [ - "If Git is deferred, load its contract with GetToolSpec first, then call it with the returned operation/args shape", - "Use Git branch to list, create, or delete branches and switch/checkout to change branches" + "Run git branch through ExecCommand to list, create, or delete branches", + "Run git switch or git checkout to change branches" ] } }, @@ -20054,14 +20054,14 @@ "control": { "kind": "delegate", "tools": [ - "Git" + "ExecCommand" ], "workflowZh": [ - "若 Git 是延迟工具,先用 GetToolSpec 加载其契约,再用 log/show 读取提交图谱与历史", + "通过 ExecCommand 运行 git log 或 git show 读取提交图谱与历史", "界面的最近访问记录仍由 Git 场景保存;Agent 读取历史不会伪装成修改该 UI 状态" ], "workflowEn": [ - "If Git is deferred, load its contract with GetToolSpec, then inspect the commit graph and history with log or show", + "Run git log or git show through ExecCommand to inspect the commit graph and history", "Recent-history UI state remains owned by the Git surface; reading history does not pretend to mutate that presentation state" ] } @@ -20073,15 +20073,15 @@ "control": { "kind": "delegate", "tools": [ - "Git" + "ExecCommand" ], "workflowZh": [ - "若 Git 是延迟工具,先用 GetToolSpec 加载其契约,再按返回的 operation/args 结构调用", - "使用 Git show 解析 revision 并读取指定提交中的对象或文件内容" + "通过 ExecCommand 运行 git show", + "解析 revision 并读取指定提交中的对象或文件内容" ], "workflowEn": [ - "If Git is deferred, load its contract with GetToolSpec first, then call it with the returned operation/args shape", - "Use Git show to resolve a revision and inspect objects or file content from a selected commit" + "Run git show through ExecCommand", + "Resolve a revision and inspect objects or file content from a selected commit" ] } }, @@ -20092,15 +20092,15 @@ "control": { "kind": "delegate", "tools": [ - "Git" + "ExecCommand" ], "workflowZh": [ - "若 Git 是延迟工具,先用 GetToolSpec 加载其契约,再按返回的 operation/args 结构调用", - "使用 Git cherry-pick 启动,并通过 --continue 或 --abort 继续或中止流程" + "通过 ExecCommand 运行 git cherry-pick", + "使用 --continue 或 --abort 继续或中止流程" ], "workflowEn": [ - "If Git is deferred, load its contract with GetToolSpec first, then call it with the returned operation/args shape", - "Use Git cherry-pick to start, then --continue or --abort to continue or cancel the operation" + "Run git cherry-pick through ExecCommand", + "Use --continue or --abort to continue or cancel the operation" ] } }, @@ -20188,26 +20188,39 @@ "带我查看当前代码改动", "Open the Git panel", "Show me the current code changes", - "若 Git 是延迟工具,先用 GetToolSpec 加载其契约,再按返回的 operation/args 结构调用", - "使用 Git 的 status 与 diff 检查当前工作树、暂存区和目标文件差异", - "If Git is deferred, load its contract with GetToolSpec first, then call it with the returned operation/args shape", - "Use Git status and diff to inspect the working tree, index, and selected file changes", - "使用 Git 的 add、restore 或 reset 完成暂存、取消暂存和选定提交重置;危险参数仍经过权限与安全检查", - "Use Git add, restore, or reset to stage, unstage, or reset to a selected commit; risky arguments still pass permission and safety checks", - "先检查 status/diff,再用 Git commit 创建经用户确认的提交", - "Inspect status/diff first, then create the user-approved commit with Git commit", - "使用 Git pull 或 push 同步远端;强制推送等高风险参数仍会被拦截或要求确认", - "Use Git pull or push to synchronize remotes; high-risk arguments such as force push remain blocked or require confirmation", - "使用 Git branch 查看、创建或删除分支,使用 switch/checkout 切换分支", - "Use Git branch to list, create, or delete branches and switch/checkout to change branches", - "若 Git 是延迟工具,先用 GetToolSpec 加载其契约,再用 log/show 读取提交图谱与历史", + "ExecCommand", + "在当前工作区通过 ExecCommand 运行 git status 与 git diff", + "检查当前工作树、暂存区和目标文件差异", + "Run git status and git diff through ExecCommand in the current workspace", + "Inspect the working tree, index, and selected file changes", + "通过 ExecCommand 运行 git add、git restore 或 git reset", + "完成暂存、取消暂存和选定提交重置;危险参数仍经过权限与安全检查", + "Run git add, git restore, or git reset through ExecCommand", + "Stage, unstage, or reset to a selected commit; risky arguments still pass permission and safety checks", + "通过 ExecCommand 先运行 git status 与 git diff", + "再运行 git commit 创建经用户确认的提交", + "Run git status and git diff through ExecCommand first", + "Then run git commit to create the user-approved commit", + "通过 ExecCommand 运行 git pull 或 git push 同步远端", + "强制推送等高风险参数仍会被拦截或要求确认", + "Run git pull or git push through ExecCommand to synchronize remotes", + "High-risk arguments such as force push remain blocked or require confirmation", + "通过 ExecCommand 运行 git branch 查看、创建或删除分支", + "运行 git switch 或 git checkout 切换分支", + "Run git branch through ExecCommand to list, create, or delete branches", + "Run git switch or git checkout to change branches", + "通过 ExecCommand 运行 git log 或 git show 读取提交图谱与历史", "界面的最近访问记录仍由 Git 场景保存;Agent 读取历史不会伪装成修改该 UI 状态", - "If Git is deferred, load its contract with GetToolSpec, then inspect the commit graph and history with log or show", + "Run git log or git show through ExecCommand to inspect the commit graph and history", "Recent-history UI state remains owned by the Git surface; reading history does not pretend to mutate that presentation state", - "使用 Git show 解析 revision 并读取指定提交中的对象或文件内容", - "Use Git show to resolve a revision and inspect objects or file content from a selected commit", - "使用 Git cherry-pick 启动,并通过 --continue 或 --abort 继续或中止流程", - "Use Git cherry-pick to start, then --continue or --abort to continue or cancel the operation" + "通过 ExecCommand 运行 git show", + "解析 revision 并读取指定提交中的对象或文件内容", + "Run git show through ExecCommand", + "Resolve a revision and inspect objects or file content from a selected commit", + "通过 ExecCommand 运行 git cherry-pick", + "使用 --continue 或 --abort 继续或中止流程", + "Run git cherry-pick through ExecCommand", + "Use --continue or --abort to continue or cancel the operation" ], "docsUrl": "https://playbook.openbitfun.com/capabilities/feature.git/" }, diff --git a/src/web-ui/src/app/scenes/agents/components/subagentEditorUtils.test.ts b/src/web-ui/src/app/scenes/agents/components/subagentEditorUtils.test.ts index c0d8acb429..475f5af409 100644 --- a/src/web-ui/src/app/scenes/agents/components/subagentEditorUtils.test.ts +++ b/src/web-ui/src/app/scenes/agents/components/subagentEditorUtils.test.ts @@ -13,7 +13,7 @@ const tools: SubagentEditorToolInfo[] = [ { name: 'Glob', description: 'Find files by pattern.', isReadonly: true }, { name: 'LS', description: 'List directory contents.', isReadonly: true }, { name: 'Write', description: 'Write file contents.', isReadonly: false }, - { name: 'Bash', description: 'Run shell commands.', isReadonly: false }, + { name: 'ExecCommand', description: 'Run shell commands.', isReadonly: false }, ]; describe('subagentEditorUtils', () => { @@ -32,7 +32,7 @@ describe('subagentEditorUtils', () => { 'Glob', 'LS', 'Write', - 'Bash', + 'ExecCommand', ]); }); @@ -40,13 +40,13 @@ describe('subagentEditorUtils', () => { const next = normalizeReviewModeState({ review: true, readonly: false, - selectedTools: new Set(['Read', 'Write', 'Bash']), + selectedTools: new Set(['Read', 'Write', 'ExecCommand']), availableTools: tools, }); expect(next.readonly).toBe(true); expect(Array.from(next.selectedTools)).toEqual(['Read']); - expect(next.removedToolNames).toEqual(['Write', 'Bash']); + expect(next.removedToolNames).toEqual(['Write', 'ExecCommand']); }); it('marks review subagent tooling invalid when the minimum diff or read tool is missing', () => { diff --git a/src/web-ui/src/app/startup/startupPerformanceContract.test.ts b/src/web-ui/src/app/startup/startupPerformanceContract.test.ts index 7f27f69f5c..392526319e 100644 --- a/src/web-ui/src/app/startup/startupPerformanceContract.test.ts +++ b/src/web-ui/src/app/startup/startupPerformanceContract.test.ts @@ -391,7 +391,6 @@ describe('startup performance contract', () => { it('keeps terminal xterm runtime out of session startup until terminal output is rendered', () => { const sessionSceneSource = readSource('../scenes/session/SessionScene.tsx'); const flexiblePanelSource = readSource('../components/panels/base/FlexiblePanel.tsx'); - const terminalToolCardSource = readSource('../../flow_chat/tool-cards/TerminalToolCard.tsx'); const execProcessToolCardSource = readSource('../../flow_chat/tool-cards/ExecProcessToolCardView.tsx'); const backgroundCommandOutputPanelSource = readSource( '../../flow_chat/components/background-command/BackgroundCommandOutputPanel.tsx' @@ -406,10 +405,6 @@ describe('startup performance contract', () => { expect(flexiblePanelSource).toContain( "import('@/tools/terminal/components/ConnectedTerminal')" ); - expect(terminalToolCardSource).not.toMatch(/from\s+['"]@\/tools\/terminal\/components['"]/); - expect(terminalToolCardSource).toContain( - "from '@/tools/terminal/components/LazyTerminalOutputRenderer'" - ); expect(execProcessToolCardSource).toContain( "from '@/tools/terminal/components/LazyTerminalOutputRenderer'" ); @@ -494,7 +489,6 @@ describe('startup performance contract', () => { expect(metadataSource).toContain('TOOL_CARD_CONFIGS'); expect(metadataSource).toContain('isCollapsibleTool'); - expect(metadataSource).not.toMatch(/from\s+['"]\.\/TerminalToolCard['"]/); expect(metadataSource).not.toMatch(/from\s+['"]\.\/FileOperationToolCard['"]/); expect(registrySource).not.toContain('export const TOOL_CARD_CONFIGS'); diff --git a/src/web-ui/src/component-library/components/registry.tsx b/src/web-ui/src/component-library/components/registry.tsx index dc8dc61dad..5759a09434 100644 --- a/src/web-ui/src/component-library/components/registry.tsx +++ b/src/web-ui/src/component-library/components/registry.tsx @@ -28,7 +28,6 @@ import { MCPToolDisplay } from '@/flow_chat/tool-cards/MCPToolDisplay'; import { ContextCompressionDisplay } from '@/flow_chat/tool-cards/ContextCompressionDisplay'; import { SkillDisplay } from '@/flow_chat/tool-cards/SkillDisplay'; import { AskUserQuestionCard } from '@/flow_chat/tool-cards/AskUserQuestionCard'; -import { GitToolDisplay } from '@/flow_chat/tool-cards/GitToolDisplay'; import { InitMiniAppDisplay } from '@/flow_chat/tool-cards/MiniAppToolDisplay'; import type { FlowToolItem, FlowThinkingItem } from '@/flow_chat/types/flow-chat'; import { TOOL_CARD_CONFIGS } from '@/flow_chat/tool-cards/toolCardMetadata'; @@ -1462,147 +1461,6 @@ console.log(user.greet());`); ), }, - { - id: 'git-tool-card', - name: 'Git - 版本控制卡片', - description: '展示Git操作结果的工具卡片组件', - category: 'flowchat-cards', - component: () => ( -
-

Git Status - Success

- ..." to unstage) - modified: src/components/App.tsx - new file: src/utils/helpers.ts - -Changes not staged for commit: - (use "git add ..." to update what will be committed) - modified: package.json`, - stderr: '', - execution_time_ms: 45, - working_directory: '/project', - command: 'git status', - operation: 'status' - }, - 'completed' - )} - config={TOOL_CARD_CONFIGS['Git']} - sessionId="preview-session" - /> - -

Git Commit - Success

- - -

Git Diff - View

- `, - stderr: '', - execution_time_ms: 35, - command: 'git diff HEAD~1', - operation: 'diff' - }, - 'completed' - )} - config={TOOL_CARD_CONFIGS['Git']} - sessionId="preview-session" - /> - -

Git Push - 执行中

- - -

Git Pull - 冲突错误

- -
- ), - }, { id: 'init-miniapp-card', name: 'InitMiniApp - 小应用创建', diff --git a/src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts b/src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts index 630ffab435..cbbff6b7aa 100644 --- a/src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts +++ b/src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts @@ -220,7 +220,7 @@ function installProcessingProjection(): void { { id: 'tool-1', type: 'tool', - toolName: 'Bash', + toolName: 'ExecCommand', toolCall: { id: 'tool-1', input: {}, diff --git a/src/web-ui/src/flow_chat/components/FlowToolCard.tsx b/src/web-ui/src/flow_chat/components/FlowToolCard.tsx index 6751e24bea..45fc8e3d1d 100644 --- a/src/web-ui/src/flow_chat/components/FlowToolCard.tsx +++ b/src/web-ui/src/flow_chat/components/FlowToolCard.tsx @@ -52,7 +52,7 @@ export const FlowToolCard: React.FC = React.memo(({ const interruptionNote = getToolInterruptionNote(effectiveToolItem, t); const cardHandlesInterruptionNote = effectiveToolItem.toolName === 'Task'; const toolCardTestId = - effectiveToolItem.toolName === 'Bash' + effectiveToolItem.toolName === 'ExecCommand' ? 'chat-shell-tool-card' : effectiveToolItem.toolName === 'WebFetch' ? 'chat-browser-tool-card' diff --git a/src/web-ui/src/flow_chat/components/modern/virtualItemHeightEstimators.ts b/src/web-ui/src/flow_chat/components/modern/virtualItemHeightEstimators.ts index ad8f6fc158..7246bc8232 100644 --- a/src/web-ui/src/flow_chat/components/modern/virtualItemHeightEstimators.ts +++ b/src/web-ui/src/flow_chat/components/modern/virtualItemHeightEstimators.ts @@ -40,7 +40,7 @@ const TOOL_EXPANDED_BASE_HEIGHT_PX = 96; const EXPLORE_GROUP_HEADER_HEIGHT_PX = 20; const EXPLORE_GROUP_MAX_CONTENT_HEIGHT_PX = 400; const ESTIMATED_TEXT_CHARS_PER_LINE = 60; -const TERMINAL_TOOL_NAMES = new Set(['Bash', 'ExecCommand', 'WriteStdin', 'ExecControl', 'TerminalControl']); +const TERMINAL_TOOL_NAMES = new Set(['ExecCommand', 'WriteStdin', 'ExecControl']); const COLLAPSED_TOOL_STATUSES = new Set(['completed', 'cancelled', 'error', 'rejected']); function clamp(value: number, min: number, max: number): number { diff --git a/src/web-ui/src/flow_chat/components/usage/SessionUsageComponents.test.tsx b/src/web-ui/src/flow_chat/components/usage/SessionUsageComponents.test.tsx index ae1001d4b9..768314d7a6 100644 --- a/src/web-ui/src/flow_chat/components/usage/SessionUsageComponents.test.tsx +++ b/src/web-ui/src/flow_chat/components/usage/SessionUsageComponents.test.tsx @@ -1574,7 +1574,7 @@ describe('Session usage report UI components', () => { const report = usageReport({ slowest: [ { - label: 'Bash', + label: 'ExecCommand', kind: 'tool', durationMs: 95_000, redacted: false, @@ -1605,7 +1605,7 @@ describe('Session usage report UI components', () => { slowestTab?.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); }); - expect(container.textContent).toContain('Bash'); + expect(container.textContent).toContain('ExecCommand'); expect(container.textContent).toContain('Input'); expect(container.textContent).toContain('curl https://api.example.test/slow'); expect(container.textContent).toContain('Status'); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts index 742bc8654c..8d4409c1c8 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts @@ -903,11 +903,9 @@ export function handleToolExecutionProgress( : []; const lastLog = existingLogs.length > 0 ? existingLogs[existingLogs.length - 1] : undefined; const isTerminalLikeProgress = - tool_name === 'Bash' || tool_name === 'ExecCommand' || tool_name === 'WriteStdin' || tool_name === 'ExecControl' || - (toolItem as any).toolName === 'Bash' || (toolItem as any).toolName === 'ExecCommand' || (toolItem as any).toolName === 'WriteStdin' || (toolItem as any).toolName === 'ExecControl'; diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts index 5150379772..43ca5819e1 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts @@ -3582,7 +3582,7 @@ describe('FlowChatStore historical session hydration state', () => { toolItems: [ { id: 'tool-1', - toolName: 'Bash', + toolName: 'ExecCommand', toolCall: { id: 'call-1', input: { command: 'printf output' } }, toolResult: { result: { diff --git a/src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts b/src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts index b3e0dccb49..e9fec35312 100644 --- a/src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts @@ -21,7 +21,6 @@ vi.mock('../tool-cards/toolCardMetadata', () => ({ 'GetFileDiff', 'GetToolSpec', 'ReviewSessionSummary', - 'TerminalControl', 'SessionControl', 'ExecControl', 'AgentWait', @@ -158,7 +157,6 @@ describe('sessionToVirtualItems explore grouping', () => { 'GetFileDiff', 'GetToolSpec', 'ReviewSessionSummary', - 'TerminalControl', 'SessionControl', 'ExecControl', 'view_image', @@ -215,7 +213,7 @@ describe('sessionToVirtualItems explore grouping', () => { }, ); - it.each(['Bash', 'Git', 'ExecCommand', 'TodoWrite', 'ContextCompression', 'Skill', 'SessionMessage'])( + it.each(['ExecCommand', 'TodoWrite', 'ContextCompression', 'Skill', 'SessionMessage'])( 'keeps conditionally important %s rounds visible', (toolName) => { const session = makeSession({ diff --git a/src/web-ui/src/flow_chat/tool-cards/GetToolSpecCard.test.tsx b/src/web-ui/src/flow_chat/tool-cards/GetToolSpecCard.test.tsx index e89761b06d..0f6af643b5 100644 --- a/src/web-ui/src/flow_chat/tool-cards/GetToolSpecCard.test.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/GetToolSpecCard.test.tsx @@ -42,13 +42,13 @@ function buildDetailItem(): FlowToolItem { toolCall: { id: 'call-spec-1', input: { - tool_name: 'Git', + tool_name: 'Worktree', }, }, toolResult: { success: true, result: { - tool_name: 'Git', + tool_name: 'Worktree', description: 'Inspect and operate on the Git repository.', input_schema: { type: 'object', @@ -128,7 +128,7 @@ describe('GetToolSpecCard', () => { }); expect(container.textContent).toContain('Tool Spec'); - expect(container.textContent).toContain('Loaded spec for Git'); + expect(container.textContent).toContain('Loaded spec for Worktree'); expect(container.textContent).not.toContain('Inspect and operate on the Git repository.'); expect(container.textContent).not.toContain('"command"'); diff --git a/src/web-ui/src/flow_chat/tool-cards/GitToolDisplay.test.tsx b/src/web-ui/src/flow_chat/tool-cards/GitToolDisplay.test.tsx deleted file mode 100644 index 68c89ac78b..0000000000 --- a/src/web-ui/src/flow_chat/tool-cards/GitToolDisplay.test.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import React, { act } from 'react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createRoot, type Root } from 'react-dom/client'; -import { JSDOM } from 'jsdom'; - -import { GitToolDisplay } from './GitToolDisplay'; -import { copyTextToClipboard } from '@/shared/utils/textSelection'; -import type { FlowToolItem, ToolCardConfig } from '../types/flow-chat'; - -globalThis.IS_REACT_ACT_ENVIRONMENT = true; - -vi.mock('react-i18next', async () => { - const { createTestI18nT } = await import('@/test/i18nTestUtils'); - return { - useTranslation: () => ({ - t: createTestI18nT('flow-chat'), - }), - }; -}); - -vi.mock('../../component-library', () => ({ - CubeLoading: () => , - IconButton: ({ - children, - tooltip, - ...props - }: React.ButtonHTMLAttributes & { tooltip?: React.ReactNode }) => ( - - ), - Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, -})); - -vi.mock('@/shared/utils/textSelection', () => ({ - copyTextToClipboard: vi.fn(async () => true), -})); - -vi.mock('@/shared/notification-system', () => ({ - notificationService: { - success: vi.fn(), - error: vi.fn(), - }, -})); - -describe('GitToolDisplay', () => { - let dom: JSDOM; - let container: HTMLDivElement; - let root: Root; - - beforeEach(() => { - dom = new JSDOM('
', { - pretendToBeVisual: true, - }); - vi.stubGlobal('window', dom.window); - vi.stubGlobal('document', dom.window.document); - vi.stubGlobal('HTMLElement', dom.window.HTMLElement); - vi.stubGlobal('CustomEvent', dom.window.CustomEvent); - - vi.mocked(copyTextToClipboard).mockClear(); - - container = dom.window.document.getElementById('root') as HTMLDivElement; - root = createRoot(container); - }); - - afterEach(() => { - act(() => { - root.unmount(); - }); - vi.unstubAllGlobals(); - }); - - it('offers a header action for copying the git command', async () => { - const toolItem: FlowToolItem = { - id: 'tool-git-1', - type: 'tool', - toolName: 'Git', - status: 'completed', - timestamp: Date.now(), - toolCall: { - id: 'call-git-1', - input: { - operation: 'status', - args: '--short', - }, - }, - toolResult: { - success: true, - result: { - success: true, - exit_code: 0, - stdout: ' M src/app.tsx', - command: 'git status --short', - }, - }, - }; - - const config: ToolCardConfig = { - toolName: 'Git', - displayName: 'Git', - icon: 'GIT', - requiresConfirmation: false, - resultDisplayType: 'detailed', - description: 'Run Git commands', - displayMode: 'compact', - }; - - act(() => { - root.render(); - }); - - expect(container.querySelector('[data-bf-part="surface"][data-bf-attention="prominent"]')).not.toBeNull(); - expect(container.querySelector('[data-bf-part="surface"][data-bf-attention="ambient"]')).toBeNull(); - expect(container.textContent).toContain('git status --short'); - expect(container.textContent).not.toContain('Gitgit status --short'); - - const copyButton = container.querySelector( - 'button[aria-label="Copy git command"]' - ); - expect(copyButton).not.toBeNull(); - - await act(async () => { - copyButton?.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); - await Promise.resolve(); - }); - - expect(copyTextToClipboard).toHaveBeenCalledWith('git status --short'); - }); -}); diff --git a/src/web-ui/src/flow_chat/tool-cards/GitToolDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/GitToolDisplay.tsx deleted file mode 100644 index 46959eb02f..0000000000 --- a/src/web-ui/src/flow_chat/tool-cards/GitToolDisplay.tsx +++ /dev/null @@ -1,191 +0,0 @@ -/** - * Display component for the Git tool. - */ - -import React, { useState, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import type { ToolCardProps } from '../types/flow-chat'; -import { GitToolCard } from '@bitfun/ui/flow-chat'; -import { ToolCardCopyAction } from './ToolCardCopyAction'; -import { createLogger } from '@/shared/utils/logger'; -import { useToolCardHeightContract } from './useToolCardHeightContract'; - -const log = createLogger('GitToolDisplay'); - -interface GitToolInput { - operation?: string; - args?: string; - working_directory?: string; - timeout?: number; -} - -interface GitToolResultData { - success?: boolean; - exit_code?: number; - stdout?: string; - stderr?: string; - execution_time_ms?: number; - working_directory?: string; - command?: string; - operation?: string; - timestamp?: string; -} - -export const GitToolDisplay: React.FC = ({ - toolItem, -}) => { - const { t } = useTranslation('flow-chat'); - const { status, toolCall, toolResult } = toolItem; - const [isExpanded, setIsExpanded] = useState(false); - const toolId = toolItem.id ?? toolCall?.id; - const { cardRootRef, applyExpandedState } = useToolCardHeightContract({ - toolId, - toolName: toolItem.toolName, - }); - - const getInputData = (): GitToolInput | null => { - if (!toolCall?.input) return null; - - const isEarlyDetection = toolCall.input._early_detection === true; - const isPartialParams = toolCall.input._partial_params === true; - - if (isEarlyDetection || isPartialParams) { - return null; - } - - return toolCall.input as GitToolInput; - }; - - const getResultData = (): GitToolResultData | null => { - if (!toolResult?.result) return null; - - try { - if (typeof toolResult.result === 'string') { - return JSON.parse(toolResult.result); - } - return toolResult.result as GitToolResultData; - } catch (e) { - log.error('Failed to parse result', e); - return null; - } - }; - - const inputData = getInputData(); - const resultData = getResultData(); - - const getCommandDisplay = () => { - if (resultData?.command) return resultData.command; - if (!inputData?.operation) return 'git'; - - let cmd = `git ${inputData.operation}`; - if (inputData.args) { - cmd += ` ${inputData.args}`; - } - return cmd; - }; - - const getOutputSummary = () => { - if (!resultData) return null; - - const stdout = resultData.stdout?.trim() || ''; - const stderr = resultData.stderr?.trim() || ''; - - if (!stdout && !stderr) return t('toolCards.git.noOutput'); - - const output = stdout || stderr; - const firstLine = output.split('\n')[0]; - if (firstLine.length > 60) { - return firstLine.substring(0, 60) + '...'; - } - return firstLine; - }; - - const outputSummary = getOutputSummary(); - const hasOutput = resultData && (resultData.stdout || resultData.stderr); - const commandText = getCommandDisplay(); - - const isLoading = status === 'preparing' || status === 'streaming' || status === 'running'; - - const isFailed = status === 'error' || (resultData && resultData.exit_code !== 0); - - const hasWarning = resultData && resultData.success && resultData.stderr; - - const toggleExpanded = useCallback(() => { - applyExpandedState(isExpanded, !isExpanded, setIsExpanded); - }, [applyExpandedState, isExpanded]); - - const getCopyCommandText = useCallback(() => commandText, [commandText]); - - const getErrorMessage = () => { - if (toolResult && 'error' in toolResult) { - return toolResult.error; - } - if (resultData?.stderr) { - return resultData.stderr; - } - return t('toolCards.git.executionFailed'); - }; - - const handleCardClick = useCallback(() => { - if (hasOutput || isFailed) { - toggleExpanded(); - } - }, [hasOutput, isFailed, toggleExpanded]); - const footerItems = [ - resultData?.working_directory?.trim() ? { - grow: true, - label: t('toolCards.terminal.workingDirectory'), - monospace: true, - value: resultData.working_directory, - } : null, - resultData?.exit_code !== undefined ? { - monospace: true, - tone: resultData.exit_code === 0 ? 'success' as const : 'danger' as const, - value: t('toolCards.git.exitCode', { code: resultData.exit_code }), - } : null, - resultData?.execution_time_ms !== undefined ? { - monospace: true, - value: resultData.execution_time_ms >= 1000 - ? `${(resultData.execution_time_ms / 1000).toFixed(2)}s` - : `${resultData.execution_time_ms}ms`, - } : null, - ].filter((item): item is NonNullable => Boolean(item)); - - const errorMeta = inputData?.operation - ? `${t('toolCards.git.operation', { op: inputData.operation })}${inputData.args ? ` | ${t('toolCards.git.args', { args: inputData.args })}` : ''}` - : undefined; - - return ( -
- - )} - loading={isLoading} - statusSummary={isFailed ? t('toolCards.git.failed') : outputSummary} - statusTone={isFailed ? 'danger' : hasWarning ? 'warning' : 'neutral'} - stdout={resultData?.stdout?.trim() || undefined} - stderr={resultData?.stderr?.trim() || undefined} - stderrLabel={resultData?.stderr - ? resultData.success ? t('toolCards.git.warning') : t('toolCards.git.error') - : undefined} - stderrTone={resultData?.success ? 'warning' : 'danger'} - footerItems={footerItems} - error={!resultData && isFailed ? getErrorMessage() : undefined} - errorMeta={errorMeta} - /> -
- ); -}; diff --git a/src/web-ui/src/flow_chat/tool-cards/README.md b/src/web-ui/src/flow_chat/tool-cards/README.md index 1fca399e6c..c73be86704 100644 --- a/src/web-ui/src/flow_chat/tool-cards/README.md +++ b/src/web-ui/src/flow_chat/tool-cards/README.md @@ -82,7 +82,7 @@ Current examples: - `useToolCardHeightContract` - `FileOperationToolCard` - `ModelThinkingDisplay` -- `TerminalToolCard` +- `ExecProcessToolCardView` - `ExploreGroupRenderer` For details, read: diff --git a/src/web-ui/src/flow_chat/tool-cards/TerminalControlDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/TerminalControlDisplay.tsx deleted file mode 100644 index c5a5576064..0000000000 --- a/src/web-ui/src/flow_chat/tool-cards/TerminalControlDisplay.tsx +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Compact display for the TerminalControl tool. - */ - -import React, { useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { - TerminalControlToolCard, - type FlowChatToolStatus, -} from '@bitfun/ui/flow-chat'; -import type { ToolCardProps } from '../types/flow-chat'; - -export const TerminalControlDisplay: React.FC = React.memo(({ - toolItem, -}) => { - const { t } = useTranslation('flow-chat'); - const { toolCall, status } = toolItem; - - const terminalSessionId = useMemo(() => { - return toolCall?.input?.terminal_session_id as string | undefined; - }, [toolCall?.input?.terminal_session_id]); - - const action = useMemo(() => { - return (toolCall?.input?.action as string | undefined) ?? 'kill'; - }, [toolCall?.input?.action]); - - const renderContent = () => { - const idLabel = terminalSessionId ? ` ${terminalSessionId}` : ''; - - const isInterrupt = action === 'interrupt'; - - if (status === 'completed') { - return `${isInterrupt - ? t('toolCards.terminalControl.sessionInterrupted') - : t('toolCards.terminalControl.sessionKilled')}${idLabel}`; - } - if (status === 'running' || status === 'streaming') { - return `${isInterrupt - ? t('toolCards.terminalControl.interruptingSession') - : t('toolCards.terminalControl.terminatingSession')}${idLabel}...`; - } - if (status === 'error') { - return `${isInterrupt - ? t('toolCards.terminalControl.interruptFailed') - : t('toolCards.terminalControl.killFailed')}${idLabel}`; - } - if (status === 'pending') { - return `${isInterrupt - ? t('toolCards.terminalControl.preparingInterrupt') - : t('toolCards.terminalControl.preparingKill')}${idLabel}`; - } - return null; - }; - - return ( - - ); -}); diff --git a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx deleted file mode 100644 index 6579f6f45c..0000000000 --- a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx +++ /dev/null @@ -1,442 +0,0 @@ -/** - * Terminal tool card component - * Displays command execution lifecycle: - * - receive tool parameters - * - wait for terminal output after launch - * - stream real output and final result - * - * Design notes: - * - Final lifecycle always comes from backend tool status - * - The only local interaction guard is `interruptRequested`, used to prevent - * duplicate cancel clicks before the backend status catches up - * - Live terminal output is rendered from store-managed progress logs - * - Clicking "Open Terminal in right panel" opens the full Terminal tab - */ - -import React, { useState, useRef, useCallback, useEffect, useLayoutEffect, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import type { ToolCardProps } from '../types/flow-chat'; -import { createTerminalTab } from '@/shared/utils/tabUtils'; -import { - CommandToolCard, - type CommandToolCardFooterItem, -} from '@bitfun/ui/flow-chat'; -import { LazyTerminalOutputRenderer } from '@/tools/terminal/components/LazyTerminalOutputRenderer'; -import { createLogger } from '@/shared/utils/logger'; -import { notificationService } from '@/shared/notification-system'; -import { useToolCardHeightContract } from './useToolCardHeightContract'; -import { useToolCardCompletionGracePeriod } from './useToolCardCompletionGracePeriod'; -import { getTerminalViewState, resolveCanCancelTool } from './terminalToolCardState'; -import { ToolTimeoutIndicator } from './ToolTimeoutIndicator'; -import { useCopyTextAction } from '../hooks/useCopyTextAction'; -import { formatSessionViewPreviewText } from '../utils/sessionViewPreview'; -import { api } from '@/infrastructure/api/service-api/ApiClient'; -import { usePeerDeviceModeOptional } from '@/infrastructure/peer-device/peerDeviceContextState'; - -const log = createLogger('TerminalToolCard'); -const TERMINAL_COLLAPSED_STATUSES = new Set(['completed', 'cancelled', 'error', 'rejected']); -const TERMINAL_OUTPUT_STREAMING_MAX_ROWS = 4; // Compact while streaming/executing -const TERMINAL_OUTPUT_EXPANDED_MAX_ROWS = 15; // Comfortable reading when manually expanded - -interface TerminalToolCardProps extends ToolCardProps { - terminalSessionId?: string; -} - -interface ParsedTerminalResult { - output: string; - exitCode: number; - workingDir: string; - executionTimeMs?: number; - wasInterrupted: boolean; - terminalSessionId?: string; -} - -function normalizeTerminalSessionId(value: unknown): string | undefined { - if (typeof value !== 'string' || value.startsWith('FlowChat-')) { - return undefined; - } - - return value; -} - -function isCollapsedTerminalStatus(status: string): boolean { - return TERMINAL_COLLAPSED_STATUSES.has(status); -} - -function getInitialTerminalExpandedState(status: string): boolean { - return !(isCollapsedTerminalStatus(status) || status === 'pending_confirmation'); -} - -function getAutoExpandedStateForTerminalStatus( - status: string, - isLastItem: boolean | undefined, - keepTailPreview: boolean, -): boolean | null { - if (isCollapsedTerminalStatus(status)) { - // A card that was already mounted while live keeps its compact output - // visible briefly at the tail. It collapses when a newer conversation - // item takes over or when the completion preview grace period expires. - return isLastItem === true && keepTailPreview ? null : false; - } - - if (status === 'pending_confirmation') { - return false; - } - - if (status === 'preparing' || status === 'streaming' || status === 'running') { - return true; - } - - return null; -} - -function parseTerminalResult(raw: unknown, durationMs?: number): ParsedTerminalResult { - let record: Record | null = null; - - if (raw != null && typeof raw === 'string') { - try { - record = JSON.parse(raw) as Record; - } catch { - record = null; - } - } else if (raw != null && typeof raw === 'object') { - record = raw as Record; - } - - if (!record) { - return { - output: '', - exitCode: 0, - workingDir: '', - executionTimeMs: undefined, - wasInterrupted: false, - terminalSessionId: undefined, - }; - } - - const stdout = typeof record.stdout === 'string' ? record.stdout : ''; - const stderr = typeof record.stderr === 'string' ? record.stderr : ''; - const combinedOutput = [stdout, stderr].filter((value) => value.length > 0).join('\n'); - const outputField = typeof record.output === 'string' ? record.output : ''; - const output = formatSessionViewPreviewText(outputField || combinedOutput); - - return { - output, - exitCode: typeof record.exit_code === 'number' ? record.exit_code : 0, - workingDir: typeof record.working_directory === 'string' ? record.working_directory : '', - executionTimeMs: - typeof record.execution_time_ms === 'number' - ? record.execution_time_ms - : typeof record.duration_ms === 'number' - ? record.duration_ms - : durationMs, - wasInterrupted: Boolean(record.interrupted), - terminalSessionId: normalizeTerminalSessionId(record.terminal_session_id), - }; -} - -export const TerminalToolCard: React.FC = ({ - toolItem, - onExpand, - terminalSessionId: propTerminalSessionId, - isLastItem, -}) => { - const { t } = useTranslation('flow-chat'); - const peerDevice = usePeerDeviceModeOptional(); - const toolCall = toolItem.toolCall; - const toolResult = toolItem.toolResult; - const command = toolCall?.input?.command; - const status = toolItem.status || 'pending'; - const isParamsStreaming = Boolean(toolItem.isParamsStreaming); - const progressMessage = typeof (toolItem as any)._progressMessage === 'string' - ? (toolItem as any)._progressMessage - : ''; - - const parsedResult = useMemo( - () => parseTerminalResult(toolResult?.result, toolResult?.duration_ms), - [toolResult?.duration_ms, toolResult?.result], - ); - - const terminalSessionId = useMemo( - () => normalizeTerminalSessionId(toolItem.terminalSessionId) - ?? parsedResult.terminalSessionId - ?? normalizeTerminalSessionId(propTerminalSessionId), - [parsedResult.terminalSessionId, propTerminalSessionId, toolItem.terminalSessionId], - ); - - const progressLogs = useMemo(() => { - const logs = (toolItem as any)._progressLogs; - if (!Array.isArray(logs)) { - return []; - } - - return logs.filter((entry): entry is string => typeof entry === 'string'); - }, [toolItem]); - - const liveOutput = useMemo(() => { - if (progressLogs.length > 0) { - return progressLogs.join(''); - } - - return progressMessage; - }, [progressLogs, progressMessage]); - - const toolId = toolItem.id ?? toolCall?.id; - const [isExpanded, setIsExpandedState] = useState(() => getInitialTerminalExpandedState(status)); - const userToggledRef = useRef(false); - const { - cardRootRef, - applyExpandedState, - } = useToolCardHeightContract({ - toolId, - toolName: toolItem.toolName, - }); - const { - begin: beginCompletionPreview, - isActive: isCompletionPreviewActive, - } = useToolCardCompletionGracePeriod({ - eligible: - isCollapsedTerminalStatus(status) && - isLastItem === true && - isExpanded && - !userToggledRef.current, - }); - const applyTerminalExpandedState = useCallback((nextExpanded: boolean) => { - if (nextExpanded === isExpanded) { - return; - } - - applyExpandedState(isExpanded, nextExpanded, setIsExpandedState, { onExpand }); - }, [applyExpandedState, isExpanded, onExpand]); - - const toggleExpanded = useCallback(() => { - userToggledRef.current = true; - applyTerminalExpandedState(!isExpanded); - }, [applyTerminalExpandedState, isExpanded]); - - const [interruptRequested, setInterruptRequested] = useState(false); - - useEffect(() => { - if (status !== 'running') { - setInterruptRequested(false); - } - }, [status]); - - useLayoutEffect(() => { - if (userToggledRef.current) { - return; - } - - const keepTailPreview = isCollapsedTerminalStatus(status) && beginCompletionPreview(); - const nextExpanded = getAutoExpandedStateForTerminalStatus(status, isLastItem, keepTailPreview); - if (nextExpanded !== null) { - applyTerminalExpandedState(nextExpanded); - } - }, [ - applyTerminalExpandedState, - beginCompletionPreview, - isCompletionPreviewActive, - isLastItem, - status, - ]); - - const showConfirmButtons = status === 'pending_confirmation'; - const canExecuteCommand = Boolean(command?.trim()); - const getCopyCommandText = useCallback( - () => (typeof command === 'string' ? command : ''), - [command], - ); - const { copied: commandCopied, copy: copyCommand } = useCopyTextAction({ - getText: getCopyCommandText, - successMessage: t('toolCards.terminal.commandCopied'), - failureMessage: t('toolCards.terminal.copyCommandFailed'), - }); - - // The Interrupt button is only meaningful if the current host can actually - // cancel a running tool. Resolution is centralized in resolveCanCancelTool so - // the same rule (local → true; cancelTool true/false; null resolved by - // hostKind — old Desktop supports it, old CLI doesn't) is unit-testable and - // stays consistent with the tool-catalog host-kind resolution. See PR #2428 - // round 5 #1. - const peerActive = Boolean(peerDevice?.peerMode.active); - const canCancelTool = resolveCanCancelTool( - peerActive, - peerDevice?.currentPeerCapabilities ?? null, - ); - - const viewState = useMemo(() => { - return getTerminalViewState({ - status, - liveOutput, - isParamsStreaming, - interruptRequested, - showConfirmButtons, - wasInterrupted: parsedResult.wasInterrupted, - canCancelTool, - }); - }, [ - canCancelTool, - isParamsStreaming, - interruptRequested, - liveOutput, - parsedResult.wasInterrupted, - showConfirmButtons, - status, - ]); - const waitingMessage = viewState.waitingMessageKey ? t(viewState.waitingMessageKey) : null; - - const handleInterrupt = useCallback(async (e: React.MouseEvent) => { - e.stopPropagation(); - - const toolUseId = toolCall?.id; - if (!toolUseId || interruptRequested) { - return; - } - - setInterruptRequested(true); - - try { - await api.invoke('cancel_tool', { - request: { - toolUseId, - reason: 'User cancelled', - }, - }); - } catch (error) { - setInterruptRequested(false); - log.error('Failed to send cancel signal', { toolUseId, error }); - // Surface the failure to the user instead of silently restoring the - // button: a "not supported on ... peer host" error (or a transport - // failure) means the target command keeps running and the click did - // nothing visible. See PR #2428 round 5 #1. - notificationService.error(t('toolCards.terminal.interruptFailed')); - } - }, [interruptRequested, t, toolCall?.id]); - - const handleOpenInPanel = useCallback((e: React.MouseEvent) => { - e.stopPropagation(); - if (!terminalSessionId) { - return; - } - - const terminalName = `Chat-${terminalSessionId.slice(0, 8)}`; - createTerminalTab(terminalSessionId, terminalName); - }, [terminalSessionId]); - - const compactSettledPreview = - isExpanded && - isLastItem === true && - isCollapsedTerminalStatus(status) && - !userToggledRef.current; - const isStreamingPhase = - viewState.displayPhase === 'live_output' || - viewState.displayPhase === 'receiving_params' || - viewState.displayPhase === 'executing'; - const maxRows = isStreamingPhase || compactSettledPreview - ? TERMINAL_OUTPUT_STREAMING_MAX_ROWS - : TERMINAL_OUTPUT_EXPANDED_MAX_ROWS; - const outputText = viewState.displayPhase === 'live_output' || viewState.showCancelledResult - ? liveOutput - : viewState.showCompletedResult - ? parsedResult.output - : ''; - const footerItems: CommandToolCardFooterItem[] = []; - - if (viewState.showCompletedResult) { - if (parsedResult.workingDir) { - footerItems.push({ - grow: true, - label: t('toolCards.terminal.workingDirectory'), - value: parsedResult.workingDir, - }); - } - footerItems.push({ - monospace: true, - tone: parsedResult.exitCode === 0 ? 'success' : 'danger', - value: ( - - {t('toolCards.terminal.exitCode', { code: parsedResult.exitCode })} - - ), - }); - if (parsedResult.executionTimeMs !== undefined) { - footerItems.push({ monospace: true, value: `${parsedResult.executionTimeMs}ms` }); - } - } else if (viewState.showCancelledResult) { - footerItems.push({ - tone: 'warning', - value: t('toolCards.terminal.commandInterrupted'), - }); - } - - return ( -
- - -
- ) : undefined} - outputDensity={isStreamingPhase || compactSettledPreview ? 'compact' : 'expanded'} - requiresConfirmation={showConfirmButtons} - status={status} - statusLabel={viewState.statusLabel - ? t(`toolCards.terminal.${viewState.statusLabel}`) - : undefined} - statusSummary={( - 0 - ? toolCall.input.timeout_ms - : undefined - } - showControls={false} - completedDurationMs={status === 'completed' ? parsedResult.executionTimeMs : undefined} - /> - )} - statusTone={viewState.statusClassName === 'status-error' ? 'danger' : 'warning'} - toggleTestId="chat-shell-command-toggle" - waitingContent={waitingMessage ?? undefined} - /> - - ); -}; - -export default TerminalToolCard; diff --git a/src/web-ui/src/flow_chat/tool-cards/index.test.ts b/src/web-ui/src/flow_chat/tool-cards/index.test.ts index 958318254e..ae296ea459 100644 --- a/src/web-ui/src/flow_chat/tool-cards/index.test.ts +++ b/src/web-ui/src/flow_chat/tool-cards/index.test.ts @@ -52,12 +52,9 @@ describe('tool card registry', () => { 'GetToolSpec', 'Skill', 'ReviewSessionSummary', - 'Git', 'GetFileDiff', - 'TerminalControl', 'SessionControl', 'SessionMessage', - 'Bash', 'RunCode', 'ExecCommand', 'WriteStdin', @@ -86,6 +83,14 @@ describe('tool card registry', () => { ); }); + it.each(['Bash', 'TerminalControl', 'Git'])( + 'does not register a dedicated %s card after the legacy tool is removed', + (toolName) => { + expect(TOOL_CARD_COMPONENTS).not.toHaveProperty(toolName); + expect(DEDICATED_TOOL_CARD_NAMES).not.toContain(toolName); + }, + ); + it.each(['ControlHub', 'FinalizeMiniApp', 'PublishMiniApp', 'PublishAppearance'])( 'treats %s as a default-card explore tool', (toolName) => { diff --git a/src/web-ui/src/flow_chat/tool-cards/index.ts b/src/web-ui/src/flow_chat/tool-cards/index.ts index 459626785c..c86a227a3c 100644 --- a/src/web-ui/src/flow_chat/tool-cards/index.ts +++ b/src/web-ui/src/flow_chat/tool-cards/index.ts @@ -41,15 +41,12 @@ import { ContextCompressionDisplay } from './ContextCompressionDisplay'; import { MCPToolDisplay } from './MCPToolDisplay'; import { SkillDisplay } from './SkillDisplay'; import { AskUserQuestionCard } from './AskUserQuestionCard'; -import { GitToolDisplay } from './GitToolDisplay'; import { GetFileDiffDisplay } from './GetFileDiffDisplay'; import { CreatePlanDisplay } from './CreatePlanDisplay'; -import { TerminalToolCard } from './TerminalToolCard'; import { RunCodeToolCard } from './RunCodeToolCard'; import { ExecCommandToolCard } from './ExecCommandToolCard'; import { WriteStdinToolCard } from './WriteStdinToolCard'; import { ExecControlToolCard } from './ExecControlToolCard'; -import { TerminalControlDisplay } from './TerminalControlDisplay'; import { InitMiniAppDisplay } from './MiniAppToolDisplay'; import { PageDeployDisplay } from './PageDeployToolDisplay'; import { PagePublishDisplay } from './PagePublishToolDisplay'; @@ -98,22 +95,13 @@ export const STANDARD_TOOL_CARD_ADAPTERS = { 'ReviewSessionSummary': ReviewSessionSummaryCard, - // Git version control - 'Git': GitToolDisplay, - // GetFileDiff tool 'GetFileDiff': GetFileDiffDisplay, - // TerminalControl tool - 'TerminalControl': TerminalControlDisplay, - // Session tools 'SessionControl': SessionControlToolCard, 'SessionMessage': SessionMessageToolCard, - // Bash tool - 'Bash': TerminalToolCard, - // Code-mode agents: one program per step instead of one card per action 'RunCode': RunCodeToolCard, diff --git a/src/web-ui/src/flow_chat/tool-cards/terminalToolCardState.test.ts b/src/web-ui/src/flow_chat/tool-cards/terminalToolCardState.test.ts deleted file mode 100644 index 8a213a5892..0000000000 --- a/src/web-ui/src/flow_chat/tool-cards/terminalToolCardState.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import type { PeerHostCapabilities } from '@/infrastructure/peer-device/PeerConnectionManager'; - -import { getTerminalViewState, resolveCanCancelTool } from './terminalToolCardState'; - -function caps(overrides: Partial): PeerHostCapabilities { - return { - idempotentDialogSubmit: false, - targetedSessionRollback: false, - tokenUsageStatistics: false, - miniAppAgentContextFilesV1: false, - cancelTool: null, - toolCatalog: null, - hostKind: null, - ...overrides, - }; -} - -describe('terminalToolCardState', () => { - it('shows receiving params while bash input is still streaming', () => { - const state = getTerminalViewState({ - status: 'streaming', - liveOutput: '', - isParamsStreaming: true, - interruptRequested: false, - showConfirmButtons: false, - wasInterrupted: false, - canCancelTool: true, - }); - - expect(state.displayPhase).toBe('receiving_params'); - expect(state.waitingMessageKey).toBe('toolCards.terminal.receivingParams'); - }); - - it('shows executing after params finish but before command output arrives', () => { - const state = getTerminalViewState({ - status: 'running', - liveOutput: '', - isParamsStreaming: false, - interruptRequested: false, - showConfirmButtons: false, - wasInterrupted: false, - canCancelTool: true, - }); - - expect(state.displayPhase).toBe('executing'); - expect(state.waitingMessageKey).toBe('toolCards.terminal.executingCommand'); - }); - - it('prefers real terminal output even if params streaming flag lags behind', () => { - const state = getTerminalViewState({ - status: 'streaming', - liveOutput: 'npm test\n', - isParamsStreaming: true, - interruptRequested: false, - showConfirmButtons: false, - wasInterrupted: false, - canCancelTool: true, - }); - - expect(state.displayPhase).toBe('live_output'); - expect(state.waitingMessageKey).toBeNull(); - }); - - it('switches to completed result once the tool finishes', () => { - const state = getTerminalViewState({ - status: 'completed', - liveOutput: 'partial output', - isParamsStreaming: false, - interruptRequested: false, - showConfirmButtons: false, - wasInterrupted: false, - canCancelTool: true, - }); - - expect(state.displayPhase).toBe('completed'); - expect(state.showCompletedResult).toBe(true); - }); - - it('hides the interrupt button when the host cannot cancel tools', () => { - // A peer host that does not advertise `cancel_tool` must not offer an - // interrupt that would be a no-op — the target command would keep running. - const withoutCapability = getTerminalViewState({ - status: 'running', - liveOutput: '', - isParamsStreaming: false, - interruptRequested: false, - showConfirmButtons: false, - wasInterrupted: false, - canCancelTool: false, - }); - expect(withoutCapability.showInterruptButton).toBe(false); - - const withCapability = getTerminalViewState({ - status: 'running', - liveOutput: '', - isParamsStreaming: false, - interruptRequested: false, - showConfirmButtons: false, - wasInterrupted: false, - canCancelTool: true, - }); - expect(withCapability.showInterruptButton).toBe(true); - }); -}); - -describe('resolveCanCancelTool', () => { - it('is always cancellable on the local (controller) surface', () => { - expect(resolveCanCancelTool(false, null)).toBe(true); - expect(resolveCanCancelTool(false, caps({ cancelTool: false }))).toBe(true); - }); - - it('stays optimistic while peer capabilities are still being probed', () => { - expect(resolveCanCancelTool(true, null)).toBe(true); - }); - - it('honors an explicitly advertised cancel_tool flag', () => { - expect(resolveCanCancelTool(true, caps({ cancelTool: true }))).toBe(true); - expect(resolveCanCancelTool(true, caps({ cancelTool: false }))).toBe(false); - }); - - it('resolves a null cancel_tool by host kind (old CLI hides the button, old Desktop keeps it)', () => { - // An older host did not advertise cancel_tool; cancelTool parses to null. - // host_type still discriminates: an old Desktop always implemented it, an - // old CLI never did — hide the button so Interrupt doesn't silently fail. - // See PR #2428 round 5 #1. - expect(resolveCanCancelTool(true, caps({ hostKind: 'cli' }))).toBe(false); - expect(resolveCanCancelTool(true, caps({ hostKind: 'desktop' }))).toBe(true); - expect(resolveCanCancelTool(true, caps({ hostKind: null }))).toBe(true); - }); - - it('an explicitly advertised false beats hostKind', () => { - expect(resolveCanCancelTool(true, caps({ cancelTool: false, hostKind: 'desktop' }))).toBe(false); - expect(resolveCanCancelTool(true, caps({ cancelTool: true, hostKind: 'cli' }))).toBe(true); - }); -}); diff --git a/src/web-ui/src/flow_chat/tool-cards/terminalToolCardState.ts b/src/web-ui/src/flow_chat/tool-cards/terminalToolCardState.ts deleted file mode 100644 index b0f0c323fe..0000000000 --- a/src/web-ui/src/flow_chat/tool-cards/terminalToolCardState.ts +++ /dev/null @@ -1,185 +0,0 @@ -import type { PeerHostCapabilities, PeerHostKind } from '@/infrastructure/peer-device/PeerConnectionManager'; - -export type TerminalWaitingMessageKey = - | 'toolCards.terminal.receivingParams' - | 'toolCards.terminal.executingCommand'; - -export type TerminalDisplayPhase = - | 'idle' - | 'receiving_params' - | 'executing' - | 'live_output' - | 'completed' - | 'cancelled_output'; - -export interface TerminalViewState { - isLoading: boolean; - isFailed: boolean; - showInterruptButton: boolean; - showCompletedResult: boolean; - showCancelledResult: boolean; - hasHeaderExtra: boolean; - statusLabel: 'rejected' | 'cancelled' | 'failed' | null; - statusClassName: 'status-rejected' | 'status-cancelled' | 'status-error' | null; - displayPhase: TerminalDisplayPhase; - waitingMessageKey: TerminalWaitingMessageKey | null; -} - -interface GetTerminalViewStateParams { - status: string; - liveOutput: string; - isParamsStreaming: boolean; - interruptRequested: boolean; - showConfirmButtons: boolean; - wasInterrupted: boolean; - /** - * Whether the current host advertises the `cancel_tool` capability. When - * false (e.g. a peer host without per-tool interrupt support), the - * Interrupt button is hidden so the UI never offers an ineffective action. - * Local and full-peer hosts set this to true. - */ - canCancelTool: boolean; -} - -function deriveDisplayPhase(params: { - status: string; - liveOutput: string; - isParamsStreaming: boolean; -}): Pick { - const { status, liveOutput, isParamsStreaming } = params; - const hasLiveOutput = liveOutput.length > 0; - - if (status === 'completed') { - return { - displayPhase: 'completed', - waitingMessageKey: null, - }; - } - - if (status === 'cancelled' && hasLiveOutput) { - return { - displayPhase: 'cancelled_output', - waitingMessageKey: null, - }; - } - - if (hasLiveOutput && (status === 'streaming' || status === 'running' || status === 'receiving')) { - return { - displayPhase: 'live_output', - waitingMessageKey: null, - }; - } - - if (isParamsStreaming && (status === 'preparing' || status === 'streaming' || status === 'receiving')) { - return { - displayPhase: 'receiving_params', - waitingMessageKey: 'toolCards.terminal.receivingParams', - }; - } - - if (status === 'running' || status === 'streaming' || status === 'receiving') { - return { - displayPhase: 'executing', - waitingMessageKey: 'toolCards.terminal.executingCommand', - }; - } - - return { - displayPhase: 'idle', - waitingMessageKey: null, - }; -} - -/** - * Resolve whether the currently rendered surface can actually cancel a running - * tool, so the Terminal Interrupt button is only offered when it will work. - * - * - Local surface: always `true`. - * - `cancelTool === true`: advertised by the host → `true`. - * - `cancelTool === false`: host explicitly unsupported → `false`. - * - `cancelTool === null` (older host that did not advertise the field): resolve - * by `hostKind`. An old Desktop always implemented `cancel_tool` (`true`); an - * old CLI never did (`false`), so the button is hidden instead of failing - * silently when the user clicks Interrupt. `hostKind === null` (truly - * unknown / still probing) stays optimistic. - * - * See PR #2428 round 5 #1. - */ -export function resolveCanCancelTool( - peerActive: boolean, - capabilities: PeerHostCapabilities | null, -): boolean { - if (!peerActive) { - return true; - } - if (capabilities === null) { - return true; - } - if (capabilities.cancelTool === true) { - return true; - } - if (capabilities.cancelTool === false) { - return false; - } - // cancelTool === null: older host, field absent — decide by host kind. - return capabilities.hostKind !== 'cli'; -} - -export type { PeerHostKind }; - -export function getTerminalViewState( - params: GetTerminalViewStateParams, -): TerminalViewState { - const { - status, - liveOutput, - isParamsStreaming, - interruptRequested, - showConfirmButtons, - wasInterrupted, - canCancelTool, - } = params; - const isRunning = status === 'running'; - const isLoading = - status === 'preparing' || - status === 'streaming' || - status === 'receiving' || - status === 'running'; - // Never offer an interrupt the host can't act on. A peer host that doesn't - // implement `cancel_tool` would otherwise leave the target command running - // while the controller just restored the button and logged an error. - const showInterruptButton = isRunning && !interruptRequested && canCancelTool; - - let statusLabel: TerminalViewState['statusLabel'] = null; - let statusClassName: TerminalViewState['statusClassName'] = null; - - if (status === 'rejected') { - statusLabel = 'rejected'; - statusClassName = 'status-rejected'; - } else if ((interruptRequested && isRunning) || wasInterrupted || status === 'cancelled') { - statusLabel = 'cancelled'; - statusClassName = 'status-cancelled'; - } else if (status === 'error') { - statusLabel = 'failed'; - statusClassName = 'status-error'; - } - - const { displayPhase, waitingMessageKey } = deriveDisplayPhase({ - status, - liveOutput, - isParamsStreaming, - }); - - return { - isLoading, - isFailed: status === 'error', - showInterruptButton, - showCompletedResult: displayPhase === 'completed', - showCancelledResult: displayPhase === 'cancelled_output', - hasHeaderExtra: Boolean(statusLabel || showConfirmButtons || showInterruptButton), - statusLabel, - statusClassName, - displayPhase, - waitingMessageKey, - }; -} diff --git a/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts b/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts index d6b16fad1e..a0c28df2f7 100644 --- a/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts +++ b/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts @@ -293,18 +293,6 @@ const TOOL_CARD_DEFINITIONS: Record = { primaryColor: APPEARANCE_DOMAIN_TOKENS.toolIdentity.reviewSummary }, - // Git version control tool - 'Git': { - toolName: 'Git', - displayName: 'Git', - icon: 'GIT', - requiresConfirmation: false, // Read-only needs no confirmation; writes are backend-controlled. - resultDisplayType: 'detailed', - description: 'Run Git commands', - displayMode: 'compact', - primaryColor: APPEARANCE_DOMAIN_TOKENS.toolIdentity.git - }, - // GetFileDiff tool 'GetFileDiff': { toolName: 'GetFileDiff', @@ -329,18 +317,6 @@ const TOOL_CARD_DEFINITIONS: Record = { primaryColor: 'var(--bf-appearance-token-color-warning)' }, - // TerminalControl tool - 'TerminalControl': { - toolName: 'TerminalControl', - displayName: 'Terminal Control', - icon: 'TC', - requiresConfirmation: false, - resultDisplayType: 'summary', - description: 'Kill or interrupt a terminal session', - displayMode: 'compact', - primaryColor: 'var(--bf-appearance-token-color-error)' - }, - 'SessionControl': { toolName: 'SessionControl', displayName: 'Session Control', @@ -363,18 +339,6 @@ const TOOL_CARD_DEFINITIONS: Record = { primaryColor: APPEARANCE_DOMAIN_TOKENS.toolIdentity.assistantAction }, - // Bash terminal tool - 'Bash': { - toolName: 'Bash', - displayName: 'Run Command', - icon: 'TERM', - requiresConfirmation: true, // Requires user confirmation. - resultDisplayType: 'detailed', - description: 'Run commands in the terminal', - displayMode: 'standard', - primaryColor: APPEARANCE_DOMAIN_TOKENS.toolIdentity.terminal - }, - // Code-mode agents (e.g. DeepSeek Harness's PTC preset) answer a step by // writing one program instead of calling one tool per action. 'RunCode': { @@ -649,13 +613,10 @@ export const DEDICATED_TOOL_CARD_NAMES = new Set([ 'Skill', 'AskUserQuestion', 'ReviewSessionSummary', - 'Git', 'GetFileDiff', 'CreatePlan', - 'TerminalControl', 'SessionControl', 'SessionMessage', - 'Bash', 'RunCode', 'ExecCommand', 'WriteStdin', @@ -692,7 +653,6 @@ export const COLLAPSIBLE_TOOL_NAMES = new Set([ 'GetFileDiff', 'GetToolSpec', 'ReviewSessionSummary', - 'TerminalControl', 'SessionControl', 'ExecControl', 'view_image', diff --git a/src/web-ui/src/infrastructure/api/generated/productControl.ts b/src/web-ui/src/infrastructure/api/generated/productControl.ts index 9a8af5e96e..f1227972cb 100644 --- a/src/web-ui/src/infrastructure/api/generated/productControl.ts +++ b/src/web-ui/src/infrastructure/api/generated/productControl.ts @@ -1,5 +1,5 @@ // Generated by scripts/generate-interactive-capabilities.mjs; do not edit. -export const PRODUCT_CONTROL_GRAPH_DIGEST = "5a170c4fb33ea5bb4fe1d839ff0d405db53b82e586aead0891babafbb59b40aa" as const; +export const PRODUCT_CONTROL_GRAPH_DIGEST = "6094ecf9b8e3d14c0783f7f0595e3d5e007362c9d89f5655c05e7307e9caa489" as const; export type ProductControlCapabilityId = "feature.ai-assistant" | "feature.agents" | "feature.personal-assistants" | "feature.projects" | "feature.files-editor" | "feature.terminal" | "feature.git" | "feature.code-review" | "feature.browser" | "feature.computer-use" | "feature.skills" | "feature.miniapps" | "feature.canvas" | "feature.tasks-automation" | "feature.insights" | "feature.ecosystem-compatibility" | "feature.remote-workspaces" | "feature.remote-connect" | "feature.detached-dispatch" | "feature.pages" | "feature.voice-input" | "feature.desktop-pet" | "setting.application.general" | "setting.application.appearance" | "setting.application.pet" | "setting.application.input" | "setting.application.development" | "setting.ai.models" | "setting.ai.memory" | "setting.workspace.session" | "setting.workspace.worktrees" | "setting.tools.execution" | "setting.tools.automation" | "setting.tools.mcp" | "setting.tools.acp" | "setting.data.usage" | "setting.data.archived" | "setting.data.diagnostics"; diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index c8dc6214de..7275dc1692 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -1606,51 +1606,6 @@ "generativeWidget": { "exportFileNamePrefix": "BitFun-Widget" }, - "git": { - "title": "Git", - "commandFailed": "Git command failed", - "executionFailed": "Git command execution failed", - "failed": "Failed", - "confirmExecute": "Confirm execute", - "cancel": "Cancel", - "copyCommand": "Copy git command", - "commandCopied": "Git command copied", - "copyCommandFailed": "Failed to copy git command", - "expandOutput": "Expand output", - "collapseOutput": "Collapse output", - "noOutput": "(no output)", - "exitCode": "Exit code: {{code}}", - "duration": "Duration: {{time}}", - "directory": "Directory: {{dir}}", - "output": "Output:", - "warning": "Warning:", - "error": "Error:", - "operation": "Operation: git {{op}}", - "args": "Args: {{args}}", - "status": "Status", - "diff": "Diff", - "log": "Log", - "add": "Stage", - "commit": "Commit", - "branch": "Branch", - "checkout": "Checkout", - "switch": "Switch", - "pull": "Pull", - "push": "Push", - "fetch": "Fetch", - "merge": "Merge", - "rebase": "Rebase", - "stash": "Stash", - "reset": "Reset", - "restore": "Restore", - "show": "Show", - "tag": "Tag", - "remote": "Remote", - "clone": "Clone", - "init": "Init", - "blame": "Blame", - "cherryPick": "Cherry-pick" - }, "initMiniApp": { "title": "Mini App", "operationInit": "Init", @@ -2043,16 +1998,6 @@ "readingFile": "Reading", "preparingRead": "Preparing to read" }, - "terminalControl": { - "terminatingSession": "Terminating terminal", - "sessionKilled": "Terminal killed", - "killFailed": "Failed to kill terminal", - "preparingKill": "Preparing to kill terminal", - "interruptingSession": "Interrupting terminal", - "sessionInterrupted": "Terminal interrupted", - "interruptFailed": "Failed to interrupt terminal", - "preparingInterrupt": "Preparing to interrupt terminal" - }, "sessionControl": { "title": "Session", "currentWorkspace": "Current workspace", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 002d2bbd50..577b4c2af7 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -1606,51 +1606,6 @@ "generativeWidget": { "exportFileNamePrefix": "BitFun组件" }, - "git": { - "title": "Git", - "commandFailed": "Git命令失败", - "executionFailed": "Git命令执行失败", - "failed": "失败", - "confirmExecute": "确认执行", - "cancel": "取消", - "copyCommand": "复制 Git 命令", - "commandCopied": "Git 命令已复制", - "copyCommandFailed": "复制 Git 命令失败", - "expandOutput": "展开输出", - "collapseOutput": "收起输出", - "noOutput": "(无输出)", - "exitCode": "退出码: {{code}}", - "duration": "耗时: {{time}}", - "directory": "目录: {{dir}}", - "output": "输出:", - "warning": "警告:", - "error": "错误:", - "operation": "操作: git {{op}}", - "args": "参数: {{args}}", - "status": "状态", - "diff": "差异", - "log": "日志", - "add": "暂存", - "commit": "提交", - "branch": "分支", - "checkout": "切换", - "switch": "切换", - "pull": "拉取", - "push": "推送", - "fetch": "获取", - "merge": "合并", - "rebase": "变基", - "stash": "暂存", - "reset": "重置", - "restore": "恢复", - "show": "显示", - "tag": "标签", - "remote": "远程", - "clone": "克隆", - "init": "初始化", - "blame": "追溯", - "cherryPick": "挑选" - }, "initMiniApp": { "title": "小应用", "operationInit": "初始化", @@ -2043,16 +1998,6 @@ "readingFile": "正在读取", "preparingRead": "准备读取" }, - "terminalControl": { - "terminatingSession": "正在终止终端", - "sessionKilled": "终端已终止", - "killFailed": "终止终端失败", - "preparingKill": "准备终止终端", - "interruptingSession": "正在中断终端", - "sessionInterrupted": "终端已中断", - "interruptFailed": "中断终端失败", - "preparingInterrupt": "准备中断终端" - }, "sessionControl": { "title": "会话", "currentWorkspace": "当前工作区", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index 5280407c10..5a6cb6bc02 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -1606,51 +1606,6 @@ "generativeWidget": { "exportFileNamePrefix": "BitFun組件" }, - "git": { - "title": "Git", - "commandFailed": "Git命令失敗", - "executionFailed": "Git命令執行失敗", - "failed": "失敗", - "confirmExecute": "確認執行", - "cancel": "取消", - "copyCommand": "複製 Git 命令", - "commandCopied": "Git 命令已複製", - "copyCommandFailed": "複製 Git 命令失敗", - "expandOutput": "展開輸出", - "collapseOutput": "收起輸出", - "noOutput": "(無輸出)", - "exitCode": "退出碼: {{code}}", - "duration": "耗時: {{time}}", - "directory": "目錄: {{dir}}", - "output": "輸出:", - "warning": "警告:", - "error": "錯誤:", - "operation": "操作: git {{op}}", - "args": "參數: {{args}}", - "status": "狀態", - "diff": "差異", - "log": "日誌", - "add": "暫存", - "commit": "提交", - "branch": "分支", - "checkout": "切換", - "switch": "切換", - "pull": "拉取", - "push": "推送", - "fetch": "獲取", - "merge": "合併", - "rebase": "變基", - "stash": "暫存", - "reset": "重設", - "restore": "恢復", - "show": "顯示", - "tag": "標籤", - "remote": "遠程", - "clone": "克隆", - "init": "初始化", - "blame": "追溯", - "cherryPick": "挑選" - }, "initMiniApp": { "title": "小應用", "operationInit": "初始化", @@ -2043,16 +1998,6 @@ "readingFile": "正在讀取", "preparingRead": "準備讀取" }, - "terminalControl": { - "terminatingSession": "正在終止終端", - "sessionKilled": "終端已終止", - "killFailed": "終止終端失敗", - "preparingKill": "準備終止終端", - "interruptingSession": "正在中斷終端", - "sessionInterrupted": "終端已中斷", - "interruptFailed": "中斷終端失敗", - "preparingInterrupt": "準備中斷終端" - }, "sessionControl": { "title": "會話", "currentWorkspace": "目前工作區", diff --git a/src/web-ui/src/shared/services/reviewSubagentCapabilities.ts b/src/web-ui/src/shared/services/reviewSubagentCapabilities.ts index 9301a38cc7..b6341f136a 100644 --- a/src/web-ui/src/shared/services/reviewSubagentCapabilities.ts +++ b/src/web-ui/src/shared/services/reviewSubagentCapabilities.ts @@ -6,7 +6,7 @@ export const REVIEW_SUBAGENT_RECOMMENDED_TOOLS = [ 'Glob', 'LS', ] as const; -export const REVIEW_SUBAGENT_OPTIONAL_TOOLS = ['Git'] as const; +export const REVIEW_SUBAGENT_OPTIONAL_TOOLS = [] as const; export type ReviewSubagentToolReadiness = 'ready' | 'degraded' | 'invalid'; diff --git a/src/web-ui/src/shared/services/tool-execution-service.ts b/src/web-ui/src/shared/services/tool-execution-service.ts index 019a43c196..1408a3c12f 100644 --- a/src/web-ui/src/shared/services/tool-execution-service.ts +++ b/src/web-ui/src/shared/services/tool-execution-service.ts @@ -8,7 +8,6 @@ import { ToolExecutionInfo, ToolResult, ToolDisplayMessage, - BashToolResult, FileToolResult, SearchToolResult, WebToolResult, @@ -276,8 +275,6 @@ export class ToolExecutionService { const normalizedName = toolName.toLowerCase().replace(/[_-]/g, ''); switch (normalizedName) { - case 'bash': - return this.normalizeBashResult(content); case 'fileread': case 'filewrite': case 'fileedit': @@ -298,27 +295,6 @@ export class ToolExecutionService { } } - private normalizeBashResult(content: any): BashToolResult { - if (typeof content === 'string') { - return { - stdout: content, - stdoutLines: content.split('\n').length, - stderr: '', - stderrLines: 0, - interrupted: false - }; - } - - return { - stdout: content.stdout || content.output || '', - stdoutLines: content.stdoutLines || (content.stdout || content.output || '').split('\n').length, - stderr: content.stderr || '', - stderrLines: content.stderrLines || (content.stderr || '').split('\n').length, - interrupted: content.interrupted || false, - exitCode: content.exitCode - }; - } - private normalizeFileResult(content: any, toolName: string): FileToolResult { const operation = toolName.toLowerCase().includes('read') ? 'read' : toolName.toLowerCase().includes('write') ? 'write' : diff --git a/src/web-ui/src/shared/types/tool-display.ts b/src/web-ui/src/shared/types/tool-display.ts index 8162856d77..f2fff4ddde 100644 --- a/src/web-ui/src/shared/types/tool-display.ts +++ b/src/web-ui/src/shared/types/tool-display.ts @@ -49,15 +49,6 @@ export interface ToolDisplayComponentProps { width?: number | string; } -export interface BashToolResult { - stdout: string; - stdoutLines: number; - stderr: string; - stderrLines: number; - interrupted: boolean; - exitCode?: number; -} - export interface FileToolResult { content?: string; filePath: string; From 1c6fa8d0e20d2ac247e9b62a55521d7bd6419eeb Mon Sep 17 00:00:00 2001 From: wsp Date: Sat, 29 Aug 2026 11:00:45 +0800 Subject: [PATCH 2/3] test(web): stabilize full frontend test suite - Complete the HooksConfig Icon mock - Align layout assertions with design-system ownership - Limit Vitest workers to prevent resource-starved timeouts --- src/web-ui/package.json | 2 +- .../config/components/HooksConfig.test.tsx | 1 + .../components/common/ConfigPageLayout.test.tsx | 15 +++++++++++---- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/web-ui/package.json b/src/web-ui/package.json index dbe69eb0fd..dec1ec80c5 100644 --- a/src/web-ui/package.json +++ b/src/web-ui/package.json @@ -26,7 +26,7 @@ "lint:fix": "eslint . --fix", "type-check": "tsc --noEmit", "test": "vitest", - "test:run": "vitest run" + "test:run": "vitest run --maxWorkers=50%" }, "dependencies": { "@bitfun/design-tokens": "workspace:^", diff --git a/src/web-ui/src/infrastructure/config/components/HooksConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/HooksConfig.test.tsx index 2f75cf948c..afb48ac8a4 100644 --- a/src/web-ui/src/infrastructure/config/components/HooksConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/HooksConfig.test.tsx @@ -43,6 +43,7 @@ vi.mock('@bitfun/ui', () => ({ ) : null), + Icon: ({ name }: { name: string }) => , Switch: ({ checked, disabled, onChange }: React.InputHTMLAttributes) => ( ), diff --git a/src/web-ui/src/infrastructure/config/components/common/ConfigPageLayout.test.tsx b/src/web-ui/src/infrastructure/config/components/common/ConfigPageLayout.test.tsx index 6d99183759..af809ceb9e 100644 --- a/src/web-ui/src/infrastructure/config/components/common/ConfigPageLayout.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/common/ConfigPageLayout.test.tsx @@ -120,7 +120,7 @@ describe('ConfigPageLayout', () => { expect(row?.querySelector('.bitfun-config-page-row__control')).toBeNull(); }); - it('forwards a feature-owned Appearance contract to the real layout nodes', () => { + it('preserves design-system component ownership while forwarding feature-owned parts', () => { act(() => { root.render( { ); }); - expect(container.querySelector('[data-testid="feature-root"]')?.getAttribute('data-bf-component')).toBe('model-settings'); - expect(container.querySelector('[data-testid="feature-content"]')?.getAttribute('data-bf-part')).toBe('providerSelection'); - expect(container.querySelector('[data-testid="feature-section"]')?.getAttribute('data-bf-part')).toBe('providerGroup'); + const rootElement = container.querySelector('[data-testid="feature-root"]'); + const contentElement = container.querySelector('[data-testid="feature-content"]'); + const sectionElement = container.querySelector('[data-testid="feature-section"]'); + + expect(rootElement?.getAttribute('data-bf-component')).toBe('scroll-area'); + expect(rootElement?.getAttribute('data-bf-part')).toBe('viewport'); + expect(contentElement?.getAttribute('data-bf-component')).toBe('model-settings'); + expect(contentElement?.getAttribute('data-bf-part')).toBe('providerSelection'); + expect(sectionElement?.getAttribute('data-bf-component')).toBe('form-section'); + expect(sectionElement?.getAttribute('data-bf-part')).toBe('providerGroup'); }); }); From 7db17e7add277607c6bb713dad1092d237c9bcad Mon Sep 17 00:00:00 2001 From: wsp Date: Sat, 29 Aug 2026 11:24:05 +0800 Subject: [PATCH 3/3] test(agent): align command tool removal fixtures Update Core test fixtures after removing the legacy Git and Bash tools: - Expect Worktree and ExecCommand in affected assertions - Provide local workspace context for deferred Worktree coverage - Match deterministic loaded-spec ordering --- .../assembly/core/src/agentic/memories/transcript.rs | 2 +- .../assembly/core/src/agentic/persistence/manager.rs | 5 ++++- .../core/src/agentic/tools/pipeline/tool_pipeline.rs | 4 +++- .../core/src/agentic/tools/product_runtime/catalog.rs | 8 +++++++- .../agentic/tools/product_runtime/loaded_spec_state.rs | 2 +- 5 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/crates/assembly/core/src/agentic/memories/transcript.rs b/src/crates/assembly/core/src/agentic/memories/transcript.rs index a30a3ae879..69d0c991e2 100644 --- a/src/crates/assembly/core/src/agentic/memories/transcript.rs +++ b/src/crates/assembly/core/src/agentic/memories/transcript.rs @@ -536,7 +536,7 @@ mod tests { .unwrap(); assert!(transcript.contains("\"function\":{\"name\":\"GetToolSpec\"")); - assert!(transcript.contains("\\\"tool_name\\\":\\\"Git\\\"")); + assert!(transcript.contains("\\\"tool_name\\\":\\\"Worktree\\\"")); assert!(transcript.contains("\"name\":\"GetToolSpec\"")); assert!(transcript.contains("\"content\":\"[cleared]\"")); assert!(!transcript.contains("full schema definition")); diff --git a/src/crates/assembly/core/src/agentic/persistence/manager.rs b/src/crates/assembly/core/src/agentic/persistence/manager.rs index beb49b737e..f039556c19 100644 --- a/src/crates/assembly/core/src/agentic/persistence/manager.rs +++ b/src/crates/assembly/core/src/agentic/persistence/manager.rs @@ -6533,7 +6533,10 @@ mod tests { assert_eq!(stats.raw_result_string_chars, 40); assert_eq!(stats.result_for_assistant_chars, 17); assert_eq!(stats.largest_raw_result_chars, 40); - assert_eq!(stats.largest_raw_result_path, "message[1].Bash.output"); + assert_eq!( + stats.largest_raw_result_path, + "message[1].ExecCommand.output" + ); assert!(!stats.largest_raw_result_path.contains(&"x".repeat(40))); } diff --git a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs index 4c4176a9aa..24bbed8e33 100644 --- a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs +++ b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs @@ -4742,7 +4742,9 @@ mod tests { assert!(result.result.result["provided_arguments"].is_null()); assert_eq!( result.result.result_for_assistant.as_deref(), - Some("Tool 'Git' failed (invalid_arguments): Validation error: operation is not supported") + Some( + "Tool 'Worktree' failed (invalid_arguments): Validation error: operation is not supported" + ) ); } diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs index e754eba01c..17b3a1bf7e 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs @@ -1202,10 +1202,16 @@ mod tests { "Worktree".to_string(), ]; + let mut context = tool_context(Some("test-agent")); + context.workspace = Some(crate::agentic::WorkspaceBinding::new( + None, + std::env::current_dir().expect("absolute test workspace root"), + )); + let manifest = resolve_product_resolved_tool_manifest( &allowed_tools, &AgentToolPolicyOverrides::default(), - &tool_context(Some("test-agent")), + &context, ) .await; diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/loaded_spec_state.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/loaded_spec_state.rs index 58b60a566e..04bbf8e1be 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime/loaded_spec_state.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/loaded_spec_state.rs @@ -235,7 +235,7 @@ mod tests { assert_eq!( loaded_specs, - vec![loaded_spec("Worktree"), loaded_spec("WebFetch")] + vec![loaded_spec("WebFetch"), loaded_spec("Worktree")] ); }