mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-01 10:42:23 +01:00
* fix(chat): warn before "regenerate" permanently deletes later messages
Both "regenerate" entry points — the per-message footer's ↻ "Regenerate
from here" button, and the vision editor's "Regenerate message" button —
call POST /api/session/{id}/truncate, which does a real, permanent SQL
delete of every message after the point clicked, then generates a new
reply. Clicking either on anything but the very last exchange did this with
zero warning: the button's own label doesn't say it also erases the rest
of the conversation, and neither button visually signals it's destructive
differently from any other message-footer action.
Caught live: a mis-click destroyed a real conversation with no way to
recover it (no backup covered the affected window). This adds a
confirmation prompt — via the existing uiModule.styledConfirm — whenever
the action would delete more than just the message(s) being regenerated,
stating the exact count of messages that would be lost. Regenerating the
most recent exchange (the everyday case, nothing exists after it) is
unaffected and still proceeds with zero friction.
New test: tests/test_regenerate_truncate_warning_js.py — drives the real
regenerateFrom/resendUserMessage functions under Node with a fake message
list DOM, confirming: the last-exchange case never prompts and truncates
directly; an earlier case prompts with the correct count; cancelling
aborts before any network call; confirming proceeds with the correct
keep_count. Also fixed a pre-existing, unrelated gap this surfaced while
verifying the full suite: test_pr6020_browser_review_regressions.py's
Node harness never stubbed `navigator` at all (sessions.js reads
navigator.platform at module load) — a gap on origin/dev itself, not
caused by this branch's diff, using the same Object.defineProperty fix
already applied on #6019/#6148 for the sibling issue there (Node 21+'s own
getter-only navigator global, not relevant to this specific failure but
the same defensive pattern).
Verified: full pytest suite in-container, 5798 passed / 11 pre-existing
unrelated failures (README/docs/agent-loop baseline) / 0 new.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* test(chat): cover plain resend after dev's replace-by-default change
dev made plain resend (no options) replace from the clicked turn by
default, with append-only resend opt-in via { append: true } (see
test_resend_message_nondestructive.py). The footer "Resend message"
button calls resendUserMessage(el) with no options, so it now truncates
everything after the clicked turn and goes through this PR's warning.
Replace the old "plain resend never prompts" test, which assumed the
previous append-by-default behavior, with one asserting that plain
resend on an earlier pair prompts with the right count, and one
asserting that explicit append-only resend never truncates or prompts.
Also resolve ui.js/sessions.js through the same cache-busting query
string chat.js imports them with. ES modules cache by full URL, so the
harness's unversioned import of ui.js was a second module instance and
its uiMod.styledConfirm stub never reached the instance chat.js calls.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(chat): warn before an edit deletes later messages; offer to fork instead
Sending an edited user message truncates the chat at that message, the
same unrecoverable server-side delete as regenerate and resend, but did
it with no prompt. It now goes through the same confirmation, worded for
the action ("Sending this edit will also permanently delete the N
messages after it"). Cancelling leaves the editor open with the draft.
The confirmation for all three actions (regenerate, resend, edit) also
offers "Fork from here": it forks the chat just before the user turn,
switches to the new chat, and replays the action there (the same message,
or the edited text), leaving the original chat untouched. forkFrom now
shares the same fork helper.
Tests drive the real editUserMessage Send handler under Node and cover
the edit prompt (count, cancel, last-message no-prompt) and the fork
choice for each action (fork keep_count, no truncate, replayed text).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(chat): cut and fork a chat by message id, not loaded-page position
Regenerate and edit sent keep_count, the clicked message's position among
the messages loaded in the page. A long chat loads only its newest messages
until the user scrolls up, so that position is smaller than the real one and
the truncate deleted real messages above the clicked one (regenerating the
latest reply in a ~50-message chat removed about a dozen earlier messages).
Resend already sent before_msg_id; regenerate and edit now do the same.
The fork options this PR adds had the same flaw. Fork now accepts
before_msg_id (fork before that message) and through_msg_id (include it),
resolved against the stored messages; keep_count stays as the fallback for a
message that has no id yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(chat): keep a regenerated turn's message id current; stop on a refused cut
Regenerate keeps the original user bubble on screen and resubmits its text
without drawing a new bubble. The resubmitted message is a new DB row, but
the bubble kept the deleted row's id, so the next regenerate's cut was
refused (404) and the page sent the turn anyway, stacking a second reply.
The stream now reports the saved user message's id (user_message_saved),
which the page attaches to the user bubble, or to the bubble a regenerate
kept. Regenerate and edit stop with an error when the server refuses the
cut instead of sending.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(chat): name the buttons in words instead of symbols
CONTRIBUTING asks for no Unicode emoji in UI or code; the test comments
used the regenerate and pencil glyphs to name buttons.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(chat): share the fork-and-send step across edit, resend and regenerate
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(chat): pass the regenerate fork its attachment ids explicitly
Resend already hands its collected file ids to _forkAndSend; regenerate
relied on the earlier _pendingRegenAttachments assignment. Pass _regenIds
too so both fork paths read the same, and check the hand-off in the
regenerate tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(chat): assert fork choices keep the user message's attachments
Run regenerate and resend's "Fork from here" against a user message with
an uploaded file and check that the send fired in the fork carries its
file id, replacing the source-text check. chat.js exposes a read-only
_pendingRegenAttachmentIds() for this, like cookbook-hwfit.js's test hooks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(chat): preserve attachments across edit and regeneration
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
542 lines
25 KiB
Python
542 lines
25 KiB
Python
"""Executable regression for the "regenerate" data-loss warning.
|
|
|
|
`regenerateFrom` (the per-message regenerate footer button), `resendUserMessage`
|
|
(footer Resend and the vision editor's "Regenerate message" button) and
|
|
`editUserMessage` (sending an edited user message) all permanently delete
|
|
every message after the point clicked via POST /api/session/{id}/truncate —
|
|
a real, unrecoverable server-side delete. Using any of them on anything but
|
|
the very last exchange used to do this with no warning at all; a mis-click
|
|
could silently erase an entire conversation. This drives the real chat.js
|
|
functions under Node, confirming:
|
|
|
|
1. Regenerating the *last* exchange (nothing after it) never prompts and
|
|
truncates directly — the everyday case must not gain friction.
|
|
2. Regenerating anything earlier prompts first, with the correct count of
|
|
messages that would be destroyed in the message text.
|
|
3. Cancelling the prompt aborts before any network call — nothing is
|
|
deleted.
|
|
4. Confirming the prompt proceeds with the correct `keep_count`.
|
|
5. Choosing "Fork from here" deletes nothing: it forks the chat just
|
|
before the user turn and replays the action (same text, or the edited
|
|
text) in the new chat instead.
|
|
|
|
All three entry points are exercised the same way since they wrap the same
|
|
destructive operation.
|
|
|
|
Unlike test_pr6020_browser_review_regressions.py's `_chat_smoke_source`
|
|
(which appends extra code to a copy of chat.js's own source — fine for
|
|
defining an extra exported function, since ES module static imports are
|
|
hoisted and evaluated before any top-level code regardless of where in the
|
|
file it appears), this harness needs its global stubs (document, fetch,
|
|
etc.) in place *before* chat.js's own import tree evaluates, since some of
|
|
those transitive dependencies touch browser globals at their own module top
|
|
level. So this loads chat.js via a real dynamic `import()` of its actual
|
|
file, in a small wrapper script that sets the stubs up first as plain
|
|
synchronous statements — dynamic `import()` runs in program order, unlike a
|
|
static `import` declaration.
|
|
"""
|
|
import json
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
_REPO = Path(__file__).resolve().parents[1]
|
|
_JS_DIR = _REPO / "static" / "js"
|
|
_HAS_NODE = shutil.which("node") is not None
|
|
|
|
|
|
def _versioned_js_uri(entry_js: Path, target_name: str) -> str:
|
|
"""file:// URI for target_name with the same cache-busting query string
|
|
entry_js's own import of it uses. ES modules cache by full URL, so
|
|
importing 'ui.js' here while chat.js imports 'ui.js?v=X' yields two module
|
|
instances, and patching uiMod here would never reach chat.js."""
|
|
src = entry_js.read_text(encoding="utf-8")
|
|
m = re.search(rf"from ['\"]\./{re.escape(target_name)}(\?[^'\"]*)?['\"]", src)
|
|
suffix = (m.group(1) or "") if m else ""
|
|
return (entry_js.parent / target_name).resolve().as_uri() + suffix
|
|
|
|
|
|
def _run_node(source: str) -> dict:
|
|
proc = subprocess.run(
|
|
["node", "--input-type=module"],
|
|
input=source,
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=_REPO,
|
|
timeout=30,
|
|
)
|
|
assert proc.returncode == 0, proc.stderr
|
|
return json.loads(proc.stdout.strip().splitlines()[-1])
|
|
|
|
|
|
_HARNESS_PREAMBLE = """
|
|
globalThis.window = globalThis;
|
|
Object.defineProperty(globalThis, "navigator", { value: { platform: "" }, writable: true, configurable: true });
|
|
globalThis.addEventListener = () => {};
|
|
globalThis.removeEventListener = () => {};
|
|
globalThis.dispatchEvent = () => {};
|
|
globalThis.requestAnimationFrame = () => 0;
|
|
globalThis.cancelAnimationFrame = () => {};
|
|
globalThis.localStorage = { getItem() { return null; }, setItem() {}, removeItem() {} };
|
|
globalThis.location = {};
|
|
globalThis.history = {};
|
|
globalThis.MutationObserver = class { observe() {} };
|
|
globalThis.CustomEvent = class {};
|
|
globalThis.Storage = class {};
|
|
|
|
const fetchCalls = [];
|
|
globalThis.fetch = async (url, opts) => {
|
|
fetchCalls.push({ url, body: opts && opts.body ? JSON.parse(opts.body) : null });
|
|
const data = url.includes('/fork') ? { id: 'forked-session', name: 'Forked chat' } : {};
|
|
// A test can make the server refuse the cut (e.g. a stale message id).
|
|
const ok = !(url.includes('/truncate') && globalThis.__refuseTruncate);
|
|
return { ok, status: ok ? 200 : 404, json: async () => data, text: async () => '', headers: { get() { return null; } } };
|
|
};
|
|
|
|
class Element {
|
|
constructor() {
|
|
this.children = [];
|
|
this.classList = { add() {}, remove() {}, toggle() {}, contains: () => false };
|
|
this.style = { setProperty() {} };
|
|
this.dataset = {};
|
|
this._listeners = {};
|
|
this._html = '';
|
|
}
|
|
get innerHTML() { return this._html; }
|
|
set innerHTML(v) { this._html = v; this.children = []; }
|
|
querySelector() { return null; }
|
|
querySelectorAll() { return []; }
|
|
appendChild(child) { this.children.push(child); return child; }
|
|
addEventListener(type, fn) { (this._listeners[type] ||= []).push(fn); }
|
|
removeEventListener() {}
|
|
focus() {}
|
|
// Fire a listener and wait for it — the edit Send handler is async.
|
|
fire(type) {
|
|
const ev = { stopPropagation() {}, preventDefault() {} };
|
|
return Promise.all((this._listeners[type] || []).map(fn => fn(ev)));
|
|
}
|
|
}
|
|
class HTMLInputElement extends Element {
|
|
get value() { return this._value || ''; }
|
|
set value(v) { this._value = v; }
|
|
}
|
|
globalThis.HTMLInputElement = HTMLInputElement;
|
|
|
|
// A minimal .msg-list DOM: `msgs` is an ordered array of
|
|
// { role: 'user'|'ai' } describing one conversation. Each entry
|
|
// becomes a fake message element good enough for regenerateFrom /
|
|
// resendUserMessage's own traversal (classList.contains, .dataset,
|
|
// querySelector('.body'), querySelectorAll('[data-file-id]')).
|
|
function buildMsgEls(msgs) {
|
|
return msgs.map((m, i) => {
|
|
const body = new Element();
|
|
body.textContent = m === 'user' ? `question ${i}` : `answer ${i}`;
|
|
body.innerHTML = body.textContent;
|
|
return {
|
|
classList: { contains: (c) => c === (m === 'user' ? 'msg-user' : 'msg-ai') },
|
|
dataset: { raw: m === 'user' ? `question ${i}` : '' },
|
|
querySelector: (sel) => sel === '.body' ? body : null,
|
|
querySelectorAll: () => [],
|
|
nextSibling: null,
|
|
remove() {},
|
|
};
|
|
});
|
|
}
|
|
|
|
const sendClicks = [];
|
|
const messageInputEl = new HTMLInputElement();
|
|
const sendBtnEl = { click: () => sendClicks.push(messageInputEl.value) };
|
|
|
|
globalThis.document = {
|
|
body: new Element(),
|
|
head: new Element(),
|
|
documentElement: new Element(),
|
|
getElementById(id) {
|
|
if (id === 'message') return messageInputEl;
|
|
if (id === 'chat-history') return this._box;
|
|
return null;
|
|
},
|
|
querySelector(sel) { return sel === '.send-btn' ? sendBtnEl : null; },
|
|
querySelectorAll() { return []; },
|
|
createElement(tag) { return tag === 'input' ? new HTMLInputElement() : new Element(); },
|
|
createTextNode(text) { return { textContent: text }; },
|
|
addEventListener() {},
|
|
removeEventListener() {},
|
|
};
|
|
"""
|
|
|
|
|
|
def _scenario_script(func_call_js: str, roles: list, target_index: int, confirm_resolution, then_js: str = "", db_ids: bool = False, refuse_truncate: bool = False, file_ids: dict | None = None) -> str:
|
|
# Globals are stubbed above as plain synchronous statements *before* the
|
|
# dynamic import()s below run — unlike a static `import` declaration,
|
|
# dynamic import() executes in program order, so chat.js's transitive
|
|
# dependency tree only evaluates once the stubs are already in place.
|
|
script = _HARNESS_PREAMBLE
|
|
script += f"""
|
|
const chat = await import({json.dumps((_JS_DIR / "chat.js").resolve().as_uri())});
|
|
const {{ default: uiMod }} = await import({json.dumps(_versioned_js_uri(_JS_DIR / "chat.js", "ui.js"))});
|
|
const {{ default: sessionMod }} = await import({json.dumps(_versioned_js_uri(_JS_DIR / "chat.js", "sessions.js"))});
|
|
|
|
let currentSession = 'test-session';
|
|
const selectedSessions = [];
|
|
sessionMod.getCurrentSessionId = () => currentSession;
|
|
sessionMod.loadSessions = async () => {{}};
|
|
sessionMod.selectSession = async (id) => {{ selectedSessions.push(id); currentSession = id; }};
|
|
|
|
const confirmCalls = [];
|
|
uiMod.styledConfirm = async (message, opts) => {{
|
|
confirmCalls.push({{ message, opts }});
|
|
return {json.dumps(confirm_resolution)};
|
|
}};
|
|
uiMod.showError = () => {{}};
|
|
uiMod.showToast = () => {{}};
|
|
|
|
globalThis.__refuseTruncate = {json.dumps(refuse_truncate)};
|
|
const msgs = buildMsgEls({json.dumps(roles)});
|
|
// Persisted messages carry their database id; the page may have loaded
|
|
// only the newest of them, so ids (not positions) identify a message.
|
|
if ({json.dumps(db_ids)}) msgs.forEach((m, i) => {{ m.dataset.dbId = `db-${{i + 40}}`; }});
|
|
// Attach uploaded files ({{ index: file_id }}) the way rendered bubbles do.
|
|
for (const [i, fileId] of Object.entries({json.dumps(file_ids or {})})) {{
|
|
msgs[i].querySelectorAll = (sel) => sel === '[data-file-id]' ? [{{ dataset: {{ fileId }} }}] : [];
|
|
}}
|
|
// Record the attachments queued for each send as the button is clicked.
|
|
const sentAttachments = [];
|
|
sendBtnEl.click = () => {{ sendClicks.push(messageInputEl.value); sentAttachments.push(chat._pendingRegenAttachmentIds()); }};
|
|
const box = {{ querySelectorAll: () => msgs, _isBox: true }};
|
|
globalThis.document._box = box;
|
|
const target = msgs[{target_index}];
|
|
|
|
await {func_call_js};
|
|
{then_js}
|
|
|
|
const forkCall = fetchCalls.find(c => c.url.includes('/fork'));
|
|
console.log(JSON.stringify({{
|
|
confirmShown: confirmCalls.length > 0,
|
|
confirmMessage: confirmCalls.length ? confirmCalls[0].message : null,
|
|
confirmOpts: confirmCalls.length ? confirmCalls[0].opts : null,
|
|
truncateCalled: fetchCalls.some(c => c.url.includes('/truncate')),
|
|
truncateKeepCount: (fetchCalls.find(c => c.url.includes('/truncate')) || {{}}).body?.keep_count ?? null,
|
|
forkCalled: Boolean(forkCall),
|
|
forkUrl: forkCall ? forkCall.url : null,
|
|
forkKeepCount: forkCall ? forkCall.body.keep_count : null,
|
|
forkBody: forkCall ? forkCall.body : null,
|
|
truncateBody: (fetchCalls.find(c => c.url.includes('/truncate')) || {{}}).body || null,
|
|
selectedSessions,
|
|
sendClicks,
|
|
sentAttachments,
|
|
pendingAttachments: chat._pendingRegenAttachmentIds(),
|
|
}}));
|
|
process.exit(0);
|
|
"""
|
|
return script
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# regenerateFrom (main chat footer "Regenerate from here" button)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
def test_regenerate_from_last_message_skips_prompt_and_truncates():
|
|
roles = ["user", "ai"] # target (index 1) IS the last message
|
|
script = _scenario_script("chat.regenerateFrom(target)", roles, 1, confirm_resolution=True)
|
|
result = _run_node(script)
|
|
assert result["confirmShown"] is False
|
|
assert result["truncateCalled"] is True
|
|
assert result["truncateKeepCount"] == 0 # keep everything before the user turn (index 0)
|
|
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
def test_regenerate_from_earlier_message_prompts_with_correct_count():
|
|
roles = ["user", "ai", "user", "ai", "user", "ai"] # target (index 1) has 4 messages after it
|
|
script = _scenario_script("chat.regenerateFrom(target)", roles, 1, confirm_resolution=True)
|
|
result = _run_node(script)
|
|
assert result["confirmShown"] is True
|
|
assert "4 messages" in result["confirmMessage"]
|
|
assert result["truncateCalled"] is True
|
|
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
def test_regenerate_from_earlier_message_cancel_aborts_without_truncating():
|
|
roles = ["user", "ai", "user", "ai"]
|
|
script = _scenario_script("chat.regenerateFrom(target)", roles, 1, confirm_resolution=False)
|
|
result = _run_node(script)
|
|
assert result["confirmShown"] is True
|
|
assert result["truncateCalled"] is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# resendUserMessage(..., { replaceFromHere: true }) (vision editor "Regenerate message")
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
def test_resend_replace_from_here_last_pair_skips_prompt():
|
|
roles = ["user", "ai"] # target (index 0) is the user turn of the last pair
|
|
script = _scenario_script(
|
|
"chat.resendUserMessage(target, { replaceFromHere: true })", roles, 0, confirm_resolution=True,
|
|
)
|
|
result = _run_node(script)
|
|
assert result["confirmShown"] is False
|
|
assert result["truncateCalled"] is True
|
|
assert result["truncateKeepCount"] == 0
|
|
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
def test_resend_replace_from_here_earlier_pair_prompts_with_correct_count():
|
|
roles = ["user", "ai", "user", "ai", "user", "ai"] # target (index 0) has 4 messages after its own pair
|
|
script = _scenario_script(
|
|
"chat.resendUserMessage(target, { replaceFromHere: true })", roles, 0, confirm_resolution=True,
|
|
)
|
|
result = _run_node(script)
|
|
assert result["confirmShown"] is True
|
|
assert "4 messages" in result["confirmMessage"]
|
|
assert result["truncateCalled"] is True
|
|
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
def test_resend_replace_from_here_cancel_aborts_without_truncating():
|
|
roles = ["user", "ai", "user", "ai"]
|
|
script = _scenario_script(
|
|
"chat.resendUserMessage(target, { replaceFromHere: true })", roles, 0, confirm_resolution=False,
|
|
)
|
|
result = _run_node(script)
|
|
assert result["confirmShown"] is True
|
|
assert result["truncateCalled"] is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# resendUserMessage(el) with no options (footer "Resend message")
|
|
# ---------------------------------------------------------------------------
|
|
# `dev` made plain resend replace-by-default (see
|
|
# test_resend_message_nondestructive.py): no options means replaceFromHere,
|
|
# so the footer Resend button now truncates everything after the clicked
|
|
# turn too, and gets the same warning. Append-only resend must be explicit.
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
def test_resend_default_earlier_pair_prompts_with_correct_count():
|
|
roles = ["user", "ai", "user", "ai", "user", "ai"]
|
|
script = _scenario_script("chat.resendUserMessage(target)", roles, 0, confirm_resolution=True)
|
|
result = _run_node(script)
|
|
assert result["confirmShown"] is True
|
|
assert "4 messages" in result["confirmMessage"]
|
|
assert result["truncateCalled"] is True
|
|
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
def test_resend_append_only_never_truncates_or_prompts():
|
|
roles = ["user", "ai", "user", "ai"]
|
|
script = _scenario_script("chat.resendUserMessage(target, { append: true })", roles, 0, confirm_resolution=True)
|
|
result = _run_node(script)
|
|
assert result["confirmShown"] is False
|
|
assert result["truncateCalled"] is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# editUserMessage (the pencil edit button, then Send)
|
|
# ---------------------------------------------------------------------------
|
|
# The edit Send handler is wired up inside editUserMessage, so these open the
|
|
# editor, type into it, and click the editor's own Send button.
|
|
|
|
_EDIT_AND_SEND = """
|
|
const _body = target.querySelector('.body');
|
|
const [_editor, _btnRow] = _body.children;
|
|
_editor.value = 'edited question';
|
|
await _btnRow.children[0].fire('click');
|
|
"""
|
|
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
def test_edit_last_message_skips_prompt_and_truncates():
|
|
roles = ["user", "ai"]
|
|
script = _scenario_script("chat.editUserMessage(target)", roles, 0, True, _EDIT_AND_SEND)
|
|
result = _run_node(script)
|
|
assert result["confirmShown"] is False
|
|
assert result["truncateCalled"] is True
|
|
assert result["truncateKeepCount"] == 0
|
|
assert result["sendClicks"] == ["edited question"]
|
|
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
def test_edit_earlier_message_prompts_with_correct_count():
|
|
roles = ["user", "ai", "user", "ai", "user", "ai"] # target (index 2) has 2 messages after its own pair
|
|
script = _scenario_script("chat.editUserMessage(target)", roles, 2, True, _EDIT_AND_SEND)
|
|
result = _run_node(script)
|
|
assert result["confirmShown"] is True
|
|
assert "2 messages" in result["confirmMessage"]
|
|
assert result["confirmOpts"]["confirmText"] == "Delete and send"
|
|
assert result["truncateCalled"] is True
|
|
assert result["truncateKeepCount"] == 2
|
|
assert result["sendClicks"] == ["edited question"]
|
|
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
def test_edit_earlier_message_cancel_aborts_without_truncating_or_sending():
|
|
roles = ["user", "ai", "user", "ai"]
|
|
script = _scenario_script("chat.editUserMessage(target)", roles, 0, False, _EDIT_AND_SEND)
|
|
result = _run_node(script)
|
|
assert result["confirmShown"] is True
|
|
assert result["truncateCalled"] is False
|
|
assert result["sendClicks"] == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# "Fork from here" — the prompt's alternate choice
|
|
# ---------------------------------------------------------------------------
|
|
# styledConfirm resolves to 'alternate' for its third button. Forking must
|
|
# not truncate anything; it forks just before the user turn (so the new chat
|
|
# ends where the action starts), switches to the fork, and replays there.
|
|
|
|
def _assert_forked_instead(result, keep_count, sent_text):
|
|
assert result["confirmShown"] is True
|
|
assert result["confirmOpts"]["alternateText"] == "Fork from here"
|
|
assert result["truncateCalled"] is False
|
|
assert result["forkCalled"] is True
|
|
assert "/api/session/test-session/fork" in result["forkUrl"]
|
|
assert result["forkKeepCount"] == keep_count
|
|
assert result["selectedSessions"] == ["forked-session"]
|
|
assert result["sendClicks"] == [sent_text]
|
|
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
def test_regenerate_fork_choice_forks_before_user_turn_and_resends():
|
|
roles = ["user", "ai", "user", "ai", "user", "ai"]
|
|
script = _scenario_script("chat.regenerateFrom(target)", roles, 3, "alternate")
|
|
_assert_forked_instead(_run_node(script), keep_count=2, sent_text="question 2")
|
|
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
def test_resend_fork_choice_forks_before_user_turn_and_resends():
|
|
roles = ["user", "ai", "user", "ai", "user", "ai"]
|
|
script = _scenario_script("chat.resendUserMessage(target)", roles, 2, "alternate")
|
|
_assert_forked_instead(_run_node(script), keep_count=2, sent_text="question 2")
|
|
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
def test_edit_fork_choice_forks_before_user_turn_and_sends_edit():
|
|
roles = ["user", "ai", "user", "ai", "user", "ai"]
|
|
script = _scenario_script("chat.editUserMessage(target)", roles, 2, "alternate", _EDIT_AND_SEND)
|
|
_assert_forked_instead(_run_node(script), keep_count=2, sent_text="edited question")
|
|
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
def test_regenerate_fork_choice_keeps_the_user_message_attachments():
|
|
roles = ["user", "ai", "user", "ai", "user", "ai"]
|
|
script = _scenario_script("chat.regenerateFrom(target)", roles, 3, "alternate", file_ids={2: "file-abc"})
|
|
result = _run_node(script)
|
|
_assert_forked_instead(result, keep_count=2, sent_text="question 2")
|
|
assert result["sentAttachments"] == [["file-abc"]]
|
|
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
def test_resend_fork_choice_keeps_the_user_message_attachments():
|
|
roles = ["user", "ai", "user", "ai", "user", "ai"]
|
|
script = _scenario_script("chat.resendUserMessage(target)", roles, 2, "alternate", file_ids={2: "file-abc"})
|
|
result = _run_node(script)
|
|
_assert_forked_instead(result, keep_count=2, sent_text="question 2")
|
|
assert result["sentAttachments"] == [["file-abc"]]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cutting by message id
|
|
# ---------------------------------------------------------------------------
|
|
# A long chat loads only its newest messages until the user scrolls up, so a
|
|
# position counted in the page is smaller than the real one. Truncating by
|
|
# that position deleted real messages above the clicked one. Every action
|
|
# sends the clicked message's database id when it has one.
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
def test_regenerate_truncates_before_the_user_message_by_id():
|
|
roles = ["user", "ai", "user", "ai"]
|
|
script = _scenario_script("chat.regenerateFrom(target)", roles, 3, True, db_ids=True)
|
|
result = _run_node(script)
|
|
assert result["truncateBody"] == {"before_msg_id": "db-42"}
|
|
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
def test_resend_truncates_before_the_user_message_by_id():
|
|
roles = ["user", "ai", "user", "ai"]
|
|
script = _scenario_script("chat.resendUserMessage(target)", roles, 2, True, db_ids=True)
|
|
result = _run_node(script)
|
|
assert result["truncateBody"] == {"before_msg_id": "db-42"}
|
|
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
def test_edit_truncates_before_the_user_message_by_id():
|
|
roles = ["user", "ai", "user", "ai"]
|
|
script = _scenario_script("chat.editUserMessage(target)", roles, 2, True, _EDIT_AND_SEND, db_ids=True)
|
|
result = _run_node(script)
|
|
assert result["truncateBody"] == {"before_msg_id": "db-42"}
|
|
assert result["sendClicks"] == ["edited question"]
|
|
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
def test_fork_choice_forks_before_the_user_message_by_id():
|
|
roles = ["user", "ai", "user", "ai", "user", "ai"]
|
|
script = _scenario_script("chat.regenerateFrom(target)", roles, 3, "alternate", db_ids=True)
|
|
result = _run_node(script)
|
|
assert result["truncateCalled"] is False
|
|
assert result["forkBody"]["before_msg_id"] == "db-42"
|
|
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
def test_fork_conversation_forks_through_the_reply_by_id():
|
|
roles = ["user", "ai", "user", "ai"]
|
|
script = _scenario_script("chat.forkFrom(target)", roles, 1, True, db_ids=True)
|
|
result = _run_node(script)
|
|
assert result["forkBody"]["through_msg_id"] == "db-41"
|
|
|
|
|
|
# A refused cut (the server can't find the message id, e.g. one a previous
|
|
# regenerate already deleted) must stop the action. Sending anyway stacked a
|
|
# second reply under the same message.
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
def test_regenerate_stops_when_the_server_refuses_the_cut():
|
|
roles = ["user", "ai"]
|
|
script = _scenario_script("chat.regenerateFrom(target)", roles, 1, True, db_ids=True, refuse_truncate=True)
|
|
result = _run_node(script)
|
|
assert result["truncateCalled"] is True
|
|
assert result["sendClicks"] == []
|
|
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
def test_edit_stops_when_the_server_refuses_the_cut():
|
|
roles = ["user", "ai"]
|
|
script = _scenario_script("chat.editUserMessage(target)", roles, 0, True, _EDIT_AND_SEND, db_ids=True, refuse_truncate=True)
|
|
result = _run_node(script)
|
|
assert result["truncateCalled"] is True
|
|
assert result["sendClicks"] == []
|
|
|
|
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
def test_edit_fork_choice_keeps_original_attachments():
|
|
roles = ["user", "ai", "user", "ai", "user", "ai"]
|
|
script = _scenario_script("chat.editUserMessage(target)", roles, 2, "alternate", _EDIT_AND_SEND, file_ids={2: "file-abc"})
|
|
result = _run_node(script)
|
|
_assert_forked_instead(result, keep_count=2, sent_text="edited question")
|
|
assert result["sentAttachments"] == [["file-abc"]]
|
|
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
def test_edit_destructive_resend_keeps_original_attachments():
|
|
roles = ["user", "ai", "user", "ai"]
|
|
script = _scenario_script("chat.editUserMessage(target)", roles, 2, True, _EDIT_AND_SEND, file_ids={2: "file-abc"})
|
|
result = _run_node(script)
|
|
assert result["truncateCalled"] is True
|
|
assert result["sentAttachments"] == [["file-abc"]]
|
|
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
def test_refused_regenerate_does_not_leak_queued_attachments():
|
|
roles = ["user", "ai"]
|
|
script = _scenario_script("chat.regenerateFrom(target)", roles, 1, True, db_ids=True,
|
|
refuse_truncate=True, file_ids={0: "file-abc"})
|
|
result = _run_node(script)
|
|
assert result["truncateCalled"] is True
|
|
assert result["sendClicks"] == []
|
|
assert result["pendingAttachments"] is None
|