Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion agent_migrator/agents/claude_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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", ""),
Expand All @@ -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 {
Expand Down Expand Up @@ -730,13 +745,16 @@ def _write_tool_batch(
"type": "tool_result",
"tool_use_id": tool_use_id,
"content": tc.result,
"is_error": tc.status == "error",
}
],
}
result_record["sourceToolAssistantUUID"] = asst_uuid
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

Expand Down
91 changes: 69 additions & 22 deletions agent_migrator/agents/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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.
Expand All @@ -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, {
Expand Down
17 changes: 16 additions & 1 deletion agent_migrator/agents/cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {}
Expand All @@ -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", "")
Expand Down Expand Up @@ -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", "")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions agent_migrator/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down