diff --git a/agent_migrator/agents/claude_code.py b/agent_migrator/agents/claude_code.py index 8e49cb3..bf946fc 100644 --- a/agent_migrator/agents/claude_code.py +++ b/agent_migrator/agents/claude_code.py @@ -458,7 +458,10 @@ def read_conversation(self, conv_id: str, project_path: Path) -> Conversation: else: result_text = str(result_content) if tuid in pending_tool_calls: - pending_tool_calls[tuid].result = result_text + tc = pending_tool_calls[tuid] + tc.result = result_text + if block.get("is_error"): + tc.status = "error" else: # Mixed content with text blocks — skip internal CC protocol messages for block in content: @@ -616,6 +619,14 @@ def _tool_use_result(tc: "ToolCallMessage") -> dict | None: the schema marks as required MUST be present here. """ if tc.name == "Bash": + if tc.status == "error": + return { + "stdout": "", + "stderr": tc.result or "", + "interrupted": False, + "isImage": False, + "noOutputExpected": False, + } # BashTool output schema: {stdout, stderr, interrupted, # isImage, noOutputExpected, ...} — all required/with defaults. # Omitting any causes safeParse to fail → invisible result. @@ -663,6 +674,8 @@ def _tool_use_result(tc: "ToolCallMessage") -> dict | None: }, } if tc.name == "Write": + if tc.status == "error": + return None return { "type": "create", "filePath": tc.input.get("file_path", ""), @@ -671,6 +684,8 @@ def _tool_use_result(tc: "ToolCallMessage") -> dict | None: "originalFile": None, } if tc.name == "Edit": + if tc.status == "error": + return None old_str = tc.input.get("old_string", "") new_str = tc.input.get("new_string", "") return { @@ -730,6 +745,7 @@ def _write_tool_batch( "type": "tool_result", "tool_use_id": tool_use_id, "content": tc.result, + "is_error": tc.status == "error", } ], } @@ -737,6 +753,8 @@ def _write_tool_batch( tur = _tool_use_result(tc) if tur is not None: result_record["toolUseResult"] = tur + elif tc.status == "error": + result_record["toolUseResult"] = tc.result f.write(json.dumps(result_record) + "\n") prev_uuid = result_uuid diff --git a/agent_migrator/agents/codex.py b/agent_migrator/agents/codex.py index 383b91d..cefb6a8 100644 --- a/agent_migrator/agents/codex.py +++ b/agent_migrator/agents/codex.py @@ -344,6 +344,29 @@ def _last_timestamp(path: Path) -> datetime | None: return last_ts +def _patch_apply_statuses(path: Path) -> dict[str, bool]: + """Return Codex apply_patch success states keyed by call_id.""" + statuses: dict[str, bool] = {} + try: + with open(path, encoding="utf-8", errors="replace") as f: + for line in f: + try: + rec = json.loads(line) + except Exception: + continue + if rec.get("type") != "event_msg": + continue + payload = rec.get("payload", {}) + if ( + payload.get("type") == "patch_apply_end" + and payload.get("call_id") + ): + statuses[payload["call_id"]] = bool(payload.get("success")) + except Exception: + pass + return statuses + + def _count_message_lines(path: Path) -> int: """Fast count of user/assistant message records without full parsing.""" count = 0 @@ -466,6 +489,7 @@ def read_conversation(self, conv_id: str, project_path: Path) -> Conversation: turns: list = [] pending_tool_calls: dict[str, ToolCallMessage] = {} + patch_apply_statuses = _patch_apply_statuses(rollout_file) model: str | None = None meta = _read_session_meta(rollout_file) @@ -605,6 +629,21 @@ def read_conversation(self, conv_id: str, project_path: Path) -> Conversation: call_id = payload.get("call_id", "") if native_name == "apply_patch" and isinstance(raw_input, str): + if ( + call_id in patch_apply_statuses + and not patch_apply_statuses[call_id] + ): + tc = ToolCallMessage( + name="apply_patch", + input={"patch": raw_input}, + result="", + timestamp=ts, + status="error", + ) + turns.append(tc) + if call_id: + pending_tool_calls[call_id] = tc + continue # Expand multi-file patch into individual Write/Edit messages patch_messages: list[ToolCallMessage] = [] for op in _read_apply_patch(raw_input): @@ -615,6 +654,7 @@ def read_conversation(self, conv_id: str, project_path: Path) -> Conversation: input={"file_path": file_path, "content": content}, result="File written successfully.", timestamp=ts, + status="success", )) elif op[0] == "update": _, file_path, old_str, new_str = op @@ -627,6 +667,7 @@ def read_conversation(self, conv_id: str, project_path: Path) -> Conversation: }, result="File edited successfully.", timestamp=ts, + status="success", )) # "delete" — omitted; no CC equivalent to show turns.extend(patch_messages) @@ -884,13 +925,14 @@ def _new_call_id() -> str: patch_input = turn.input.get( "patch", json.dumps(turn.input) ) + patch_failed = turn.status == "error" f.write(_make_response_item(t_ts, { "type": "custom_tool_call", "name": "apply_patch", "input": patch_input, "call_id": call_id, - "status": "completed", + "status": "failed" if patch_failed else "completed", }) + "\n") # event_msg/patch_apply_begin + patch_apply_end pair. @@ -899,34 +941,39 @@ def _new_call_id() -> str: # on_patch_apply_begin during live sessions. We emit a # synthetic agent_message per changed file so that file # operations are visible in the /resume history view. - changes = _parse_apply_patch_changes(patch_input) - for changed_path, change_info in changes.items(): - change_type = change_info.get("type", "update") - verb = {"add": "Created", "delete": "Deleted"}.get( - change_type, "Updated" - ) + changes = ( + {} + if patch_failed + else _parse_apply_patch_changes(patch_input) + ) + if not patch_failed: + for changed_path, change_info in changes.items(): + change_type = change_info.get("type", "update") + verb = {"add": "Created", "delete": "Deleted"}.get( + change_type, "Updated" + ) + f.write(_make_event({ + "type": "agent_message", + "message": f"{verb} `{changed_path}`", + "phase": "commentary", + "memory_citation": None, + }) + "\n") f.write(_make_event({ - "type": "agent_message", - "message": f"{verb} `{changed_path}`", - "phase": "commentary", - "memory_citation": None, + "type": "patch_apply_begin", + "call_id": call_id, + "turn_id": turn_id, + "auto_approved": True, + "changes": changes, }) + "\n") - f.write(_make_event({ - "type": "patch_apply_begin", - "call_id": call_id, - "turn_id": turn_id, - "auto_approved": True, - "changes": changes, - }) + "\n") f.write(_make_event({ "type": "patch_apply_end", "call_id": call_id, "turn_id": turn_id, - "stdout": turn.result or "Applied.", - "stderr": "", - "success": True, + "stdout": "" if patch_failed else (turn.result or "Applied."), + "stderr": turn.result if patch_failed else "", + "success": not patch_failed, "changes": changes, - "status": "completed", + "status": "failed" if patch_failed else "completed", }) + "\n") f.write(_make_response_item(t_ts, { diff --git a/agent_migrator/agents/cursor.py b/agent_migrator/agents/cursor.py index d58fc99..abd3763 100644 --- a/agent_migrator/agents/cursor.py +++ b/agent_migrator/agents/cursor.py @@ -68,6 +68,11 @@ class ServerUploadError(AgentNetworkError): } +def _cursor_tool_status(tfd: dict) -> str: + status = str(tfd.get("status", "")).lower() + return "error" if status in {"error", "failed"} else "success" + + def _adapt_cursor_tool( tfd: dict, bubble: dict, @@ -85,6 +90,7 @@ def _adapt_cursor_tool( raw_args_str = tfd.get("rawArgs", "") params_str = tfd.get("params", "") result_str = tfd.get("result", "") + failed = _cursor_tool_status(tfd) == "error" try: raw = json.loads(raw_args_str) if raw_args_str else {} @@ -101,6 +107,9 @@ def _adapt_cursor_tool( cc_name = _CURSOR_TO_STANDARD_TOOL_MAP.get(name, name) + if failed and cc_name in {"Edit", "Write", "MultiEdit", "NotebookEdit"}: + return name, raw or params, result_str + if cc_name == "Read": # rawArgs: {"path": "..."} — maps to CC file_path file_path = raw.get("path") or params.get("targetFile", "") @@ -581,6 +590,11 @@ def _adapt_cc_tool( cursor_name, tool_num = _STANDARD_TO_CURSOR_TOOL_MAP.get(name, (name, 0)) code_blocks: list = [] + if turn.status == "error" and name in { + "Edit", "Write", "MultiEdit", "NotebookEdit" + }: + return cursor_name, tool_num, json.dumps(inp), raw_result, [], {} + # Build Cursor-format params, result, and codeBlocks based on tool type. if name == "Read": file_path = inp.get("file_path", "") @@ -1370,6 +1384,7 @@ def read_conversation(self, conv_id: str, project_path: Path) -> Conversation: name=tool_name, input=input_dict, result=result, + status=_cursor_tool_status(tfd), )) else: # Regular assistant text bubble @@ -1619,7 +1634,7 @@ def _make_bubble_base(bubble_id: str, btype: int, ts_iso: str) -> dict: "toolCallId": tool_call_id, "toolIndex": 0, "modelCallId": tool_call_id, - "status": "completed", + "status": "error" if turn.status == "error" else "completed", "name": cursor_name, "rawArgs": json.dumps(turn.input), "tool": tool_num, diff --git a/agent_migrator/models.py b/agent_migrator/models.py index 4fdee33..26c55f3 100644 --- a/agent_migrator/models.py +++ b/agent_migrator/models.py @@ -56,6 +56,7 @@ class ToolCallMessage: input: dict result: str # serialized result string timestamp: datetime | None = None + status: Literal["success", "error", "unknown"] = "unknown" MessageTurn = Union[TextMessage, ToolCallMessage]