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, model_provider=None,
goal_related=False, goal_related=False,
regeneration=False,
): ):
"""Bridge a WebUI chat turn through Hermes Gateway's API server. """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 # same sort key; later transcript merges can then fall back to
# role/content ordering instead of turn order. # role/content ordering instead of turn order.
assistant_ts = now + 0.000001 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" pending_source = getattr(s, "pending_user_source", None) or "webui"
if pending_source != "webui": from api.streaming import _active_turn_authority, _materialize_active_turn_user
user_msg["_source"] = pending_source
if attachments: active_turn_identity = _active_turn_authority(s, stream_id, msg_text)
user_msg["attachments"] = list(attachments) 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} assistant_msg = {"role": "assistant", "content": assistant_text, "timestamp": assistant_ts}
saved_reasoning = STREAM_REASONING_TEXT.get(stream_id, "") saved_reasoning = STREAM_REASONING_TEXT.get(stream_id, "")
if saved_reasoning: if saved_reasoning:
assistant_msg["reasoning"] = saved_reasoning assistant_msg["reasoning"] = saved_reasoning
previous_messages = list(getattr(s, "messages", None) or []) 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 {}) previous_process_wakeup_pause = dict(getattr(s, "process_wakeup_pause", {}) or {})
# Stamp stable ids on the two new rows (shared with the display merge # 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 # 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", "_state_db_row_id",
"_db_row_id", "_db_row_id",
"state_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 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.""" """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] message = scrub_internal_replay_fields([message], message_records=True)[0]
if not isinstance(message, dict): if not isinstance(message, dict):
return _redact_value(message, _enabled=_enabled) return _redact_value(message, _enabled=_enabled)
@@ -1131,13 +1140,22 @@ def _public_message_projection(message, *, _enabled: bool):
] ]
else: else:
item[key] = _redact_value(value, _enabled=_enabled) item[key] = _redact_value(value, _enabled=_enabled)
if is_active:
item["_active_turn_user"] = True
return item return item
def _redact_messages(messages, *, _enabled: bool): def _redact_messages(messages, *, _enabled: bool, _active_turn_token=None):
if not isinstance(messages, list): if not isinstance(messages, list):
return _redact_value(messages, _enabled=_enabled) 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): 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) return _redact_value(scrubbed, _enabled=_enabled)
result = {} result = {}
for key, child in scrubbed.items(): 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) result[key] = _redact_messages(child, _enabled=_enabled)
elif key == "tool_calls" and isinstance(child, list): elif key == "tool_calls" and isinstance(child, list):
result[key] = _redact_tool_calls(child, _enabled=_enabled) 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: else:
result[key] = _redact_value(child, _enabled=_enabled) result[key] = _redact_value(child, _enabled=_enabled)
return result return result
@@ -1193,30 +1213,21 @@ def _copy_json_value(value):
def redact_session_data(session_dict: dict) -> dict: def redact_session_data(session_dict: dict) -> dict:
"""Redact credentials from message content, tool data, and session sidecars. """Redact credentials in the public session response without mutation."""
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.
"""
from api.config import load_settings from api.config import load_settings
_enabled = bool(load_settings().get("api_redact_enabled", True)) _enabled = bool(load_settings().get("api_redact_enabled", True))
if not isinstance(session_dict, dict): if not isinstance(session_dict, dict):
return {} return {}
result = {} 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(): for key, value in session_dict.items():
if key in _PUBLIC_MESSAGE_INTERNAL_FIELDS: if key in _PUBLIC_MESSAGE_INTERNAL_FIELDS:
continue continue
if key == 'title' and isinstance(value, str): if key == 'title' and isinstance(value, str):
result[key] = _redact_text(value, _enabled=_enabled) result[key] = _redact_text(value, _enabled=_enabled)
elif key in {'messages', 'context_messages'}: 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): elif key == 'tool_calls' and isinstance(value, list):
result[key] = _redact_tool_calls(value, _enabled=_enabled) result[key] = _redact_tool_calls(value, _enabled=_enabled)
elif key in {'todo_state', 'runtime_journal_snapshot'}: elif key in {'todo_state', 'runtime_journal_snapshot'}:
+398 -13
View File
@@ -2889,6 +2889,7 @@ from api.config import (
ACTIVE_RUNS_LOCK, ACTIVE_RUNS_LOCK,
register_stream_owner, register_stream_owner,
register_session_writeback_owner, register_session_writeback_owner,
clear_session_writeback_owner_if_owned,
stream_owner_session_id, stream_owner_session_id,
unregister_stream_owner, unregister_stream_owner,
CHAT_LOCK, CHAT_LOCK,
@@ -13208,6 +13209,26 @@ def handle_get(handler, parsed) -> bool:
): ):
raw["is_cli_session"] = False raw["is_cli_session"] = False
raw["read_only"] = True 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) redact = redact_session_data(raw)
_t5 = _time.monotonic() _t5 = _time.monotonic()
if _diag: _diag.stage("t5_after_redact") 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()) latest_text = " ".join(str(latest.get("content") or "").split())
msg_text = " ".join(str(msg or "").split()) msg_text = " ".join(str(msg or "").split())
if latest_text == msg_text: if latest_text == msg_text:
if str(source or "").strip().lower() == "fork":
latest["_fork_child_turn"] = s.session_id
return return
user_msg = {"role": "user", "content": msg} user_msg = {"role": "user", "content": msg}
from api.process_event_utils import build_active_turn_token, stamp_message_source 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, source,
active_turn_token=build_active_turn_token(getattr(s, "active_stream_id", None), started_at), 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: if isinstance(started_at, (int, float)) and started_at > 0:
user_msg["timestamp"] = float(started_at) user_msg["timestamp"] = float(started_at)
if attachments: 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 return title_from([{"role": "user", "content": text}], fallback) or fallback
_RETAINED_CONTEXT_USER_UNSET = object()
def _prepare_chat_start_session_for_stream( def _prepare_chat_start_session_for_stream(
s, s,
*, *,
@@ -21810,6 +21838,9 @@ def _prepare_chat_start_session_for_stream(
stream_id: str, stream_id: str,
started_at: float | None = None, started_at: float | None = None,
source: str = "webui", 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. """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 a normal session message. Empty sessions are never saved here because this
helper only runs after a non-empty message is validated. 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.workspace = workspace
s.model = model s.model = model
s.model_provider = model_provider s.model_provider = model_provider
@@ -21829,21 +21865,62 @@ def _prepare_chat_start_session_for_stream(
s.pending_user_message = msg s.pending_user_message = msg
s.pending_attachments = attachments s.pending_attachments = attachments
s.pending_started_at = started_at if started_at is not None else time.time() 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) 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") provisional_title = _provisional_title_from_prompt(msg, current_title or "Untitled")
if provisional_title and not _is_default_or_empty_session_title(provisional_title): if provisional_title and not _is_default_or_empty_session_title(provisional_title):
s.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( _checkpoint_user_message_for_eager_session_save(
s, s,
msg, msg,
attachments, attachments,
s.pending_started_at, s.pending_started_at,
source=source, source=effective_source,
) )
s.save() if not defer_save:
s.save()
def _is_hidden_empty_session(s) -> bool: 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 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: 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. """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", source: str = "webui",
moa_config=None, moa_config=None,
external_runtime_owned: bool | None = None, external_runtime_owned: bool | None = None,
regeneration=None,
): ):
"""Persist pending state, register an SSE channel, and start an agent turn.""" """Persist pending state, register an SSE channel, and start an agent turn."""
if external_runtime_owned is None: if external_runtime_owned is None:
@@ -22092,6 +22411,20 @@ def _start_chat_stream_for_session(
"_status": 409, "_status": 409,
} }
needs_stale_cleanup = False 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 stream_id = uuid.uuid4().hex
diag.stage("save_pending_state") if diag else None diag.stage("save_pending_state") if diag else None
was_hidden_empty_session = _is_hidden_empty_session(s) 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",
"effective_model_provider", "effective_model_provider",
"error", "error",
"code",
"active_stream_id", "active_stream_id",
"_status", "_status",
): ):
@@ -22258,6 +22592,7 @@ def _start_run(
diag=None, diag=None,
moa_config=None, moa_config=None,
gateway_chat_enabled: bool | None = None, gateway_chat_enabled: bool | None = None,
regeneration=None,
): ):
"""Shared start-run helper for /api/chat/start and start_session_turn. """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 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: def _legacy_start_run(request: StartRunRequest) -> dict:
return _start_chat_stream_for_session( return _start_chat_stream_for_session(
s, s,
@@ -22299,6 +22636,7 @@ def _start_run(
source=request.source or source, source=request.source or source,
moa_config=moa_config, moa_config=moa_config,
external_runtime_owned=gateway_chat_enabled, external_runtime_owned=gateway_chat_enabled,
regeneration=regeneration,
) )
def _legacy_adapter_factory(): def _legacy_adapter_factory():
@@ -22340,6 +22678,7 @@ def _start_run(
source=source, source=source,
moa_config=moa_config, moa_config=moa_config,
external_runtime_owned=gateway_chat_enabled, 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": "suppressed", "reason": "silent_control_message"},
status=200, 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 # Reject a stale local Agent runtime before materialising, claiming, or
# mutating any session state. Gateway-backed turns run in the gateway's # mutating any session state. Gateway-backed turns run in the gateway's
# process and do not depend on this WebUI process's imported checkout. # 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) return j(handler, stale_response, status=409)
diag.stage("get_session") if diag else None diag.stage("get_session") if diag else None
try: 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: except KeyError:
# No WebUI sidecar. If this is a foreign-origin session (CLI, # No WebUI sidecar. If this is a foreign-origin session (CLI,
# TUI, Desktop) with recoverable state.db messages, claim it by # 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 s.profile = requested_profile
else: else:
return bad(handler, "Session not found", 404) 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 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: if not msg:
return bad(handler, "message is required") return bad(handler, "message is required")
diag.stage("normalize_attachments") if diag else None 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) recovery = compression_recovery_payload_for_session(s)
if recovery and not attachments and is_generic_continuation_intent(msg): if recovery and not attachments and is_generic_continuation_intent(msg):
return j( return j(
@@ -23113,7 +23482,10 @@ def _handle_chat_start(handler, body, diag=None):
) )
diag.stage("resolve_workspace") if diag else None diag.stage("resolve_workspace") if diag else None
try: 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: except WorkspaceBindingPersistenceError as e:
return bad(handler, str(e), 500) return bad(handler, str(e), 500)
except ValueError as e: 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 # compares). This survives same-model follow-up sends (the onchange marker
# is one-shot) yet can't outlive a real switch. # is one-shot) yet can't outlive a real switch.
try: try:
if explicit_model_pick: if explicit_model_pick and regeneration is None:
from api.models import model_explicit_pick_signature as _mk_sig from api.models import model_explicit_pick_signature as _mk_sig
s.model_explicit_pick_signature = _mk_sig(model, model_provider) s.model_explicit_pick_signature = _mk_sig(model, model_provider)
except Exception: except Exception:
@@ -23215,6 +23587,7 @@ def _handle_chat_start(handler, body, diag=None):
"route": "/api/chat/start", "route": "/api/chat/start",
"diag": diag, "diag": diag,
"gateway_chat_enabled": gateway_chat_enabled, "gateway_chat_enabled": gateway_chat_enabled,
"regeneration": regeneration,
} }
if not gateway_chat_enabled and moa_config is not None: if not gateway_chat_enabled and moa_config is not None:
start_run_kwargs["moa_config"] = moa_config start_run_kwargs["moa_config"] = moa_config
@@ -23231,7 +23604,7 @@ def _handle_chat_start(handler, body, diag=None):
return restore_err return restore_err
return None return None
if recovery: if recovery and regeneration is None:
recovery_cleared_for_start = copy.deepcopy(recovery) recovery_cleared_for_start = copy.deepcopy(recovery)
clear_compression_recovery(s) clear_compression_recovery(s)
try: try:
@@ -23239,8 +23612,9 @@ def _handle_chat_start(handler, body, diag=None):
s, s,
**start_run_kwargs, **start_run_kwargs,
) )
except Exception: except Exception as exc:
_restore_cleared_recovery() if not getattr(exc, "_regeneration_accepted", False):
_restore_cleared_recovery()
raise raise
# Map adapter-selection NotImplementedError (501) onto the legacy # Map adapter-selection NotImplementedError (501) onto the legacy
# bad-request response shape that this route exposed historically # 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) 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): def _normalize_chat_attachments(raw_attachments):
"""Normalize attachment payloads from the browser. """Normalize attachment payloads from the browser.
+394 -12
View File
@@ -9,17 +9,382 @@ from __future__ import annotations
import json import json
import logging import logging
import uuid import uuid
import copy
import hashlib
import math
from dataclasses import dataclass
from contextlib import nullcontext
from bisect import bisect_left from bisect import bisect_left
from typing import Any from typing import Any
from api.config import LOCK, _get_session_agent_lock from api.config import LOCK, _get_session_agent_lock
from api.models import get_session, SESSIONS from api.models import get_session, SESSIONS
from api.agent_sessions import normalize_agent_session_source
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
AUTO_TITLE_LABELS = {'untitled', 'new chat'} 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: def _live_active_stream_id(session) -> str | None:
"""Return session.active_stream_id ONLY if that stream is live in THIS """Return session.active_stream_id ONLY if that stream is live in THIS
process; else None. process; else None.
@@ -119,16 +484,21 @@ def truncate_context_for_display_keep(
context_messages: list | None, context_messages: list | None,
full_messages: list | None, full_messages: list | None,
keep: int, keep: int,
*,
return_boundary_index: bool = False,
) -> list: ) -> list:
"""Align model context with display prefix ``full_messages[:keep]``.""" """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: if keep <= 0:
return [] return _result([])
ctx = context_messages if isinstance(context_messages, list) else [] ctx = context_messages if isinstance(context_messages, list) else []
msgs = full_messages if isinstance(full_messages, list) else [] msgs = full_messages if isinstance(full_messages, list) else []
if not ctx: if not ctx:
return [] return _result([])
if len(msgs) == 0: if len(msgs) == 0:
return [] return _result([])
# Only the perfectly-parallel case (display and context row-for-row) can be # 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 — # sliced at the raw display index. When the two arrays differ in length —
# in EITHER direction — they have diverged and need alignment: # 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 # strips unanswered tool_calls; gateway: it forwards no tool_calls/tool rows
# at all), so we do not re-do that trimming here. # at all), so we do not re-do that trimming here.
if len(ctx) == len(msgs): 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: def _row_signature(row: Any) -> tuple[str, ...] | None:
if not isinstance(row, dict): if not isinstance(row, dict):
@@ -374,8 +744,8 @@ def truncate_context_for_display_keep(
and isinstance(msgs[keep - 1], dict) and isinstance(msgs[keep - 1], dict)
and msgs[keep - 1].get('role') == 'user' and msgs[keep - 1].get('role') == 'user'
): ):
return ctx[:last_kept + 1] return _result(ctx[:last_kept + 1], last_kept)
return ctx[:first_unkept] return _result(ctx[:first_unkept], first_unkept - 1)
if last_kept is not None: if last_kept is not None:
ambiguous_first_unkept = ambiguous_matches[keep] ambiguous_first_unkept = ambiguous_matches[keep]
if ( if (
@@ -383,8 +753,8 @@ def truncate_context_for_display_keep(
and isinstance(msgs[keep - 1], dict) and isinstance(msgs[keep - 1], dict)
and msgs[keep - 1].get('role') != 'user' and msgs[keep - 1].get('role') != 'user'
): ):
return ctx[:ambiguous_first_unkept] return _result(ctx[:ambiguous_first_unkept], ambiguous_first_unkept - 1)
return ctx[:last_kept + 1] return _result(ctx[:last_kept + 1], last_kept)
# Both boundary rows were ambiguous/unmatched (common in large sessions # Both boundary rows were ambiguous/unmatched (common in large sessions
# where context rows have lost their id/timestamp so the matcher can't # 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): for i in range(keep - 1, -1, -1):
resolved = matches[i] if matches[i] is not None else ambiguous_matches[i] resolved = matches[i] if matches[i] is not None else ambiguous_matches[i]
if resolved is not None: if resolved is not None:
return ctx[:resolved + 1] return _result(ctx[:resolved + 1], resolved)
# Final fallback preserves #5096 behavior when alignment is unreliable # Final fallback preserves #5096 behavior when alignment is unreliable
# (no display row resolved to a context index, or keep >= len(msgs)). # (no display row resolved to a context index, or keep >= len(msgs)).
prefix_len = max(0, len(ctx) - len(msgs)) prefix_len = max(0, len(ctx) - len(msgs))
prefix = ctx[:prefix_len] prefix = ctx[:prefix_len]
suffix = 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]: 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): if isinstance(content, list):
parts = [] parts = []
for p in content: for p in content:
if isinstance(p, dict) and p.get('type') == 'text': if not isinstance(p, dict):
parts.append(p.get('text', '')) 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 ' '.join(parts)
return str(content) 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) attach_todo_state(raw, messages)
if tool_calls is not None: if tool_calls is not None:
raw['tool_calls'] = tool_calls 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 return raw
@@ -1530,12 +1553,29 @@ def _clean_synthetic_control_messages_with_provenance(messages):
def _active_turn_authority(session, stream_id, msg_text): def _active_turn_authority(session, stream_id, msg_text):
"""Capture the stream-owned pending turn before settlement mutates it.""" """Capture the stream-owned pending turn before settlement mutates it."""
pending_text = getattr(session, 'pending_user_message', None) 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 { 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, 'text': pending_text if pending_text is not None else msg_text,
'timestamp': getattr(session, 'pending_started_at', None), 'timestamp': getattr(session, 'pending_started_at', None),
'source': getattr(session, 'pending_user_source', None) or 'webui', 'source': getattr(session, 'pending_user_source', None) or 'webui',
'attachments': copy.deepcopy(getattr(session, 'pending_attachments', None) or []), 'attachments': copy.deepcopy(getattr(session, 'pending_attachments', None) or []),
'checkpoint': checkpoint,
'current_turn_user_idx': None, 'current_turn_user_idx': None,
'turn_id': '', 'turn_id': '',
} }
@@ -1628,7 +1668,7 @@ def _mark_active_turn_checkpoint_in_history(messages, identity, msg_text, *, all
if ( if (
not isinstance(message, dict) not isinstance(message, dict)
or message.get('role') != 'user' 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 return messages, False
_mark_active_turn_checkpoint(message, identity) _mark_active_turn_checkpoint(message, identity)
@@ -1675,19 +1715,24 @@ def _find_active_turn_checkpoint_index(result_messages, previous_context, identi
if ( if (
isinstance(message, dict) isinstance(message, dict)
and message.get('role') == 'user' 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 idx
return None return None
def _materialize_active_turn_user(identity, msg_text, source): def _materialize_active_turn_user(identity, msg_text, source):
message = { checkpoint = identity.get('checkpoint') if isinstance(identity, dict) else None
'role': 'user', message = (
'content': identity.get('text') if isinstance(identity, dict) else msg_text, 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 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'] message['timestamp'] = identity['timestamp']
if identity.get('attachments'): if identity.get('attachments'):
message['attachments'] = copy.deepcopy(identity['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', identity.get('source') or source or 'webui',
active_turn_token=identity.get('token'), 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: else:
stamp_message_source(message, source) stamp_message_source(
message,
source,
)
return message return message
@@ -1713,7 +1765,18 @@ def _settle_current_turn_boundary(previous_context, result_messages, identity, m
msg_text, msg_text,
) )
if _checkpoint_idx is not None: 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 return result_messages
previous_context = list(previous_context or []) previous_context = list(previous_context or [])
if _messages_have_prefix(result_messages, previous_context): if _messages_have_prefix(result_messages, previous_context):
@@ -7379,7 +7442,7 @@ def _materialize_pending_user_turn_before_error(session) -> bool:
except (TypeError, ValueError): except (TypeError, ValueError):
return False return False
return ( 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_ts == recovered_ts
and existing_source == pending_source and existing_source == pending_source
and list(existing.get('attachments') or []) == pending_attachments and list(existing.get('attachments') or []) == pending_attachments
@@ -7393,6 +7456,8 @@ def _materialize_pending_user_turn_before_error(session) -> bool:
'timestamp': recovered_ts, 'timestamp': recovered_ts,
'_recovered': True, '_recovered': True,
} }
if str(pending_source or '').strip().lower() == 'fork':
recovered['_fork_child_turn'] = session.session_id
stamp_message_source(recovered, pending_source) stamp_message_source(recovered, pending_source)
if pending_attachments: if pending_attachments:
recovered['attachments'] = pending_attachments recovered['attachments'] = pending_attachments
+50 -1
View File
@@ -1953,6 +1953,52 @@ async function send(){
}finally{ _sendInProgress=false; _sendInProgressSid=null; } }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 LIVE_STREAMS={};
const _STREAM_NOTIFICATION_BACKGROUND={}; const _STREAM_NOTIFICATION_BACKGROUND={};
@@ -6139,7 +6185,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const _prevCost=(S.session&&S.session.estimated_cost)||0; const _prevCost=(S.session&&S.session.estimated_cost)||0;
const _prevCacheRead=(S.session&&S.session.cache_read_tokens)||0; const _prevCacheRead=(S.session&&S.session.cache_read_tokens)||0;
const _prevCacheWrite=(S.session&&S.session.cache_write_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). // #4720: reset _oldestIdx (full-load symmetry; keeps the #4613 anchor aligned).
if(typeof _oldestIdx!=='undefined')_oldestIdx=d.session._messages_offset||0; if(typeof _oldestIdx!=='undefined')_oldestIdx=d.session._messages_offset||0;
S.messages=_filterRecoveryControlMessages(S.messages || []); S.messages=_filterRecoveryControlMessages(S.messages || []);
@@ -6578,6 +6624,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
} else if(d.session&&typeof d.session==='object'){ } else if(d.session&&typeof d.session==='object'){
S.session=d.session; S.session=d.session;
const _nextMsgs3018=(d.session.messages||[]).filter(m=>m&&m.role); const _nextMsgs3018=(d.session.messages||[]).filter(m=>m&&m.role);
if(typeof _adoptRegenerationRevision==='function')_adoptRegenerationRevision(d.session);
_attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018); _attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018);
S.messages=_carryForwardEphemeralTurnFields(S.messages||[], _nextMsgs3018); S.messages=_carryForwardEphemeralTurnFields(S.messages||[], _nextMsgs3018);
if(S.session&&S.session.session_id){ if(S.session&&S.session.session_id){
@@ -6818,6 +6865,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
: (typeof _messageUserUnpinned!=='undefined' && _messageUserUnpinned)); : (typeof _messageUserUnpinned!=='undefined' && _messageUserUnpinned));
S.session=sessionPayload; S.session=sessionPayload;
const _nextMsgs3018=(sessionPayload.messages||[]).filter(m=>m&&m.role); const _nextMsgs3018=(sessionPayload.messages||[]).filter(m=>m&&m.role);
if(typeof _adoptRegenerationRevision==='function')_adoptRegenerationRevision(sessionPayload);
_attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018); _attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018);
S.messages=_carryForwardEphemeralTurnFields(S.messages||[], _nextMsgs3018); S.messages=_carryForwardEphemeralTurnFields(S.messages||[], _nextMsgs3018);
if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(S.session); 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(_){} try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){}
if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id); if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id);
} }
if(typeof _adoptRegenerationRevision==='function')_adoptRegenerationRevision(session);
const _markerOnlyAssistantError=_replaceMarkerOnlyAssistantWithStreamError(S.messages); const _markerOnlyAssistantError=_replaceMarkerOnlyAssistantWithStreamError(S.messages);
if(_markerOnlyAssistantError&&typeof showToast==='function') showToast('No response received after context compression. Please retry.',5000,'error'); if(_markerOnlyAssistantError&&typeof showToast==='function') showToast('No response received after context compression. Please retry.',5000,'error');
const hasMessageToolMetadata=S.messages.some(m=>{ const hasMessageToolMetadata=S.messages.some(m=>{
+17 -1
View File
@@ -189,6 +189,15 @@ function _clearRememberedNewChatDraftSession(sid) {
} catch (_) {} } 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() { async function _restoreRememberedNewChatDraftSession() {
let sid = ''; let sid = '';
try { sid = localStorage.getItem(NEW_CHAT_DRAFT_SESSION_KEY) || ''; } catch (_) { 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'){ if(consumedExplicitModelOverride&&typeof _clearEmptyComposerModelOverride==='function'){
_clearEmptyComposerModelOverride(); _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; S._pendingSessionToolsets=null;
if(_sessionSourceFilter==='cli') _sessionSourceFilter='webui'; if(_sessionSourceFilter==='cli') _sessionSourceFilter='webui';
if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(S.session); 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}); return loadSession(continuationSid,{...opts,skipLineageResolve:true,skipContinuationResolve:true,force:true,_preloadNotified:true});
} }
S.session=data.session; S.session=data.session;
if(typeof _adoptRegenerationRevision==='function') _adoptRegenerationRevision(data.session);
if(typeof _clearEmptyComposerModelOverride==='function') _clearEmptyComposerModelOverride(); if(typeof _clearEmptyComposerModelOverride==='function') _clearEmptyComposerModelOverride();
// Loading a real existing session abandons any pre-session toolset override // Loading a real existing session abandons any pre-session toolset override
// staged on the empty composer before any deferred refresh work runs. // 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(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.session.message_count=Number(data.session.message_count || msgs.length);
S.lastUsage={...(data.session.last_usage||S.lastUsage||{})}; S.lastUsage={...(data.session.last_usage||S.lastUsage||{})};
// Phase 2: the messages=1 response carries the canonical cold-load // Phase 2: the messages=1 response carries the canonical cold-load
@@ -3905,6 +3916,11 @@ async function _ensureAllMessagesLoaded() {
_syncToolCallsForLoadedMessages(msgs, data.session.tool_calls); _syncToolCallsForLoadedMessages(msgs, data.session.tool_calls);
if (S.session && S.session.session_id === sid) { if (S.session && S.session.session_id === sid) {
S.session.message_count = Number(data.session.message_count || msgs.length); 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 { } finally {
_loadingOlder = false; _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.'); if(!sid) throw new Error('Compression recovery did not return a session.');
try{localStorage.setItem('hermes-webui-session',sid);}catch(_){} try{localStorage.setItem('hermes-webui-session',sid);}catch(_){}
if(typeof loadSession==='function') await loadSession(sid,{preserveActiveInput:false}); 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 renderSessionList==='function') await renderSessionList();
if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(sid); if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(sid);
if(typeof showToast==='function') showToast((data&&data.message)||'Started focused continuation.',3000,'success'); if(typeof showToast==='function') showToast((data&&data.message)||'Started focused continuation.',3000,'success');
@@ -9982,6 +9982,7 @@ async function refreshSession() {
try { try {
const data = await api(`/api/session?session_id=${encodeURIComponent(S.session.session_id)}`); const data = await api(`/api/session?session_id=${encodeURIComponent(S.session.session_id)}`);
S.session = data.session; S.session = data.session;
if(typeof _adoptRegenerationRevision==='function') _adoptRegenerationRevision(data.session);
S.messages = data.session.messages || []; S.messages = data.session.messages || [];
_messagesTruncated = !!data.session._messages_truncated; _messagesTruncated = !!data.session._messages_truncated;
_oldestIdx = data.session._messages_offset || 0; _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 * 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 * identical user turns, and matching on text would swallow the new one. The
* discriminator is therefore exact identity, never proximity: the active turn's * discriminator is therefore exact identity, never proximity: the active turn's
* row either carries the server-stamped `_active_turn_token` (stream_id + * public row carries `_active_turn_user`, while private rows carry the server-
* started_at — unique to this turn), or its timestamp equals `pending_started_at` * stamped `_active_turn_token` (stream_id + started_at — unique to this turn),
* within a precision-only epsilon that absorbs float/state.db drift but never a * or their timestamp equals `pending_started_at` within a precision-only epsilon
* full second. A whole-second (or sub-second) mismatch is ambiguous and returns * that absorbs float/state.db drift but never a full second. A whole-second (or
* null so the caller materializes the pending turn — the transient duplicate is * sub-second) mismatch is ambiguous and returns null so the caller materializes
* harmless, hiding a turn + moving its attachments is not. Text equality is * the pending turn — the transient duplicate is harmless, hiding a turn + moving
* still required downstream, so a false match needs identical text AND an exact * its attachments is not. Text equality is still required downstream, so a false
* identity signal. * match needs identical text AND an exact identity signal.
*/ */
function _pendingActiveTurnUserMessage(messages, session){ function _pendingActiveTurnUserMessage(messages, session){
const startedAt=Number(session?.pending_started_at); const startedAt=Number(session?.pending_started_at);
@@ -10842,6 +10843,8 @@ function _pendingActiveTurnUserMessage(messages, session){
const msg=list[i]; const msg=list[i];
if(!msg||String(msg.role||'')!=='user') continue; if(!msg||String(msg.role||'')!=='user') continue;
if(typeof _isContextCompactionMessage==='function'&&_isContextCompactionMessage(msg)) 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 // Unambiguous: the row carries the active turn's exact token
// (stream_id + started_at) stamped by the server's eager-checkpoint path. // (stream_id + started_at) stamped by the server's eager-checkpoint path.
if(typeof _activeTurnTokenMatches==='function'&&_activeTurnTokenMatches(msg,session)) return msg; if(typeof _activeTurnTokenMatches==='function'&&_activeTurnTokenMatches(msg,session)) return msg;
@@ -19286,30 +19289,25 @@ async function submitEdit(msgIdx, newText) {
async function regenerateResponse(btn) { async function regenerateResponse(btn) {
if(!S.session || S.busy) return; if(!S.session || S.busy) return;
const row = btn.closest('[data-msg-idx]'); const row=btn&&btn.closest&&btn.closest('[data-msg-idx]');
if(!row) return; if(!row)return;
const assistantIdx = parseInt(row.dataset.msgIdx, 10); const clickedAbsoluteIndex=_oldestIdx+parseInt(row.dataset.msgIdx,10);
const absoluteKeepCount = _oldestIdx + assistantIdx;
const initialSid = S.session.session_id; 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'){ if(typeof _ensureAllMessagesLoaded==='function'){
await _ensureAllMessagesLoaded(); await _ensureAllMessagesLoaded();
} }
if(!S.session || S.session.session_id !== initialSid) return; 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 { try {
await api('/api/session/truncate', {method:'POST', body:JSON.stringify({ await startRegeneration(initialSid, S.session.regeneration_revision);
session_id: initialSid,
keep_count: absoluteKeepCount
})});
S.messages = S.messages.slice(0, absoluteKeepCount);
renderMessages();
$('msg').value = lastUserText;
await send();
} catch(e) { setStatus(t('regen_failed') + e.message); } } 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 // System-minted session (#6022): explicit worktree:false — creating a
// file from a blank page must not inherit the config worktree default. // 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})}); 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;} }catch(e){setStatus(t('create_failed')+e.message);return;}
} }
if(!S.session)return; if(!S.session)return;
@@ -21441,7 +21439,7 @@ async function promptNewFolder(targetDir = S.currentDir || '.'){
// System-minted session (#6022): explicit worktree:false — creating a // System-minted session (#6022): explicit worktree:false — creating a
// folder from a blank page must not inherit the config worktree default. // 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})}); 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;} }catch(e){setStatus(t('folder_create_failed')+e.message);return;}
} }
if(!S.session)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""" _DRIVER_SRC = r"""
const fs = require('fs'); const fs = require('fs');
function extractNewSession(src) { function extractFunction(src, signature) {
const start = src.indexOf('async function newSession('); const start = src.indexOf(signature);
if (start < 0) throw new Error('newSession not found'); if (start < 0) throw new Error(signature + ' not found');
let depth = 0; let depth = 0;
let bodyStart = src.indexOf('{', src.indexOf(')', start)); let bodyStart = src.indexOf('{', src.indexOf(')', start));
for (let i = bodyStart; i < src.length; i++) { for (let i = bodyStart; i < src.length; i++) {
@@ -26,7 +26,7 @@ function extractNewSession(src) {
if (depth === 0) return src.slice(start, i + 1); 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'); 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 () => { (async () => {
await newSession(false, {}); 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 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") body = _function_body(UI_JS, "regenerateResponse")
assert re.search(r"absoluteKeepCount\s*=\s*_oldestIdx\s*\+\s*assistantIdx", body) assert "await startRegeneration(initialSid, S.session.regeneration_revision)" in body
assert "keep_count: absoluteKeepCount" in body assert "/api/session/truncate" not in body
assert "await send()" not in body
def test_submit_edit_captures_absolute_before_await(): 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) cap = re.search(r"absoluteKeepCount\s*=\s*_oldestIdx\s*\+\s*msgIdx", body)
assert cap assert cap
first_await = re.search(r"\bawait\b", body) 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()