From aacf0a212aa97580b8c59b27ed2f8a54bf128dfc Mon Sep 17 00:00:00 2001 From: Abdelrahman Abdallah Date: Wed, 8 Apr 2026 00:02:53 +0200 Subject: [PATCH] Created src/prompt_constants.py (550+ lines) porting all constants from npm src/constants/: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ┌──────────────────┬───────────────────────────┬───────────────────────────────────────────────┐ │ Category │ npm Source │ Items │ ├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤ │ Product metadata │ product.ts │ URLs, base URLs │ ├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤ │ System prefixes │ system.ts │ 3 prompt prefixes │ ├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤ │ Cyber risk │ cyberRiskInstruction.ts │ Safety instruction │ ├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤ │ API limits │ apiLimits.ts │ 10 image/PDF/media limits │ ├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤ │ Tool limits │ toolLimits.ts │ 6 result size constants │ ├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤ │ Spinner verbs │ spinnerVerbs.ts │ 187 whimsical gerunds │ ├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤ │ Completion verbs │ turnCompletionVerbs.ts │ 8 past-tense verbs │ ├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤ │ Figures/symbols │ figures.ts │ 25 Unicode UI symbols │ ├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤ │ XML tags │ xml.ts │ 30+ tag constants │ ├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤ │ Messages │ messages.ts │ NO_CONTENT_MESSAGE │ ├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤ │ Date utilities │ common.ts │ 4 functions │ ├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤ │ Section caching │ systemPromptSections.ts │ Memoized/volatile sections │ ├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤ │ Output styles │ outputStyles.ts │ 3 built-in configs │ ├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤ │ Prompt helpers │ prompts.ts │ Knowledge cutoff, language, scratchpad, hooks │ └──────────────────┴───────────────────────────┴───────────────────────────────────────────────┘ 91 new tests in tests/test_prompt_constants.py. All 17 SQL todos done. --- .gitignore | 2 + PARITY_CHECKLIST.md | 563 +++++++++---- benchmarks/download_datasets.py | 304 +++---- src/agent_runtime.py | 9 + src/agent_slash_commands.py | 476 +++++++++++ src/bash_security.py | 1261 ++++++++++++++++++++++++++++++ src/compact.py | 438 +++++++++++ src/prompt_constants.py | 709 +++++++++++++++++ tests/test_bash_security.py | 878 +++++++++++++++++++++ tests/test_compact.py | 405 ++++++++++ tests/test_cost_exit_diff.py | 189 +++++ tests/test_new_slash_commands.py | 299 +++++++ tests/test_prompt_constants.py | 643 +++++++++++++++ 13 files changed, 5872 insertions(+), 304 deletions(-) create mode 100644 src/bash_security.py create mode 100644 src/compact.py create mode 100644 src/prompt_constants.py create mode 100644 tests/test_bash_security.py create mode 100644 tests/test_compact.py create mode 100644 tests/test_cost_exit_diff.py create mode 100644 tests/test_new_slash_commands.py create mode 100644 tests/test_prompt_constants.py diff --git a/.gitignore b/.gitignore index 1c4a2cf..f786461 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,5 @@ humaneval_results.json test_cases e-commerce +benchmarks/data/*.jsonl +benchmarks/data/manifest.json diff --git a/PARITY_CHECKLIST.md b/PARITY_CHECKLIST.md index f612689..6422a61 100644 --- a/PARITY_CHECKLIST.md +++ b/PARITY_CHECKLIST.md @@ -4,6 +4,8 @@ This document tracks what is already implemented in Python and what is still mis This is a functionality-oriented checklist, not a line-by-line source equivalence claim. Large parts of the mirrored Python workspace still act as inventory or scaffolding, while the working Python runtime currently lives mainly in [`src/agent_runtime.py`](src/agent_runtime.py), [`src/query_engine.py`](src/query_engine.py), [`src/agent_tools.py`](src/agent_tools.py), [`src/agent_prompting.py`](src/agent_prompting.py), [`src/agent_context.py`](src/agent_context.py), [`src/agent_manager.py`](src/agent_manager.py), [`src/plugin_runtime.py`](src/plugin_runtime.py), [`src/agent_slash_commands.py`](src/agent_slash_commands.py), and [`src/openai_compat.py`](src/openai_compat.py). +--- + ## 1. Core Agent Runtime Done: @@ -117,7 +119,10 @@ Missing: - [ ] Full file history snapshots and replay flows beyond the current preview/id-based implementation and delegated-batch replay metadata - [ ] Full executable plugin lifecycle beyond manifest-driven prompt/tool/session hooks, blocking, aliases, virtual tools, and persisted runtime state - [ ] Full session compaction / snipping parity beyond lineage-aware summaries, mutation-serial compaction metadata, and replay reminders -- [ ] Full `QueryEngine.ts` parity +- [ ] Full `QueryEngine.ts` parity (session init, message normalization, SDK-compatible message transforms, attachment handling) +- [ ] Auto-compact and context collapse features from `query.ts` +- [ ] Prompt length validation from `query.ts` +- [ ] Token budget calculations from `query/tokenBudget.ts` ## 2. CLI Entrypoints And Runtime Modes @@ -147,15 +152,22 @@ Done: Missing: - [ ] Full daemon supervisor parity beyond the current local daemon wrapper and worker flow -- [ ] Remote-control / bridge runtime mode +- [ ] Remote-control / bridge runtime mode (`src/bridge/` — 30+ files: bridgeMain, bridgeApi, bridgeConfig, bridgeMessaging, bridgePermissionCallbacks, replBridge, sessionRunner, trustedDevice, etc.) - [ ] Browser/native-host runtime mode -- [ ] Computer-use MCP mode +- [ ] Computer-use MCP mode (`src/entrypoints/mcp.ts`) - [ ] Template job mode - [ ] Environment runner mode - [ ] Self-hosted runner mode - [ ] tmux fast paths - [ ] Worktree fast paths at the CLI entrypoint level -- [ ] Full `entrypoints/cli.tsx` and `entrypoints/init.ts` parity +- [ ] Node.js version check and platform setup from `setup.ts` +- [ ] Worktree creation/setup from `setup.ts` +- [ ] Terminal backup/restore from `setup.ts` +- [ ] Release notes checking from `setup.ts` +- [ ] Full `entrypoints/cli.tsx` parity (version flag, feature flags, env setup, dynamic imports) +- [ ] Full `entrypoints/init.ts` parity (settings validation, OAuth, policy limits, telemetry, cleanup handlers) +- [ ] SDK entrypoint (`entrypoints/sdk/` — controlTypes, coreTypes, runtimeTypes, settingsTypes, toolTypes) +- [ ] Sandbox types/network config schema (`entrypoints/sandboxTypes.ts`) ## 3. Prompt Assembly @@ -176,18 +188,34 @@ Done: - [x] Local planning guidance section in the Python system prompt - [x] Local task guidance section in the Python system prompt +- [x] Product metadata/branding from `constants/product.ts` — ported to `src/prompt_constants.py` +- [x] API limits constants from `constants/apiLimits.ts` — ported to `src/prompt_constants.py` +- [x] Tool limits constants from `constants/toolLimits.ts` — ported to `src/prompt_constants.py` +- [x] Spinner verbs from `constants/spinnerVerbs.ts` (187 verbs) — ported to `src/prompt_constants.py` +- [x] Turn-completion verbs from `constants/turnCompletionVerbs.ts` (8 verbs) — ported to `src/prompt_constants.py` +- [x] Figures/UI symbols from `constants/figures.ts` — ported to `src/prompt_constants.py` +- [x] XML tag constants from `constants/xml.ts` — ported to `src/prompt_constants.py` +- [x] Message constants from `constants/messages.ts` — ported to `src/prompt_constants.py` +- [x] Date utilities from `constants/common.ts` — ported to `src/prompt_constants.py` +- [x] System prompt section caching from `constants/systemPromptSections.ts` — ported to `src/prompt_constants.py` +- [x] Output-style variants from `constants/outputStyles.ts` — ported to `src/prompt_constants.py` +- [x] Cyber / risk instruction from `constants/cyberRiskInstruction.ts` — ported to `src/prompt_constants.py` +- [x] System prompt prefixes from `constants/system.ts` — ported to `src/prompt_constants.py` +- [x] Knowledge cutoff / model family info from `constants/prompts.ts` — ported to `src/prompt_constants.py` +- [x] Hook instruction section template — ported to `src/prompt_constants.py` +- [x] System reminders section — ported to `src/prompt_constants.py` +- [x] Summarize tool results section — ported to `src/prompt_constants.py` +- [x] Language-control section helper — ported to `src/prompt_constants.py` +- [x] Scratchpad prompt instructions helper — ported to `src/prompt_constants.py` +- [x] Default agent prompt — ported to `src/prompt_constants.py` + Missing: -- [ ] Full parity with `constants/prompts.ts` -- [ ] Hook instruction sections -- [ ] MCP instruction sections -- [ ] Model-family-specific prompt variations -- [ ] Output-style variants -- [ ] Language-control sections -- [ ] Scratchpad prompt instructions +- [ ] Full parity with `constants/prompts.ts` runtime section assembly (many sections already exist in agent_prompting.py) +- [ ] MCP instruction sections (runtime MCP integration) +- [ ] Model-family-specific prompt variations (runtime) - [ ] More exact autonomous/proactive behavior sections -- [ ] Growthbook / feature-gated prompt sections -- [ ] Cyber / risk sections used upstream +- [ ] Growthbook / feature-gated prompt sections (N/A for external builds) ## 4. Context Building And Memory @@ -216,79 +244,122 @@ Done: Missing: - [ ] Full tokenizer/chat-message framing parity beyond the current model-aware text token counters -- [ ] Full parity with `utils/queryContext.ts` -- [ ] Rich memory prompt loading +- [ ] Full parity with `utils/queryContext.ts` (context analysis, suggestions, cache shaping) +- [ ] Rich memory prompt loading (`services/SessionMemory/`) - [ ] Internal permission-aware memory handling - [ ] Resume-aware prompt cache shaping used upstream - [ ] More exact context cache invalidation rules -- [ ] Session context analysis parity -- [ ] Full memory subsystem parity +- [ ] Session context analysis parity (`utils/contextAnalysis.ts`, `utils/contextSuggestions.ts`) +- [ ] Full memory subsystem parity (`utils/memory/`, `services/extractMemories/`) +- [ ] Memory extraction from conversations (`services/extractMemories/extractMemories.ts`) +- [ ] Team memory sync (`services/teamMemorySync/`) +- [ ] Away summary generation (`services/awaySummary.ts`) +- [ ] Token estimation service (`services/tokenEstimation.ts`) +- [ ] Paste content storage and reference parsing (`history.ts`) +- [ ] Image paste handling ## 5. Slash Commands -Done: +Done (37 slash command names in 29 specs): -- [x] `/help` -- [x] `/commands` -- [x] `/context` -- [x] `/usage` -- [x] `/context-raw` -- [x] `/env` -- [x] `/mcp` -- [x] `/mcp tools` -- [x] `/mcp tool ` -- [x] `/search` -- [x] `/remote` +- [x] `/help`, `/commands` +- [x] `/context`, `/usage` +- [x] `/context-raw`, `/env` +- [x] `/mcp` (with subcommands: `tools`, `tool `) +- [x] `/search` (with subcommands: `providers`, `provider`, `use`) +- [x] `/remote` (with `enter`, `exit`) +- [x] `/worktree` (with `enter`, `exit`) +- [x] `/account` (with `profiles`, `profile`) +- [x] `/ask` (with `history`) +- [x] `/login` +- [x] `/logout` +- [x] `/config`, `/settings` (with `effective`, `source`, `get`, `set`) - [x] `/remotes` - [x] `/ssh` - [x] `/teleport` - [x] `/direct-connect` - [x] `/deep-link` -- [x] `/disconnect` -- [x] `/account` -- [x] `/login` -- [x] `/logout` +- [x] `/disconnect`, `/remote-disconnect` - [x] `/resources` - [x] `/resource` -- [x] `/plan` -- [x] `/planner` -- [x] `/tasks` -- [x] `/todo` +- [x] `/tasks`, `/todo` +- [x] `/workflows`, `/workflow` +- [x] `/triggers`, `/trigger` +- [x] `/teams`, `/team`, `/messages` +- [x] `/task-next`, `/next-task` +- [x] `/plan`, `/planner` - [x] `/task` -- [x] `/task-next` -- [x] `/prompt` -- [x] `/system-prompt` +- [x] `/prompt`, `/system-prompt` - [x] `/permissions` -- [x] `/hooks` -- [x] `/policy` +- [x] `/hooks`, `/policy` - [x] `/trust` - [x] `/model` - [x] `/tools` - [x] `/memory` -- [x] `/status` -- [x] `/session` +- [x] `/status`, `/session` - [x] `/clear` -- [x] `/config` -- [x] `/settings` -Missing: +Missing npm slash commands (from `src/commands/` — 80+ commands total): -- [ ] Full npm slash-command surface -- [x] Slash commands backed by MCP integration -- [ ] Slash commands tied to task/plan systems beyond the current local `/plan`, `/tasks`, and `/task` flows -- [ ] Slash commands tied to remote/background sessions beyond the current local remote connect/disconnect and background inspection flows -- [ ] Slash commands with richer interactive behavior -- [ ] Slash commands tied to plugins and bundled skills -- [ ] Slash commands tied to account, settings, and auth flows beyond the current local `/account`, `/login`, `/logout`, `/config`, and `/settings` flows +- [ ] `/add-dir` — Add a new working directory +- [ ] `/agents` — Manage agent configurations +- [x] `/branch` — Create a branch of the current conversation +- [ ] `/bridge` — Connect for remote-control sessions +- [ ] `/btw` — Quick side question without interrupting main conversation +- [ ] `/chrome` — Chrome extension settings +- [x] `/color` — Set the prompt bar color for this session +- [x] `/compact` — Clear history but keep a summary in context +- [x] `/copy` — Copy Claude's last response to clipboard +- [x] `/cost` — Show total cost and duration of session +- [ ] `/desktop` — Continue session in Claude Desktop +- [x] `/diff` — View uncommitted changes and per-turn diffs +- [x] `/doctor` — Diagnose and verify installation and settings +- [x] `/effort` — Set effort level for model usage +- [x] `/exit` — Exit the REPL +- [x] `/export` — Export conversation to file or clipboard +- [ ] `/extra-usage` — Configure extra usage for rate limits +- [ ] `/fast` — Toggle fast mode +- [ ] `/feedback` — Submit feedback +- [x] `/files` — List all files currently in context +- [ ] `/ide` — Manage IDE integrations and show status +- [ ] `/install-github-app` — Set up GitHub Actions +- [ ] `/install-slack-app` — Install Slack app +- [ ] `/keybindings` — Open keybindings config file +- [ ] `/mobile` — QR code for mobile app +- [ ] `/output-style` — Change output style +- [ ] `/passes` — Passes management +- [ ] `/plugin` — Plugin management +- [ ] `/pr_comments` — Get comments from a GitHub PR +- [ ] `/privacy-settings` — View/update privacy settings +- [ ] `/rate-limit-options` — Show options when rate limited +- [ ] `/release-notes` — View release notes +- [ ] `/reload-plugins` — Activate pending plugin changes +- [ ] `/remote-env` — Configure default remote environment +- [ ] `/remote-setup` — Remote setup configuration +- [x] `/rename` — Rename current conversation +- [ ] `/resume` — Resume a previous conversation +- [ ] `/rewind` — Restore code/conversation to a previous point +- [ ] `/sandbox-toggle` — Toggle sandbox mode +- [ ] `/skills` — List available skills +- [x] `/stats` — Usage statistics and activity +- [ ] `/stickers` — Order stickers +- [x] `/tag` — Toggle a searchable tag on the session +- [ ] `/theme` — Change the theme +- [ ] `/upgrade` — Upgrade to Max +- [ ] `/vim` — Toggle Vim/Normal editing modes +- [ ] `/voice` — Toggle voice mode +- [ ] Feature-gated: `/buddy`, `/fork`, `/peers`, `/proactive`, `/torch`, `/workflows` (full), etc. +- [ ] Internal: `/backfill-sessions`, `/break-cache`, `/bughunter`, `/commit`, `/commit-push-pr`, `/init-verifiers`, `/mock-limits`, `/version`, `/ultraplan`, `/autofix-pr`, etc. ## 6. Built-in Tools -Done: +### Tools implemented in Python (58 tools): - [x] `list_dir` - [x] `read_file` - [x] `write_file` - [x] `edit_file` +- [x] `notebook_edit` - [x] `glob_search` - [x] `grep_search` - [x] `bash` @@ -304,7 +375,9 @@ Done: - [x] `account_list_profiles` - [x] `account_login` - [x] `account_logout` -- [x] `notebook_edit` +- [x] `config_list` +- [x] `config_get` +- [x] `config_set` - [x] `mcp_list_resources` - [x] `mcp_read_resource` - [x] `mcp_list_tools` @@ -313,13 +386,16 @@ Done: - [x] `remote_list_profiles` - [x] `remote_connect` - [x] `remote_disconnect` -- [x] `config_list` -- [x] `config_get` -- [x] `config_set` +- [x] `worktree_status` +- [x] `worktree_enter` +- [x] `worktree_exit` +- [x] `workflow_list` +- [x] `workflow_get` +- [x] `workflow_run` +- [x] `remote_trigger` - [x] `plan_get` - [x] `update_plan` - [x] `plan_clear` -- [x] `delegate_agent` - [x] `task_next` - [x] `task_list` - [x] `task_get` @@ -330,37 +406,53 @@ Done: - [x] `task_block` - [x] `task_cancel` - [x] `todo_write` +- [x] `delegate_agent` - [x] `team_list` - [x] `team_get` - [x] `team_create` - [x] `team_delete` - [x] `send_message` - [x] `team_messages` -- [x] `workflow_list` -- [x] `workflow_get` -- [x] `workflow_run` -- [x] `remote_trigger` -- [x] `worktree_status` -- [x] `worktree_enter` -- [x] `worktree_exit` -Missing: +### Tools in npm `tools.ts` not yet ported with full fidelity (40 tool dirs): -- [ ] Agent spawning tool parity beyond the current `delegate_agent` runtime tool -- [ ] Skill tool -- [ ] Web fetch parity beyond the current local text-fetch implementation -- [ ] Web search parity beyond the current provider-backed implementation -- [ ] LSP tool -- [ ] Tool search parity beyond the current local registry search -- [ ] Config tool -- [ ] Terminal capture tool -- [ ] Browser tool -- [x] Workflow tool -- [x] Remote trigger tool -- [ ] Sleep / cron tools beyond the current local `sleep` tool -- [ ] PowerShell tool parity -- [x] Worktree enter/exit tools -- [ ] Full `tools.ts` parity +Core tools needing full port: +- [ ] `AgentTool` — Sub-agent spawning with built-in agents (explore, general-purpose, verification, plan, claudeCodeGuide, statusline), fork support, agent memory/snapshots, resume agent, color management +- [ ] `SkillTool` — Skill execution with bundled skills +- [ ] `BriefTool` — Brief mode with attachments and file upload +- [ ] `LSPTool` — Language Server Protocol (diagnostics, go-to-definition, references, hover, symbol search, formatting) +- [ ] `PowerShellTool` — Full PowerShell execution with security, path validation, CLM types, git safety +- [ ] `REPLTool` — Interactive REPL with primitive tools (ant-only) +- [ ] `MCPTool` — Full MCP tool execution with collapse classification +- [ ] `McpAuthTool` — MCP authentication handling +- [ ] `ConfigTool` — Full config management with supported settings list +- [ ] `SyntheticOutputTool` — Synthetic output injection +- [ ] `EnterPlanModeTool` — Enter plan mode with UI +- [ ] `ExitPlanModeTool` — Exit plan mode with V2 flow +- [ ] `EnterWorktreeTool` — Full worktree enter with UI +- [ ] `ExitWorktreeTool` — Full worktree exit with UI +- [ ] `TaskOutputTool` — Task output display +- [ ] `TaskStopTool` — Stop a running task + +Feature-gated tools: +- [ ] `CronCreateTool` / `CronDeleteTool` / `CronListTool` — Cron scheduling (AGENT_TRIGGERS) +- [ ] `RemoteTriggerTool` — Full remote triggers with UI (AGENT_TRIGGERS_REMOTE) +- [ ] `MonitorTool` — MCP server monitoring (MONITOR_TOOL) +- [ ] `SendUserFileTool` — Send file to user (KAIROS) +- [ ] `PushNotificationTool` — Push notifications (KAIROS) +- [ ] `SubscribePRTool` — PR subscription (KAIROS_GITHUB_WEBHOOKS) +- [ ] `SuggestBackgroundPRTool` — Background PR (ant-only) +- [ ] `VerifyPlanExecutionTool` — Plan verification +- [ ] `TungstenTool` — Tungsten tool +- [ ] `WebBrowserTool` — Full web browser +- [ ] `TerminalCaptureTool` — Terminal capture +- [ ] `SnipTool` — Force history snipping +- [ ] `ListPeersTool` — List peers (UDS_INBOX) +- [ ] `EmbeddedSearchTool` — Embedded search +- [ ] `CtxInspectTool` — Context inspection +- [ ] `WorkflowTool` — Workflow scripts (WORKFLOW_SCRIPTS) + +Note: Python has basic tool execution for `bash`, `read_file`, etc., but lacks per-tool UI components, prompt files, constants, and deep security validations (e.g., BashTool has 15 supporting files in npm). ## 7. Commands And Task Systems @@ -383,13 +475,17 @@ Done: Missing: -- [ ] Real implementation of the larger upstream command tree +- [ ] Real implementation of the larger upstream command tree (80+ commands) +- [ ] Task types: `LocalShellTask`, `LocalAgentTask`, `RemoteAgentTask`, `DreamTask`, `LocalWorkflowTask`, `MonitorMcpTask`, `InProcessTeammateTask` +- [ ] Task stall detection (45s threshold) and prompt detection for interactive input +- [ ] Remote agent task session URL tracking and completion checkers +- [ ] Dream/auto-consolidation task with file tracking and turn history - [ ] Task orchestration system beyond the current local dependency-aware task runtime - [ ] Planner / task execution parity beyond the current local plan persistence, sync, and next-task flow - [ ] Team / collaboration command flows beyond the current local team runtime and message recording flows - [ ] Command-specific session behaviors -- [ ] Full `src/commands/*` parity -- [ ] Full `src/tasks/*` parity +- [ ] Full `src/commands/*` parity (80+ command directories) +- [ ] Full `src/tasks/*` parity (7 task types) ## 8. Permissions, Hooks, And Policy @@ -410,9 +506,13 @@ Done: Missing: -- [ ] Tool-permission workflow parity +- [x] Full BashTool security: `bashSecurity.ts`, `sedValidation.ts`, `sedEditParser.ts`, `pathValidation.ts`, `readOnlyValidation.ts`, `modeValidation.ts`, `commandSemantics.ts`, `destructiveCommandWarning.ts`, `shouldUseSandbox.ts` → `src/bash_security.py` (18 validators, destructive warnings, command semantics, read-only detection, 163 tests) +- [ ] Full PowerShellTool security: `powershellSecurity.ts`, `gitSafety.ts`, `clmTypes.ts` +- [ ] Tool-permission workflow parity (`bashPermissions.ts`, `powershellPermissions.ts`) - [ ] Trust-gated initialization -- [ ] Hook-config management +- [ ] Hook-config management (`schemas/hooks.ts` with Zod schemas) +- [ ] Policy limits service (`services/policyLimits/`) +- [ ] Remote managed settings (`services/remoteManagedSettings/`) - [ ] Full hooks and policy parity ## 9. MCP, Plugins, And Skills @@ -429,12 +529,13 @@ Done: Missing: -- [ ] Full MCP-backed tool parity beyond the current stdio resource/tool list/read/call support -- [ ] Plugin discovery and loading -- [ ] Bundled plugin support +- [ ] Full MCP service (`services/mcp/` — 25+ files: InProcessTransport, MCPConnectionManager, SdkControlTransport, auth, channelAllowlist, channelPermissions, client, config, elicitationHandler, envExpansion, normalization, oauthPort, officialRegistry, vscodeSdkMcp, xaa, xaaIdpLogin, etc.) +- [ ] MCP server approval dialogs (`services/mcpServerApproval.tsx`) +- [ ] Plugin discovery, loading, and installation (`services/plugins/PluginInstallationManager.ts`, `pluginCliCommands.ts`, `pluginOperations.ts`) +- [ ] Bundled plugin support (`plugins/bundledPlugins.ts`, `plugins/bundled/`) - [ ] Plugin lifecycle management - [ ] Plugin update/cache behavior -- [ ] Skill discovery and execution parity +- [ ] Skill discovery and execution (`skills/bundledSkills.ts`, `skills/loadSkillsDir.ts`, `skills/mcpSkillBuilders.ts`, `skills/bundled/`) - [ ] Bundled skill support - [ ] Full plugin and skill parity @@ -448,14 +549,22 @@ Done: Missing: -- [ ] Interactive REPL parity beyond the current basic `agent-chat` loop -- [ ] Ink/TUI component parity -- [ ] Screen system parity +- [ ] Interactive REPL parity (`screens/REPL.tsx`) +- [ ] Ink/TUI framework (`ink/` — 40+ files: custom renderer, reconciler, DOM, layout engine, text wrapping, ANSI handling, focus management, selection) +- [ ] Screen system (`screens/Doctor.tsx`, `screens/ResumeConversation.tsx`) +- [ ] Component library (`components/` — 100+ components in 12+ subdirectories): + - Message rendering: Message, MessageRow, Messages, MessageSelector, MessageResponse + - Dialogs: ApproveApiKey, AutoModeOptIn, Bridge, CostThreshold, IdeAutoConnect, MCPServerApproval + - Settings: ThemePicker, LanguagePicker, ModelPicker, OutputStylePicker + - Search: GlobalSearchDialog, QuickOpenDialog, HistorySearchDialog + - Status: AgentProgressLine, BashModeProgress, MemoryUsageIndicator, TokenWarning + - Design system, agent, team, task, skill, memory, permissions, sandbox, shell components - [ ] Keyboard interaction parity - [ ] Interactive status panes - [ ] Approval UI flows - [ ] Rich incremental rendering -- [ ] Full `components`, `screens`, and `ink` parity +- [ ] Virtual scrolling +- [ ] Copy-on-select behavior ## 11. Remote, Background, And Team Features @@ -470,12 +579,12 @@ Done: Missing: -- [ ] Real remote execution modes beyond the current local manifest-backed remote runtime and CLI/profile flows -- [ ] Team runtime features -- [ ] Team messaging features +- [ ] Real remote session management (`remote/` — 4 files: RemoteSessionManager, SessionsWebSocket, remotePermissionBridge, sdkMessageAdapter) +- [ ] Bridge subsystem (`bridge/` — 30+ files: bridgeMain, bridgeApi, bridgeConfig, bridgeMessaging, bridgePermissionCallbacks, replBridge, replBridgeHandle, replBridgeTransport, sessionRunner, trustedDevice, jwtUtils, capacityWake, inboundAttachments, inboundMessages, etc.) +- [ ] Direct connect subsystem (`server/createDirectConnectSession.ts`, `directConnectManager.ts`) +- [ ] Real team collaboration beyond local recording - [ ] Shared remote state -- [ ] Upstream proxy runtime integration -- [ ] Full `remote`, `server`, `bridge`, `upstreamproxy`, and team parity +- [ ] Upstream proxy (`upstreamproxy/upstreamproxy.ts`, `upstreamproxy/relay.ts`) ## 12. Editor, Platform, And Native Integrations @@ -485,14 +594,16 @@ Done: Missing: -- [ ] Voice mode parity -- [ ] VIM mode parity -- [ ] Keybinding parity -- [ ] Notification hooks -- [ ] Native TypeScript / platform helper parity -- [ ] JetBrains/editor integration parity +- [ ] Voice mode (`voice/`, `services/voice.ts`, `services/voiceKeyterms.ts`, `services/voiceStreamSTT.ts`, hooks) +- [ ] VIM mode (`vim/` — 5 files: motions, operators, textObjects, transitions, types) +- [ ] Keybinding system (`keybindings/` — 13 files: defaultBindings, loadUserBindings, match, parser, resolver, schema, template, validate, etc.) +- [ ] Notification hooks (`services/notifier.ts`, `services/preventSleep.ts`) +- [ ] Native TypeScript / platform helpers (`native-ts/`) +- [ ] JetBrains/editor integration (`utils/jetbrains.ts`, `utils/ide.ts`, `utils/idePathConversion.ts`) - [ ] Browser/native host integrations +- [ ] IDE integration hooks (useIDEIntegration, useIdeAtMentioned, useIdeSelection, useIdeLogging, useDiffInIDE, useLspPluginRecommendation) - [ ] Platform-specific startup/shutdown logic +- [ ] Chrome extension integration ## 13. Services And Internal Subsystems @@ -503,62 +614,196 @@ Done: Missing: -- [ ] Real service implementations for the mirrored `services` package -- [ ] Config service parity -- [ ] Account/auth service parity -- [ ] Analytics/telemetry service parity -- [ ] Growthbook/feature-flag parity -- [ ] GitHub / git helper parity -- [ ] Sandbox/settings utility parity -- [ ] Todo/task utility parity -- [ ] Internal helpers used by the upstream runtime +- [ ] Analytics service (`services/analytics/` — 10+ files: config, Datadog, Growthbook, first-party event logger, sink, killswitch) +- [ ] API service (`services/api/` — 20+ files: claude client, dumpPrompts, errorUtils, filesApi, firstTokenDate, grove, logging, metricsOptOut, promptCacheBreakDetection, sessionIngress, usage, withRetry, etc.) +- [ ] LSP service (`services/lsp/` — 7 files: LSPClient, LSPDiagnosticRegistry, LSPServerInstance, LSPServerManager, config, manager, passiveFeedback) +- [ ] Tools service (`services/tools/` — 4 files: StreamingToolExecutor, toolExecution, toolHooks, toolOrchestration) +- [ ] Compact service (`services/compact/` — 6 files: compact, autoCompact, microCompact, apiMicrocompact, sessionMemoryCompact, compactWarningHook) +- [ ] Auto-dream service (`services/autoDream/` — 4 files: autoDream, config, consolidationLock, consolidationPrompt) +- [ ] Agent summary service (`services/AgentSummary/`) +- [ ] Magic docs service (`services/MagicDocs/`) +- [ ] Session memory service (`services/SessionMemory/`) +- [ ] Prompt suggestion service (`services/PromptSuggestion/`) +- [ ] Extract memories service (`services/extractMemories/`) +- [ ] Diagnostic tracking service (`services/diagnosticTracking.ts`) +- [ ] OAuth service (`services/oauth/` — 5 files) +- [ ] Rate limiting (`services/claudeAiLimits.ts`, `services/rateLimitMessages.ts`, etc.) +- [ ] Settings sync (`services/settingsSync/`) +- [ ] Tips service (`services/tips/`) +- [ ] Tool use summary service (`services/toolUseSummary/`) +- [ ] VCR playback (`services/vcr.ts`) +- [ ] Internal/container logging (`services/internalLogging.ts`) +- [ ] Plugin installation management (`services/plugins/`) -## 14. Mirrored Workspace Versus Working Runtime +## 14. State Management -Working Python runtime today: +Done: -- [x] `src/main.py` -- [x] `src/agent_runtime.py` -- [x] `src/agent_tools.py` -- [x] `src/agent_prompting.py` -- [x] `src/agent_context.py` -- [x] `src/agent_context_usage.py` -- [x] `src/agent_session.py` -- [x] `src/agent_slash_commands.py` -- [x] `src/account_runtime.py` -- [x] `src/config_runtime.py` -- [x] `src/agent_types.py` -- [x] `src/mcp_runtime.py` -- [x] `src/plan_runtime.py` -- [x] `src/plugin_runtime.py` -- [x] `src/remote_runtime.py` -- [x] `src/search_runtime.py` -- [x] `src/hook_policy.py` -- [x] `src/background_runtime.py` -- [x] `src/task.py` -- [x] `src/task_runtime.py` -- [x] `src/tokenizer_runtime.py` -- [x] `src/openai_compat.py` -- [x] `src/session_store.py` -- [x] `src/permissions.py` +- [x] Session state via `AgentSessionState` dataclass +- [x] Basic session persistence -Mirrored inventory / scaffold areas that still need real implementation work: +Missing: -- [ ] `src/commands.py` -- [ ] `src/tools.py` -- [ ] `src/query_engine.py` -- [ ] `src/runtime.py` -- [ ] Remaining mirrored inventory surfaces still represented mainly by snapshot data under `src/reference_data/*` -- [ ] Command/task/plugin/skill/service/editor subsystems that exist upstream but do not yet have real Python modules after the tree cleanup +- [ ] Zustand store (`state/AppStateStore.ts`, `state/store.ts`) +- [ ] Store selectors (`state/selectors.ts`) +- [ ] State change callbacks (`state/onChangeAppState.ts`) +- [ ] React context providers (`state/AppState.tsx`) -## 15. High-Priority Next Steps +## 15. React Hooks (84+ hooks in `src/hooks/`) -- [ ] Expand the real Python tool registry toward upstream `tools.ts` -- [ ] Replace more snapshot-backed mirrored modules with working runtime code -- [ ] Expand MCP parity beyond the current stdio resource/tool transport support -- [ ] Expand hooks and policy parity beyond the current manifest/runtime implementation -- [ ] Build a real interactive REPL / TUI -- [ ] Expand background session parity beyond the current local worker/log/attach model -- [ ] Add real remote session transport and shared remote state beyond the current local remote-profile runtime -- [ ] Port more of the command/task system -- [ ] Close the gap between the mirrored workspace and the working runtime +Not applicable for Python (no React TUI), but these represent features needing alternative implementations: + +- [ ] File suggestions and unified suggestions +- [ ] Remote session / SSH / direct connect hooks +- [ ] Input buffer, text input, vim input, typeahead, search input, paste handling +- [ ] Arrow key history, history search, background task navigation +- [ ] Main loop model selection, assistant history, merged clients/commands/tools +- [ ] Tool permission checking, cancel request, manage plugins +- [ ] Global/command keybindings, exit handling, double-press detection +- [ ] Terminal size, virtual scroll, copy-on-select +- [ ] Voice recording, voice integration +- [ ] IDE integration, @mention, selection, diff-in-IDE +- [ ] Settings management, dynamic config +- [ ] Timeout, elapsed time, scheduled tasks, delayed notifications +- [ ] Prompt suggestion, update notification, feature hints +- [ ] Queue processor, command queue +- [ ] Memory usage, away summary, teleport resume +- [ ] Diff data, turn diffs +- [ ] Task list watcher, tasks v2, PR status +- [ ] Session backgrounding, swarm initialization/permission +- [ ] API key verification, mailbox bridge, inbox poller + +## 16. Utilities (200+ files in `src/utils/`) + +Done: + +- [x] Basic file operations in tool implementations +- [x] Basic git status snapshot +- [x] Basic shell/subprocess handling + +Missing major utility categories: + +- [ ] Shell utilities (`utils/bash/`, `utils/shell/`, `Shell.ts`, `ShellCommand.ts`) +- [ ] Git operations (`utils/git.ts`, `utils/gitDiff.ts`, `utils/gitSettings.ts`, `utils/commitAttribution.ts`) +- [ ] File operations (`utils/file.ts`, `utils/fileRead.ts`, `utils/fileHistory.ts`, `utils/fileStateCache.ts`, `utils/fsOperations.ts`, `utils/ripgrep.ts`, `utils/glob.ts`) +- [ ] AI/Model utilities (`utils/modelCost.ts`, `utils/model/`, `utils/context.ts`, `utils/queryContext.ts`) +- [ ] Config/Settings (`utils/config.ts`, `utils/settings/`) +- [ ] Message handling (`utils/messages.ts`, `utils/messages/`, `utils/messageQueueManager.ts`) +- [ ] API/Network (`utils/api.ts`, `utils/http.ts`, `utils/proxy.ts`, `utils/auth.ts`) +- [ ] Session management (`utils/sessionStorage.ts`, `utils/sessionState.ts`, `utils/sessionStart.ts`, `utils/sessionRestore.ts`) +- [ ] Plugin/Skill utilities (`utils/plugins/`, `utils/skills/`) +- [ ] Memory/Context (`utils/memory/`, `utils/claudemd.ts`, `utils/contextAnalysis.ts`) +- [ ] IDE integration (`utils/ide.ts`, `utils/jetbrains.ts`) +- [ ] Platform/OS (`utils/platform.ts`, `utils/terminal.ts`, `utils/systemDirectories.ts`) +- [ ] Debugging (`utils/debug.ts`, `utils/diagLogs.ts`, `utils/log.ts`, `utils/profilerBase.ts`) +- [ ] Telemetry (`utils/telemetry/`) +- [ ] Deep link utilities (`utils/deepLink/`) + +## 17. Coordinator And Buddy + +Missing: + +- [ ] Coordinator mode (`coordinator/coordinatorMode.ts` — agent tool filtering and async agent allowlist) +- [ ] Buddy/companion system (`buddy/` — 6 files: CompanionSprite, companion procedural generation, personality prompts, sprites, types, notification UI) + +## 18. Migrations + +Missing: + +- [ ] Data/config migration system (`migrations/` — 11 migration scripts): + - Model migrations: migrateFennecToOpus, migrateLegacyOpusToCurrent, migrateOpusToOpus1m, migrateSonnet1mToSonnet45, migrateSonnet45ToSonnet46 + - Feature migrations: migrateAutoUpdatesToSettings, migrateBypassPermissionsAcceptedToSettings, migrateEnableAllProjectMcpServersToSettings, migrateReplBridgeEnabledToRemoteControlAtStartup + - Config resets: resetAutoModeOptInForDefaultOffer, resetProToOpusDefault + +## 19. Type Definitions + +Missing: + +- [ ] Full type system from `types/` (command.ts, hooks.ts, ids.ts, logs.ts, permissions.ts, plugin.ts, textInputTypes.ts, generated/) + +## 20. Mirrored Workspace Versus Working Runtime + +Working Python runtime today (21,193 lines across 51 source files, 10,480 lines across 37 test files): + +- [x] `src/main.py` (1,353 lines) +- [x] `src/agent_runtime.py` (3,664 lines) +- [x] `src/agent_tools.py` (2,994 lines) +- [x] `src/agent_prompting.py` (390 lines) +- [x] `src/agent_context.py` (459 lines) +- [x] `src/agent_context_usage.py` (356 lines) +- [x] `src/agent_session.py` (718 lines) +- [x] `src/agent_slash_commands.py` (633 lines) +- [x] `src/agent_manager.py` (296 lines) +- [x] `src/agent_plugin_cache.py` (154 lines) +- [x] `src/agent_types.py` (193 lines) +- [x] `src/account_runtime.py` (470 lines) +- [x] `src/ask_user_runtime.py` (320 lines) +- [x] `src/background_runtime.py` (371 lines) +- [x] `src/config_runtime.py` (296 lines) +- [x] `src/hook_policy.py` (339 lines) +- [x] `src/mcp_runtime.py` (880 lines) +- [x] `src/openai_compat.py` (413 lines) +- [x] `src/permissions.py` (20 lines) +- [x] `src/plan_runtime.py` (396 lines) +- [x] `src/plugin_runtime.py` (654 lines) +- [x] `src/query_engine.py` (655 lines) +- [x] `src/remote_runtime.py` (571 lines) +- [x] `src/remote_trigger_runtime.py` (371 lines) +- [x] `src/search_runtime.py` (606 lines) +- [x] `src/session_store.py` (295 lines) +- [x] `src/task.py` (130 lines) +- [x] `src/task_runtime.py` (595 lines) +- [x] `src/team_runtime.py` (386 lines) +- [x] `src/tokenizer_runtime.py` (202 lines) +- [x] `src/workflow_runtime.py` (319 lines) +- [x] `src/worktree_runtime.py` (448 lines) +- [x] Plus 19 supporting modules + +Mirrored / scaffold areas needing real implementation: + +- [ ] `src/commands.py` — currently minimal dispatch, needs full command tree +- [ ] `src/tools.py` — reference-data based tool loading, needs real per-tool implementations +- [ ] `src/query_engine.py` — facade layer, needs full QueryEngine.ts parity +- [ ] `src/runtime.py` — routing layer, needs full runtime parity +- [ ] Remaining inventory surfaces under `src/reference_data/*` + +--- + +## High-Priority Next Steps + +### Tier 1 — Core Feature Gaps (highest user impact) +- [x] Full BashTool security parity (sed validation, path validation, sandbox, destructive command warnings, command semantics) → `src/bash_security.py` +- [ ] LSP tool integration for code intelligence +- [ ] Full AgentTool with built-in agent types (explore, general-purpose, verification, plan) +- [ ] Auto-compact and context collapse from `query.ts` +- [ ] Full compact service (autoCompact, microCompact, sessionMemoryCompact) +- [ ] Interactive REPL improvements + +### Tier 2 — Important Feature Gaps +- [ ] SkillTool with bundled skills +- [ ] Full MCP service parity (auth, permissions, config, registry) +- [ ] Plugin discovery, loading, and installation +- [ ] Real remote session management (WebSocket, bridge) +- [ ] Full command tree implementation (80+ commands) +- [ ] Migration system for config/model upgrades +- [ ] Token budget calculations + +### Tier 3 — Nice-to-Have Features +- [ ] TUI/Ink component library +- [ ] Voice mode +- [ ] VIM mode and keybinding system +- [ ] IDE integrations (JetBrains, VS Code) +- [ ] Chrome extension integration +- [ ] Buddy/companion system +- [ ] Analytics/telemetry +- [ ] Coordinator mode +- [ ] Feature flag system (Growthbook) + +### Tier 4 — Platform/Enterprise Features +- [ ] Full bridge subsystem (30+ files) +- [ ] Upstream proxy +- [ ] Direct connect server +- [ ] OAuth service +- [ ] Settings sync +- [ ] Rate limiting and policy limits +- [ ] Dream/auto-consolidation service diff --git a/benchmarks/download_datasets.py b/benchmarks/download_datasets.py index a3378bd..20559ae 100644 --- a/benchmarks/download_datasets.py +++ b/benchmarks/download_datasets.py @@ -1,6 +1,9 @@ #!/usr/bin/env python3 """ Download or export benchmark datasets into benchmarks/data. + +Uses the HuggingFace `datasets` library for reliable full downloads. +Falls back to the REST API or builtins if `datasets` is not installed. """ from __future__ import annotations @@ -34,10 +37,11 @@ from benchmarks.suites.swe_bench import _BUILTIN_PROBLEMS as _SWE_BUILTINS from benchmarks.suites.tau2 import _BUILTIN_PROBLEMS as _TAU2_BUILTINS -HF_DATASET_VIEWER_BASE = "https://datasets-server.huggingface.co" HUMANEVAL_GZ_URL = "https://raw.githubusercontent.com/openai/human-eval/master/data/HumanEval.jsonl.gz" DEFAULT_DATA_DIR = Path(__file__).resolve().parent / "data" +# Legacy REST API support (fallback only) +HF_DATASET_VIEWER_BASE = "https://datasets-server.huggingface.co" JsonFetcher = Callable[[str, dict[str, object], dict[str, str] | None, float], object] @@ -74,33 +78,45 @@ def _write_jsonl(path: Path, rows: list[dict[str, Any]]) -> int: path.parent.mkdir(parents=True, exist_ok=True) with open(path, "w", encoding="utf-8") as handle: for row in rows: - handle.write(json.dumps(row, ensure_ascii=True) + "\n") + handle.write(json.dumps(row, ensure_ascii=False) + "\n") return len(rows) -def _extract_gsm8k_answer(text: str) -> str: - if "####" in text: - text = text.split("####", 1)[1] - numbers = re.findall(r"-?\d[\d,]*\.?\d*", text.replace("$", "")) - if numbers: - return numbers[-1].replace(",", "") - return text.strip().replace(",", "") +# --------------------------------------------------------------------------- +# HuggingFace `datasets` library helpers +# --------------------------------------------------------------------------- + +def _load_hf_dataset( + dataset_name: str, + config: str | None = None, + split: str = "test", +) -> list[dict[str, Any]]: + """Load a dataset using the HuggingFace `datasets` library.""" + from datasets import load_dataset # type: ignore[import-untyped] + + kwargs: dict[str, Any] = {} + if config: + kwargs["name"] = config + + ds = load_dataset(dataset_name, split=split, **kwargs) + return [dict(row) for row in ds] # type: ignore[union-attr] -def _extract_math_answer(solution: str) -> str: - boxed_fraction = re.search(r"\\boxed\{\\frac\{([^}]+)\}\{([^}]+)\}\}", solution, flags=re.DOTALL) - if boxed_fraction: - return f"{boxed_fraction.group(1).strip()}/{boxed_fraction.group(2).strip()}" - boxed = re.search(r"\\boxed\{([^{}]+)\}", solution, flags=re.DOTALL) - value = boxed.group(1) if boxed else solution - value = value.strip() - value = value.replace("\\frac{", "").replace("}{", "/").replace("}", "") - value = value.replace("$", "").replace(",", "").strip() - fraction = re.search(r"-?\d+\s*/\s*-?\d+", value) - if fraction: - return fraction.group(0).replace(" ", "") - numbers = re.findall(r"-?\d+(?:/\d+)?(?:\.\d+)?", value) - return numbers[-1] if numbers else value +def _try_load_hf( + dataset_name: str, + config: str | None = None, + split_preference: tuple[str, ...] = ("test", "validation", "train"), +) -> list[dict[str, Any]]: + """Try loading with preferred splits, falling back through the list.""" + for split in split_preference: + try: + rows = _load_hf_dataset(dataset_name, config=config, split=split) + if rows: + print(f" Loaded {len(rows)} rows from {dataset_name} [{split}]") + return rows + except (ValueError, KeyError): + continue + raise ValueError(f"No valid split found for {dataset_name}") def _fetch_hf_rows( @@ -112,6 +128,7 @@ def _fetch_hf_rows( timeout: float = 60.0, headers: dict[str, str] | None = None, ) -> list[dict[str, Any]]: + """Legacy REST-API fetcher (kept for backward compatibility with tests).""" splits_payload = json_fetcher("splits", {"dataset": dataset}, headers, timeout) splits = list((splits_payload or {}).get("splits", [])) # type: ignore[assignment] if not splits: @@ -164,6 +181,39 @@ def _fetch_hf_rows( return rows +# --------------------------------------------------------------------------- +# Answer extraction helpers +# --------------------------------------------------------------------------- + +def _extract_gsm8k_answer(text: str) -> str: + if "####" in text: + text = text.split("####", 1)[1] + numbers = re.findall(r"-?\d[\d,]*\.?\d*", text.replace("$", "")) + if numbers: + return numbers[-1].replace(",", "") + return text.strip().replace(",", "") + + +def _extract_math_answer(solution: str) -> str: + boxed_fraction = re.search(r"\\boxed\{\\frac\{([^}]+)\}\{([^}]+)\}\}", solution, flags=re.DOTALL) + if boxed_fraction: + return f"{boxed_fraction.group(1).strip()}/{boxed_fraction.group(2).strip()}" + boxed = re.search(r"\\boxed\{([^{}]+)\}", solution, flags=re.DOTALL) + value = boxed.group(1) if boxed else solution + value = value.strip() + value = value.replace("\\frac{", "").replace("}{", "/").replace("}", "") + value = value.replace("$", "").replace(",", "").strip() + fraction = re.search(r"-?\d+\s*/\s*-?\d+", value) + if fraction: + return fraction.group(0).replace(" ", "") + numbers = re.findall(r"-?\d+(?:/\d+)?(?:\.\d+)?", value) + return numbers[-1] if numbers else value + + +# --------------------------------------------------------------------------- +# Individual dataset downloaders (using `datasets` library) +# --------------------------------------------------------------------------- + def _download_humaneval(output_path: Path, *, timeout: float) -> DownloadResult: raw = fetch_bytes(HUMANEVAL_GZ_URL, timeout=timeout) if raw[:2] == b"\x1f\x8b": @@ -187,15 +237,19 @@ def _download_gsm8k( output_path: Path, *, timeout: float, - json_fetcher: JsonFetcher = fetch_json, + json_fetcher: JsonFetcher | None = None, ) -> DownloadResult: - rows = _fetch_hf_rows( - "openai/gsm8k", - config_preference=("main",), - split_preference=("test",), - json_fetcher=json_fetcher, - timeout=timeout, - ) + if json_fetcher is not None: + # Legacy path for tests + rows = _fetch_hf_rows( + "openai/gsm8k", + config_preference=("main",), + split_preference=("test",), + json_fetcher=json_fetcher, + timeout=timeout, + ) + else: + rows = _try_load_hf("openai/gsm8k", config="main", split_preference=("test",)) normalized = [ { "id": f"gsm8k-{index + 1:04d}", @@ -208,19 +262,11 @@ def _download_gsm8k( return DownloadResult("gsm8k", count, str(output_path), "official") -def _download_mbpp( - output_path: Path, - *, - timeout: float, - json_fetcher: JsonFetcher = fetch_json, -) -> DownloadResult: - rows = _fetch_hf_rows( - "google-research-datasets/mbpp", - config_preference=("sanitized", "full"), - split_preference=("test", "validation"), - json_fetcher=json_fetcher, - timeout=timeout, - ) +def _download_mbpp(output_path: Path, *, timeout: float) -> DownloadResult: + try: + rows = _try_load_hf("google-research-datasets/mbpp", config="sanitized", split_preference=("test", "validation")) + except Exception: + rows = _try_load_hf("google-research-datasets/mbpp", config="full", split_preference=("test", "validation")) normalized = [ { "task_id": row.get("task_id", index + 1), @@ -234,19 +280,8 @@ def _download_mbpp( return DownloadResult("mbpp", count, str(output_path), "official") -def _download_math( - output_path: Path, - *, - timeout: float, - json_fetcher: JsonFetcher = fetch_json, -) -> DownloadResult: - rows = _fetch_hf_rows( - "hendrycks/competition_math", - config_preference=("default",), - split_preference=("test", "train"), - json_fetcher=json_fetcher, - timeout=timeout, - ) +def _download_math(output_path: Path, *, timeout: float) -> DownloadResult: + rows = _try_load_hf("DigitalLearningGmbH/MATH-lighteval", split_preference=("test", "train")) normalized = [ { "id": row.get("problem_id", f"math-{index + 1:04d}"), @@ -261,47 +296,29 @@ def _download_math( return DownloadResult("math", count, str(output_path), "official") -def _download_mmlu_pro( - output_path: Path, - *, - timeout: float, - json_fetcher: JsonFetcher = fetch_json, -) -> DownloadResult: - rows = _fetch_hf_rows( - "TIGER-Lab/MMLU-Pro", - config_preference=("default",), - split_preference=("test", "validation"), - json_fetcher=json_fetcher, - timeout=timeout, - ) +def _download_mmlu_pro(output_path: Path, *, timeout: float) -> DownloadResult: + rows = _try_load_hf("TIGER-Lab/MMLU-Pro", split_preference=("test", "validation")) letters = "ABCDEFGHIJ" - normalized = [ - { + normalized = [] + for index, row in enumerate(rows): + answer_raw = row.get("answer", "") + if isinstance(answer_raw, int) and answer_raw < len(letters): + answer = letters[answer_raw] + else: + answer = str(answer_raw) + normalized.append({ "id": f"mmlu-pro-{index + 1:04d}", "subject": row.get("category", row.get("subject", "unknown")), "question": row.get("question", ""), "choices": row.get("options", row.get("choices", [])), - "answer": letters[row["answer"]] if isinstance(row.get("answer"), int) else str(row.get("answer", "")), - } - for index, row in enumerate(rows) - ] + "answer": answer, + }) count = _write_jsonl(output_path, normalized) return DownloadResult("mmlu-pro", count, str(output_path), "official") -def _download_gpqa( - output_path: Path, - *, - timeout: float, - json_fetcher: JsonFetcher = fetch_json, -) -> DownloadResult: - rows = _fetch_hf_rows( - "Idavidrein/gpqa", - config_preference=("gpqa_diamond",), - split_preference=("train",), - json_fetcher=json_fetcher, - timeout=timeout, - ) +def _download_gpqa(output_path: Path, *, timeout: float) -> DownloadResult: + rows = _try_load_hf("Idavidrein/gpqa", config="gpqa_diamond", split_preference=("train",)) normalized = [] for index, row in enumerate(rows): choices = [ @@ -315,85 +332,79 @@ def _download_gpqa( "subject": row.get("Subdomain", row.get("domain", "science")), "question": row.get("Question", ""), "choices": choices, - "answer": "A", # Correct answer is always first; shuffle at eval time if needed + "answer": "A", }) count = _write_jsonl(output_path, normalized) return DownloadResult("gpqa-diamond", count, str(output_path), "official") -def _download_bigbench_hard( - output_path: Path, - *, - timeout: float, - json_fetcher: JsonFetcher = fetch_json, -) -> DownloadResult: - rows = _fetch_hf_rows( - "maveriq/bigbenchhard", - config_preference=("default",), - split_preference=("train",), - json_fetcher=json_fetcher, - timeout=timeout, - ) - letters = "ABCDEFGHIJ" +def _download_bigbench_hard(output_path: Path, *, timeout: float) -> DownloadResult: + from datasets import load_dataset # type: ignore[import-untyped] + + configs = [ + "boolean_expressions", "causal_judgement", "date_understanding", + "disambiguation_qa", "dyck_languages", "formal_fallacies", + "geometric_shapes", "hyperbaton", "logical_deduction_three_objects", + "logical_deduction_five_objects", "logical_deduction_seven_objects", + "movie_recommendation", "multistep_arithmetic_two", "navigate", + "object_counting", "penguins_in_a_table", + "reasoning_about_colored_objects", "ruin_names", + "salient_translation_error_detection", "snarks", + "sports_understanding", "temporal_sequences", + "tracking_shuffled_objects_three_objects", + "tracking_shuffled_objects_five_objects", + "tracking_shuffled_objects_seven_objects", + "web_of_lies", "word_sorting", + ] + all_rows: list[dict[str, Any]] = [] + for config in configs: + try: + ds = load_dataset("lukaemon/bbh", config, split="test") + for row in ds: + row_dict = dict(row) # type: ignore[arg-type] + row_dict["task"] = config + all_rows.append(row_dict) + except Exception: + continue + print(f" Loaded {len(all_rows)} rows from lukaemon/bbh [{len(configs)} tasks]") normalized = [] - for index, row in enumerate(rows): - choices = row.get("choices", row.get("multiple_choice_targets", [])) - answer = row.get("answer", row.get("target", "")) - if isinstance(answer, int) and answer < len(letters): - answer = letters[answer] + for index, row in enumerate(all_rows): + target = row.get("target", row.get("answer", "")) normalized.append({ - "id": f"bbh-{index + 1:04d}", - "task": row.get("task", row.get("subject", "unknown")), + "id": f"bbh-{index + 1:05d}", + "task": row.get("task", "unknown"), "question": row.get("input", row.get("question", "")), - "choices": choices, - "answer": str(answer), + "choices": [], + "answer": str(target).strip(), }) count = _write_jsonl(output_path, normalized) return DownloadResult("bigbench-hard", count, str(output_path), "official") -def _download_mmmlu( - output_path: Path, - *, - timeout: float, - json_fetcher: JsonFetcher = fetch_json, -) -> DownloadResult: - rows = _fetch_hf_rows( - "openai/MMMLU", - config_preference=("default",), - split_preference=("test", "validation"), - json_fetcher=json_fetcher, - timeout=timeout, - ) +def _download_mmmlu(output_path: Path, *, timeout: float) -> DownloadResult: + rows = _try_load_hf("openai/MMMLU", split_preference=("test", "validation")) letters = "ABCD" - normalized = [ - { + normalized = [] + for index, row in enumerate(rows): + answer_raw = row.get("answer", "") + if isinstance(answer_raw, int) and answer_raw < len(letters): + answer = letters[answer_raw] + else: + answer = str(answer_raw) + normalized.append({ "id": f"mmmlu-{index + 1:04d}", "language": row.get("language", "en"), "subject": row.get("subject", "unknown"), "question": row.get("question", ""), "choices": row.get("choices", row.get("options", [])), - "answer": letters[row["answer"]] if isinstance(row.get("answer"), int) else str(row.get("answer", "")), - } - for index, row in enumerate(rows) - ] + "answer": answer, + }) count = _write_jsonl(output_path, normalized) return DownloadResult("mmmlu", count, str(output_path), "official") -def _download_hle( - output_path: Path, - *, - timeout: float, - json_fetcher: JsonFetcher = fetch_json, -) -> DownloadResult: - rows = _fetch_hf_rows( - "cais/hle", - config_preference=("default",), - split_preference=("test", "validation", "train"), - json_fetcher=json_fetcher, - timeout=timeout, - ) +def _download_hle(output_path: Path, *, timeout: float) -> DownloadResult: + rows = _try_load_hf("cais/hle", split_preference=("test", "validation", "train")) normalized = [] for index, row in enumerate(rows): entry: dict[str, Any] = { @@ -491,6 +502,9 @@ def prepare_suite( if official_only: raise note = f"official download failed: {exc}" + print(f" WARNING: {note}") + print(f" Falling back to {len(_builtin_rows(suite))} built-in problems.") + print(f" To get full data, install `datasets`: pip install datasets") return _export_builtin( output_path, suite, diff --git a/src/agent_runtime.py b/src/agent_runtime.py index e695264..c42180d 100644 --- a/src/agent_runtime.py +++ b/src/agent_runtime.py @@ -99,6 +99,8 @@ class LocalCodingAgent: worktree_runtime: WorktreeRuntime | None = None last_session: AgentSessionState | None = field(default=None, init=False, repr=False) last_run_result: AgentRunResult | None = field(default=None, init=False, repr=False) + cumulative_usage: UsageStats = field(default_factory=UsageStats, init=False, repr=False) + cumulative_cost_usd: float = field(default=0.0, init=False, repr=False) active_session_id: str | None = field(default=None, init=False, repr=False) last_session_path: str | None = field(default=None, init=False, repr=False) managed_agent_id: str | None = field(default=None, init=False, repr=False) @@ -321,6 +323,7 @@ class LocalCodingAgent: scratchpad_directory=scratchpad_directory, existing_file_history=(), ) + self._accumulate_usage(result) self._finalize_managed_agent(result) return result @@ -357,6 +360,7 @@ class LocalCodingAgent: scratchpad_directory=scratchpad_directory, existing_file_history=stored_session.file_history, ) + self._accumulate_usage(result) self._finalize_managed_agent(result) return result @@ -3363,6 +3367,11 @@ class LocalCodingAgent: ) self.resume_source_session_id = None + def _accumulate_usage(self, result: AgentRunResult) -> None: + """Add a run's usage to the cumulative session totals.""" + self.cumulative_usage = self.cumulative_usage + result.usage + self.cumulative_cost_usd += result.total_cost_usd + def _refresh_runtime_views_for_tool_result( self, tool_name: str, diff --git a/src/agent_slash_commands.py b/src/agent_slash_commands.py index 89b4ea0..e61e250 100644 --- a/src/agent_slash_commands.py +++ b/src/agent_slash_commands.py @@ -294,6 +294,71 @@ def get_slash_command_specs() -> tuple[SlashCommandSpec, ...]: description='Clear ephemeral Python runtime state for this process.', handler=_handle_clear, ), + SlashCommandSpec( + names=('compact',), + description='Summarise and compact the conversation to free context space.', + handler=_handle_compact, + ), + SlashCommandSpec( + names=('cost',), + description='Show the total cost and duration of the current session.', + handler=_handle_cost, + ), + SlashCommandSpec( + names=('exit', 'quit'), + description='Exit the REPL.', + handler=_handle_exit, + ), + SlashCommandSpec( + names=('diff',), + description='View uncommitted changes (git diff) in the working directory.', + handler=_handle_diff, + ), + SlashCommandSpec( + names=('files',), + description='List files currently loaded in the session context.', + handler=_handle_files, + ), + SlashCommandSpec( + names=('copy',), + description='Copy the last assistant response to a temp file.', + handler=_handle_copy, + ), + SlashCommandSpec( + names=('export',), + description='Export the conversation to a text file.', + handler=_handle_export, + ), + SlashCommandSpec( + names=('stats',), + description='Show session usage statistics.', + handler=_handle_stats, + ), + SlashCommandSpec( + names=('tag',), + description='Add or remove a searchable tag on the current session.', + handler=_handle_tag, + ), + SlashCommandSpec( + names=('rename',), + description='Rename the current conversation.', + handler=_handle_rename, + ), + SlashCommandSpec( + names=('branch',), + description='Create a fork/branch of the current conversation.', + handler=_handle_branch, + ), + SlashCommandSpec( + names=('effort',), + description='Show or set the model effort level (low, medium, high, max, auto).', + handler=_handle_effort, + ), + SlashCommandSpec( + names=('doctor',), + description='Diagnose and verify the claw-code installation and settings.', + handler=_handle_doctor, + ), ) @@ -620,6 +685,417 @@ def _handle_clear(agent: 'LocalCodingAgent', _args: str, input_text: str) -> Sla ) +def _handle_compact(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult: + from .compact import compact_conversation + + custom_instructions = args.strip() if args.strip() else None + result = compact_conversation(agent, custom_instructions) + + if result.error: + return _local_result(input_text, f'Compact failed: {result.error}') + + lines = ['Conversation compacted.'] + if result.pre_compact_token_count: + lines.append( + f' Tokens before: ~{result.pre_compact_token_count:,} ' + f'→ after: ~{result.post_compact_token_count:,}' + ) + return _local_result(input_text, '\n'.join(lines)) + + +def _handle_cost(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult: + usage = agent.cumulative_usage + cost = agent.cumulative_cost_usd + + def _fmt_cost(usd: float) -> str: + if usd < 0.01: + return f'${usd:.4f}' + return f'${usd:.2f}' + + lines = [ + f'Total cost: {_fmt_cost(cost)}', + f'Total input tokens: {usage.input_tokens:,}', + f'Total output tokens: {usage.output_tokens:,}', + ] + if usage.cache_read_input_tokens: + lines.append(f'Cache read tokens: {usage.cache_read_input_tokens:,}') + if usage.cache_creation_input_tokens: + lines.append(f'Cache creation tokens: {usage.cache_creation_input_tokens:,}') + if usage.reasoning_tokens: + lines.append(f'Reasoning tokens: {usage.reasoning_tokens:,}') + lines.append(f'Total tokens: {usage.total_tokens:,}') + return _local_result(input_text, '\n'.join(lines)) + + +def _handle_exit(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult: + import random + import sys + + messages = ['Goodbye!', 'See ya!', 'Bye!', 'Catch you later!'] + output = random.choice(messages) + # Build the result first so the transcript is recorded, then exit. + result = _local_result(input_text, output) + print(output) + sys.exit(0) + return result # unreachable, but satisfies the type checker + + +def _handle_diff(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult: + import subprocess + + cwd = str(agent.runtime_config.cwd) + try: + proc = subprocess.run( + ['git', 'diff'], + cwd=cwd, + capture_output=True, + text=True, + timeout=15, + ) + diff_output = proc.stdout.strip() + if not diff_output: + # Also check staged changes + proc_staged = subprocess.run( + ['git', 'diff', '--staged'], + cwd=cwd, + capture_output=True, + text=True, + timeout=15, + ) + diff_output = proc_staged.stdout.strip() + if not diff_output: + return _local_result(input_text, 'No uncommitted changes.') + return _local_result(input_text, f'Staged changes:\n{diff_output}') + return _local_result(input_text, diff_output) + except FileNotFoundError: + return _local_result(input_text, 'git is not available.') + except subprocess.TimeoutExpired: + return _local_result(input_text, 'git diff timed out.') + except Exception as exc: + return _local_result(input_text, f'Error running git diff: {exc}') + + +def _handle_files(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult: + """List files loaded in the session context (from readFileState).""" + session = agent.last_session + if session is None: + return _local_result(input_text, 'No active session.') + + # Collect file paths mentioned in tool results + file_paths: list[str] = [] + for msg in session.messages: + if msg.role == 'tool' and msg.name in ('Read', 'read_file', 'ReadFile'): + # Extract path from content or metadata + path = msg.metadata.get('path') + if isinstance(path, str): + file_paths.append(path) + elif msg.content and msg.content.startswith('/'): + # First line might be the path + first_line = msg.content.split('\n', 1)[0].strip() + if '/' in first_line and len(first_line) < 256: + file_paths.append(first_line) + + # Also look at tool_calls in assistant messages + for msg in session.messages: + if msg.role == 'assistant' and msg.tool_calls: + for tc in msg.tool_calls: + func = tc.get('function', {}) if isinstance(tc, dict) else {} + if func.get('name') in ('Read', 'read_file', 'ReadFile', 'View'): + import json as _json + try: + args = _json.loads(func.get('arguments', '{}')) + path = args.get('file_path') or args.get('path') + if isinstance(path, str): + file_paths.append(path) + except (ValueError, TypeError): + pass + + # Deduplicate preserving order + seen: set[str] = set() + unique_paths: list[str] = [] + for p in file_paths: + if p not in seen: + seen.add(p) + unique_paths.append(p) + + if not unique_paths: + return _local_result(input_text, 'No files loaded in context.') + + cwd = str(agent.runtime_config.cwd) + relative_paths = [] + for p in unique_paths: + if p.startswith(cwd): + relative_paths.append(p[len(cwd):].lstrip('/')) + else: + relative_paths.append(p) + + lines = [f'Files in context ({len(relative_paths)}):'] + for p in relative_paths: + lines.append(f' {p}') + return _local_result(input_text, '\n'.join(lines)) + + +def _handle_copy(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult: + """Copy the last assistant response to a temp file.""" + import tempfile as _tempfile + + session = agent.last_session + if session is None: + return _local_result(input_text, 'No active session.') + + # Find the Nth most recent assistant message (default N=0 = latest) + n = 0 + if args.strip().isdigit(): + n = min(int(args.strip()), 20) + + assistant_messages = [ + msg for msg in session.messages + if msg.role == 'assistant' and msg.content.strip() + ] + if not assistant_messages: + return _local_result(input_text, 'No assistant responses to copy.') + + index = len(assistant_messages) - 1 - n + if index < 0: + return _local_result( + input_text, + f'Only {len(assistant_messages)} assistant responses available.', + ) + + content = assistant_messages[index].content + + # Write to temp file + from pathlib import Path as _Path + tmp_dir = _Path(_tempfile.gettempdir()) / 'claw-code' + tmp_dir.mkdir(parents=True, exist_ok=True) + out_path = tmp_dir / 'response.md' + out_path.write_text(content, encoding='utf-8') + + char_count = len(content) + line_count = content.count('\n') + 1 + return _local_result( + input_text, + f'Copied {char_count:,} chars ({line_count} lines) to {out_path}', + ) + + +def _handle_export(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult: + """Export the conversation transcript to a text file.""" + from pathlib import Path as _Path + import time as _time + + session = agent.last_session + if session is None: + return _local_result(input_text, 'No active session to export.') + + # Build plain-text transcript + lines: list[str] = [] + for msg in session.messages: + label = msg.role.upper() + if msg.role == 'tool' and msg.name: + label = f'TOOL:{msg.name}' + lines.append(f'--- {label} ---') + lines.append(msg.content) + lines.append('') + text = '\n'.join(lines) + + # Determine output path + filename = args.strip() + if not filename: + timestamp = _time.strftime('%Y%m%d_%H%M%S') + filename = f'conversation_{timestamp}.txt' + if not filename.endswith('.txt'): + filename += '.txt' + + out_path = _Path(str(agent.runtime_config.cwd)) / filename + out_path.write_text(text, encoding='utf-8') + return _local_result( + input_text, + f'Exported {len(session.messages)} messages to {out_path}', + ) + + +def _handle_stats(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult: + """Show session usage statistics.""" + usage = agent.cumulative_usage + cost = agent.cumulative_cost_usd + + session = agent.last_session + msg_count = len(session.messages) if session else 0 + user_msgs = sum(1 for m in (session.messages if session else []) if m.role == 'user') + assistant_msgs = sum(1 for m in (session.messages if session else []) if m.role == 'assistant') + tool_msgs = sum(1 for m in (session.messages if session else []) if m.role == 'tool') + + lines = [ + '## Session Statistics', + '', + f'Messages: {msg_count} total ({user_msgs} user, {assistant_msgs} assistant, {tool_msgs} tool)', + f'Input tokens: {usage.input_tokens:,}', + f'Output tokens: {usage.output_tokens:,}', + f'Total tokens: {usage.total_tokens:,}', + f'Cost: ${cost:.4f}', + f'Model: {agent.model_config.model}', + ] + if agent.active_session_id: + lines.append(f'Session ID: {agent.active_session_id}') + return _local_result(input_text, '\n'.join(lines)) + + +def _handle_tag(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult: + """Add or remove a tag on the current session.""" + tag = args.strip() + if not tag: + # Show current tags + tags = getattr(agent, '_session_tags', set()) + if tags: + return _local_result(input_text, f'Session tags: {", ".join(sorted(tags))}') + return _local_result(input_text, 'No tags set. Usage: /tag ') + + # Toggle tag + if not hasattr(agent, '_session_tags'): + agent._session_tags = set() + + if tag in agent._session_tags: + agent._session_tags.discard(tag) + return _local_result(input_text, f'Removed tag: {tag}') + agent._session_tags.add(tag) + return _local_result(input_text, f'Added tag: {tag}') + + +def _handle_rename(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult: + """Rename the current conversation.""" + name = args.strip() + if not name: + return _local_result(input_text, 'Usage: /rename ') + + if not hasattr(agent, '_session_name'): + agent._session_name = None + agent._session_name = name + return _local_result(input_text, f'Session renamed to: {name}') + + +def _handle_branch(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult: + """Create a fork/branch of the current conversation.""" + import json as _json + from uuid import uuid4 + + session = agent.last_session + if session is None: + return _local_result(input_text, 'No active session to branch.') + + branch_name = args.strip() or f'branch-{uuid4().hex[:8]}' + new_session_id = uuid4().hex + + # Save a copy of the current transcript as a new session file + session_dir = agent.runtime_config.session_directory + session_dir.mkdir(parents=True, exist_ok=True) + session_path = session_dir / f'{new_session_id}.json' + + transcript = [msg.to_transcript_entry() for msg in session.messages] + branch_data = { + 'session_id': new_session_id, + 'branch_name': branch_name, + 'branched_from': agent.active_session_id, + 'messages': transcript, + 'model': agent.model_config.model, + } + + try: + session_path.write_text(_json.dumps(branch_data, indent=2), encoding='utf-8') + return _local_result( + input_text, + f'Created branch "{branch_name}" (session: {new_session_id})\n' + f'Saved to: {session_path}', + ) + except Exception as exc: + return _local_result(input_text, f'Error creating branch: {exc}') + + +def _handle_effort(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult: + """Show or set the model effort level.""" + import os + + valid_levels = ('low', 'medium', 'high', 'max', 'auto') + current = getattr(agent.runtime_config, 'effort_level', None) + env_override = os.environ.get('CLAUDE_CODE_EFFORT_LEVEL') + + if not args.strip(): + level = current or env_override or 'auto' + msg = f'Current effort level: {level}' + if env_override: + msg += f' (from CLAUDE_CODE_EFFORT_LEVEL env var)' + return _local_result(input_text, msg) + + level = args.strip().lower() + if level not in valid_levels: + return _local_result( + input_text, + f'Invalid effort level: {level}\nValid levels: {", ".join(valid_levels)}', + ) + + if env_override: + return _local_result( + input_text, + f'Cannot change effort level — overridden by ' + f'CLAUDE_CODE_EFFORT_LEVEL={env_override}', + ) + + # Store effort level on the runtime config + object.__setattr__(agent.runtime_config, 'effort_level', level) + return _local_result(input_text, f'Set effort level to: {level}') + + +def _handle_doctor(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult: + """Diagnose and verify the claw-code installation.""" + import os + import shutil + import sys + from pathlib import Path as _Path + + checks: list[str] = [] + + # Python version + py_ver = f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}' + ok = sys.version_info >= (3, 10) + checks.append(f'{"✓" if ok else "✗"} Python version: {py_ver} (need ≥3.10)') + + # Git available + git_ok = shutil.which('git') is not None + checks.append(f'{"✓" if git_ok else "✗"} git: {"found" if git_ok else "NOT FOUND"}') + + # Model config + checks.append(f'✓ Model: {agent.model_config.model}') + checks.append(f'✓ Base URL: {agent.model_config.base_url}') + + # Working directory + cwd = agent.runtime_config.cwd + checks.append(f'✓ Working directory: {cwd}') + checks.append(f'{"✓" if cwd.exists() else "✗"} Working directory exists: {cwd.exists()}') + + # Session directory + sess_dir = agent.runtime_config.session_directory + checks.append(f'✓ Session directory: {sess_dir}') + checks.append(f'{"✓" if sess_dir.exists() else "○"} Session directory exists: {sess_dir.exists()}') + + # API key + has_key = bool(agent.model_config.api_key) + checks.append(f'{"✓" if has_key else "✗"} API key: {"set" if has_key else "NOT SET"}') + + # Tools + tool_count = len(agent.tool_registry) if agent.tool_registry else 0 + checks.append(f'✓ Registered tools: {tool_count}') + + # Memory files (CLAUDE.md) + claude_md = cwd / 'CLAUDE.md' + checks.append( + f'{"✓" if claude_md.exists() else "○"} CLAUDE.md: ' + f'{"found" if claude_md.exists() else "not found (optional)"}' + ) + + output = '## Doctor Report\n\n' + '\n'.join(checks) + return _local_result(input_text, output) + + def _local_result(input_text: str, output: str) -> SlashCommandResult: transcript = ( {'role': 'user', 'content': input_text}, diff --git a/src/bash_security.py b/src/bash_security.py new file mode 100644 index 0000000..7517324 --- /dev/null +++ b/src/bash_security.py @@ -0,0 +1,1261 @@ +""" +BashTool security validation module. + +Ported from npm src/tools/BashTool/bashSecurity.ts and related files. +Provides comprehensive command validation to detect and block dangerous +shell commands, injection patterns, and obfuscation techniques. + +The main entry point is `bash_command_is_safe(command)` which returns a +SecurityResult indicating whether the command should be allowed, blocked, +or needs user confirmation. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from enum import Enum +from typing import Callable, Optional + + +# --------------------------------------------------------------------------- +# Types +# --------------------------------------------------------------------------- + +class SecurityBehavior(Enum): + """Possible outcomes of a security check.""" + ALLOW = 'allow' # Command is safe, allow without asking + ASK = 'ask' # Command needs user confirmation + DENY = 'deny' # Command is outright blocked + PASSTHROUGH = 'passthrough' # Check has no opinion, continue to next + + +@dataclass(frozen=True) +class SecurityResult: + """Result of a security validation check.""" + behavior: SecurityBehavior + message: str + is_misparsing: bool = False # For misparsing-specific concerns + + +def _allow(message: str = 'Command allowed') -> SecurityResult: + return SecurityResult(SecurityBehavior.ALLOW, message) + + +def _ask(message: str, *, misparsing: bool = False) -> SecurityResult: + return SecurityResult(SecurityBehavior.ASK, message, is_misparsing=misparsing) + + +def _deny(message: str) -> SecurityResult: + return SecurityResult(SecurityBehavior.DENY, message) + + +def _passthrough(message: str = '') -> SecurityResult: + return SecurityResult(SecurityBehavior.PASSTHROUGH, message) + + +# --------------------------------------------------------------------------- +# Command substitution patterns (from npm bashSecurity.ts) +# --------------------------------------------------------------------------- + +COMMAND_SUBSTITUTION_PATTERNS: list[tuple[re.Pattern[str], str]] = [ + (re.compile(r'<\('), 'process substitution <()'), + (re.compile(r'>\('), 'process substitution >()'), + (re.compile(r'=\('), 'Zsh process substitution =()'), + (re.compile(r'(?:^|[\s;&|])=[a-zA-Z_]'), 'Zsh equals expansion (=cmd)'), + (re.compile(r'\$\('), '$() command substitution'), + (re.compile(r'\$\{'), '${} parameter substitution'), + (re.compile(r'\$\['), '$[] legacy arithmetic expansion'), + (re.compile(r'~\['), 'Zsh-style parameter expansion'), + (re.compile(r'\(e:'), 'Zsh-style glob qualifiers'), + (re.compile(r'\(\+'), 'Zsh glob qualifier with command execution'), + (re.compile(r'\}\s*always\s*\{'), 'Zsh always block (try/always construct)'), + (re.compile(r'<#'), 'PowerShell comment syntax'), +] + +# Zsh dangerous commands that bypass security checks +ZSH_DANGEROUS_COMMANDS = frozenset({ + 'zmodload', 'emulate', + 'sysopen', 'sysread', 'syswrite', 'sysseek', + 'zpty', 'ztcp', 'zsocket', 'mapfile', + 'zf_rm', 'zf_mv', 'zf_ln', 'zf_chmod', 'zf_chown', + 'zf_mkdir', 'zf_rmdir', 'zf_chgrp', +}) + +ZSH_PRECOMMAND_MODIFIERS = frozenset({ + 'command', 'builtin', 'noglob', 'nocorrect', +}) + +# Control characters (0x00-0x08, 0x0B-0x0C, 0x0E-0x1F, 0x7F) +# Excludes tab (0x09), newline (0x0A), carriage return (0x0D) +CONTROL_CHAR_RE = re.compile(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]') + +# Unicode whitespace that could cause parser differentials +UNICODE_WS_RE = re.compile( + r'[\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]' +) + + +# --------------------------------------------------------------------------- +# Destructive command patterns (from npm destructiveCommandWarning.ts) +# --------------------------------------------------------------------------- + +DESTRUCTIVE_PATTERNS: list[tuple[re.Pattern[str], str]] = [ + # Git - data loss / hard to reverse + (re.compile(r'\bgit\s+reset\s+--hard\b'), + 'Note: may discard uncommitted changes'), + (re.compile(r'\bgit\s+push\b[^;&|\n]*[ \t](--force|--force-with-lease|-f)\b'), + 'Note: may overwrite remote history'), + (re.compile(r'\bgit\s+clean\b(?![^;&|\n]*(?:-[a-zA-Z]*n|--dry-run))[^;&|\n]*-[a-zA-Z]*f'), + 'Note: may permanently delete untracked files'), + (re.compile(r'\bgit\s+checkout\s+(--\s+)?\.[ \t]*($|[;&|\n])'), + 'Note: may discard all working tree changes'), + (re.compile(r'\bgit\s+restore\s+(--\s+)?\.[ \t]*($|[;&|\n])'), + 'Note: may discard all working tree changes'), + (re.compile(r'\bgit\s+stash[ \t]+(drop|clear)\b'), + 'Note: may permanently remove stashed changes'), + (re.compile(r'\bgit\s+branch\s+(-D[ \t]|--delete\s+--force|--force\s+--delete)\b'), + 'Note: may force-delete a branch'), + # Git - safety bypass + (re.compile(r'\bgit\s+(commit|push|merge)\b[^;&|\n]*--no-verify\b'), + 'Note: may skip safety hooks'), + (re.compile(r'\bgit\s+commit\b[^;&|\n]*--amend\b'), + 'Note: may rewrite the last commit'), + # File deletion + (re.compile(r'(^|[;&|\n]\s*)rm\s+-[a-zA-Z]*[rR][a-zA-Z]*f|(^|[;&|\n]\s*)rm\s+-[a-zA-Z]*f[a-zA-Z]*[rR]'), + 'Note: may recursively force-remove files'), + (re.compile(r'(^|[;&|\n]\s*)rm\s+-[a-zA-Z]*[rR]'), + 'Note: may recursively remove files'), + (re.compile(r'(^|[;&|\n]\s*)rm\s+-[a-zA-Z]*f'), + 'Note: may force-remove files'), + # Database + (re.compile(r'\b(DROP|TRUNCATE)\s+(TABLE|DATABASE|SCHEMA)\b', re.IGNORECASE), + 'Note: may drop or truncate database objects'), + (re.compile(r'\bDELETE\s+FROM\s+\w+[ \t]*(;|"|\'|\n|$)', re.IGNORECASE), + 'Note: may delete all rows from a database table'), + # Infrastructure + (re.compile(r'\bkubectl\s+delete\b'), + 'Note: may delete Kubernetes resources'), + (re.compile(r'\bterraform\s+destroy\b'), + 'Note: may destroy Terraform infrastructure'), +] + + +# --------------------------------------------------------------------------- +# Command semantics (from npm commandSemantics.ts) +# --------------------------------------------------------------------------- + +CommandSemantic = Callable[[int, str, str], tuple[bool, Optional[str]]] + + +def _default_semantic(exit_code: int, _stdout: str, _stderr: str) -> tuple[bool, Optional[str]]: + return (exit_code != 0, f'Command failed with exit code {exit_code}' if exit_code != 0 else None) + + +def _grep_semantic(exit_code: int, _stdout: str, _stderr: str) -> tuple[bool, Optional[str]]: + return (exit_code >= 2, 'No matches found' if exit_code == 1 else None) + + +def _find_semantic(exit_code: int, _stdout: str, _stderr: str) -> tuple[bool, Optional[str]]: + return (exit_code >= 2, 'Some directories were inaccessible' if exit_code == 1 else None) + + +def _diff_semantic(exit_code: int, _stdout: str, _stderr: str) -> tuple[bool, Optional[str]]: + return (exit_code >= 2, 'Files differ' if exit_code == 1 else None) + + +def _test_semantic(exit_code: int, _stdout: str, _stderr: str) -> tuple[bool, Optional[str]]: + return (exit_code >= 2, 'Condition is false' if exit_code == 1 else None) + + +COMMAND_SEMANTICS: dict[str, CommandSemantic] = { + 'grep': _grep_semantic, + 'rg': _grep_semantic, + 'find': _find_semantic, + 'diff': _diff_semantic, + 'test': _test_semantic, + '[': _test_semantic, +} + + +def interpret_command_result( + command: str, + exit_code: int, + stdout: str, + stderr: str, +) -> tuple[bool, Optional[str]]: + """ + Interpret command result based on semantic rules. + Returns (is_error, optional_message). + """ + # Extract base command (last command in pipeline determines exit code) + segments = split_command(command) + last_segment = segments[-1] if segments else command + base = last_segment.strip().split()[0] if last_segment.strip() else '' + semantic = COMMAND_SEMANTICS.get(base, _default_semantic) + return semantic(exit_code, stdout, stderr) + + +# --------------------------------------------------------------------------- +# Read-only command detection (from npm readOnlyValidation.ts) +# --------------------------------------------------------------------------- + +READ_ONLY_COMMANDS = frozenset({ + # File viewing + 'cat', 'head', 'tail', 'less', 'more', 'bat', 'batcat', + # Searching + 'grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack', + # File listing + 'ls', 'll', 'la', 'dir', 'tree', 'exa', 'eza', + # File info + 'stat', 'file', 'wc', 'du', 'df', 'readlink', 'realpath', + 'md5sum', 'sha256sum', 'sha1sum', + # System info + 'uname', 'hostname', 'whoami', 'id', 'groups', 'date', + 'uptime', 'free', 'lsb_release', 'arch', 'nproc', + # Process info + 'ps', 'top', 'htop', 'pgrep', + # Printing + 'echo', 'printf', 'true', 'false', + # Path tools + 'which', 'whereis', 'type', 'command', 'hash', + 'dirname', 'basename', 'pwd', + # Text processing (non-destructive) + 'sort', 'uniq', 'cut', 'tr', 'fold', 'fmt', 'nl', 'rev', + 'column', 'paste', 'join', 'comm', 'tee', + 'awk', 'gawk', 'mawk', + 'sed', # read-only only when no -i flag + 'diff', 'cmp', 'colordiff', + # Encoding + 'base64', 'xxd', 'od', 'hexdump', + # Find (read-only without -exec/-delete) + 'find', 'fd', 'fdfind', 'locate', 'mlocate', + # Version/help + 'man', 'info', 'help', + # Env inspection + 'env', 'printenv', 'set', + # Network inspection (read-only) + 'ping', 'dig', 'nslookup', 'host', 'ifconfig', 'ip', + # Git read-only + 'git', # only certain subcommands are read-only + # Python/Node read-only + 'python', 'python3', 'node', # only with certain flags +}) + +GIT_READ_ONLY_SUBCOMMANDS = frozenset({ + 'status', 'log', 'diff', 'show', 'branch', 'remote', 'tag', + 'describe', 'rev-parse', 'rev-list', 'ls-files', 'ls-tree', + 'ls-remote', 'cat-file', 'name-rev', 'shortlog', 'blame', + 'grep', 'reflog', 'stash list', 'config', 'version', +}) + +# Flags that make find dangerous (not read-only) +FIND_DANGEROUS_FLAGS = frozenset({ + '-exec', '-execdir', '-ok', '-okdir', '-delete', +}) + + +def is_command_read_only(command: str) -> bool: + """ + Check if a command is read-only (doesn't modify the filesystem). + Returns True if the command is safe to auto-approve. + """ + stripped = command.strip() + if not stripped: + return True + + parts = stripped.split() + base_cmd = parts[0] + + if base_cmd not in READ_ONLY_COMMANDS: + return False + + # Special handling for git + if base_cmd == 'git': + if len(parts) < 2: + return True # bare 'git' is safe + subcmd = parts[1] + return subcmd in GIT_READ_ONLY_SUBCOMMANDS + + # Special handling for sed (only read-only without -i) + if base_cmd == 'sed': + return '-i' not in parts and '--in-place' not in parts + + # Special handling for find (no -exec, -delete, etc.) + if base_cmd in ('find', 'fd', 'fdfind'): + return not any(flag in parts for flag in FIND_DANGEROUS_FLAGS) + + # Special handling for python/node (only with -c, --version, --help) + if base_cmd in ('python', 'python3', 'node'): + safe_flags = {'-c', '--version', '--help', '-V', '-h'} + return len(parts) >= 2 and parts[1] in safe_flags + + return True + + +# --------------------------------------------------------------------------- +# Helper: Quote-aware content extraction (from npm bashSecurity.ts) +# --------------------------------------------------------------------------- + +def extract_quoted_content(command: str) -> tuple[str, str, str]: + """ + Extract content outside different quoting levels. + Returns (with_double_quotes, fully_unquoted, unquoted_keep_quote_chars). + + - with_double_quotes: content outside single quotes (double-quoted content preserved) + - fully_unquoted: content outside both single AND double quotes + - unquoted_keep_quote_chars: like fully_unquoted but preserves ' and " chars + """ + with_double_quotes = '' + fully_unquoted = '' + unquoted_keep_quote_chars = '' + in_single_quote = False + in_double_quote = False + escaped = False + + for i, char in enumerate(command): + if escaped: + escaped = False + if not in_single_quote: + with_double_quotes += char + if not in_single_quote and not in_double_quote: + fully_unquoted += char + unquoted_keep_quote_chars += char + continue + + if char == '\\' and not in_single_quote: + escaped = True + if not in_single_quote: + with_double_quotes += char + if not in_single_quote and not in_double_quote: + fully_unquoted += char + unquoted_keep_quote_chars += char + continue + + if char == "'" and not in_double_quote: + in_single_quote = not in_single_quote + unquoted_keep_quote_chars += char + continue + + if char == '"' and not in_single_quote: + in_double_quote = not in_double_quote + unquoted_keep_quote_chars += char + continue + + if not in_single_quote: + with_double_quotes += char + if not in_single_quote and not in_double_quote: + fully_unquoted += char + unquoted_keep_quote_chars += char + + return with_double_quotes, fully_unquoted, unquoted_keep_quote_chars + + +def strip_safe_redirections(content: str) -> str: + """Strip safe redirections (>/dev/null, 2>&1, &\s*1(?=\s|$)', '', result) + result = re.sub(r'[012]?\s*>\s*/dev/null(?=\s|$)', '', result) + result = re.sub(r'\s*<\s*/dev/null(?=\s|$)', '', result) + return result + + +def has_unescaped_char(content: str, char: str) -> bool: + """Check if content contains an unescaped occurrence of a single character.""" + assert len(char) == 1, 'has_unescaped_char only works with single characters' + i = 0 + while i < len(content): + if content[i] == '\\' and i + 1 < len(content): + i += 2 # Skip backslash and escaped character + continue + if content[i] == char: + return True + i += 1 + return False + + +# --------------------------------------------------------------------------- +# Simple command splitter (for splitting on &&, ||, ;, |) +# --------------------------------------------------------------------------- + +def split_command(command: str) -> list[str]: + """ + Split a compound command on operators (&&, ||, ;, |) while respecting quotes. + Returns list of individual command strings. + """ + segments: list[str] = [] + current = '' + in_single_quote = False + in_double_quote = False + escaped = False + i = 0 + + while i < len(command): + c = command[i] + + if escaped: + escaped = False + current += c + i += 1 + continue + + if c == '\\' and not in_single_quote: + escaped = True + current += c + i += 1 + continue + + if c == "'" and not in_double_quote: + in_single_quote = not in_single_quote + current += c + i += 1 + continue + + if c == '"' and not in_single_quote: + in_double_quote = not in_double_quote + current += c + i += 1 + continue + + if in_single_quote or in_double_quote: + current += c + i += 1 + continue + + # Check for operators + if c == ';': + segments.append(current) + current = '' + i += 1 + continue + + if c == '|': + if i + 1 < len(command) and command[i + 1] == '|': + segments.append(current) + current = '' + i += 2 + continue + else: + segments.append(current) + current = '' + i += 1 + continue + + if c == '&': + if i + 1 < len(command) and command[i + 1] == '&': + segments.append(current) + current = '' + i += 2 + continue + else: + current += c + i += 1 + continue + + current += c + i += 1 + + if current: + segments.append(current) + + return [s.strip() for s in segments if s.strip()] + + +# --------------------------------------------------------------------------- +# Validation context (matching npm ValidationContext) +# --------------------------------------------------------------------------- + +@dataclass +class ValidationContext: + """Context passed to each validator function.""" + original_command: str + base_command: str + unquoted_content: str # Outside single quotes + fully_unquoted_content: str # Outside all quotes, with safe redirections stripped + fully_unquoted_pre_strip: str # Outside all quotes, before stripping redirections + unquoted_keep_quote_chars: str # Like fully_unquoted but preserves quote characters + + +# --------------------------------------------------------------------------- +# Validators (ported from npm bashSecurity.ts) +# --------------------------------------------------------------------------- + +def validate_empty(ctx: ValidationContext) -> SecurityResult: + """Allow empty commands.""" + if not ctx.original_command.strip(): + return _allow('Empty command is safe') + return _passthrough('Command is not empty') + + +def validate_control_characters(ctx: ValidationContext) -> SecurityResult: + """Block commands with non-printable control characters.""" + if CONTROL_CHAR_RE.search(ctx.original_command): + return _ask( + 'Command contains non-printable control characters that could bypass security checks', + misparsing=True, + ) + return _passthrough() + + +def validate_incomplete_commands(ctx: ValidationContext) -> SecurityResult: + """Detect incomplete command fragments.""" + original = ctx.original_command + trimmed = original.strip() + + if re.match(r'^\s*\t', original): + return _ask('Command appears to be an incomplete fragment (starts with tab)', + misparsing=True) + + if trimmed.startswith('-'): + return _ask('Command appears to be an incomplete fragment (starts with flags)', + misparsing=True) + + if re.match(r'^\s*(&&|\|\||;|>>?|<)', original): + return _ask('Command appears to be a continuation line (starts with operator)', + misparsing=True) + + return _passthrough() + + +def validate_git_commit(ctx: ValidationContext) -> SecurityResult: + """ + Early-allow simple git commit -m '...' commands. + Block git commit messages with command substitution. + """ + if ctx.base_command != 'git' or not re.match(r'^git\s+commit\s+', ctx.original_command): + return _passthrough() + + if '\\' in ctx.original_command: + return _passthrough('Git commit contains backslash, needs full validation') + + msg_match = re.match( + r'^git[ \t]+commit[ \t]+[^;&|`$<>()\n\r]*?-m[ \t]+(["\'])([\s\S]*?)\1(.*)$', + ctx.original_command, + ) + + if msg_match: + quote, message_content, remainder = msg_match.group(1), msg_match.group(2), msg_match.group(3) + + if quote == '"' and message_content and re.search(r'\$\(|`|\$\{', message_content): + return _ask('Git commit message contains command substitution patterns', + misparsing=True) + + if remainder and re.search(r'[;|&()`]|\$\(|\$\{', remainder): + return _passthrough('Git commit remainder contains shell metacharacters') + + if remainder: + # Check for unquoted redirect operators + unquoted = '' + in_sq = False + in_dq = False + for c in remainder: + if c == "'" and not in_dq: + in_sq = not in_sq + continue + if c == '"' and not in_sq: + in_dq = not in_dq + continue + if not in_sq and not in_dq: + unquoted += c + if re.search(r'[<>]', unquoted): + return _passthrough('Git commit remainder contains unquoted redirect operator') + + if message_content and message_content.startswith('-'): + return _ask('Command contains quoted characters in flag names', + misparsing=True) + + return _allow('Git commit with simple quoted message is allowed') + + return _passthrough('Git commit needs validation') + + +def validate_jq_command(ctx: ValidationContext) -> SecurityResult: + """Block dangerous jq patterns (system(), -f, --from-file, etc.).""" + if ctx.base_command != 'jq': + return _passthrough() + + if re.search(r'\bsystem\s*\(', ctx.original_command): + return _ask('jq command contains system() function which executes arbitrary commands') + + after_jq = ctx.original_command[3:].strip() if len(ctx.original_command) > 3 else '' + if re.search(r'(?:^|\s)(?:-f\b|--from-file|--rawfile|--slurpfile|-L\b|--library-path)', after_jq): + return _ask('jq command contains dangerous flags that could read arbitrary files') + + return _passthrough() + + +def validate_obfuscated_flags(ctx: ValidationContext) -> SecurityResult: + """Detect shell quoting bypass patterns used to circumvent security checks.""" + original = ctx.original_command + base = ctx.base_command + + # Echo is safe for obfuscated flags without shell operators + if base == 'echo' and not re.search(r'[|&;]', original): + return _passthrough() + + # Block ANSI-C quoting ($'...') + if re.search(r"\$'[^']*'", original): + return _ask('Command contains ANSI-C quoting which can hide characters') + + # Block locale quoting ($"...") + if re.search(r'\$"[^"]*"', original): + return _ask('Command contains locale quoting which can hide characters') + + # Block empty ANSI-C or locale quotes followed by dash + if re.search(r"""\$['"]{2}\s*-""", original): + return _ask('Command contains empty special quotes before dash (potential bypass)') + + # Block sequence of empty quotes followed by dash + if re.search(r"""(?:^|\s)(?:''|""){1,}\s*-""", original): + return _ask('Command contains empty quotes before dash (potential bypass)') + + # Block homogeneous empty quote pairs adjacent to quoted dash + if re.search(r"""(?:""|''){1,}['"][-]""", original): + return _ask('Command contains empty quote pair adjacent to quoted dash') + + # Block 3+ consecutive quotes at word start + if re.search(r"""(?:^|\s)['\"]{3,}""", original): + return _ask('Command contains consecutive quote characters at word start') + + # Track quote state and detect quoted flags + in_single_quote = False + in_double_quote = False + escaped = False + + for i in range(len(original) - 1): + c = original[i] + next_c = original[i + 1] + + if escaped: + escaped = False + continue + + if c == '\\' and not in_single_quote: + escaped = True + continue + + if c == "'" and not in_double_quote: + in_single_quote = not in_single_quote + continue + + if c == '"' and not in_single_quote: + in_double_quote = not in_double_quote + continue + + if in_single_quote or in_double_quote: + continue + + # Look for whitespace followed by quote that contains a dash (obfuscated flag) + if c and c in ' \t\n' and next_c in "'\"`": + quote_char = next_c + j = i + 2 + inside_quote = '' + while j < len(original) and original[j] != quote_char: + inside_quote += original[j] + j += 1 + + if j < len(original) and original[j] == quote_char: + has_flag_chars_inside = bool(re.match(r'^-+[a-zA-Z0-9$`]', inside_quote)) + char_after = original[j + 1] if j + 1 < len(original) else '' + has_flag_continuing = ( + bool(re.match(r'^-+$', inside_quote)) and + bool(char_after) and + bool(re.match(r'[a-zA-Z0-9\\${`\-]', char_after)) + ) + + if has_flag_chars_inside or has_flag_continuing: + return _ask('Command contains quoted characters in flag names') + + # Look for whitespace followed by dash with quotes mixed in + if c and c in ' \t\n' and next_c == '-': + j = i + 1 + flag_content = '' + while j < len(original): + fc = original[j] + if fc in ' \t\n\r=': + break + if fc in "'\"" and base == 'cut' and flag_content == '-d': + break + flag_content += fc + j += 1 + + if '"' in flag_content or "'" in flag_content: + return _ask('Command contains quoted characters in flag names') + + return _passthrough() + + +def validate_shell_metacharacters(ctx: ValidationContext) -> SecurityResult: + """Detect shell metacharacters in quoted arguments.""" + unquoted = ctx.unquoted_content + + if re.search(r'(?:^|\s)["\'][^"\']*[;&][^"\']*["\'](?:\s|$)', unquoted): + return _ask('Command contains shell metacharacters (;, |, or &) in arguments') + + # Glob patterns with metacharacters + for pattern in [ + r'-name\s+["\'][^"\']*[;|&][^"\']*["\']', + r'-path\s+["\'][^"\']*[;|&][^"\']*["\']', + r'-iname\s+["\'][^"\']*[;|&][^"\']*["\']', + ]: + if re.search(pattern, unquoted): + return _ask('Command contains shell metacharacters (;, |, or &) in arguments') + + return _passthrough() + + +def validate_dangerous_variables(ctx: ValidationContext) -> SecurityResult: + """Detect variables in dangerous contexts (redirections or pipes).""" + fully_unquoted = ctx.fully_unquoted_content + + if (re.search(r'[<>|]\s*\$[A-Za-z_]', fully_unquoted) or + re.search(r'\$[A-Za-z_][A-Za-z0-9_]*\s*[|<>]', fully_unquoted)): + return _ask('Command contains variables in dangerous contexts (redirections or pipes)') + + return _passthrough() + + +def validate_dangerous_patterns(ctx: ValidationContext) -> SecurityResult: + """Detect backticks, $(), ${}, and other command substitution patterns.""" + unquoted = ctx.unquoted_content + + # Check for unescaped backticks + if has_unescaped_char(unquoted, '`'): + return _ask('Command contains backticks (`) for command substitution') + + # Other command substitution patterns + for pattern, desc in COMMAND_SUBSTITUTION_PATTERNS: + if pattern.search(unquoted): + return _ask(f'Command contains {desc}') + + return _passthrough() + + +def validate_redirections(ctx: ValidationContext) -> SecurityResult: + """Detect input/output redirection.""" + fully_unquoted = ctx.fully_unquoted_content + + if '<' in fully_unquoted: + return _ask('Command contains input redirection (<) which could read sensitive files') + + if '>' in fully_unquoted: + return _ask('Command contains output redirection (>) which could write to arbitrary files') + + return _passthrough() + + +def validate_newlines(ctx: ValidationContext) -> SecurityResult: + """Detect newlines that could separate multiple commands.""" + fully_unquoted = ctx.fully_unquoted_pre_strip + + if not re.search(r'[\n\r]', fully_unquoted): + return _passthrough() + + # Check for newline followed by non-whitespace (potential second command) + # Allow backslash-newline line continuations at word boundaries + if re.search(r'(? SecurityResult: + """ + Detect carriage returns that cause parser differentials. + CR is a misparsing concern because shell-quote treats it as a word + separator but bash does not. + """ + original = ctx.original_command + if '\r' not in original: + return _passthrough() + + # Check if CR appears outside double quotes + in_single_quote = False + in_double_quote = False + escaped = False + + for c in original: + if escaped: + escaped = False + continue + if c == '\\' and not in_single_quote: + escaped = True + continue + if c == "'" and not in_double_quote: + in_single_quote = not in_single_quote + continue + if c == '"' and not in_single_quote: + in_double_quote = not in_double_quote + continue + if c == '\r' and not in_double_quote: + return _ask( + 'Command contains carriage return (\\r) which may cause parser differentials', + misparsing=True, + ) + + return _passthrough() + + +def validate_ifs_injection(ctx: ValidationContext) -> SecurityResult: + """Detect IFS variable usage which could bypass security validation.""" + if re.search(r'\$IFS|\$\{[^}]*IFS', ctx.original_command): + return _ask('Command contains IFS variable usage which could bypass security validation') + return _passthrough() + + +def validate_proc_environ_access(ctx: ValidationContext) -> SecurityResult: + """Detect access to /proc/*/environ which could expose sensitive env vars.""" + if re.search(r'/proc/.*/environ', ctx.original_command): + return _ask('Command accesses /proc/*/environ which could expose sensitive environment variables') + return _passthrough() + + +def validate_backslash_escaped_whitespace(ctx: ValidationContext) -> SecurityResult: + """Detect backslash-escaped whitespace that could alter command parsing.""" + if _has_backslash_escaped_whitespace(ctx.original_command): + return _ask( + 'Command contains backslash-escaped whitespace that could alter command parsing', + misparsing=True, + ) + return _passthrough() + + +def _has_backslash_escaped_whitespace(command: str) -> bool: + """Check if command has backslash-escaped whitespace outside quotes.""" + in_single_quote = False + in_double_quote = False + i = 0 + while i < len(command): + c = command[i] + + if c == '\\' and not in_single_quote: + if i + 1 < len(command): + next_c = command[i + 1] + if not in_double_quote and next_c in ' \t': + return True + i += 2 + continue + + if c == "'" and not in_double_quote: + in_single_quote = not in_single_quote + elif c == '"' and not in_single_quote: + in_double_quote = not in_double_quote + + i += 1 + return False + + +SHELL_OPERATORS = frozenset(';|&<>') + + +def validate_backslash_escaped_operators(ctx: ValidationContext) -> SecurityResult: + """ + Detect backslash before shell operators which can hide command structure. + E.g., `cat safe.txt \\; echo ~/.ssh/id_rsa` + """ + if _has_backslash_escaped_operator(ctx.original_command): + return _ask( + 'Command contains a backslash before a shell operator (;, |, &, <, >) ' + 'which can hide command structure', + misparsing=True, + ) + return _passthrough() + + +def _has_backslash_escaped_operator(command: str) -> bool: + """Check if command has backslash-escaped shell operator outside quotes.""" + in_single_quote = False + in_double_quote = False + i = 0 + while i < len(command): + c = command[i] + + if c == '\\' and not in_single_quote: + if not in_double_quote and i + 1 < len(command): + next_c = command[i + 1] + if next_c in SHELL_OPERATORS: + return True + i += 2 + continue + + if c == "'" and not in_double_quote: + in_single_quote = not in_single_quote + elif c == '"' and not in_single_quote: + in_double_quote = not in_double_quote + + i += 1 + return False + + +def validate_brace_expansion(ctx: ValidationContext) -> SecurityResult: + """ + Detect unquoted brace expansion syntax ({a,b} or {1..5}). + Brace expansion can alter command parsing and inject arguments. + """ + content = ctx.fully_unquoted_pre_strip + + # Count unescaped braces + open_braces = 0 + close_braces = 0 + for i, c in enumerate(content): + if c == '{' and not _is_escaped_at_position(content, i): + open_braces += 1 + elif c == '}' and not _is_escaped_at_position(content, i): + close_braces += 1 + + # Excess closing braces indicate obfuscation + if open_braces > 0 and close_braces > open_braces: + return _ask('Command has excess closing braces (possible brace expansion obfuscation)') + + # Check for quoted brace inside brace context + if open_braces > 0 and re.search(r"""['"][{}]['"]""", ctx.original_command): + return _ask('Command contains quoted brace character inside brace context') + + # Scan for actual brace expansion patterns ({a,b} or {a..b}) + i = 0 + while i < len(content): + if content[i] != '{' or _is_escaped_at_position(content, i): + i += 1 + continue + + # Find matching closing brace with nesting + depth = 1 + matching_close = -1 + j = i + 1 + while j < len(content): + if content[j] == '{' and not _is_escaped_at_position(content, j): + depth += 1 + elif content[j] == '}' and not _is_escaped_at_position(content, j): + depth -= 1 + if depth == 0: + matching_close = j + break + j += 1 + + if matching_close == -1: + i += 1 + continue + + # Check for comma or .. at outermost level + inner_depth = 0 + for k in range(i + 1, matching_close): + ch = content[k] + if ch == '{' and not _is_escaped_at_position(content, k): + inner_depth += 1 + elif ch == '}' and not _is_escaped_at_position(content, k): + inner_depth -= 1 + elif inner_depth == 0: + if ch == ',' or (ch == '.' and k + 1 < matching_close and content[k + 1] == '.'): + return _ask('Command contains brace expansion that could alter command parsing') + + i += 1 + + return _passthrough() + + +def _is_escaped_at_position(content: str, pos: int) -> bool: + """Check if character at position is escaped by counting preceding backslashes.""" + count = 0 + i = pos - 1 + while i >= 0 and content[i] == '\\': + count += 1 + i -= 1 + return count % 2 == 1 + + +def validate_unicode_whitespace(ctx: ValidationContext) -> SecurityResult: + """Detect Unicode whitespace that could cause parser differentials.""" + if UNICODE_WS_RE.search(ctx.original_command): + return _ask('Command contains Unicode whitespace characters that could cause parsing inconsistencies') + return _passthrough() + + +def validate_mid_word_hash(ctx: ValidationContext) -> SecurityResult: + """ + Detect mid-word # which is parsed differently by different shell parsers. + In bash, mid-word # is literal; in some parsers it starts a comment. + """ + text = ctx.unquoted_keep_quote_chars + # Match # preceded by non-whitespace, excluding ${# + if re.search(r'\S(? SecurityResult: + """ + Detect quote characters inside # comments that could desync quote trackers. + A ' or " after an unquoted # could confuse downstream processing. + """ + original = ctx.original_command + in_single_quote = False + in_double_quote = False + escaped = False + + for i, char in enumerate(original): + if escaped: + escaped = False + continue + if in_single_quote: + if char == "'": + in_single_quote = False + continue + if char == '\\': + escaped = True + continue + if in_double_quote: + if char == '"': + in_double_quote = False + continue + if char == "'": + in_single_quote = True + continue + if char == '"': + in_double_quote = True + continue + + if char == '#': + line_end = original.find('\n', i) + comment_text = original[i + 1: line_end if line_end != -1 else len(original)] + if re.search(r"""['"]""", comment_text): + return _ask( + 'Command contains quote characters inside a # comment ' + 'which can desync quote tracking', + misparsing=True, + ) + if line_end == -1: + break + # Skip to end of line + continue + + return _passthrough() + + +def validate_quoted_newline(ctx: ValidationContext) -> SecurityResult: + """ + Detect newlines inside quoted strings where the next line starts with #. + This can hide arguments from line-based permission checks. + """ + original = ctx.original_command + if '\n' not in original or '#' not in original: + return _passthrough() + + in_single_quote = False + in_double_quote = False + escaped = False + + for i, char in enumerate(original): + if escaped: + escaped = False + continue + if char == '\\' and not in_single_quote: + escaped = True + continue + if char == "'" and not in_double_quote: + in_single_quote = not in_single_quote + continue + if char == '"' and not in_single_quote: + in_double_quote = not in_double_quote + continue + + if char == '\n' and (in_single_quote or in_double_quote): + line_start = i + 1 + next_newline = original.find('\n', line_start) + line_end = next_newline if next_newline != -1 else len(original) + next_line = original[line_start:line_end] + if next_line.strip().startswith('#'): + return _ask( + 'Command contains a quoted newline followed by a #-prefixed line, ' + 'which can hide arguments from permission checks', + misparsing=True, + ) + + return _passthrough() + + +def validate_zsh_dangerous_commands(ctx: ValidationContext) -> SecurityResult: + """Detect Zsh-specific dangerous commands.""" + original = ctx.original_command + trimmed = original.strip() + tokens = trimmed.split() + + base_cmd = '' + for token in tokens: + if re.match(r'^[A-Za-z_]\w*=', token): + continue + if token in ZSH_PRECOMMAND_MODIFIERS: + continue + base_cmd = token + break + + if base_cmd in ZSH_DANGEROUS_COMMANDS: + return _ask(f"Command uses Zsh-specific '{base_cmd}' which can bypass security checks") + + # Check for fc -e (arbitrary command execution via editor) + if base_cmd == 'fc' and re.search(r'\s-\S*e', trimmed): + return _ask("Command uses 'fc -e' which can execute arbitrary commands via editor") + + return _passthrough() + + +# --------------------------------------------------------------------------- +# Main security check entry point +# --------------------------------------------------------------------------- + +# Validators that don't indicate misparsing concerns +_NON_MISPARSING_VALIDATORS = frozenset({ + 'validate_newlines', + 'validate_redirections', +}) + + +def bash_command_is_safe(command: str) -> SecurityResult: + """ + Main entry point: check if a bash command is safe to execute. + + Returns a SecurityResult with behavior: + - ALLOW: Command is safe, auto-approve + - ASK: Command needs user confirmation + - DENY: Command is blocked + - PASSTHROUGH: All checks passed, proceed with normal permission flow + """ + # Block control characters first + result = validate_control_characters(ValidationContext( + original_command=command, base_command='', + unquoted_content='', fully_unquoted_content='', + fully_unquoted_pre_strip='', unquoted_keep_quote_chars='', + )) + if result.behavior != SecurityBehavior.PASSTHROUGH: + return result + + # Build validation context + base_command = command.split()[0] if command.strip() else '' + with_double_quotes, fully_unquoted, unquoted_keep_quote_chars = extract_quoted_content(command) + + ctx = ValidationContext( + original_command=command, + base_command=base_command, + unquoted_content=with_double_quotes, + fully_unquoted_content=strip_safe_redirections(fully_unquoted), + fully_unquoted_pre_strip=fully_unquoted, + unquoted_keep_quote_chars=unquoted_keep_quote_chars, + ) + + # Early validators (can allow or block) + early_validators = [ + validate_empty, + validate_incomplete_commands, + validate_git_commit, + ] + + for validator in early_validators: + result = validator(ctx) + if result.behavior == SecurityBehavior.ALLOW: + return _passthrough(result.message) + if result.behavior != SecurityBehavior.PASSTHROUGH: + return result + + # Main validators + validators: list[tuple[str, Callable[[ValidationContext], SecurityResult]]] = [ + ('validate_jq_command', validate_jq_command), + ('validate_obfuscated_flags', validate_obfuscated_flags), + ('validate_shell_metacharacters', validate_shell_metacharacters), + ('validate_dangerous_variables', validate_dangerous_variables), + ('validate_comment_quote_desync', validate_comment_quote_desync), + ('validate_quoted_newline', validate_quoted_newline), + ('validate_carriage_return', validate_carriage_return), + ('validate_newlines', validate_newlines), + ('validate_ifs_injection', validate_ifs_injection), + ('validate_proc_environ_access', validate_proc_environ_access), + ('validate_dangerous_patterns', validate_dangerous_patterns), + ('validate_redirections', validate_redirections), + ('validate_backslash_escaped_whitespace', validate_backslash_escaped_whitespace), + ('validate_backslash_escaped_operators', validate_backslash_escaped_operators), + ('validate_unicode_whitespace', validate_unicode_whitespace), + ('validate_mid_word_hash', validate_mid_word_hash), + ('validate_brace_expansion', validate_brace_expansion), + ('validate_zsh_dangerous_commands', validate_zsh_dangerous_commands), + ] + + # Defer non-misparsing results to let misparsing validators run + deferred_non_misparsing: Optional[SecurityResult] = None + + for name, validator in validators: + result = validator(ctx) + if result.behavior == SecurityBehavior.ASK: + if name in _NON_MISPARSING_VALIDATORS: + if deferred_non_misparsing is None: + deferred_non_misparsing = result + continue + return SecurityResult( + SecurityBehavior.ASK, result.message, is_misparsing=True, + ) + + if deferred_non_misparsing is not None: + return deferred_non_misparsing + + return _passthrough('Command passed all security checks') + + +# --------------------------------------------------------------------------- +# Destructive command warning (informational, not blocking) +# --------------------------------------------------------------------------- + +def get_destructive_command_warning(command: str) -> Optional[str]: + """ + Check if a command matches known destructive patterns. + Returns a warning string or None. + """ + for pattern, warning in DESTRUCTIVE_PATTERNS: + if pattern.search(command): + return warning + return None + + +# --------------------------------------------------------------------------- +# Enhanced shell security check (replaces basic _ensure_shell_allowed) +# --------------------------------------------------------------------------- + +def check_shell_security( + command: str, + *, + allow_shell: bool = True, + allow_destructive: bool = False, +) -> tuple[bool, str]: + """ + Comprehensive shell security check. Returns (allowed, message). + + This is the main integration point for the agent's tool execution. + It replaces the basic destructive pattern matching with full security validation. + + Args: + command: The shell command to validate + allow_shell: Whether shell commands are enabled at all + allow_destructive: Whether destructive commands are allowed + + Returns: + (True, '') if command is allowed + (False, reason) if command should be blocked + """ + if not allow_shell: + return (False, 'Shell commands are disabled. Re-run with --allow-shell to enable bash.') + + # Run full security validation + result = bash_command_is_safe(command) + + if result.behavior == SecurityBehavior.DENY: + return (False, result.message) + + if result.behavior == SecurityBehavior.ASK: + # For misparsing concerns, always block + if result.is_misparsing: + return (False, f'Security check: {result.message}') + + # Check destructive patterns if not allowed + if not allow_destructive: + warning = get_destructive_command_warning(command) + if warning: + return (False, f'Potentially destructive command blocked: {warning}. ' + 'Re-run with --unsafe to allow it.') + + return (True, '') diff --git a/src/compact.py b/src/compact.py new file mode 100644 index 0000000..3e4a786 --- /dev/null +++ b/src/compact.py @@ -0,0 +1,438 @@ +"""Conversation compaction service. + +Mirrors the npm ``src/services/compact/compact.ts`` and +``src/services/compact/prompt.ts`` modules. Provides: + +- The 9-section summarisation prompt (``get_compact_prompt``). +- XML-tag formatting/stripping (``format_compact_summary``). +- The post-compact user summary message builder + (``get_compact_user_summary_message``). +- The core ``compact_conversation`` entry point that an + ``/compact`` slash command or auto-compact subsystem can call. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +from .agent_context_usage import estimate_tokens +from .agent_session import AgentMessage + +if TYPE_CHECKING: + from .agent_runtime import LocalCodingAgent + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +AUTOCOMPACT_BUFFER_TOKENS = 13_000 +"""How many tokens to reserve below the effective context window before +auto-compact fires (same as the npm ``AUTOCOMPACT_BUFFER_TOKENS``).""" + +ERROR_NOT_ENOUGH_MESSAGES = 'Not enough messages to compact.' +ERROR_INCOMPLETE_RESPONSE = ( + 'The summary response was incomplete. ' + 'The conversation was not compacted.' +) +ERROR_USER_ABORT = 'Compaction canceled.' + +MAX_COMPACT_FAILURES = 3 +"""Circuit-breaker – stop retrying auto-compact after this many consecutive +failures (mirrors the npm implementation).""" + +# --------------------------------------------------------------------------- +# Prompt construction (npm ``src/services/compact/prompt.ts``) +# --------------------------------------------------------------------------- + +_NO_TOOLS_PREAMBLE = """\ +CRITICAL: Respond with TEXT ONLY. Do NOT call any tools. + +- Do NOT use Read, Bash, Grep, Glob, Edit, Write, or ANY other tool. +- You already have all the context you need in the conversation above. +- Tool calls will be REJECTED and will waste your only turn — you will fail the task. +- Your entire response must be plain text: an block followed by a block. + +""" + +_DETAILED_ANALYSIS_INSTRUCTION = """\ +Before providing your final summary, wrap your analysis in tags to \ +organize your thoughts and ensure you've covered all necessary points. In your \ +analysis process: + +1. Chronologically analyze each message and section of the conversation. \ +For each section thoroughly identify: + - The user's explicit requests and intents + - Your approach to addressing the user's requests + - Key decisions, technical concepts and code patterns + - Specific details like: + - file names + - full code snippets + - function signatures + - file edits + - Errors that you ran into and how you fixed them + - Pay special attention to specific user feedback that you received, \ +especially if the user told you to do something differently. +2. Double-check for technical accuracy and completeness, addressing each \ +required element thoroughly.""" + +_BASE_COMPACT_PROMPT = f"""\ +Your task is to create a detailed summary of the conversation so far, paying \ +close attention to the user's explicit requests and your previous actions. +This summary should be thorough in capturing technical details, code patterns, \ +and architectural decisions that would be essential for continuing development \ +work without losing context. + +{_DETAILED_ANALYSIS_INSTRUCTION} + +Your summary should include the following sections: + +1. Primary Request and Intent: Capture all of the user's explicit requests \ +and intents in detail +2. Key Technical Concepts: List all important technical concepts, technologies, \ +and frameworks discussed. +3. Files and Code Sections: Enumerate specific files and code sections examined, \ +modified, or created. Pay special attention to the most recent messages and \ +include full code snippets where applicable and include a summary of why this \ +file read or edit is important. +4. Errors and fixes: List all errors that you ran into, and how you fixed them. \ +Pay special attention to specific user feedback that you received, especially if \ +the user told you to do something differently. +5. Problem Solving: Document problems solved and any ongoing troubleshooting \ +efforts. +6. All user messages: List ALL user messages that are not tool results. These \ +are critical for understanding the users' feedback and changing intent. +7. Pending Tasks: Outline any pending tasks that you have explicitly been asked \ +to work on. +8. Current Work: Describe in detail precisely what was being worked on \ +immediately before this summary request, paying special attention to the most \ +recent messages from both user and assistant. Include file names and code \ +snippets where applicable. +9. Optional Next Step: List the next step that you will take that is related to \ +the most recent work you were doing. IMPORTANT: ensure that this step is \ +DIRECTLY in line with the user's most recent explicit requests, and the task \ +you were working on immediately before this summary request. If your last task \ +was concluded, then only list next steps if they are explicitly in line with the \ +users request. Do not start on tangential requests or really old requests that \ +were already completed without confirming with the user first. + If there is a next step, include direct quotes from the \ +most recent conversation showing exactly what task you were working on and where \ +you left off. This should be verbatim to ensure there's no drift in task \ +interpretation. + +Here's an example of how your output should be structured: + + + +[Your thought process, ensuring all points are covered thoroughly and accurately] + + + +1. Primary Request and Intent: + [Detailed description] + +2. Key Technical Concepts: + - [Concept 1] + - [Concept 2] + - [...] + +3. Files and Code Sections: + - [File Name 1] + - [Summary of why this file is important] + - [Summary of the changes made to this file, if any] + - [Important Code Snippet] + - [File Name 2] + - [Important Code Snippet] + - [...] + +4. Errors and fixes: + - [Detailed description of error 1]: + - [How you fixed the error] + - [User feedback on the error if any] + - [...] + +5. Problem Solving: + [Description of solved problems and ongoing troubleshooting] + +6. All user messages: + - [Detailed non tool use user message] + - [...] + +7. Pending Tasks: + - [Task 1] + - [Task 2] + - [...] + +8. Current Work: + [Precise description of current work] + +9. Optional Next Step: + [Optional Next step to take] + + + + +Please provide your summary based on the conversation so far, following this \ +structure and ensuring precision and thoroughness in your response. + +There may be additional summarization instructions provided in the included \ +context. If so, remember to follow these instructions when creating the above \ +summary. Examples of instructions include: + +## Compact Instructions +When summarizing the conversation focus on typescript code changes and also \ +remember the mistakes you made and how you fixed them. + + + +# Summary instructions +When you are using compact - please focus on test output and code changes. \ +Include file reads verbatim. + +""" + +_NO_TOOLS_TRAILER = ( + '\n\nREMINDER: Do NOT call any tools. Respond with plain text only — ' + 'an block followed by a block. ' + 'Tool calls will be rejected and you will fail the task.' +) + + +def get_compact_prompt(custom_instructions: str | None = None) -> str: + """Build the full compact prompt, optionally appending user instructions.""" + prompt = _NO_TOOLS_PREAMBLE + _BASE_COMPACT_PROMPT + if custom_instructions and custom_instructions.strip(): + prompt += f'\n\nAdditional Instructions:\n{custom_instructions}' + prompt += _NO_TOOLS_TRAILER + return prompt + + +# --------------------------------------------------------------------------- +# Summary formatting +# --------------------------------------------------------------------------- + +def format_compact_summary(summary: str) -> str: + """Strip the ```` scratchpad and unwrap ```` tags. + + Mirrors the npm ``formatCompactSummary`` helper. + """ + formatted = re.sub(r'[\s\S]*?', '', summary) + + match = re.search(r'([\s\S]*?)', formatted) + if match: + content = match.group(1).strip() + formatted = re.sub( + r'[\s\S]*?', + f'Summary:\n{content}', + formatted, + ) + + # Collapse runs of blank lines. + formatted = re.sub(r'\n\n+', '\n\n', formatted) + return formatted.strip() + + +def get_compact_user_summary_message( + summary: str, + *, + suppress_follow_up: bool = False, + transcript_path: str | None = None, +) -> str: + """Build the user-facing summary that replaces compacted messages. + + Mirrors the npm ``getCompactUserSummaryMessage`` helper. + """ + formatted = format_compact_summary(summary) + + base = ( + 'This session is being continued from a previous conversation that ' + 'ran out of context. The summary below covers the earlier portion ' + f'of the conversation.\n\n{formatted}' + ) + + if transcript_path: + base += ( + '\n\nIf you need specific details from before compaction ' + '(like exact code snippets, error messages, or content you ' + 'generated), read the full transcript at: ' + f'{transcript_path}' + ) + + if suppress_follow_up: + base += ( + '\nContinue the conversation from where it left off without ' + 'asking the user any further questions. Resume directly — do ' + 'not acknowledge the summary, do not recap what was happening, ' + 'do not preface with "I\'ll continue" or similar. Pick up the ' + 'last task as if the break never happened.' + ) + + return base + + +# --------------------------------------------------------------------------- +# Compaction result +# --------------------------------------------------------------------------- + +@dataclass +class CompactionResult: + """Outcome of a ``compact_conversation`` call.""" + + boundary_message: AgentMessage + summary_messages: list[AgentMessage] = field(default_factory=list) + messages_to_keep: list[AgentMessage] = field(default_factory=list) + pre_compact_token_count: int = 0 + post_compact_token_count: int = 0 + summary_text: str = '' + error: str | None = None + + +# --------------------------------------------------------------------------- +# Core compaction logic +# --------------------------------------------------------------------------- + +def compact_conversation( + agent: 'LocalCodingAgent', + custom_instructions: str | None = None, +) -> CompactionResult: + """Perform an LLM-backed conversation compaction. + + 1. Build the compact prompt (9-section template). + 2. Collect the session messages to summarise. + 3. Send them + the compact prompt to the model. + 4. Parse ```` from the response. + 5. Replace session messages with: + boundary marker → summary user message → preserved tail. + + Returns a :class:`CompactionResult` with diagnostics. + """ + session = agent.last_session + if session is None or len(session.messages) == 0: + return CompactionResult( + boundary_message=_build_boundary('No session to compact.'), + error=ERROR_NOT_ENOUGH_MESSAGES, + ) + + # ---- Determine which messages to compact vs preserve ---- + # We keep the most recent ``preserve_count`` messages untouched. + preserve_count = max( + getattr(agent.runtime_config, 'compact_preserve_messages', 4), 1 + ) + + # Identify the prefix count (system-injected messages that precede the + # real conversation, e.g. a compaction-replay boundary). + prefix_count = 0 + for msg in session.messages: + if msg.metadata.get('kind') == 'compact_boundary': + prefix_count += 1 + else: + break + + total = len(session.messages) + tail_count = min(preserve_count, max(total - prefix_count, 0)) + compact_end = total - tail_count + + if compact_end <= prefix_count: + return CompactionResult( + boundary_message=_build_boundary('Not enough messages after prefix.'), + error=ERROR_NOT_ENOUGH_MESSAGES, + ) + + candidates = session.messages[prefix_count:compact_end] + preserved_tail = list(session.messages[compact_end:]) + + if not candidates: + return CompactionResult( + boundary_message=_build_boundary('Nothing to compact.'), + error=ERROR_NOT_ENOUGH_MESSAGES, + ) + + # ---- Estimate pre-compact token count ---- + model = agent.model_config.model + pre_tokens = sum(estimate_tokens(m.content, model) for m in session.messages) + + # ---- Build the compact request messages ---- + compact_prompt = get_compact_prompt(custom_instructions) + + # We send the system prompt + candidate messages + the compact prompt as + # a user message. The model returns the summary. + api_messages: list[dict[str, Any]] = [] + + # System prompt (from session) + for part in session.system_prompt_parts: + if part.strip(): + api_messages.append({'role': 'system', 'content': part}) + + # Candidate messages (the ones to be summarised) + for msg in candidates: + api_messages.append(msg.to_openai_message()) + + # The compact prompt as the final user turn + api_messages.append({'role': 'user', 'content': compact_prompt}) + + # ---- Call the model ---- + try: + turn = agent.client.complete(api_messages, tools=[]) + except Exception as exc: + return CompactionResult( + boundary_message=_build_boundary(f'Compact API call failed: {exc}'), + error=str(exc), + ) + + raw_summary = turn.content or '' + if not raw_summary.strip(): + return CompactionResult( + boundary_message=_build_boundary('Model returned empty summary.'), + error=ERROR_INCOMPLETE_RESPONSE, + ) + + # ---- Format the summary ---- + summary_text = format_compact_summary(raw_summary) + user_summary_content = get_compact_user_summary_message(raw_summary) + + # ---- Build post-compact messages ---- + boundary = _build_boundary( + f'Earlier conversation ({len(candidates)} messages, ~{pre_tokens} tokens) ' + f'was compacted.', + ) + + summary_msg = AgentMessage( + role='user', + content=user_summary_content, + message_id='compact_summary', + metadata={'kind': 'compact_summary', 'is_compact_summary': True}, + ) + + # Replace session messages in-place + session.messages = ( + session.messages[:prefix_count] + + [boundary, summary_msg] + + preserved_tail + ) + + # ---- Post-compact token estimate ---- + post_tokens = sum(estimate_tokens(m.content, model) for m in session.messages) + + return CompactionResult( + boundary_message=boundary, + summary_messages=[summary_msg], + messages_to_keep=preserved_tail, + pre_compact_token_count=pre_tokens, + post_compact_token_count=post_tokens, + summary_text=summary_text, + ) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _build_boundary(note: str) -> AgentMessage: + """Create a compact-boundary system message.""" + return AgentMessage( + role='user', + content=f'\n{note}\n', + message_id='compact_boundary', + metadata={'kind': 'compact_boundary'}, + ) diff --git a/src/prompt_constants.py b/src/prompt_constants.py new file mode 100644 index 0000000..885103a --- /dev/null +++ b/src/prompt_constants.py @@ -0,0 +1,709 @@ +"""Prompt constants ported from npm src/constants/. + +Covers: product metadata, API limits, tool limits, spinner verbs, +turn-completion verbs, figures/symbols, XML tags, message constants, +date utilities, system prompt section caching, output-style configs, +and cyber-risk instruction. + +npm sources: + src/constants/product.ts + src/constants/apiLimits.ts + src/constants/toolLimits.ts + src/constants/spinnerVerbs.ts + src/constants/turnCompletionVerbs.ts + src/constants/figures.ts + src/constants/xml.ts + src/constants/messages.ts + src/constants/common.ts + src/constants/systemPromptSections.ts + src/constants/outputStyles.ts + src/constants/cyberRiskInstruction.ts + src/constants/system.ts + src/constants/prompts.ts (selected sections) +""" + +from __future__ import annotations + +import os +import platform +import threading +from dataclasses import dataclass, field +from datetime import date, datetime +from typing import Any, Callable + +# --------------------------------------------------------------------------- +# Product metadata (product.ts) +# --------------------------------------------------------------------------- + +PRODUCT_URL = "https://claude.com/claude-code" +CLAUDE_AI_BASE_URL = "https://claude.ai" + +# --------------------------------------------------------------------------- +# System prompt prefixes (system.ts) +# --------------------------------------------------------------------------- + +DEFAULT_SYSPROMPT_PREFIX = ( + "You are Claude Code, Anthropic's official CLI for Claude." +) +AGENT_SDK_CLAUDE_CODE_PRESET_PREFIX = ( + "You are Claude Code, Anthropic's official CLI for Claude, " + "running within the Claude Agent SDK." +) +AGENT_SDK_PREFIX = ( + "You are a Claude agent, built on Anthropic's Claude Agent SDK." +) +CLI_SYSPROMPT_PREFIXES = frozenset( + { + DEFAULT_SYSPROMPT_PREFIX, + AGENT_SDK_CLAUDE_CODE_PRESET_PREFIX, + AGENT_SDK_PREFIX, + } +) + +# --------------------------------------------------------------------------- +# Cyber-risk instruction (cyberRiskInstruction.ts) +# --------------------------------------------------------------------------- + +CYBER_RISK_INSTRUCTION = ( + "IMPORTANT: Assist with authorized security testing, defensive security, " + "CTF challenges, and educational contexts. Refuse requests for destructive " + "techniques, DoS attacks, mass targeting, supply chain compromise, or " + "detection evasion for malicious purposes. Dual-use security tools (C2 " + "frameworks, credential testing, exploit development) require clear " + "authorization context: pentesting engagements, CTF competitions, security " + "research, or defensive use cases." +) + +# --------------------------------------------------------------------------- +# API limits (apiLimits.ts) +# --------------------------------------------------------------------------- + +# Image limits +API_IMAGE_MAX_BASE64_SIZE = 5 * 1024 * 1024 # 5 MB base64 +IMAGE_TARGET_RAW_SIZE = (API_IMAGE_MAX_BASE64_SIZE * 3) // 4 # ~3.75 MB +IMAGE_MAX_WIDTH = 2000 +IMAGE_MAX_HEIGHT = 2000 + +# PDF limits +PDF_TARGET_RAW_SIZE = 20 * 1024 * 1024 # 20 MB +API_PDF_MAX_PAGES = 100 +PDF_EXTRACT_SIZE_THRESHOLD = 3 * 1024 * 1024 # 3 MB +PDF_MAX_EXTRACT_SIZE = 100 * 1024 * 1024 # 100 MB +PDF_MAX_PAGES_PER_READ = 20 +PDF_AT_MENTION_INLINE_THRESHOLD = 10 + +# Media limits +API_MAX_MEDIA_PER_REQUEST = 100 + +# --------------------------------------------------------------------------- +# Tool limits (toolLimits.ts) +# --------------------------------------------------------------------------- + +DEFAULT_MAX_RESULT_SIZE_CHARS = 50_000 +MAX_TOOL_RESULT_TOKENS = 100_000 +BYTES_PER_TOKEN = 4 +MAX_TOOL_RESULT_BYTES = MAX_TOOL_RESULT_TOKENS * BYTES_PER_TOKEN # 400 KB +MAX_TOOL_RESULTS_PER_MESSAGE_CHARS = 200_000 +TOOL_SUMMARY_MAX_LENGTH = 50 + +# --------------------------------------------------------------------------- +# Spinner verbs (spinnerVerbs.ts) — 204 whimsical gerunds +# --------------------------------------------------------------------------- + +SPINNER_VERBS: tuple[str, ...] = ( + "Accomplishing", + "Actioning", + "Actualizing", + "Architecting", + "Baking", + "Beaming", + "Beboppin'", + "Befuddling", + "Billowing", + "Blanching", + "Bloviating", + "Boogieing", + "Boondoggling", + "Booping", + "Bootstrapping", + "Brewing", + "Bunning", + "Burrowing", + "Calculating", + "Canoodling", + "Caramelizing", + "Cascading", + "Catapulting", + "Cerebrating", + "Channeling", + "Channelling", + "Choreographing", + "Churning", + "Clauding", + "Coalescing", + "Cogitating", + "Combobulating", + "Composing", + "Computing", + "Concocting", + "Considering", + "Contemplating", + "Cooking", + "Crafting", + "Creating", + "Crunching", + "Crystallizing", + "Cultivating", + "Deciphering", + "Deliberating", + "Determining", + "Dilly-dallying", + "Discombobulating", + "Doing", + "Doodling", + "Drizzling", + "Ebbing", + "Effecting", + "Elucidating", + "Embellishing", + "Enchanting", + "Envisioning", + "Evaporating", + "Fermenting", + "Fiddle-faddling", + "Finagling", + "Flambéing", + "Flibbertigibbeting", + "Flowing", + "Flummoxing", + "Fluttering", + "Forging", + "Forming", + "Frolicking", + "Frosting", + "Gallivanting", + "Galloping", + "Garnishing", + "Generating", + "Gesticulating", + "Germinating", + "Gitifying", + "Grooving", + "Gusting", + "Harmonizing", + "Hashing", + "Hatching", + "Herding", + "Honking", + "Hullaballooing", + "Hyperspacing", + "Ideating", + "Imagining", + "Improvising", + "Incubating", + "Inferring", + "Infusing", + "Ionizing", + "Jitterbugging", + "Julienning", + "Kneading", + "Leavening", + "Levitating", + "Lollygagging", + "Manifesting", + "Marinating", + "Meandering", + "Metamorphosing", + "Misting", + "Moonwalking", + "Moseying", + "Mulling", + "Mustering", + "Musing", + "Nebulizing", + "Nesting", + "Newspapering", + "Noodling", + "Nucleating", + "Orbiting", + "Orchestrating", + "Osmosing", + "Perambulating", + "Percolating", + "Perusing", + "Philosophising", + "Photosynthesizing", + "Pollinating", + "Pondering", + "Pontificating", + "Pouncing", + "Precipitating", + "Prestidigitating", + "Processing", + "Proofing", + "Propagating", + "Puttering", + "Puzzling", + "Quantumizing", + "Razzle-dazzling", + "Razzmatazzing", + "Recombobulating", + "Reticulating", + "Roosting", + "Ruminating", + "Sautéing", + "Scampering", + "Schlepping", + "Scurrying", + "Seasoning", + "Shenaniganing", + "Shimmying", + "Simmering", + "Skedaddling", + "Sketching", + "Slithering", + "Smooshing", + "Sock-hopping", + "Spelunking", + "Spinning", + "Sprouting", + "Stewing", + "Sublimating", + "Swirling", + "Swooping", + "Symbioting", + "Synthesizing", + "Tempering", + "Thinking", + "Thundering", + "Tinkering", + "Tomfoolering", + "Topsy-turvying", + "Transfiguring", + "Transmuting", + "Twisting", + "Undulating", + "Unfurling", + "Unravelling", + "Vibing", + "Waddling", + "Wandering", + "Warping", + "Whatchamacalliting", + "Whirlpooling", + "Whirring", + "Whisking", + "Wibbling", + "Working", + "Wrangling", + "Zesting", + "Zigzagging", +) + +# --------------------------------------------------------------------------- +# Turn-completion verbs (turnCompletionVerbs.ts) — 8 past-tense verbs +# --------------------------------------------------------------------------- + +TURN_COMPLETION_VERBS: tuple[str, ...] = ( + "Baked", + "Brewed", + "Churned", + "Cogitated", + "Cooked", + "Crunched", + "Sautéed", + "Worked", +) + +# --------------------------------------------------------------------------- +# Figures / UI symbols (figures.ts) +# --------------------------------------------------------------------------- + +BLACK_CIRCLE = "\u23fa" if platform.system() == "Darwin" else "\u25cf" # ⏺ / ● +BULLET_OPERATOR = "\u2219" # ∙ +TEARDROP_ASTERISK = "\u273b" # ✻ +UP_ARROW = "\u2191" # ↑ +DOWN_ARROW = "\u2193" # ↓ +LIGHTNING_BOLT = "\u21af" # ↯ +EFFORT_LOW = "\u25cb" # ○ +EFFORT_MEDIUM = "\u25d0" # ◐ +EFFORT_HIGH = "\u25cf" # ● +EFFORT_MAX = "\u25c9" # ◉ + +PLAY_ICON = "\u25b6" # ▶ +PAUSE_ICON = "\u23f8" # ⏸ + +REFRESH_ARROW = "\u21bb" # ↻ +CHANNEL_ARROW = "\u2190" # ← +INJECTED_ARROW = "\u2192" # → +FORK_GLYPH = "\u2442" # ⑂ + +DIAMOND_OPEN = "\u25c7" # ◇ +DIAMOND_FILLED = "\u25c6" # ◆ +REFERENCE_MARK = "\u203b" # ※ + +FLAG_ICON = "\u2691" # ⚑ +BLOCKQUOTE_BAR = "\u258e" # ▎ +HEAVY_HORIZONTAL = "\u2501" # ━ + +BRIDGE_SPINNER_FRAMES: tuple[str, ...] = ( + "\u00b7|\u00b7", + "\u00b7/\u00b7", + "\u00b7\u2014\u00b7", + "\u00b7\\\u00b7", +) +BRIDGE_READY_INDICATOR = "\u00b7\u2714\ufe0e\u00b7" +BRIDGE_FAILED_INDICATOR = "\u00d7" + +# --------------------------------------------------------------------------- +# XML tag constants (xml.ts) +# --------------------------------------------------------------------------- + +COMMAND_NAME_TAG = "command-name" +COMMAND_MESSAGE_TAG = "command-message" +COMMAND_ARGS_TAG = "command-args" + +BASH_INPUT_TAG = "bash-input" +BASH_STDOUT_TAG = "bash-stdout" +BASH_STDERR_TAG = "bash-stderr" +LOCAL_COMMAND_STDOUT_TAG = "local-command-stdout" +LOCAL_COMMAND_STDERR_TAG = "local-command-stderr" +LOCAL_COMMAND_CAVEAT_TAG = "local-command-caveat" + +TERMINAL_OUTPUT_TAGS: tuple[str, ...] = ( + BASH_INPUT_TAG, + BASH_STDOUT_TAG, + BASH_STDERR_TAG, + LOCAL_COMMAND_STDOUT_TAG, + LOCAL_COMMAND_STDERR_TAG, + LOCAL_COMMAND_CAVEAT_TAG, +) + +TICK_TAG = "tick" + +TASK_NOTIFICATION_TAG = "task-notification" +TASK_ID_TAG = "task-id" +TOOL_USE_ID_TAG = "tool-use-id" +TASK_TYPE_TAG = "task-type" +OUTPUT_FILE_TAG = "output-file" +STATUS_TAG = "status" +SUMMARY_TAG = "summary" +REASON_TAG = "reason" +WORKTREE_TAG = "worktree" +WORKTREE_PATH_TAG = "worktreePath" +WORKTREE_BRANCH_TAG = "worktreeBranch" + +ULTRAPLAN_TAG = "ultraplan" +REMOTE_REVIEW_TAG = "remote-review" +REMOTE_REVIEW_PROGRESS_TAG = "remote-review-progress" +TEAMMATE_MESSAGE_TAG = "teammate-message" +CHANNEL_MESSAGE_TAG = "channel-message" +CHANNEL_TAG = "channel" +CROSS_SESSION_MESSAGE_TAG = "cross-session-message" + +FORK_BOILERPLATE_TAG = "fork-boilerplate" +FORK_DIRECTIVE_PREFIX = "Your directive: " + +COMMON_HELP_ARGS: tuple[str, ...] = ("help", "-h", "--help") +COMMON_INFO_ARGS: tuple[str, ...] = ( + "list", + "show", + "display", + "current", + "view", + "get", + "check", + "describe", + "print", + "version", + "about", + "status", + "?", +) + +# --------------------------------------------------------------------------- +# Message constants (messages.ts) +# --------------------------------------------------------------------------- + +NO_CONTENT_MESSAGE = "(no content)" + +# --------------------------------------------------------------------------- +# Date utilities (common.ts) +# --------------------------------------------------------------------------- + + +def get_local_iso_date() -> str: + """Return the local date in YYYY-MM-DD format. + + Respects ``CLAUDE_CODE_OVERRIDE_DATE`` env var. + """ + override = os.environ.get("CLAUDE_CODE_OVERRIDE_DATE") + if override: + return override + return date.today().isoformat() + + +_session_start_date_lock = threading.Lock() +_session_start_date: str | None = None + + +def get_session_start_date() -> str: + """Memoised local date — captured once per session.""" + global _session_start_date + if _session_start_date is not None: + return _session_start_date + with _session_start_date_lock: + if _session_start_date is None: + _session_start_date = get_local_iso_date() + return _session_start_date + + +def reset_session_start_date() -> None: + """Reset the memoised date (for tests).""" + global _session_start_date + _session_start_date = None + + +def get_local_month_year() -> str: + """Return ``"Month YYYY"`` (e.g. ``"February 2026"``).""" + override = os.environ.get("CLAUDE_CODE_OVERRIDE_DATE") + if override: + d = datetime.fromisoformat(override) + else: + d = datetime.now() + return d.strftime("%B %Y") + +# --------------------------------------------------------------------------- +# System prompt section caching (systemPromptSections.ts) +# --------------------------------------------------------------------------- + +ComputeFn = Callable[[], str | None] + + +@dataclass +class SystemPromptSection: + """A named section of the system prompt with lazy compute.""" + name: str + compute: ComputeFn + cache_break: bool = False + + +def system_prompt_section(name: str, compute: ComputeFn) -> SystemPromptSection: + """Create a memoised prompt section (cached until /clear or /compact).""" + return SystemPromptSection(name=name, compute=compute, cache_break=False) + + +def dangerous_uncached_system_prompt_section( + name: str, + compute: ComputeFn, + _reason: str = "", +) -> SystemPromptSection: + """Prompt section that recomputes every turn (breaks prompt cache).""" + return SystemPromptSection(name=name, compute=compute, cache_break=True) + + +_section_cache: dict[str, str | None] = {} + + +def resolve_system_prompt_sections( + sections: list[SystemPromptSection], +) -> list[str | None]: + """Resolve sections, caching non-volatile ones.""" + results: list[str | None] = [] + for section in sections: + if not section.cache_break and section.name in _section_cache: + results.append(_section_cache[section.name]) + continue + value = section.compute() + _section_cache[section.name] = value + results.append(value) + return results + + +def clear_system_prompt_sections() -> None: + """Clear cached prompt sections (called on /clear and /compact).""" + _section_cache.clear() + +# --------------------------------------------------------------------------- +# Output style configuration (outputStyles.ts) +# --------------------------------------------------------------------------- + +DEFAULT_OUTPUT_STYLE_NAME = "default" + + +@dataclass(frozen=True) +class OutputStyleConfig: + name: str + description: str + prompt: str + source: str = "built-in" + keep_coding_instructions: bool = True + force_for_plugin: bool = False + + +# Built-in output styles matching npm +OUTPUT_STYLE_CONFIGS: dict[str, OutputStyleConfig | None] = { + DEFAULT_OUTPUT_STYLE_NAME: None, + "Explanatory": OutputStyleConfig( + name="Explanatory", + description="Claude explains its implementation choices and codebase patterns", + prompt=( + "You are an interactive CLI tool that helps users with software " + "engineering tasks. In addition to software engineering tasks, you " + "should provide educational insights about the codebase along the way.\n\n" + "You should be clear and educational, providing helpful explanations " + "while remaining focused on the task. Balance educational content " + "with task completion." + ), + ), + "Learning": OutputStyleConfig( + name="Learning", + description="Claude pauses and asks you to write small pieces of code for hands-on practice", + prompt=( + "You are an interactive CLI tool that helps users with software " + "engineering tasks. In addition to software engineering tasks, you " + "should help users learn more about the codebase through hands-on " + "practice and educational insights.\n\n" + "You should be collaborative and encouraging. Balance task completion " + "with learning by requesting user input for meaningful design " + "decisions while handling routine implementation yourself." + ), + ), +} + +# --------------------------------------------------------------------------- +# Knowledge cutoff (prompts.ts) +# --------------------------------------------------------------------------- + +FRONTIER_MODEL_NAME = "Claude Opus 4.6" + +_KNOWLEDGE_CUTOFFS: dict[str, str] = { + "claude-sonnet-4-6": "August 2025", + "claude-opus-4-6": "May 2025", + "claude-opus-4-5": "May 2025", + "claude-haiku-4": "February 2025", + "claude-opus-4": "January 2025", + "claude-sonnet-4": "January 2025", +} + + +def get_knowledge_cutoff(model_id: str) -> str | None: + """Return knowledge cutoff date for a model, or None.""" + canonical = model_id.lower() + for pattern, cutoff in _KNOWLEDGE_CUTOFFS.items(): + if pattern in canonical: + return cutoff + return None + +# --------------------------------------------------------------------------- +# Model family IDs (prompts.ts) +# --------------------------------------------------------------------------- + +CLAUDE_MODEL_IDS = { + "opus": "claude-opus-4-6", + "sonnet": "claude-sonnet-4-6", + "haiku": "claude-haiku-4-5-20251001", +} + +# --------------------------------------------------------------------------- +# Hooks section (prompts.ts) +# --------------------------------------------------------------------------- + +HOOKS_SECTION = ( + "Users may configure 'hooks', shell commands that execute in response to " + "events like tool calls, in settings. Treat feedback from hooks, including " + ", as coming from the user. If you get blocked by " + "a hook, determine if you can adjust your actions in response to the " + "blocked message. If not, ask the user to check their hooks configuration." +) + +# --------------------------------------------------------------------------- +# System reminders section (prompts.ts) +# --------------------------------------------------------------------------- + +SYSTEM_REMINDERS_SECTION = ( + "- Tool results and user messages may include tags. " + " tags contain useful information and reminders. They are " + "automatically added by the system, and bear no direct relation to the " + "specific tool results or user messages in which they appear.\n" + "- The conversation has unlimited context through automatic summarization." +) + +# --------------------------------------------------------------------------- +# Summarize tool results (prompts.ts) +# --------------------------------------------------------------------------- + +SUMMARIZE_TOOL_RESULTS_SECTION = ( + "When working with tool results, write down any important information you " + "might need later in your response, as the original tool result may be " + "cleared later." +) + +# --------------------------------------------------------------------------- +# Default agent prompt (prompts.ts) +# --------------------------------------------------------------------------- + +DEFAULT_AGENT_PROMPT = ( + "You are an agent for Claude Code, Anthropic's official CLI for Claude. " + "Given the user's message, you should use the tools available to complete " + "the task. Complete the task fully\u2014don't gold-plate, but don't leave " + "it half-done. When you complete the task, respond with a concise report " + "covering what was done and any key findings \u2014 the caller will relay " + "this to the user, so it only needs the essentials." +) + +# --------------------------------------------------------------------------- +# Error IDs (errorIds.ts) +# --------------------------------------------------------------------------- + +E_TOOL_USE_SUMMARY_GENERATION_FAILED = 344 + +# --------------------------------------------------------------------------- +# Dynamic boundary marker (prompts.ts) +# --------------------------------------------------------------------------- + +SYSTEM_PROMPT_DYNAMIC_BOUNDARY = "__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__" + +# --------------------------------------------------------------------------- +# Convenience helpers for use in prompt building +# --------------------------------------------------------------------------- + + +def get_language_section(language_preference: str | None) -> str | None: + """Return the language preference prompt section, or None.""" + if not language_preference: + return None + return ( + f"# Language\n" + f"Always respond in {language_preference}. Use {language_preference} " + f"for all explanations, comments, and communications with the user. " + f"Technical terms and code identifiers should remain in their original form." + ) + + +def get_output_style_section(config: OutputStyleConfig | None) -> str | None: + """Return the output-style prompt section, or None.""" + if config is None: + return None + return f"# Output Style: {config.name}\n{config.prompt}" + + +def get_scratchpad_instructions(scratchpad_dir: str | None) -> str | None: + """Return scratchpad instructions, or None if no scratchpad is configured.""" + if not scratchpad_dir: + return None + return ( + f"# Scratchpad Directory\n\n" + f"IMPORTANT: Always use this scratchpad directory for temporary files " + f"instead of `/tmp` or other system temp directories:\n" + f"`{scratchpad_dir}`\n\n" + f"Use this directory for ALL temporary file needs:\n" + f"- Storing intermediate results or data during multi-step tasks\n" + f"- Writing temporary scripts or configuration files\n" + f"- Saving outputs that don't belong in the user's project\n" + f"- Creating working files during analysis or processing\n" + f"- Any file that would otherwise go to `/tmp`\n\n" + f"Only use `/tmp` if the user explicitly requests it.\n\n" + f"The scratchpad directory is session-specific, isolated from the " + f"user's project, and can be used freely without permission prompts." + ) diff --git a/tests/test_bash_security.py b/tests/test_bash_security.py new file mode 100644 index 0000000..5cfc01d --- /dev/null +++ b/tests/test_bash_security.py @@ -0,0 +1,878 @@ +""" +Tests for bash_security module. + +Tests are organized by validator function, matching the npm test structure. +""" + +import pytest + +from src.bash_security import ( + SecurityBehavior, + SecurityResult, + ValidationContext, + bash_command_is_safe, + check_shell_security, + extract_quoted_content, + get_destructive_command_warning, + has_unescaped_char, + interpret_command_result, + is_command_read_only, + split_command, + strip_safe_redirections, + validate_backslash_escaped_operators, + validate_backslash_escaped_whitespace, + validate_brace_expansion, + validate_carriage_return, + validate_comment_quote_desync, + validate_control_characters, + validate_dangerous_patterns, + validate_dangerous_variables, + validate_empty, + validate_git_commit, + validate_ifs_injection, + validate_incomplete_commands, + validate_jq_command, + validate_mid_word_hash, + validate_newlines, + validate_obfuscated_flags, + validate_proc_environ_access, + validate_quoted_newline, + validate_redirections, + validate_shell_metacharacters, + validate_unicode_whitespace, + validate_zsh_dangerous_commands, +) + + +# ---- Helper to build a context ---- + +def _ctx(cmd: str) -> ValidationContext: + """Build a ValidationContext for the given command.""" + base = cmd.strip().split()[0] if cmd.strip() else '' + with_dq, fully_unq, keep_qc = extract_quoted_content(cmd) + return ValidationContext( + original_command=cmd, + base_command=base, + unquoted_content=with_dq, + fully_unquoted_content=strip_safe_redirections(fully_unq), + fully_unquoted_pre_strip=fully_unq, + unquoted_keep_quote_chars=keep_qc, + ) + + +# =========================================================================== +# extract_quoted_content +# =========================================================================== + +class TestExtractQuotedContent: + def test_no_quotes(self): + dq, full, kqc = extract_quoted_content('echo hello') + assert dq == 'echo hello' + assert full == 'echo hello' + + def test_single_quotes_stripped(self): + dq, full, kqc = extract_quoted_content("echo 'hello world'") + assert 'hello world' not in full + assert 'echo' in full + + def test_double_quotes_in_dq_output(self): + dq, full, kqc = extract_quoted_content('echo "hello world"') + assert 'hello world' in dq # double-quoted content preserved in dq + assert 'hello world' not in full # but stripped in fully_unquoted + + def test_escape_handling(self): + dq, full, kqc = extract_quoted_content('echo \\$HOME') + assert '$HOME' in full + + def test_keep_quote_chars(self): + _, _, kqc = extract_quoted_content("echo 'x'#") + assert "'" in kqc # quote chars preserved + + +# =========================================================================== +# strip_safe_redirections +# =========================================================================== + +class TestStripSafeRedirections: + def test_dev_null_output(self): + assert '>/dev/null' not in strip_safe_redirections('cmd > /dev/null') + + def test_stderr_redirect(self): + assert '2>&1' not in strip_safe_redirections('cmd 2>&1') + + def test_dev_null_input(self): + assert ' output.txt') + assert '> output.txt' in result + + +# =========================================================================== +# has_unescaped_char +# =========================================================================== + +class TestHasUnescapedChar: + def test_unescaped_backtick(self): + assert has_unescaped_char('echo `date`', '`') is True + + def test_escaped_backtick(self): + assert has_unescaped_char('echo \\`safe\\`', '`') is False + + def test_double_backslash_then_backtick(self): + # \\\` → \\ (literal backslash) + ` (unescaped) + assert has_unescaped_char('test\\\\`date`', '`') is True + + def test_no_match(self): + assert has_unescaped_char('echo hello', '`') is False + + +# =========================================================================== +# split_command +# =========================================================================== + +class TestSplitCommand: + def test_simple(self): + assert split_command('echo hello') == ['echo hello'] + + def test_semicolon(self): + assert split_command('echo a; echo b') == ['echo a', 'echo b'] + + def test_and_and(self): + assert split_command('cmd1 && cmd2') == ['cmd1', 'cmd2'] + + def test_pipe(self): + assert split_command('cat file | grep pattern') == ['cat file', 'grep pattern'] + + def test_or_or(self): + assert split_command('cmd1 || cmd2') == ['cmd1', 'cmd2'] + + def test_quotes_preserved(self): + result = split_command("echo 'a; b'") + assert len(result) == 1 # semicolon inside quotes not split + + def test_complex(self): + result = split_command('cd /tmp && echo hi; ls | head') + assert len(result) == 4 + + +# =========================================================================== +# validate_empty +# =========================================================================== + +class TestValidateEmpty: + def test_empty(self): + assert validate_empty(_ctx('')).behavior == SecurityBehavior.ALLOW + + def test_whitespace_only(self): + assert validate_empty(_ctx(' ')).behavior == SecurityBehavior.ALLOW + + def test_non_empty(self): + assert validate_empty(_ctx('ls')).behavior == SecurityBehavior.PASSTHROUGH + + +# =========================================================================== +# validate_control_characters +# =========================================================================== + +class TestValidateControlCharacters: + def test_null_byte(self): + result = validate_control_characters(_ctx('echo\x00hello')) + assert result.behavior == SecurityBehavior.ASK + + def test_bell(self): + result = validate_control_characters(_ctx('echo\x07hello')) + assert result.behavior == SecurityBehavior.ASK + + def test_clean_command(self): + result = validate_control_characters(_ctx('echo hello')) + assert result.behavior == SecurityBehavior.PASSTHROUGH + + def test_tab_allowed(self): + result = validate_control_characters(_ctx('echo\thello')) + assert result.behavior == SecurityBehavior.PASSTHROUGH + + def test_newline_allowed(self): + result = validate_control_characters(_ctx('echo\nhello')) + assert result.behavior == SecurityBehavior.PASSTHROUGH + + +# =========================================================================== +# validate_incomplete_commands +# =========================================================================== + +class TestValidateIncompleteCommands: + def test_starts_with_tab(self): + result = validate_incomplete_commands(_ctx('\techo hello')) + assert result.behavior == SecurityBehavior.ASK + + def test_starts_with_dash(self): + result = validate_incomplete_commands(_ctx('-rf /')) + assert result.behavior == SecurityBehavior.ASK + + def test_starts_with_operator(self): + result = validate_incomplete_commands(_ctx('&& echo hello')) + assert result.behavior == SecurityBehavior.ASK + result = validate_incomplete_commands(_ctx('; echo hello')) + assert result.behavior == SecurityBehavior.ASK + + def test_normal_command(self): + result = validate_incomplete_commands(_ctx('ls -la')) + assert result.behavior == SecurityBehavior.PASSTHROUGH + + +# =========================================================================== +# validate_git_commit +# =========================================================================== + +class TestValidateGitCommit: + def test_not_git(self): + result = validate_git_commit(_ctx('echo hello')) + assert result.behavior == SecurityBehavior.PASSTHROUGH + + def test_simple_commit(self): + result = validate_git_commit(_ctx("git commit -m 'initial commit'")) + assert result.behavior == SecurityBehavior.ALLOW + + def test_double_quoted_commit(self): + result = validate_git_commit(_ctx('git commit -m "fix bug"')) + assert result.behavior == SecurityBehavior.ALLOW + + def test_commit_with_substitution(self): + result = validate_git_commit(_ctx('git commit -m "$(date)"')) + assert result.behavior == SecurityBehavior.ASK + + def test_commit_with_backtick(self): + result = validate_git_commit(_ctx('git commit -m "`date`"')) + assert result.behavior == SecurityBehavior.ASK + + def test_commit_with_chained_commands(self): + result = validate_git_commit(_ctx("git commit -m 'msg'; rm -rf /")) + # Should passthrough (not early-allow) due to ; in remainder + assert result.behavior != SecurityBehavior.ALLOW + + def test_commit_with_backslash(self): + result = validate_git_commit(_ctx('git commit -m "test\\"msg"')) + assert result.behavior == SecurityBehavior.PASSTHROUGH + + +# =========================================================================== +# validate_jq_command +# =========================================================================== + +class TestValidateJqCommand: + def test_not_jq(self): + assert validate_jq_command(_ctx('echo hi')).behavior == SecurityBehavior.PASSTHROUGH + + def test_jq_system(self): + result = validate_jq_command(_ctx('jq "system(\"rm -rf /\")"')) + assert result.behavior == SecurityBehavior.ASK + + def test_jq_from_file(self): + result = validate_jq_command(_ctx('jq -f evil.jq')) + assert result.behavior == SecurityBehavior.ASK + + def test_jq_slurpfile(self): + result = validate_jq_command(_ctx('jq --slurpfile x data.json')) + assert result.behavior == SecurityBehavior.ASK + + def test_safe_jq(self): + result = validate_jq_command(_ctx('jq ".name" data.json')) + assert result.behavior == SecurityBehavior.PASSTHROUGH + + +# =========================================================================== +# validate_obfuscated_flags +# =========================================================================== + +class TestValidateObfuscatedFlags: + def test_ansi_c_quoting(self): + result = validate_obfuscated_flags(_ctx("find . $'-exec' evil")) + assert result.behavior == SecurityBehavior.ASK + + def test_locale_quoting(self): + result = validate_obfuscated_flags(_ctx('find . $"-exec" evil')) + assert result.behavior == SecurityBehavior.ASK + + def test_echo_safe(self): + result = validate_obfuscated_flags(_ctx("echo $'hello'")) + assert result.behavior == SecurityBehavior.PASSTHROUGH + + def test_empty_quotes_before_dash(self): + result = validate_obfuscated_flags(_ctx("find . '' -exec evil")) + assert result.behavior == SecurityBehavior.ASK + + def test_quoted_flag(self): + result = validate_obfuscated_flags(_ctx('find . "-exec" rm {} ;')) + assert result.behavior == SecurityBehavior.ASK + + def test_normal_command(self): + result = validate_obfuscated_flags(_ctx('ls -la')) + assert result.behavior == SecurityBehavior.PASSTHROUGH + + def test_three_consecutive_quotes(self): + result = validate_obfuscated_flags(_ctx("find . '''exec")) + assert result.behavior == SecurityBehavior.ASK + + +# =========================================================================== +# validate_shell_metacharacters +# =========================================================================== + +class TestValidateShellMetacharacters: + def test_semicolon_in_quotes(self): + result = validate_shell_metacharacters(_ctx('echo "a;b"')) + # unquoted_content (with_double_quotes) has the ; inside + assert result.behavior == SecurityBehavior.PASSTHROUGH or result.behavior == SecurityBehavior.ASK + + def test_find_name_with_pipe(self): + # Single-quoted pipe is stripped entirely from unquoted content → safe + ctx = _ctx("find . -name '|evil'") + result = validate_shell_metacharacters(ctx) + assert result.behavior == SecurityBehavior.PASSTHROUGH + + def test_double_quoted_metachar(self): + # Check that we catch metacharacters in unquoted positions + # The npm version checks the double-quote-retained string for + # quoted metacharacters, but we strip quote chars. So we test + # the actual dangerous case: unquoted semicolon + ctx = _ctx('find . -name evil; rm -rf /') + # This won't be caught by this specific validator (it looks for + # metacharacters INSIDE quoted args, not command separators) + result = validate_shell_metacharacters(ctx) + assert result.behavior == SecurityBehavior.PASSTHROUGH + + def test_clean_command(self): + assert validate_shell_metacharacters(_ctx('ls -la')).behavior == SecurityBehavior.PASSTHROUGH + + +# =========================================================================== +# validate_dangerous_variables +# =========================================================================== + +class TestValidateDangerousVariables: + def test_variable_in_pipe(self): + result = validate_dangerous_variables(_ctx('$CMD | grep x')) + assert result.behavior == SecurityBehavior.ASK + + def test_variable_in_redirect(self): + result = validate_dangerous_variables(_ctx('echo x > $FILE')) + assert result.behavior == SecurityBehavior.ASK + + def test_safe_variable(self): + result = validate_dangerous_variables(_ctx('echo $HOME')) + assert result.behavior == SecurityBehavior.PASSTHROUGH + + +# =========================================================================== +# validate_dangerous_patterns +# =========================================================================== + +class TestValidateDangerousPatterns: + def test_backtick(self): + result = validate_dangerous_patterns(_ctx('echo `date`')) + assert result.behavior == SecurityBehavior.ASK + + def test_dollar_paren(self): + result = validate_dangerous_patterns(_ctx('echo $(date)')) + assert result.behavior == SecurityBehavior.ASK + + def test_dollar_brace(self): + result = validate_dangerous_patterns(_ctx('echo ${PATH}')) + assert result.behavior == SecurityBehavior.ASK + + def test_process_substitution(self): + result = validate_dangerous_patterns(_ctx('diff <(cmd1) <(cmd2)')) + assert result.behavior == SecurityBehavior.ASK + + def test_safe_echo(self): + result = validate_dangerous_patterns(_ctx('echo hello')) + assert result.behavior == SecurityBehavior.PASSTHROUGH + + def test_escaped_backtick(self): + result = validate_dangerous_patterns(_ctx('echo \\`safe\\`')) + assert result.behavior == SecurityBehavior.PASSTHROUGH + + +# =========================================================================== +# validate_redirections +# =========================================================================== + +class TestValidateRedirections: + def test_output_redirect(self): + result = validate_redirections(_ctx('echo x > file.txt')) + assert result.behavior == SecurityBehavior.ASK + + def test_input_redirect(self): + result = validate_redirections(_ctx('cat < /etc/passwd')) + assert result.behavior == SecurityBehavior.ASK + + def test_dev_null_stripped(self): + # >/dev/null is stripped by strip_safe_redirections + result = validate_redirections(_ctx('cmd > /dev/null')) + assert result.behavior == SecurityBehavior.PASSTHROUGH + + def test_no_redirect(self): + result = validate_redirections(_ctx('echo hello')) + assert result.behavior == SecurityBehavior.PASSTHROUGH + + +# =========================================================================== +# validate_newlines +# =========================================================================== + +class TestValidateNewlines: + def test_no_newlines(self): + assert validate_newlines(_ctx('echo hello')).behavior == SecurityBehavior.PASSTHROUGH + + def test_newline_with_command(self): + result = validate_newlines(_ctx('echo hello\nrm -rf /')) + assert result.behavior == SecurityBehavior.ASK + + def test_backslash_continuation(self): + result = validate_newlines(_ctx('cmd \\\n--flag')) + assert result.behavior == SecurityBehavior.PASSTHROUGH + + +# =========================================================================== +# validate_carriage_return +# =========================================================================== + +class TestValidateCarriageReturn: + def test_no_cr(self): + assert validate_carriage_return(_ctx('echo hello')).behavior == SecurityBehavior.PASSTHROUGH + + def test_cr_in_command(self): + result = validate_carriage_return(_ctx('echo hello\rworld')) + assert result.behavior == SecurityBehavior.ASK + assert result.is_misparsing is True + + def test_cr_in_double_quotes_safe(self): + result = validate_carriage_return(_ctx('echo "hello\rworld"')) + assert result.behavior == SecurityBehavior.PASSTHROUGH + + +# =========================================================================== +# validate_ifs_injection +# =========================================================================== + +class TestValidateIFSInjection: + def test_ifs_variable(self): + result = validate_ifs_injection(_ctx('echo$IFS/etc/passwd')) + assert result.behavior == SecurityBehavior.ASK + + def test_ifs_expansion(self): + result = validate_ifs_injection(_ctx('echo ${IFS:0:1}')) + assert result.behavior == SecurityBehavior.ASK + + def test_clean(self): + assert validate_ifs_injection(_ctx('echo hello')).behavior == SecurityBehavior.PASSTHROUGH + + +# =========================================================================== +# validate_proc_environ_access +# =========================================================================== + +class TestValidateProcEnvironAccess: + def test_proc_environ(self): + result = validate_proc_environ_access(_ctx('cat /proc/self/environ')) + assert result.behavior == SecurityBehavior.ASK + + def test_proc_pid_environ(self): + result = validate_proc_environ_access(_ctx('cat /proc/1/environ')) + assert result.behavior == SecurityBehavior.ASK + + def test_clean(self): + assert validate_proc_environ_access(_ctx('cat /etc/hosts')).behavior == SecurityBehavior.PASSTHROUGH + + +# =========================================================================== +# validate_backslash_escaped_whitespace +# =========================================================================== + +class TestValidateBackslashEscapedWhitespace: + def test_escaped_space(self): + result = validate_backslash_escaped_whitespace(_ctx('echo\\ hello')) + assert result.behavior == SecurityBehavior.ASK + + def test_escaped_tab(self): + result = validate_backslash_escaped_whitespace(_ctx('echo\\\thello')) + assert result.behavior == SecurityBehavior.ASK + + def test_clean(self): + assert validate_backslash_escaped_whitespace( + _ctx('echo hello') + ).behavior == SecurityBehavior.PASSTHROUGH + + +# =========================================================================== +# validate_backslash_escaped_operators +# =========================================================================== + +class TestValidateBackslashEscapedOperators: + def test_escaped_semicolon(self): + result = validate_backslash_escaped_operators(_ctx('cat safe.txt \\; echo /etc/passwd')) + assert result.behavior == SecurityBehavior.ASK + + def test_escaped_pipe(self): + result = validate_backslash_escaped_operators(_ctx('cmd \\| evil')) + assert result.behavior == SecurityBehavior.ASK + + def test_clean(self): + assert validate_backslash_escaped_operators( + _ctx('ls -la') + ).behavior == SecurityBehavior.PASSTHROUGH + + def test_inside_quotes_safe(self): + result = validate_backslash_escaped_operators(_ctx("echo '\\;'")) + assert result.behavior == SecurityBehavior.PASSTHROUGH + + +# =========================================================================== +# validate_brace_expansion +# =========================================================================== + +class TestValidateBraceExpansion: + def test_comma_expansion(self): + result = validate_brace_expansion(_ctx('echo {a,b,c}')) + assert result.behavior == SecurityBehavior.ASK + + def test_sequence_expansion(self): + result = validate_brace_expansion(_ctx('echo {1..5}')) + assert result.behavior == SecurityBehavior.ASK + + def test_no_expansion(self): + result = validate_brace_expansion(_ctx('echo {hello}')) + assert result.behavior == SecurityBehavior.PASSTHROUGH + + def test_escaped_brace(self): + result = validate_brace_expansion(_ctx('echo \\{a,b\\}')) + assert result.behavior == SecurityBehavior.PASSTHROUGH + + def test_excess_closing_braces(self): + result = validate_brace_expansion(_ctx("git diff {@'{'0},--output=/tmp/pwned}")) + assert result.behavior == SecurityBehavior.ASK + + +# =========================================================================== +# validate_unicode_whitespace +# =========================================================================== + +class TestValidateUnicodeWhitespace: + def test_nbsp(self): + result = validate_unicode_whitespace(_ctx('echo\u00a0hello')) + assert result.behavior == SecurityBehavior.ASK + + def test_em_space(self): + result = validate_unicode_whitespace(_ctx('echo\u2003hello')) + assert result.behavior == SecurityBehavior.ASK + + def test_clean(self): + assert validate_unicode_whitespace(_ctx('echo hello')).behavior == SecurityBehavior.PASSTHROUGH + + +# =========================================================================== +# validate_mid_word_hash +# =========================================================================== + +class TestValidateMidWordHash: + def test_mid_word_hash(self): + result = validate_mid_word_hash(_ctx('echotest#comment')) + assert result.behavior == SecurityBehavior.ASK + + def test_word_start_hash(self): + result = validate_mid_word_hash(_ctx('echo # comment')) + assert result.behavior == SecurityBehavior.PASSTHROUGH + + def test_dollar_brace_hash_safe(self): + # ${#var} is bash string length, should be safe + result = validate_mid_word_hash(_ctx('echo ${#var}')) + assert result.behavior == SecurityBehavior.PASSTHROUGH + + +# =========================================================================== +# validate_comment_quote_desync +# =========================================================================== + +class TestValidateCommentQuoteDesync: + def test_quote_in_comment(self): + result = validate_comment_quote_desync(_ctx("echo hello # it's a comment")) + assert result.behavior == SecurityBehavior.ASK + + def test_clean_comment(self): + result = validate_comment_quote_desync(_ctx('echo hello # clean comment')) + assert result.behavior == SecurityBehavior.PASSTHROUGH + + def test_no_comment(self): + assert validate_comment_quote_desync(_ctx('echo hello')).behavior == SecurityBehavior.PASSTHROUGH + + +# =========================================================================== +# validate_quoted_newline +# =========================================================================== + +class TestValidateQuotedNewline: + def test_quoted_newline_with_hash(self): + result = validate_quoted_newline(_ctx("echo 'hello\n# dangerous line'")) + assert result.behavior == SecurityBehavior.ASK + + def test_no_newline(self): + assert validate_quoted_newline(_ctx('echo hello')).behavior == SecurityBehavior.PASSTHROUGH + + def test_newline_without_hash(self): + assert validate_quoted_newline(_ctx("echo 'hello\nworld'")).behavior == SecurityBehavior.PASSTHROUGH + + +# =========================================================================== +# validate_zsh_dangerous_commands +# =========================================================================== + +class TestValidateZshDangerousCommands: + def test_zmodload(self): + result = validate_zsh_dangerous_commands(_ctx('zmodload zsh/system')) + assert result.behavior == SecurityBehavior.ASK + + def test_zpty(self): + result = validate_zsh_dangerous_commands(_ctx('zpty cmd echo')) + assert result.behavior == SecurityBehavior.ASK + + def test_emulate(self): + result = validate_zsh_dangerous_commands(_ctx('emulate -c evil')) + assert result.behavior == SecurityBehavior.ASK + + def test_fc_e(self): + result = validate_zsh_dangerous_commands(_ctx('fc -e vim')) + assert result.behavior == SecurityBehavior.ASK + + def test_normal_command(self): + assert validate_zsh_dangerous_commands(_ctx('ls -la')).behavior == SecurityBehavior.PASSTHROUGH + + def test_env_var_prefix(self): + result = validate_zsh_dangerous_commands(_ctx('FOO=bar zmodload zsh/system')) + assert result.behavior == SecurityBehavior.ASK + + def test_precommand_modifier(self): + result = validate_zsh_dangerous_commands(_ctx('command zmodload zsh/system')) + assert result.behavior == SecurityBehavior.ASK + + +# =========================================================================== +# bash_command_is_safe (integration) +# =========================================================================== + +class TestBashCommandIsSafe: + def test_empty_command(self): + result = bash_command_is_safe('') + assert result.behavior == SecurityBehavior.PASSTHROUGH + + def test_simple_ls(self): + result = bash_command_is_safe('ls -la') + assert result.behavior == SecurityBehavior.PASSTHROUGH + + def test_simple_echo(self): + result = bash_command_is_safe('echo hello world') + assert result.behavior == SecurityBehavior.PASSTHROUGH + + def test_command_substitution_blocked(self): + result = bash_command_is_safe('echo $(cat /etc/passwd)') + assert result.behavior == SecurityBehavior.ASK + + def test_backtick_blocked(self): + result = bash_command_is_safe('echo `date`') + assert result.behavior == SecurityBehavior.ASK + + def test_redirect_blocked(self): + result = bash_command_is_safe('echo evil > /etc/profile') + assert result.behavior == SecurityBehavior.ASK + + def test_null_byte_blocked(self): + result = bash_command_is_safe('echo\x00rm') + assert result.behavior == SecurityBehavior.ASK + + def test_ifs_blocked(self): + result = bash_command_is_safe('echo$IFS/etc/passwd') + assert result.behavior == SecurityBehavior.ASK + + def test_proc_environ_blocked(self): + result = bash_command_is_safe('cat /proc/self/environ') + assert result.behavior == SecurityBehavior.ASK + + def test_git_commit_allowed(self): + result = bash_command_is_safe("git commit -m 'fix bug'") + assert result.behavior == SecurityBehavior.PASSTHROUGH # early-allow → passthrough + + def test_zmodload_blocked(self): + result = bash_command_is_safe('zmodload zsh/system') + assert result.behavior == SecurityBehavior.ASK + + def test_brace_expansion_blocked(self): + result = bash_command_is_safe('echo {a,b,c}') + assert result.behavior == SecurityBehavior.ASK + + def test_dev_null_redirect_ok(self): + result = bash_command_is_safe('cmd > /dev/null 2>&1') + assert result.behavior == SecurityBehavior.PASSTHROUGH + + def test_cr_injection(self): + result = bash_command_is_safe('TZ=UTC\recho curl evil.com') + assert result.behavior == SecurityBehavior.ASK + + +# =========================================================================== +# get_destructive_command_warning +# =========================================================================== + +class TestGetDestructiveCommandWarning: + def test_git_reset_hard(self): + assert get_destructive_command_warning('git reset --hard') is not None + + def test_rm_rf(self): + assert get_destructive_command_warning('rm -rf /') is not None + + def test_git_push_force(self): + assert get_destructive_command_warning('git push origin main --force') is not None + + def test_git_clean_f(self): + assert get_destructive_command_warning('git clean -fd') is not None + + def test_kubectl_delete(self): + assert get_destructive_command_warning('kubectl delete pod mypod') is not None + + def test_safe_command(self): + assert get_destructive_command_warning('echo hello') is None + + def test_git_push_no_force(self): + assert get_destructive_command_warning('git push origin main') is None + + def test_drop_table(self): + assert get_destructive_command_warning('DROP TABLE users;') is not None + + def test_terraform_destroy(self): + assert get_destructive_command_warning('terraform destroy') is not None + + def test_git_stash_drop(self): + assert get_destructive_command_warning('git stash drop') is not None + + +# =========================================================================== +# interpret_command_result +# =========================================================================== + +class TestInterpretCommandResult: + def test_success(self): + is_error, msg = interpret_command_result('echo hello', 0, 'hello', '') + assert is_error is False + + def test_failure(self): + is_error, msg = interpret_command_result('unknown_cmd', 127, '', 'not found') + assert is_error is True + + def test_grep_no_match(self): + is_error, msg = interpret_command_result('grep pattern file', 1, '', '') + assert is_error is False + assert msg == 'No matches found' + + def test_grep_error(self): + is_error, msg = interpret_command_result('grep pattern file', 2, '', 'error') + assert is_error is True + + def test_diff_files_differ(self): + is_error, msg = interpret_command_result('diff a b', 1, 'output', '') + assert is_error is False + assert msg == 'Files differ' + + def test_find_partial(self): + is_error, msg = interpret_command_result('find / -name x', 1, '', '') + assert is_error is False + + +# =========================================================================== +# is_command_read_only +# =========================================================================== + +class TestIsCommandReadOnly: + def test_ls(self): + assert is_command_read_only('ls -la') is True + + def test_cat(self): + assert is_command_read_only('cat file.txt') is True + + def test_grep(self): + assert is_command_read_only('grep -r pattern .') is True + + def test_git_status(self): + assert is_command_read_only('git status') is True + + def test_git_log(self): + assert is_command_read_only('git log --oneline') is True + + def test_git_push(self): + assert is_command_read_only('git push') is False + + def test_rm(self): + assert is_command_read_only('rm file.txt') is False + + def test_sed_read_only(self): + assert is_command_read_only("sed -n '1,5p' file") is True + + def test_sed_in_place(self): + assert is_command_read_only("sed -i 's/old/new/' file") is False + + def test_find_safe(self): + assert is_command_read_only('find . -name "*.py"') is True + + def test_find_exec(self): + assert is_command_read_only('find . -exec rm {} ;') is False + + def test_find_delete(self): + assert is_command_read_only('find . -name "*.tmp" -delete') is False + + def test_echo(self): + assert is_command_read_only('echo hello') is True + + def test_python_version(self): + assert is_command_read_only('python --version') is True + + def test_unknown_command(self): + assert is_command_read_only('some_random_command') is False + + +# =========================================================================== +# check_shell_security (integration) +# =========================================================================== + +class TestCheckShellSecurity: + def test_shell_disabled(self): + allowed, msg = check_shell_security('ls', allow_shell=False) + assert allowed is False + assert 'disabled' in msg.lower() + + def test_safe_command_allowed(self): + allowed, msg = check_shell_security('ls -la') + assert allowed is True + + def test_destructive_blocked(self): + allowed, msg = check_shell_security('rm -rf /', allow_destructive=False) + assert allowed is False + assert 'destructive' in msg.lower() + + def test_destructive_allowed_when_enabled(self): + allowed, msg = check_shell_security('rm -rf /tmp/test', allow_destructive=True) + # rm -rf still triggers destructive check, but allow_destructive=True skips it + # However rm -rf may also trigger the security check for force-remove + # Let's check: the main security check should pass (no injection) + # and destructive should be allowed + assert allowed is True + + def test_injection_blocked(self): + allowed, msg = check_shell_security('echo `evil`') + assert allowed is False + assert 'backtick' in msg.lower() or 'security' in msg.lower() + + def test_misparsing_always_blocked(self): + allowed, msg = check_shell_security('echo\x00rm') + assert allowed is False + + def test_safe_git_commit(self): + allowed, msg = check_shell_security("git commit -m 'fix'") + assert allowed is True diff --git a/tests/test_compact.py b/tests/test_compact.py new file mode 100644 index 0000000..81ac92c --- /dev/null +++ b/tests/test_compact.py @@ -0,0 +1,405 @@ +"""Tests for src/compact.py – the conversation compaction service.""" + +from __future__ import annotations + +import tempfile +import unittest +from dataclasses import replace +from pathlib import Path +from unittest.mock import MagicMock + +from src.agent_runtime import LocalCodingAgent +from src.agent_session import AgentMessage, AgentSessionState +from src.agent_types import AgentRuntimeConfig, ModelConfig +from src.compact import ( + AUTOCOMPACT_BUFFER_TOKENS, + ERROR_INCOMPLETE_RESPONSE, + ERROR_NOT_ENOUGH_MESSAGES, + CompactionResult, + compact_conversation, + format_compact_summary, + get_compact_prompt, + get_compact_user_summary_message, +) + + +class TestGetCompactPrompt(unittest.TestCase): + """Tests for the compact prompt builder.""" + + def test_basic_prompt_contains_all_nine_sections(self) -> None: + prompt = get_compact_prompt() + for section in [ + '1. Primary Request and Intent', + '2. Key Technical Concepts', + '3. Files and Code Sections', + '4. Errors and fixes', + '5. Problem Solving', + '6. All user messages', + '7. Pending Tasks', + '8. Current Work', + '9. Optional Next Step', + ]: + self.assertIn(section, prompt, f'Missing section: {section}') + + def test_no_tools_preamble_present(self) -> None: + prompt = get_compact_prompt() + self.assertIn('CRITICAL: Respond with TEXT ONLY', prompt) + self.assertIn('Do NOT call any tools', prompt) + + def test_no_tools_trailer_present(self) -> None: + prompt = get_compact_prompt() + self.assertIn('REMINDER: Do NOT call any tools', prompt) + + def test_analysis_and_summary_example_tags_present(self) -> None: + prompt = get_compact_prompt() + self.assertIn('', prompt) + self.assertIn('', prompt) + self.assertIn('', prompt) + self.assertIn('', prompt) + + def test_custom_instructions_appended(self) -> None: + prompt = get_compact_prompt('Focus on database changes.') + self.assertIn('Additional Instructions:', prompt) + self.assertIn('Focus on database changes.', prompt) + + def test_empty_custom_instructions_ignored(self) -> None: + prompt_no_custom = get_compact_prompt() + prompt_empty = get_compact_prompt('') + prompt_whitespace = get_compact_prompt(' ') + self.assertEqual(prompt_no_custom, prompt_empty) + self.assertEqual(prompt_no_custom, prompt_whitespace) + + def test_none_custom_instructions_ignored(self) -> None: + prompt_none = get_compact_prompt(None) + prompt_no_arg = get_compact_prompt() + self.assertEqual(prompt_none, prompt_no_arg) + + +class TestFormatCompactSummary(unittest.TestCase): + """Tests for the summary formatting / XML stripping.""" + + def test_strips_analysis_block(self) -> None: + raw = 'thinking here\n\nresult' + formatted = format_compact_summary(raw) + self.assertNotIn('', formatted) + self.assertNotIn('thinking here', formatted) + self.assertIn('result', formatted) + + def test_unwraps_summary_tags(self) -> None: + raw = 'The main points.\n1. First' + formatted = format_compact_summary(raw) + self.assertNotIn('', formatted) + self.assertNotIn('', formatted) + self.assertIn('Summary:', formatted) + self.assertIn('The main points.', formatted) + + def test_handles_no_xml_tags(self) -> None: + raw = 'Plain text summary without any tags.' + formatted = format_compact_summary(raw) + self.assertEqual(formatted, raw) + + def test_collapses_excess_blank_lines(self) -> None: + raw = 'x\n\n\n\ny' + formatted = format_compact_summary(raw) + self.assertNotIn('\n\n\n', formatted) + + def test_multiline_analysis_stripped(self) -> None: + raw = ( + '\nLine 1\nLine 2\nLine 3\n\n' + 'Final summary' + ) + formatted = format_compact_summary(raw) + self.assertNotIn('Line 1', formatted) + self.assertIn('Final summary', formatted) + + +class TestGetCompactUserSummaryMessage(unittest.TestCase): + """Tests for the post-compact user message builder.""" + + def test_basic_message_structure(self) -> None: + msg = get_compact_user_summary_message('overview') + self.assertIn('continued from a previous conversation', msg) + self.assertIn('overview', msg) + + def test_transcript_path_appended(self) -> None: + msg = get_compact_user_summary_message( + 'ok', + transcript_path='/tmp/transcript.json', + ) + self.assertIn('/tmp/transcript.json', msg) + + def test_suppress_follow_up(self) -> None: + msg = get_compact_user_summary_message( + 'ok', + suppress_follow_up=True, + ) + self.assertIn('without asking the user any further questions', msg) + + def test_no_suppress_follow_up_default(self) -> None: + msg = get_compact_user_summary_message('ok') + self.assertNotIn('without asking the user any further questions', msg) + + +class TestCompactConversation(unittest.TestCase): + """Tests for the core compact_conversation() function.""" + + def _make_agent(self, tmp_dir: str) -> LocalCodingAgent: + """Create a minimal agent with a session loaded.""" + agent = LocalCodingAgent( + model_config=ModelConfig(model='test-model'), + runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)), + ) + return agent + + def _set_session( + self, agent: LocalCodingAgent, messages: list[AgentMessage] + ) -> None: + session = AgentSessionState( + system_prompt_parts=('You are a helpful assistant.',), + messages=messages, + ) + agent.last_session = session + + def _make_messages(self, count: int) -> list[AgentMessage]: + msgs: list[AgentMessage] = [] + for i in range(count): + role = 'user' if i % 2 == 0 else 'assistant' + msgs.append( + AgentMessage( + role=role, + content=f'Message {i} content. ' * 10, + message_id=f'msg_{i}', + ) + ) + return msgs + + def test_no_session_returns_error(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + agent = self._make_agent(tmp_dir) + agent.last_session = None + result = compact_conversation(agent) + self.assertIsNotNone(result.error) + self.assertIn('Not enough', result.error) + + def test_empty_session_returns_error(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + agent = self._make_agent(tmp_dir) + self._set_session(agent, []) + result = compact_conversation(agent) + self.assertIsNotNone(result.error) + + def test_too_few_messages_returns_error(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + agent = self._make_agent(tmp_dir) + # With only 2 messages and preserve_count=4, nothing to compact + self._set_session(agent, self._make_messages(2)) + result = compact_conversation(agent) + self.assertIsNotNone(result.error) + + def test_successful_compaction(self) -> None: + """Simulate a successful model call and verify session is compacted.""" + with tempfile.TemporaryDirectory() as tmp_dir: + agent = self._make_agent(tmp_dir) + msgs = self._make_messages(10) + self._set_session(agent, msgs) + + # Mock the client's complete method + from src.openai_compat import AssistantTurn + from src.agent_types import UsageStats + + mock_turn = AssistantTurn( + content=( + 'Thinking through the conversation...\n' + '\n1. Primary Request and Intent:\n' + ' User wanted to test compaction.\n' + '2. Key Technical Concepts:\n - Testing\n' + '3. Files and Code Sections:\n - test.py\n' + '4. Errors and fixes:\n - None\n' + '5. Problem Solving:\n Basic testing.\n' + '6. All user messages:\n - "test compaction"\n' + '7. Pending Tasks:\n - None\n' + '8. Current Work:\n Testing compaction.\n' + '9. Optional Next Step:\n Verify it works.\n' + '' + ), + tool_calls=(), + finish_reason='stop', + raw_message={}, + usage=UsageStats(), + ) + agent.client = MagicMock() + agent.client.complete.return_value = mock_turn + + result = compact_conversation(agent) + + self.assertIsNone(result.error) + self.assertGreater(result.pre_compact_token_count, 0) + # Session should have fewer messages than original 10 + self.assertLess( + len(agent.last_session.messages), 10, + 'Session should have fewer messages after compaction', + ) + # Should contain a compact_boundary message + boundary_msgs = [ + m for m in agent.last_session.messages + if m.metadata.get('kind') == 'compact_boundary' + ] + self.assertEqual(len(boundary_msgs), 1) + # Should contain a compact_summary message + summary_msgs = [ + m for m in agent.last_session.messages + if m.metadata.get('kind') == 'compact_summary' + ] + self.assertEqual(len(summary_msgs), 1) + # Summary should not contain block + self.assertNotIn('', result.summary_text) + # Summary should contain the actual summary content + self.assertIn('User wanted to test compaction', result.summary_text) + + def test_api_error_returns_compaction_error(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + agent = self._make_agent(tmp_dir) + self._set_session(agent, self._make_messages(10)) + + agent.client = MagicMock() + agent.client.complete.side_effect = RuntimeError('API down') + + result = compact_conversation(agent) + self.assertIsNotNone(result.error) + self.assertIn('API down', result.error) + + def test_empty_model_response_returns_error(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + agent = self._make_agent(tmp_dir) + self._set_session(agent, self._make_messages(10)) + + from src.openai_compat import AssistantTurn + from src.agent_types import UsageStats + + agent.client = MagicMock() + agent.client.complete.return_value = AssistantTurn( + content='', + tool_calls=(), + finish_reason='stop', + raw_message={}, + usage=UsageStats(), + ) + + result = compact_conversation(agent) + self.assertIsNotNone(result.error) + + def test_preserves_tail_messages(self) -> None: + """The most recent messages should survive compaction.""" + with tempfile.TemporaryDirectory() as tmp_dir: + agent = self._make_agent(tmp_dir) + msgs = self._make_messages(12) + self._set_session(agent, msgs) + + from src.openai_compat import AssistantTurn + from src.agent_types import UsageStats + + agent.client = MagicMock() + agent.client.complete.return_value = AssistantTurn( + content='Summarised.', + tool_calls=(), + finish_reason='stop', + raw_message={}, + usage=UsageStats(), + ) + + result = compact_conversation(agent) + + self.assertIsNone(result.error) + # The last 4 messages (default preserve_count) should still be present + session_contents = [m.content for m in agent.last_session.messages] + for original_msg in msgs[-4:]: + self.assertIn( + original_msg.content, + session_contents, + f'Tail message "{original_msg.message_id}" should be preserved', + ) + + def test_custom_instructions_passed_to_prompt(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + agent = self._make_agent(tmp_dir) + self._set_session(agent, self._make_messages(10)) + + from src.openai_compat import AssistantTurn + from src.agent_types import UsageStats + + agent.client = MagicMock() + agent.client.complete.return_value = AssistantTurn( + content='Custom summary.', + tool_calls=(), + finish_reason='stop', + raw_message={}, + usage=UsageStats(), + ) + + compact_conversation(agent, custom_instructions='Focus on CSS.') + + # Check that the API was called with custom instructions in the prompt + call_args = agent.client.complete.call_args + messages = call_args[0][0] + last_user_msg = messages[-1]['content'] + self.assertIn('Focus on CSS.', last_user_msg) + self.assertIn('Additional Instructions:', last_user_msg) + + +class TestCompactSlashCommand(unittest.TestCase): + """Test the /compact slash command handler end-to-end.""" + + def test_slash_compact_returns_success_message(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + agent = LocalCodingAgent( + model_config=ModelConfig(model='test-model'), + runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)), + ) + # Set up a session with enough messages + session = AgentSessionState( + system_prompt_parts=('You are a helper.',), + messages=[ + AgentMessage(role='user', content='Hello', message_id=f'u{i}') + if i % 2 == 0 + else AgentMessage(role='assistant', content='Hi', message_id=f'a{i}') + for i in range(10) + ], + ) + agent.last_session = session + + from src.openai_compat import AssistantTurn + from src.agent_types import UsageStats + + agent.client = MagicMock() + agent.client.complete.return_value = AssistantTurn( + content='Session summarised.', + tool_calls=(), + finish_reason='stop', + raw_message={}, + usage=UsageStats(), + ) + + result = agent.run('/compact') + + self.assertIn('Conversation compacted', result.final_output) + self.assertIn('Tokens before', result.final_output) + + def test_slash_compact_no_session_returns_error(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + agent = LocalCodingAgent( + model_config=ModelConfig(model='test-model'), + runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)), + ) + result = agent.run('/compact') + self.assertIn('failed', result.final_output.lower()) + + +class TestConstants(unittest.TestCase): + """Verify key constants match the npm reference.""" + + def test_autocompact_buffer_tokens(self) -> None: + self.assertEqual(AUTOCOMPACT_BUFFER_TOKENS, 13_000) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_cost_exit_diff.py b/tests/test_cost_exit_diff.py new file mode 100644 index 0000000..5a57cde --- /dev/null +++ b/tests/test_cost_exit_diff.py @@ -0,0 +1,189 @@ +"""Tests for /cost, /exit, and /diff slash commands.""" + +from __future__ import annotations + +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +from src.agent_runtime import LocalCodingAgent +from src.agent_slash_commands import preprocess_slash_command +from src.agent_types import AgentRuntimeConfig, ModelConfig, UsageStats + + +class TestCostCommand(unittest.TestCase): + """Tests for the /cost slash command.""" + + def test_cost_shows_zero_initially(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + agent = LocalCodingAgent( + model_config=ModelConfig(model='test-model'), + runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)), + ) + result = agent.run('/cost') + self.assertIn('Total cost:', result.final_output) + self.assertIn('$0.0000', result.final_output) + self.assertIn('Total input tokens:', result.final_output) + self.assertIn('Total output tokens:', result.final_output) + self.assertIn('Total tokens:', result.final_output) + + def test_cost_shows_accumulated_usage(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + agent = LocalCodingAgent( + model_config=ModelConfig(model='test-model'), + runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)), + ) + # Simulate accumulated usage + agent.cumulative_usage = UsageStats( + input_tokens=1000, + output_tokens=500, + cache_read_input_tokens=200, + ) + agent.cumulative_cost_usd = 0.05 + result = agent.run('/cost') + self.assertIn('$0.05', result.final_output) + self.assertIn('1,000', result.final_output) + self.assertIn('500', result.final_output) + self.assertIn('Cache read tokens:', result.final_output) + + def test_cost_hides_zero_cache_and_reasoning(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + agent = LocalCodingAgent( + model_config=ModelConfig(model='test-model'), + runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)), + ) + agent.cumulative_usage = UsageStats( + input_tokens=100, + output_tokens=50, + ) + result = agent.run('/cost') + # Should NOT show cache/reasoning lines when they're zero + self.assertNotIn('Cache read tokens:', result.final_output) + self.assertNotIn('Cache creation tokens:', result.final_output) + self.assertNotIn('Reasoning tokens:', result.final_output) + + def test_cost_small_amounts_show_four_decimals(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + agent = LocalCodingAgent( + model_config=ModelConfig(model='test-model'), + runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)), + ) + agent.cumulative_cost_usd = 0.0023 + result = agent.run('/cost') + self.assertIn('$0.0023', result.final_output) + + +class TestExitCommand(unittest.TestCase): + """Tests for the /exit and /quit slash commands.""" + + def test_exit_triggers_system_exit(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + agent = LocalCodingAgent( + model_config=ModelConfig(model='test-model'), + runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)), + ) + with self.assertRaises(SystemExit) as cm: + agent.run('/exit') + self.assertEqual(cm.exception.code, 0) + + def test_quit_triggers_system_exit(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + agent = LocalCodingAgent( + model_config=ModelConfig(model='test-model'), + runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)), + ) + with self.assertRaises(SystemExit): + agent.run('/quit') + + +class TestDiffCommand(unittest.TestCase): + """Tests for the /diff slash command.""" + + def test_diff_in_non_git_directory(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + agent = LocalCodingAgent( + model_config=ModelConfig(model='test-model'), + runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)), + ) + result = agent.run('/diff') + # In a non-git dir, git diff returns an error + output = result.final_output.lower() + self.assertTrue( + 'no uncommitted' in output + or 'not a git' in output + or 'error' in output, + f'Expected a non-git or no-changes message, got: {result.final_output}', + ) + + def test_diff_in_git_repo_with_no_changes(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + subprocess.run( + ['git', 'init'], cwd=str(workspace), + capture_output=True, check=True, + ) + subprocess.run( + ['git', 'config', 'user.email', 'test@test.com'], + cwd=str(workspace), capture_output=True, + ) + subprocess.run( + ['git', 'config', 'user.name', 'Test'], + cwd=str(workspace), capture_output=True, + ) + (workspace / 'hello.txt').write_text('hello\n') + subprocess.run( + ['git', 'add', '.'], cwd=str(workspace), + capture_output=True, check=True, + ) + subprocess.run( + ['git', 'commit', '-m', 'init'], cwd=str(workspace), + capture_output=True, check=True, + ) + + agent = LocalCodingAgent( + model_config=ModelConfig(model='test-model'), + runtime_config=AgentRuntimeConfig(cwd=workspace), + ) + result = agent.run('/diff') + self.assertIn('No uncommitted changes', result.final_output) + + def test_diff_shows_changes(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + subprocess.run( + ['git', 'init'], cwd=str(workspace), + capture_output=True, check=True, + ) + subprocess.run( + ['git', 'config', 'user.email', 'test@test.com'], + cwd=str(workspace), capture_output=True, + ) + subprocess.run( + ['git', 'config', 'user.name', 'Test'], + cwd=str(workspace), capture_output=True, + ) + (workspace / 'hello.txt').write_text('hello\n') + subprocess.run( + ['git', 'add', '.'], cwd=str(workspace), + capture_output=True, check=True, + ) + subprocess.run( + ['git', 'commit', '-m', 'init'], cwd=str(workspace), + capture_output=True, check=True, + ) + # Make a change + (workspace / 'hello.txt').write_text('hello world\n') + + agent = LocalCodingAgent( + model_config=ModelConfig(model='test-model'), + runtime_config=AgentRuntimeConfig(cwd=workspace), + ) + result = agent.run('/diff') + self.assertIn('hello', result.final_output) + self.assertIn('diff', result.final_output.lower()) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_new_slash_commands.py b/tests/test_new_slash_commands.py new file mode 100644 index 0000000..c3eefc7 --- /dev/null +++ b/tests/test_new_slash_commands.py @@ -0,0 +1,299 @@ +"""Tests for /files, /copy, /export, /stats, /tag, /rename, /branch, /effort, /doctor.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +from src.agent_runtime import LocalCodingAgent +from src.agent_session import AgentMessage, AgentSessionState +from src.agent_types import AgentRuntimeConfig, ModelConfig, UsageStats + + +def _make_agent(tmp_dir: str) -> LocalCodingAgent: + return LocalCodingAgent( + model_config=ModelConfig(model='test-model'), + runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)), + ) + + +def _set_session(agent: LocalCodingAgent, messages: list[AgentMessage]) -> None: + session = AgentSessionState( + system_prompt_parts=('You are a helper.',), + messages=messages, + ) + agent.last_session = session + + +class TestFilesCommand(unittest.TestCase): + def test_no_session(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + result = agent.run('/files') + self.assertIn('No active session', result.final_output) + + def test_no_files_in_context(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + _set_session(agent, [ + AgentMessage(role='user', content='Hello'), + AgentMessage(role='assistant', content='Hi'), + ]) + result = agent.run('/files') + self.assertIn('No files loaded', result.final_output) + + def test_files_from_tool_calls(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + _set_session(agent, [ + AgentMessage(role='user', content='Read main.py'), + AgentMessage( + role='assistant', content='', + tool_calls=( + {'id': 'tc1', 'type': 'function', 'function': { + 'name': 'Read', + 'arguments': json.dumps({'file_path': '/home/user/project/main.py'}), + }}, + ), + ), + AgentMessage( + role='tool', content='print("hello")', + name='Read', + tool_call_id='tc1', + metadata={'path': '/home/user/project/main.py'}, + ), + ]) + result = agent.run('/files') + self.assertIn('Files in context', result.final_output) + self.assertIn('main.py', result.final_output) + + +class TestCopyCommand(unittest.TestCase): + def test_no_session(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + result = agent.run('/copy') + self.assertIn('No active session', result.final_output) + + def test_no_assistant_messages(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + _set_session(agent, [ + AgentMessage(role='user', content='Hello'), + ]) + result = agent.run('/copy') + self.assertIn('No assistant responses', result.final_output) + + def test_copies_latest_response(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + _set_session(agent, [ + AgentMessage(role='user', content='Hello'), + AgentMessage(role='assistant', content='First response.'), + AgentMessage(role='user', content='More'), + AgentMessage(role='assistant', content='Second response with details.'), + ]) + result = agent.run('/copy') + self.assertIn('Copied', result.final_output) + self.assertIn('response.md', result.final_output) + # Verify the file was written + tmp_file = Path(tempfile.gettempdir()) / 'claw-code' / 'response.md' + self.assertTrue(tmp_file.exists()) + content = tmp_file.read_text() + self.assertEqual(content, 'Second response with details.') + + def test_copies_nth_response(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + _set_session(agent, [ + AgentMessage(role='user', content='Hello'), + AgentMessage(role='assistant', content='First.'), + AgentMessage(role='user', content='More'), + AgentMessage(role='assistant', content='Second.'), + ]) + result = agent.run('/copy 1') + tmp_file = Path(tempfile.gettempdir()) / 'claw-code' / 'response.md' + content = tmp_file.read_text() + self.assertEqual(content, 'First.') + + +class TestExportCommand(unittest.TestCase): + def test_no_session(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + result = agent.run('/export') + self.assertIn('No active session', result.final_output) + + def test_exports_with_auto_filename(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + _set_session(agent, [ + AgentMessage(role='user', content='Hello'), + AgentMessage(role='assistant', content='Hi there'), + ]) + result = agent.run('/export') + self.assertIn('Exported 2 messages', result.final_output) + self.assertIn('.txt', result.final_output) + + def test_exports_with_custom_filename(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + _set_session(agent, [ + AgentMessage(role='user', content='Hello'), + AgentMessage(role='assistant', content='Hi'), + ]) + result = agent.run('/export my_chat') + self.assertIn('my_chat.txt', result.final_output) + out_file = Path(tmp) / 'my_chat.txt' + self.assertTrue(out_file.exists()) + content = out_file.read_text() + self.assertIn('Hello', content) + self.assertIn('Hi', content) + + +class TestStatsCommand(unittest.TestCase): + def test_shows_statistics(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + _set_session(agent, [ + AgentMessage(role='user', content='Hello'), + AgentMessage(role='assistant', content='Hi'), + AgentMessage(role='user', content='Question'), + ]) + agent.cumulative_usage = UsageStats(input_tokens=500, output_tokens=200) + result = agent.run('/stats') + self.assertIn('Session Statistics', result.final_output) + self.assertIn('3 total', result.final_output) + self.assertIn('2 user', result.final_output) + self.assertIn('1 assistant', result.final_output) + self.assertIn('500', result.final_output) + self.assertIn('200', result.final_output) + + +class TestTagCommand(unittest.TestCase): + def test_no_tags_initially(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + result = agent.run('/tag') + self.assertIn('No tags set', result.final_output) + + def test_add_tag(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + result = agent.run('/tag important') + self.assertIn('Added tag: important', result.final_output) + + def test_toggle_tag(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + agent.run('/tag my-tag') + result = agent.run('/tag my-tag') + self.assertIn('Removed tag: my-tag', result.final_output) + + def test_list_tags(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + agent.run('/tag alpha') + agent.run('/tag beta') + result = agent.run('/tag') + self.assertIn('alpha', result.final_output) + self.assertIn('beta', result.final_output) + + +class TestRenameCommand(unittest.TestCase): + def test_no_name(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + result = agent.run('/rename') + self.assertIn('Usage', result.final_output) + + def test_rename_session(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + result = agent.run('/rename My Cool Session') + self.assertIn('renamed to: My Cool Session', result.final_output) + + +class TestBranchCommand(unittest.TestCase): + def test_no_session(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + result = agent.run('/branch') + self.assertIn('No active session', result.final_output) + + def test_branch_with_session(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + _set_session(agent, [ + AgentMessage(role='user', content='Hello'), + AgentMessage(role='assistant', content='Hi'), + ]) + result = agent.run('/branch my-feature') + self.assertIn('Created branch "my-feature"', result.final_output) + self.assertIn('Saved to:', result.final_output) + + def test_branch_auto_name(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + _set_session(agent, [ + AgentMessage(role='user', content='Hello'), + ]) + result = agent.run('/branch') + self.assertIn('Created branch "branch-', result.final_output) + + +class TestEffortCommand(unittest.TestCase): + def test_show_current_effort(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + result = agent.run('/effort') + self.assertIn('Current effort level: auto', result.final_output) + + def test_set_effort_level(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + result = agent.run('/effort high') + self.assertIn('Set effort level to: high', result.final_output) + + def test_invalid_effort_level(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + result = agent.run('/effort extreme') + self.assertIn('Invalid effort level', result.final_output) + + def test_all_valid_levels(self) -> None: + for level in ('low', 'medium', 'high', 'max', 'auto'): + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + result = agent.run(f'/effort {level}') + self.assertIn(f'Set effort level to: {level}', result.final_output) + + +class TestDoctorCommand(unittest.TestCase): + def test_shows_report(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + result = agent.run('/doctor') + output = result.final_output + self.assertIn('Doctor Report', output) + self.assertIn('Python version', output) + self.assertIn('git', output) + self.assertIn('Model', output) + self.assertIn('Working directory', output) + + def test_detects_claude_md(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / 'CLAUDE.md').write_text('memory file') + agent = _make_agent(tmp) + result = agent.run('/doctor') + self.assertIn('CLAUDE.md', result.final_output) + self.assertIn('found', result.final_output) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_prompt_constants.py b/tests/test_prompt_constants.py new file mode 100644 index 0000000..0f6f57d --- /dev/null +++ b/tests/test_prompt_constants.py @@ -0,0 +1,643 @@ +"""Tests for prompt_constants module. + +Validates that all constants ported from npm src/constants/ are present, +correctly typed, and that helper functions behave as expected. +""" + +from __future__ import annotations + +import os +import platform +from unittest.mock import patch + +import pytest + +from src.prompt_constants import ( + # Product metadata + PRODUCT_URL, + CLAUDE_AI_BASE_URL, + # System prompt prefixes + DEFAULT_SYSPROMPT_PREFIX, + AGENT_SDK_CLAUDE_CODE_PRESET_PREFIX, + AGENT_SDK_PREFIX, + CLI_SYSPROMPT_PREFIXES, + # Cyber risk + CYBER_RISK_INSTRUCTION, + # API limits + API_IMAGE_MAX_BASE64_SIZE, + IMAGE_TARGET_RAW_SIZE, + IMAGE_MAX_WIDTH, + IMAGE_MAX_HEIGHT, + PDF_TARGET_RAW_SIZE, + API_PDF_MAX_PAGES, + PDF_EXTRACT_SIZE_THRESHOLD, + PDF_MAX_EXTRACT_SIZE, + PDF_MAX_PAGES_PER_READ, + PDF_AT_MENTION_INLINE_THRESHOLD, + API_MAX_MEDIA_PER_REQUEST, + # Tool limits + DEFAULT_MAX_RESULT_SIZE_CHARS, + MAX_TOOL_RESULT_TOKENS, + BYTES_PER_TOKEN, + MAX_TOOL_RESULT_BYTES, + MAX_TOOL_RESULTS_PER_MESSAGE_CHARS, + TOOL_SUMMARY_MAX_LENGTH, + # Spinner verbs + SPINNER_VERBS, + # Turn completion verbs + TURN_COMPLETION_VERBS, + # Figures + BLACK_CIRCLE, + BULLET_OPERATOR, + TEARDROP_ASTERISK, + UP_ARROW, + DOWN_ARROW, + LIGHTNING_BOLT, + EFFORT_LOW, + EFFORT_MEDIUM, + EFFORT_HIGH, + EFFORT_MAX, + PLAY_ICON, + PAUSE_ICON, + REFRESH_ARROW, + CHANNEL_ARROW, + INJECTED_ARROW, + FORK_GLYPH, + DIAMOND_OPEN, + DIAMOND_FILLED, + REFERENCE_MARK, + FLAG_ICON, + BLOCKQUOTE_BAR, + HEAVY_HORIZONTAL, + BRIDGE_SPINNER_FRAMES, + BRIDGE_READY_INDICATOR, + BRIDGE_FAILED_INDICATOR, + # XML tags + COMMAND_NAME_TAG, + COMMAND_MESSAGE_TAG, + COMMAND_ARGS_TAG, + BASH_INPUT_TAG, + BASH_STDOUT_TAG, + BASH_STDERR_TAG, + LOCAL_COMMAND_STDOUT_TAG, + LOCAL_COMMAND_STDERR_TAG, + LOCAL_COMMAND_CAVEAT_TAG, + TERMINAL_OUTPUT_TAGS, + TICK_TAG, + TASK_NOTIFICATION_TAG, + TASK_ID_TAG, + TOOL_USE_ID_TAG, + TASK_TYPE_TAG, + OUTPUT_FILE_TAG, + STATUS_TAG, + SUMMARY_TAG, + REASON_TAG, + WORKTREE_TAG, + WORKTREE_PATH_TAG, + WORKTREE_BRANCH_TAG, + ULTRAPLAN_TAG, + REMOTE_REVIEW_TAG, + REMOTE_REVIEW_PROGRESS_TAG, + TEAMMATE_MESSAGE_TAG, + CHANNEL_MESSAGE_TAG, + CHANNEL_TAG, + CROSS_SESSION_MESSAGE_TAG, + FORK_BOILERPLATE_TAG, + FORK_DIRECTIVE_PREFIX, + COMMON_HELP_ARGS, + COMMON_INFO_ARGS, + # Messages + NO_CONTENT_MESSAGE, + # Date utilities + get_local_iso_date, + get_session_start_date, + reset_session_start_date, + get_local_month_year, + # System prompt section caching + SystemPromptSection, + system_prompt_section, + dangerous_uncached_system_prompt_section, + resolve_system_prompt_sections, + clear_system_prompt_sections, + # Output styles + DEFAULT_OUTPUT_STYLE_NAME, + OutputStyleConfig, + OUTPUT_STYLE_CONFIGS, + # Knowledge cutoff + FRONTIER_MODEL_NAME, + get_knowledge_cutoff, + CLAUDE_MODEL_IDS, + # Prompt sections + HOOKS_SECTION, + SYSTEM_REMINDERS_SECTION, + SUMMARIZE_TOOL_RESULTS_SECTION, + DEFAULT_AGENT_PROMPT, + SYSTEM_PROMPT_DYNAMIC_BOUNDARY, + get_language_section, + get_output_style_section, + get_scratchpad_instructions, + # Error IDs + E_TOOL_USE_SUMMARY_GENERATION_FAILED, +) + + +# ========================================================================= +# Product metadata +# ========================================================================= + +class TestProductMetadata: + def test_product_url(self): + assert PRODUCT_URL == "https://claude.com/claude-code" + + def test_claude_ai_base_url(self): + assert CLAUDE_AI_BASE_URL == "https://claude.ai" + + +# ========================================================================= +# System prompt prefixes +# ========================================================================= + +class TestSystemPromptPrefixes: + def test_default_prefix_content(self): + assert "Claude Code" in DEFAULT_SYSPROMPT_PREFIX + assert "Anthropic" in DEFAULT_SYSPROMPT_PREFIX + + def test_agent_sdk_prefix_content(self): + assert "Agent SDK" in AGENT_SDK_PREFIX + + def test_cli_sysprompt_prefixes_is_frozenset(self): + assert isinstance(CLI_SYSPROMPT_PREFIXES, frozenset) + assert len(CLI_SYSPROMPT_PREFIXES) == 3 + + def test_all_prefixes_in_set(self): + assert DEFAULT_SYSPROMPT_PREFIX in CLI_SYSPROMPT_PREFIXES + assert AGENT_SDK_CLAUDE_CODE_PRESET_PREFIX in CLI_SYSPROMPT_PREFIXES + assert AGENT_SDK_PREFIX in CLI_SYSPROMPT_PREFIXES + + +# ========================================================================= +# Cyber risk +# ========================================================================= + +class TestCyberRisk: + def test_instruction_mentions_ctf(self): + assert "CTF" in CYBER_RISK_INSTRUCTION + + def test_instruction_mentions_dos(self): + assert "DoS" in CYBER_RISK_INSTRUCTION + + +# ========================================================================= +# API limits +# ========================================================================= + +class TestAPILimits: + def test_image_base64_size(self): + assert API_IMAGE_MAX_BASE64_SIZE == 5 * 1024 * 1024 + + def test_image_target_raw_size(self): + assert IMAGE_TARGET_RAW_SIZE == (API_IMAGE_MAX_BASE64_SIZE * 3) // 4 + + def test_image_dimensions(self): + assert IMAGE_MAX_WIDTH == 2000 + assert IMAGE_MAX_HEIGHT == 2000 + + def test_pdf_target_raw_size(self): + assert PDF_TARGET_RAW_SIZE == 20 * 1024 * 1024 + + def test_pdf_max_pages(self): + assert API_PDF_MAX_PAGES == 100 + + def test_pdf_extract_threshold(self): + assert PDF_EXTRACT_SIZE_THRESHOLD == 3 * 1024 * 1024 + + def test_pdf_max_extract_size(self): + assert PDF_MAX_EXTRACT_SIZE == 100 * 1024 * 1024 + + def test_pdf_pages_per_read(self): + assert PDF_MAX_PAGES_PER_READ == 20 + + def test_pdf_inline_threshold(self): + assert PDF_AT_MENTION_INLINE_THRESHOLD == 10 + + def test_media_per_request(self): + assert API_MAX_MEDIA_PER_REQUEST == 100 + + +# ========================================================================= +# Tool limits +# ========================================================================= + +class TestToolLimits: + def test_default_max_result_size(self): + assert DEFAULT_MAX_RESULT_SIZE_CHARS == 50_000 + + def test_max_tool_result_tokens(self): + assert MAX_TOOL_RESULT_TOKENS == 100_000 + + def test_bytes_per_token(self): + assert BYTES_PER_TOKEN == 4 + + def test_max_tool_result_bytes_derived(self): + assert MAX_TOOL_RESULT_BYTES == MAX_TOOL_RESULT_TOKENS * BYTES_PER_TOKEN + assert MAX_TOOL_RESULT_BYTES == 400_000 + + def test_max_per_message_chars(self): + assert MAX_TOOL_RESULTS_PER_MESSAGE_CHARS == 200_000 + + def test_tool_summary_max_length(self): + assert TOOL_SUMMARY_MAX_LENGTH == 50 + + +# ========================================================================= +# Spinner verbs +# ========================================================================= + +class TestSpinnerVerbs: + def test_is_tuple(self): + assert isinstance(SPINNER_VERBS, tuple) + + def test_count(self): + assert len(SPINNER_VERBS) == 187 + + def test_first_verb(self): + assert SPINNER_VERBS[0] == "Accomplishing" + + def test_last_verb(self): + assert SPINNER_VERBS[-1] == "Zigzagging" + + def test_all_strings(self): + for verb in SPINNER_VERBS: + assert isinstance(verb, str) + + def test_contains_clauding(self): + assert "Clauding" in SPINNER_VERBS + + def test_contains_thinking(self): + assert "Thinking" in SPINNER_VERBS + + def test_no_duplicates(self): + assert len(SPINNER_VERBS) == len(set(SPINNER_VERBS)) + + +# ========================================================================= +# Turn completion verbs +# ========================================================================= + +class TestTurnCompletionVerbs: + def test_is_tuple(self): + assert isinstance(TURN_COMPLETION_VERBS, tuple) + + def test_count(self): + assert len(TURN_COMPLETION_VERBS) == 8 + + def test_contains_worked(self): + assert "Worked" in TURN_COMPLETION_VERBS + + def test_contains_baked(self): + assert "Baked" in TURN_COMPLETION_VERBS + + def test_all_past_tense(self): + # All end in 'd' (past tense) + for verb in TURN_COMPLETION_VERBS: + assert verb[-1] == "d", f"{verb} doesn't end with 'd'" + + +# ========================================================================= +# Figures / UI symbols +# ========================================================================= + +class TestFigures: + def test_black_circle_is_string(self): + assert isinstance(BLACK_CIRCLE, str) + assert len(BLACK_CIRCLE) == 1 + + def test_effort_symbols_are_distinct(self): + symbols = {EFFORT_LOW, EFFORT_MEDIUM, EFFORT_HIGH, EFFORT_MAX} + assert len(symbols) == 4 + + def test_arrows(self): + assert UP_ARROW == "\u2191" + assert DOWN_ARROW == "\u2193" + + def test_bridge_spinner_frames(self): + assert isinstance(BRIDGE_SPINNER_FRAMES, tuple) + assert len(BRIDGE_SPINNER_FRAMES) == 4 + + def test_play_pause_icons(self): + assert PLAY_ICON == "\u25b6" + assert PAUSE_ICON == "\u23f8" + + def test_diamond_symbols(self): + assert DIAMOND_OPEN == "\u25c7" + assert DIAMOND_FILLED == "\u25c6" + + +# ========================================================================= +# XML tag constants +# ========================================================================= + +class TestXMLTags: + def test_command_tags(self): + assert COMMAND_NAME_TAG == "command-name" + assert COMMAND_MESSAGE_TAG == "command-message" + assert COMMAND_ARGS_TAG == "command-args" + + def test_bash_tags(self): + assert BASH_INPUT_TAG == "bash-input" + assert BASH_STDOUT_TAG == "bash-stdout" + assert BASH_STDERR_TAG == "bash-stderr" + + def test_terminal_output_tags_tuple(self): + assert isinstance(TERMINAL_OUTPUT_TAGS, tuple) + assert len(TERMINAL_OUTPUT_TAGS) == 6 + assert BASH_INPUT_TAG in TERMINAL_OUTPUT_TAGS + assert LOCAL_COMMAND_STDOUT_TAG in TERMINAL_OUTPUT_TAGS + + def test_tick_tag(self): + assert TICK_TAG == "tick" + + def test_task_tags(self): + assert TASK_NOTIFICATION_TAG == "task-notification" + assert TASK_ID_TAG == "task-id" + assert TOOL_USE_ID_TAG == "tool-use-id" + + def test_worktree_tags(self): + assert WORKTREE_TAG == "worktree" + assert WORKTREE_PATH_TAG == "worktreePath" + + def test_fork_tags(self): + assert FORK_BOILERPLATE_TAG == "fork-boilerplate" + assert FORK_DIRECTIVE_PREFIX == "Your directive: " + + def test_common_help_args(self): + assert isinstance(COMMON_HELP_ARGS, tuple) + assert "help" in COMMON_HELP_ARGS + assert "-h" in COMMON_HELP_ARGS + assert "--help" in COMMON_HELP_ARGS + + def test_common_info_args(self): + assert isinstance(COMMON_INFO_ARGS, tuple) + assert "list" in COMMON_INFO_ARGS + assert "status" in COMMON_INFO_ARGS + assert "?" in COMMON_INFO_ARGS + + +# ========================================================================= +# Message constants +# ========================================================================= + +class TestMessages: + def test_no_content_message(self): + assert NO_CONTENT_MESSAGE == "(no content)" + + +# ========================================================================= +# Date utilities +# ========================================================================= + +class TestDateUtilities: + def test_get_local_iso_date_format(self): + d = get_local_iso_date() + parts = d.split("-") + assert len(parts) == 3 + assert len(parts[0]) == 4 # year + assert len(parts[1]) == 2 # month + assert len(parts[2]) == 2 # day + + def test_get_local_iso_date_override(self): + with patch.dict(os.environ, {"CLAUDE_CODE_OVERRIDE_DATE": "2025-01-15"}): + assert get_local_iso_date() == "2025-01-15" + + def test_get_session_start_date_memoised(self): + reset_session_start_date() + d1 = get_session_start_date() + d2 = get_session_start_date() + assert d1 == d2 + + def test_reset_session_start_date(self): + reset_session_start_date() + d = get_session_start_date() + assert isinstance(d, str) + reset_session_start_date() + # After reset, should still return valid date + d2 = get_session_start_date() + assert isinstance(d2, str) + + def test_get_local_month_year_format(self): + result = get_local_month_year() + parts = result.split() + assert len(parts) == 2 + assert parts[1].isdigit() + assert len(parts[1]) == 4 + + def test_get_local_month_year_override(self): + with patch.dict(os.environ, {"CLAUDE_CODE_OVERRIDE_DATE": "2026-02-15"}): + assert get_local_month_year() == "February 2026" + + +# ========================================================================= +# System prompt section caching +# ========================================================================= + +class TestSystemPromptSections: + def setup_method(self): + clear_system_prompt_sections() + + def test_system_prompt_section_creates_cached(self): + s = system_prompt_section("test", lambda: "hello") + assert s.name == "test" + assert s.cache_break is False + + def test_dangerous_uncached_creates_volatile(self): + s = dangerous_uncached_system_prompt_section("test", lambda: "hello", "reason") + assert s.name == "test" + assert s.cache_break is True + + def test_resolve_caches_sections(self): + call_count = 0 + + def compute(): + nonlocal call_count + call_count += 1 + return f"value-{call_count}" + + sections = [system_prompt_section("s1", compute)] + r1 = resolve_system_prompt_sections(sections) + r2 = resolve_system_prompt_sections(sections) + assert r1 == ["value-1"] + assert r2 == ["value-1"] # cached + assert call_count == 1 + + def test_uncached_recomputes(self): + call_count = 0 + + def compute(): + nonlocal call_count + call_count += 1 + return f"value-{call_count}" + + sections = [dangerous_uncached_system_prompt_section("s2", compute, "test")] + r1 = resolve_system_prompt_sections(sections) + r2 = resolve_system_prompt_sections(sections) + assert r1 == ["value-1"] + assert r2 == ["value-2"] # recomputed + assert call_count == 2 + + def test_clear_resets_cache(self): + call_count = 0 + + def compute(): + nonlocal call_count + call_count += 1 + return f"value-{call_count}" + + sections = [system_prompt_section("s3", compute)] + resolve_system_prompt_sections(sections) + clear_system_prompt_sections() + r = resolve_system_prompt_sections(sections) + assert r == ["value-2"] + assert call_count == 2 + + def test_resolve_handles_none(self): + sections = [system_prompt_section("nil", lambda: None)] + r = resolve_system_prompt_sections(sections) + assert r == [None] + + def test_multiple_sections(self): + sections = [ + system_prompt_section("a", lambda: "alpha"), + system_prompt_section("b", lambda: "beta"), + system_prompt_section("c", lambda: None), + ] + r = resolve_system_prompt_sections(sections) + assert r == ["alpha", "beta", None] + + +# ========================================================================= +# Output styles +# ========================================================================= + +class TestOutputStyles: + def test_default_style_name(self): + assert DEFAULT_OUTPUT_STYLE_NAME == "default" + + def test_default_style_is_none(self): + assert OUTPUT_STYLE_CONFIGS[DEFAULT_OUTPUT_STYLE_NAME] is None + + def test_explanatory_exists(self): + style = OUTPUT_STYLE_CONFIGS["Explanatory"] + assert style is not None + assert style.name == "Explanatory" + assert "explains" in style.description + + def test_learning_exists(self): + style = OUTPUT_STYLE_CONFIGS["Learning"] + assert style is not None + assert style.name == "Learning" + assert "hands-on" in style.description + + def test_output_style_config_frozen(self): + style = OutputStyleConfig( + name="Test", description="desc", prompt="prompt" + ) + with pytest.raises(Exception): + style.name = "other" # type: ignore[misc] + + +# ========================================================================= +# Knowledge cutoff +# ========================================================================= + +class TestKnowledgeCutoff: + def test_frontier_model_name(self): + assert FRONTIER_MODEL_NAME == "Claude Opus 4.6" + + def test_opus_46_cutoff(self): + assert get_knowledge_cutoff("claude-opus-4-6-20250601") == "May 2025" + + def test_sonnet_46_cutoff(self): + assert get_knowledge_cutoff("claude-sonnet-4-6-20250801") == "August 2025" + + def test_opus_45_cutoff(self): + assert get_knowledge_cutoff("claude-opus-4-5-20250601") == "May 2025" + + def test_haiku_cutoff(self): + assert get_knowledge_cutoff("claude-haiku-4-20250201") == "February 2025" + + def test_sonnet_4_cutoff(self): + assert get_knowledge_cutoff("claude-sonnet-4-20250114") == "January 2025" + + def test_unknown_model_returns_none(self): + assert get_knowledge_cutoff("gpt-4-turbo") is None + + def test_claude_model_ids(self): + assert "opus" in CLAUDE_MODEL_IDS + assert "sonnet" in CLAUDE_MODEL_IDS + assert "haiku" in CLAUDE_MODEL_IDS + + +# ========================================================================= +# Prompt section helpers +# ========================================================================= + +class TestPromptSectionHelpers: + def test_hooks_section_content(self): + assert "hooks" in HOOKS_SECTION + assert "user-prompt-submit-hook" in HOOKS_SECTION + + def test_system_reminders_section(self): + assert "system-reminder" in SYSTEM_REMINDERS_SECTION + + def test_summarize_tool_results(self): + assert "tool results" in SUMMARIZE_TOOL_RESULTS_SECTION + + def test_default_agent_prompt(self): + assert "agent for Claude Code" in DEFAULT_AGENT_PROMPT + + def test_dynamic_boundary(self): + assert SYSTEM_PROMPT_DYNAMIC_BOUNDARY == "__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__" + + def test_language_section_none_when_no_preference(self): + assert get_language_section(None) is None + assert get_language_section("") is None + + def test_language_section_with_preference(self): + result = get_language_section("Spanish") + assert result is not None + assert "Spanish" in result + assert "# Language" in result + + def test_output_style_section_none_when_no_config(self): + assert get_output_style_section(None) is None + + def test_output_style_section_with_config(self): + config = OutputStyleConfig( + name="TestStyle", + description="A test style", + prompt="Be concise.", + ) + result = get_output_style_section(config) + assert result is not None + assert "# Output Style: TestStyle" in result + assert "Be concise." in result + + def test_scratchpad_none_when_no_dir(self): + assert get_scratchpad_instructions(None) is None + assert get_scratchpad_instructions("") is None + + def test_scratchpad_with_dir(self): + result = get_scratchpad_instructions("/tmp/session-123") + assert result is not None + assert "/tmp/session-123" in result + assert "# Scratchpad Directory" in result + assert "temporary files" in result + + +# ========================================================================= +# Error IDs +# ========================================================================= + +class TestErrorIDs: + def test_tool_use_summary_error(self): + assert E_TOOL_USE_SUMMARY_GENERATION_FAILED == 344