Convert OpenCode events into the shared SDK-style trajectory shape. Text/tool parts are merged by part id, turns are split on OpenCode step-finish boundaries, and the canonical answer is scoped to the final assistant message instead of concatenating all intermediate narration.
(
events: list[dict[str, Any]],
plain_lines: list[str],
*,
duration_ms: float | None = None,
stderr: str = "",
)
| 421 | |
| 422 | |
| 423 | def _build_trajectory_from_events( |
| 424 | events: list[dict[str, Any]], |
| 425 | plain_lines: list[str], |
| 426 | *, |
| 427 | duration_ms: float | None = None, |
| 428 | stderr: str = "", |
| 429 | ) -> tuple[str, OpenCodeTrajectory]: |
| 430 | """Convert OpenCode events into the shared SDK-style trajectory shape. |
| 431 | |
| 432 | Text/tool parts are merged by part id, turns are split on OpenCode |
| 433 | step-finish boundaries, and the canonical answer is scoped to the final |
| 434 | assistant message instead of concatenating all intermediate narration. |
| 435 | """ |
| 436 | text_by_part: OrderedDict[str, str] = OrderedDict() |
| 437 | msg_by_part: dict[str, str] = {} |
| 438 | tool_calls: OrderedDict[str, ToolCall] = OrderedDict() |
| 439 | |
| 440 | steps: list[dict[str, Any]] = [] |
| 441 | pending_text: list[str] = [] |
| 442 | pending_tools: list[str] = [] |
| 443 | |
| 444 | def _close_step(input_tokens: int, output_tokens: int) -> None: |
| 445 | if not (pending_text or pending_tools or input_tokens or output_tokens): |
| 446 | return |
| 447 | steps.append( |
| 448 | { |
| 449 | "text_ids": list(pending_text), |
| 450 | "tool_ids": list(pending_tools), |
| 451 | "input_tokens": input_tokens, |
| 452 | "output_tokens": output_tokens, |
| 453 | } |
| 454 | ) |
| 455 | pending_text.clear() |
| 456 | pending_tools.clear() |
| 457 | |
| 458 | for index, event in enumerate(events): |
| 459 | part = _candidate_part(event) |
| 460 | if part is None: |
| 461 | part = event |
| 462 | |
| 463 | part_type = str(part.get("type") or part.get("kind") or "").lower() |
| 464 | part_id = str( |
| 465 | part.get("id") |
| 466 | or part.get("partID") |
| 467 | or part.get("messageID") |
| 468 | or f"event_{index}" |
| 469 | ) |
| 470 | |
| 471 | if _is_step_finish(event, part, part_type): |
| 472 | step_in, step_out = _usage_from_events([event]) |
| 473 | _close_step(step_in, step_out) |
| 474 | continue |
| 475 | |
| 476 | text_value = part.get("text") or part.get("content") |
| 477 | if isinstance(text_value, str) and _is_answer_text(part_type, part): |
| 478 | text_by_part[part_id] = _merge_text( |
| 479 | text_by_part.get(part_id, ""), text_value |
| 480 | ) |