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:
Abdelrahman Abdallah
2026-04-08 00:02:53 +02:00
parent 90489e7bfc
commit aacf0a212a
13 changed files with 5872 additions and 304 deletions
+2
View File
@@ -32,3 +32,5 @@ humaneval_results.json
test_cases
e-commerce
benchmarks/data/*.jsonl
benchmarks/data/manifest.json
+404 -159
View File
@@ -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
View File
@@ -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,
+9
View File
@@ -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,
+476
View File
@@ -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},
+1261
View File
File diff suppressed because it is too large Load Diff
+438
View File
@@ -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'},
)
+709
View File
@@ -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."
)
+878
View File
@@ -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
+405
View File
@@ -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()
+189
View File
@@ -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()
+299
View File
@@ -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()
+643
View File
@@ -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