diff --git a/src/backend/server/routes/von_routes.py b/src/backend/server/routes/von_routes.py index bc301947..dbbba6f7 100644 --- a/src/backend/server/routes/von_routes.py +++ b/src/backend/server/routes/von_routes.py @@ -116,6 +116,13 @@ build_turn_output_health, turn_output_health_issue_messages, ) +from ...services.turn_failure_capsule_service import ( + TURN_FAILURE_CAPSULE_MAX_BYTES, + TURN_FAILURE_CAPSULE_SCHEMA_VERSION, + build_turn_failure_capsule, + project_stored_turn_failure_capsule, + turn_failure_capsule_size_bytes, +) from ...services.response_transformation_telemetry import ( build_response_transformation_event, build_response_transformation_telemetry_payload, @@ -7451,6 +7458,74 @@ def _finalise_llm_debug_info( terminal_status = _progress_str( llm_interaction_payload.get("ordinary_turn_terminal_status") ) or "not_reported" + outcome_report: Mapping[str, Any] | None = None + outcome_report_sources: list[Any] = [*aux_llm_calls_payload] + if isinstance(diagnostics_payload, Mapping): + diagnostic_aux = diagnostics_payload.get("aux_llm_calls") + if isinstance(diagnostic_aux, list): + outcome_report_sources.extend(diagnostic_aux) + for entry in reversed(outcome_report_sources): + if not isinstance(entry, Mapping): + continue + if ( + entry.get("type") == "adaptive_turn_effect_outcome_report" + and entry.get("schema_version") + == "adaptive_turn_effect_outcome_report.v1" + ): + outcome_report = entry + break + + if terminal_status == "not_reported" and isinstance(outcome_report, Mapping): + terminal_status = ( + _progress_str(outcome_report.get("terminal_status")) or terminal_status + ) + + response_authority = _progress_str(llm_debug_info.get("response_authority")) + if response_authority is None and isinstance(outcome_report, Mapping): + response_authority = _progress_str( + outcome_report.get("response_authority") + ) + report_facts = ( + outcome_report.get("facts") + if isinstance(outcome_report, Mapping) + and isinstance(outcome_report.get("facts"), list) + else [] + ) + turn_failure_capsule = build_turn_failure_capsule( + request_id=llm_debug_info.get("request_id"), + terminal_status=terminal_status, + response_authority=response_authority, + visible_response=final_response_text, + outcome_report=outcome_report, + turn_error_code=( + ( + llm_debug_info.get("error_code") + or ( + terminal_status + if llm_debug_info.get("error") is not None + and terminal_status not in {"completed", "not_reported"} + else None + ) + ) + if not report_facts + else None + ), + turn_error_text=(llm_debug_info.get("error") if not report_facts else None), + code_version=code_version_details.get("version"), + git_commit=code_version_details.get("git_commit"), + sensitive_identity_values=tuple( + value + for value in ( + namespace, + session_id, + resolved_actor_concept_id, + user_id, + org_id, + ) + if isinstance(value, str) and value.strip() + ), + ) + llm_debug_info["turn_failure_capsule"] = turn_failure_capsule turn_execution_record: dict[str, Any] = { "schema_version": "turn_execution_record.observational.v1", "record_kind": "observational", @@ -13280,6 +13355,7 @@ def _coerce_spoken_text(text: object) -> str | None: }, "messages": current_turn_messages, "response": response_text, + "response_authority": response_authority, "presenter_channels": presenter_channels, "screen_backfill_second_pass_attempted": screen_backfill_second_pass_attempted, "screen_backfill_second_pass_reason": screen_backfill_second_pass_reason, @@ -14353,6 +14429,122 @@ def history_telemetry_locator(): return jsonify({"error": str(e)}), 500 +@von_bp.route("/history/turn_failure_capsule", methods=["GET"]) +def history_turn_failure_capsule(): + """Return one stored, bounded turn projection after exact auth checks.""" + + try: + from ...security.access_control import get_effective_user_concept_id + + user_concept_id = get_effective_user_concept_id() + except Exception: + user_concept_id = session.get("user_concept_id") + user_concept_id = _normalise_concept_id(user_concept_id) + if not user_concept_id: + return jsonify({"error": "Not authenticated"}), 401 + + request_id = _progress_str(request.args.get("request_id")) + session_id = _progress_str( + request.args.get("session_id") or session.get("session_id") + ) + history_index = request.args.get("history_index", type=int) + if not request_id: + return jsonify({"error": "request_id required"}), 400 + if not session_id: + return jsonify({"error": "session_id required"}), 400 + if history_index is None or history_index < 0: + return jsonify({"error": "history_index required"}), 400 + + window_session_id = request.headers.get("X-Von-Window-Session") + effective = get_effective_context( + window_session_id, dict(session), user_concept_id + ) + actor_namespace, organisation_concept_id = _resolve_history_request_scope_hints( + user_concept_id=user_concept_id, + effective_context=effective, + ) + + owner_user_id, shared_invite = _resolve_shared_conversation_owner( + user_concept_id=user_concept_id, + session_id=session_id, + ) + if not owner_user_id: + if not chat_history_service.has_chat_history_session( + user_concept_id, + session_id, + namespace=actor_namespace, + ) and not chat_history_service.has_chat_history_session( + user_concept_id, + session_id, + namespace=None, + ): + return jsonify({"error": "Not authorised for conversation"}), 403 + owner_user_id = user_concept_id + + shared_org_id = ( + _normalise_concept_id(shared_invite.get("organisation_concept_id")) + if isinstance(shared_invite, Mapping) + else None + ) + organisation_concept_id = shared_org_id or organisation_concept_id + owner_namespace = ( + _derive_namespace_for_user_org(owner_user_id, organisation_concept_id) + or actor_namespace + or chat_history_service.resolve_chat_history_namespace(owner_user_id) + ) + + try: + compact_debug = chat_history_service.get_chat_history_debug_entry( + user_id=owner_user_id, + session_id=session_id, + history_index=history_index, + namespace=owner_namespace, + include_legacy=True, + hydrate_blob_refs=False, + ) + if not compact_debug and owner_namespace is not None: + compact_debug = chat_history_service.get_chat_history_debug_entry( + user_id=owner_user_id, + session_id=session_id, + history_index=history_index, + namespace=None, + include_legacy=True, + hydrate_blob_refs=False, + ) + except Exception as exc: + current_app.logger.warning( + "Stored turn failure capsule lookup failed: %s", + type(exc).__name__, + ) + if chat_history_service.is_transient_chat_history_error(exc): + return ( + jsonify({"error": "failure_capsule_temporarily_unavailable"}), + 503, + ) + return jsonify({"error": "failure_capsule_lookup_failed"}), 500 + + if not isinstance(compact_debug, Mapping): + return jsonify({"error": "failure_capsule_not_available"}), 404 + stored_request_id = _progress_str(compact_debug.get("request_id")) + if stored_request_id and stored_request_id != request_id: + return jsonify({"error": "request_id does not belong to history_index"}), 403 + if stored_request_id != request_id: + return jsonify({"error": "failure_capsule_not_available"}), 404 + + capsule = compact_debug.get("turn_failure_capsule") + projected_capsule = project_stored_turn_failure_capsule(capsule) + if ( + not isinstance(projected_capsule, Mapping) + or projected_capsule.get("schema_version") + != TURN_FAILURE_CAPSULE_SCHEMA_VERSION + or _progress_str(projected_capsule.get("request_id")) != request_id + or turn_failure_capsule_size_bytes(projected_capsule) + > TURN_FAILURE_CAPSULE_MAX_BYTES + ): + return jsonify({"error": "failure_capsule_not_available"}), 404 + return jsonify(dict(projected_capsule)), 200 + + @von_bp.route("/history/turn_telemetry_access", methods=["GET"]) def history_turn_telemetry_access(): """Issue fresh actor-bound MCP read delegations for one exact turn.""" diff --git a/src/backend/services/adaptive_turn_service.py b/src/backend/services/adaptive_turn_service.py index c0e332aa..ed4a8ac9 100644 --- a/src/backend/services/adaptive_turn_service.py +++ b/src/backend/services/adaptive_turn_service.py @@ -6549,10 +6549,27 @@ def finish(text: str, *, status: str = "completed") -> AdaptiveTurnResult: f"\n\n{quoted_draft}" ) response_authority = "canonical_outcome" + bounded_model_draft = _bounded_outcome_text(model_draft, limit=2_000) aux_calls.append( { "type": "adaptive_turn_effect_outcome_report", **outcome_report, + "response_authority": response_authority, + **( + { + "model_draft": { + "authority": "non_authoritative", + "preview": bounded_model_draft, + "char_count": len(model_draft), + "preview_truncated": ( + bounded_model_draft is not None + and len(bounded_model_draft) < len(model_draft) + ), + } + } + if bounded_model_draft is not None + else {} + ), } ) evidence_index = _compact_evidence_index(evidence_store.index()) diff --git a/src/backend/services/turn_failure_capsule_service.py b/src/backend/services/turn_failure_capsule_service.py new file mode 100644 index 00000000..fa73516a --- /dev/null +++ b/src/backend/services/turn_failure_capsule_service.py @@ -0,0 +1,797 @@ +from __future__ import annotations + +import hashlib +import json +import re +from datetime import datetime, timezone +from typing import Any, Mapping, Sequence + + +TURN_FAILURE_CAPSULE_SCHEMA_VERSION = "turn_failure_capsule.v1" +TURN_FAILURE_CAPSULE_MAX_BYTES = 8 * 1024 + +_MAX_EFFECTS = 8 +_VISIBLE_RESPONSE_LIMIT = 1400 +_DRAFT_LIMIT = 800 +_ERROR_PREVIEW_LIMIT = 360 +_HANDLE_LIMIT = 180 +_NAME_LIMIT = 140 +_STATUS_LIMIT = 96 +_ERROR_CODE_LIMIT = 140 + +_FAILURE_STATUSES = frozenset( + {"failed", "partial", "indeterminate", "not_started", "blocked", "error"} +) +_SECRET_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = ( + ( + re.compile( + r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY-----.*?" + r"-----END (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY-----", + flags=re.IGNORECASE | re.DOTALL, + ), + "[redacted-private-key]", + ), + ( + re.compile( + r"(?i)\b(authorization\s*[:=]\s*(?:bearer|basic)\s+)([^\s,;]+)" + ), + r"\1[redacted]", + ), + ( + re.compile( + r"(?i)\b(api[_-]?key|access[_-]?token|refresh[_-]?token|" + r"id[_-]?token|token|password|passwd|secret|client[_-]?secret|cookie|" + r"session[_-]?token|signature|nonce)(\s*[:=]\s*)([^\s,;]+)" + ), + r"\1\2[redacted]", + ), + (re.compile(r"\bsk-[A-Za-z0-9_-]{8,}\b"), "[redacted]"), + ( + re.compile( + r"\b(?:gh[opurs]_[A-Za-z0-9_]{12,}|github_pat_[A-Za-z0-9_]{12,}|" + r"AKIA[0-9A-Z]{12,}|AIza[0-9A-Za-z_-]{20,}|" + r"xox[baprs]-[0-9A-Za-z-]{10,})\b" + ), + "[redacted]", + ), + ( + re.compile( + r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\." + r"[A-Za-z0-9_-]{8,}\b" + ), + "[redacted]", + ), + ( + re.compile( + r"(?i)\b((?:mongodb(?:\+srv)?|postgres(?:ql)?|mysql|redis)://)" + r"[^@\s/]+@" + ), + r"\1[redacted]@", + ), + ( + re.compile( + r"(?im)^[ \t]*File\s+[\"'][^\"']+[\"'],\s+line\s+\d+" + r"(?:,\s+in\s+[^\n]+)?$(?:\n[ \t]+[^\n]+)?" + ), + "[redacted-trace-frame]", + ), + ( + re.compile( + r"(?i)(?:[A-Z]:\\Users\\[^\s\"'<>]+|" + r"/(?:Users|home)/[^\s\"'<>]+)" + ), + "[redacted-local-path]", + ), + ( + re.compile(r"(?i)"), + "asyncio lock", + ), + (re.compile(r"(?i)\b0x[0-9a-f]{6,}\b"), "[memory-address]"), +) + + +def _utc_now_iso() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _compact_json_bytes(value: Mapping[str, Any]) -> bytes: + return json.dumps( + value, + ensure_ascii=True, + sort_keys=True, + separators=(",", ":"), + default=str, + ).encode("utf-8") + + +def _clean_string(value: Any) -> str | None: + if not isinstance(value, str): + return None + cleaned = value.replace("\r\n", "\n").replace("\r", "\n").strip() + return cleaned or None + + +def _redact_text( + value: Any, + *, + sensitive_identity_values: Sequence[str], + redaction_counter: list[int], +) -> str | None: + text = _clean_string(value) + if text is None: + return None + + for identity in sorted( + { + item.strip() + for item in sensitive_identity_values + if isinstance(item, str) and item.strip() + }, + key=lambda item: (-len(item), item), + ): + occurrences = text.count(identity) + if occurrences: + text = text.replace(identity, "[redacted-identity]") + redaction_counter[0] += occurrences + + for pattern, replacement in _SECRET_PATTERNS: + text, count = pattern.subn(replacement, text) + redaction_counter[0] += count + return text + + +def _bounded_scalar( + value: Any, + *, + limit: int, + sensitive_identity_values: Sequence[str], + redaction_counter: list[int], + truncation_counter: list[int] | None = None, +) -> str | None: + text = _redact_text( + value, + sensitive_identity_values=sensitive_identity_values, + redaction_counter=redaction_counter, + ) + if text is None: + return None + if "[redacted-identity]" in text: + return None + if len(text) <= limit: + return text + if truncation_counter is not None: + truncation_counter[0] += 1 + return f"{text[: max(0, limit - 3)].rstrip()}..." + + +def _text_surface( + value: Any, + *, + limit: int, + sensitive_identity_values: Sequence[str], + redaction_counter: list[int], +) -> dict[str, Any] | None: + safe_text = _redact_text( + value, + sensitive_identity_values=sensitive_identity_values, + redaction_counter=redaction_counter, + ) + if safe_text is None: + return None + truncated = len(safe_text) > limit + preview = safe_text if not truncated else safe_text[:limit].rstrip() + return { + "text": preview, + "char_count": len(safe_text), + "sha256": hashlib.sha256(safe_text.encode("utf-8")).hexdigest(), + "truncated": truncated, + } + + +def _shrink_text_surface(surface: Any, limit: int) -> bool: + if not isinstance(surface, dict): + return False + text = surface.get("text") + if not isinstance(text, str) or len(text) <= limit: + return False + surface["text"] = text[:limit].rstrip() + surface["truncated"] = True + return True + + +def _canonical_readback_verdict(fact: Mapping[str, Any]) -> str | None: + if fact.get("canonical_readback_verified") is True: + return "verified" + if fact.get("canonical_readback_present") is True: + return "present_unverified" + if fact.get("canonical_readback_present") is False: + return "not_present" + return None + + +def _project_effect( + fact: Mapping[str, Any], + *, + sensitive_identity_values: Sequence[str], + redaction_counter: list[int], + truncation_counter: list[int], +) -> dict[str, Any] | None: + def scalar(value: Any, limit: int = _HANDLE_LIMIT) -> str | None: + return _bounded_scalar( + value, + limit=limit, + sensitive_identity_values=sensitive_identity_values, + redaction_counter=redaction_counter, + truncation_counter=truncation_counter, + ) + + effect_id = scalar(fact.get("effect_id")) + name = scalar(fact.get("tool"), _NAME_LIMIT) + status = scalar(fact.get("effect_status"), _STATUS_LIMIT) + if not any((effect_id, name, status)): + return None + + projected: dict[str, Any] = {} + for key, value in ( + ("effect_id", effect_id), + ("name", name), + ( + "initial_status", + scalar(fact.get("initial_effect_status"), _STATUS_LIMIT), + ), + ("status", status), + ( + "current_outcome_status", + scalar(fact.get("current_outcome_status"), _STATUS_LIMIT), + ), + ( + "reconciliation_status", + scalar(fact.get("reconciliation_status"), _STATUS_LIMIT), + ), + ("canonical_readback_verdict", _canonical_readback_verdict(fact)), + ( + "recovered_by_effect_id", + scalar(fact.get("recovered_by_effect_id")), + ), + ("workflow_id", scalar(fact.get("workflow_id"))), + ("instance_id", scalar(fact.get("instance_id"))), + ("evidence_id", scalar(fact.get("evidence_id"))), + ): + if value is not None: + projected[key] = value + if isinstance(fact.get("changed"), bool): + projected["changed"] = fact.get("changed") + if isinstance(fact.get("outcome_resolved"), bool): + projected["outcome_resolved"] = fact.get("outcome_resolved") + + error_code = scalar(fact.get("error_code"), _ERROR_CODE_LIMIT) + error_preview = _text_surface( + fact.get("error"), + limit=_ERROR_PREVIEW_LIMIT, + sensitive_identity_values=sensitive_identity_values, + redaction_counter=redaction_counter, + ) + if error_code is not None or error_preview is not None: + projected["error"] = { + **({"code": error_code} if error_code is not None else {}), + **({"preview": error_preview} if error_preview is not None else {}), + } + return projected + + +def _effect_priority( + indexed_fact: tuple[int, Mapping[str, Any]], +) -> tuple[int, int]: + index, fact = indexed_fact + has_direct_error = bool( + _clean_string(fact.get("error_code")) or _clean_string(fact.get("error")) + ) + status = (_clean_string(fact.get("effect_status")) or "").lower() + if has_direct_error: + return (0, index) + if status in _FAILURE_STATUSES: + return (1, index) + return (2, index) + + +def _build_turn_error( + *, + error_code: Any, + error_text: Any, + sensitive_identity_values: Sequence[str], + redaction_counter: list[int], + truncation_counter: list[int], +) -> dict[str, Any] | None: + code = _bounded_scalar( + error_code, + limit=_ERROR_CODE_LIMIT, + sensitive_identity_values=sensitive_identity_values, + redaction_counter=redaction_counter, + truncation_counter=truncation_counter, + ) + preview = _text_surface( + error_text, + limit=_ERROR_PREVIEW_LIMIT, + sensitive_identity_values=sensitive_identity_values, + redaction_counter=redaction_counter, + ) + if code is None and preview is None: + return None + return { + **({"code": code} if code is not None else {}), + **({"preview": preview} if preview is not None else {}), + } + + +def _enforce_hard_bound(capsule: dict[str, Any]) -> None: + """Deterministically stay within the portable 8 KiB contract. + + The first effect is the highest-priority direct failure. It is never removed. + Terminal fields and any direct turn error likewise survive every reduction. + """ + + summary = capsule["effect_summary"] + redaction = capsule["redaction"] + + def oversized() -> bool: + return len(_compact_json_bytes(capsule)) > TURN_FAILURE_CAPSULE_MAX_BYTES + + while oversized() and len(capsule["effects"]) > 1: + capsule["effects"].pop() + summary["included_count"] -= 1 + summary["omitted_count"] += 1 + redaction["truncated"] = True + + for surface, limit in ( + (capsule.get("visible_response"), 700), + (capsule.get("pre_presentation_draft"), 400), + ): + if oversized() and _shrink_text_surface(surface, limit): + redaction["truncated"] = True + for effect in capsule["effects"]: + if not oversized(): + break + error = effect.get("error") + if isinstance(error, Mapping) and _shrink_text_surface( + error.get("preview"), 180 + ): + redaction["truncated"] = True + + if oversized() and capsule["effects"]: + essential_keys = { + "effect_id", + "name", + "initial_status", + "status", + "current_outcome_status", + "outcome_resolved", + "reconciliation_status", + "canonical_readback_verdict", + "error", + "recovered_by_effect_id", + } + first_effect = capsule["effects"][0] + capsule["effects"] = [ + {key: value for key, value in first_effect.items() if key in essential_keys} + ] + removed = summary["included_count"] - 1 + summary["included_count"] = 1 + summary["omitted_count"] += max(0, removed) + redaction["truncated"] = True + + if oversized(): + _shrink_text_surface(capsule.get("visible_response"), 240) + _shrink_text_surface(capsule.get("pre_presentation_draft"), 160) + for effect in capsule["effects"]: + error = effect.get("error") + if isinstance(error, Mapping): + _shrink_text_surface(error.get("preview"), 120) + redaction["truncated"] = True + + if oversized(): + # All remaining strings are already bounded; this defensive final shape + # retains the terminal and direct failure while dropping optional views. + capsule.pop("visible_response", None) + capsule.pop("pre_presentation_draft", None) + capsule.pop("canonical_scope_modes", None) + redaction["truncated"] = True + + if oversized(): # pragma: no cover - guard against future schema expansion + raise ValueError("turn_failure_capsule_exceeds_hard_bound") + + +def build_turn_failure_capsule( + *, + request_id: Any, + terminal_status: Any, + response_authority: Any, + visible_response: Any, + outcome_report: Mapping[str, Any] | None, + turn_error_code: Any = None, + turn_error_text: Any = None, + code_version: Any = None, + git_commit: Any = None, + sensitive_identity_values: Sequence[str] = (), + generated_at_utc: str | None = None, +) -> dict[str, Any]: + """Build a portable projection without re-deciding any effect outcome.""" + + redaction_counter = [0] + truncation_counter = [0] + + def scalar(value: Any, limit: int = _HANDLE_LIMIT) -> str | None: + return _bounded_scalar( + value, + limit=limit, + sensitive_identity_values=sensitive_identity_values, + redaction_counter=redaction_counter, + truncation_counter=truncation_counter, + ) + + report = ( + outcome_report + if isinstance(outcome_report, Mapping) + and outcome_report.get("schema_version") + == "adaptive_turn_effect_outcome_report.v1" + else None + ) + raw_facts = report.get("facts") if isinstance(report, Mapping) else [] + indexed_facts = [ + (index, fact) + for index, fact in enumerate(raw_facts if isinstance(raw_facts, list) else []) + if isinstance(fact, Mapping) + ] + ordered_facts = [fact for _index, fact in sorted(indexed_facts, key=_effect_priority)] + effects = [ + effect + for fact in ordered_facts[:_MAX_EFFECTS] + if ( + effect := _project_effect( + fact, + sensitive_identity_values=sensitive_identity_values, + redaction_counter=redaction_counter, + truncation_counter=truncation_counter, + ) + ) + is not None + ] + + scope_modes: list[str] = [] + raw_scopes = report.get("canonical_scopes") if isinstance(report, Mapping) else [] + if isinstance(raw_scopes, list): + for raw_scope in raw_scopes: + if not isinstance(raw_scope, Mapping): + continue + mode = scalar(raw_scope.get("mode"), _STATUS_LIMIT) + if mode in {"user", "organisation", "global"} and mode not in scope_modes: + scope_modes.append(mode) + + visible_surface = _text_surface( + visible_response, + limit=_VISIBLE_RESPONSE_LIMIT, + sensitive_identity_values=sensitive_identity_values, + redaction_counter=redaction_counter, + ) + draft_value: Any = None + if isinstance(report, Mapping): + raw_draft = report.get("model_draft") + if isinstance(raw_draft, Mapping): + draft_value = raw_draft.get("preview") + else: + draft_value = raw_draft + draft_surface = _text_surface( + draft_value, + limit=_DRAFT_LIMIT, + sensitive_identity_values=sensitive_identity_values, + redaction_counter=redaction_counter, + ) + if draft_surface is not None: + draft_surface["authority"] = "non_authoritative" + + turn_error = _build_turn_error( + error_code=turn_error_code, + error_text=turn_error_text, + sensitive_identity_values=sensitive_identity_values, + redaction_counter=redaction_counter, + truncation_counter=truncation_counter, + ) + total_count = len(indexed_facts) + initial_omitted_count = max(0, total_count - len(effects)) + producer = { + key: value + for key, value in ( + ("code_version", scalar(code_version, _HANDLE_LIMIT)), + ("git_commit", scalar(git_commit, _HANDLE_LIMIT)), + ) + if value is not None + } + capsule: dict[str, Any] = { + "schema_version": TURN_FAILURE_CAPSULE_SCHEMA_VERSION, + "generated_at_utc": scalar(generated_at_utc or _utc_now_iso(), _HANDLE_LIMIT), + "request_id": scalar(request_id, _HANDLE_LIMIT), + "terminal_status": scalar(terminal_status, _STATUS_LIMIT), + "response_authority": ( + scalar(response_authority, _STATUS_LIMIT) or "not_recorded" + ), + "producer": producer, + "effects": effects, + "effect_summary": { + "total_count": total_count, + "included_count": len(effects), + "omitted_count": initial_omitted_count, + }, + "canonical_scope_modes": scope_modes, + "redaction": { + "applied": redaction_counter[0] > 0, + "redacted_count": redaction_counter[0], + "truncated": bool( + truncation_counter[0] + or initial_omitted_count + or (visible_surface and visible_surface.get("truncated")) + or (draft_surface and draft_surface.get("truncated")) + or any( + isinstance(effect.get("error"), Mapping) + and isinstance(effect["error"].get("preview"), Mapping) + and effect["error"]["preview"].get("truncated") is True + for effect in effects + ) + ), + }, + } + for key, value in ( + ("visible_response", visible_surface), + ("pre_presentation_draft", draft_surface), + ("turn_error", turn_error), + ): + if value is not None: + capsule[key] = value + capsule = {key: value for key, value in capsule.items() if value is not None} + _enforce_hard_bound(capsule) + return capsule + + +def turn_failure_capsule_size_bytes(capsule: Mapping[str, Any]) -> int: + return len(_compact_json_bytes(capsule)) + + +def project_stored_turn_failure_capsule( + value: Any, +) -> dict[str, Any] | None: + """Fail-closed allowlist for serving an already-produced capsule. + + This is deliberately not a repair or recomputation path. It preserves the + stored producer, hashes, text projections, and outcome fields verbatim while + dropping fields outside the portable schema. Invalid required structure is + unavailable rather than partially reinterpreted. + """ + + if not isinstance(value, Mapping): + return None + if value.get("schema_version") != TURN_FAILURE_CAPSULE_SCHEMA_VERSION: + return None + + def required_string(raw: Any, limit: int) -> str | None: + return raw if isinstance(raw, str) and 0 < len(raw) <= limit else None + + def optional_string(raw: Any, limit: int) -> tuple[bool, str | None]: + if raw is None: + return True, None + if not isinstance(raw, str) or not raw or len(raw) > limit: + return False, None + return True, raw + + def text_surface( + raw: Any, *, allow_authority: bool = False + ) -> dict[str, Any] | None: + if not isinstance(raw, Mapping): + return None + text = raw.get("text") + char_count = raw.get("char_count") + sha256 = raw.get("sha256") + truncated = raw.get("truncated") + if ( + not isinstance(text, str) + or len(text) > _VISIBLE_RESPONSE_LIMIT + or not isinstance(char_count, int) + or isinstance(char_count, bool) + or char_count < len(text) + or not isinstance(sha256, str) + or re.fullmatch(r"[0-9a-f]{64}", sha256) is None + or not isinstance(truncated, bool) + ): + return None + projected = { + "text": text, + "char_count": char_count, + "sha256": sha256, + "truncated": truncated, + } + if allow_authority: + if raw.get("authority") != "non_authoritative": + return None + projected["authority"] = "non_authoritative" + return projected + + request_id = required_string(value.get("request_id"), _HANDLE_LIMIT) + generated_at_utc = required_string( + value.get("generated_at_utc"), _HANDLE_LIMIT + ) + terminal_status = required_string(value.get("terminal_status"), _STATUS_LIMIT) + response_authority = required_string( + value.get("response_authority"), _STATUS_LIMIT + ) + if not all((request_id, generated_at_utc, terminal_status, response_authority)): + return None + + producer_raw = value.get("producer") + if not isinstance(producer_raw, Mapping): + return None + producer: dict[str, str] = {} + for key in ("code_version", "git_commit"): + valid, projected = optional_string(producer_raw.get(key), _HANDLE_LIMIT) + if not valid: + return None + if projected is not None: + producer[key] = projected + + effects_raw = value.get("effects") + if not isinstance(effects_raw, list) or len(effects_raw) > _MAX_EFFECTS: + return None + effects: list[dict[str, Any]] = [] + string_fields = { + "effect_id": _HANDLE_LIMIT, + "name": _NAME_LIMIT, + "initial_status": _STATUS_LIMIT, + "status": _STATUS_LIMIT, + "current_outcome_status": _STATUS_LIMIT, + "reconciliation_status": _STATUS_LIMIT, + "canonical_readback_verdict": _STATUS_LIMIT, + "recovered_by_effect_id": _HANDLE_LIMIT, + "workflow_id": _HANDLE_LIMIT, + "instance_id": _HANDLE_LIMIT, + "evidence_id": _HANDLE_LIMIT, + } + for raw_effect in effects_raw: + if not isinstance(raw_effect, Mapping): + return None + effect: dict[str, Any] = {} + for key, limit in string_fields.items(): + valid, projected = optional_string(raw_effect.get(key), limit) + if not valid: + return None + if projected is not None: + effect[key] = projected + if not any(key in effect for key in ("effect_id", "name", "status")): + return None + for key in ("changed", "outcome_resolved"): + raw_boolean = raw_effect.get(key) + if raw_boolean is not None and not isinstance(raw_boolean, bool): + return None + if isinstance(raw_boolean, bool): + effect[key] = raw_boolean + raw_error = raw_effect.get("error") + if raw_error is not None: + if not isinstance(raw_error, Mapping): + return None + error: dict[str, Any] = {} + valid, error_code = optional_string( + raw_error.get("code"), _ERROR_CODE_LIMIT + ) + if not valid: + return None + if error_code is not None: + error["code"] = error_code + if raw_error.get("preview") is not None: + preview = text_surface(raw_error.get("preview")) + if preview is None: + return None + error["preview"] = preview + if not error: + return None + effect["error"] = error + effects.append(effect) + + summary_raw = value.get("effect_summary") + if not isinstance(summary_raw, Mapping): + return None + summary_values: dict[str, int] = {} + for key in ("total_count", "included_count", "omitted_count"): + raw_count = summary_raw.get(key) + if ( + not isinstance(raw_count, int) + or isinstance(raw_count, bool) + or raw_count < 0 + ): + return None + summary_values[key] = raw_count + if ( + summary_values["included_count"] != len(effects) + or summary_values["total_count"] + != summary_values["included_count"] + summary_values["omitted_count"] + ): + return None + + raw_scope_modes = value.get("canonical_scope_modes") + if not isinstance(raw_scope_modes, list) or len(raw_scope_modes) > 3: + return None + scope_modes: list[str] = [] + for raw_mode in raw_scope_modes: + if ( + raw_mode not in {"user", "organisation", "global"} + or raw_mode in scope_modes + ): + return None + scope_modes.append(raw_mode) + + redaction_raw = value.get("redaction") + if not isinstance(redaction_raw, Mapping): + return None + applied = redaction_raw.get("applied") + redacted_count = redaction_raw.get("redacted_count") + truncated = redaction_raw.get("truncated") + if ( + not isinstance(applied, bool) + or not isinstance(redacted_count, int) + or isinstance(redacted_count, bool) + or redacted_count < 0 + or not isinstance(truncated, bool) + ): + return None + + projected_capsule: dict[str, Any] = { + "schema_version": TURN_FAILURE_CAPSULE_SCHEMA_VERSION, + "generated_at_utc": generated_at_utc, + "request_id": request_id, + "terminal_status": terminal_status, + "response_authority": response_authority, + "producer": producer, + "effects": effects, + "effect_summary": summary_values, + "canonical_scope_modes": scope_modes, + "redaction": { + "applied": applied, + "redacted_count": redacted_count, + "truncated": truncated, + }, + } + for key, allow_authority in ( + ("visible_response", False), + ("pre_presentation_draft", True), + ): + if value.get(key) is None: + continue + projected_surface = text_surface( + value.get(key), allow_authority=allow_authority + ) + if projected_surface is None: + return None + projected_capsule[key] = projected_surface + + raw_turn_error = value.get("turn_error") + if raw_turn_error is not None: + if not isinstance(raw_turn_error, Mapping): + return None + turn_error: dict[str, Any] = {} + valid, code = optional_string(raw_turn_error.get("code"), _ERROR_CODE_LIMIT) + if not valid: + return None + if code is not None: + turn_error["code"] = code + if raw_turn_error.get("preview") is not None: + preview = text_surface(raw_turn_error.get("preview")) + if preview is None: + return None + turn_error["preview"] = preview + if not turn_error: + return None + projected_capsule["turn_error"] = turn_error + return projected_capsule + + +__all__ = [ + "TURN_FAILURE_CAPSULE_MAX_BYTES", + "TURN_FAILURE_CAPSULE_SCHEMA_VERSION", + "build_turn_failure_capsule", + "project_stored_turn_failure_capsule", + "turn_failure_capsule_size_bytes", +] diff --git a/src/frontend/web/von_interface/static/js/chatTab.js b/src/frontend/web/von_interface/static/js/chatTab.js index afc0b0a1..071849c3 100644 --- a/src/frontend/web/von_interface/static/js/chatTab.js +++ b/src/frontend/web/von_interface/static/js/chatTab.js @@ -117,6 +117,9 @@ const CONVERSATION_SITUATION_EXPORT_SCHEMA_VERSION = 'conversation_situation_exp const CONVERSATION_TELEMETRY_LOCATOR_FETCH_TIMEOUT_MS = 4000; const TURN_TELEMETRY_MCP_ACCESS_SCHEMA_VERSION = 'turn_telemetry_mcp_access.v1'; const TURN_TELEMETRY_LOCATOR_SCHEMA_VERSION = 'turn_telemetry_locator.v1'; +const TURN_FAILURE_CAPSULE_SCHEMA_VERSION = 'turn_failure_capsule.v1'; +const TURN_FAILURE_CAPSULE_CLIPBOARD_MAX_UTF8_BYTES = 16 * 1024; +const TURN_FAILURE_CAPSULE_MAX_EFFECTS = 16; const TURN_LIVE_PROGRESS_LOCATOR_SCHEMA_VERSION = 'turn_live_progress_locator.v1'; const WORKFLOW_USE_EPISODES_LOCATOR_SCHEMA_VERSION = 'workflow_use_episodes_locator.v1'; const WORKFLOW_DEFINITION_LOCATOR_SCHEMA_VERSION = 'workflow_definition_locator.v1'; @@ -34521,8 +34524,8 @@ function setLlmDebugButtonCopyState(button, state) { button.textContent = 'Copied'; button.dataset.copyFeedback = 'Copied'; button.classList.add(LLM_DEBUG_BUTTON_COPIED_CLASS); - button.setAttribute('title', 'Copied LLM reference JSON to clipboard. Shift-click to open details.'); - button.setAttribute('aria-label', 'LLM reference JSON copied. Shift-click to open details.'); + button.setAttribute('title', 'Copied bounded turn capsule JSON. Shift-click to open details.'); + button.setAttribute('aria-label', 'Bounded turn capsule JSON copied. Shift-click to open details.'); return; } if (state === 'failed') { @@ -34530,14 +34533,14 @@ function setLlmDebugButtonCopyState(button, state) { button.dataset.copyFeedback = 'Copy failed'; button.classList.add(LLM_DEBUG_BUTTON_COPY_FAILED_CLASS); button.setAttribute('title', 'Copy failed. Shift-click to open LLM details.'); - button.setAttribute('aria-label', 'Copy LLM reference JSON failed. Shift-click to open details.'); + button.setAttribute('aria-label', 'Copy bounded turn capsule JSON failed. Shift-click to open details.'); return; } button.textContent = defaultLabel; button.classList.add(LLM_DEBUG_BUTTON_COPY_AVAILABLE_CLASS); - button.setAttribute('title', 'Copy LLM reference JSON. Shift-click to open details.'); - button.setAttribute('aria-label', 'Copy LLM reference JSON. Shift-click to open details.'); + button.setAttribute('title', 'Copy bounded turn capsule JSON. Shift-click to open details.'); + button.setAttribute('aria-label', 'Copy bounded turn capsule JSON. Shift-click to open details.'); } // Initialize LLM debug popup handlers @@ -34837,6 +34840,7 @@ function hasLlmDebugPayload(debugData) { || (Array.isArray(debugData.aux_llm_calls) && debugData.aux_llm_calls.length > 0) || debugData.llm_interaction || debugData.context_stats + || resolveStoredTurnFailureCapsule(debugData) || extractThinkingCriticOutput(debugData) ); } @@ -34958,7 +34962,525 @@ async function loadLlmDebugDataForTurn(turnId, options = {}) { return fetchPromise; } +function utf8ByteLength(value) { + let byteLength = 0; + for (const character of String(value ?? '')) { + const codePoint = character.codePointAt(0); + if (codePoint <= 0x7f) { + byteLength += 1; + } else if (codePoint <= 0x7ff) { + byteLength += 2; + } else if (codePoint <= 0xffff) { + byteLength += 3; + } else { + byteLength += 4; + } + } + return byteLength; +} + +function truncateUtf8Text(value, maxBytes) { + const text = String(value ?? ''); + if (utf8ByteLength(text) <= maxBytes) { + return { text, truncated: false }; + } + + let result = ''; + let byteLength = 0; + for (const character of text) { + const characterBytes = utf8ByteLength(character); + if (byteLength + characterBytes > maxBytes) { + break; + } + result += character; + byteLength += characterBytes; + } + return { text: result, truncated: true }; +} + +function unicodeCharacterCount(value) { + let count = 0; + for (const _character of String(value ?? '')) { + count += 1; + } + return count; +} + +function redactCredentialLikeText(value) { + let text = String(value ?? ''); + let redactedCount = 0; + const replace = (pattern, replacement) => { + text = text.replace(pattern, (...args) => { + redactedCount += 1; + return typeof replacement === 'function' + ? replacement(...args) + : replacement; + }); + }; + + replace(/\b(?:Authorization\s*[:=]\s*)?(?:Bearer|Basic)\s+[A-Za-z0-9._~+/-]+=*/gi, '[REDACTED-AUTHORIZATION]'); + replace(/\bsk-[A-Za-z0-9_-]{8,}\b/g, '[REDACTED]'); + replace(/\beyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, '[REDACTED]'); + replace( + /\b(api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|password|passwd|secret|client[_-]?secret|cookie|session[_-]?token|signature|nonce)(\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s,;&]+)/gi, + (_match, label, separator) => `${label}${separator}[REDACTED]` + ); + replace( + /\b((?:mongodb(?:\+srv)?|postgres(?:ql)?|mysql|redis):\/\/)[^@\s/]+@/gi, + (_match, scheme) => `${scheme}[REDACTED]@` + ); + replace( + /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/gi, + '[REDACTED-PRIVATE-KEY]' + ); + return { text, redactedCount }; +} + +function projectCapsuleToken(value, { maxBytes = 256, pattern = null } = {}) { + if (typeof value !== 'string') { + return null; + } + const redacted = redactCredentialLikeText(value); + if (redacted.redactedCount > 0) { + return null; + } + const token = redacted.text.trim(); + if (!token || utf8ByteLength(token) > maxBytes) { + return null; + } + if (pattern && !pattern.test(token)) { + return null; + } + return token; +} + +function projectCapsuleTextSurface(value, { maxBytes, requireNonAuthoritative = false } = {}) { + if (!value || typeof value !== 'object' || Array.isArray(value) || typeof value.text !== 'string') { + return null; + } + if (requireNonAuthoritative && value.authority !== 'non_authoritative') { + return null; + } + + const redacted = redactCredentialLikeText(value.text); + const bounded = truncateUtf8Text(redacted.text, maxBytes); + const locallyChanged = redacted.redactedCount > 0 || bounded.truncated; + const projected = { + text: bounded.text + }; + if (locallyChanged) { + projected.char_count = unicodeCharacterCount(bounded.text); + } else if (Number.isSafeInteger(value.char_count) && value.char_count >= 0) { + projected.char_count = value.char_count; + } + if ( + !locallyChanged + && typeof value.sha256 === 'string' + && /^[a-f0-9]{64}$/i.test(value.sha256.trim()) + ) { + projected.sha256 = value.sha256.trim().toLowerCase(); + } + if (typeof value.truncated === 'boolean' || bounded.truncated) { + projected.truncated = Boolean(value.truncated || bounded.truncated); + } + if (requireNonAuthoritative) { + projected.authority = 'non_authoritative'; + } + return { + projected, + redactedCount: redacted.redactedCount, + truncated: bounded.truncated + }; +} + +function projectCapsuleError(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + const code = projectCapsuleToken(value.code, { + maxBytes: 160, + pattern: /^[A-Za-z0-9][A-Za-z0-9_.:-]*$/ + }); + const projected = {}; + if (code) { + projected.code = code; + } + let redactedCount = 0; + let truncated = false; + const preview = projectCapsuleTextSurface(value.preview, { maxBytes: 600 }); + if (preview) { + projected.preview = preview.projected; + redactedCount += preview.redactedCount; + truncated = preview.truncated; + } + if (Object.keys(projected).length === 0) { + return null; + } + return { projected, redactedCount, truncated }; +} + +function projectCapsuleEffect(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + const projected = {}; + const identifierFields = [ + 'effect_id', + 'recovered_by_effect_id', + 'workflow_id', + 'instance_id', + 'evidence_id' + ]; + identifierFields.forEach((fieldName) => { + const projectedValue = projectCapsuleToken(value[fieldName], { maxBytes: 256 }); + if (projectedValue) { + projected[fieldName] = projectedValue; + } + }); + const name = projectCapsuleToken(value.name, { maxBytes: 320 }); + if (name) { + projected.name = name; + } + [ + 'initial_status', + 'status', + 'current_outcome_status', + 'reconciliation_status', + 'canonical_readback_verdict' + ].forEach((fieldName) => { + const projectedValue = projectCapsuleToken(value[fieldName], { + maxBytes: 128, + pattern: /^[A-Za-z0-9][A-Za-z0-9_.:-]*$/ + }); + if (projectedValue) { + projected[fieldName] = projectedValue; + } + }); + ['changed', 'outcome_resolved'].forEach((fieldName) => { + if (typeof value[fieldName] === 'boolean') { + projected[fieldName] = value[fieldName]; + } + }); + + let redactedCount = 0; + let truncated = false; + const error = projectCapsuleError(value.error); + if (error) { + projected.error = error.projected; + redactedCount += error.redactedCount; + truncated = error.truncated; + } + if (Object.keys(projected).length === 0) { + return null; + } + return { projected, redactedCount, truncated }; +} + +function projectTurnFailureCapsule(value, { expectedRequestId = null } = {}) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + if (value.schema_version !== TURN_FAILURE_CAPSULE_SCHEMA_VERSION) { + return null; + } + + const requestId = projectCapsuleToken(value.request_id, { maxBytes: 256 }); + const terminalStatus = projectCapsuleToken(value.terminal_status, { + maxBytes: 128, + pattern: /^[A-Za-z0-9][A-Za-z0-9_.:-]*$/ + }); + const responseAuthority = projectCapsuleToken(value.response_authority, { + maxBytes: 128, + pattern: /^[A-Za-z0-9][A-Za-z0-9_.:-]*$/ + }); + const cleanExpectedRequestId = typeof expectedRequestId === 'string' + ? expectedRequestId.trim() + : ''; + if ( + !requestId + || !terminalStatus + || !responseAuthority + || (cleanExpectedRequestId && requestId !== cleanExpectedRequestId) + ) { + return null; + } + + const projected = { + schema_version: TURN_FAILURE_CAPSULE_SCHEMA_VERSION + }; + const generatedAtUtc = projectCapsuleToken(value.generated_at_utc, { maxBytes: 64 }); + if (generatedAtUtc && Number.isFinite(Date.parse(generatedAtUtc))) { + projected.generated_at_utc = generatedAtUtc; + } + projected.request_id = requestId; + projected.terminal_status = terminalStatus; + projected.response_authority = responseAuthority; + + if (value.producer && typeof value.producer === 'object' && !Array.isArray(value.producer)) { + const producer = {}; + ['code_version', 'git_commit'].forEach((fieldName) => { + const projectedValue = projectCapsuleToken(value.producer[fieldName], { maxBytes: 256 }); + if (projectedValue) { + producer[fieldName] = projectedValue; + } + }); + if (Object.keys(producer).length > 0) { + projected.producer = producer; + } + } + + let frontendRedactedCount = 0; + let frontendTruncated = false; + const visibleResponse = projectCapsuleTextSurface(value.visible_response, { maxBytes: 4000 }); + if (visibleResponse) { + projected.visible_response = visibleResponse.projected; + frontendRedactedCount += visibleResponse.redactedCount; + frontendTruncated = frontendTruncated || visibleResponse.truncated; + } + const draft = projectCapsuleTextSurface(value.pre_presentation_draft, { + maxBytes: 4000, + requireNonAuthoritative: true + }); + if (draft) { + projected.pre_presentation_draft = draft.projected; + frontendRedactedCount += draft.redactedCount; + frontendTruncated = frontendTruncated || draft.truncated; + } + + const sourceEffects = Array.isArray(value.effects) ? value.effects : null; + if (sourceEffects) { + const projectedEffects = []; + sourceEffects.slice(0, TURN_FAILURE_CAPSULE_MAX_EFFECTS).forEach((effect) => { + const projectedEffect = projectCapsuleEffect(effect); + if (!projectedEffect) { + return; + } + projectedEffects.push(projectedEffect.projected); + frontendRedactedCount += projectedEffect.redactedCount; + frontendTruncated = frontendTruncated || projectedEffect.truncated; + }); + projected.effects = projectedEffects; + frontendTruncated = frontendTruncated || sourceEffects.length > TURN_FAILURE_CAPSULE_MAX_EFFECTS; + } + + if (value.effect_summary && typeof value.effect_summary === 'object' && !Array.isArray(value.effect_summary)) { + const effectSummary = {}; + ['total_count', 'included_count', 'omitted_count'].forEach((fieldName) => { + if (Number.isSafeInteger(value.effect_summary[fieldName]) && value.effect_summary[fieldName] >= 0) { + effectSummary[fieldName] = value.effect_summary[fieldName]; + } + }); + if (Object.keys(effectSummary).length > 0) { + projected.effect_summary = effectSummary; + } + } + + if (Array.isArray(value.canonical_scope_modes)) { + const allowedModes = new Set(['user', 'organisation', 'global']); + projected.canonical_scope_modes = Array.from(new Set( + value.canonical_scope_modes.filter((mode) => allowedModes.has(mode)) + )).slice(0, 3); + } + + const turnError = projectCapsuleError(value.turn_error); + if (turnError) { + projected.turn_error = turnError.projected; + frontendRedactedCount += turnError.redactedCount; + frontendTruncated = frontendTruncated || turnError.truncated; + } + + if (value.redaction && typeof value.redaction === 'object' && !Array.isArray(value.redaction)) { + const redaction = {}; + ['applied', 'truncated'].forEach((fieldName) => { + if (typeof value.redaction[fieldName] === 'boolean') { + redaction[fieldName] = value.redaction[fieldName]; + } + }); + if (Number.isSafeInteger(value.redaction.redacted_count) && value.redaction.redacted_count >= 0) { + redaction.redacted_count = value.redaction.redacted_count; + } + if (Object.keys(redaction).length > 0) { + projected.redaction = redaction; + } + } + + if (frontendRedactedCount > 0 || frontendTruncated) { + const existingRedaction = projected.redaction || {}; + projected.redaction = { + ...existingRedaction, + applied: Boolean(existingRedaction.applied || frontendRedactedCount > 0), + redacted_count: (Number.isSafeInteger(existingRedaction.redacted_count) + ? existingRedaction.redacted_count + : 0) + frontendRedactedCount, + truncated: Boolean(existingRedaction.truncated || frontendTruncated) + }; + } + return projected; +} + +function serialisePrettyJsonWithinUtf8Limit(value) { + const jsonText = JSON.stringify(value, null, 2); + return utf8ByteLength(jsonText) <= TURN_FAILURE_CAPSULE_CLIPBOARD_MAX_UTF8_BYTES + ? jsonText + : null; +} + +function markCapsuleFrontendTruncated(capsule) { + const redaction = capsule.redaction && typeof capsule.redaction === 'object' + ? capsule.redaction + : {}; + capsule.redaction = { + ...redaction, + applied: Boolean(redaction.applied), + redacted_count: Number.isSafeInteger(redaction.redacted_count) + ? redaction.redacted_count + : 0, + truncated: true + }; +} + +function compactCapsuleTextSurface(surface, maxBytes) { + if (!surface || typeof surface !== 'object' || typeof surface.text !== 'string') { + return; + } + const bounded = truncateUtf8Text(surface.text, maxBytes); + surface.text = bounded.text; + if (bounded.truncated) { + surface.char_count = unicodeCharacterCount(bounded.text); + delete surface.sha256; + surface.truncated = true; + } +} + +function stringifyBoundedTurnFailureCapsule(projectedCapsule) { + let jsonText = serialisePrettyJsonWithinUtf8Limit(projectedCapsule); + if (jsonText) { + return jsonText; + } + + const capsule = JSON.parse(JSON.stringify(projectedCapsule)); + markCapsuleFrontendTruncated(capsule); + delete capsule.effect_summary; + delete capsule.canonical_scope_modes; + compactCapsuleTextSurface(capsule.visible_response, 1024); + compactCapsuleTextSurface(capsule.pre_presentation_draft, 1024); + compactCapsuleTextSurface(capsule.turn_error?.preview, 256); + if (Array.isArray(capsule.effects)) { + capsule.effects.forEach((effect) => compactCapsuleTextSurface(effect?.error?.preview, 256)); + } + jsonText = serialisePrettyJsonWithinUtf8Limit(capsule); + if (jsonText) { + return jsonText; + } + + if (Array.isArray(capsule.effects)) { + capsule.effects.forEach((effect) => { + delete effect.initial_status; + delete effect.changed; + delete effect.current_outcome_status; + delete effect.outcome_resolved; + delete effect.reconciliation_status; + delete effect.canonical_readback_verdict; + delete effect.recovered_by_effect_id; + delete effect.workflow_id; + delete effect.instance_id; + delete effect.evidence_id; + }); + const failedEffects = capsule.effects.filter((effect) => effect?.error?.code); + const otherEffects = capsule.effects.filter((effect) => !effect?.error?.code); + capsule.effects = [...failedEffects, ...otherEffects]; + } + delete capsule.producer; + jsonText = serialisePrettyJsonWithinUtf8Limit(capsule); + if (jsonText) { + return jsonText; + } + + delete capsule.pre_presentation_draft; + delete capsule.visible_response; + if (capsule.turn_error?.preview) { + delete capsule.turn_error.preview; + } + if (Array.isArray(capsule.effects)) { + capsule.effects.forEach((effect) => { + if (effect?.error?.preview) { + delete effect.error.preview; + } + }); + while (capsule.effects.length > 0) { + jsonText = serialisePrettyJsonWithinUtf8Limit(capsule); + if (jsonText) { + return jsonText; + } + capsule.effects.pop(); + } + } + return serialisePrettyJsonWithinUtf8Limit(capsule); +} + +function resolveStoredTurnFailureCapsule(debugData) { + if (!debugData || typeof debugData !== 'object') { + return null; + } + const candidates = [ + debugData.failure_capsule, + debugData.turn_failure_capsule, + debugData.turn_execution_diagnostics?.failure_capsule, + debugData.turn_execution_record?.failure_capsule + ]; + return candidates.find((candidate) => ( + candidate + && typeof candidate === 'object' + && candidate.schema_version === TURN_FAILURE_CAPSULE_SCHEMA_VERSION + )) || null; +} + async function buildLlmDebugClipboardJsonForTurn(turnId) { + const debugData = llmDebugData.get(turnId); + if (!debugData || typeof debugData !== 'object') { + return null; + } + + const storedCapsule = resolveStoredTurnFailureCapsule(debugData); + const storedProjection = projectTurnFailureCapsule(storedCapsule); + const requestId = resolveConversationTelemetryRequestId(debugData) + || storedProjection?.request_id + || ''; + const historyLocation = cloneConversationHistoryLocation( + debugData?.turn_execution_diagnostics?.history_location + || debugData?.history_location + ); + const hasExactHistoryLocation = Boolean( + typeof historyLocation?.session_id === 'string' + && historyLocation.session_id.trim() + && Number.isInteger(historyLocation.history_index) + && historyLocation.history_index >= 0 + ); + const fetchedCapsule = requestId && hasExactHistoryLocation + ? await fetchTurnFailureCapsule({ + requestId, + sessionId: historyLocation?.session_id || null, + historyIndex: Number.isInteger(historyLocation?.history_index) + ? historyLocation.history_index + : null + }) + : null; + const capsule = fetchedCapsule || projectTurnFailureCapsule(storedCapsule, { + expectedRequestId: requestId || null + }); + if (!capsule) { + return null; + } + if (fetchedCapsule && typeof turnId === 'string' && turnId.trim()) { + setLlmDebugDataEntry(turnId, { + ...debugData, + turn_failure_capsule: fetchedCapsule + }); + } + return stringifyBoundedTurnFailureCapsule(capsule); +} + +async function buildLlmDebugDeepInspectionJsonForTurn(turnId) { let debugDataRaw = llmDebugData.get(turnId); if (!hasLlmDebugLocatorReference(debugDataRaw)) { return null; @@ -35010,14 +35532,14 @@ async function copyLlmDebugJsonForTurn(turnId, button) { const jsonText = await buildLlmDebugClipboardJsonForTurn(turnId, { button }); if (!jsonText) { setLlmDebugButtonCopyState(button, 'failed'); - showToast('No LLM debug data stored for this turn.'); + showToast('No bounded turn capsule is available for this turn.'); return false; } const copied = await copyTextWithClipboardFallback(jsonText); setLlmDebugButtonCopyState(button, copied ? 'copied' : 'failed'); if (!copied) { - showToast('Unable to copy LLM reference JSON.'); + showToast('Unable to copy bounded turn capsule JSON.'); } return copied; } @@ -35220,7 +35742,7 @@ async function showLlmDebugPopup(turnId, options = {}) { } // Prefer turn_execution_diagnostics for parity with active-turn diagnostic references. - popup.dataset.currentDebugData = await buildLlmDebugClipboardJsonForTurn(turnId, options) || ''; + popup.dataset.currentDebugData = await buildLlmDebugDeepInspectionJsonForTurn(turnId, options) || ''; // Show popup - update aria-hidden BEFORE showing to avoid accessibility warning popup.setAttribute('aria-hidden', 'false'); @@ -36032,6 +36554,43 @@ async function fetchConversationTelemetryLocatorPayload(sessionId) { } } +async function fetchTurnFailureCapsule({ + requestId, + sessionId = null, + historyIndex = null +} = {}) { + const cleanRequestId = typeof requestId === 'string' ? requestId.trim() : ''; + if (!cleanRequestId) { + return null; + } + + try { + const params = new URLSearchParams({ request_id: cleanRequestId }); + const cleanSessionId = typeof sessionId === 'string' ? sessionId.trim() : ''; + if (cleanSessionId) { + params.set('session_id', cleanSessionId); + } + if (Number.isInteger(historyIndex) && historyIndex >= 0) { + params.set('history_index', String(historyIndex)); + } + const response = await fetchWithTimeout( + `/von/history/turn_failure_capsule?${params.toString()}`, + { + headers: buildChatFetchHeaders(), + timeoutMs: CONVERSATION_TELEMETRY_LOCATOR_FETCH_TIMEOUT_MS + } + ); + const body = await response.json(); + if (!response.ok || !body || typeof body !== 'object') { + return null; + } + return projectTurnFailureCapsule(body, { expectedRequestId: cleanRequestId }); + } catch (error) { + console.warn('[chatTab] Failed to fetch bounded turn failure capsule:', error); + return null; + } +} + async function fetchTurnTelemetryMcpAccess({ requestId, sessionId = null, diff --git a/tests/backend/test_adaptive_turn_service.py b/tests/backend/test_adaptive_turn_service.py index 55f7836c..cfd8202d 100644 --- a/tests/backend/test_adaptive_turn_service.py +++ b/tests/backend/test_adaptive_turn_service.py @@ -3750,6 +3750,16 @@ def handler(name: str, _arguments: dict[str, Any]) -> dict[str, Any]: assert current_state_phases[0]["observation"]["target_concept_ids"] == [ concept_id ] + outcome_report = next( + item + for item in result.aux_llm_calls + if item.get("type") == "adaptive_turn_effect_outcome_report" + ) + assert outcome_report["response_authority"] == "canonical_outcome" + assert outcome_report["model_draft"]["authority"] == "non_authoritative" + assert outcome_report["model_draft"]["preview"].startswith( + "The paper and PDF were represented" + ) def test_one_exact_read_does_not_resolve_multi_target_create( diff --git a/tests/backend/test_turn_failure_capsule_service.py b/tests/backend/test_turn_failure_capsule_service.py new file mode 100644 index 00000000..bdae81c9 --- /dev/null +++ b/tests/backend/test_turn_failure_capsule_service.py @@ -0,0 +1,290 @@ +from __future__ import annotations + +import json + +from src.backend.services.turn_failure_capsule_service import ( + TURN_FAILURE_CAPSULE_MAX_BYTES, + build_turn_failure_capsule, + turn_failure_capsule_size_bytes, +) + + +def _paper_outcome_report() -> dict: + return { + "type": "adaptive_turn_effect_outcome_report", + "schema_version": "adaptive_turn_effect_outcome_report.v1", + "terminal_status": "effect_partially_completed", + "response_authority": "canonical_outcome", + "model_draft": { + "authority": "non_authoritative", + "preview": ( + "The paper was fully represented in #V#michael_witbrock@uoasail " + "with api_key=never-copy-this." + ), + }, + "canonical_scopes": [ + {"mode": "user", "concept_id": "#V#michael_witbrock"} + ], + "facts": [ + { + "effect_id": "effect-download-paper", + "tool": "download_paper", + "effect_status": "failed", + "initial_effect_status": "failed", + "changed": False, + "outcome_resolved": False, + "canonical_readback_present": False, + "error_code": "arxiv_acquisition_unavailable", + "error": ( + "RuntimeError: " + "is bound to a different event loop" + ), + "argument_identity": {"concept_id": "#V#private_argument"}, + "receipt": {"access_token": "never-copy-this"}, + "target_ids": ["#V#private_target"], + }, + { + "effect_id": "effect-create-paper", + "tool": "create_concepts", + "effect_status": "indeterminate", + "initial_effect_status": "indeterminate", + "changed": None, + "current_outcome_status": "target_observed", + "outcome_resolved": True, + "reconciliation_status": "current_state_observed", + "canonical_readback_present": True, + "canonical_readback_verified": False, + "workflow_id": "#V#paper_representation_workflow", + "instance_id": "paper-instance-opaque", + "evidence_id": "evidence-paper-readback", + "error_code": "ontology_mutation_postcondition_failed", + }, + { + "effect_id": "effect-marker", + "tool": "record_source_processing_marker", + "effect_status": "succeeded", + "initial_effect_status": "succeeded", + "changed": True, + "canonical_readback_present": True, + "canonical_readback_verified": True, + }, + ], + } + + +def test_capsule_projects_exact_paper_lock_incident_without_private_scope() -> None: + capsule = build_turn_failure_capsule( + request_id="paper-partial-current-state-readback", + terminal_status="effect_partially_completed", + response_authority="canonical_outcome", + visible_response=( + "User scope #V#michael_witbrock. The acquisition failed because " + "the asyncio lock used another event loop." + ), + outcome_report=_paper_outcome_report(), + code_version="v20260818_backend+gabc123", + git_commit="abc123" * 6 + "abcd", + sensitive_identity_values=( + "#V#michael_witbrock@uoasail", + "#V#michael_witbrock", + "#V#uoasail", + ), + generated_at_utc="2026-08-18T00:00:00Z", + ) + + assert capsule["schema_version"] == "turn_failure_capsule.v1" + assert capsule["terminal_status"] == "effect_partially_completed" + assert capsule["response_authority"] == "canonical_outcome" + assert capsule["producer"] == { + "code_version": "v20260818_backend+gabc123", + "git_commit": "abc123" * 6 + "abcd", + } + assert capsule["canonical_scope_modes"] == ["user"] + assert capsule["effect_summary"] == { + "total_count": 3, + "included_count": 3, + "omitted_count": 0, + } + + download = next( + effect for effect in capsule["effects"] if effect["name"] == "download_paper" + ) + assert download["status"] == "failed" + assert download["changed"] is False + assert download["error"]["code"] == "arxiv_acquisition_unavailable" + assert download["error"]["preview"]["text"] == ( + "RuntimeError: asyncio lock is bound to a different event loop" + ) + + create = next( + effect for effect in capsule["effects"] if effect["name"] == "create_concepts" + ) + assert create["initial_status"] == "indeterminate" + assert create["status"] == "indeterminate" + assert create["current_outcome_status"] == "target_observed" + assert create["outcome_resolved"] is True + assert create["reconciliation_status"] == "current_state_observed" + assert create["canonical_readback_verdict"] == "present_unverified" + assert create["instance_id"] == "paper-instance-opaque" + assert create["evidence_id"] == "evidence-paper-readback" + + assert capsule["pre_presentation_draft"]["authority"] == "non_authoritative" + serialised = json.dumps(capsule, sort_keys=True) + for forbidden in ( + "#V#michael_witbrock", + "#V#uoasail", + "never-copy-this", + "private_argument", + "private_target", + "argument_identity", + "receipt", + "target_ids", + "0x10ABCDEF", + ): + assert forbidden not in serialised + assert capsule["redaction"]["applied"] is True + assert capsule["redaction"]["redacted_count"] >= 4 + + +def test_capsule_redacts_secret_forms_from_every_text_surface() -> None: + report = _paper_outcome_report() + report["model_draft"]["preview"] = ( + "Authorization: Bearer bearer-secret password=hunter2 " + "mongodb://admin:password@db.example/von nonce=draft-nonce\n" + "-----BEGIN ENCRYPTED PRIVATE KEY-----\nprivate-key-material\n" + "-----END ENCRYPTED PRIVATE KEY-----" + ) + report["facts"][0]["error"] = ( + "token=token-secret sk-abcdefghijklmno " + "eyJabcdefghijk.abcdefghijk.abcdefghijk " + "ghp_abcdefghijklmnopqrstuv\n" + " File \"/Users/private/person/project/secret.py\", line 41, in run\n" + " private_function(secret_argument)\n" + "RuntimeError: failed" + ) + capsule = build_turn_failure_capsule( + request_id="secret-redaction", + terminal_status="effect_failed", + response_authority="canonical_outcome", + visible_response=( + "https://example.test/?api_key=visible-secret&token=query-secret " + "C:\\Users\\private\\Von\\trace.log cookie=session-secret " + "signature=visible-signature" + ), + outcome_report=report, + generated_at_utc="2026-08-18T00:00:00Z", + ) + + serialised = json.dumps(capsule, sort_keys=True) + for secret in ( + "bearer-secret", + "hunter2", + "admin:password", + "private-key-material", + "token-secret", + "query-secret", + "sk-abcdefghijklmno", + "eyJabcdefghijk", + "ghp_abcdefghijklmnopqrstuv", + "/Users/private/person", + "private_function(secret_argument)", + "C:\\Users\\private", + "visible-secret", + "session-secret", + "draft-nonce", + "visible-signature", + ): + assert secret not in serialised + assert capsule["redaction"]["redacted_count"] >= 8 + + +def test_capsule_hard_bound_is_deterministic_and_keeps_direct_failure() -> None: + facts = [] + for index in range(100): + facts.append( + { + "effect_id": f"effect-{index}-" + "e" * 500, + "tool": f"tool-{index}-" + "n" * 500, + "effect_status": "failed", + "initial_effect_status": "indeterminate", + "current_outcome_status": "target_observed", + "outcome_resolved": index % 2 == 0, + "reconciliation_status": "current_state_observed", + "canonical_readback_present": True, + "error_code": f"direct_failure_{index}", + "error": "diagnostic " + "x" * 5000, + "workflow_id": "workflow-" + "w" * 500, + "instance_id": "instance-" + "i" * 500, + "evidence_id": "evidence-" + "v" * 500, + } + ) + report = { + "schema_version": "adaptive_turn_effect_outcome_report.v1", + "facts": facts, + "canonical_scopes": [{"mode": "user"}], + "model_draft": {"preview": "draft " + "d" * 20_000}, + } + kwargs = dict( + request_id="hard-bound", + terminal_status="effect_failed", + response_authority="canonical_outcome", + visible_response="visible " + "v" * 30_000, + outcome_report=report, + code_version="version-" + "c" * 500, + git_commit="a" * 500, + generated_at_utc="2026-08-18T00:00:00Z", + ) + + first = build_turn_failure_capsule(**kwargs) + second = build_turn_failure_capsule(**kwargs) + + assert first == second + assert turn_failure_capsule_size_bytes(first) <= TURN_FAILURE_CAPSULE_MAX_BYTES + assert first["terminal_status"] == "effect_failed" + assert first["effects"][0]["error"]["code"] == "direct_failure_0" + assert first["effect_summary"]["total_count"] == 100 + assert first["effect_summary"]["omitted_count"] > 0 + assert first["redaction"]["truncated"] is True + + +def test_completed_turn_capsule_has_no_fabricated_failure() -> None: + capsule = build_turn_failure_capsule( + request_id="completed-neighbour", + terminal_status="completed", + response_authority="model", + visible_response="The requested read completed.", + outcome_report=None, + code_version="v-test", + generated_at_utc="2026-08-18T00:00:00Z", + ) + + assert capsule["terminal_status"] == "completed" + assert capsule["response_authority"] == "model" + assert capsule["effects"] == [] + assert capsule["effect_summary"] == { + "total_count": 0, + "included_count": 0, + "omitted_count": 0, + } + assert "turn_error" not in capsule + + +def test_non_effect_route_error_uses_only_supplied_typed_error() -> None: + capsule = build_turn_failure_capsule( + request_id="route-error", + terminal_status="model_error", + response_authority=None, + visible_response=None, + outcome_report=None, + turn_error_code="provider_transport_failed", + turn_error_text="Authorization: Bearer do-not-copy connection failed", + generated_at_utc="2026-08-18T00:00:00Z", + ) + + assert capsule["effects"] == [] + assert capsule["response_authority"] == "not_recorded" + assert capsule["turn_error"]["code"] == "provider_transport_failed" + assert capsule["turn_error"]["preview"]["text"] == ( + "Authorization: Bearer [redacted] connection failed" + ) + assert "do-not-copy" not in json.dumps(capsule) diff --git a/tests/backend/test_von_history_telemetry_locator_endpoint.py b/tests/backend/test_von_history_telemetry_locator_endpoint.py index 73450e19..db0b39ac 100644 --- a/tests/backend/test_von_history_telemetry_locator_endpoint.py +++ b/tests/backend/test_von_history_telemetry_locator_endpoint.py @@ -1,5 +1,7 @@ from __future__ import annotations +from copy import deepcopy + from flask import Flask from src.backend.services.conversation_scope_binding_service import ( @@ -179,3 +181,333 @@ def test_history_turn_telemetry_access_requires_authenticated_actor(monkeypatch) assert response.status_code == 401 assert response.get_json()["error"] == "Not authenticated" + + +def _stored_failure_capsule(request_id: str) -> dict: + return { + "schema_version": "turn_failure_capsule.v1", + "generated_at_utc": "2026-08-18T00:00:00Z", + "request_id": request_id, + "terminal_status": "effect_partially_completed", + "response_authority": "canonical_outcome", + "producer": { + "code_version": "v20260818_backend+gabc123", + "git_commit": "abc123abc123abc123abc123abc123abc123abcd", + }, + "effects": [ + { + "effect_id": "effect-download", + "name": "download_paper", + "initial_status": "failed", + "status": "failed", + "changed": False, + "outcome_resolved": False, + "error": { + "code": "arxiv_acquisition_unavailable", + "preview": { + "text": ( + "RuntimeError: asyncio lock is bound to a different " + "event loop" + ), + "char_count": 66, + "sha256": "a" * 64, + "truncated": False, + }, + }, + } + ], + "effect_summary": { + "total_count": 1, + "included_count": 1, + "omitted_count": 0, + }, + "canonical_scope_modes": ["user"], + "redaction": { + "applied": True, + "redacted_count": 1, + "truncated": False, + }, + } + + +def test_history_turn_failure_capsule_returns_only_stored_shared_grant_projection( + monkeypatch, +): + import src.backend.server.routes.von_routes as von_routes + + request_id = "req-capsule-shared" + capsule = _stored_failure_capsule(request_id) + stored_capsule = deepcopy(capsule) + stored_capsule["mcp_access"] = {"signed_ref": "must-not-escape"} + stored_capsule["actor_concept_id"] = "#V#private_actor" + stored_capsule["prompt"] = "private prompt" + stored_capsule["producer"]["namespace"] = "#V#private_actor@org" + stored_capsule["effects"][0]["arguments"] = {"raw": "private argument"} + stored_capsule["effects"][0]["error"]["receipt"] = { + "access_token": "private token" + } + stored_capsule["effects"][0]["error"]["preview"]["messages"] = [ + "private message" + ] + captured: dict[str, object] = {} + + monkeypatch.setattr( + "src.backend.security.access_control.get_effective_user_concept_id", + lambda: "#V#invitee", + ) + monkeypatch.setattr( + von_routes, + "get_effective_context", + lambda *_args, **_kwargs: {"namespace": "#V#invitee@org"}, + ) + monkeypatch.setattr( + von_routes, + "_resolve_history_request_scope_hints", + lambda **_kwargs: ("#V#invitee@org", "#V#org"), + ) + monkeypatch.setattr( + von_routes, + "_resolve_shared_conversation_owner", + lambda **_kwargs: ( + "#V#owner", + {"organisation_concept_id": "#V#org"}, + ), + ) + monkeypatch.setattr( + von_routes, + "_derive_namespace_for_user_org", + lambda *_args, **_kwargs: "#V#owner@org", + ) + + def get_debug(**kwargs): + captured.update(kwargs) + return { + "request_id": request_id, + "turn_failure_capsule": stored_capsule, + "aux_llm_calls": {"offloaded": True}, + } + + monkeypatch.setattr( + von_routes.chat_history_service, + "get_chat_history_debug_entry", + get_debug, + ) + monkeypatch.setattr( + von_routes, + "get_runtime_code_version_info", + lambda: (_ for _ in ()).throw( + AssertionError("capsule readback must not substitute current runtime") + ), + ) + + app = Flask(__name__) + app.secret_key = "test-secret" + app.register_blueprint(von_routes.von_bp, url_prefix="/von") + response = app.test_client().get( + "/von/history/turn_failure_capsule", + query_string={ + "request_id": request_id, + "session_id": "shared-session", + "history_index": 5, + }, + headers={"X-Von-Window-Session": "shared-window"}, + ) + + assert response.status_code == 200 + assert response.get_json() == capsule + assert captured == { + "user_id": "#V#owner", + "session_id": "shared-session", + "history_index": 5, + "namespace": "#V#owner@org", + "include_legacy": True, + "hydrate_blob_refs": False, + } + assert "mcp_access" not in response.get_json() + assert "history_owner_user_id" not in response.get_json() + assert "private" not in str(response.get_json()) + + +def test_history_turn_failure_capsule_denies_cross_actor_conversation( + monkeypatch, +): + import src.backend.server.routes.von_routes as von_routes + + monkeypatch.setattr( + "src.backend.security.access_control.get_effective_user_concept_id", + lambda: "#V#wrong_actor", + ) + monkeypatch.setattr( + von_routes, + "get_effective_context", + lambda *_args, **_kwargs: {"namespace": "#V#wrong_actor@org"}, + ) + monkeypatch.setattr( + von_routes, + "_resolve_history_request_scope_hints", + lambda **_kwargs: ("#V#wrong_actor@org", "#V#org"), + ) + monkeypatch.setattr( + von_routes, + "_resolve_shared_conversation_owner", + lambda **_kwargs: (None, None), + ) + monkeypatch.setattr( + von_routes.chat_history_service, + "has_chat_history_session", + lambda *_args, **_kwargs: False, + ) + monkeypatch.setattr( + von_routes.chat_history_service, + "get_chat_history_debug_entry", + lambda **_kwargs: (_ for _ in ()).throw( + AssertionError("cross-actor denial must happen before debug lookup") + ), + ) + + app = Flask(__name__) + app.secret_key = "test-secret" + app.register_blueprint(von_routes.von_bp, url_prefix="/von") + response = app.test_client().get( + "/von/history/turn_failure_capsule", + query_string={ + "request_id": "req-private", + "session_id": "private-session", + "history_index": 2, + }, + ) + + assert response.status_code == 403 + assert response.get_json() == {"error": "Not authorised for conversation"} + + +def test_history_turn_failure_capsule_denies_wrong_request_for_history_entry( + monkeypatch, +): + import src.backend.server.routes.von_routes as von_routes + + monkeypatch.setattr( + "src.backend.security.access_control.get_effective_user_concept_id", + lambda: "#V#owner", + ) + monkeypatch.setattr( + von_routes, + "get_effective_context", + lambda *_args, **_kwargs: {"namespace": "#V#owner@org"}, + ) + monkeypatch.setattr( + von_routes, + "_resolve_history_request_scope_hints", + lambda **_kwargs: ("#V#owner@org", "#V#org"), + ) + monkeypatch.setattr( + von_routes, + "_resolve_shared_conversation_owner", + lambda **_kwargs: ("#V#owner", None), + ) + monkeypatch.setattr( + von_routes, + "_derive_namespace_for_user_org", + lambda *_args, **_kwargs: "#V#owner@org", + ) + monkeypatch.setattr( + von_routes.chat_history_service, + "get_chat_history_debug_entry", + lambda **_kwargs: { + "request_id": "different-request", + "turn_failure_capsule": _stored_failure_capsule("different-request"), + }, + ) + + app = Flask(__name__) + app.secret_key = "test-secret" + app.register_blueprint(von_routes.von_bp, url_prefix="/von") + response = app.test_client().get( + "/von/history/turn_failure_capsule", + query_string={ + "request_id": "requested-turn", + "session_id": "owner-session", + "history_index": 4, + }, + ) + + assert response.status_code == 403 + assert response.get_json() == { + "error": "request_id does not belong to history_index" + } + + +def test_history_turn_failure_capsule_legacy_absence_is_typed_404(monkeypatch): + import src.backend.server.routes.von_routes as von_routes + + monkeypatch.setattr( + "src.backend.security.access_control.get_effective_user_concept_id", + lambda: "#V#owner", + ) + monkeypatch.setattr( + von_routes, + "get_effective_context", + lambda *_args, **_kwargs: {"namespace": "#V#owner@org"}, + ) + monkeypatch.setattr( + von_routes, + "_resolve_history_request_scope_hints", + lambda **_kwargs: ("#V#owner@org", "#V#org"), + ) + monkeypatch.setattr( + von_routes, + "_resolve_shared_conversation_owner", + lambda **_kwargs: ("#V#owner", None), + ) + monkeypatch.setattr( + von_routes, + "_derive_namespace_for_user_org", + lambda *_args, **_kwargs: "#V#owner@org", + ) + monkeypatch.setattr( + von_routes.chat_history_service, + "get_chat_history_debug_entry", + lambda **_kwargs: { + "request_id": "legacy-request", + "aux_llm_calls": [{"type": "adaptive_turn_effect_outcome_report"}], + }, + ) + + app = Flask(__name__) + app.secret_key = "test-secret" + app.register_blueprint(von_routes.von_bp, url_prefix="/von") + response = app.test_client().get( + "/von/history/turn_failure_capsule", + query_string={ + "request_id": "legacy-request", + "session_id": "legacy-session", + "history_index": 1, + }, + ) + + assert response.status_code == 404 + assert response.get_json() == {"error": "failure_capsule_not_available"} + + +def test_history_turn_failure_capsule_requires_authenticated_actor(monkeypatch): + import src.backend.server.routes.von_routes as von_routes + + monkeypatch.setattr( + "src.backend.security.access_control.get_effective_user_concept_id", + lambda: None, + ) + + app = Flask(__name__) + app.secret_key = "test-secret" + app.register_blueprint(von_routes.von_bp, url_prefix="/von") + response = app.test_client().get( + "/von/history/turn_failure_capsule", + query_string={ + "request_id": "unauthenticated-request", + "session_id": "private-session", + "history_index": 1, + }, + ) + + assert response.status_code == 401 + assert response.get_json() == {"error": "Not authenticated"} diff --git a/tests/backend/test_von_turn_execution_debug_info.py b/tests/backend/test_von_turn_execution_debug_info.py index cf490eb9..32a5645a 100644 --- a/tests/backend/test_von_turn_execution_debug_info.py +++ b/tests/backend/test_von_turn_execution_debug_info.py @@ -225,6 +225,101 @@ def test_generate_error_debug_preserves_paid_calls_before_route_failure( result["turn_execution_record"]["llm_usage_cost_summary"] == result["llm_usage_cost_summary"] ) + capsule = result["turn_failure_capsule"] + assert capsule["terminal_status"] == "model_error" + assert capsule["response_authority"] == "not_recorded" + assert capsule["effects"] == [] + assert capsule["turn_error"]["preview"]["text"] == ( + "route finalisation failed" + ) + + +def test_finalise_persists_inline_turn_failure_capsule_from_outcome_report( + monkeypatch, +) -> None: + from src.backend.server.routes import von_routes + + version = { + "version": "v20260818_backend+gabc123", + "git_commit": "abc123abc123abc123abc123abc123abc123abcd", + } + monkeypatch.setattr(von_routes, "get_runtime_code_version_info", lambda: version) + monkeypatch.setattr(von_routes, "get_model_registry_snapshot", lambda: None) + outcome_report = { + "type": "adaptive_turn_effect_outcome_report", + "schema_version": "adaptive_turn_effect_outcome_report.v1", + "terminal_status": "effect_partially_completed", + "response_authority": "canonical_outcome", + "model_draft": { + "authority": "non_authoritative", + "preview": ( + "I represented it in #V#person@org for session-paper-lock." + ), + }, + "canonical_scopes": [{"mode": "user", "concept_id": "#V#person"}], + "facts": [ + { + "effect_id": "effect-download", + "tool": "download_paper", + "effect_status": "failed", + "initial_effect_status": "failed", + "changed": False, + "outcome_resolved": False, + "canonical_readback_present": False, + "error_code": "arxiv_acquisition_unavailable", + "error": ( + "RuntimeError: asyncio lock is bound to a different event loop" + ), + } + ], + } + + result = von_routes._finalise_llm_debug_info( + llm_debug_info={ + "request_id": "req-paper-lock", + "interaction_timestamp_utc": "2026-08-18T00:00:00Z", + "response": ( + "User scope #V#person in session-paper-lock; " + "the paper download failed." + ), + "llm_interaction": { + "calls": [], + }, + "tool_invocations": [], + "turn_execution_record_tool_invocations": [], + "turn_execution_diagnostics": {}, + "aux_llm_calls": [outcome_report], + }, + prompt_text="Represent the paper.", + response_text=( + "User scope #V#person in session-paper-lock; " + "the paper download failed." + ), + session_id="session-paper-lock", + namespace="#V#person@org", + user_id="#V#person", + org_id="#V#org", + ) + + capsule = result["turn_failure_capsule"] + assert capsule["schema_version"] == "turn_failure_capsule.v1" + assert capsule["request_id"] == "req-paper-lock" + assert capsule["terminal_status"] == "effect_partially_completed" + assert capsule["response_authority"] == "canonical_outcome" + assert capsule["producer"] == { + "code_version": version["version"], + "git_commit": version["git_commit"], + } + assert capsule["effects"][0]["error"]["code"] == ( + "arxiv_acquisition_unavailable" + ) + assert capsule["pre_presentation_draft"]["authority"] == ( + "non_authoritative" + ) + assert "#V#person" not in capsule["visible_response"]["text"] + assert "session-paper-lock" not in capsule["visible_response"]["text"] + assert "session-paper-lock" not in capsule["pre_presentation_draft"]["text"] + assert result["code_version_details"] == version def test_finalise_reuses_turn_pricing_snapshot_summary(monkeypatch) -> None: @@ -287,6 +382,12 @@ def fail_if_registry_is_read_again(): assert result["llm_usage_cost_summary"] == supplied_summary assert result["turn_execution_record"]["llm_usage_cost_summary"] == supplied_summary + capsule = result["turn_failure_capsule"] + assert capsule["terminal_status"] == "completed" + assert capsule["response_authority"] == "not_recorded" + assert capsule["visible_response"]["text"] == "Done." + assert capsule["effects"] == [] + assert "turn_error" not in capsule def test_finalise_llm_debug_info_preserves_bounded_evidence_envelope( diff --git a/tests/frontend/chatTabConversationLlmTelemetryCopy.test.js b/tests/frontend/chatTabConversationLlmTelemetryCopy.test.js index 62fdbb5f..eedd97b0 100644 --- a/tests/frontend/chatTabConversationLlmTelemetryCopy.test.js +++ b/tests/frontend/chatTabConversationLlmTelemetryCopy.test.js @@ -205,7 +205,11 @@ describe('chat conversation info copy control', () => { } }; - global.fetch = jest.fn(); + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 404, + json: async () => ({ error: 'not_available_in_test' }) + }); fetchWithTimeout.mockResolvedValue({ ok: true, json: async () => ({ diff --git a/tests/frontend/chatTabLlmDebugWorkflowExecution.test.js b/tests/frontend/chatTabLlmDebugWorkflowExecution.test.js index 744096d3..8161dc78 100644 --- a/tests/frontend/chatTabLlmDebugWorkflowExecution.test.js +++ b/tests/frontend/chatTabLlmDebugWorkflowExecution.test.js @@ -6,6 +6,82 @@ function flushAsyncClickHandler() { return new Promise((resolve) => setTimeout(resolve, 0)); } +function buildFailureCapsule(overrides = {}) { + return { + schema_version: 'turn_failure_capsule.v1', + generated_at_utc: '2026-08-17T10:40:58.471Z', + request_id: 'req-capsule', + terminal_status: 'effect_partially_completed', + response_authority: 'canonical_outcome', + producer: { + code_version: '2026.08.17', + git_commit: '0123456789abcdef' + }, + visible_response: { + text: 'The paper could not be downloaded, but the concept was represented.', + char_count: 67, + sha256: 'a'.repeat(64), + truncated: false + }, + pre_presentation_draft: { + text: 'I represented the concept and attempted the paper download.', + char_count: 58, + sha256: 'b'.repeat(64), + truncated: false, + authority: 'non_authoritative' + }, + effects: [ + { + effect_id: 'effect-download-paper', + name: 'download_paper', + initial_status: 'indeterminate', + status: 'failed', + changed: false, + current_outcome_status: 'failed', + outcome_resolved: true, + reconciliation_status: 'resolved', + canonical_readback_verdict: 'not_present', + error: { + code: 'arxiv_acquisition_unavailable', + preview: { + text: 'RuntimeError: asyncio lock is bound to a different event loop', + char_count: 65, + sha256: 'c'.repeat(64), + truncated: false + } + }, + recovered_by_effect_id: 'effect-represent-concept', + workflow_id: 'workflow-paper-representation', + instance_id: 'instance-paper-representation', + evidence_id: 'evidence-paper-download' + }, + { + effect_id: 'effect-represent-concept', + name: 'represent_concept', + initial_status: 'completed', + status: 'completed', + changed: true, + current_outcome_status: 'completed', + outcome_resolved: true, + reconciliation_status: 'resolved', + canonical_readback_verdict: 'verified' + } + ], + effect_summary: { + total_count: 2, + included_count: 2, + omitted_count: 0 + }, + canonical_scope_modes: ['user'], + redaction: { + applied: false, + redacted_count: 0, + truncated: false + }, + ...overrides + }; +} + jest.mock('../../src/frontend/web/von_interface/static/js/apiService.js', () => ({ annotateTurn: jest.fn(), fetchWithTimeout: jest.fn(), @@ -33,6 +109,7 @@ jest.mock('../../src/frontend/web/von_interface/static/js/utils/textDecorator.js describe('LLM debug popup workflow execution hook', () => { beforeEach(() => { global.fetch = undefined; + window.scrollTo = jest.fn(); const { fetchWithTimeout } = require('../../src/frontend/web/von_interface/static/js/apiService.js'); fetchWithTimeout.mockReset(); document.body.innerHTML = ` @@ -52,15 +129,42 @@ describe('LLM debug popup workflow execution hook', () => { `; }); - test('conversation turn LLM button copies locator JSON on plain click and marks copied', async () => { + test('plain click copies the motivating incident capsule without delegation or raw diagnostics', async () => { const { __testOnly_appendMessage, setLlmDebugDataForTurn } = require(chatTabModulePath); + const { fetchWithTimeout } = require('../../src/frontend/web/von_interface/static/js/apiService.js'); const writeText = jest.fn().mockResolvedValue(undefined); Object.assign(navigator, { clipboard: { writeText } }); + fetchWithTimeout.mockResolvedValue({ + ok: true, + json: async () => ({ + ...buildFailureCapsule({ request_id: 'req-copy' }), + prompt_preview: 'DO_NOT_COPY_PROMPT_SENTINEL', + messages: [{ role: 'user', content: 'DO_NOT_COPY_MESSAGE_SENTINEL' }], + response: 'DO_NOT_COPY_RAW_RESPONSE_SENTINEL', + namespace_context: { + namespace: '#V#private-user@private-org', + user_id: '#V#private-user', + org_id: '#V#private-org' + }, + metadata: { secret: 'DO_NOT_COPY_METADATA_SENTINEL' }, + workflow_routing_diagnostics: { raw: 'DO_NOT_COPY_ROUTING_SENTINEL' }, + mcp_access: { + turn_execution_get_diagnostics: { + arguments: { + turn_telemetry_ref: { + signature: 'DO_NOT_COPY_SIGNATURE_SENTINEL', + nonce: 'DO_NOT_COPY_NONCE_SENTINEL' + } + } + } + } + }) + }); setLlmDebugDataForTurn('assistant-copy', { model: 'gpt-5.2-test', @@ -68,8 +172,13 @@ describe('LLM debug popup workflow execution hook', () => { response: 'ok', turn_execution_diagnostics: { request_id: 'req-copy', - prompt_preview: 'hello' - } + prompt_preview: 'hello', + history_location: { + session_id: 'session-copy', + history_index: 289 + } + }, + error: 'DO_NOT_COPY_DEBUG_ERROR_SENTINEL' }); __testOnly_appendMessage('Von', 'Done', 'assistant-copy', true); @@ -85,77 +194,88 @@ describe('LLM debug popup workflow execution hook', () => { await flushAsyncClickHandler(); expect(writeText).toHaveBeenCalledTimes(1); - const copiedPayload = JSON.parse(writeText.mock.calls[0][0]); - expect(copiedPayload.schema_version).toBe('turn_telemetry_locator.v1'); + const copiedText = writeText.mock.calls[0][0]; + const copiedPayload = JSON.parse(copiedText); + expect(Buffer.byteLength(copiedText, 'utf8')).toBeLessThanOrEqual(16 * 1024); + expect(copiedPayload.schema_version).toBe('turn_failure_capsule.v1'); expect(copiedPayload.request_id).toBe('req-copy'); - expect(copiedPayload.prompt_preview).toBe('hello'); + expect(copiedPayload.terminal_status).toBe('effect_partially_completed'); + expect(copiedPayload.response_authority).toBe('canonical_outcome'); + expect(copiedPayload.effects[0]).toEqual(expect.objectContaining({ + name: 'download_paper', + status: 'failed', + error: expect.objectContaining({ + code: 'arxiv_acquisition_unavailable', + preview: expect.objectContaining({ + text: 'RuntimeError: asyncio lock is bound to a different event loop' + }) + }) + })); + const [capsuleUrl] = fetchWithTimeout.mock.calls[0]; + const parsedUrl = new URL(capsuleUrl, 'https://example.test'); + expect(parsedUrl.pathname).toBe('/von/history/turn_failure_capsule'); + expect(parsedUrl.searchParams.get('request_id')).toBe('req-copy'); + expect(parsedUrl.searchParams.get('session_id')).toBe('session-copy'); + expect(parsedUrl.searchParams.get('history_index')).toBe('289'); + expect(fetchWithTimeout.mock.calls.some(([url]) => ( + String(url).includes('/turn_telemetry_access') + || String(url).includes('/telemetry_locator') + ))).toBe(false); + expect(copiedText).not.toContain('DO_NOT_COPY_'); + expect(copiedPayload.prompt_preview).toBeUndefined(); + expect(copiedPayload.namespace_context).toBeUndefined(); + expect(copiedPayload.metadata).toBeUndefined(); + expect(copiedPayload.workflow_routing_diagnostics).toBeUndefined(); + expect(copiedPayload.mcp_access).toBeUndefined(); expect(copiedPayload.messages).toBeUndefined(); expect(copiedPayload.response).toBeUndefined(); expect(button.classList.contains('llm-debug-button-copied')).toBe(true); expect(button.textContent).toBe('Copied'); expect(button.dataset.copyFeedback).toBe('Copied'); - expect(button.getAttribute('title')).toContain('Copied LLM reference JSON'); + expect(button.getAttribute('title')).toContain('Copied bounded turn capsule JSON'); expect(popup.classList.contains('hidden')).toBe(true); }); - test('conversation turn LLM button copies history locator without hydrating debug data', async () => { + test('fetched capsule is retained for historical copy while every fresh or expired credential is excluded', async () => { const { - __testOnly_appendMessage, + __testOnly_buildLlmDebugClipboardJsonForTurn, setLlmDebugDataForTurn } = require(chatTabModulePath); - const writeText = jest.fn().mockResolvedValue(undefined); - Object.assign(navigator, { - clipboard: { writeText } - }); - global.fetch = jest.fn(); const { fetchWithTimeout } = require('../../src/frontend/web/von_interface/static/js/apiService.js'); fetchWithTimeout.mockResolvedValueOnce({ ok: true, json: async () => ({ - schema_version: 'conversation_llm_telemetry_locator.v1', - session_id: 'session-history-ref', - turns: [ - { - turn_id: 'history-assistant', - request_id: 'req-history-ref', - history_location: { - session_id: 'session-history-ref', - history_index: 4 - } - } - ] - }) - }).mockResolvedValueOnce({ - ok: true, - json: async () => ({ - schema_version: 'turn_telemetry_mcp_access.v1', - history_location: { - session_id: 'session-history-ref', - history_index: 4 - }, + ...buildFailureCapsule({ request_id: 'req-history-ref' }), mcp_access: { - chat_history_get_debug_entry: { - tool_name: 'chat_history_get_debug_entry', + turn_execution_get_diagnostics: { + tool_name: 'turn_execution_get_diagnostics', arguments: { - history_location_ref: { - signature: 'signed-history-ref' + request_id: 'req-history-ref', + turn_telemetry_ref: { + signature: 'fresh-signature-sentinel', + nonce: 'fresh-nonce-sentinel', + expires_at_utc: '2099-01-01T00:00:00Z' } } }, - turn_execution_get_diagnostics: { + expired_descriptor: { tool_name: 'turn_execution_get_diagnostics', arguments: { - request_id: 'req-history-ref', turn_telemetry_ref: { - signature: 'signed-turn-ref' + signature: 'expired-signature-sentinel', + expires_at_utc: '2000-01-01T00:00:00Z' } } } } }) + }).mockResolvedValueOnce({ + ok: false, + json: async () => ({ error: 'failure_capsule_temporarily_unavailable' }) }); - setLlmDebugDataForTurn('assistant-history-ref', { + setLlmDebugDataForTurn('history-assistant', { + request_id: 'req-history-ref', history_location: { session_id: 'session-history-ref', history_index: 4 @@ -163,45 +283,287 @@ describe('LLM debug popup workflow execution hook', () => { timestamp: '2026-06-03T00:00:00.000Z' }); - __testOnly_appendMessage('Von', 'Stored history turn', 'assistant-history-ref', false, true); + const firstText = await __testOnly_buildLlmDebugClipboardJsonForTurn('history-assistant'); + const secondText = await __testOnly_buildLlmDebugClipboardJsonForTurn('history-assistant'); - const button = document.querySelector('.llm-debug-button'); - expect(button).not.toBeNull(); + expect(secondText).toBe(firstText); + expect(fetchWithTimeout).toHaveBeenCalledTimes(2); + expect(firstText).not.toContain('signature-sentinel'); + expect(firstText).not.toContain('nonce-sentinel'); + expect(firstText).not.toContain('expires_at_utc'); + expect(JSON.parse(firstText).mcp_access).toBeUndefined(); + }); - button.dispatchEvent(new MouseEvent('click', { bubbles: true })); - await Promise.resolve(); - await Promise.resolve(); - await flushAsyncClickHandler(); + test('capsule projection redacts credential-shaped text and enforces the pretty UTF-8 16 KiB ceiling', async () => { + const { + __testOnly_buildLlmDebugClipboardJsonForTurn, + setLlmDebugDataForTurn + } = require(chatTabModulePath); + const { fetchWithTimeout } = require('../../src/frontend/web/von_interface/static/js/apiService.js'); + const oversizedEffects = Array.from({ length: 8 }, (_unused, index) => ({ + effect_id: index === 0 + ? 'signature=effect-identifier-secret' + : `effect-${index}-${'x'.repeat(220)}`, + name: index === 0 + ? 'nonce=effect-name-secret' + : `effect_${index}_${'n'.repeat(260)}`, + initial_status: 'indeterminate', + status: 'failed', + changed: false, + current_outcome_status: 'failed', + outcome_resolved: true, + reconciliation_status: 'resolved', + canonical_readback_verdict: 'not_present', + error: { + code: index === 0 ? 'arxiv_acquisition_unavailable' : `failure_${index}`, + preview: { + text: `${index === 0 ? 'Bearer super-secret-token sk-abcdefghijk signature=error-signature-secret nonce=error-nonce-secret ' : ''}${'🙂'.repeat(4000)}`, + char_count: 8000, + sha256: 'd'.repeat(64), + truncated: false + } + }, + workflow_id: index === 0 + ? 'mongodb://db-user:db-password@private-cluster.example' + : `workflow-${'w'.repeat(220)}`, + instance_id: `instance-${'i'.repeat(220)}`, + evidence_id: `evidence-${'e'.repeat(220)}`, + arbitrary_raw_payload: 'DO_NOT_COPY_EFFECT_RAW_SENTINEL' + })); + fetchWithTimeout.mockResolvedValue({ + ok: true, + json: async () => buildFailureCapsule({ + request_id: 'req-unicode-bound', + producer: { + code_version: 'signature=producer-signature-secret', + git_commit: 'nonce=producer-nonce-secret' + }, + visible_response: { + text: `Bearer visible-secret mongodb://reader:uri-password@cluster.example/path?access_token=query-token-secret -----BEGIN PRIVATE KEY-----\npem-private-key-secret\n-----END PRIVATE KEY----- ${'🙂'.repeat(20000)}`, + char_count: 40022, + sha256: 'e'.repeat(64), + truncated: false + }, + pre_presentation_draft: { + text: `sk-abcdefghijk ${'漢'.repeat(20000)}`, + char_count: 20015, + sha256: 'f'.repeat(64), + truncated: false, + authority: 'non_authoritative' + }, + effects: oversizedEffects, + effect_summary: { + total_count: oversizedEffects.length, + included_count: oversizedEffects.length, + omitted_count: 0 + } + }) + }); + setLlmDebugDataForTurn('assistant-unicode-bound', { + request_id: 'req-unicode-bound', + history_location: { + session_id: 'session-unicode-bound', + history_index: 7 + } + }); + + const jsonText = await __testOnly_buildLlmDebugClipboardJsonForTurn( + 'assistant-unicode-bound' + ); + const payload = JSON.parse(jsonText); - expect(global.fetch).not.toHaveBeenCalled(); + expect(Buffer.byteLength(jsonText, 'utf8')).toBeLessThanOrEqual(16 * 1024); + expect(payload.terminal_status).toBe('effect_partially_completed'); + expect(payload.effects[0].error.code).toBe('arxiv_acquisition_unavailable'); + const copiedErrorPreview = payload.effects[0].error.preview; + expect(copiedErrorPreview.sha256).toBeUndefined(); + expect(copiedErrorPreview.char_count).toBe( + Array.from(copiedErrorPreview.text).length + ); + expect(jsonText).not.toContain('super-secret-token'); + expect(jsonText).not.toContain('sk-abcdefghijk'); + expect(jsonText).not.toContain('effect-identifier-secret'); + expect(jsonText).not.toContain('effect-name-secret'); + expect(jsonText).not.toContain('db-password'); + expect(jsonText).not.toContain('producer-signature-secret'); + expect(jsonText).not.toContain('producer-nonce-secret'); + expect(jsonText).not.toContain('error-signature-secret'); + expect(jsonText).not.toContain('error-nonce-secret'); + expect(jsonText).not.toContain('uri-password'); + expect(jsonText).not.toContain('query-token-secret'); + expect(jsonText).not.toContain('pem-private-key-secret'); + expect(jsonText).not.toContain('DO_NOT_COPY_EFFECT_RAW_SENTINEL'); + expect(payload.redaction).toEqual(expect.objectContaining({ + applied: true, + truncated: true + })); + }); + + test('malformed or mismatched capsule fails closed without copying an old locator', async () => { + const { + __testOnly_buildLlmDebugClipboardJsonForTurn, + setLlmDebugDataForTurn + } = require(chatTabModulePath); + const { fetchWithTimeout } = require('../../src/frontend/web/von_interface/static/js/apiService.js'); + fetchWithTimeout.mockResolvedValue({ + ok: true, + json: async () => ({ + ...buildFailureCapsule({ request_id: 'different-request' }), + schema_version: 'turn_failure_capsule.future' + }) + }); + setLlmDebugDataForTurn('assistant-malformed-capsule', { + request_id: 'req-malformed-capsule', + history_location: { + session_id: 'session-malformed-capsule', + history_index: 11 + }, + prompt_preview: 'OLD_LOCATOR_PROMPT_SENTINEL', + telemetry_locator_mcp_access: { + turn_execution_get_diagnostics: { + arguments: { + turn_telemetry_ref: { signature: 'OLD_LOCATOR_SIGNATURE_SENTINEL' } + } + } + } + }); + + const jsonText = await __testOnly_buildLlmDebugClipboardJsonForTurn( + 'assistant-malformed-capsule' + ); + + expect(jsonText).toBeNull(); + expect(fetchWithTimeout).toHaveBeenCalledTimes(1); const [calledUrl] = fetchWithTimeout.mock.calls[0]; - const parsedUrl = new URL(calledUrl, 'https://example.test'); - expect(parsedUrl.pathname).toBe('/von/history/telemetry_locator'); - expect(parsedUrl.searchParams.get('session_id')).toBe('session-history-ref'); - expect(writeText).toHaveBeenCalledTimes(1); - const copiedPayload = JSON.parse(writeText.mock.calls[0][0]); - expect(copiedPayload.schema_version).toBe('turn_telemetry_locator.v1'); - expect(copiedPayload.request_id).toBe('req-history-ref'); - expect(copiedPayload.history_location).toEqual({ - session_id: 'session-history-ref', - history_index: 4 + expect(calledUrl).toContain('/von/history/turn_failure_capsule?'); + expect(calledUrl).not.toContain('/telemetry_locator'); + expect(calledUrl).not.toContain('/turn_telemetry_access'); + }); + + test('completed and unresolved neighbour capsules do not fabricate failures', async () => { + const { + __testOnly_buildLlmDebugClipboardJsonForTurn, + setLlmDebugDataForTurn + } = require(chatTabModulePath); + const { fetchWithTimeout } = require('../../src/frontend/web/von_interface/static/js/apiService.js'); + fetchWithTimeout + .mockResolvedValueOnce({ + ok: true, + json: async () => buildFailureCapsule({ + request_id: 'req-completed-neighbour', + terminal_status: 'completed', + response_authority: 'model_answer', + effects: [], + effect_summary: { + total_count: 0, + included_count: 0, + omitted_count: 0 + }, + canonical_scope_modes: [] + }) + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => buildFailureCapsule({ + request_id: 'req-unresolved-neighbour', + terminal_status: 'effect_outcome_unknown', + effects: [{ + effect_id: 'effect-unknown', + name: 'represent_concept', + status: 'indeterminate', + outcome_resolved: false + }], + effect_summary: { + total_count: 1, + included_count: 1, + omitted_count: 0 + } + }) + }); + setLlmDebugDataForTurn('assistant-completed-neighbour', { + request_id: 'req-completed-neighbour', + history_location: { + session_id: 'session-neighbours', + history_index: 1 + } }); - expect(copiedPayload.mcp_access).toEqual(expect.objectContaining({ - chat_history_get_debug_entry: expect.any(Object), - turn_execution_get_diagnostics: expect.any(Object) - })); - expect(copiedPayload.retrieval_status).toBe( - 'server_delegation_available' + setLlmDebugDataForTurn('assistant-unresolved-neighbour', { + request_id: 'req-unresolved-neighbour', + history_location: { + session_id: 'session-neighbours', + history_index: 2 + } + }); + + const completed = JSON.parse(await __testOnly_buildLlmDebugClipboardJsonForTurn( + 'assistant-completed-neighbour' + )); + const unresolved = JSON.parse(await __testOnly_buildLlmDebugClipboardJsonForTurn( + 'assistant-unresolved-neighbour' + )); + + expect(completed.effects).toEqual([]); + expect(completed.turn_error).toBeUndefined(); + expect(unresolved.effects).toEqual([expect.objectContaining({ + status: 'indeterminate', + outcome_resolved: false + })]); + expect(unresolved.effects[0].error).toBeUndefined(); + expect(unresolved.turn_error).toBeUndefined(); + }); + + test('copies a stored generic turn error without locator context or delegated access', async () => { + const { + __testOnly_buildLlmDebugClipboardJsonForTurn, + setLlmDebugDataForTurn + } = require(chatTabModulePath); + const { fetchWithTimeout } = require('../../src/frontend/web/von_interface/static/js/apiService.js'); + setLlmDebugDataForTurn('assistant-generic-turn-error', { + turn_failure_capsule: buildFailureCapsule({ + request_id: 'req-generic-turn-error', + terminal_status: 'failed', + response_authority: 'model', + effects: [], + effect_summary: { + total_count: 0, + included_count: 0, + omitted_count: 0 + }, + turn_error: { + code: 'worker_terminated', + preview: { + text: 'The worker stopped before completing the turn.', + char_count: 46, + sha256: '1'.repeat(64), + truncated: false + } + }, + mcp_access: { + turn_execution_get_diagnostics: { + arguments: { + turn_telemetry_ref: { + signature: 'DO_NOT_COPY_STORED_SIGNATURE_SENTINEL' + } + } + } + } + }) + }); + + const jsonText = await __testOnly_buildLlmDebugClipboardJsonForTurn( + 'assistant-generic-turn-error' ); - const [turnAccessUrl] = fetchWithTimeout.mock.calls[1]; - expect( - new URL(turnAccessUrl, 'https://example.test').pathname - ).toBe('/von/history/turn_telemetry_access'); - expect(copiedPayload.llm_debug_data).toBeUndefined(); - expect(copiedPayload.messages).toBeUndefined(); - expect(copiedPayload.response).toBeUndefined(); - expect(copiedPayload.workflow_discovery).toBeUndefined(); - expect(copiedPayload.stage_diagnostics).toBeUndefined(); + const payload = JSON.parse(jsonText); + + expect(fetchWithTimeout).not.toHaveBeenCalled(); + expect(payload.turn_error).toEqual(expect.objectContaining({ + code: 'worker_terminated', + preview: expect.objectContaining({ + text: 'The worker stopped before completing the turn.' + }) + })); + expect(payload.effects).toEqual([]); + expect(jsonText).not.toContain('DO_NOT_COPY_STORED_SIGNATURE_SENTINEL'); }); test('conversation turn LLM button keeps popup behaviour on shift-click', async () => { @@ -237,7 +599,9 @@ describe('LLM debug popup workflow execution hook', () => { expect(popup.classList.contains('hidden')).toBe(false); expect(popup.getAttribute('aria-hidden')).toBe('false'); const payload = JSON.parse(popup.dataset.currentDebugData || '{}'); + expect(payload.schema_version).toBe('turn_telemetry_locator.v1'); expect(payload.request_id).toBe('req-popup'); + expect(payload.prompt_preview).toBe('open details'); }); test('surfaces execution traces and prefers the selected-workflow trace', async () => { diff --git a/tests/frontend/chatTabThinkingDiagnosticsCopy.test.js b/tests/frontend/chatTabThinkingDiagnosticsCopy.test.js index ea5d6980..d0af19c2 100644 --- a/tests/frontend/chatTabThinkingDiagnosticsCopy.test.js +++ b/tests/frontend/chatTabThinkingDiagnosticsCopy.test.js @@ -62,7 +62,11 @@ describe('chat thinking diagnostics copy control', () => { const chatTab = require(chatTabModulePath); const { fetchWithTimeout } = require('../../src/frontend/web/von_interface/static/js/apiService.js'); const button = document.getElementById('copyThinkingDiagnosticsButton'); - global.fetch = jest.fn(); + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 404, + json: async () => ({ error: 'not_available_in_test' }) + }); fetchWithTimeout.mockResolvedValue({ ok: true, json: async () => ({