* 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>
Test Suite Notes
Purpose
This file documents the shared test helpers and the review expectations that go
with them. The suite is being refactored incrementally, so this is a working
reference for that effort - not a claim that the suite is already fully
organized. Read it before adding a new helper or before reviewing a PR that
touches tests/helpers/.
For the broader rules - test taxonomy, determinism/isolation rules, the
behavioral-vs-source-text policy, and helper/factory extraction rules - see
TESTING_STANDARD.md. This file is the concrete helper
reference; that file is the standard the refactor works toward.
Running focused subsets (taxonomy markers)
The shared static-server fixture binds an ephemeral loopback port and publishes
ODYSSEUS_TEST_STATIC_ORIGIN to browser tests and their Node subprocesses.
ODYSSEUS_TEST_STATIC_PORT can pin a port for an external client; leave it unset
for parallel runs. An occupied explicit port is refused rather than reused. The
server handles each connection on its own thread, so a speculative browser
connection that never sends a request cannot stall the requests behind it.
tests/conftest.py tags every test at collection time with two markers derived
from its filename by tests/_taxonomy.py: an area_* marker (e.g.
area_security) and a finer sub_* marker (e.g. sub_owner_scope). This adds
markers only - it moves no files and changes no test behavior. Use them to run a
focused slice:
./venv/bin/python -m pytest -m area_security
./venv/bin/python -m pytest -m "area_services and sub_cookbook"
Areas are security, routes, services, cli, js, helpers, unit, and
uncategorized. Classification is conservative and token-based: a file that
matches no area keyword falls back to area_uncategorized with its filename as
the sub-area. The area_* names are registered in pyproject.toml; the dynamic
sub_* names are registered before collection by pytest_configure in
tests/conftest.py, so unknown-mark warnings still flag genuine typos.
The full suite does not come back clean on every machine. KNOWN_FAILURES.md lists which failures are expected, which are test bugs worth fixing, and the prerequisites a clean run needs; anything not on that list is a regression until shown otherwise.
For common focused runs, use tests/run_focus.py. It validates area and
sub-area names, accepts sub-areas with or without the sub_ prefix, and passes
extra pytest arguments after --:
./venv/bin/python tests/run_focus.py --area security
./venv/bin/python tests/run_focus.py --area services --sub-area cookbook
./venv/bin/python tests/run_focus.py --sub-area sub_cookbook
./venv/bin/python tests/run_focus.py --keyword taxonomy
./venv/bin/python tests/run_focus.py --last-failed
./venv/bin/python tests/run_focus.py --dry-run --area services --sub-area cookbook
./venv/bin/python tests/run_focus.py --area services -- --maxfail=1 -q
Fast lane and duration visibility
--fast runs the fast lane: the tests that are not marked slow (it adds the
marker expression not slow). It composes with --area/--sub-area using
and. Because no tests may be marked slow yet, --fast can initially match
the full focused selection; it becomes a real speed-up as slow marks are added
from duration evidence. Use it for quick local or reviewer feedback; it does not
replace broader focused or full-suite validation before merge.
--durations N and --durations-min FLOAT add pytest's slowest-test reporting
so you can see where time goes. They are reporting only and do not count as a
focus selector, so --durations must be combined with a real selector
(--area, --sub-area, --keyword, --last-failed, or --fast).
Use the project Python environment before running these commands. The examples
use the repo's documented ./venv/bin/python path so they do not accidentally
fall back to system Python.
./venv/bin/python tests/run_focus.py --fast
./venv/bin/python tests/run_focus.py --area services --fast
./venv/bin/python tests/run_focus.py --area services --durations 25
./venv/bin/python tests/run_focus.py --area services --fast --durations 25 --durations-min 0.05
The slow marker is opt-in. Mark a test slow only with duration evidence
(from --durations), not by guessing - see the fast-lane policy in
TESTING_STANDARD.md. --fast is for quick reviewer feedback and must not
replace the full suite before merge. A slow mark only excludes a test from the
fast lane; the test stays runnable directly, e.g.:
./venv/bin/python -m pytest tests/test_auth_config_lock_concurrency.py
./venv/bin/python -m pytest -m slow
Parallel shards (--shard N/M)
CI no longer runs the whole suite as one workload. The python-tests job is a
four-way matrix, and each job runs one section:
./venv/bin/python -m pytest -q --shard 1/4
tests/_shards.py owns the partition and tests/conftest.py applies it. The
unit of a shard is a test file, so tests that share module state stay
together, and assignment is a total function of the file path - every file
lands in exactly one shard, and the four shards together run every test exactly
once. The partition is deliberately not built on the area_* markers: those
do not partition the suite, because a file may carry a hand-applied area_*
mark on top of the one derived from its filename.
Sharding deselects; it does not narrow collection. Every test module is still
imported, in the same order, in every shard, so the import-time stubbing in
conftest.py behaves identically whether the suite runs whole or in sections.
Only the deselected tests' call phase is skipped.
Balance comes from the slow marker: a slow item is weighted far above an
ordinary one, and files are packed heaviest-first into the lightest shard. The
plan depends only on the collected file set, so every parallel job computes the
same one from the same commit. As more tests earn a slow mark from duration
evidence, the sections even out further - no duration table to keep current.
--shard 1/1 is a no-op, and a selector that is malformed or out of range ends
the run with a usage error rather than quietly testing a subset. If you change
the shard count, change DEFAULT_SHARD_COUNT and the ci.yml matrix together;
tests/test_shards.py fails when they drift apart.
Local pytest workers
Install the application environment and parallel test tooling with
python -m pip install -r requirements-dev.txt. Parallelism is opt-in; ordinary
pytest remains serial, and the four CI shards are unchanged.
python -m pytest -q -n 4 -p no:cacheprovider --max-worker-restart=0
python -m pytest -q -n 0 -p no:cacheprovider # full serial release oracle
See the Wave 6 measurements before choosing
a worker count. -n auto uses physical cores through xdist's psutil extra; it
still needs enough memory for each worker's collection and application imports.
Before collection, tests.helpers.worker_runtime gives each process private
data, attachment, embedding-cache, browser-runtime, and temporary directories.
pytest's basetemp is the controller root's pytest directory, with xdist's
popen-gw<n> beneath it, which keeps tmp_path Unix sockets inside the
107-byte path limit. An explicit --basetemp still wins.
An atomic random suffix separates concurrent invocations with the same worker
label; paths inside the root have stable names. Function fixtures still own
their databases and test-specific state. Normal teardown removes the root and
restores the environment. A hard-killed standalone process can leave its owned
root behind, but later runs allocate a fresh namespace and never adopt it.
APP_PORT opts into tests against an externally launched smoke application.
Run that suite with -n 0 so its accounts, endpoints, and application data have
one owner. Unset APP_PORT for ordinary unit/regression invocations. Selected
live smoke tests are rejected under xdist with a collection failure; -m "not serial" may exclude them. The existing smoke skips when no application
is launched remain visible in full-suite counts.
Measurements disable pytest's advisory cache to keep concurrent invocations from sharing last-failed metadata. They also disable worker restart so crashes remain immediately visible. No test retries or default worker count are added.
Order-sensitivity reporting (report-only)
tests/run_order_report.py runs pytest with the collected test items shuffled
by a seeded RNG, to surface order-sensitive tests (hidden coupling through
shared import state, module caches, databases, etc.). It is report-only: it is
not wired into CI, adds no gate, and changes no normal pytest collection or
ordering - the shuffle exists only inside this runner. The seed is always
printed, and pytest targets/options go after a literal --:
./venv/bin/python tests/run_order_report.py --seed 123 -- tests/cli/ -q
./venv/bin/python tests/run_order_report.py -- tests/cli/ -q # generates and prints a seed
The same seed reproduces the same order when the reported working directory, pytest target arguments, and test environment are also the same. The runner prints all command arguments with shell-safe POSIX quoting and uses the invoking Python interpreter.
A generated-seed run starts with output like:
[order-report] working directory: /path/to/odysseus
[order-report] shuffling test order with seed 284734921
[order-report] reproduce from this working directory with the same test environment:
[order-report] reproduce with: /path/to/odysseus/venv/bin/python /path/to/odysseus/tests/run_order_report.py --seed 284734921 -- tests/cli/ -q
Run the printed command from the reported working directory to reproduce the same fixed-seed order:
[order-report] working directory: /path/to/odysseus
[order-report] shuffling test order with seed 284734921
[order-report] reproduce from this working directory with the same test environment:
[order-report] reproduce with: /path/to/odysseus/venv/bin/python /path/to/odysseus/tests/run_order_report.py --seed 284734921 -- tests/cli/ -q
Pytest output remains visible between the report header and footer. A failing run ends with pytest's normal failure report followed by:
FAILED tests/example_test.py::test_example - AssertionError
[order-report] seed 284734921: pytest exit code 1 (report-only; fix order-sensitive failures in separate scoped PRs)
Failures discovered this way are real isolation bugs: fix them in separate
scoped PRs - do not silence them with skip/xfail, and do not "fix" them by
depending on a particular order.
The runner propagates pytest's exit code, so it composes with normal local workflows; "report-only" means it is not a CI gate, not that failures are swallowed.
CSS computed-style snapshot
tests/test_css_computed_style_snapshot.py pins the rendered result of
the shipped ordered stylesheet cascade, whose behavior depends on source order,
by hashing getComputedStyle over a fixed element inventory across pages,
viewports, themes and density modes. Any PR that moves CSS has to produce an
identical digest or explain why it did not.
./venv/bin/python -m pytest tests/test_css_computed_style_snapshot.py
./venv/bin/python scripts/css_snapshot.py --check # standalone, no pytest
./venv/bin/python scripts/css_snapshot.py --write-baseline # re-record, deliberately
The inventory, the baseline and the capture live in tests/css_snapshot/;
tests/css_snapshot/README.md documents what is covered, what is deliberately
not, and how to find the property that moved when it fails. The run takes about
21 seconds and skips when npm ci has not been run.
Release smoke suite
tests/smoke/ drives every advertised feature area once, end to end,
against a real instance - the safety net the unit suite does not provide
for a route move or a module split. One command boots the worktree and
runs it:
scripts/odysseus-smoke # boot, run every area, stop again
scripts/odysseus-smoke --keep-up # leave the instance running
scripts/odysseus-smoke --areas # the coverage table, without booting
It reads its target instance out of the environment (APP_PORT through
internal_api_base(), plus the dev admin account), so under a plain
pytest with nothing booted every scenario skips with the reason and
the full suite stays green. Models are served by a deterministic
loopback stub, never a live endpoint; email uses the repo's existing
ODYSSEUS_EMAIL_FIXTURE path.
The report is a per-area table that also prints the areas the suite
deliberately does not cover, so it cannot be read as coverage of
everything it omits. tests/smoke/README.md documents what is in each
list and why.
Core principles
- Keep PRs small and homogeneous: one kind of change per PR.
- Prefer explicit local setup over hidden global fixtures.
- Avoid expanding the root
conftest.pyunless absolutely necessary. - Do not mix file moves with logic changes in the same PR.
- Do not weaken tests with
skip/xfailjust to make CI pass. - Validate the focused files you changed, plus any neighboring or order-sensitive groups they interact with.
Helper conventions
The helpers below live under tests/helpers/. They exist to remove repeated
boilerplate that already appeared across multiple tests. Reach for one only when
your test matches its intended use; do not stretch a helper to cover a new case.
tests.helpers.stylesheets.app_css
Use when a test asserts on a CSS rule.
- Returns every app stylesheet concatenated in the order
static/index.htmlloads them, which is the order the cascade actually has. - App styles live across an ordered cascade; reading one fragment alone ties the test to whichever file a rule sits in today, so it goes red when a rule moves without the rendered page changing.
stylesheet_paths()andstylesheet_urls()are there when a test needs the files or the request URLs rather than their contents.stylesheet_link_tags()returns the<link>markup for a synthetic page driven through Playwright, so it gets the whole shipped cascade.- All of them fail loudly if
index.htmllinks a stylesheet that is missing. - Not for vendored CSS under
static/lib/, which they deliberately skip.
tests.helpers.cli_loader.load_script
Use when a test needs to import a script under scripts/ without repeating
SourceFileLoader / importlib.util boilerplate.
- Intended for script/CLI tests that load a single file from
scripts/. - Not for arbitrary package imports - use a normal
importfor those. - When migrating an existing test to it, keep the existing stubs and assertions
unchanged. Any
sys.modulesstubs the script needs at import time must still be injected (e.g. viamonkeypatch) before callingload_script.
tests.helpers.import_state.clear_module
Use when a test must drop one cached module and its parent-package attribute before a fresh import.
- Clears
sys.modules[name]. - Clears the parent-package attribute when present.
- Good replacement for local
sys.modules.pop(...)+delattr(parent, child)blocks.
tests.helpers.import_state.preserve_import_state
Use when a test temporarily installs stubs into sys.modules and needs
deterministic cleanup afterward.
- Context manager: restores both
sys.modulesentries and parent-package attributes on exit (normal or exception). - Useful around module-level stubs or temporary imports.
- Prefer narrow, explicit module names over broad ones.
tests.helpers.import_state.clear_fake_database_modules
Use only for the guarded fake/stub database cleanup pattern.
- Preserves a real-looking
core.database(one with a string__file__). - Removes a fake/stub
core.databaseand the relatedsrc.databasestate. - Do not use as a general database reset fixture.
tests.helpers.import_state.clear_fake_endpoint_resolver_modules
Use only for the guarded fake/stub src.endpoint_resolver cleanup pattern.
- Preserves real resolver modules (those with a truthy
__file__). - Evicts fake/stub resolver modules and the dependent route modules that were cached against them.
- Accepts explicit extra dependent module names to evict alongside the defaults.
tests.helpers.sqlite_db.make_temp_sqlite
Use for the repeated file-backed temp sqlite setup in tests.
- Only constructs
(SessionLocal, engine, tmpfile)from the repeated block. - Does not patch modules and does not clean up the temp file.
- The caller must bind
SessionLocalexplicitly onto whatever module the code under test reads, and must keep the returned objects alive. - Do not use it as a general DB fixture framework.
tests.helpers.db_stubs.make_core_db_stub
Use for small import-time core.database stubs with a placeholder
SessionLocal.
- Pass model names via
modelswhen MagicMock attributes are sufficient. - Pass
attributeswhen an import needs exact placeholder values. - Set
install_core_package=Trueonly when the test also needs a fake parentcoremodule stub. - Keep custom fake sessions and route-specific database behavior local.
What not to abstract yet
Some remaining patterns should stay as-is for now rather than being forced into helpers:
- Large mixed files such as security/review regression files.
- Broad setup-oriented
sys.modulesstub installers. - One-off custom module patching.
- Custom DB session, route, and app setup.
Validation expectations
Run validation locally before opening or approving a PR. Practical checks:
git diff --check- catch whitespace and conflict-marker errors../venv/bin/python -m py_compile <changed files>- confirm changed files compile.- Focused
./venv/bin/python -m pyteston the changed test files. ./venv/bin/python -m pyteston neighboring or order-sensitive test groups that share import state with the changed files.grepfor the old boilerplate when replacing it, to confirm no stragglers remain.- A fresh audit worktree when changing the helpers themselves, so stale
__pycache__or import state cannot mask a regression.
Current roadmap
- Import-state cleanup - complete.
- Document helper conventions (this file).
- Pilot the repeated import-time
core.databasestub helper. - Add further tiny helpers only when the repeated semantics are clear.
- Start low-risk file moves only after helper conventions are documented.
- Avoid moving high-risk security/route regression files first.