ralphx 0.3.4__py3-none-any.whl → 0.4.0__py3-none-any.whl
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.
- ralphx/__init__.py +1 -1
- ralphx/adapters/base.py +10 -2
- ralphx/adapters/claude_cli.py +222 -82
- ralphx/api/routes/auth.py +780 -98
- ralphx/api/routes/config.py +3 -56
- ralphx/api/routes/export_import.py +6 -9
- ralphx/api/routes/loops.py +4 -4
- ralphx/api/routes/planning.py +882 -19
- ralphx/api/routes/resources.py +528 -6
- ralphx/api/routes/stream.py +58 -56
- ralphx/api/routes/templates.py +2 -2
- ralphx/api/routes/workflows.py +258 -47
- ralphx/cli.py +4 -1
- ralphx/core/auth.py +372 -172
- ralphx/core/database.py +588 -164
- ralphx/core/executor.py +170 -19
- ralphx/core/loop.py +15 -2
- ralphx/core/loop_templates.py +29 -3
- ralphx/core/planning_iteration_executor.py +633 -0
- ralphx/core/planning_service.py +119 -24
- ralphx/core/preview.py +9 -25
- ralphx/core/project_db.py +864 -121
- ralphx/core/project_export.py +1 -5
- ralphx/core/project_import.py +14 -29
- ralphx/core/resources.py +28 -2
- ralphx/core/sample_project.py +1 -5
- ralphx/core/templates.py +9 -9
- ralphx/core/workflow_executor.py +32 -3
- ralphx/core/workflow_export.py +4 -7
- ralphx/core/workflow_import.py +3 -27
- ralphx/mcp/__init__.py +6 -2
- ralphx/mcp/registry.py +3 -3
- ralphx/mcp/tools/diagnostics.py +1 -1
- ralphx/mcp/tools/monitoring.py +10 -16
- ralphx/mcp/tools/workflows.py +115 -33
- ralphx/mcp_server.py +6 -2
- ralphx/static/assets/index-BuLI7ffn.css +1 -0
- ralphx/static/assets/index-DWvlqOTb.js +264 -0
- ralphx/static/assets/index-DWvlqOTb.js.map +1 -0
- ralphx/static/index.html +2 -2
- ralphx/templates/loop_templates/consumer.md +2 -2
- {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/METADATA +33 -12
- {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/RECORD +45 -44
- ralphx/static/assets/index-CcRDyY3b.css +0 -1
- ralphx/static/assets/index-CcxfTosc.js +0 -251
- ralphx/static/assets/index-CcxfTosc.js.map +0 -1
- {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/WHEEL +0 -0
- {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/entry_points.txt +0 -0
ralphx/__init__.py
CHANGED
ralphx/adapters/base.py
CHANGED
|
@@ -5,7 +5,7 @@ from dataclasses import dataclass, field
|
|
|
5
5
|
from datetime import datetime
|
|
6
6
|
from enum import Enum
|
|
7
7
|
from pathlib import Path
|
|
8
|
-
from typing import Any, AsyncIterator, Optional
|
|
8
|
+
from typing import Any, AsyncIterator, Callable, Optional
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
class AdapterEvent(str, Enum):
|
|
@@ -100,6 +100,8 @@ class LLMAdapter(ABC):
|
|
|
100
100
|
tools: Optional[list[str]] = None,
|
|
101
101
|
timeout: int = 300,
|
|
102
102
|
json_schema: Optional[dict] = None,
|
|
103
|
+
on_session_start: Optional[Callable[[str], None]] = None,
|
|
104
|
+
on_event: Optional[Callable[["StreamEvent"], None]] = None,
|
|
103
105
|
) -> ExecutionResult:
|
|
104
106
|
"""Execute a prompt and return the result.
|
|
105
107
|
|
|
@@ -110,6 +112,8 @@ class LLMAdapter(ABC):
|
|
|
110
112
|
timeout: Timeout in seconds.
|
|
111
113
|
json_schema: Optional JSON schema for structured output validation.
|
|
112
114
|
When provided, the result will include structured_output.
|
|
115
|
+
on_session_start: Optional callback fired when session ID is available.
|
|
116
|
+
on_event: Optional callback fired for each streaming event (for persistence).
|
|
113
117
|
|
|
114
118
|
Returns:
|
|
115
119
|
ExecutionResult with session info and output.
|
|
@@ -174,6 +178,10 @@ class LLMAdapter(ABC):
|
|
|
174
178
|
Marker string to append to prompt.
|
|
175
179
|
"""
|
|
176
180
|
now = datetime.utcnow().isoformat()
|
|
181
|
+
# Sanitize values to prevent HTML comment injection (e.g., --> in mode name)
|
|
182
|
+
safe_run_id = run_id.replace("--", "").replace('"', "")
|
|
183
|
+
safe_slug = project_slug.replace("--", "").replace('"', "")
|
|
184
|
+
safe_mode = mode.replace("--", "").replace('"', "")
|
|
177
185
|
return f"""
|
|
178
186
|
|
|
179
|
-
<!-- RALPHX_TRACKING run_id="{
|
|
187
|
+
<!-- RALPHX_TRACKING run_id="{safe_run_id}" project="{safe_slug}" iteration={iteration} mode="{safe_mode}" ts="{now}" -->"""
|
ralphx/adapters/claude_cli.py
CHANGED
|
@@ -115,10 +115,16 @@ class ClaudeCLIAdapter(LLMAdapter):
|
|
|
115
115
|
if self._settings_path and self._settings_path.exists():
|
|
116
116
|
cmd.extend(["--settings", str(self._settings_path)])
|
|
117
117
|
|
|
118
|
-
#
|
|
118
|
+
# Configure available tools
|
|
119
|
+
# --tools specifies which built-in tools are available
|
|
120
|
+
# --allowedTools is for fine-grained permission patterns like Bash(git:*)
|
|
119
121
|
if tools:
|
|
120
|
-
|
|
121
|
-
|
|
122
|
+
# Enable specific tools
|
|
123
|
+
cmd.extend(["--tools", ",".join(tools)])
|
|
124
|
+
else:
|
|
125
|
+
# Disable all built-in tools to prevent Claude from trying to use them
|
|
126
|
+
# Without this, Claude may try to use Read/Edit/etc and hit API errors
|
|
127
|
+
cmd.extend(["--tools", ""])
|
|
122
128
|
|
|
123
129
|
return cmd
|
|
124
130
|
|
|
@@ -130,6 +136,7 @@ class ClaudeCLIAdapter(LLMAdapter):
|
|
|
130
136
|
timeout: int = 300,
|
|
131
137
|
json_schema: Optional[dict] = None,
|
|
132
138
|
on_session_start: Optional[Callable[[str], None]] = None,
|
|
139
|
+
on_event: Optional[Callable[[StreamEvent], None]] = None,
|
|
133
140
|
) -> ExecutionResult:
|
|
134
141
|
"""Execute a prompt and return the result.
|
|
135
142
|
|
|
@@ -145,7 +152,10 @@ class ClaudeCLIAdapter(LLMAdapter):
|
|
|
145
152
|
"""
|
|
146
153
|
# When using json_schema, use dedicated non-streaming execution
|
|
147
154
|
if json_schema:
|
|
148
|
-
return await self._execute_with_schema(
|
|
155
|
+
return await self._execute_with_schema(
|
|
156
|
+
prompt, model, tools, timeout, json_schema,
|
|
157
|
+
on_session_start=on_session_start, on_event=on_event,
|
|
158
|
+
)
|
|
149
159
|
|
|
150
160
|
# Standard streaming execution
|
|
151
161
|
import logging
|
|
@@ -176,10 +186,20 @@ class ClaudeCLIAdapter(LLMAdapter):
|
|
|
176
186
|
elif event.type == AdapterEvent.COMPLETE:
|
|
177
187
|
result.exit_code = event.data.get("exit_code", 0)
|
|
178
188
|
|
|
189
|
+
# Fire on_event callback for event persistence
|
|
190
|
+
if on_event:
|
|
191
|
+
on_event(event)
|
|
192
|
+
|
|
179
193
|
except asyncio.TimeoutError:
|
|
180
194
|
result.timeout = True
|
|
181
195
|
result.success = False
|
|
182
196
|
result.error_message = f"Execution timed out after {timeout}s"
|
|
197
|
+
if on_event:
|
|
198
|
+
on_event(StreamEvent(
|
|
199
|
+
type=AdapterEvent.ERROR,
|
|
200
|
+
error_message=result.error_message,
|
|
201
|
+
error_code="TIMEOUT",
|
|
202
|
+
))
|
|
183
203
|
await self.stop()
|
|
184
204
|
|
|
185
205
|
result.completed_at = datetime.utcnow()
|
|
@@ -200,6 +220,8 @@ class ClaudeCLIAdapter(LLMAdapter):
|
|
|
200
220
|
tools: Optional[list[str]],
|
|
201
221
|
timeout: int,
|
|
202
222
|
json_schema: dict,
|
|
223
|
+
on_session_start: Optional[Callable[[str], None]] = None,
|
|
224
|
+
on_event: Optional[Callable[[StreamEvent], None]] = None,
|
|
203
225
|
) -> ExecutionResult:
|
|
204
226
|
"""Execute with JSON schema for structured output.
|
|
205
227
|
|
|
@@ -277,6 +299,35 @@ class ClaudeCLIAdapter(LLMAdapter):
|
|
|
277
299
|
# Extract text from result if available
|
|
278
300
|
result.text_output = data.get("result", "")
|
|
279
301
|
|
|
302
|
+
# Fire callbacks for event persistence
|
|
303
|
+
if on_session_start and result.session_id:
|
|
304
|
+
on_session_start(result.session_id)
|
|
305
|
+
if on_event:
|
|
306
|
+
# Save metadata about the execution
|
|
307
|
+
on_event(StreamEvent(
|
|
308
|
+
type=AdapterEvent.INIT,
|
|
309
|
+
data={
|
|
310
|
+
"session_id": result.session_id,
|
|
311
|
+
"num_turns": data.get("num_turns"),
|
|
312
|
+
"cost_usd": data.get("cost_usd"),
|
|
313
|
+
"is_error": data.get("is_error"),
|
|
314
|
+
},
|
|
315
|
+
))
|
|
316
|
+
# Save the result text
|
|
317
|
+
if result.text_output:
|
|
318
|
+
on_event(StreamEvent(
|
|
319
|
+
type=AdapterEvent.TEXT,
|
|
320
|
+
text=result.text_output,
|
|
321
|
+
))
|
|
322
|
+
# Save completion/error
|
|
323
|
+
if result.success:
|
|
324
|
+
on_event(StreamEvent(type=AdapterEvent.COMPLETE))
|
|
325
|
+
elif result.error_message:
|
|
326
|
+
on_event(StreamEvent(
|
|
327
|
+
type=AdapterEvent.ERROR,
|
|
328
|
+
error_message=result.error_message,
|
|
329
|
+
))
|
|
330
|
+
|
|
280
331
|
except json.JSONDecodeError as e:
|
|
281
332
|
result.success = False
|
|
282
333
|
result.error_message = f"Failed to parse JSON output: {e}"
|
|
@@ -332,6 +383,10 @@ class ClaudeCLIAdapter(LLMAdapter):
|
|
|
332
383
|
Yields:
|
|
333
384
|
StreamEvent objects as execution progresses.
|
|
334
385
|
"""
|
|
386
|
+
# Reset session_id to prevent stale values from previous executions
|
|
387
|
+
# leaking into results when the new execution fails before INIT
|
|
388
|
+
self._session_id = None
|
|
389
|
+
|
|
335
390
|
# Validate and refresh token if needed (before spawning)
|
|
336
391
|
# Use validate=True to actually test the token works
|
|
337
392
|
if not await refresh_token_if_needed(self._project_id, validate=True):
|
|
@@ -354,6 +409,15 @@ class ClaudeCLIAdapter(LLMAdapter):
|
|
|
354
409
|
|
|
355
410
|
cmd = self._build_command(model, tools)
|
|
356
411
|
|
|
412
|
+
# Log the exact command for debugging concurrency issues
|
|
413
|
+
import logging
|
|
414
|
+
_cli_log = logging.getLogger(__name__)
|
|
415
|
+
_cli_log.warning(f"[CLAUDE_CLI] Running command: {' '.join(cmd)}")
|
|
416
|
+
_cli_log.warning(f"[CLAUDE_CLI] Working dir: {self.project_path}")
|
|
417
|
+
_cli_log.warning(f"[CLAUDE_CLI] Tools: {tools}")
|
|
418
|
+
_cli_log.warning(f"[CLAUDE_CLI] Prompt length: {len(prompt)} chars")
|
|
419
|
+
_cli_log.warning(f"[CLAUDE_CLI] Prompt preview: {prompt[:500]}...")
|
|
420
|
+
|
|
357
421
|
# Start the process
|
|
358
422
|
self._process = await asyncio.create_subprocess_exec(
|
|
359
423
|
*cmd,
|
|
@@ -361,6 +425,7 @@ class ClaudeCLIAdapter(LLMAdapter):
|
|
|
361
425
|
stdout=asyncio.subprocess.PIPE,
|
|
362
426
|
stderr=asyncio.subprocess.PIPE,
|
|
363
427
|
cwd=str(self.project_path),
|
|
428
|
+
limit=4 * 1024 * 1024, # 4MB buffer for large JSON lines (e.g. Edit tool inputs)
|
|
364
429
|
)
|
|
365
430
|
|
|
366
431
|
# Send the prompt
|
|
@@ -392,53 +457,98 @@ class ClaudeCLIAdapter(LLMAdapter):
|
|
|
392
457
|
return b"".join(chunks)
|
|
393
458
|
|
|
394
459
|
try:
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
event = self._parse_event(data)
|
|
420
|
-
if event:
|
|
421
|
-
if event.type == AdapterEvent.TEXT:
|
|
422
|
-
text_events += 1
|
|
423
|
-
text_preview = (event.text or "")[:80].replace("\n", "\\n")
|
|
424
|
-
_stream_log.warning(f"[STREAM] TEXT #{text_events}: {len(event.text or '')} chars, preview: {text_preview}")
|
|
425
|
-
yield event
|
|
426
|
-
except json.JSONDecodeError:
|
|
427
|
-
# Non-JSON output, treat as plain text
|
|
428
|
-
yield StreamEvent(
|
|
429
|
-
type=AdapterEvent.TEXT,
|
|
430
|
-
text=line_text,
|
|
431
|
-
)
|
|
432
|
-
_stream_log.warning(f"[STREAM] Done: {line_count} lines, {text_events} TEXT events")
|
|
460
|
+
# Start stderr drain early to prevent buffer deadlock
|
|
461
|
+
if self._process.stderr:
|
|
462
|
+
stderr_task = asyncio.create_task(drain_stderr())
|
|
463
|
+
|
|
464
|
+
if self._process.stdout:
|
|
465
|
+
import logging
|
|
466
|
+
import time
|
|
467
|
+
_stream_log = logging.getLogger(__name__)
|
|
468
|
+
line_count = 0
|
|
469
|
+
text_events = 0
|
|
470
|
+
|
|
471
|
+
# Two timeout strategies:
|
|
472
|
+
# 1. line_timeout: Max time to wait for ANY output (prevents deadlock)
|
|
473
|
+
# 2. meaningful_timeout: Max time since last meaningful event (TEXT/TOOL_USE/TOOL_RESULT)
|
|
474
|
+
line_timeout = 30 # 30s max wait for any line
|
|
475
|
+
meaningful_timeout = min(max(timeout - 30, 60), 270) # Scale with timeout param, min 60s, max 4.5 min
|
|
476
|
+
last_meaningful_time = time.time()
|
|
477
|
+
|
|
478
|
+
async def read_line_with_timeout():
|
|
479
|
+
"""Read a line with timeout."""
|
|
480
|
+
return await asyncio.wait_for(
|
|
481
|
+
self._process.stdout.readline(),
|
|
482
|
+
timeout=line_timeout
|
|
483
|
+
)
|
|
433
484
|
|
|
434
|
-
|
|
435
|
-
|
|
485
|
+
while True:
|
|
486
|
+
# Check meaningful event timeout
|
|
487
|
+
time_since_meaningful = time.time() - last_meaningful_time
|
|
488
|
+
if time_since_meaningful > meaningful_timeout:
|
|
489
|
+
_stream_log.warning(f"[STREAM] Meaningful event timeout after {time_since_meaningful:.0f}s")
|
|
490
|
+
raise asyncio.TimeoutError(f"No meaningful output for {meaningful_timeout}s")
|
|
491
|
+
|
|
492
|
+
try:
|
|
493
|
+
line = await read_line_with_timeout()
|
|
494
|
+
if not line: # EOF
|
|
495
|
+
break
|
|
496
|
+
except asyncio.TimeoutError:
|
|
497
|
+
# Check if it's been too long since meaningful event
|
|
498
|
+
time_since_meaningful = time.time() - last_meaningful_time
|
|
499
|
+
if time_since_meaningful > meaningful_timeout:
|
|
500
|
+
_stream_log.warning(f"[STREAM] Meaningful event timeout after {time_since_meaningful:.0f}s")
|
|
501
|
+
raise
|
|
502
|
+
# Otherwise keep waiting - Claude might be working on a tool
|
|
503
|
+
_stream_log.info(f"[STREAM] No line for {line_timeout}s, but meaningful event was {time_since_meaningful:.0f}s ago, continuing...")
|
|
504
|
+
continue
|
|
505
|
+
|
|
506
|
+
line_count += 1
|
|
507
|
+
try:
|
|
508
|
+
line_text = line.decode(errors="replace").strip()
|
|
509
|
+
except Exception:
|
|
510
|
+
continue # Skip lines that can't be decoded
|
|
511
|
+
if not line_text:
|
|
512
|
+
continue
|
|
513
|
+
|
|
514
|
+
try:
|
|
515
|
+
data = json.loads(line_text)
|
|
516
|
+
msg_type = data.get("type", "unknown")
|
|
517
|
+
# Log full event structure for debugging
|
|
518
|
+
_stream_log.warning(f"[STREAM] Line {line_count}: type={msg_type}, keys={list(data.keys())}")
|
|
519
|
+
if msg_type not in ("content_block_delta", "text"):
|
|
520
|
+
_stream_log.warning(f"[STREAM] Full event: {json.dumps(data)[:500]}")
|
|
521
|
+
|
|
522
|
+
# Parse events (may return multiple for assistant messages with multiple blocks)
|
|
523
|
+
for event in self._parse_events(data):
|
|
524
|
+
# Reset meaningful timeout on actual content
|
|
525
|
+
if event.type in (AdapterEvent.TEXT, AdapterEvent.TOOL_USE, AdapterEvent.TOOL_RESULT, AdapterEvent.INIT):
|
|
526
|
+
last_meaningful_time = time.time()
|
|
527
|
+
|
|
528
|
+
if event.type == AdapterEvent.TEXT:
|
|
529
|
+
text_events += 1
|
|
530
|
+
text_preview = (event.text or "")[:80].replace("\n", "\\n")
|
|
531
|
+
_stream_log.warning(f"[STREAM] TEXT #{text_events}: {len(event.text or '')} chars, preview: {text_preview}")
|
|
532
|
+
elif event.type == AdapterEvent.TOOL_USE:
|
|
533
|
+
_stream_log.warning(f"[STREAM] TOOL_USE: {event.tool_name}")
|
|
534
|
+
yield event
|
|
535
|
+
except json.JSONDecodeError:
|
|
536
|
+
# Non-JSON output, treat as plain text
|
|
537
|
+
last_meaningful_time = time.time() # Plain text is meaningful
|
|
538
|
+
yield StreamEvent(
|
|
539
|
+
type=AdapterEvent.TEXT,
|
|
540
|
+
text=line_text,
|
|
541
|
+
)
|
|
542
|
+
_stream_log.warning(f"[STREAM] Done: {line_count} lines, {text_events} TEXT events")
|
|
543
|
+
|
|
544
|
+
# Wait for process to complete
|
|
545
|
+
await self._process.wait()
|
|
436
546
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
547
|
+
# Collect stderr result
|
|
548
|
+
if stderr_task:
|
|
549
|
+
stderr_data = await stderr_task
|
|
550
|
+
if stderr_data:
|
|
551
|
+
stderr_content.append(stderr_data.decode(errors="replace").strip())
|
|
442
552
|
|
|
443
553
|
except asyncio.TimeoutError:
|
|
444
554
|
# Cancel stderr task if still running
|
|
@@ -450,16 +560,19 @@ class ClaudeCLIAdapter(LLMAdapter):
|
|
|
450
560
|
pass
|
|
451
561
|
yield StreamEvent(
|
|
452
562
|
type=AdapterEvent.ERROR,
|
|
453
|
-
error_message=
|
|
563
|
+
error_message="Stream timed out - no response for too long",
|
|
454
564
|
error_code="TIMEOUT",
|
|
455
565
|
)
|
|
456
566
|
await self.stop()
|
|
457
|
-
raise
|
|
567
|
+
return # Don't re-raise - we handled it by yielding ERROR
|
|
458
568
|
|
|
459
569
|
# Emit error if non-zero exit code or stderr content
|
|
460
570
|
exit_code = self._process.returncode or 0
|
|
461
571
|
if exit_code != 0 or stderr_content:
|
|
462
572
|
stderr_text = "\n".join(stderr_content)
|
|
573
|
+
# Log full stderr for debugging (before truncation)
|
|
574
|
+
_cli_log.warning(f"[CLAUDE_CLI] Exit code: {exit_code}")
|
|
575
|
+
_cli_log.warning(f"[CLAUDE_CLI] Full stderr ({len(stderr_text)} chars): {stderr_text}")
|
|
463
576
|
error_msg = f"Claude CLI error (exit {exit_code})"
|
|
464
577
|
if stderr_text:
|
|
465
578
|
# Truncate stderr to 500 chars with indicator
|
|
@@ -481,95 +594,122 @@ class ClaudeCLIAdapter(LLMAdapter):
|
|
|
481
594
|
|
|
482
595
|
self._process = None
|
|
483
596
|
|
|
484
|
-
def
|
|
485
|
-
"""Parse a stream-json event into
|
|
597
|
+
def _parse_events(self, data: dict) -> list[StreamEvent]:
|
|
598
|
+
"""Parse a stream-json event into StreamEvent(s).
|
|
486
599
|
|
|
487
600
|
Args:
|
|
488
601
|
data: Parsed JSON data from stdout.
|
|
489
602
|
|
|
490
603
|
Returns:
|
|
491
|
-
|
|
604
|
+
List of StreamEvents (may be empty if not recognized).
|
|
492
605
|
"""
|
|
606
|
+
events = []
|
|
493
607
|
msg_type = data.get("type")
|
|
494
608
|
|
|
495
609
|
# Init message with session ID (only for system/init events)
|
|
496
610
|
if msg_type in ("init", "system"):
|
|
497
611
|
self._session_id = data.get("session_id")
|
|
498
|
-
|
|
612
|
+
events.append(StreamEvent(
|
|
499
613
|
type=AdapterEvent.INIT,
|
|
500
614
|
data={"session_id": self._session_id},
|
|
501
|
-
)
|
|
615
|
+
))
|
|
616
|
+
return events
|
|
502
617
|
|
|
503
|
-
# Content block events
|
|
618
|
+
# Content block events (streaming API format)
|
|
504
619
|
if msg_type == "content_block_delta":
|
|
505
620
|
delta = data.get("delta", {})
|
|
506
621
|
delta_type = delta.get("type")
|
|
507
622
|
|
|
508
623
|
if delta_type == "text_delta":
|
|
509
|
-
|
|
624
|
+
events.append(StreamEvent(
|
|
510
625
|
type=AdapterEvent.TEXT,
|
|
511
626
|
text=delta.get("text", ""),
|
|
512
|
-
)
|
|
627
|
+
))
|
|
513
628
|
|
|
514
|
-
|
|
515
|
-
# Tool input being streamed
|
|
516
|
-
return None # Accumulate in content_block_stop
|
|
629
|
+
return events
|
|
517
630
|
|
|
518
631
|
if msg_type == "content_block_start":
|
|
519
632
|
content_block = data.get("content_block", {})
|
|
520
633
|
if content_block.get("type") == "tool_use":
|
|
521
|
-
|
|
634
|
+
events.append(StreamEvent(
|
|
522
635
|
type=AdapterEvent.TOOL_USE,
|
|
523
636
|
tool_name=content_block.get("name"),
|
|
524
637
|
tool_input=content_block.get("input", {}),
|
|
525
|
-
)
|
|
638
|
+
))
|
|
639
|
+
return events
|
|
526
640
|
|
|
527
641
|
# Tool result (from Claude Code's output)
|
|
528
642
|
if msg_type == "tool_result":
|
|
529
|
-
|
|
643
|
+
events.append(StreamEvent(
|
|
530
644
|
type=AdapterEvent.TOOL_RESULT,
|
|
531
645
|
tool_name=data.get("name"),
|
|
532
646
|
tool_result=data.get("result"),
|
|
533
|
-
)
|
|
647
|
+
))
|
|
648
|
+
return events
|
|
534
649
|
|
|
535
650
|
# Error events
|
|
536
651
|
if msg_type == "error":
|
|
537
|
-
|
|
652
|
+
events.append(StreamEvent(
|
|
538
653
|
type=AdapterEvent.ERROR,
|
|
539
654
|
error_message=data.get("message", "Unknown error"),
|
|
540
655
|
error_code=data.get("code"),
|
|
541
|
-
)
|
|
656
|
+
))
|
|
657
|
+
return events
|
|
542
658
|
|
|
543
659
|
# Assistant message with content
|
|
544
660
|
# Claude Code stream-json format: {"type": "assistant", "message": {"content": [...]}}
|
|
661
|
+
# Can contain multiple content blocks (text, tool_use, etc.) - emit ALL of them
|
|
545
662
|
if msg_type == "assistant":
|
|
546
663
|
message = data.get("message", {})
|
|
547
664
|
content = message.get("content") or data.get("content")
|
|
548
665
|
if isinstance(content, list):
|
|
549
666
|
for block in content:
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
667
|
+
block_type = block.get("type")
|
|
668
|
+
if block_type == "tool_use":
|
|
669
|
+
events.append(StreamEvent(
|
|
670
|
+
type=AdapterEvent.TOOL_USE,
|
|
671
|
+
tool_name=block.get("name"),
|
|
672
|
+
tool_input=block.get("input", {}),
|
|
673
|
+
))
|
|
674
|
+
elif block_type == "text":
|
|
675
|
+
text = block.get("text", "")
|
|
676
|
+
if text: # Only emit non-empty text
|
|
677
|
+
events.append(StreamEvent(
|
|
678
|
+
type=AdapterEvent.TEXT,
|
|
679
|
+
text=text,
|
|
680
|
+
))
|
|
681
|
+
return events
|
|
682
|
+
|
|
683
|
+
# Tool result from Claude CLI (sent as user message with nested tool_result blocks)
|
|
684
|
+
if msg_type == "user":
|
|
685
|
+
message = data.get("message", {})
|
|
686
|
+
content = message.get("content", [])
|
|
687
|
+
if isinstance(content, list):
|
|
688
|
+
for block in content:
|
|
689
|
+
if isinstance(block, dict) and block.get("type") == "tool_result":
|
|
690
|
+
result_text = block.get("content", "")
|
|
691
|
+
events.append(StreamEvent(
|
|
692
|
+
type=AdapterEvent.TOOL_RESULT,
|
|
693
|
+
tool_name=None,
|
|
694
|
+
tool_result=str(result_text)[:500],
|
|
695
|
+
))
|
|
696
|
+
return events
|
|
555
697
|
|
|
556
698
|
# Result event contains the complete output (final message)
|
|
699
|
+
# Don't duplicate - the assistant message already has the text
|
|
557
700
|
if msg_type == "result":
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
return StreamEvent(
|
|
561
|
-
type=AdapterEvent.TEXT,
|
|
562
|
-
text=result_text,
|
|
563
|
-
)
|
|
701
|
+
# Only emit if we haven't seen any text yet (edge case)
|
|
702
|
+
pass
|
|
564
703
|
|
|
565
704
|
# Message completion
|
|
566
705
|
if msg_type == "message_stop":
|
|
567
|
-
|
|
706
|
+
events.append(StreamEvent(
|
|
568
707
|
type=AdapterEvent.COMPLETE,
|
|
569
708
|
data={"session_id": self._session_id},
|
|
570
|
-
)
|
|
709
|
+
))
|
|
710
|
+
return events
|
|
571
711
|
|
|
572
|
-
return
|
|
712
|
+
return events
|
|
573
713
|
|
|
574
714
|
@staticmethod
|
|
575
715
|
def is_available() -> bool:
|