diff --git a/docs/engineering/contextual_knowledge_evolution.md b/docs/engineering/contextual_knowledge_evolution.md
index 7ca19113b..5ec1ba973 100644
--- a/docs/engineering/contextual_knowledge_evolution.md
+++ b/docs/engineering/contextual_knowledge_evolution.md
@@ -98,6 +98,13 @@ Raw actor-scoped diagnostic text remains in its authoritative diagnostic
carrier. Situation revisions retain the producing request identity so an
invisible sidecar change can be traced to the turn that authored it.
+Treat the situation, retained observations, and omission state as one coherent
+carrier snapshot at read boundaries. After a mutation or reset, read back the
+whole projection rather than combining a new situation revision with
+turn-start observation state. An authoritative read may legitimately reduce
+counts or clear content; stale asynchronous responses must not repopulate what
+that read superseded.
+
Carrier visibility, contribution, and destructive authority are different.
An accepted participant may contribute turns without thereby acquiring the
right to reset the owner's transcript and shared situation. Treat erase,
@@ -147,7 +154,12 @@ As observed on 29 July 2026, related capabilities are distributed across:
- inspectable conversation-situation text and bounded exact observations on
chat-history sessions, used as provisional per-conversation context rather
- than publication or canonical knowledge;
+ than publication or canonical knowledge; the conversation UI exposes the
+ text, revision provenance, retention state, and exact retained observations;
+- an authority-bound, bounded `chat_history_get_segments` projection that
+ carries the same situation and observations for telemetry consumers, while
+ the compact conversation locator reports only availability and freshness
+ metadata plus the route to that carrier-bearing read;
- base concept and text relations in `concept_service.py`,
`concept_relation_service.py`, and `text_value_service.py`;
- actor- and organisation-visible assertion deltas in
diff --git a/src/backend/integrations/internal_mcp/catalogue.py b/src/backend/integrations/internal_mcp/catalogue.py
index c61087bcc..41a00213f 100644
--- a/src/backend/integrations/internal_mcp/catalogue.py
+++ b/src/backend/integrations/internal_mcp/catalogue.py
@@ -16125,6 +16125,64 @@ def _turn_execution_namespace_coverage_report(**kwargs):
)
+_DELEGATED_TELEMETRY_STDIO_MAX_RESPONSE_CHARS_DEFAULT = 100_000
+_DELEGATED_TELEMETRY_STDIO_MAX_RESPONSE_CHARS_MIN = 10_000
+_DELEGATED_TELEMETRY_PAGE_CONTEXT_FIELDS = (
+ "request_id",
+ "history_location",
+ "read_delegation",
+ "provenance",
+ "session_id",
+ "chat_session_id",
+ "history_owner_user_id",
+ "requested_user_id",
+ "namespace",
+ "access_mode",
+ "identifier_binding",
+)
+
+
+def _delegated_telemetry_json_default(value: Any) -> Any:
+ """Return a stable JSON representation for persisted BSON/Python values."""
+
+ if isinstance(value, datetime):
+ normalised = value
+ if normalised.tzinfo is None:
+ normalised = normalised.replace(tzinfo=timezone.utc)
+ return normalised.isoformat()
+ if isinstance(value, (set, frozenset)):
+ return sorted(value, key=lambda item: str(item))
+ return str(value)
+
+
+def _delegated_telemetry_stdio_max_response_chars() -> int:
+ """Mirror the stdio text guard without importing its catalogue consumer."""
+
+ raw_value = os.getenv("VON_MCP_STDIO_MAX_RESPONSE_CHARS")
+ if raw_value:
+ try:
+ parsed = int(raw_value)
+ except ValueError:
+ parsed = _DELEGATED_TELEMETRY_STDIO_MAX_RESPONSE_CHARS_DEFAULT
+ return max(_DELEGATED_TELEMETRY_STDIO_MAX_RESPONSE_CHARS_MIN, parsed)
+ return _DELEGATED_TELEMETRY_STDIO_MAX_RESPONSE_CHARS_DEFAULT
+
+
+def _delegated_telemetry_stdio_response_chars(payload: Mapping[str, Any]) -> int:
+ """Measure the exact JSON text length checked by the stdio response guard."""
+
+ import json
+
+ return len(
+ json.dumps(
+ dict(payload),
+ indent=2,
+ ensure_ascii=True,
+ default=_delegated_telemetry_json_default,
+ )
+ )
+
+
def _bounded_delegated_telemetry_payload(
payload: Mapping[str, Any],
*,
@@ -16162,39 +16220,85 @@ def _bounded_delegated_telemetry_payload(
sort_keys=True,
separators=(",", ":"),
ensure_ascii=True,
+ default=_delegated_telemetry_json_default,
)
+ canonical_payload = json.loads(canonical_json)
total_chars = len(canonical_json)
digest = hashlib.sha256(canonical_json.encode("utf-8")).hexdigest()
- end = min(total_chars, offset + limit)
- page = {
- "schema_version": "bounded_telemetry_json_page.v1",
+ response_limit = _delegated_telemetry_stdio_max_response_chars()
+
+ page_context: dict[str, Any] = {
+ "success": True,
"artifact_kind": artifact_kind,
- "encoding": "canonical_json_ascii",
- "sha256": digest,
- "offset": offset,
- "limit": limit,
- "returned_chars": max(0, end - offset),
- "total_chars": total_chars,
- "has_more": end < total_chars,
- "next_offset": end if end < total_chars else None,
- "json_chunk": canonical_json[offset:end] if offset <= total_chars else "",
}
- if preserve_inline_below_limit and offset == 0 and total_chars <= limit:
+ for field_name in _DELEGATED_TELEMETRY_PAGE_CONTEXT_FIELDS:
+ if field_name in canonical_payload or field_name in {
+ "request_id",
+ "history_location",
+ "read_delegation",
+ "provenance",
+ }:
+ page_context[field_name] = canonical_payload.get(field_name)
+
+ def _page_for_end(end: int) -> dict[str, Any]:
return {
- **dict(payload),
+ "schema_version": "bounded_telemetry_json_page.v1",
+ "artifact_kind": artifact_kind,
+ "encoding": "canonical_json_ascii",
+ "sha256": digest,
+ "offset": offset,
+ "limit": limit,
+ "returned_chars": max(0, end - offset),
+ "total_chars": total_chars,
+ "has_more": end < total_chars,
+ "next_offset": end if end < total_chars else None,
+ "json_chunk": (canonical_json[offset:end] if offset <= total_chars else ""),
+ }
+
+ def _paged_response_for_end(end: int) -> dict[str, Any]:
+ return {
+ **page_context,
+ "bounded_read": _page_for_end(end),
+ }
+
+ if preserve_inline_below_limit and offset == 0 and total_chars <= limit:
+ inline_page = _page_for_end(total_chars)
+ inline_payload = {
+ **canonical_payload,
"bounded_read": {
- key: value for key, value in page.items() if key != "json_chunk"
+ key: value for key, value in inline_page.items() if key != "json_chunk"
},
}
- return {
- "success": True,
- "artifact_kind": artifact_kind,
- "request_id": payload.get("request_id"),
- "history_location": payload.get("history_location"),
- "read_delegation": payload.get("read_delegation"),
- "provenance": payload.get("provenance"),
- "bounded_read": page,
- }
+ if _delegated_telemetry_stdio_response_chars(inline_payload) <= response_limit:
+ return inline_payload
+
+ requested_end = min(total_chars, offset + limit)
+ requested_page = _paged_response_for_end(requested_end)
+ if _delegated_telemetry_stdio_response_chars(requested_page) <= response_limit:
+ return requested_page
+
+ low = min(offset, total_chars)
+ high = requested_end
+ while low < high:
+ candidate_end = (low + high + 1) // 2
+ candidate_page = _paged_response_for_end(candidate_end)
+ if _delegated_telemetry_stdio_response_chars(candidate_page) <= response_limit:
+ low = candidate_end
+ else:
+ high = candidate_end - 1
+
+ if low <= offset and offset < total_chars:
+ return {
+ **page_context,
+ "success": False,
+ "error_code": "telemetry_page_envelope_too_large",
+ "message": (
+ "Authority-bound telemetry page metadata exceeds the configured "
+ "stdio response guard."
+ ),
+ "response_guard_chars": response_limit,
+ }
+ return _paged_response_for_end(low)
def _chat_history_get_segments(**kwargs):
@@ -16258,6 +16362,7 @@ def _chat_history_get_segments(**kwargs):
include_debug=include_debug,
history_tail_limit=effective_kwargs.get("history_tail_limit"),
return_meta=True,
+ include_conversation_state=True,
)
except chat_history_service.ChatHistoryServiceError as exc:
return make_error_response(
@@ -16274,6 +16379,26 @@ def _chat_history_get_segments(**kwargs):
else:
segments, meta = result, {"history_truncated": False}
+ meta_mapping = meta if isinstance(meta, Mapping) else {}
+ raw_situation = meta_mapping.get("conversation_situation")
+ conversation_situation = (
+ dict(raw_situation) if isinstance(raw_situation, Mapping) else None
+ )
+ raw_observations = meta_mapping.get("conversation_observations")
+ conversation_observations = (
+ list(raw_observations) if isinstance(raw_observations, list) else []
+ )
+ raw_observation_state = meta_mapping.get("conversation_observation_state")
+ if isinstance(raw_observation_state, Mapping):
+ conversation_observation_state = dict(raw_observation_state)
+ else:
+ conversation_observation_state = {
+ "schema_version": "conversation_observation_state.v1",
+ "retained_count": len(conversation_observations),
+ "total_count": len(conversation_observations),
+ "omitted_count": 0,
+ "retention_limit": chat_history_service.CONVERSATION_OBSERVATION_MAX_ITEMS,
+ }
payload = {
"success": True,
"session_id": access.get("session_id"),
@@ -16285,17 +16410,42 @@ def _chat_history_get_segments(**kwargs):
"identifier_binding": access.get("identifier_binding"),
"segments": segments,
"segment_count": len(segments) if isinstance(segments, list) else 0,
- "history_truncated": bool(
- meta.get("history_truncated") if isinstance(meta, Mapping) else False
- ),
+ "history_truncated": bool(meta_mapping.get("history_truncated", False)),
+ "conversation_situation": conversation_situation,
+ "conversation_observations": conversation_observations,
+ "conversation_observation_state": conversation_observation_state,
}
if authorisation.get("delegated"):
payload["read_delegation"] = authorisation.get("read_delegation")
- return _with_rag_provenance(
+ provenanced_payload = _with_rag_provenance(
payload=payload,
item_kind="chat_history_segments",
source_system="mongo.chat_history",
)
+ bounded_payload = _bounded_delegated_telemetry_payload(
+ provenanced_payload,
+ arguments=effective_kwargs,
+ delegated=bool(authorisation.get("delegated")),
+ artifact_kind="chat_history_segments",
+ preserve_inline_below_limit=True,
+ )
+ if authorisation.get("delegated"):
+ for field_name in (
+ "session_id",
+ "chat_session_id",
+ "history_owner_user_id",
+ "requested_user_id",
+ "namespace",
+ "access_mode",
+ "identifier_binding",
+ "read_delegation",
+ ):
+ if field_name in provenanced_payload:
+ bounded_payload.setdefault(
+ field_name,
+ provenanced_payload[field_name],
+ )
+ return bounded_payload
def _chat_history_get_debug_entry(**kwargs):
@@ -35704,6 +35854,8 @@ def _build_default_catalogue_diagnostics_and_research_definitions() -> (
"history_tail_limit": (int, type(None)),
"include_debug": (bool,),
"include_legacy": (bool,),
+ "offset": (int,),
+ "limit": (int,),
},
allow_unknown=True,
description=(
@@ -35714,9 +35866,10 @@ def _build_default_catalogue_diagnostics_and_research_definitions() -> (
output_schema=None,
category="read",
description=(
- "Fetch the stored conversation transcript in segment form, with optional embedded "
- "assistant llm_debug_data and history_location locators. Prefer conversation_ref "
- "over raw session_id on model-driven paths."
+ "Fetch the bounded stored conversation carrier: transcript segments, inspectable "
+ "situation text, exact observations, and optional assistant llm_debug_data with "
+ "history_location locators. Prefer conversation_ref over raw session_id on "
+ "model-driven paths; use offset/limit when a delegated response is paged."
),
),
MethodDefinition(
@@ -35771,9 +35924,10 @@ def _build_default_catalogue_diagnostics_and_research_definitions() -> (
output_schema=None,
category="read",
description=(
- "Build the compact conversation telemetry locator so agents can fetch the same "
- "stored conversation/turn diagnostics without pasting large JSON blobs. Prefer "
- "conversation_ref over raw session_id on model-driven paths."
+ "Build compact conversation telemetry and carrier-freshness metadata, with an "
+ "authority-bound descriptor for fetching the full conversation carrier through "
+ "chat_history_get_segments. Prefer conversation_ref over raw session_id on "
+ "model-driven paths."
),
),
MethodDefinition(
diff --git a/src/backend/mcp_server/vontology_mcp.json b/src/backend/mcp_server/vontology_mcp.json
index 630b42817..52d6f1b2a 100644
--- a/src/backend/mcp_server/vontology_mcp.json
+++ b/src/backend/mcp_server/vontology_mcp.json
@@ -228,7 +228,7 @@
},
{
"name": "chat_history_get_segments",
- "description": "Fetch the stored conversation transcript in segment form, with optional embedded assistant llm_debug_data and history_location locators. Prefer conversation_ref over raw session_id on model-driven paths.",
+ "description": "Fetch the bounded stored conversation carrier: transcript segments, inspectable situation text, exact observations, and optional assistant llm_debug_data with history_location locators. Prefer conversation_ref over raw session_id on model-driven paths; use offset/limit when a delegated response is paged.",
"inputSchema": {
"type": "object",
"properties": {
@@ -277,6 +277,12 @@
},
"include_legacy": {
"type": "boolean"
+ },
+ "offset": {
+ "type": "integer"
+ },
+ "limit": {
+ "type": "integer"
}
},
"required": [],
@@ -751,7 +757,7 @@
},
{
"name": "conversation_telemetry_get_locator",
- "description": "Build the compact conversation telemetry locator so agents can fetch the same stored conversation/turn diagnostics without pasting large JSON blobs. Prefer conversation_ref over raw session_id on model-driven paths.",
+ "description": "Build compact conversation telemetry and carrier-freshness metadata, with an authority-bound descriptor for fetching the full conversation carrier through chat_history_get_segments. Prefer conversation_ref over raw session_id on model-driven paths.",
"inputSchema": {
"type": "object",
"properties": {
diff --git a/src/backend/server/routes/generate_route_support.py b/src/backend/server/routes/generate_route_support.py
index db9f28e33..67f8459e1 100644
--- a/src/backend/server/routes/generate_route_support.py
+++ b/src/backend/server/routes/generate_route_support.py
@@ -433,6 +433,9 @@ def _build_generate_success_body(
llm_debug_info: Mapping[str, Any],
display_elements_contract: Mapping[str, Any] | None,
rag_trace: Any,
+ conversation_situation: Mapping[str, Any] | None = None,
+ conversation_observations: Sequence[Mapping[str, Any]] | None = None,
+ conversation_observation_state: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
return {
"success": success,
@@ -461,6 +464,21 @@ def _build_generate_success_body(
),
"display_elements": display_elements_contract,
"rag_trace": rag_trace,
+ "conversation_situation": (
+ dict(conversation_situation)
+ if isinstance(conversation_situation, Mapping)
+ else None
+ ),
+ "conversation_observations": [
+ dict(observation)
+ for observation in (conversation_observations or ())
+ if isinstance(observation, Mapping)
+ ],
+ "conversation_observation_state": (
+ dict(conversation_observation_state)
+ if isinstance(conversation_observation_state, Mapping)
+ else None
+ ),
}
diff --git a/src/backend/server/routes/von_routes.py b/src/backend/server/routes/von_routes.py
index e2ea9b934..cacf29b9b 100644
--- a/src/backend/server/routes/von_routes.py
+++ b/src/backend/server/routes/von_routes.py
@@ -9286,6 +9286,85 @@ def _persist_conversation_situation_fail_soft(
)
+def _read_conversation_carrier_after_persistence_fail_soft(
+ *,
+ user_id: str | None,
+ session_id: str,
+ namespace: str | None,
+ fallback_situation: Mapping[str, Any] | None,
+ fallback_observations: list[dict[str, Any]],
+ fallback_observation_state: Mapping[str, Any],
+ request_id: str | None = None,
+) -> tuple[dict[str, Any] | None, list[dict[str, Any]], dict[str, Any]]:
+ """Read one canonical carrier snapshot, preserving the turn-start snapshot on failure."""
+
+ fallback_snapshot = (
+ dict(fallback_situation) if isinstance(fallback_situation, Mapping) else None,
+ [
+ dict(observation)
+ for observation in fallback_observations
+ if isinstance(observation, Mapping)
+ ],
+ dict(fallback_observation_state),
+ )
+ if not isinstance(user_id, str) or not user_id.strip():
+ return fallback_snapshot
+
+ try:
+ session_state = chat_history_service.get_chat_history_session_state(
+ user_id=user_id.strip(),
+ session_id=session_id,
+ namespace=namespace,
+ include_history=False,
+ )
+ if not isinstance(session_state, Mapping):
+ raise RuntimeError("canonical conversation session state was not found")
+
+ conversation_situation, _, _ = (
+ _normalise_conversation_situation_descriptor(
+ session_state.get("conversation_situation")
+ )
+ )
+ observations = (
+ [
+ dict(observation)
+ for observation in session_state.get(
+ "conversation_observations", []
+ )
+ if isinstance(observation, Mapping)
+ ]
+ if isinstance(session_state.get("conversation_observations"), list)
+ else []
+ )
+ raw_observation_state = session_state.get(
+ "conversation_observation_state"
+ )
+ observation_state = (
+ dict(raw_observation_state)
+ if isinstance(raw_observation_state, Mapping)
+ else {
+ "schema_version": "conversation_observation_state.v1",
+ "retained_count": len(observations),
+ "total_count": len(observations),
+ "omitted_count": 0,
+ "retention_limit": (
+ chat_history_service.CONVERSATION_OBSERVATION_MAX_ITEMS
+ ),
+ }
+ )
+ return conversation_situation, observations, observation_state
+ except Exception as exc:
+ _safe_app_log(
+ "warning",
+ "Conversation carrier read-back unavailable for session_id=%s "
+ "request_id=%s: %s",
+ session_id,
+ request_id,
+ exc,
+ )
+ return fallback_snapshot
+
+
def _namespace_is_org_scoped(namespace: str | None) -> bool:
return (
isinstance(namespace, str)
@@ -12819,6 +12898,19 @@ def _refresh_llm_debug_timing_payload(debug_payload: dict[str, Any]) -> None:
updated_by=user_concept_id,
request_id=request_id,
)
+ (
+ response_conversation_situation,
+ response_conversation_observations,
+ response_conversation_observation_state,
+ ) = _read_conversation_carrier_after_persistence_fail_soft(
+ user_id=history_user_id,
+ session_id=session_id,
+ namespace=history_namespace,
+ fallback_situation=conversation_situation_descriptor,
+ fallback_observations=conversation_observations,
+ fallback_observation_state=conversation_observation_state,
+ request_id=request_id,
+ )
if progress_updates_enabled:
final_progress_payload = {
@@ -12868,6 +12960,11 @@ def _refresh_llm_debug_timing_payload(debug_payload: dict[str, Any]) -> None:
llm_debug_info=llm_debug_info,
display_elements_contract=display_elements_contract,
rag_trace=rag_trace,
+ conversation_situation=response_conversation_situation,
+ conversation_observations=response_conversation_observations,
+ conversation_observation_state=(
+ response_conversation_observation_state
+ ),
)
)
if adaptive_success:
diff --git a/src/backend/services/browser_test_auth_service.py b/src/backend/services/browser_test_auth_service.py
index fa76382e5..5d3adc117 100644
--- a/src/backend/services/browser_test_auth_service.py
+++ b/src/backend/services/browser_test_auth_service.py
@@ -655,6 +655,27 @@ def _chat_fixture_specs() -> Iterable[dict[str, Any]]:
{
"session_id": "browser-fixture-user-view-state",
"session_name": "Authenticated user-view fixture sanity check",
+ "conversation_situation": {
+ "text": (
+ "The user and Von are validating the authenticated browser "
+ "experience. Their shared goal is to keep saved conversations, "
+ "unread messages, organisation context, and the conversation "
+ "situation inspectable at desktop and mobile widths. Refreshing "
+ "this browser-test fixture must not duplicate its durable records."
+ ),
+ "source_request_id": "browser_user_view.v1:situation",
+ },
+ "conversation_observations": (
+ {
+ "observation_id": "browser_user_view.v1:fixture-ready",
+ "kind": "fixture_state",
+ "observed_at_utc": _BROWSER_TEST_FIXTURE_STABLE_SEEDED_AT,
+ "capability_name": "authenticated_browser_user_view",
+ "terminal_status": "ready",
+ "outcome_finality": "fixture",
+ "changed": False,
+ },
+ ),
"history": (
{
"fixture_entry_id": "user-view-fixture-user-1",
@@ -793,7 +814,11 @@ def _ensure_fixture_chat_session(
with bypass_access_control():
doc = coll.find_one(
{"user_id": user_concept_id, "session_id": session_id},
- {"history.fixture_entry_id": 1},
+ {
+ "history.fixture_entry_id": 1,
+ "conversation_situation": 1,
+ "conversation_observations.observation_id": 1,
+ },
)
existing_ids: set[str] = set()
@@ -822,10 +847,62 @@ def _ensure_fixture_chat_session(
)
created_entries += 1
+ situation_updated = False
+ situation_spec = spec.get("conversation_situation")
+ if isinstance(situation_spec, Mapping):
+ situation_text = str(situation_spec.get("text") or "").strip()
+ existing_situation = (
+ doc.get("conversation_situation") if isinstance(doc, Mapping) else None
+ )
+ existing_text = (
+ str(existing_situation.get("text") or "").strip()
+ if isinstance(existing_situation, Mapping)
+ else ""
+ )
+ existing_revision = (
+ existing_situation.get("revision")
+ if isinstance(existing_situation, Mapping)
+ and isinstance(existing_situation.get("revision"), int)
+ and not isinstance(existing_situation.get("revision"), bool)
+ else 0
+ )
+ if situation_text and situation_text != existing_text:
+ result = chat_history_service.set_chat_history_conversation_situation(
+ user_id=user_concept_id,
+ session_id=session_id,
+ text=situation_text,
+ expected_revision=max(0, existing_revision),
+ source="browser_test_fixture",
+ updated_by=VON_SYSTEM_ID,
+ namespace=namespace,
+ include_legacy=False,
+ source_request_id=str(
+ situation_spec.get("source_request_id")
+ or f"{_BROWSER_TEST_FIXTURE_ID}:situation"
+ ),
+ )
+ situation_updated = bool(result.get("updated"))
+
+ observations_added = 0
+ for observation in spec.get("conversation_observations") or ():
+ if not isinstance(observation, dict):
+ continue
+ result = chat_history_service.append_chat_history_conversation_observation(
+ user_id=user_concept_id,
+ session_id=session_id,
+ observation=observation,
+ namespace=namespace,
+ include_legacy=False,
+ )
+ if result.get("updated"):
+ observations_added += 1
+
return {
"session_id": session_id,
"session_name": session_name,
"created_entries": created_entries,
+ "situation_updated": situation_updated,
+ "observations_added": observations_added,
}
diff --git a/src/backend/services/chat_history_service.py b/src/backend/services/chat_history_service.py
index d9c61bfb8..f16ab096e 100644
--- a/src/backend/services/chat_history_service.py
+++ b/src/backend/services/chat_history_service.py
@@ -1606,53 +1606,15 @@ def get_chat_history_session_state(
if not isinstance(doc, dict):
return None
- try:
- conversation_situation = _conversation_situation_from_doc(doc)
- except ChatHistoryServiceError as exc:
- # A corrupt optional carrier must not make the canonical transcript
- # unavailable. Writes remain strict and CAS will not overwrite the
- # malformed field because its revision cannot match.
- logger.warning(
- "Ignoring malformed conversation situation for session_id=%s: %s",
- session_id,
- exc,
- )
- conversation_situation = None
history = (
_normalise_chat_history_entries(doc.get("history", []))
if include_history
else []
)
- observations = _conversation_observations_from_doc(doc)
- raw_observations = doc.get("conversation_observations")
- raw_retained_count = (
- len(raw_observations) if isinstance(raw_observations, list) else 0
- )
- raw_observation_total = doc.get("conversation_observation_total")
- observation_total = (
- raw_observation_total
- if isinstance(raw_observation_total, int)
- and not isinstance(raw_observation_total, bool)
- and raw_observation_total >= 0
- else raw_retained_count
- )
- observation_total = max(
- observation_total,
- raw_retained_count,
- len(observations),
- )
state = {
"session_id": session_id,
"history": history,
- "conversation_situation": conversation_situation,
- "conversation_observations": observations,
- "conversation_observation_state": {
- "schema_version": "conversation_observation_state.v1",
- "retained_count": len(observations),
- "total_count": observation_total,
- "omitted_count": max(0, observation_total - len(observations)),
- "retention_limit": CONVERSATION_OBSERVATION_MAX_ITEMS,
- },
+ **_conversation_state_from_doc(doc),
}
if (
include_history
@@ -1989,6 +1951,50 @@ def _conversation_observations_from_doc(
return observations
+def _conversation_state_from_doc(doc: Dict[str, Any]) -> Dict[str, Any]:
+ """Return bounded conversation state without making an optional carrier brittle."""
+
+ try:
+ conversation_situation = _conversation_situation_from_doc(doc)
+ except ChatHistoryServiceError as exc:
+ logger.warning(
+ "Ignoring malformed conversation situation for session_id=%s: %s",
+ doc.get("session_id"),
+ exc,
+ )
+ conversation_situation = None
+
+ observations = _conversation_observations_from_doc(doc)
+ raw_observations = doc.get("conversation_observations")
+ raw_retained_count = (
+ len(raw_observations) if isinstance(raw_observations, list) else 0
+ )
+ raw_observation_total = doc.get("conversation_observation_total")
+ observation_total = (
+ raw_observation_total
+ if isinstance(raw_observation_total, int)
+ and not isinstance(raw_observation_total, bool)
+ and raw_observation_total >= 0
+ else raw_retained_count
+ )
+ observation_total = max(
+ observation_total,
+ raw_retained_count,
+ len(observations),
+ )
+ return {
+ "conversation_situation": conversation_situation,
+ "conversation_observations": observations,
+ "conversation_observation_state": {
+ "schema_version": "conversation_observation_state.v1",
+ "retained_count": len(observations),
+ "total_count": observation_total,
+ "omitted_count": max(0, observation_total - len(observations)),
+ "retention_limit": CONVERSATION_OBSERVATION_MAX_ITEMS,
+ },
+ }
+
+
def append_chat_history_conversation_observation(
*,
user_id: str,
@@ -2113,11 +2119,12 @@ def append_chat_history_conversation_observation(
include_legacy=include_legacy,
include_history=False,
)
- observations = (
+ raw_observations = (
current_state.get("conversation_observations")
if isinstance(current_state, dict)
else []
)
+ observations = raw_observations if isinstance(raw_observations, list) else []
duplicate = any(
isinstance(item, dict) and item.get("observation_id") == observation_id
for item in observations
@@ -2301,6 +2308,84 @@ def _hydrate_chat_history_entries(history: Any) -> List[Dict[str, Any]]:
return _normalise_chat_history_entries(history, hydrate_blob_refs=True)
+def _conversation_locator_state_from_doc(doc: Dict[str, Any]) -> Dict[str, Any]:
+ """Return compact carrier availability/freshness without exposing its content."""
+
+ raw_situation_state = doc.get("conversation_situation_state")
+ raw_situation = doc.get("conversation_situation")
+ if isinstance(raw_situation_state, dict):
+ situation_available = raw_situation_state.get("available") is True
+ raw_revision = raw_situation_state.get("revision")
+ raw_updated_at = raw_situation_state.get("updated_at")
+ else:
+ situation_available = bool(
+ isinstance(raw_situation, dict)
+ and isinstance(raw_situation.get("text"), str)
+ and raw_situation.get("text", "").strip()
+ )
+ raw_revision = (
+ raw_situation.get("revision") if isinstance(raw_situation, dict) else None
+ )
+ raw_updated_at = (
+ raw_situation.get("updated_at") if isinstance(raw_situation, dict) else None
+ )
+ situation_revision = (
+ raw_revision
+ if isinstance(raw_revision, int)
+ and not isinstance(raw_revision, bool)
+ and raw_revision >= 0
+ else None
+ )
+ situation_updated_at = _coerce_datetime(raw_updated_at)
+
+ raw_observation_state = doc.get("conversation_observation_state")
+ raw_observations = doc.get("conversation_observations")
+ fallback_retained_count = (
+ len(raw_observations) if isinstance(raw_observations, list) else 0
+ )
+ retained_candidate = (
+ raw_observation_state.get("retained_count")
+ if isinstance(raw_observation_state, dict)
+ else fallback_retained_count
+ )
+ retained_count = (
+ retained_candidate
+ if isinstance(retained_candidate, int)
+ and not isinstance(retained_candidate, bool)
+ and retained_candidate >= 0
+ else fallback_retained_count
+ )
+ total_candidate = (
+ raw_observation_state.get("total_count")
+ if isinstance(raw_observation_state, dict)
+ else doc.get("conversation_observation_total")
+ )
+ total_count = (
+ total_candidate
+ if isinstance(total_candidate, int)
+ and not isinstance(total_candidate, bool)
+ and total_candidate >= 0
+ else retained_count
+ )
+ total_count = max(total_count, retained_count)
+ return {
+ "conversation_situation_state": {
+ "available": situation_available,
+ "revision": situation_revision,
+ "updated_at": (
+ situation_updated_at.isoformat() if situation_updated_at else None
+ ),
+ },
+ "conversation_observation_state": {
+ "schema_version": "conversation_observation_state.v1",
+ "retained_count": retained_count,
+ "total_count": total_count,
+ "omitted_count": max(0, total_count - retained_count),
+ "retention_limit": CONVERSATION_OBSERVATION_MAX_ITEMS,
+ },
+ }
+
+
def get_chat_history_telemetry_locator_projection(
user_id: str,
session_id: str,
@@ -2357,6 +2442,39 @@ def get_chat_history_telemetry_locator_projection(
"created_at": 1,
"updated_at": 1,
"history": compact_history_projection,
+ "conversation_situation_state": {
+ "available": {
+ "$cond": [
+ {
+ "$eq": [
+ {"$type": "$conversation_situation.text"},
+ "string",
+ ]
+ },
+ {
+ "$gt": [
+ {"$strLenCP": "$conversation_situation.text"},
+ 0,
+ ]
+ },
+ False,
+ ]
+ },
+ "revision": "$conversation_situation.revision",
+ "updated_at": "$conversation_situation.updated_at",
+ },
+ "conversation_observation_state": {
+ "retained_count": {
+ "$size": {
+ "$cond": [
+ {"$isArray": "$conversation_observations"},
+ "$conversation_observations",
+ [],
+ ]
+ }
+ },
+ "total_count": "$conversation_observation_total",
+ },
}
try:
@@ -2386,15 +2504,25 @@ def get_chat_history_telemetry_locator_projection(
)
else:
# Test doubles and older collection adapters may not expose aggregate.
+ fallback_projection = {
+ key: value
+ for key, value in metadata_projection.items()
+ if key
+ not in {
+ "history",
+ "conversation_situation_state",
+ "conversation_observation_state",
+ }
+ } | {
+ "history": 1,
+ "conversation_situation": 1,
+ "conversation_observations": 1,
+ "conversation_observation_total": 1,
+ }
doc = _read_find_one(
chat_history_coll,
query,
- {
- key: value
- for key, value in metadata_projection.items()
- if key != "history"
- }
- | {"history": 1},
+ fallback_projection,
operation=(
"get_chat_history_telemetry_locator_projection.find_fallback"
),
@@ -2402,7 +2530,14 @@ def get_chat_history_telemetry_locator_projection(
if doc is not None:
break
_record_chat_history_read_success()
- return dict(doc) if isinstance(doc, dict) else None
+ if not isinstance(doc, dict):
+ return None
+ payload = dict(doc)
+ payload.update(_conversation_locator_state_from_doc(payload))
+ payload.pop("conversation_situation", None)
+ payload.pop("conversation_observations", None)
+ payload.pop("conversation_observation_total", None)
+ return payload
except PyMongoError as exc:
_record_chat_history_read_failure(
"get_chat_history_telemetry_locator_projection",
@@ -2430,12 +2565,14 @@ def get_chat_history_segments(
history_tail_limit: Optional[int] = None,
return_meta: bool = False,
hydrate_blob_refs: bool = False,
+ include_conversation_state: bool = False,
) -> List[List[Dict[str, Any]]] | tuple[List[List[Dict[str, Any]]], Dict[str, Any]]:
"""
Return chat history split into segments separated by reset markers.
Retrieves history from the requested session only.
- When return_meta is True, returns (segments, {"history_truncated": bool}).
+ When return_meta is True, returns ``(segments, metadata)``. Callers may opt
+ into bounded conversation state in that metadata without another DB read.
"""
if not user_id:
raise ChatHistoryServiceError("user_id is required.")
@@ -2452,7 +2589,16 @@ def get_chat_history_segments(
history_offset = 0
history_length = None
doc = None
- projection = None
+ projection = (
+ {
+ "history": 1,
+ "conversation_situation": 1,
+ "conversation_observations": 1,
+ "conversation_observation_total": 1,
+ }
+ if include_conversation_state
+ else None
+ )
read_queries = _build_chat_history_session_read_queries(
user_id=user_id,
session_id=session_id,
@@ -2464,17 +2610,24 @@ def get_chat_history_segments(
if hasattr(chat_history_coll, "aggregate") and callable(
getattr(chat_history_coll, "aggregate")
):
+ tail_projection = {
+ "history": _history_tail_projection_expr(
+ history_tail_limit=history_tail_limit,
+ include_debug=include_debug,
+ ),
+ "history_length": {"$size": _history_array_expr()},
+ }
+ if include_conversation_state:
+ tail_projection.update(
+ {
+ "conversation_situation": 1,
+ "conversation_observations": 1,
+ "conversation_observation_total": 1,
+ }
+ )
pipeline = [
{"$match": query},
- {
- "$project": {
- "history": _history_tail_projection_expr(
- history_tail_limit=history_tail_limit,
- include_debug=include_debug,
- ),
- "history_length": {"$size": _history_array_expr()},
- }
- },
+ {"$project": tail_projection},
]
try:
doc = next(
@@ -2513,7 +2666,10 @@ def get_chat_history_segments(
break
if not doc:
_record_chat_history_read_success()
- return ([], {"history_truncated": False}) if return_meta else []
+ empty_meta = {"history_truncated": False}
+ if include_conversation_state:
+ empty_meta.update(_conversation_state_from_doc({}))
+ return ([], empty_meta) if return_meta else []
history = _normalise_chat_history_entries(
doc.get("history") or [],
@@ -2521,7 +2677,10 @@ def get_chat_history_segments(
)
if not isinstance(history, list) or not history:
_record_chat_history_read_success()
- return ([], {"history_truncated": False}) if return_meta else []
+ empty_history_meta = {"history_truncated": False}
+ if include_conversation_state:
+ empty_history_meta.update(_conversation_state_from_doc(doc))
+ return ([], empty_history_meta) if return_meta else []
if history_length is None:
history_length_raw = doc.get("history_length")
if isinstance(history_length_raw, int):
@@ -2565,7 +2724,10 @@ def get_chat_history_segments(
result = _chunk_history_segments(segments, segment_size)
_record_chat_history_read_success()
if return_meta:
- return result, {"history_truncated": history_truncated}
+ result_meta = {"history_truncated": history_truncated}
+ if include_conversation_state:
+ result_meta.update(_conversation_state_from_doc(doc))
+ return result, result_meta
return result
except PyMongoError as e:
_record_chat_history_read_failure("get_chat_history_segments", e)
diff --git a/src/backend/services/conversation_telemetry_locator_service.py b/src/backend/services/conversation_telemetry_locator_service.py
index 3944f1c43..d7759c28f 100644
--- a/src/backend/services/conversation_telemetry_locator_service.py
+++ b/src/backend/services/conversation_telemetry_locator_service.py
@@ -425,6 +425,30 @@ def _build_bound_conversation_args(*, include_debug: bool | None = None) -> dict
user_id=user_id_value,
organisation_concept_id=resolved_org_id,
)
+ raw_situation_state = session_projection.get("conversation_situation_state")
+ conversation_situation_state = (
+ dict(raw_situation_state)
+ if isinstance(raw_situation_state, Mapping)
+ else {
+ "available": False,
+ "revision": None,
+ "updated_at": None,
+ }
+ )
+ raw_observation_state = session_projection.get("conversation_observation_state")
+ conversation_observation_state = (
+ dict(raw_observation_state)
+ if isinstance(raw_observation_state, Mapping)
+ else {
+ "schema_version": "conversation_observation_state.v1",
+ "retained_count": 0,
+ "total_count": 0,
+ "omitted_count": 0,
+ "retention_limit": (
+ chat_history_service.CONVERSATION_OBSERVATION_MAX_ITEMS
+ ),
+ }
+ )
return {
"schema_version": CONVERSATION_LLM_TELEMETRY_LOCATOR_SCHEMA_VERSION,
@@ -432,6 +456,8 @@ def _build_bound_conversation_args(*, include_debug: bool | None = None) -> dict
"session_id": session_id_value,
"session_name": _safe_str(session_projection.get("session_name")),
"namespace_context": namespace_context,
+ "conversation_situation_state": conversation_situation_state,
+ "conversation_observation_state": conversation_observation_state,
"metadata": metadata,
"mcp_access": {
"conversation_telemetry_get_locator": _build_tool_call_descriptor(
@@ -449,8 +475,9 @@ def _build_bound_conversation_args(*, include_debug: bool | None = None) -> dict
"history_tail_limit": 100,
},
purpose=(
- "Fetch a bounded transcript projection; use each turn's exact "
- "debug-entry descriptor for persisted debug payloads."
+ "Fetch the bounded conversation carrier: transcript segments, "
+ "inspectable situation text, and exact observations. Use each "
+ "turn's debug-entry descriptor for persisted debug payloads."
),
),
},
diff --git a/src/frontend/web/von_interface/static/js/chatTab.js b/src/frontend/web/von_interface/static/js/chatTab.js
index 7ccb0c25f..9a28965d3 100644
--- a/src/frontend/web/von_interface/static/js/chatTab.js
+++ b/src/frontend/web/von_interface/static/js/chatTab.js
@@ -111,6 +111,7 @@ const CONVERSATION_INFO_EXPORT_SCHEMA_VERSION = 'conversation_info_export.v1';
const CONVERSATION_TELEMETRY_ACCESS_SCHEMA_VERSION = 'conversation_telemetry_access.v1';
const CONVERSATION_LLM_TELEMETRY_SCHEMA_VERSION = 'conversation_llm_telemetry.v1';
const CONVERSATION_LLM_TELEMETRY_LOCATOR_SCHEMA_VERSION = 'conversation_llm_telemetry_locator.v1';
+const CONVERSATION_SITUATION_EXPORT_SCHEMA_VERSION = 'conversation_situation_export.v1';
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';
@@ -148,6 +149,8 @@ const historyMetricsState = {
lastHealthy: null
};
const sessionHistoryCache = new Map();
+const conversationSituationStateBySession = new Map();
+let conversationSituationRefreshRequestCounter = 0;
let loadingChatSessionId = null;
const SESSION_TABS_REFRESH_COOLDOWN_MS = 15_000;
let lastSessionTabsRefreshMs = 0;
@@ -3990,6 +3993,682 @@ function createClientRequestId() {
return `req-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
+function _normaliseConversationSituationInteger(value, fallback = 0) {
+ return Number.isInteger(value) && value >= 0 ? value : fallback;
+}
+
+function _normaliseConversationSituationDescriptor(value) {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ return null;
+ }
+ const descriptor = {
+ text: (typeof value.text === 'string' && value.text.trim()) ? value.text : null,
+ revision: _normaliseConversationSituationInteger(value.revision),
+ source: (typeof value.source === 'string' && value.source.trim()) ? value.source.trim() : null,
+ updated_by: (typeof value.updated_by === 'string' && value.updated_by.trim())
+ ? value.updated_by.trim()
+ : null,
+ updated_at: (typeof value.updated_at === 'string' && value.updated_at.trim())
+ ? value.updated_at.trim()
+ : null
+ };
+ if (typeof value.source_request_id === 'string' && value.source_request_id.trim()) {
+ descriptor.source_request_id = value.source_request_id.trim();
+ }
+ return descriptor;
+}
+
+function _normaliseConversationObservation(value) {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ return null;
+ }
+ const observation = {};
+ Object.entries(value).forEach(([key, rawValue]) => {
+ if (
+ typeof rawValue === 'string'
+ || typeof rawValue === 'boolean'
+ || (typeof rawValue === 'number' && Number.isFinite(rawValue))
+ || rawValue === null
+ ) {
+ observation[key] = rawValue;
+ }
+ });
+ const observationId = typeof observation.observation_id === 'string'
+ ? observation.observation_id.trim()
+ : '';
+ const kind = typeof observation.kind === 'string' ? observation.kind.trim() : '';
+ if (!observationId || !kind) {
+ return null;
+ }
+ observation.observation_id = observationId;
+ observation.kind = kind;
+ return observation;
+}
+
+function _normaliseConversationObservationState(value, retainedCount) {
+ const fallbackRetained = _normaliseConversationSituationInteger(retainedCount);
+ const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
+ const retained = _normaliseConversationSituationInteger(
+ source.retained_count,
+ fallbackRetained
+ );
+ const total = Math.max(
+ retained,
+ _normaliseConversationSituationInteger(source.total_count, retained)
+ );
+ return {
+ schema_version: (
+ typeof source.schema_version === 'string'
+ && source.schema_version.trim()
+ )
+ ? source.schema_version.trim()
+ : 'conversation_observation_state.v1',
+ retained_count: retained,
+ total_count: total,
+ omitted_count: Math.max(
+ 0,
+ _normaliseConversationSituationInteger(
+ source.omitted_count,
+ total - retained
+ )
+ ),
+ retention_limit: _normaliseConversationSituationInteger(
+ source.retention_limit,
+ Math.max(retained, 12)
+ )
+ };
+}
+
+function _conversationSituationPayloadHasCarrierState(payload) {
+ if (!payload || typeof payload !== 'object') {
+ return false;
+ }
+ return Object.prototype.hasOwnProperty.call(payload, 'conversation_situation')
+ || Object.prototype.hasOwnProperty.call(payload, 'conversation_observations')
+ || Object.prototype.hasOwnProperty.call(payload, 'conversation_observation_state');
+}
+
+function _conversationSituationPayloadHasCompleteCarrierState(payload) {
+ return _conversationSituationPayloadHasCarrierState(payload)
+ && Object.prototype.hasOwnProperty.call(payload, 'conversation_situation')
+ && Object.prototype.hasOwnProperty.call(payload, 'conversation_observations')
+ && Object.prototype.hasOwnProperty.call(payload, 'conversation_observation_state');
+}
+
+function _acceptConversationSituationPayload(payload, sessionId, options = {}) {
+ const sid = normaliseHistorySessionId(sessionId);
+ if (!sid || !_conversationSituationPayloadHasCarrierState(payload)) {
+ return null;
+ }
+ const authoritativeSnapshot = options.authoritativeSnapshot === true;
+
+ const incomingSituation = _normaliseConversationSituationDescriptor(
+ payload.conversation_situation
+ );
+ const incomingObservations = Array.isArray(payload.conversation_observations)
+ ? payload.conversation_observations
+ .map(_normaliseConversationObservation)
+ .filter(Boolean)
+ : [];
+ const incomingObservationState = _normaliseConversationObservationState(
+ payload.conversation_observation_state,
+ incomingObservations.length
+ );
+ const existing = conversationSituationStateBySession.get(sid);
+ const existingRevision = _normaliseConversationSituationInteger(
+ existing?.situation?.revision
+ );
+ const incomingRevision = _normaliseConversationSituationInteger(
+ incomingSituation?.revision
+ );
+ const existingObservationTotal = _normaliseConversationSituationInteger(
+ existing?.observationState?.total_count
+ );
+ const canonicalSituationRevision = Number.isInteger(
+ existing?.canonicalSituationRevision
+ )
+ ? existing.canonicalSituationRevision
+ : null;
+ const incomingPredatesCanonicalSituation = (
+ !authoritativeSnapshot
+ && canonicalSituationRevision !== null
+ && incomingRevision < canonicalSituationRevision
+ );
+ const useIncomingSituation = (
+ authoritativeSnapshot
+ || !existing
+ || incomingRevision >= existingRevision
+ );
+ const useIncomingObservations = (
+ !incomingPredatesCanonicalSituation
+ && (
+ authoritativeSnapshot
+ || !existing
+ || incomingObservationState.total_count >= existingObservationTotal
+ )
+ );
+
+ const accepted = {
+ sessionId: sid,
+ // Asynchronous generate responses merge their monotonic dimensions
+ // independently so a slow response cannot regress either half of the
+ // carrier. Guarded canonical history reads replace the whole snapshot,
+ // including deliberate resets to a lower revision or total.
+ situation: useIncomingSituation
+ ? incomingSituation
+ : existing.situation,
+ observations: useIncomingObservations
+ ? incomingObservations
+ : existing.observations,
+ observationState: useIncomingObservations
+ ? incomingObservationState
+ : existing.observationState,
+ canonicalSituationRevision: authoritativeSnapshot
+ ? incomingRevision
+ : canonicalSituationRevision,
+ availability: 'ready',
+ notice: typeof options.notice === 'string' ? options.notice : '',
+ loadedAtMs: Date.now()
+ };
+ conversationSituationStateBySession.set(sid, accepted);
+ if (sid === activeChatSessionId) {
+ _renderConversationSituationForActiveSession();
+ }
+ return accepted;
+}
+
+function _markConversationSituationLoading(sessionId, message = 'Loading the shared situation…') {
+ const sid = normaliseHistorySessionId(sessionId);
+ if (!sid) {
+ return null;
+ }
+ const existing = conversationSituationStateBySession.get(sid);
+ const nextState = existing
+ ? {
+ ...existing,
+ availability: 'refreshing',
+ notice: message
+ }
+ : {
+ sessionId: sid,
+ situation: null,
+ observations: [],
+ observationState: _normaliseConversationObservationState(null, 0),
+ availability: 'loading',
+ notice: message,
+ loadedAtMs: null
+ };
+ conversationSituationStateBySession.set(sid, nextState);
+ if (sid === activeChatSessionId) {
+ _renderConversationSituationForActiveSession();
+ }
+ return nextState;
+}
+
+function _markConversationSituationUnavailable(sessionId, message) {
+ const sid = normaliseHistorySessionId(sessionId);
+ if (!sid) {
+ return null;
+ }
+ const existing = conversationSituationStateBySession.get(sid);
+ const hasRetainedState = Boolean(
+ existing?.situation
+ || (Array.isArray(existing?.observations) && existing.observations.length > 0)
+ );
+ const nextState = {
+ sessionId: sid,
+ situation: existing?.situation || null,
+ observations: Array.isArray(existing?.observations) ? existing.observations : [],
+ observationState: existing?.observationState
+ || _normaliseConversationObservationState(null, 0),
+ canonicalSituationRevision: Number.isInteger(
+ existing?.canonicalSituationRevision
+ )
+ ? existing.canonicalSituationRevision
+ : null,
+ availability: hasRetainedState ? 'stale' : 'unavailable',
+ notice: message || 'The shared situation is temporarily unavailable.',
+ loadedAtMs: existing?.loadedAtMs || null
+ };
+ conversationSituationStateBySession.set(sid, nextState);
+ if (sid === activeChatSessionId) {
+ _renderConversationSituationForActiveSession();
+ }
+ return nextState;
+}
+
+function _setConversationSituationPanelOpen(isOpen) {
+ const panel = document.getElementById('conversationSituationPanel');
+ const toggle = document.getElementById('conversationSituationToggleBtn');
+ const open = Boolean(isOpen);
+ if (panel) {
+ panel.classList.toggle('hidden', !open);
+ }
+ if (toggle) {
+ toggle.setAttribute('aria-expanded', String(open));
+ }
+}
+
+function _humaniseConversationSituationField(value) {
+ const cleaned = String(value || '').trim().replace(/[_-]+/g, ' ');
+ if (!cleaned) {
+ return 'Observation';
+ }
+ return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
+}
+
+function _formatConversationSituationValue(key, value) {
+ if (typeof value === 'boolean') {
+ return value ? 'Yes' : 'No';
+ }
+ if (value === null || value === undefined || value === '') {
+ return '—';
+ }
+ if (
+ typeof value === 'string'
+ && (
+ key.endsWith('_at')
+ || key.endsWith('_at_utc')
+ || key === 'updated_at'
+ || key === 'completed_at'
+ )
+ ) {
+ return formatAbsoluteTimestamp(value) || value;
+ }
+ return String(value);
+}
+
+function _appendConversationSituationDefinitionRow(list, label, value, key = '') {
+ if (!list || value === null || value === undefined || value === '') {
+ return;
+ }
+ const term = document.createElement('dt');
+ term.textContent = label;
+ const description = document.createElement('dd');
+ description.textContent = _formatConversationSituationValue(key, value);
+ if (typeof value === 'string' && description.textContent !== value) {
+ description.title = value;
+ }
+ list.appendChild(term);
+ list.appendChild(description);
+}
+
+function _renderConversationObservation(observation) {
+ const card = document.createElement('article');
+ card.className = 'chat-situation-observation';
+
+ const title = document.createElement('h4');
+ title.className = 'chat-situation-observation-title';
+ title.textContent = _humaniseConversationSituationField(observation.kind);
+ card.appendChild(title);
+
+ const fields = document.createElement('dl');
+ fields.className = 'chat-situation-observation-fields';
+ const preferredKeys = [
+ 'observation_id',
+ 'observed_at_utc',
+ 'terminal_status',
+ 'effect_status',
+ 'outcome_finality',
+ 'final_state',
+ 'capability_name',
+ 'workflow_id',
+ 'execution_id',
+ 'instance_id',
+ 'request_id',
+ 'effect_id',
+ 'call_id',
+ 'completed_at',
+ 'execution_trace_id',
+ 'changed',
+ 'failure_detail_available'
+ ];
+ const excludedKeys = new Set(['schema_version', 'kind']);
+ const orderedKeys = [
+ ...preferredKeys.filter(key => Object.prototype.hasOwnProperty.call(observation, key)),
+ ...Object.keys(observation)
+ .filter(key => !preferredKeys.includes(key) && !excludedKeys.has(key))
+ .sort()
+ ];
+ orderedKeys.forEach((key) => {
+ _appendConversationSituationDefinitionRow(
+ fields,
+ _humaniseConversationSituationField(key),
+ observation[key],
+ key
+ );
+ });
+ card.appendChild(fields);
+ return card;
+}
+
+function _renderConversationSituationForActiveSession() {
+ const body = document.getElementById('conversationSituationBody');
+ const status = document.getElementById('conversationSituationStatus');
+ const badge = document.getElementById('conversationSituationBadge');
+ const refreshButton = document.getElementById('conversationSituationRefreshBtn');
+ const copyButton = document.getElementById('conversationSituationCopyBtn');
+ if (!body) {
+ return;
+ }
+ body.textContent = '';
+
+ const sid = normaliseHistorySessionId(activeChatSessionId);
+ const state = sid ? conversationSituationStateBySession.get(sid) : null;
+ const revision = _normaliseConversationSituationInteger(state?.situation?.revision);
+ const retainedCount = _normaliseConversationSituationInteger(
+ state?.observationState?.retained_count,
+ state?.observations?.length || 0
+ );
+ const hasCarrierContent = Boolean(state?.situation || retainedCount > 0);
+ const isBusy = Boolean(
+ sid
+ && (!state || ['loading', 'refreshing'].includes(state.availability))
+ );
+
+ body.setAttribute('aria-busy', String(isBusy));
+ if (status) {
+ if (!sid) {
+ status.textContent = '';
+ } else if (!state || state.availability === 'loading') {
+ status.textContent = state?.notice || 'Loading the shared situation…';
+ } else if (state.availability === 'refreshing') {
+ status.textContent = state.notice || 'Refreshing the shared situation…';
+ } else if (
+ state.availability === 'stale'
+ || state.availability === 'unavailable'
+ ) {
+ status.textContent = (
+ state.notice
+ || 'The shared situation is temporarily unavailable.'
+ );
+ } else {
+ status.textContent = state.notice || '';
+ }
+ }
+
+ if (badge) {
+ if (hasCarrierContent) {
+ badge.textContent = revision > 0 ? `r${revision}` : String(retainedCount);
+ badge.classList.remove('hidden');
+ badge.title = revision > 0
+ ? `Conversation situation revision ${revision}`
+ : `${retainedCount} retained observations`;
+ } else {
+ badge.textContent = '';
+ badge.classList.add('hidden');
+ badge.removeAttribute('title');
+ }
+ }
+ if (refreshButton) {
+ refreshButton.disabled = !sid || ['loading', 'refreshing'].includes(state?.availability);
+ }
+ if (copyButton) {
+ copyButton.disabled = !sid || !state || state.availability === 'loading';
+ }
+
+ if (!sid) {
+ const empty = document.createElement('p');
+ empty.className = 'chat-situation-empty';
+ empty.textContent = 'Select or start a conversation to inspect its shared situation.';
+ body.appendChild(empty);
+ return;
+ }
+
+ if (!state || state.availability === 'loading') {
+ const loading = document.createElement('p');
+ loading.className = 'chat-situation-notice';
+ loading.textContent = state?.notice || 'Loading the shared situation…';
+ body.appendChild(loading);
+ return;
+ }
+
+ if (state.availability === 'refreshing') {
+ const refreshing = document.createElement('p');
+ refreshing.className = 'chat-situation-notice';
+ refreshing.textContent = state.notice || 'Refreshing the shared situation…';
+ body.appendChild(refreshing);
+ } else if (state.availability === 'stale' || state.availability === 'unavailable') {
+ const warning = document.createElement('p');
+ warning.className = 'chat-situation-notice is-warning';
+ warning.textContent = state.notice || 'The shared situation is temporarily unavailable.';
+ body.appendChild(warning);
+ }
+
+ const situation = state.situation;
+ if (situation?.text) {
+ const textSection = document.createElement('section');
+ const label = document.createElement('h4');
+ label.className = 'chat-situation-section-label';
+ label.textContent = 'Current description';
+ const text = document.createElement('pre');
+ text.className = 'chat-situation-text';
+ text.textContent = situation.text;
+ textSection.appendChild(label);
+ textSection.appendChild(text);
+ body.appendChild(textSection);
+ } else {
+ const empty = document.createElement('p');
+ empty.className = 'chat-situation-empty';
+ empty.textContent = revision > 0
+ ? 'This conversation currently has no situation description; the prior description was cleared.'
+ : 'No shared situation description has been recorded for this conversation yet.';
+ body.appendChild(empty);
+ }
+
+ if (situation) {
+ const metadataSection = document.createElement('section');
+ const label = document.createElement('h4');
+ label.className = 'chat-situation-section-label';
+ label.textContent = 'Revision and provenance';
+ const metadata = document.createElement('dl');
+ metadata.className = 'chat-situation-metadata';
+ _appendConversationSituationDefinitionRow(
+ metadata,
+ 'Revision',
+ situation.revision,
+ 'revision'
+ );
+ _appendConversationSituationDefinitionRow(metadata, 'Source', situation.source, 'source');
+ _appendConversationSituationDefinitionRow(
+ metadata,
+ 'Updated by',
+ situation.updated_by,
+ 'updated_by'
+ );
+ _appendConversationSituationDefinitionRow(
+ metadata,
+ 'Updated',
+ situation.updated_at,
+ 'updated_at'
+ );
+ _appendConversationSituationDefinitionRow(
+ metadata,
+ 'Source request',
+ situation.source_request_id,
+ 'source_request_id'
+ );
+ metadataSection.appendChild(label);
+ metadataSection.appendChild(metadata);
+ body.appendChild(metadataSection);
+ }
+
+ const observations = Array.isArray(state.observations) ? state.observations : [];
+ const observationState = state.observationState
+ || _normaliseConversationObservationState(null, observations.length);
+ const details = document.createElement('details');
+ details.className = 'chat-situation-observations';
+ const summary = document.createElement('summary');
+ summary.textContent = `Recent exact observations (${observationState.retained_count})`;
+ const summaryMeta = document.createElement('span');
+ summaryMeta.className = 'chat-situation-observation-summary-meta';
+ summaryMeta.textContent = `${observationState.total_count} total · limit ${observationState.retention_limit}`;
+ summary.appendChild(summaryMeta);
+ details.appendChild(summary);
+
+ const observationList = document.createElement('div');
+ observationList.className = 'chat-situation-observation-list';
+ if (observationState.omitted_count > 0) {
+ const omission = document.createElement('p');
+ omission.className = 'chat-situation-observation-omission';
+ omission.textContent = `${observationState.omitted_count} older observation${observationState.omitted_count === 1 ? ' was' : 's were'} omitted by retention.`;
+ observationList.appendChild(omission);
+ }
+ if (observations.length === 0) {
+ const empty = document.createElement('p');
+ empty.className = 'chat-situation-empty';
+ empty.textContent = 'No exact observations are currently retained.';
+ observationList.appendChild(empty);
+ } else {
+ observations.forEach(observation => {
+ observationList.appendChild(_renderConversationObservation(observation));
+ });
+ }
+ details.appendChild(observationList);
+ body.appendChild(details);
+}
+
+function _buildConversationSituationExportPayload(sessionId = activeChatSessionId) {
+ const sid = normaliseHistorySessionId(sessionId);
+ const state = sid ? conversationSituationStateBySession.get(sid) : null;
+ return {
+ schema_version: CONVERSATION_SITUATION_EXPORT_SCHEMA_VERSION,
+ generated_at_utc: new Date().toISOString(),
+ session_id: sid,
+ session_name: sid === activeChatSessionId ? activeChatSessionName : null,
+ availability: state?.availability || 'unavailable',
+ conversation_situation: state?.situation || null,
+ conversation_observations: state?.observations || [],
+ conversation_observation_state: state?.observationState || null
+ };
+}
+
+async function _copyActiveConversationSituation(button) {
+ if (!(button instanceof HTMLButtonElement)) {
+ return false;
+ }
+ const payload = _buildConversationSituationExportPayload();
+ return copyJsonTextWithButtonFeedback(
+ button,
+ JSON.stringify(payload, null, 2),
+ { fallbackLabel: 'Copy JSON' }
+ );
+}
+
+async function refreshConversationSituationForSession(sessionId = activeChatSessionId) {
+ const sid = normaliseHistorySessionId(sessionId);
+ if (!sid) {
+ _renderConversationSituationForActiveSession();
+ return false;
+ }
+
+ const requestId = ++conversationSituationRefreshRequestCounter;
+ _markConversationSituationLoading(sid, 'Refreshing the shared situation…');
+ try {
+ await _ensureInviteSessionContext();
+ const userContext = getUserContext();
+ const params = new URLSearchParams({
+ session_id: sid,
+ segments: '1',
+ segment_size: '1',
+ tail_limit: '1',
+ include_debug: '0'
+ });
+ if (userContext?.user_id) {
+ params.append('user_id', userContext.user_id);
+ }
+ const response = await fetch(`/von/history?${params.toString()}`, {
+ headers: buildChatFetchHeaders()
+ });
+ const data = await response.json().catch(() => ({}));
+ if (requestId !== conversationSituationRefreshRequestCounter) {
+ return false;
+ }
+ if (!response.ok || data?.degraded === true || data?.authenticated === false) {
+ const message = data?.authenticated === false
+ ? 'Sign in to inspect this conversation situation.'
+ : describeHistoryUnavailable(data);
+ _markConversationSituationUnavailable(sid, message);
+ return false;
+ }
+ if (!_conversationSituationPayloadHasCompleteCarrierState(data)) {
+ _markConversationSituationUnavailable(
+ sid,
+ 'The server did not return conversation situation state; try refreshing again.'
+ );
+ return false;
+ }
+ return Boolean(_acceptConversationSituationPayload(data, sid, {
+ authoritativeSnapshot: true,
+ notice: 'Conversation situation refreshed.'
+ }));
+ } catch (error) {
+ if (requestId !== conversationSituationRefreshRequestCounter) {
+ return false;
+ }
+ _markConversationSituationUnavailable(
+ sid,
+ 'The shared situation could not be refreshed; try again.'
+ );
+ console.warn('[chatTab] Unable to refresh conversation situation:', error);
+ return false;
+ }
+}
+
+function _clearConversationSituationState(options = {}) {
+ conversationSituationRefreshRequestCounter += 1;
+ conversationSituationStateBySession.clear();
+ if (options.closePanel === true) {
+ _setConversationSituationPanelOpen(false);
+ }
+ _renderConversationSituationForActiveSession();
+}
+
+function initializeConversationSituationPanel() {
+ const toggle = document.getElementById('conversationSituationToggleBtn');
+ const closeButton = document.getElementById('conversationSituationCloseBtn');
+ const refreshButton = document.getElementById('conversationSituationRefreshBtn');
+ const copyButton = document.getElementById('conversationSituationCopyBtn');
+ if (!toggle || toggle.dataset.situationPanelWired === 'true') {
+ _renderConversationSituationForActiveSession();
+ return;
+ }
+ toggle.dataset.situationPanelWired = 'true';
+ toggle.addEventListener('click', () => {
+ const willOpen = toggle.getAttribute('aria-expanded') !== 'true';
+ _setConversationSituationPanelOpen(willOpen);
+ _renderConversationSituationForActiveSession();
+ if (willOpen && activeChatSessionId) {
+ void refreshConversationSituationForSession(activeChatSessionId);
+ }
+ if (willOpen) {
+ document.getElementById('conversationSituationPanel')?.focus({
+ preventScroll: true
+ });
+ }
+ });
+ closeButton?.addEventListener('click', () => {
+ _setConversationSituationPanelOpen(false);
+ toggle.focus();
+ });
+ refreshButton?.addEventListener('click', () => {
+ void refreshConversationSituationForSession(activeChatSessionId);
+ });
+ copyButton?.addEventListener('click', () => {
+ void _copyActiveConversationSituation(copyButton);
+ });
+ document.addEventListener('keydown', (event) => {
+ if (
+ event.key === 'Escape'
+ && toggle.getAttribute('aria-expanded') === 'true'
+ ) {
+ _setConversationSituationPanelOpen(false);
+ toggle.focus();
+ }
+ });
+ _renderConversationSituationForActiveSession();
+}
+
function updateSessionHistoryCache(sessionId, history, meta = {}) {
const sid = String(sessionId || '').trim();
if (!sid || !Array.isArray(history)) {
@@ -21622,6 +22301,18 @@ function setActiveChatSession(sessionId, sessionName) {
if (previousSessionId !== activeChatSessionId) {
synchroniseLlmExecutionContext({ reason: 'conversation_session_changed' });
+ if (activeChatSessionId) {
+ if (conversationSituationStateBySession.has(activeChatSessionId)) {
+ _renderConversationSituationForActiveSession();
+ } else {
+ _markConversationSituationLoading(
+ activeChatSessionId,
+ 'Loading the selected conversation situation…'
+ );
+ }
+ } else {
+ _renderConversationSituationForActiveSession();
+ }
}
if (activeChatSessionId) {
@@ -24094,6 +24785,23 @@ async function createChatSession(sessionName) {
}
setActiveChatSession(data?.session_id, data?.session_name);
+ _acceptConversationSituationPayload(
+ _conversationSituationPayloadHasCarrierState(data)
+ ? data
+ : {
+ conversation_situation: null,
+ conversation_observations: [],
+ conversation_observation_state: {
+ schema_version: 'conversation_observation_state.v1',
+ retained_count: 0,
+ total_count: 0,
+ omitted_count: 0,
+ retention_limit: 12
+ }
+ },
+ data?.session_id,
+ { authoritativeSnapshot: true }
+ );
syncActiveChatSessionThinkingState();
const nowIso = new Date().toISOString();
@@ -24963,6 +25671,7 @@ async function loadChatHistory(options = {}) {
if (data?.degraded === true) {
const degradedMessage = describeHistoryUnavailable(data);
+ _markConversationSituationUnavailable(targetSessionId, degradedMessage);
setHistoryLoadState({
sessionId: targetSessionId,
message: degradedMessage,
@@ -24986,6 +25695,18 @@ async function loadChatHistory(options = {}) {
return false;
}
+ if (response.ok && _conversationSituationPayloadHasCompleteCarrierState(data)) {
+ _acceptConversationSituationPayload(data, targetSessionId, {
+ authoritativeSnapshot: true,
+ notice: 'Conversation situation loaded.'
+ });
+ } else if (response.ok) {
+ _markConversationSituationUnavailable(
+ targetSessionId,
+ 'The server did not return conversation situation state; try refreshing again.'
+ );
+ }
+
if (response.ok && Array.isArray(data?.history)) {
const historyMessages = filterRecentPair
? selectRecentChatPair(data.history)
@@ -25029,6 +25750,7 @@ async function loadChatHistory(options = {}) {
const fallbackMessage = (typeof data?.error === 'string' && data.error.trim())
? data.error.trim()
: 'Conversation history request failed; please retry.';
+ _markConversationSituationUnavailable(targetSessionId, fallbackMessage);
setHistoryLoadState({
sessionId: targetSessionId,
message: fallbackMessage,
@@ -25068,6 +25790,7 @@ async function loadChatHistory(options = {}) {
}
const targetSessionId = normaliseHistorySessionId(requestedSessionId || activeChatSessionId);
const fallbackMessage = 'Conversation history request failed; please retry.';
+ _markConversationSituationUnavailable(targetSessionId, fallbackMessage);
setHistoryLoadState({
sessionId: targetSessionId,
message: fallbackMessage,
@@ -28827,6 +29550,7 @@ async function handleOrgSwitchForChatTab(_detail) {
lastRenderedSessionCount = 0;
chatSessionMetadataOpenKey = null;
_clearChatSessionMetadata();
+ _clearConversationSituationState({ closePanel: true });
// JVNAUTOSCI-1011: Clear the active session and chat display on org switch
// to prevent showing data from the previous org
@@ -28903,6 +29627,7 @@ function handleAuthStatusChangeForChatTab(detail) {
lastRenderedSessionCount = 0;
chatSessionMetadataOpenKey = null;
_clearChatSessionMetadata();
+ _clearConversationSituationState({ closePanel: true });
if (container) {
renderChatSessionTabsPlaceholder(detail?.authenticated ? 'loading' : 'unauthenticated');
@@ -29180,6 +29905,7 @@ export function initializeChatTab() {
initializeLlmDebugPopup();
bindDiagnosticsExportShortcut();
initializeHistoryControls();
+ initializeConversationSituationPanel();
updateHistoryBanner();
setupChatTabContextMenu();
@@ -31259,6 +31985,7 @@ async function handleSendPrompt(options = {}) {
if (!claimForegroundDelivery()) {
return false;
}
+ _acceptConversationSituationPayload(data, request.sessionId);
const responsePresenterState = resolveResponsePresenterState(data);
const responseChannels = responsePresenterState.presenterChannels;
const screenText = responsePresenterState.screenText;
@@ -33879,7 +34606,11 @@ function buildConversationTelemetryAccessPayload({ locatorPayload = null } = {})
steps: retrievalStatus === 'server_delegation_available'
? [
'Call conversation_telemetry_get_locator first to fetch the authoritative per-turn locator for this session.',
- 'Call chat_history_get_segments for the bounded transcript projection.',
+ (
+ 'Call chat_history_get_segments for the bounded conversation-carrier '
+ + 'projection, including the shared situation, retained exact observations, '
+ + 'observation state, and transcript segments.'
+ ),
'Use each returned turn descriptor to fetch the exact bounded debug or diagnostics artefact.'
]
: [
@@ -34270,6 +35001,21 @@ export function __testOnly_buildWorkflowMonitorLocatorPayload() {
export async function __testOnly_loadChatHistory(options = {}) {
return loadChatHistory(options);
}
+export function __testOnly_initializeConversationSituationPanel() {
+ initializeConversationSituationPanel();
+}
+export function __testOnly_applyConversationSituationPayload(payload, sessionId, options = {}) {
+ return _acceptConversationSituationPayload(payload, sessionId, options);
+}
+export function __testOnly_buildConversationSituationExportPayload(sessionId = null) {
+ return _buildConversationSituationExportPayload(sessionId || activeChatSessionId);
+}
+export async function __testOnly_refreshConversationSituation(sessionId = null) {
+ return refreshConversationSituationForSession(sessionId || activeChatSessionId);
+}
+export function __testOnly_resetConversationSituationState() {
+ _clearConversationSituationState({ closePanel: true });
+}
export async function __testOnly_refreshChatSessionTabs() {
return refreshChatSessionTabs();
}
@@ -34351,6 +35097,7 @@ export function __testOnly_resetChatRequestState() {
activeChatSessionId = null;
activeChatSessionName = null;
activeChatSessionOwnerId = null;
+ _clearConversationSituationState({ closePanel: true });
renderChatTaskQueuePanel();
updateSendButtonForCurrentChatState();
}
diff --git a/src/frontend/web/von_interface/static/js/test/chatTab.test.js b/src/frontend/web/von_interface/static/js/test/chatTab.test.js
index 3200bc8f2..b531691de 100644
--- a/src/frontend/web/von_interface/static/js/test/chatTab.test.js
+++ b/src/frontend/web/von_interface/static/js/test/chatTab.test.js
@@ -6,6 +6,11 @@ import {
__testOnly_buildWorkflowMonitorExportPayload,
__testOnly_buildWorkflowMonitorLocatorPayload,
__testOnly_loadChatHistory,
+ __testOnly_initializeConversationSituationPanel,
+ __testOnly_applyConversationSituationPayload,
+ __testOnly_buildConversationSituationExportPayload,
+ __testOnly_refreshConversationSituation,
+ __testOnly_resetConversationSituationState,
__testOnly_refreshChatSessionTabs,
__testOnly_refreshAvailableWorkflowDefinitions,
__testOnly_refreshWorkflowCapabilityIndexStatus,
@@ -1715,6 +1720,426 @@ describe('workflow monitor active snapshot degradation handling', () => {
});
});
+describe('conversation situation affordance', () => {
+ const situationPayload = (overrides = {}) => ({
+ history: [],
+ segments_returned: 0,
+ total_segments: 0,
+ has_more_history: false,
+ conversation_situation: {
+ text: 'The user and Von are planning together.',
+ revision: 3,
+ source: 'adaptive_turn',
+ updated_by: '#V#user',
+ updated_at: '2026-07-29T09:00:00+00:00',
+ source_request_id: 'request-situation-3'
+ },
+ conversation_observations: [
+ {
+ schema_version: 'conversation_observation.v1',
+ observation_id: 'effect:paper-representation',
+ kind: 'durable_effect_terminal',
+ observed_at_utc: '2026-07-29T09:01:00Z',
+ capability_name: 'represent_paper',
+ terminal_status: 'completed',
+ changed: true
+ }
+ ],
+ conversation_observation_state: {
+ schema_version: 'conversation_observation_state.v1',
+ retained_count: 1,
+ total_count: 4,
+ omitted_count: 3,
+ retention_limit: 12
+ },
+ ...overrides
+ });
+
+ beforeEach(() => {
+ const { getUserContext, postJson } = require('../apiService.js');
+ getUserContext.mockReturnValue({
+ user_id: '#V#user',
+ org_id: '#V#org',
+ language: 'en-NZ',
+ gmail_profile: null
+ });
+ postJson.mockResolvedValue({ status: 'updated' });
+ document.body.innerHTML = `
+
+