claude_agent 0.7.15 → 0.7.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,339 @@
1
+ # Architecture
2
+
3
+ Internal architecture of the ClaudeAgent Ruby SDK.
4
+
5
+ ---
6
+
7
+ ## Layer Diagram
8
+
9
+ ```
10
+ ClaudeAgent.ask / .chat / .query
11
+ |
12
+ Configuration (Stripe-style global defaults, Forwardable delegators)
13
+ |
14
+ Options (validation, CLI arg serialization, env vars)
15
+ |
16
+ Conversation / Client (lifecycle, turns, event dispatch)
17
+ |
18
+ EventHandler / TurnResult / CumulativeUsage
19
+ |
20
+ ControlProtocol (request/response routing, hooks, MCP, permissions)
21
+ Primitives | Lifecycle | Messaging | Commands | RequestHandling
22
+ |
23
+ Transport::Subprocess (JSON Lines framing, stdin/stdout, process mgmt)
24
+ |
25
+ Claude Code CLI (spawned as subprocess)
26
+ ```
27
+
28
+ ---
29
+
30
+ ## Module Responsibilities
31
+
32
+ ### Entry Points
33
+
34
+ | Module | Role |
35
+ |--------------------------|-------------------------------------------------------------------------------------------------------|
36
+ | `ClaudeAgent.ask` | One-shot query, returns `TurnResult`. Merges global config, builds `EventHandler` from `on_*` kwargs. |
37
+ | `ClaudeAgent.chat` | Multi-turn conversation. Block form auto-cleans; no block returns `Conversation`. |
38
+ | `ClaudeAgent.query` | Low-level streaming enumerator. Returns `Enumerator<Message>`. |
39
+ | `ClaudeAgent.query_turn` | Like `query` but accumulates into `TurnResult` with optional `EventHandler`. |
40
+
41
+ ### Configuration Layer
42
+
43
+ | Module | Role |
44
+ |-----------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
45
+ | `Configuration` | Stripe-style global defaults. Holds all configurable fields (Tier 1/2/3), global `PermissionPolicy`, `HookRegistry`, and MCP server registrations. `to_options(**overrides)` merges config + per-request kwargs into an `Options` instance. |
46
+ | `Options` | All configurable attributes with validation and CLI arg serialization. Includes `Serializer` mixin for `to_cli_args` and `to_env`. Auto-compiles `PermissionPolicy` to lambda, `HookRegistry` to hash. Auto-sets `permission_prompt_tool_name = "stdio"` when `can_use_tool` or `permission_queue` is present. |
47
+
48
+ ### Conversation Layer
49
+
50
+ | Module | Role |
51
+ |----------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
52
+ | `Conversation` | High-level lifecycle manager. Wraps `Client` with auto-connect on first `say`, multi-turn history, tool activity timeline with timestamps, and cumulative cost tracking. Partitions kwargs into callbacks / conversation keys / options keys. Supports `open` (block), `resume` (session ID), and permission mode mapping (`:queue`, `:accept_edits`, policy, callable). |
53
+ | `Client` | Bidirectional connection to CLI. Composes `Transport`, `ControlProtocol`, `EventHandler`, `CumulativeUsage`, and `PermissionQueue`. Provides `send_message`, `receive_turn`, `send_and_receive`, `stream_input`, `interrupt`, and `abort!`. Includes `Commands` mixin for control operations (permission mode, model changes, file rewind, MCP server management). |
54
+
55
+ ### Event & Accumulation Layer
56
+
57
+ | Module | Role |
58
+ |-------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
59
+ | `EventHandler` | Three-layer event dispatch: (1) `:message` catch-all, (2) type-based (`:assistant`, `:stream_event`, `:status`, etc.), (3) decomposed (`:text`, `:thinking`, `:tool_use`, `:tool_result`). Pairs tool results with their originating tool use blocks. Supports `EventHandler.define` DSL and method chaining. |
60
+ | `TurnResult` | Message accumulator for a single agent turn. Convenience accessors: `text`, `thinking`, `tool_uses`, `tool_results`, `tool_executions`, `cost`, `session_id`, `usage`, `model`, `structured_output`, `permission_denials`. Accumulates streaming text deltas as fallback for aborted turns. |
61
+ | `CumulativeUsage` | Thread-safe (Mutex) token and cost tracker across turns. Sums `input_tokens`, `output_tokens`, cache tokens. Takes session-cumulative `total_cost_usd` and `num_turns` from the most recent `ResultMessage`. |
62
+
63
+ ### Protocol Layer
64
+
65
+ | Module | Role |
66
+ |-------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
67
+ | `ControlProtocol` | Core protocol handler. Composed of five submodules (below). Manages shared state: transport, parser, request counter, pending requests/results, threading primitives (Mutex, ConditionVariable, Queue), abort signal. |
68
+ | `Primitives` | Low-level read/write helpers. `write_message` serializes and sends JSON. Request/response ID generation. Stateless except for shared counters and pending-request maps. |
69
+ | `Lifecycle` | Connection lifecycle: `start` (connect transport, spawn reader thread, send initialize), `stop` (end input, join reader, close transport), `abort!` (cancel pending requests, drain permission queue, terminate transport). Background `reader_loop` routes `control_request`, `control_response`, and SDK messages to appropriate handlers or the message queue. |
70
+ | `Messaging` | Consumer-facing message delivery: `each_message`, `receive_response`, `send_user_message`, `stream_input`, `stream_conversation`. Reads from the internal `Queue`, parses via `MessageParser`, checks abort signal. |
71
+ | `Commands` | Control commands sent to CLI: `change_permission_mode`, `change_model`, `rewind_files`, `mcp_server_status`, `set_mcp_servers`, `interrupt`. Each sends a `control_request` and waits for the response. |
72
+ | `RequestHandling` | Handles incoming control requests from CLI: `can_use_tool` (three modes: synchronous callback, queue-based, default allow), `hook_callback`, `mcp_message` (routes to SDK MCP server instances), `elicitation`. Normalizes Ruby field names to CLI camelCase keys. |
73
+
74
+ ### Transport Layer
75
+
76
+ | Module | Role |
77
+ |-------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
78
+ | `Transport::Base` | Abstract base class defining the transport interface. |
79
+ | `Transport::Subprocess` | Spawns Claude Code CLI via `Open3.popen3` or custom spawn function. Manages stdin/stdout/stderr streams. JSON Lines framing with partial-JSON buffering. Version checking against minimum CLI version. Supports graceful termination (SIGTERM) and force kill (SIGKILL). Custom spawn support via `SpawnOptions` / `SpawnedProcess` for non-standard process management. |
80
+
81
+ ### Parsing Layer
82
+
83
+ | Module | Role |
84
+ |-----------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
85
+ | `MessageParser` | Registry-based router. Maps raw JSON hashes (string keys, camelCase) to typed message objects. `deep_transform_keys` normalizes to snake_case symbols. Dispatches by `type` (top-level) or `type:subtype` (system messages). Unknown types wrapped in `GenericMessage`. |
86
+
87
+ ### Permission System
88
+
89
+ | Module | Role |
90
+ |---------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
91
+ | `PermissionPolicy` | Declarative DSL for permission rules: `allow`, `deny`, `allow_matching`, `deny_matching`, `allow_all`, `deny_all`, `ask` (custom fallback). Compiles to a `can_use_tool` lambda. Rules evaluated in order; first match wins. |
92
+ | `PermissionQueue` | Thread-safe `Queue` wrapper for deferred permission requests. Non-blocking `poll`, blocking `pop(timeout:)`, and `drain!` for abort cleanup. |
93
+ | `PermissionRequest` | Deferred permission request resolved from any thread. `allow!` / `deny!` unblock the reader thread via Mutex + ConditionVariable. Supports hybrid mode: callback can call `context.request.defer!` to enqueue instead of returning synchronously. |
94
+
95
+ ### Hook System
96
+
97
+ | Module | Role |
98
+ |----------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
99
+ | `HookRegistry` | Ruby-friendly DSL mapping idiomatic method names (`before_tool_use`, `after_tool_use`, `on_session_start`, etc.) to CLI event names (`PreToolUse`, `PostToolUse`, `SessionStart`, etc.). Compiles to the `Hash{String => Array<HookMatcher>}` format consumed by `Options#hooks`. Supports regex/string tool matchers and additive merge. |
100
+
101
+ ### MCP Layer
102
+
103
+ | Module | Role |
104
+ |---------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
105
+ | `MCP::Server` | In-process MCP server. Handles JSON-RPC messages: `initialize`, `tools/list`, `tools/call`. Registered via `Options#mcp_servers` with `type: "sdk"`. Block DSL for inline tool definition. |
106
+ | `MCP::Tool` | Single tool definition with name, description, JSON Schema (auto-normalized from Ruby types/symbols), optional annotations, and handler block. Formats results as MCP content blocks. |
107
+
108
+ ### Session Layer
109
+
110
+ | Module | Role |
111
+ |--------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
112
+ | `Session` | Rails-like finder with Stripe-style resource methods. `find` / `retrieve` / `all` / `where` class methods. Instance methods: `messages` (returns `SessionMessageRelation`), `rename`, `tag_session`, `fork`, `reload`, `resume`. |
113
+ | `SessionMessageRelation` | Chainable Enumerable query object. Lazy evaluation with `where(limit:, offset:)`. Wraps `GetSessionMessages`. |
114
+ | `ListSessions` | Reads session metadata from disk without spawning CLI. Returns `SessionInfo` sorted by last modified. Supports directory scoping and git worktree inclusion. |
115
+ | `GetSessionMessages` | Reads JSONL session transcript, reconstructs main conversation thread, returns `SessionMessage` array with pagination. |
116
+ | `GetSessionInfo` | Targeted single-session lookup by UUID. |
117
+ | `ForkSession` | Creates a new session file with remapped UUIDs, optional truncation point. |
118
+ | `SessionMutations` | Appends `custom-title` and `tag` entries to session files. |
119
+ | `SessionPaths` | Shared infrastructure for resolving session file paths across projects and worktrees. |
120
+
121
+ ### Abort & Signal
122
+
123
+ | Module | Role |
124
+ |-------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
125
+ | `AbortController` | JavaScript-style abort controller. Owns an `AbortSignal`. `abort(reason)` triggers the signal; `reset!` clears it for reuse. |
126
+ | `AbortSignal` | Thread-safe (Mutex + ConditionVariable) signal. `aborted?`, `reason`, `on_abort` callbacks, `wait(timeout:)`, `check!` (raises `AbortError`). Used by `ControlProtocol` (reader loop check, queue push), `Conversation` (auto-reset per turn). |
127
+
128
+ ### Tool Activity Tracking
129
+
130
+ | Module | Role |
131
+ |-----------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
132
+ | `ToolActivity` | Immutable (`Data.define`) record of a completed tool execution. Pairs `ToolUseBlock` + `ToolResultBlock` with turn index and wall-clock timestamps. |
133
+ | `LiveToolActivity` | Mutable wrapper for real-time status tracking. States: `:running`, `:done`, `:error`. Updated by progress messages. Suitable for live UIs. |
134
+ | `ToolActivityTracker` | Enumerable collection with auto-wiring. Attaches to `EventHandler` or `Client`. Callbacks: `on_start`, `on_complete`, `on_progress`, `on_change`. Query methods: `running`, `done`, `errored`, `find_by_id`. |
135
+
136
+ ---
137
+
138
+ ## Data Flow
139
+
140
+ ### One-Shot Query (`ask`)
141
+
142
+ ```
143
+ User calls ClaudeAgent.ask(prompt, **kwargs)
144
+ |
145
+ +-- extract_callbacks separates on_* from config overrides
146
+ +-- Configuration.to_options merges global defaults + overrides --> Options
147
+ +-- build_events creates EventHandler from callbacks
148
+ |
149
+ +-- query_turn(prompt, options, events)
150
+ |
151
+ +-- ClaudeAgent.query(prompt, options) returns Enumerator
152
+ | |
153
+ | +-- Transport::Subprocess.new(options)
154
+ | +-- ControlProtocol.new(transport, options)
155
+ | +-- protocol.start(streaming: true)
156
+ | | +-- transport.connect --> spawn CLI subprocess
157
+ | | +-- reader_loop starts in background Thread
158
+ | | +-- send_initialize --> handshake with CLI
159
+ | +-- protocol.send_user_message(prompt)
160
+ | | +-- write JSON to stdin
161
+ | +-- protocol.each_message yields parsed messages
162
+ | +-- reader_loop reads JSON Lines from stdout
163
+ | +-- routes control_request to RequestHandling
164
+ | +-- routes control_response to pending request
165
+ | +-- queues SDK messages for consumer
166
+ | +-- consumer pops from Queue
167
+ | +-- MessageParser.parse(raw) --> typed message
168
+ | +-- yield message to Enumerator
169
+ |
170
+ +-- TurnResult << message (accumulates)
171
+ +-- EventHandler.handle(message) (dispatches events)
172
+ +-- yield message to caller block (if given)
173
+ +-- return TurnResult
174
+ ```
175
+
176
+ ### Multi-Turn Conversation (`chat`)
177
+
178
+ ```
179
+ User calls ClaudeAgent.chat(**kwargs)
180
+ |
181
+ +-- merge_config_into_kwargs applies global defaults
182
+ +-- Conversation.new(**merged)
183
+ |
184
+ +-- partition_kwargs --> callbacks / conversation_kwargs / options_kwargs
185
+ +-- build_options (compiles PermissionPolicy, HookRegistry, permission mode)
186
+ +-- Client.new(options)
187
+ +-- register_callbacks on Client's EventHandler
188
+ +-- register_timing_hooks for tool activity timestamps
189
+ |
190
+ +-- conversation.say(prompt)
191
+ |
192
+ +-- ensure_connected! (auto-connects on first call)
193
+ | +-- Client.connect
194
+ | +-- ControlProtocol.start(streaming: true)
195
+ +-- Client.send_and_receive(prompt)
196
+ | +-- send_message --> protocol.send_user_message
197
+ | +-- receive_turn
198
+ | +-- TurnResult.new
199
+ | +-- receive_response yields messages
200
+ | +-- TurnResult << message
201
+ | +-- EventHandler.handle(message)
202
+ | +-- CumulativeUsage.track(message)
203
+ | +-- stops on ResultMessage
204
+ +-- build_tool_activities (timestamps from hooks)
205
+ +-- return TurnResult
206
+ ```
207
+
208
+ ### Permission Request Flow
209
+
210
+ ```
211
+ CLI sends control_request { subtype: "can_use_tool" }
212
+ |
213
+ +-- reader_loop receives raw message
214
+ +-- handle_control_request dispatches to handle_can_use_tool
215
+ |
216
+ +-- Mode 1: Synchronous callback
217
+ | +-- options.can_use_tool.call(name, input, context)
218
+ | +-- callback returns PermissionResultAllow or PermissionResultDeny
219
+ | +-- (or callback calls context.request.defer! to switch to queue mode)
220
+ |
221
+ +-- Mode 2: Queue-based
222
+ | +-- PermissionRequest created with Mutex + ConditionVariable
223
+ | +-- pushed to PermissionQueue
224
+ | +-- reader thread blocks on perm_request.wait(timeout:)
225
+ | +-- main thread polls client.pending_permission
226
+ | +-- main thread calls request.allow! or request.deny!
227
+ | +-- ConditionVariable.broadcast unblocks reader thread
228
+ |
229
+ +-- Mode 3: Default allow (no callback, no queue)
230
+ |
231
+ +-- normalize_permission_result --> Hash
232
+ +-- send_control_response back to CLI
233
+ ```
234
+
235
+ ---
236
+
237
+ ## Immutable Types
238
+
239
+ All message types and content blocks use `Data.define`, frozen at construction:
240
+
241
+ **Messages**: `UserMessage`, `UserMessageReplay`, `AssistantMessage`, `SystemMessage`, `ResultMessage`, `StreamEvent`, `CompactBoundaryMessage`, `StatusMessage`, `ToolProgressMessage`, `HookResponseMessage`, `AuthStatusMessage`, `TaskNotificationMessage`, `HookStartedMessage`, `HookProgressMessage`, `ToolUseSummaryMessage`, `FilesPersistedEvent`, `TaskStartedMessage`, `TaskProgressMessage`, `RateLimitEvent`, `PromptSuggestionMessage`, `ElicitationCompleteMessage`, `LocalCommandOutputMessage`, `GenericMessage`
242
+
243
+ **Content Blocks**: `TextBlock`, `ThinkingBlock`, `ToolUseBlock`, `ToolResultBlock`, `ServerToolUseBlock`, `ServerToolResultBlock`, `ImageContentBlock`, `GenericBlock`
244
+
245
+ **Data Types**: `SessionInfo`, `SessionMessage`, `ForkSessionResult`, `ToolActivity`, `TaskUsage`, `SDKPermissionDenial`, `RewindFilesResult`, `ToolsPreset`, `SlashCommand`, `McpServerStatus`, `McpSetServersResult`, `PermissionResultAllow`, `PermissionResultDeny`
246
+
247
+ ---
248
+
249
+ ## Thread Safety
250
+
251
+ | Component | Mechanism | Purpose |
252
+ |---------------------------------------------|-------------------------------|----------------------------------------------------------------------------------------------------|
253
+ | `PermissionRequest` | `Mutex` + `ConditionVariable` | Reader thread blocks on `wait`; main thread resolves via `allow!` / `deny!` |
254
+ | `PermissionQueue` | `Queue` (thread-safe stdlib) | Bridges reader thread and consumer thread for permission requests |
255
+ | `AbortSignal` | `Mutex` + `ConditionVariable` | Multiple consumers check `aborted?`; `on_abort` callbacks fire once; `wait` blocks until triggered |
256
+ | `CumulativeUsage` | `Mutex` | All reads and writes synchronized |
257
+ | `ControlProtocol` | `Mutex` + `ConditionVariable` | Shared state for pending requests/results; reader thread signals consumer |
258
+ | `Transport::Subprocess` | `Mutex` | Protects stdin writes and stream close operations |
259
+ | `ControlProtocol.reader_loop` | Background `Thread` | Reads transport, routes control messages, queues SDK messages |
260
+ | `Transport::Subprocess.start_stderr_reader` | Background `Thread` | Drains stderr to prevent pipe buffer fill; forwards to `stderr_callback` |
261
+
262
+ ---
263
+
264
+ ## Types Reference
265
+
266
+ ### Core Return Types
267
+
268
+ | Type | Description |
269
+ |-------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
270
+ | `TurnResult` | Accumulator for a single turn. Accessors: `text`, `thinking`, `tool_uses`, `tool_results`, `tool_executions`, `cost`, `session_id`, `usage`, `model`, `stop_reason`, `structured_output`, `permission_denials` |
271
+ | `CumulativeUsage` | Cross-turn token/cost tracker. Fields: `input_tokens`, `output_tokens`, `cache_read_input_tokens`, `cache_creation_input_tokens`, `total_cost_usd`, `num_turns`, `duration_ms`, `duration_api_ms` |
272
+ | `EventHandler` | Event dispatcher. Events: `message`, `user`, `assistant`, `system`, `result`, `stream_event`, `status`, `tool_progress`, `text`, `thinking`, `tool_use`, `tool_result`, and more |
273
+
274
+ ### Tool Activity
275
+
276
+ | Type | Description |
277
+ |-----------------------|-----------------------------------------------------------------------------------------------|
278
+ | `ToolActivity` | Immutable. Post-turn record of a tool execution with timestamps and turn index |
279
+ | `LiveToolActivity` | Mutable. Real-time status (`:running`, `:done`, `:error`) with elapsed time |
280
+ | `ToolActivityTracker` | Enumerable collection with `on_start` / `on_complete` / `on_progress` / `on_change` callbacks |
281
+
282
+ ### Permissions
283
+
284
+ | Type | Description |
285
+ |-------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|
286
+ | `PermissionRequest` | Deferred request with `allow!` / `deny!` / `defer!`. Thread-safe resolution |
287
+ | `PermissionQueue` | Thread-safe queue with `poll` (non-blocking) and `pop` (blocking) |
288
+ | `PermissionPolicy` | Declarative DSL. Compiles to `can_use_tool` lambda |
289
+ | `PermissionResultAllow` | Allow response with optional `updated_input` and `updated_permissions` |
290
+ | `PermissionResultDeny` | Deny response with `message` and `interrupt` flag |
291
+ | `ToolPermissionContext` | Context passed to `can_use_tool`: `permission_suggestions`, `blocked_path`, `decision_reason`, `tool_use_id`, `agent_id`, `description`, `signal`, `request` |
292
+
293
+ ### Sessions
294
+
295
+ | Type | Description |
296
+ |--------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------|
297
+ | `Session` | Rich finder object. Class methods: `find`, `retrieve`, `all`, `where`. Instance: `messages`, `rename`, `fork`, `resume`, `reload` |
298
+ | `SessionInfo` | Immutable metadata: `session_id`, `summary`, `last_modified`, `file_size`, `custom_title`, `first_prompt`, `git_branch`, `cwd`, `tag`, `created_at` |
299
+ | `SessionMessage` | Immutable transcript entry: `type`, `uuid`, `session_id`, `message`, `parent_tool_use_id` |
300
+ | `SessionMessageRelation` | Chainable Enumerable with `where(limit:, offset:)` |
301
+ | `ForkSessionResult` | Fork result with new `session_id` |
302
+
303
+ ### MCP
304
+
305
+ | Type | Description |
306
+ |-----------------------|-----------------------------------------------------------------------------------------------|
307
+ | `MCP::Server` | In-process MCP server hosting `MCP::Tool` instances |
308
+ | `MCP::Tool` | Tool definition with schema normalization and handler block |
309
+ | `McpServerStatus` | Status of an MCP server: `name`, `status`, `server_info`, `error`, `config`, `scope`, `tools` |
310
+ | `McpSetServersResult` | Result of dynamic server management: `added`, `removed`, `errors` |
311
+
312
+ ### Abort
313
+
314
+ | Type | Description |
315
+ |-------------------|-------------------------------------------------------------------------------------------|
316
+ | `AbortController` | Owns an `AbortSignal`. Methods: `abort(reason)`, `reset!` |
317
+ | `AbortSignal` | Thread-safe signal. Methods: `aborted?`, `reason`, `on_abort`, `wait`, `check!`, `reset!` |
318
+
319
+ ---
320
+
321
+ ## Development
322
+
323
+ ```bash
324
+ bin/setup # Install dependencies
325
+ bundle exec rake # Unit tests + RBS + RuboCop (default task)
326
+ bundle exec rake test # Unit tests only
327
+ bundle exec rake test_integration # Integration tests (requires CLI v2.0.0+)
328
+ bundle exec rake test_smoke # Smoke tests against local LLM (e.g. Ollama)
329
+ bundle exec rake test_all # All tests
330
+ bundle exec rake rbs # Validate RBS type signatures
331
+ bundle exec rubocop # Lint
332
+ bin/console # IRB with gem loaded
333
+ ```
334
+
335
+ **Binstubs**: `bin/test`, `bin/test-integration`, `bin/test-all`, `bin/test-smoke`, `bin/rbs-validate`
336
+
337
+ **Test structure**: Unit tests in `test/claude_agent/` (no CLI required), integration tests in `test/integration/` (require `INTEGRATION=true`), smoke tests in `test/smoke/` (require Ollama + `SMOKE=true`). Support files and mocks in `test/support/`.
338
+
339
+ **Single file**: `bundle exec ruby -Itest test/claude_agent/test_foo.rb`