feat(harness): authority, turn contract, tools, clean_agent_preview

Chat/agent harness work: request-authority model, turn contract, tool
policy/parsing/execution, clean_agent_preview streaming/thinking, ask_user
persistence, session probes, and scheduler.

Source: vendor/photocraft (ad1cff44) harness files, 3-way merged onto
upstream/dev (a8c147b2). Harness core files were independently developed
in both lineages; resolution policy documented in pr_work/C_conflicts.md
(vendor kept where ahead, upstream kept where vendor is behind; full delta
preserved in pr_work/C_upstream_delta.patch).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pewdiepie-archdaemon
2026-10-13 13:41:53 +00:00
co-authored by Claude Opus 5.5
parent a8c147b238
commit 364229f2dc
60 changed files with 2778 additions and 696 deletions
+369
View File
@@ -0,0 +1,369 @@
# Wave 6 test-isolation foundation
## Provenance and boundary
- Worktree: `/home/alteixeira20/Projects/Odysseus/odysseus-wave6-test-isolation`.
- Starting branch: `wave6/test-isolation-foundation`.
- Starting HEAD: `b1666951faf8285054e1ca90f11533b0fb53fb57`.
- Starting TREE: `a3dee24e3c56c22e1d3f9805f409bf16b086c3d4`.
- `git status --short --branch`: `## wave6/test-isolation-foundation` only.
- `git status --porcelain`: empty. Starting worktree clean.
- No rebase, merge, push, PR edit, or access to the Wave 4 worktree.
- All changes are test code, test helpers, or this test validation report.
No production code changed; no STOP-level exception was needed.
## Contamination map
| Source | Mutation / binding | Previous cleanup | Escaping state / effect |
| --- | --- | --- | --- |
| Both tests in `tests/test_truncate_message_count_regression.py`, through `_make_manager` | Creates an unowned `mkstemp` DB; directly sets `os.environ["DATABASE_URL"]`; reloads `core.database` then `core.session_manager` | Only the assertion's ORM session is closed | Environment, engine, factory, declarative Base, ORM classes, manager globals, and registered engine listeners survive; database files are never deleted |
| Collection of `tests/test_manage_tasks_owner_scope.py` | Creates a module-lifetime temporary DB and assigns `_TS` permanently to `core.database.SessionLocal` | Some individual rows deleted; ORM sessions closed | The collection-time factory assignment leaks to unrelated tests; truncation reload replaces it, so production handlers and `_seed` / `_get` use different databases |
| `test_checkin_digest_owner_scope.py`, `test_gallery_owner_filter_single_user.py`, `test_document_session_owner_scope.py` | Create module-lifetime DB files / engines and retain local factories | Some rows deleted / route factory restored / ORM sessions closed | Owned files and engines lack teardown; retained imported ORM identities can disagree with reloaded module globals |
| `tests/conftest.py` bootstrap | `DATABASE_URL` was set only if absent before importing `core.database` | No environment restoration | An inherited developer URL could be opened and initialized during collection |
`importlib.reload` updates the existing module dictionary in place; it does
not change the `sys.modules` module object's identity. Previously imported
symbols nevertheless keep their old class/factory identities. In particular,
`src.database` re-exports old objects, while reloaded `core.session_manager`
rebinds its `DbSession`, `DbChatMessage`, `DbDocument`, `SessionLocal`, and
`SessionManager`. Old functions still resolve globals through those changed
module dictionaries. Each database reload also registers a new SQLite connect
listener on SQLAlchemy's Engine class.
The current failures are principally in `test_manage_tasks_owner_scope.py`:
`_seed` writes through its retained `_TS`, but `do_manage_tasks` imports the
now-replaced factory from `core.database` at call time. Ownerless / cross-owner
mutations incorrectly observe "task not found"; successful mutation/list/create
witnesses likewise read or write the wrong test database. Four invalid-date
cases still pass, so the observed contamination count is 16 rather than all
20 task-owner cases.
`src.settings` has TTL caches, but those caches do not resolve DATABASE_URL.
The contaminating helper neither patches nor reloads settings. The database
URL is read directly from the environment at database import/reload time;
settings cache invalidation is not the correction for this leak.
## Baseline reproduction before editing
All baseline commands explicitly selected in-memory bootstrap storage. Python
with installed pytest/SQLAlchemy was
`/tmp/pr40-final-validation-venv/bin/python` (pytest 9.1.1, SQLAlchemy 2.1.1).
```sh
DATABASE_URL=sqlite:///:memory: /tmp/pr40-final-validation-venv/bin/python -m pytest -q -p no:cacheprovider tests/test_manage_tasks_owner_scope.py tests/test_checkin_digest_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_document_session_owner_scope.py tests/test_truncate_message_count_regression.py
# 30 passed, 9 warnings in 2.48s
DATABASE_URL=sqlite:///:memory: /tmp/pr40-final-validation-venv/bin/python -m pytest -q -p no:cacheprovider tests/test_truncate_message_count_regression.py tests/test_manage_tasks_owner_scope.py tests/test_checkin_digest_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_document_session_owner_scope.py
# 16 failed, 14 passed, 9 warnings in 2.40s
DATABASE_URL=sqlite:///:memory: /tmp/pr40-final-validation-venv/bin/python -m pytest -q -p no:cacheprovider -rx --runxfail tests/test_runtime_behavior_regressions.py -k negative_web
# 2 failed (IndexError), 1 passed, 13 deselected, 1 warning in 1.61s
```
The same 30 database tests shuffled with `random.Random(42)` before editing
reported **9 failed, 21 passed**, 9 warnings in 2.37s. Exact command:
```sh
/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py seed:42 before-seed42 tests/test_manage_tasks_owner_scope.py tests/test_checkin_digest_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_document_session_owner_scope.py tests/test_truncate_message_count_regression.py
```
## Isolation correction and regression evidence
- `tests/helpers/database.py` owns a fresh TemporaryDirectory, file-backed
SQLite engine with NullPool, real schema, and factory for each invocation.
Teardown disposes the engine and removes the directory including sidecars.
- The truncation fixture temporarily patches only resource bindings in
`core.database`, `src.database`, and `core.session_manager`, with a matching
temporary environment URL. Patches are undone before resource disposal.
No module reload, module eviction, new ORM class, or listener registration.
- Four owner modules now own a disposable database per test rather than at
collection. Only the task-owner module patches the canonical factory; that
patch is scoped to the test and restored during teardown. The other modules
retain their existing handler/route wiring and ownership assertions.
- The existing pytest bootstrap always chooses in-memory storage before
production database imports and registers environment restoration with the
pytest configuration lifecycle. No new process-wide autouse cleanup fixture.
- `tests/test_database_test_isolation.py` checks real persistent truncation,
exact class/factory restoration, file removal, clean subsequent database
state, and exception teardown. Child pytest runs execute the actual original
witness entry points in both directions and check process-visible bindings
after collection and after every test. An inherited developer-DB sentinel
remains byte-identical, with no database sidecars created, and the inherited
environment URL is restored after pytest exits.
Mutation checks temporarily restored one original file at a time, ran
`test_actual_tests_restore_process_state_and_ignore_inherited_database[True]`,
and restored the corrected file in `finally`. Both mutations made the new
regression fail (pytest exit 1): original truncation was caught as
"test leaked database or module state", original task-owner collection as
"collection changed database bindings". No mutation was retained.
## Historical negative-web witnesses and production blocker
Both parameter cases in
`test_negative_web_wording_withholds_the_web_tools_unhandled` intended to prove
that inferred negative-web wording can receive a memory-only answer without
`web_search` or `web_fetch` being offered to the model:
1. `Summarise what you already know. Do not search the web.`
2. `No web search please, just tell me what you know about Python decorators.`
Both previously indexed an empty fake-model call list and xfailed on unrelated
`IndexError`. The fake also emitted provider-shaped `choices` JSON at a seam
that now consumes normalized `{"delta": ...}` SSE.
The repaired witnesses drive the unmodified public `stream_agent_loop`, mock
only model output and existing infrastructure edges, capture the actual SSE,
and require one model call, withheld web schemas, and delivered fake answer.
The existing passing "don't search online" control and positive search
control remain. The fake now emits normalized SSE.
The two historical xfails remain **strict**, with
`raises=_MemoryOnlyTurnBlocked`. That exception is emitted only when the real
loop makes zero model calls and returns the exact known disabled-web response.
IndexError, arbitrary assertions, or other exceptions no longer qualify.
This is a genuine production defect outside the lane: negative-web detection
is true for both phrasings, but `_classify_agent_request` returns `{'web'}` and
`_web_search_unavailable_for_turn` returns true after the negative has disabled
web tools. The loop short-circuits the requested memory-only response with:
> Web access is disabled for this turn. Enable web search and resend the request.
The passing "Answer from memory only, don't search online." control has
`{'memory', 'web'}` intent domains and does not short-circuit. A minimized
reproduction using those three production functions requires neither a model
nor a mock. Relevant production locations (intentionally untouched):
`src/agent_loop.py` negative detector near line 864, classifier near 10371,
negative-tool disabling near 20735, unavailable calculation near 22233, and
short-circuit response near 25101.
```sh
/tmp/pr40-final-validation-venv/bin/python -m pytest -q -p no:cacheprovider --runxfail tests/test_runtime_behavior_regressions.py -k negative_web_wording_withholds_the_web_tools_unhandled
# 2 failed with _MemoryOnlyTurnBlocked, 14 deselected, 1 warning in 0.75s
```
No production repair was attempted. Runtime intent/availability behavior,
authority, completion/evidence, and Wave 4 effects files remain untouched.
This blocker prevents a READY release-foundation verdict despite passing test
isolation validation.
## Final order validation
The focused manifest has 50 cases: the five baseline database modules plus
`test_database_test_isolation.py` and `test_runtime_behavior_regressions.py`.
Normal collection order is alphabetical by the seven explicitly selected files.
Reversed and seeded runs reorder **individual collected test cases**, including
parameterized cases. Both contamination directions also vary file collection
order. Every execution manifest is retained as JSON in the evidence directory.
| Run | Result |
| --- | --- |
| Normal | 48 passed, 2 xfailed, 0 skipped, 11 warnings in 9.01s |
| Reverse of all collected cases | 48 passed, 2 xfailed, 0 skipped, 11 warnings in 8.47s |
| Truncation module first, owner witnesses afterward | 48 passed, 2 xfailed, 0 skipped, 11 warnings in 8.00s |
| All four owner modules first, truncation afterward | 48 passed, 2 xfailed, 0 skipped, 11 warnings in 8.98s |
| Seed 1 | 48 passed, 2 xfailed, 0 skipped, 11 warnings in 9.01s |
| Seed 42 | 48 passed, 2 xfailed, 0 skipped, 11 warnings in 8.98s |
| Seed 2026 | 48 passed, 2 xfailed, 0 skipped, 11 warnings in 8.97s |
Exact executed commands:
```sh
/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py normal after-normal tests/test_checkin_digest_owner_scope.py tests/test_database_test_isolation.py tests/test_document_session_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_manage_tasks_owner_scope.py tests/test_runtime_behavior_regressions.py tests/test_truncate_message_count_regression.py
/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py reverse after-reverse tests/test_checkin_digest_owner_scope.py tests/test_database_test_isolation.py tests/test_document_session_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_manage_tasks_owner_scope.py tests/test_runtime_behavior_regressions.py tests/test_truncate_message_count_regression.py
/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py normal after-truncation-first tests/test_truncate_message_count_regression.py tests/test_checkin_digest_owner_scope.py tests/test_database_test_isolation.py tests/test_document_session_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_manage_tasks_owner_scope.py tests/test_runtime_behavior_regressions.py
/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py normal after-owners-first tests/test_checkin_digest_owner_scope.py tests/test_document_session_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_manage_tasks_owner_scope.py tests/test_truncate_message_count_regression.py tests/test_database_test_isolation.py tests/test_runtime_behavior_regressions.py
/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py seed:1 after-seed1 tests/test_checkin_digest_owner_scope.py tests/test_database_test_isolation.py tests/test_document_session_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_manage_tasks_owner_scope.py tests/test_runtime_behavior_regressions.py tests/test_truncate_message_count_regression.py
/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py seed:42 after-seed42 tests/test_checkin_digest_owner_scope.py tests/test_database_test_isolation.py tests/test_document_session_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_manage_tasks_owner_scope.py tests/test_runtime_behavior_regressions.py tests/test_truncate_message_count_regression.py
/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py seed:2026 after-seed2026 tests/test_checkin_digest_owner_scope.py tests/test_database_test_isolation.py tests/test_document_session_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_manage_tasks_owner_scope.py tests/test_runtime_behavior_regressions.py tests/test_truncate_message_count_regression.py
```
The order driver is test-session-local; it is not installed into conftest or
production and never changes the normal suite's ordering. Its exact source
(for recreating `/tmp/odysseus-wave6-isolation-evidence/run_order.py`) is:
```python
import json
import os
from pathlib import Path
import random
import sys
import pytest
out = Path('/tmp/odysseus-wave6-isolation-evidence')
mode, label, *modules = sys.argv[1:]
class Order:
@pytest.hookimpl(trylast=True)
def pytest_collection_modifyitems(self, items):
if mode == 'reverse':
items.reverse()
elif mode.startswith('seed:'):
random.Random(int(mode.split(':')[1])).shuffle(items)
(out / f'{label}-manifest.json').write_text(json.dumps([i.nodeid for i in items], indent=2) + '\n')
os.environ['DATABASE_URL'] = 'sqlite:///:memory:'
args = ['-q', '-p', 'no:cacheprovider', '-rsx', *modules]
(out / f'{label}-command.json').write_text(json.dumps({'argv':sys.argv,'pytest_args':args,'DATABASE_URL':os.environ['DATABASE_URL']},indent=2) + '\n')
raise SystemExit(pytest.main(args, plugins=[Order()]))
```
## Adjacent and complete validation
The first complete run finished with **44 failed, 12509 passed, 62 skipped,
2 xfailed**, 184 warnings and 6 passing subtests in 391.85s. Its failure
inventory was 42 missing-Playwright-package failures and two generated
configuration-reference freshness failures. The local worktree had not had
`npm ci` run; the generator includes test-source line references, and the
bootstrap edit moved the existing references by one line.
`npm ci --ignore-scripts` installed the four already-locked packages; package
and lock files were unchanged. Cached Chromium launched successfully
(version 151.0.7922.34). A bootstrap comment now explains why isolation must
precede collection and preserves the existing referenced line locations,
without editing the generated website or the generator. This follow-up is
commit `f67d0388fd805867a0920dbffd4da056b1361e38`
(`fix(tests): preserve bootstrap reference locations`).
```sh
npm ci --ignore-scripts
# exit 0; 4 packages installed, lockfile and package policy unchanged
python scripts/generate_env_reference.py --check
# exit 0; website/configuration-reference.md is up to date (112 variables)
/tmp/pr40-final-validation-venv/bin/python -m pytest -q -p no:cacheprovider tests/test_document_module_api.py tests/test_env_reference.py
# 16 passed, 1 warning in 17.00s
```
The first full run and the 44-case focused rerun are retained separately from
the final complete run. The 44-case rerun reported **1 failed, 43 passed**
in 73.61s: the existing font/color browser witness observed its font-size
icon still showing `13` after its text had changed to size 5 (expected `24`).
An isolated reproduction of that unchanged test passed in 2.13s. This is an
observed intermittent browser UI witness; no database failure was involved,
and a production root cause has not been established. The test and editor
implementation remain unchanged, with both observations retained rather
than suppressing the failed run. No failure was hidden by skipping or deleting a test.
```sh
/tmp/pr40-final-validation-venv/bin/python -m pytest -q -p no:cacheprovider -rsx tests/test_session_manager.py tests/test_session_manager_cleanup.py tests/test_session_manager_persist_guard.py tests/test_session_mutation_lookup.py tests/test_session_discovery_message_count.py tests/test_history_order_by_timestamp_regression.py tests/test_history_display_model_hydration.py tests/test_session_search.py tests/test_session_search_batch_fetch.py tests/test_session_list_owner_scope.py tests/test_session_owner_attribution.py tests/test_session_endpoint_owner_scope.py tests/test_session_concurrent.py tests/test_app_db_permissions.py tests/test_database_utcnow.py tests/test_tool_policy.py tests/test_turn_contract.py tests/test_chat_preprocess_tool_policy.py tests/test_chat_route_tool_policy.py tests/test_resend_message_nondestructive.py
# 1262 passed, 0 skipped, 0 xfailed, 7 warnings in 40.56s
/tmp/pr40-final-validation-venv/bin/python -m pytest -q -p no:cacheprovider -rsx
# 12556 passed, 59 skipped, 2 xfailed, 184 warnings, 6 subtests passed in 468.49s (0:07:48); exit 0
python -m compileall -q app.py core routes src services scripts tests
# exit 0
git diff --check
# exit 0, no output
git diff --check b1666951faf8285054e1ca90f11533b0fb53fb57
# exit 0, no output
git diff --name-only --diff-filter=U
# exit 0, no output
rg -n '^(<<<<<<< |=======$|>>>>>>> )' <each modified file>
# exit 1, no matches
```
No test was deleted, no skip was added, and no unrelated xfail was added. The
same two historical parameter cases remain xfailed for the demonstrated
production blocker, now restricted to that actual contract failure.
The isolation commit is
`e2b847c84d27ce785565757e81ca5293896eaab3`
(`fix(tests): isolate database and module state`). The final bounded commit
contains the repaired web witnesses and this validation report. Final commit,
HEAD, TREE and clean status are supplied in the accompanying final report to
avoid placing a commit's own hash inside its committed contents.
Full logs, commands, and per-run manifests from this session are in
`/tmp/odysseus-wave6-isolation-evidence/`.
The follow-up normal focused run after preserving bootstrap reference
locations is recorded in `after-bootstrap-followup.log`. It reported **48 passed, 2 xfailed**, 11 warnings in 9.55s. Its exact command is:
```sh
/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py normal after-bootstrap-followup tests/test_checkin_digest_owner_scope.py tests/test_database_test_isolation.py tests/test_document_session_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_manage_tasks_owner_scope.py tests/test_runtime_behavior_regressions.py tests/test_truncate_message_count_regression.py
```
The additional browser observation came from the 44-case manifest retained in
`full-failures-manifest.json`; every exact node ID in that diagnostic command is
retained in `full-failures-rerun-command.json`. The isolated diagnostic command:
```sh
/tmp/pr40-final-validation-venv/bin/python -m pytest -q -p no:cacheprovider tests/test_document_rich_font_color_controls.py::test_numeric_font_size_and_custom_colors_work_on_desktop_and_mobile
# 1 passed, 1 warning in 2.13s
```
## Independent review disposition
A read-only review of isolation commit e2b847c8 raised two concerns, which
were checked against the frozen source rather than accepted as proven leaks:
- `core.models._SESSION_MANAGER_INSTANCE`: `SessionManager.__init__` only
initializes its owned cache and loads metadata; it does not call the global
setter. `SessionManager.add_message` explicitly persists through its own
manager instead of calling `Session.add_message` / the singleton. Neither
truncation witness nor the helper invokes a singleton setter or API route
setup. Thus the asserted singleton mutation is absent from this lane's real
seam. Resetting that unrelated singleton would add cleanup without a proven
owned mutation and could hide a genuine leak elsewhere.
- Eager real-database import versus `clear_fake_database_modules`: the exact
pre-import block already exists on frozen b1666951. The clearing helper
intentionally leaves real modules with a string `__file__` intact and evicts
replacement fake modules lacking it. The correction neither adds that
pre-import nor changes fake-module identification. Its "real module" early
return is intended behavior, not a newly introduced cleanup conflict.
Neither concern supplied a failing reproducer. Both were resolved by the
existing source paths and the actual collection/teardown identity regression;
no speculative global cleanup was introduced.
The minimized production reproduction command was:
```sh
DATABASE_URL=sqlite:///:memory: /tmp/pr40-final-validation-venv/bin/python - <<'PYTHON'
from src.agent_loop import _explicitly_avoids_web_lookup, _classify_agent_request, _web_search_unavailable_for_turn
from src.tool_policy import WEB_TOOL_NAMES
for text in (
"Answer from memory only, don't search online.",
'Summarise what you already know. Do not search the web.',
'No web search please, just tell me what you know about Python decorators.',
):
intent = _classify_agent_request([{'role': 'user', 'content': text}], text)
domains = set(intent['domains'])
print(repr(text))
print('negative_web:', _explicitly_avoids_web_lookup(text))
print('intent_domains:', sorted(domains))
print('web_disabled_short_circuit:', _web_search_unavailable_for_turn(domains, set(WEB_TOOL_NAMES), text, None, None))
PYTHON
```
Results: negative-web detection true for all three; intent domains respectively
`['memory', 'web']`, `['web']`, `['web']`; short-circuit false, true, true.
## Final validation result and verdict
The final complete run in `full-pytest-final.log` exited 0:
**12556 passed, 59 skipped, 2 xfailed**, 184 warnings and 6 passing subtests in
468.49s. All 12617 collected cases are accounted for. The existing optional,
platform-specific and opt-in skips remain; no skip marker was added. Installing
the already-locked Node dependencies enabled three previously skipped cases,
so the skip count decreased from 62 in the first run to 59. No tests were
deleted and no unrelated xfail was introduced.
The font/color browser witness passed in the final complete run. Its failed
44-case diagnostic and passing isolated result remain documented as an
intermittent observation; no production root cause was established or fixed.
Database/environment/module isolation validation is green. Both historical
negative-web cases remain narrowly expected failures for a reproduced genuine
runtime defect outside the test-isolation boundary. These are integration
blockers; production intent/short-circuit behavior must be corrected in an
appropriate lane before removing their strict markers.
WAVE 6 TEST-ISOLATION FOUNDATION NOT READY
+45
View File
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import { chromium } from 'playwright';
const browser = await chromium.launch({ headless: true });
try {
for (const width of [390, 900]) {
const page = await browser.newPage({ viewport: { width, height: 844 }, acceptDownloads: true });
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
await page.setContent('<link rel="stylesheet" href="/static/style.css"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?mobile-export-regression=1');
mod.init('/api');
mod.injectFreshDoc({ id: 'export-test', title: 'Export test', language: 'markdown', version_count: 1, current_content: '# Mobile export\n\nActual document content.' });
});
await page.locator('#doc-footer-export-btn').waitFor({ state: 'visible' });
await page.evaluate(() => {
document.querySelector('.doc-editor-pane').style.setProperty('z-index', '12000', 'important');
});
await page.locator('#doc-footer-export-btn').click();
const menu = page.locator('#doc-export-menu');
await menu.waitFor({ state: 'visible' });
const bounds = await menu.boundingBox();
assert(bounds.x >= 0 && bounds.y >= 0);
assert(bounds.x + bounds.width <= width && bounds.y + bounds.height <= 844);
assert(await menu.evaluate(el => Number(getComputedStyle(el).zIndex) > 12000));
await page.screenshot({ path: `/tmp/document-export-${width}.png` });
// A second click closes the menu, then reopening must still allow export.
await page.locator('#doc-footer-export-btn').click();
assert.equal(await menu.count(), 0);
await page.locator('#doc-footer-export-btn').click();
const pending = page.waitForEvent('download');
await page.locator('#doc-export-menu button').filter({ hasText: /^Export Markdown$/ }).click();
const download = await pending;
assert(download.suggestedFilename().endsWith('.md'));
assert.equal(await download.failure(), null);
const stream = await download.createReadStream();
let content = '';
for await (const chunk of stream) content += chunk;
assert.equal(content, '# Mobile export\n\nActual document content.');
await page.close();
console.log(`PASS ${width}px: visible menu, toggle, download contents`);
}
} finally {
await browser.close();
}
+58
View File
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { chromium } from 'playwright';
const source = readFileSync(new URL('../static/js/document.js', import.meta.url), 'utf8');
const build = source.slice(source.indexOf(' function _buildAnnotation('), source.indexOf(' // Prompt user for an instruction'));
const state = source.slice(source.indexOf(' let _pdfAnnotationDragging'), source.indexOf(' // Per-doc last-used line spacing'));
const placement = source.slice(source.indexOf(' const isBlankPageTarget'), source.indexOf(' pane.appendChild(pageWrap);', source.indexOf(' const isBlankPageTarget')));
const browser = await chromium.launch({ headless: true });
try {
for (const touch of [true, false]) {
const page = await browser.newPage({ viewport: { width: 600, height: 800 }, hasTouch: touch });
await page.setContent('<div id="page" style="position:relative;width:500px;height:700px;background:white"></div>');
await page.evaluate(({ build, state, placement }) => {
new Function(`
const activeDocId = 'test', docId = activeDocId;
const _pdfPaneAnnotationsByDoc = new Map([['test', []]]);
const annotationRefs = _pdfPaneAnnotationsByDoc.get('test');
const _pdfLastLineHeight = new Map(), _pdfLastFontSize = new Map();
let _pdfDropMode = 'check', serial = 0;
const _newAnnotationId = () => 'new-' + (++serial);
const _pushPdfUndoSnapshot = () => {};
const _schedulePdfPaneSave = () => {};
const pageWrap = document.getElementById('page');
const page = {page: 1};
${state}
${build}
${placement}
for (let i = 0; i < 2; i++) {
const {ref} = _buildAnnotation(pageWrap, {id:'a'+i, page:1, x:20+i*35, y:30, w:5, h:5, kind:'check'});
annotationRefs.push(ref);
ref._setHandlesVisible(true);
}
window.refs = annotationRefs;
`)();
}, { build, state, placement });
const grip = page.locator('[data-ann-id="a0"] [title="Drag to move"]');
const box = await grip.boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(300, 220);
assert(await page.evaluate(() => refs.every(ref => [...ref.wrap.querySelectorAll('[title]')].every(el => el.style.display === 'none'))));
// Proximity updates must not bring controls back while dragging.
await page.evaluate(() => refs.forEach(ref => ref._setHandlesVisible(true)));
assert(await page.locator('[data-ann-id="a1"] [title="Delete annotation"]').isHidden());
await page.mouse.up();
assert.equal(await page.locator('.pdf-annotation-wrap').count(), 2);
assert(await page.locator('[data-ann-id="a1"] [title="Delete annotation"]').isVisible());
await page.locator('[data-ann-id="a1"] [title="Delete annotation"]').click();
assert.equal(await page.locator('.pdf-annotation-wrap').count(), 1);
await page.locator('#page').click({ position: { x: 100, y: 450 } });
assert.equal(await page.locator('.pdf-annotation-wrap').count(), 2);
console.log(`PASS ${touch ? 'touch' : 'mouse'}: drag controls hidden, delete only deletes, blank page still places`);
await page.close();
}
} finally {
await browser.close();
}
+1 -1
View File
@@ -89,7 +89,7 @@ a status code. `scripts/odysseus-smoke --areas` prints the current list.
| Email | the fixture inbox lists, opens with its body, and the unread count drops on mark-read |
| Memory | a fact is listed, found by search, and gone after delete |
| Uploads | an attachment reads back byte for byte |
| Cookbook | hardware is detected; a fresh install's recommendation request returns the explicit empty-catalog Rescan guidance; state persists |
| Cookbook | hardware is detected and recommendations come back sized against it; state persists |
| Settings | a preference written on one session is still there after a new login |
## What is not covered, and why
+1
View File
@@ -20,3 +20,4 @@ def test_terse_timed_calendar_create_promotes_to_calendar():
assert intent.needs_tools
assert intent.category == "calendar"
+65
View File
@@ -1,5 +1,7 @@
import json
import pytest
from src.agent_evidence import (
CompletionRequirements,
CompletionStatus,
@@ -934,3 +936,66 @@ def test_exhaustion_and_awaiting_user_are_non_completion_states():
ledger = EvidenceLedger()
assert ledger.evaluate(exhausted=True).status == CompletionStatus.EXHAUSTED
assert ledger.evaluate(awaiting_user=True).status == CompletionStatus.AWAITING_USER
@pytest.mark.parametrize("tool", ["create_document", "update_document", "edit_document"])
def test_authoritative_document_write_records_document_mutation_evidence(tool):
ledger = EvidenceLedger.from_tool_events([
{"tool": tool, "command": "fix grammar", "exit_code": 0},
])
mutations = [event for event in ledger.events
if event.kind == EvidenceKind.DOCUMENT_MUTATION]
assert len(mutations) == 1
assert mutations[0].authoritative and mutations[0].success
assert ledger._supports_document_mutation() is True
def test_non_authoritative_document_write_is_not_mutation_evidence():
ledger = EvidenceLedger.from_tool_events([
{"tool": "update_document", "command": "fix grammar"},
])
assert ledger._supports_document_mutation() is False
def test_successful_private_write_supports_mutation_claim_without_artifact():
# manage_calendar:create_event has no filesystem artifact, so the completion
# verifier previously erased the model's "I created the meeting" claim. A
# successful authoritative owned-state write must now support it.
from src.agent_runtime.completion import completion_answer
ledger = EvidenceLedger.from_tool_events([
{
"round": 1,
"tool": "manage_calendar",
"command": json.dumps({
"action": "create_event",
"title": "Meeting with Sam",
"start": "2026-10-16T15:00:00",
}),
"output": "Created event [Meeting with Sam]",
"exit_code": 0,
"execution_attempted": True,
},
])
assert ledger._private_action_mutations == ["manage_calendar:create_event"]
answer, _ = completion_answer(
"I created the meeting with Sam.", ledger, ledger.evaluate())
assert "created the meeting with Sam" in answer
assert not answer.startswith("Unsupported execution claims")
def test_failed_private_write_does_not_support_mutation_claim():
ledger = EvidenceLedger.from_tool_events([
{
"round": 1,
"tool": "manage_calendar",
"command": json.dumps({"action": "create_event", "title": "x"}),
"output": "denied",
"exit_code": 1,
"execution_attempted": True,
},
])
assert ledger._private_action_mutations == []
+85
View File
@@ -87,6 +87,91 @@ def test_ask_user_is_emitted_last_and_persisted(monkeypatch):
assert metrics["tool_events"][0]["ask_user"] == payload
def test_clean_preview_ask_user_ends_turn_without_second_round(monkeypatch):
"""The compact ``stream_preview`` runtime must treat ask_user as a turn boundary.
Regression for chat d8dca715: the preview fed the ask_user tool result
back to the model, so it burned a second provider round and persisted the
model's follow-up prose instead of the question.
"""
import src.clean_agent_preview as module
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
from src.tool_policy import ToolPolicy
from src.turn_contract import resolve_full_inventory_contract
payload = {
"question": "What would be most useful to start with?",
"options": [
{"label": "A 30-day meditation plan"},
{"label": "How to measure brain activity"},
],
"multi": False,
}
requests = []
responses = iter([
{'choices': [{'delta': {'tool_calls': [{'index': 0, 'id': 'ask-1',
'function': {'name': 'ask_user',
'arguments': json.dumps(payload, ensure_ascii=False)}}]}}]},
{'choices': [{'delta': {'content': 'SHOULD NOT BE REQUESTED'}}]},
])
class Response:
def __init__(self, body): self.body = body
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
def raise_for_status(self): pass
async def aiter_lines(self):
yield 'data: ' + json.dumps(self.body)
yield 'data: [DONE]'
class Client:
def __init__(self, **kwargs): pass
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
def stream(self, *args, **kwargs):
requests.append(kwargs['json'])
return Response(next(responses))
async def execute(block, **kwargs):
parsed = json.loads(block.content)
return 'ask_user', {
'ask_user': parsed,
'output': 'Awaiting their selection.',
'exit_code': 0,
}
monkeypatch.setattr(module.httpx, 'AsyncClient', Client)
monkeypatch.setattr(module, 'execute_tool_block', execute)
schema = next(s for s in FUNCTION_TOOL_SCHEMAS if s['function']['name'] == 'ask_user')
contract = resolve_full_inventory_contract(schemas=[schema], policy=ToolPolicy())
raw = _collect(module.stream_preview(
endpoint_url='http://test', model='test', headers={},
messages=[{'role': 'user', 'content': 'Help me pick a project.'}],
turn_contract=contract, session_id='test', owner='test',
disabled_tools=set(), tool_policy=ToolPolicy(),
))
events = _events(raw)
# Ending the turn means no second provider round is requested.
assert len(requests) == 1
ask_user_events = [event for event in events if event.get('type') == 'ask_user']
assert len(ask_user_events) == 1
assert ask_user_events[0]['data'] == payload
# The saved assistant text is the question, not the model's follow-up prose.
final = next(event for event in events if event.get('type') == 'final_response')
assert final['content'] == payload['question']
types = [event.get('type') for event in events]
assert types.index('ask_user') < types.index('metrics')
# Reconstructable from persisted metadata, and no phantom extra rounds.
metrics = next(event['data'] for event in events if event.get('type') == 'metrics')
assert metrics['tool_events'][0]['ask_user'] == payload
assert metrics['agent_rounds'] == 1
assert metrics['clean_v3_turn'][-1] == {
'role': 'assistant', 'content': payload['question'],
}
def test_frontend_uses_one_renderer_for_live_and_restored_cards():
chat = (ROOT / "static" / "js" / "chat.js").read_text(encoding="utf-8")
renderer = (ROOT / "static" / "js" / "chatRenderer.js").read_text(encoding="utf-8")
+1
View File
@@ -60,6 +60,7 @@ def test_login_page_static_assets_resolve_under_mount_path():
mounted_login_url = "https://example.test/odysseus/login"
for relative_asset, mounted_path in (
("static/manifest.json", "/odysseus/static/manifest.json"),
("static/icons/icon-192.png", "/odysseus/static/icons/icon-192.png"),
(
"static/fonts/FiraCode-Regular.woff2",
"/odysseus/static/fonts/FiraCode-Regular.woff2",
+31
View File
@@ -0,0 +1,31 @@
"""A valid login session cookie must survive a deploy restart of the server.
AuthManager keeps tokens in memory but persists them to sessions.json on create
and reloads them at startup, so a restart must NOT log the user out (the 302 the
bench saw was the restart gap / a client-side artifact, not session loss).
"""
from core import auth
def test_session_token_survives_a_restart(tmp_path):
auth_path = str(tmp_path / "auth.json")
first = auth.AuthManager(auth_path)
assert first.create_user("pewds", "hunter2", is_admin=True)
token = first.create_session_trusted("pewds")
assert token and first.validate_token(token)
# Simulate a process restart: a brand-new AuthManager on the same data dir.
second = auth.AuthManager(auth_path)
assert second.validate_token(token) is True
assert second.get_username_for_token(token) == "pewds"
def test_revoked_token_does_not_survive_restart(tmp_path):
auth_path = str(tmp_path / "auth.json")
first = auth.AuthManager(auth_path)
first.create_user("pewds", "hunter2", is_admin=True)
token = first.create_session_trusted("pewds")
first.revoke_token(token)
second = auth.AuthManager(auth_path)
assert second.validate_token(token) is False
+42
View File
@@ -0,0 +1,42 @@
"""Spec-surface admission: the strict router picks the tools after authority is
built, so spec turns admit the router's families; compact/full stay unchanged."""
import json
from src.agent_runtime.authority import create_request_authority, ExactOperation
def _permits(auth, tool, args=None):
return auth.permits(ExactOperation.normalize(tool, json.dumps(args or {})))
def _auth(text, **kw):
return create_request_authority(text, owner="pewds", session_id="s1", workspace="", **kw)
def test_spec_surface_admits_a_router_chosen_family_tool():
auth = _auth("Search for the appointment email", spec_surface=True)
assert _permits(auth, "mcp__email__search_emails", {"action": "list", "query": "appointment"})
def test_non_spec_turn_does_not_admit_the_same_call():
auth = _auth("Search for the appointment email")
assert not _permits(auth, "mcp__email__search_emails", {"action": "list", "query": "appointment"})
def test_spec_surface_admits_browser_interaction_and_batch():
auth = _auth("do the thing", spec_surface=True)
assert _permits(auth, "private_browser", {"action": "batch"})
assert _permits(auth, "private_browser", {"action": "click", "selector": "#go"})
assert _permits(auth, "private_browser", {"action": "fill", "selector": "#q", "text": "x"})
def test_batch_is_read_but_click_stays_verb_gated_off_spec():
auth = _auth("search the web for the appointment")
assert _permits(auth, "private_browser", {"action": "batch"})
assert not _permits(auth, "private_browser", {"action": "click", "selector": "#go"})
def test_spec_surface_keeps_the_exact_read_input_seal():
auth = _auth("Read note id abc123", spec_surface=True)
assert _permits(auth, "manage_notes", {"action": "view", "id": "abc123"})
assert not _permits(auth, "manage_notes", {"action": "view", "id": "zzz999"})
+20
View File
@@ -1,8 +1,28 @@
import json
import pytest
from src.clean_agent_preview import preview_tool_result_text
@pytest.mark.parametrize('snapshot', [None, '', ' '])
def test_text_and_title_survive_without_snapshot(snapshot):
result = {'url': 'https://example.org', 'title': 'Access denied',
'text': 'Unusual traffic. Complete the CAPTCHA.', 'snapshot': snapshot}
output = preview_tool_result_text({'output': json.dumps(result)}, 'private_browser', {})
assert 'Access denied' in output
assert 'Unusual traffic. Complete the CAPTCHA.' in output
from src.clean_agent_preview import browser_observation_access_blocked
assert browser_observation_access_blocked(output)
def test_snapshot_takes_precedence_over_duplicate_page_text():
output = preview_tool_result_text({'output': json.dumps({
'snapshot': '- link "Report" [ref=e1]', 'text': 'Duplicated page body',
})}, 'private_browser', {})
assert '[ref=e1]' in output
assert 'Duplicated page body' not in output
def test_legacy_page_text_retains_access_block_evidence():
result = {'url': 'https://example.org', 'title': 'Security verification',
'text': 'Unusual traffic. Complete the CAPTCHA.'}
+37
View File
@@ -231,6 +231,25 @@ def test_disabled_tools_respects_missing_vs_explicit_toggles():
)
def test_request_authority_grants_web_when_turn_offers_web_tools():
"""Web tools offered for a turn must carry request authority too.
Regression: the authority capabilities gate only recognized explicit
browser wording (``_explicit_browser_intent``/``_external_discovery_intent``),
so an implicit research question with the Web toggle on offered
``web_search`` in the model's schema but rejected every call with
``request_authority_denied``. The gate must also grant ``search_browser``
whenever web tools are not disabled for the turn.
"""
source = _CHAT_ROUTES.read_text(encoding="utf-8")
assert "({'search_browser'} if (" in source, (
"request authority must be built with a search_browser capabilities gate"
)
assert "or \"web_search\" not in disabled_tools" in source, (
"authority must grant search_browser whenever web_search is offered"
)
def test_explicit_private_browser_workflow_survives_web_search_disabled():
source = _CHAT_ROUTES.read_text(encoding="utf-8")
assert "_explicit_private_browser_intent" in source
@@ -748,3 +767,21 @@ def test_frontend_sends_explicit_allow_web_search_false_in_agent_mode():
assert "fd.append('allow_web_search', el('web-toggle').checked ? 'true' : 'false')" in source, (
"Frontend must send explicit allow_web_search=false in agent mode when toggle is off"
)
def test_spec_surface_skips_the_contract_scope_tool_denial():
"""On the strict-router (spec) surface the regex turn contract must NOT
scope disabled_tools, or the spec router menu (email/notes/...) is emptied
before src/spec_router.py runs. Compact/full/none keep the denial."""
source = _CHAT_ROUTES.read_text(encoding="utf-8")
assert "if not _spec_surface:" in source, (
"the contract-scope disabled_tools.update must be guarded for spec turns"
)
idx = source.index("if not _spec_surface:")
segment = source[idx:idx + 500]
assert "disabled_tools.update(" in segment, (
"the contract-scope denial must live inside the spec guard"
)
assert "if not _turn_contract.permits(" in segment, (
"the guarded block must be the contract-scope denial"
)
+424 -34
View File
@@ -109,12 +109,37 @@ def test_deepseek_flash_keeps_tools_but_drops_unsupported_forced_choice():
}
compatible = provider_compatible_tool_choice_request(request, 'deepseek-flash')
assert [tool['function']['name'] for tool in compatible['tools']] == ['inspect_media']
# A forced named choice must not hide the other offered tools.
assert [tool['function']['name'] for tool in compatible['tools']] == [
'inspect_media', 'python'
]
assert 'tool_choice' not in compatible
assert request['tool_choice']['function']['name'] == 'inspect_media'
assert provider_compatible_tool_choice_request(request, 'qwen3.5-9b') is request
def test_deepseek_forced_read_does_not_hide_other_offered_tools():
# Regression: a required calendar read forced tool_choice=manage_calendar on
# a turn that also offered the email tools; the DeepSeek compatibility path
# used to shrink `tools` to manage_calendar only, so the model reported the
# email tools the user asked for were unavailable.
request = {
'model': 'deepseek-v4-flash',
'tools': [
{'type': 'function', 'function': {'name': 'manage_calendar'}},
{'type': 'function', 'function': {'name': 'mcp__email__search_emails'}},
{'type': 'function', 'function': {'name': 'mcp__email__read_email'}},
],
'tool_choice': {'type': 'function', 'function': {'name': 'manage_calendar'}},
}
compatible = provider_compatible_tool_choice_request(request, 'deepseek-v4-flash')
names = [tool['function']['name'] for tool in compatible['tools']]
assert names == [
'manage_calendar', 'mcp__email__search_emails', 'mcp__email__read_email'
]
assert 'tool_choice' not in compatible
def test_deepseek_v4_flash_drops_named_choice_for_thinking_mode():
request = {
'tools': [{'type': 'function', 'function': {'name': 'write_file'}}],
@@ -136,8 +161,10 @@ def test_kimi_k3_drops_named_choice_incompatible_with_thinking_mode():
compatible = provider_compatible_tool_choice_request(request, 'kimi-k3')
# The full tool surface is preserved; only the unsupported forced choice
# is dropped.
assert [tool['function']['name'] for tool in compatible['tools']] == [
'inspect_media'
'inspect_media', 'write_file'
]
assert 'tool_choice' not in compatible
@@ -210,8 +237,8 @@ def test_private_browser_covered_click_does_not_advance_dom_revision():
def test_compact_notes_preserves_create_vs_edit_and_replacement_semantics():
schema = next(s for s in FUNCTION_TOOL_SCHEMAS if s['function']['name'] == 'manage_notes')
compact = compact_schemas([schema])[0]['function']
assert 'add creates a new note' in compact['description']
assert 'update with id' in compact['description']
assert 'ONE add call' in compact['description']
assert 'update with id edits an existing note, never add' in compact['description']
assert 'replaces the whole checklist' in compact['parameters']['properties']['checklist_items']['description']
@@ -3511,6 +3538,10 @@ def test_every_compactly_offered_preview_tool_has_valid_policy_permitted_call():
'web_fetch': ({'url': 'https://example.com'}, 'read this page'),
'web_search': ({'query': 'current AI news'}, 'search the web'),
'youtube_tool': ({'action': 'metadata', 'video_url': 'https://youtube.com/watch?v=x'}, 'read YouTube metadata'),
'plan_edit': ({'prompt': 'make it black and white'}, 'make it black and white'),
'run_editor_command': ({'op': 'document.info'}, 'inspect the document'),
'get_editor_screenshot': ({}, 'take a screenshot of the editor'),
'ai_edit_image': ({'op': 'remove-bg'}, 'remove the background'),
}
schemas = {
schema['function']['name']: schema
@@ -3537,6 +3568,9 @@ def test_every_compactly_offered_preview_tool_has_valid_policy_permitted_call():
}
else {'image_editing'} if name == 'edit_image'
else {'image_generation'} if name == 'generate_image'
else {'photo_editor'} if name in {
'plan_edit', 'run_editor_command', 'get_editor_screenshot', 'ai_edit_image',
}
else frozenset()
),
contract_required_tools={name},
@@ -4366,21 +4400,20 @@ def test_v3_schema_uses_configured_versioned_contract_root(tmp_path, monkeypatch
(contract_root / 'eval_alltools_unseen_compare.py').write_text(
"def tools_for_mode(tools, mode):\n"
" assert mode == 'compact_contract_v5'\n"
" tools[0]['function']['description'] = 'versioned-contract-loaded'\n"
" tools[0]['function']['parameters']['properties']['action']['description'] = 'versioned-contract-loaded'\n"
" return tools\n",
encoding='utf-8',
)
monkeypatch.setenv('ODYSSEUS_TOOL_CONTRACT_ROOT', str(contract_root))
module.contract_builder.cache_clear()
try:
# Probe a tool whose compact description the harness does not
# replace; manage_notes now carries a full harness-owned override.
search = next(
notes = next(
s for s in FUNCTION_TOOL_SCHEMAS
if s['function']['name'] == 'web_search'
if s['function']['name'] == 'manage_notes'
)
compact = module.compact_schemas([search])[0]
assert compact['function']['description'].startswith('versioned-contract-loaded')
compact = module.compact_schemas([notes])[0]
assert compact['function']['parameters']['properties']['action']['description'] == 'versioned-contract-loaded'
assert 'update with id edits an existing note, never add' in compact['function']['description']
finally:
module.contract_builder.cache_clear()
@@ -4392,6 +4425,7 @@ def test_v3_document_edit_schema_has_one_unambiguous_structured_form():
assert parameters['required'] == ['edits']
assert set(parameters['properties']) == {'edits', 'more'}
assert parameters['properties']['more']['type'] == 'boolean'
assert 'more' not in parameters['required']
def test_v3_ui_schema_advertises_only_policy_executable_client_local_actions():
@@ -4526,7 +4560,7 @@ async def test_stream_emits_incremental_text_and_persistable_history(monkeypatch
raw = [chunk async for chunk in stream_preview(endpoint_url='http://test', model='test', messages=[{'role': 'user', 'content': 'hi'}], headers={}, turn_contract=contract, session_id='test', owner='test', disabled_tools=set(), tool_policy=ToolPolicy())]
events = [json.loads(s[6:]) for s in raw if '[DONE]' not in s]
assert [e['delta'] for e in events if 'delta' in e] == ['Hello', ' there']
assert requests[0]['messages'][0]['content'].startswith('You are Odysseus.')
assert requests[0]['messages'][0]['content'].startswith('You are Ajax, the assistant in Odysseus.')
metrics = next(e['data'] for e in events if e.get('type') == 'metrics')
assert metrics['clean_v3_turn'] == [{'role': 'assistant', 'content': 'Hello there'}]
assert raw[-1] == 'data: [DONE]\n\n'
@@ -4562,9 +4596,7 @@ async def test_preview_provider_stream_error_is_terminal_not_empty_answer(monkey
disabled_tools=set(), tool_policy=ToolPolicy(),
)]
assert raw[-1].startswith('event: error\ndata: ')
assert 'selected model provider failed' in raw[-1]
assert 'provider_stream_error' in raw[-1]
assert 'Qwen3_5MTPDraftModel' not in raw[-1]
assert 'Qwen3_5MTPDraftModel' in raw[-1]
assert all('returned no answer' not in chunk for chunk in raw)
assert all('"type": "metrics"' not in chunk for chunk in raw)
@@ -4664,6 +4696,74 @@ def test_ajax_c375_tool_surface_disables_progressive_thinking():
)
@pytest.mark.parametrize('name,args,reason', [
('python', {}, 'code'),
('apply_patch', {}, 'code'),
('edit_document', {}, 'document_edit'),
('draft_email_reply', {}, 'email_draft'),
('manage_tasks', {'action': 'create'}, 'task_design'),
('manage_skills', {'action': 'publish'}, 'skill_evaluation'),
('manage_tasks', {'action': 'list'}, ''),
('manage_tasks', {'action': 'pause'}, ''),
('manage_skills', {'action': 'view'}, ''),
('read_file', {}, ''),
('web_search', {}, ''),
('manage_notes', {'action': 'add'}, ''),
])
def test_tool_deliberation_is_operation_specific(name, args, reason):
from src.clean_agent_preview import tool_deliberation_reason, progressive_thinking_for_turn
assert tool_deliberation_reason(name, args) == reason
schemas = [{'function': {'name': name}}]
assert progressive_thinking_for_turn('ajax_c375', schemas, deliberate=bool(reason)) == bool(reason)
assert not progressive_thinking_for_turn('other-model', schemas, deliberate=True)
@pytest.mark.asyncio
async def test_task_creation_deliberates_before_executing_once(monkeypatch):
import src.clean_agent_preview as module
requests, executions = [], []
arguments = {'action': 'create', 'name': 'Morning news', 'prompt': 'Summarize news', 'schedule': 'daily', 'time': '09:00'}
proposal = {'choices': [{'delta': {'tool_calls': [{'index': 0, 'id': 'task-call',
'function': {'name': 'manage_tasks', 'arguments': json.dumps(arguments)}}]}}]}
responses = iter([proposal, proposal, {'choices': [{'delta': {'content': 'Created the task.'}}]}])
class Response:
def __init__(self, payload): self.payload = payload
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
def raise_for_status(self): pass
async def aiter_lines(self):
yield 'data: ' + json.dumps(self.payload)
yield 'data: [DONE]'
class Client:
def __init__(self, **kwargs): pass
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
def stream(self, *args, **kwargs):
requests.append(kwargs['json'])
return Response(next(responses))
async def execute(block, **kwargs):
executions.append(block)
return 'manage_tasks', {'output': 'Created task task-1', 'exit_code': 0}
monkeypatch.setattr(module.httpx, 'AsyncClient', Client)
monkeypatch.setattr(module, 'execute_tool_block', execute)
schemas = [s for s in FUNCTION_TOOL_SCHEMAS if s['function']['name'] == 'manage_tasks']
contract = resolve_full_inventory_contract(schemas=schemas, policy=ToolPolicy())
raw = [chunk async for chunk in stream_preview(
endpoint_url='http://test', model='ajax_c375',
messages=[{'role': 'user', 'content': 'Create a daily task at 9am to summarize news.'}],
headers={}, turn_contract=contract, session_id='test', owner='test',
disabled_tools=set(), tool_policy=ToolPolicy(), max_rounds=4,
)]
assert len(executions) == 1, '\n'.join(raw)
assert [r['chat_template_kwargs']['enable_thinking'] for r in requests] == [False, True, False]
assert not any(m.get('tool_calls') for m in requests[1]['messages'])
assert not any('event: error' in chunk for chunk in raw)
@pytest.mark.asyncio
async def test_calendar_list_structured_result_owns_linked_terminal_render(monkeypatch):
import src.clean_agent_preview as module
@@ -5427,7 +5527,9 @@ async def test_model_choice_budget_preserves_evidence_for_final_answer_without_e
@pytest.mark.asyncio
async def test_failed_static_fetch_recovers_once_through_rendered_browser(monkeypatch):
@pytest.mark.parametrize('thinking_mode', ['auto', 'off', 'on'])
@pytest.mark.parametrize('token_limit', [None, 1024])
async def test_failed_static_fetch_recovers_once_through_rendered_browser(monkeypatch, thinking_mode, token_limit):
from dataclasses import replace
import src.clean_agent_preview as module
@@ -5489,13 +5591,17 @@ async def test_failed_static_fetch_recovers_once_through_rendered_browser(monkey
routing_experiment='recent_model_choice',
)
raw = [chunk async for chunk in stream_preview(
endpoint_url='http://test', model='test',
endpoint_url='http://test', model='ajax_c375', thinking_mode=thinking_mode,
messages=[{'role': 'user', 'content': 'Tell me more about that Reuters report.'}],
headers={}, turn_contract=contract, session_id='test', owner='test',
disabled_tools=set(), tool_policy=ToolPolicy(), max_rounds=4,
disabled_tools=set(), tool_policy=ToolPolicy(), max_rounds=4, max_tokens=token_limit,
)]
assert executions == ['web_fetch', 'private_browser', 'web_fetch']
thinking = [r['chat_template_kwargs']['enable_thinking'] for r in requests]
# Compact owns the protocol, including for old sessions with On/Off saved.
assert thinking == [False, True, True, False]
assert requests[1]['max_tokens'] == (token_limit or 4096)
assert all(
schema['function']['name'] != 'web_fetch'
for schema in requests[1].get('tools', [])
@@ -5572,7 +5678,8 @@ async def test_duplicate_fetch_does_not_hide_distinct_pagination_request(monkeyp
@pytest.mark.asyncio
async def test_blocked_search_engine_browser_forces_native_web_search(monkeypatch):
@pytest.mark.parametrize('empty_challenge', [False, True])
async def test_blocked_search_engine_browser_forces_native_web_search(monkeypatch, empty_challenge):
import src.clean_agent_preview as module
responses = iter([
@@ -5594,10 +5701,8 @@ async def test_blocked_search_engine_browser_forces_native_web_search(monkeypatc
},
}]}}]},
{'choices': [{'delta': {'content': (
'Recent AI developments include new model releases and policy updates. '
'The search results identify the relevant primary sources and dates for each item, '
'including publication details, concrete findings, and links readers can inspect '
'for the full context behind each development.'
'I found the [AI update](https://example.com/ai) page. '
'The browser search was blocked, so I used native search to locate it.'
)}}]},
])
requests = []
@@ -5626,6 +5731,9 @@ async def test_blocked_search_engine_browser_forces_native_web_search(monkeypatc
if block.tool_type == 'private_browser':
return 'private_browser', {
'output': json.dumps({
'url': 'https://www.google.com/sorry/index?continue=search',
'title': 'Client Challenge', 'snapshot': '(empty page)',
} if empty_challenge else {
'url': 'https://www.google.com/sorry/index?continue=search',
'text': 'Our systems have detected unusual traffic. Complete the reCAPTCHA.',
}),
@@ -5651,7 +5759,7 @@ async def test_blocked_search_engine_browser_forces_native_web_search(monkeypatc
)
raw = [chunk async for chunk in stream_preview(
endpoint_url='http://test', model='test',
messages=[{'role': 'user', 'content': 'Open browser and find an AI model release.'}],
messages=[{'role': 'user', 'content': 'Open browser and find the AI update page on example.com. Give me its link.'}],
headers={}, turn_contract=contract, session_id='test', owner='test',
disabled_tools=set(), tool_policy=ToolPolicy(), max_rounds=4,
)]
@@ -5659,6 +5767,7 @@ async def test_blocked_search_engine_browser_forces_native_web_search(monkeypatc
assert executions == ['private_browser', 'web_search']
assert requests[1]['tool_choice'] == 'required'
assert [s['function']['name'] for s in requests[1]['tools']] == ['web_search']
assert not any(chunk.startswith('event: error') for chunk in raw), raw
events = [json.loads(chunk[6:]) for chunk in raw if '[DONE]' not in chunk]
assert any(
event.get('type') == 'tool_loop_recovery'
@@ -5721,9 +5830,10 @@ async def test_native_stream_reserves_remaining_budget_for_required_artifact(mon
async def execute(block, **kwargs):
executed.append(block.tool_type)
return block.tool_type, {"output": "ok", "exit_code": 0,
"materialized_artifacts": ["/tmp_workspace/results"]
if block.tool_type == "python" and "out.md" in block.content else []}
if block.tool_type == 'python' and '/tmp_workspace/results/out.md' in block.content:
return block.tool_type, {'output': 'ok', 'exit_code': 0,
'materialized_artifacts': ['/tmp_workspace/results']}
return block.tool_type, {"output": "ok", "exit_code": 0}
monkeypatch.setattr(module.httpx, "AsyncClient", Client)
monkeypatch.setattr(module, "execute_tool_block", execute)
@@ -5835,9 +5945,10 @@ async def test_native_stream_reserves_wall_time_for_required_artifact(monkeypatc
executed.append(block.tool_type)
if block.tool_type == "web_search":
now[0] = 450.0
return block.tool_type, {"output": "ok", "exit_code": 0,
"materialized_artifacts": ["/tmp_workspace/results"]
if block.tool_type == "python" and "out.md" in block.content else []}
if block.tool_type == 'python' and '/tmp_workspace/results/out.md' in block.content:
return block.tool_type, {'output': 'ok', 'exit_code': 0,
'materialized_artifacts': ['/tmp_workspace/results']}
return block.tool_type, {"output": "ok", "exit_code": 0}
monkeypatch.setattr(module.time, "monotonic", lambda: now[0])
monkeypatch.setattr(module.httpx, "AsyncClient", Client)
@@ -6429,14 +6540,15 @@ async def test_native_stream_permanently_withholds_tool_when_model_ignores_dupli
@pytest.mark.asyncio
async def test_native_stream_terminates_after_calling_a_permanently_suppressed_tool(monkeypatch):
@pytest.mark.parametrize('ignores_final_instruction', [False, True])
async def test_native_stream_terminates_after_calling_a_permanently_suppressed_tool(monkeypatch, ignores_final_instruction):
import src.clean_agent_preview as module
arguments = json.dumps({"path": "/workspace/fixtures/video.mp4"})
responses = iter([
{"choices": [{"delta": {"tool_calls": [{"index": 0, "id": f"inspect-{index}", "function": {
"name": "inspect_media", "arguments": arguments,
}}]}}]}
for index in range(1, 4)
for index in range(1, 5 if ignores_final_instruction else 4)
] + [{"choices": [{"delta": {"content": "Final answer from existing evidence."}}]}])
class Response:
@@ -6488,9 +6600,15 @@ async def test_native_stream_terminates_after_calling_a_permanently_suppressed_t
assert 'tools' not in requests[3]
assert 'best concise final answer' in requests[3]['messages'][-1]['content'].lower()
final = [event for event in events if event.get("type") == "final_response"]
assert final == []
metrics = next(event['data'] for event in events if event.get('type') == 'metrics')
assert metrics['clean_v3_turn'][-1]['content'] == 'Final answer from existing evidence.'
if ignores_final_instruction:
assert len(final) == 1
assert 'model repeated a tool call' in final[0]['content']
assert metrics['clean_v3_turn'][-1]['content'] == final[0]['content']
else:
assert final == []
assert metrics['clean_v3_turn'][-1]['content'] == 'Final answer from existing evidence.'
assert raw[-1] == 'data: [DONE]\n\n'
@pytest.mark.asyncio
@@ -7284,3 +7402,275 @@ async def test_tool_round_leadin_is_replaced_by_terminal_synthesis(monkeypatch):
"I'll browse IKEA and look at their chairs.",
'The most epic option is the DYVLINGE swivel chair.',
]
@pytest.mark.asyncio
async def test_denied_call_after_successful_evidence_recovers_instead_of_refusing(monkeypatch):
"""A blocked call must not discard evidence an earlier call already produced."""
import src.clean_agent_preview as module
def call(index, name, arguments):
return {'index': index, 'id': f'call-{index}', 'function': {
'name': name, 'arguments': json.dumps(arguments),
}}
packets = iter([
{'choices': [{'delta': {'tool_calls': [
call(0, 'web_search', {'query': 'EEG headset prices'})]}}]},
{'choices': [{'delta': {'tool_calls': [
call(0, 'web_fetch', {'url': 'https://vendor.example/eeg'})]}}]},
{'choices': [{'delta': {'content': (
'Based on the search result, the EEG headset starts at $299 from the '
'vendor. I could not open the vendor page because that operation was '
'not permitted.'
)}}]},
])
requests = []
class Response:
def __init__(self, payload): self.payload = payload
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
def raise_for_status(self): pass
async def aiter_lines(self):
yield 'data: ' + json.dumps(self.payload)
yield 'data: [DONE]'
class Client:
def __init__(self, **kwargs): pass
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
def stream(self, *args, **kwargs):
requests.append(kwargs['json'])
return Response(next(packets))
async def execute(block, **kwargs):
if block.tool_type == 'web_search':
return 'web_search', {
'output': '[1] EEG Headset Prices\n https://vendor.example/eeg',
'exit_code': 0,
'evidence_status': 'available',
}
return 'web_fetch', {
'output': 'The exact operation is outside server request authority.',
'exit_code': 1,
'blocked': True,
'failure_kind': 'request_authority_denied',
}
monkeypatch.setattr(module.httpx, 'AsyncClient', Client)
monkeypatch.setattr(module, 'execute_tool_block', execute)
schemas = [
schema for schema in FUNCTION_TOOL_SCHEMAS
if schema['function']['name'] in {'web_search', 'web_fetch'}
]
contract = resolve_full_inventory_contract(schemas=schemas, policy=ToolPolicy())
raw = [chunk async for chunk in stream_preview(
endpoint_url='http://test', model='test',
messages=[{'role': 'user', 'content': (
'Search the web for the latest EEG headset prices and open the vendor page.'
)}],
headers={}, turn_contract=contract, session_id='test', owner='test',
disabled_tools=set(), tool_policy=ToolPolicy(), max_rounds=5,
)]
events = [json.loads(chunk[6:]) for chunk in raw if '[DONE]' not in chunk]
assert any(
event.get('type') == 'completion_recovery'
and event.get('reason') == 'denial_after_successful_evidence'
for event in events
)
visible = ''.join(
str(e.get('delta') or e.get('content') or '') for e in events
)
assert '$299' in visible
assert denied_response() not in visible
# The recovery round must synthesize without tools.
assert requests[-1].get('tools') in (None, [])
@pytest.mark.asyncio
@pytest.mark.parametrize('thinking_mode, expected_thinking', [
('on', {'type': 'enabled'}),
(None, {'type': 'enabled'}),
('off', {'type': 'disabled'}),
])
async def test_deepseek_v4_preview_reserves_reasoning_budget_and_honors_toggle(
monkeypatch, thinking_mode, expected_thinking,
):
import src.clean_agent_preview as module
requests = []
class Response:
def __init__(self, payload): self.payload = payload
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
def raise_for_status(self): pass
async def aiter_lines(self):
yield 'data: ' + json.dumps(self.payload)
yield 'data: [DONE]'
class Client:
def __init__(self, **kwargs): pass
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
def stream(self, *args, **kwargs):
requests.append(kwargs['json'])
return Response({'choices': [{'delta': {
'reasoning_content': 'Let me reason about this.',
'content': 'The answer is 42.',
}}]})
monkeypatch.setattr(module.httpx, 'AsyncClient', Client)
contract = resolve_full_inventory_contract(schemas=[], policy=ToolPolicy())
kwargs = {} if thinking_mode is None else {'thinking_mode': thinking_mode}
raw = [chunk async for chunk in stream_preview(
endpoint_url='https://api.deepseek.com/v1/chat/completions',
model='deepseek-v4-flash',
messages=[{'role': 'user', 'content': 'What is the answer?'}],
headers={}, turn_contract=contract, session_id='test', owner='test',
disabled_tools=set(), tool_policy=ToolPolicy(), **kwargs,
)]
assert requests, 'a provider request should have been sent'
sent = requests[0]
if expected_thinking == {'type': 'disabled'}:
assert sent.get('reasoning_effort') == 'none'
else:
# Reasoning-capable models must reserve budget above the 768 default.
assert sent['max_tokens'] >= 4096
assert sent.get('reasoning_effort')
assert sent.get('thinking') == expected_thinking
assert any('The answer is 42.' in chunk for chunk in raw)
def test_workspace_process_denial_uses_an_honest_refusal(tmp_path):
from src.clean_agent_preview import (
workspace_process_denial,
WORKSPACE_REQUIRED_REFUSAL,
denied_response,
)
# A resource-identity / sealed-workspace denial is recognised from any of
# the tool-event fields (error / output JSON / failure_kind).
assert workspace_process_denial([
{'tool': 'bash', 'exit_code': 1, 'blocked': True,
'output': '{"error":"Process creation requires a sealed workspace and launch scope",'
'"failure_kind":"resource_identity_denied"}'},
])
assert workspace_process_denial([{'failure_kind': 'resource_identity_denied'}])
# A plain request-authority denial is not a workspace denial.
assert not workspace_process_denial([
{'tool': 'bash', 'blocked': True, 'failure_kind': 'request_authority_denied',
'error': 'The exact operation is outside server request authority.'},
])
assert not workspace_process_denial([])
assert 'workspace' in WORKSPACE_REQUIRED_REFUSAL.lower()
assert WORKSPACE_REQUIRED_REFUSAL != denied_response()
# The terminal-denial branch picks the workspace message when appropriate.
source = (Path(__file__).resolve().parents[1] / 'src/clean_agent_preview.py').read_text()
assert 'if workspace_process_denial(executions)' in source
assert 'WORKSPACE_REQUIRED_REFUSAL' in source
def test_spec_router_think_decision_and_reason(monkeypatch):
import sys
import types
from src.clean_agent_preview import (
spec_router_thinking_decision,
spec_router_think_reason,
)
# --- router disabled -> no decision, no reason (default OFF) -------------
fake = types.ModuleType('src.spec_router')
fake.think_router_enabled = lambda: False
fake.route_thinking = lambda text: {'think': True, 'p_think': 0.9, 'ms': 1.0}
monkeypatch.setitem(sys.modules, 'src.spec_router', fake)
assert spec_router_thinking_decision('u', 'm', 'o', {}, 'hi') is None
assert spec_router_think_reason(1, None, '') == ''
# --- enabled but not a spec surface -> no decision ----------------------
fake.think_router_enabled = lambda: True
monkeypatch.setattr(
'src.agent_loop._configured_model_tool_surface_raw',
lambda *a, **k: 'native', raising=False,
)
assert spec_router_thinking_decision('u', 'm', 'o', {}, 'hi') is None
# --- enabled on spec + think=True -> reason only on round 1 when no other
monkeypatch.setattr(
'src.agent_loop._configured_model_tool_surface_raw',
lambda *a, **k: 'spec', raising=False,
)
decision = spec_router_thinking_decision('u', 'm', 'o', {}, 'plan the refactor')
assert decision == {'think': True, 'p_think': 0.9, 'ms': 1.0}
assert spec_router_think_reason(1, decision, '') == 'router_think'
# existing rule wins; only round 1; think=False never fires
assert spec_router_think_reason(1, decision, 'code') == ''
assert spec_router_think_reason(2, decision, '') == ''
assert spec_router_think_reason(1, {'think': False}, '') == ''
# --- route_thinking raises -> fail open (None), rules only --------------
def _boom(text):
raise RuntimeError('router down')
fake.route_thinking = _boom
assert spec_router_thinking_decision('u', 'm', 'o', {}, 'hi') is None
assert spec_router_think_reason(1, None, '') == ''
# --- wiring present in stream_preview -----------------------------------
source = (Path(__file__).resolve().parents[1] / 'src/clean_agent_preview.py').read_text()
assert 'spec_router_thinking_decision(' in source
assert 'spec_router_think_reason(' in source
def test_qwen_think_split_streams_thinking_then_answer_without_buffering():
from src.clean_agent_preview import qwen_think_split_events
state = {'think_emitted': 0, 'answer_emitted': 0, 'closed': False}
events = []
content = ''
for piece in ['<think>', 'abc', '</think>', 'Hello']:
content += piece
events.extend(qwen_think_split_events(content, state))
thinking = ''.join(e['delta'] for e in events if e.get('thinking'))
answer = ''.join(e['delta'] for e in events if not e.get('thinking'))
assert thinking == 'abc'
assert answer == 'Hello'
# every emitted delta is non-empty and typed
assert all(e.get('delta') for e in events)
assert [e.get('thinking') for e in events] == [True, None]
# the stored answer stays identical to visible_content_after_qwen_thinking
from src.clean_agent_preview import visible_content_after_qwen_thinking
assert visible_content_after_qwen_thinking(content) == 'Hello'
def test_qwen_think_split_holds_back_a_partial_close_tag():
from src.clean_agent_preview import qwen_think_split_events
state = {'think_emitted': 0, 'answer_emitted': 0, 'closed': False}
events = []
content = ''
for piece in ['<think>a', '</thi', 'nk>', 'B']:
content += piece
events.extend(qwen_think_split_events(content, state))
thinking = ''.join(e['delta'] for e in events if e.get('thinking'))
answer = ''.join(e['delta'] for e in events if not e.get('thinking'))
assert thinking == 'a' # the partial '</thi' is never leaked
assert answer == 'B'
assert all(e.get('delta') for e in events)
def test_qwen_think_split_is_inert_without_a_think_block():
from src.clean_agent_preview import qwen_think_split_events
state = {'think_emitted': 0, 'answer_emitted': 0, 'closed': False}
assert qwen_think_split_events('plain answer', state) == []
assert qwen_think_split_events('', state) == []
assert state == {'think_emitted': 0, 'answer_emitted': 0, 'closed': False}
# source-level: stream_preview wires the incremental split
source = (Path(__file__).resolve().parents[1] / 'src/clean_agent_preview.py').read_text()
assert 'qwen_think_split_events(' in source
assert "think_answer_streamed" in source
+36
View File
@@ -17,6 +17,42 @@ ERROR = 'event: error\ndata: {"status": 504, "error": {"message": "stream timeou
DONE = 'data: [DONE]\n\n'
@pytest.mark.asyncio
async def test_answer_deltas_are_visible_before_terminal_completion_check():
finish = asyncio.Event()
@with_completion_gate
async def stream(messages):
yield _event({'delta': 'Live answer starts. '})
await finish.wait()
yield _event({'type': 'metrics', 'data': {}})
yield DONE
output = stream([])
first = await asyncio.wait_for(anext(output), timeout=0.2)
assert json.loads(first[6:])['delta'] == 'Live answer starts. '
finish.set()
remaining = [chunk async for chunk in output]
assert _decision([first, *remaining])['can_complete'] is True
@pytest.mark.asyncio
async def test_completion_replacement_replaces_already_streamed_answer_for_turn():
@with_completion_gate
async def stream(messages):
yield _event({'delta': 'The function returns a boolean. I ran pytest and all tests passed.'})
yield _event({'type': 'metrics', 'data': {}})
yield DONE
chunks = [chunk async for chunk in stream([{'role': 'user', 'content': 'Explain this code.'}])]
frames = [data for event, data in _frames(chunks) if event == 'message' and isinstance(data, dict)]
assert any(item.get('delta') == 'The function returns a boolean.' for item in frames)
assert not any('I ran pytest' in str(item.get('delta', '')) for item in frames)
replacement = next(item for item in frames if item.get('type') == 'final_response')
assert 'I ran pytest' not in replacement['content']
assert replacement['replacement_scope'] == 'turn'
@pytest.mark.asyncio
@pytest.mark.parametrize('terminal_texts', [[], ['', ''], ['Recovered answer with literal [DONE] text.']])
async def test_terminal_round_retraction_does_not_resurrect_buffered_drafts(terminal_texts):
+2 -2
View File
@@ -338,8 +338,8 @@ def test_hardlinked_effect_state_is_control_plane_without_scanning_the_store(tmp
assert str(store) not in listed
assert resources._control_plane_path(str(alias), snapshot=snapshot) is True
assert str(store) not in globbed, "the effect store is listed one level, never recursively inventoried"
# Multi-linked regular files are treated as control-plane resources to prevent hardlink race/escape behavior.
# Multiply linked files elsewhere stay ordinary.
elsewhere = tmp_path / "other.txt"
elsewhere.write_text("y")
os.link(elsewhere, workspace / "pnpm-style.txt")
assert resources._control_plane_path(str(workspace / "pnpm-style.txt")) is True
assert resources._control_plane_path(str(workspace / "pnpm-style.txt")) is False
+2 -1
View File
@@ -148,6 +148,7 @@ def _chat_stream_endpoint(
endpoint_url="https://selected.example/v1",
session_model="selected-model",
session_history=(),
attachment_meta=(),
):
def add_message(message):
captured.setdefault("added_messages", []).append(message)
@@ -172,7 +173,7 @@ def _chat_stream_endpoint(
{"role": "assistant", "content": "old answer"},
{"role": "user", "content": "hello"},
],
preprocessed=SimpleNamespace(attachment_meta=[]),
preprocessed=SimpleNamespace(attachment_meta=list(attachment_meta)),
auto_opened_docs=[],
rag_sources=[],
web_sources=[],
+14
View File
@@ -7,6 +7,7 @@ os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
import pytest
from src.llm_core import _supports_thinking
from src.model_profiles import supports_user_thinking_toggle
@pytest.mark.parametrize(
@@ -25,3 +26,16 @@ def test_deepseek_v4_models_support_thinking(model):
@pytest.mark.parametrize("model", ["deepseek-v3", "deepseek-chat"])
def test_other_deepseek_models_are_not_promoted_to_thinking(model):
assert _supports_thinking(model) is False
@pytest.mark.parametrize(
"model",
[
"deepseek-v4",
"deepseek-v4-flash",
"DeepSeek-V4-Flash",
"deepseek/deepseek-v4-flash",
],
)
def test_deepseek_v4_models_expose_the_user_thinking_toggle(model):
assert supports_user_thinking_toggle(model) is True
+3 -3
View File
@@ -368,7 +368,7 @@ def test_detached_container_math_typesets_with_the_real_renderer(node_available)
mdToHtml defers math to a document-scoped flush, which cannot reach a
detached node, so the export has to typeset its own container before
handing it to browser printing. This is that container: pending spans in, real
handing it to html2pdf. This is that container: pending spans in, real
KaTeX markup out, no .katex-error and nothing left pending.
"""
out = _run_node(
@@ -400,7 +400,7 @@ def test_detached_container_math_typesets_with_the_real_renderer(node_available)
assert "ody-math-pending" not in out["written"]
def test_pdf_export_typesets_its_container_before_print():
def test_pdf_export_typesets_its_container_before_html2pdf():
"""Ordering in a call site, so pin the call site. No node needed."""
source = document_source()
match = re.search(r"\n async function exportAsPdf\(\) \{(.*?)\n \}\n", source, re.S)
@@ -410,7 +410,7 @@ def test_pdf_export_typesets_its_container_before_print():
render = "await markdownModule.renderMath(container);"
assert render in body, "the export never typesets its detached container"
assert body.index("container.innerHTML = html;") < body.index(render)
assert body.index(render) < body.index("frame.contentWindow.print()")
assert body.index(render) < body.index("window.html2pdf()")
def test_md_to_html_renders_inline_once_katex_is_loaded(node_available):
+145
View File
@@ -0,0 +1,145 @@
"""Model-picker backend: a slow/dead endpoint must not stall /api/models refresh.
Covers the fixes in routes/model_routes.py:
- the local endpoint refresh timeout default is 2s (was 10s);
- a recently failed/timed-out endpoint keeps a ~60s cooldown even on a
user-forced refresh, so a forced refresh does not re-probe a dead endpoint
on every picker open;
- probing is parallel, so one slow endpoint does not push the whole refresh
past its own timeout.
"""
import json
import sys
import time
import types
from types import SimpleNamespace
from unittest.mock import MagicMock
from tests.helpers.import_state import (
clear_fake_endpoint_resolver_modules,
preserve_import_state,
)
def _load_model_routes():
with preserve_import_state(
"core.database", "src.database", "core.session_manager", "routes.model_routes"
):
clear_fake_endpoint_resolver_modules()
if "core.database" not in sys.modules:
stub = types.ModuleType("core.database")
for name in (
"SessionLocal", "ModelEndpoint", "Session", "ChatMessage", "Document",
"DocumentVersion", "GalleryImage", "GalleryAlbum", "Note", "CalendarCal",
"CalendarEvent", "ScheduledTask", "TaskRun", "McpServer",
"ProviderAuthSession", "Base",
):
setattr(stub, name, MagicMock())
stub.utcnow_naive = MagicMock()
sys.modules["core.database"] = stub
import routes.model_routes as model_routes
return model_routes
def _endpoint(**kw):
base = dict(
id="ep", base_url="http://127.0.0.1:9/v1", api_key="k",
provider_auth_id=None, cached_models="[]", pinned_models=[],
is_enabled=True, model_refresh_mode="auto",
)
base.update(kw)
return SimpleNamespace(**base)
def test_local_refresh_timeout_is_two_seconds():
model_routes = _load_model_routes()
ep = _endpoint(base_url="http://100.67.207.85:8071/v1")
assert model_routes._endpoint_refresh_timeout(ep, "local") == 2.0
assert model_routes._endpoint_refresh_timeout(ep, "remote") == 2.0
def test_recent_failure_is_not_reprobed_even_when_forced():
model_routes = _load_model_routes()
router = model_routes.setup_model_routes(model_discovery=None)
should = router._should_refresh_endpoint
ep = _endpoint(id="remote-dead", base_url="http://100.67.207.85:8071/v1")
now = time.time()
ok, info = should(ep, now, force=True)
assert ok is True and info.get("base")
key = info["key"]
# A failure one second ago keeps a 60s cooldown even on a forced refresh.
router._refresh_state[key] = {
"fail_count": 1, "last_failure": now - 1.0, "inflight": False,
}
ok_recent, _ = should(ep, time.time(), force=True)
assert ok_recent is False
# A failure long past the 60s floor is re-probed again.
router._refresh_state[key] = {
"fail_count": 1, "last_failure": now - 120.0, "inflight": False,
}
ok_old, _ = should(ep, time.time(), force=True)
assert ok_old is True
class _FakeQuery:
def __init__(self, rows, first_value):
self._rows = rows
self._first = first_value
def filter(self, *args, **kwargs):
return self
def all(self):
return list(self._rows)
def first(self):
return self._first
class _FakeDB:
def __init__(self, rows, first_value):
self._rows = rows
self._first = first_value
self.committed = False
def query(self, model):
return _FakeQuery(self._rows, self._first)
def commit(self):
self.committed = True
def close(self):
pass
def test_one_slow_endpoint_does_not_stall_the_forced_refresh(monkeypatch):
model_routes = _load_model_routes()
router = model_routes.setup_model_routes(model_discovery=None)
healthy = _endpoint(id="healthy", base_url="http://127.0.0.1:7021/v1")
dead = _endpoint(id="dead", base_url="http://127.0.0.1:9/v1")
monkeypatch.setattr(model_routes, "SessionLocal", lambda: _FakeDB([healthy, dead], healthy))
monkeypatch.setattr(
model_routes, "_disable_stale_cookbook_local_endpoints", lambda db: False
)
def _fake_probe(base, api_key=None, timeout=5):
if "127.0.0.1:9" in base:
time.sleep(min(float(timeout or 2), 2.0))
raise RuntimeError("timed out")
return ["healthy-model"]
monkeypatch.setattr(model_routes, "_probe_endpoint", _fake_probe)
started = time.time()
done = router._refresh_caches_bg(force=True)
assert done is not None
assert done.wait(5) is True
elapsed = time.time() - started
assert elapsed <= 3.0, f"forced refresh took {elapsed:.2f}s"
assert json.loads(healthy.cached_models) == ["healthy-model"]
@@ -8,6 +8,6 @@ ROOT = Path(__file__).resolve().parents[1]
def test_pdf_backed_documents_do_not_offer_destructive_html_pdf_export():
source = document_source()
assert "if (!isForm && (_isDocxLang(lang) || typeof window.print === 'function')) {" in source
assert "label: _isDocxLang(lang) ? 'Convert to PDF' : 'Print / save PDF'" in source
assert "if (!isForm) {" in source
assert "label: _isDocxLang(lang) ? 'Convert to PDF' : 'Print as PDF'" in source
assert "destroy the original page layout, images, and form structure" in source
+4 -4
View File
@@ -240,7 +240,8 @@ def test_non_qwen_fallback_keeps_requested_temperature(monkeypatch):
def test_qwen_notes_fallback_reenables_personal_managers(monkeypatch):
"""Model/route fallback cannot bypass caller-disabled tools; denials persist."""
"""The answering candidate's notes mode must unblock the managers for
execution, not just enable them in its own route schemas."""
_install_route_probe(monkeypatch)
stream_round = 0
@@ -298,9 +299,8 @@ def test_qwen_notes_fallback_reenables_personal_managers(monkeypatch):
)
)
# Caller hard denials are absolute: route fallback cannot re-enable disabled tools.
assert {"manage_notes", "manage_calendar", "manage_tasks"}.issubset(
seen_exec["disabled_tools"]
assert seen_exec["disabled_tools"].isdisjoint(
{"manage_notes", "manage_calendar", "manage_tasks"}
)
+8 -2
View File
@@ -1,7 +1,11 @@
"""Regression guard for the README title presentation.
The original ASCII-art banner needed a code fence to preserve whitespace.
Keep the wordmark title identity and the historical ASCII-fencing guard.
Originally (#1390) the README opened with an ASCII-art banner that had to live
inside a ``` code fence, otherwise GitHub's markdown collapsed its leading
whitespace and box-drawing rules and rendered it misaligned. The README refresh
(#4306) dropped that banner in favour of a centered wordmark image, so the guard
now pins the wordmark identity instead, while still catching the original failure
mode if an un-fenced ASCII banner is ever reintroduced.
"""
from pathlib import Path
@@ -19,6 +23,8 @@ def _fenced_segments(text: str):
def test_readme_opens_with_wordmark_title():
# The README must still open with a recognizable Odysseus title: now the
# centered wordmark image rather than an H1 / ASCII banner.
head = "\n".join(README.read_text(encoding="utf-8").splitlines()[:15])
assert 'alt="Odysseus"' in head, "README must open with the Odysseus wordmark image"
+81
View File
@@ -64,12 +64,57 @@ def test_explicit_request_classes_remain_narrow(user_text, allowed, denied):
assert not grant.permits(ExactOperation.normalize(denied, '{}'))
def test_search_selection_keeps_the_sibling_web_reader_authorized():
# "Search the web ..." narrows selection to web_search alone, but the turn
# must be able to read a URL the search surfaces; otherwise every result is
# a dead end and the turn fails closed with request_authority_denied.
grant = create_request_authority(
"Search the web for the latest 2025 consumer EEG headset prices "
"under $500 and cite sources")
assert grant.permits(ExactOperation.normalize("web_search", '{"query":"eeg"}'))
assert grant.permits(
ExactOperation.normalize("web_fetch", '{"url":"https://example.com/report"}'))
def test_fetch_only_selection_does_not_grant_search():
grant = create_request_authority("Fetch https://example.com and summarize it")
assert grant.permits(
ExactOperation.normalize("web_fetch", '{"url":"https://example.com"}'))
assert not grant.permits(ExactOperation.normalize("web_search", '{"query":"x"}'))
def test_implicit_editor_revision_authority_requires_active_document():
prompt = ('ga mer ut pa att vi tanker pa framtid for bjorn och att vi faktist '
'trivs sa bra har med framtid planerad')
edit = ExactOperation.normalize("edit_document", '{"edits":[]}')
with_editor = create_request_authority(prompt, active_document=True)
without_editor = create_request_authority(prompt, active_document=False)
assert with_editor.permits(edit)
assert not without_editor.permits(edit)
def test_safe_task_read_does_not_authorize_same_tool_mutation():
grant = create_request_authority("List my tasks")
assert grant.permits(ExactOperation.normalize("manage_tasks", '{"action":"list"}'))
assert not grant.permits(ExactOperation.normalize("manage_tasks", '{"action":"create","prompt":"run bash"}'))
def test_action_only_read_tolerates_benign_model_arguments():
# "What do you remember about me?" pins manage_memory to action=list. The
# model must not be denied merely for adding an innocuous limit/cursor, while
# a different (mutating) action stays outside the read grant.
grant = create_request_authority("What do you remember about me?")
memory = next(g for g in grant.grants if g.tool == "manage_memory")
assert memory.inputs is None
assert grant.permits(ExactOperation.normalize("manage_memory", '{"action":"list"}'))
assert grant.permits(
ExactOperation.normalize("manage_memory", '{"action":"list","limit":10}'))
assert not grant.permits(
ExactOperation.normalize("manage_memory", '{"action":"delete","id":"x"}'))
def test_exact_read_identifiers_cannot_be_changed_by_model():
grant = create_request_authority("Read note id abc123")
read = next(g for g in grant.grants if g.tool == "manage_notes")
@@ -413,6 +458,42 @@ async def test_server_seeded_defaults_have_authority_but_legacy_rows_do_not_gain
engine.dispose()
@pytest.mark.asyncio
async def test_unsealed_housekeeping_row_is_replaced_with_an_authorized_copy(monkeypatch, tmp_path):
"""A built-in row that predates the authority seal (NULL snapshot) must be
replaced by a freshly seeded, authorized copy, not left unrunnable. This is
the 'Email Tags' -> 'no matching server request authority' failure."""
import core.database as database
import routes.prefs_routes as preferences
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from src.task_scheduler import TaskScheduler
engine = create_engine(f"sqlite:///{tmp_path / 'tasks.db'}")
database.Base.metadata.create_all(engine)
sessions = sessionmaker(bind=engine)
monkeypatch.setattr(database, "SessionLocal", sessions)
monkeypatch.setattr(preferences, "_load_for_user", lambda owner: {})
scheduler = TaskScheduler(session_manager=None)
monkeypatch.setattr(scheduler, "ensure_assistant_defaults", AsyncMock())
with sessions() as db:
db.add(database.ScheduledTask(id="broken", owner="alice", name="Email Tags",
task_type="action", action="check_email_urgency",
status="active", request_authority_json=None))
db.commit()
await scheduler.ensure_defaults("alice")
with sessions() as db:
rows = db.query(database.ScheduledTask).filter(
database.ScheduledTask.action == "check_email_urgency").all()
assert len(rows) == 1, "the unsealed row must be replaced, not duplicated"
task = rows[0]
assert task.id != "broken"
assert task.status == "active", "the user's enable choice must survive the reseed"
grant = restore_task_authority(task.request_authority_json, task.prompt,
task.task_type, task.action, owner=task.owner)
assert grant.permits(task_operation(task.task_type, task.action, task.prompt))
engine.dispose()
@pytest.mark.asyncio
async def test_dispatch_binds_explicit_authority_for_nested_handler(monkeypatch):
from src import tool_execution as execution
+1 -1
View File
@@ -139,7 +139,7 @@ def run_terminal_branch(monkeypatch, native_calls, result, operation=None, *, ma
_required_read_native_id=al._required_read_native_id,
_dispatch_required_safe_read=al._dispatch_required_safe_read,
_compute_final_metrics=lambda *a, **kw: {"tool_events": a[8]},
session_id="fixture", disabled_tools=set(), _caller_hard_denials=frozenset(), tool_policy=policy,
session_id="fixture", disabled_tools=set(), tool_policy=policy,
owner="fixture", workspace=None, run_security=context, active_document=None,
client_runtime_context=None, round_num=1, model="fixture-model",
actual_endpoint_id="fixture-endpoint", actual_endpoint_label="fixture",
+31
View File
@@ -549,3 +549,34 @@ def test_host_shell_native_arguments_use_the_same_verifier_classification(comman
encoded = json.dumps({'command': command, 'timeout': 120})
assert command_is_test(encoded) is expected
assert command_is_validation(encoded) is expected
@pytest.mark.parametrize('tool', ['create_document', 'update_document', 'edit_document'])
def test_successful_document_write_supports_unnamed_mutation_claim(tool):
ledger = EvidenceLedger.from_tool_events([
{'tool': tool, 'command': 'fix grammar', 'exit_code': 0},
])
claim = 'I updated the document with the grammar fix.'
answer, reason = completion_answer(claim, ledger, ledger.evaluate())
assert claim in answer
assert not reason
def test_document_write_without_authoritative_receipt_cannot_support_claim():
ledger = EvidenceLedger.from_tool_events([
{'tool': 'update_document', 'command': 'fix grammar'},
])
claim = 'I updated the document with the grammar fix.'
answer, reason = completion_answer(claim, ledger, ledger.evaluate())
assert reason
assert claim not in answer
def test_document_evidence_does_not_support_named_file_claim():
ledger = EvidenceLedger.from_tool_events([
{'tool': 'update_document', 'command': 'fix grammar', 'exit_code': 0},
])
claim = 'I updated config.py.'
answer, reason = completion_answer(claim, ledger, ledger.evaluate())
assert reason
assert claim not in answer
+108
View File
@@ -0,0 +1,108 @@
"""Session-load 404 regression (passover item #9).
Loading a chat session used to fire two benign 404s in the browser console:
GET /api/research/status/<id> (no research for this session)
GET /api/chat/stream_status/<id> (no active stream for this session)
Both are now a normal "empty" state: HTTP 200 with an explicit status
("none" / "idle"). The clients already branch on the body status, so this is
backward compatible (an older server's 404 is still tolerated).
"""
import asyncio
import inspect
from types import SimpleNamespace
from unittest.mock import MagicMock
from routes import chat_routes
from routes import research_routes
def _route_endpoint(router, path, method="GET"):
for route in router.routes:
if getattr(route, "path", "") != path:
continue
if method in getattr(route, "methods", set()):
return route.endpoint
raise AssertionError(f"{method} {path} not registered")
def _req(user="alice"):
return SimpleNamespace(state=SimpleNamespace(current_user=user))
# --- /api/research/status/<id> -------------------------------------------
def test_research_status_returns_200_none_when_absent(tmp_path, monkeypatch):
monkeypatch.setattr(research_routes, "DEEP_RESEARCH_DIR", str(tmp_path / "deep_research"))
handler = MagicMock()
handler._active_tasks = {}
router = research_routes.setup_research_routes(handler)
endpoint = _route_endpoint(router, "/api/research/status/{session_id}")
out = asyncio.run(endpoint(session_id="sess-abc", request=_req()))
assert out == {"status": "none"}
def test_research_status_returns_status_when_present(tmp_path, monkeypatch):
monkeypatch.setattr(research_routes, "DEEP_RESEARCH_DIR", str(tmp_path / "deep_research"))
handler = MagicMock()
handler._active_tasks = {"sess-abc": {"owner": "alice"}}
handler.get_status.return_value = {"status": "running", "progress": {"phase": "searching"}}
handler.get_source_state.return_value = None
handler.get_source_coverage.return_value = None
handler.get_navigation_trace.return_value = None
handler.get_action_trace.return_value = None
router = research_routes.setup_research_routes(handler)
endpoint = _route_endpoint(router, "/api/research/status/{session_id}")
out = asyncio.run(endpoint(session_id="sess-abc", request=_req()))
assert out["status"] == "running"
# --- /api/chat/stream_status/<id> ----------------------------------------
def _chat_stream_status_endpoint(monkeypatch):
monkeypatch.setattr(chat_routes, "_verify_session_owner", lambda request, session_id: None)
router = chat_routes.setup_chat_routes(
MagicMock(), MagicMock(), MagicMock(), MagicMock(), MagicMock(), MagicMock()
)
return _route_endpoint(router, "/api/chat/stream_status/{session_id}")
def test_stream_status_returns_200_idle_when_no_stream(monkeypatch):
endpoint = _chat_stream_status_endpoint(monkeypatch)
chat_routes._active_streams.pop("sess-idle", None)
out = asyncio.run(endpoint(SimpleNamespace(), "sess-idle"))
assert out == {"status": "idle"}
def test_stream_status_returns_record_when_streaming(monkeypatch):
endpoint = _chat_stream_status_endpoint(monkeypatch)
chat_routes._active_streams["sess-live"] = {"status": "streaming", "mode": "agent"}
try:
out = asyncio.run(endpoint(SimpleNamespace(), "sess-live"))
finally:
chat_routes._active_streams.pop("sess-live", None)
assert out["status"] == "streaming"
# --- client compatibility (source-level) ---------------------------------
def test_chat_watchdog_accepts_200_idle_body():
src = inspect.getsource(chat_routes) # ensure chat_routes imported
chatjs = open("static/js/chat.js", encoding="utf-8").read()
# The stale-stream watchdog must key off the body status, not only a 404.
assert "_noActiveStream" in chatjs
assert "_probe.status !== 'streaming'" in chatjs
assert src # keep flake happy
def test_sessions_check_uses_body_status_not_ok_only():
sj = open("static/js/sessions.js", encoding="utf-8").read()
assert "info.status !== 'streaming'" in sj
assert "status !== 'streaming'" in sj
@@ -0,0 +1,33 @@
"""Health audit: SSE client disconnects (e.g. /api/editor/bridge/events) surface
as anyio.EndOfStream through Starlette's BaseHTTPMiddleware. The slow-request
middleware must map that to a client-side 499, not a logged 500 traceback."""
import asyncio
import anyio
from starlette.requests import Request
from app import _SlowRequestLogMiddleware
def _request(path):
return Request({"type": "http", "method": "GET", "path": path, "headers": []})
def test_stream_disconnect_is_mapped_to_499():
async def call_next(_request):
raise anyio.EndOfStream
mw = _SlowRequestLogMiddleware(lambda *a: None)
resp = asyncio.run(mw.dispatch(_request("/api/editor/bridge/events"), call_next))
assert resp.status_code == 499
def test_real_responses_pass_through():
from starlette.responses import JSONResponse
async def call_next(_request):
return JSONResponse({"ok": True}, status_code=200)
mw = _SlowRequestLogMiddleware(lambda *a: None)
resp = asyncio.run(mw.dispatch(_request("/api/health"), call_next))
assert resp.status_code == 200
+33
View File
@@ -328,6 +328,30 @@ def test_conversational_request_wrappers_preserve_explicit_family(message, expec
assert requested_capabilities(message) == expected
@pytest.mark.parametrize("message", [
"30-day task + protocol doc",
"I wanna work on a project where I test meditation for 30 days. But I test my brain activity before and after so I can compare.",
"Protocol doc only",
"Make a 30-day meditation plan",
"help me build a habit tracker for the project",
])
def test_bare_project_task_noun_does_not_inject_task_tools(message):
# A project description that merely contains the noun "task" must not offer
# the background-task scheduler or let the model auto-create a task.
assert "tasks" not in requested_capabilities(message)
assert selected_tools_for_request(message) != frozenset({"manage_tasks"})
@pytest.mark.parametrize("message", [
"Create a task to call the dentist",
"List my scheduled tasks",
"show me my automations",
"what scheduled tasks do i have?",
])
def test_explicit_task_management_still_routes_to_tasks(message):
assert "tasks" in requested_capabilities(message)
@pytest.mark.parametrize("message", [
"can u also pop the notes panel open for me?",
"k leave it for now - also pop open the calendar panel",
@@ -4413,3 +4437,12 @@ def test_chinese_video_to_image_artifact_keeps_media_and_workspace_tools():
"请完整浏览 /workspace/input/video.mp4,为每款零食截图并拼成清单图,"
"输出到 /workspace/snack_checklist.jpg,交付前打开成品图检查。"
) == {"media_inspection", "shell_files"}
def test_named_shell_selects_shell_family_even_for_a_bare_command():
# An explicitly named shell is a request to use the shell even when the
# rest of the message is a bare/typo'd command; admission must match the
# always-offered bash tool.
assert requested_capabilities("Bash commabd Ls la") == {"shell_files"}
assert requested_capabilities("bash ls") == {"shell_files"}
assert requested_capabilities("use the terminal to show me the files") == {"shell_files"}