Compare commits

...
Author SHA1 Message Date
nopoz bebf53be3c fix(agent): make preview and browser output parsers linear (#7003)
Six regexes over tool output backtracked polynomially on long whitespace
runs or repeated openers: the browser snapshot generic-line filter and
post-interaction split, the task-row and skill-bullet parsers, the email
"no results" check, and the Google /sorry/ interstitial check.

Rewrite each without overlapping quantifiers, keeping the same matches for
the text these tools emit. Move the /sorry/ check into
is_google_sorry_url() so it can be tested.
2026-10-30 22:46:19 -07:00
Alexandre Teixeira a8e0ff1c8e Merge pull request #6998 from odysseus-dev/maintainer/fix-6546-scientific-data
fix(llm): support scientific and decimal literals in degenerate guard
2026-10-31 02:43:09 +00:00
Alexandre Teixeira 31685b050a fix(llm): support scientific and decimal literals in degenerate guard
Preserve complete scientific notation and signed floating-point literals across
arbitrary streaming chunk boundaries in _DegenerateStreamGuard. Buffer pending
tokens until delimiter characters arrive to avoid splitting numbers into
incomplete or arbitrary alphanumeric tokens, while maintaining the 2048-number
runaway backstop and detection of repeated phrases and word loops.
2026-10-30 20:45:09 +00:00
Alexandre Teixeira 03d25943ee Merge pull request #6993 from odysseus-dev/perf/editor-cursor-style-invalidation
perf(editor): avoid brush cursor style invalidation
2026-10-30 20:09:16 +00:00
Alexandre Teixeira 71e3649985 Merge pull request #6947 from redpersongpt/codex/compare-stream-contract
fix(compare): reject incomplete and errored SSE responses
2026-10-30 19:47:28 +00:00
Alexandre Teixeira 45a57c0c38 Merge pull request #6911 from redpersongpt/codex/nested-window-close
fix(ui): return to the parent view when closing an item
2026-10-30 19:47:11 +00:00
Alexandre Teixeira 71f6dc4e5d Merge pull request #6912 from redpersongpt/codex/email-linkify-single-pass
fix(email): linkify URLs and addresses without rewriting anchors
2026-10-30 19:46:54 +00:00
Alexandre Teixeira 0c8a06a937 Merge pull request #6943 from redpersongpt/codex/chat-edit-textarea-content
fix(chat): size user-message editors from wrapped content
2026-10-30 19:46:37 +00:00
Alexandre Teixeira eea39ce103 Merge pull request #6934 from redpersongpt/codex/skill-editor-load-guard
fix(skills): wait for source before enabling the editor
2026-10-30 19:46:21 +00:00
Alexandre Teixeira 9a5a15e17f Merge pull request #6932 from redpersongpt/codex/skill-editor-source-height
fix(skills): size the raw editor from its source lines
2026-10-30 19:46:04 +00:00
Alexandre Teixeira f141ad2be5 Merge pull request #6919 from redpersongpt/codex/memory-panel-refresh
fix(memory): refresh records when opening the panel
2026-10-30 19:45:47 +00:00
Alexandre Teixeira a6e9294394 Merge pull request #6918 from redpersongpt/codex/memory-stale-category
fix(memory): reset category filter when its last entry disappears
2026-10-30 19:45:30 +00:00
Alexandre Teixeira cfbe4aeca4 Merge pull request #6915 from redpersongpt/codex/image-quality-explicit-default
fix(images): preserve explicit medium quality requests
2026-10-30 19:45:13 +00:00
Alexandre Teixeira 6f0679bb25 Merge pull request #6253 from VykosMolt/fix/generate-image-native-schema-5520
fix(tools): reject generate_image calls without a prompt
2026-10-30 19:44:56 +00:00
Alexandre Teixeira 93c93bf076 Merge pull request #6748 from dxrpl/fix/6747-recipe-panel-toggle
fix(cookbook): bring back the Dependencies recipe-panel caret (#6747)
2026-10-30 19:44:39 +00:00
Alexandre Teixeira f0f7464dfa Merge pull request #6952 from redpersongpt/codex/mail-cli-date-order
fix(mail): sort CLI message batches by UTC date
2026-10-30 19:44:23 +00:00
Alexandre Teixeira 1e57f774d3 Merge pull request #6913 from redpersongpt/codex/font-compound-style-family
fix(fonts): group compound weight and style variants by family
2026-10-30 19:44:07 +00:00
Alexandre Teixeira 2b310010cf Merge pull request #6930 from redpersongpt/codex/stale-hf-cache-markers
fix(cookbook): ignore empty markers beside completed blobs
2026-10-30 19:43:50 +00:00
Alexandre Teixeira b88154ecf9 Merge pull request #6730 from dxrpl/fix/hwfit-hf-param-count
fix(hwfit): keep the MoE active size for HF collection models (#6729)
2026-10-30 19:43:34 +00:00
Alexandre Teixeira e7796f71d3 Merge pull request #6890 from redpersongpt/codex/checkin-calendar-window-boundaries
fix(checkin): avoid duplicate calendar events at window boundaries
2026-10-30 19:43:18 +00:00
Alexandre Teixeira 412789051e Merge pull request #6585 from robyroro/codex/notes-clear-fields
fix(notes): persist removal of optional note fields
2026-10-30 19:43:01 +00:00
Alexandre Teixeira b8adc4eb60 Merge pull request #6904 from dxrpl/fix/calendar-config-object-body
fix(calendar): answer 400 when a CalDAV config body is not a JSON object (#6903)
2026-10-30 19:42:45 +00:00
Alexandre Teixeira 2246215b79 Merge pull request #6906 from dxrpl/fix/more-validation
fix(api): answer 400 for a malformed body on integrations and document export-zip (#6905)
2026-10-30 19:42:28 +00:00
Alexandre Teixeira 06fee0a3d8 Merge pull request #6546 from harshit-ojha0324/fix/degenerate-guard-numeric-data
fix(llm): stop the repeat guard from cutting off numeric data
2026-10-30 19:41:03 +00:00
Alexandre Teixeira f3813c261c perf(editor): avoid brush cursor style invalidation 2026-10-30 19:27:58 +00:00
Alexandre Teixeira e7a376b161 Merge pull request #6950 from redpersongpt/codex/group-peer-history-identity
fix(group): preserve peer identity when reloading history
2026-10-30 19:03:48 +00:00
Alexandre Teixeira fec2bb11d6 Merge pull request #6951 from redpersongpt/codex/gallery-cli-tag-filters
fix(gallery): match stacked CLI filters against AI tags
2026-10-30 19:03:01 +00:00
Alexandre Teixeira c811f76d44 Merge pull request #6935 from redpersongpt/codex/email-paperclip-files
fix(email): retain files in attachment and inline-image pickers
2026-10-30 19:02:22 +00:00
Alexandre Teixeira 02d28c06d0 Merge pull request #6888 from redpersongpt/codex/skill-version-preservation
fix(skills): preserve version strings across markdown saves
2026-10-30 19:01:47 +00:00
Alexandre Teixeira 00ecf1c74e Merge pull request #6819 from dxrpl/fix/6818-image-routes-bad-input
fix(gallery): answer 400 for a missing or invalid image on the image-edit routes (#6818)
2026-10-30 19:01:11 +00:00
Alexandre Teixeira 63feb227bb Merge pull request #6732 from dxrpl/fix/hwfit-skip-non-llm-collection-models
fix(hwfit): keep non-LLM HF collection models out of the LLM catalog (#6731)
2026-10-30 19:00:33 +00:00
Alexandre Teixeira 12fcddefab Merge pull request #6891 from redpersongpt/codex/pdf-form-unicode-field-names
fix(forms): preserve Unicode field names during PDF export
2026-10-30 18:59:47 +00:00
Alexandre Teixeira b46aa02ce1 Merge pull request #6954 from redpersongpt/codex/discovery-valid-model-ids
fix(models): retain valid discovery IDs beside malformed rows
2026-10-30 18:58:55 +00:00
Alexandre Teixeira 352ee706f3 Merge pull request #6825 from dxrpl/fix/6824-cron-five-fields
fix(tasks): refuse cron expressions with a seconds field (#6824)
2026-10-30 18:58:03 +00:00
Alexandre Teixeira 98b359cd4c Merge pull request #6982 from odysseus-dev/test/remediation-notes-process-cleanup
perf(tests): close owned Notes descendants before draining output
2026-10-30 18:53:34 +00:00
Alexandre Teixeira 2a2256af0e Merge pull request #6979 from odysseus-dev/test/remediation-static-contract-sensitivity
test: restore shadowed editor assertions and require effective procfs guards
2026-10-30 18:52:46 +00:00
Alexandre Teixeira 4f131d7c21 Merge branch 'dev' into test/remediation-notes-process-cleanup 2026-10-30 18:33:52 +00:00
Alexandre Teixeira 945e747eb4 Merge branch 'dev' into test/remediation-static-contract-sensitivity 2026-10-30 18:33:48 +00:00
Alexandre Teixeira 61051d28f0 Merge pull request #6996 from odysseus-dev/test/ci-python-baseline-repair
test(caldav): restore redirect positive-control coverage
2026-10-30 15:53:51 +00:00
PR #6029 Reviewer d87ae2a7f5 test(caldav): restore redirect positive-control coverage 2026-10-30 15:35:37 +00:00
Alexandre Teixeira 0e49c4ef40 Merge pull request #6978 from odysseus-dev/test/remediation-false-green-oracles
test: reject vacuous success in email, bridge, auth and deletion tests
2026-10-30 15:25:10 +00:00
Alexandre Teixeira 1aff71836b Merge pull request #6611 from robyroro/fix/chat-tidy-preserve-content
fix(chats): preserve saved conversations during tidy
2026-10-30 15:09:09 +00:00
Alexandre Teixeira b1c3125865 test: accept positional arguments in task endpoint mock for auto-sort 2026-10-30 15:00:08 +00:00
Alexandre Teixeira dbee1155e6 Merge pull request #6080 from alteixeira20/fix/cookbook-stale-llama-server-shim
fix(cookbook): recover from stale llama-server shims
2026-10-30 14:13:15 +00:00
Alexandre Teixeira 56c7cdf804 Merge pull request #6544 from harshit-ojha0324/fix/mcp-http-connect-leak
fix(mcp): close the HTTP transport when a remote connect fails
2026-10-30 14:09:43 +00:00
Alexandre Teixeira fe5d5caf34 Merge pull request #6977 from odysseus-dev/test/remediation-execution-isolation
test: fix pytest schema loss across route-client threads
2026-10-30 13:59:20 +00:00
Alexandre Teixeira bb654482f3 Merge pull request #6774 from odysseus-dev/fix/ios-safe-area-postmerge-hardening
fix(ui): harden iOS viewport and safe-area behavior after #6396
2026-10-30 13:56:25 +00:00
Alexandre Teixeira f45724faed Merge pull request #6983 from odysseus-dev/test/remediation-auth-fixture-seed
perf(tests): reuse immutable auth setup with fresh per-case state
2026-10-30 13:42:10 +00:00
Alexandre Teixeira 6fc6095aec test(e2e): harden mobile layout coverage, artifact isolation, and live pane assertions
- Store Playwright evidence in ignored test-results/ paths by default while preserving env override
- Clean up disposable temporary application data directories in /tmp on global teardown and exit signals
- Add Chromium mobile and tablet viewport profiles (phone portrait/landscape, tablet) for automated coverage
- Add a scoped Chromium mobile layout CI check on shard 1/4 to prevent redundant matrix execution
- Reach mobile Settings and Notes views via genuine sidebar navigation controls opened through the hamburger button
- Add assertions against live rendered Notes and Documents panes honoring safe-area top and height constraints
- Document local WebKit prerequisites and physical iOS/PWA test limitations in tests/e2e/README.md and package.json
2026-10-30 13:41:15 +00:00
Alexandre Teixeira ef16398c5c merge(dev): integrate latest dev into fix/ios-safe-area-postmerge-hardening 2026-10-30 13:25:33 +00:00
Alexandre Teixeira 6037707430 fix(cookbook): launch validated llama-server candidate and handle PATH precedence
Ensure the native candidate that passed the health check is prioritized and executed when an earlier broken candidate exists on PATH.

Reconcile Odysseus-owned shims safely, invalidate command hash cache, and verify launchability rather than command existence before serve dispatch.

Fixes #6079
2026-10-30 13:15:40 +00:00
Alexandre Teixeira 5c542d03ac Merge remote-tracking branch 'origin/dev' into fix/pr-6080-llama-recovery 2026-10-30 13:02:47 +00:00
red person 4fe8fb28e5 test: isolate photo routing from external MCP registry state 2026-10-29 23:45:05 +03:00
red person 84e232e7ee fix(cache): retain the upstream worker key format 2026-10-29 23:42:25 +03:00
red person 2cace27723 test: isolate photo routing from external MCP registry state 2026-10-29 23:40:25 +03:00
red person 508c7b3d9d Merge current dev into PR 6932 2026-10-29 23:28:45 +03:00
red person 5b8a72931d Merge current dev into PR 6934 2026-10-29 23:28:28 +03:00
red person 2e7edf908d Merge current dev into PR 6935 2026-10-29 23:27:57 +03:00
red person b80f9afb3e Merge current dev into PR 6943 2026-10-29 23:27:34 +03:00
red person 533d48ad8d Merge current dev into Compare SSE fix 2026-10-29 23:26:15 +03:00
red person d5bc7fb70e Merge current dev into group peer history fix 2026-10-29 23:21:20 +03:00
Alexandre Teixeira 3b8c60ac1e test: check database ownership against the private temp namespace 2026-10-29 17:49:45 +00:00
Alexandre Teixeira d237c3fbba perf(tests): reuse immutable admin auth configuration 2026-10-29 17:35:59 +00:00
Alexandre Teixeira f510b1973a perf(tests): bound Notes server descendant cleanup 2026-10-29 17:35:52 +00:00
Alexandre Teixeira fe81c491cd test: give collection database URL one cleanup owner 2026-10-29 17:30:10 +00:00
Alexandre Teixeira 1339efa856 test: restore editor aliases and validate procfs guard control flow 2026-10-29 17:12:26 +00:00
Alexandre Teixeira 6f5c28b345 test: require real cold reads, cancellation and auth writes 2026-10-29 17:12:20 +00:00
Alexandre Teixeira aa2db8d87c test: keep worker collection databases consistent across threads 2026-10-29 17:12:14 +00:00
dxrpl 6b284d2bc5 test(gallery): enable local image models in the bad-input test 2026-10-28 18:27:00 +01:00
dxrpl 8acaec20ee fix(gallery): answer 400 for a missing or invalid image on the image-edit routes (#6818) 2026-10-28 18:25:04 +01:00
red person 5f18bcf6f8 fix(models): retain valid discovery IDs beside malformed rows 2026-10-28 14:52:12 +03:00
red person 18b6140511 fix(mail): sort CLI message batches by UTC date 2026-10-28 14:39:41 +03:00
red person eeb1107f7b fix(gallery): match stacked CLI filters against AI tags 2026-10-28 14:33:23 +03:00
red person 4795c8d462 fix(group): preserve peer identity when reloading history 2026-10-28 14:27:17 +03:00
red person 5c738a5a8e fix(compare): reject incomplete and errored SSE responses 2026-10-28 13:09:35 +03:00
red person 0ff5d47a8c fix(chat): size user-message editors from wrapped content 2026-10-28 12:03:20 +03:00
red person f780f1dce9 test(email): align document identity guards with picker cache 2026-10-28 10:04:55 +03:00
red person 1c5853003e fix(email): retain inline image selections before input reset 2026-10-28 07:34:10 +03:00
red person 0f8b137713 fix(email): retain paperclip files when resetting the input 2026-10-28 07:26:05 +03:00
red person 2c5d5005ba fix(skills): wait for source before enabling the editor 2026-10-28 07:06:39 +03:00
red person 901b3ff42d fix(skills): align the chat link editor version 2026-10-28 06:58:54 +03:00
red person c207ed1ed8 fix(skills): size the raw editor from its source lines 2026-10-28 06:54:12 +03:00
red person 8b3a866899 fix(cookbook): ignore empty markers beside completed blobs 2026-10-28 06:31:50 +03:00
red person 4d8d363681 fix(memory): refresh records when opening the panel 2026-10-28 03:11:11 +03:00
red person a7e8a6ef2e fix(memory): reset category filter when its last entry disappears 2026-10-28 02:40:54 +03:00
red person 12631da4d3 fix(images): preserve explicit medium quality requests
Use configured quality only for omitted arguments in generation, MCP and edit helpers. Keep explicit medium choices from being overwritten by a low or high default.
2026-10-28 01:02:48 +03:00
red person 927985f847 fix(fonts): group compound weight and style variants by family 2026-10-28 00:23:18 +03:00
red person 2046ce893f fix(email): linkify URLs and addresses without rewriting anchors 2026-10-28 00:12:23 +03:00
red person 35ef91b25c fix(ui): return to the parent view when closing an item 2026-10-27 23:58:00 +03:00
dxrpl 91922d854c fix(api): answer 400 for a malformed body on integrations and document export-zip (#6905) 2026-10-27 18:33:23 +01:00
dxrpl 641752387b fix(calendar): answer 400 when a CalDAV config body is not a JSON object (#6903) 2026-10-27 18:09:14 +01:00
red person b3f86a0724 fix(forms): preserve Unicode field names during PDF export 2026-10-26 14:04:59 +03:00
red person 94dc74e500 fix(checkin): avoid duplicate calendar events at window boundaries 2026-10-26 12:00:12 +03:00
red person fd27e9a429 fix(skills): preserve version strings across markdown saves 2026-10-26 10:32:26 +03:00
dxrpl bf3c000d1c fix(tasks): refuse cron expressions with a seconds field (#6824) 2026-10-24 23:41:29 +02:00
PR #6029 Reviewer 7fd4e0e9bb fix(ui): harden iOS safe-area and viewport regressions 2026-10-20 11:39:51 +01:00
dxrplandClaude Opus 5.5 8cd96aad46 fix(cookbook): bring back the Dependencies recipe-panel caret (#6747)
cb052eee removed the caret that opens a backend's recipe panel, saying
the action had moved into the Installed menu, but that menu never got a
recipe item. The panel (model picker, commands, Run) could not be
opened, and the HW Fit / serve-card deep links that click the caret
only flashed the row. Render the caret again on every recipe row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:59:56 +02:00
dxrplandClaude Opus 5.5 6123b70b5c fix(hwfit): keep non-LLM HF collection models out of the LLM catalog (#6731)
Tracked HF collections also contain time-series forecasters, image
encoders and image/video generators. They were imported as "general"
LLMs and offered for vllm serve. Skip their pipeline tags; embedding,
reranker and RL-tagged LLM repos stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:15:08 +02:00
dxrplandClaude Opus 5.5 bfa514f6e8 fix(hwfit): keep the MoE active size for HF collection models (#6729)
_entry_from_collection_item parsed the repo name (the only source of the
"-A3B" active size) only when HF's numParameters was missing, which is
almost never. Every MoE model from the HF collection catalog was scored
as dense, with speed estimates 3-4x too low.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:11:46 +02:00
VykosMolt 1ff2fd0054 Merge dev and retain native image prompt validation 2026-10-09 01:34:21 +02:00
robyroro ad3e309074 fix(chats): preserve saved conversations during tidy 2026-10-08 06:48:41 +03:00
robyroro 15c4cef36a fix(notes): honor explicit null when clearing optional fields 2026-10-07 19:50:03 +03:00
harshit-ojha0324 84cdd866c5 fix(llm): stop the repeat guard from cutting off numeric data
The degenerate-stream guard treats a long run of one token, a dominant
token, or a periodic window as token collapse. Numbers repeat that way
by design: an 8x8 zero or identity matrix in LaTeX, a padded buffer, a
sparse 0/1 list. Each of those was stopped mid-answer with 'started
repeating tokens'.

Skip the same-run and dominance checks when the repeated token is a
number, skip the periodic check when the recent window is mostly
numbers, and skip the exact-block check when the block is all numbers.
A run of 2048 numbers in a row still stops, so a model stuck printing
'0 0 0' is still caught. Word loops and mixed word/number loops are
unchanged.

Fixes #5560
2026-10-06 19:01:12 -04:00
harshit-ojha0324 50b05bf54f fix(mcp): close the HTTP transport when a remote connect fails
_connect_http entered streamablehttp_client and ClientSession on an
AsyncExitStack but never closed it when initialize or list_tools
failed. The half-open transport was left to the garbage collector,
which exited its AnyIO cancel scope from the wrong task, and the event
loop kept spinning: about 18% of a core per failed connect, growing
with each one, until restart.

Close the stack in the connecting task on any failure or cancellation,
the same way _connect_sse already does.

Fixes #5518
2026-10-06 19:01:10 -04:00
VykosMoltandClaude dbfc96b8f3 fix(tools): add the native function schema for generate_image
generate_image existed in the prompt sections, the executor and the image_gen
MCP server, but not in FUNCTION_TOOL_SCHEMAS. API models are only sent schemas
from that list, so the tool selector could pick it and the schema filter would
drop it again — the model then improvised text-shaped calls it had no way to
make work.

The existing JSON fallback in function_call_to_tool_block already produces
content the executor parses, so no converter branch is needed. A required-args
entry is, though: without it a prompt-less call falls through to the line
parser and the whole JSON blob becomes the image prompt.

Fixes #5520

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-05 02:42:07 +02:00
Alexandre Teixeira a7c03f85bf fix(cookbook): recover from stale llama-server shims
Reconcile Odysseus-owned Python compatibility shims when llama_cpp.server is no longer available, allowing healthy native llama-server binaries in PATH to be discovered and executed without shadowing.

Fixes #6079
2026-08-17 00:59:13 +01:00
98 changed files with 4691 additions and 429 deletions
+7
View File
@@ -256,3 +256,10 @@ jobs:
env:
PYTEST_SHARD: ${{ matrix.shard }}
run: python -m pytest -q -rs --durations=25 --shard "$PYTEST_SHARD"
- name: Playwright mobile layout checks (shard 1/4 only)
if: steps.docs-check.outputs.docs_only != 'true' && matrix.shard == '1/4'
run: |
ODYSSEUS_TEST_PYTHON=python ODYSSEUS_LAYOUT_BROWSER=chromium npx --no-install playwright test \
--config tests/e2e/ios-layout.config.cjs \
--reporter=line
+2 -2
View File
@@ -33,7 +33,7 @@ async def list_tools() -> list[Tool]:
"prompt": {"type": "string", "description": "Image description prompt"},
"model": {"type": "string", "description": "Model name (auto-detects if omitted)"},
"size": {"type": "string", "description": "Image size (default 1024x1024)"},
"quality": {"type": "string", "description": "Quality: low, medium, high, auto (default medium)"},
"quality": {"type": "string", "description": "Quality: low, medium, high, auto (default configured quality, otherwise medium)"},
},
"required": ["prompt"],
},
@@ -66,7 +66,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
if not model_spec:
model_spec = _settings.get("image_model", "")
if quality == "medium" and _settings.get("image_quality"):
if "quality" not in arguments and _settings.get("image_quality"):
quality = _settings["image_quality"]
# Auto-detect best available image model
+5 -1
View File
@@ -9,7 +9,11 @@
"test:photo-editor": "playwright test --config tests/e2e/playwright.config.js",
"test:photo-editor:install": "playwright install chromium firefox webkit",
"test:photo-editor:firefox": "PHOTO_EDITOR_E2E_BROWSER=firefox playwright test --config tests/e2e/playwright.config.js",
"test:photo-editor:webkit": "PHOTO_EDITOR_E2E_BROWSER=webkit playwright test --config tests/e2e/playwright.config.js"
"test:photo-editor:webkit": "PHOTO_EDITOR_E2E_BROWSER=webkit playwright test --config tests/e2e/playwright.config.js",
"test:ios-layout": "playwright test --config tests/e2e/ios-layout.config.cjs",
"test:ios-layout:chromium": "ODYSSEUS_LAYOUT_BROWSER=chromium playwright test --config tests/e2e/ios-layout.config.cjs",
"test:ios-layout:webkit": "ODYSSEUS_LAYOUT_BROWSER=webkit playwright test --config tests/e2e/ios-layout.config.cjs",
"test:ios-layout:install": "playwright install chromium webkit"
},
"devDependencies": {
"@antithesishq/bombadil": "^0.7.0",
+4
View File
@@ -887,6 +887,8 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
"""Create a new integration (admin only)."""
_require_admin_user(request)
body = await request.json()
if not isinstance(body, dict):
raise HTTPException(400, "Request body must be a JSON object")
item = add_integration(body)
return {"ok": True, "integration": mask_integration_secret(item)}
@@ -895,6 +897,8 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
"""Update an existing integration (admin only)."""
_require_admin_user(request)
body = await request.json()
if not isinstance(body, dict):
raise HTTPException(400, "Request body must be a JSON object")
item = update_integration(integration_id, body)
if not item:
raise HTTPException(404, "Integration not found")
+16 -16
View File
@@ -80,6 +80,18 @@ def _require_user(request: Request) -> str:
return FALLBACK_OWNER
async def _json_object_body(request: Request) -> dict:
"""Parse a JSON object body. An unreadable body counts as empty; a body
that is valid JSON but not an object (a list, string or number) is a 400."""
try:
body = await request.json()
except Exception:
return {}
if not isinstance(body, dict):
raise HTTPException(400, "Request body must be a JSON object")
return body
def _get_or_404_calendar(db, cal_id: str, owner: str) -> CalendarCal:
cal = db.query(CalendarCal).filter(CalendarCal.id == cal_id).first()
if not cal:
@@ -994,10 +1006,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
async def save_config(request: Request):
"""Legacy single-account endpoint — upserts the first account."""
owner = _require_user(request)
try:
body = await request.json()
except Exception:
body = {}
body = await _json_object_body(request)
accounts = _get_caldav_accounts(owner)
if not (body.get("url") or "").strip():
_save_caldav_accounts(owner, [])
@@ -1052,10 +1061,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
"""Add a new CalDAV account."""
import uuid as _uuid
owner = _require_user(request)
try:
body = await request.json()
except Exception:
body = {}
body = await _json_object_body(request)
from src.caldav_sync import validate_caldav_url
try:
url = validate_caldav_url(body.get("url", ""))
@@ -1080,10 +1086,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
async def update_caldav_account(account_id: str, request: Request):
"""Update an existing CalDAV account by id."""
owner = _require_user(request)
try:
body = await request.json()
except Exception:
body = {}
body = await _json_object_body(request)
accounts = _get_caldav_accounts(owner)
idx = next((i for i, a in enumerate(accounts) if a.get("id") == account_id), None)
if idx is None:
@@ -1124,10 +1127,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
test an already-saved account. Falls back to the first saved account
when nothing is provided."""
owner = _require_user(request)
try:
body = await request.json()
except Exception:
body = {}
body = await _json_object_body(request)
url = (body.get("url") or "").strip()
user = (body.get("username") or "").strip()
pw = body.get("password") or ""
+165 -1
View File
@@ -14,6 +14,7 @@ from fastapi import HTTPException
from pydantic import BaseModel
from routes._validators import validate_remote_host, validate_ssh_port
from routes.cookbook_output import HF_PARTIAL_BLOB_HELPER
from core.platform_compat import _ssh_exec_argv
logger = logging.getLogger(__name__)
@@ -378,6 +379,7 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
"""
lines = [
"import json, os, re, shutil, subprocess, urllib.request",
*HF_PARTIAL_BLOB_HELPER.splitlines(),
"models = []",
"seen = set()",
"BLOCKED_ROOTS = ('/sys', '/proc', '/dev', '/run', '/var/run')",
@@ -436,8 +438,10 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
" sz, nf, ic = 0, 0, False",
" if os.path.isdir(blobs):",
" for f in os.scandir(blobs):",
" if f.name.endswith('.incomplete'):",
" if not is_partial_blob(f.path): continue",
" ic = True",
" if f.is_file(): nf += 1; sz += f.stat().st_size",
" if f.name.endswith('.incomplete'): ic = True",
" snap = os.path.join(cache, d, 'snapshots')",
" def snapshot_size():",
" total, count, incomplete = 0, 0, False",
@@ -1030,6 +1034,166 @@ def _append_llama_cpp_linux_accel_build_lines(runner_lines: list[str]) -> None:
runner_lines.append(' fi # end _odysseus_have_prebuilt guard')
ODYSSEUS_LLAMA_SHIM_MARKER = "# Auto-generated by Odysseus Cookbook: a `llama-server` lookalike"
def _append_llama_server_resolution_definition_lines(runner_lines: list[str]) -> None:
"""Define the helper shell function that discovers, reconciles and prioritizes llama-server."""
marker = f"'{_bash_squote(ODYSSEUS_LLAMA_SHIM_MARKER)}'"
runner_lines.append('_odysseus_resolve_llama_server() {')
runner_lines.append(' _odysseus_llama_bin=""')
runner_lines.append(f' local _marker={marker}')
runner_lines.append(' local _saved_ifs="$IFS"')
runner_lines.append(" IFS=':'")
runner_lines.append(' local -a _parts')
runner_lines.append(' read -r -a _parts <<< "$PATH"')
runner_lines.append(' IFS="$_saved_ifs"')
runner_lines.append(' local _dir _cand')
runner_lines.append(' for _dir in "${_parts[@]}"; do')
runner_lines.append(' [ -n "$_dir" ] || continue')
runner_lines.append(' _cand="$_dir/llama-server"')
runner_lines.append(' [ -x "$_cand" ] || continue')
runner_lines.append(' if [ -f "$_cand" ] && [ ! -L "$_cand" ] && grep -Fxq "$_marker" "$_cand" 2>/dev/null; then')
runner_lines.append(' continue')
runner_lines.append(' fi')
runner_lines.append(' if "$_cand" --version >/dev/null 2>&1; then')
runner_lines.append(' _odysseus_llama_bin="$_cand"')
runner_lines.append(' break')
runner_lines.append(' fi')
runner_lines.append(' done')
runner_lines.append(' if [ -f "$HOME/bin/llama-server" ] && [ ! -L "$HOME/bin/llama-server" ] && grep -Fxq "$_marker" "$HOME/bin/llama-server" 2>/dev/null; then')
runner_lines.append(' if [ -n "$_odysseus_llama_bin" ]; then')
runner_lines.append(' echo "[odysseus] Native llama-server detected — retiring Odysseus compatibility shim in ~/bin"')
runner_lines.append(' rm -f "$HOME/bin/llama-server"')
runner_lines.append(' hash -r')
runner_lines.append(' elif python3 -c "import llama_cpp.server" 2>/dev/null; then')
runner_lines.append(' _odysseus_llama_bin="$HOME/bin/llama-server"')
runner_lines.append(' else')
runner_lines.append(' echo "[odysseus] Removing stale Odysseus llama-server shim (python3 -m llama_cpp.server unavailable)"')
runner_lines.append(' rm -f "$HOME/bin/llama-server"')
runner_lines.append(' hash -r')
runner_lines.append(' fi')
runner_lines.append(' fi')
runner_lines.append(' if [ -n "$_odysseus_llama_bin" ]; then')
runner_lines.append(' local _cand_dir')
runner_lines.append(' _cand_dir="$(dirname "$_odysseus_llama_bin")"')
runner_lines.append(' if [ "${PATH%%:*}" != "$_cand_dir" ]; then')
runner_lines.append(' export PATH="$_cand_dir:$PATH"')
runner_lines.append(' fi')
runner_lines.append(' hash -r')
runner_lines.append(' return 0')
runner_lines.append(' fi')
runner_lines.append(' return 1')
runner_lines.append('}')
def _append_llama_server_shim_definition_lines(runner_lines: list[str]) -> None:
"""Define the helper shell function that generates the Odysseus compatibility shim."""
_append_llama_server_resolution_definition_lines(runner_lines)
marker = f"'{_bash_squote(ODYSSEUS_LLAMA_SHIM_MARKER)}'"
runner_lines.append('_odysseus_create_llama_shim() {')
runner_lines.append(' local _target="$HOME/bin/llama-server"')
runner_lines.append(f' local _marker={marker}')
runner_lines.append(' if [ -e "$_target" ] || [ -L "$_target" ]; then')
runner_lines.append(' if [ -f "$_target" ] && [ ! -L "$_target" ] && grep -Fxq "$_marker" "$_target" 2>/dev/null; then')
runner_lines.append(' : # existing Odysseus shim — safe to replace')
runner_lines.append(' else')
runner_lines.append(' echo "[odysseus] WARNING: $_target already exists and is not an Odysseus-managed shim; leaving untouched." >&2')
runner_lines.append(' return 1')
runner_lines.append(' fi')
runner_lines.append(' fi')
runner_lines.append(' mkdir -p "$HOME/bin" || return 1')
runner_lines.append(' local _tmp')
runner_lines.append(' _tmp="$(mktemp "$HOME/bin/.llama-server.odysseus.tmp.XXXXXX")" || {')
runner_lines.append(' echo "[odysseus] ERROR: unable to create secure temporary llama-server shim in $HOME/bin" >&2')
runner_lines.append(' return 1')
runner_lines.append(' }')
runner_lines.append(" if ! cat > \"$_tmp\" <<'_ODY_LLAMA_SHIM_EOF'")
runner_lines.append('#!/usr/bin/env bash')
runner_lines.append(ODYSSEUS_LLAMA_SHIM_MARKER)
runner_lines.append('# that translates the native CLI to `python -m llama_cpp.server`.')
runner_lines.append('# Lets cookbook-generated launch commands run unchanged on hosts')
runner_lines.append('# where only the pip llama-cpp-python package is installed.')
runner_lines.append('ARGS=()')
runner_lines.append('while [ $# -gt 0 ]; do')
runner_lines.append(' case "$1" in')
runner_lines.append(' -ngl|--gpu-layers|--n-gpu-layers) ARGS+=(--n_gpu_layers "$2"); shift 2 ;;')
runner_lines.append(' -c|--ctx-size) ARGS+=(--n_ctx "$2"); shift 2 ;;')
runner_lines.append(' -b|--batch-size) ARGS+=(--n_batch "$2"); shift 2 ;;')
runner_lines.append(' -ub|--ubatch-size) shift 2 ;; # llama-cpp-python has no separate ubatch')
runner_lines.append(' --flash-attn) ARGS+=(--flash_attn true); shift 2 ;;')
runner_lines.append(' --cache-type-k) ARGS+=(--type_k "$2"); shift 2 ;;')
runner_lines.append(' --cache-type-v) ARGS+=(--type_v "$2"); shift 2 ;;')
runner_lines.append(' --n-cpu-moe) ARGS+=(--n_cpu_moe "$2"); shift 2 ;;')
runner_lines.append(' --mmproj) ARGS+=(--clip_model_path "$2"); shift 2 ;;')
runner_lines.append(' --image-max-tokens) shift 2 ;; # native-only')
runner_lines.append(' --no-mmap) ARGS+=(--no_mmap true); shift ;;')
runner_lines.append(' --no-warmup) shift ;; # native-only')
runner_lines.append(' --chat-template) ARGS+=(--chat_format "$2"); shift 2 ;;')
runner_lines.append(' --fit|--split-mode|--tensor-split|--main-gpu|--parallel) shift 2 ;; # native-only')
runner_lines.append(' --mlock) ARGS+=(--use_mlock true); shift ;;')
runner_lines.append(' *) ARGS+=("$1"); shift ;;')
runner_lines.append(' esac')
runner_lines.append('done')
runner_lines.append('exec python3 -m llama_cpp.server "${ARGS[@]}"')
runner_lines.append('_ODY_LLAMA_SHIM_EOF')
runner_lines.append(' then')
runner_lines.append(' rm -f -- "$_tmp"')
runner_lines.append(' return 1')
runner_lines.append(' fi')
runner_lines.append(' chmod +x "$_tmp" || { rm -f -- "$_tmp"; return 1; }')
runner_lines.append(' mv -f -- "$_tmp" "$_target" || { rm -f -- "$_tmp"; return 1; }')
runner_lines.append(' hash -r')
runner_lines.append(' echo "[odysseus] Created llama-server shim → python -m llama_cpp.server (no native binary needed)"')
runner_lines.append('}')
def _append_llama_server_shim_reconciliation_lines(runner_lines: list[str]) -> None:
"""Reconcile an existing ~/bin/llama-server if it is owned by Odysseus Cookbook.
Invariants:
- Never touch an arbitrary user-owned executable or symlink.
- If ~/bin/llama-server is an Odysseus generated shim:
- If a native llama-server is present and healthy elsewhere on PATH, remove the shim
so the native binary is used.
- If python3 -m llama_cpp.server is unavailable, remove the stale shim
and hash -r so the fallback/build path is triggered.
- If no healthy native server exists and llama_cpp.server is working, preserve it.
- If a healthy native candidate exists on PATH (even later in PATH), prioritize it and
ensure the launched executable is the validated candidate.
"""
runner_lines.append('[ -r ~/.config/odysseus-llama-cpp-env ] && . ~/.config/odysseus-llama-cpp-env')
runner_lines.append('if ! declare -F _odysseus_resolve_llama_server >/dev/null 2>&1; then')
_append_llama_server_resolution_definition_lines(runner_lines)
runner_lines.append('fi')
runner_lines.append('_odysseus_resolve_llama_server || true')
def _append_llama_server_finalization_lines(runner_lines: list[str], platform_hint: str = "") -> None:
"""Reconcile post-install launcher availability and enforce fail-closed invariant.
Guarantees that if python3 -m llama_cpp.server is usable but the `llama-server`
command is unresolvable, the compatibility launcher is created. If no usable
launcher exists after all build/install attempts, sets ODYSSEUS_PREFLIGHT_EXIT=127.
"""
err_msg = (
f"ERROR: llama.cpp serving is not available on {platform_hint} after install attempts."
if platform_hint
else "ERROR: llama.cpp serving is not available after install/build attempts."
)
runner_lines.append('if ! declare -F _odysseus_resolve_llama_server >/dev/null 2>&1; then')
_append_llama_server_resolution_definition_lines(runner_lines)
runner_lines.append('fi')
runner_lines.append('if [ -z "$_odysseus_llama_bin" ] && python3 -c "import llama_cpp.server" 2>/dev/null; then')
runner_lines.append(' _odysseus_create_llama_shim')
runner_lines.append(' _odysseus_resolve_llama_server || true')
runner_lines.append('fi')
runner_lines.append('if [ -z "$_odysseus_llama_bin" ]; then')
runner_lines.append(f' echo "{err_msg}"')
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
runner_lines.append('fi')
def _llama_cpp_rebuild_cmd(update_source: bool = False) -> str:
"""Shell command that clears the Cookbook-managed llama.cpp build.
+18 -4
View File
@@ -8,6 +8,20 @@ import re
_FETCHING_ZERO_FILES_RE = re.compile(r"Fetching\s+0\s+files", re.IGNORECASE)
# Shared source embedded in local/remote inventory and status probes. A blank
# retry marker beside its completed, nonempty content-addressed blob is stale;
# a blank marker alone may be a download that has not received its first byte.
HF_PARTIAL_BLOB_HELPER = """def is_partial_blob(path):
if not path.endswith('.incomplete'):
return False
try:
completed = path[:-len('.incomplete')]
return (os.path.getsize(path) != 0 or not os.path.isfile(completed)
or os.path.getsize(completed) == 0)
except OSError:
return True
"""
# Probe scripts for the dead-session download check, run as
# `python3 -c <PROBE> <repo_id> <cache_root>` (locally or over SSH).
# cache_root is the task's custom download dir, '' for the default HF cache.
@@ -15,7 +29,7 @@ _FETCHING_ZERO_FILES_RE = re.compile(r"Fetching\s+0\s+files", re.IGNORECASE)
# HF_HOME=<local_dir>, so that task's cache lives under <local_dir>/hub, and
# the probe process's own environment knows nothing about it.
HF_CACHE_COMPLETE_PROBE = (
"import os,sys;"
"import os,sys\n" + HF_PARTIAL_BLOB_HELPER +
"repo=sys.argv[1];"
"root=os.path.expanduser(sys.argv[2]) if len(sys.argv)>2 and sys.argv[2] else '';"
"base=os.path.join(root,'hub') if root else (os.environ.get('HUGGINGFACE_HUB_CACHE') or os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'hub'));"
@@ -24,18 +38,18 @@ HF_CACHE_COMPLETE_PROBE = (
"ok=os.path.isdir(snap) and any(os.path.isdir(os.path.join(snap,x)) and os.listdir(os.path.join(snap,x)) for x in os.listdir(snap));"
"inc=False;"
"blobs=os.path.join(d,'blobs');"
"inc=os.path.isdir(blobs) and any(x.endswith('.incomplete') for x in os.listdir(blobs));"
"inc=os.path.isdir(blobs) and any(is_partial_blob(os.path.join(blobs,x)) for x in os.listdir(blobs));"
"sys.exit(0 if ok and not inc else 1)"
)
HF_CACHE_INCOMPLETE_PROBE = (
"import os,sys;"
"import os,sys\n" + HF_PARTIAL_BLOB_HELPER +
"repo=sys.argv[1];"
"root=os.path.expanduser(sys.argv[2]) if len(sys.argv)>2 and sys.argv[2] else '';"
"base=os.path.join(root,'hub') if root else (os.environ.get('HUGGINGFACE_HUB_CACHE') or os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'hub'));"
"d=os.path.join(base,'models--'+repo.replace('/','--'));"
"blobs=os.path.join(d,'blobs');"
"inc=os.path.isdir(blobs) and any(x.endswith('.incomplete') for x in os.listdir(blobs));"
"inc=os.path.isdir(blobs) and any(is_partial_blob(os.path.join(blobs,x)) for x in os.listdir(blobs));"
"sys.exit(0 if inc else 1)"
)
+17 -57
View File
@@ -57,9 +57,11 @@ from routes.cookbook_helpers import (
_pip_install_no_cache, _user_shell_path_bootstrap, _venv_safe_local_pip_install_cmd,
_diagnose_serve_output, run_ssh_command_async,
_ollama_bind_from_cmd, _pip_install_fallback_chain, _pip_install_no_cache,
_user_shell_path_bootstrap, _venv_safe_local_pip_install_cmd,
_append_pip_install_runner_lines, _pip_install_command_without_break_system_packages,
_normalize_llama_cpp_python_cache_types,
_append_llama_server_shim_definition_lines,
_append_llama_server_shim_reconciliation_lines,
_append_llama_server_finalization_lines,
ModelDownloadRequest, ServeRequest,
)
@@ -2161,7 +2163,7 @@ def setup_cookbook_routes() -> APIRouter:
ps_lines.append('}')
elif "llama_cpp" in req.cmd or "llama-server" in req.cmd:
ps_lines.append('# Auto-install llama-cpp-python if missing')
ps_lines.append('try { python -c "import llama_cpp" 2>$null } catch {}')
ps_lines.append('try { python -c "import llama_cpp.server" 2>$null } catch {}')
ps_lines.append('if ($LASTEXITCODE -ne 0) {')
ps_lines.append(' Write-Host "Installing llama-cpp-python..."')
ps_lines.append(' python -m pip install llama-cpp-python[server]')
@@ -2247,14 +2249,17 @@ def setup_cookbook_routes() -> APIRouter:
# ollama is found (otherwise macOS falls back to a slow source build).
# /opt/homebrew = Apple Silicon, /usr/local = Intel; harmless on Linux.
runner_lines.append('export PATH="$HOME/.local/bin:$HOME/bin:$HOME/llama.cpp/build/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"')
_append_llama_server_shim_definition_lines(runner_lines)
_append_llama_server_shim_reconciliation_lines(runner_lines)
runner_lines.append('if [ -d /data/data/com.termux ]; then')
runner_lines.append(' # Termux: no native build — use the Python bindings (CPU).')
runner_lines.append(' if ! python3 -c "import llama_cpp" 2>/dev/null; then')
runner_lines.append(' if ! python3 -c "import llama_cpp.server" 2>/dev/null; then')
runner_lines.append(' pkg install -y cmake 2>/dev/null')
runner_lines.append(' pip install numpy diskcache jinja2 2>/dev/null')
runner_lines.append(' CMAKE_ARGS="-DGGML_BLAS=OFF -DGGML_LLAMAFILE=OFF" pip install \'llama-cpp-python[server]\' --no-build-isolation --no-cache-dir 2>&1 || true')
runner_lines.append(' fi')
runner_lines.append('elif ! command -v llama-server &>/dev/null; then')
_append_llama_server_finalization_lines(runner_lines, platform_hint="Termux")
runner_lines.append('elif [ -z "$_odysseus_llama_bin" ]; then')
runner_lines.append(' echo "Native llama-server not found — building from source (one-time, may take a few minutes)..."')
runner_lines.append(' mkdir -p ~/bin')
runner_lines.append(' cd ~ && [ -d llama.cpp ] || git clone --depth 1 https://github.com/ggml-org/llama.cpp')
@@ -2271,8 +2276,7 @@ def setup_cookbook_routes() -> APIRouter:
# would reuse the bad settings and fail again. CMAKE_BUILD_TYPE is
# explicit so the binary is optimized (Metal auto-enables on macOS).
runner_lines.append(' cd ~/llama.cpp && rm -rf build && cmake -B build -DCMAKE_BUILD_TYPE=Release \\')
runner_lines.append(' && cmake --build build -j"$NPROC" --target llama-server \\')
runner_lines.append(' && ln -sf ~/llama.cpp/build/bin/llama-server ~/bin/llama-server')
runner_lines.append(' && cmake --build build -j"$NPROC" --target llama-server')
runner_lines.append(' else')
_append_llama_cpp_linux_accel_build_lines(runner_lines)
runner_lines.append(' fi')
@@ -2296,58 +2300,13 @@ def setup_cookbook_routes() -> APIRouter:
runner_lines.append(' fi')
runner_lines.append(' fi')
runner_lines.append(' fi')
# SHORT-CIRCUIT before the build/pip fallback: if the
# native binary is missing but llama_cpp Python is already
# installed, drop a wrapper at ~/bin/llama-server that
# translates llama-server CLI args to llama_cpp.server's
# underscore-style flags. The user's serve command stays
# `llama-server ...` and "just works" — no build, no cmake,
# no second install. This is the path that unblocks every
# remote where pip-installed llama-cpp-python is already
# working but Cookbook used to insist on a native binary.
runner_lines.append(' if ! command -v llama-server >/dev/null 2>&1 && python3 -c "import llama_cpp" 2>/dev/null; then')
runner_lines.append(' mkdir -p ~/bin')
runner_lines.append(' cat > ~/bin/llama-server <<\'_ODY_LLAMA_SHIM_EOF\'')
runner_lines.append('#!/usr/bin/env bash')
runner_lines.append('# Auto-generated by Odysseus Cookbook: a `llama-server` lookalike')
runner_lines.append('# that translates the native CLI to `python -m llama_cpp.server`.')
runner_lines.append('# Lets cookbook-generated launch commands run unchanged on hosts')
runner_lines.append('# where only the pip llama-cpp-python package is installed.')
runner_lines.append('ARGS=()')
runner_lines.append('while [ $# -gt 0 ]; do')
runner_lines.append(' case "$1" in')
runner_lines.append(' -ngl|--gpu-layers|--n-gpu-layers) ARGS+=(--n_gpu_layers "$2"); shift 2 ;;')
runner_lines.append(' -c|--ctx-size) ARGS+=(--n_ctx "$2"); shift 2 ;;')
runner_lines.append(' -b|--batch-size) ARGS+=(--n_batch "$2"); shift 2 ;;')
runner_lines.append(' -ub|--ubatch-size) shift 2 ;; # llama-cpp-python has no separate ubatch')
runner_lines.append(' --flash-attn) ARGS+=(--flash_attn true); shift 2 ;;')
runner_lines.append(' --cache-type-k) ARGS+=(--type_k "$2"); shift 2 ;;')
runner_lines.append(' --cache-type-v) ARGS+=(--type_v "$2"); shift 2 ;;')
runner_lines.append(' --n-cpu-moe) ARGS+=(--n_cpu_moe "$2"); shift 2 ;;')
runner_lines.append(' --mmproj) ARGS+=(--clip_model_path "$2"); shift 2 ;;')
runner_lines.append(' --image-max-tokens) shift 2 ;; # native-only')
runner_lines.append(' --no-mmap) ARGS+=(--no_mmap true); shift ;;')
runner_lines.append(' --no-warmup) shift ;; # native-only')
runner_lines.append(' --chat-template) ARGS+=(--chat_format "$2"); shift 2 ;;')
runner_lines.append(' --fit|--split-mode|--tensor-split|--main-gpu|--parallel) shift 2 ;; # native-only')
runner_lines.append(' --mlock) ARGS+=(--use_mlock true); shift ;;')
runner_lines.append(' *) ARGS+=("$1"); shift ;;')
runner_lines.append(' esac')
runner_lines.append('done')
runner_lines.append('exec python3 -m llama_cpp.server "${ARGS[@]}"')
runner_lines.append('_ODY_LLAMA_SHIM_EOF')
runner_lines.append(' chmod +x ~/bin/llama-server')
runner_lines.append(' echo "[odysseus] Created llama-server shim → python -m llama_cpp.server (no native binary needed)"')
runner_lines.append(' fi')
runner_lines.append(' # If the native build failed, fall back to the Python bindings.')
runner_lines.append(' if ! command -v llama-server &>/dev/null && ! python3 -c "import llama_cpp" 2>/dev/null; then')
runner_lines.append(' _odysseus_resolve_llama_server || true')
# If the native build failed, fall back to the Python bindings.
runner_lines.append(' if [ -z "$_odysseus_llama_bin" ] && ! python3 -c "import llama_cpp.server" 2>/dev/null; then')
runner_lines.append(' echo "llama-server build failed — installing Python bindings as fallback..."')
runner_lines.append(f" {_pip_install_fallback_chain('llama-cpp-python[server]', python_cmd='pip')} || true")
runner_lines.append(' fi')
runner_lines.append(' if ! command -v llama-server &>/dev/null && ! python3 -c "import llama_cpp" 2>/dev/null; then')
runner_lines.append(' echo "ERROR: llama.cpp serving is not available after install/build attempts."')
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
runner_lines.append(' fi')
_append_llama_server_finalization_lines(runner_lines)
runner_lines.append('fi')
elif re.search(r"\bollama\s+serve\b", req.cmd):
handled_ollama_serve = True
@@ -4343,7 +4302,7 @@ def setup_cookbook_routes() -> APIRouter:
tmux output can stop at a stale progress line if the pane/session
disappears before Cookbook captures the final DOWNLOAD_OK marker.
In that case, trust the cache shape: a snapshot directory with files
and no *.incomplete blobs means HuggingFace finished materializing the
and no unfinished blobs means HuggingFace finished materializing the
model. cache_root is the task's custom download dir — the runner
pointed HF_HOME there, so the cache lives under <cache_root>/hub,
not wherever this probe's environment says.
@@ -4368,7 +4327,8 @@ def setup_cookbook_routes() -> APIRouter:
"""Best-effort check for resumable HF partial blobs.
A lost SSH/tmux session can leave a real download still incomplete.
Treat any *.incomplete blob as stronger evidence than stale
Treat unfinished blobs (excluding empty markers paired with a
completed blob) as stronger evidence than stale
"100%" lines in the captured pane output.
"""
if not repo_id or "/" not in repo_id:
+3 -1
View File
@@ -781,9 +781,11 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
except Exception as e:
logger.warning("Failed to parse export request body, defaulting to empty", exc_info=e)
data = {}
ids = data.get("ids") or []
ids = data.get("ids") if isinstance(data, dict) else None
if not ids:
raise HTTPException(400, "No documents specified")
if not isinstance(ids, list) or not all(isinstance(i, str) for i in ids):
raise HTTPException(400, "ids must be a list of document ids")
_ext = {
"javascript": ".js", "python": ".py", "html": ".html", "css": ".css",
"markdown": ".md", "json": ".json", "yaml": ".yml", "bash": ".sh",
+3 -2
View File
@@ -19,9 +19,10 @@ def _split_family_token(token):
def _derive_family(filename):
"""Derive a font-family name from a filename like 'JetBrainsMono-Regular.woff2' → 'JetBrains Mono'."""
name = os.path.splitext(filename)[0]
# Strip common weight/style suffixes
# Strip weight/style suffixes, including BoldItalic and Bold-Italic.
name = re.sub(
r'[-_ ]?(Thin|ExtraLight|UltraLight|Light|Regular|Medium|SemiBold|DemiBold|Bold|ExtraBold|UltraBold|Black|Heavy|Italic|Oblique|Variable|VF)$',
r'[-_ ]?(Thin|ExtraLight|UltraLight|Light|Regular|Medium|SemiBold|DemiBold|Bold|ExtraBold|UltraBold|Black|Heavy|Italic|Oblique|Variable|VF)'
r'(?:[-_ ]?(?:Italic|Oblique))?$',
'', name, flags=re.IGNORECASE
)
# Replace dashes/underscores with spaces
+6 -11
View File
@@ -2433,11 +2433,10 @@ def setup_gallery_routes() -> APIRouter:
image_b64 = body.get("image")
amount = body.get("amount", 50) / 100.0
from PIL import Image, ImageFilter
from PIL import ImageFilter
import base64, io
img_bytes = _b64_bytes(image_b64)
img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
img = _b64_to_pil_image(image_b64, mode="RGB")
# Unsharp mask: radius=2, percent=amount*200, threshold=3
sharpened = img.filter(ImageFilter.UnsharpMask(radius=2, percent=int(amount * 200), threshold=3))
@@ -2472,8 +2471,7 @@ def setup_gallery_routes() -> APIRouter:
except ImportError:
raise HTTPException(500, "Server missing a required dependency")
# Decode source image (RGB; Real-ESRGAN doesn't preserve alpha).
img_bytes = _b64_bytes(image_b64)
src = Image.open(io.BytesIO(img_bytes)).convert("RGB")
src = _b64_to_pil_image(image_b64, mode="RGB")
try:
patch_realesrgan_torchvision_compat()
from realesrgan import RealESRGANer
@@ -2525,8 +2523,7 @@ def setup_gallery_routes() -> APIRouter:
import numpy as np
except ImportError:
raise HTTPException(500, "Server missing a required dependency")
img_bytes = _b64_bytes(image_b64)
src = Image.open(io.BytesIO(img_bytes)).convert("RGB")
src = _b64_to_pil_image(image_b64, mode="RGB")
try:
patch_realesrgan_torchvision_compat()
from basicsr.archs.rrdbnet_arch import RRDBNet
@@ -2778,8 +2775,7 @@ def setup_gallery_routes() -> APIRouter:
from PIL import Image
import base64, io
img_bytes = _b64_bytes(image_b64)
img = Image.open(io.BytesIO(img_bytes)).convert("RGBA")
img = _b64_to_pil_image(image_b64)
W, H = img.size
hint = None
@@ -2868,8 +2864,7 @@ def setup_gallery_routes() -> APIRouter:
from PIL import Image, ImageFilter, ImageEnhance
import numpy as np
img_bytes = _b64_bytes(image_b64)
img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
img = _b64_to_pil_image(image_b64, mode="RGB")
# Try GFPGAN first (AI face restoration)
try:
+12 -9
View File
@@ -728,32 +728,35 @@ def setup_note_routes(task_scheduler=None, upload_handler=None):
)
if body.title is not None:
note.title = body.title
if body.content is not None:
# Omitted fields preserve their values; explicit null clears the
# nullable fields used by the editor's removal controls.
supplied = body.model_fields_set
if "content" in supplied:
note.content = body.content
if body.items is not None:
note.items = json.dumps(body.items)
if "items" in supplied:
note.items = json.dumps(body.items) if body.items is not None else None
flag_modified(note, "items")
if body.note_type is not None:
note.note_type = body.note_type
if body.color is not None:
if "color" in supplied:
note.color = body.color
if body.label is not None:
if "label" in supplied:
note.label = body.label
if body.pinned is not None:
note.pinned = body.pinned
if body.archived is not None:
note.archived = body.archived
if body.due_date is not None:
if "due_date" in supplied:
note.due_date = body.due_date
if body.image_url is not None:
if "image_url" in supplied:
note.image_url = body.image_url
if body.gallery_id is not None:
if "gallery_id" in supplied:
note.gallery_id = body.gallery_id
if body.repeat is not None:
note.repeat = body.repeat
if body.sort_order is not None:
note.sort_order = body.sort_order
if body.agent_session_id is not None:
if "agent_session_id" in supplied:
note.agent_session_id = body.agent_session_id
db.commit()
+7 -40
View File
@@ -1315,7 +1315,7 @@ def setup_session_routes(
def auto_sort_sessions(request: Request, skip_llm: bool = False):
"""Use AI to categorize all sessions into folders.
Phase 1 deletes empty/throwaway sessions and Phase 2 asks the LLM
Phase 1 deletes empty/Incognito sessions and Phase 2 asks the LLM
to assign folders. When `skip_llm=true` the endpoint returns
after Phase 1 — used by the "Tidy (no AI)" UI affordance so
users can clean junk without spending tokens.
@@ -1326,21 +1326,11 @@ def setup_session_routes(
single_user_mode = not user and _auth_disabled()
user_sessions = session_manager.get_sessions_for_user(user)
# Delete empty and throwaway sessions before sorting
# Saved conversations are never disposable based on their title or prompt.
from core.database import ChatMessage as DbMsg
db = SessionLocal()
deleted_empty = 0
deleted_throwaway = 0
# Names that indicate a throwaway/test session (case-insensitive exact or prefix match)
_THROWAWAY_NAMES = {
"test", "testing", "asdf", "asd", "hello", "hi", "hey",
"yo", "sup", "hola", "hii", "hiii", "heyo",
"foo", "bar", "baz", "tmp", "temp", "scratch", "untitled",
"new chat", "delete", "remove", "junk", "trash", "xxx",
"abc", "qwerty", "blah", "stuff", "whatever", "idk",
"ok", "lol", "bruh", "hmm", "hm", "meh",
}
_THROWAWAY_MAX_MESSAGES = 4 # only delete if <= this many messages
try:
rows_q = db.query(DbSession).filter(DbSession.archived == False)
if user:
@@ -1349,15 +1339,11 @@ def setup_session_routes(
rows_q = rows_q.filter(DbSession.owner == user)
rows = rows_q.limit(2000).all()
folder_map = {r.id: r.folder for r in rows}
# Precompute per-session message counts in TWO aggregate queries
# Precompute per-session message counts in one aggregate query
# instead of 1–3 queries PER session — with many chats the per-row
# loop was doing thousands of round-trips and blowing the timeout.
from sqlalchemy import func as _sa_func
_counts = dict(db.query(DbMsg.session_id, _sa_func.count(DbMsg.id)).group_by(DbMsg.session_id).all())
_asst_counts = dict(
db.query(DbMsg.session_id, _sa_func.count(DbMsg.id))
.filter(DbMsg.role == "assistant").group_by(DbMsg.session_id).all()
)
cleanup_now = utcnow_naive()
for row in rows:
# Never delete important sessions
@@ -1367,9 +1353,10 @@ def setup_session_routes(
if (row.name or "").strip() == "Incognito":
should_delete = True
deleted_throwaway += 1
db.delete(row)
if hasattr(session_manager, 'delete_session'):
session_manager.delete_session(row.id)
else:
db.delete(row)
continue
if is_session_recently_active(row, now=cleanup_now):
continue
@@ -1378,31 +1365,11 @@ def setup_session_routes(
if msg_count == 0:
should_delete = True
deleted_empty += 1
elif msg_count <= _THROWAWAY_MAX_MESSAGES:
name = (row.name or "").strip().lower()
# Check first user message content (AI renames sessions, so
# "hi" becomes "Casual Greeting Exchange" — name alone won't match)
first_msg = db.query(DbMsg.content).filter(
DbMsg.session_id == row.id, DbMsg.role == "user"
).order_by(DbMsg.timestamp).first()
first_text = (first_msg[0] or "").strip().lower() if first_msg else ""
# Count assistant messages — if user sent something but AI never replied, it's dead
assistant_count = _asst_counts.get(row.id, 0)
if name in _THROWAWAY_NAMES or name.startswith("chat:") or first_text in _THROWAWAY_NAMES:
should_delete = True
deleted_throwaway += 1
# Single user message with no AI response = dead session
elif msg_count == 1 and assistant_count == 0:
should_delete = True
deleted_throwaway += 1
# Short phrase (1-3 words) with no real AI conversation (<=2 msgs)
elif msg_count <= 2 and first_text and len(first_text.split()) <= 3 and len(first_text) <= 40:
should_delete = True
deleted_throwaway += 1
if should_delete:
db.delete(row)
if hasattr(session_manager, 'delete_session'):
session_manager.delete_session(row.id)
else:
db.delete(row)
if deleted_empty or deleted_throwaway:
db.commit()
logger.info(f"Auto-sort: deleted {deleted_empty} empty + {deleted_throwaway} throwaway sessions")
+3 -9
View File
@@ -20,7 +20,7 @@ from src.task_action_policy import (
is_admin_only_task_action,
owner_has_admin_task_privileges,
)
from src.task_scheduler import compute_next_run, HOUSEKEEPING_DEFAULTS
from src.task_scheduler import compute_next_run, is_valid_cron, HOUSEKEEPING_DEFAULTS
from routes.prefs_routes import _load_for_user, _save_for_user
logger = logging.getLogger(__name__)
@@ -475,10 +475,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
if req.trigger_type == "schedule" and req.schedule == "cron" and not req.cron_expression:
raise HTTPException(400, "Cron expression is required for cron schedule")
if req.trigger_type == "schedule" and req.schedule == "cron" and req.cron_expression:
try:
from croniter import croniter
croniter(req.cron_expression)
except Exception:
if not is_valid_cron(req.cron_expression):
raise HTTPException(400, "Invalid cron expression")
if req.trigger_type == "event" and not req.trigger_event:
raise HTTPException(400, "Event name is required for event-triggered tasks")
@@ -788,10 +785,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
task.character_id = req.character_id or None
if req.cron_expression is not None:
if req.cron_expression:
try:
from croniter import croniter
croniter(req.cron_expression)
except Exception:
if not is_valid_cron(req.cron_expression):
raise HTTPException(400, "Invalid cron expression")
task.cron_expression = req.cron_expression or None
+9 -2
View File
@@ -6,6 +6,7 @@ that are awkward at the shell). Filters images by tag, prompt, favorite,
album, and surfaces EXIF metadata.
odysseus-gallery list --limit 20 --favorites
odysseus-gallery list --tag "sunset,beach" # all tags, manual or AI
odysseus-gallery show IMAGE_ID
odysseus-gallery albums | jq -r '.[] | .name'
odysseus-gallery search "sunset" # matches prompt + tags
@@ -83,7 +84,13 @@ def cmd_list(args):
fail(f"no album named {args.album!r}")
q = q.filter(GalleryImage.album_id == al.id)
if args.tag:
q = q.filter(GalleryImage.tags.ilike(f"%{args.tag}%"))
for tag in (value.strip() for value in args.tag.split(",")):
if not tag:
continue
q = q.filter(
GalleryImage.tags.ilike(f"%{tag}%") |
GalleryImage.ai_tags.ilike(f"%{tag}%")
)
q = q.order_by(GalleryImage.created_at.desc()).limit(args.limit)
emit([_serialize_image(i) for i in q.all()], args)
finally:
@@ -159,7 +166,7 @@ def _build_parser():
pl.add_argument("--limit", type=int, default=50)
pl.add_argument("--favorites", action="store_true")
pl.add_argument("--album", help="filter by album name")
pl.add_argument("--tag", help="substring match against tags column")
pl.add_argument("--tag", help="comma-separated tags; each matches manual or AI tags by substring")
pl.set_defaults(func=cmd_list)
psh = sub.add_parser("show", parents=[common])
+12 -6
View File
@@ -43,7 +43,7 @@ import logging
import os
import re
import sys
from datetime import datetime
from datetime import datetime, timezone
from pathlib import Path
# Anchor the path so the script works when invoked from anywhere.
@@ -136,7 +136,7 @@ def cmd_list(args) -> None:
emit([], args)
return
all_uids = data[0].split()
# Newest first, limit
# Keep a bounded arrival batch; order its decoded rows by Date below.
uids = list(reversed(all_uids))[: args.limit]
out = []
for uid in uids:
@@ -165,10 +165,15 @@ def cmd_list(args) -> None:
sender_name, sender_addr = parseaddr(sender)
date_raw = msg.get("Date", "")
try:
iso = parsedate_to_datetime(date_raw).isoformat() if date_raw else ""
parsed_date = parsedate_to_datetime(date_raw) if date_raw else None
iso = parsed_date.isoformat() if parsed_date else ""
if parsed_date is not None and parsed_date.tzinfo is None:
parsed_date = parsed_date.replace(tzinfo=timezone.utc)
date_epoch = parsed_date.timestamp() if parsed_date else float("-inf")
except Exception:
iso = ""
out.append({
date_epoch = float("-inf")
out.append((date_epoch, {
"uid": uid.decode(),
"date": iso,
"from_addr": sender_addr,
@@ -176,10 +181,11 @@ def cmd_list(args) -> None:
"subject": subject,
"is_read": "\\Seen" in flags,
"is_answered": "\\Answered" in flags,
})
}))
except Exception as e:
sys.stderr.write(f"warn: skipping uid {uid!r}: {e}\n")
emit(out, args)
out.sort(key=lambda item: item[0], reverse=True)
emit([row for _, row in out], args)
# ─── read ────────────────────────────────────────────────────────────
+36 -6
View File
@@ -11,6 +11,38 @@ import time
from urllib.request import urlopen
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
def _stop_owned_server(server, identity):
"""Close only identity-bound descendants, including MCP's new sessions.
Capture before signalling the parent: an orphaned stderr holder can no
longer be attributed by ancestry. Never search by executable name or kill
the caller's process group.
"""
from src import process_lifecycle as lifecycle
descendants = (lifecycle.bind_descendants([identity.pid])
if identity.verdict() == lifecycle.OWNED else [])
if os.name == "nt":
if identity.verdict() == lifecycle.OWNED:
lifecycle.taskkill_tree(identity.pid)
else:
outcome = lifecycle.terminate_identities(
[*(entry.identity for entry in reversed(descendants)), identity],
steps=lifecycle.term_kill_steps(10),
)
if outcome.survivors or outcome.unverified:
raise RuntimeError(f"Disposable Notes cleanup could not verify exit: {outcome}")
try:
logs, _ = server.communicate(timeout=2)
except subprocess.TimeoutExpired as error:
# Do not turn an unidentified pipe holder into an unbounded wait.
server.stdout.close()
raise RuntimeError("Disposable Notes output pipe remained open after owned cleanup") from error
return logs
def main():
@@ -36,7 +68,10 @@ def main():
server = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app:app", "--host", "127.0.0.1", "--port", str(port)],
cwd=target, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
start_new_session=(os.name != "nt"),
)
from src.process_lifecycle import ProcessIdentity
server_identity = ProcessIdentity.capture(server.pid)
result = None
try:
origin = f"http://127.0.0.1:{port}"
@@ -62,12 +97,7 @@ def main():
print(result.stderr, file=sys.stderr)
return result.returncode
finally:
server.terminate()
try:
logs, _ = server.communicate(timeout=10)
except subprocess.TimeoutExpired:
server.kill()
logs, _ = server.communicate()
logs = _stop_owned_server(server, server_identity)
if result is None or result.returncode:
print(logs[-8000:], file=sys.stderr)
+20 -2
View File
@@ -202,17 +202,35 @@ def _infer_use_case(repo_id, pipeline_tag):
return "general"
# HF collections mix in models no Cookbook LLM backend can serve (vision
# encoders, time-series forecasters, image/video generators). Image models
# have their own registry, so keep these out of the LLM catalog.
_NON_LLM_PIPELINE_TAGS = frozenset({
"time-series-forecasting",
"zero-shot-image-classification",
"image-classification",
"image-feature-extraction",
"text-to-image",
"image-to-video",
"text-to-video",
})
def _entry_from_collection_item(collection, item, source):
repo_id = item.get("id") or ""
if item.get("type") != "model" or not repo_id:
return None
if (item.get("pipeline_tag") or "").strip().lower() in _NON_LLM_PIPELINE_TAGS:
return None
repo_prefix = source.get("repo_prefix")
if repo_prefix and not repo_id.startswith(repo_prefix):
return None
raw_params = item.get("numParameters") or 0
active = None
# Read the name even when HF gives a total: it is the only source of the
# MoE active size (e.g. "-35B-A3B"), which the speed estimate depends on.
name_params, active = _parse_params_from_name(repo_id)
if not raw_params:
raw_params, active = _parse_params_from_name(repo_id)
raw_params = name_params
param_label, raw_params = _format_params(raw_params)
if not raw_params:
return None
+6 -2
View File
@@ -84,10 +84,14 @@ _FM_KEY_RE = re.compile(r"^([a-z_][a-z0-9_]*):\s*(.*)$", re.IGNORECASE)
_FM_BLOCK_LIST_RE = re.compile(r"^\s*-\s*(.*)$")
def _parse_scalar(raw: str) -> Any:
def _parse_scalar(raw: str, *, string_only: bool = False) -> Any:
raw = raw.strip()
if raw == "":
return ""
# String fields keep their spelling; quoted values still use the decoder
# below so existing escapes round-trip as before.
if string_only and not (raw[0] == raw[-1] and raw[0] in ("'", '"')):
return raw
if raw.startswith("[") and raw.endswith("]"):
inner = raw[1:-1].strip()
if not inner:
@@ -171,7 +175,7 @@ def parse_frontmatter(text: str) -> tuple[Dict[str, Any], str]:
pending_key = key
fm[key] = []
else:
fm[key] = _parse_scalar(val)
fm[key] = _parse_scalar(val, string_only=(key == "version"))
pending_key = None
continue
m2 = _FM_BLOCK_LIST_RE.match(line)
+8 -8
View File
@@ -1207,7 +1207,7 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
Line 1: prompt describing the image
Line 2: model name (optional, default auto-detects: prefers gpt-image-1.5 > gpt-image-1)
Line 3: size (optional, defaults to 1024x1024)
Line 4: quality (optional, defaults to medium — options: low, medium, high, auto)
Line 4: quality (optional, defaults to configured quality or medium — low, medium, high, auto)
"""
import base64
import httpx
@@ -1225,13 +1225,13 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
prompt = str(args.get('prompt') or '').strip()
model_spec = str(args.get('model') or '').strip()
size = str(args.get('size') or '1024x1024')
quality = str(args.get('quality') or 'medium')
quality = str(args.get('quality') or '')
else:
lines = content.strip().split("\n")
prompt = lines[0].strip() if lines else ""
model_spec = lines[1].strip() if len(lines) > 1 and lines[1].strip() else ""
size = lines[2].strip() if len(lines) > 2 and lines[2].strip() else "1024x1024"
quality = lines[3].strip() if len(lines) > 3 and lines[3].strip() else "medium"
quality = lines[3].strip() if len(lines) > 3 and lines[3].strip() else ""
if not prompt:
return {"error": "Image prompt is required (line 1)"}
@@ -1249,8 +1249,8 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
# Use admin-configured model/quality if not specified by the tool call
if not model_spec:
model_spec = _settings.get("image_model", "")
if quality == "medium" and _settings.get("image_quality"):
quality = _settings["image_quality"]
if not quality:
quality = _settings.get("image_quality") or "medium"
# Auto-detect best available image model if still not set
if not model_spec:
@@ -1482,7 +1482,7 @@ async def do_edit_image(
session_id: Optional[str] = None,
owner: Optional[str] = None,
size: str = "auto",
quality: str = "medium",
quality: Optional[str] = None,
progress_callback: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None,
incognito: bool = False,
) -> Dict:
@@ -1509,8 +1509,8 @@ async def do_edit_image(
if not model_spec:
model_spec = _settings.get("image_model", "")
if quality == "medium" and _settings.get("image_quality"):
quality = _settings["image_quality"]
if quality is None:
quality = _settings.get("image_quality") or "medium"
if not model_spec:
return {"error": "No image model selected for image editing"}
+3 -2
View File
@@ -25,7 +25,7 @@ def compact_browser_observation(value, budget=8000):
# Snapshot text already contains labels and refs in DOM order.
# The refs mapping repeats them and buries menus in raw JSON.
lines = [line for line in snapshot.splitlines()
if not re.fullmatch(r'\s*-?\s*generic(?:\s+\[ref=e\d+\])?:?\s*', line)]
if not re.fullmatch(r'\s*(?:-\s*)?generic(?:\s+\[ref=e\d+\])?:?\s*', line)]
pages.append(('URL: ' + str(url) + '\n' if url else '') + '\n'.join(lines))
elif url:
notices.append('URL: ' + str(url))
@@ -37,7 +37,8 @@ def compact_browser_observation(value, budget=8000):
parsed = json.loads(item)
except (ValueError, TypeError):
# CLI status text precedes the JSON post-interaction state.
parts = re.split(r'\n\n\[(?:post-[^\]]+|page state after failed [^\]]+)\]\n', item)
# Labels are one line; stopping at \n keeps the split linear.
parts = re.split(r'\n\n\[(?:post-[^\]\n]+|page state after failed [^\]\n]+)\]\n', item)
if len(parts) > 1:
for part in parts:
visit(part)
+14 -8
View File
@@ -1229,7 +1229,7 @@ def tasks_terminal_response(raw, *, user_text='', max_items=20):
text = str(payload or '').strip()
rows = []
for line in text.splitlines():
match = re.match(r'^\s*\d+\.\s+(.*?)\s+\(([^)]+)\)\s+—\s+(.+)$', line)
match = re.match(r'^\s*\d+\.\s(.*?)\s\(([^()]+)\)\s+—\s+(.+)$', line)
if match:
details = match[3].strip()
rows.append({
@@ -1346,7 +1346,7 @@ def skills_terminal_response(raw, *, user_text='', max_items=20):
continue
match = re.match(r'^\s*-\s+\*\*([^*]+)\*\*(?:\s+\(([^)]+)\))?', line)
if not match:
match = re.match(r'^\s*-\s+([^:]+?)(?:\s+\(([^)]+)\))?(?:\s*:|$)', line)
match = re.match(r'^\s*-\s+([^\s:][^:]*?)(?:\s\(([^()]+)\)(?:\s*:|$)|:|$)', line)
if match and status:
rows.append((status, match.group(1).strip(), (match.group(2) or '').strip()))
if not rows:
@@ -5065,7 +5065,7 @@ def email_search_result_empty(value):
try:
decoded = json.loads(value)
except (TypeError, ValueError):
return bool(re.fullmatch(r'\s*No emails matched [^\n]+\.?\s*', value, re.I))
return bool(re.fullmatch(r'\s*No emails matched [^\n]+(?:\n\s*)?', value, re.I))
if decoded == value:
return False
value = decoded
@@ -5339,6 +5339,16 @@ def private_browser_effective_url(result):
return urls[-1] if urls else ''
def is_google_sorry_url(url):
"""Recognize Google's rate-limit interstitial."""
# Matching the host separately keeps this linear; one combined pattern
# rescans the host from every dot.
return any(
re.fullmatch(r'(?:[^/]+\.)?google\.[^/]+', host[1], re.I)
for host in re.finditer(r'https?://([^/]+)/sorry/', url, re.I)
)
def browser_observation_page_missing(raw):
"""Recognize a rendered error page, rather than an article mentioning 404."""
text = str(raw or '')
@@ -8213,11 +8223,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
browser_search_url = requested_browser_url or effective_browser_url
is_search_engine_navigation = contains_search_engine_navigation(
browser_search_url
) or bool(re.search(
r'https?://(?:[^/]+\.)?google\.[^/]+/sorry/',
effective_browser_url,
re.I,
))
) or is_google_sorry_url(effective_browser_url)
has_native_search = any(
canonical(schema['function']['name']) == 'web_search'
for schema in offered
+49 -11
View File
@@ -422,7 +422,18 @@ def _stream_delta_event(text: str, *, thinking: bool = False) -> str:
return f"data: {json.dumps(payload)}\n\n"
_DEGENERATE_WORD_RE = re.compile(r"[A-Za-z0-9_\u0370-\u03ff\u0400-\u04ff]+")
_WORD_CHARS = r"A-Za-z0-9_\u0370-\u03ff\u0400-\u04ff"
_DELIM_RE = re.compile(rf"[^{_WORD_CHARS}+\-.]")
_NUMERIC_PATTERN = rf"(?<![{_WORD_CHARS}])[+-]?(?:[0-9]+\.[0-9]+|\.[0-9]+|[0-9]+)(?:[eE][+-]?[0-9]+)?(?![{_WORD_CHARS}])"
_NUMERIC_FULLMATCH_RE = re.compile(rf"^[+-]?(?:[0-9]+\.[0-9]+|\.[0-9]+|[0-9]+)(?:[eE][+-]?[0-9]+)?$")
_DEGENERATE_WORD_RE = re.compile(rf"[{_WORD_CHARS}]+")
_TOKEN_RE = re.compile(rf"(?P<NUM>{_NUMERIC_PATTERN})|(?P<WORD>[{_WORD_CHARS}]+)")
def _is_numeric_token(token: str) -> bool:
if token.isdigit():
return True
return _NUMERIC_FULLMATCH_RE.fullmatch(token) is not None
class _DegenerateStreamGuard:
@@ -432,32 +443,50 @@ class _DegenerateStreamGuard:
"Summer Summer ..."). This is not a useful response and can burn context,
browser memory, and GPU time. Keep the guard conservative: only fire on long
same-token runs or a very dominant repeated token in the recent window.
Repetition made only of numbers is data (a zero or identity matrix, a
padded buffer), so it only stops at a long all-numeric backstop.
"""
def __init__(self, model: str):
self.model = model or "model"
self.last_token = ""
self.same_run = 0
self.numeric_run = 0
self.recent_tokens: List[str] = []
self.periodic_tokens: List[str] = []
self.total_chars = 0
self._buffer = ""
def check(self, text: str) -> Optional[str]:
if not text:
return None
self.total_chars += len(text)
# Keep one-character tokens. Structured output often varies through
# numeric selectors or coordinates; dropping those tokens can make
# valid CSS/SVG rows look like an exact repeated phrase.
tokens = [t.lower() for t in _DEGENERATE_WORD_RE.findall(text)]
self._buffer += text
m_delims = list(_DELIM_RE.finditer(self._buffer))
if m_delims:
split_idx = m_delims[-1].end()
head = self._buffer[:split_idx]
self._buffer = self._buffer[split_idx:]
elif len(self._buffer) >= 256:
head = self._buffer
self._buffer = ""
else:
return None
tokens = []
for m in _TOKEN_RE.finditer(head):
tokens.append((m.group(0).lower(), m.lastgroup == "NUM"))
if not tokens:
return None
for token in tokens:
for token, is_num in tokens:
if token == self.last_token:
self.same_run += 1
else:
self.last_token = token
self.same_run = 1
self.numeric_run = self.numeric_run + 1 if is_num else 0
self.recent_tokens.append(token)
self.periodic_tokens.append(token)
if len(self.recent_tokens) > 96:
@@ -466,14 +495,20 @@ class _DegenerateStreamGuard:
self.periodic_tokens = self.periodic_tokens[-2048:]
reason = None
if self.same_run >= 28 and self.total_chars >= 100:
if self.same_run >= 28 and self.total_chars >= 100 and not _is_numeric_token(self.last_token):
reason = f"repeated '{self.last_token}' {self.same_run} times"
elif len(self.recent_tokens) >= 72:
top = max(set(self.recent_tokens), key=self.recent_tokens.count)
count = self.recent_tokens.count(top)
if count >= 60 and count / max(len(self.recent_tokens), 1) >= 0.78:
if count >= 60 and count / max(len(self.recent_tokens), 1) >= 0.78 and not _is_numeric_token(top):
reason = f"repeated '{top}' {count}/{len(self.recent_tokens)} recent tokens"
if not reason and len(self.recent_tokens) >= 48:
if (
not reason
and len(self.recent_tokens) >= 48
# A window that is mostly numbers is matrix/table data; its
# periodicity is real. Mixed loops still hit the exact-block check.
and sum(_is_numeric_token(t) for t in self.recent_tokens) * 2 < len(self.recent_tokens)
):
# Detect a periodic suffix instead of counting repeated n-grams.
# Reused property names are normal in CSS/SVG/JSON; a collapsed
# generation repeats nearly every token at a short fixed period.
@@ -508,12 +543,15 @@ class _DegenerateStreamGuard:
if history[previous:previous + anchor_size] != anchor:
continue
period = current_anchor - previous
if period < 24 or len(history) < period * 2:
if period < 24 or len(history) < period * 2 or self.numeric_run >= period:
continue
if history[-2 * period:-period] == history[-period:]:
if history[-2 * period:-period] == history[-period:] and not all(_is_numeric_token(t) for t in history[-period:]):
reason = f"repeated an exact {period}-token block twice"
break
if not reason and self.numeric_run >= 2048:
reason = f"emitted {self.numeric_run} numbers in a row"
if not reason:
return None
+15 -5
View File
@@ -10,6 +10,7 @@ import logging
import os
import re
import asyncio
import contextlib
from typing import Any, Dict, List, Optional, Set, Tuple
from src.database import McpServer, SessionLocal
@@ -383,12 +384,21 @@ class McpManager:
provider = build_provider(server_id, url, on_redirect=_on_redirect)
stack = AsyncExitStack()
transport = await stack.enter_async_context(streamablehttp_client(url, auth=provider))
read_stream, write_stream, _get_session_id = transport
session = await stack.enter_async_context(ClientSession(read_stream, write_stream))
await session.initialize()
try:
transport = await stack.enter_async_context(streamablehttp_client(url, auth=provider))
read_stream, write_stream, _get_session_id = transport
session = await stack.enter_async_context(ClientSession(read_stream, write_stream))
await session.initialize()
tools_result = await session.list_tools()
tools_result = await session.list_tools()
except BaseException:
# Close the half-open transport here, in the task that entered
# it. Left for the garbage collector, its AnyIO cancel scope is
# exited from the wrong task and the event loop keeps spinning
# (#5518). Its own teardown error would only mask the real one.
with contextlib.suppress(Exception):
await stack.aclose()
raise
tools = []
for tool in tools_result.tools:
tools.append({
+4 -1
View File
@@ -193,7 +193,10 @@ class ModelDiscovery:
data = r.json()
# Some OpenAI-compatible servers return a bare list, not {"data": [...]}.
items = data if isinstance(data, list) else ((data or {}).get("data") or [])
ids = [m.get("id") for m in items if isinstance(m, dict) and m.get("id")]
ids = [
m["id"] for m in items
if isinstance(m, dict) and isinstance(m.get("id"), str) and m["id"]
]
if ids:
return {
"host": host,
+4 -2
View File
@@ -102,8 +102,10 @@ _PLAIN_FRONT_MATTER_RE = re.compile(
# - **label:** value <!-- field=NAME-ENC type=text -->
# - **label** [opts]: value <!-- field=NAME-ENC type=choice -->
# - [x] **label** <!-- field=NAME-ENC type=checkbox -->
# Accept Unicode word characters for older saved markers, whose encoder left
# non-ASCII letters/numbers literal. Newly rendered names are ASCII-safe.
_FIELD_BULLET_RE = re.compile(
r'^\s*-\s+(?P<body>.*?)\s*<!--\s*field=(?P<name>[A-Za-z0-9_.%-]+)\s+type=(?P<type>\w+)\s*-->\s*$'
r'^\s*-\s+(?P<body>.*?)\s*<!--\s*field=(?P<name>[\w.%-]+)\s+type=(?P<type>\w+)\s*-->\s*$'
)
@@ -115,7 +117,7 @@ def _encode_name(name: str) -> str:
"""
out = []
for ch in name or "":
if ch.isalnum() or ch in ("_", ".", "-"):
if (ch.isascii() and ch.isalnum()) or ch in ("_", ".", "-"):
out.append(ch)
else:
for b in ch.encode("utf-8"):
+8 -51
View File
@@ -12,16 +12,6 @@ from datetime import datetime, timedelta, timezone
logger = logging.getLogger(__name__)
# Names that indicate a throwaway/test session
_THROWAWAY_NAMES = {
"test", "testing", "asdf", "asd", "hello", "hi", "hey",
"yo", "sup", "hola", "hii", "hiii", "heyo",
"foo", "bar", "baz", "tmp", "temp", "scratch", "untitled",
"new chat", "delete", "remove", "junk", "trash", "xxx",
"abc", "qwerty", "blah", "stuff", "whatever", "idk",
"ok", "lol", "bruh", "hmm", "hm", "meh",
}
_THROWAWAY_MAX_MESSAGES = 4
_FRESH_EMPTY_SESSION_GRACE = timedelta(minutes=10)
_FRESH_SESSION_GRACE = _FRESH_EMPTY_SESSION_GRACE
@@ -54,14 +44,15 @@ def is_session_recently_active(row, now=None, grace=_FRESH_SESSION_GRACE) -> boo
async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bool = True) -> str:
"""Run session cleanup + (optional) AI folder sort for the given owner.
"""Remove empty/Incognito sessions and optionally organize saved chats.
Args:
owner: user whose sessions to process
skip_llm: when True, do only Phase 1 (delete empty/throwaway sessions);
skip_llm: when True, do only Phase 1 (delete empty/Incognito sessions);
skip Phase 2 (AI folder assignment). Used by the built-in daily
background sweep so it never burns LLM tokens.
delete_throwaway: when False, only empty/incognito sessions are deleted.
delete_throwaway: retained for caller compatibility. Ordinary sessions
with saved messages are always preserved, regardless of this flag.
Returns a human-readable summary of what was done.
"""
@@ -71,7 +62,7 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo
db = SessionLocal()
try:
# ── Phase 1: Delete empty/throwaway sessions ──
# ── Phase 1: Delete only empty and explicitly Incognito sessions ──
deleted_empty = 0
deleted_throwaway = 0
@@ -93,47 +84,13 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo
if is_session_recently_active(row, now=cleanup_now):
continue
msg_count = db.query(DbMsg.id).filter(
has_messages = db.query(DbMsg.id).filter(
DbMsg.session_id == row.id
).limit(_THROWAWAY_MAX_MESSAGES + 1).count()
should_delete = False
if msg_count == 0:
).first() is not None
if not has_messages:
if is_fresh:
continue
should_delete = True
deleted_empty += 1
elif delete_throwaway and msg_count <= _THROWAWAY_MAX_MESSAGES:
name = (row.name or "").strip().lower()
first_msg = db.query(DbMsg.content).filter(
DbMsg.session_id == row.id, DbMsg.role == "user"
).order_by(DbMsg.timestamp).first()
first_text = (first_msg[0] or "").strip().lower() if first_msg else ""
assistant_count = db.query(DbMsg.id).filter(
DbMsg.session_id == row.id, DbMsg.role == "assistant"
).limit(1).count()
if name in _THROWAWAY_NAMES or name.startswith("chat:") or first_text in _THROWAWAY_NAMES:
should_delete = True
deleted_throwaway += 1
elif msg_count == 1 and assistant_count == 0:
should_delete = True
deleted_throwaway += 1
elif msg_count <= 4 and first_text and len(first_text.split()) <= 8 and len(first_text) <= 80:
# Short trivial chats — e.g. "write hi to a friend" → "Hi!"
should_delete = True
deleted_throwaway += 1
else:
# Aggressive: total message text under 250 chars combined = trivial
msg_rows = db.query(DbMsg.content).filter(
DbMsg.session_id == row.id
).all()
total_chars = sum(len(m[0] or "") for m in msg_rows)
if total_chars <= 250:
should_delete = True
deleted_throwaway += 1
if should_delete:
db.delete(row)
if deleted_empty or deleted_throwaway:
+22 -2
View File
@@ -132,6 +132,23 @@ async def _cached(key: Tuple, ttl: float, fetch: Callable[[], Awaitable[Any]]) -
_shared_cache_pending.pop(key, None)
def is_valid_cron(cron_expression: str) -> bool:
"""True for a standard 5-field cron expression that croniter can parse.
croniter also accepts 6- and 7-field expressions with a seconds field, but
the scheduler works at one-minute resolution and would run such a task
every second.
"""
if len((cron_expression or "").split()) != 5:
return False
try:
from croniter import croniter
croniter(cron_expression)
except Exception:
return False
return True
def compute_next_run(schedule: str, scheduled_time: str,
scheduled_day: int = None,
scheduled_date: datetime = None,
@@ -175,6 +192,9 @@ def compute_next_run(schedule: str, scheduled_time: str,
return dt.astimezone(timezone.utc).replace(tzinfo=None)
if schedule == "cron" and cron_expression:
if not is_valid_cron(cron_expression):
logger.warning(f"Invalid cron expression '{cron_expression}'")
return None
try:
from croniter import croniter
cron = croniter(cron_expression, now)
@@ -306,7 +326,7 @@ def _digest_windows(now):
def _checkin_calendar_events(db, owner, start, end):
"""Calendar events in [start, end] for ONE owner, for the check-in digest.
"""Calendar events in [start, end) for ONE owner, for the check-in digest.
Ownership lives on CalendarCal.owner; events inherit it via calendar_id.
The digest query had no owner scope, so it pulled EVERY user's events into
@@ -320,7 +340,7 @@ def _checkin_calendar_events(db, owner, start, end):
.filter(
_CC.owner == owner,
_CE.dtstart >= start,
_CE.dtstart <= end,
_CE.dtstart < end,
_CE.status != "cancelled",
)
.order_by(_CE.dtstart)
+1
View File
@@ -41,6 +41,7 @@ _REQUIRED_NATIVE_TOOL_ARGS = {
"write_file": ("path",),
"edit_file": ("path",),
"apply_patch": ("patch_text", "patchText", "patch"),
"generate_image": ("prompt",),
"host_shell": ("command",),
}
+3 -1
View File
@@ -4592,7 +4592,9 @@ body.bg-pattern-ascii-fireflies {
width:100%; background:var(--bg); color:var(--fg);
border:1px solid var(--border); border-radius:6px;
padding:8px; font-family:inherit; font-size:0.95rem;
resize:vertical; outline:none;
resize:both; outline:none;
field-sizing:content;
max-width:100%; box-sizing:border-box;
min-height:80px;
}
.edit-textarea:focus { border-color:var(--red); }
+6 -3
View File
@@ -2690,11 +2690,14 @@ body.theme-frosted .modal {
max-height: 95dvh !important;
}
/* Notes and Documents must respect the same viewport and safe-area
top as other mobile full-height sheets. Keep these authoritative rules
consistent with 05-documents.css and 14-email.css. */
body.doc-view .doc-editor-pane,
body.notes-view .notes-pane {
inset: auto 0 0 !important;
height: 95dvh !important;
max-height: 95dvh !important;
inset: var(--sheet-top) 0 0 !important;
height: calc(var(--app-height) - var(--sheet-top)) !important;
max-height: calc(var(--app-height) - var(--sheet-top)) !important;
}
}
-7
View File
@@ -16,13 +16,6 @@
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
<script nonce="{{CSP_NONCE}}">
window._odysseusLoadTime = Date.now();
// Stops iOS focus-zoom on every field; iOS still allows pinch-zoom, other platforms would not.
(function(){
var ua = navigator.userAgent || '';
var isIOS = /iPad|iPhone|iPod/.test(ua) || (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1);
var vp = document.querySelector('meta[name="viewport"]');
if (isIOS && vp) vp.setAttribute('content', vp.getAttribute('content') + ', maximum-scale=1');
})();
(function(){
try {
var t = JSON.parse(localStorage.getItem('odysseus-theme'));
+7 -1
View File
@@ -771,7 +771,13 @@ function _getModal() {
<button class="cal-side-nav cal-side-nav-next" id="cal-side-next" title="Next" aria-label="Next">&rsaquo;</button>
</div>`;
document.body.appendChild(_modal);
_modal.querySelector('#cal-close').addEventListener('click', closeCalendar);
_modal.querySelector('#cal-close').addEventListener('click', () => {
if (_modal.querySelector('.cal-form')) {
_render();
return;
}
closeCalendar();
});
_modal.querySelector('#cal-side-prev')?.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
+65
View File
@@ -0,0 +1,65 @@
function streamError(payload) {
const detail = payload?.data ?? payload;
const candidates = [detail?.message, detail?.text, detail?.error?.message,
detail?.error, payload?.message, payload?.text, payload?.error?.message,
payload?.error, typeof payload === 'string' ? payload : null];
const message = candidates.find(value => typeof value === 'string' && value.trim());
return new Error(message || 'Model request failed.');
}
/** Decode complete SSE frames, requiring the backend's terminal DONE event. */
export async function consumeCompareEvents(response, onEvent, onActivity) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let line = '', event = 'message', data = [], skipLF = false;
try {
while (true) {
const { done, value } = await reader.read();
if (!done) onActivity?.();
let chunk = decoder.decode(value, { stream: !done });
if (skipLF && chunk.length) {
if (chunk.startsWith('\n')) chunk = chunk.slice(1);
skipLF = false;
}
const separators = /\r\n|\r|\n/g;
let start = 0, separator;
while ((separator = separators.exec(chunk))) {
line += chunk.slice(start, separator.index);
start = separator.index + separator[0].length;
skipLF = separator[0] === '\r' && start === chunk.length;
if (!line) {
if (data.length || event === 'error') {
const raw = data.join('\n');
if (event !== 'error' && raw === '[DONE]') return;
let payload;
try { payload = JSON.parse(raw); }
catch (_) {
if (event === 'error') throw streamError(raw);
throw new Error('Invalid model stream data.');
}
if (event === 'error' || payload?.type === 'error') throw streamError(payload);
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new Error('Invalid model stream data.');
}
await onEvent(payload);
}
event = 'message';
data = [];
} else if (!line.startsWith(':')) {
const colon = line.indexOf(':');
const field = colon < 0 ? line : line.slice(0, colon);
let value = colon < 0 ? '' : line.slice(colon + 1);
if (value.startsWith(' ')) value = value.slice(1);
if (field === 'data') data.push(value);
else if (field === 'event') event = value || 'message';
}
line = '';
}
line += chunk.slice(start);
if (done) throw new Error('Stream ended before completion.');
}
} finally {
try { await reader.cancel(); } catch (_) {}
reader.releaseLock();
}
}
+12 -22
View File
@@ -1,5 +1,6 @@
// compare/stream.js — SSE streaming to panes
import state from './state.js';
import { consumeCompareEvents } from './sse.js';
import { addFinishBadge } from './vote.js?v=20260828resendcaldrag1';
import { getModelCost, renderAskUserCard, safeDisplayImageSrc } from '../chatRenderer.js?v=20260914metricssummary1';
import markdownModule from '../markdown.js';
@@ -472,25 +473,9 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
_resetIdleTimeout(); // any chunk = stream is alive
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6);
if (data === '[DONE]') break;
await consumeCompareEvents(response,
(json) => {
try {
const json = JSON.parse(data);
if (json.type === 'metrics') {
metrics = json.data;
@@ -672,7 +657,7 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
if (hist) hist.scrollTop = hist.scrollHeight;
} else if (json.delta) {
// Skip text deltas if we already rendered an image
if (aiMsgEl._imageData) continue;
if (aiMsgEl._imageData) return;
// Capture TTFT on very first text delta
if (!accumulated && !_ttft) _ttft = performance.now() - _timerStart;
// On first delta, destroy spinner and prepare text area
@@ -701,8 +686,7 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
_scheduleLiveRender(target);
}
} catch (e) { console.warn('Compare stream render error:', e); }
}
}
}, _resetIdleTimeout);
streamOk = true;
// Destroy any remaining spinner
@@ -900,9 +884,15 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
}
} else {
console.error('Compare stream error:', error);
aiBody.innerHTML = '<span style="color:var(--color-error);">Error: ' + escapeHtml(error.message) + '</span>';
awaitingChoice = false;
const message = state._blindMode ? 'Model request failed. Try again.' : error.message;
aiBody.innerHTML = '<span style="color:var(--color-error);">Error: ' + escapeHtml(message) + '</span>';
}
} finally {
if (aiMsgEl._spinner) {
aiMsgEl._spinner.destroy();
aiMsgEl._spinner = null;
}
clearTimeout(timeoutId);
_timerDone = true;
cancelAnimationFrame(_rafId);
+6 -5
View File
@@ -1195,11 +1195,12 @@ async function _fetchDependencies() {
// For backends with a recipe catalog (vllm / sglang / llama_cpp),
// append a caret button that toggles a per-row recipe panel below.
const hasRecipe = RECIPE_BACKENDS.has(pkg.name);
// Standalone recipe-caret button removed — the "Pick install
// command" action lives inside the Installed ▾ dropdown menu
// (see _showDepMenu) so each row only has ONE caret to click.
// Kept the variable so downstream concat code stays the same.
const recipeCaret = '';
// The caret is the only way to open the recipe panel (the Installed ▾
// menu has no recipe item), and the HW Fit / serve-card deep links
// open it by clicking this caret (#6747).
const recipeCaret = hasRecipe
? `<button class="cookbook-dep-tag cookbook-dep-recipe-caret" data-dep-recipe-toggle="${esc(pkg.name)}" title="Pick a model to see the exact install commands" aria-expanded="false" style="background:none;border:1px solid var(--border);padding:2px 6px;display:inline-flex;align-items:center;cursor:pointer;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="transition:transform 0.15s"><polyline points="6 9 12 15 18 9"/></svg></button>`
: '';
const recipePanel = hasRecipe ? _recipePanelHtml(pkg.name) : '';
// When llama_cpp (or any future engine) reports build_deps_missing
// from its system_prereqs probe, surface a one-tap install button
+2 -2
View File
@@ -5232,7 +5232,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
}
async function _handleAttachUpload(e) {
const files = e.target.files;
const files = Array.from(e.target.files || []);
e.target.value = ''; // reset for next upload
await _uploadComposeFiles(files);
}
@@ -5963,7 +5963,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
}
async function _handleMarkdownImageUpload(e) {
const files = e.target.files;
const files = Array.from(e.target.files || []);
e.target.value = '';
await _uploadMarkdownImages(files);
}
+8 -1
View File
@@ -2650,7 +2650,14 @@ export function openEmailLibrary(opts = {}) {
}
// Wire events
document.getElementById('email-lib-close').addEventListener('click', closeEmailLibrary);
document.getElementById('email-lib-close').addEventListener('click', () => {
const modal = document.getElementById('email-lib-modal');
if (modal?.classList.contains('email-reading') || modal?.querySelector('.doclib-card-expanded')) {
_exitEmailReaderModeForList();
return;
}
closeEmailLibrary();
});
document.getElementById('email-settings-header-back')?.addEventListener('click', _hideEmailSettingsPage);
// Clicking the modal header (anywhere except buttons/inputs) collapses
+18 -10
View File
@@ -73,16 +73,24 @@ function _isDangerousSrcset(value) {
// Escape + linkify URLs and email addresses. Returns innerHTML-safe markup.
export function _escLinkify(text) {
const escaped = _esc(text);
// URLs: http(s)://... or www....
const urlRe = /\b((?:https?:\/\/|www\.)[^\s<>"']+[^\s<>"'.,;:!?)\]])/g;
const mailRe = /\b([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})\b/g;
return escaped
.replace(urlRe, (m) => {
const href = m.startsWith('www.') ? `https://${m}` : m;
return `<a href="${_attrEsc(href)}" target="_blank" rel="noopener noreferrer">${m}</a>`;
})
.replace(mailRe, (m) => `<a href="${_attrEsc(`mailto:${m}`)}">${m}</a>`);
const raw = String(text || '');
// Match raw text once so generated anchors are never linkified again.
const tokenRe = /\b((?:https?:\/\/|www\.)[^\s<>"']+[^\s<>"'.,;:!?)\]])|\b([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})\b/g;
const parts = [];
let offset = 0;
for (const match of raw.matchAll(tokenRe)) {
parts.push(_esc(raw.slice(offset, match.index)));
const token = match[0];
const isUrl = !!match[1];
const href = isUrl
? (token.startsWith('www.') ? `https://${token}` : token)
: `mailto:${token}`;
const attrs = isUrl ? ' target="_blank" rel="noopener noreferrer"' : '';
parts.push(`<a href="${_attrEsc(_esc(href))}"${attrs}>${_esc(token)}</a>`);
offset = match.index + token.length;
}
parts.push(_esc(raw.slice(offset)));
return parts.join('');
}
// Pull display name out of "Name <email@x>"; fallback to local-part of
+28 -2
View File
@@ -5759,7 +5759,20 @@ function _updateBrushCursor(e) {
state.cursorEl.style.height = diameterY + 'px';
}
state.cursorEl.classList.toggle('ge-brush-cursor--pixel', pixelBrush);
state.cursorEl.style.transform = `translate3d(${clientX - diameterX / 2}px, ${clientY - diameterY / 2}px, 0)`;
// Updating an inline style on every move invalidates document-wide :has()
// selectors. A paused animation keeps the same transform in the rendering
// pipeline without mutating the cursor's style attribute on every frame.
const cursorTransform = `translate3d(${clientX - diameterX / 2}px, ${clientY - diameterY / 2}px, 0)`;
if (!state.cursorEl._positionAnimation || state.cursorEl._positionAnimation.playState === 'idle') {
state.cursorEl._positionAnimation = state.cursorEl.animate(
[{ transform: cursorTransform }, { transform: cursorTransform }],
{ duration: 1, fill: 'both' },
);
state.cursorEl._positionAnimation.pause();
state.cursorEl._positionAnimation.currentTime = 0;
} else {
state.cursorEl._positionAnimation.effect.setKeyframes([{ transform: cursorTransform }, { transform: cursorTransform }]);
}
if (state.cursorEl.style.display !== 'block') {
state.cursorEl.style.display = 'block';
}
@@ -14061,7 +14074,20 @@ export function closeEditor(options = {}) {
const h = state.editorDocClickHandlers.pop();
try { document.removeEventListener('click', h); } catch {}
}
if (state.cursorEl) { state.cursorEl.remove(); state.cursorEl = null; }
if (_cursorRaf) { cancelAnimationFrame(_cursorRaf); _cursorRaf = null; }
_pendingCursorEvent = null;
_lastCursorRect = null;
state._lastCursorDiameterX = null;
state._lastCursorDiameterY = null;
state._lastCursorBorderColor = null;
if (state.cursorEl) {
if (state.cursorEl._positionAnimation) {
try { state.cursorEl._positionAnimation.cancel(); } catch {}
state.cursorEl._positionAnimation = null;
}
state.cursorEl.remove();
state.cursorEl = null;
}
try { document.getElementById('ge-cursor-layer')?.remove(); } catch {}
// Tear down all floating popups + the dock so closing the editor
// doesn't leave stale chips/panels behind on top of the gallery.
+4 -2
View File
@@ -801,7 +801,8 @@ async function _sendRoundRobin(msg, box) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: [{
role: 'user',
content: `[${m._groupName || m.display}]: ${response}`
content: `[${m._groupName || m.display}]: ${response}`,
metadata: { group_peer: true, group_model: m._groupName || m.display, model: m.mid }
}]}),
});
} catch (e) { console.warn('[group] sync failed:', e); }
@@ -828,7 +829,8 @@ async function _syncAllResponses(holders) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: [{
role: 'user',
content: `[${model._groupName || model.display}]: ${response}`
content: `[${model._groupName || model.display}]: ${response}`,
metadata: { group_peer: true, group_model: model._groupName || model.display, model: model.mid }
}]}),
});
} catch (e) { /* silent */ }
-2
View File
@@ -379,7 +379,6 @@ document.addEventListener('DOMContentLoaded', markComposerUserEdited, { once: tr
root.classList.toggle('keyboard-open', keyboardOpen);
if (keyboardOpen) root.style.setProperty('--keyboard-viewport-height', vv.height + 'px');
else root.style.removeProperty('--keyboard-viewport-height');
if (window.scrollY) window.scrollTo(0, 0);
};
// iOS fires visualViewport resize only once, mid-animation, and no focusin when
// the tapped field already had focus (e.g. the composer after a new chat)
@@ -392,7 +391,6 @@ document.addEventListener('DOMContentLoaded', markComposerUserEdited, { once: tr
window.visualViewport.addEventListener('scroll', _syncKeyboardViewport);
document.addEventListener('focusin', _syncThroughKeyboardAnimation);
document.addEventListener('focusout', _syncThroughKeyboardAnimation);
window.addEventListener('scroll', () => { if (window.scrollY) window.scrollTo(0, 0); }, { passive: true });
let _lastVVHeight = window.visualViewport.height;
window.visualViewport.addEventListener('resize', function() {
+5 -3
View File
@@ -163,11 +163,13 @@ function buildCategoryChips() {
const container = document.getElementById('memory-category-filters');
if (!container) return;
const cats = new Set(memories.map(m => m.category || 'fact'));
if (activeCategory !== 'all' && !cats.has(activeCategory)) activeCategory = 'all';
// Hide the chip row entirely when there are no memories — no point showing
// an "all" chip with nothing to filter.
if (!memories.length) { container.innerHTML = ''; return; }
const cats = new Set(memories.map(m => m.category || 'fact'));
const sorted = ['all', ...Array.from(cats).sort()];
const counts = memories.reduce((result, memory) => {
const category = memory.category || 'fact';
@@ -277,6 +279,7 @@ export function openMemoryModal(tabName = 'browse') {
else if (m.default?.loadSkills) m.default.loadSkills(true);
});
}
if (mode === 'memory') return loadMemories();
}
function _decodeMemoryAnchorId(memoryId) {
@@ -297,9 +300,8 @@ function _resetMemoryDeepLinkFilters() {
// Memory search output may expose a shortened id, so prefix matching is
// intentional here.
export async function openMemory(memoryId, attempt = 0) {
openMemoryModal('browse');
_resetMemoryDeepLinkFilters();
await loadMemories();
await openMemoryModal('browse');
if (renderMemoryList) renderMemoryList();
const wanted = _decodeMemoryAnchorId(memoryId);
if (!wanted) return;
+13 -2
View File
@@ -299,7 +299,18 @@ function _renderHistoryMessage(msg, modelName) {
} else {
displayContent = '';
}
if (msg.role === 'user') {
let displayRole = msg.role;
// Peer turns remain user-role context for the model, but are agent replies
// in history. Only explicit identity plus its exact prefix can opt in.
if (displayRole === 'user' && meta?.group_peer === true
&& typeof meta.group_model === 'string' && meta.group_model) {
const prefix = `[${meta.group_model}]: `;
if (displayContent.startsWith(prefix)) {
displayRole = 'assistant';
displayContent = displayContent.slice(prefix.length);
}
}
if (displayRole === 'user') {
displayContent = _stripUserVisionBlocks(displayContent);
const trimmed = displayContent.trim();
if (
@@ -316,7 +327,7 @@ function _renderHistoryMessage(msg, modelName) {
displayContent = `[Doc edit: ${docEditMatch[1]}] ${docEditMatch[3]}`;
}
}
return _addHistoryMessageWithFullRenderer(msg.role, displayContent, modelName, meta);
return _addHistoryMessageWithFullRenderer(displayRole, displayContent, modelName, meta);
}
function _clearHistoryPager() {
+11
View File
@@ -1260,6 +1260,9 @@ async function _expandSkillCard(card, name) {
card._md = md || '';
} else {
pre.textContent = 'Loading…';
const editBtn = [...card.querySelectorAll('.skill-card-preview button')]
.find(button => button.textContent.trim() === 'Edit');
if (editBtn) editBtn.disabled = true;
try {
const md = await _fetchSkillMarkdown(name);
pre.textContent = md || '(empty)';
@@ -1267,6 +1270,8 @@ async function _expandSkillCard(card, name) {
card._md = md;
} catch (e) {
pre.textContent = `Failed to load SKILL.md: ${e.message || 'Unknown error'}`;
} finally {
if (editBtn) editBtn.disabled = !card._mdLoaded;
}
}
}
@@ -1275,6 +1280,9 @@ async function _expandSkillCard(card, name) {
// Swap the read-only <pre> for an editable <textarea> (and back). The
// Edit button toggles; a Save button commits via the markdown endpoint.
function _toggleSkillEdit(card, name) {
// A loading/error preview is not source text. Menu entry points must obey
// the same readiness gate as the disabled footer control.
if (!card._mdLoaded) return;
const preview = card.querySelector('.skill-card-preview');
if (!preview) return;
const existing = preview.querySelector('.skill-md-editor');
@@ -1288,6 +1296,9 @@ function _toggleSkillEdit(card, name) {
ta.className = 'skill-md-editor';
ta.spellcheck = false;
ta.value = (card._md != null ? card._md : (pre ? pre.textContent : '')) || '';
// Let the source set the intrinsic height; the card's existing flex/max
// height keeps long files bounded and the Save footer reachable.
ta.rows = Math.max(ta.rows, ta.value.split(/\r\n|\r|\n/).length);
ta.addEventListener('click', (e) => e.stopPropagation());
if (pre) pre.style.display = 'none';
preview.insertBefore(ta, preview.querySelector('.doclib-card-expanded-actions'));
+3 -2
View File
@@ -267,8 +267,9 @@ export function makeWindowResizable(content, options = {}) {
content.style.maxWidth = 'none';
content.style.maxHeight = 'none';
const left = parseFloat(content.style.left);
if (Number.isFinite(left) && left + w > window.innerWidth - padRight) {
content.style.left = Math.max(padLeft, window.innerWidth - padRight - w) + 'px';
if (Number.isFinite(left)) {
const rightmostLeft = Math.max(padLeft, window.innerWidth - padRight - w);
content.style.left = Math.max(padLeft, Math.min(left, rightmostLeft)) + 'px';
}
}
} catch (_) {}
+2 -1
View File
@@ -7,7 +7,7 @@
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
// - API / non-GET: never cached.
// Bump CACHE_NAME whenever the precache list or SW logic changes.
const CACHE_NAME = 'odysseus-v1114-pr40-editor-precache';
const CACHE_NAME = 'odysseus-v1115-pr40-editor-precache';
// KaTeX resolves these from its own stylesheet, so caching the CSS without them
// gives offline math fallback glyphs instead of proper typesetting.
@@ -38,6 +38,7 @@ const KATEX_FONTS = [
// Both are fetched at install time, in the background. Entries must match the
// exact URL the browser requests, query string included.
const PRECACHE = [
'/static/js/compare/sse.js',
'/',
'/static/css/00-tokens.css?v=20260929csssplit',
'/static/css/01-agent-chat.css?v=20260929csssplit',
+4 -2
View File
@@ -99,8 +99,10 @@ cleanup. Specifically:
`os.environ[...] = ...` that outlives the test.
- **Current working directory** - never `chdir` without restoring; never assert
against cwd-relative paths like `./data`. Use a temp workspace helper instead.
- **Database** - the root `conftest.py` defaults `DATABASE_URL` to an in-memory
SQLite for collection safety. A test that needs a real file-backed DB must opt
- **Database** - the root `conftest.py` defaults `DATABASE_URL` to a private,
worker-owned SQLite file for collection safety and consistent schema across
TestClient threads. This fallback is not a per-test data fixture. A test that
writes or depends on particular rows must opt
in explicitly via `tests.helpers.sqlite_db.make_temp_sqlite` and bind its
`SessionLocal` onto the module under test. Do not rely on a persistent
on-disk DB existing.
+67
View File
@@ -0,0 +1,67 @@
"""Gallery CLI tag filters match both sources and stack like the web library."""
from datetime import datetime, timedelta
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from core.database import Base, GalleryAlbum, GalleryImage
from tests.helpers.cli_loader import load_script
@pytest.fixture
def gallery_cli(monkeypatch, tmp_path):
engine = create_engine(f"sqlite:///{tmp_path / 'gallery.db'}")
Base.metadata.create_all(engine)
sessions = sessionmaker(bind=engine)
with sessions() as db:
db.add(GalleryAlbum(id='sea', name='Sea'))
for index, (uid, tags, ai_tags, favorite, album) in enumerate([
('manual', 'sunset,beach', '', True, 'sea'),
('ai', '', 'Beach,sunset', False, 'sea'),
('split', 'sunset', 'beach', True, None),
('one', 'sunset', '', False, None),
('other', 'forest', 'moss', False, None),
('inactive', 'sunset,beach', 'sunset,beach', True, 'sea'),
]):
db.add(GalleryImage(id=uid, filename=uid+'.png', tags=tags, ai_tags=ai_tags,
favorite=favorite, album_id=album, is_active=uid!='inactive',
created_at=datetime(2026, 1, 1) + timedelta(minutes=index)))
db.commit()
cli = load_script('odysseus-gallery')
monkeypatch.setattr(cli, 'SessionLocal', sessions)
output = []
monkeypatch.setattr(cli, 'emit', lambda rows, args: output.extend(rows))
def run(*argv):
output.clear()
args = cli._build_parser().parse_args(['list', *argv])
args.func(args)
return [row['id'] for row in output]
yield run
engine.dispose()
@pytest.mark.parametrize('tag,expected', [
('sunset', ['one', 'split', 'ai', 'manual']),
('beach,sunset', ['split', 'ai', 'manual']),
('sunset,beach', ['split', 'ai', 'manual']),
(', sunset, , BEACH ,', ['split', 'ai', 'manual']),
('forest', ['other']),
('no-match', []),
(' , ', ['other', 'one', 'split', 'ai', 'manual']),
('sun', ['one', 'split', 'ai', 'manual']),
])
def test_tag_sources_and_stacking(gallery_cli, tag, expected):
assert gallery_cli('--tag', tag) == expected
@pytest.mark.parametrize('options,expected', [
([], ['other', 'one', 'split', 'ai', 'manual']),
(['--tag', 'beach,sunset', '--favorites'], ['split', 'manual']),
(['--tag', 'beach,sunset', '--album', 'Sea'], ['ai', 'manual']),
(['--tag', 'beach', '--limit', '1'], ['split']),
])
def test_tag_filters_preserve_other_list_options(gallery_cli, options, expected):
assert gallery_cli(*options) == expected
+66
View File
@@ -0,0 +1,66 @@
"""The bounded arrival batch is displayed by Date, rather than arrival ID."""
from contextlib import contextmanager
from types import SimpleNamespace
import pytest
from tests.cli.test_mail_cli_read_empty_fetch import _load_mail_cli
@pytest.mark.parametrize('dates,expected', [
(['Wed, 03 Jun 2026 12:00:00 +0000', 'Mon, 01 Jun 2026 09:00:00 +0000'], ['1', '2']),
(['Wed, 03 Jun 2026 12:00:00 +0000', 'Wed, 03 Jun 2026 13:00:00 +0200'], ['1', '2']),
(['Wed, 03 Jun 2026 12:00:00', 'Wed, 03 Jun 2026 11:00:00 +0000'], ['1', '2']),
(['Wed, 03 Jun 2026 12:00:00 +0000', None, 'not a date'], ['1', '3', '2']),
(['Wed, 03 Jun 2026 12:00:00 +0000', 'Wed, 03 Jun 2026 14:00:00 +0200'], ['2', '1']),
(['Wed, 03 Jun 2026 12:00:00 +0000', 'Thu, 04 Jun 2026 12:00:00 +0000'], ['2', '1']),
(['Wed, 31 Dec 1969 12:00:00 +0000', None], ['1', '2']),
])
def test_list_sorts_parsed_dates_without_changing_row_schema(monkeypatch, dates, expected):
cli = _load_mail_cli(monkeypatch)
fetched = []
class Connection:
def select(self, folder, readonly=True):
assert readonly is True
return 'OK', [str(len(dates)).encode()]
def search(self, *_):
return 'OK', [' '.join(str(i+1) for i in range(len(dates))).encode()]
def fetch(self, uid, _):
fetched.append(uid.decode())
date = dates[int(uid)-1]
raw = 'From: Sender <sender@example.com>\r\nSubject: Item '+uid.decode()+'\r\n'
if date is not None:
raw += 'Date: '+date+'\r\n'
return 'OK', [(b'1 (FLAGS (\\Seen) RFC822.HEADER)', (raw+'\r\n').encode())]
@contextmanager
def imap(_):
yield Connection()
rows = []
monkeypatch.setattr(cli, '_imap', imap)
monkeypatch.setattr(cli, 'emit', lambda value, args: rows.extend(value))
cli.cmd_list(SimpleNamespace(account='fixture', folder='INBOX', limit=20))
assert [row['uid'] for row in rows] == expected
assert fetched == [str(i) for i in range(len(dates), 0, -1)]
assert all(set(row) == {'uid', 'date', 'from_addr', 'from_name', 'subject', 'is_read', 'is_answered'} for row in rows)
assert all(row['is_read'] and not row['is_answered'] for row in rows)
def test_list_still_limits_the_arrival_batch(monkeypatch):
cli = _load_mail_cli(monkeypatch)
fetched = []
class Connection:
def select(self, *_args, **_kwargs):return 'OK', []
def search(self, *_):return 'OK', [b'1 2 3']
def fetch(self, uid, _):
fetched.append(uid)
return 'OK', [(b'3 (FLAGS () RFC822.HEADER)', b'From: x@example.com\r\nSubject: bounded\r\n\r\n')]
@contextmanager
def imap(_):yield Connection()
monkeypatch.setattr(cli, '_imap', imap)
monkeypatch.setattr(cli, 'emit', lambda *_:None)
cli.cmd_list(SimpleNamespace(account='fixture', folder='INBOX', limit=1))
assert fetched == [b'3']
+14 -1
View File
@@ -12,9 +12,17 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# File-backed databases remain fixture-owned. Cleanup restores the caller.
# Keep the existing generated environment-reference locations stable.
_database_environment = pytest.MonkeyPatch()
_database_environment.setenv("DATABASE_URL", "sqlite:///:memory:")
from tests.helpers.worker_runtime import bootstrap_runtime, configure_runtime
_runtime_environment = bootstrap_runtime()
# A file keeps the collection schema consistent across TestClient threads;
# SingletonThreadPool can evict the populated in-memory main-thread database.
# One patcher owns this variable, including restoration of an inherited URL.
_database_environment.setenv(
"DATABASE_URL",
f"sqlite:///{_runtime_environment.root / 'collection.db'}"
if _runtime_environment is not None else "sqlite:///:memory:",
)
_collection_database_engine = None
# Pre-import real heavy modules BEFORE any test file's module-level stubs can
# replace them with MagicMock. Some test files (e.g. test_llm_core_sanitize_*)
@@ -26,6 +34,7 @@ try:
import sqlalchemy.orm # noqa: F401
import core.database # noqa: F401
import src.database
_collection_database_engine = core.database.engine
except ImportError:
pass # not installed - the stubs below will handle it
@@ -100,6 +109,10 @@ def pytest_configure(config):
only registers marker names; it imports no production module.
"""
config.add_cleanup(_database_environment.undo)
if _collection_database_engine is not None:
# Registered after runtime cleanup, so connections close before the
# owned file is removed (also required on Windows).
config.add_cleanup(_collection_database_engine.dispose)
import pathlib
from tests._taxonomy import discover_markers
+52
View File
@@ -1,5 +1,7 @@
# Browser End-to-End Tests
## Photo Editor Release Gate
The photo editor release gate launches an isolated Odysseus server on port
`7013` with a temporary SQLite database and disposable authenticated account.
@@ -18,3 +20,53 @@ The disposable account is created in the temporary data directory and exercises
owner-scoped draft and notification routes without using an existing account.
`PHOTO_EDITOR_E2E_AUTH=0` selects the legacy unauthenticated configuration for
investigating it; offline-upgrade coverage requires the authenticated mode.
## Mobile and iOS Layout Suite
The dedicated mobile and iOS layout suite exercises viewport behavior,
accessibility zoom, scrolling, mobile safe-area geometry (Notes and Documents
panes), and floating window restoration against an isolated local Odysseus server
running on port `17739`.
```bash
npm install
npm run test:ios-layout:chromium
```
### Profiles and Browser Prerequisites
- **Chromium profiles** (`desktop-chromium`, `phone-chromium-portrait`, `phone-chromium-landscape`, `tablet-chromium`):
Provide automated baseline coverage in CI and local development without requiring
WebKit dependencies.
- **WebKit profiles** (`phone-webkit-portrait`, `phone-webkit-landscape`, `tablet-webkit`):
Used for platform-oriented WebKit rendering validation. WebKit requires browser binaries:
```bash
npm run test:ios-layout:install
# or: npx playwright install webkit
npm run test:ios-layout:webkit
```
To run all installed profiles:
```bash
npm run test:ios-layout
```
### Evidence and Cleanup
- **Evidence artifacts**: Screenshots are written to `test-results/ios-layout/` by
default and traces are written to `test-results/ios-layout-traces/`. Both paths
are git-ignored. Set `ODYSSEUS_LAYOUT_EVIDENCE_DIR` to override the screenshot
output path.
- **Teardown**: Disposable application state and database files created in the
OS temporary directory are automatically cleaned up on both normal and failure
teardown, preserving diagnostic screenshots and failure traces.
### Platform Considerations
Emulated Chromium and WebKit viewports validate deterministic CSS cascade,
computed layout geometry, viewport meta tags, and JavaScript restoration. However,
browser emulation is not a physical device substitute: physical iOS Safari and
standalone installed PWAs should still be verified separately for native gesture
pinch-zoom, software keyboard dismissal transitions, and dynamic hardware notch/island insets.
+28
View File
@@ -0,0 +1,28 @@
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
/**
* Global teardown for the iOS/mobile layout Playwright test suite.
* Cleans up only disposable temporary application data directories,
* preserving diagnostic artifacts (screenshots and failure traces) in test-results/.
*/
module.exports = async () => {
const dataDirectory = process.env.ODYSSEUS_LAYOUT_DATA_DIR;
if (!dataDirectory || typeof dataDirectory !== 'string') return;
try {
const resolvedData = path.resolve(dataDirectory);
const allowedTmpBases = [os.tmpdir(), '/var/tmp', '/tmp'].map(b => path.resolve(b));
const isTmp = allowedTmpBases.some(base => resolvedData.startsWith(base + path.sep));
const baseName = path.basename(resolvedData);
if (isTmp && (baseName.startsWith('odysseus-ios-layout-') || baseName.startsWith('odysseus-pr6396-'))) {
if (fs.existsSync(resolvedData)) {
fs.rmSync(resolvedData, { recursive: true, force: true });
}
}
} catch (err) {
console.warn(`[ios-layout-teardown] Warning: failed to clean temporary runtime dir: ${err.message}`);
}
};
+148
View File
@@ -0,0 +1,148 @@
// Tests against the running Odysseus app, not a static mock page.
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { defineConfig } = require('@playwright/test');
const repositoryRoot = path.resolve(__dirname, '../..');
const port = Number(process.env.ODYSSEUS_LAYOUT_PORT || '17739');
const baseURL = `http://127.0.0.1:${port}`;
const defaultPython = process.platform === 'win32'
? path.join(repositoryRoot, '.venv', 'Scripts', 'python.exe')
: path.join(repositoryRoot, '.venv', 'bin', 'python');
const python = process.env.ODYSSEUS_TEST_PYTHON
|| (fs.existsSync(defaultPython) ? defaultPython : 'python3');
const dataDirectory = process.env.ODYSSEUS_LAYOUT_DATA_DIR
|| fs.mkdtempSync(path.join(os.tmpdir(), 'odysseus-ios-layout-'));
process.env.ODYSSEUS_LAYOUT_DATA_DIR = dataDirectory;
// Default evidence directory to repository-relative test-results/ios-layout
// while preserving the environment-variable override if provided.
const evidenceDir = process.env.ODYSSEUS_LAYOUT_EVIDENCE_DIR
|| path.join(repositoryRoot, 'test-results', 'ios-layout');
const outputDir = path.join(repositoryRoot, 'test-results', 'ios-layout-traces');
// Emergency process-exit cleanup for disposable application runtime state
const cleanDataDirSync = () => {
const dir = process.env.ODYSSEUS_LAYOUT_DATA_DIR;
if (!dir || typeof dir !== 'string') return;
try {
const resolved = path.resolve(dir);
const allowed = [os.tmpdir(), '/var/tmp', '/tmp'].map(b => path.resolve(b));
if (allowed.some(base => resolved.startsWith(base + path.sep))) {
const baseName = path.basename(resolved);
if (baseName.startsWith('odysseus-ios-layout-') || baseName.startsWith('odysseus-pr6396-')) {
if (fs.existsSync(resolved)) {
fs.rmSync(resolved, { recursive: true, force: true });
}
}
}
} catch (_) {}
};
process.once('SIGINT', () => { cleanDataDirSync(); process.exit(130); });
process.once('SIGTERM', () => { cleanDataDirSync(); process.exit(143); });
function isWebKitInstalled() {
try {
const { webkit } = require('@playwright/test');
return fs.existsSync(webkit.executablePath());
} catch (_) {
return false;
}
}
const chromiumProjects = [
{
name: 'desktop-chromium',
use: { browserName: 'chromium', viewport: { width: 1440, height: 900 } },
},
{
name: 'phone-chromium-portrait',
use: { browserName: 'chromium', viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true },
},
{
name: 'phone-chromium-landscape',
use: { browserName: 'chromium', viewport: { width: 844, height: 390 }, hasTouch: true, isMobile: true },
},
{
name: 'tablet-chromium',
use: { browserName: 'chromium', viewport: { width: 820, height: 1000 }, hasTouch: true, isMobile: true },
},
];
const webkitProjects = [
{
name: 'phone-webkit-portrait',
use: { browserName: 'webkit', viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true },
},
{
name: 'phone-webkit-landscape',
use: { browserName: 'webkit', viewport: { width: 844, height: 390 }, hasTouch: true, isMobile: true },
},
{
name: 'tablet-webkit',
use: { browserName: 'webkit', viewport: { width: 820, height: 1000 }, hasTouch: true, isMobile: true },
},
];
// Determine active projects based on environment and local browser availability
const requestedBrowser = (process.env.ODYSSEUS_LAYOUT_BROWSER || 'all').toLowerCase();
let selectedProjects = [];
if (requestedBrowser === 'chromium') {
selectedProjects = chromiumProjects;
} else if (requestedBrowser === 'webkit') {
selectedProjects = webkitProjects;
} else {
// 'all': Include chromium always; include webkit if installed or if explicitly targeted via cli args
const hasCliWebKitTarget = process.argv.some(arg => arg.toLowerCase().includes('webkit'));
if (isWebKitInstalled() || hasCliWebKitTarget) {
selectedProjects = [...chromiumProjects, ...webkitProjects];
} else {
// Only Chromium is available locally (e.g. standard CI / default runner)
selectedProjects = chromiumProjects;
}
}
module.exports = defineConfig({
testDir: path.join(__dirname, 'ios-layout'),
testMatch: ['**/*.spec.cjs'],
outputDir,
timeout: 90_000,
expect: { timeout: 15_000 },
retries: 0,
workers: 1,
reporter: [['line']],
globalTeardown: require.resolve('./ios-layout-teardown.cjs'),
use: {
baseURL,
headless: true,
serviceWorkers: 'block',
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
},
projects: selectedProjects,
metadata: { evidenceDir },
webServer: {
command: `${JSON.stringify(python)} -m uvicorn app:app --host 127.0.0.1 --port ${port}`,
cwd: repositoryRoot,
url: baseURL,
timeout: 120_000,
reuseExistingServer: false,
env: {
...process.env,
AUTH_ENABLED: 'false',
// Never connect the disposable app to the host's real ChromaDB.
CHROMADB_HOST: '127.0.0.1',
CHROMADB_PORT: String(port + 1),
LLM_HOST: '127.0.0.1',
EMBEDDING_URL: `http://127.0.0.1:${port + 1}/v1/embeddings`,
DATABASE_URL: `sqlite:///${path.join(dataDirectory, 'app.db')}`,
ODYSSEUS_DATA_DIR: dataDirectory,
ODYSSEUS_STARTUP_WARMUPS: '0',
RESPONSE_COMPRESSION_ENABLED: 'false',
},
},
});
@@ -0,0 +1,313 @@
const { test, expect } = require('@playwright/test');
const fs = require('node:fs');
const path = require('node:path');
async function openApp(page) {
const response = await page.goto('/', { waitUntil: 'domcontentloaded' });
expect(response?.ok()).toBeTruthy();
// The runtime currently produces two #message nodes. Select the
// initialized composer while diagnosing whether both are visible.
const composers = await page.locator('textarea#message').evaluateAll(nodes =>
nodes.map((el, index) => ({
index,
initialized: el.dataset.startupPreserveBound === '1',
visible: !!el.getClientRects().length &&
getComputedStyle(el).visibility !== 'hidden',
parent: el.parentElement?.className || '',
}))
);
if (composers.length !== 1) {
console.log('Composer DOM diagnostic:', JSON.stringify(composers));
}
const activeComposer = page.locator(
'textarea#message[data-startup-preserve-bound="1"]'
);
await expect(activeComposer).toHaveCount(1);
await expect(activeComposer).toBeVisible();
expect(
composers.filter(composer => composer.visible).length,
'Exactly one composer should be visible'
).toBe(1);
}
function isMobileViewport(testInfo) {
const width = testInfo.project.use?.viewport?.width || 0;
return width > 0 && width <= 768;
}
/**
* Reach Settings through genuine UI controls on either desktop or mobile viewports.
* On mobile (<=768px), the rail is hidden and the sidebar must be opened via hamburger.
*/
async function openSettings(page, isMobile) {
let settingsButton;
if (isMobile) {
const hamburger = page.locator('#hamburger-btn:visible').first();
await expect(hamburger, 'Hamburger button must be visible on mobile').toBeVisible({ timeout: 5000 });
await hamburger.click();
const sidebar = page.locator('#sidebar:not(.hidden)');
await expect(sidebar, 'Sidebar must open upon hamburger tap').toBeVisible({ timeout: 5000 });
await page.waitForTimeout(300);
settingsButton = sidebar.locator('#user-bar-settings');
} else {
settingsButton = page.locator('#rail-settings:visible, #user-bar-settings:visible').first();
}
await expect(settingsButton, 'Settings button must be reachable and visible').toBeVisible({ timeout: 5000 });
await settingsButton.click();
const dialog = page.locator('#settings-modal');
await expect(dialog, 'Settings modal must become visible').toBeVisible({ timeout: 5000 });
return dialog;
}
/**
* Reach Notes through genuine UI controls on either desktop or mobile viewports.
*/
async function openNotes(page, isMobile) {
let notesButton;
if (isMobile) {
const hamburger = page.locator('#hamburger-btn:visible').first();
await expect(hamburger, 'Hamburger button must be visible on mobile').toBeVisible({ timeout: 5000 });
await hamburger.click();
const sidebar = page.locator('#sidebar:not(.hidden)');
await expect(sidebar, 'Sidebar must open upon hamburger tap').toBeVisible({ timeout: 5000 });
await page.waitForTimeout(300);
notesButton = sidebar.locator('#tool-notes-btn');
} else {
notesButton = page.locator('#rail-notes:visible, #tool-notes-btn:visible').first();
}
await expect(notesButton, 'Notes button must be reachable and visible').toBeVisible({ timeout: 5000 });
await notesButton.click();
const realNotes = page.locator('.notes-pane').first();
await expect(realNotes, 'Notes pane must become visible').toBeVisible({ timeout: 5000 });
return realNotes;
}
/**
* Reach Document editor through genuine UI controls on either desktop or mobile viewports.
*/
async function openDocument(page, isMobile) {
let docButton;
if (isMobile) {
const hamburger = page.locator('#hamburger-btn:visible').first();
await expect(hamburger, 'Hamburger button must be visible on mobile').toBeVisible({ timeout: 5000 });
await hamburger.click();
const sidebar = page.locator('#sidebar:not(.hidden)');
await expect(sidebar, 'Sidebar must open upon hamburger tap').toBeVisible({ timeout: 5000 });
await page.waitForTimeout(300);
docButton = sidebar.locator('#library-new-doc-btn');
} else {
docButton = page.locator('#library-new-doc-btn:visible').first();
if (!(await docButton.count())) {
const hamburger = page.locator('#hamburger-btn:visible').first();
if (await hamburger.count()) {
await hamburger.click();
await page.waitForTimeout(300);
docButton = page.locator('#sidebar:not(.hidden) #library-new-doc-btn');
}
}
}
await expect(docButton, 'Document creation button must be reachable and visible').toBeVisible({ timeout: 5000 });
await docButton.click();
const docPane = page.locator('.doc-editor-pane').first();
await expect(docPane, 'Document editor pane must become visible').toBeVisible({ timeout: 10000 });
return docPane;
}
test('renders actual Odysseus UI and captures review screenshots', async ({ page }, testInfo) => {
await openApp(page);
const out = testInfo.config.metadata.evidenceDir;
fs.mkdirSync(out, { recursive: true });
const name = testInfo.project.name;
const isMobile = isMobileViewport(testInfo);
// 1. Chat screenshot (default view)
await page.screenshot({ path: path.join(out, `${name}-chat.png`), fullPage: false, animations: 'disabled' });
// 2. Settings screenshot via actual user-visible navigation
await openApp(page);
await openSettings(page, isMobile);
await page.screenshot({ path: path.join(out, `${name}-settings.png`), fullPage: false, animations: 'disabled' });
// 3. Notes screenshot via actual user-visible navigation
await openApp(page);
await openNotes(page, isMobile);
await page.screenshot({ path: path.join(out, `${name}-notes.png`), fullPage: false, animations: 'disabled' });
testInfo.annotations.push({ type: 'evidence', description: out });
});
test('viewport meta does not restrict user zoom', async ({ page }) => {
await openApp(page);
const meta = await page.locator('meta[name="viewport"]').getAttribute('content');
expect(meta || '').not.toMatch(/maximum-scale\s*=\s*1(?:\b|\.)/i);
expect(meta || '').not.toMatch(/user-scalable\s*=\s*no/i);
});
test('browser-owned window scrolling is not globally reset', async ({ page }) => {
await openApp(page);
const value = await page.evaluate(async () => {
document.documentElement.style.overflowY = 'auto';
document.body.style.overflowY = 'auto';
document.body.style.height = 'auto';
document.body.style.minHeight = '3200px';
const spacer = document.createElement('div');
spacer.style.cssText = 'position:absolute;top:3100px;width:1px;height:10px;pointer-events:none;';
document.body.appendChild(spacer);
window.scrollTo(0, 250);
await new Promise(resolve => setTimeout(resolve, 150));
return window.scrollY;
});
expect(value).toBeGreaterThan(100);
});
test('CSS cascade rules for mobile Notes and Documents respect safe areas', async ({ page }, testInfo) => {
test.skip(!isMobileViewport(testInfo), 'Only mobile viewports <=768px');
await openApp(page);
const results = await page.evaluate(() => {
document.documentElement.style.setProperty('--safe-top', '44px');
document.documentElement.style.setProperty('--sheet-top', '68px');
document.documentElement.style.setProperty('--app-height', '844px');
const values = {};
for (const [key, bodyClass, paneClass] of [
['notes', 'notes-view', 'notes-pane'],
['document', 'doc-view', 'doc-editor-pane'],
]) {
const pane = document.createElement('section');
pane.className = paneClass;
pane.style.opacity = '0'; // CSS cascade regression check
document.body.appendChild(pane);
document.body.classList.add(bodyClass);
const style = window.getComputedStyle(pane);
values[key] = {
top: style.top,
height: style.height,
insetTop: pane.getBoundingClientRect().top,
};
pane.remove();
document.body.classList.remove(bodyClass);
}
return values;
});
for (const key of ['notes', 'document']) {
expect(results[key].top, key).toBe('68px');
expect(parseFloat(results[key].height), key).toBeGreaterThanOrEqual(770);
}
});
test('real Notes and Document panes honor safe-area geometry in the live application', async ({ page }, testInfo) => {
const isMobile = isMobileViewport(testInfo);
test.skip(!isMobile, 'Only mobile viewports <=768px');
await openApp(page);
// Configure simulated safe-area insets (e.g. iPhone notch / Dynamic Island)
await page.evaluate(() => {
document.documentElement.style.setProperty('--safe-top', '44px');
document.documentElement.style.setProperty('--sheet-top', '68px');
document.documentElement.style.setProperty('--app-height', '844px');
});
// 1. Verify actual Notes UI
const realNotes = await openNotes(page, isMobile);
const notesMetrics = await realNotes.evaluate(el => {
const style = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
return {
top: style.top,
height: style.height,
maxHeight: style.maxHeight,
rectTop: rect.top,
rectHeight: rect.height,
};
});
expect(notesMetrics.top, 'Real Notes pane top must match --sheet-top').toBe('68px');
expect(parseFloat(notesMetrics.height), 'Real Notes pane height must respect safe area').toBeGreaterThanOrEqual(770);
expect(notesMetrics.rectTop, 'Real Notes pane must not overlap the 44px safe-area top notch').toBeGreaterThanOrEqual(44);
// 2. Verify actual Document UI
await openApp(page);
await page.evaluate(() => {
document.documentElement.style.setProperty('--safe-top', '44px');
document.documentElement.style.setProperty('--sheet-top', '68px');
document.documentElement.style.setProperty('--app-height', '844px');
});
const realDoc = await openDocument(page, isMobile);
const docMetrics = await realDoc.evaluate(el => {
const style = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
return {
top: style.top,
height: style.height,
maxHeight: style.maxHeight,
rectTop: rect.top,
rectHeight: rect.height,
};
});
expect(docMetrics.top, 'Real Document pane top must match --sheet-top').toBe('68px');
expect(parseFloat(docMetrics.height), 'Real Document pane height must respect safe area').toBeGreaterThanOrEqual(770);
expect(docMetrics.rectTop, 'Real Document pane must not overlap the 44px safe-area top notch').toBeGreaterThanOrEqual(44);
});
test('restored floating windows clamp away from a left safe area', async ({ page }, testInfo) => {
test.skip(isMobileViewport(testInfo), 'Geometry test uses desktop resizable windows');
await openApp(page);
const value = await page.evaluate(async () => {
document.documentElement.style.setProperty('--safe-left', '58px');
document.body.style.setProperty('padding-left', '58px', 'important');
const probe = document.createElement('div');
probe.style.cssText = 'position:fixed;left:10px;top:80px;width:420px;height:250px;z-index:999999;';
document.body.appendChild(probe);
const key = 'odysseus-layout-restore-test';
localStorage.setItem(key, JSON.stringify({ w: 480, h: 280 }));
try {
// Body horizontal padding transitions over 160ms. Wait for the
// simulated safe-area change to settle before restoring geometry.
await new Promise(resolve => setTimeout(resolve, 300));
const padAtRestore = parseFloat(
getComputedStyle(document.body).paddingLeft
);
if (Math.abs(padAtRestore - 58) > 0.5) {
throw new Error(
`Safe-area fixture did not settle: ${padAtRestore}px`
);
}
// Verify the running server actually serves the patched module.
const response = await fetch(
'/static/js/windowResize.js',
{ cache: 'no-store' }
);
if (!response.ok) {
throw new Error('Cannot fetch windowResize.js');
}
const moduleSource = await response.text();
if (!moduleSource.includes('const rightmostLeft = Math.max(padLeft')) {
throw new Error('Running server is serving unpatched windowResize.js');
}
const { makeWindowResizable } = await import('/static/js/windowResize.js');
makeWindowResizable(probe, { storageKey: key, mobileSkip: 768 });
await new Promise(resolve => setTimeout(resolve, 300));
return {
left: parseFloat(probe.style.left),
width: parseFloat(probe.style.width),
paddingLeft: parseFloat(getComputedStyle(document.body).paddingLeft),
viewportWidth: window.innerWidth,
};
} finally {
localStorage.removeItem(key);
probe.remove();
}
});
expect(value.paddingLeft).toBeGreaterThanOrEqual(58);
expect(value.width).toBe(480);
expect(value.left).toBeGreaterThanOrEqual(value.paddingLeft);
});
+477
View File
@@ -46,3 +46,480 @@ test('brush cursor matches the rendered brush diameter and replaces the crosshai
await expect.poll(async () => (await cursor.boundingBox()).width)
.toBeCloseTo(measurements.expectedWidth * 2, 1);
});
test('brush cursor follows the pointer before, during, and after a stroke and cancels animations on close', async ({ page }) => {
await openBlankEditor(page, { width: 400, height: 300 });
await selectTool(page, 'brush');
const canvas = await page.locator('.ge-main-canvas').boundingBox();
const cursor = page.locator('.ge-brush-cursor');
const checkCenter = async (x, y) => {
await expect.poll(async () => {
const box = await cursor.boundingBox();
return box ? Math.max(Math.abs(box.x + box.width / 2 - x), Math.abs(box.y + box.height / 2 - y)) : Infinity;
}).toBeLessThan(1);
};
const a = { x: canvas.x + canvas.width * 0.3, y: canvas.y + canvas.height * 0.4 };
const b = { x: canvas.x + canvas.width * 0.7, y: canvas.y + canvas.height * 0.6 };
await page.mouse.move(a.x, a.y);
await checkCenter(a.x, a.y);
await page.mouse.down();
await page.mouse.move(b.x, b.y, { steps: 8 });
await checkCenter(b.x, b.y);
await page.mouse.up();
await page.mouse.move(a.x, a.y);
await checkCenter(a.x, a.y);
// 1. Ensure at least one animation exists before checking cancellation
// 2. Verify the actual cursor-position animation is paused while active
const activeAnimation = await page.evaluate(() => {
const cursorEl = document.querySelector('.ge-brush-cursor');
const anims = cursorEl ? cursorEl.getAnimations() : [];
return {
count: anims.length,
hasPositionAnim: Boolean(cursorEl?._positionAnimation),
positionAnimPaused: cursorEl?._positionAnimation?.playState === 'paused',
positionAnimCurrentTime: cursorEl?._positionAnimation?.currentTime,
allPaused: anims.length > 0 && anims.every(a => a.playState === 'paused'),
};
});
expect(activeAnimation.count).toBeGreaterThanOrEqual(1);
expect(activeAnimation.hasPositionAnim).toBe(true);
expect(activeAnimation.positionAnimPaused).toBe(true);
expect(activeAnimation.positionAnimCurrentTime).toBe(0);
expect(activeAnimation.allPaused).toBe(true);
// 3. Verify it is canceled when the editor closes
// 4. Ensure temporary test-only references are cleaned up
const closeOutcome = await page.evaluate(async () => {
const cursorEl = document.querySelector('.ge-brush-cursor');
const anims = cursorEl ? cursorEl.getAnimations() : [];
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
editor.closeEditor({ force: true });
return {
trackedCount: anims.length,
allIdle: anims.length > 0 && anims.every(a => a.playState === 'idle'),
states: anims.map(a => a.playState),
};
});
await expect(cursor).toHaveCount(0);
expect(closeOutcome.trackedCount).toBeGreaterThanOrEqual(1);
expect(closeOutcome.allIdle).toBe(true);
expect(closeOutcome.states.every(s => s === 'idle')).toBe(true);
// Verify no temporary references linger on window
const windowClean = await page.evaluate(() => {
return !('__cursorAnimationsForTest' in window) && !('__styleTransformMutations' in window);
});
expect(windowClean).toBe(true);
});
test('steady pointer movement does not perform repeated inline style-transform writes', async ({ page }) => {
await openBlankEditor(page, { width: 400, height: 300 });
await selectTool(page, 'brush');
const canvas = await page.locator('.ge-main-canvas').boundingBox();
const cursor = page.locator('.ge-brush-cursor');
// Warm up the cursor first: move pointer onto canvas
const startX = canvas.x + canvas.width * 0.25;
const startY = canvas.y + canvas.height * 0.25;
await page.mouse.move(startX, startY);
await expect(cursor).toBeVisible();
// Observe style attribute mutations during steady pointer movement.
// Brush size, color, and mode remain unchanged throughout.
await page.evaluate(() => {
const el = document.querySelector('.ge-brush-cursor');
window.__styleMutationRecords = [];
const observer = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type === 'attributes' && m.attributeName === 'style') {
const oldStyle = m.oldValue || '';
const currentStyle = el.getAttribute('style') || '';
// Track mutations where style-transform was written inline
if (el.style.transform || currentStyle.includes('transform:') || oldStyle.includes('transform:')) {
window.__styleMutationRecords.push({
oldStyle,
currentStyle,
inlineTransform: el.style.transform,
});
}
}
}
});
observer.observe(el, { attributes: true, attributeFilter: ['style'], attributeOldValue: true });
window.__styleMutationObserver = observer;
});
// Preserve actual mouse interactions: steady movement across 10 steps
const endX = canvas.x + canvas.width * 0.75;
const endY = canvas.y + canvas.height * 0.75;
await page.mouse.move(endX, endY, { steps: 10 });
// Confirm the cursor accurately tracked the pointer
await expect.poll(async () => {
const box = await cursor.boundingBox();
return box ? Math.max(Math.abs(box.x + box.width / 2 - endX), Math.abs(box.y + box.height / 2 - endY)) : Infinity;
}).toBeLessThan(1);
// Collect observation results and clean up observer and globals
const { mutationCount, inlineTransform, animCount } = await page.evaluate(() => {
window.__styleMutationObserver?.disconnect();
delete window.__styleMutationObserver;
const records = window.__styleMutationRecords || [];
delete window.__styleMutationRecords;
const el = document.querySelector('.ge-brush-cursor');
return {
mutationCount: records.length,
inlineTransform: el?.style.transform || '',
animCount: el?.getAnimations?.().length || 0,
};
});
// The optimized implementation uses paused Web Animations keyframes, avoiding repeated
// inline style-transform writes that trigger expensive :has() document invalidations.
expect(mutationCount).toBe(0);
expect(inlineTransform).toBe('');
expect(animCount).toBeGreaterThanOrEqual(1);
});
test('cross-tool cursor positioning, sizing, and styling across brush, eraser, pixel, lasso, inpaint, liquify, and clone', async ({ page }) => {
await openBlankEditor(page, { width: 400, height: 300 });
const canvas = await page.locator('.ge-main-canvas').boundingBox();
const cursor = page.locator('.ge-brush-cursor');
const checkCenter = async (x, y) => {
await expect.poll(async () => {
const box = await cursor.boundingBox();
return box ? Math.max(Math.abs(box.x + box.width / 2 - x), Math.abs(box.y + box.height / 2 - y)) : Infinity;
}).toBeLessThan(1);
};
const midX = canvas.x + canvas.width / 2;
const midY = canvas.y + canvas.height / 2;
// 1. Brush tool
await selectTool(page, 'brush');
await page.mouse.move(midX, midY);
await expect(cursor).toBeVisible();
await checkCenter(midX, midY);
let props = await page.evaluate(() => {
const el = document.querySelector('.ge-brush-cursor');
return {
isPixel: el.classList.contains('ge-brush-cursor--pixel'),
display: el.style.display,
borderStyle: el.style.borderStyle,
};
});
expect(props.isPixel).toBe(false);
expect(props.display).toBe('block');
// 2. Eraser tool
await selectTool(page, 'eraser');
await page.mouse.move(midX + 10, midY + 10);
await checkCenter(midX + 10, midY + 10);
props = await page.evaluate(() => {
const el = document.querySelector('.ge-brush-cursor');
return {
borderColor: el.style.borderColor,
isPixel: el.classList.contains('ge-brush-cursor--pixel'),
};
});
expect(props.borderColor).toBe('rgba(255, 255, 255, 0.6)');
expect(props.isPixel).toBe(false);
// 3. Pixel brush (via tool / preset)
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.tool = 'pixel';
state.brushPixel = true;
state.brushSize = 16;
});
await page.mouse.move(midX - 10, midY - 10);
await checkCenter(midX - 10, midY - 10);
props = await page.evaluate(() => {
const el = document.querySelector('.ge-brush-cursor');
return {
isPixel: el.classList.contains('ge-brush-cursor--pixel'),
width: parseFloat(el.style.width),
};
});
expect(props.isPixel).toBe(true);
// 4. Lasso tool
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.tool = 'lasso';
state.brushPixel = false;
});
await page.mouse.move(midX + 20, midY - 10);
await checkCenter(midX + 20, midY - 10);
props = await page.evaluate(() => {
const el = document.querySelector('.ge-brush-cursor');
return {
borderColor: el.style.borderColor,
background: el.style.background,
};
});
expect(props.borderColor).toBe('rgba(255, 255, 255, 0.85)');
expect(props.background).toBe('rgba(0, 0, 0, 0.15)');
// 5. Inpaint tool (paint mode and erase mode)
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.tool = 'inpaint';
state.inpaintEraseMode = false;
});
await page.mouse.move(midX, midY);
await checkCenter(midX, midY);
props = await page.evaluate(() => {
const el = document.querySelector('.ge-brush-cursor');
return {
borderColor: el.style.borderColor,
background: el.style.background,
borderStyle: el.style.borderStyle,
};
});
expect(props.borderColor).toBe('rgba(255, 80, 80, 0.8)');
expect(props.borderStyle).toBe('solid');
// Inpaint erase mode
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.inpaintEraseMode = true;
});
await page.mouse.move(midX + 5, midY + 5);
props = await page.evaluate(() => {
const el = document.querySelector('.ge-brush-cursor');
return {
borderColor: el.style.borderColor,
borderStyle: el.style.borderStyle,
};
});
expect(props.borderColor).toBe('rgba(255, 255, 255, 0.9)');
expect(props.borderStyle).toBe('dashed');
// 6. Liquify tool
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.tool = 'liquify';
state.liquifySize = 100;
});
await page.mouse.move(midX - 15, midY + 15);
await checkCenter(midX - 15, midY + 15);
props = await page.evaluate(() => {
const el = document.querySelector('.ge-brush-cursor');
return {
width: parseFloat(el.style.width),
height: parseFloat(el.style.height),
display: el.style.display,
};
});
expect(props.display).toBe('block');
expect(props.width).toBeGreaterThan(0);
// 7. Clone tool
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.tool = 'clone';
state.brushSize = 50;
});
await page.mouse.move(midX, midY);
await checkCenter(midX, midY);
await expect(cursor).toBeVisible();
// Clean close
await page.evaluate(async () => {
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
editor.closeEditor({ force: true });
});
await expect(cursor).toHaveCount(0);
});
test('canvas boundaries, pointer exit and re-entry, and viewport resize', async ({ page }) => {
await openBlankEditor(page, { width: 400, height: 300 });
await selectTool(page, 'brush');
const canvas = await page.locator('.ge-main-canvas').boundingBox();
const cursor = page.locator('.ge-brush-cursor');
const checkCenter = async (x, y) => {
await expect.poll(async () => {
const box = await cursor.boundingBox();
return box ? Math.max(Math.abs(box.x + box.width / 2 - x), Math.abs(box.y + box.height / 2 - y)) : Infinity;
}).toBeLessThan(1);
};
// 1. Enter canvas
const insideX = canvas.x + canvas.width * 0.5;
const insideY = canvas.y + canvas.height * 0.5;
await page.mouse.move(insideX, insideY);
await expect(cursor).toBeVisible();
await checkCenter(insideX, insideY);
// 2. Exit canvas boundary: cursor should hide
const outsideX = canvas.x - 50;
const outsideY = canvas.y - 50;
await page.mouse.move(outsideX, outsideY);
await expect(cursor).toBeHidden();
// 3. Re-enter canvas: cursor should restore visibility and position
await page.mouse.move(insideX, insideY);
await expect(cursor).toBeVisible();
await checkCenter(insideX, insideY);
// 4. Viewport resize
await page.setViewportSize({ width: 1280, height: 800 });
await page.waitForTimeout(100);
const newCanvas = await page.locator('.ge-main-canvas').boundingBox();
const newInsideX = newCanvas.x + newCanvas.width * 0.5;
const newInsideY = newCanvas.y + newCanvas.height * 0.5;
await page.mouse.move(newInsideX, newInsideY);
await expect(cursor).toBeVisible();
await checkCenter(newInsideX, newInsideY);
// Clean close
await page.evaluate(async () => {
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
editor.closeEditor({ force: true });
});
await expect(cursor).toHaveCount(0);
});
for (const view of ['2x zoom', 'fit-to-view']) {
test(`zoom and fit-to-view cursor scaling and positioning: ${view}`, async ({ page }) => {
const size = view === '2x zoom' ? { width: 400, height: 300 } : { width: 1600, height: 1200 };
await openBlankEditor(page, size);
await selectTool(page, 'brush');
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.brushSize = 50;
// The zoom-in control applies 1.25x through the real zoom handler.
state.zoom = 1.6;
state.zoomViewMode = 'scale';
});
await page.locator('#ge-zoom-in').click();
await expect.poll(() => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { zoom: state.zoom, mode: state.zoomViewMode };
})).toEqual({ zoom: 2, mode: 'scale' });
if (view === 'fit-to-view') {
await page.locator('#ge-zoom-100').click();
const fitted = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const area = document.querySelector('.ge-canvas-area');
const pad = state._footerDocked ? 0 : 20;
const rulerPad = state.rulersVisible ? 18 : 0;
return {
zoom: state.zoom,
mode: state.zoomViewMode,
expectedZoom: Math.min(1,
(area.clientWidth - 2 * pad - rulerPad) / state.imgWidth,
(area.clientHeight - 2 * pad - rulerPad) / state.imgHeight),
};
});
expect(fitted.mode).toBe('fit');
expect(fitted.zoom).toBeGreaterThan(0);
expect(fitted.zoom).toBeLessThan(1);
expect(fitted.zoom).toBeCloseTo(fitted.expectedZoom, 6);
}
const canvas = page.locator('.ge-main-canvas');
const canvasBox = await canvas.boundingBox();
const cursor = page.locator('.ge-brush-cursor');
if (view === '2x zoom') {
expect(canvasBox.width).toBeCloseTo(size.width * 2, 1);
expect(canvasBox.height).toBeCloseTo(size.height * 2, 1);
}
const targetX = canvasBox.x + canvasBox.width * 0.4;
const targetY = canvasBox.y + canvasBox.height * 0.4;
await page.mouse.move(targetX, targetY);
await expect(cursor).toBeVisible();
// Pointer coordinates can be rounded to device pixels; keep the existing
// subpixel alignment tolerance separate from the stricter diameter check.
await expect.poll(async () => {
const box = await cursor.boundingBox();
return box ? Math.max(
Math.abs(box.x + box.width / 2 - targetX),
Math.abs(box.y + box.height / 2 - targetY),
) : Infinity;
}).toBeLessThan(1);
await expect.poll(() => page.evaluate(() => {
const canvasEl = document.querySelector('.ge-main-canvas');
const canvasRect = canvasEl.getBoundingClientRect();
const cursorRect = document.querySelector('.ge-brush-cursor').getBoundingClientRect();
return Math.max(
Math.abs(cursorRect.width - 50 * canvasRect.width / canvasEl.width),
Math.abs(cursorRect.height - 50 * canvasRect.height / canvasEl.height),
);
})).toBeLessThan(0.1);
await page.evaluate(async () => {
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
editor.closeEditor({ force: true });
});
await expect(cursor).toHaveCount(0);
});
}
test('memory and lifecycle: repeated editor open/close cycles retain no detached cursors or active animations', async ({ page }) => {
for (let cycle = 0; cycle < 5; cycle++) {
await openBlankEditor(page, { width: 400, height: 300 });
await selectTool(page, 'brush');
const canvas = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.move(canvas.x + canvas.width * 0.5, canvas.y + canvas.height * 0.5);
const cursor = page.locator('.ge-brush-cursor');
await expect(cursor).toBeVisible();
// Verify cursor layer and animation are active
const cycleState = await page.evaluate(() => {
const el = document.querySelector('.ge-brush-cursor');
window.__lastCycleAnim = el?._positionAnimation;
return {
cursorCount: document.querySelectorAll('.ge-brush-cursor').length,
layerCount: document.querySelectorAll('#ge-cursor-layer').length,
hasAnim: Boolean(el?._positionAnimation),
animPaused: el?._positionAnimation?.playState === 'paused',
};
});
expect(cycleState.cursorCount).toBe(1);
expect(cycleState.layerCount).toBe(1);
expect(cycleState.animPaused).toBe(true);
// Close editor
await page.evaluate(async () => {
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
editor.closeEditor({ force: true });
});
// Verify complete cleanup after close
await expect(cursor).toHaveCount(0);
const postClose = await page.evaluate(() => {
const anim = window.__lastCycleAnim;
delete window.__lastCycleAnim;
return {
cursorCount: document.querySelectorAll('.ge-brush-cursor').length,
layerCount: document.querySelectorAll('#ge-cursor-layer').length,
animIdle: anim ? anim.playState === 'idle' : true,
activeAnims: document.getAnimations().filter(a => {
const target = a.effect?.target;
return target && (target.classList?.contains('ge-brush-cursor') || target.id === 'ge-cursor-layer');
}).length,
};
});
expect(postClose.cursorCount).toBe(0);
expect(postClose.layerCount).toBe(0);
expect(postClose.animIdle).toBe(true);
expect(postClose.activeAnims).toBe(0);
}
});
+28 -4
View File
@@ -14,12 +14,22 @@ from routes.auth_routes import SESSION_COOKIE
from tests.helpers.database import disposable_database
@pytest.fixture
def authenticated_app(tmp_path, monkeypatch):
monkeypatch.setenv("AUTH_ENABLED", "true")
auth = AuthManager(str(tmp_path / "auth.json"))
@pytest.fixture(scope="module")
def auth_configuration(tmp_path_factory):
"""Hash real fixture passwords once; only immutable bytes are reused."""
path = tmp_path_factory.mktemp("admin-auth-seed") / "auth.json"
auth = AuthManager(str(path))
for user, admin in (("member", False), ("admin", True)):
assert auth.create_user(user, "disposable-test-password", is_admin=admin)
return path.read_bytes()
@pytest.fixture
def authenticated_app(tmp_path, monkeypatch, auth_configuration):
monkeypatch.setenv("AUTH_ENABLED", "true")
path = tmp_path / "auth.json"
path.write_bytes(auth_configuration)
auth = AuthManager(str(path))
app = FastAPI()
app.state.auth_manager = auth
@@ -133,3 +143,17 @@ def test_diagnostics_require_real_admin_before_sensitive_work(
for operation in (stats, rag.get_stats, research.call_research_service,
extract_id, transcript, collect, read_log):
operation.assert_not_called()
@pytest.mark.parametrize("instance", [0, 1])
def test_authenticated_fixture_has_private_users_sessions_and_file(authenticated_app, instance):
app, client_for = authenticated_app
auth = app.state.auth_manager
assert set(auth.users) == {"member", "admin"}
assert not auth._sessions
with client_for("member") as client:
assert auth.get_username_for_token(client.cookies.get(SESSION_COOKIE)) == "member"
assert auth.create_user("fixture-local", "disposable-test-password")
# Both parameter cases mutate their own file. Any shared manager, mutable
# users/sessions or written-back seed makes the next case fail, in any order.
assert "fixture-local" in auth.users
+43 -6
View File
@@ -1929,23 +1929,60 @@ def test_place_layer_reports_false_on_queued_response(monkeypatch):
assert placed is False
def test_bridge_await_cancellation_handled_cleanly():
def test_bridge_await_cancellation_handled_cleanly(monkeypatch):
import asyncio
from contextlib import suppress
from httpx import ASGITransport, AsyncClient
app = _bridge_app()
async def scenario():
entered_wait = asyncio.Event()
real_wait_for = asyncio.wait_for
waits = []
async def observe_wait(awaitable, timeout):
# Observe the real wait; do not replace its result or cancellation.
waits.append(awaitable)
entered_wait.set()
return await real_wait_for(awaitable, timeout)
monkeypatch.setattr(eaar.asyncio, "wait_for", observe_wait)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
cmd_resp = await c.post("/api/editor/bridge/command", json={"session": "cancel-sess", "op": "document.info", "wait": False})
assert cmd_resp.status_code == 200
assert cmd_resp.json()["completed"] is False
cid = cmd_resp.json()["queued"][0]
await_task = asyncio.create_task(c.post("/api/editor/bridge/await", json={"id": cid, "timeout": 30}))
await asyncio.sleep(0.05)
await_task.cancel()
try:
await await_task
except asyncio.CancelledError:
pass
await real_wait_for(entered_wait.wait(), 5)
assert len(waits) == 1
assert not waits[0].done()
assert not await_task.done(), "await returned without a queued result"
await_task.cancel()
response = await real_wait_for(await_task, 5)
assert response.status_code == 200
assert response.json() == {
"ok": False, "error": "cancelled", "status": "cancelled",
"completed": False, "id": cid,
}
finally:
if not await_task.done():
await_task.cancel()
with suppress(asyncio.CancelledError):
await await_task
# Cancelling the waiter must not cancel the queued command itself.
payload = {"width": 137, "height": 251}
delivered = await c.post("/api/editor/bridge/result", json={"id": cid, "ok": True, "result": payload})
assert delivered.status_code == 200
completed = await c.post("/api/editor/bridge/await", json={"id": cid, "timeout": 1})
assert completed.status_code == 200
assert completed.json()["status"] == "completed"
assert completed.json()["completed"] is True
assert completed.json()["ok"] is True
assert completed.json()["result"] == payload
assert len(waits) == 2
asyncio.run(scenario())
+28 -13
View File
@@ -206,35 +206,50 @@ class TestDiskConsistency:
@pytest.mark.slow
def test_file_always_valid_json_during_concurrent_ops(self, tmp_path):
mgr = _fresh_auth_manager(tmp_path)
mgr.create_user("admin", "adminpw", is_admin=True)
assert mgr.create_user("admin", "adminpw", is_admin=True)
stop_event = threading.Event()
reader_ready = threading.Event()
corruption_found = []
observations = {"reads": 0, "with_writes": 0}
def reader():
while not stop_event.is_set():
try:
with open(mgr.auth_path, "r") as f:
content = f.read()
json.loads(content)
data = json.loads(content)
observations["reads"] += 1
if any(name.startswith("stress") for name in data["users"]):
observations["with_writes"] += 1
reader_ready.set()
except json.JSONDecodeError as e:
corruption_found.append(str(e))
break
except FileNotFoundError:
pass
time.sleep(0.001)
def writer():
assert reader_ready.wait(5), "reader never observed the initial file"
results = []
for i in range(50):
mgr.create_user(f"stress{i}", f"pw{i}")
results.append(mgr.create_user(f"stress{i}", f"pw{i}"))
return results
reader_thread = threading.Thread(target=reader)
writer_thread = threading.Thread(target=writer)
reader_thread.start()
writer_thread.start()
writer_thread.join()
stop_event.set()
reader_thread.join()
# Futures propagate reader/writer exceptions to the test thread.
with ThreadPoolExecutor(max_workers=2) as pool:
reading = pool.submit(reader)
writing = pool.submit(writer)
try:
results = writing.result(timeout=120)
finally:
stop_event.set()
reading.result(timeout=5)
assert not corruption_found, f"Corrupt JSON detected: {corruption_found[0]}"
assert len(results) == 50 and all(results), "writer operations did not succeed"
assert observations["reads"] > 0
assert observations["with_writes"] > 0, "reader saw no persisted writer activity"
expected = {"admin"} | {f"stress{i}" for i in range(50)}
with open(mgr.auth_path) as persisted:
assert set(json.load(persisted)["users"]) == expected
assert set(_fresh_auth_manager(tmp_path).users) == expected
+40 -12
View File
@@ -13,6 +13,7 @@ import socketserver
import threading
import pytest
import requests
from src import caldav_sync, caldav_writeback
@@ -26,12 +27,13 @@ def test_build_dav_client_disables_redirects():
def test_dav_client_does_not_follow_redirect_to_internal_host():
"""End-to-end through the real DAVClient: a 302 toward an internal host
must NOT be followed. Without the fix the sink is contacted (SSRF); with it
the redirect is refused and the sink is never reached."""
must NOT be followed. Exercise the session limit separately because some
caldav versions independently refuse cross-origin PROPFIND redirects."""
pytest.importorskip("caldav")
sink_hits: list[str] = []
public_methods: list[str] = []
same_origin_hits: list[str] = []
class _Internal(http.server.BaseHTTPRequestHandler):
# Stand-in for an internal service the attacker redirects toward.
@@ -48,9 +50,15 @@ def test_dav_client_does_not_follow_redirect_to_internal_host():
class _Public(http.server.BaseHTTPRequestHandler):
# The "validated" public CalDAV server that redirects everything inward.
def do_GET(self): # noqa: N802
if self.path == "/landing":
same_origin_hits.append(self.path)
self.send_response(200)
self.end_headers()
return
public_methods.append(self.command)
self.send_response(302)
self.send_header("Location", f"http://127.0.0.1:{internal_port}/leak")
location = "/landing" if self.path == "/same" else f"http://127.0.0.1:{internal_port}/leak"
self.send_header("Location", location)
self.end_headers()
do_PROPFIND = do_GET
@@ -62,8 +70,10 @@ def test_dav_client_does_not_follow_redirect_to_internal_host():
internal_port = internal.server_address[1]
public = socketserver.TCPServer(("127.0.0.1", 0), _Public)
public_port = public.server_address[1]
threading.Thread(target=internal.serve_forever, daemon=True).start()
threading.Thread(target=public.serve_forever, daemon=True).start()
internal_thread = threading.Thread(target=internal.serve_forever, daemon=True)
public_thread = threading.Thread(target=public.serve_forever, daemon=True)
internal_thread.start()
public_thread.start()
try:
public_url = f"http://127.0.0.1:{public_port}/dav"
client = caldav_sync._build_dav_client(public_url, "u", "p")
@@ -82,17 +92,35 @@ def test_dav_client_does_not_follow_redirect_to_internal_host():
assert public_methods in (["PROPFIND"], ["PROPFIND", "GET"]), public_methods
assert sink_hits == [], "redirect toward an internal host must not be followed"
# Positive control: demonstrate that the sink would see the redirect
# if the session's redirect protection were removed.
# GET redirects are handled by the HTTP session even in caldav 3.7.
# This makes the cross-port SSRF assertion sensitive to our own limit.
with pytest.raises(Exception, match="Exceeded 0 redirects"):
client.request(public_url, "GET", "")
assert sink_hits == [], "GET redirect toward the internal sink must be blocked"
# requests can reach the cross-port sink when redirects are enabled,
# even if DAVClient independently rejects cross-origin PROPFIND redirects.
with requests.Session() as session:
response = session.get(public_url, timeout=5)
assert response.status_code == 207
assert sink_hits == ["/leak"], "the cross-port redirect must reach the sink"
# GET follows redirects at the HTTP session layer across these caldav
# versions. Check the application's limit on a same-origin redirect too.
same_url = f"http://127.0.0.1:{public_port}/same"
with pytest.raises(Exception, match="Exceeded 0 redirects"):
client.request(same_url, "GET", "")
assert same_origin_hits == []
client.session.max_redirects = 30
try:
client.request(public_url, "PROPFIND", "")
except Exception:
pass # The sink's response is intentionally not a valid DAV body.
assert sink_hits == ["/leak"], "the test must detect a followed redirect"
client.request(same_url, "GET", "")
assert same_origin_hits == ["/landing"]
finally:
internal.shutdown()
public.shutdown()
internal_thread.join()
public_thread.join()
internal.server_close()
public.server_close()
def test_sync_and_writeback_construct_clients_through_the_helper():
+35
View File
@@ -0,0 +1,35 @@
"""The CalDAV config routes answer 400 for a body that is JSON but not an object."""
import asyncio
import json
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from routes.calendar_routes import _json_object_body
def _request(raw: bytes):
async def _json():
return json.loads(raw)
return SimpleNamespace(json=_json)
def _parse(raw: bytes):
return asyncio.run(_json_object_body(_request(raw)))
def test_object_body_is_returned():
assert _parse(b'{"url": "https://dav.example/"}') == {"url": "https://dav.example/"}
@pytest.mark.parametrize("raw", [b"", b"not json"])
def test_unreadable_body_counts_as_empty(raw):
assert _parse(raw) == {}
@pytest.mark.parametrize("raw", [b"[]", b'"x"', b"5", b"null"])
def test_non_object_body_is_a_400(raw):
with pytest.raises(HTTPException) as exc:
_parse(raw)
assert exc.value.status_code == 400
@@ -0,0 +1,148 @@
"""Both chat tidy entry points retain saved conversations and still remove empties."""
import asyncio
from datetime import datetime, timedelta, timezone
import pytest
from fastapi import APIRouter, FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
import core.database as database
import core.session_manager as manager_module
import routes.session_routes as routes
import src.session_actions as actions
@pytest.fixture
def tidy_api(tmp_path, monkeypatch):
engine = create_engine(
f"sqlite:///{(tmp_path / 'tidy.db').as_posix()}",
connect_args={"check_same_thread": False},
)
database.Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine, autoflush=False)
monkeypatch.setattr(database, "SessionLocal", factory)
monkeypatch.setattr(manager_module, "SessionLocal", factory)
monkeypatch.setattr(routes, "SessionLocal", factory)
monkeypatch.setattr(routes, "router", APIRouter(prefix="/api"))
monkeypatch.setattr(routes, "effective_user", lambda request: "alice")
manager = manager_module.SessionManager.__new__(manager_module.SessionManager)
manager.sessions = {}
manager.upload_handler = None
monkeypatch.setattr(manager, "get_sessions_for_user", lambda owner: {})
app = FastAPI()
app.include_router(routes.setup_session_routes(manager, {}))
with TestClient(app) as client:
yield client, factory, manager
engine.dispose()
def run_tidy(client, entrypoint):
if entrypoint == "action":
return asyncio.run(actions.run_auto_sort("alice", skip_llm=True))
response = client.post("/api/sessions/auto-sort?skip_llm=true")
assert response.status_code == 200, response.text
return response.json()
@pytest.mark.parametrize("entrypoint", ["route", "action"])
@pytest.mark.parametrize("title,prompt", [
("New chat", "Please analyze the following contract and retain all details. " * 20),
("Contract analysis", "Review contract"),
("Chat: analysis", "Explain these results and retain the full answer"),
("Test", "Here is a large input that should be analyzed and retained"),
], ids=["generic-title", "short-prompt", "title-prefix", "test-title"])
def test_tidy_preserves_nontrivial_saved_answer(tidy_api, title, prompt, entrypoint):
client, factory, manager = tidy_api
old = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(hours=2)
answer = "Clause analysis and recommended changes.\n" * 2000
with factory() as db:
db.add(database.Session(
id="valuable-chat", name=title, owner="alice", endpoint_url="", model="",
archived=False, is_important=False, message_count=2,
created_at=old, updated_at=old, last_accessed=old, last_message_at=old,
))
db.commit()
db.add_all([
database.ChatMessage(id="prompt", session_id="valuable-chat", role="user",
content=prompt, timestamp=old),
database.ChatMessage(id="answer", session_id="valuable-chat", role="assistant",
content=answer, timestamp=old + timedelta(seconds=1)),
])
db.commit()
run_tidy(client, entrypoint)
with factory() as db:
remaining = db.query(database.ChatMessage).filter_by(session_id="valuable-chat").count()
survives = db.get(database.Session, "valuable-chat") is not None
assert survives and remaining == 2, "Title/prompt heuristics must not erase saved answers"
with factory() as db:
assert db.get(database.ChatMessage, "answer").content == answer
@pytest.mark.parametrize("entrypoint", ["route", "action"])
@pytest.mark.parametrize("kind", ["unanswered", "greeting", "empty", "fresh-empty", "starred-empty", "archived", "other-owner", "incognito"])
def test_tidy_cleanup_boundaries(tidy_api, entrypoint, kind):
client, factory, manager = tidy_api
now = datetime.now(timezone.utc).replace(tzinfo=None)
timestamp = now if kind == "fresh-empty" else now - timedelta(hours=2)
message = kind in {"unanswered", "greeting", "archived", "other-owner", "incognito"}
with factory() as db:
db.add(database.Session(
id="boundary-chat", name="Incognito" if kind == "incognito" else "New chat",
owner="bob" if kind == "other-owner" else "alice", endpoint_url="", model="",
archived=kind == "archived", is_important=kind == "starred-empty",
# Deliberately inaccurate metadata: cleanup must use message rows.
message_count=0 if message else 40,
created_at=timestamp, updated_at=timestamp, last_accessed=timestamp, last_message_at=timestamp,
))
db.commit()
if message:
db.add(database.ChatMessage(id="boundary-message", session_id="boundary-chat", role="user",
content="hi" if kind == "greeting" else "Pending question " * 100,
timestamp=timestamp))
db.commit()
run_tidy(client, entrypoint)
deleted = kind in {"empty", "incognito"}
with factory() as db:
assert (db.get(database.Session, "boundary-chat") is None) == deleted
assert db.query(database.ChatMessage).filter_by(session_id="boundary-chat").count() == int(message and not deleted)
@pytest.mark.parametrize("entrypoint", ["route", "action"])
def test_tidy_keeps_folder_assignment_working(tidy_api, monkeypatch, entrypoint):
from types import SimpleNamespace
import src.llm_core as llm
import src.task_endpoint as endpoints
client, factory, manager = tidy_api
old = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(hours=2)
for sid in ["chat-one-long-id", "chat-two-long-id"]:
with factory() as db:
db.add(database.Session(id=sid, name="New chat", owner="alice", endpoint_url="", model="",
archived=False, created_at=old, updated_at=old,
last_accessed=old, last_message_at=old))
db.commit()
db.add(database.ChatMessage(id=f"{sid}-message", session_id=sid, role="user",
content="Saved question", timestamp=old))
db.commit()
manager.sessions[sid] = SimpleNamespace(id=sid, name="New chat", archived=False,
updated_at=old.isoformat(), created_at=old.isoformat())
monkeypatch.setattr(manager, "get_sessions_for_user", lambda owner: manager.sessions)
monkeypatch.setattr(endpoints, "resolve_task_endpoint", lambda *args, **kwargs: ("http://model.invalid", "test", {}))
response_text = '{"folders":{"Projects":["chat-one","chat-two"]}}'
monkeypatch.setattr(llm, "llm_call", lambda *args, **kwargs: response_text)
async def async_response(*args, **kwargs):
return response_text
monkeypatch.setattr(llm, "llm_call_async", async_response)
if entrypoint == "action":
result = asyncio.run(actions.run_auto_sort("alice"))
assert "Sorted 2 sessions" in result
else:
response = client.post("/api/sessions/auto-sort")
assert response.status_code == 200, response.text
assert response.json()["updated"] == 2
with factory() as db:
assert db.query(database.ChatMessage).count() == 2
assert {row.folder for row in db.query(database.Session).all()} == {"Projects"}
+43 -2
View File
@@ -7,13 +7,13 @@ CalendarCal.owner; the query must join it, like routes/calendar_routes.
"""
import uuid
import sys
from datetime import datetime
from datetime import datetime, timedelta
import pytest
from tests.helpers.database import disposable_database
from core.database import CalendarEvent, CalendarCal
from src.task_scheduler import _checkin_calendar_events
from src.task_scheduler import _checkin_calendar_events, _digest_windows
@pytest.fixture(autouse=True)
@@ -68,3 +68,44 @@ def test_cancelled_excluded_and_window_respected():
assert "cancelled" not in [ev.summary for ev in out]
finally:
db.close()
@pytest.mark.parametrize("days, expected_label", [(2, "this_week"), (7, "next_30_days")])
def test_boundary_event_appears_in_only_one_digest_window(days, expected_label):
now = datetime(2026, 6, 1, 9)
boundary = now + timedelta(days=days)
with _TS() as db:
db.add(CalendarCal(id="boundary-cal", owner="alice", name="Boundary"))
db.add(CalendarEvent(
uid="boundary-event", calendar_id="boundary-cal", summary="Boundary meeting",
dtstart=boundary, dtend=boundary + timedelta(hours=1), status="confirmed",
))
db.commit()
buckets = [
label for label, start, end in _digest_windows(now)
if _checkin_calendar_events(db, "alice", start, end)
]
assert buckets == [expected_label]
def test_digest_includes_start_and_excludes_end():
start = datetime(2026, 6, 1, 9)
end = start + timedelta(days=2)
with _TS() as db:
db.add(CalendarCal(id="bounds-cal", owner="alice", name="Bounds"))
for uid, when in [
("before", start - timedelta(seconds=1)),
("start", start),
("inside", end - timedelta(seconds=1)),
("end", end),
]:
db.add(CalendarEvent(
uid=uid, calendar_id="bounds-cal", summary=uid, dtstart=when,
dtend=when + timedelta(hours=1), status="confirmed",
))
db.commit()
assert [ev.uid for ev in _checkin_calendar_events(db, "alice", start, end)] == [
"start", "inside",
]
@@ -0,0 +1,70 @@
"""Compare's actual stream consumer must preserve evidence and terminal state."""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
CASES = [
("lf", 'data: {"delta":"Hello"}\n\ndata: [DONE]\n\n', False, True, "Hello"),
("crlf", 'data:{"delta":"Héllö"}\r\n\r\ndata:[DONE]\r\n\r\n', False, True, "Héllö"),
("multiline", 'data: {"delta":\ndata: "Hello"}\n\ndata: [DONE]\n\n', False, True, "Hello"),
("eof", 'data: {"delta":"Partial"}\n\n', False, False, "ended before completion"),
("named-error", 'event: error\ndata: {"text":"Provider Z private-model failed"}\n\n', False, False, "Provider Z private-model failed"),
("typed-error", 'data: {"type":"error","data":{"message":"Provider Z <img src=x onerror=alert(1)>"}}\n\ndata: [DONE]\n\n', False, False, "Provider Z <img"),
("blind-error", 'event:error\ndata:{"message":"Provider Z private-model failed"}\n\n', True, False, "request failed"),
("empty-error", 'event:error\ndata:\n\n', False, False, "request failed"),
("done-open", 'data: {"delta":"Hello"}\n\ndata: [DONE]\n\n', False, True, "Hello"),
("heartbeat", 'data: {"delta":"Hello"}\n\ndata: [DONE]\n\n', False, True, "Hello"),
("cr", 'data: {"delta":"Hello"}\r\rdata:[DONE]\r\r', False, True, "Hello"),
("invalid", 'data: not-json\n\ndata: [DONE]\n\n', False, False, "Invalid model stream data"),
]
@pytest.mark.parametrize("kind,wire,blind,success,text", CASES)
def test_compare_stream_contract(kind, wire, blind, success, text):
if not shutil.which("node"):
pytest.skip("node not installed")
script = r"""
const assert=require('node:assert/strict');
let chromium;try{({chromium}=require('playwright'));}catch(e){if(e.code==='MODULE_NOT_FOUND')process.exit(77);throw e;}
(async()=>{
const browser=await chromium.launch({headless:true});
try{
const page=await browser.newPage();
await page.goto(process.env.ODYSSEUS_TEST_STATIC_ORIGIN+'/static/js/storage.js');
await page.setContent('<div class="compare-pane" data-pane="0"><div id="cmp-badge-0"></div><div id="cmp-history-0"><div class="msg msg-ai"><div class="body"></div></div></div></div>');
const result=await page.evaluate(async(data)=>{
const {default:state}=await import('/static/js/compare/state.js');
const {streamToPane}=await import('/static/js/compare/stream.js');
Object.assign(state,{isActive:true,_selectedModels:[{model:'fixture',name:'Fixture'}],_paneSessionIds:['session'],_compareMode:'chat',_parallel:true,_blindMode:data.blind,_timeout:data.kind==='heartbeat'?1:5});
const nativeFetch=window.fetch,bytes=new TextEncoder().encode(data.wire);let index=0,cancelled=false,context=null;
window.fetch=async(url,options)=>{
if(!String(url).endsWith('/api/chat_stream'))return nativeFetch(url,options);
context=options.body.get('search_context');
const heartbeats=[...Array(6).fill(': ping\n\n'),data.wire];
return new Response(new ReadableStream({start(controller){options.signal.addEventListener('abort',()=>controller.error(new DOMException('Stopped','AbortError')),{once:true});},async pull(controller){if(data.kind==='heartbeat'){if(index<heartbeats.length){await new Promise(r=>setTimeout(r,200));controller.enqueue(new TextEncoder().encode(heartbeats[index++]));}else controller.close();}else if(index<bytes.length)controller.enqueue(bytes.slice(index,index+=1));else if(data.kind!=='done-open')controller.close();},cancel(){cancelled=true;}}),{headers:{'Content-Type':'text/event-stream'}});
};
const message=document.querySelector('.msg-ai'),spinner=document.createElement('span');let spinnerDestroyed=false;spinner.textContent='Loading';message.querySelector('.body').appendChild(spinner);message._spinner={element:spinner,destroy(){spinnerDestroyed=true;spinner.remove();}};
await Promise.race([streamToPane(0,'session','Prompt',message,{skipBadge:true}),new Promise((_,reject)=>setTimeout(()=>reject(Error('Consumer did not finish at DONE')),data.kind==='heartbeat'?6000:1500))]);
const pane=document.querySelector('.compare-pane');
return {done:pane.classList.contains('is-done'),failed:pane.classList.contains('is-failed'),text:pane.innerText,images:pane.querySelectorAll('img').length,cancelled,spinnerDestroyed,spinnerOwned:!!message._spinner};
},JSON.parse(process.argv[1]));
const expected=JSON.parse(process.argv[1]);
assert.equal(result.done,expected.success);assert.equal(result.failed,!expected.success);assert(result.text.includes(expected.text),JSON.stringify(result));assert.equal(result.images,0);
assert(result.spinnerDestroyed);assert(!result.spinnerOwned);
if(expected.blind)assert(!/Provider Z|private-model/.test(result.text),JSON.stringify(result));
if(expected.kind==='done-open')assert(result.cancelled);
console.log(JSON.stringify(result));
}finally{await browser.close();}
})().catch(e=>{console.error(e);process.exitCode=1;});
"""
data = dict(kind=kind, wire=wire, blind=blind, success=success, text=text)
result = subprocess.run(["node", "-e", script, json.dumps(data)], cwd=ROOT,
capture_output=True, text=True, timeout=30)
if result.returncode == 77:
pytest.skip("playwright not installed")
assert result.returncode == 0, result.stdout + result.stderr
@@ -1,4 +1,14 @@
import os
import subprocess
from pathlib import Path
import pytest
from core.platform_compat import find_bash, git_bash_path
from routes.cookbook_helpers import (
ODYSSEUS_LLAMA_SHIM_MARKER,
_append_llama_server_shim_definition_lines,
_append_llama_server_shim_reconciliation_lines,
_append_llama_server_finalization_lines,
)
from tests.helpers.stylesheets import app_css
@@ -615,3 +625,730 @@ def test_local_dependency_probe_refreshes_user_site_visibility():
# deps (realesrgan) probe as not-installed until a restart. See #4810.
assert "if user_site and os.path.isdir(user_site):" in source
assert "site.addsitedir(user_site)" in source
def test_llama_runner_path_preserves_standard_user_bin_order():
"""The runner PATH must preserve standard user $HOME/bin precedence while
relying on ownership-aware reconciliation to retire Odysseus shims when
native builds exist."""
source = _read("routes/cookbook_routes.py")
assert (
'export PATH="$HOME/.local/bin:$HOME/bin:$HOME/llama.cpp/build/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"'
in source
)
def _run_shell(tmp_path, lines, cwd=None, path=None, extra_env=None):
from core.platform_compat import find_bash, git_bash_path
bash = find_bash() or "bash"
env = dict(os.environ, HOME=git_bash_path(tmp_path))
if path is not None:
env["PATH"] = path
if extra_env:
env.update(extra_env)
return subprocess.run(
[bash, "-c", "\n".join(lines)],
cwd=str(cwd) if cwd else None,
env=env,
capture_output=True,
text=True,
)
def _write_odysseus_shim(path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
f"#!/usr/bin/env bash\n{ODYSSEUS_LLAMA_SHIM_MARKER}\nexec python3 -m llama_cpp.server \"$@\"\n",
encoding="utf-8",
)
path.chmod(0o755)
def _write_mock_native(path: Path, succeeds: bool = True) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
if succeeds:
content = '#!/usr/bin/env bash\nif [ "$1" = "--version" ]; then echo "version: 0.1.0-dev"; exit 0; fi\necho "RUN_NATIVE: $@"\nexit 0\n'
else:
content = '#!/usr/bin/env bash\necho "error: missing libllama.so" >&2\nexit 127\n'
path.write_text(content, encoding="utf-8")
path.chmod(0o755)
def _write_mock_python3(path: Path, succeeds: bool = True) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(f"#!/usr/bin/env bash\nexit {0 if succeeds else 1}\n", encoding="utf-8")
path.chmod(0o755)
def test_odysseus_create_llama_shim_never_overwrites_user_regular_file(tmp_path):
"""Creation boundary: never overwrite a non-executable user-owned regular file."""
user_file = tmp_path / "bin/llama-server"
user_file.parent.mkdir(parents=True, exist_ok=True)
original_content = "user configuration or binary data without marker"
user_file.write_text(original_content, encoding="utf-8")
lines = []
_append_llama_server_shim_definition_lines(lines)
lines.append('_odysseus_create_llama_shim')
res = _run_shell(tmp_path, lines)
assert res.returncode != 0, "Creation should return non-zero when target is a user file"
assert user_file.read_text(encoding="utf-8") == original_content, "User file must remain byte-for-byte unchanged"
def test_odysseus_create_llama_shim_never_overwrites_user_symlink(tmp_path):
"""Creation boundary: never follow or overwrite a user-owned symlink."""
bin_dir = tmp_path / "bin"
bin_dir.mkdir(parents=True, exist_ok=True)
target_file = tmp_path / "user_target.txt"
target_file.write_text("precious user target content", encoding="utf-8")
user_symlink = bin_dir / "llama-server"
user_symlink.symlink_to(target_file)
lines = []
_append_llama_server_shim_definition_lines(lines)
lines.append('_odysseus_create_llama_shim')
res = _run_shell(tmp_path, lines)
assert res.returncode != 0, "Creation should return non-zero when target is a user symlink"
assert user_symlink.is_symlink(), "Symlink must remain intact"
assert target_file.read_text(encoding="utf-8") == "precious user target content", "Target file must not be modified"
def test_odysseus_create_llama_shim_never_overwrites_dangling_symlink(tmp_path):
"""Creation boundary: never overwrite a dangling user-owned symlink."""
bin_dir = tmp_path / "bin"
bin_dir.mkdir(parents=True, exist_ok=True)
dangling_symlink = bin_dir / "llama-server"
dangling_symlink.symlink_to(tmp_path / "nonexistent_target")
lines = []
_append_llama_server_shim_definition_lines(lines)
lines.append('_odysseus_create_llama_shim')
res = _run_shell(tmp_path, lines)
assert res.returncode != 0, "Creation should return non-zero when target is a dangling symlink"
assert dangling_symlink.is_symlink(), "Dangling symlink must remain intact"
def test_odysseus_create_llama_shim_safely_replaces_existing_odysseus_shim(tmp_path):
"""Creation boundary: safely replace an existing Odysseus-owned compatibility shim."""
shim_file = tmp_path / "bin/llama-server"
shim_file.parent.mkdir(parents=True, exist_ok=True)
shim_file.write_text(f"#!/bin/bash\n{ODYSSEUS_LLAMA_SHIM_MARKER}\n# old shim body\n", encoding="utf-8")
lines = []
_append_llama_server_shim_definition_lines(lines)
lines.append('_odysseus_create_llama_shim')
res = _run_shell(tmp_path, lines)
assert res.returncode == 0
new_content = shim_file.read_text(encoding="utf-8")
assert ODYSSEUS_LLAMA_SHIM_MARKER in new_content
assert "exec python3 -m llama_cpp.server" in new_content
def test_odysseus_create_llama_shim_secure_temp_prevents_symlink_attack(tmp_path):
"""Creation boundary: secure mktemp prevents predictable $$ temp symlink attacks.
Given:
- A victim file at ~/victim containing DO_NOT_TOUCH
- A pre-created symlink at ~/bin/.llama-server.odysseus.tmp.$$ pointing to ~/victim
- _odysseus_create_llama_shim executed within that exact same bash PID ($$)
Prove:
- ~/victim remains unmodified
- Pre-existing crafted symlink is NOT followed or overwritten
- Legitimate shim is successfully created at ~/bin/llama-server via secure temp path
"""
lines = [
'victim="$HOME/victim"',
'printf "DO_NOT_TOUCH\\n" > "$victim"',
'mkdir -p "$HOME/bin"',
'old_tmp="$HOME/bin/.llama-server.odysseus.tmp.$$";',
'ln -s "$victim" "$old_tmp"',
]
_append_llama_server_shim_definition_lines(lines)
lines.append('_odysseus_create_llama_shim')
lines.append('res=$?')
lines.append('echo "CREATION_RES=$res"')
res = _run_shell(tmp_path, lines)
assert res.returncode == 0
assert "CREATION_RES=0" in res.stdout
victim_file = tmp_path / "victim"
assert victim_file.exists()
assert victim_file.read_text(encoding="utf-8") == "DO_NOT_TOUCH\n", "Victim file must remain untouched"
target_shim = tmp_path / "bin/llama-server"
assert target_shim.exists()
assert ODYSSEUS_LLAMA_SHIM_MARKER in target_shim.read_text(encoding="utf-8")
assert target_shim.stat().st_mode & 0o111 != 0, "Shim must be executable"
def test_stale_odysseus_shim_removed_when_python_server_unusable(tmp_path):
"""Scenario 1: Stale Odysseus shim + missing Python server -> owned shim removed."""
shim_file = tmp_path / "bin/llama-server"
_write_odysseus_shim(shim_file)
lines = []
_append_llama_server_shim_reconciliation_lines(lines)
res = _run_shell(tmp_path, lines)
assert res.returncode == 0
assert not shim_file.exists(), "Stale Odysseus shim must be removed when python server is unavailable"
def test_healthy_odysseus_shim_retained_when_no_native_server(tmp_path):
"""Scenario 2: Healthy Odysseus shim + no native server -> shim preserved."""
shim_file = tmp_path / "bin/llama-server"
_write_odysseus_shim(shim_file)
mock_py_dir = tmp_path / "mock_bin"
_write_mock_python3(mock_py_dir / "python3", succeeds=True)
lines = []
_append_llama_server_shim_reconciliation_lines(lines)
res = _run_shell(tmp_path, lines, path=f"{git_bash_path(mock_py_dir)}:{os.environ.get('PATH', '')}")
assert res.returncode == 0
assert shim_file.exists(), "Healthy Odysseus shim must be preserved when no native server is present"
def test_reconciliation_does_not_retire_healthy_shim_for_broken_native_binary(tmp_path):
"""Reconciliation health check: a broken native candidate executable must NOT retire
a healthy Odysseus Python shim."""
shim_file = tmp_path / "bin/llama-server"
_write_odysseus_shim(shim_file)
_write_mock_native(tmp_path / "llama.cpp/build/bin/llama-server", succeeds=False)
mock_py_dir = tmp_path / "mock_bin"
_write_mock_python3(mock_py_dir / "python3", succeeds=True)
lines = [f'export PATH="$HOME/llama.cpp/build/bin:{git_bash_path(mock_py_dir)}:$PATH"']
_append_llama_server_shim_reconciliation_lines(lines)
res = _run_shell(tmp_path, lines)
assert res.returncode == 0
assert shim_file.exists(), "Healthy Odysseus shim must NOT be retired when native binary is broken"
def test_odysseus_shim_retired_when_native_server_healthy(tmp_path):
"""Scenario 3: Odysseus shim + healthy native server available -> native server wins."""
shim_file = tmp_path / "bin/llama-server"
_write_odysseus_shim(shim_file)
_write_mock_native(tmp_path / "llama.cpp/build/bin/llama-server", succeeds=True)
lines = ['export PATH="$HOME/bin:$HOME/llama.cpp/build/bin:$PATH"']
_append_llama_server_shim_reconciliation_lines(lines)
res = _run_shell(tmp_path, lines)
assert res.returncode == 0
assert not shim_file.exists(), "Odysseus shim must be retired when healthy native server is available"
def test_user_owned_bin_llama_server_never_touched(tmp_path):
"""Scenario 4: Arbitrary user ~/bin/llama-server -> untouched and not deprioritized."""
user_bin = tmp_path / "bin/llama-server"
user_bin.parent.mkdir(parents=True, exist_ok=True)
user_bin.write_text("#!/bin/bash\n# Custom user build script\nexec /opt/custom/llama-server\n", encoding="utf-8")
user_bin.chmod(0o755)
_write_mock_native(tmp_path / "llama.cpp/build/bin/llama-server", succeeds=True)
lines = ['export PATH="$HOME/bin:$HOME/llama.cpp/build/bin:$PATH"']
_append_llama_server_shim_reconciliation_lines(lines)
res = _run_shell(tmp_path, lines)
assert res.returncode == 0
assert user_bin.exists(), "User-owned ~/bin/llama-server must never be deleted"
assert "Custom user build script" in user_bin.read_text(encoding="utf-8")
def test_reconciliation_ignores_native_binary_outside_effective_path(tmp_path):
"""Native candidate outside effective PATH must NOT be considered."""
shim_file = tmp_path / "bin/llama-server"
_write_odysseus_shim(shim_file)
# Native binary placed in /usr_custom/bin which is NOT in PATH
_write_mock_native(tmp_path / "usr_custom/bin/llama-server", succeeds=True)
mock_py_dir = tmp_path / "mock_bin"
_write_mock_python3(mock_py_dir / "python3", succeeds=True)
lines = []
_append_llama_server_shim_reconciliation_lines(lines)
# PATH only contains ~/bin and mock_py_dir — usr_custom is omitted
path = f"{git_bash_path(tmp_path / 'bin')}:{git_bash_path(mock_py_dir)}"
res = _run_shell(tmp_path, lines, path=path)
assert res.returncode == 0
assert shim_file.exists(), "Shim must NOT be retired by native binary outside effective PATH"
def test_reconciliation_skips_home_bin_with_or_without_trailing_slash(tmp_path):
"""Reconciliation must exclude $HOME/bin and $HOME/bin/ from native candidate discovery."""
shim_file = tmp_path / "bin/llama-server"
_write_odysseus_shim(shim_file)
mock_py_dir = tmp_path / "mock_bin"
_write_mock_python3(mock_py_dir / "python3", succeeds=True)
lines = []
_append_llama_server_shim_reconciliation_lines(lines)
# PATH has ~/bin/ with trailing slash
path = f"{git_bash_path(tmp_path / 'bin')}/:{git_bash_path(mock_py_dir)}"
res = _run_shell(tmp_path, lines, path=path)
assert res.returncode == 0
assert shim_file.exists(), "Shim must NOT rediscover itself when PATH has trailing slash in $HOME/bin/"
def test_reconciliation_does_not_discover_cwd_executable_when_path_omits_dot(tmp_path):
"""Reconciliation must NOT synthesize an empty/trailing PATH element that searches cwd.
Given:
- Current working directory contains an executable `llama-server`
- Healthy Odysseus Python shim in ~/bin/llama-server
- PATH does NOT contain '.' or cwd
- No other healthy native binary on PATH
Prove:
- Reconciliation does not discover ./llama-server
- The healthy Odysseus shim in ~/bin/llama-server is preserved
"""
shim_file = tmp_path / "bin/llama-server"
_write_odysseus_shim(shim_file)
mock_py_dir = tmp_path / "mock_bin"
_write_mock_python3(mock_py_dir / "python3", succeeds=True)
cwd_dir = tmp_path / "workdir"
_write_mock_native(cwd_dir / "llama-server", succeeds=True)
lines = []
_append_llama_server_shim_reconciliation_lines(lines)
path = f"{git_bash_path(tmp_path / 'bin')}:{git_bash_path(mock_py_dir)}:/usr/bin:/bin"
res = _run_shell(tmp_path, lines, cwd=cwd_dir, path=path)
assert res.returncode == 0
assert shim_file.exists(), "Healthy Odysseus shim must NOT be retired by finding ./llama-server in cwd"
def test_reconciliation_discovers_healthy_native_on_explicit_other_path_entry(tmp_path):
"""Reconciliation must discover healthy native binaries placed on explicit PATH entries."""
shim_file = tmp_path / "bin/llama-server"
_write_odysseus_shim(shim_file)
custom_opt_bin = tmp_path / "custom_opt/bin"
_write_mock_native(custom_opt_bin / "llama-server", succeeds=True)
lines = []
_append_llama_server_shim_reconciliation_lines(lines)
path = f"{git_bash_path(tmp_path / 'bin')}:{git_bash_path(custom_opt_bin)}:/usr/bin:/bin"
res = _run_shell(tmp_path, lines, path=path)
assert res.returncode == 0
assert not shim_file.exists(), "Odysseus shim must be retired when healthy native server is on explicit PATH entry"
def test_reconciliation_safely_iterates_path_with_glob_metacharacters(tmp_path):
"""Reconciliation must not perform pathname expansion / globbing on PATH components.
Given:
- A directory path containing shell glob characters: ~/custom[dir]/bin
- A native binary ~/custom[dir]/bin/llama-server
- A working directory containing matching glob entries
- An existing Odysseus shim in ~/bin/llama-server
Prove:
- Reconciliation correctly parses the literal path without expanding [dir]
- Discovers the healthy native server and retires the Odysseus shim
"""
shim_file = tmp_path / "bin/llama-server"
_write_odysseus_shim(shim_file)
glob_dir = tmp_path / "custom[dir]/bin"
_write_mock_native(glob_dir / "llama-server", succeeds=True)
cwd_dir = tmp_path / "cwd"
cwd_dir.mkdir(parents=True, exist_ok=True)
(cwd_dir / "customd").mkdir(parents=True, exist_ok=True)
(cwd_dir / "customi").mkdir(parents=True, exist_ok=True)
lines = []
_append_llama_server_shim_reconciliation_lines(lines)
path = f"{git_bash_path(tmp_path / 'bin')}:{git_bash_path(glob_dir)}:/usr/bin:/bin"
res = _run_shell(tmp_path, lines, cwd=cwd_dir, path=path)
assert res.returncode == 0
assert not shim_file.exists(), "Reconciliation must discover literal bracketed PATH without globbing"
def test_fallback_install_success_creates_usable_launcher(tmp_path):
"""Scenario 5: No native + no Python server -> fallback install succeeds
-> usable compatibility launcher exists and resolves command -v llama-server."""
mock_py_dir = tmp_path / "mock_bin"
_write_mock_python3(mock_py_dir / "python3", succeeds=True)
lines = [f'export PATH="$HOME/bin:{git_bash_path(mock_py_dir)}:$PATH"']
_append_llama_server_shim_definition_lines(lines)
_append_llama_server_finalization_lines(lines)
lines.append('command -v llama-server')
res = _run_shell(tmp_path, lines)
assert res.returncode == 0
created_shim = tmp_path / "bin/llama-server"
assert created_shim.exists()
assert ODYSSEUS_LLAMA_SHIM_MARKER in created_shim.read_text(encoding="utf-8")
assert str(tmp_path / "bin/llama-server") in res.stdout
def test_failed_fallback_sets_preflight_exit_127(tmp_path):
"""Scenario 6: No native + failed fallback installation -> ODYSSEUS_PREFLIGHT_EXIT=127."""
lines = [
'ODYSSEUS_PREFLIGHT_EXIT=""',
'export PATH="$HOME/bin:$PATH"',
]
_append_llama_server_shim_definition_lines(lines)
_append_llama_server_finalization_lines(lines)
lines.append('echo "EXIT=$ODYSSEUS_PREFLIGHT_EXIT"')
res = _run_shell(tmp_path, lines)
assert res.returncode == 0
assert "EXIT=127" in res.stdout
def test_python_server_readiness_requires_llama_cpp_server_module():
"""Scenario 7 & 8: Python server readiness requires `import llama_cpp.server`,
while GPU offload capability checks still use base `import llama_cpp`."""
routes_source = _read("routes/cookbook_routes.py")
helpers_source = _read("routes/cookbook_helpers.py")
# Windows fallback readiness check requires .server
assert 'try { python -c "import llama_cpp.server" 2>$null } catch {}' in routes_source
# Termux fallback readiness check requires .server
assert 'if ! python3 -c "import llama_cpp.server" 2>/dev/null; then' in routes_source
# Build-failure fallback pip check requires .server
assert 'if [ -z "$_odysseus_llama_bin" ] && ! python3 -c "import llama_cpp.server" 2>/dev/null; then' in routes_source
# Shim reconciliation and post-install finalization require .server
assert 'elif python3 -c "import llama_cpp.server" 2>/dev/null; then' in helpers_source
assert 'if [ -z "$_odysseus_llama_bin" ] && python3 -c "import llama_cpp.server" 2>/dev/null; then' in helpers_source
# GPU offload support checks still intentionally inspect base llama_cpp
assert 'llama_cpp.llama_supports_gpu_offload()' in routes_source
def test_darwin_native_build_does_not_force_write_bin_symlink():
"""Darwin source build must not overwrite ~/bin/llama-server with a forced symlink."""
source = _read("routes/cookbook_routes.py")
assert "&& ln -sf ~/llama.cpp/build/bin/llama-server ~/bin/llama-server" not in source
assert 'cmake --build build -j"$NPROC" --target llama-server' in source
@pytest.mark.asyncio
async def test_generated_runner_preflight_retires_stale_shim_before_backend_selection(monkeypatch, tmp_path):
"""Integration regression: The actual generated Cookbook runner must execute
ownership reconciliation BEFORE evaluating the backend selection (`if Termux ... elif ! command -v llama-server`).
Given:
- Executable stale Odysseus shim in ~/bin/llama-server (with canonical marker)
- Healthy native llama-server in ~/llama.cpp/build/bin/llama-server
- ~/bin before ~/llama.cpp/build/bin on PATH
Prove:
1. Generated runner contains reconciliation shell lines strictly before the backend branching
2. Executing the generated runner script retires the stale ~/bin/llama-server shim
3. Resolves to the healthy native binary without attempting source build or pip fallback.
"""
from unittest.mock import AsyncMock, MagicMock
from routes.cookbook_helpers import ServeRequest
import routes.cookbook_routes as cookbook_routes
from starlette.requests import Request
fake_home = tmp_path / "fake_home"
stale_shim = fake_home / "bin/llama-server"
_write_odysseus_shim(stale_shim)
native_bin = fake_home / "llama.cpp/build/bin/llama-server"
_write_mock_native(native_bin, succeeds=True)
# Mock environment to generate the real runner file with isolated DB
tmux_log_dir = tmp_path / "tmux_logs"
tmux_log_dir.mkdir(parents=True)
monkeypatch.setattr(cookbook_routes, "TMUX_LOG_DIR", tmux_log_dir)
monkeypatch.setattr(cookbook_routes, "require_admin", lambda request: None)
monkeypatch.setattr(cookbook_routes, "_binary_available", AsyncMock(return_value=True))
monkeypatch.setattr(cookbook_routes, "load_stored_hf_token", lambda **kwargs: "")
monkeypatch.setattr("core.database.SessionLocal", MagicMock())
class _Proc:
returncode = 0
stderr = None
async def wait(self):
return 0
monkeypatch.setattr(cookbook_routes.asyncio, "create_subprocess_shell", AsyncMock(return_value=_Proc()))
router = cookbook_routes.setup_cookbook_routes()
endpoint = None
for route in router.routes:
if route.path == "/api/model/serve" and "POST" in route.methods:
endpoint = route.endpoint
break
assert endpoint is not None
from types import SimpleNamespace
http_req = Request({
"type": "http",
"method": "POST",
"path": "/api/model/serve",
"headers": [],
"app": SimpleNamespace(state=SimpleNamespace(auth_manager=SimpleNamespace(
is_configured=True, is_admin=lambda user: user == "admin"))),
})
http_req.state.current_user = "admin"
http_req.state.local_model_authority = True
req = ServeRequest(
repo_id="test/repo",
cmd="llama-server -m /dummy/model.gguf --port 8085",
remote_host=None,
)
resp = await endpoint(http_req, req)
assert resp["ok"] is True
session_id = resp["session_id"]
runner_file = tmux_log_dir / f"{session_id}_run.sh"
assert runner_file.exists()
runner_script = runner_file.read_text(encoding="utf-8")
# Assert structural ordering in the generated script:
# Reconciliation must appear strictly BEFORE the Termux/backend check
recon_idx = runner_script.find("Native llama-server detected — retiring Odysseus compatibility shim in ~/bin")
termux_idx = runner_script.find("if [ -d /data/data/com.termux ]; then")
backend_idx = runner_script.find('elif [ -z "$_odysseus_llama_bin" ]; then')
assert recon_idx != -1, "Reconciliation block missing from generated runner"
assert termux_idx != -1, "Termux check missing from generated runner"
assert backend_idx != -1, "Backend selection missing from generated runner"
assert recon_idx < termux_idx < backend_idx, "Reconciliation must execute strictly before backend selection"
# Execute the generated runner preflight in a real subshell
res = _run_shell(fake_home, [str(runner_file)], path=f"{git_bash_path(fake_home / 'bin')}:{git_bash_path(fake_home / 'llama.cpp/build/bin')}:/usr/bin:/bin")
assert res.returncode == 0
assert not stale_shim.exists(), "Stale ~/bin/llama-server must have been retired by the generated preflight"
assert "RUN_NATIVE: -m /dummy/model.gguf --port 8085" in res.stdout
assert "Native llama-server not found — building from source" not in res.stdout
def test_broken_first_native_candidate_healthy_second_candidate_executes_healthy(tmp_path):
"""Regression finding P2: When PATH contains an earlier broken candidate followed by a
healthy candidate, reconciliation and launch must select and execute the healthy candidate."""
from core.platform_compat import git_bash_path
broken_dir = tmp_path / "broken_bin"
_write_mock_native(broken_dir / "llama-server", succeeds=False)
healthy_dir = tmp_path / "healthy_bin"
_write_mock_native(healthy_dir / "llama-server", succeeds=True)
shim_file = tmp_path / "bin/llama-server"
_write_odysseus_shim(shim_file)
lines = [
f'export PATH="$HOME/bin:{git_bash_path(broken_dir)}:{git_bash_path(healthy_dir)}:/usr/bin:/bin"',
]
_append_llama_server_shim_reconciliation_lines(lines)
lines.append('echo "RESOLVED=$_odysseus_llama_bin"')
lines.append('command -v llama-server')
lines.append('llama-server -m /dummy/model.gguf --port 8080')
res = _run_shell(tmp_path, lines)
assert res.returncode == 0
assert not shim_file.exists(), "Stale Odysseus shim must be retired"
assert f"RESOLVED={git_bash_path(healthy_dir / 'llama-server')}" in res.stdout
assert str(healthy_dir / "llama-server") in res.stdout
assert "RUN_NATIVE: -m /dummy/model.gguf --port 8080" in res.stdout
assert "error: missing libllama.so" not in res.stdout
def test_paths_with_spaces_broken_first_healthy_second_launch(tmp_path):
"""Regression: Paths containing spaces must be safely resolved, prioritized and launched."""
from core.platform_compat import git_bash_path
broken_dir = tmp_path / "broken path with spaces/bin"
_write_mock_native(broken_dir / "llama-server", succeeds=False)
healthy_dir = tmp_path / "healthy path with spaces/bin"
_write_mock_native(healthy_dir / "llama-server", succeeds=True)
shim_file = tmp_path / "bin/llama-server"
_write_odysseus_shim(shim_file)
model_dir = tmp_path / "models with spaces"
model_dir.mkdir(parents=True, exist_ok=True)
model_file = model_dir / "my model.gguf"
model_file.write_text("dummy", encoding="utf-8")
lines = [
f'export PATH="$HOME/bin:{git_bash_path(broken_dir)}:{git_bash_path(healthy_dir)}:/usr/bin:/bin"',
]
_append_llama_server_shim_reconciliation_lines(lines)
lines.append(f'llama-server -m "{git_bash_path(model_file)}" --port 8080')
res = _run_shell(tmp_path, lines)
assert res.returncode == 0
assert not shim_file.exists(), "Stale shim must be retired"
assert f"RUN_NATIVE: -m {git_bash_path(model_file)} --port 8080" in res.stdout
assert "error: missing libllama.so" not in res.stdout
def test_command_hash_invalidation_switches_from_cached_broken_to_healthy(tmp_path):
"""Regression: Command cache invalidation (`hash -r`) must prevent bash from reusing a
previously cached lookup to an earlier broken executable."""
from core.platform_compat import git_bash_path
broken_dir = tmp_path / "broken_bin"
_write_mock_native(broken_dir / "llama-server", succeeds=False)
healthy_dir = tmp_path / "healthy_bin"
_write_mock_native(healthy_dir / "llama-server", succeeds=True)
lines = [
# Populate bash hash cache with broken binary
f'export PATH="{git_bash_path(broken_dir)}:/usr/bin:/bin"',
'llama-server >/dev/null 2>&1 || true',
'hash',
# Now add healthy candidate later in PATH and run reconciliation
f'export PATH="$HOME/bin:{git_bash_path(broken_dir)}:{git_bash_path(healthy_dir)}:/usr/bin:/bin"',
]
_append_llama_server_shim_reconciliation_lines(lines)
lines.append('llama-server -m /dummy/test.gguf')
lines.append('hash')
res = _run_shell(tmp_path, lines)
assert res.returncode == 0
assert "RUN_NATIVE: -m /dummy/test.gguf" in res.stdout
# Verify hash table ended with healthy binary, not broken
assert str(healthy_dir / "llama-server") in res.stdout
def test_broken_native_candidate_falls_back_to_healthy_python_compatibility_shim(tmp_path):
"""Regression: A broken native candidate must not shadow or retire a healthy Python shim."""
from core.platform_compat import git_bash_path
broken_dir = tmp_path / "broken_bin"
_write_mock_native(broken_dir / "llama-server", succeeds=False)
shim_file = tmp_path / "bin/llama-server"
_write_odysseus_shim(shim_file)
mock_py_dir = tmp_path / "mock_bin"
_write_mock_python3(mock_py_dir / "python3", succeeds=True)
lines = [
f'export PATH="$HOME/bin:{git_bash_path(broken_dir)}:{git_bash_path(mock_py_dir)}:/usr/bin:/bin"',
]
_append_llama_server_shim_reconciliation_lines(lines)
lines.append('echo "RESOLVED=$_odysseus_llama_bin"')
lines.append('command -v llama-server')
res = _run_shell(tmp_path, lines)
assert res.returncode == 0
assert shim_file.exists(), "Healthy Python compatibility shim must be preserved"
assert f"RESOLVED={git_bash_path(shim_file)}" in res.stdout
assert str(shim_file) in res.stdout
def test_broken_native_and_missing_python_backend_sets_preflight_127(tmp_path):
"""Regression: When the only native candidates are broken and Python server is unavailable,
finalization must fail closed with ODYSSEUS_PREFLIGHT_EXIT=127 rather than claiming readiness."""
from core.platform_compat import git_bash_path
broken_dir = tmp_path / "broken_bin"
_write_mock_native(broken_dir / "llama-server", succeeds=False)
mock_py_dir = tmp_path / "mock_bin"
_write_mock_python3(mock_py_dir / "python3", succeeds=False)
lines = [
'ODYSSEUS_PREFLIGHT_EXIT=""',
f'export PATH="$HOME/bin:{git_bash_path(broken_dir)}:{git_bash_path(mock_py_dir)}:/usr/bin:/bin"',
]
_append_llama_server_shim_definition_lines(lines)
_append_llama_server_shim_reconciliation_lines(lines)
_append_llama_server_finalization_lines(lines)
lines.append('echo "EXIT=$ODYSSEUS_PREFLIGHT_EXIT"')
res = _run_shell(tmp_path, lines)
assert res.returncode == 0
assert "EXIT=127" in res.stdout
@pytest.mark.asyncio
async def test_generated_runner_broken_first_healthy_second_e2e(monkeypatch, tmp_path):
"""Integration regression: The full generated Cookbook runner script must launch the healthy
native candidate even when an earlier broken candidate exists on PATH."""
from unittest.mock import AsyncMock, MagicMock
from types import SimpleNamespace
from routes.cookbook_helpers import ServeRequest
import routes.cookbook_routes as cookbook_routes
from starlette.requests import Request
from core.platform_compat import git_bash_path
fake_home = tmp_path / "fake_home"
stale_shim = fake_home / "bin/llama-server"
_write_odysseus_shim(stale_shim)
broken_bin = fake_home / "broken/bin/llama-server"
_write_mock_native(broken_bin, succeeds=False)
healthy_bin = fake_home / "healthy/bin/llama-server"
_write_mock_native(healthy_bin, succeeds=True)
tmux_log_dir = tmp_path / "tmux_logs"
tmux_log_dir.mkdir(parents=True)
monkeypatch.setattr(cookbook_routes, "TMUX_LOG_DIR", tmux_log_dir)
monkeypatch.setattr(cookbook_routes, "require_admin", lambda request: None)
monkeypatch.setattr(cookbook_routes, "_binary_available", AsyncMock(return_value=True))
monkeypatch.setattr(cookbook_routes, "load_stored_hf_token", lambda **kwargs: "")
monkeypatch.setattr("core.database.SessionLocal", MagicMock())
class _Proc:
returncode = 0
stderr = None
async def wait(self):
return 0
monkeypatch.setattr(cookbook_routes.asyncio, "create_subprocess_shell", AsyncMock(return_value=_Proc()))
router = cookbook_routes.setup_cookbook_routes()
endpoint = None
for route in router.routes:
if route.path == "/api/model/serve" and "POST" in route.methods:
endpoint = route.endpoint
break
assert endpoint is not None
http_req = Request({
"type": "http",
"method": "POST",
"path": "/api/model/serve",
"headers": [],
"app": SimpleNamespace(state=SimpleNamespace(auth_manager=SimpleNamespace(
is_configured=True, is_admin=lambda user: user == "admin"))),
})
http_req.state.current_user = "admin"
http_req.state.local_model_authority = True
req = ServeRequest(
repo_id="test/repo",
cmd="llama-server -m /dummy/model.gguf --port 8090",
remote_host=None,
)
resp = await endpoint(http_req, req)
assert resp["ok"] is True
session_id = resp["session_id"]
runner_file = tmux_log_dir / f"{session_id}_run.sh"
assert runner_file.exists()
# Broken is earlier on PATH than healthy
res = _run_shell(
fake_home,
[str(runner_file)],
path=f"{git_bash_path(fake_home / 'bin')}:{git_bash_path(broken_bin.parent)}:{git_bash_path(healthy_bin.parent)}:/usr/bin:/bin",
)
assert res.returncode == 0
assert not stale_shim.exists(), "Stale shim must have been retired"
assert "RUN_NATIVE: -m /dummy/model.gguf --port 8090" in res.stdout
assert "error: missing libllama.so" not in res.stdout
assert "Native llama-server not found — building from source" not in res.stdout
@@ -0,0 +1,38 @@
"""Guard the only entry point to the Cookbook → Dependencies recipe panel.
The recipe panel (model picker, Pip/Docker toggle, commands and Run) is
rendered hidden, and only the `[data-dep-recipe-toggle]` caret shows it.
The HW Fit and serve-card deep links (`openCookbookDependencies(..., {
expandRecipe })`) open it by clicking that same caret. When the caret was
dropped from the row markup, nothing could open the panel (#6747).
"""
import re
from pathlib import Path
JS_DIR = Path(__file__).resolve().parent.parent / "static" / "js"
COOKBOOK_JS = JS_DIR / "cookbook.js"
DIAGNOSIS_JS = JS_DIR / "cookbook-diagnosis.js"
def _row_renderer(source: str) -> str:
start = source.index("const hasRecipe = RECIPE_BACKENDS.has(pkg.name);")
end = source.index("+ recipePanel;", start)
return source[start:end]
def test_recipe_rows_render_the_panel_toggle():
row = _row_renderer(COOKBOOK_JS.read_text(encoding="utf-8"))
caret = re.search(r"const recipeCaret = hasRecipe\s*\?\s*`([^`]*)`\s*:\s*'';", row)
assert caret, "recipe rows must render a caret, or the recipe panel cannot be opened"
assert 'data-dep-recipe-toggle="${esc(pkg.name)}"' in caret.group(1)
assert 'aria-expanded="false"' in caret.group(1)
assert "+ recipeCaret" in row
def test_toggle_handler_and_deep_link_use_the_rendered_attribute():
cookbook = COOKBOOK_JS.read_text(encoding="utf-8")
diagnosis = DIAGNOSIS_JS.read_text(encoding="utf-8")
assert "list.querySelectorAll('[data-dep-recipe-toggle]')" in cookbook
assert "row.querySelector('[data-dep-recipe-toggle]')" in diagnosis
@@ -0,0 +1,83 @@
"""A completed blob's empty retry marker must not keep its model stalled."""
import json
import os
import subprocess
import sys
import pytest
from routes.cookbook_helpers import _cached_model_scan_script
from routes.cookbook_output import HF_CACHE_COMPLETE_PROBE, HF_CACHE_INCOMPLETE_PROBE
_REPO = "fixture/stale-marker"
_STATES = ["complete", "stale", "empty-unpaired", "partial", "partial-paired", "empty-paired-empty"]
@pytest.fixture(params=["custom", "hf-home", "hub-env"])
def cache(request, tmp_path):
root = tmp_path / "cache with spaces"
model = root / "hub" / ("models--" + _REPO.replace("/", "--"))
blobs = model / "blobs"
blobs.mkdir(parents=True)
snapshot = model / "snapshots" / "revision"
snapshot.mkdir(parents=True)
(snapshot / "config.json").write_text("{}")
env = {key: value for key, value in os.environ.items()
if key not in {"HF_HOME", "HUGGINGFACE_HUB_CACHE", "HF_HUB_CACHE"}}
if request.param == "custom":
argument = str(root)
scan = _cached_model_scan_script(add_hf_cache=str(root / "hub"))
else:
argument = ""
env["HF_HOME" if request.param == "hf-home" else "HUGGINGFACE_HUB_CACHE"] = str(
root if request.param == "hf-home" else root / "hub"
)
scan = _cached_model_scan_script()
return blobs, argument, env, scan
def _seed(blobs, state):
if state in {"complete", "stale", "partial-paired"}:
(blobs / "sha").write_bytes(b"completed model data")
elif state == "empty-paired-empty":
(blobs / "sha").touch()
if state != "complete":
(blobs / "sha.incomplete").write_bytes(b"partial" if state.startswith("partial") else b"")
@pytest.mark.parametrize("state", _STATES)
@pytest.mark.parametrize("probe", [HF_CACHE_COMPLETE_PROBE, HF_CACHE_INCOMPLETE_PROBE], ids=["complete", "incomplete"])
def test_cache_status_probes_distinguish_stale_and_partial(cache, state, probe):
blobs, argument, env, _ = cache
_seed(blobs, state)
before = {path.name: path.read_bytes() for path in blobs.iterdir()}
result = subprocess.run([sys.executable, "-c", probe, _REPO, argument],
env=env, capture_output=True, text=True, timeout=15)
complete = state in {"complete", "stale"}
expected = complete if probe == HF_CACHE_COMPLETE_PROBE else not complete
assert result.returncode == (0 if expected else 1), result.stderr
assert {path.name: path.read_bytes() for path in blobs.iterdir()} == before
@pytest.mark.parametrize("state", _STATES)
def test_generated_inventory_agrees_without_mutating_cache(cache, state):
blobs, _, env, scan = cache
_seed(blobs, state)
before = {path.name: path.read_bytes() for path in blobs.iterdir()}
# Inventory's unrelated Ollama discovery stays off the network. Execute
# the actual generated scanner, including its cache-root resolution.
harness = (
"import urllib.request,shutil\n"
"def offline(*args,**kwargs): raise OSError('Ollama outside this fixture')\n"
"urllib.request.urlopen=offline\nshutil.which=lambda name: None\n"
+ scan
)
result = subprocess.run([sys.executable, "-c", harness], env=env,
capture_output=True, text=True, timeout=15)
assert result.returncode == 0, result.stderr
row = next(item for item in json.loads(result.stdout) if item["repo_id"] == _REPO)
assert row["has_incomplete"] == (state not in {"complete", "stale"})
if state in {"complete", "stale"}:
assert row["nb_files"] == 1
assert row["size_bytes"] == len(b"completed model data")
assert {path.name: path.read_bytes() for path in blobs.iterdir()} == before
+11 -2
View File
@@ -85,6 +85,9 @@ def test_actual_tests_restore_process_state_and_ignore_inherited_database(tmp_pa
import os
import sys
import pytest
import tempfile
from pathlib import Path
from sqlalchemy.engine import make_url
def snapshot():
import core
@@ -107,7 +110,11 @@ def test_actual_tests_restore_process_state_and_ignore_inherited_database(tmp_pa
class StateGuard:
def pytest_sessionstart(self):
self.initial = snapshot()
assert self.initial[0] == "sqlite:///:memory:"
assert self.initial[0] != inherited_url
self.default_path = Path(make_url(self.initial[0]).database)
assert self.default_path.is_file()
assert self.default_path.name == "collection.db"
assert self.default_path.parent == Path(tempfile.gettempdir()).parent
def pytest_collection_finish(self):
assert snapshot() == self.initial, "collection changed database bindings"
@@ -118,9 +125,11 @@ def test_actual_tests_restore_process_state_and_ignore_inherited_database(tmp_pa
assert snapshot() == self.initial, "test leaked database or module state"
inherited_url = os.environ["DATABASE_URL"]
guard = StateGuard()
result = pytest.main(["-q", "-p", "no:cacheprovider", *sys.argv[1:]],
plugins=[StateGuard()])
plugins=[guard])
assert os.environ["DATABASE_URL"] == inherited_url
assert not guard.default_path.exists(), "collection database outlived its runtime"
raise SystemExit(result)
''')
result = subprocess.run(
@@ -11,6 +11,8 @@ cache from the DB, where the rows are already gone, and the token is rejected.
"""
import asyncio
import types
import pytest
from fastapi import HTTPException
from routes.auth_routes import setup_auth_routes, DeleteUserRequest
@@ -60,12 +62,9 @@ def test_refused_delete_does_not_invalidate_cache():
invalidations = []
router = setup_auth_routes(_auth_manager(delete_result=False))
handler = _handler(router)
try:
with pytest.raises(HTTPException) as refused:
asyncio.run(handler(DeleteUserRequest(username="admin"), _fake_request(invalidations)))
raised = False
except Exception:
raised = True
assert raised, "a refused delete should raise (HTTP 400)"
assert refused.value.status_code == 400
assert invalidations == [], "a refused delete must not touch the token cache"
+47
View File
@@ -0,0 +1,47 @@
"""POST /api/documents/export-zip answers 400 for ids that are not a list of document ids."""
import json
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from fastapi import HTTPException
from tests.helpers.import_state import clear_fake_database_modules
clear_fake_database_modules()
import routes.document_routes as droutes
class _Req:
def __init__(self, body):
self.state = SimpleNamespace(current_user="tester")
self._body = body
async def json(self):
return self._body
def _export_zip():
router = droutes.setup_document_routes(MagicMock(), None)
for r in router.routes:
if getattr(r, "path", None) == "/api/documents/export-zip":
return r.endpoint
raise RuntimeError("export-zip route not found")
@pytest.mark.parametrize("body", [[], "x", 5, {}, {"ids": []}, {"ids": None}])
async def test_missing_ids_is_a_400(body):
with pytest.raises(HTTPException) as exc:
await _export_zip()(_Req(body))
assert exc.value.status_code == 400
assert exc.value.detail == "No documents specified"
@pytest.mark.parametrize("ids", [5, "abc", {"a": 1}, [1, 2], [["a"]], ["a", None]])
async def test_ids_that_are_not_a_list_of_strings_is_a_400(ids):
with pytest.raises(HTTPException) as exc:
await _export_zip()(_Req({"ids": ids}))
assert exc.value.status_code == 400
assert exc.value.detail == "ids must be a list of document ids"
@@ -93,7 +93,7 @@ def test_custom_filter_reuses_convolve_kernel():
assert "convolve(readSource(snap), k, divisor, bias, 1)" in EFFECTS
def test_new_filter_aliases_registered():
def test_blur_and_custom_filter_aliases_registered():
assert "'box-blur': 'blur-box'" in EDITOR
assert "'surface-blur': 'blur-surface'" in EDITOR
assert "custom: 'other-custom'" in EDITOR
+53
View File
@@ -1,6 +1,7 @@
"""DOM-XSS regressions for email plain-text linkification helpers."""
import json
import os
import shutil
import subprocess
import textwrap
@@ -100,3 +101,55 @@ def test_email_html_sanitizer_runs_to_fixpoint():
assert "for (let i = 0; i < 4; i++)" in source
assert "const next = _sanitizeHtmlOnce(out);" in source
assert "if (next === out) break;" in source
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_linkify_preserves_text_and_link_targets_in_a_real_dom():
cases = [
("See https://example.test/contact?email=jane@example.test&next=1 and bob@example.test.",
["https://example.test/contact?email=jane@example.test&next=1", "mailto:bob@example.test"]),
("https://alice@example.test/path", ["https://alice@example.test/path"]),
("www.example.test/?email=jane@example.test", ["https://www.example.test/?email=jane@example.test"]),
("<https://example.test/path> and \"jane@example.test\"",
["https://example.test/path", "mailto:jane@example.test"]),
("(https://example.test/path), jane@example.test!",
["https://example.test/path", "mailto:jane@example.test"]),
("https://example.test/?literal=&quot;&next=1", ["https://example.test/?literal=&quot;&next=1"]),
('<img src=x onerror="alert(1)"> jane@example.test', ["mailto:jane@example.test"]),
("No links\njust <plain> & text", []),
]
script = r'''
import { chromium } from 'playwright';
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN;
await page.goto(`${origin}/static/js/documentStats.js`);
const results = await page.evaluate(async (cases) => {
const { _escLinkify } = await import('/static/js/emailLibrary/utils.js');
return cases.map(([raw]) => {
const div = document.createElement('div');
div.innerHTML = _escLinkify(raw);
return {
text: div.textContent,
hrefs: [...div.querySelectorAll('a')].map(a => a.getAttribute('href')),
otherElements: [...div.querySelectorAll('*')].map(e => e.tagName).filter(t => t !== 'A'),
unsafeBlankLink: [...div.querySelectorAll('a[target="_blank"]')]
.some(a => !a.relList.contains('noopener') || !a.relList.contains('noreferrer')),
};
});
}, JSON.parse(process.env.ODYSSEUS_LINKIFY_CASES));
console.log(JSON.stringify(results));
} finally {
await browser.close();
}
'''
result = subprocess.run(
["node", "--input-type=module", "-e", script], cwd=_REPO,
capture_output=True, text=True, timeout=30,
env={**os.environ, "ODYSSEUS_LINKIFY_CASES": json.dumps(cases)},
)
assert result.returncode == 0, result.stderr
results = json.loads(result.stdout)
for (raw, hrefs), actual in zip(cases, results, strict=True):
assert actual == {"text": raw, "hrefs": hrefs, "otherElements": [], "unsafeBlankLink": False}, raw
@@ -56,14 +56,35 @@ def test_cold_start_with_no_snapshot_and_no_index_is_not_marked_stale(monkeypatc
# falls through to the real (blocking) path, so the response is not a
# stale-marked snapshot.
endpoint = _list_endpoint()
calls = []
class EmptyMailbox:
def select(self, folder, readonly=False):
calls.append(("select", folder, readonly))
return "OK", [b"0"]
def uid(self, command, *args):
assert command == "SEARCH"
calls.append(("search", args))
return "OK", [b""]
def logout(self):
pass
def connect(*args, **kwargs):
calls.append(("connect", args, kwargs))
return EmptyMailbox()
monkeypatch.setattr(er, "_start_poller", lambda: None)
monkeypatch.setattr(er, "_email_index_list", lambda *a, **k: ([], 0, None))
monkeypatch.setattr(er, "_imap_connect", lambda *a, **k: (_ for _ in ()).throw(
RuntimeError("offline test")))
try:
result = _call(endpoint)
except Exception:
return # the cold path legitimately attempted real IMAP
monkeypatch.setattr(er, "_imap_connect", connect)
result = _call(endpoint)
assert [call[0] for call in calls] == ["connect", "select", "search"]
assert calls[1][2] is True
assert isinstance(result, dict)
assert result["emails"] == []
assert result["total"] == 0
assert not result.get("error")
assert not (result.get("sync") or {}).get("stale")
@@ -0,0 +1,61 @@
"""The native FileList must survive resetting the compose file input."""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
from tests.helpers.document_source import function_body
@pytest.mark.parametrize("handler,uploader", [("_handleAttachUpload", "_uploadComposeFiles"), ("_handleMarkdownImageUpload", "_uploadMarkdownImages")])
@pytest.mark.parametrize("selections", [[["one.png"]], [["one.png", "two.png"]], [["one.png"], ["one.png"]], [[]]])
def test_compose_file_selection_survives_reset(tmp_path, selections, handler, uploader):
if not shutil.which("node"):
pytest.skip("node not installed")
repo = Path(__file__).resolve().parents[1]
for name, icon in [("one.png", "icon-192.png"), ("two.png", "icon-512.png")]:
shutil.copyfile(repo / "static/icons" / icon, tmp_path / name)
script = r"""
const fs=require('node:fs'),assert=require('node:assert/strict'),crypto=require('node:crypto');
let chromium;try{({chromium}=require('playwright'));}catch(e){if(e.code==='MODULE_NOT_FOUND')process.exit(77);throw e;}
(async()=>{
const browser=await chromium.launch({headless:true});
try{
const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));
await page.goto(process.env.ODYSSEUS_TEST_STATIC_ORIGIN+'/static/js/storage.js');
await page.setContent('<input type="file" multiple aria-label="Compose attachments">');
await page.addScriptTag({content:`
window.uploads=[];window.finished=0;
async function __UPLOADER__(files){
for(const file of files){
const hash=Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',await file.arrayBuffer()))).map(x=>x.toString(16).padStart(2,'0')).join('');
window.uploads.push({name:file.name,hash});
}
}
__HANDLER__
document.querySelector('input').addEventListener('change',async event=>{
await __HANDLER_NAME__(event);window.finished++;
});
`});
const selections=JSON.parse(process.argv[1]),root=process.argv[2],expected=[];
for(const [index,names] of selections.entries()){
await page.getByLabel('Compose attachments').setInputFiles(names.map(name=>root+'/'+name));
await page.waitForFunction(n=>window.finished===n,index+1);
expected.push(...names.map(name=>({name,hash:crypto.createHash('sha256').update(fs.readFileSync(root+'/'+name)).digest('hex')})));
assert.deepEqual(await page.evaluate(()=>window.uploads),expected);
assert.equal(await page.getByLabel('Compose attachments').inputValue(),'');
assert.equal(await page.getByLabel('Compose attachments').evaluate(el=>el.files.length),0);
}
assert.deepEqual(errors,[]);console.log(JSON.stringify({files:expected.length}));
}finally{await browser.close();}
})().catch(e=>{console.error(e);process.exitCode=1;});
""".replace("__HANDLER__", function_body(handler)).replace("__HANDLER_NAME__", handler).replace("__UPLOADER__", uploader)
result = subprocess.run(["node", "-e", script, json.dumps(selections), str(tmp_path)],
cwd=Path(__file__).resolve().parents[1], capture_output=True,
text=True, timeout=30)
if result.returncode == 77:
pytest.skip("playwright not installed")
assert result.returncode == 0, result.stdout + result.stderr
assert json.loads(result.stdout)["files"] == sum(map(len, selections))
+39
View File
@@ -1,3 +1,8 @@
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
import routes.font_routes as font_routes
from routes.font_routes import _derive_family
@@ -9,3 +14,37 @@ def test_derive_family_splits_common_family_suffixes():
assert _derive_family("FiraCode-SemiBold.ttf") == "Fira Code"
assert _derive_family("NotoSans-Bold.otf") == "Noto Sans"
assert _derive_family("RobotoSlab-Bold.woff2") == "Roboto Slab"
@pytest.mark.parametrize("filename,expected", [
("JetBrainsMono-SemiBoldItalic.woff2", "JetBrains Mono"),
("FiraCode-BoldItalic.ttf", "Fira Code"),
("NotoSans-ExtraLightItalic.otf", "Noto Sans"),
("RobotoSlab-UltraBoldOblique.WOFF2", "Roboto Slab"),
("SourceSansPro-Bold-Italic.woff", "Source Sans Pro"),
("FiraCode-SemiBold_Italic.ttf", "Fira Code"),
("FiraCode-Italic.woff2", "Fira Code"),
("FiraCode-VF.woff2", "Fira Code"),
("MyFont-Book.woff2", "My Font Book"),
])
def test_compound_weight_and_style_are_not_part_of_the_family(filename, expected):
assert _derive_family(filename) == expected
def test_custom_font_endpoint_groups_all_styles_under_one_family(tmp_path, monkeypatch):
files = ["JetBrainsMono-Regular.woff2", "JetBrainsMono-SemiBoldItalic.woff2",
"JetBrainsMono-Bold-Oblique.ttf"]
for name in [*files, "OtherFont-Regular.otf", "README.md"]:
(tmp_path / name).write_bytes(b"filename-only discovery fixture")
monkeypatch.setattr(font_routes, "CUSTOM_FONTS_DIR", str(tmp_path))
app = FastAPI()
app.include_router(font_routes.setup_font_routes())
with TestClient(app) as client:
response = client.get("/api/fonts/custom")
assert response.status_code == 200
fonts = response.json()["fonts"]
assert set(fonts) == {"JetBrains Mono", "Other Font"}
assert fonts["JetBrains Mono"] == [
{"file": name, "url": f"/static/fonts/custom/{name}", "format": name.rsplit(".", 1)[1]}
for name in sorted(files)
]
+37
View File
@@ -8,6 +8,8 @@ label with `[^*]+`, so they could not match a label like "Email *" / "State *"
stayed empty and the exported PDF (and signed-reply attachment) came out blank
for that field, with no error.
"""
import pytest
from src.pdf_form_doc import render_form_as_markdown, parse_markdown_to_values
@@ -38,3 +40,38 @@ def test_plain_labels_and_colon_values_unaffected():
vals = parse_markdown_to_values(md)
assert vals["name"] == "Alice"
assert vals["time"] == "9:00 sharp"
@pytest.mark.parametrize("name", ["Prénom", "Año", "İsim", "氏名", "اسم", "n²", "Cafe\u0301"])
@pytest.mark.parametrize("field_type, value", [
("text", "Alice"), ("checkbox", True), ("choice", "Yes"),
])
def test_unicode_field_names_survive_export_roundtrip(name, field_type, value):
fields = [{"name": name, "label": "Field *", "type": field_type,
"value": value, "options": ["Yes", "No"], "page": 1}]
markdown = render_form_as_markdown(fields, "upload", "Form")
marker_name = markdown.split("<!-- field=", 1)[1].split(" type=", 1)[0]
assert marker_name.isascii()
assert parse_markdown_to_values(markdown) == {name: value}
def test_previously_saved_unicode_markers_remain_exportable():
# Older imports emitted Unicode letters literally. Their persisted
# markdown must also work after upgrading, without re-importing the PDF.
markdown = (
"- **First name:** Alice <!-- field=Prénom type=text -->\n"
"- [x] **Consent** <!-- field=同意 type=checkbox -->\n"
"- **Year** [2025 / 2026]: 2026 <!-- field=Año type=choice -->\n"
)
assert parse_markdown_to_values(markdown) == {
"Prénom": "Alice", "同意": True, "Año": "2026",
}
def test_unicode_names_do_not_collide_with_literal_percent_or_ascii_names():
names = ["Prénom", "Pr%C3%A9nom", "Prenom", "Section.1_name-2"]
fields = [{"name": name, "type": "text", "value": str(i), "page": 1}
for i, name in enumerate(names)]
markdown = render_form_as_markdown(fields, "upload", "Form")
assert "field=Section.1_name-2 " in markdown
assert parse_markdown_to_values(markdown) == {name: str(i) for i, name in enumerate(names)}
+46
View File
@@ -0,0 +1,46 @@
import asyncio
import base64
import io
import pytest
from fastapi import HTTPException
from PIL import Image
import routes.gallery_routes as gallery
ROUTES = ['sharpen', 'remove-bg', 'denoise', 'upscale-local', 'enhance-face']
def route(name):
return next(r for r in gallery.setup_gallery_routes().routes if r.path == f'/api/image/{name}')
def call(name, monkeypatch, body):
monkeypatch.setattr(gallery, 'require_privilege', lambda *args: 'alice')
monkeypatch.setattr(gallery, '_local_image_models_enabled', lambda: True)
monkeypatch.setattr(gallery, '_remote_image_ops_base', lambda: '')
class Request:
async def json(self):
return body
return asyncio.run(route(name).endpoint(Request()))
@pytest.mark.parametrize('name', ROUTES)
@pytest.mark.parametrize('body', [
{},
{'image': '!!notb64'},
{'image': base64.b64encode(b'hello').decode()},
])
def test_bad_image_is_a_client_error(name, body, monkeypatch):
with pytest.raises(HTTPException) as exc:
call(name, monkeypatch, body)
assert exc.value.status_code == 400
def test_sharpen_still_accepts_a_valid_image(monkeypatch):
output = io.BytesIO()
Image.new('RGB', (4, 4), 'red').save(output, format='PNG')
result = call('sharpen', monkeypatch, {'image': base64.b64encode(output.getvalue()).decode()})
assert Image.open(io.BytesIO(base64.b64decode(result['image']))).size == (4, 4)
@@ -0,0 +1,59 @@
"""Native image calls must match the server schema and provide a prompt.
A missing prompt must be rejected before the fallback line parser mistakes the
whole arguments object for the requested image.
"""
import json
def _schemas():
import src.agent_tools # noqa: F401 (tool_schemas <-> agent_tools import cycle)
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
return {s["function"]["name"]: s["function"] for s in FUNCTION_TOOL_SCHEMAS}
def _convert(arguments):
import src.agent_tools # noqa: F401
from src.tool_schemas import function_call_to_tool_block
return function_call_to_tool_block("generate_image", json.dumps(arguments))
def test_generate_image_is_offered_to_native_function_calling_models():
schemas = _schemas()
assert "generate_image" in schemas, (
"generate_image has no native schema, so it is filtered out of the tools "
"sent to API models even when the selector picks it"
)
assert schemas["generate_image"]["parameters"]["required"] == ["prompt"]
def test_native_call_reaches_the_executor_with_its_arguments_intact():
from src.tool_execution import _build_mcp_args
args = {"prompt": "a cat riding a bicycle", "model": "gpt-image-1",
"size": "1024x1024", "quality": "high"}
block = _convert(args)
assert block is not None and block.tool_type == "generate_image"
assert _build_mcp_args("generate_image", block.content) == args
def test_call_without_a_prompt_is_rejected_instead_of_drawing_its_own_arguments():
"""No prompt key means the line parser takes the whole JSON blob as the prompt,
so the model gets an image of its own arguments."""
assert _convert({"size": "512x512"}) is None
assert _convert({}) is None
assert _convert({"prompt": " "}) is None
def test_advertised_parameters_are_the_ones_the_image_server_accepts():
"""Anything the schema advertises but the executor drops is a silent no-op."""
from mcp_servers.image_gen_server import list_tools
import asyncio
server_schema = asyncio.run(list_tools())[0].inputSchema
assert set(_schemas()["generate_image"]["parameters"]["properties"]) == set(
server_schema["properties"]
)
+67
View File
@@ -0,0 +1,67 @@
"""Peer injections retain their context role but hydrate as the named agent."""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
from tests.helpers.js_modules import js_function_source
ROOT = Path(__file__).resolve().parents[1]
def _node(script, *args):
result = subprocess.run(['node', '--input-type=module', '-e', script, *map(json.dumps, args)],
cwd=ROOT, capture_output=True, text=True, timeout=15)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
@pytest.mark.skipif(not shutil.which('node'), reason='node not installed')
@pytest.mark.parametrize('mode', ['parallel', 'round-robin'])
def test_peer_sync_persists_explicit_identity(mode):
source = (ROOT / 'static/js/group.js').read_text()
functions = '\n'.join(js_function_source(name, source) for name in ['_syncAllResponses', '_sendRoundRobin'])
data = _node(r'''
const source=JSON.parse(process.argv[1]),mode=JSON.parse(process.argv[2]);
const _models=[{mid:'model-a',display:'Model A',_groupName:'Persona A'}, {mid:'model-b',display:'Model B'}];
const _participantSessions=['a','b'],API_BASE='http://app',sent=[];
let _abortControllers=[];
const uiModule={scrollHistory(){}};
function _createGroupBubble(model){return {dataset:{raw:'Reply from '+model.mid}};}
async function _streamToHolder(){} function _saveState(){}
async function fetch(url,options){sent.push({url,...JSON.parse(options.body)});return {ok:true};}
eval(source+'\nglobalThis.runSync=async()=> mode==="parallel" ? _syncAllResponses(_models.map(_createGroupBubble)) : _sendRoundRobin("Hi",null);');
await globalThis.runSync();console.log(JSON.stringify(sent));
''', functions, mode)
assert len(data) == 2
for item in data:
message = item['messages'][0]
assert message['role'] == 'user' # Model context must retain peer attribution.
peer = 'model-b' if item['url'].endswith('/a/inject_messages') else 'model-a'
name = 'Model B' if peer == 'model-b' else 'Persona A'
assert message['content'] == f'[{name}]: Reply from {peer}'
assert message['metadata'] == {'group_peer': True, 'group_model': name, 'model': peer}
@pytest.mark.skipif(not shutil.which('node'), reason='node not installed')
@pytest.mark.parametrize('role,content,meta,expected_role,expected_content', [
('user', '[Persona A]: Reply', {'group_peer': True, 'group_model': 'Persona A'}, 'assistant', 'Reply'),
('user', '[Persona A]: Human text', {}, 'user', '[Persona A]: Human text'),
('user', 'Human text', {}, 'user', 'Human text'),
('assistant', 'Parent reply', {'group_model': 'Persona A'}, 'assistant', 'Parent reply'),
('user', 'No matching prefix', {'group_peer': True, 'group_model': 'Persona A'}, 'user', 'No matching prefix'),
('user', '[3]: Human text', {'group_peer': True, 'group_model': 3}, 'user', '[3]: Human text'),
('user', '[Persona A]: Human text', {'group_peer': False, 'group_model': 'Persona A'}, 'user', '[Persona A]: Human text'),
])
def test_history_uses_explicit_peer_identity_only(role, content, meta, expected_role, expected_content):
source = js_function_source('_renderHistoryMessage', (ROOT / 'static/js/sessions.js').read_text())
result = _node(r'''
const source=JSON.parse(process.argv[1]),message=JSON.parse(process.argv[2]);
function _displayHistoryContent(text){return text;}function _stripUserVisionBlocks(text){return text;}
function _addHistoryMessageWithFullRenderer(role,content,model,metadata){return {role,content,metadata};}
const run=eval('('+source+')');console.log(JSON.stringify(run(message,'session-model')));
''', source, {'role': role, 'content': content, 'metadata': meta})
assert result['role'] == expected_role
assert result['content'] == expected_content
assert result['metadata'] == {**meta, '_fromHistory': True}
+33
View File
@@ -0,0 +1,33 @@
from services.hwfit.hf_discovery import _entry_from_collection_item
_SOURCE = {"owner": "Qwen", "provider": "Qwen"}
_COLLECTION = {"title": "Qwen3.6"}
def _item(repo_id, num_params):
item = {"id": repo_id, "type": "model", "pipeline_tag": "text-generation"}
if num_params is not None:
item["numParameters"] = num_params
return item
def test_moe_active_size_is_read_from_name_when_hf_reports_total():
# HF collection items carry numParameters for nearly every repo. The MoE
# active size only exists in the name, so it must still be parsed.
entry = _entry_from_collection_item(_COLLECTION, _item("Qwen/Qwen3.6-35B-A3B", 35_951_822_704), _SOURCE)
assert entry["parameters_raw"] == 35_951_822_704
assert entry["is_moe"] is True
assert entry["active_parameters"] == 3_000_000_000
def test_moe_entry_without_hf_count_falls_back_to_name():
entry = _entry_from_collection_item(_COLLECTION, _item("Qwen/Qwen3-Coder-30B-A3B-Instruct", None), _SOURCE)
assert entry["parameters_raw"] == 30_000_000_000
assert entry["is_moe"] is True
assert entry["active_parameters"] == 3_000_000_000
def test_dense_entry_keeps_hf_count_and_is_not_moe():
entry = _entry_from_collection_item(_COLLECTION, _item("Qwen/Qwen3.6-27B", 27_781_427_952), _SOURCE)
assert entry["parameters_raw"] == 27_781_427_952
assert "is_moe" not in entry
@@ -0,0 +1,31 @@
import pytest
from services.hwfit.hf_discovery import _entry_from_collection_item
_SOURCE = {"owner": "google", "provider": "Google"}
_COLLECTION = {"title": "collection"}
@pytest.mark.parametrize("repo_id,pipeline_tag", [
("google/timesfm-3.0-pytorch", "time-series-forecasting"),
("google/siglip2-base-patch16-224", "zero-shot-image-classification"),
("moonshotai/MoonViT-SO-400M", "image-feature-extraction"),
("zai-org/CogView4-6B", "text-to-image"),
("zai-org/CogVideoX-5b-I2V", "image-to-video"),
("zai-org/CogVideoX1.5-5B", "text-to-video"),
])
def test_non_llm_collection_models_are_skipped(repo_id, pipeline_tag):
item = {"id": repo_id, "type": "model", "pipeline_tag": pipeline_tag, "numParameters": 1_000_000_000}
assert _entry_from_collection_item(_COLLECTION, item, _SOURCE) is None
@pytest.mark.parametrize("repo_id,pipeline_tag", [
("google/gemma-4-12B-it", "image-text-to-text"),
("Qwen/Qwen3.6-27B", "text-generation"),
("Qwen/Qwen3-Embedding-4B", "feature-extraction"),
("NousResearch/DeepHermes-Egregore-v2-RLAIF-8b-Atropos", "reinforcement-learning"),
("Qwen/Qwen3-Next-80B-A3B-Instruct", ""),
])
def test_servable_collection_models_are_kept(repo_id, pipeline_tag):
item = {"id": repo_id, "type": "model", "pipeline_tag": pipeline_tag, "numParameters": 4_000_000_000}
assert _entry_from_collection_item(_COLLECTION, item, _SOURCE) is not None
+74
View File
@@ -0,0 +1,74 @@
"""Explicit quality choices must take precedence over the configured default."""
import asyncio
import json
import pytest
from PIL import Image
@pytest.mark.parametrize("caller", ["json", "lines", "mcp", "edit"])
@pytest.mark.parametrize("requested,configured,expected", [
("medium", "high", "medium"),
("medium", "low", "medium"),
("low", "high", "low"),
("high", "low", "high"),
("auto", "high", "auto"),
(None, "high", "high"),
(None, None, "medium"),
])
def test_image_quality_precedence(monkeypatch, tmp_path, caller, requested, configured, expected):
from src import ai_interaction, settings
from mcp_servers.image_gen_server import call_tool
config = {"image_gen_enabled": True, "image_model": "gpt-image-1"}
if configured is not None:
config["image_quality"] = configured
monkeypatch.setattr(settings, "load_settings", lambda: config)
monkeypatch.setattr(ai_interaction, "_resolve_model", lambda *args, **kwargs:
("http://fixture.invalid/v1/chat/completions", "gpt-image-1", {}))
captured = []
class Response:
status_code = 418
text = "Fixture captured request"
def json(self):
return {"error": {"message": self.text}}
class Client:
def __init__(self, *args, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *args):
pass
async def post(self, url, **kwargs):
captured.append(kwargs.get("json") or kwargs.get("data"))
return Response()
monkeypatch.setattr("httpx.AsyncClient", Client)
args = {"prompt": "Fixture image", "model": "gpt-image-1"}
if requested is not None:
args["quality"] = requested
if caller == "json":
operation = ai_interaction.do_generate_image(json.dumps(args), incognito=True)
elif caller == "lines":
content = "Fixture image\ngpt-image-1\n1024x1024"
if requested is not None:
content += "\n" + requested
operation = ai_interaction.do_generate_image(content, incognito=True)
elif caller == "mcp":
operation = call_tool("generate_image", args)
else:
source = tmp_path / "fixture.png"
Image.new("RGB", (2, 2), "white").save(source)
kwargs = {} if requested is None else {"quality": requested}
operation = ai_interaction.do_edit_image("Fixture image", str(source), size="1024x1024",
incognito=True, **kwargs)
result = asyncio.run(operation)
assert "418" in str(result), result
assert len(captured) == 1
assert captured[0]["quality"] == expected
+25
View File
@@ -177,3 +177,28 @@ def test_update_integration_rejects_invalid_base_url_without_changing_existing(
assert exc.value.status_code == 400
assert exc.value.detail == message
assert integrations.load_integrations()[0]["base_url"] == "https://example.test"
@pytest.mark.parametrize("body", [[], "x", 5, None])
def test_create_integration_rejects_non_object_body(integrations_routes, body):
endpoint, session_cookie, http_exception = integrations_routes
create_integration = endpoint("/api/auth/integrations", "POST")
with pytest.raises(http_exception) as exc:
asyncio.run(create_integration(_JsonRequest(body, session_cookie)))
assert exc.value.status_code == 400
assert integrations.load_integrations() == []
@pytest.mark.parametrize("body", [[], "x", 5, None])
def test_update_integration_rejects_non_object_body(integrations_routes, body):
endpoint, session_cookie, http_exception = integrations_routes
update_integration = endpoint("/api/auth/integrations/{integration_id}", "PUT")
integrations.save_integrations([{"id": "existing", "name": "Original", "base_url": "https://example.test"}])
with pytest.raises(http_exception) as exc:
asyncio.run(update_integration("existing", _JsonRequest(body, session_cookie)))
assert exc.value.status_code == 400
assert integrations.load_integrations()[0]["name"] == "Original"
+115
View File
@@ -1091,6 +1091,121 @@ def test_degenerate_stream_guard_allows_long_rows_with_changing_values():
assert guard.check(rows) is None
def _latex_matrix(cell):
rows = (" & ".join(cell(i, j) for j in range(8)) for i in range(8))
return "$$\\begin{pmatrix}\n" + " \\\\\n".join(rows) + "\n\\end{pmatrix}$$\n"
@pytest.mark.parametrize("text", [
_latex_matrix(lambda i, j: "0"),
_latex_matrix(lambda i, j: "1" if i == j else "0"),
_latex_matrix(lambda i, j: "2e10"),
_latex_matrix(lambda i, j: "1e-5"),
_latex_matrix(lambda i, j: "1.25e-3"),
_latex_matrix(lambda i, j: "-0.125"),
_latex_matrix(lambda i, j: "0.0"),
_latex_matrix(lambda i, j: "3.14"),
"buf = [" + ", ".join("0" for _ in range(64)) + "]\n",
"buf = [" + ", ".join("1.25e-3" for _ in range(64)) + "]\n",
"buf = [" + ", ".join("-0.125" for _ in range(64)) + "]\n",
"t,v1,v2,v3\n" + "\n".join(f"{i},1.25e-3,-0.125,2e10" for i in range(25)) + "\n",
"\n".join(f"{i}. Solve: $\\frac{{{i}}}{{{i+1}}}x + \\frac{{1}}{{{i+2}}} = \\frac{{{i+3}}}{{{i+4}}}$" for i in range(1, 20)) + "\n",
"<svg>" + "".join(f'<rect x="{i*10}" y="{j*10}" width="10" height="10" fill="#abc"/>' for i in range(4) for j in range(4)) + "</svg>\n",
"{" + ", ".join(f'"key_{i}": "translation_{i}"' for i in range(40)) + "}\n",
])
@pytest.mark.parametrize("chunk", [1, 3, 6, 8, 16, 32, 64, 128])
def test_degenerate_stream_guard_allows_numeric_data(text, chunk):
"""#5560: legitimate matrices, scientific numbers, tables, and structured data
repeat values or formatting by design; they are not token collapse."""
guard = llm_core._DegenerateStreamGuard("math-model")
assert all(guard.check(text[i:i + chunk]) is None for i in range(0, len(text), chunk))
@pytest.mark.parametrize("chunk", [1, 6, 64, 3000])
def test_degenerate_stream_guard_allows_numeric_data_full_and_char_stream(chunk):
text = _latex_matrix(lambda i, j: "1.25e-3")
guard = llm_core._DegenerateStreamGuard("math-model")
assert all(guard.check(text[i:i + chunk]) is None for i in range(0, len(text), chunk))
@pytest.mark.parametrize("loop_text", [
"0 " * 3000,
"1.25e-3 " * 3000,
"1 2 " * 1500,
])
@pytest.mark.parametrize("chunk", [1, 6, 32, 64, 3000])
def test_degenerate_stream_guard_still_stops_an_endless_number_run(loop_text, chunk):
guard = llm_core._DegenerateStreamGuard("looping-model")
stopped = False
for i in range(0, len(loop_text), chunk):
res = guard.check(loop_text[i:i + chunk])
if res is not None:
assert "numbers in a row" in res
stopped = True
break
assert stopped
@pytest.mark.parametrize("loop_text", [
"row 0 0 0 0 " * 300,
"Step 1 2 3 " * 200,
])
@pytest.mark.parametrize("chunk", [1, 6, 32, 64, 1500])
def test_degenerate_stream_guard_still_stops_a_mixed_word_number_loop(loop_text, chunk):
guard = llm_core._DegenerateStreamGuard("looping-model")
stopped = False
for i in range(0, len(loop_text), chunk):
res = guard.check(loop_text[i:i + chunk])
if res is not None:
assert "repeated" in res
stopped = True
break
assert stopped
@pytest.mark.parametrize("loop_text", [
"Summer " * 80,
"also be a software developer mode " * 30,
])
@pytest.mark.parametrize("chunk", [1, 6, 32, 64, 500])
def test_degenerate_stream_guard_still_stops_repeated_words_and_phrases(loop_text, chunk):
guard = llm_core._DegenerateStreamGuard("looping-model")
stopped = False
for i in range(0, len(loop_text), chunk):
res = guard.check(loop_text[i:i + chunk])
if res is not None:
assert "repeated" in res
stopped = True
break
assert stopped
@pytest.mark.parametrize("bad_token", [
"1face ",
"1e ",
"25e ",
"0x12 ",
])
@pytest.mark.parametrize("chunk", [1, 6, 32, 64, 250])
def test_degenerate_stream_guard_stops_alphanumeric_repetition_resembling_numbers(bad_token, chunk):
guard = llm_core._DegenerateStreamGuard("looping-model")
text = bad_token * 50
stopped = False
for i in range(0, len(text), chunk):
res = guard.check(text[i:i + chunk])
if res is not None:
assert "repeated" in res
stopped = True
break
assert stopped
def test_terminal_stream_retries_degenerate_generation_before_emitting(monkeypatch):
calls = 0
requests = []
+19 -1
View File
@@ -51,7 +51,7 @@ async def test_cron_create_edit_resume_and_invalid_edit_rollback(storage):
@pytest.mark.asyncio
@pytest.mark.parametrize('expression', [None, '', 'bad', '99 99 * * *'])
@pytest.mark.parametrize('expression', [None, '', 'bad', '99 99 * * *', '* * * * * *', '* * * * * * *'])
async def test_invalid_cron_does_not_create_inert_task(storage, expression):
result = await call(action='create', name='Invalid', prompt='Test', schedule='cron',
cron_expression=expression)
@@ -171,3 +171,21 @@ async def test_invalid_cron_retime_rolls_back_all_edits(storage):
row = db.get(database.ScheduledTask, result['task_id'])
assert row.name == 'Digest' and row.cron_expression == '15 9 * * 1,3,5'
assert row.next_run == datetime(2026, 10, 5, 9, 15)
@pytest.mark.parametrize('expression', ['* * * * *', '15 9 * * 1,3,5', '*/5 * * * *'])
def test_is_valid_cron_accepts_five_fields(expression):
from src.task_scheduler import is_valid_cron
assert is_valid_cron(expression)
@pytest.mark.parametrize('expression', [None, '', 'bad', '99 99 * * *', '* * * * * *', '* * * * * * *', '* * * *'])
def test_is_valid_cron_rejects_everything_else(expression):
from src.task_scheduler import is_valid_cron
assert not is_valid_cron(expression)
@pytest.mark.parametrize('expression', ['* * * * * *', '* * * * * * *'])
def test_compute_next_run_refuses_a_seconds_field(expression):
from src.task_scheduler import compute_next_run
assert compute_next_run('cron', '09:00', None, None, cron_expression=expression) is None
+71
View File
@@ -39,3 +39,74 @@ def test_http_transport_routes_to_start_http_connect():
result = asyncio.run(mgr.connect_server("id1", "n", "http", url="https://x/mcp"))
assert result == "ROUTED"
m.assert_called_once()
def _patch_http_transport(monkeypatch, initialize):
"""Fake the Streamable HTTP transport and record, for each close, whether
it ran in the task that opened it (AnyIO requires that)."""
import contextlib
import mcp
import mcp.client.streamable_http as streamable_http
import src.mcp_oauth as mcp_oauth
closes = []
@contextlib.asynccontextmanager
async def fake_client(url, auth=None):
opener = asyncio.current_task()
try:
yield (object(), object(), lambda: None)
finally:
closes.append(asyncio.current_task() is opener)
class FakeSession:
def __init__(self, *a):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
FakeSession.initialize = initialize
monkeypatch.setattr(streamable_http, "streamablehttp_client", fake_client)
monkeypatch.setattr(mcp, "ClientSession", FakeSession)
monkeypatch.setattr(mcp_oauth, "build_provider", lambda *a, **k: None)
return closes
def test_failed_http_connect_closes_transport_in_its_own_task(monkeypatch):
"""#5518: a failed connect left its transport for the GC, which exited the
AnyIO cancel scope from the wrong task and kept the event loop spinning."""
async def initialize(self):
raise RuntimeError("Session terminated")
closes = _patch_http_transport(monkeypatch, initialize)
mgr = McpManager()
ok = asyncio.run(mgr._start_http_connect("s1", "remote", "http://x/mcp/", wait=2))
assert ok is False
assert mgr._connections["s1"]["status"] == "error"
assert "Session terminated" in mgr._connections["s1"]["error"]
assert closes == [True]
assert "s1" not in mgr._stacks
def test_cancelled_http_connect_closes_transport(monkeypatch):
"""Deleting a server mid-connect cancels the connect task; the transport
must still be closed rather than leaked."""
async def initialize(self):
await asyncio.sleep(3600)
closes = _patch_http_transport(monkeypatch, initialize)
mgr = McpManager()
async def run():
await mgr._start_http_connect("s1", "remote", "http://x/mcp/", wait=0.05)
await mgr.disconnect_server("s1")
await asyncio.sleep(0)
asyncio.run(run())
assert closes == [True]
+33
View File
@@ -0,0 +1,33 @@
"""A malformed provider row must not hide its valid neighboring models."""
import pytest
from src.model_discovery import ModelDiscovery
@pytest.mark.parametrize('wrapped', [False, True], ids=['bare', 'openai'])
@pytest.mark.parametrize('invalid_id', [None, '', 0, False, [], {}, 42, True, ['bad'], {'bad': 'id'}])
def test_discovery_keeps_valid_models_beside_invalid_ids(monkeypatch, wrapped, invalid_id):
discovery = ModelDiscovery('fixture')
rows = [{'id': '/valid-model'}, {'id': invalid_id}, 'not a row', None, {'id': 'second-model'}]
payload = {'data': rows} if wrapped else rows
class Response:
is_success = True
def json(self):return payload
monkeypatch.setattr('src.model_discovery.httpx.get', lambda *_args, **_kwargs: Response())
monkeypatch.setattr(discovery, '_fingerprint_provider', lambda *_:'fixture')
result = discovery._check_port('fixture', 8080)
assert result is not None
assert result['models'] == ['/valid-model', 'second-model']
assert result['models_display'] == ['valid-model', 'second-model']
assert result['provider'] == 'fixture'
@pytest.mark.parametrize('payload', [{'data': [{'id': 42}]}, [{'id': ['bad']}], {'data': []}])
def test_discovery_with_no_valid_ids_stays_unavailable(monkeypatch, payload):
class Response:
is_success = True
def json(self):return payload
monkeypatch.setattr('src.model_discovery.httpx.get', lambda *_args, **_kwargs:Response())
assert ModelDiscovery('fixture')._check_port('fixture',8080) is None
+82
View File
@@ -0,0 +1,82 @@
"""Explicit null must clear nullable note fields; omission must preserve them."""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from tests.helpers.database import disposable_database
@pytest.fixture
def client(tmp_path, monkeypatch):
import routes.note.note_routes as routes
with disposable_database(tmp_path) as factory:
monkeypatch.setattr(routes, "SessionLocal", factory)
app = FastAPI()
@app.middleware("http")
async def identity(request, call_next):
request.state.current_user = request.headers.get("x-test-user", "alice")
return await call_next(request)
monkeypatch.setattr(routes, "_scheduler_ref", None)
app.include_router(routes.setup_note_routes())
with TestClient(app) as api:
yield api
@pytest.mark.parametrize("field,value", [
("due_date", "2030-10-08T09:00:00"),
("label", "home"),
("image_url", "https://example.com/note.png"),
("content", "Saved body"),
("items", [{"text": "Saved item", "done": False}]),
("color", "blue"),
("gallery_id", "gallery-1"),
("agent_session_id", "chat-1"),
])
def test_explicit_null_clears_saved_field(client, field, value):
created = client.post("/api/notes", json={"title": "Test note"})
assert created.status_code == 200, created.text
path = f"/api/notes/{created.json()['id']}"
populated = client.put(path, json={field: value})
assert populated.status_code == 200, populated.text
assert client.get(path).json()[field] == value
updated = client.put(path, json={field: None})
assert updated.status_code == 200, updated.text
saved = client.get(path)
assert saved.status_code == 200, saved.text
assert saved.json()[field] is None
assert updated.json()[field] is None
def test_omitted_fields_survive_title_edit(client):
fields = {
"due_date": "2030-10-08T09:00:00",
"label": "home",
"image_url": "https://example.com/note.png",
"content": "Saved body",
"items": [{"text": "Saved item", "done": False}],
"color": "blue",
"gallery_id": "gallery-1",
}
created = client.post("/api/notes", json={"title": "Before", **fields})
assert created.status_code == 200, created.text
path = f"/api/notes/{created.json()['id']}"
updated = client.put(path, json={"title": "After"})
assert updated.status_code == 200, updated.text
saved = client.get(path).json()
assert saved["title"] == "After"
for field, value in fields.items():
assert saved[field] == value
def test_null_update_cannot_clear_another_users_note(client):
created = client.post("/api/notes", json={"title": "Private", "label": "home"})
assert created.status_code == 200, created.text
path = f"/api/notes/{created.json()['id']}"
response = client.put(path, json={"label": None}, headers={"x-test-user": "bob"})
assert response.status_code == 404
assert client.get(path).json()["label"] == "home"
+43
View File
@@ -0,0 +1,43 @@
"""A detached Notes descendant must not hold output open or kill a bystander."""
import os
import runpy
import subprocess
import sys
from pathlib import Path
import pytest
from src import process_lifecycle as lifecycle
ROOT = Path(__file__).resolve().parents[1]
@pytest.mark.skipif(os.name == "nt", reason="POSIX detached-session regression; Windows uses taskkill tree")
def test_notes_cleanup_reaps_detached_pipe_holder_and_preserves_bystander():
stop = runpy.run_path(str(ROOT / "scripts/verify_notes_browser_runtime.py"))["_stop_owned_server"]
child = "import time; time.sleep(60)"
program = ("import subprocess,sys,time; "
f"p=subprocess.Popen([sys.executable,'-c',{child!r}],start_new_session=True); "
"print(p.pid,flush=True); time.sleep(60)")
with subprocess.Popen([sys.executable, "-c", child]) as bystander:
server = subprocess.Popen([sys.executable, "-c", program], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True, start_new_session=True)
root_identity = lifecycle.ProcessIdentity.capture(server.pid)
detached = None
try:
child_pid = int(server.stdout.readline())
detached = lifecycle.ProcessIdentity.capture(child_pid)
assert os.getpgid(child_pid) == child_pid
stop(server, root_identity)
assert server.poll() is not None
assert detached.exited()
assert bystander.poll() is None, "cleanup touched an unrelated process"
finally:
# Test-owned fallback cleanup also runs when the negative control
# intentionally substitutes the old parent-only teardown.
targets = [identity for identity in (detached, root_identity) if identity]
lifecycle.terminate_identities(targets, steps=lifecycle.term_kill_steps(1))
server.wait(timeout=3)
if server.stdout:
server.stdout.close()
bystander.terminate()
bystander.wait(timeout=3)
+3 -1
View File
@@ -144,10 +144,12 @@ def test_editor_agent_endpoint_reports_scoped_tools():
async def _route_contract(monkeypatch, message, *, attachment_meta=(), form=None, messages_out=None):
from routes import chat_routes
from src import tool_security
from src import tool_security, tool_utils
from tests.test_foreground_model_routing import _RouteRequest, _chat_stream_endpoint
with monkeypatch.context() as route_patch:
# This built-in catalog fixture has no configured external MCP servers.
route_patch.setattr(tool_utils, "get_mcp_manager", lambda: None)
endpoint = _chat_stream_endpoint(
route_patch, "agent", {},
session_model="odysseus-qwen3.5-tools-pre-heretic",
+155 -34
View File
@@ -12,6 +12,8 @@ exercising every procfs-touching code path on both a Linux and a non-Linux
host. ``test_cookbook_stop_without_procfs.py`` covers the behaviour itself.
"""
import ast
import pytest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
@@ -75,37 +77,112 @@ def _scan_sites(tree: ast.AST) -> list[ast.Call]:
return sites
def _guard_lines(tree: ast.AST) -> list[int]:
"""Line numbers of calls that test whether procfs is present."""
lines = []
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
func = node.func
name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", "")
if name not in _GUARD_FUNCS:
continue
subject_is_procfs = (
name in {"has_procfs", "is_wsl"}
or (isinstance(func, ast.Attribute) and _is_procfs_root(func.value))
or any(_is_procfs_root(arg) for arg in node.args)
)
if subject_is_procfs:
lines.append(node.lineno)
return lines
def _condition_requires_procfs(node: ast.AST, truth: bool) -> bool:
"""Whether this Boolean outcome proves a direct procfs check succeeded.
An ignored call, another function's check, or a check on only one possible
OR branch provides no proof. Keep unknown expressions conservative.
"""
if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not):
return _condition_requires_procfs(node.operand, not truth)
if isinstance(node, ast.BoolOp):
facts = [_condition_requires_procfs(value, truth) for value in node.values]
all_values_required = isinstance(node.op, ast.And) == truth
return any(facts) if all_values_required else all(facts)
if not isinstance(node, ast.Call):
return False
func = node.func
name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", "")
subject_is_procfs = (
name == "has_procfs"
or (isinstance(func, ast.Attribute) and _is_procfs_root(func.value))
or any(_is_procfs_root(arg) for arg in node.args)
)
return truth and name in _GUARD_FUNCS and subject_is_procfs
def _enclosing_scope(tree: ast.AST, node: ast.AST) -> ast.AST:
"""Smallest function/module scope containing ``node``."""
best = tree
for candidate in ast.walk(tree):
if not isinstance(candidate, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
end = getattr(candidate, "end_lineno", None) or candidate.lineno
if candidate.lineno <= node.lineno <= end:
if best is tree or candidate.lineno > best.lineno:
best = candidate
return best
def _guarded_scan_ids(tree: ast.AST) -> set[int]:
"""Follow conditional/early-exit paths instead of comparing line numbers.
Each function starts with no procfs assumption. Facts from a loop or try
body cannot escape to code which may run without that body completing.
This intentionally requires direct checks; arbitrary aliases/call graphs
are not a proof that procfs exists at the enumeration site.
"""
scans = {id(site) for site in _scan_sites(tree)}
guarded = set()
def expression(node, present):
if node is None:
return
if id(node) in scans and present:
guarded.add(id(node))
if isinstance(node, ast.BoolOp):
for value in node.values:
expression(value, present)
# Later AND operands run only after a true prefix; later OR
# operands run only after a false prefix.
present = present or _condition_requires_procfs(value, isinstance(node.op, ast.And))
elif isinstance(node, ast.IfExp):
expression(node.test, present)
expression(node.body, present or _condition_requires_procfs(node.test, True))
expression(node.orelse, present or _condition_requires_procfs(node.test, False))
elif isinstance(node, (ast.ListComp, ast.SetComp, ast.GeneratorExp, ast.DictComp)):
for generator in node.generators:
expression(generator.iter, present)
for condition in generator.ifs:
expression(condition, present)
present = present or _condition_requires_procfs(condition, True)
for attr in ("elt", "key", "value"):
expression(getattr(node, attr, None), present)
elif isinstance(node, ast.Lambda):
expression(node.body, False)
else:
for child in ast.iter_child_nodes(node):
expression(child, present)
def block(statements, present):
for statement in statements:
if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)):
block(statement.body, False)
for value in statement.decorator_list + statement.args.defaults:
expression(value, present)
elif isinstance(statement, ast.ClassDef):
block(statement.body, present)
elif isinstance(statement, ast.If):
expression(statement.test, present)
yes = block(statement.body, present or _condition_requires_procfs(statement.test, True))
no = block(statement.orelse, present or _condition_requires_procfs(statement.test, False))
continuing = [fact for fact in (yes, no) if fact is not None]
if not continuing:
return None
present = all(continuing)
elif isinstance(statement, (ast.Return, ast.Raise, ast.Break, ast.Continue)):
for child in ast.iter_child_nodes(statement):
expression(child, present)
return None
elif isinstance(statement, (ast.For, ast.AsyncFor, ast.While)):
condition = statement.test if isinstance(statement, ast.While) else statement.iter
expression(condition, present)
body_present = present or (isinstance(statement, ast.While) and _condition_requires_procfs(condition, True))
block(statement.body, body_present)
block(statement.orelse, present)
elif isinstance(statement, (ast.Try, getattr(ast, "TryStar", ast.Try))):
block(statement.body, present)
block(statement.orelse, present)
block(statement.finalbody, present)
for handler in statement.handlers:
block(handler.body, present)
elif isinstance(statement, (ast.With, ast.AsyncWith)):
for item in statement.items:
expression(item.context_expr, present)
block(statement.body, present)
else:
expression(statement, present)
return present
block(tree.body, False)
return guarded
def _app_python_files() -> list[Path]:
@@ -139,12 +216,10 @@ def _collect_sites() -> tuple[list[str], list[str]]:
sites = _scan_sites(tree)
if not sites:
continue
guards = _guard_lines(tree)
guards = _guarded_scan_ids(tree)
for site in sites:
scope = _enclosing_scope(tree, site)
start = getattr(scope, "lineno", 0)
label = f"{path.relative_to(REPO_ROOT)}:{site.lineno}"
if any(start <= g < site.lineno for g in guards):
if id(site) in guards:
guarded.append(label)
else:
unguarded.append(label)
@@ -154,7 +229,7 @@ def _collect_sites() -> tuple[list[str], list[str]]:
def test_every_procfs_scan_is_guarded_by_an_existence_check():
_guarded, unguarded = _collect_sites()
assert not unguarded, (
"procfs pid scans with no existence check in the enclosing function — "
"procfs pid scans without an effective conditional existence guard — "
"these raise FileNotFoundError on macOS and Windows: "
+ ", ".join(unguarded)
)
@@ -176,3 +251,49 @@ def test_the_guard_detector_still_sees_the_known_scans():
f"root was renamed. Found: {sorted(found)}"
)
assert len(found) >= 3, f"expected at least 3 procfs scans, found {sorted(found)}"
@pytest.mark.parametrize("body", [
'Path("/proc").exists(); os.listdir("/proc")',
'if Path("/proc").exists():\n pass\nos.listdir("/proc")',
'if not Path("/proc").exists():\n os.listdir("/proc")',
'if ready or Path("/proc").exists():\n os.listdir("/proc")',
'if ready:\n Path("/proc").exists()\nos.listdir("/proc")',
'os.listdir("/proc"); Path("/proc").exists()',
'if Path("/tmp").exists():\n os.listdir("/proc")',
'def other():\n Path("/proc").exists()\nos.listdir("/proc")',
'if not has_procfs():\n pass\nos.listdir("/proc")',
'for x in []:\n if not has_procfs():\n return\nos.listdir("/proc")',
'if has_procfs():\n return\nelse:\n os.listdir("/proc")',
'[pid for pid in os.listdir("/proc") if has_procfs()]',
], ids=["ignored", "expired-branch", "inverted", "unsafe-or", "sibling-branch",
"too-late", "wrong-path", "other-function", "no-early-exit", "loop-may-not-run",
"wrong-else", "late-comprehension-filter"])
def test_guard_detector_rejects_ineffective_checks(body):
tree = ast.parse("def scan():\n" + "\n".join(" " + line for line in body.splitlines()))
sites = _scan_sites(tree)
assert sites, "negative control must contain a recognized enumeration"
assert any(id(site) not in _guarded_scan_ids(tree) for site in sites)
@pytest.mark.parametrize("body", [
'if Path("/proc").exists():\n os.listdir("/proc")',
'if not Path("/proc").exists():\n return\nos.listdir("/proc")',
'if not has_procfs():\n raise RuntimeError()\nos.listdir(PROC_ROOT)',
'if has_procfs():\n pass\nelse:\n return\nPROC_ROOT.iterdir()',
'if ready and has_procfs():\n os.scandir("/proc")',
'if not has_procfs() or disabled:\n return\nPROC_ROOT.glob("*")',
'if sys.platform == "linux" and has_procfs():\n PROC_ROOT.iterdir()',
'for item in items:\n if not has_procfs():\n continue\n os.listdir("/proc")',
'if os.path.isdir("/proc"):\n os.listdir("/proc")',
'if has_procfs() and list(PROC_ROOT.iterdir()):\n pass',
'if not has_procfs() or list(PROC_ROOT.iterdir()):\n pass',
'[pid for item in items if has_procfs() for pid in os.listdir("/proc")]',
], ids=["positive-branch", "early-return", "early-raise", "else-exits", "and",
"negative-or", "platform-and-procfs", "loop-continue", "isdir",
"short-circuit-and", "short-circuit-or", "guarded-comprehension"])
def test_guard_detector_preserves_effective_checks(body):
tree = ast.parse("def scan():\n" + "\n".join(" " + line for line in body.splitlines()))
sites = _scan_sites(tree)
assert sites, "positive control must contain a recognized enumeration"
assert all(id(site) in _guarded_scan_ids(tree) for site in sites)
+141
View File
@@ -0,0 +1,141 @@
"""Regression tests for py/polynomial-redos sinks in the agent preview parsers.
Each pattern had overlapping quantifiers (adjacent `\\s` runs, or a lazy group
that rescanned the rest of the line from every candidate) that backtrack
polynomially when the tail fails. The rewrites keep the same matches for the
text these tools emit. The old patterns took 5-20s on the flood inputs below;
the new ones finish in milliseconds against a 4s budget.
"""
import time
import pytest
from src.browser_observation import compact_browser_observation
from src.clean_agent_preview import (
email_search_result_empty,
is_google_sorry_url,
skills_terminal_response,
tasks_terminal_response,
)
_BUDGET_S = 4.0
_DASH = chr(0x2014) # the separator src/tools/system.py writes into task rows
def _timed(fn, *args):
start = time.perf_counter()
result = fn(*args)
return result, time.perf_counter() - start
# Browser snapshot: bare `generic` container lines are dropped, others kept.
def test_generic_snapshot_lines_are_dropped():
snapshot = '- generic [ref=e1]:\n - generic\n-generic:\n- generic button\n- button "Go" [ref=e2]'
output = compact_browser_observation({'snapshot': snapshot})
assert output == '- generic button\n- button "Go" [ref=e2]'
def test_generic_filter_whitespace_flood_is_fast():
(output, dt) = _timed(compact_browser_observation, {'snapshot': '\t' * 120000 + '!'})
assert dt < _BUDGET_S, f'generic filter took {dt:.2f}s'
assert output.endswith('!')
# Browser CLI text: status text is split from the post-interaction state.
@pytest.mark.parametrize('label', ['post-click page state', 'page state after failed click'])
def test_post_interaction_state_is_split(label):
raw = f'Clicked\n\n[{label}]\n{{"snapshot": "- link \\"A\\" [ref=e3]"}}'
assert compact_browser_observation(raw) == 'Clicked\n\n- link "A" [ref=e3]'
@pytest.mark.parametrize('opener', ['\n\n[post-', '\n\n[page state after failed '])
def test_post_interaction_split_flood_is_fast(opener):
(_, dt) = _timed(compact_browser_observation, 'x' + opener * 15000)
assert dt < _BUDGET_S, f'post-interaction split took {dt:.2f}s'
# Task rows: `N. name (id) <dash> detail`, where the name may contain parentheses.
def test_task_rows_parse():
raw = (f'1. Backup (nightly) (abc123) {_DASH} active, daily at 02:00\n'
f'2. (e2) {_DASH} paused\n'
f'not a task row')
assert tasks_terminal_response(raw) == (
f'Tasks (2):\n- [Backup (nightly)](#task-abc123) {_DASH} active\n'
f'- [](#task-e2) {_DASH} paused'
)
@pytest.mark.parametrize('evil', ['0.' + '\t' * 3000 + '!', '0.\t\t(' + '\t((' * 20000])
def test_task_row_flood_is_fast(evil):
(output, dt) = _timed(tasks_terminal_response, evil)
assert dt < _BUDGET_S, f'task row parse took {dt:.2f}s'
assert output == evil.strip()
# Skill inventory: `- name (category): summary` under a status heading.
def test_skill_rows_parse():
raw = '## Published\n- pdf-tools (documents): Read PDFs\n- web\n## Drafts\n- notes (writing)'
assert skills_terminal_response(raw) == (
'Skills (3):\n\n**Published**\n- [pdf-tools](#skill-pdf-tools) (documents)\n'
'- [web](#skill-web)\n\n**Drafts**\n- [notes](#skill-notes) (writing)'
)
def test_nameless_skill_bullet_is_not_a_row():
raw = '## Published\n- : x'
assert skills_terminal_response(raw) == raw
def test_skill_row_flood_is_fast():
(_, dt) = _timed(skills_terminal_response, '## Published\n- \t9' + '\t\t' * 30000 + '!')
assert dt < _BUDGET_S, f'skill row parse took {dt:.2f}s'
# Email search: the plain-text "no results" message.
@pytest.mark.parametrize('value,expected', [
('No emails matched from:bob.', True),
(' no emails matched "invoice"\n\n', True),
('No emails matched ', False),
('No emails matched x\nmore', False),
('Found 2 emails', False),
])
def test_email_no_results_message(value, expected):
assert email_search_result_empty(value) is expected
def test_email_no_results_flood_is_fast():
(result, dt) = _timed(email_search_result_empty, 'No emails matched x' + '\t' * 60000 + '\n!')
assert dt < _BUDGET_S, f'email no-results check took {dt:.2f}s'
assert result is False
# Google rate-limit interstitial.
@pytest.mark.parametrize('url,expected', [
('https://www.google.com/sorry/index?continue=x', True),
('http://google.co.uk/sorry/', True),
('HTTPS://IPV4.GOOGLE.COM/SORRY/', True),
('https://example.com/sorry/?next=https://www.google.de/sorry/', True),
('https://example.com/sorry/', False),
('https://notgoogle.com/sorry/', False),
('https://www.google.com/search?q=sorry', False),
('', False),
])
def test_is_google_sorry_url(url, expected):
assert is_google_sorry_url(url) is expected
@pytest.mark.parametrize('evil', [
'http://google.' + '..google.' * 50000,
'http://' + 'x.' * 200000 + 'google./sorry/', # reaches the host match
])
def test_google_sorry_flood_is_fast(evil):
(result, dt) = _timed(is_google_sorry_url, evil)
assert dt < _BUDGET_S, f'google sorry check took {dt:.2f}s'
assert result is False
+4 -3
View File
@@ -128,7 +128,7 @@ def test_auto_sort_keeps_fresh_session_while_first_response_is_pending(monkeypat
db.close()
def test_auto_sort_still_deletes_old_throwaway_sessions(monkeypatch):
def test_auto_sort_keeps_old_nonempty_sessions_even_with_throwaway_title(monkeypatch):
session_factory = _make_session_factory()
_install_session_factory(monkeypatch, session_factory)
@@ -160,7 +160,8 @@ def test_auto_sort_still_deletes_old_throwaway_sessions(monkeypatch):
db = session_factory()
try:
assert db.query(DbSession).filter(DbSession.id == sid).first() is None
assert "Cleaned 1 sessions" in result
assert db.query(DbSession).filter(DbSession.id == sid).first() is not None
assert db.query(DbMessage).filter(DbMessage.session_id == sid).count() == 1
assert "Cleaned 0 sessions" in result
finally:
db.close()
+13 -9
View File
@@ -19,6 +19,7 @@ from core.database import ChatMessage as DbMessage
from core.database import Session as DbSession
_TMPDB = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
_TMPDB.close() # SQLite owns its connections; release the Windows file handle.
_ENGINE = create_engine(
f"sqlite:///{_TMPDB.name}",
connect_args={"check_same_thread": False},
@@ -230,12 +231,14 @@ def test_patch_session_updates_persisted_cwd(monkeypatch):
def test_auto_sort_skip_llm_cleans_owner_stamped_sessions_when_auth_disabled(monkeypatch):
import routes.session_routes as sr
from fastapi import APIRouter
from unittest.mock import MagicMock
_stub_multipart_if_missing(monkeypatch)
monkeypatch.setenv("AUTH_ENABLED", "false")
monkeypatch.setattr(sr, "SessionLocal", _TS)
monkeypatch.setattr(sr, "effective_user", lambda request: None)
monkeypatch.setattr(sr, "router", APIRouter(prefix="/api"))
sid = str(uuid.uuid4())
old_time = cdb.utcnow_naive() - timedelta(hours=2)
@@ -250,19 +253,12 @@ def test_auto_sort_skip_llm_cleans_owner_stamped_sessions_when_auth_disabled(mon
endpoint_url="http://localhost",
model="gpt-4",
archived=False,
message_count=1,
message_count=0,
created_at=old_time,
updated_at=old_time,
last_message_at=old_time,
last_accessed=old_time,
))
db.add(DbMessage(
id="m-" + uuid.uuid4().hex,
session_id=sid,
role="user",
content="hi",
timestamp=old_time,
))
db.commit()
finally:
db.close()
@@ -270,6 +266,13 @@ def test_auto_sort_skip_llm_cleans_owner_stamped_sessions_when_auth_disabled(mon
session = MagicMock(id=sid, name="New chat", model="gpt-4", endpoint_url="http://localhost", rag=False, archived=False)
sm = MagicMock()
sm.get_sessions_for_user.return_value = {sid: session}
def delete_session(session_id):
with _TS() as db:
db.query(DbSession).filter(DbSession.id == session_id).delete()
db.commit()
sm.delete_session.side_effect = delete_session
router = sr.setup_session_routes(sm, {})
endpoint = next(r.endpoint for r in router.routes
if getattr(r, "path", "") == "/api/sessions/auto-sort"
@@ -277,7 +280,8 @@ def test_auto_sort_skip_llm_cleans_owner_stamped_sessions_when_auth_disabled(mon
result = endpoint(request=MagicMock(), skip_llm=True)
assert result["deleted_throwaway"] == 1
assert result["deleted_empty"] == 1
assert result["deleted_throwaway"] == 0
db = _TS()
try:
assert db.query(DbSession).filter(DbSession.id == sid).first() is None
+76
View File
@@ -0,0 +1,76 @@
"""Raw skill source needs a usable editor height without hiding its footer."""
import json
import os
from pathlib import Path
import shutil
import subprocess
from bs4 import BeautifulSoup
import pytest
@pytest.mark.parametrize("width", [1440, 390])
@pytest.mark.parametrize("lines", [8, 40, 400])
def test_skill_editor_source_height(tmp_path, width, lines):
if not shutil.which("node"):
pytest.skip("node not installed")
repo = Path(__file__).resolve().parents[1]
index = BeautifulSoup((repo / "static/index.html").read_text(), "html.parser")
modal = index.select_one("#memory-modal")
modal["class"] = [value for value in modal.get("class", []) if value != "hidden"]
for panel in modal.select("[data-memory-panel]"):
classes = set(panel.get("class", []))
if panel["data-memory-panel"] == "skills":
classes.discard("hidden")
else:
classes.add("hidden")
panel["class"] = sorted(classes)
styles = "".join(str(node) for node in index.select('link[rel="stylesheet"]'))
document = tmp_path / "skills.html"
document.write_text(styles + str(modal) + str(index.select_one("#toast")), encoding="utf-8")
script = r"""
const fs=require('node:fs'),assert=require('node:assert/strict');
let chromium;
try{({chromium}=require('playwright'));}catch(e){if(e.code==='MODULE_NOT_FOUND')process.exit(77);throw e;}
(async()=>{
const browser=await chromium.launch({headless:true});
try{
const origin=process.env.ODYSSEUS_TEST_STATIC_ORIGIN,lines=Number(process.argv[3]);
const page=await browser.newPage({viewport:{width:Number(process.argv[2]),height:900}});page.setDefaultTimeout(5000);
let source=Array.from({length:lines},(_,i)=>'Line '+(i+1)+' of synthetic skill source').join('\n'),posts=0;
const errors=[];page.on('pageerror',e=>errors.push(e.message));
await page.route(origin+'/api/**',async route=>{
const request=route.request(),path=new URL(request.url()).pathname;let data={};
if(path==='/api/skills')data={skills:[{name:'fixture-height',description:'Source height fixture',status:'draft',source:'user',confidence:0.9}]};
else if(path.endsWith('/markdown')){
if(request.method()==='POST'){posts++;source=request.postDataJSON().markdown;data={ok:true};}else data={markdown:source};
}else if(path.endsWith('/audit-all/status'))data={status:'none'};
await route.fulfill({contentType:'application/json',body:JSON.stringify(data)});
});
await page.goto(origin+'/static/js/storage.js');await page.setContent(fs.readFileSync(process.argv[1],'utf8'));
await page.evaluate(async()=>{const skills=await import('/static/js/skills.js');await skills.loadSkills();});
const card=page.locator('.skill-card[data-skill-name="fixture-height"]');await card.locator('.skill-card-name').click();
await card.getByRole('button',{name:'Edit',exact:true}).click();const editor=card.locator('.skill-md-editor');await editor.waitFor({state:'visible'});
const geometry=await editor.evaluate(el=>{
const box=el.getBoundingClientRect(),lineHeight=parseFloat(getComputedStyle(el).lineHeight);
const modal=el.closest('.modal-content').getBoundingClientRect();
return {height:box.height,visibleLines:el.clientHeight/lineHeight,modalBottom:modal.bottom,viewportHeight:innerHeight};
});
assert(geometry.visibleLines>=Math.min(lines,8),JSON.stringify(geometry));
const save=card.getByRole('button',{name:'Save',exact:true}),bounds=await save.boundingBox();
assert(bounds.y>=0&&bounds.y+bounds.height<=geometry.viewportHeight,JSON.stringify({geometry,bounds}));
assert(geometry.modalBottom<=geometry.viewportHeight+1,JSON.stringify(geometry));
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false);
await editor.fill(source+'\nSaved final line');await save.click();await page.waitForFunction(()=>!document.querySelector('.skill-md-editor'));
assert.equal(posts,1);assert(source.endsWith('Saved final line'));assert.deepEqual(errors,[]);
console.log(JSON.stringify({width:process.argv[2],lines,geometry,posts}));
}finally{await browser.close();}
})().catch(e=>{console.error(e);process.exitCode=1;});
"""
result = subprocess.run(["node", "-e", script, str(document), str(width), str(lines)],
cwd=repo, env=os.environ.copy(), capture_output=True,
text=True, timeout=45)
if result.returncode == 77:
pytest.skip("playwright not installed")
assert result.returncode == 0, result.stdout + result.stderr
assert json.loads(result.stdout)["posts"] == 1
@@ -0,0 +1,99 @@
"""An unloaded or failed SKILL.md must never become editable placeholder text."""
import json
import os
from pathlib import Path
import shutil
import subprocess
from bs4 import BeautifulSoup
import pytest
@pytest.mark.parametrize("width", [1440, 390])
@pytest.mark.parametrize("mode", ["delayed", "error", "menu-error", "empty", "collapse"])
def test_skill_editor_waits_for_source(tmp_path, width, mode):
if not shutil.which("node"):
pytest.skip("node not installed")
repo = Path(__file__).resolve().parents[1]
index = BeautifulSoup((repo / "static/index.html").read_text(), "html.parser")
modal = index.select_one("#memory-modal")
modal["class"] = [value for value in modal.get("class", []) if value != "hidden"]
for panel in modal.select("[data-memory-panel]"):
classes = set(panel.get("class", []))
if panel["data-memory-panel"] == "skills":
classes.discard("hidden")
else:
classes.add("hidden")
panel["class"] = sorted(classes)
styles = "".join(str(node) for node in index.select('link[rel="stylesheet"]'))
document = tmp_path / "skills.html"
document.write_text(styles + str(modal) + str(index.select_one("#toast")), encoding="utf-8")
script = r"""
const fs=require('node:fs'),assert=require('node:assert/strict');
let chromium;try{({chromium}=require('playwright'));}catch(e){if(e.code==='MODULE_NOT_FOUND')process.exit(77);throw e;}
(async()=>{
const browser=await chromium.launch({headless:true});
try{
const origin=process.env.ODYSSEUS_TEST_STATIC_ORIGIN,mode=process.argv[3];
const page=await browser.newPage({viewport:{width:Number(process.argv[2]),height:900}});page.setDefaultTimeout(5000);
const original=mode==='empty'?'':'---\nname: fixture-loading\n---\n\n## Procedure\nOriginal source';
let source=original,posts=0,gets=0,release;const gate=new Promise(r=>release=r),errors=[];
page.on('pageerror',e=>errors.push(e.message));
await page.route(origin+'/api/**',async route=>{
const request=route.request(),path=new URL(request.url()).pathname;let data={};
if(path==='/api/skills')data={skills:[{name:'fixture-loading',description:'Lazy loading fixture',status:'draft',source:'user',confidence:0.9}]};
else if(path.endsWith('/markdown')){
if(request.method()==='POST'){posts++;source=request.postDataJSON().markdown;data={ok:true};}
else{
gets++;if(gets===1)await gate;
if((mode==='error'&&gets===1)||(mode==='menu-error'&&gets<=2)){
await route.fulfill({status:500,contentType:'application/json',body:JSON.stringify({detail:'Fixture read failed'})});return;
}
data={markdown:source};
}
}else if(path.endsWith('/audit-all/status'))data={status:'none'};
await route.fulfill({contentType:'application/json',body:JSON.stringify(data)});
});
await page.goto(origin+'/static/js/storage.js');await page.setContent(fs.readFileSync(process.argv[1],'utf8'));
await page.evaluate(async()=>{const skills=await import('/static/js/skills.js');await skills.loadSkills();});
const card=page.locator('.skill-card[data-skill-name="fixture-loading"]');await card.locator('.skill-card-name').click();
await card.getByText('Loading…',{exact:true}).waitFor();
const edit=card.getByRole('button',{name:'Edit',exact:true});assert(await edit.isDisabled(),'Edit must stay disabled while source is pending');
assert.equal(await card.locator('.skill-md-editor').count(),0);assert.equal(posts,0);
if(mode==='collapse')await card.locator('.skill-chevron-up').click();
release();
if(mode==='error'||mode==='menu-error'){
await card.getByText('Failed to load SKILL.md: Fixture read failed',{exact:true}).waitFor();
assert(await edit.isDisabled(),'Failed source must not be editable');
assert.equal(await card.locator('.skill-md-editor').count(),0);assert.equal(posts,0);
await card.locator('.skill-chevron-up').click();
if(mode==='menu-error'){
await card.locator('.skill-kebab-btn').click();
await page.locator('.skill-kebab-menu:visible').getByRole('button',{name:'Edit',exact:true}).click();
await card.getByText('Failed to load SKILL.md: Fixture read failed',{exact:true}).waitFor();
assert.equal(await card.locator('.skill-md-editor').count(),0);
assert(await edit.isDisabled());assert.equal(posts,0);
await card.locator('.skill-chevron-up').click();
}
await card.locator('.skill-card-name').click();
}else if(mode==='collapse'){
await page.waitForFunction(()=>document.querySelector('.skill-card')?._mdLoaded);
assert.equal(await card.locator('.skill-md-editor').count(),0);
assert(!(await card.getAttribute('class')).includes('doclib-card-expanded'));
await card.locator('.skill-card-name').click();
}
await page.waitForFunction(()=>document.querySelector('.skill-card')?._mdLoaded);
assert(await edit.isEnabled());await edit.click();const editor=card.locator('.skill-md-editor');await editor.waitFor({state:'visible'});assert.equal(await editor.inputValue(),original);
await editor.fill(original+'\nSaved real source');await card.getByRole('button',{name:'Save',exact:true}).click();await page.waitForFunction(()=>!document.querySelector('.skill-md-editor'));
assert.equal(posts,1);assert.equal(source,original+'\nSaved real source');assert.deepEqual(errors,[]);
console.log(JSON.stringify({mode,width:process.argv[2],gets,posts}));
}finally{await browser.close();}
})().catch(e=>{console.error(e);process.exitCode=1;});
"""
result = subprocess.run(["node", "-e", script, str(document), str(width), mode],
cwd=repo, env=os.environ.copy(), capture_output=True,
text=True, timeout=45)
if result.returncode == 77:
pytest.skip("playwright not installed")
assert result.returncode == 0, result.stdout + result.stderr
assert json.loads(result.stdout)["posts"] == 1
+59
View File
@@ -0,0 +1,59 @@
"""Issue #2131: editing a skill must not reinterpret its version as a number."""
import pytest
from services.memory.skill_format import Skill, parse_frontmatter
from services.memory.skills import SkillsManager
@pytest.mark.parametrize(
"raw, expected",
[
("1.10", "1.10"),
("2.20", "2.20"),
("01", "01"),
("0", "0"),
("1.0.0", "1.0.0"),
("true", "true"),
("null", "null"),
('"1.10"', "1.10"),
("'2.20'", "2.20"),
('"release\\u002d01"', "release-01"),
],
)
def test_version_survives_repeated_markdown_saves(raw, expected):
markdown = f"---\nname: demo\nversion: {raw}\nconfidence: 0.80\n---\n"
for _ in range(3):
skill = Skill.from_markdown(markdown)
assert skill.version == expected
assert skill.confidence == 0.8
markdown = skill.to_markdown()
def test_version_preservation_keeps_other_frontmatter_types():
fm, _ = parse_frontmatter(
"---\nversion: 1.10\nconfidence: 0.80\nuses: 3\n"
"enabled: true\ntags: [one, two]\n---\n"
)
assert fm == {
"version": "1.10", "confidence": 0.8, "uses": 3,
"enabled": True, "tags": ["one", "two"],
}
@pytest.mark.parametrize("version_line", ["", "version:\n", 'version: ""\n'])
def test_missing_or_empty_version_retains_default(version_line):
skill = Skill.from_markdown(f"---\nname: demo\n{version_line}---\n")
assert skill.version == "1.0.0"
def test_version_survives_unrelated_edits_on_disk(tmp_path):
manager = SkillsManager(str(tmp_path))
manager.add_skill(name="demo", version="1.10", owner="alice")
for i in range(3):
assert manager.update_skill("demo", {"description": f"Edit {i}"}, owner="alice")
stored = manager.load(owner="alice")
assert stored[0]["version"] == "1.10"
assert stored[0]["description"] == f"Edit {i}"
markdown = next((tmp_path / "skills").rglob("SKILL.md")).read_text(encoding="utf-8")
assert "version: 1.10\n" in markdown
+29
View File
@@ -79,3 +79,32 @@ def test_tmp_path_under_private_runtime_fits_unix_socket(tmp_path):
# witness. A nested pytest-of-<user> basetemp made it 110 bytes under xdist.
with socket.socket(socket.AF_UNIX) as sock:
sock.bind(str(tmp_path / "tmux.sock"))
def test_collection_database_survives_thread_connection_churn():
"""TestClient threads must not lose the collection database or its schema.
SingletonThreadPool's default five-connection limit used to close the main
thread's in-memory database after repeated route clients. Each new thread
also saw an empty database. Exercise more threads than that limit and then
verify both compatibility factories still see all registered tables.
"""
from concurrent.futures import ThreadPoolExecutor
from sqlalchemy import inspect
import core.database as database
import src.database as compatibility
expected = set(database.Base.metadata.tables)
def table_names(factory):
with factory() as session:
return set(inspect(session.connection()).get_table_names())
assert {"documents", "model_endpoints", "mcp_servers", "calendars"} <= expected
assert expected <= table_names(database.SessionLocal)
for _ in range(8):
# A fresh executor creates a fresh thread rather than reusing one.
with ThreadPoolExecutor(max_workers=1) as executor:
assert expected <= executor.submit(table_names, database.SessionLocal).result()
assert expected <= table_names(database.SessionLocal)
assert expected <= table_names(compatibility.SessionLocal)