diff --git a/hindsight-integrations/codex/scripts/lib/content.py b/hindsight-integrations/codex/scripts/lib/content.py index 502a973a..eee4232c 100644 --- a/hindsight-integrations/codex/scripts/lib/content.py +++ b/hindsight-integrations/codex/scripts/lib/content.py @@ -2,13 +2,22 @@ Adapts Openclaw/Claude Code content processing for Codex's transcript format. -Codex transcript format (JSONL): - {"session_id": "...", "ts": 1234567890, "msg": {"type": "user_message", "message": "..."}} +Codex rollout format (JSONL): + Each line is a RolloutLine: {"timestamp": "...", "type": "", ...} + Item types: session_meta, response_item, event_msg, turn_context, compacted. -EventMsg types (from codex-rs/protocol/src/protocol.rs, serde snake_case): - - user_message → role: user - - agent_message → role: assistant - - task_started, task_complete, exec, etc. → skipped + ResponseItem types we care about: + - message (role: user/assistant) — text content + - local_shell_call — shell command invocations + - function_call — tool/function calls with name + arguments + - function_call_output — tool return values + - custom_tool_call / custom_tool_call_output — freeform tools + - web_search_call — web search invocations + + EventMsg types (Extended persistence mode): + - exec_command_end — shell command results with stdout/stderr + - patch_apply_end — code patch application results + - mcp_tool_call_end — MCP tool call results """ import json @@ -16,6 +25,9 @@ import os import re from datetime import datetime, timezone +# Maximum length for tool output content in JSON format. +_MAX_TOOL_OUTPUT_CHARS = 2000 + # --------------------------------------------------------------------------- # Memory tag stripping (anti-feedback-loop) @@ -38,20 +50,47 @@ def strip_memory_tags(content: str) -> str: # --------------------------------------------------------------------------- -def read_transcript(transcript_path: str) -> list: - """Read a Codex JSONL transcript and return list of {role, content} dicts. +def read_transcript(transcript_path: str, include_tool_calls: bool = False) -> list: + """Read a Codex JSONL transcript and return list of message dicts. - Codex disk format (rollout-*.jsonl): - User: {"type":"response_item","payload":{"type":"message","role":"user", - "content":[{"type":"input_text","text":"..."}]}} - Assistant: {"type":"response_item","payload":{"type":"message","role":"assistant", - "content":[{"type":"output_text","text":"..."}],"phase":"final_answer"}} + When include_tool_calls is False (legacy mode), returns simple + {role, content} dicts with text-only content. + + When include_tool_calls is True, returns richer message dicts with + structured content blocks (matching Claude Code's JSON format): + - {"role": "user", "content": [{"type": "text", "text": "..."}]} + - {"role": "assistant", "content": [ + {"type": "text", "text": "..."}, + {"type": "tool_use", "name": "shell", "input": {"command": ["ls"]}}, + {"type": "tool_result", "content": "file1.txt\\nfile2.txt"} + ]} + + Codex rollout format (rollout-*.jsonl): + ResponseItems: + message: text messages with role + local_shell_call: shell command invocations + function_call: tool calls with name + arguments + function_call_output: tool return values + custom_tool_call: freeform tool calls + custom_tool_call_output: freeform tool results + EventMsgs: + exec_command_end: shell results with stdout/stderr/exit_code + patch_apply_end: code patch results + mcp_tool_call_end: MCP tool results Flat format for testing: {"role": "user", "content": "..."} """ if not transcript_path or not os.path.isfile(transcript_path): return [] + + if include_tool_calls: + return _read_transcript_rich(transcript_path) + return _read_transcript_text(transcript_path) + + +def _read_transcript_text(transcript_path: str) -> list: + """Legacy text-only transcript reader.""" messages = [] try: with open(transcript_path) as f: @@ -91,6 +130,242 @@ def read_transcript(transcript_path: str) -> list: return messages +def _read_transcript_rich(transcript_path: str) -> list: + """Rich transcript reader that preserves tool calls as structured content blocks. + + Collects all response_items and event_msgs into a sequence of messages + with structured content blocks. Tool calls and their outputs are grouped + under the assistant role. + """ + messages = [] + # Buffer for collecting assistant content blocks between user messages + assistant_blocks = [] + + def _flush_assistant(): + nonlocal assistant_blocks + if assistant_blocks: + messages.append({"role": "assistant", "content": assistant_blocks}) + assistant_blocks = [] + + try: + with open(transcript_path) as f: + for line in f: + line = line.strip() + if not line: + continue + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + + # Flat format (testing / future compatibility) + if "role" in entry and "content" in entry: + content = entry["content"] + if entry["role"] == "user": + _flush_assistant() + if isinstance(content, str): + content = [{"type": "text", "text": content}] + messages.append({"role": "user", "content": content}) + elif entry["role"] == "assistant": + if isinstance(content, str): + content = [{"type": "text", "text": content}] + if isinstance(content, list): + assistant_blocks.extend(content) + else: + assistant_blocks.append({"type": "text", "text": str(content)}) + continue + + item_type = entry.get("type") + + # --- response_item --- + if item_type == "response_item": + payload = entry.get("payload", {}) + ptype = payload.get("type") + + if ptype == "message": + role = payload.get("role", "") + if role == "user": + _flush_assistant() + text = _extract_text_from_content_blocks(payload.get("content", [])) + if text: + messages.append({"role": "user", "content": [{"type": "text", "text": text}]}) + elif role == "assistant": + # Only include final_answer (not reasoning/intermediary) + if payload.get("phase") != "final_answer": + continue + text = _extract_text_from_content_blocks(payload.get("content", [])) + if text: + assistant_blocks.append({"type": "text", "text": text}) + + elif ptype == "local_shell_call": + action = payload.get("action", {}) + command = action.get("command", []) + assistant_blocks.append({ + "type": "tool_use", + "name": "shell", + "input": {"command": command}, + }) + + elif ptype == "function_call": + name = payload.get("name", "unknown") + arguments = payload.get("arguments", "{}") + try: + inp = json.loads(arguments) + except (json.JSONDecodeError, TypeError): + inp = {"raw": arguments} + assistant_blocks.append({ + "type": "tool_use", + "name": name, + "input": inp, + }) + + elif ptype == "function_call_output": + output = payload.get("output", "") + output_text = _extract_function_output_text(output) + if output_text: + assistant_blocks.append({ + "type": "tool_result", + "content": _truncate(output_text), + }) + + elif ptype == "custom_tool_call": + name = payload.get("name", "unknown") + inp_str = payload.get("input", "{}") + try: + inp = json.loads(inp_str) + except (json.JSONDecodeError, TypeError): + inp = {"raw": inp_str} + assistant_blocks.append({ + "type": "tool_use", + "name": name, + "input": inp, + }) + + elif ptype == "custom_tool_call_output": + output = payload.get("output", "") + output_text = _extract_function_output_text(output) + if output_text: + assistant_blocks.append({ + "type": "tool_result", + "content": _truncate(output_text), + }) + + elif ptype == "web_search_call": + action = payload.get("action", {}) + query = action.get("query", "") if isinstance(action, dict) else "" + if query: + assistant_blocks.append({ + "type": "tool_use", + "name": "web_search", + "input": {"query": query}, + }) + + # --- event_msg --- + elif item_type == "event_msg": + payload = entry.get("payload", {}) + ptype = payload.get("type") + + if ptype == "exec_command_end": + command = payload.get("command", []) + output = payload.get("aggregated_output", "") + exit_code = payload.get("exit_code") + status = payload.get("status", "") + # Add as tool_use + tool_result pair + assistant_blocks.append({ + "type": "tool_use", + "name": "shell", + "input": {"command": command}, + }) + result_parts = [] + if output: + result_parts.append(output) + if exit_code is not None and exit_code != 0: + result_parts.append(f"exit_code: {exit_code}") + if status and status != "completed": + result_parts.append(f"status: {status}") + if result_parts: + assistant_blocks.append({ + "type": "tool_result", + "content": _truncate("\n".join(result_parts)), + }) + + elif ptype == "patch_apply_end": + changes = payload.get("changes", []) + status = payload.get("status", "") + if changes: + assistant_blocks.append({ + "type": "tool_use", + "name": "patch", + "input": {"changes": changes}, + }) + if status: + assistant_blocks.append({ + "type": "tool_result", + "content": f"status: {status}", + }) + + elif ptype == "mcp_tool_call_end": + result = payload.get("result", {}) + result_text = "" + if isinstance(result, dict): + content_items = result.get("content", []) + if isinstance(content_items, list): + texts = [] + for item in content_items: + if isinstance(item, dict) and item.get("type") == "text": + texts.append(item.get("text", "")) + result_text = "\n".join(texts) + elif isinstance(content_items, str): + result_text = content_items + elif isinstance(result, str): + result_text = result + if result_text: + assistant_blocks.append({ + "type": "tool_result", + "content": _truncate(result_text), + }) + + except OSError: + pass + + _flush_assistant() + return messages + + +def _extract_text_from_content_blocks(content_blocks: list) -> str: + """Extract text from Codex content blocks (input_text/output_text).""" + text_parts = [] + for block in content_blocks: + if isinstance(block, dict) and block.get("type") in ("input_text", "output_text"): + t = block.get("text", "").strip() + if t: + text_parts.append(t) + return "\n".join(text_parts).strip() + + +def _extract_function_output_text(output) -> str: + """Extract text from a function_call_output payload. + + The output can be either a plain string or a list of content items. + """ + if isinstance(output, str): + return output.strip() + if isinstance(output, list): + texts = [] + for item in output: + if isinstance(item, dict) and item.get("type") in ("input_text", "text"): + texts.append(item.get("text", "")) + return "\n".join(texts).strip() + return "" + + +def _truncate(text: str) -> str: + """Truncate text to _MAX_TOOL_OUTPUT_CHARS.""" + if len(text) > _MAX_TOOL_OUTPUT_CHARS: + return text[:_MAX_TOOL_OUTPUT_CHARS] + "... (truncated)" + return text + + # --------------------------------------------------------------------------- # Recall: query composition and truncation # --------------------------------------------------------------------------- @@ -259,18 +534,20 @@ def prepare_retention_transcript( messages: list, retain_roles: list = None, retain_full_window: bool = False, + include_tool_calls: bool = False, ) -> tuple: """Format messages into a retention transcript. - Outputs plain text with [role: ...]...[role:end] markers. - Codex doesn't have tool calls to retain (it's a coding agent with - shell/patch commands, not MCP tools), so we use the text format only. + When include_tool_calls is True, outputs JSON with full message structure + including tool calls and their inputs (matching Claude Code's format). + Otherwise outputs the legacy text format with [role: ...]...[role:end] markers. Args: - messages: List of {role, content} dicts. + messages: List of message dicts with 'role' and 'content'. retain_roles: Roles to include (default: ['user', 'assistant']). retain_full_window: If True, retain all messages. If False, retain only the last turn (last user msg + responses). + include_tool_calls: If True, output JSON format with full tool call data. Returns: (transcript_text, message_count) or (None, 0) if nothing to retain. @@ -291,9 +568,42 @@ def prepare_retention_transcript( target_messages = messages[last_user_idx:] allowed_roles = set(retain_roles or ["user", "assistant"]) + + if include_tool_calls: + return _prepare_json_transcript(target_messages, allowed_roles) + return _prepare_text_transcript(target_messages, allowed_roles) + + +def _prepare_json_transcript(messages: list, allowed_roles: set) -> tuple: + """Format messages as JSON with full tool call data.""" + structured_messages = [] + for msg in messages: + role = msg.get("role", "unknown") + if role not in allowed_roles: + continue + + content = msg.get("content", "") + blocks = _strip_memory_tags_from_blocks(content) + if not blocks: + continue + + structured_messages.append({"role": role, "content": blocks}) + + if not structured_messages: + return None, 0 + + transcript = json.dumps(structured_messages, indent=None, ensure_ascii=False) + if len(transcript.strip()) < 10: + return None, 0 + + return transcript, len(structured_messages) + + +def _prepare_text_transcript(messages: list, allowed_roles: set) -> tuple: + """Format messages as legacy text with [role:]...[role:end] markers.""" parts = [] - for msg in target_messages: + for msg in messages: role = msg.get("role", "unknown") if role not in allowed_roles: continue @@ -316,3 +626,32 @@ def prepare_retention_transcript( return None, 0 return transcript, len(parts) + + +def _strip_memory_tags_from_blocks(content) -> list: + """Strip memory tags from content, handling both string and list formats. + + Returns a list of content blocks with memory tags removed. + """ + if isinstance(content, str): + cleaned = strip_memory_tags(content).strip() + return [{"type": "text", "text": cleaned}] if cleaned else [] + + if not isinstance(content, list): + return [] + + blocks = [] + for block in content: + if not isinstance(block, dict): + continue + block_type = block.get("type", "") + + if block_type == "text": + text = strip_memory_tags(block.get("text", "")).strip() + if text: + blocks.append({"type": "text", "text": text}) + elif block_type in ("tool_use", "tool_result"): + # Pass through tool blocks as-is + blocks.append(block) + + return blocks diff --git a/hindsight-integrations/codex/scripts/retain.py b/hindsight-integrations/codex/scripts/retain.py index d3c27e58..ddcd487c 100755 --- a/hindsight-integrations/codex/scripts/retain.py +++ b/hindsight-integrations/codex/scripts/retain.py @@ -56,7 +56,8 @@ def main(): transcript_path = hook_input.get("transcript_path", "") # Read full transcript - all_messages = read_transcript(transcript_path) + include_tool_calls = config.get("retainToolCalls", True) + all_messages = read_transcript(transcript_path, include_tool_calls=include_tool_calls) if not all_messages: debug_log(config, "No messages in transcript, skipping retain") return @@ -93,7 +94,7 @@ def main(): # Format transcript retain_roles = config.get("retainRoles", ["user", "assistant"]) transcript, message_count = prepare_retention_transcript( - messages_to_retain, retain_roles, retain_full_window + messages_to_retain, retain_roles, retain_full_window, include_tool_calls=include_tool_calls ) if not transcript: diff --git a/hindsight-integrations/codex/settings.json b/hindsight-integrations/codex/settings.json index 9d1c66fd..7ddcc7d5 100644 --- a/hindsight-integrations/codex/settings.json +++ b/hindsight-integrations/codex/settings.json @@ -14,6 +14,7 @@ "recallMaxQueryChars": 800, "recallRoles": ["user", "assistant"], "recallPromptPreamble": "Relevant memories from past conversations (prioritize recent when conflicting). Only use memories that are directly useful to continue this conversation; ignore the rest:", + "retainToolCalls": true, "retainRoles": ["user", "assistant"], "retainEveryNTurns": 10, "retainOverlapTurns": 2, diff --git a/hindsight-integrations/codex/tests/test_content.py b/hindsight-integrations/codex/tests/test_content.py index 4406edf8..cdfd6ecd 100644 --- a/hindsight-integrations/codex/tests/test_content.py +++ b/hindsight-integrations/codex/tests/test_content.py @@ -17,6 +17,12 @@ from lib.content import ( ) +def _write_jsonl(tmp_path, entries): + f = tmp_path / "transcript.jsonl" + f.write_text("\n".join(json.dumps(e) for e in entries)) + return str(f) + + # --------------------------------------------------------------------------- # strip_memory_tags # --------------------------------------------------------------------------- @@ -50,12 +56,6 @@ class TestStripMemoryTags: # --------------------------------------------------------------------------- -def _write_jsonl(tmp_path, entries): - f = tmp_path / "transcript.jsonl" - f.write_text("\n".join(json.dumps(e) for e in entries)) - return str(f) - - class TestReadTranscriptFlat: def test_reads_flat_format(self, tmp_path): path = _write_jsonl(tmp_path, [ @@ -362,3 +362,358 @@ class TestPrepareRetentionTranscript: msgs = [{"role": "assistant", "content": "only assistant"}] result, _ = prepare_retention_transcript(msgs, retain_full_window=False) assert result is None + + +# --------------------------------------------------------------------------- +# read_transcript — rich format (include_tool_calls=True) +# --------------------------------------------------------------------------- + + +class TestReadTranscriptRich: + def test_reads_messages_as_structured_blocks(self, tmp_path): + entries = [ + { + "type": "response_item", + "payload": { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "hello"}], + }, + }, + { + "type": "response_item", + "payload": { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "hi there"}], + "phase": "final_answer", + }, + }, + ] + path = _write_jsonl(tmp_path, entries) + msgs = read_transcript(path, include_tool_calls=True) + assert len(msgs) == 2 + assert msgs[0]["role"] == "user" + assert msgs[0]["content"] == [{"type": "text", "text": "hello"}] + assert msgs[1]["role"] == "assistant" + assert msgs[1]["content"] == [{"type": "text", "text": "hi there"}] + + def test_reads_function_call(self, tmp_path): + entries = [ + { + "type": "response_item", + "payload": { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "list files"}], + }, + }, + { + "type": "response_item", + "payload": { + "type": "function_call", + "name": "Read", + "arguments": '{"file_path": "/tmp/foo.txt"}', + "call_id": "call_1", + }, + }, + { + "type": "response_item", + "payload": { + "type": "function_call_output", + "call_id": "call_1", + "output": "file contents here", + }, + }, + ] + path = _write_jsonl(tmp_path, entries) + msgs = read_transcript(path, include_tool_calls=True) + assert len(msgs) == 2 # user + assistant + assistant = msgs[1] + assert assistant["role"] == "assistant" + assert len(assistant["content"]) == 2 + assert assistant["content"][0]["type"] == "tool_use" + assert assistant["content"][0]["name"] == "Read" + assert assistant["content"][0]["input"] == {"file_path": "/tmp/foo.txt"} + assert assistant["content"][1]["type"] == "tool_result" + assert "file contents here" in assistant["content"][1]["content"] + + def test_reads_local_shell_call(self, tmp_path): + entries = [ + { + "type": "response_item", + "payload": { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "run ls"}], + }, + }, + { + "type": "response_item", + "payload": { + "type": "local_shell_call", + "call_id": "call_1", + "status": "completed", + "action": {"type": "exec", "command": ["ls", "-la"]}, + }, + }, + ] + path = _write_jsonl(tmp_path, entries) + msgs = read_transcript(path, include_tool_calls=True) + assistant = msgs[1] + assert assistant["content"][0]["type"] == "tool_use" + assert assistant["content"][0]["name"] == "shell" + assert assistant["content"][0]["input"]["command"] == ["ls", "-la"] + + def test_reads_exec_command_end_event(self, tmp_path): + entries = [ + { + "type": "response_item", + "payload": { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "check git status"}], + }, + }, + { + "type": "event_msg", + "payload": { + "type": "exec_command_end", + "call_id": "call_1", + "command": ["git", "status"], + "aggregated_output": "On branch main\nnothing to commit", + "exit_code": 0, + "status": "completed", + }, + }, + ] + path = _write_jsonl(tmp_path, entries) + msgs = read_transcript(path, include_tool_calls=True) + assistant = msgs[1] + assert assistant["content"][0]["type"] == "tool_use" + assert assistant["content"][0]["name"] == "shell" + assert assistant["content"][0]["input"]["command"] == ["git", "status"] + assert assistant["content"][1]["type"] == "tool_result" + assert "On branch main" in assistant["content"][1]["content"] + + def test_reads_exec_command_end_with_nonzero_exit(self, tmp_path): + entries = [ + { + "type": "response_item", + "payload": { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "run bad cmd"}], + }, + }, + { + "type": "event_msg", + "payload": { + "type": "exec_command_end", + "call_id": "call_1", + "command": ["false"], + "aggregated_output": "", + "exit_code": 1, + "status": "failed", + }, + }, + ] + path = _write_jsonl(tmp_path, entries) + msgs = read_transcript(path, include_tool_calls=True) + assistant = msgs[1] + result = assistant["content"][1] + assert "exit_code: 1" in result["content"] + assert "status: failed" in result["content"] + + def test_reads_patch_apply_end_event(self, tmp_path): + entries = [ + { + "type": "response_item", + "payload": { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "fix the bug"}], + }, + }, + { + "type": "event_msg", + "payload": { + "type": "patch_apply_end", + "call_id": "call_1", + "changes": [{"file": "main.py", "diff": "+fixed line"}], + "status": "applied", + }, + }, + ] + path = _write_jsonl(tmp_path, entries) + msgs = read_transcript(path, include_tool_calls=True) + assistant = msgs[1] + assert assistant["content"][0]["type"] == "tool_use" + assert assistant["content"][0]["name"] == "patch" + assert assistant["content"][1]["content"] == "status: applied" + + def test_reads_web_search_call(self, tmp_path): + entries = [ + { + "type": "response_item", + "payload": { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "search for python docs"}], + }, + }, + { + "type": "response_item", + "payload": { + "type": "web_search_call", + "status": "completed", + "action": {"type": "search", "query": "python documentation"}, + }, + }, + ] + path = _write_jsonl(tmp_path, entries) + msgs = read_transcript(path, include_tool_calls=True) + assistant = msgs[1] + assert assistant["content"][0]["type"] == "tool_use" + assert assistant["content"][0]["name"] == "web_search" + assert assistant["content"][0]["input"]["query"] == "python documentation" + + def test_skips_non_final_answer_in_rich_mode(self, tmp_path): + entries = [ + { + "type": "response_item", + "payload": { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "thinking..."}], + "phase": "reasoning", + }, + }, + { + "type": "response_item", + "payload": { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "The answer"}], + "phase": "final_answer", + }, + }, + ] + path = _write_jsonl(tmp_path, entries) + msgs = read_transcript(path, include_tool_calls=True) + assert len(msgs) == 1 + assert msgs[0]["content"][0]["text"] == "The answer" + + def test_flat_format_works_in_rich_mode(self, tmp_path): + path = _write_jsonl(tmp_path, [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi"}, + ]) + msgs = read_transcript(path, include_tool_calls=True) + assert len(msgs) == 2 + assert msgs[0]["content"] == [{"type": "text", "text": "hello"}] + assert msgs[1]["content"] == [{"type": "text", "text": "hi"}] + + def test_truncates_long_tool_output(self, tmp_path): + long_output = "x" * 3000 + entries = [ + { + "type": "response_item", + "payload": { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "do something"}], + }, + }, + { + "type": "response_item", + "payload": { + "type": "function_call", + "name": "Read", + "arguments": "{}", + "call_id": "call_1", + }, + }, + { + "type": "response_item", + "payload": { + "type": "function_call_output", + "call_id": "call_1", + "output": long_output, + }, + }, + ] + path = _write_jsonl(tmp_path, entries) + msgs = read_transcript(path, include_tool_calls=True) + result = msgs[1]["content"][1] + assert len(result["content"]) < 3000 + assert "truncated" in result["content"] + + +# --------------------------------------------------------------------------- +# prepare_retention_transcript — JSON format (include_tool_calls=True) +# --------------------------------------------------------------------------- + + +class TestPrepareRetentionTranscriptJson: + def test_json_format_basic(self): + msgs = [ + {"role": "user", "content": [{"type": "text", "text": "hello"}]}, + {"role": "assistant", "content": [{"type": "text", "text": "hi"}]}, + ] + transcript, count = prepare_retention_transcript( + msgs, retain_full_window=True, include_tool_calls=True + ) + assert count == 2 + parsed = json.loads(transcript) + assert len(parsed) == 2 + assert parsed[0]["role"] == "user" + assert parsed[1]["role"] == "assistant" + + def test_json_format_with_tool_calls(self): + msgs = [ + {"role": "user", "content": [{"type": "text", "text": "list files"}]}, + { + "role": "assistant", + "content": [ + {"type": "tool_use", "name": "shell", "input": {"command": ["ls"]}}, + {"type": "tool_result", "content": "file1.txt"}, + {"type": "text", "text": "Here are the files."}, + ], + }, + ] + transcript, count = prepare_retention_transcript( + msgs, retain_full_window=True, include_tool_calls=True + ) + parsed = json.loads(transcript) + assistant = parsed[1] + assert any(b["type"] == "tool_use" for b in assistant["content"]) + assert any(b["type"] == "tool_result" for b in assistant["content"]) + + def test_json_format_strips_memory_tags(self): + msgs = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "secret real question"}, + ], + }, + ] + transcript, _ = prepare_retention_transcript( + msgs, retain_full_window=True, include_tool_calls=True + ) + assert "hindsight_memories" not in transcript + assert "secret" not in transcript + assert "real question" in transcript + + def test_json_format_filters_roles(self): + msgs = [ + {"role": "user", "content": [{"type": "text", "text": "user msg"}]}, + {"role": "assistant", "content": [{"type": "text", "text": "assistant msg"}]}, + ] + transcript, count = prepare_retention_transcript( + msgs, retain_roles=["user"], retain_full_window=True, include_tool_calls=True + ) + parsed = json.loads(transcript) + assert count == 1 + assert all(m["role"] == "user" for m in parsed)