fix(#6611): make regeneration start atomic (#6677)

* fix(#6611): make regeneration transaction canonical

* fix(#6611): close cross-provider regeneration blockers

* fix(#6611): harden regeneration transaction boundaries

* fix(#6611): bind canonical context and fork lineage

* fix(#6611): restore active turn token stamping

* test(#6611): make regeneration regressions CI portable

* test(#6611): preserve the tracked regeneration error fixture

* fix(#6611): preserve extracted session harness contracts

* fix(regeneration): preserve concurrent and imported turn ownership (#6611)

* fix(regeneration): honor imported turn ownership safely (#6611)

* test(regeneration): cover both session lock outcomes (#6611)

* test(regeneration): clear unused transaction fixtures (#6611)

* fix(#6611): consume public active-turn marker

* ci: re-trigger (Playwright install hung ~2.5h on GH runners, warm-cache retry)

---------

Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com>
Co-authored-by: n <a@n>
This commit is contained in:
authored and GitHub committed 2026-08-19 11:11:53 +00:00
1 parent 963799158a
commit 4d03578192
17 files changed
+2339 -99

No files matched your search

+18 -6
View File
@@ -909,6 +909,7 @@ def _run_gateway_chat_streaming(
*,
model_provider=None,
goal_related=False,
regeneration=False,
):
"""Bridge a WebUI chat turn through Hermes Gateway's API server.
@@ -1279,18 +1280,29 @@ def _run_gateway_chat_streaming(
# same sort key; later transcript merges can then fall back to
# role/content ordering instead of turn order.
assistant_ts = now + 0.000001
user_msg = {"role": "user", "content": str(msg_text or ""), "timestamp": now}
pending_source = getattr(s, "pending_user_source", None) or "webui"
if pending_source != "webui":
user_msg["_source"] = pending_source
if attachments:
user_msg["attachments"] = list(attachments)
from api.streaming import _active_turn_authority, _materialize_active_turn_user
active_turn_identity = _active_turn_authority(s, stream_id, msg_text)
user_msg = _materialize_active_turn_user(
active_turn_identity,
str(msg_text or ""),
pending_source,
)
user_msg["timestamp"] = float(
active_turn_identity.get("timestamp") or now
)
assistant_msg = {"role": "assistant", "content": assistant_text, "timestamp": assistant_ts}
saved_reasoning = STREAM_REASONING_TEXT.get(stream_id, "")
if saved_reasoning:
assistant_msg["reasoning"] = saved_reasoning
previous_messages = list(getattr(s, "messages", None) or [])
previous_context = list(getattr(s, "context_messages", None) or getattr(s, "messages", None) or [])
stored_context = getattr(s, "context_messages", None)
previous_context = list(
stored_context
if isinstance(stored_context, list) and (regeneration or stored_context)
else getattr(s, "messages", None) or []
)
previous_process_wakeup_pause = dict(getattr(s, "process_wakeup_pause", {}) or {})
# Stamp stable ids on the two new rows (shared with the display merge
# below) so display and model-context copies share an id for the
+28 -17
View File
@@ -19,6 +19,9 @@ _PUBLIC_MESSAGE_INTERNAL_FIELDS = frozenset({
"_state_db_row_id",
"_db_row_id",
"state_db_row_id",
"_active_turn_token",
"_active_turn_user",
"_fork_child_turn",
})
@@ -1114,8 +1117,14 @@ def scrub_internal_replay_fields(
return result
def _public_message_projection(message, *, _enabled: bool):
def _public_message_projection(message, *, _enabled: bool, _active_turn_token=None):
"""Return one public transcript message without internal replay fields."""
is_active = (
isinstance(message, dict)
and message.get("role") == "user"
and _active_turn_token is not None
and message.get("_active_turn_token") == _active_turn_token
)
message = scrub_internal_replay_fields([message], message_records=True)[0]
if not isinstance(message, dict):
return _redact_value(message, _enabled=_enabled)
@@ -1131,13 +1140,22 @@ def _public_message_projection(message, *, _enabled: bool):
]
else:
item[key] = _redact_value(value, _enabled=_enabled)
if is_active:
item["_active_turn_user"] = True
return item
def _redact_messages(messages, *, _enabled: bool):
def _redact_messages(messages, *, _enabled: bool, _active_turn_token=None):
if not isinstance(messages, list):
return _redact_value(messages, _enabled=_enabled)
return [_public_message_projection(message, _enabled=_enabled) for message in messages]
return [
_public_message_projection(
message,
_enabled=_enabled,
_active_turn_token=_active_turn_token,
)
for message in messages
]
def _redact_tool_calls(tool_calls, *, _enabled: bool):
@@ -1152,10 +1170,12 @@ def _redact_nested_message_containers(value, *, _enabled: bool):
return _redact_value(scrubbed, _enabled=_enabled)
result = {}
for key, child in scrubbed.items():
if key == "messages" and isinstance(child, list):
if key in {"messages", "context_messages"} and isinstance(child, list):
result[key] = _redact_messages(child, _enabled=_enabled)
elif key == "tool_calls" and isinstance(child, list):
result[key] = _redact_tool_calls(child, _enabled=_enabled)
elif key == "runtime_journal_snapshot" and isinstance(child, dict):
result[key] = _redact_nested_message_containers(child, _enabled=_enabled)
else:
result[key] = _redact_value(child, _enabled=_enabled)
return result
@@ -1193,30 +1213,21 @@ def _copy_json_value(value):
def redact_session_data(session_dict: dict) -> dict:
"""Redact credentials from message content, tool data, and session sidecars.
Applies to: messages[], tool_calls[], todo_state, runtime_journal_snapshot,
and title.
The underlying session file is not modified; redaction is response-layer only.
Reads the ``api_redact_enabled`` setting ONCE for the entire response and
threads it through to avoid hundreds of settings.json reads per session
payload (a 50-message session has hundreds of nested strings). When the
setting is disabled this is also a fast path: the recursion still walks
but every string returns early.
"""
"""Redact credentials in the public session response without mutation."""
from api.config import load_settings
_enabled = bool(load_settings().get("api_redact_enabled", True))
if not isinstance(session_dict, dict):
return {}
result = {}
from api.process_event_utils import build_active_turn_token
_active_turn_token = build_active_turn_token(session_dict.get("active_stream_id"), session_dict.get("pending_started_at"))
for key, value in session_dict.items():
if key in _PUBLIC_MESSAGE_INTERNAL_FIELDS:
continue
if key == 'title' and isinstance(value, str):
result[key] = _redact_text(value, _enabled=_enabled)
elif key in {'messages', 'context_messages'}:
result[key] = _redact_messages(value, _enabled=_enabled)
result[key] = _redact_messages(value, _enabled=_enabled, _active_turn_token=_active_turn_token)
elif key == 'tool_calls' and isinstance(value, list):
result[key] = _redact_tool_calls(value, _enabled=_enabled)
elif key in {'todo_state', 'runtime_journal_snapshot'}:
+398 -13
View File
@@ -2889,6 +2889,7 @@ from api.config import (
ACTIVE_RUNS_LOCK,
register_stream_owner,
register_session_writeback_owner,
clear_session_writeback_owner_if_owned,
stream_owner_session_id,
unregister_stream_owner,
CHAT_LOCK,
@@ -13208,6 +13209,26 @@ def handle_get(handler, parsed) -> bool:
):
raw["is_cli_session"] = False
raw["read_only"] = True
imported_turn_marker = any(
isinstance(row, dict) and row.get("_active_turn_token")
for row in _all_msgs
)
if (
not raw.get("read_only")
and not _truncated
and (not raw.get("is_cli_session") or imported_turn_marker)
):
from api.session_ops import regeneration_authority, regeneration_state
canonical_state = regeneration_state(s)
revision = regeneration_authority(
s,
rows=canonical_state[0],
context=canonical_state[1],
full_transcript=True,
canonical_state=canonical_state,
)
if revision:
raw["regeneration_revision"] = revision
redact = redact_session_data(raw)
_t5 = _time.monotonic()
if _diag: _diag.stage("t5_after_redact")
@@ -21763,6 +21784,8 @@ def _checkpoint_user_message_for_eager_session_save(s, msg: str, attachments, st
latest_text = " ".join(str(latest.get("content") or "").split())
msg_text = " ".join(str(msg or "").split())
if latest_text == msg_text:
if str(source or "").strip().lower() == "fork":
latest["_fork_child_turn"] = s.session_id
return
user_msg = {"role": "user", "content": msg}
from api.process_event_utils import build_active_turn_token, stamp_message_source
@@ -21772,6 +21795,8 @@ def _checkpoint_user_message_for_eager_session_save(s, msg: str, attachments, st
source,
active_turn_token=build_active_turn_token(getattr(s, "active_stream_id", None), started_at),
)
if str(source or "").strip().lower() == "fork":
user_msg["_fork_child_turn"] = s.session_id
if isinstance(started_at, (int, float)) and started_at > 0:
user_msg["timestamp"] = float(started_at)
if attachments:
@@ -21799,6 +21824,9 @@ def _provisional_title_from_prompt(prompt: str, fallback: str = "Untitled") -> s
return title_from([{"role": "user", "content": text}], fallback) or fallback
_RETAINED_CONTEXT_USER_UNSET = object()
def _prepare_chat_start_session_for_stream(
s,
*,
@@ -21810,6 +21838,9 @@ def _prepare_chat_start_session_for_stream(
stream_id: str,
started_at: float | None = None,
source: str = "webui",
retained_user=None,
retained_context_user=_RETAINED_CONTEXT_USER_UNSET,
defer_save: bool = False,
):
"""Persist chat-start state according to webui.session_save_mode.
@@ -21820,6 +21851,11 @@ def _prepare_chat_start_session_for_stream(
a normal session message. Empty sessions are never saved here because this
helper only runs after a non-empty message is validated.
"""
effective_source = (
"fork"
if str(getattr(s, "session_source", None) or "").strip().lower() == "fork"
else source
)
s.workspace = workspace
s.model = model
s.model_provider = model_provider
@@ -21829,21 +21865,62 @@ def _prepare_chat_start_session_for_stream(
s.pending_user_message = msg
s.pending_attachments = attachments
s.pending_started_at = started_at if started_at is not None else time.time()
s.pending_user_source = source
s.pending_user_source = effective_source
if retained_user is not None:
from api.process_event_utils import build_active_turn_token
retained_user["timestamp"] = s.pending_started_at
active_turn_token = build_active_turn_token(stream_id, s.pending_started_at)
retained_user["_active_turn_token"] = active_turn_token
if str(effective_source or "").strip().lower() == "fork":
retained_user["_fork_child_turn"] = s.session_id
if retained_context_user is not _RETAINED_CONTEXT_USER_UNSET:
if retained_context_user is not None and not any(
row is retained_context_user
for row in list(getattr(s, "context_messages", None) or [])
):
raise RuntimeError("regeneration retained context row is not installed")
if isinstance(retained_context_user, dict):
retained_context_user["timestamp"] = s.pending_started_at
retained_context_user["_active_turn_token"] = active_turn_token
if str(effective_source or "").strip().lower() == "fork":
retained_context_user["_fork_child_turn"] = s.session_id
else:
retained_id = retained_user.get("id") or retained_user.get("message_id")
retained_old_timestamp = retained_user.get("timestamp")
retained_old_content = retained_user.get("content")
for context_row in reversed(list(getattr(s, "context_messages", None) or [])):
if not isinstance(context_row, dict) or context_row.get("role") != "user":
continue
context_id = context_row.get("id") or context_row.get("message_id")
id_match = retained_id is not None and context_id == retained_id
old_shape_match = (
retained_old_timestamp is not None
and context_row.get("timestamp") == retained_old_timestamp
and context_row.get("content") == retained_old_content
)
if not (id_match or old_shape_match):
continue
context_row["timestamp"] = s.pending_started_at
context_row["_active_turn_token"] = active_turn_token
if str(effective_source or "").strip().lower() == "fork":
context_row["_fork_child_turn"] = s.session_id
break
current_title = getattr(s, "title", None)
if _is_default_or_empty_session_title(current_title):
if retained_user is None and _is_default_or_empty_session_title(current_title):
provisional_title = _provisional_title_from_prompt(msg, current_title or "Untitled")
if provisional_title and not _is_default_or_empty_session_title(provisional_title):
s.title = provisional_title
if get_webui_session_save_mode() == "eager":
if retained_user is None and get_webui_session_save_mode() == "eager":
_checkpoint_user_message_for_eager_session_save(
s,
msg,
attachments,
s.pending_started_at,
source=source,
source=effective_source,
)
s.save()
if not defer_save:
s.save()
def _is_hidden_empty_session(s) -> bool:
@@ -21891,6 +21968,247 @@ def _active_stream_blocks_chat_start(session, stream_id: str | None) -> bool:
return False
def _start_regeneration_stream_locked(
s,
*,
turn,
workspace: str,
model: str,
model_provider,
normalized_model: bool,
diag,
goal_related: bool,
source: str,
moa_config,
backend_is_gateway: bool,
):
"""Commit a retained-row regeneration before releasing its real worker."""
from api.session_ops import (
RegenerationUnavailable,
apply_regeneration_plan,
plan_regeneration,
restore_regeneration_state,
snapshot_regeneration_state,
)
try:
plan = plan_regeneration(
s, expected_revision=turn.revision, lock_held=True
)
turn = plan.turn
except RegenerationUnavailable as exc:
return {
"error": str(exc),
"code": exc.code,
"_status": exc.status,
}
# Snapshot only after lock-held authority validation, before mutation.
snapshot = snapshot_regeneration_state(s)
if compression_recovery_payload_for_session(s):
clear_compression_recovery(s)
stream_id = uuid.uuid4().hex
gateway_starting = False
thread_started = False
save_attempted = False
accepted = False
journal_event = {}
release_worker = threading.Event()
abort_worker = threading.Event()
worker_thread = None
worker_target = (
_run_gateway_chat_streaming if backend_is_gateway else _run_agent_streaming
)
worker_kwargs = {
"model_provider": model_provider,
"goal_related": goal_related,
}
if backend_is_gateway:
worker_kwargs["regeneration"] = True
if moa_config and not backend_is_gateway:
worker_kwargs["moa_config"] = moa_config
def _gated_worker():
release_worker.wait()
if abort_worker.is_set():
return
worker_target(
s.session_id,
turn.message_text,
model,
workspace,
stream_id,
copy.deepcopy(turn.attachments),
**worker_kwargs,
)
def _cleanup_owned_start():
if goal_related:
STREAM_GOAL_RELATED.pop(stream_id, None)
with STREAMS_LOCK:
STREAMS.pop(stream_id, None)
unregister_stream_owner(stream_id)
clear_session_writeback_owner_if_owned(s.session_id, stream_id)
if gateway_starting:
try:
from api.gateway_chat import (
_clear_gateway_run_starting,
_finish_gateway_run_starting,
)
_finish_gateway_run_starting(stream_id)
_clear_gateway_run_starting(stream_id)
except Exception:
logger.debug(
"Failed to clear compensated gateway start %s",
stream_id,
exc_info=True,
)
try:
applied, retained_context_user = apply_regeneration_plan(
s,
plan,
return_context_user=True,
)
if not applied:
restore_regeneration_state(s, snapshot)
return {
"error": "Session changed while regeneration was being prepared.",
"code": "stale_regeneration_revision",
"_status": 409,
}
retained_user = s.messages[-1]
msg = turn.message_text
attachments = copy.deepcopy(turn.attachments)
was_hidden_empty_session = _is_hidden_empty_session(s)
_prepare_chat_start_session_for_stream(
s,
msg=msg,
attachments=attachments,
workspace=workspace,
model=model,
model_provider=model_provider,
stream_id=stream_id,
source=turn.source,
retained_user=retained_user,
retained_context_user=retained_context_user,
defer_save=True,
)
diag.stage("turn_journal_submitted") if diag else None
from api.turn_journal import append_turn_journal_event
journal_event = append_turn_journal_event(
s.session_id,
{
"event": "submitted",
"stream_id": stream_id,
"role": "user",
"content": msg,
"attachments": attachments,
"workspace": workspace,
"model": model,
"model_provider": model_provider,
"created_at": s.pending_started_at,
},
)
diag.stage("stream_registration") if diag else None
stream = create_stream_channel()
register_stream_owner(stream_id, s.session_id)
with STREAMS_LOCK:
STREAMS[stream_id] = stream
if goal_related:
STREAM_GOAL_RELATED[stream_id] = True
if backend_is_gateway:
from api.gateway_chat import _mark_gateway_run_starting
gateway_starting = True
_mark_gateway_run_starting(stream_id)
diag.stage("worker_thread_start") if diag else None
worker_thread = threading.Thread(target=_gated_worker, daemon=True)
worker_thread.start()
thread_started = True
save_attempted = True
s.save()
accepted = True
set_last_workspace(workspace)
release_worker.set()
except Exception as exc:
abort_worker.set()
release_worker.set()
if (
thread_started
and worker_thread is not None
and callable(getattr(worker_thread, "join", None))
):
worker_thread.join(timeout=1)
_cleanup_owned_start()
if accepted:
if journal_event:
try:
append_turn_journal_event(
s.session_id,
{
"event": "interrupted",
"stream_id": stream_id,
"turn_id": journal_event.get("turn_id"),
"reason": "post_acceptance_workspace_failure",
},
)
except Exception:
logger.warning("Failed to close accepted regeneration journal", exc_info=True)
exc._regeneration_accepted = True
raise
restore_regeneration_state(s, snapshot)
if save_attempted:
try:
s.save(touch_updated_at=False)
except Exception:
logger.exception(
"Failed to persist compensated regeneration for %s",
s.session_id,
)
if journal_event:
try:
append_turn_journal_event(
s.session_id,
{
"event": "interrupted",
"stream_id": stream_id,
"turn_id": journal_event.get("turn_id"),
"reason": "start_compensated",
},
)
except Exception:
logger.warning(
"Failed to close compensated turn journal event",
exc_info=True,
)
raise
release_worker.set()
if was_hidden_empty_session:
publish_session_list_changed(
"session_new",
profile=getattr(s, "profile", None),
session_id=getattr(s, "session_id", None),
)
response = {
"stream_id": stream_id,
"session_id": s.session_id,
"pending_started_at": s.pending_started_at,
"turn_id": journal_event.get("turn_id"),
"title": s.title,
}
if normalized_model:
response["effective_model"] = model
if model_provider:
response["effective_model_provider"] = model_provider
return response
def _active_run_stream_for_session(session_id: str | None) -> str | None:
"""Return a live worker stream for this session even if sidecar stream id is clear.
@@ -22024,6 +22342,7 @@ def _start_chat_stream_for_session(
source: str = "webui",
moa_config=None,
external_runtime_owned: bool | None = None,
regeneration=None,
):
"""Persist pending state, register an SSE channel, and start an agent turn."""
if external_runtime_owned is None:
@@ -22092,6 +22411,20 @@ def _start_chat_stream_for_session(
"_status": 409,
}
needs_stale_cleanup = False
if regeneration is not None:
return _start_regeneration_stream_locked(
s,
turn=regeneration,
workspace=workspace,
model=model,
model_provider=model_provider,
normalized_model=normalized_model,
diag=diag,
goal_related=goal_related,
source=source,
moa_config=moa_config,
backend_is_gateway=backend_is_gateway,
)
stream_id = uuid.uuid4().hex
diag.stage("save_pending_state") if diag else None
was_hidden_empty_session = _is_hidden_empty_session(s)
@@ -22222,6 +22555,7 @@ def _chat_start_response_from_run_start(result):
"effective_model",
"effective_model_provider",
"error",
"code",
"active_stream_id",
"_status",
):
@@ -22258,6 +22592,7 @@ def _start_run(
diag=None,
moa_config=None,
gateway_chat_enabled: bool | None = None,
regeneration=None,
):
"""Shared start-run helper for /api/chat/start and start_session_turn.
@@ -22286,6 +22621,8 @@ def _start_run(
)
if runtime_adapter_enabled() or runtime_adapter_runner_enabled():
if regeneration is not None and runtime_adapter_runner_enabled():
return {"error": "Regeneration is not supported by the runner backend.", "code": "unsupported_regeneration_backend", "_status": 409}
def _legacy_start_run(request: StartRunRequest) -> dict:
return _start_chat_stream_for_session(
s,
@@ -22299,6 +22636,7 @@ def _start_run(
source=request.source or source,
moa_config=moa_config,
external_runtime_owned=gateway_chat_enabled,
regeneration=regeneration,
)
def _legacy_adapter_factory():
@@ -22340,6 +22678,7 @@ def _start_run(
source=source,
moa_config=moa_config,
external_runtime_owned=gateway_chat_enabled,
regeneration=regeneration,
)
@@ -22992,6 +23331,14 @@ def _handle_chat_start(handler, body, diag=None):
{"status": "suppressed", "reason": "silent_control_message"},
status=200,
)
if body.get("regenerate") is True:
from api.runtime_adapter import runtime_adapter_runner_enabled
if runtime_adapter_runner_enabled():
return j(handler, {
"error": "Regeneration is not supported by the runner backend.",
"code": "unsupported_regeneration_backend",
}, status=409)
# Reject a stale local Agent runtime before materialising, claiming, or
# mutating any session state. Gateway-backed turns run in the gateway's
# process and do not depend on this WebUI process's imported checkout.
@@ -23000,7 +23347,10 @@ def _handle_chat_start(handler, body, diag=None):
return j(handler, stale_response, status=409)
diag.stage("get_session") if diag else None
try:
s = _get_or_materialize_session(body["session_id"], refresh_cli_messages=True)
s = _get_or_materialize_session(
body["session_id"],
refresh_cli_messages=body.get("regenerate") is not True,
)
except KeyError:
# No WebUI sidecar. If this is a foreign-origin session (CLI,
# TUI, Desktop) with recoverable state.db messages, claim it by
@@ -23092,12 +23442,31 @@ def _handle_chat_start(handler, body, diag=None):
s.profile = requested_profile
else:
return bad(handler, "Session not found", 404)
regeneration = None
if body.get("regenerate") is True:
if any(key in body for key in ("message", "attachments", "keep_count", "prompt", "prompt_index")):
return j(handler, {"error": "regeneration accepts only regeneration_revision", "code": "invalid_regeneration_request"}, status=400)
if not isinstance(body.get("regeneration_revision"), str):
return j(handler, {"error": "regeneration_revision is required", "code": "stale_regeneration_revision"}, status=409)
try:
from api.session_ops import plan_regeneration, RegenerationUnavailable
regeneration = plan_regeneration(
s, expected_revision=body["regeneration_revision"]
)
except RegenerationUnavailable as exc:
return j(handler, {"error": str(exc), "code": exc.code}, status=exc.status)
msg = regeneration.turn.message_text
attachments = copy.deepcopy(regeneration.turn.attachments)[:20]
else:
msg = None
attachments = None
diag.stage("normalize_message") if diag else None
msg = str(body.get("message", "")).strip()
msg = str(msg if msg is not None else body.get("message", "")).strip()
if not msg:
return bad(handler, "message is required")
diag.stage("normalize_attachments") if diag else None
attachments = _normalize_chat_attachments(body.get("attachments") or [])[:20]
if attachments is None:
attachments = _normalize_chat_attachments(body.get("attachments") or [])[:20]
recovery = compression_recovery_payload_for_session(s)
if recovery and not attachments and is_generic_continuation_intent(msg):
return j(
@@ -23113,7 +23482,10 @@ def _handle_chat_start(handler, body, diag=None):
)
diag.stage("resolve_workspace") if diag else None
try:
workspace = _resolve_chat_workspace_with_recovery(s, body.get("workspace"))
if regeneration is not None:
workspace = _resolve_chat_workspace_for_regeneration(s, body.get("workspace"))
else:
workspace = _resolve_chat_workspace_with_recovery(s, body.get("workspace"))
except WorkspaceBindingPersistenceError as e:
return bad(handler, str(e), 500)
except ValueError as e:
@@ -23156,7 +23528,7 @@ def _handle_chat_start(handler, body, diag=None):
# compares). This survives same-model follow-up sends (the onchange marker
# is one-shot) yet can't outlive a real switch.
try:
if explicit_model_pick:
if explicit_model_pick and regeneration is None:
from api.models import model_explicit_pick_signature as _mk_sig
s.model_explicit_pick_signature = _mk_sig(model, model_provider)
except Exception:
@@ -23215,6 +23587,7 @@ def _handle_chat_start(handler, body, diag=None):
"route": "/api/chat/start",
"diag": diag,
"gateway_chat_enabled": gateway_chat_enabled,
"regeneration": regeneration,
}
if not gateway_chat_enabled and moa_config is not None:
start_run_kwargs["moa_config"] = moa_config
@@ -23231,7 +23604,7 @@ def _handle_chat_start(handler, body, diag=None):
return restore_err
return None
if recovery:
if recovery and regeneration is None:
recovery_cleared_for_start = copy.deepcopy(recovery)
clear_compression_recovery(s)
try:
@@ -23239,8 +23612,9 @@ def _handle_chat_start(handler, body, diag=None):
s,
**start_run_kwargs,
)
except Exception:
_restore_cleared_recovery()
except Exception as exc:
if not getattr(exc, "_regeneration_accepted", False):
_restore_cleared_recovery()
raise
# Map adapter-selection NotImplementedError (501) onto the legacy
# bad-request response shape that this route exposed historically
@@ -23283,6 +23657,17 @@ def _resolve_chat_workspace_with_recovery(s, requested_workspace) -> str:
return str(persisted.workspace)
def _resolve_chat_workspace_for_regeneration(s, requested_workspace) -> str:
"""Resolve regeneration's workspace without persisting before start acceptance."""
if requested_workspace not in (None, ""):
return str(resolve_trusted_workspace(requested_workspace))
workspace, _recovered = resolve_implicit_workspace_with_recovery(
getattr(s, "workspace", None),
get_last_workspace,
)
return str(workspace)
def _normalize_chat_attachments(raw_attachments):
"""Normalize attachment payloads from the browser.
+394 -12
View File
@@ -9,17 +9,382 @@ from __future__ import annotations
import json
import logging
import uuid
import copy
import hashlib
import math
from dataclasses import dataclass
from contextlib import nullcontext
from bisect import bisect_left
from typing import Any
from api.config import LOCK, _get_session_agent_lock
from api.models import get_session, SESSIONS
from api.agent_sessions import normalize_agent_session_source
logger = logging.getLogger(__name__)
AUTO_TITLE_LABELS = {'untitled', 'new chat'}
class RegenerationUnavailable(Exception):
def __init__(self, code: str, status: int = 409, message: str | None = None):
super().__init__(message or code)
self.code = code
self.status = status
def _regeneration_source_class(value):
raw = str(value or "").strip().lower()
if not raw:
return ""
if raw == "fork":
return "fork"
normalized = normalize_agent_session_source(raw).get("session_source")
return str(normalized or raw).strip().lower()
def _regeneration_source_allowed(value):
return _regeneration_source_class(value) in {"webui", "fork"}
def _selected_regeneration_turn_owned(session, row) -> bool:
"""Accept only a final row whose provenance proves WebUI ownership."""
if getattr(session, "read_only", False) or not isinstance(row, dict):
return False
session_source = _regeneration_source_class(getattr(session, "session_source", None))
imported_session = bool(
getattr(session, "is_cli_session", False)
or session_source not in {"", "webui", "fork"}
)
raw_sources = (
getattr(session, "raw_source", None),
getattr(session, "source_tag", None),
)
row_source = row.get("_source") or row.get("source")
if session_source == "fork":
if any(source and not _regeneration_source_allowed(source) for source in raw_sources):
return False
if row_source and not _regeneration_source_allowed(row_source):
return False
return bool(
getattr(session, "parent_session_id", None)
and row.get("_fork_child_turn") == getattr(session, "session_id", None)
)
if imported_session:
token = row.get("_active_turn_token")
if not isinstance(token, str) or not token.strip() or ":" not in token:
return False
stream_id, started_at = token.rsplit(":", 1)
try:
started = float(started_at)
except (TypeError, ValueError):
return False
from api.process_event_utils import build_active_turn_token
if not math.isfinite(started) or started <= 0:
return False
if build_active_turn_token(stream_id, started) != token:
return False
else:
if any(source and not _regeneration_source_allowed(source) for source in raw_sources):
return False
if row_source and not _regeneration_source_allowed(row_source):
return False
return True
@dataclass(frozen=True)
class RegenerationTurn:
user_index: int
assistant_index: int
message: dict
message_text: str
attachments: list
source: str
message_count: int
revision: str
row_digest: str
@dataclass(frozen=True)
class RegenerationPlan:
canonical_rows: list
canonical_context: list
turn: RegenerationTurn
revision: str
row_digest: str
message_count: int
truncation_boundary: int
def plan_regeneration(session, *, expected_revision=None, lock_held=False):
"""Prepare one canonical display/context pair for a locked regeneration."""
lock_context = nullcontext() if lock_held else _get_session_agent_lock(session.session_id)
with lock_context:
rows, context = regeneration_state(session)
revision = regeneration_revision_for(rows, session=session, context=context)
if expected_revision is not None and expected_revision != revision:
raise RegenerationUnavailable("stale_regeneration_revision")
turn = resolve_regeneration_turn(
rows, session=session, expected_revision=revision,
lock_held=True, context=context,
)
return RegenerationPlan(
canonical_rows=copy.deepcopy(rows),
canonical_context=copy.deepcopy(context),
turn=turn,
revision=revision,
row_digest=turn.row_digest,
message_count=len(rows),
truncation_boundary=turn.user_index + 1,
)
def apply_regeneration_plan(
session,
plan: RegenerationPlan,
*,
return_context_user: bool = False,
):
"""Install the prepared pair and truncate it without a second authority read."""
def _result(success, context_user=None):
return (success, context_user) if return_context_user else success
if not isinstance(plan, RegenerationPlan):
return _result(False)
rows = copy.deepcopy(plan.canonical_rows)
context = copy.deepcopy(plan.canonical_context)
if len(rows) != plan.message_count or plan.truncation_boundary != plan.turn.user_index + 1:
return _result(False)
if regeneration_revision_for(rows, session=session, context=context) != plan.revision:
return _result(False)
session.messages = rows
session.context_messages = context
current = session.messages[plan.turn.user_index]
if not isinstance(current, dict) or current.get("role") != "user":
return _result(False)
truncate_session_at_keep(session, plan.truncation_boundary)
prepared_context, context_boundary_index = truncate_context_for_display_keep(
context,
rows,
plan.truncation_boundary,
return_boundary_index=True,
)
session.context_messages = prepared_context if prepared_context or not context else context[: plan.truncation_boundary]
retained_context_user = None
if context_boundary_index is not None:
for context_row in reversed(session.context_messages[: context_boundary_index + 1]):
if isinstance(context_row, dict) and context_row.get("role") == "user":
retained_context_user = context_row
break
return _result(True, retained_context_user)
def snapshot_regeneration_state(session):
return copy.deepcopy(session.__dict__)
def restore_regeneration_state(session, snapshot):
session.__dict__.clear()
session.__dict__.update(copy.deepcopy(snapshot))
def regeneration_revision_for(rows, *, session=None, context=None) -> str:
"""Hash the canonical writable transcript and its aligned context."""
payload = json.dumps(
{
"session_id": str(getattr(session, "session_id", "") or "") if session is not None else "",
"messages": list(rows or []),
"context_messages": list(context or []),
"truncation_watermark": getattr(session, "truncation_watermark", None) if session is not None else None,
"truncation_boundary": getattr(session, "truncation_boundary", None) if session is not None else None,
},
sort_keys=True,
separators=(",", ":"),
default=str,
)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def regeneration_transcript(session, *, state_messages=None):
"""Return the state.db-reconciled transcript used by every authority consumer."""
if state_messages is None:
return regeneration_state(session)[0]
from api.models import reconciled_state_db_messages_for_session
return reconciled_state_db_messages_for_session(session, state_messages=state_messages)
def regeneration_context(session):
return regeneration_state(session)[1]
def regeneration_state(session):
"""Read one immutable state.db snapshot and reconcile both transcript views."""
from api.models import (
get_state_db_session_messages,
reconciled_state_db_messages_for_session,
)
state_messages = get_state_db_session_messages(
getattr(session, "session_id", None),
profile=getattr(session, "profile", None),
)
return (
reconciled_state_db_messages_for_session(
session,
state_messages=state_messages,
),
reconciled_state_db_messages_for_session(
session,
prefer_context=True,
state_messages=state_messages,
),
)
def regeneration_revision(session) -> str:
rows, context = regeneration_state(session)
return regeneration_revision_for(
rows,
session=session,
context=context,
)
def regeneration_authority(
session,
rows=None,
*,
context=None,
full_transcript=True,
canonical_state=None,
):
"""Mint a revision only for a complete, writable, canonical transcript."""
if not full_transcript:
return None
if getattr(session, "active_stream_id", None) or getattr(session, "pending_user_message", None):
return None
canonical_rows, canonical_context = canonical_state or regeneration_state(session)
rows = list(canonical_rows if rows is None else rows)
if not rows:
return None
if rows != canonical_rows:
return None
if context is not None and list(context or []) != canonical_context:
return None
try:
resolve_regeneration_turn(
canonical_rows,
session=session,
context=canonical_context,
)
except RegenerationUnavailable:
return None
return regeneration_revision_for(
canonical_rows,
session=session,
context=canonical_context,
)
def resolve_regeneration_turn(
rows,
*,
session=None,
expected_revision=None,
lock_held=False,
context=None,
):
"""Select the current session's final complete local exchange under its lock."""
legacy_session_call = session is None and not isinstance(rows, (list, tuple))
legacy_context = None
if legacy_session_call:
session = rows
rows, legacy_context = regeneration_state(session)
lock_context = (
_get_session_agent_lock(session.session_id)
if legacy_session_call and not lock_held
else nullcontext()
)
with lock_context:
rows = list(rows or [])
if context is None:
context = legacy_context
if context is None:
_, context = regeneration_state(session)
context = list(context)
revision = regeneration_revision_for(rows, session=session, context=context)
if expected_revision is not None and expected_revision != revision:
raise RegenerationUnavailable("stale_regeneration_revision")
if getattr(session, "active_stream_id", None):
raise RegenerationUnavailable("session_active")
if getattr(session, "pending_user_message", None):
raise RegenerationUnavailable("session_active")
assistant_index = next(
(
index
for index in range(len(rows) - 1, -1, -1)
if isinstance(rows[index], dict)
and rows[index].get("role") == "assistant"
and _assistant_message_has_final_visible_text(rows[index])
),
None,
)
if assistant_index is not None:
index = next(
(
candidate
for candidate in range(assistant_index - 1, -1, -1)
if isinstance(rows[candidate], dict)
and rows[candidate].get("role") == "user"
),
None,
)
else:
index = None
if index is not None:
if any(
isinstance(row, dict) and row.get("role") == "user"
for row in rows[assistant_index + 1:]
):
raise RegenerationUnavailable("no_regenerable_turn", 400)
if any(
isinstance(row, dict) and row.get("role") in {"assistant", "tool"}
for row in rows[assistant_index + 1:]
):
raise RegenerationUnavailable("no_regenerable_turn", 400)
row = rows[index]
if not _selected_regeneration_turn_owned(session, row):
raise RegenerationUnavailable("regeneration_read_only", 403)
content = _extract_text(row.get("content", ""))
if content:
row_digest = hashlib.sha256(
json.dumps(
row,
sort_keys=True,
separators=(",", ":"),
default=str,
).encode("utf-8")
).hexdigest()
return RegenerationTurn(
index,
assistant_index,
copy.deepcopy(row),
content,
copy.deepcopy(row.get("attachments") or []),
str(row.get("_source") or "webui"),
len(rows),
revision,
row_digest,
)
raise RegenerationUnavailable("no_regenerable_turn", 400)
def _assistant_message_has_final_visible_text(message) -> bool:
from api.streaming import _assistant_message_has_final_visible_text as _has_final_text
return _has_final_text(message)
def _live_active_stream_id(session) -> str | None:
"""Return session.active_stream_id ONLY if that stream is live in THIS
process; else None.
@@ -119,16 +484,21 @@ def truncate_context_for_display_keep(
context_messages: list | None,
full_messages: list | None,
keep: int,
*,
return_boundary_index: bool = False,
) -> list:
"""Align model context with display prefix ``full_messages[:keep]``."""
def _result(rows, boundary_index=None):
return (rows, boundary_index) if return_boundary_index else rows
if keep <= 0:
return []
return _result([])
ctx = context_messages if isinstance(context_messages, list) else []
msgs = full_messages if isinstance(full_messages, list) else []
if not ctx:
return []
return _result([])
if len(msgs) == 0:
return []
return _result([])
# Only the perfectly-parallel case (display and context row-for-row) can be
# sliced at the raw display index. When the two arrays differ in length —
# in EITHER direction — they have diverged and need alignment:
@@ -145,7 +515,7 @@ def truncate_context_for_display_keep(
# strips unanswered tool_calls; gateway: it forwards no tool_calls/tool rows
# at all), so we do not re-do that trimming here.
if len(ctx) == len(msgs):
return ctx[:keep]
return _result(ctx[:keep], min(keep, len(ctx)) - 1)
def _row_signature(row: Any) -> tuple[str, ...] | None:
if not isinstance(row, dict):
@@ -374,8 +744,8 @@ def truncate_context_for_display_keep(
and isinstance(msgs[keep - 1], dict)
and msgs[keep - 1].get('role') == 'user'
):
return ctx[:last_kept + 1]
return ctx[:first_unkept]
return _result(ctx[:last_kept + 1], last_kept)
return _result(ctx[:first_unkept], first_unkept - 1)
if last_kept is not None:
ambiguous_first_unkept = ambiguous_matches[keep]
if (
@@ -383,8 +753,8 @@ def truncate_context_for_display_keep(
and isinstance(msgs[keep - 1], dict)
and msgs[keep - 1].get('role') != 'user'
):
return ctx[:ambiguous_first_unkept]
return ctx[:last_kept + 1]
return _result(ctx[:ambiguous_first_unkept], ambiguous_first_unkept - 1)
return _result(ctx[:last_kept + 1], last_kept)
# Both boundary rows were ambiguous/unmatched (common in large sessions
# where context rows have lost their id/timestamp so the matcher can't
@@ -403,14 +773,15 @@ def truncate_context_for_display_keep(
for i in range(keep - 1, -1, -1):
resolved = matches[i] if matches[i] is not None else ambiguous_matches[i]
if resolved is not None:
return ctx[:resolved + 1]
return _result(ctx[:resolved + 1], resolved)
# Final fallback preserves #5096 behavior when alignment is unreliable
# (no display row resolved to a context index, or keep >= len(msgs)).
prefix_len = max(0, len(ctx) - len(msgs))
prefix = ctx[:prefix_len]
suffix = ctx[prefix_len:]
return prefix + suffix[:keep]
result = prefix + suffix[:keep]
return _result(result, len(result) - 1 if result else None)
def truncate_session_at_keep(session, keep: int) -> tuple[int, int]:
@@ -618,7 +989,18 @@ def _extract_text(content: Any) -> str:
if isinstance(content, list):
parts = []
for p in content:
if isinstance(p, dict) and p.get('type') == 'text':
parts.append(p.get('text', ''))
if not isinstance(p, dict):
continue
part_type = str(p.get('type') or '').lower()
if part_type not in ('', 'text', 'input_text', 'output_text'):
continue
part_text = (
p.get('text')
or p.get('content')
or p.get('input_text')
or p.get('output_text')
or ''
)
parts.append(str(part_text))
return ' '.join(parts)
return str(content)
+76 -11
View File
@@ -156,6 +156,29 @@ def _session_payload_with_full_messages(session, *, tool_calls=None):
attach_todo_state(raw, messages)
if tool_calls is not None:
raw['tool_calls'] = tool_calls
try:
from api.session_ops import (
regeneration_authority,
regeneration_state,
)
canonical_messages, canonical_context = regeneration_state(session)
revision = regeneration_authority(
session,
rows=canonical_messages,
context=canonical_context,
full_transcript=True,
canonical_state=(canonical_messages, canonical_context),
)
if revision:
messages = list(canonical_messages)
raw['messages'] = messages
raw['message_count'] = len(messages)
attach_todo_state(raw, messages)
raw['regeneration_revision'] = revision
else:
raw.pop('regeneration_revision', None)
except Exception:
raw.pop('regeneration_revision', None)
return raw
@@ -1530,12 +1553,29 @@ def _clean_synthetic_control_messages_with_provenance(messages):
def _active_turn_authority(session, stream_id, msg_text):
"""Capture the stream-owned pending turn before settlement mutates it."""
pending_text = getattr(session, 'pending_user_message', None)
token = build_active_turn_token(stream_id, getattr(session, 'pending_started_at', None))
checkpoint = (
next(
(
copy.deepcopy(message)
for message in reversed(list(getattr(session, 'messages', None) or []))
if isinstance(message, dict)
and message.get('role') == 'user'
and message.get('_active_turn_token') == token
),
None,
)
if token
else None
)
return {
'token': build_active_turn_token(stream_id, getattr(session, 'pending_started_at', None)),
'session_id': getattr(session, 'session_id', None),
'token': token,
'text': pending_text if pending_text is not None else msg_text,
'timestamp': getattr(session, 'pending_started_at', None),
'source': getattr(session, 'pending_user_source', None) or 'webui',
'attachments': copy.deepcopy(getattr(session, 'pending_attachments', None) or []),
'checkpoint': checkpoint,
'current_turn_user_idx': None,
'turn_id': '',
}
@@ -1628,7 +1668,7 @@ def _mark_active_turn_checkpoint_in_history(messages, identity, msg_text, *, all
if (
not isinstance(message, dict)
or message.get('role') != 'user'
or _normalize_user_text(message.get('content')) != _normalize_user_text(expected_text)
or _normalize_user_text(_message_text(message.get('content'))) != _normalize_user_text(expected_text)
):
return messages, False
_mark_active_turn_checkpoint(message, identity)
@@ -1675,19 +1715,24 @@ def _find_active_turn_checkpoint_index(result_messages, previous_context, identi
if (
isinstance(message, dict)
and message.get('role') == 'user'
and _normalize_user_text(message.get('content')) == _normalize_user_text(expected_text)
and _normalize_user_text(_message_text(message.get('content'))) == _normalize_user_text(expected_text)
):
return idx
return None
def _materialize_active_turn_user(identity, msg_text, source):
message = {
'role': 'user',
'content': identity.get('text') if isinstance(identity, dict) else msg_text,
}
checkpoint = identity.get('checkpoint') if isinstance(identity, dict) else None
message = (
copy.deepcopy(checkpoint)
if isinstance(checkpoint, dict) and checkpoint.get('role') == 'user'
else {
'role': 'user',
'content': identity.get('text') if isinstance(identity, dict) else msg_text,
}
)
if isinstance(identity, dict):
if identity.get('timestamp') is not None:
if identity.get('timestamp') is not None and message.get('timestamp') is None:
message['timestamp'] = identity['timestamp']
if identity.get('attachments'):
message['attachments'] = copy.deepcopy(identity['attachments'])
@@ -1696,8 +1741,15 @@ def _materialize_active_turn_user(identity, msg_text, source):
identity.get('source') or source or 'webui',
active_turn_token=identity.get('token'),
)
if str(identity.get('source') or source or '').strip().lower() == 'fork':
child_session_id = identity.get('session_id')
if child_session_id:
message['_fork_child_turn'] = child_session_id
else:
stamp_message_source(message, source)
stamp_message_source(
message,
source,
)
return message
@@ -1713,7 +1765,18 @@ def _settle_current_turn_boundary(previous_context, result_messages, identity, m
msg_text,
)
if _checkpoint_idx is not None:
_mark_active_turn_checkpoint(result_messages[_checkpoint_idx], identity)
existing_checkpoint = result_messages[_checkpoint_idx]
if isinstance(identity.get('checkpoint'), dict):
retained_checkpoint = _materialize_active_turn_user(identity, msg_text, source)
if (
retained_checkpoint.get('id') is None
and isinstance(existing_checkpoint, dict)
and existing_checkpoint.get('id') is not None
):
retained_checkpoint['id'] = existing_checkpoint['id']
result_messages[_checkpoint_idx] = retained_checkpoint
else:
_mark_active_turn_checkpoint(existing_checkpoint, identity)
return result_messages
previous_context = list(previous_context or [])
if _messages_have_prefix(result_messages, previous_context):
@@ -7379,7 +7442,7 @@ def _materialize_pending_user_turn_before_error(session) -> bool:
except (TypeError, ValueError):
return False
return (
_normalize_user_text(existing.get('content')) == _normalize_user_text(pending_text)
_normalize_user_text(_message_text(existing.get('content'))) == _normalize_user_text(pending_text)
and existing_ts == recovered_ts
and existing_source == pending_source
and list(existing.get('attachments') or []) == pending_attachments
@@ -7393,6 +7456,8 @@ def _materialize_pending_user_turn_before_error(session) -> bool:
'timestamp': recovered_ts,
'_recovered': True,
}
if str(pending_source or '').strip().lower() == 'fork':
recovered['_fork_child_turn'] = session.session_id
stamp_message_source(recovered, pending_source)
if pending_attachments:
recovered['attachments'] = pending_attachments
+50 -1
View File
@@ -1953,6 +1953,52 @@ async function send(){
}finally{ _sendInProgress=false; _sendInProgressSid=null; }
}
async function startRegeneration(sessionId, regenerationRevision){
const sid=String(sessionId||'');
if(!sid||!regenerationRevision||!S.session||S.session.session_id!==sid)return;
const snapshot=Array.isArray(S.messages)?S.messages.slice():[];
let assistantIndex=-1;
let userIndex=-1;
for(let i=snapshot.length-1;i>=0;i--){
if(assistantIndex<0&&snapshot[i]?.role==='assistant'){assistantIndex=i;continue;}
if(assistantIndex>=0&&snapshot[i]?.role==='user'){userIndex=i;break;}
}
if(userIndex<0)return;
const retained=Object.assign({},snapshot[userIndex],{_pending:true});
S.messages=snapshot.slice(0,userIndex+1);
S.messages[userIndex]=retained;
renderMessages();setBusy(true);
if(typeof ensureLiveWorklogShell==='function')ensureLiveWorklogShell();
else if(typeof appendThinking==='function')appendThinking('',{pending:true});
try{
const response=await api('/api/chat/start',{method:'POST',body:JSON.stringify({
session_id:sid,regenerate:true,regeneration_revision:regenerationRevision
})});
if(!S.session||S.session.session_id!==sid)return;
const streamId=response&&response.stream_id;
if(!streamId)throw new Error('Regeneration did not start a stream.');
S.activeStreamId=streamId;
S.session.active_stream_id=streamId;
S.session.regeneration_revision=null;
if(typeof response.pending_started_at==='number')S.session.pending_started_at=response.pending_started_at;
if(response.title&&typeof applySessionTitleUpdate==='function')applySessionTitleUpdate(sid,response.title);
if(!INFLIGHT[sid])INFLIGHT[sid]={messages:S.messages.slice(),uploaded:[],toolCalls:[]};
markInflight(sid,streamId);
if(typeof saveInflightState==='function')saveInflightState(sid,{streamId,messages:S.messages.slice(),uploaded:[],toolCalls:[]});
if(typeof showLiveRunStatus==='function')showLiveRunStatus(sid,{startedAt:S.session.pending_started_at||Date.now()/1000});
if(typeof updateSendBtn==='function')updateSendBtn();
if(typeof renderSessionList==='function')void renderSessionList();
attachLiveStream(sid,streamId,[]);
}catch(error){
if(S.session&&S.session.session_id===sid){
S.messages=snapshot;delete INFLIGHT[sid];
if(typeof clearInflightState==='function')clearInflightState(sid);
removeThinking();renderMessages();setBusy(false);setComposerStatus('');
}
throw error;
}
}
const LIVE_STREAMS={};
const _STREAM_NOTIFICATION_BACKGROUND={};
@@ -6139,7 +6185,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const _prevCost=(S.session&&S.session.estimated_cost)||0;
const _prevCacheRead=(S.session&&S.session.cache_read_tokens)||0;
const _prevCacheWrite=(S.session&&S.session.cache_write_tokens)||0;
S.session=d.session;S.messages=_carryForwardEphemeralTurnFields(S.messages||[], d.session.messages||[]);if(typeof _messagesTruncated!=='undefined')_messagesTruncated=!!d.session._messages_truncated;
S.session=d.session;S.messages=_carryForwardEphemeralTurnFields(S.messages||[], d.session.messages||[]);if(typeof _adoptRegenerationRevision==='function')_adoptRegenerationRevision(d.session);if(typeof _messagesTruncated!=='undefined')_messagesTruncated=!!d.session._messages_truncated;
// #4720: reset _oldestIdx (full-load symmetry; keeps the #4613 anchor aligned).
if(typeof _oldestIdx!=='undefined')_oldestIdx=d.session._messages_offset||0;
S.messages=_filterRecoveryControlMessages(S.messages || []);
@@ -6578,6 +6624,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
} else if(d.session&&typeof d.session==='object'){
S.session=d.session;
const _nextMsgs3018=(d.session.messages||[]).filter(m=>m&&m.role);
if(typeof _adoptRegenerationRevision==='function')_adoptRegenerationRevision(d.session);
_attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018);
S.messages=_carryForwardEphemeralTurnFields(S.messages||[], _nextMsgs3018);
if(S.session&&S.session.session_id){
@@ -6818,6 +6865,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
: (typeof _messageUserUnpinned!=='undefined' && _messageUserUnpinned));
S.session=sessionPayload;
const _nextMsgs3018=(sessionPayload.messages||[]).filter(m=>m&&m.role);
if(typeof _adoptRegenerationRevision==='function')_adoptRegenerationRevision(sessionPayload);
_attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018);
S.messages=_carryForwardEphemeralTurnFields(S.messages||[], _nextMsgs3018);
if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(S.session);
@@ -6990,6 +7038,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){}
if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id);
}
if(typeof _adoptRegenerationRevision==='function')_adoptRegenerationRevision(session);
const _markerOnlyAssistantError=_replaceMarkerOnlyAssistantWithStreamError(S.messages);
if(_markerOnlyAssistantError&&typeof showToast==='function') showToast('No response received after context compression. Please retry.',5000,'error');
const hasMessageToolMetadata=S.messages.some(m=>{
+17 -1
View File
@@ -189,6 +189,15 @@ function _clearRememberedNewChatDraftSession(sid) {
} catch (_) {}
}
function _adoptRegenerationRevision(sessionPayload){
if(!S||!S.session||!sessionPayload||typeof sessionPayload!=='object') return;
if(Object.prototype.hasOwnProperty.call(sessionPayload,'regeneration_revision')){
S.session.regeneration_revision=sessionPayload.regeneration_revision;
}else{
delete S.session.regeneration_revision;
}
}
async function _restoreRememberedNewChatDraftSession() {
let sid = '';
try { sid = localStorage.getItem(NEW_CHAT_DRAFT_SESSION_KEY) || ''; } catch (_) { sid = ''; }
@@ -1491,7 +1500,7 @@ async function newSession(flash, options={}){
if(consumedExplicitModelOverride&&typeof _clearEmptyComposerModelOverride==='function'){
_clearEmptyComposerModelOverride();
}
S.session=data.session;S.messages=data.session.messages||[];
S.session=data.session;if(typeof _adoptRegenerationRevision==='function') _adoptRegenerationRevision(data.session);S.messages=data.session.messages||[];
S._pendingSessionToolsets=null;
if(_sessionSourceFilter==='cli') _sessionSourceFilter='webui';
if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(S.session);
@@ -1974,6 +1983,7 @@ async function loadSession(sid){
return loadSession(continuationSid,{...opts,skipLineageResolve:true,skipContinuationResolve:true,force:true,_preloadNotified:true});
}
S.session=data.session;
if(typeof _adoptRegenerationRevision==='function') _adoptRegenerationRevision(data.session);
if(typeof _clearEmptyComposerModelOverride==='function') _clearEmptyComposerModelOverride();
// Loading a real existing session abandons any pre-session toolset override
// staged on the empty composer before any deferred refresh work runs.
@@ -3209,6 +3219,7 @@ async function _ensureMessagesLoaded(sid, opts) {
);
}
if(S.session&&S.session.session_id===sid){
if(typeof _adoptRegenerationRevision==='function') _adoptRegenerationRevision(data.session);
S.session.message_count=Number(data.session.message_count || msgs.length);
S.lastUsage={...(data.session.last_usage||S.lastUsage||{})};
// Phase 2: the messages=1 response carries the canonical cold-load
@@ -3905,6 +3916,11 @@ async function _ensureAllMessagesLoaded() {
_syncToolCallsForLoadedMessages(msgs, data.session.tool_calls);
if (S.session && S.session.session_id === sid) {
S.session.message_count = Number(data.session.message_count || msgs.length);
if (Object.prototype.hasOwnProperty.call(data.session, 'regeneration_revision')) {
S.session.regeneration_revision = data.session.regeneration_revision;
} else {
delete S.session.regeneration_revision;
}
}
} finally {
_loadingOlder = false;
+27 -29
View File
@@ -491,7 +491,7 @@ async function startCompressionRecovery(btn){
if(!sid) throw new Error('Compression recovery did not return a session.');
try{localStorage.setItem('hermes-webui-session',sid);}catch(_){}
if(typeof loadSession==='function') await loadSession(sid,{preserveActiveInput:false});
else if(data.session){S.session=data.session;S.messages=data.session.messages||[];syncTopbar();renderMessages();}
else if(data.session){S.session=data.session;if(typeof _adoptRegenerationRevision==='function')_adoptRegenerationRevision(data.session);S.messages=data.session.messages||[];syncTopbar();renderMessages();}
if(typeof renderSessionList==='function') await renderSessionList();
if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(sid);
if(typeof showToast==='function') showToast((data&&data.message)||'Started focused continuation.',3000,'success');
@@ -9982,6 +9982,7 @@ async function refreshSession() {
try {
const data = await api(`/api/session?session_id=${encodeURIComponent(S.session.session_id)}`);
S.session = data.session;
if(typeof _adoptRegenerationRevision==='function') _adoptRegenerationRevision(data.session);
S.messages = data.session.messages || [];
_messagesTruncated = !!data.session._messages_truncated;
_oldestIdx = data.session._messages_offset || 0;
@@ -10825,14 +10826,14 @@ function _activeTurnTokenMatches(msg, session){
* same text twice in a row (a plain "继续" follow-up) legitimately gets two
* identical user turns, and matching on text would swallow the new one. The
* discriminator is therefore exact identity, never proximity: the active turn's
* row either carries the server-stamped `_active_turn_token` (stream_id +
* started_at — unique to this turn), or its timestamp equals `pending_started_at`
* within a precision-only epsilon that absorbs float/state.db drift but never a
* full second. A whole-second (or sub-second) mismatch is ambiguous and returns
* null so the caller materializes the pending turn — the transient duplicate is
* harmless, hiding a turn + moving its attachments is not. Text equality is
* still required downstream, so a false match needs identical text AND an exact
* identity signal.
* public row carries `_active_turn_user`, while private rows carry the server-
* stamped `_active_turn_token` (stream_id + started_at — unique to this turn),
* or their timestamp equals `pending_started_at` within a precision-only epsilon
* that absorbs float/state.db drift but never a full second. A whole-second (or
* sub-second) mismatch is ambiguous and returns null so the caller materializes
* the pending turn — the transient duplicate is harmless, hiding a turn + moving
* its attachments is not. Text equality is still required downstream, so a false
* match needs identical text AND an exact identity signal.
*/
function _pendingActiveTurnUserMessage(messages, session){
const startedAt=Number(session?.pending_started_at);
@@ -10842,6 +10843,8 @@ function _pendingActiveTurnUserMessage(messages, session){
const msg=list[i];
if(!msg||String(msg.role||'')!=='user') continue;
if(typeof _isContextCompactionMessage==='function'&&_isContextCompactionMessage(msg)) continue;
// Public projections replace the private token with this authoritative marker.
if(msg._active_turn_user===true) return msg;
// Unambiguous: the row carries the active turn's exact token
// (stream_id + started_at) stamped by the server's eager-checkpoint path.
if(typeof _activeTurnTokenMatches==='function'&&_activeTurnTokenMatches(msg,session)) return msg;
@@ -19286,30 +19289,25 @@ async function submitEdit(msgIdx, newText) {
async function regenerateResponse(btn) {
if(!S.session || S.busy) return;
const row = btn.closest('[data-msg-idx]');
if(!row) return;
const assistantIdx = parseInt(row.dataset.msgIdx, 10);
const absoluteKeepCount = _oldestIdx + assistantIdx;
const row=btn&&btn.closest&&btn.closest('[data-msg-idx]');
if(!row)return;
const clickedAbsoluteIndex=_oldestIdx+parseInt(row.dataset.msgIdx,10);
const initialSid = S.session.session_id;
let lastUserText = '';
for(let i = assistantIdx - 1; i >= 0; i--) {
const m = S.messages[i];
if(m && m.role === 'user') { lastUserText = msgContent(m); break; }
}
if(!lastUserText) return;
if(typeof _ensureAllMessagesLoaded==='function'){
await _ensureAllMessagesLoaded();
}
if(!S.session || S.session.session_id !== initialSid) return;
if(!S.session.regeneration_revision){ setStatus(t('regen_failed')); return; }
let latestAssistantIndex=-1;
for(let i=S.messages.length-1;i>=0;i--){
if(S.messages[i]?.role==='assistant'){latestAssistantIndex=i;break;}
}
if(clickedAbsoluteIndex!==latestAssistantIndex){
setStatus(t('regen_failed'));
return;
}
try {
await api('/api/session/truncate', {method:'POST', body:JSON.stringify({
session_id: initialSid,
keep_count: absoluteKeepCount
})});
S.messages = S.messages.slice(0, absoluteKeepCount);
renderMessages();
$('msg').value = lastUserText;
await send();
await startRegeneration(initialSid, S.session.regeneration_revision);
} catch(e) { setStatus(t('regen_failed') + e.message); }
}
@@ -21408,7 +21406,7 @@ async function promptNewFile(targetDir = S.currentDir || '.'){
// System-minted session (#6022): explicit worktree:false — creating a
// file from a blank page must not inherit the config worktree default.
const r=await api('/api/session/new',{method:'POST',body:JSON.stringify({workspace:ws,worktree:false})});
if(r&&r.session){S._pendingSessionToolsets=null;S.session=r.session;S.messages=[];syncTopbar();renderMessages();await renderSessionList();}
if(r&&r.session){S._pendingSessionToolsets=null;S.session=r.session;if(typeof _adoptRegenerationRevision==='function') _adoptRegenerationRevision(r.session);S.messages=[];syncTopbar();renderMessages();await renderSessionList();}
}catch(e){setStatus(t('create_failed')+e.message);return;}
}
if(!S.session)return;
@@ -21441,7 +21439,7 @@ async function promptNewFolder(targetDir = S.currentDir || '.'){
// System-minted session (#6022): explicit worktree:false — creating a
// folder from a blank page must not inherit the config worktree default.
const r=await api('/api/session/new',{method:'POST',body:JSON.stringify({workspace:ws,worktree:false})});
if(r&&r.session){S._pendingSessionToolsets=null;S.session=r.session;S.messages=[];syncTopbar();renderMessages();await renderSessionList();}
if(r&&r.session){S._pendingSessionToolsets=null;S.session=r.session;if(typeof _adoptRegenerationRevision==='function') _adoptRegenerationRevision(r.session);S.messages=[];syncTopbar();renderMessages();await renderSessionList();}
}catch(e){setStatus(t('folder_create_failed')+e.message);return;}
}
if(!S.session)return;
+22
View File
@@ -0,0 +1,22 @@
import json
from pathlib import Path
_FIXTURE_PATH = Path(__file__).parent / "fixtures" / "issue6611_regeneration_rows.json"
_EXPECTED_ROWS = [
{"role": "user", "content": "same prompt"},
{"role": "assistant", "content": "provider failed", "_error": True},
]
def load_issue6611_fixture():
fixture = json.loads(_FIXTURE_PATH.read_text(encoding="utf-8"))
assert set(fixture) == {"issue", "rows"}
assert fixture["issue"] == 6611
assert isinstance(fixture["rows"], list)
assert fixture["rows"] == _EXPECTED_ROWS
assert all(set(row) == {"role", "content"} for row in fixture["rows"][:1])
assert set(fixture["rows"][1]) == {"role", "content", "_error"}
assert [row["role"] for row in fixture["rows"]] == ["user", "assistant"]
assert fixture["rows"][1]["_error"] is True
return fixture
+14
View File
@@ -0,0 +1,14 @@
{
"issue": 6611,
"rows": [
{
"role": "user",
"content": "same prompt"
},
{
"role": "assistant",
"content": "provider failed",
"_error": true
}
]
}
@@ -13,9 +13,9 @@ NODE = shutil.which("node")
_DRIVER_SRC = r"""
const fs = require('fs');
function extractNewSession(src) {
const start = src.indexOf('async function newSession(');
if (start < 0) throw new Error('newSession not found');
function extractFunction(src, signature) {
const start = src.indexOf(signature);
if (start < 0) throw new Error(signature + ' not found');
let depth = 0;
let bodyStart = src.indexOf('{', src.indexOf(')', start));
for (let i = bodyStart; i < src.length; i++) {
@@ -26,7 +26,7 @@ function extractNewSession(src) {
if (depth === 0) return src.slice(start, i + 1);
}
}
throw new Error('newSession body not closed');
throw new Error(signature + ' body not closed');
}
const src = fs.readFileSync(process.argv[2], 'utf8');
@@ -156,7 +156,8 @@ globalThis.api = async (url, opts) => {
};
};
eval(extractNewSession(src));
eval(extractFunction(src, 'function _adoptRegenerationRevision('));
eval(extractFunction(src, 'async function newSession('));
(async () => {
await newSession(false, {});
+145
View File
@@ -0,0 +1,145 @@
import json
import subprocess
from pathlib import Path
from tests._issue6611_fixture import load_issue6611_fixture
ROOT = Path(__file__).parents[1]
def _start_regeneration_source():
source = (ROOT / "static" / "messages.js").read_text(encoding="utf-8")
start = source.index("async function startRegeneration(")
end = source.index("\nconst LIVE_STREAMS=", start)
return source[start:end]
def _run_node(scenario, *, with_metadata=False):
function_source = _start_regeneration_source()
initial_messages = load_issue6611_fixture()["rows"]
if with_metadata:
initial_messages[0].update({"attachments": ["proof.txt"], "custom": "keep"})
messages_json = json.dumps(initial_messages)
script = f"""
const result={{renders:0,busy:[],attached:[],bodies:[],thinking:0}};
let S={{session:{{session_id:'s1',regeneration_revision:'rev-1'}},messages:{messages_json}}};
const INFLIGHT={{}};
function renderMessages(){{result.renders++;}}
function setBusy(v){{result.busy.push(v);}}
function ensureLiveWorklogShell(){{result.thinking++;}}
function appendThinking(){{result.thinking++;}}
function removeThinking(){{result.thinking--;}}
function setComposerStatus(){{}}
function clearInflightState(){{}}
function markInflight(sid,streamId){{result.marked=[sid,streamId];}}
function saveInflightState(){{}}
function showLiveRunStatus(){{}}
function updateSendBtn(){{}}
function renderSessionList(){{}}
function applySessionTitleUpdate(){{}}
function attachLiveStream(sid,streamId,files){{result.attached.push([sid,streamId,files]);}}
{function_source}
async function api(_path, options){{
result.bodies.push(JSON.parse(options.body));
if('{scenario}'==='reject') throw new Error('typed rejection');
if('{scenario}'==='switch'){{
S.session={{session_id:'s2'}};
S.messages=[{{role:'user',content:'other session'}}];
}}
return {{stream_id:'stream-1',pending_started_at:123,title:'Title'}};
}}
(async()=>{{
try{{await startRegeneration('s1','rev-1');}}catch(error){{result.error=error.message;}}
result.messages=S.messages;
result.session=S.session;
result.inflight=Object.keys(INFLIGHT);
process.stdout.write(JSON.stringify(result));
}})();
"""
completed = subprocess.run(
["node", "-e", script],
cwd=ROOT,
text=True,
capture_output=True,
check=True,
)
return json.loads(completed.stdout)
def test_reporter_flow_keeps_one_prompt_and_adopts_one_accepted_stream():
result = _run_node("success", with_metadata=True)
assert [row["role"] for row in result["messages"]] == ["user"]
assert result["messages"][0]["content"] == "same prompt"
assert result["messages"][0]["attachments"] == ["proof.txt"]
assert result["messages"][0]["custom"] == "keep"
assert result["attached"] == [["s1", "stream-1", []]]
assert result["bodies"] == [
{"session_id": "s1", "regenerate": True, "regeneration_revision": "rev-1"}
]
def test_issue_artifact_regeneration_leaves_one_user_row():
fixture = load_issue6611_fixture()
assert fixture["issue"] == 6611
artifact_rows = fixture["rows"]
result = _run_node("success")
assert [row["role"] for row in result["messages"]].count("user") == 1
assert [row["role"] for row in result["messages"]] == ["user"]
assert result["messages"][0]["content"] == artifact_rows[0]["content"]
def test_normal_full_load_adopts_and_clears_regeneration_revision():
source = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
start = source.index("function _adoptRegenerationRevision(")
end = source.index("\n}\n\nasync function _restoreRememberedNewChatDraftSession", start) + 2
function_source = source[start:end]
script = f"""
let S={{session:{{session_id:'s1',regeneration_revision:'old'}}}};
{function_source}
_adoptRegenerationRevision({{session_id:'s1',regeneration_revision:'fresh'}});
if(S.session.regeneration_revision!=='fresh') throw new Error('fresh revision was not adopted');
_adoptRegenerationRevision({{session_id:'s1'}});
if(Object.prototype.hasOwnProperty.call(S.session,'regeneration_revision')) throw new Error('stale revision survived replacement');
process.stdout.write('revision adoption ok');
"""
result = subprocess.run(["node", "-e", script], cwd=ROOT, text=True, capture_output=True, check=True)
assert result.stdout == "revision adoption ok"
def test_typed_rejection_restores_the_complete_local_transcript():
result = _run_node("reject")
assert [row["role"] for row in result["messages"]] == ["user", "assistant"]
assert result["error"] == "typed rejection"
assert result["busy"][-1] is False
assert result["attached"] == []
def test_delayed_response_never_attaches_to_a_newly_selected_session():
result = _run_node("switch")
assert result["session"]["session_id"] == "s2"
assert result["messages"] == [{"role": "user", "content": "other session"}]
assert result["attached"] == []
def test_regenerate_response_has_no_truncate_or_generic_send_reentry():
source = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
body = source[
source.index("async function regenerateResponse"):
source.index("// postProcessRenderedMessages")
]
assert "startRegeneration(initialSid" in body
assert "/api/session/truncate" not in body
assert "await send(" not in body
def test_regenerate_response_loads_the_full_session_before_requiring_revision():
source = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
body = source[
source.index("async function regenerateResponse"):
source.index("// postProcessRenderedMessages")
]
assert "if(!S.session || S.busy || !S.session.regeneration_revision) return;" not in body
assert body.index("await _ensureAllMessagesLoaded()") < body.index(
"if(!S.session.regeneration_revision)"
)
@@ -0,0 +1,63 @@
import copy
from api.models import Session
from api.session_ops import resolve_regeneration_turn, regeneration_revision
def _session():
return Session(
session_id="issue6611",
messages=[
{
"role": "user",
"content": "same prompt",
"id": "u1",
"_source": "webui",
"timestamp": 123,
"attachments": [
{
"name": "a.txt",
"path": "C:/uploads/a.txt",
"mime": "text/plain",
"size": 7,
"is_image": False,
"upload_id": "upload-1",
}
],
},
{"role": "assistant", "content": "failed"},
],
context_messages=[{"role": "user", "content": "same prompt", "id": "u1"}, {"role": "assistant", "content": "failed"}],
)
def test_regenerate_errored_turn_yields_one_user_row():
session = _session()
turn = resolve_regeneration_turn(session)
assert [row["role"] for row in session.messages[: turn.user_index + 1]] == ["user"]
assert turn.message["id"] == "u1"
def test_retained_row_preserves_attachments_and_identity():
session = _session()
turn = resolve_regeneration_turn(session)
retained = copy.deepcopy(turn.message)
assert retained["id"] == "u1"
assert retained["timestamp"] == 123
assert retained["attachments"] == [
{
"name": "a.txt",
"path": "C:/uploads/a.txt",
"mime": "text/plain",
"size": 7,
"is_image": False,
"upload_id": "upload-1",
}
]
assert regeneration_revision(session)
def test_generic_operations_have_no_regeneration_coordinate():
session = _session()
assert "regeneration_revision" not in session.__dict__
assert session.messages[-1]["content"] == "failed"
@@ -0,0 +1,436 @@
import json
import shutil
import subprocess
from pathlib import Path
import pytest
from api.helpers import redact_session_data
from api.models import Session
from api.session_ops import (
RegenerationUnavailable,
regeneration_authority,
regeneration_revision_for,
regeneration_state,
resolve_regeneration_turn,
)
from api.streaming import _session_payload_with_full_messages
from tests.js_source_extract import extract_function
ROOT = Path(__file__).resolve().parents[1]
def _session():
return Session(
session_id="authority6611",
messages=[{"role": "user", "content": "p", "_source": "webui"}, {"role": "assistant", "content": "a"}],
context_messages=[{"role": "user", "content": "p"}, {"role": "assistant", "content": "a"}],
)
def test_all_terminal_payloads_carry_fresh_revision():
session = _session()
payload = _session_payload_with_full_messages(session)
assert payload["regeneration_revision"] == regeneration_authority(session, rows=session.messages)
assert payload["regeneration_revision"] == regeneration_revision_for(session.messages, session=session, context=session.context_messages)
def test_get_revision_consumer_delegates_imported_ownership_to_shared_authority():
from pathlib import Path
source = (Path(__file__).parents[1] / "api" / "routes.py").read_text(encoding="utf-8")
get_start = source.index("imported_turn_marker = any(")
body = source[get_start:get_start + 900]
assert "regeneration_authority(" in body
assert "imported_turn_marker" in body
def test_private_active_turn_token_never_public():
session = _session()
session.messages[-2]["_active_turn_token"] = "private"
public = redact_session_data(session.compact() | {"messages": session.messages})
assert "_active_turn_token" not in str(public)
def test_private_active_turn_token_is_redacted_in_nested_context_and_journal():
session = _session()
session_dict = session.compact() | {
"messages": session.messages,
"context_messages": [
{
"role": "user",
"content": "p",
"_active_turn_token": "secret",
"_fork_child_turn": "authority6611",
}
],
"runtime_journal_snapshot": {
"context_messages": [
{
"role": "user",
"content": "p",
"_active_turn_token": "secret",
"_fork_child_turn": "authority6611",
}
]
},
}
public = redact_session_data(session_dict)
assert public["context_messages"][0].get("_active_turn_token") is None
assert public["context_messages"][0].get("_fork_child_turn") is None
assert public["runtime_journal_snapshot"]["context_messages"][0].get("_active_turn_token") is None
assert public["runtime_journal_snapshot"]["context_messages"][0].get("_fork_child_turn") is None
def test_public_active_turn_marker_matches_only_the_active_user_row():
from api.process_event_utils import build_active_turn_token
session = _session()
session.active_stream_id = "stream-active"
session.pending_started_at = 123.0
token = build_active_turn_token(session.active_stream_id, session.pending_started_at)
session.messages[0]["_active_turn_token"] = token
session.messages.insert(0, {"role": "user", "content": "older", "_active_turn_token": "other"})
public = redact_session_data(
session.compact()
| {
"active_stream_id": session.active_stream_id,
"pending_started_at": session.pending_started_at,
"messages": session.messages,
"context_messages": [
{"role": "user", "content": "older", "_active_turn_token": "other"},
{"role": "user", "content": "p", "_active_turn_token": token},
],
}
)
assert public["messages"][1]["_active_turn_user"] is True
assert "_active_turn_user" not in public["messages"][0]
assert public["context_messages"][1]["_active_turn_user"] is True
assert "_active_turn_token" not in str(public)
def test_public_active_turn_marker_is_consumed_by_browser_with_timestamp_drift():
node = shutil.which("node")
if node is None:
pytest.skip("node is required for the browser projection probe")
from api.process_event_utils import build_active_turn_token
stream_id = "projection-browser-stream"
started_at = 123.5
token = build_active_turn_token(stream_id, started_at)
public = redact_session_data(
{
"active_stream_id": stream_id,
"pending_started_at": started_at,
"messages": [
{
"role": "user",
"content": "same prompt",
"timestamp": 100.0,
"_active_turn_token": token,
}
],
}
)
projected = public["messages"][0]
assert projected["_active_turn_user"] is True
assert "_active_turn_token" not in projected
ui_source = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
functions = "\n".join(
extract_function(ui_source, name)
for name in (
"_messageTimestampSeconds",
"_activeTurnTokenMatches",
"_pendingActiveTurnUserMessage",
)
)
script = f"""
const _PENDING_ACTIVE_TURN_TS_EPSILON=1e-6;
{functions}
const messages={json.dumps([projected])};
const session={{active_stream_id:{json.dumps(stream_id)},pending_started_at:{started_at + 1}}};
const selected=_pendingActiveTurnUserMessage(messages,session);
if(selected!==messages[0]) throw new Error('projected active row was not selected');
process.stdout.write(JSON.stringify({{marker:selected._active_turn_user,token:selected._active_turn_token||null}}));
"""
result = subprocess.run([node, "-e", script], text=True, capture_output=True, check=True)
assert json.loads(result.stdout) == {"marker": True, "token": None}
def test_parent_or_foreign_source_refuses_authority():
session = _session()
session.messages[-2]["_source"] = "cron"
assert regeneration_authority(session, rows=session.messages) is None
try:
resolve_regeneration_turn(session)
except RegenerationUnavailable as exc:
assert exc.code == "regeneration_read_only"
else:
raise AssertionError("foreign source was accepted")
def test_writable_imported_session_accepts_only_a_marked_final_user_turn(monkeypatch, tmp_path):
from api.process_event_utils import build_active_turn_token
from api import models as models_api
session = _session()
session.session_source = "cli"
session.is_cli_session = True
session.raw_source = "cli"
session.source_tag = "cli"
session.messages[-2]["_source"] = "cli"
session.active_stream_id = "imported-stream"
session.pending_started_at = 123.0
token = build_active_turn_token(session.active_stream_id, session.pending_started_at)
session.active_stream_id = None
session.pending_started_at = None
session.messages[-2]["_active_turn_token"] = token
monkeypatch.setattr(models_api, "SESSION_DIR", tmp_path)
monkeypatch.setattr(models_api, "SESSION_INDEX_FILE", tmp_path / "_index.json")
session.save(touch_updated_at=False)
session = Session.load(session.session_id)
revision = regeneration_authority(session)
assert revision
assert resolve_regeneration_turn(session, expected_revision=revision).message["content"] == "p"
assert _session_payload_with_full_messages(session)["regeneration_revision"] == revision
def test_get_and_terminal_consumers_emit_imported_marked_revision(monkeypatch):
from types import SimpleNamespace
from urllib.parse import urlparse
from api import models as models_api
from api import routes
from api.process_event_utils import build_active_turn_token
session = _session()
session.session_source = "desktop"
session.is_cli_session = True
session.raw_source = "desktop"
session.source_tag = "desktop"
session.messages[-2]["_source"] = "desktop"
session.messages[-2]["_active_turn_token"] = build_active_turn_token("imported-stream", 123.0)
revision = regeneration_authority(session)
captured = {}
def capture(_handler, data, status=200, **_kwargs):
captured["data"] = data
captured["status"] = status
monkeypatch.setattr(routes, "get_session", lambda *_args, **_kwargs: session)
monkeypatch.setattr(routes, "_session_visible_to_active_profile", lambda *_args: True)
monkeypatch.setattr(routes, "_clear_stale_stream_state", lambda *_args: None)
monkeypatch.setattr(routes, "_session_requires_cli_metadata_lookup", lambda *_args: False)
monkeypatch.setattr(routes, "get_state_db_session_messages", lambda *_args, **_kwargs: [])
monkeypatch.setattr(models_api, "get_state_db_session_messages", lambda *_args, **_kwargs: [])
monkeypatch.setattr(routes, "_resolve_effective_session_model_for_display", lambda *_args: None)
monkeypatch.setattr(routes, "_resolve_effective_session_model_provider_for_display", lambda *_args: None)
monkeypatch.setattr(routes, "j", capture)
routes.handle_get(
SimpleNamespace(_safe_webui_print=lambda *_args: None),
urlparse(f"/api/session?session_id={session.session_id}&messages=1&resolve_model=0"),
)
assert captured["status"] == 200
assert captured["data"]["session"]["regeneration_revision"] == revision
assert _session_payload_with_full_messages(session)["regeneration_revision"] == revision
def test_get_and_terminal_consumers_omit_imported_unowned_revision(monkeypatch):
from types import SimpleNamespace
from urllib.parse import urlparse
from api import models as models_api
from api import routes
session = _session()
session.session_source = "desktop"
session.is_cli_session = True
session.raw_source = "desktop"
session.source_tag = "desktop"
session.messages[-2]["_source"] = "desktop"
captured = {}
def capture(_handler, data, status=200, **_kwargs):
captured["data"] = data
captured["status"] = status
monkeypatch.setattr(routes, "get_session", lambda *_args, **_kwargs: session)
monkeypatch.setattr(routes, "_session_visible_to_active_profile", lambda *_args: True)
monkeypatch.setattr(routes, "_clear_stale_stream_state", lambda *_args: None)
monkeypatch.setattr(routes, "_session_requires_cli_metadata_lookup", lambda *_args: False)
monkeypatch.setattr(routes, "get_state_db_session_messages", lambda *_args, **_kwargs: [])
monkeypatch.setattr(models_api, "get_state_db_session_messages", lambda *_args, **_kwargs: [])
monkeypatch.setattr(routes, "_resolve_effective_session_model_for_display", lambda *_args: None)
monkeypatch.setattr(routes, "_resolve_effective_session_model_provider_for_display", lambda *_args: None)
monkeypatch.setattr(routes, "j", capture)
routes.handle_get(
SimpleNamespace(_safe_webui_print=lambda *_args: None),
urlparse(f"/api/session?session_id={session.session_id}&messages=1&resolve_model=0"),
)
assert captured["status"] == 200
assert "regeneration_revision" not in captured["data"]["session"]
assert "regeneration_revision" not in _session_payload_with_full_messages(session)
@pytest.mark.parametrize(
"marker,read_only,expected",
[(None, False, None), ("bad-token", False, None), ("valid-earlier", False, None), ("valid-final", True, None)],
)
def test_imported_turn_matrix_rejects_unowned_or_read_only_rows(marker, read_only, expected):
from api.process_event_utils import build_active_turn_token
session = _session()
session.session_source = "desktop"
session.is_cli_session = True
session.read_only = read_only
valid = build_active_turn_token("imported-stream", 123.0)
if marker == "valid-earlier":
session.messages[-2]["_active_turn_token"] = valid
session.messages.append({"role": "user", "content": "foreign", "_source": "desktop"})
session.messages.append({"role": "assistant", "content": "foreign answer"})
elif marker == "valid-final":
session.messages[-2]["_active_turn_token"] = valid
elif marker:
session.messages[-2]["_active_turn_token"] = marker
assert regeneration_authority(session) is expected
assert "regeneration_revision" not in _session_payload_with_full_messages(session)
def test_fork_child_has_regeneration_authority():
session = _session()
session.session_source = "fork"
session.parent_session_id = "parent-6611"
session.messages[-2]["_fork_child_turn"] = session.session_id
revision = regeneration_authority(session)
assert revision
assert resolve_regeneration_turn(session, expected_revision=revision).source == "webui"
def test_fork_of_fork_copied_parent_marker_refuses_authority():
session = _session()
session.session_source = "fork"
session.parent_session_id = "parent-6611"
session.messages[-2]["_fork_child_turn"] = "parent-6611"
assert regeneration_authority(session) is None
try:
resolve_regeneration_turn(session)
except RegenerationUnavailable as exc:
assert exc.code == "regeneration_read_only"
else:
raise AssertionError("parent fork marker was accepted by child")
def test_current_fork_child_materialization_binds_and_accepts_its_new_turn(monkeypatch):
from api import routes
session = Session(
session_id="fork-child-current-6611",
messages=[
{"role": "user", "content": "parent prompt", "_fork_child_turn": "parent-6611"},
{"role": "assistant", "content": "parent answer"},
],
context_messages=[
{"role": "user", "content": "parent prompt"},
{"role": "assistant", "content": "parent answer"},
],
session_source="fork",
parent_session_id="parent-6611",
)
monkeypatch.setattr(routes, "register_session_writeback_owner", lambda *_args: None)
monkeypatch.setattr(routes, "get_webui_session_save_mode", lambda: "eager")
routes._prepare_chat_start_session_for_stream(
session,
msg="current child prompt",
attachments=[],
workspace="C:/workspace",
model="model",
model_provider="provider",
stream_id="fork-current-stream",
started_at=123.0,
defer_save=True,
)
assert session.messages[-1]["_fork_child_turn"] == session.session_id
session.messages.append({"role": "assistant", "content": "current answer"})
session.active_stream_id = None
session.pending_user_message = None
session.pending_attachments = []
session.pending_started_at = None
session.pending_user_source = None
revision = regeneration_authority(session)
assert revision
assert resolve_regeneration_turn(session, expected_revision=revision).message["content"] == "current child prompt"
def test_fork_without_child_lineage_refuses_authority():
session = _session()
session.session_source = "fork"
session.parent_session_id = None
assert regeneration_authority(session) is None
try:
resolve_regeneration_turn(session)
except RegenerationUnavailable as exc:
assert exc.code == "regeneration_read_only"
else:
raise AssertionError("parent-only fork state was accepted")
def test_authority_withholds_a_noncanonical_display_projection():
session = _session()
canonical_rows, canonical_context = regeneration_state(session)
projected_rows = canonical_rows + [{"role": "tool", "content": "stitched parent"}]
assert regeneration_authority(
session,
rows=projected_rows,
context=canonical_context,
) is None
def test_terminal_payload_embeds_the_rows_it_hashes(monkeypatch):
session = _session()
canonical_rows = [
{"role": "user", "content": "recovered", "_source": "webui"},
{"role": "assistant", "content": "answer"},
]
canonical_context = list(canonical_rows)
monkeypatch.setattr(
"api.session_ops.regeneration_state",
lambda _session: (canonical_rows, canonical_context),
)
payload = _session_payload_with_full_messages(session)
assert payload["messages"] == canonical_rows
assert payload["message_count"] == len(canonical_rows)
assert payload["regeneration_revision"] == regeneration_authority(
session,
rows=canonical_rows,
context=canonical_context,
)
def test_recovered_display_context_pair_survives_local_and_gateway_apply(monkeypatch):
session = _session()
canonical_rows = [
{"role": "user", "content": "recovered", "id": "u-recovered", "_source": "webui"},
{"role": "assistant", "content": "failed"},
]
canonical_context = [
{"role": "system", "content": "recovered context only"},
*canonical_rows,
]
monkeypatch.setattr(
"api.session_ops.regeneration_state",
lambda _session: (canonical_rows, canonical_context),
)
from api.session_ops import apply_regeneration_plan, plan_regeneration
plan = plan_regeneration(session)
assert apply_regeneration_plan(session, plan)
assert session.messages == canonical_rows[:1]
assert any(row.get("content") == "recovered" for row in session.context_messages)
payload = _session_payload_with_full_messages(session)
assert payload["messages"] == canonical_rows
assert payload["message_count"] == len(canonical_rows)
@@ -0,0 +1,67 @@
import copy
import pytest
from api.models import Session
from api.session_ops import RegenerationUnavailable, apply_regeneration_plan, plan_regeneration, regeneration_revision
def _session():
return Session(
session_id="settlement6611",
messages=[
{"role": "user", "content": "old", "id": "u0", "_source": "webui"},
{"role": "assistant", "content": "old answer"},
{"role": "user", "content": "latest", "id": "u1", "_source": "webui"},
{"role": "assistant", "content": "error"},
],
context_messages=[
{"role": "user", "content": "old", "id": "u0"},
{"role": "assistant", "content": "old answer"},
{"role": "user", "content": "latest", "id": "u1"},
{"role": "assistant", "content": "error"},
],
)
def test_recovered_prefix_survives_regeneration_truncation():
session = _session()
plan = plan_regeneration(session)
assert apply_regeneration_plan(session, plan)
assert [row["content"] for row in session.messages] == ["old", "old answer", "latest"]
def test_stale_revision_rejects_without_mutation():
session = _session()
before = copy.deepcopy(session.__dict__)
with pytest.raises(RegenerationUnavailable):
plan_regeneration(session, expected_revision="stale-revision")
assert session.__dict__ == before
def test_double_fire_revision_changes_after_winner():
session = _session()
plan = plan_regeneration(session)
assert apply_regeneration_plan(session, plan)
assert regeneration_revision(session) != plan.revision
def test_trailing_tool_state_is_not_regenerable_as_an_older_exchange():
session = Session(
session_id="settlement6611-tool-tail",
messages=[
{"role": "user", "content": "prompt", "_source": "webui"},
{"role": "assistant", "content": "visible answer"},
{"role": "tool", "content": "unfinished tool result"},
],
context_messages=[
{"role": "user", "content": "prompt"},
{"role": "assistant", "content": "visible answer"},
{"role": "tool", "content": "unfinished tool result"},
],
)
try:
plan_regeneration(session)
except RegenerationUnavailable as exc:
assert exc.code == "no_regenerable_turn"
else:
raise AssertionError("trailing tool state was treated as a completed exchange")
@@ -0,0 +1,573 @@
import copy
import queue
import threading
from types import SimpleNamespace
from pathlib import Path
import pytest
from api.models import Session
from api.session_ops import (
RegenerationUnavailable,
RegenerationPlan,
apply_regeneration_plan,
plan_regeneration,
restore_regeneration_state,
snapshot_regeneration_state,
)
from tests._issue6611_fixture import load_issue6611_fixture
def _session():
rows = [
{"role": "user", "content": "prompt", "id": "u1", "_source": "webui"},
{"role": "assistant", "content": "failed"},
]
return Session(
session_id="transaction6611",
messages=copy.deepcopy(rows),
context_messages=copy.deepcopy(rows),
workspace="C:/workspace",
)
def test_plan_installs_rows_and_context_as_one_prepared_pair():
session = _session()
plan = plan_regeneration(session)
assert isinstance(plan, RegenerationPlan)
assert plan.canonical_rows == session.messages
assert plan.canonical_context == session.context_messages
applied, retained_context_user = apply_regeneration_plan(
session,
plan,
return_context_user=True,
)
assert applied
assert retained_context_user is session.context_messages[0]
assert session.messages == plan.canonical_rows[: plan.truncation_boundary]
assert session.context_messages == plan.canonical_context[: plan.truncation_boundary]
assert any(row.get("content") == "prompt" for row in session.context_messages)
assert [row["role"] for row in session.messages] == ["user"]
def test_apply_consumes_prepared_pair_without_ambient_authority_read(monkeypatch):
session = _session()
plan = plan_regeneration(session)
monkeypatch.setattr(
"api.session_ops.regeneration_state",
lambda _session: (_ for _ in ()).throw(AssertionError("ambient read")),
)
session.messages[0]["content"] = "changed"
assert apply_regeneration_plan(session, plan)
assert session.messages == plan.canonical_rows[: plan.truncation_boundary]
def test_complete_session_snapshot_restores_every_attribute():
session = _session()
session.compression_state = {"marker": "kept"}
session._anchor_scene_index = 7
before = copy.deepcopy(session.__dict__)
snapshot = snapshot_regeneration_state(session)
session.messages.clear()
session.context_messages.clear()
session.compression_state["marker"] = "changed"
session._anchor_scene_index = 99
restore_regeneration_state(session, snapshot)
assert session.__dict__ == before
def test_persisted_preacceptance_rollback_restores_exact_snapshot(monkeypatch):
session = _session()
snapshot = snapshot_regeneration_state(session)
persisted = []
monkeypatch.setattr(Session, "save", lambda self, **_kwargs: persisted.append(copy.deepcopy(self.__dict__)))
session.active_stream_id = "stale"
session.pending_user_message = "changed"
restore_regeneration_state(session, snapshot)
session.save(touch_updated_at=False)
assert persisted == [snapshot]
def test_noop_rejection_does_not_need_persisted_rollback():
session = _session()
snapshot = snapshot_regeneration_state(session)
restore_regeneration_state(session, snapshot)
assert session.__dict__ == snapshot
def test_locked_stale_plan_does_not_restore_a_request_time_snapshot(monkeypatch):
from api import routes
session = _session()
session.active_stream_id = "accepted-after-browser-validation"
current_state = copy.deepcopy(session.__dict__)
def stale_plan(*_args, **_kwargs):
raise RegenerationUnavailable("stale_regeneration_revision")
monkeypatch.setattr("api.session_ops.plan_regeneration", stale_plan)
result = routes._start_regeneration_stream_locked(
session,
turn=SimpleNamespace(revision="old-revision"),
workspace="C:/workspace",
model="model",
model_provider="provider",
normalized_model=False,
diag=None,
goal_related=False,
source="webui",
moa_config=None,
backend_is_gateway=False,
)
assert result["code"] == "stale_regeneration_revision"
assert session.__dict__ == current_state
def test_locked_unexpected_plan_error_does_not_restore_a_request_time_snapshot(monkeypatch):
from api import routes
session = _session()
session.active_stream_id = "accepted-after-browser-validation"
current_state = copy.deepcopy(session.__dict__)
def unexpected_plan(*_args, **_kwargs):
raise RuntimeError("plan failed")
monkeypatch.setattr("api.session_ops.plan_regeneration", unexpected_plan)
with pytest.raises(RuntimeError, match="plan failed"):
routes._start_regeneration_stream_locked(
session,
turn=SimpleNamespace(revision="old-revision"),
workspace="C:/workspace",
model="model",
model_provider="provider",
normalized_model=False,
diag=None,
goal_related=False,
source="webui",
moa_config=None,
backend_is_gateway=False,
)
assert session.__dict__ == current_state
def test_locked_preacceptance_exception_restores_the_transaction_snapshot(monkeypatch):
from api import routes
session = _session()
plan = plan_regeneration(session)
before = copy.deepcopy(session.__dict__)
def fail_prepare(*_args, **_kwargs):
raise RuntimeError("prepare failed")
monkeypatch.setattr(routes, "_prepare_chat_start_session_for_stream", fail_prepare)
with pytest.raises(RuntimeError, match="prepare failed"):
routes._start_regeneration_stream_locked(
session,
turn=plan.turn,
workspace="C:/workspace",
model="model",
model_provider="provider",
normalized_model=False,
diag=None,
goal_related=False,
source="webui",
moa_config=None,
backend_is_gateway=False,
)
assert session.__dict__ == before
def test_locked_postacceptance_workspace_exception_does_not_restore_turn(monkeypatch):
from api import routes
import api.turn_journal as turn_journal
session = _session()
plan = plan_regeneration(session)
monkeypatch.setattr(routes, "register_session_writeback_owner", lambda *_args: None)
monkeypatch.setattr(routes, "clear_session_writeback_owner_if_owned", lambda *_args: None)
monkeypatch.setattr(routes, "register_stream_owner", lambda *_args: None)
monkeypatch.setattr(routes, "unregister_stream_owner", lambda *_args: None)
monkeypatch.setattr(routes, "create_stream_channel", lambda: queue.Queue())
monkeypatch.setattr(turn_journal, "append_turn_journal_event", lambda *_args, **_kwargs: {"turn_id": "turn-6611"})
monkeypatch.setattr(Session, "save", lambda *_args, **_kwargs: None)
monkeypatch.setattr(routes, "set_last_workspace", lambda *_args: (_ for _ in ()).throw(RuntimeError("workspace failed")))
class FakeThread:
def __init__(self, *args, **kwargs):
pass
def start(self):
return None
def join(self, timeout=None):
return None
monkeypatch.setattr(routes.threading, "Thread", FakeThread)
before = copy.deepcopy(session.__dict__)
with pytest.raises(RuntimeError, match="workspace failed") as raised:
routes._start_regeneration_stream_locked(
session,
turn=plan.turn,
workspace="C:/workspace",
model="model",
model_provider="provider",
normalized_model=False,
diag=None,
goal_related=False,
source="webui",
moa_config=None,
backend_is_gateway=False,
)
assert raised.value._regeneration_accepted is True
assert session.active_stream_id is not None
assert session.__dict__ != before
def test_chat_start_losing_regeneration_preserves_locked_send_winner(monkeypatch, tmp_path):
from api import routes
from api import models as models_api
import api.runtime_adapter as runtime_adapter
session = _session()
session.session_id = "route-race-6611"
session.model_explicit_pick_signature = "before-regeneration"
revision = plan_regeneration(session).revision
monkeypatch.setattr(models_api, "SESSION_DIR", tmp_path)
monkeypatch.setattr(models_api, "SESSION_INDEX_FILE", tmp_path / "_index.json")
monkeypatch.setattr(runtime_adapter, "runtime_adapter_runner_enabled", lambda: False)
monkeypatch.setattr(routes, "_get_or_materialize_session", lambda *_args, **_kwargs: session)
monkeypatch.setattr(routes, "_agent_runtime_barrier_response", lambda **_kwargs: None)
monkeypatch.setattr(routes, "_session_visible_to_active_profile", lambda *_args: True)
monkeypatch.setattr(routes, "_get_active_profile_name", lambda: "default")
monkeypatch.setattr(routes, "_read_profile_model_config", lambda *_args: (None, None, {}))
monkeypatch.setattr(routes, "_resolve_chat_workspace_for_regeneration", lambda *_args: "C:/workspace")
monkeypatch.setattr(routes, "get_config_snapshot", lambda: {})
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda *_args: False)
monkeypatch.setattr(routes, "_resolve_compatible_session_model_state", lambda *_args, **_kwargs: ("model", "provider", False))
monkeypatch.setattr(routes, "_repair_foreign_session_model_provider", lambda *_args, **_kwargs: "provider")
monkeypatch.setattr(routes, "compression_recovery_payload_for_session", lambda *_args: None)
winner_started = threading.Event()
winner_finished = threading.Event()
def lose_after_locked_send(*_args, **_kwargs):
def normal_send_winner():
with routes._get_session_agent_lock(session.session_id):
session.active_stream_id = "winner-stream"
session.pending_user_message = "winner prompt"
session.pending_started_at = 222.0
session.pending_user_source = "webui"
session.save(touch_updated_at=False)
winner_started.set()
winner_finished.set()
thread = threading.Thread(target=normal_send_winner)
thread.start()
assert winner_started.wait(1)
assert winner_finished.wait(1)
thread.join(timeout=1)
return {
"error": "stale regeneration revision",
"code": "stale_regeneration_revision",
"_status": 409,
}
captured = {}
monkeypatch.setattr(routes, "_start_run", lose_after_locked_send)
monkeypatch.setattr(
routes,
"j",
lambda _handler, data, status=200, **_kwargs: captured.update(data=data, status=status),
)
routes._handle_chat_start(
None,
{
"session_id": session.session_id,
"regenerate": True,
"regeneration_revision": revision,
"explicit_model_pick": True,
},
)
assert captured["status"] == 409
assert (
session.active_stream_id,
session.pending_user_message,
session.pending_started_at,
session.pending_user_source,
) == ("winner-stream", "winner prompt", 222.0, "webui")
assert session.model_explicit_pick_signature == "before-regeneration"
reloaded = Session.load(session.session_id)
assert (
reloaded.active_stream_id,
reloaded.pending_user_message,
reloaded.pending_started_at,
reloaded.pending_user_source,
) == ("winner-stream", "winner prompt", 222.0, "webui")
def test_regeneration_lock_winner_blocks_a_later_normal_send(monkeypatch):
from api import routes
session = _session()
accepted = threading.Event()
normal_checked = threading.Event()
normal_result = {}
monkeypatch.setattr(routes, "_agent_runtime_barrier_response", lambda **_kwargs: None)
monkeypatch.setattr(routes, "_active_run_stream_for_session", lambda *_args: None)
def accepted_regeneration(value, **_kwargs):
value.active_stream_id = "accepted-regeneration"
value.pending_user_message = "regenerated prompt"
accepted.set()
def ordinary_send_observer():
with routes._get_session_agent_lock(value.session_id):
normal_result["active_stream_id"] = value.active_stream_id
normal_result["pending_user_message"] = value.pending_user_message
normal_checked.set()
thread = threading.Thread(target=ordinary_send_observer)
thread.start()
assert not normal_checked.wait(0.05)
return {"stream_id": "accepted-regeneration", "session_id": value.session_id}
monkeypatch.setattr(routes, "_start_regeneration_stream_locked", accepted_regeneration)
result = routes._start_chat_stream_for_session(
session,
msg="ignored",
attachments=[],
workspace="C:/workspace",
model="model",
model_provider="provider",
external_runtime_owned=False,
regeneration=SimpleNamespace(revision="revision"),
)
assert accepted.is_set()
assert result["stream_id"] == "accepted-regeneration"
assert normal_checked.wait(1)
assert normal_result == {
"active_stream_id": "accepted-regeneration",
"pending_user_message": "regenerated prompt",
}
def test_prepare_mirrors_active_turn_token_to_context_before_timestamp_mutation(monkeypatch):
from api import routes
session = _session()
retained_user = session.messages[0]
session.context_messages = [
{"role": "user", "content": "prompt"},
{"role": "user", "content": "prompt"},
]
retained_context_user = session.context_messages[1]
monkeypatch.setattr(routes, "register_session_writeback_owner", lambda *_args: None)
routes._prepare_chat_start_session_for_stream(
session,
msg="prompt",
attachments=[],
workspace="C:/workspace",
model="model",
model_provider="provider",
stream_id="token-parity-stream",
started_at=99.0,
retained_user=retained_user,
retained_context_user=retained_context_user,
defer_save=True,
)
assert retained_user["timestamp"] == 99.0
assert "timestamp" not in session.context_messages[0]
assert "_active_turn_token" not in session.context_messages[0]
assert retained_context_user["timestamp"] == 99.0
assert retained_context_user["_active_turn_token"] == retained_user["_active_turn_token"]
def test_prepare_marks_new_fork_turn_in_eager_materialization(monkeypatch):
from api import routes
from api.streaming import _materialize_active_turn_user
session = Session(
session_id="fork-prepare-6611",
messages=[],
context_messages=[],
session_source="fork",
parent_session_id="parent-6611",
)
monkeypatch.setattr(routes, "register_session_writeback_owner", lambda *_args: None)
monkeypatch.setattr(routes, "get_webui_session_save_mode", lambda: "eager")
routes._prepare_chat_start_session_for_stream(
session,
msg="new fork prompt",
attachments=[],
workspace="C:/workspace",
model="model",
model_provider="provider",
stream_id="fork-prepare-stream",
started_at=99.0,
defer_save=True,
)
assert len(session.messages) == 1
assert session.messages[0]["_fork_child_turn"] == session.session_id
deferred = Session(
session_id="fork-deferred-6611",
messages=[],
context_messages=[],
session_source="fork",
parent_session_id="parent-6611",
)
routes._prepare_chat_start_session_for_stream(
deferred,
msg="deferred fork prompt",
attachments=[],
workspace="C:/workspace",
model="model",
model_provider="provider",
stream_id="fork-deferred-stream",
started_at=100.0,
defer_save=True,
)
materialized = _materialize_active_turn_user(
{
"text": deferred.pending_user_message,
"source": deferred.pending_user_source,
"timestamp": deferred.pending_started_at,
"session_id": deferred.session_id,
},
deferred.pending_user_message,
deferred.pending_user_source,
)
assert materialized["_fork_child_turn"] == deferred.session_id
def test_issue_artifact_rows_follow_production_regeneration_and_error_settlement(monkeypatch, tmp_path):
"""Exercise production plan/apply/prepare/materializer/save-reload helpers, not HTTP integration."""
from api import routes
from api import models as models_api
from api.session_ops import apply_regeneration_plan, plan_regeneration
from api.streaming import _materialize_pending_user_turn_before_error
rows = load_issue6611_fixture()["rows"]
session = Session(
session_id="artifact-production-6611",
messages=copy.deepcopy(rows),
context_messages=copy.deepcopy(rows),
workspace="C:/workspace",
)
plan = plan_regeneration(session)
assert apply_regeneration_plan(session, plan)
monkeypatch.setattr(routes, "register_session_writeback_owner", lambda *_args: None)
routes._prepare_chat_start_session_for_stream(
session,
msg=rows[0]["content"],
attachments=[],
workspace="C:/workspace",
model="model",
model_provider="provider",
stream_id="artifact-production-stream",
started_at=99.0,
retained_user=session.messages[-1],
defer_save=True,
)
assert [row["role"] for row in session.messages].count("user") == 1
assert session.messages[0]["content"] == rows[0]["content"]
assert _materialize_pending_user_turn_before_error(session) is False
session.active_stream_id = None
session.pending_user_message = None
session.pending_attachments = []
session.pending_started_at = None
session.pending_user_source = None
session.messages.append(copy.deepcopy(rows[1]))
assert [row["role"] for row in session.messages] == ["user", "assistant"]
assert [row["content"] for row in session.messages if row["role"] == "user"] == [rows[0]["content"]]
monkeypatch.setattr(models_api, "SESSION_DIR", tmp_path)
monkeypatch.setattr(models_api, "SESSION_INDEX_FILE", tmp_path / "_index.json")
session.save(touch_updated_at=False)
reloaded = Session.load(session.session_id)
assert reloaded is not None
assert [row["role"] for row in reloaded.messages] == ["user", "assistant"]
assert [row["content"] for row in reloaded.messages if row["role"] == "user"] == [rows[0]["content"]]
def test_locked_start_always_replans_after_browser_validation():
source = (Path(__file__).parents[1] / "api" / "routes.py").read_text(encoding="utf-8")
start = source.index("def _start_regeneration_stream_locked(")
end = source.index("def _active_run_stream_for_session", start)
body = source[start:end]
assert "plan = plan_regeneration(" in body
assert "expected_revision=turn.revision" in body
assert "lock_held=True" in body
def test_regeneration_preview_has_no_request_snapshot_or_outer_restore_owner():
source = (Path(__file__).parents[1] / "api" / "routes.py").read_text(encoding="utf-8")
chat_start = source.index("def _handle_chat_start(")
chat_end = source.index("def _resolve_chat_workspace_with_recovery", chat_start)
body = source[chat_start:chat_end]
assert "regeneration_snapshot = snapshot_regeneration_state" not in body
assert "transaction_snapshot" not in body
assert "_restore_regeneration_preacceptance" not in body
regeneration_preview = body[body.index('if body.get("regenerate") is True:'):body.index('diag.stage("normalize_message")')]
assert "_clear_stale_stream_state" not in regeneration_preview
assert "model_explicit_pick_signature" not in regeneration_preview
def test_concurrent_normal_winner_survives_regeneration_409_in_memory_and_after_reload(monkeypatch, tmp_path):
from api import routes
from api import models as models_api
session = _session()
session.session_id = "race-6611"
session.active_stream_id = "stale-stream"
session.pending_user_message = "stale prompt"
session.pending_started_at = 111.0
session.pending_user_source = "webui"
monkeypatch.setattr(models_api, "SESSION_DIR", tmp_path)
monkeypatch.setattr(models_api, "SESSION_INDEX_FILE", tmp_path / "_index.json")
session.save(touch_updated_at=False)
winner_started = threading.Event()
winner_finished = threading.Event()
def stale_plan(*_args, **_kwargs):
def normal_winner():
winner_started.set()
session.active_stream_id = "winner-stream"
session.pending_user_message = "winner prompt"
session.pending_started_at = 222.0
session.pending_user_source = "webui"
session.save(touch_updated_at=False)
winner_finished.set()
threading.Thread(target=normal_winner).start()
assert winner_started.wait(1)
assert winner_finished.wait(1)
raise RegenerationUnavailable("stale_regeneration_revision")
monkeypatch.setattr("api.session_ops.plan_regeneration", stale_plan)
result = routes._start_regeneration_stream_locked(
session,
turn=SimpleNamespace(revision="old-revision"),
workspace="C:/workspace",
model="model",
model_provider="provider",
normalized_model=False,
diag=None,
goal_related=False,
source="webui",
moa_config=None,
backend_is_gateway=False,
)
assert result["_status"] == 409
assert (session.active_stream_id, session.pending_user_message, session.pending_started_at, session.pending_user_source) == (
"winner-stream", "winner prompt", 222.0, "webui"
)
reloaded = Session.load(session.session_id)
assert (reloaded.active_stream_id, reloaded.pending_user_message, reloaded.pending_started_at, reloaded.pending_user_source) == (
"winner-stream", "winner prompt", 222.0, "webui"
)
@@ -28,10 +28,11 @@ def test_submit_edit_uses_absolute_keep_count():
assert "keep_count: absoluteKeepCount" in body
def test_regenerate_uses_absolute_keep_count():
def test_regenerate_delegates_to_atomic_start_without_client_truncation():
body = _function_body(UI_JS, "regenerateResponse")
assert re.search(r"absoluteKeepCount\s*=\s*_oldestIdx\s*\+\s*assistantIdx", body)
assert "keep_count: absoluteKeepCount" in body
assert "await startRegeneration(initialSid, S.session.regeneration_revision)" in body
assert "/api/session/truncate" not in body
assert "await send()" not in body
def test_submit_edit_captures_absolute_before_await():
@@ -39,4 +40,4 @@ def test_submit_edit_captures_absolute_before_await():
cap = re.search(r"absoluteKeepCount\s*=\s*_oldestIdx\s*\+\s*msgIdx", body)
assert cap
first_await = re.search(r"\bawait\b", body)
assert first_await and cap.start() < first_await.start()
assert first_await and cap.start() < first_await.start()