* 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:
17 files changed
+2339
-99
No files matched your search
+18
-6
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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, {});
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in new issue
Block a user