Release exp-v0.52.197: preserve api_content for agent replay (#6757, @franksong2702)

Ships PR #6757 with a maintainer hardening pass folded in (authorship preserved).

Preserves the exact provider-facing reply text (api_content) through the internal
session store for byte-accurate agent replay, while guaranteeing it never reaches
a browser: every client-facing surface strips it via the public projection.

Maintainer fixes (2 defects my adversarial re-gate reproduced):
- Selective session redaction restored: only transcript fields are scrubbed;
  operational fields (e.g. workspace path) pass through unchanged, so a path
  containing credential-shaped text is no longer masked into an invalid path.
- Runner-backed SSE relay now projects payloads before emit, closing an
  api_content leak through _stream_runner_run_events.
+5 regression tests. Codex adversarial re-gate: SAFE TO SHIP. Full suite clean.

Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
This commit is contained in:
nesquena-hermesandfranksong2702 committed 2026-08-11 02:10:21 +00:00
1 parent 728ec10e66
commit cf7eb48e56
9 files changed
+3187 -112

No files matched your search

+2
View File
@@ -5,6 +5,8 @@
### Changed
- **Agent replay is now byte-accurate without ever exposing internal provider text to the browser.** The exact provider-facing reply text (`api_content`) is preserved through the internal session store so agent-to-agent replay is faithful, while every client-facing surface (HTTP responses, SSE journal + runner streams, shares) strips it and its provenance aliases through the public projection. Includes a maintainer hardening pass: session redaction is selective (transcript fields are scrubbed; operational fields such as the workspace path pass through unchanged, so a workspace path that merely contains credential-shaped text is no longer masked into an invalid path), and the runner-backed SSE relay now projects payloads before emit so `api_content` cannot leak through that path. Thanks @franksong2702. (#6757)
- **Transparent Stream copy buttons now confirm success in place.** The tool-event and thinking copy controls flash a check for ~1.5s after a successful copy — the same feedback the normal chat copy buttons already give — then revert to the copy glyph. The confirmation is now clearly visible without hover (important on touch), stays correct across live-row reconciliation, cleans up if its row is torn down mid-feedback, and is never serialized into a restored session snapshot. Thanks @Stacey2911. (#6037)
- **Kanban boards can now set a default workspace path.** The board settings modal gains a "Default workspace path" field (a type-or-pick combobox sourced from your saved workspaces, so you can choose a known path or type a custom one — validated server-side). Tasks on the board without an explicit workspace path inherit this default. The board switcher chip is now always shown (even with a single board) and its menu item is now "Board settings…", enabled for the default board. Thanks @rodboev. (#6055, #5932)
+228 -35
View File
@@ -14,6 +14,13 @@ from api.config import IMAGE_EXTS, MD_EXTS
logger = logging.getLogger(__name__)
_PUBLIC_MESSAGE_INTERNAL_FIELDS = frozenset({
"api_content",
"_state_db_row_id",
"_db_row_id",
"state_db_row_id",
})
# Treat stalled/closed HTTP clients as normal disconnects. Long-lived SSE
# connections often end this way when a browser tab sleeps, a phone switches
@@ -949,10 +956,7 @@ def _redact_value(v, *, _enabled: bool | None = None):
if isinstance(v, str):
return _redact_text(v, _enabled=_enabled)
if isinstance(v, dict):
return {
key: _redact_value(value, _enabled=_enabled)
for key, value in v.items()
}
return {key: _redact_value(value, _enabled=_enabled) for key, value in v.items()}
if isinstance(v, list):
return [_redact_value(item, _enabled=_enabled) for item in v]
return v
@@ -985,26 +989,207 @@ def _redact_message_content_part(part, *, _enabled: bool):
return result
def _scrub_alias_record(record):
"""Copy one schema record while removing private replay aliases.
This deliberately copies only one record level. Values such as a tool's
``args`` or a function's opaque ``arguments`` are business payloads, not
nested WebUI records, so parsing or recursively walking them would corrupt
valid user data.
"""
if not isinstance(record, dict):
return _copy_json_value(record)
return {
key: _copy_json_value(value)
for key, value in record.items()
if key not in _PUBLIC_MESSAGE_INTERNAL_FIELDS
}
def _scrub_content_part(part):
"""Scrub one canonical ``messages[*].content[*]`` part."""
return _scrub_alias_record(part)
def _scrub_function_record(function):
"""Scrub a tool-call function envelope without parsing arguments."""
return _scrub_alias_record(function)
def _scrub_tool_call_record(tool_call):
"""Scrub one canonical tool-call record and its function envelope."""
result = _scrub_alias_record(tool_call)
if not isinstance(tool_call, dict):
return result
function = tool_call.get("function")
if isinstance(function, dict):
result["function"] = _scrub_function_record(function)
return result
def _scrub_message_record(message, *, preserve_api_content: bool = False):
"""Scrub one message record along its authoritative nested schema paths."""
if not isinstance(message, dict):
return _copy_json_value(message)
result = _scrub_alias_record(message)
if preserve_api_content and isinstance(message.get("api_content"), str) and message.get("api_content"):
result["api_content"] = message["api_content"]
content = message.get("content")
if isinstance(content, list):
result["content"] = [_scrub_content_part(part) for part in content]
tool_calls = message.get("tool_calls")
if isinstance(tool_calls, list):
result["tool_calls"] = [_scrub_tool_call_record(call) for call in tool_calls]
return result
def _scrub_message_records(messages, *, preserve_api_content: bool = False):
if not isinstance(messages, list):
return _copy_json_value(messages)
return [
_scrub_message_record(message, preserve_api_content=preserve_api_content)
for message in messages
]
def _scrub_tool_call_records(tool_calls):
if not isinstance(tool_calls, list):
return _copy_json_value(tool_calls)
return [_scrub_tool_call_record(call) for call in tool_calls]
def _scrub_runtime_journal_snapshot(snapshot):
"""Scrub only the snapshot's real message/tool-call arrays.
A live tool's ``args`` dictionary is intentionally opaque. In particular,
``tool_calls[].args.messages[]`` is ordinary business input even though the
field name happens to be ``messages``; it is not a transcript container.
"""
if not isinstance(snapshot, dict):
return _copy_json_value(snapshot)
result = _copy_json_value(snapshot)
if isinstance(snapshot.get("messages"), list):
result["messages"] = _scrub_message_records(snapshot["messages"])
if isinstance(snapshot.get("tool_calls"), list):
result["tool_calls"] = _scrub_tool_call_records(snapshot["tool_calls"])
return result
def scrub_internal_replay_fields(
value,
*,
preserve_message_api_content: bool = False,
message_records: bool | None = None,
):
"""Copy runtime data and scrub aliases at authoritative schema positions.
``message_records`` selects the shape of a bare list (messages versus
session-level tool calls). A session-shaped dictionary follows only its
named ``messages``, ``context_messages``, ``tool_calls``, and
``runtime_journal_snapshot`` fields. No generic key-name recursion is
performed, so arbitrary nested tool arguments remain byte-for-byte intact.
The Agent boundary may retain a message record's trusted ``api_content``;
public/import boundaries use the default, stripping it everywhere.
"""
if isinstance(value, list):
if message_records is False:
return _scrub_tool_call_records(value)
return _scrub_message_records(
value,
preserve_api_content=preserve_message_api_content,
)
if not isinstance(value, dict):
return _copy_json_value(value)
result = _copy_json_value(value)
for key, child in value.items():
if key in {"messages", "context_messages"} and isinstance(child, list):
result[key] = _scrub_message_records(
child,
preserve_api_content=preserve_message_api_content,
)
elif key == "tool_calls" and isinstance(child, list):
result[key] = _scrub_tool_call_records(child)
elif key == "runtime_journal_snapshot" and isinstance(child, dict):
result[key] = _scrub_runtime_journal_snapshot(child)
return result
def _public_message_projection(message, *, _enabled: bool):
"""Return one public transcript message without internal replay fields."""
message = scrub_internal_replay_fields([message], message_records=True)[0]
if not isinstance(message, dict):
return _redact_value(message, _enabled=_enabled)
item = {}
allow_native_image = message.get("role") == "user"
for key, value in message.items():
if key in _PUBLIC_MESSAGE_INTERNAL_FIELDS:
continue
if allow_native_image and key == "content" and isinstance(value, list):
item[key] = [
_redact_message_content_part(part, _enabled=_enabled)
for part in value
]
else:
item[key] = _redact_value(value, _enabled=_enabled)
return item
def _redact_messages(messages, *, _enabled: bool):
if not isinstance(messages, list):
return _redact_value(messages, _enabled=_enabled)
redacted = []
for message in messages:
if not isinstance(message, dict):
redacted.append(_redact_value(message, _enabled=_enabled))
continue
item = {}
allow_native_image = message.get("role") == "user"
for key, value in message.items():
if allow_native_image and key == "content" and isinstance(value, list):
item[key] = [
_redact_message_content_part(part, _enabled=_enabled)
for part in value
]
else:
item[key] = _redact_value(value, _enabled=_enabled)
redacted.append(item)
return redacted
return [_public_message_projection(message, _enabled=_enabled) for message in messages]
def _redact_tool_calls(tool_calls, *, _enabled: bool):
scrubbed = scrub_internal_replay_fields(tool_calls, message_records=False)
return _redact_value(scrubbed, _enabled=_enabled)
def _redact_nested_message_containers(value, *, _enabled: bool):
"""Redact only the runtime snapshot's authoritative message arrays."""
scrubbed = scrub_internal_replay_fields(value)
if not isinstance(scrubbed, dict):
return _redact_value(scrubbed, _enabled=_enabled)
result = {}
for key, child in scrubbed.items():
if key == "messages" and isinstance(child, list):
result[key] = _redact_messages(child, _enabled=_enabled)
elif key == "tool_calls" and isinstance(child, list):
result[key] = _redact_tool_calls(child, _enabled=_enabled)
else:
result[key] = _redact_value(child, _enabled=_enabled)
return result
def public_session_projection(session_dict: dict) -> dict:
"""Return a public session payload with redaction and alias stripping.
Callers use this for every response/export/SSE session payload. It never
mutates the in-memory session or the caller's dictionary.
"""
return redact_session_data(session_dict)
def strip_public_internal_fields(value, *, message_records: bool = False):
"""Deep-copy imported records through the shared schema scrubber.
JSON import uses this before constructing or saving a ``Session``. The
Four replay aliases belong to a message/content-part/tool-call/function
record itself; matching names inside user content or tool arguments are
ordinary JSON and must be preserved. This is intentionally independent of
the credential-redaction setting: caller-supplied provider sidecars must
never become durable WebUI session state.
"""
return scrub_internal_replay_fields(value, message_records=message_records)
def _copy_json_value(value):
"""Deep-copy JSON-shaped data without applying message-field filtering."""
if isinstance(value, dict):
return {key: _copy_json_value(child) for key, child in value.items()}
if isinstance(value, list):
return [_copy_json_value(child) for child in value]
return value
def redact_session_data(session_dict: dict) -> dict:
@@ -1022,20 +1207,28 @@ def redact_session_data(session_dict: dict) -> dict:
"""
from api.config import load_settings
_enabled = bool(load_settings().get("api_redact_enabled", True))
result = dict(session_dict)
if isinstance(result.get('title'), str):
result['title'] = _redact_text(result['title'], _enabled=_enabled)
if 'messages' in result:
result['messages'] = _redact_messages(result['messages'], _enabled=_enabled)
if 'tool_calls' in result:
result['tool_calls'] = _redact_value(result['tool_calls'], _enabled=_enabled)
if 'todo_state' in result:
result['todo_state'] = _redact_value(result['todo_state'], _enabled=_enabled)
if 'runtime_journal_snapshot' in result:
result['runtime_journal_snapshot'] = _redact_value(
result['runtime_journal_snapshot'],
_enabled=_enabled,
)
if not isinstance(session_dict, dict):
return {}
result = {}
for key, value in session_dict.items():
if key in _PUBLIC_MESSAGE_INTERNAL_FIELDS:
continue
if key == 'title' and isinstance(value, str):
result[key] = _redact_text(value, _enabled=_enabled)
elif key in {'messages', 'context_messages'}:
result[key] = _redact_messages(value, _enabled=_enabled)
elif key == 'tool_calls' and isinstance(value, list):
result[key] = _redact_tool_calls(value, _enabled=_enabled)
elif key in {'todo_state', 'runtime_journal_snapshot'}:
result[key] = _redact_nested_message_containers(value, _enabled=_enabled)
else:
# Operational fields (workspace path, ids, config, timestamps, etc.)
# are NOT credential-masked: a valid workspace path may legitimately
# contain a credential-shaped component, and masking it would corrupt
# the authoritative value the client echoes back on the next send.
# Deep-copy them through unchanged (only transcript-bearing fields
# above carry free-form user/model text worth redacting).
result[key] = _copy_json_value(value)
return result
+729 -14
View File
@@ -7986,6 +7986,11 @@ def get_state_db_session_messages(
'codex_reasoning_items',
'reasoning_content',
'codex_message_items',
# Hermes Agent stores the exact provider-facing text here when
# it differs from the clean transcript content. Keep this
# sidecar in the WebUI's internal history; the provider-safe
# projection strips it before any direct API request.
'api_content',
]
id_col = ['id'] if 'id' in available else []
selected = id_col + ['role', 'content', 'timestamp'] + [c for c in optional if c in available]
@@ -8090,6 +8095,11 @@ def get_state_db_session_messages(
'content': row['content'],
'timestamp': row['timestamp'],
}
# ``id`` is the durable SQLite row identity, not the WebUI's
# session-local stable message id. Keep it in a private
# provenance field so duplicate reconciliation can align a
# state.db sidecar without changing the existing ``id`` key
# used by WebUI transcript merge/dedup logic.
for col in optional:
if col not in row.keys():
continue
@@ -8099,6 +8109,16 @@ def get_state_db_session_messages(
if col in {'tool_calls', 'reasoning_details', 'codex_reasoning_items', 'codex_message_items'}:
value = _json_loads_if_string(value)
msg[col] = value
# Keep durable provenance only alongside a real Agent replay
# sidecar. Ordinary state.db rows must retain their historic
# shape and must not acquire internal bookkeeping fields.
if (
id_col
and row['id'] is not None
and isinstance(msg.get('api_content'), str)
and msg['api_content']
):
msg['_state_db_row_id'] = row['id']
if msg.get('role') == 'tool' and msg.get('tool_name') and not msg.get('name'):
msg['name'] = msg['tool_name']
msgs.append(msg)
@@ -8131,6 +8151,8 @@ def get_state_db_session_message_prefix_summary(
before_ts = float(before_timestamp)
except (TypeError, ValueError):
return None
if not math.isfinite(before_ts):
return None
if isinstance(profile, str) and profile:
db_path = _get_profile_home(profile) / 'state.db'
@@ -8219,12 +8241,13 @@ def get_state_db_session_message_keys_before_timestamp(
available = {str(row['name']) for row in cur.fetchall()}
if not {'id', 'session_id', 'role', 'content', 'timestamp', 'tool_calls'}.issubset(available):
return None
api_content_select = ", api_content" if "api_content" in available else ""
cur.execute(
"""
f"""
SELECT
COALESCE(role, '') AS role,
COALESCE(content, '') AS content,
tool_calls
tool_calls{api_content_select}
FROM messages
WHERE session_id = ? AND timestamp IS NOT NULL AND timestamp < ?
ORDER BY timestamp ASC, id ASC
@@ -8237,6 +8260,7 @@ def get_state_db_session_message_keys_before_timestamp(
"role": row["role"],
"content": row["content"],
"tool_calls": _json_loads_if_string(row["tool_calls"]),
"api_content": row["api_content"] if "api_content" in available else None,
}
)
for row in cur.fetchall()
@@ -8299,6 +8323,11 @@ def _normalized_message_timestamp_for_key(value):
timestamp = float(value)
except (TypeError, ValueError):
return str(value)
if not math.isfinite(timestamp):
# Keep malformed metadata from crashing key construction. The
# reconciliation layer separately marks this row invalid so it cannot
# fall through to a weaker metadata-free match.
return f"<invalid:{value!r}>"
# Truncate to second-level granularity so that sub-second drift between
# the sidecar JSON write and the state.db created_at write does not cause
# the legacy dedup key to differ for the same logical message.
@@ -8312,9 +8341,29 @@ def _message_timestamp_as_float(msg):
if value is None or value == "":
return None
try:
return float(value)
timestamp = float(value)
except (TypeError, ValueError):
return None
return timestamp if math.isfinite(timestamp) else None
def _session_message_api_content_key(msg: dict):
"""Return the exact trusted provider sidecar used in duplicate identity."""
if not isinstance(msg, dict):
return None
value = msg.get("api_content")
return value if isinstance(value, str) and value else None
def _session_message_key_with_sidecar(base_key: tuple, msg: dict) -> tuple:
"""Append provider sidecar identity only when one is actually present.
The no-sidecar key shape is an internal compatibility surface used by
reconciliation tests and callers. A present sidecar must extend that
identity so different provider bytes cannot collapse into one duplicate.
"""
sidecar = _session_message_api_content_key(msg)
return base_key if sidecar is None else (*base_key, sidecar)
def _session_message_merge_key(msg: dict):
@@ -8322,7 +8371,9 @@ def _session_message_merge_key(msg: dict):
return ("non_dict", repr(msg))
message_identity = msg.get("id") or msg.get("message_id")
if message_identity:
return ("message_id", str(message_identity))
return _session_message_key_with_sidecar(
("message_id", str(message_identity)), msg
)
# Include tool_calls so assistant messages that invoke different tools
# (but share identical empty content and same-second timestamp) are not
# collapsed by the merge-key guard at line ~4216. Without this,
@@ -8331,7 +8382,7 @@ def _session_message_merge_key(msg: dict):
# every state.db tool-call after the first one registered by the sidecar.
_tc = msg.get("tool_calls")
_tc_key = json.dumps(_tc, sort_keys=True, default=str) if _tc else ""
return (
return _session_message_key_with_sidecar((
"legacy",
str(msg.get("role") or ""),
str(msg.get("content") or ""),
@@ -8339,7 +8390,7 @@ def _session_message_merge_key(msg: dict):
str(msg.get("tool_call_id") or ""),
str(msg.get("tool_name") or msg.get("name") or ""),
_tc_key,
)
), msg)
def _session_messages_have_prefix(messages, prefix) -> bool:
@@ -8386,6 +8437,585 @@ def _merge_session_display_metadata(target: dict | None, source: dict | None) ->
target[key] = copy.deepcopy(value)
def _state_db_row_identity_details(message: dict | None) -> tuple[str | None, bool]:
"""Return ``(row_id, valid)`` for private state.db provenance aliases.
A message carrying two different aliases is contradictory provenance. It
must not silently fall through to timestamp/sequence matching, because that
would turn an identity conflict into a guessed provider-side payload.
"""
if not isinstance(message, dict):
return None, True
values = set()
for key in ("_state_db_row_id", "_db_row_id", "state_db_row_id"):
if key not in message or message.get(key) in (None, ""):
continue
value = message.get(key)
if isinstance(value, bool):
return None, False
if isinstance(value, int):
normalized = str(value) if value >= 0 else None
elif isinstance(value, float):
normalized = str(int(value)) if math.isfinite(value) and value >= 0 and value.is_integer() else None
elif isinstance(value, str):
text = value.strip()
normalized = text if text.isdigit() else None
if normalized is not None:
normalized = str(int(normalized))
else:
normalized = None
if normalized is None:
return None, False
values.add(normalized)
if len(values) > 1:
return None, False
return (next(iter(values)) if values else None), True
def _state_db_row_identity(message: dict | None):
"""Return durable state.db provenance without treating WebUI ``id`` as it."""
identity, valid = _state_db_row_identity_details(message)
return identity if valid else None
def _stable_message_identity_details(message: dict | None) -> tuple[str | None, bool]:
"""Return a canonical stable message id and whether its aliases are valid."""
if not isinstance(message, dict):
return None, True
values = set()
for key in ("id", "message_id"):
if key not in message or message.get(key) in (None, ""):
continue
value = message.get(key)
if isinstance(value, bool):
return None, False
if isinstance(value, (int, str)):
normalized = str(value).strip()
elif isinstance(value, float):
normalized = str(value) if math.isfinite(value) else ""
else:
normalized = ""
if not normalized:
return None, False
values.add(normalized)
if len(values) > 1:
return None, False
return (next(iter(values)) if values else None), True
def _message_identity_compatible(target: dict | None, source: dict | None) -> bool:
"""Return whether private identities do not contradict one another."""
target_stable, target_stable_valid = _stable_message_identity_details(target)
source_stable, source_stable_valid = _stable_message_identity_details(source)
if not target_stable_valid or not source_stable_valid:
return False
if target_stable is not None and source_stable is not None and target_stable != source_stable:
return False
target_row_id, target_row_id_valid = _state_db_row_identity_details(target)
source_row_id, source_row_id_valid = _state_db_row_identity_details(source)
if not target_row_id_valid or not source_row_id_valid:
return False
if target_row_id is not None and source_row_id is not None and target_row_id != source_row_id:
return False
return _visible_content_compatible(target, source)
def _message_exact_timestamp(message: dict | None):
"""Return a numeric transcript timestamp, preserving ``0`` as valid."""
if not isinstance(message, dict):
return None
parsed, valid = _message_exact_timestamp_details(message)
return parsed if valid else None
def _message_exact_timestamp_details(message: dict | None) -> tuple[float | None, bool]:
"""Return ``(timestamp, valid)`` while distinguishing absent metadata."""
if not isinstance(message, dict):
return None, True
for key in ("timestamp", "_ts"):
if key not in message or message.get(key) in (None, ""):
continue
try:
parsed = float(message.get(key))
except (TypeError, ValueError):
return None, False
if not math.isfinite(parsed):
return None, False
return parsed, True
return None, True
def _message_sidecar_role(message: dict | None):
if not isinstance(message, dict):
return None
role = str(message.get("role") or "").strip().lower()
return role if role in {"user", "assistant"} else None
_WORKSPACE_PREFIX_RE = re.compile(r"^\s*\[Workspace(?:::v1)?:[^\]]+\]\s*")
def _message_visible_content_key(message: dict | None):
"""Return a canonical visible-content key for sidecar reconciliation."""
if not isinstance(message, dict):
return None
role = _message_sidecar_role(message)
if role is None:
return None
def _normalize(value, *, strip_prefix=False):
if isinstance(value, str):
text = value
if strip_prefix:
text = _WORKSPACE_PREFIX_RE.sub("", text, count=1)
return " ".join(text.split())
if isinstance(value, list):
return [_normalize(item, strip_prefix=strip_prefix) for item in value]
if isinstance(value, dict):
return {
key: _normalize(child, strip_prefix=False)
for key, child in value.items()
if key not in {"api_content", "_state_db_row_id", "_db_row_id", "state_db_row_id"}
}
return value
normalized = _normalize(message.get("content"), strip_prefix=(role == "user"))
try:
content = json.dumps(normalized, ensure_ascii=False, sort_keys=True, default=str)
except (TypeError, ValueError):
content = repr(normalized)
return role, content
def _visible_content_compatible(target: dict | None, source: dict | None) -> bool:
"""Return True only when role and visible content agree exactly."""
return _message_visible_content_key(target) == _message_visible_content_key(source)
def _copy_api_content_sidecar(target: dict | None, source: dict | None) -> bool:
"""Copy a non-empty internal sidecar without replacing an existing one."""
if not isinstance(target, dict) or not isinstance(source, dict):
return False
target_role = _message_sidecar_role(target)
source_role = _message_sidecar_role(source)
if target_role is None or target_role != source_role:
return False
if not _visible_content_compatible(target, source):
return False
if target.get("api_content") not in (None, ""):
return True
api_content = source.get("api_content")
if isinstance(api_content, str) and api_content:
target["api_content"] = api_content
return True
return False
def _reconcile_api_content_sidecars(sidecar_messages: list, state_messages: list) -> None:
"""Attach state.db ``api_content`` to the matching sidecar transcript rows.
Matching is intentionally stricter than visible transcript dedupe. A
unique stable message id is preferred, followed by a unique durable
state.db row id, exact timestamps, same-second timestamp drift, and finally
a mutually-unique role/content pair that may have mixed timestamp metadata.
Duplicate, malformed, or contradictory provenance is consumed without a
fallback guess. Repeated visible rows without unique provenance remain
unattached. If both sides already carry different non-empty provider
sidecars, neither is attached; the append-only merge preserves those rows
as distinct.
"""
sidecar = [message for message in sidecar_messages or () if isinstance(message, dict)]
state = [
message
for message in state_messages or ()
if isinstance(message, dict)
and _message_sidecar_role(message) is not None
and isinstance(message.get("api_content"), str)
and message.get("api_content")
]
if not sidecar or not state:
return
used_targets: set[int] = set()
used_sources: set[int] = set()
# Invalid provenance is never treated as absent metadata. Consume those
# rows up front so no weaker tier can attach an arbitrary provider sidecar.
for index, message in enumerate(sidecar):
_, row_id_valid = _state_db_row_identity_details(message)
_, timestamp_valid = _message_exact_timestamp_details(message)
_, stable_id_valid = _stable_message_identity_details(message)
if not row_id_valid or not timestamp_valid or not stable_id_valid:
used_targets.add(index)
for source_index, message in enumerate(state):
_, row_id_valid = _state_db_row_identity_details(message)
_, timestamp_valid = _message_exact_timestamp_details(message)
_, stable_id_valid = _stable_message_identity_details(message)
if not row_id_valid or not timestamp_valid or not stable_id_valid:
used_sources.add(source_index)
# 1. Stable message identity. This is the strongest WebUI-side identity
# and must run before timestamp/content fallbacks. Duplicate or conflicting
# aliases are consumed above and cannot fall through to a guess.
targets_by_stable_id = {}
state_by_stable_id = {}
for index, message in enumerate(sidecar):
if index in used_targets:
continue
stable_id, valid = _stable_message_identity_details(message)
if valid and stable_id is not None:
targets_by_stable_id.setdefault(stable_id, []).append(index)
for source_index, message in enumerate(state):
if source_index in used_sources:
continue
stable_id, valid = _stable_message_identity_details(message)
if valid and stable_id is not None:
state_by_stable_id.setdefault(stable_id, []).append(source_index)
stable_ids = list(targets_by_stable_id)
stable_ids.extend(
stable_id
for stable_id in state_by_stable_id
if stable_id not in targets_by_stable_id
)
duplicate_stable_target_indexes = set()
duplicate_stable_source_indexes = set()
for stable_id in stable_ids:
target_indexes = targets_by_stable_id.get(stable_id, [])
source_indexes = state_by_stable_id.get(stable_id, [])
if len(target_indexes) > 1 or len(source_indexes) > 1:
# Durable row ids are stronger provenance than a repeated stable
# message id. Defer this bucket's quarantine until after the row-id
# tier so unique row-id pairs can still resolve it one-to-one.
duplicate_stable_target_indexes.update(target_indexes)
duplicate_stable_source_indexes.update(source_indexes)
for stable_id in stable_ids:
target_indexes = [
index
for index in targets_by_stable_id.get(stable_id, ())
if index not in used_targets
]
source_indexes = [
index
for index in state_by_stable_id.get(stable_id, ())
if index not in used_sources
]
# A singleton stable id present on only one side remains eligible for
# stronger durable-row, timestamp, or content matching below.
if len(target_indexes) != 1 or len(source_indexes) != 1:
continue
target_index = target_indexes[0]
source_index = source_indexes[0]
if not _message_identity_compatible(sidecar[target_index], state[source_index]):
used_targets.add(target_index)
used_sources.add(source_index)
continue
target_api_content = _session_message_api_content_key(sidecar[target_index])
source_api_content = _session_message_api_content_key(state[source_index])
used_targets.add(target_index)
used_sources.add(source_index)
if (
target_api_content is not None
and source_api_content is not None
and target_api_content != source_api_content
):
continue
_copy_api_content_sidecar(sidecar[target_index], state[source_index])
# 2. Durable row identity. This path is unambiguous only when every
# alias agrees, each row id occurs once on each side, and visible content
# is compatible. Any duplicate/conflicting id is consumed and rejected;
# it must not fall through to a weaker tier. A unique row id with
# conflicting non-empty sidecars is left for the append-only merge, which
# can preserve both authoritative payloads without guessing.
targets_by_row_id = {}
for index, message in enumerate(sidecar):
if index in used_targets:
continue
row_id, valid = _state_db_row_identity_details(message)
if not valid:
used_targets.add(index)
elif row_id is not None:
targets_by_row_id.setdefault(row_id, []).append(index)
state_by_row_id = {}
for source_index, message in enumerate(state):
if source_index in used_sources:
continue
row_id, valid = _state_db_row_identity_details(message)
if not valid:
used_sources.add(source_index)
elif row_id is not None:
state_by_row_id.setdefault(row_id, []).append(source_index)
# Reject duplicate source buckets independently of the target map. A
# source-only duplicate id has no target loop iteration to consume it, so
# without this pre-pass it would descend into timestamp/metadata-free
# matching and attach one arbitrary provider sidecar.
for source_indexes in state_by_row_id.values():
if len(source_indexes) > 1:
used_sources.update(source_indexes)
for row_id, original_target_indexes in targets_by_row_id.items():
original_source_indexes = state_by_row_id.get(row_id, [])
target_indexes = [
index for index in original_target_indexes if index not in used_targets
]
source_indexes = [
index for index in original_source_indexes if index not in used_sources
]
if len(original_target_indexes) != 1 or len(original_source_indexes) != 1:
used_targets.update(target_indexes)
used_sources.update(source_indexes)
continue
if len(target_indexes) != 1 or len(source_indexes) != 1:
continue
target_index = target_indexes[0]
source_index = source_indexes[0]
if not _message_identity_compatible(sidecar[target_index], state[source_index]):
# Row ids never override contradictory stable ids, aliases, roles,
# or visible content. Quarantine both sides before fallback.
used_targets.add(target_index)
used_sources.add(source_index)
continue
target_api_content = _session_message_api_content_key(sidecar[target_index])
source_api_content = _session_message_api_content_key(state[source_index])
if (
target_api_content is not None
and source_api_content is not None
and target_api_content != source_api_content
):
# A durable row id is not sufficient to reconcile two already
# authoritative provider payloads. Quarantine both rows so a
# weaker timestamp/content tier cannot attach one payload to the
# other row.
used_targets.add(target_index)
used_sources.add(source_index)
continue
used_targets.add(target_index)
used_sources.add(source_index)
_copy_api_content_sidecar(sidecar[target_index], state[source_index])
# Any duplicate stable-id rows left unresolved by the authoritative row-id
# tier are ambiguous and must not fall through to timestamps/content.
used_targets.update(
index for index in duplicate_stable_target_indexes if index not in used_targets
)
used_sources.update(
index for index in duplicate_stable_source_indexes if index not in used_sources
)
# 3. Exact role + timestamp. Equal timestamps are not provenance; resolve
# only mutually-unique compatible pairs inside a bucket and never guess by
# list order when a relationship is ambiguous.
sidecar_by_exact_timestamp = {}
state_by_exact_timestamp = {}
for index, message in enumerate(sidecar):
if index in used_targets:
continue
role = _message_sidecar_role(message)
timestamp = _message_exact_timestamp(message)
_, valid = _state_db_row_identity_details(message)
if valid and role is not None and timestamp is not None:
sidecar_by_exact_timestamp.setdefault((role, timestamp), []).append(index)
for source_index, message in enumerate(state):
if source_index in used_sources:
continue
role = _message_sidecar_role(message)
timestamp = _message_exact_timestamp(message)
_, valid = _state_db_row_identity_details(message)
if valid and role is not None and timestamp is not None:
state_by_exact_timestamp.setdefault((role, timestamp), []).append((source_index, message))
for key, state_rows in state_by_exact_timestamp.items():
target_indexes = [
index
for index in sidecar_by_exact_timestamp.get(key, ())
if index not in used_targets
]
if not target_indexes:
# No exact-timestamp target is not an ambiguity: leave the source
# row available for the documented same-second/content tiers.
continue
# Use the same mutually-unique compatibility graph as the same-second
# tier. This resolves distinct content pairs inside a repeated exact
# timestamp bucket without guessing by list order.
compatible_targets_by_source = {}
for source_index, source_message in state_rows:
if source_index in used_sources:
continue
compatible_targets_by_source[source_index] = [
target_index
for target_index in target_indexes
if target_index not in used_targets
and _message_identity_compatible(sidecar[target_index], source_message)
and (
_session_message_api_content_key(sidecar[target_index]) is None
or _session_message_api_content_key(sidecar[target_index])
== _session_message_api_content_key(source_message)
)
]
compatible_sources_by_target = {}
for source_index, candidates in compatible_targets_by_source.items():
for target_index in candidates:
compatible_sources_by_target.setdefault(target_index, []).append(source_index)
for source_index, candidates in compatible_targets_by_source.items():
if len(candidates) != 1:
continue
target_index = candidates[0]
if len(compatible_sources_by_target.get(target_index, ())) != 1:
continue
if source_index in used_sources or target_index in used_targets:
continue
used_sources.add(source_index)
used_targets.add(target_index)
_copy_api_content_sidecar(sidecar[target_index], state[source_index])
# 4. Same-second sub-second drift. The sidecar JSON and state.db can
# record one logical turn with different fractional timestamps while still
# landing in the same wall-clock second. Do not use the truncated second
# as identity by itself: require a unique *compatible* visible row on each
# side. A candidate is compatible only when role + normalized visible
# content and all available provenance agree, and neither side carries a
# conflicting provider sidecar.
# This keeps repeated text, contradictory provenance, and conflicting wire
# payloads fail-closed instead of guessing by list order.
sidecar_by_same_second = {}
state_by_same_second = {}
for index, message in enumerate(sidecar):
if index in used_targets:
continue
role = _message_sidecar_role(message)
timestamp = _message_exact_timestamp(message)
row_id, valid = _state_db_row_identity_details(message)
if valid and role is not None and timestamp is not None:
second = _normalized_message_timestamp_for_key(timestamp)
sidecar_by_same_second.setdefault((role, second), []).append(index)
for source_index, message in enumerate(state):
if source_index in used_sources:
continue
role = _message_sidecar_role(message)
timestamp = _message_exact_timestamp(message)
row_id, valid = _state_db_row_identity_details(message)
if valid and role is not None and timestamp is not None:
second = _normalized_message_timestamp_for_key(timestamp)
state_by_same_second.setdefault((role, second), []).append(source_index)
for key, state_indexes in state_by_same_second.items():
target_indexes = [
index
for index in sidecar_by_same_second.get(key, ())
if index not in used_targets
]
if not target_indexes:
continue
compatible_targets_by_source = {}
for source_index in state_indexes:
if source_index in used_sources:
continue
source_message = state[source_index]
compatible_targets_by_source[source_index] = [
target_index
for target_index in target_indexes
if target_index not in used_targets
and _message_identity_compatible(sidecar[target_index], source_message)
and (
_session_message_api_content_key(sidecar[target_index]) is None
or _session_message_api_content_key(sidecar[target_index])
== _session_message_api_content_key(source_message)
)
]
compatible_sources_by_target = {}
for source_index, candidates in compatible_targets_by_source.items():
for target_index in candidates:
compatible_sources_by_target.setdefault(target_index, []).append(source_index)
# Accept only one-to-one compatible pairs. This deliberately does not
# zip a bucket: repeated same-text rows or equal timestamps remain
# distinct until durable provenance resolves them.
for source_index, candidates in compatible_targets_by_source.items():
if len(candidates) != 1:
continue
target_index = candidates[0]
if len(compatible_sources_by_target.get(target_index, ())) != 1:
continue
if source_index in used_sources or target_index in used_targets:
continue
used_sources.add(source_index)
used_targets.add(target_index)
_copy_api_content_sidecar(sidecar[target_index], state[source_index])
# 5. Role + visible-content fallback tolerates mixed timestamp metadata,
# but only when the candidate relationship is mutually unique. Repeated
# identical rows have no principled ordering and remain unattached.
sidecar_by_visible_content = {}
state_by_visible_content = {}
for index, message in enumerate(sidecar):
if index in used_targets:
continue
role = _message_sidecar_role(message)
_, row_id_valid = _state_db_row_identity_details(message)
_, stable_id_valid = _stable_message_identity_details(message)
_, timestamp_valid = _message_exact_timestamp_details(message)
if (
row_id_valid
and stable_id_valid
and timestamp_valid
and role is not None
and _message_visible_content_key(message) is not None
):
sidecar_by_visible_content.setdefault(_message_visible_content_key(message), []).append(index)
for source_index, message in enumerate(state):
if source_index in used_sources:
continue
role = _message_sidecar_role(message)
_, row_id_valid = _state_db_row_identity_details(message)
_, stable_id_valid = _stable_message_identity_details(message)
_, timestamp_valid = _message_exact_timestamp_details(message)
if (
row_id_valid
and stable_id_valid
and timestamp_valid
and role is not None
and _message_visible_content_key(message) is not None
):
state_by_visible_content.setdefault(_message_visible_content_key(message), []).append(source_index)
for visible_key, target_indexes in sidecar_by_visible_content.items():
source_indexes = state_by_visible_content.get(visible_key, [])
if len(target_indexes) != 1 or len(source_indexes) != 1:
continue
target_index = target_indexes[0]
source_index = source_indexes[0]
target_timestamp, target_timestamp_valid = _message_exact_timestamp_details(
sidecar[target_index]
)
source_timestamp, source_timestamp_valid = _message_exact_timestamp_details(
state[source_index]
)
if (
not target_timestamp_valid
or not source_timestamp_valid
or (target_timestamp is not None and source_timestamp is not None)
):
continue
if not _message_identity_compatible(sidecar[target_index], state[source_index]):
continue
target_api_content = _session_message_api_content_key(sidecar[target_index])
source_api_content = _session_message_api_content_key(state[source_index])
if (
target_api_content is not None
and source_api_content is not None
and target_api_content != source_api_content
):
continue
used_targets.add(target_index)
used_sources.add(source_index)
_copy_api_content_sidecar(sidecar[target_index], state[source_index])
def _session_message_dedup_key(msg: dict):
"""Like _session_message_merge_key but preserves full-precision timestamp.
@@ -8398,13 +9028,15 @@ def _session_message_dedup_key(msg: dict):
return ("non_dict", repr(msg))
message_identity = msg.get("id") or msg.get("message_id")
if message_identity:
return ("message_id", str(message_identity))
return _session_message_key_with_sidecar(
("message_id", str(message_identity)), msg
)
# Include tool_calls in the key so assistant messages that carry
# different tool invocations (but identical empty content/timestamp)
# are never collapsed into one. (#3346 regression)
_tc = msg.get("tool_calls")
_tc_key = json.dumps(_tc, sort_keys=True, default=str) if _tc else ""
return (
return _session_message_key_with_sidecar((
"legacy",
str(msg.get("role") or ""),
str(msg.get("content") or ""),
@@ -8412,7 +9044,7 @@ def _session_message_dedup_key(msg: dict):
str(msg.get("tool_call_id") or ""),
str(msg.get("tool_name") or msg.get("name") or ""),
_tc_key,
)
), msg)
def _normalized_session_message_content(msg: dict) -> str:
@@ -8450,12 +9082,12 @@ def _session_message_content_key(msg: dict):
content = " ".join(
_strip_workspace_prefix(content, include_legacy=True).split()
)
return (
return _session_message_key_with_sidecar((
role,
content,
str(msg.get("tool_call_id") or ""),
str(msg.get("tool_name") or msg.get("name") or ""),
)
), msg)
def _session_message_visible_key(msg: dict):
@@ -8467,11 +9099,11 @@ def _session_message_visible_key(msg: dict):
# ("assistant", "") and the merge treats state.db rows as replays.
_tc = msg.get("tool_calls")
_tc_key = json.dumps(_tc, sort_keys=True, default=str) if _tc else ""
return (
return _session_message_key_with_sidecar((
str(msg.get("role") or ""),
_normalized_session_message_content(msg),
_tc_key,
)
), msg)
def _build_visible_duplicate_lookup(visible_keys: set[tuple]) -> dict:
@@ -8496,6 +9128,7 @@ def _matching_visible_duplicate(visible_key: tuple, visible_keys: set[tuple], lo
return visible_key
role = visible_key[0]
content = visible_key[1] if len(visible_key) > 1 else ""
sidecar = visible_key[3] if len(visible_key) > 3 else None
if not content:
return None
if lookup is None:
@@ -8505,7 +9138,8 @@ def _matching_visible_duplicate(visible_key: tuple, visible_keys: set[tuple], lo
for existing_key in lookup.get("by_role", {}).get(role, []):
existing_role = existing_key[0]
existing_content = existing_key[1] if len(existing_key) > 1 else ""
if role != existing_role or not existing_content:
existing_sidecar = existing_key[3] if len(existing_key) > 3 else None
if role != existing_role or sidecar != existing_sidecar or not existing_content:
continue
# Exact visible-key equality was checked above. For very large payloads
# (tool logs / request dumps), Python-in substring and fuzzy-token
@@ -8582,6 +9216,12 @@ def state_db_delta_after_context(sidecar_context: list, state_messages: list) ->
if not sidecar_context or not state_messages:
return state_messages
# Attach trusted state.db sidecars before computing context keys. A missing
# sidecar is not a wildcard: once attached, it participates in every
# duplicate/prefix decision below and distinct provider bytes remain
# distinct rows.
_reconcile_api_content_sidecars(sidecar_context, state_messages)
# Recovered interrupted turns are special: the visible interruption marker
# is synthetic, so the recovered user turn should still count as a mirrored
# prefix when it is the actual aligned prefix row.
@@ -8836,6 +9476,46 @@ def merge_session_messages_append_only(
"""
sidecar_messages = list(sidecar_messages or [])
state_messages = list(state_messages or [])
_reconcile_api_content_sidecars(sidecar_messages, state_messages)
# The reconciler's quarantine sets are invocation-local. Mirror the
# identity-bucket guards here because this append-only merge has its own
# row-id fast path that must not re-admit a row isolated above.
sidecar_row_id_counts = collections.Counter()
state_row_id_counts = collections.Counter()
for message in sidecar_messages:
row_id, row_id_valid = _state_db_row_identity_details(message)
if row_id_valid and row_id is not None:
sidecar_row_id_counts[row_id] += 1
for message in state_messages:
row_id, row_id_valid = _state_db_row_identity_details(message)
if row_id_valid and row_id is not None:
state_row_id_counts[row_id] += 1
def _row_id_fast_path_allowed(existing, incoming) -> bool:
if not isinstance(existing, dict) or not isinstance(incoming, dict):
return False
existing_row_id, existing_row_id_valid = _state_db_row_identity_details(existing)
incoming_row_id, incoming_row_id_valid = _state_db_row_identity_details(incoming)
if (
not existing_row_id_valid
or not incoming_row_id_valid
or existing_row_id is None
or existing_row_id != incoming_row_id
):
return False
if (
sidecar_row_id_counts.get(existing_row_id, 0) > 1
or state_row_id_counts.get(existing_row_id, 0) > 1
):
return False
_, existing_timestamp_valid = _message_exact_timestamp_details(existing)
_, incoming_timestamp_valid = _message_exact_timestamp_details(incoming)
return (
existing_timestamp_valid
and incoming_timestamp_valid
and _message_identity_compatible(existing, incoming)
)
# Per-invocation cache keyed by message identity. Sidecar/state message objects
# are retained for this call, and this function does not mutate key-defining
# fields before each helper call.
@@ -8977,6 +9657,8 @@ def merge_session_messages_append_only(
merged_by_message_key = {}
merged_by_dedup_key = {}
merged_by_visible_key = {}
merged_by_row_id = {}
ambiguous_row_ids = set()
max_sidecar_timestamp = None
def _remember_merged_message(message):
@@ -8985,6 +9667,12 @@ def merge_session_messages_append_only(
merged_by_message_key.setdefault(_cached_message_key(message, "merge"), message)
merged_by_dedup_key.setdefault(_cached_message_key(message, "dedup"), message)
merged_by_visible_key.setdefault(_cached_message_key(message, "visible"), message)
row_id, row_id_valid = _state_db_row_identity_details(message)
if row_id_valid and row_id is not None:
if row_id in merged_by_row_id:
ambiguous_row_ids.add(row_id)
else:
merged_by_row_id[row_id] = message
for msg in sidecar_messages:
timestamp = _message_timestamp_as_float(msg)
@@ -9051,6 +9739,32 @@ def merge_session_messages_append_only(
# are caught by the dedup guard (#3346).
seen_dedup_keys.add(dedup_key)
continue
row_id, row_id_valid = _state_db_row_identity_details(msg)
row_id_sidecar_conflict = False
existing = (
merged_by_row_id.get(row_id)
if row_id_valid and row_id is not None
else None
)
if (
row_id_valid
and row_id is not None
and existing is not None
and row_id not in ambiguous_row_ids
and _row_id_fast_path_allowed(existing, msg)
):
existing_api_content = _session_message_api_content_key(existing)
incoming_api_content = _session_message_api_content_key(msg)
row_id_sidecar_conflict = (
existing_api_content is not None
and incoming_api_content is not None
and existing_api_content != incoming_api_content
)
if not row_id_sidecar_conflict:
if existing_api_content is None and incoming_api_content is not None:
_copy_api_content_sidecar(existing, msg)
_merge_session_display_metadata(existing, msg)
continue
# Skip rows ABOVE the watermark only while the sidecar has NOT advanced
# past the watermark. Because Session.save() no longer auto-clears the
# watermark, an unconditional `timestamp > watermark` skip would become
@@ -9185,6 +9899,7 @@ def merge_session_messages_append_only(
and max_sidecar_timestamp is not None
and timestamp is not None
and timestamp <= max_sidecar_timestamp
and not row_id_sidecar_conflict
):
# When a truncation watermark is active and the sidecar holds only
# the edited user checkpoint, state.db may contain an assistant/tool
+126 -38
View File
@@ -2891,6 +2891,8 @@ from api.helpers import (
_security_headers,
_sanitize_error,
redact_session_data,
public_session_projection,
strip_public_internal_fields,
_redact_text,
_CLIENT_DISCONNECT_ERRORS,
)
@@ -9623,6 +9625,7 @@ from api.models import (
get_state_db_session_message_keys_before_timestamp,
get_state_db_session_summary,
merge_session_messages_append_only,
_reconcile_api_content_sidecars,
_enrich_sidebar_lineage_metadata,
_active_stream_ids,
_evict_sessions_over_cap,
@@ -13283,7 +13286,7 @@ def handle_get(handler, parsed) -> bool:
}
attach_todo_state(sess, msgs)
sess = _merge_cli_sidebar_metadata(sess, cli_meta)
return j(handler, {"session": redact_session_data(sess)})
return j(handler, {"session": public_session_projection(sess)})
if parsed.path == "/api/session/lineage/report":
sid = parse_qs(parsed.query).get("session_id", [""])[0]
@@ -14411,7 +14414,9 @@ def handle_post(handler, parsed) -> bool:
"created_at": share_meta["share_created_at"],
"updated_at": share_meta["share_updated_at"],
},
"session": response_session.compact() | {"messages": response_session.messages},
"session": public_session_projection(
response_session.compact() | {"messages": response_session.messages}
),
},
)
@@ -14450,7 +14455,9 @@ def handle_post(handler, parsed) -> bool:
handler,
{
"ok": True,
"session": response_session.compact() | {"messages": response_session.messages},
"session": public_session_projection(
response_session.compact() | {"messages": response_session.messages}
),
},
)
@@ -14580,7 +14587,9 @@ def handle_post(handler, parsed) -> bool:
profile=getattr(s, "profile", None),
session_id=getattr(s, "session_id", None),
)
payload = {"session": s.compact() | {"messages": s.messages}}
payload = {
"session": public_session_projection(s.compact() | {"messages": s.messages})
}
if worktree_skipped:
# Config-default worktree was skipped (non-git workspace); tell the
# client the session is plain so the UI doesn't assume isolation.
@@ -14677,7 +14686,14 @@ def handle_post(handler, parsed) -> bool:
session_id=getattr(copied_session, "session_id", None),
)
return j(handler, {"session": copied_session.compact() | {"messages": copied_session.messages}})
return j(
handler,
{
"session": public_session_projection(
copied_session.compact() | {"messages": copied_session.messages}
)
},
)
except Exception as e:
return bad(handler, str(e))
@@ -15076,7 +15092,10 @@ def handle_post(handler, parsed) -> bool:
except Exception:
logger.debug("Failed to close workspace terminal after workspace update")
set_last_workspace(new_ws)
return j(handler, {"session": s.compact() | {"messages": s.messages}})
return j(
handler,
{"session": public_session_projection(s.compact() | {"messages": s.messages})},
)
if parsed.path == "/api/session/worktree/remove":
sid = body.get("session_id", "")
if not sid or not isinstance(sid, str) or not sid.strip():
@@ -15353,7 +15372,13 @@ def handle_post(handler, parsed) -> bool:
from api.config import _evict_session_agent
_evict_session_agent(body["session_id"])
return j(
handler, {"ok": True, "session": s.compact() | {"messages": s.messages}}
handler,
{
"ok": True,
"session": public_session_projection(
s.compact() | {"messages": s.messages}
),
},
)
if parsed.path == "/api/session/branch":
@@ -15455,11 +15480,21 @@ def handle_post(handler, parsed) -> bool:
from api.session_ops import truncate_context_for_display_keep
fork_keep = keep_count if keep_count is not None else len(source_messages)
forked_context = truncate_context_for_display_keep(
getattr(source, "context_messages", None),
source_messages,
fork_keep,
forked_context = copy.deepcopy(
truncate_context_for_display_keep(
getattr(source, "context_messages", None),
source_messages,
fork_keep,
)
)
# `truncate_context_for_display_keep` aligns rows by visible display
# identity but intentionally does not carry provider replay sidecars.
# Reconcile only the retained prefix before constructing the branch so
# its persisted model context receives the same conservative
# api_content bytes as the forked display messages. The helper mutates
# the freshly aligned context copy; the source session and
# forked_messages ownership remain untouched.
_reconcile_api_content_sidecars(forked_context, forked_messages)
branch = Session(
workspace=source.workspace,
model=source.model,
@@ -17028,7 +17063,10 @@ def _handle_session_export(handler, parsed):
active_profile = get_active_profile_name()
if not _profiles_match(getattr(s, "profile", None), active_profile):
return bad(handler, "Session not found", 404)
safe = redact_session_data(s.__dict__)
# ``public_session_projection`` supersedes the narrower
# ``redact_session_data`` path so export context_messages uses the same
# alias-stripping boundary as the visible transcript.
safe = public_session_projection(s.__dict__)
qs = parse_qs(parsed.query)
fmt = qs.get("format", ["json"])[0].lower()
if fmt == "html":
@@ -17694,6 +17732,29 @@ def _runner_event_payload(entry: dict):
return entry
def _project_runner_event_payload(payload):
"""Strip internal replay fields (api_content, row-id aliases) from a runner
SSE payload before it is relayed to the browser.
Runner-backed SSE relays adapter payloads verbatim; a terminal event can
carry a full ``session`` object (with per-message ``api_content`` sidecars)
or be session/message-shaped itself. Neither must reach a client, so route
the transcript-bearing shapes through the same public projection every other
session emitter uses. Non-session payloads pass through unchanged.
"""
if not isinstance(payload, dict):
return payload
# Terminal events wrap the session under a "session" key.
if isinstance(payload.get("session"), dict):
projected = dict(payload)
projected["session"] = public_session_projection(payload["session"])
return projected
# Payload is itself session/message-shaped (has a messages transcript).
if "messages" in payload or "context_messages" in payload:
return public_session_projection(payload)
return payload
def _runner_event_id(run_id: str, entry: dict) -> str | None:
event_id = entry.get("event_id") or entry.get("id")
if event_id:
@@ -17740,7 +17801,7 @@ def _stream_runner_run_events(handler, run_id: str, cursor: str | None = None) -
if not isinstance(entry, dict):
continue
event = _runner_event_name(entry)
_sse_with_id(handler, event, _runner_event_payload(entry), _runner_event_id(run_id, entry))
_sse_with_id(handler, event, _project_runner_event_payload(_runner_event_payload(entry)), _runner_event_id(run_id, entry))
emitted = True
if event in SSE_RELAY_CLOSE_EVENTS:
terminal = True
@@ -22793,7 +22854,7 @@ def _handle_chat_sync(handler, body):
_merge_display_messages_after_agent_result,
_restore_display_reasoning_metadata,
_restore_reasoning_metadata,
_sanitize_messages_for_api,
_sanitize_messages_for_agent,
_compact_session_image_parts_for_persistence,
_context_messages_for_new_turn,
_workspace_context_prefix,
@@ -22824,7 +22885,7 @@ def _handle_chat_sync(handler, body):
result = agent.run_conversation(
user_message=workspace_ctx + msg,
system_message=workspace_system_msg,
conversation_history=_sanitize_messages_for_api(
conversation_history=_sanitize_messages_for_agent(
_previous_context_messages,
cfg=get_config(),
effective_model=_model,
@@ -22906,7 +22967,7 @@ def _handle_chat_sync(handler, body):
{
"answer": result.get("final_response") or "",
"status": "done" if result.get("completed", True) else "partial",
"session": s.compact() | {"messages": s.messages},
"session": public_session_projection(s.compact() | {"messages": s.messages}),
"result": {k: v for k, v in result.items() if k != "messages"},
},
)
@@ -26080,6 +26141,11 @@ def _normalize_message_for_import_refresh(message: object) -> object:
normalized = dict(message)
normalized.pop("timestamp", None)
normalized.pop("_ts", None)
# These are WebUI/Agent replay bookkeeping aliases at the message's top
# level. Strip only those exact keys; nested business payloads are opaque
# and must remain part of the semantic import comparison.
for key in ("api_content", "_state_db_row_id", "_db_row_id", "state_db_row_id"):
normalized.pop(key, None)
return normalized
@@ -26238,19 +26304,21 @@ def _handle_session_import_cli(handler, body):
return j(
handler,
{
"session": existing.compact()
| {
"messages": existing.messages,
"is_cli_session": (False if _existing_is_sa else True),
# Greptile #4911 follow-up: read read_only from
# the persisted Session, NOT from cli_meta. This
# refresh path is for an already-WebUI-owned
# session; the WebUI's persisted view is the
# source of truth for the response, not the
# foreign store's current value. (Mirrors the
# GET /api/session fix.)
"read_only": bool(getattr(existing, "read_only", False)),
},
"session": public_session_projection(
existing.compact()
| {
"messages": existing.messages,
"is_cli_session": (False if _existing_is_sa else True),
# Greptile #4911 follow-up: read read_only from
# the persisted Session, NOT from cli_meta. This
# refresh path is for an already-WebUI-owned
# session; the WebUI's persisted view is the
# source of truth for the response, not the
# foreign store's current value. (Mirrors the
# GET /api/session fix.)
"read_only": bool(getattr(existing, "read_only", False)),
}
),
"imported": False,
},
)
@@ -26334,7 +26402,13 @@ def _handle_session_import_cli(handler, body):
"messages": msgs,
"tool_calls": [],
}
return j(handler, {"session": session_payload, "imported": False})
return j(
handler,
{
"session": public_session_projection(session_payload),
"imported": False,
},
)
s = import_cli_session(
sid,
@@ -26379,11 +26453,13 @@ def _handle_session_import_cli(handler, body):
return j(
handler,
{
"session": s.compact()
| {
"messages": msgs,
"is_cli_session": True,
},
"session": public_session_projection(
s.compact()
| {
"messages": msgs,
"is_cli_session": True,
}
),
"imported": True,
},
)
@@ -26393,9 +26469,15 @@ def _handle_session_import(handler, body):
"""Import a session from a JSON export. Creates a new session with a new ID."""
if not body or not isinstance(body, dict):
return bad(handler, "Request body must be a JSON object")
messages = body.get("messages")
messages = strip_public_internal_fields(
body.get("messages"),
message_records=True,
)
if not isinstance(messages, list):
return bad(handler, 'JSON must contain a "messages" array')
raw_tool_calls = body.get("tool_calls", [])
if not isinstance(raw_tool_calls, list):
return bad(handler, 'JSON "tool_calls" must be an array')
title = body.get("title", "Imported session")
try:
workspace = str(resolve_trusted_workspace(body.get("workspace", str(DEFAULT_WORKSPACE))))
@@ -26407,7 +26489,7 @@ def _handle_session_import(handler, body):
workspace=workspace,
model=model,
messages=messages,
tool_calls=body.get("tool_calls", []),
tool_calls=strip_public_internal_fields(raw_tool_calls),
profile=get_active_profile_name(),
)
s.pinned = body.get("pinned", False)
@@ -26417,7 +26499,13 @@ def _handle_session_import(handler, body):
_evict_sessions_over_cap() # #4765: safe LRU eviction (never active/unsaved)
s.save()
publish_session_list_changed("session_import")
return j(handler, {"ok": True, "session": s.compact() | {"messages": s.messages}})
return j(
handler,
{
"ok": True,
"session": public_session_projection(s.compact() | {"messages": s.messages}),
},
)
def _mask_secrets(obj):
+133 -13
View File
@@ -51,7 +51,11 @@ from api.config import (
_main_model_request_overrides,
PROCESS_SESSION_INDEX, PROCESS_SESSION_INDEX_LOCK,
)
from api.helpers import redact_session_data, _redact_text
from api.helpers import (
redact_session_data,
scrub_internal_replay_fields,
_redact_text,
)
from api.compression_anchor import is_context_compression_marker, visible_messages_for_anchor
from api.compression_recovery import stamp_compression_exhausted_recovery
from api.metering import meter
@@ -129,6 +133,13 @@ def _redacted_session_payload_with_full_messages(session, *, tool_calls=None) ->
return None
def _ephemeral_session_payload(session_id: str, messages) -> dict:
"""Project the non-persistent ``/btw`` terminal session for public SSE."""
return redact_session_data(
{'session_id': session_id, 'messages': messages if isinstance(messages, list) else []}
)
def _cancel_event_payload(
message: str = "Cancelled by user",
*,
@@ -4975,6 +4986,7 @@ def _sanitize_messages_for_api(
effective_model: str | None = None,
effective_provider: str | None = None,
effective_base_url: str | None = None,
preserve_api_content: bool = False,
requested_provider: str = "",
):
"""Return a deep copy of messages with only API-safe fields.
@@ -4995,7 +5007,18 @@ def _sanitize_messages_for_api(
remaining replay gap where an older native image in the saved transcript kept
causing 400s on every later text-only turn (#2297).
"""
strip_native_images = cfg is not None and _resolve_image_input_mode(cfg, effective_provider or "", effective_model or "", requested_provider=requested_provider) == "text"
strip_native_images = cfg is not None and _resolve_image_input_mode(
cfg,
effective_provider or "",
effective_model or "",
requested_provider=requested_provider,
) == "text"
allowed_keys = _API_SAFE_MSG_KEYS
if preserve_api_content:
# ``api_content`` is an internal Agent replay sidecar. It is opt-in
# here because direct provider/compression projections must continue to
# reject unknown bookkeeping fields.
allowed_keys = _API_SAFE_MSG_KEYS | {"api_content"}
# First pass: collect all tool_call_ids declared by assistant messages.
# Handles both OpenAI ('id') and Anthropic ('call_id') field names.
valid_tool_call_ids: set = set()
@@ -5041,7 +5064,16 @@ def _sanitize_messages_for_api(
if not tid or tid not in valid_tool_call_ids:
# Orphaned tool result — skip to avoid 400 from strict providers.
continue
sanitized = {k: v for k, v in msg.items() if k in _API_SAFE_MSG_KEYS}
sanitized = {k: v for k, v in msg.items() if k in allowed_keys}
sanitized = scrub_internal_replay_fields(
[sanitized],
preserve_message_api_content=preserve_api_content,
message_records=True,
)[0]
if sanitized.get("role") not in {"user", "assistant"}:
sanitized.pop("api_content", None)
elif not isinstance(sanitized.get("api_content"), str) or not sanitized.get("api_content"):
sanitized.pop("api_content", None)
# Drop empty tool_calls — strict providers (DeepSeek, newer OpenAI)
# reject tool_calls: [] with HTTP 400 even when no orphaned calls exist.
if 'tool_calls' in sanitized and not sanitized['tool_calls']:
@@ -5125,6 +5157,33 @@ def _sanitize_messages_for_api(
return final
def _sanitize_messages_for_agent(
messages,
*,
cfg: dict = None,
effective_model: str | None = None,
effective_provider: str | None = None,
effective_base_url: str | None = None,
requested_provider: str = "",
):
"""Build the internal Agent replay projection with ``api_content`` intact.
``api_content`` is a durable provider-facing sidecar, not a direct-provider
payload field. Keep this opt-in at one named boundary so every Agent
history call uses the same contract while ordinary API/compression callers
continue to use the default-stripping sanitizer.
"""
return _sanitize_messages_for_api(
messages,
cfg=cfg,
effective_model=effective_model,
effective_provider=effective_provider,
effective_base_url=effective_base_url,
preserve_api_content=True,
requested_provider=requested_provider,
)
def _api_safe_message_positions(messages):
"""Return [(original_index, sanitized_message)] for API-safe messages."""
valid_tool_call_ids: set = set()
@@ -5157,6 +5216,10 @@ def _api_safe_message_positions(messages):
if not tid or tid not in valid_tool_call_ids:
continue
sanitized = {k: v for k, v in msg.items() if k in _API_SAFE_MSG_KEYS}
sanitized = scrub_internal_replay_fields(
[sanitized],
message_records=True,
)[0]
if 'tool_calls' in sanitized and not sanitized['tool_calls']:
del sanitized['tool_calls']
if is_recovered:
@@ -5241,7 +5304,11 @@ def _deduplicate_context_messages(messages):
if _is_compressed_context_tool_result_summary_message(msg) and not msg.get('tool_call_id'):
deduped.append(msg)
continue
key = _message_identity(msg)
# Context ownership is provider-facing: two rows with identical visible
# text but different durable ``api_content`` sidecars are distinct turns.
# Keep the display identity unchanged so ordinary transcript dedup still
# collapses the same visible row.
key = _message_replay_key(msg)
if isinstance(msg, dict) and msg.get('role') == 'user' and key is not None:
user_exact_key = (
key,
@@ -5620,11 +5687,12 @@ def _message_identity(msg):
)
def _messages_have_prefix(messages, prefix):
def _messages_have_prefix(messages, prefix, *, key_fn=None):
key_fn = key_fn or _message_identity
if len(messages or []) < len(prefix or []):
return False
for idx, expected in enumerate(prefix or []):
if _message_identity((messages or [])[idx]) != _message_identity(expected):
if key_fn((messages or [])[idx]) != key_fn(expected):
return False
return True
@@ -5632,16 +5700,30 @@ def _messages_have_prefix(messages, prefix):
def _message_replay_key(msg):
"""Return a stable comparison key for replay/overlap de-duplication."""
identity = _message_identity(msg)
# ``api_content`` is a provider-facing replay sidecar. It must participate
# in context/replay overlap identity or two same-visible turns can collapse
# before the Agent sees the original wire bytes. Keep synthetic/adjacent
# partial collapse on the existing identity path; partial rows are display
# bookkeeping rather than durable provider turns.
raw_sidecar = (
msg.get("api_content")
if isinstance(msg, dict) and not msg.get("_partial")
else None
)
sidecar = raw_sidecar if isinstance(raw_sidecar, str) and raw_sidecar else None
if identity is not None:
if sidecar is not None:
return (*identity, sidecar)
return identity
if not isinstance(msg, dict):
return None
return (
key = (
str(msg.get('role') or ''),
_message_text(msg.get('content', '')),
str(msg.get('tool_call_id') or ''),
json.dumps(msg.get('tool_calls') or [], sort_keys=True, ensure_ascii=False),
)
return (*key, sidecar) if sidecar is not None else key
def _strip_replayed_prefix(existing_messages, candidates):
@@ -5674,6 +5756,20 @@ def _looks_like_replayed_session_arc_summary(previous_msg, candidate_msg):
return False
if previous_msg.get('role') != candidate_msg.get('role'):
return False
previous_api_content = previous_msg.get("api_content")
candidate_api_content = candidate_msg.get("api_content")
normalized_previous_api_content = (
" ".join(previous_api_content.split())
if isinstance(previous_api_content, str) and previous_api_content
else None
)
normalized_candidate_api_content = (
" ".join(candidate_api_content.split())
if isinstance(candidate_api_content, str) and candidate_api_content
else None
)
if normalized_previous_api_content != normalized_candidate_api_content:
return False
previous_text = " ".join(_message_text(previous_msg.get('content', '')).split())
candidate_text = " ".join(_message_text(candidate_msg.get('content', '')).split())
if len(previous_text) < 2000 or len(candidate_text) < 2000:
@@ -5730,7 +5826,11 @@ def _dedupe_replayed_context_messages(previous_context, result_messages, msg_tex
if not previous_context or not result_messages:
return result_messages
previous_user_tail = _stale_user_tail_candidate(_last_user_row(previous_context))
if not _messages_have_prefix(result_messages, previous_context):
if not _messages_have_prefix(
result_messages,
previous_context,
key_fn=_message_replay_key,
):
# Agent-side role-sequence repair can replace the last prior user row
# with a repaired current-user row. In that shape the result no longer
# has `previous_context` as an exact prefix, but it should still be
@@ -5739,7 +5839,11 @@ def _dedupe_replayed_context_messages(previous_context, result_messages, msg_tex
msg_text
and len(previous_context) >= 1
and len(result_messages) >= len(previous_context)
and _messages_have_prefix(result_messages, previous_context[:-1])
and _messages_have_prefix(
result_messages,
previous_context[:-1],
key_fn=_message_replay_key,
)
):
boundary_idx = len(previous_context) - 1
boundary_row = result_messages[boundary_idx]
@@ -5758,6 +5862,11 @@ def _dedupe_replayed_context_messages(previous_context, result_messages, msg_tex
# history in previous_context untouched.
cleaned_boundary = copy.deepcopy(boundary_row)
cleaned_boundary['content'] = msg_text
# The stale row's provider-facing payload describes the
# discarded prefix + current turn, not the rewritten
# visible turn. Do not let those obsolete bytes survive
# into Agent context.
cleaned_boundary.pop('api_content', None)
candidates = [cleaned_boundary] + result_messages[boundary_idx + 1:]
else:
candidates = result_messages[boundary_idx:]
@@ -6099,6 +6208,10 @@ def _strip_stale_user_merge_from_messages(
):
cleaned = copy.deepcopy(msg) if isinstance(msg, dict) else {'role': 'user', 'content': msg_text}
cleaned['content'] = msg_text
# The stale row's provider-facing payload describes the discarded
# merged prefix, so it is invalid once the visible content is
# rewritten to the submitted turn.
cleaned.pop('api_content', None)
out.append(cleaned)
else:
out.append(msg)
@@ -9761,7 +9874,7 @@ def _run_agent_streaming(
_run_conversation_kwargs = dict(
user_message=user_message,
system_message=workspace_system_msg,
conversation_history=_sanitize_messages_for_api(
conversation_history=_sanitize_messages_for_agent(
_previous_context_messages,
cfg=_cfg,
effective_model=resolved_model,
@@ -9852,8 +9965,15 @@ def _run_agent_streaming(
if isinstance(_m, dict) and _m.get('role') == 'assistant':
_answer = str(_m.get('content', ''))
break
# /btw is intentionally non-persistent, but its terminal SSE
# payload is still public output. Project the ephemeral
# session before enqueueing it so raw Agent ``api_content`` or
# provenance aliases cannot cross the wire.
_ephemeral_session = _ephemeral_session_payload(
session_id, result.get('messages', [])
)
put('done', {
'session': {'session_id': session_id, 'messages': result.get('messages', [])},
'session': _ephemeral_session,
'usage': {'input_tokens': 0, 'output_tokens': 0},
'ephemeral': True,
'answer': _answer,
@@ -10257,7 +10377,7 @@ def _run_agent_streaming(
_heal_kwargs = dict(
user_message=user_message,
system_message=workspace_system_msg,
conversation_history=_sanitize_messages_for_api(
conversation_history=_sanitize_messages_for_agent(
_previous_context_messages,
cfg=_cfg,
effective_model=resolved_model,
@@ -11497,7 +11617,7 @@ def _run_agent_streaming(
_heal_kwargs2 = dict(
user_message=user_message,
system_message=workspace_system_msg,
conversation_history=_sanitize_messages_for_api(
conversation_history=_sanitize_messages_for_agent(
_previous_context_messages,
cfg=_cfg,
effective_model=resolved_model,
+2 -2
View File
@@ -153,7 +153,7 @@ def test_get_session_route_uses_shared_synthesiser():
src = ROUTES_PY.read_text(encoding="utf-8")
# Find the /api/session GET block (not /api/sessions).
block = re.search(
r'if parsed\.path == "/api/session":.*?return j\(handler, \{"session": redact_session_data\(sess\)\}\)',
r'if parsed\.path == "/api/session":.*?return j\(handler, \{"session": public_session_projection\(sess\)\}\)',
src,
re.DOTALL,
)
@@ -185,7 +185,7 @@ def test_get_session_preserves_cli_read_only_flag():
sets to True for BOTH the explicit AND the source-refused
refusal paths."""
block = re.search(
r'if parsed\.path == "/api/session":.*?return j\(handler, \{"session": redact_session_data\(sess\)\}\)\)?',
r'if parsed\.path == "/api/session":.*?return j\(handler, \{"session": public_session_projection\(sess\)\}\)\)?',
ROUTES_PY.read_text(encoding="utf-8"),
re.DOTALL,
)
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,109 @@
"""Regression coverage for the two maintainer fixes folded into PR #6757.
1. redact_session_data must NOT credential-mask operational session fields such
as the workspace path — a valid path can legitimately contain a
credential-shaped component, and masking it corrupts the authoritative value
the client echoes back on the next send (which then fails validation).
2. Runner-backed SSE payloads must be projected before relay so an internal
`api_content` replay sidecar never reaches the browser.
"""
from __future__ import annotations
def test_redact_session_data_preserves_credential_shaped_workspace_path():
from api.helpers import redact_session_data
# A real workspace path whose leaf looks like a secret token.
workdir = "/home/user/keys/sk-abcdefghijklmnopqrstuvwxyz012345/project"
session = {
"id": "sess-1",
"workspace": workdir,
"workdir": workdir,
"cwd": workdir,
"messages": [{"role": "user", "content": "hello"}],
}
out = redact_session_data(session)
# Operational path fields survive verbatim (not masked).
assert out["workspace"] == workdir
assert out["workdir"] == workdir
assert out["cwd"] == workdir
# And the internal replay/id aliases are still stripped from messages.
assert "api_content" not in out["messages"][0]
def test_redact_session_data_still_strips_api_content_from_messages():
from api.helpers import redact_session_data
session = {
"id": "sess-2",
"workspace": "/home/user/project",
"messages": [
{
"role": "assistant",
"content": "clean transcript text",
"api_content": "PROVIDER REPLAY BYTES — must never leak",
"_state_db_row_id": 42,
"_db_row_id": 42,
"state_db_row_id": 42,
}
],
"context_messages": [
{"role": "user", "content": "ctx", "api_content": "ctx replay bytes"}
],
}
out = redact_session_data(session)
msg = out["messages"][0]
assert "api_content" not in msg
assert "_state_db_row_id" not in msg
assert "_db_row_id" not in msg
assert "state_db_row_id" not in msg
assert msg["content"] == "clean transcript text"
# context_messages sidecars are stripped too.
assert "api_content" not in out["context_messages"][0]
def test_project_runner_event_payload_strips_api_content_from_session():
from api.routes import _project_runner_event_payload
payload = {
"status": "done",
"session": {
"id": "sess-3",
"messages": [
{
"role": "assistant",
"content": "visible",
"api_content": "REPLAY BYTES — must not reach browser",
}
],
},
}
out = _project_runner_event_payload(payload)
assert "api_content" not in out["session"]["messages"][0]
assert out["session"]["messages"][0]["content"] == "visible"
# Non-transcript fields on the envelope are preserved.
assert out["status"] == "done"
def test_project_runner_event_payload_strips_api_content_from_message_shaped():
from api.routes import _project_runner_event_payload
payload = {
"id": "sess-4",
"messages": [
{"role": "assistant", "content": "hi", "api_content": "REPLAY — leak?"}
],
}
out = _project_runner_event_payload(payload)
assert "api_content" not in out["messages"][0]
def test_project_runner_event_payload_passes_non_session_through():
from api.routes import _project_runner_event_payload
# A token/delta event with no transcript must pass through untouched.
payload = {"type": "token", "delta": "hello", "seq": 7}
assert _project_runner_event_payload(payload) == payload
# Non-dict payloads pass through unchanged.
assert _project_runner_event_payload("raw") == "raw"
assert _project_runner_event_payload(None) is None
+61 -10
View File
@@ -17,6 +17,7 @@ from api.streaming import (
_attachment_name,
_build_native_multimodal_message,
_NATIVE_IMAGE_MAX_BYTES,
_sanitize_messages_for_agent,
_sanitize_messages_for_api,
)
from api.routes import _normalize_chat_attachments
@@ -355,18 +356,68 @@ class TestBuildNativeMultimodalMessage:
assert sanitized == [{'role': 'user', 'content': content}]
def test_sync_chat_history_sanitizer_receives_config(self):
"""#2398: fallback POST /api/chat must use the text-mode history sanitizer too."""
src = Path('api/routes.py').read_text()
assert 'conversation_history=_sanitize_messages_for_api(' in src, (
'The legacy synchronous /api/chat endpoint must sanitize history through '
'_sanitize_messages_for_api.'
)
assert 'cfg=get_config(),' in src, (
'The legacy synchronous /api/chat endpoint must pass current config into '
'_sanitize_messages_for_api so historical image_url parts are stripped '
'for text-mode providers just like the streaming endpoint.'
"""#2398/#6751: Agent history strips native images but keeps wire sidecars."""
original_user_wire = '[Workspace::v1: /original]\ndescribe this image'
original_assistant_wire = '[Workspace::v1: /original]\nIt is a chart.'
history = [
{
'role': 'user',
'content': [
{'type': 'text', 'text': 'describe this image'},
{'type': 'image_url', 'image_url': {'url': 'data:image/png;base64,AAA='}},
],
'api_content': original_user_wire,
},
{
'role': 'assistant',
'content': 'It is a chart.',
'api_content': original_assistant_wire,
},
]
cfg = {'agent': {'image_input_mode': 'text'}}
sanitized = _sanitize_messages_for_agent(history, cfg=cfg)
assert sanitized[0]['content'] == 'describe this image'
assert 'image_url' not in str(sanitized)
assert sanitized[0]['api_content'] == original_user_wire
assert sanitized[1]['api_content'] == original_assistant_wire
def test_agent_history_sanitizer_forwards_requested_provider(self, monkeypatch):
"""The Agent wrapper must preserve master image-routing provenance."""
import api.streaming as streaming
captured = {}
def resolve_mode(cfg, active_provider, active_model, *, requested_provider):
captured.update({
'cfg': cfg,
'active_provider': active_provider,
'active_model': active_model,
'requested_provider': requested_provider,
})
return 'native'
monkeypatch.setattr(streaming, '_resolve_image_input_mode', resolve_mode)
history = [{'role': 'user', 'content': 'hello', 'api_content': 'wire'}]
cfg = {'agent': {'image_input_mode': 'auto'}}
sanitized = _sanitize_messages_for_agent(
history,
cfg=cfg,
effective_model='model-a',
effective_provider='custom',
requested_provider='custom:gateway-a',
)
assert captured == {
'cfg': cfg,
'active_provider': 'custom',
'active_model': 'model-a',
'requested_provider': 'custom:gateway-a',
}
assert sanitized == history
def test_fake_png_rejected_by_magic_bytes(self):
"""A file named .png that is not actually an image must be rejected."""
with TemporaryDirectory() as d: