feat: v0.4.1 — ChatGPT tool-output content types and conv_id fix
First real-data export against v0.4.0 surfaced 66 unknown blocks across three content types — captured live and added. Added: - execution_output (Code Interpreter / container.exec / python tool output) → tool_result block. output=content.text, tool_name=author.name, is_error=metadata.aggregate_result.status, summary=metadata.reasoning_title - system_error → error tool_result with tool_name=author.name - tether_browsing_display: spinner placeholders (empty result+summary) skip silently with DEBUG log; defensive populated-case branch maps to tool_result (untested in real data) - tool_result block schema: optional `summary` field rendered as italic line between header and fence - tool_result rendering: tool_name appears in header when present (e.g. `📤 Result: container.exec`); existing tool_name=None calls unchanged - _ROLE_LABELS["tool"] = ("🔧 Tool", "tool") Fixed: - chatgpt.normalize_conversation reads `conversation_id` as fallback for `id`. Live API uses conversation_id; fixtures use id. Pre-fix: empty id in YAML frontmatter and missing context in WARNING logs. Tests: 11 new (192 total, 0 failures). Fixture extended with 4 tool-output cases (execution_output success, empty execution_output that should skip, system_error, tether_browsing_display spinner). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -80,13 +80,19 @@ def make_tool_result_block(
|
||||
output: str,
|
||||
tool_name: str | None = None,
|
||||
is_error: bool = False,
|
||||
summary: str | None = None,
|
||||
) -> dict:
|
||||
"""Return a tool_result block."""
|
||||
"""Return a tool_result block.
|
||||
|
||||
``summary`` is an optional short human label rendered between header and
|
||||
fence (e.g. ChatGPT's ``metadata.reasoning_title`` for execution_output).
|
||||
"""
|
||||
return {
|
||||
"type": BLOCK_TYPE_TOOL_RESULT,
|
||||
"tool_name": tool_name,
|
||||
"output": output if isinstance(output, str) else str(output),
|
||||
"is_error": bool(is_error),
|
||||
"summary": summary,
|
||||
}
|
||||
|
||||
|
||||
@@ -217,10 +223,21 @@ def _render_one(block: dict) -> str:
|
||||
if btype == BLOCK_TYPE_TOOL_RESULT:
|
||||
output = block.get("output", "")
|
||||
is_error = bool(block.get("is_error"))
|
||||
header = "❌ **Result (error)**" if is_error else "📤 **Result**"
|
||||
tool_name = block.get("tool_name") or ""
|
||||
summary = block.get("summary") or ""
|
||||
icon = "❌" if is_error else "📤"
|
||||
label = "Result (error)" if is_error else "Result"
|
||||
if tool_name:
|
||||
header = f"{icon} **{label}: {tool_name}**"
|
||||
else:
|
||||
header = f"{icon} **{label}**"
|
||||
fence = _safe_fence(output)
|
||||
body = f"{fence}\n{output}\n{fence}"
|
||||
return _blockquote_prefix(f"{header}\n{body}")
|
||||
if summary:
|
||||
inner = f"{header}\n*{summary}*\n{body}"
|
||||
else:
|
||||
inner = f"{header}\n{body}"
|
||||
return _blockquote_prefix(inner)
|
||||
if btype == BLOCK_TYPE_CITATION:
|
||||
url = block.get("url", "")
|
||||
title = block.get("title") or url
|
||||
|
||||
@@ -16,6 +16,7 @@ _ROLE_LABELS = {
|
||||
"user": ("🧑 Human", "user"),
|
||||
"assistant": ("🤖 Assistant", "assistant"),
|
||||
"system": ("⚙️ System", "system"),
|
||||
"tool": ("🔧 Tool", "tool"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ from src.blocks import (
|
||||
make_image_placeholder,
|
||||
make_text_block,
|
||||
make_thinking_block,
|
||||
make_tool_result_block,
|
||||
make_unknown_block,
|
||||
)
|
||||
from src.loss_report import LossReport
|
||||
@@ -576,7 +577,10 @@ class ChatGPTProvider(BaseProvider):
|
||||
include project information.
|
||||
"""
|
||||
report = loss_report if loss_report is not None else LossReport()
|
||||
conv_id = raw.get("id", "")
|
||||
# ChatGPT's /backend-api/conversation/<id> response uses ``conversation_id``
|
||||
# at the top level (not ``id``); fixtures and listing summaries use ``id``.
|
||||
# Read both so both code paths populate the normalized ``id`` correctly.
|
||||
conv_id = raw.get("id") or raw.get("conversation_id") or ""
|
||||
title = raw.get("title") or "Untitled"
|
||||
created_at = _ts_to_iso(raw.get("create_time"))
|
||||
updated_at = _ts_to_iso(raw.get("update_time"))
|
||||
@@ -708,9 +712,11 @@ def _build_message(
|
||||
ts = msg_data.get("create_time")
|
||||
metadata = msg_data.get("metadata") or {}
|
||||
is_hidden = bool(metadata.get("is_visually_hidden_from_conversation"))
|
||||
author_name = author.get("name") or None
|
||||
|
||||
blocks = _extract_blocks_for_content(
|
||||
content_type, content_obj, role, conv_id, node_id, report
|
||||
content_type, content_obj, role, conv_id, node_id, report,
|
||||
author_name=author_name, msg_metadata=metadata,
|
||||
)
|
||||
|
||||
if not blocks:
|
||||
@@ -766,6 +772,8 @@ def _extract_blocks_for_content(
|
||||
conv_id: str,
|
||||
node_id: str,
|
||||
report: LossReport,
|
||||
author_name: str | None = None,
|
||||
msg_metadata: dict | None = None,
|
||||
) -> list[dict]:
|
||||
"""Dispatch on content_type and return a list of blocks for one message."""
|
||||
|
||||
@@ -775,6 +783,19 @@ def _extract_blocks_for_content(
|
||||
if content_type == "multimodal_text":
|
||||
return _extract_multimodal_blocks(content_obj, role, conv_id, node_id, report)
|
||||
|
||||
if content_type == "execution_output":
|
||||
return _extract_execution_output_blocks(
|
||||
content_obj, author_name, msg_metadata or {}, conv_id, node_id
|
||||
)
|
||||
|
||||
if content_type == "system_error":
|
||||
return _extract_system_error_blocks(content_obj, author_name)
|
||||
|
||||
if content_type == "tether_browsing_display":
|
||||
return _extract_tether_browsing_display_blocks(
|
||||
content_obj, author_name, conv_id, node_id
|
||||
)
|
||||
|
||||
if content_type == "code":
|
||||
code_text = content_obj.get("text") or "\n".join(
|
||||
p for p in content_obj.get("parts", []) if isinstance(p, str)
|
||||
@@ -1085,3 +1106,110 @@ def _extract_editable_context_blocks(
|
||||
)
|
||||
|
||||
return blocks
|
||||
|
||||
|
||||
def _extract_execution_output_blocks(
|
||||
content_obj: dict,
|
||||
author_name: str | None,
|
||||
msg_metadata: dict,
|
||||
conv_id: str,
|
||||
node_id: str,
|
||||
) -> list[dict]:
|
||||
"""Map a ChatGPT ``execution_output`` content (Code Interpreter / container.exec
|
||||
/ python tool output) onto a ``tool_result`` block.
|
||||
|
||||
Locked shape (captured live during planning v0.4.1):
|
||||
content.text → output
|
||||
author.name → tool_name
|
||||
metadata.aggregate_result.status → "error" → is_error=True
|
||||
metadata.reasoning_title → summary
|
||||
|
||||
Empty ``content.text`` → skip (DEBUG log) — a tool that emits no output is
|
||||
a transient artifact, not archival content.
|
||||
"""
|
||||
text = content_obj.get("text") or ""
|
||||
if not text.strip():
|
||||
logger.debug(
|
||||
"[chatgpt] Skipping empty execution_output in conversation %s message %s",
|
||||
conv_id[:8],
|
||||
node_id[:8],
|
||||
)
|
||||
return []
|
||||
|
||||
aggregate = msg_metadata.get("aggregate_result") or {}
|
||||
status = aggregate.get("status") if isinstance(aggregate, dict) else None
|
||||
is_error = isinstance(status, str) and status.lower() == "error"
|
||||
summary = msg_metadata.get("reasoning_title") or None
|
||||
|
||||
return [
|
||||
make_tool_result_block(
|
||||
output=text,
|
||||
tool_name=author_name,
|
||||
is_error=is_error,
|
||||
summary=summary,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _extract_system_error_blocks(
|
||||
content_obj: dict,
|
||||
author_name: str | None,
|
||||
) -> list[dict]:
|
||||
"""Map a ChatGPT ``system_error`` content onto an error ``tool_result`` block.
|
||||
|
||||
Captured shape: ``{content_type, name, text}`` where ``text`` is the error
|
||||
message (e.g. ``"Error: Error from browse service: 503"``). ``author.name``
|
||||
identifies the failing tool (e.g. ``"web"``).
|
||||
"""
|
||||
text = content_obj.get("text") or ""
|
||||
if not text:
|
||||
text = "(error with no message)"
|
||||
return [
|
||||
make_tool_result_block(
|
||||
output=text,
|
||||
tool_name=author_name,
|
||||
is_error=True,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _extract_tether_browsing_display_blocks(
|
||||
content_obj: dict,
|
||||
author_name: str | None,
|
||||
conv_id: str,
|
||||
node_id: str,
|
||||
) -> list[dict]:
|
||||
"""Handle ChatGPT's ``tether_browsing_display`` content.
|
||||
|
||||
Captured live: most instances are **spinner placeholders** (transient UI
|
||||
state — empty fields, ``metadata.command == "spinner"``). The actual
|
||||
retrieval content arrives as a sibling/child ``multimodal_text`` message
|
||||
that already extracts cleanly via the existing handler.
|
||||
|
||||
Locked behavior:
|
||||
- If ``result`` AND ``summary`` are both empty → skip silently (DEBUG).
|
||||
These are spinners; the real content is elsewhere.
|
||||
- Otherwise (defensive: never observed populated in real data) → render
|
||||
as a ``tool_result`` block carrying ``result`` as output and
|
||||
``summary`` as the optional summary line.
|
||||
"""
|
||||
result = content_obj.get("result") or ""
|
||||
summary = content_obj.get("summary") or ""
|
||||
|
||||
if not result.strip() and not summary.strip():
|
||||
logger.debug(
|
||||
"[chatgpt] Skipping tether_browsing_display spinner in "
|
||||
"conversation %s message %s (empty result/summary)",
|
||||
conv_id[:8],
|
||||
node_id[:8],
|
||||
)
|
||||
return []
|
||||
|
||||
return [
|
||||
make_tool_result_block(
|
||||
output=result or summary,
|
||||
tool_name=author_name,
|
||||
is_error=False,
|
||||
summary=summary if result and summary else None,
|
||||
)
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user