Created src/prompt_constants.py (550+ lines) porting all constants from npm src/constants/:
┌──────────────────┬───────────────────────────┬───────────────────────────────────────────────┐ │ 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.
This commit is contained in:
@@ -32,3 +32,5 @@ humaneval_results.json
|
||||
|
||||
test_cases
|
||||
e-commerce
|
||||
benchmarks/data/*.jsonl
|
||||
benchmarks/data/manifest.json
|
||||
|
||||
+404
-159
@@ -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 <name>`
|
||||
- [x] `/search`
|
||||
- [x] `/remote`
|
||||
- [x] `/help`, `/commands`
|
||||
- [x] `/context`, `/usage`
|
||||
- [x] `/context-raw`, `/env`
|
||||
- [x] `/mcp` (with subcommands: `tools`, `tool <name>`)
|
||||
- [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
|
||||
|
||||
+159
-145
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 <tag-name>')
|
||||
|
||||
# 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 <name>')
|
||||
|
||||
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},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+438
@@ -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 <analysis> block followed by a <summary> block.
|
||||
|
||||
"""
|
||||
|
||||
_DETAILED_ANALYSIS_INSTRUCTION = """\
|
||||
Before providing your final summary, wrap your analysis in <analysis> 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:
|
||||
|
||||
<example>
|
||||
<analysis>
|
||||
[Your thought process, ensuring all points are covered thoroughly and accurately]
|
||||
</analysis>
|
||||
|
||||
<summary>
|
||||
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]
|
||||
|
||||
</summary>
|
||||
</example>
|
||||
|
||||
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:
|
||||
<example>
|
||||
## Compact Instructions
|
||||
When summarizing the conversation focus on typescript code changes and also \
|
||||
remember the mistakes you made and how you fixed them.
|
||||
</example>
|
||||
|
||||
<example>
|
||||
# Summary instructions
|
||||
When you are using compact - please focus on test output and code changes. \
|
||||
Include file reads verbatim.
|
||||
</example>
|
||||
"""
|
||||
|
||||
_NO_TOOLS_TRAILER = (
|
||||
'\n\nREMINDER: Do NOT call any tools. Respond with plain text only — '
|
||||
'an <analysis> block followed by a <summary> 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 ``<analysis>`` scratchpad and unwrap ``<summary>`` tags.
|
||||
|
||||
Mirrors the npm ``formatCompactSummary`` helper.
|
||||
"""
|
||||
formatted = re.sub(r'<analysis>[\s\S]*?</analysis>', '', summary)
|
||||
|
||||
match = re.search(r'<summary>([\s\S]*?)</summary>', formatted)
|
||||
if match:
|
||||
content = match.group(1).strip()
|
||||
formatted = re.sub(
|
||||
r'<summary>[\s\S]*?</summary>',
|
||||
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 ``<summary>`` 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'<system-reminder>\n{note}\n</system-reminder>',
|
||||
message_id='compact_boundary',
|
||||
metadata={'kind': 'compact_boundary'},
|
||||
)
|
||||
@@ -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 "
|
||||
"<user-prompt-submit-hook>, 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 <system-reminder> tags. "
|
||||
"<system-reminder> 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."
|
||||
)
|
||||
@@ -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 '</dev/null' not in strip_safe_redirections('cmd < /dev/null')
|
||||
|
||||
def test_preserves_other_redirections(self):
|
||||
result = strip_safe_redirections('cmd > 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
|
||||
@@ -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('<analysis>', prompt)
|
||||
self.assertIn('</analysis>', prompt)
|
||||
self.assertIn('<summary>', prompt)
|
||||
self.assertIn('</summary>', 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 = '<analysis>thinking here</analysis>\n\n<summary>result</summary>'
|
||||
formatted = format_compact_summary(raw)
|
||||
self.assertNotIn('<analysis>', formatted)
|
||||
self.assertNotIn('thinking here', formatted)
|
||||
self.assertIn('result', formatted)
|
||||
|
||||
def test_unwraps_summary_tags(self) -> None:
|
||||
raw = '<summary>The main points.\n1. First</summary>'
|
||||
formatted = format_compact_summary(raw)
|
||||
self.assertNotIn('<summary>', formatted)
|
||||
self.assertNotIn('</summary>', 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 = '<analysis>x</analysis>\n\n\n\n<summary>y</summary>'
|
||||
formatted = format_compact_summary(raw)
|
||||
self.assertNotIn('\n\n\n', formatted)
|
||||
|
||||
def test_multiline_analysis_stripped(self) -> None:
|
||||
raw = (
|
||||
'<analysis>\nLine 1\nLine 2\nLine 3\n</analysis>\n'
|
||||
'<summary>Final summary</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('<summary>overview</summary>')
|
||||
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(
|
||||
'<summary>ok</summary>',
|
||||
transcript_path='/tmp/transcript.json',
|
||||
)
|
||||
self.assertIn('/tmp/transcript.json', msg)
|
||||
|
||||
def test_suppress_follow_up(self) -> None:
|
||||
msg = get_compact_user_summary_message(
|
||||
'<summary>ok</summary>',
|
||||
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('<summary>ok</summary>')
|
||||
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=(
|
||||
'<analysis>Thinking through the conversation...</analysis>\n'
|
||||
'<summary>\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'
|
||||
'</summary>'
|
||||
),
|
||||
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 <analysis> block
|
||||
self.assertNotIn('<analysis>', 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='<summary>Summarised.</summary>',
|
||||
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='<summary>Custom summary.</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='<summary>Session summarised.</summary>',
|
||||
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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user