Compare commits

..
Author SHA1 Message Date
Alexandre Teixeira 716e7f1e28 docs(style): improve roadmap and contributing Markdown layout 2026-11-11 16:56:17 +00:00
Alexandre Teixeira a130611340 docs: refresh roadmap and modularization contribution guidance 2026-11-11 16:42:22 +00:00
Léo 0276e15f6e docs(maintainers): define the feature proposal workflow (#7000)
The handbook says substantial features need Product Owner approval
before implementation, but not how a proposal gets there, what counts
as substantial, or what happens when no answer comes. Add the full
workflow: triage within 7 days, a decision brief, four product labels,
a lazy-consensus timeout that only applies to big-but-reversible work,
and definitions for every judgement call (small, big, reversible, new
direction, fits ROADMAP, objection, recorded decision).

The Feature Request form gets three optional fields for the brief, and
the PR template, CONTRIBUTING and triage point at the workflow.
2026-11-09 11:23:38 +01:00
34e4666f35 fix(agent): sanitize tool message predecessors to prevent provider errors (refs #6314) (#6476)
* fix(agent): normalize tool-call message order without dropping history

Restore the context compactor to the current dev implementation so the PR no longer removes unrelated helpers, then repair the predecessor contract on top of it.

The sanitizer now iterates one normalization pass to a fixpoint, so it validates each batch against its own effective output and a second sanitation run is a no-op. Assistant text turns immediately before a tool_calls message are merged into it, preserving completed call/result evidence when the transcript was trimmed mid-history. Batches that still lack a user or tool predecessor are stripped or dropped as before.

* fix(agent): preserve multimodal content when dropping invalid tool calls

---------

Co-authored-by: ShahabAhmed01 <192491541+ShahabAhmed01@users.noreply.github.com>
Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
2026-11-09 02:07:22 +00:00
red person 1c15090949 fix(calendar): render multi-day events within their day cells (#6948) 2026-11-08 02:44:56 +00:00
PrinceandAlexandre Teixeira 37454f9d34 fix: recognize Conifer as a known provider endpoint (#6429)
* fix: recognize Conifer as a known provider endpoint

Recognize Conifer (api.conifer.build) in providers.js endpoint labels and map conifer.build to the openrouter curation sentinel in routes/model_routes.py so server-returned models are displayed as curated while preserving OpenAI wire compatibility.

Fixes #6205

* test(providers): remove trailing blank line

---------

Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
2026-11-08 02:27:35 +00:00
Grzegorz ae03bb8363 fix(chat): show the timeout message instead of an empty bubble (#6764) (#6768)
_isAgent was a const inside the try block of handleChatSubmit, but the
catch block reads it to pick the timeout message, so a client timeout
threw a ReferenceError and left the reply bubble empty. Declare it next
to the other catch-visible variables and assign it where it is computed.
2026-11-08 02:02:37 +00:00
Yu-Wei ChangandAlexandre Teixeira e4cc8efa19 fix(search): keep reasoning out of web search queries and stop misrouting named targets (#7054)
* fix(llm): keep reasoning out of answers when thinking is off

Small-budget utility calls on reasoning models spend every token on
reasoning, and llm_call then returned reasoning_content as the answer. Web
search extraction sent that text to SearXNG as the query, which found
nothing, and session titles fell back as unusable.

Treat thinking_mode="off" as "only the answer": non-streaming calls now
return empty instead of substituting reasoning, matching the streaming path.
Callers that do not turn thinking off keep the existing fallback.

Turn thinking off for search query extraction, session titles without a
configured effort, scheduled task names, and deep research classification,
and strip inline think blocks from the extracted query.

* fix(routing): require computer task wording for named hosts

"on <word>" and "from <word>" counted as a named computer on their own, so
"Who is Jensen Huang from NVIDIA?" and "What happened on Monday?" routed to
shell tools and the workspace toolset.

A target now only counts next to wording that describes work on a machine,
such as "check disk usage on gpu-box" or "copy the logs from gpu-box". The
intent classifier and the agent loop's local computer check share the pattern.

* fix(chat): resolve follow-up references in web search queries

Query extraction only saw the latest message, so "Does he work with Lisa
Su?" lost who "he" was. Include the last four turns, capped per turn, and
instruct the model to use them only to resolve references. Incognito turns
do not use saved session history.

* test(llm): stop reasoning fallback tests hanging in isolation

The module replaced src.agent_tools and core.database with MagicMock before
importing agent_loop. strip_tool_blocks then returned a mock, and
_strip_think_blocks looped forever because a mock's find() never returns -1.
This hung whenever the file ran alone or first on a worker. Import the real
modules instead.

* test(search): remove extra blank line at EOF

---------

Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
2026-11-06 02:11:20 +00:00
Yu-Wei Chang 1844b084bc refactor(cookbook): move endpoint registration out of the routes closure (#7031)
* refactor(cookbook): move endpoint registration out of the routes closure

_auto_register_llm_endpoint and _auto_register_image_endpoint use no
closure state and model_serve is their only caller, so they can live in a
plain module and be imported and tested directly. Bodies are unchanged
apart from indentation.

Tests now import the helpers instead of reading them from the route's
closure. That lookup also failed if the route stopped referencing a
helper, so add a test that pins which helper model_serve calls.

Part of #4071

* fix(cookbook): bound the OLLAMA_HOST port scan

The pattern rescanned the rest of the token from every OLLAMA_HOST=
occurrence, so a long serve command with repeated assignments took
quadratic time. model_serve runs this on the event loop, and a 192 KB
command stalled the whole app for about 9s.

Cap the host at 253 characters, the longest DNS name, so every valid host
still matches.

Part of #4071
2026-11-06 02:00:44 +00:00
Grzegorz c21980f5a6 fix(auth): revoke API tokens when an admin is demoted (#6831) (#6832) 2026-11-03 05:53:59 +00:00
Grzegorz 7fdc97880a fix(auth): reject usernames with slashes, control characters or over 64 characters (#6820) (#6821) 2026-11-03 05:50:21 +00:00
Grzegorz 9974841c49 fix(auth): validate privilege value types and reject non-object bodies (#6829) (#6830) 2026-11-03 05:48:20 +00:00
GrzegorzandClaude Opus 5.5 631c05603c fix(cookbook): make the Dependencies recipe Run button pass validation (#6725) (#6726)
The recipe Run button sent repo_id "<backend> setup"; the space fails the
pip package-spec check, so every Run returned "Invalid pip package name".
It now sends "<backend>-setup".

The vLLM/SGLang recipes start with `uv pip install ... --torch-backend
auto`, but uv was not in the serve command allowlist. Allow uv only for
the `pip install` subcommand.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 05:41:21 +00:00
red person f814018098 fix(documents): preserve read pagination within tool output budget (#6810) 2026-11-03 05:37:48 +00:00
Grzegorz d47d85d8a4 fix(history): only accept user/assistant string messages on the session message route (#6826) (#6827) 2026-11-03 05:35:25 +00:00
John WandClaude Opus 5.5 77964b0025 fix(ui): keep the confirm dialog's OK button in line with the others (#6782)
* fix(ui): keep the confirm dialog's OK button in line with the others

#styled-confirm-ok was moved down 2px (position: relative; top: 2px), so
the OK button sat 2px below Cancel and the alternate button in every
styledConfirm dialog. Its label, and the alternate button's, now sit in a
span nudged the same way as the Cancel label, so the text keeps its place
and no button moves. Stylesheets and app.js get a new cache-bust token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(ui): build confirm labels inline and share the Cancel nudge rule

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cache): keep upstream's service worker cache name format

dev's editor tests require CACHE_NAME to match
odysseus-v<N>-pr40-editor-precache, so keep the version bump and use
that suffix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 05:29:42 +00:00
red person 682662d36f fix(email): decode MIME alternatives without duplicating bodies (#6953) 2026-11-03 00:39:55 +00:00
red person b7ab0a37c1 fix(modals): keep restored settings and theme windows open (#6924)
* fix(modals): keep restored settings and theme windows open

* fix(theme): cancel closing on a rapid toggle reopen

* test: isolate photo routing from external MCP registry state

* fix(cache): preserve the upstream editor worker key format
2026-11-03 00:37:38 +00:00
red person 3efbecd50a fix(skills): restore the source editor from test results (#6931)
* fix(skills): restore the source editor from test results

* fix(skills): align the chat link module version

* test: isolate photo routing from external MCP registry state
2026-11-03 00:35:37 +00:00
red person 06c1e71fa0 fix(research): typeset math in visual reports with the shared renderer (#6929) 2026-11-03 00:33:43 +00:00
red person 1e34f938cc fix(chat): export the complete conversation across PDF pages (#6921) 2026-11-03 00:30:07 +00:00
John WandClaude Opus 5.5 596e1421e4 fix(ui): let confirm-dialog buttons grow to fit wrapped labels (#6617)
* fix(ui): let confirm-dialog buttons grow to fit wrapped labels

The global `button { height:32px }` rule also applies to styledConfirm's
buttons. A label that wraps to two lines, such as "Delete chat and images",
"Launch anyway (CPU)" or a three-button dialog's alternate action, keeps
one line's height, so its second line renders below the button. On narrow
windows the dialog makes the buttons equal width, so this hits most
multi-word labels; it also happens on desktop, where the dialog box is
narrow too.

Give .styled-confirm-box .confirm-btn height:auto with min-height:32px.
Single-line buttons keep their current 32px.

Bumps the shared stylesheet/app cache token and the service worker cache
name, since a stylesheet changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(static): leave admin.js's cache tag unchanged

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cache): keep upstream's service worker cache name format

dev's editor tests require CACHE_NAME to match
odysseus-v<N>-pr40-editor-precache, so keep the version bump and use
that suffix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:27:29 +00:00
red person cad14f81b9 fix(documents): preserve the selected chat mode on open (#6945)
* fix(documents): preserve the selected chat mode on open

* test: isolate photo routing from external MCP registry state
2026-11-03 00:25:29 +00:00
red person 8532f4606b fix(models): resolve configured defaults after automatic fallback (#6949)
* fix(models): resolve configured defaults after automatic fallback

* test: isolate photo routing from external MCP registry state
2026-11-03 00:23:17 +00:00
red person 5d4e8d57bb fix(memory): rank identity records with other relevance matches (#6925) 2026-11-03 00:21:16 +00:00
red person ed531cfc61 fix(hwfit): complete CPU probe results and clear remote ports (#6920) 2026-11-03 00:19:03 +00:00
Merajul Arefin f00ae350cd fix(cli): multi-word and literal-wildcard search in odysseus-docs (#7007)
* fix(cli): multi-word and literal-wildcard search in odysseus-docs

odysseus-docs search wrapped the whole query in one `%{query}%` LIKE over title/current_content, so multi-word queries only matched the exact adjacent phrase and literal % / _ acted as SQL wildcards. Split on whitespace, AND per token, and escape LIKE wildcards with the shared escape_like_pattern() helper. Whitespace-only queries now match all documents, consistent with document-library search.

Fixes #2123.

Tests: tests/cli/test_docs_cli_search.py (5 tests; multi-word, %, _, backslash, whitespace); 4 fail pre-fix, 5 pass post-fix. tests/cli full suite: 82 passed.

* test(cli): name whitespace-query search test accurately

The test passes a whitespace-only query, not an empty one; the old name implied a different input. Behavior is unchanged.
2026-11-02 23:48:14 +00:00
red person c1f959ae61 fix(research): retain reports after failed deletion (#7043) 2026-11-02 23:45:48 +00:00
Alexandre Teixeira 2790708b19 feat(benchmarks): add offline evidence integrity auditor (#7030)
* feat(benchmarks): add offline C5 evidence auditor

* fix(benchmarks): prevent sensitive data in audit logs

* fix(benchmarks): prevent Markdown report injection

* fix(benchmarks): prevent Markdown inline injection

* fix(benchmarks): secure GFM report rendering

* ci(benchmarks): gate rendered GFM security regressions
2026-11-02 23:07:56 +00:00
4126bea113 fix(gallery): drop the second scrollbar on the photo detail view (#6597)
* fix(gallery): drop the second scrollbar on the photo detail view

With a photo open on desktop, the modal body, the images container and the
detail view are each height: 100%, so the container fills the body exactly.
The container's inline 2px top margin (grid-view spacing) then overflows the
body by 2px, and the body shows its own scrollbar next to the inspector's.
Zero that margin while the detail view is open.

Bumps the shared stylesheet/app cache token and the service worker cache
name, since a stylesheet changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(static): leave admin.js's cache tag unchanged

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cache): keep upstream's service worker cache name format

dev's editor tests require CACHE_NAME to match
odysseus-v<N>-pr40-editor-precache, so keep the version bump and use
that suffix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
2026-11-02 15:44:29 +00:00
DracieVajkoandMatej f7f6abf82b feat(settings): add a tool approval mode with an explicit off (YOLO) option (#6304)
* feat: add optional tool approval mode

Admin-configurable tool_approval_mode setting (ask/auto) for the
post-external-context tool approval gate.

- ask (default): existing approval flow, gate stays on
- auto: bypasses the approval gate for all runs - same effect as
  ODYSSEUS_TOOL_APPROVAL_GATE=0. Documented honestly in the settings
  row copy and in THREAT_MODEL.md, per review on #6304.
- value validated server-side ({ask, auto}, anything else -> 400)
- no new env var: the setting wires to the existing gate control
- tests: setting validation + _tool_approval_gate_bypassed behavior

* feat: surface deployment gate state on Tool approval row

Follow-up to StressTestor's re-review: when ODYSSEUS_TOOL_APPROVAL_GATE is
set to a falsy value, the Tool approval row still read 'Always ask' even though
the gate was off, misleading admins the other way.

GET /api/auth/settings now returns tool_approval_gate_env_disabled (derived
from TOOL_APPROVAL_GATE_ENABLED) for admins, and the settings row renders a
red notice naming the env var and explaining that the gate is off for every
run regardless of the stored setting. Non-admin/scrubbed responses do not
expose the deployment state. Test covers both env values and the scrub path.

* refactor: rename tool approval mode auto to off

The gate-off value is now "off" ("Off (YOLO)" in the UI) so the auto-approve name stays available for the future judged mode.

---------

Co-authored-by: Matej <matej@local>
2026-11-01 12:22:56 -07:00
Alexandre Teixeira 2a413814c5 fix(chat): honor effective RAG policy in context building (#7025) 2026-11-01 16:14:51 +00:00
Tarık KöşkerandAlexandre Teixeira 1de3c72140 fix(effects): open the effect journal in binary mode on Windows (#6811)
Raw os.open() is text-mode on Windows unless O_BINARY is set, so each
\n written by the journal became \r\n. The log tracks byte offsets,
so the next append read the extra byte as a corrupt tail and every tool
call after the first in a turn failed with 'Effect claim could not be
persisted durably'.

OR O_BINARY (0 on POSIX) into the journal and launch-reservation opens.

Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
2026-11-01 16:12:42 +00:00
103 changed files with 8457 additions and 868 deletions
@@ -14,6 +14,8 @@ body:
If your idea needs community input before it becomes a concrete proposal,
start a [discussion](https://github.com/odysseus-dev/odysseus/discussions/categories/ideas) instead.
How a proposal is triaged and decided: [product and feature proposals](https://github.com/odysseus-dev/odysseus/blob/dev/docs/maintainers/product-and-features.md#feature-proposal-workflow).
- type: checkboxes
id: prerequisites
attributes:
@@ -79,6 +81,32 @@ body:
label: Prior Art / Related Issues
description: Link any related issues, discussions, or external references that informed this proposal.
- type: dropdown
id: scope
attributes:
label: Size and reversibility
description: Your best guess; the triager decides. Definitions are in the maintainer handbook (product-and-features.md).
multiple: true
options:
- "Small, reversible change within ROADMAP"
- "Big (more than one PR, or changes existing default behaviour)"
- "New direction (new area, multi-user, data leaving the machine, ...)"
- "Hard to undo (migration, deleted data, changed contract or permissions)"
- "Not sure"
- type: textarea
id: decision_questions
attributes:
label: Questions for the Product Owner
description: If this needs a product decision, the exact yes/no questions — one per independent part.
placeholder: "1. Should notes be shareable between accounts on one instance? (yes/no)"
- type: textarea
id: evidence
attributes:
label: Screenshots, recording or demo
description: For anything that renders, attach screenshots or a short recording. Link a demo branch if you have one.
- type: dropdown
id: willing_to_implement
attributes:
+3
View File
@@ -13,6 +13,9 @@
Private maintainer-preview PRs may instead use:
N/A — maintainer integration work
New features: the linked issue carries `product: approved` or
`product: maintainer decision` (docs/maintainers/product-and-features.md).
-->
Fixes #
+17
View File
@@ -264,3 +264,20 @@ jobs:
ODYSSEUS_TEST_PYTHON=python ODYSSEUS_LAYOUT_BROWSER=chromium npx --no-install playwright test \
--config tests/e2e/ios-layout.config.cjs \
--reporter=line
benchmark-gfm-security:
name: Benchmark GFM rendering security
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.11"
# Test-only renderer, pinned independently of application requirements.
- name: Install GitHub Flavored Markdown renderer
run: |
python -m pip install --disable-pip-version-check --only-binary=:all: "cmarkgfm==2025.10.22"
- name: Verify rendered benchmark report security
run: python tests/test_audit_c5_benchmark_evidence.py --gfm
+107 -63
View File
@@ -1,28 +1,50 @@
# Contributing to Odysseus
Thanks for helping. The project is moving quickly, so the best contributions are focused, easy to review, and easy to test.
Thanks for helping improve Odysseus. The best contributions are **focused, reviewable, and backed by meaningful validation**.
> [!IMPORTANT]
> **Public pull requests target `dev`, not `main`.** Search existing work before implementing, and follow the current [PR template](.github/pull_request_template.md). Runtime changes require relevant execution evidence, not just a green CI badge.
## At a glance
| Topic | What contributors should do |
| --- | --- |
| **Branch** | Target `dev`; `main` is the curated release branch |
| **Scope** | One coherent change per PR; coordinate larger modularization work through [#4071](https://github.com/odysseus-dev/odysseus/issues/4071) |
| **Validation** | Document commands, results, runtime checks, and anything not tested |
| **Visual changes** | Run the UI, match the existing visual language, and attach screenshots or clips |
| **Review** | Follow the [risk-based maintainer policy](docs/maintainers/changes-and-review.md) and applicable repository protections |
| **Security** | Report vulnerabilities privately using [SECURITY.md](SECURITY.md) |
**Jump to:** [Branches](#branch-model) · [Getting started](#before-you-start) · [Setup](#setup) · [Checks](#running-checks) · [Pull requests](#pull-requests) · [Modularization](#structural-changes-and-modularization) · [Visual changes](#style-and-visual-changes) · [Code conventions](#code-conventions) · [Issue reports](#issue-reports)
---
## Branch model
Odysseus has two branches:
| Branch | Purpose |
| --- | --- |
| **`dev`** | Default integration branch and target for every public PR. Work can be in flux. |
| **`main`** | Curated release branch. A [Release Maintainer](docs/maintainers/testing-and-releases.md#release-ownership) promotes a verified `dev` commit. |
- **`dev`** — where all PRs land. Things can be in flux here; the merge button gets used freely.
- **`main`** — what users run. Curated and tested by the release's [Release Maintainer](docs/maintainers/testing-and-releases.md#release-ownership). Fast-forwarded to a stable `dev` commit at each release.
If your PR accidentally targets `main`, use **Edit** on GitHub to change its base to `dev`. Changing the base does not itself require a rebase.
**Open your PR against `dev`, not `main`.** The GitHub "base" dropdown defaults to `dev`. If you opened a PR against `main` by accident, click "Edit" on the PR and change the base — no rebase needed.
End-users cloning the repo will land on `dev` by default. To run the curated/stable version: `git checkout main` after clone.
End users cloning the repository land on `dev` by default. To use the curated release branch, run `git checkout main` after cloning.
## Before You Start
- Search existing issues and pull requests before opening a new one.
- Prefer one bug fix or feature per pull request.
- Avoid broad rewrites, formatting-only changes, or moving many files unless the issue is specifically about structure.
- If you want to work on a large feature, open an issue first and describe the approach.
1. Search [open issues](https://github.com/odysseus-dev/odysseus/issues) and [PRs](https://github.com/odysseus-dev/odysseus/pulls) to avoid duplicate or conflicting work.
2. Prefer one bug fix, feature, or coherent refactor per PR. Avoid broad rewrites, formatting-only churn, and unrelated file moves.
3. For a feature, open a **Feature Request** first. Big, new-direction, or difficult-to-reverse changes require the documented [product decision process](docs/maintainers/product-and-features.md#feature-proposal-workflow).
4. For structural work, coordinate the intended module boundary with maintainers and the [modularization tracker #4071](https://github.com/odysseus-dev/odysseus/issues/4071).
> [!NOTE]
> **Coding agents:** If you use an LLM coding agent, open an issue describing the problem before submitting its PR. Bulk agent-generated submissions that ignore the project's contribution format or visual style may be closed without review, even when the underlying fix is correct.
## Setup
Docker is the recommended path for normal testing:
<details open>
<summary><strong>Docker (recommended for normal testing)</strong></summary>
```bash
git clone https://github.com/odysseus-dev/odysseus.git
@@ -31,7 +53,10 @@ cp .env.example .env
docker compose up -d --build
```
Manual development uses Python 3.11+:
</details>
<details>
<summary><strong>Manual Python development (Python 3.11+)</strong></summary>
```bash
python3 -m venv venv
@@ -40,11 +65,14 @@ pip install -r requirements.txt
python -m uvicorn app:app --host 127.0.0.1 --port 7000
```
Windows is not actively tested. Docker on Linux or a Linux/macOS manual install is the safer path for now.
</details>
> [!NOTE]
> Native Windows is not actively tested. Docker on Linux or a Linux/macOS manual installation is the safer path for now. Document the environment actually used for testing.
## Running Checks
Run the smallest relevant checks for your change:
Run relevant checks for the affected area. Examples from the existing contributor workflow:
```bash
python -m pytest
@@ -60,78 +88,94 @@ docker compose up -d --build
docker compose logs --tail=120 odysseus
```
Mention what you ran in the pull request description. If you could not run a check, say so.
> [!IMPORTANT]
> **Report what actually ran.** Include commands, results, and relevant manual/runtime verification in the PR description. If a check was not run, mark it as **not run** and explain the limitation. Passing syntax checks and unit tests do not automatically prove changed behavior works in the running application.
See the [testing and releases handbook](docs/maintainers/testing-and-releases.md) for risk-based validation and isolation requirements.
## Maintainer governance and review guidance
For repository roles, product decisions, risk-based approvals, issue triage, independent security reviews, CodeQL findings, and release handoffs, see the [Odysseus maintainer handbook](docs/maintainers/README.md). This complements the contributor requirements below and does not change the branch model or current PR template.
The [Odysseus maintainer handbook](docs/maintainers/README.md) is authoritative for repository roles, product decisions, risk-based approvals, issue triage, security review, CodeQL findings, and release handoffs.
> [!NOTE]
> The handbook complements this contributor guide. It does not override the branch model, live GitHub protection rules, or the PR template.
## Pull Requests
Good pull requests usually include:
Keep PRs **cohesive**. Small, focused changes are the default. A larger, coordinated module decomposition may be appropriate under the criteria below, but unrelated cleanup, formatting, refactors, and behavior changes must not be mixed.
- A short explanation of the bug or feature.
- The files or areas changed.
- Manual test steps or automated test results from running the actual app, not just the test suite.
- Screenshots or short recordings for UI changes.
- Links to related issues, for example `Fixes #123`.
| Include in the PR | Why it matters |
| --- | --- |
| **Summary and scope** | Explain the problem, intended behavior, and areas changed |
| **Linked issue** | Connect the PR to the existing report or approved work, for example `Fixes #123` or `Part of #4071` |
| **Validation evidence** | Show manual test steps or automated results that exercise the actual behavior |
| **UI evidence, when applicable** | Attach screenshots or short recordings from the running application |
| **Limitations** | Clearly identify tests or environments that were not exercised |
Please keep PRs small. Large PRs that mix unrelated cleanup, formatting, refactors, and behavior changes are much harder to review.
For exact required sections and attestations, follow the current [PR template](.github/pull_request_template.md).
> **Auto-generated PRs.** If you are running an LLM agent (Devin, Cursor, OpenHands, Claude Code, etc.) against this repo: please open an issue describing the problem first instead of opening a PR directly. Bulk agent-generated PRs that don't match the project's visual style or contribution format will be closed without review, even when the underlying fix is correct.
### Structural changes and modularization
**One coherent responsibility is the unit of review.** A larger PR can be appropriate when extracting that responsibility from an oversized module is safer or simpler than splitting the work into many conflicting or interdependent PRs.
Before implementation and review:
- **Coordinate:** Read existing issues/PRs and agree on the boundary with maintainers. Use [#4071](https://github.com/odysseus-dev/odysseus/issues/4071) for codebase modularization.
- **Constrain scope:** Keep one subsystem or responsibility per PR. Avoid unrelated features, formatting, and opportunistic cleanup.
- **Preserve contracts:** Maintain observable behavior, imports, public interfaces, persistence contracts, and runtime invariants unless an approved issue explicitly scopes a change.
- **Validate behavior:** Include targeted regression tests and runtime checks. Frontend work needs browser validation and screenshots or recordings when rendering may change.
- **Explain tradeoffs:** Describe extraction boundaries, compatibility, validation results, and known limitations in the PR.
> [!TIP]
> **Large does not mean broad.** There is no arbitrary line-count limit for a well-scoped extraction. Correctness, reviewability, and integration risk determine whether the scope is acceptable.
> [!CAUTION]
> **Temporary Harness V2 coordination boundary:** Unrelated changes to `src/agent_loop.py` and closely related execution, authorization, orchestration, and context contracts are temporarily frozen while Harness V2 is developed and validated. Coordinate such work with maintainers. Other modules remain open, and urgent bug or security reports are welcome.
These contributions remain subject to the [risk-based review policy](docs/maintainers/changes-and-review.md), current repository protections, and PR template.
## Style and visual changes
Odysseus has an intentional visual style. PRs that ignore it will be closed without merge, no matter how correct the underlying code is.
Odysseus has an intentional visual language. Changes that ignore it can be closed without merge even when their underlying code is correct.
Before submitting any change that affects what the app looks like — buttons, icons, fonts, colors, spacing, layout, CSS, HTML, SVG, or any `static/js/` module that draws to the DOM — please:
For any change affecting buttons, icons, typography, colors, spacing, layout, CSS, HTML, SVG, or JavaScript that renders into the DOM:
1. **Run the app locally** and view the change in a browser. Type-checks and unit tests are not enough.
2. **Attach a screenshot or short clip** of the change in the running app. Add a mobile screenshot too if the change affects mobile.
3. **Match the existing visual language.** Specifically:
- Reuse existing CSS variables (`--red`, `--fg`, `--bg`, `--card`, `--border`, …). Do not introduce new color values, font sizes, or spacing units.
- Reuse existing button, input, card, and border classes. Don't invent parallel styling for similar widgets.
- **No Unicode emoji in UI or code.** Use inline SVG (matching the monochrome icon style already in `static/index.html`) or plain text.
- Monospaced font (`Fira Code`) for primary UI text. Don't override.
- Dark theme is the default; any light-mode work goes through the existing theme system, not hard-coded.
4. **Don't add parallel components.** If a similar widget already exists in the app, extend it instead of writing a new one.
1. **Run the application locally** and inspect the change in a browser. Type checks and unit tests alone are insufficient.
2. **Attach a screenshot or short clip** from the running app, plus a mobile screenshot when mobile behavior changes.
3. **Reuse the existing design system:**
- Prefer existing CSS tokens (`--red`, `--fg`, `--bg`, `--card`, `--border`, etc.). Do not introduce parallel colors, type scales, or spacing systems.
- Reuse existing button, input, card, and border classes rather than creating duplicate patterns.
- Do **not** add Unicode emoji in UI or code. Use inline SVG consistent with `static/index.html` or plain text.
- Preserve the existing primary monospaced font (`Fira Code`).
- Keep dark mode as the default; route any light-mode work through the existing theme system.
4. **Extend existing components** instead of introducing a parallel component for the same purpose.
If you are unsure whether a change is "visual," it is. Default to attaching a screenshot.
> [!IMPORTANT]
> If you are unsure whether a change is visual, assume that it is and provide a screenshot. Visual evidence does not replace behavioral validation.
## Code conventions
Don't hardcode values that the project already exposes through a constant or a helper. Hardcoded literals drift out of sync, break on non-default deployments, and reintroduce bugs we've already fixed.
**Use existing constants and helpers.** Hardcoded duplicates drift, break non-default deployments, and can reintroduce previously fixed issues.
- **Filesystem paths:** never build writable paths from `Path(__file__)...` into the source tree, hardcode `/app/...`, or use a relative `"data/..."` string. Every persisted file and directory has a named constant in `src/constants.py` (for example `AUTH_FILE`, `USER_PREFS_FILE`, `SETTINGS_FILE`, `TTS_CACHE_DIR`, `CHROMA_DIR`). Import and use that named constant; do not re-derive the path locally with `os.path.join(DATA_DIR, "x.json")` or `DATA_DIR / "x.json"`. `DATA_DIR` is the single place that reads `ODYSSEUS_DATA_DIR`, so use it directly only for dynamic paths that have no fixed name (for example per-owner files). If a data file or directory has no constant yet, add one to `src/constants.py`. The source tree is read-only in Docker and `/app/...` does not exist on native runs; guard directory creation so an unwritable path degrades gracefully instead of crashing at import.
- **Internal API / loopback URLs:** don't hardcode `http://localhost:7000`. Use `internal_api_base()` from `src.constants` (it honors `ODYSSEUS_INTERNAL_BASE` / `APP_PORT`).
- **Ports, limits, model lists, and similar:** reuse the existing constant if one exists; if it doesn't and the value is used in more than one place, add a constant rather than copying the literal.
- **Filesystem paths:** Do not derive writable paths from `Path(__file__)...` into the source tree, hardcode `/app/...`, or use a relative `"data/..."` string. Persisted files and directories have named constants in `src/constants.py`, such as `AUTH_FILE`, `USER_PREFS_FILE`, `SETTINGS_FILE`, `TTS_CACHE_DIR`, and `CHROMA_DIR`. Import those names rather than deriving paths with `os.path.join(DATA_DIR, "x.json")` or `DATA_DIR / "x.json"`. `DATA_DIR` reads `ODYSSEUS_DATA_DIR` and is intended directly only for dynamic paths without fixed names, such as per-owner files. Add a constant when a fixed path does not yet have one. The source tree is read-only in Docker, and `/app/...` does not exist on native runs. Guard directory creation so unwritable paths degrade gracefully rather than crashing at import.
- **Internal API and loopback URLs:** Do not hardcode `http://localhost:7000`. Use `internal_api_base()` from `src.constants`, which honors `ODYSSEUS_INTERNAL_BASE` and `APP_PORT`.
- **Ports, limits, and model lists:** Reuse existing constants. When a value is shared by multiple locations and has no constant, add one in `src/constants.py` instead of duplicating a literal. `core/constants.py` is a compatibility re-export, not another source of truth.
If you need a value that has no constant or helper yet, add it to `src/constants.py` (the single source of truth for paths and config; `core/constants.py` only re-exports it for backward compatibility) and import it, rather than repeating a literal across files.
**Commits:** use [Conventional Commits](https://www.conventionalcommits.org), `type(scope): summary` (e.g. `fix(search): ...`, `feat(notes): ...`, `docs(contributing): ...`). Common types: `fix`, `feat`, `refactor`, `docs`, `test`, `chore`, `ci`. Keep the subject short and imperative; put the "why" in the body when it isn't obvious.
**Commits:** Use [Conventional Commits](https://www.conventionalcommits.org) with `type(scope): summary`, for example `fix(search): ...`, `feat(notes): ...`, or `docs(contributing): ...`. Common types include `fix`, `feat`, `refactor`, `docs`, `test`, `chore`, and `ci`. Keep the subject short and imperative, with the motivation in the body when useful.
## Issue Reports
For bugs, include:
| Bug reports should include | Model-serving reports should additionally include |
| --- | --- |
| Installation method (Docker, Python, WSL, etc.) | Backend (Ollama, vLLM, SGLang, llama.cpp, LM Studio, etc.) |
| OS, browser, and device when relevant | Model name |
| Exact reproduction steps | GPU/CPU and operating system |
| Expected versus actual behavior | Cookbook task logs or backend/server logs |
| Relevant logs, screenshots, or terminal output | Relevant backend configuration |
- Install method: Docker, manual Python, WSL, etc.
- OS, browser, and device if relevant.
- Exact steps to reproduce.
- Expected behavior and actual behavior.
- Logs, screenshots, or terminal output.
For model-serving issues, include:
- Backend: Ollama, vLLM, SGLang, llama.cpp, LM Studio, etc.
- Model name.
- GPU/CPU and operating system.
- Cookbook task logs or server logs.
Issues with only "help", "does not work", or a screenshot without context may be closed as not actionable.
Issues containing only "help", "does not work", or an unexplained screenshot may be closed as not actionable. Start with a reproducible problem or use [Discussions](https://github.com/odysseus-dev/odysseus/discussions) for setup questions.
## Security
Do not post secrets, API keys, private logs, personal documents, or public IPs in issues or pull requests.
For security reports, follow [SECURITY.md](SECURITY.md).
> [!CAUTION]
> **Do not publish secrets.** Keep API keys, sensitive logs, personal documents, and public IP addresses out of public issues and PRs. Report vulnerabilities privately through [SECURITY.md](SECURITY.md).
+104 -74
View File
@@ -1,87 +1,117 @@
# Roadmap / Help Wanted
Odysseus is on a voyage, but not home yet. It works great for me (lol), but this ship is moving fast and feedback/help would be appreciated! (I don't know what I'm doing, help).
Odysseus is an evolving, self-hosted AI workspace. This roadmap highlights where improvements and contributions are most useful, from reliability and maintainability to product capabilities.
If you see weird CSS, strange layout behavior, or a suspiciously murky corner of
the codebase, you are probably right to stay away.
> [!NOTE]
> **Direction, not a release schedule.** This page is not a complete issue backlog or a promise to deliver every proposal. Implementation scope and priority are coordinated through existing issues, pull requests, and the project's [feature proposal process](docs/maintainers/product-and-features.md).
## High Priority
## At a glance
- SQUASH BUGS
- Fresh install smoke tests on Linux, macOS, and Windows. Docker, native Python,
and WSL all need coverage.
| Area | Direction | Coordination |
| --- | --- | --- |
| **Stabilization** | Reliability, CI, fresh installs, security, and integrations | [Stabilization & Modularization milestone](https://github.com/odysseus-dev/odysseus/milestone/1) |
| **Modularization** | Clearer module ownership, safer changes, and contributor onboarding | [Canonical tracker #4071](https://github.com/odysseus-dev/odysseus/issues/4071) |
| **Agent runtime** | Active Harness V2 development, with a narrow temporary change freeze | [Coordination in #4071](https://github.com/odysseus-dev/odysseus/issues/4071) |
| **Product improvements** | Local models, research, frontend experience, and operations | [Issues](https://github.com/odysseus-dev/odysseus/issues) and [feature proposals](docs/maintainers/product-and-features.md) |
- Integration audit: do integrations even work? Confirm what works, what needs setup docs, and what should be removed or hidden.
- Cookbook reliability on other computers. This is probably the area most likely to need work across different machines, GPUs, drivers, shells, and Python environments.
- Cookbook SGLang support across platforms. Make sure SGLang setup/serve works
predictably on Linux, Windows/WSL, macOS where possible, Docker, and common
NVIDIA/AMD hardware paths.
- Deep Research model presets by hardware. Recommend approved model/parameter
profiles for small, medium, and large local setups so people with different
hardware can use Deep Research without guessing. Surface this either in Deep
Research settings or as a Cookbook scan/dropdown suggestion.
- Cookbook model scan/download ranking. Prioritize newer architectures and
better hardware-fit models instead of scoring everything almost the same.
Ranking should account for architecture age, quant format, VRAM/RAM fit,
backend support, vision/mmproj requirements, and likely serve reliability.
- Cookbook error feedback and logging. Failed downloads, dependency installs,
preflights, and serve jobs should show the actual command/output/error in the
UI, with copyable logs and clear next steps instead of just "crashed".
- Agent prompt/context bloat. Agent mode is too heavy for smaller local models:
tool schemas, skills, memory, documents, and instructions can eat the context
before the user request really starts. We need slimmer prompts, better tool
selection, smaller default tool sets, and clearer guidance for models with
4k/8k/16k context windows.
- Local model speculative decoding support. For Odysseus-tuned local models,
plan to ship or recommend a small same-tokenizer draft model when the serving
backend supports it. Early vLLM testing showed a generic `Qwen3-0.6B` draft
beside `Qwen3-8B` can materially reduce wall time, while an unsupported
DSpark conversion performed poorly. Treat this as a supported draft-model lane
first; keep MTP-specific packaging as future work only when the architecture
and runtime support are real. Judge this by time-to-success, tool correctness,
grammar, and unchanged target output, not tokens/sec alone.
- Skill/tool prompt-injection audit. User-editable skills, notes, documents,
fetched pages, and memories should be treated as untrusted data. Keep testing
whether models follow malicious instructions from those surfaces.
- Better degraded-state reporting for ChromaDB, SearXNG, email, ntfy, and provider probes.
- Email performance audit. Fetching, searching, opening, deleting, and sending
email can feel slow, especially over IMAP/SMTP providers with high latency.
Need someone who knows mail performance to profile the current flow, identify
whether the bottleneck is IMAP folder select/fetch, cache invalidation,
attachment/body loading, SMTP handshakes, or frontend refresh behavior, then
propose safer caching/prefetch/batching without breaking multi-account state.
- Provider setup/probing audit for Anthropic, Gemini, Groq, xAI, OpenRouter, OpenAI, and DeepSeek.
**Jump to:** [Stabilization](#stabilization-and-reliability) · [Modularization](#modularization-and-maintainability) · [AI and local models](#ai-local-models-and-research) · [Frontend](#frontend-and-user-experience) · [Backend](#backend-and-operations) · [Contributing](#contributing)
## Refactor Targets
- CSS cleanup. `static/style.css` basically Calypso's island atm.
- Tour core helper. The onboarding tours have too much copy-pasted scaffolding; promote a shared `tour-core.js` helper before adding more tours.
- Modal/window positioning cleanup. Some window controls have improved, but the
underlying popup/dropdown/fixed-position behavior is still too fragile.
- Mobile media override discoverability. A lot of "CSS did not move" bugs are mobile `@media` overrides of the same selector; comments or linting around desktop/mobile paired rules would help.
- Dead code pass for old routes, stale feature flags, and unused UI states.
---
## Frontend
## Stabilization and reliability
- Expand the Editor for quicker, more robust everyday use. Better file/document
handling, smoother window behavior, clearer save/export flows, stronger image
editing affordances, and fewer brittle edge cases.
- Better AI integration for Notes and Todos. Notes should be easier for the
agent to read, update, summarize, and turn into actions. Todos should be
assignable to an agent from the UI, possibly through a button, task action,
or dedicated skill/tool flow.
- Mobile gallery/editor polish. Easier to launch/download inpaint model or any missing pieces.
- Accessibility pass: keyboard navigation, focus states, contrast, reduced motion.
- Improve empty states and error messages on fresh installs.
- Tighten first-run setup, hints, and tours so they do not repeat or fight each other.
- Vendor CDN assets eventually for a more fully self-hosted/offline mode.
**Priority:** Make Odysseus dependable across installations, platforms, providers, and supported hardware.
## Backend
| Workstream | What needs improvement | Tracking |
| --- | --- | --- |
| **CI and validation** | Exact-revision validation, meaningful regression coverage, and release confidence | [#5932](https://github.com/odysseus-dev/odysseus/issues/5932), [#7109](https://github.com/odysseus-dev/odysseus/issues/7109) |
| **Test quality** | Deterministic tests, isolation, focused execution, and maintainable test structure | [#2523](https://github.com/odysseus-dev/odysseus/issues/2523), [testing standard](tests/TESTING_STANDARD.md) |
| **Fresh installations** | Smoke tests for Linux, macOS, Windows, WSL, and Docker; baseline startup and graceful degradation | [#3968](https://github.com/odysseus-dev/odysseus/issues/3968) |
| **Email integration** | Remaining Google OAuth regressions, account isolation, and cross-provider reliability | [#5637](https://github.com/odysseus-dev/odysseus/issues/5637) |
| **Storage integrity** | Upload payload growth, indexing, and attachment/file lifecycle | [#4889](https://github.com/odysseus-dev/odysseus/issues/4889) |
- More tests around endpoint probing and provider setup.
- Better task scheduler defaults and visibility.
- Backup/restore guide and helper flow for `data/`.
- Security hardening around admin-only tools and clear docs for their risk.
Additional reliability priorities:
## Not The Focus Right Now
- **Cookbook:** Improve model installation, dependency management, hardware detection, and model serving across operating systems, GPUs, drivers, shells, Python environments, and supported backends.
- **Security:** Keep strengthening authorization, administrative tools, agent tool execution, prompt-injection boundaries, filesystem access, and untrusted-content handling.
- **Integrations and degraded states:** Audit supported providers and optional services, including ChromaDB, SearXNG, email, and ntfy. Make failures observable and nonessential outages predictable.
I prob shouldnt add more themes.
> [!TIP]
> Start with an existing, reproducible issue. The [Stabilization & Modularization milestone](https://github.com/odysseus-dev/odysseus/milestone/1) is a curated work queue, not an invitation to implement every item in this roadmap without coordination.
## Modularization and maintainability
The goal is **clearer responsibility boundaries and safer development**, not smaller files for their own sake. Changes should preserve behavior, compatibility, and relevant runtime contracts.
| Area | Current foundation | Remaining opportunities |
| --- | --- | --- |
| **CSS** | The former `static/style.css` has been split into ordered files under `static/css/` | Improve ownership while preserving the cascade, themes, and responsive layouts |
| **Tools** | Substantial tool-domain extraction has landed | Clarify remaining ownership and preserve compatibility through the existing tool interfaces |
| **Frontend** | Existing feature modules remain in use | Decompose large document, gallery, settings, calendar, and other UI modules around coherent behavior |
| **Backend** | Domain-specific routes and services already exist | Identify safe boundaries in Cookbook routes, persistence, and other tightly coupled modules |
| **Tests** | Taxonomy, focused execution, and structural improvements exist | Align fixtures, helpers, and regression coverage with implementation boundaries |
| **UI infrastructure** | Targeted cleanup is underway | Consolidate tour scaffolding, modal/window positioning, responsive overrides, and obsolete paths |
**Canonical coordination:** [Codebase modularization and contributor onboarding, #4071](https://github.com/odysseus-dev/odysseus/issues/4071).
> [!IMPORTANT]
> **Cohesive work is more important than a small line count.** A larger PR may be appropriate for one well-defined decomposition when several smaller PRs would create avoidable conflicts or integration overhead. Coordinate the boundary, avoid unrelated work, and supply regression and runtime evidence. See [CONTRIBUTING.md](CONTRIBUTING.md#structural-changes-and-modularization).
### Agent runtime and Harness V2
Harness V2 is under active development.
> [!CAUTION]
> **Temporary agent-runtime freeze.** Unrelated changes to `src/agent_loop.py` and closely related execution, orchestration, authorization, and context contracts must be coordinated with maintainers while Harness V2 is developed and validated.
>
> This does **not** freeze unrelated application modules. Urgent bug reports and security issues remain welcome. No release date is implied.
## AI, local models, and research
These are **areas of product interest**, not commitments to a specific implementation or delivery order. Follow normal issue triage and product-decision requirements.
| Direction | Opportunity |
| --- | --- |
| **Smaller-model agents** | Reduce unnecessary prompt, tool-schema, skill, memory, and document context overhead; improve tool selection for constrained context windows |
| **Cookbook and SGLang** | Improve installation and serving across relevant hardware, operating systems, and deployment methods |
| **Model discovery** | Rank models by architecture, quantization, RAM/VRAM fit, backend support, multimodal requirements, and expected reliability |
| **Deep Research presets** | Explore suitable model and parameter configurations for small, medium, and large local systems |
| **Actionable diagnostics** | Expose command output, logs, and next steps for failed downloads, dependencies, preflights, and serving jobs |
| **Speculative decoding** | Investigate compatible draft-model configurations and evaluate end-to-end correctness and time to success |
| **Provider setup** | Improve endpoint discovery, capability checks, and setup for supported local and hosted providers |
## Frontend and user experience
- **Editors and files:** Improve document/file editing, window behavior, save/export flows, and image-editing reliability.
- **Notes and Todos:** Expand agent-assisted reading, updates, summarization, and turning information into tasks.
- **Mobile:** Improve gallery/editor usability, model setup, and responsive behavior.
- **Accessibility:** Strengthen keyboard navigation, focus states, contrast, and reduced-motion support.
- **First run:** Improve setup, hints, empty states, error messages, and onboarding tours without duplicate guidance.
- **Self-hosting:** Reduce unnecessary external asset dependencies where practical for offline operation.
## Backend and operations
- **Email performance:** Measure fetching, searching, body and attachment loading, caching, sending, and frontend refresh behavior before optimizing.
- **Provider reliability:** Expand regression coverage for setup, endpoint probing, and degraded-service behavior.
- **Task scheduling:** Improve defaults, reliability, and operational visibility.
- **Data recovery:** Improve backup and restore guidance and workflows for persistent data.
- **Administrative security:** Review privileged capabilities and sensitive integrations against their authorization boundaries.
## Not a current priority
- Additional themes without a demonstrated product need.
- Broad rewrites or framework migrations without clear, measurable value.
- Changes undertaken solely to reduce file counts.
- Uncoordinated changes to temporarily frozen agent-runtime contracts.
---
## Contributing
1. Read [CONTRIBUTING.md](CONTRIBUTING.md) and search [existing issues](https://github.com/odysseus-dev/odysseus/issues) and [PRs](https://github.com/odysseus-dev/odysseus/pulls).
2. Coordinate modularization through [#4071](https://github.com/odysseus-dev/odysseus/issues/4071) and active stabilization work through the [milestone](https://github.com/odysseus-dev/odysseus/milestone/1).
3. Use the repository's issue and PR templates and target **`dev`**.
4. Include relevant tests, runtime evidence, and screenshots or recordings for visual changes.
> [!NOTE]
> Product-direction or difficult-to-reverse changes follow the [feature proposal process](docs/maintainers/product-and-features.md). Report security vulnerabilities privately through [SECURITY.md](SECURITY.md), never in a public issue.
+1 -1
View File
@@ -64,7 +64,7 @@ External content that reaches the LLM is treated as untrusted via `src/prompt_se
`src/tool_capabilities.py` carries a second layer: once untrusted content has entered a run, `ToolRunSecurityContext.decision_for()` blocks tools that execute code, mutate state, or cause external side effects until the user authorises the action separately.
It is on unless `ODYSSEUS_TOOL_APPROVAL_GATE` is set to a falsy value (`0`/`false`/`no`/`off`). Request authority narrows which tool families a turn may use, but within an admitted family it does not bind the exact action: a request to read email admits `send_email` and `delete_email`, and agent processes inherit the host network. Until those are covered by their own boundaries, this gate is what stops an injected instruction from reaching them. Turning it off lets a successful injection reach `bash`, `host_shell`, `send_email` and `delete_email` without a separate confirmation.
It is on unless `ODYSSEUS_TOOL_APPROVAL_GATE` is set to a falsy value (`0`/`false`/`no`/`off`). The admin-only `tool_approval_mode` setting (Settings → Agent Tools, `off` = "Off (YOLO)") is the same knob exposed in the UI: with `off`, every run sets `approval_gate_bypassed` and this gate stops asking, exactly as if the env var were off. Request authority narrows which tool families a turn may use, but within an admitted family it does not bind the exact action: a request to read email admits `send_email` and `delete_email`, and agent processes inherit the host network. Until those are covered by their own boundaries, this gate is what stops an injected instruction from reaching them. Turning it off — by either knob — lets a successful injection reach `bash`, `host_shell`, `send_email` and `delete_email` without a separate confirmation.
Three exemptions apply even when the gate is on, all deliberate:
+39 -1
View File
@@ -47,7 +47,7 @@ ADMIN_PRIVILEGES["allowed_models_restricted"] = False
# backwards for this sentinel.
ADMIN_PRIVILEGES["block_all_models"] = False
from src.constants import AUTH_FILE, PASSWORD_MIN_LENGTH
from src.constants import AUTH_FILE, PASSWORD_MIN_LENGTH, USERNAME_MAX_LENGTH
from src.owner_identity import RESERVED_AUTH_USERNAMES
DEFAULT_AUTH_PATH = AUTH_FILE
TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days
@@ -62,6 +62,24 @@ TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days
RESERVED_USERNAMES = frozenset(RESERVED_AUTH_USERNAMES)
def username_error(username: str | None) -> Optional[str]:
"""Why a new username is not acceptable, or None when it is.
Admin routes carry the name as a URL path segment, where "/" never
reaches the handler, so such an account could not be managed afterwards.
"""
name = (username or "").strip()
if not name:
return "Username is required"
if len(name) > USERNAME_MAX_LENGTH:
return f"Username must be at most {USERNAME_MAX_LENGTH} characters"
if "/" in name or "\\" in name or any(not c.isprintable() for c in name):
return "Username must not contain slashes or control characters"
if name in (".", ".."):
return "Username is not allowed"
return None
def normalize_known_username(users: Dict[str, Any], username: str | None) -> Optional[str]:
"""Return a normalized username only when it exists in the auth user map."""
key = str(username or "").strip().lower()
@@ -396,6 +414,15 @@ class AuthManager:
current = self.get_privileges(username)
for k, v in privileges.items():
if k in DEFAULT_PRIVILEGES:
default = DEFAULT_PRIVILEGES[k]
if isinstance(default, bool):
ok = isinstance(v, bool)
elif isinstance(default, int):
ok = isinstance(v, int) and not isinstance(v, bool)
else:
ok = isinstance(v, list)
if not ok:
raise ValueError(f"Invalid value for privilege '{k}'")
current[k] = v
self._config["users"][username]["privileges"] = current
self._save()
@@ -436,6 +463,17 @@ class AuthManager:
admin_count = sum(1 for d in self.users.values() if d.get("is_admin"))
if admin_count <= 1:
return SetAdminResult.LAST_ADMIN
# Token creation is admin-only and a demoted user can neither
# list nor revoke their tokens, so purge them with the role.
# Done before the flag flips: if the token store fails the
# exception propagates and the user is still an admin.
from core.database import get_db_session, ApiToken
with get_db_session() as db:
removed_tokens = db.query(ApiToken).filter(ApiToken.owner == username).delete()
if removed_tokens:
logger.info(
f"Revoked {removed_tokens} API token(s) owned by demoted admin '{username}'"
)
# Write order matters for lock-free readers: get_privileges()
# reads without _config_lock and trusts is_admin, so the admin
# flag must be flipped while the stored map is safe to expose —
+2 -1
View File
@@ -9,6 +9,7 @@ This handbook records the project's maintainer governance and gives reviewers a
| Area | Working rule |
|---|---|
| Product direction | Pewds is the Product Owner |
| New feature | Feature Request issue; big, new-direction or hard-to-undo features need a recorded Product Owner decision ([workflow](product-and-features.md#feature-proposal-workflow)) |
| Ordinary, reversible maintainer PR | Self-merge allowed after appropriate validation and applicable repository checks |
| External contribution | Maintainer review required |
| High-risk change | At least one **independent, qualified** maintainer review |
@@ -22,7 +23,7 @@ These are governance rules, not guarantees that a GitHub permission or branch-pr
## Start here
- [Roles and access](roles-and-access.md): responsibility, permissions, and continuity.
- [Product and feature proposals](product-and-features.md): product approval versus technical ownership.
- [Product and feature proposals](product-and-features.md): product approval versus technical ownership, and the feature proposal workflow.
- [Changes and review](changes-and-review.md): review thresholds, self-merge, contributor corrections.
- [Practical review playbook](review-playbook.md): risk, provenance, CI, CodeQL, independent audit, and handoff evidence.
- [Triage](triage.md): turning incoming reports into actionable work.
+167 -13
View File
@@ -2,13 +2,15 @@
Pewds ([@pewdiepie-archdaemon](https://github.com/pewdiepie-archdaemon)) is the Product Owner for Odysseus and has final authority over product direction. Maintainers own technical feasibility, implementation, and safety.
This page has two parts: **which changes need product approval**, and **the workflow every feature proposal follows**, from issue to merged PR. The terms in bold are defined in [Definitions](#definitions); when a case is unclear, the definitions decide, not intuition.
## Which changes require product approval?
**Product Owner approval before implementation:** substantial new features, changes to product direction, and product changes that are hard to reverse.
**Product Owner approval before implementation:** features that are **big**, set a **new direction**, or are **hard to undo**.
**No separate product approval by default:** bug fixes, security fixes, maintenance, tests, CI, documentation, internal refactors, and small, reversible product changes within existing direction.
**No separate product approval by default:** bug fixes, security fixes, maintenance, tests, CI, documentation, internal refactors, and features that are **small**, **reversible**, and **fit ROADMAP**.
Maintainers may make those smaller product decisions. Pewds can ask for changes or a rollback; the maintainer owns safe rollback or recovery.
Maintainers may make those smaller product decisions. Pewds can ask for changes or a rollback; the maintainer who decided owns the rollback or recovery.
### Urgent security and reliability fixes
@@ -25,19 +27,171 @@ Product Owner proposal or implementation
A Product Owner proposal is already product-approved; it is not automatically technically approved or exempt from high-risk review.
## Community or maintainer initiated
## Feature proposal workflow
Community proposals start as a Feature Request issue. Check [ROADMAP.md](../../ROADMAP.md) and existing issues first; take early ideas to [Discussions](https://github.com/odysseus-dev/odysseus/discussions/categories/ideas).
Every community or maintainer feature proposal follows these steps. The issue is the record at every step: labels show where a proposal stands, and comments hold the decisions.
```text
Feature proposal
-> Product Owner accepts / rejects, where approval is required
-> technical feasibility
-> plan and implementation
-> risk-based review and validation
```mermaid
flowchart TD
A["0. Idea (optional)<br/>Discussions › Ideas"] -.-> B["1. Feature Request issue"]
B --> C{"2. Triage<br/>within 7 days"}
C -- "duplicate / not actionable" --> X["Closed with a reason"]
C -- "small + reversible + fits ROADMAP" --> M["product: maintainer decision"]
C -- "big, new direction or hard to undo" --> N["product: needs decision"]
N --> D["3. Decision brief"]
D --> E{"4. Product Owner decides"}
E -- yes --> P["product: approved"]
E -- no --> R["product: declined<br/>closed as not planned"]
E -- "no answer in 14 days" --> L{"Only reason was size?"}
L -- yes --> M
L -- "no: new direction or hard to undo" --> N
M --> F["5. Implementation PRs<br/>Part of #N"]
P --> F
F --> G["6. Review and merge"]
G --> H["7. Close the issue"]
```
Record the decision in an issue, proposal, work item, or PR so future maintainers can discover it. Do not rely on a chat-only decision.
### Step 0 — Idea (optional)
Half-formed ideas go to [Discussions › Ideas](https://github.com/odysseus-dev/odysseus/discussions/categories/ideas) to gauge interest. Nothing is decided there. A proposal is ready for step 1 when it is **actionable**.
### Step 1 — Feature Request issue
**Who:** the proposer.
Open a [Feature Request](https://github.com/odysseus-dev/odysseus/issues/new?template=feature_request.yml) after checking [ROADMAP.md](../../ROADMAP.md) and open issues. Besides the required fields, the form asks three optional questions that make triage faster:
- whether the proposer thinks it is big, a new direction, or hard to undo;
- the yes/no questions they want the Product Owner to answer;
- screenshots, a recording, or a demo branch.
A proposal with several independent parts may be one issue, provided each part is listed separately so it can be decided separately.
### Step 2 — Triage
**Who:** a **triager**. **When:** within 7 days of the issue being opened.
The triager does one of three things and records why in a comment:
| Outcome | When | Label | Next |
|---|---|---|---|
| Close | **Duplicate** or not **actionable** | — (closed as duplicate or not planned, with a link) | — |
| Maintainer decision | **Small** *and* **reversible** *and* **fits ROADMAP** | `product: maintainer decision` | Step 5 |
| Needs the Product Owner | **Big**, *or* **new direction**, *or* **hard to undo** | `product: needs decision` | Step 3 |
The triage comment names the ROADMAP line the proposal fits, or says that it fits none, and which of big / new direction / hard to undo applies. When the triager is unsure, the proposal goes to the Product Owner.
### Step 3 — Decision brief
**Who:** the proposer writes it; the triager checks it is complete before the clock starts.
The brief goes at the top of the issue body (edit the issue, or post it as the first comment and link it from the body) and contains:
1. **Summary:** what changes for users, in three to five lines.
2. **Questions:** the exact yes/no questions, one per independent part.
3. **Evidence:** screenshots or a recording for anything that renders; a demo branch if one exists.
4. **What cannot be undone:** migrations, deleted data, changed contracts, or "nothing".
5. **For big designs only:** architecture, migration and rollback, and the planned PR slices. A long design may live in a proposal document under `/design` and be linked from the brief.
When the brief is complete, the triager mentions the Product Owner on the issue. This is **day 0** for the timeout.
### Step 4 — Product Owner decision
**Who:** the Product Owner. **When:** on the issue at any time, or at the next **queue review**.
| Answer | Label | Recorded as |
|---|---|---|
| Yes, possibly with conditions | `product: approved` | A **recorded decision** listing any conditions |
| No | `product: declined` | A recorded decision; the issue is closed as not planned |
| A question | stays `product: needs decision` | The clock pauses until the proposer answers, then restarts at day 0 |
| Different answers per part | the label for the approved part(s) | The recorded decision lists each part and its answer |
A decision given outside GitHub (Matrix, a call) counts once a maintainer has recorded it on the issue. A declined feature can be maintained in a contributor's fork, and the issue can be reopened only with **materially new** information.
#### When there is no answer: lazy consensus
| Day | What happens |
|---|---|
| 0 | Brief complete; Product Owner mentioned on the issue |
| 7 | One reminder from a maintainer, and the issue is put on the next queue review |
| 14 | If there has been no **objection**: |
- If the **only** reason the proposal needed the Product Owner was that it is **big** — it is **reversible** and **fits ROADMAP** — a maintainer may decide. They record "decided by lazy consensus on YYYY-MM-DD", apply `product: maintainer decision`, and own any rollback Pewds later asks for.
- If it is a **new direction** or **hard to undo**, it keeps waiting. Silence never approves a one-way change or a change of direction. It is raised at each queue review.
- A proposal still waiting after **90 days** is closed as not planned with a comment saying it expired without a decision. It can be reopened by anyone with the same brief.
### Step 5 — Implementation
**Who:** the proposer or any contributor.
Feature PRs link the issue with `Part of #N` (or `Fixes #N` for the last slice) and stay within what was decided. A maintainer does not merge a feature PR whose issue carries neither `product: approved` nor `product: maintainer decision`, and checks any conditions in the recorded decision.
If the work grows beyond what was decided — a new part, or a part that becomes hard to undo — that part goes back to step 2.
A maintainer's own small, reversible feature that fits ROADMAP may skip the separate issue: the PR description states why it qualifies, and the PR is the record.
### Step 6 — Review and merge
Unchanged: [changes and review](changes-and-review.md). Product approval is not technical approval; high-risk changes still need an independent qualified review.
### Step 7 — Close
The issue is closed when the last PR lands. If the feature was a ROADMAP item, the same PR or a follow-up updates ROADMAP.md.
## Labels
| Label | Meaning | Applied by |
|---|---|---|
| `product: needs decision` | Waiting for the Product Owner | Triager |
| `product: approved` | The Product Owner said yes (see the recorded decision for scope and conditions) | Product Owner or the maintainer recording the decision |
| `product: maintainer decision` | Small, reversible and on ROADMAP, or decided by lazy consensus | Triager, or the maintainer applying lazy consensus |
| `product: declined` | The Product Owner said no | Product Owner or the maintainer recording the decision |
Only maintainers and the Product Owner set or change these labels. Exactly one of them is on a triaged feature issue at any time.
## Definitions
**Feature.** A change that adds or alters user-visible behavior, a setting, or a public contract (HTTP API, MCP tool, plugin interface, file format). Restoring documented or clearly intended behavior is a bug fix, not a feature.
**Small.** All of the following are true:
- it ships as one PR of ordinary size — as a guide, no more than about 600 changed lines excluding tests, generated files and lockfiles;
- it touches one area of the app;
- it adds an option or a capability without changing what existing users see or get by default;
- it adds no new dependency, service, or background process.
Anything else is **big**.
**Reversible.** Reverting the PR(s) fully restores the previous state for every user, with nothing left behind: no data migrated, rewritten or deleted; no stored format that other code or users now depend on; no public contract that integrations have started to use; no security boundary moved. If any of those is not true, the change is **hard to undo**.
**Hard to undo.** Not reversible. Typical cases: data migrations, deleting or purging data, new persisted formats, new or changed authentication, permission or sharing models, and removing or renaming a public API, MCP tool, setting or file location.
**New direction.** The change takes Odysseus somewhere it has not committed to. Any of:
- a new top-level area of the app (a new panel, tab or module);
- multi-user or collaboration features where the app was single-user;
- data or requests leaving the user's machine by default, including telemetry and new external services;
- dropping or adding a supported platform, install method or model backend;
- anything listed under "Not The Focus Right Now" in ROADMAP.md;
- reversing a previous Product Owner decision.
**Fits ROADMAP.** The triager can point to a specific ROADMAP.md item that the proposal implements or directly extends, and the proposal is not listed under "Not The Focus Right Now". "It is in the same area as an item" is not enough.
**Actionable.** The issue states a concrete problem and a concrete change in behavior, specific enough to write acceptance criteria from.
**Duplicate.** Same user-facing outcome as an open issue or open PR, or a request for a ROADMAP item that adds nothing to it. Close it with a link, and move any new information to the original. A proposal that offers to *implement* a ROADMAP item, with a concrete design, is not a duplicate: it is triaged normally and usually **fits ROADMAP**.
**Triager.** Any maintainer other than the proposer. A maintainer's own proposal is triaged by another maintainer.
**Objection.** A comment on the issue — or one recorded there from another channel — by the Product Owner or a maintainer, asking to stop, wait, or change the scope. A maintainer's objection stops lazy consensus for that proposal; it then waits for the Product Owner like a new direction does. Questions and suggestions that do not ask to stop are not objections.
**Recorded decision.** A comment on the issue that states the decision, who made it, the date, where it was made if not on GitHub, the parts it covers, and any conditions — together with the matching label change.
**Queue review.** Every 14 days, a maintainer brings the [`product: needs decision` list](https://github.com/odysseus-dev/odysseus/issues?q=is%3Aissue+is%3Aopen+label%3A%22product%3A+needs+decision%22) to the Product Owner and records each answer on its issue. The maintainer who runs it is agreed among maintainers and noted here: *to be decided*.
**Days.** Calendar days, counted from the timestamp of the event on the issue (label applied, brief completed, reminder posted).
**Materially new.** Information the original decision did not have: a different design, new evidence of demand, or a changed ROADMAP.
## Decision boundaries
@@ -45,4 +199,4 @@ The Product Owner decides **what belongs in Odysseus**. Maintainers decide **how
A rejected or out-of-scope feature can be maintained independently in a contributor's fork.
If the Product Owner is unavailable, work requiring prior product approval waits unless authority was explicitly delegated. Smaller decisions and urgent fixes follow the rules above.
If the Product Owner is unavailable, work that needs product approval follows the timeout above. It never proceeds on silence when it is a new direction or hard to undo. Smaller decisions and urgent fixes follow the rules above.
+2
View File
@@ -26,6 +26,8 @@ Record what is known and distinguish it from what remains unverified:
- Priority, ownership, and relevant domain expertise.
- Remaining unknowns, risks, and safe recovery or workaround when available.
Feature requests follow the [feature proposal workflow](product-and-features.md#feature-proposal-workflow): triage within 7 days, and a product label on every triaged feature issue.
Small and obvious fixes may go straight to a focused PR, consistent with [CONTRIBUTING.md](../../CONTRIBUTING.md) and [changes and review](changes-and-review.md).
## Practical priority language
+12 -15
View File
@@ -33,6 +33,7 @@ from mcp.types import Tool, TextContent
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.email_mime import decode_text_part as _decode_text_part
server = Server("email")
EMAIL_SOCKET_TIMEOUT = float(os.environ.get("EMAIL_SOCKET_TIMEOUT", "20"))
from src.constants import DATA_DIR as _DATA_DIR, APP_DB, EMAIL_CACHE_DB, SETTINGS_FILE as _SETTINGS_FILE, MAIL_ATTACHMENTS_DIR
@@ -923,29 +924,25 @@ def _extract_text(msg):
"""Extract plain text body from email message."""
if msg.is_multipart():
text_parts = []
html_parts = []
for part in msg.walk():
ct = part.get_content_type()
cd = str(part.get("Content-Disposition", ""))
if ct == "text/plain" and "attachment" not in cd:
payload = part.get_payload(decode=True)
if payload:
charset = part.get_content_charset() or "utf-8"
text_parts.append(payload.decode(charset, errors="replace"))
elif ct == "text/html" and not text_parts and "attachment" not in cd:
payload = part.get_payload(decode=True)
if payload:
charset = part.get_content_charset() or "utf-8"
raw_html = payload.decode(charset, errors="replace")
text = _decode_text_part(part)
if text:
text_parts.append(text)
elif ct == "text/html" and not html_parts and "attachment" not in cd:
raw_html = _decode_text_part(part)
if raw_html:
text = re.sub(r"<br\s*/?>", "\n", raw_html, flags=re.I)
text = re.sub(r"<[^>]+>", "", text)
text = html.unescape(text)
text_parts.append(text.strip())
return "\n".join(text_parts)
html_parts.append(text.strip())
return "\n".join(text_parts or html_parts)
else:
payload = msg.get_payload(decode=True)
if payload:
charset = msg.get_content_charset() or "utf-8"
text = payload.decode(charset, errors="replace")
text = _decode_text_part(msg)
if text:
if msg.get_content_type() == "text/html":
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.I)
text = re.sub(r"</(?:p|div|li|tr|h[1-6])\s*>", "\n", text, flags=re.I)
+53 -10
View File
@@ -13,11 +13,12 @@ import re
from pathlib import Path
from core.atomic_io import atomic_write_json, atomic_write_text
from core.auth import AuthManager, RESERVED_USERNAMES, SetAdminResult, TOKEN_TTL
from core.auth import AuthManager, RESERVED_USERNAMES, SetAdminResult, TOKEN_TTL, username_error
from src.constants import DEEP_RESEARCH_DIR, MEMORY_FILE, PASSWORD_MIN_LENGTH, SKILLS_DIR
from src.auth_helpers import _auth_disabled
from src.rate_limiter import RateLimiter
from src.settings_scrub import scrub_settings
from src import tool_capabilities
from src.settings import (
load_settings as _load_settings,
save_settings as _save_settings,
@@ -154,8 +155,9 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
raise HTTPException(400, "Already configured")
if len(body.password) < PASSWORD_MIN_LENGTH:
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters")
if len(body.username.strip()) < 1:
raise HTTPException(400, "Username is required")
name_error = username_error(body.username)
if name_error:
raise HTTPException(400, name_error)
if body.username.lower() in RESERVED_USERNAMES:
raise HTTPException(403, "Username is reserved")
ok = await asyncio.to_thread(auth_manager.setup, body.username, body.password)
@@ -174,8 +176,9 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
raise HTTPException(403, "Registration is disabled. Ask an admin for an account.")
if len(body.password) < PASSWORD_MIN_LENGTH:
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters")
if len(body.username.strip()) < 1:
raise HTTPException(400, "Username is required")
name_error = username_error(body.username)
if name_error:
raise HTTPException(400, name_error)
if body.username.lower() in RESERVED_USERNAMES:
raise HTTPException(403, "Username is reserved")
ok = await asyncio.to_thread(auth_manager.create_user, body.username, body.password, is_admin=False)
@@ -333,8 +336,9 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
raise HTTPException(403, "Admin only")
if len(body.password) < PASSWORD_MIN_LENGTH:
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters")
if len(body.username.strip()) < 1:
raise HTTPException(400, "Username is required")
name_error = username_error(body.username)
if name_error:
raise HTTPException(400, name_error)
if body.username.lower() in RESERVED_USERNAMES:
raise HTTPException(403, "Username is reserved")
ok = auth_manager.create_user(body.username, body.password, body.is_admin)
@@ -361,8 +365,16 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
user = _get_current_user(request)
if not user or not auth_manager.is_admin(user):
raise HTTPException(403, "Admin only")
body = await request.json()
ok = auth_manager.set_privileges(username, body)
try:
body = await request.json()
except ValueError:
raise HTTPException(400, "Request body must be a JSON object")
if not isinstance(body, dict):
raise HTTPException(400, "Request body must be a JSON object")
try:
ok = auth_manager.set_privileges(username, body)
except ValueError as e:
raise HTTPException(400, str(e))
if not ok:
raise HTTPException(404, "User not found or is admin")
return {"ok": True, "privileges": auth_manager.get_privileges(username)}
@@ -376,6 +388,9 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
new_username = (body.username or "").strip().lower()
if not new_username:
raise HTTPException(400, "Username required")
name_error = username_error(new_username)
if name_error:
raise HTTPException(400, name_error)
if old_username == new_username:
return {"ok": True, "username": new_username, "renamed_self": old_username == user}
if old_username not in auth_manager.users:
@@ -653,7 +668,18 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
user = _get_current_user(request)
if not user or not auth_manager.is_admin(user):
raise HTTPException(403, "Admin only")
result = auth_manager.set_admin(username, body.is_admin, user)
try:
result = auth_manager.set_admin(username, body.is_admin, user)
finally:
# A demotion purges the user's ApiToken rows; dirty the bearer
# cache so already-cached tokens stop authenticating.
if not body.is_admin:
invalidator = getattr(
getattr(getattr(request, "app", None), "state", None),
"invalidate_token_cache", None,
)
if callable(invalidator):
invalidator()
if result is SetAdminResult.USER_NOT_FOUND:
raise HTTPException(404, "User not found")
if result is SetAdminResult.NOT_AUTHORIZED:
@@ -753,6 +779,11 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
user = _get_current_user(request)
settings = without_retired_settings(_load_settings())
if _auth_disabled() or (user and auth_manager.is_admin(user)):
# Deployment-level truth for the Tool approval row: a falsy
# ODYSSEUS_TOOL_APPROVAL_GATE disables the approval gate for every
# run regardless of the stored tool_approval_mode, so surface it to
# admins instead of letting the row mislead them the other way.
settings["tool_approval_gate_env_disabled"] = not tool_capabilities.TOOL_APPROVAL_GATE_ENABLED
return settings
return scrub_settings(settings)
@@ -769,6 +800,9 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
"agent_max_tool_calls": (0, 1000), # 0 = unlimited
"auto_compact_threshold_percent": (50, 95),
}
_VALID_VALUES = {
"tool_approval_mode": {"ask", "off"},
}
for key in DEFAULT_SETTINGS:
if key in RETIRED_SETTING_KEYS:
continue
@@ -782,6 +816,15 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
except (TypeError, ValueError):
raise HTTPException(400, f"{key} must be an integer")
val = max(lo, min(val, hi))
if key in _VALID_VALUES:
allowed = _VALID_VALUES[key]
if not isinstance(val, str) or val not in allowed:
raise HTTPException(
400,
f"{key} must be one of {sorted(allowed)}",
)
current[key] = val
_save_settings(current)
return without_retired_settings(current)
+6 -6
View File
@@ -696,11 +696,10 @@ async def auto_name_session(session_manager, sess):
logger.info(f"Auto-named session {sess.id} deterministically (no authorized model endpoint): {title}")
return
# max_tokens big enough that reasoning models (Minimax M2,
# DeepSeek R1, QwQ, etc.) have headroom for <think>…</think>
# plus the actual title — 200 used to clip them mid-reasoning
# so strip_think left an empty string and no rename happened.
# Timeout matches: 60s gives slow local reasoners room to finish.
# A configured effort is the user's explicit choice. Otherwise thinking
# stays off, because reasoning would use up this small token budget and
# leave no title.
title_effort = effort_for_call(t_url, t_model, owner, sess)
try:
title = await llm_call_async(
t_url,
@@ -713,7 +712,8 @@ async def auto_name_session(session_manager, sess):
max_tokens=64,
headers=t_headers,
timeout=15,
reasoning_effort=effort_for_call(t_url, t_model, owner, sess),
reasoning_effort=title_effort,
thinking_mode=None if title_effort else "off",
)
except Exception as e:
logger.warning("[auto-name] non-spec title call failed for %s: %s", sess.id, e)
+1
View File
@@ -0,0 +1 @@
"""Cookbook route domain package (#4071)."""
+284
View File
@@ -0,0 +1,284 @@
"""Auto-register Cookbook-served models as model endpoints."""
import json
import logging
import uuid
from routes.cookbook_helpers import ServeRequest
logger = logging.getLogger(__name__)
def _auto_register_image_endpoint(
req: ServeRequest,
remote: str | None,
serve_session_id: str | None = None,
) -> str | None:
"""Register a diffusion model as an image endpoint so it appears in the model selector."""
import re
from core.database import SessionLocal, ModelEndpoint
from routes.model_routes import _normalize_model_ids
from src.settings import load_settings, save_settings
# Parse port from command (--port NNNN), default 8100 for diffusion_server
port_match = re.search(r'--port\s+(\d+)', req.cmd)
port = int(port_match.group(1)) if port_match else 8100
# Determine host
if remote:
# SSH alias — use as hostname (Tailscale resolves it later)
host = remote.split("@")[-1] if "@" in remote else remote
else:
host = "localhost"
base_url = f"http://{host}:{port}/v1"
# Friendly display name from repo_id
short_name = req.repo_id.split("/")[-1] if "/" in req.repo_id else req.repo_id
display_name = f"{short_name} (image)"
pinned_models = [req.repo_id] if req.repo_id else []
db = SessionLocal()
try:
# Check for existing endpoint with same base_url — update it
existing = db.query(ModelEndpoint).filter(ModelEndpoint.base_url == base_url).first()
if existing:
existing.is_enabled = True
existing.model_type = "image"
# Follow the newly served model unless the user renamed the endpoint.
pinned_names = {f"{m.split('/')[-1]} (image)" for m in _normalize_model_ids(existing.pinned_models)}
if existing.name in pinned_names:
existing.name = display_name
existing.endpoint_kind = "local"
existing.model_refresh_mode = "manual"
existing.cookbook_serve_session_id = serve_session_id
if pinned_models:
existing.cached_models = json.dumps(pinned_models)
existing.pinned_models = json.dumps(pinned_models)
db.commit()
settings = load_settings()
if settings.get("image_gen_enabled") is not True:
settings["image_gen_enabled"] = True
save_settings(settings)
logger.info(f"Updated existing image endpoint: {base_url}")
return existing.id
ep_id = f"img-{uuid.uuid4().hex[:8]}"
ep = ModelEndpoint(
id=ep_id,
name=display_name,
base_url=base_url,
api_key=None,
is_enabled=True,
model_type="image",
endpoint_kind="local",
model_refresh_mode="manual",
cached_models=json.dumps(pinned_models) if pinned_models else None,
pinned_models=json.dumps(pinned_models) if pinned_models else None,
cookbook_serve_session_id=serve_session_id,
)
db.add(ep)
db.commit()
settings = load_settings()
settings["image_gen_enabled"] = True
if not settings.get("image_model"):
settings["image_model"] = req.repo_id
save_settings(settings)
logger.info(f"Auto-registered image endpoint: {display_name} @ {base_url}")
return ep_id
except Exception as e:
logger.error(f"Failed to auto-register image endpoint: {e}")
db.rollback()
return None
finally:
db.close()
def _auto_register_llm_endpoint(
req: ServeRequest,
remote: str | None,
serve_session_id: str | None = None,
) -> str | None:
"""Register a freshly-served LLM as a model endpoint so it appears in the
model picker without a manual /setup step — the text-model sibling of
_auto_register_image_endpoint.
Cookbook serve commands launch an OpenAI-compatible server (llama.cpp's
llama-server, vLLM, SGLang, or Ollama) on a known port. We point an
endpoint at that server's /v1; the picker auto-discovers the model id by
probing /v1/models and dims the endpoint until the server is reachable,
so registering immediately (before the server finishes loading) is safe.
"""
logger.info(
f"_auto_register_llm_endpoint: ENTRY repo_id={req.repo_id!r} "
f"remote={remote!r} cmd_prefix={req.cmd[:80]!r}"
)
import re
from core.database import SessionLocal, ModelEndpoint
from routes.model_routes import _normalize_model_ids
# Port: ordered fallbacks so we match whatever the user actually
# asked for, not a hardcoded default:
# 1. explicit `--port N` (vllm / sglang / llama-server)
# 2. `OLLAMA_HOST=host:port` (the way Ollama specifies its bind)
# 3. fallback by backend (11434 ollama / 8080 llama.cpp)
# Previously the OLLAMA_HOST form was silently ignored and we
# registered every Ollama endpoint at 11434 — even if the user
# set OLLAMA_HOST=0.0.0.0:11435 to avoid colliding with an
# existing systemd Ollama, the registered endpoint pointed at
# the OLD port and showed as offline.
port_match = re.search(r'--port\s+(\d+)', req.cmd)
# Host length is capped at the longest DNS name; unbounded, repeated
# assignments make this scan quadratic and stall the event loop.
ollama_host_match = re.search(r'OLLAMA_HOST=[^\s]{0,253}?:(\d+)', req.cmd)
if port_match:
port = int(port_match.group(1))
elif ollama_host_match:
port = int(ollama_host_match.group(1))
elif "ollama" in req.cmd:
port = 11434
else:
port = 8080 # llama.cpp's llama-server default — the Apple Silicon path
# Determine host. The cookbook tmux for `local=true` serves runs INSIDE
# the odysseus container — so the right URL for the in-container
# backend to reach it is `localhost`, NOT `host.docker.internal`
# (the latter points at the docker HOST, which doesn't have a server
# on that port). The previous host.docker.internal fallback only made
# sense for /setup-added external services like systemd Ollama on the
# host — and those go through manual setup, not this auto-register
# code path. For remote serves we still use the SSH host alias.
if remote:
host = remote.split("@")[-1] if "@" in remote else remote
elif re.search(r"\bdocker\s+exec\s+(?:ollama-rocm|ollama-test)\b", req.cmd or ""):
host = "host.docker.internal"
else:
host = "localhost"
base_url = f"http://{host}:{port}/v1"
short_name = req.repo_id.split("/")[-1] if "/" in req.repo_id else req.repo_id
display_name = short_name or "Local model"
is_mlx_deepseek_v4 = (
"mlx_lm.server" in (req.cmd or "")
and "deepseek-v4" in ((req.repo_id or "") + " " + (req.cmd or "")).lower()
)
mlx_shim_model_id = ""
if is_mlx_deepseek_v4 and short_name:
home_match = re.search(r"((?:/Users|/home)/[^/\s'\"]+)", req.cmd or "")
remote_home = home_match.group(1) if home_match else ""
if remote_home:
mlx_shim_model_id = f"{remote_home}/.cache/odysseus/mlx-shims/{short_name}"
# If the serve command opts models into OpenAI tool-calling, record it so
# agent_loop trusts emitted tool_calls instead of the name heuristic.
is_ollama_endpoint = "ollama" in (req.cmd or "").lower()
supports_tools = True if "--enable-auto-tool-choice" in req.cmd else None
# Pin the model the user launched for every Cookbook-created LLM
# endpoint, not just Ollama. Some OpenAI-compatible servers report a
# deployment alias from /v1/models, and a stale server can answer on the
# same port while the new launch failed. Keeping the requested model id
# pinned makes the picker reflect the actual launch intent.
launched_model = mlx_shim_model_id or req.repo_id
pinned_models = [launched_model] if launched_model else []
# Launch registration must not delete other endpoints.
# Matching names, model pins, and failed probes do not prove that
# another serve has stopped. Lifecycle cleanup owns termination.
db = SessionLocal()
try:
# Reuse an endpoint already pointed at this URL instead of duplicating.
existing = db.query(ModelEndpoint).filter(ModelEndpoint.base_url == base_url).first()
if existing:
existing.is_enabled = True
existing.model_type = "llm"
# Follow the newly served model unless the user renamed the endpoint.
pinned_names = {m.split("/")[-1] for m in _normalize_model_ids(existing.pinned_models)}
if existing.name in pinned_names:
existing.name = display_name
existing.endpoint_kind = "local"
existing.model_refresh_mode = "auto"
# A reused endpoint belongs to the newest serve session.
existing.cookbook_serve_session_id = serve_session_id
if launched_model:
# Pins represent model selection, not launch history.
existing_pins = _normalize_model_ids(
existing.pinned_models
)
if launched_model not in existing_pins:
existing_pins.append(launched_model)
existing.pinned_models = json.dumps(existing_pins)
if is_ollama_endpoint:
existing.endpoint_kind = "ollama"
if pinned_models:
existing.cached_models = json.dumps(pinned_models)
if supports_tools is not None:
existing.supports_tools = supports_tools
db.commit()
logger.info(f"Updated existing local model endpoint: {base_url}")
# Re-probe so cached_models matches what the server actually
# serves right now (the URL may have stayed the same but the
# model behind it changed across launches).
try:
if mlx_shim_model_id:
existing.cached_models = json.dumps([mlx_shim_model_id])
existing.pinned_models = json.dumps([mlx_shim_model_id])
db.commit()
else:
from routes.model_routes import _probe_endpoint
import json as _json2
probed = _probe_endpoint(base_url, existing.api_key, timeout=5)
if probed:
existing.cached_models = _json2.dumps(probed)
db.commit()
except Exception as _pe:
logger.warning(f"Re-probe failed for {base_url}: {_pe!r}")
return existing.id
ep_id = f"local-{uuid.uuid4().hex[:8]}"
ep = ModelEndpoint(
id=ep_id,
name=display_name,
base_url=base_url,
api_key=None,
is_enabled=True,
model_type="llm",
endpoint_kind="ollama" if is_ollama_endpoint else "local",
model_refresh_mode="auto",
cached_models=json.dumps(pinned_models) if pinned_models else None,
pinned_models=json.dumps(pinned_models) if pinned_models else None,
supports_tools=supports_tools,
cookbook_serve_session_id=serve_session_id,
)
db.add(ep)
db.commit()
logger.info(f"Auto-registered local model endpoint: {display_name} @ {base_url}")
# Probe /v1/models NOW and write cached_models so the chat
# picker actually shows the model on the next /api/models
# call. Without this immediate probe, the endpoint has empty
# cached_models until the next background refresh fires (up
# to a minute later) and the picker shows nothing — even
# though the endpoint is in the DB and the server is up.
try:
if mlx_shim_model_id:
ep.cached_models = json.dumps([mlx_shim_model_id])
ep.pinned_models = json.dumps([mlx_shim_model_id])
db.commit()
logger.info(f"Auto-register: pinned MLX DeepSeek-V4 shim model @ {base_url}")
else:
from routes.model_routes import _probe_endpoint
import json as _json2
probed = _probe_endpoint(base_url, None, timeout=5)
if probed:
ep.cached_models = _json2.dumps(probed)
db.commit()
logger.info(f"Auto-register: probed {len(probed)} models @ {base_url}")
except Exception as _pe:
logger.warning(f"Auto-register: probe-after-create failed for {base_url}: {_pe!r}")
return ep_id
except Exception as e:
logger.error(f"Failed to auto-register local model endpoint: {e}")
db.rollback()
return None
finally:
db.close()
+6 -1
View File
@@ -728,8 +728,13 @@ def _check_serve_binary(seg: str) -> None:
if not tokens:
return
env_re = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
first = next((t for t in tokens if not env_re.match(t)), "")
idx = next((i for i, t in enumerate(tokens) if not env_re.match(t)), len(tokens))
first = tokens[idx] if idx < len(tokens) else ""
base = os.path.basename(first)
# The vLLM/SGLang Dependencies recipes install with `uv pip install …
# --torch-backend auto`. Allow uv for that subcommand only, not `uv run`.
if base == "uv" and tokens[idx + 1:idx + 3] == ["pip", "install"]:
return
if base not in _SERVE_CMD_ALLOWLIST:
raise HTTPException(
400,
+3 -271
View File
@@ -43,6 +43,9 @@ from routes.cookbook_output import (
error_aware_output_tail, classify_dead_download,
HF_CACHE_COMPLETE_PROBE, HF_CACHE_INCOMPLETE_PROBE,
)
from routes.cookbook.endpoint_registration import (
_auto_register_image_endpoint, _auto_register_llm_endpoint,
)
logger = logging.getLogger(__name__)
@@ -1408,90 +1411,6 @@ def setup_cookbook_routes() -> APIRouter:
return {"models": models, "host": host or "local"}
def _auto_register_image_endpoint(
req: ServeRequest,
remote: str | None,
serve_session_id: str | None = None,
) -> str | None:
"""Register a diffusion model as an image endpoint so it appears in the model selector."""
import re
from core.database import SessionLocal, ModelEndpoint
from routes.model_routes import _normalize_model_ids
from src.settings import load_settings, save_settings
# Parse port from command (--port NNNN), default 8100 for diffusion_server
port_match = re.search(r'--port\s+(\d+)', req.cmd)
port = int(port_match.group(1)) if port_match else 8100
# Determine host
if remote:
# SSH alias — use as hostname (Tailscale resolves it later)
host = remote.split("@")[-1] if "@" in remote else remote
else:
host = "localhost"
base_url = f"http://{host}:{port}/v1"
# Friendly display name from repo_id
short_name = req.repo_id.split("/")[-1] if "/" in req.repo_id else req.repo_id
display_name = f"{short_name} (image)"
pinned_models = [req.repo_id] if req.repo_id else []
db = SessionLocal()
try:
# Check for existing endpoint with same base_url — update it
existing = db.query(ModelEndpoint).filter(ModelEndpoint.base_url == base_url).first()
if existing:
existing.is_enabled = True
existing.model_type = "image"
# Follow the newly served model unless the user renamed the endpoint.
pinned_names = {f"{m.split('/')[-1]} (image)" for m in _normalize_model_ids(existing.pinned_models)}
if existing.name in pinned_names:
existing.name = display_name
existing.endpoint_kind = "local"
existing.model_refresh_mode = "manual"
existing.cookbook_serve_session_id = serve_session_id
if pinned_models:
existing.cached_models = json.dumps(pinned_models)
existing.pinned_models = json.dumps(pinned_models)
db.commit()
settings = load_settings()
if settings.get("image_gen_enabled") is not True:
settings["image_gen_enabled"] = True
save_settings(settings)
logger.info(f"Updated existing image endpoint: {base_url}")
return existing.id
ep_id = f"img-{uuid.uuid4().hex[:8]}"
ep = ModelEndpoint(
id=ep_id,
name=display_name,
base_url=base_url,
api_key=None,
is_enabled=True,
model_type="image",
endpoint_kind="local",
model_refresh_mode="manual",
cached_models=json.dumps(pinned_models) if pinned_models else None,
pinned_models=json.dumps(pinned_models) if pinned_models else None,
cookbook_serve_session_id=serve_session_id,
)
db.add(ep)
db.commit()
settings = load_settings()
settings["image_gen_enabled"] = True
if not settings.get("image_model"):
settings["image_model"] = req.repo_id
save_settings(settings)
logger.info(f"Auto-registered image endpoint: {display_name} @ {base_url}")
return ep_id
except Exception as e:
logger.error(f"Failed to auto-register image endpoint: {e}")
db.rollback()
return None
finally:
db.close()
def _pick_free_port_for_ollama(
remote: str | None, ssh_port: str | None, start_port: int, max_offset: int
) -> int | None:
@@ -1680,193 +1599,6 @@ def setup_cookbook_routes() -> APIRouter:
return
logger.debug(f"crash-watchdog: no exit marker for {session_id} within window; leaving endpoint {endpoint_id}")
def _auto_register_llm_endpoint(
req: ServeRequest,
remote: str | None,
serve_session_id: str | None = None,
) -> str | None:
"""Register a freshly-served LLM as a model endpoint so it appears in the
model picker without a manual /setup step — the text-model sibling of
_auto_register_image_endpoint.
Cookbook serve commands launch an OpenAI-compatible server (llama.cpp's
llama-server, vLLM, SGLang, or Ollama) on a known port. We point an
endpoint at that server's /v1; the picker auto-discovers the model id by
probing /v1/models and dims the endpoint until the server is reachable,
so registering immediately (before the server finishes loading) is safe.
"""
logger.info(
f"_auto_register_llm_endpoint: ENTRY repo_id={req.repo_id!r} "
f"remote={remote!r} cmd_prefix={req.cmd[:80]!r}"
)
import re
from core.database import SessionLocal, ModelEndpoint
from routes.model_routes import _normalize_model_ids
# Port: ordered fallbacks so we match whatever the user actually
# asked for, not a hardcoded default:
# 1. explicit `--port N` (vllm / sglang / llama-server)
# 2. `OLLAMA_HOST=host:port` (the way Ollama specifies its bind)
# 3. fallback by backend (11434 ollama / 8080 llama.cpp)
# Previously the OLLAMA_HOST form was silently ignored and we
# registered every Ollama endpoint at 11434 — even if the user
# set OLLAMA_HOST=0.0.0.0:11435 to avoid colliding with an
# existing systemd Ollama, the registered endpoint pointed at
# the OLD port and showed as offline.
port_match = re.search(r'--port\s+(\d+)', req.cmd)
ollama_host_match = re.search(r'OLLAMA_HOST=[^\s]*?:(\d+)', req.cmd)
if port_match:
port = int(port_match.group(1))
elif ollama_host_match:
port = int(ollama_host_match.group(1))
elif "ollama" in req.cmd:
port = 11434
else:
port = 8080 # llama.cpp's llama-server default — the Apple Silicon path
# Determine host. The cookbook tmux for `local=true` serves runs INSIDE
# the odysseus container — so the right URL for the in-container
# backend to reach it is `localhost`, NOT `host.docker.internal`
# (the latter points at the docker HOST, which doesn't have a server
# on that port). The previous host.docker.internal fallback only made
# sense for /setup-added external services like systemd Ollama on the
# host — and those go through manual setup, not this auto-register
# code path. For remote serves we still use the SSH host alias.
if remote:
host = remote.split("@")[-1] if "@" in remote else remote
elif re.search(r"\bdocker\s+exec\s+(?:ollama-rocm|ollama-test)\b", req.cmd or ""):
host = "host.docker.internal"
else:
host = "localhost"
base_url = f"http://{host}:{port}/v1"
short_name = req.repo_id.split("/")[-1] if "/" in req.repo_id else req.repo_id
display_name = short_name or "Local model"
is_mlx_deepseek_v4 = (
"mlx_lm.server" in (req.cmd or "")
and "deepseek-v4" in ((req.repo_id or "") + " " + (req.cmd or "")).lower()
)
mlx_shim_model_id = ""
if is_mlx_deepseek_v4 and short_name:
home_match = re.search(r"((?:/Users|/home)/[^/\s'\"]+)", req.cmd or "")
remote_home = home_match.group(1) if home_match else ""
if remote_home:
mlx_shim_model_id = f"{remote_home}/.cache/odysseus/mlx-shims/{short_name}"
# If the serve command opts models into OpenAI tool-calling, record it so
# agent_loop trusts emitted tool_calls instead of the name heuristic.
is_ollama_endpoint = "ollama" in (req.cmd or "").lower()
supports_tools = True if "--enable-auto-tool-choice" in req.cmd else None
# Pin the model the user launched for every Cookbook-created LLM
# endpoint, not just Ollama. Some OpenAI-compatible servers report a
# deployment alias from /v1/models, and a stale server can answer on the
# same port while the new launch failed. Keeping the requested model id
# pinned makes the picker reflect the actual launch intent.
launched_model = mlx_shim_model_id or req.repo_id
pinned_models = [launched_model] if launched_model else []
# Launch registration must not delete other endpoints.
# Matching names, model pins, and failed probes do not prove that
# another serve has stopped. Lifecycle cleanup owns termination.
db = SessionLocal()
try:
# Reuse an endpoint already pointed at this URL instead of duplicating.
existing = db.query(ModelEndpoint).filter(ModelEndpoint.base_url == base_url).first()
if existing:
existing.is_enabled = True
existing.model_type = "llm"
# Follow the newly served model unless the user renamed the endpoint.
pinned_names = {m.split("/")[-1] for m in _normalize_model_ids(existing.pinned_models)}
if existing.name in pinned_names:
existing.name = display_name
existing.endpoint_kind = "local"
existing.model_refresh_mode = "auto"
# A reused endpoint belongs to the newest serve session.
existing.cookbook_serve_session_id = serve_session_id
if launched_model:
# Pins represent model selection, not launch history.
existing_pins = _normalize_model_ids(
existing.pinned_models
)
if launched_model not in existing_pins:
existing_pins.append(launched_model)
existing.pinned_models = json.dumps(existing_pins)
if is_ollama_endpoint:
existing.endpoint_kind = "ollama"
if pinned_models:
existing.cached_models = json.dumps(pinned_models)
if supports_tools is not None:
existing.supports_tools = supports_tools
db.commit()
logger.info(f"Updated existing local model endpoint: {base_url}")
# Re-probe so cached_models matches what the server actually
# serves right now (the URL may have stayed the same but the
# model behind it changed across launches).
try:
if mlx_shim_model_id:
existing.cached_models = json.dumps([mlx_shim_model_id])
existing.pinned_models = json.dumps([mlx_shim_model_id])
db.commit()
else:
from routes.model_routes import _probe_endpoint
import json as _json2
probed = _probe_endpoint(base_url, existing.api_key, timeout=5)
if probed:
existing.cached_models = _json2.dumps(probed)
db.commit()
except Exception as _pe:
logger.warning(f"Re-probe failed for {base_url}: {_pe!r}")
return existing.id
ep_id = f"local-{uuid.uuid4().hex[:8]}"
ep = ModelEndpoint(
id=ep_id,
name=display_name,
base_url=base_url,
api_key=None,
is_enabled=True,
model_type="llm",
endpoint_kind="ollama" if is_ollama_endpoint else "local",
model_refresh_mode="auto",
cached_models=json.dumps(pinned_models) if pinned_models else None,
pinned_models=json.dumps(pinned_models) if pinned_models else None,
supports_tools=supports_tools,
cookbook_serve_session_id=serve_session_id,
)
db.add(ep)
db.commit()
logger.info(f"Auto-registered local model endpoint: {display_name} @ {base_url}")
# Probe /v1/models NOW and write cached_models so the chat
# picker actually shows the model on the next /api/models
# call. Without this immediate probe, the endpoint has empty
# cached_models until the next background refresh fires (up
# to a minute later) and the picker shows nothing — even
# though the endpoint is in the DB and the server is up.
try:
if mlx_shim_model_id:
ep.cached_models = json.dumps([mlx_shim_model_id])
ep.pinned_models = json.dumps([mlx_shim_model_id])
db.commit()
logger.info(f"Auto-register: pinned MLX DeepSeek-V4 shim model @ {base_url}")
else:
from routes.model_routes import _probe_endpoint
import json as _json2
probed = _probe_endpoint(base_url, None, timeout=5)
if probed:
ep.cached_models = _json2.dumps(probed)
db.commit()
logger.info(f"Auto-register: probed {len(probed)} models @ {base_url}")
except Exception as _pe:
logger.warning(f"Auto-register: probe-after-create failed for {base_url}: {_pe!r}")
return ep_id
except Exception as e:
logger.error(f"Failed to auto-register local model endpoint: {e}")
db.rollback()
return None
finally:
db.close()
@router.post("/api/model/serve")
async def model_serve(request: Request, req: ServeRequest):
"""Launch a model server in a tmux session (or PowerShell background process on Windows).
+18 -23
View File
@@ -36,6 +36,7 @@ from fastapi import Query, HTTPException, Request
from pydantic import BaseModel
from typing import Optional, List
from src.email_mime import decode_text_part as _decode_text_part
from src.auth_helpers import _auth_disabled, get_current_user
from src.secret_storage import decrypt as _decrypt
from src.url_safety import OutboundAddressBlocked, connect_outbound_tcp
@@ -1776,44 +1777,38 @@ def _extract_html(msg):
ct = part.get_content_type()
cd = str(part.get("Content-Disposition", ""))
if ct == "text/html" and "attachment" not in cd:
payload = part.get_payload(decode=True)
if payload:
charset = part.get_content_charset() or "utf-8"
return payload.decode(charset, errors="replace")
text = _decode_text_part(part)
if text:
return text
elif msg.get_content_type() == "text/html":
payload = msg.get_payload(decode=True)
if payload:
charset = msg.get_content_charset() or "utf-8"
return payload.decode(charset, errors="replace")
text = _decode_text_part(msg)
if text:
return text
return None
def _extract_text(msg):
if msg.is_multipart():
text_parts = []
html_parts = []
for part in msg.walk():
ct = part.get_content_type()
cd = str(part.get("Content-Disposition", ""))
if ct == "text/plain" and "attachment" not in cd:
payload = part.get_payload(decode=True)
if payload:
charset = part.get_content_charset() or "utf-8"
text_parts.append(payload.decode(charset, errors="replace"))
elif ct == "text/html" and not text_parts and "attachment" not in cd:
payload = part.get_payload(decode=True)
if payload:
charset = part.get_content_charset() or "utf-8"
raw_html = payload.decode(charset, errors="replace")
text = _decode_text_part(part)
if text:
text_parts.append(text)
elif ct == "text/html" and not html_parts and "attachment" not in cd:
raw_html = _decode_text_part(part)
if raw_html:
text = re.sub(r"<br\s*/?>", "\n", raw_html, flags=re.I)
text = re.sub(r"<[^>]+>", "", text)
text = html.unescape(text)
text_parts.append(text.strip())
return "\n".join(text_parts)
html_parts.append(text.strip())
return "\n".join(text_parts or html_parts)
else:
payload = msg.get_payload(decode=True)
if payload:
charset = msg.get_content_charset() or "utf-8"
text = payload.decode(charset, errors="replace")
text = _decode_text_part(msg)
if text:
if msg.get_content_type() == "text/html":
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.I)
text = re.sub(r"</(?:p|div|li|tr|h[1-6])\s*>", "\n", text, flags=re.I)
+6
View File
@@ -471,10 +471,16 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
_verify_session_owner(request, session_id)
try:
body = await request.json()
if not isinstance(body, dict):
raise HTTPException(400, "Request body must be a JSON object")
role = body.get("role", "assistant")
content = body.get("content", "")
if role not in ("user", "assistant"):
raise HTTPException(400, "role must be 'user' or 'assistant'")
if not content:
raise HTTPException(400, "content is required")
if not isinstance(content, str):
raise HTTPException(400, "content must be a string")
metadata = sanitize_client_message_metadata(body.get("metadata"))
_reserve_message_uploads(request, content, metadata)
msg = ChatMessage(role=role, content=content, metadata=metadata)
+1
View File
@@ -418,6 +418,7 @@ _HOST_TO_CURATED = (
("nvidia.com", "nvidia"),
("openrouter.ai", "openrouter"),
("ollama.com", "ollama"),
("conifer.build", "openrouter"),
)
+1
View File
@@ -340,6 +340,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
max_tokens=20,
headers=headers,
timeout=15,
thinking_mode="off",
)
title = result.strip().strip('"\'').strip()
return title[:60] if title else prompt[:50].strip()
File diff suppressed because it is too large Load Diff
+12 -4
View File
@@ -27,6 +27,7 @@ from pathlib import Path
try:
from core.database import SessionLocal, Document, DocumentVersion
from src.session_search import escape_like_pattern
quiet_logs()
except ModuleNotFoundError as e:
sys.stderr.write(f"error: {e}\nhint: run from repo root with venv active.\n")
@@ -139,10 +140,17 @@ def cmd_export(args):
def cmd_search(args):
db = SessionLocal()
try:
like = f"%{args.query}%"
rows = db.query(Document).filter(
(Document.title.ilike(like)) | (Document.current_content.ilike(like))
).order_by(Document.updated_at.desc()).limit(args.limit).all()
# Per-term AND search: a single `%foo bar%` LIKE only matched the
# exact phrase, so "machine learning" missed docs containing both
# words apart. LIKE wildcards are escaped per token.
q = db.query(Document)
for tok in args.query.split():
term = f"%{escape_like_pattern(tok)}%"
q = q.filter(
Document.title.ilike(term, escape="\\")
| Document.current_content.ilike(term, escape="\\")
)
rows = q.order_by(Document.updated_at.desc()).limit(args.limit).all()
emit([_serialize(d) for d in rows], args)
finally:
db.close()
+9
View File
@@ -660,6 +660,8 @@ def _detect_windows():
"gpu_vram_gb": d.get("gpu_vram_gb"),
"gpu_count": _as_int(d.get("gpu_count"), 0),
"backend": d.get("gpu_backend", "cpu_x86"),
"gpus": [],
"gpu_groups": [],
"homogeneous": True,
"gpu_error": None,
"platform": "windows",
@@ -822,12 +824,14 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
if result:
result = _attach_probe_context(result, host=host)
_remote_host = None
_remote_port = None
_remote_platform = None
_cache_by_host[cache_key] = (now, result)
return result
# SSH may work while the PowerShell hardware probe still fails.
result = {"error": f"Windows hardware probe failed for {host}", "host": host}
_remote_host = None
_remote_port = None
_remote_platform = None
_cache_by_host[cache_key] = (now, result)
return result
@@ -852,6 +856,7 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
result = {"error": f"Cannot connect to {host}", "host": host}
_cache_by_host[cache_key] = (now, result)
_remote_host = None
_remote_port = None
_remote_platform = None
return result
available_ram = round(_get_available_ram_gb(), 1)
@@ -893,6 +898,9 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
"gpu_name": None,
"gpu_vram_gb": None,
"gpu_count": 0,
"gpus": [],
"gpu_groups": [],
"homogeneous": True,
"backend": backend,
# Set when nvidia-smi exists but failed (e.g. driver/library
# version mismatch) — lets the UI say "GPU driver error" instead
@@ -902,6 +910,7 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
result = _attach_probe_context(result, host=host)
_remote_host = None
_remote_port = None
_remote_platform = None
_cache_by_host[cache_key] = (now, result)
return result
+16 -1
View File
@@ -64,6 +64,21 @@ _SHELL_COMMAND = (
r"nvcc|docker|systemctl|journalctl|tmux|git)"
)
_BENCHMARK_COMMAND = r"(?:[a-z][a-z0-9_-]*bench(?:mark)?s?|bench(?:mark)?s?)"
# "on <word>" and "from <word>" alone describe people, companies, and topics
# ("Jensen Huang from NVIDIA", "a book on Rome") as often as hosts, so a target
# only counts as a computer next to wording that describes work on a machine.
_NAMED_COMPUTER = (
r"(?!this\b|my\b|the\b|a\b|an\b|that\b|it\b|same\b|current\b)"
r"[a-z][a-z0-9_.-]{1,31}\b"
)
NAMED_COMPUTER_TASK = (
r"\b(?:disks?|uptime|processes|logs?|files?|folders?|director(?:y|ies)|running|installed|"
r"(?:storage|memory|ram|vram|cpu|gpu)\s+(?:usage|space|utili[sz]ation|load|temp(?:erature)?))\b"
rf"[^.?!\n]{{0,80}}\bon\s+{_NAMED_COMPUTER}"
r"|\b(?:copy|fetch|get|grab|pull|download|scp|rsync|sync|read|show|list|tail|check)\b"
r"[^.?!\n]{0,60}\b(?:files?|logs?|folders?|director(?:y|ies)|configs?|checkpoints?|backups?)\b"
rf"[^.?!\n]{{0,40}}\bfrom\s+{_NAMED_COMPUTER}"
)
_CODE_ACTION = r"(?:write|create|add|edit|modify|code|program|implement|build)"
_CODE_ARTIFACT = (
r"(?:code|function|class|script|module|component|snippet|program|app|feature|file|"
@@ -164,7 +179,7 @@ _ROUTING_PATTERNS: tuple[tuple[str, str, Pattern[str]], ...] = tuple(
("workspace", "file/code inspection request", rf"{_PLEASE}(?:find|inspect|look\s+at|open|read|check)\b.{{0,120}}\b(?:file|folder|directory|repo|repository|code|source|logs?|trace|stack|diff)\b"),
("workspace", "server/process debugging request", rf"{_PLEASE}(?:check|debug|fix|restart|start|stop|kill|tail|inspect)\b.{{0,120}}\b(?:server|service|process|port|docker|container|tmux|endpoint|logs?)\b"),
("workspace", "local computer task request", r"\b(?:on|from|in|using|with)\s+(?:this|my|the)\s+(?:computer|machine|pc|laptop|device|system)\b|\b(?:local|host)\s+(?:computer|machine|files?|system)\b"),
("workspace", "named computer task request", r"\b(?:on|from)\s+(?!this\b|my\b|the\b|a\b|an\b|that\b|it\b|same\b|current\b)(?:[a-z][a-z0-9_.-]{1,31})\b"),
("workspace", "named computer task request", NAMED_COMPUTER_TASK),
("workspace", "terminal workspace request", rf"\b(?:terminal|shell|workspace|tmux|docker|container|git|branch|commit|diff|pytest|stacktrace|traceback|{_BENCHMARK_COMMAND}|eval(?:uation)?s?)\b"),
# Shell / remote-host intent.
+18 -5
View File
@@ -14,12 +14,12 @@ import csv
import difflib
import html
import json
import logging
import os
import re
import shlex
import shutil
import time
import logging
import hashlib
from src.web_recovery import WebRecoveryBudget
from itertools import count
@@ -44,6 +44,7 @@ from src.model_profiles import (
is_odysseus_merged_tools_model,
tool_schema_profile,
)
from src.action_intents import NAMED_COMPUTER_TASK
from src.agent_evidence import (
EvidenceLedger,
command_has_mutation_effect,
@@ -8764,7 +8765,7 @@ _LOCAL_COMPUTER_REFERENCE_RE = re.compile(
r"\b(?:on|from|in|using|with)\s+(?:this|my|the)\s+(?:computer|machine|pc|laptop|device|system)\b"
r"|\b(?:this|my|the)\s+(?:computer|machine|pc|laptop|device|system)\b"
r"|\b(?:local|host)\s+(?:computer|machine|files?|system)\b"
r"|\b(?:on|from)\s+(?!this\b|my\b|the\b|a\b|an\b)(?:[a-z][a-z0-9_.-]{1,31})\b",
rf"|{NAMED_COMPUTER_TASK}",
re.IGNORECASE,
)
_LOCAL_NETWORK_REFERENCE_RE = re.compile(
@@ -16994,6 +16995,20 @@ def _detect_runaway_call(call_freq, threshold=15):
sig = next((s for s, n in call_freq.items() if n >= threshold), None)
return sig.split(":", 1)[0] if sig else None
def _tool_approval_gate_bypassed(
exact_approval: Optional[ExactToolApproval] = None,
) -> bool:
"""Return whether the post-external-context tool approval gate is bypassed.
``tool_approval_mode=off`` (admin setting, Settings → Agent Tools) bypasses
the gate on every run, the same effect as ``ODYSSEUS_TOOL_APPROVAL_GATE=0``.
A chat-session grant bypasses it only within the scope of its own task.
"""
return (
get_setting("tool_approval_mode", "ask") == "off"
or bool(exact_approval and exact_approval.allow_remaining_actions)
)
def _tool_result_signature(tool_result_records: list[dict]) -> str:
"""Return a bounded fingerprint of the observable result of a tool batch.
@@ -21437,9 +21452,7 @@ async def stream_agent_loop(
)
or messages_contain_external_untrusted_context(messages)
),
approval_gate_bypassed=bool(
exact_approval and exact_approval.allow_remaining_actions
),
approval_gate_bypassed=_tool_approval_gate_bypassed(exact_approval),
delegated_credential=bool(delegated_credential),
)
_has_tui_host_bridge = _tui_host_bridge_is_usable(client_runtime_context)
+6 -5
View File
@@ -47,6 +47,7 @@ EFFECTS_DIR = os.path.join(DATA_DIR, "effects")
_RUN_ID = re.compile(r"[a-f0-9]{32}")
_TYPES = {"claim": EffectClaim, "outcome": EffectOutcome, "observation": Observation}
_VERSION = 1
_O_BINARY = getattr(os, "O_BINARY", 0) # Windows: raw os.open() is text-mode without it
def _fsync_directory(directory: str | os.PathLike) -> None:
@@ -243,7 +244,7 @@ class EffectLog:
"""Hold the file exclusively with every durable record merged."""
assert self.path is not None
_ensure_directory(self.path.parent)
flags = os.O_RDWR | os.O_APPEND | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0)
flags = os.O_RDWR | os.O_APPEND | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) | _O_BINARY
descriptor = os.open(self.path, flags, 0o600)
try:
if fcntl is not None:
@@ -358,7 +359,7 @@ class EffectLog:
with self._lock:
try:
descriptor = os.open(self.path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
| getattr(os, "O_CLOEXEC", 0))
| getattr(os, "O_CLOEXEC", 0) | _O_BINARY)
except FileNotFoundError:
return
except OSError:
@@ -394,7 +395,7 @@ class EffectLog:
log = cls(run_id, directory=directory)
assert log.path is not None
try:
descriptor = os.open(log.path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0))
descriptor = os.open(log.path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) | _O_BINARY)
except FileNotFoundError:
return log
except OSError as error:
@@ -460,7 +461,7 @@ class EffectLog:
temporary = target.with_suffix(".tmp")
data = json.dumps({"run_id": self.run_id, "effect_id": effect_id}, sort_keys=True).encode()
_ensure_directory(target.parent)
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0)
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) | _O_BINARY
descriptor = os.open(temporary, flags, 0o600)
try:
view = memoryview(data)
@@ -478,7 +479,7 @@ class EffectLog:
return None
base = Path(directory) if directory is not None else effects_dir()
try:
descriptor = os.open(base / f"launch-{generation}.json", os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
descriptor = os.open(base / f"launch-{generation}.json", os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | _O_BINARY)
with os.fdopen(descriptor, "rb") as stream:
if os.fstat(stream.fileno()).st_nlink != 1:
return None
+12 -3
View File
@@ -4,7 +4,7 @@ import html
import difflib
import logging
import re
from src.constants import MAX_READ_CHARS
from src.constants import MAX_OUTPUT_CHARS, MAX_READ_CHARS
from src.tool_approvals import document_content_digest
from src.tool_utils import _parse_tool_args, get_upload_handler
from src.upload_handler import reserve_upload_references
@@ -1175,14 +1175,23 @@ class ManageDocumentTool:
except (TypeError, ValueError):
offset = 0
offset = min(offset, len(body))
anchor = f"[{doc.title}](#document-{doc.id})"
header = f"{anchor} — click to open in editor.\n\n```{doc.language or ''}\n"
# Reserve the closing fence and the longest possible paging
# notice before selecting the page. The common output cap must
# never hide content that next_offset claims was delivered.
notice = f"\n... (truncated, {len(body)} chars total; next_offset={len(body)})"
page_budget = MAX_OUTPUT_CHARS - len(header) - len(notice) - len("\n```")
if page_budget < 1:
return {"error": "Document title or language is too long to display a read page", "exit_code": 1}
preview_limit = min(preview_limit, page_budget)
end = min(offset + preview_limit, len(body))
truncated = end < len(body)
preview = body[offset:end]
if truncated:
preview += f"\n... (truncated, {len(body)} chars total; next_offset={end})"
anchor = f"[{doc.title}](#document-{doc.id})"
return {
"response": f"{anchor} — click to open in editor.\n\n```{doc.language or ''}\n{preview}\n```",
"response": f"{header}{preview}\n```",
"document": {
"id": doc.id,
"title": doc.title,
+60 -11
View File
@@ -9,9 +9,61 @@ from src.chat_helpers import extract_urls
from src.youtube_handler import is_youtube_url
from src.search import comprehensive_web_search, fetch_webpage_content
from src.prompt_security import UNTRUSTED_CONTEXT_POLICY, untrusted_context_message
from src.text_helpers import strip_think
logger = logging.getLogger(__name__)
_SEARCH_QUERY_HISTORY_TURNS = 4
_SEARCH_QUERY_HISTORY_TURN_CHARS = 500
def _search_query_messages(message: str, history: List[Any]) -> List[Dict[str, str]]:
"""Build the query extraction prompt, with recent turns to resolve references.
A follow-up such as "Does he work with Jane?" has no searchable subject on
its own. The current turn is already the last history entry when the session
persists it, so a trailing user entry is dropped rather than repeated.
"""
turns = [m for m in history if getattr(m, "role", "") in {"user", "assistant"}]
if turns and turns[-1].role == "user":
turns.pop()
lines = []
for turn in turns[-_SEARCH_QUERY_HISTORY_TURNS:]:
content = turn.content
if isinstance(content, list):
content = " ".join(
str(part.get("text") or "") for part in content if isinstance(part, dict)
)
text = " ".join(strip_think(str(content or ""), prose=False, prompt_echo=False).split())
if text:
lines.append(f"{turn.role}: {text[:_SEARCH_QUERY_HISTORY_TURN_CHARS]}")
if not lines:
return [
{
"role": "system",
"content": "Extract a concise search query from the user's message. Reply ONLY with the query.",
},
{"role": "user", "content": message},
]
conversation = "\n".join(lines)
return [
{
"role": "system",
"content": (
"Extract a concise search query from the user's latest message. "
"Use the earlier conversation only to resolve references such as pronouns, "
"and leave out earlier topics the latest message does not refer to. "
"Reply ONLY with the query."
),
},
{
"role": "user",
"content": f"Earlier conversation:\n{conversation}\n\nLatest message:\n{message}",
},
]
def _clean_search_query(query: str, max_len: int = 200) -> str:
"""Strip fenced code blocks from a search query while preserving inline
@@ -422,21 +474,18 @@ class ChatProcessor:
generated_query = llm_call(
t_url,
t_model,
[
{
"role": "system",
"content": (
"Extract a concise search query from the user's message. "
"Reply ONLY with the query."
),
},
{"role": "user", "content": message},
],
_search_query_messages(
message,
[] if incognito else getattr(session, "history", None) or [],
),
headers=t_headers,
temperature=0.1,
max_tokens=50,
timeout=15,
).strip()
thinking_mode="off",
)
# Models without a reasoning parser put thinking in the content.
generated_query = strip_think(generated_query, prose=False, prompt_echo=False).strip()
if generated_query:
# LLM successfully generated a non-empty query -> use the generated query
+1
View File
@@ -175,6 +175,7 @@ AGENT_ISOLATED_TMP_DIRNAME = ".tmp"
# Auth policy
PASSWORD_MIN_LENGTH = 8
USERNAME_MAX_LENGTH = 64
# Default parameters
DEFAULT_TEMPERATURE = 1.0
+89 -13
View File
@@ -42,6 +42,15 @@ def _content_as_text(content: Any) -> str:
_MULTIMODAL_IMAGE_TYPES = {"image_url", "input_image", "image"}
def _has_preservable_content(content: Any) -> bool:
"""Retain nonblank text or nonempty multimodal blocks after tool-call removal."""
if isinstance(content, str):
return bool(content.strip())
if isinstance(content, list):
return bool(content)
return False
def prune_multimodal_images(
messages: List[Dict],
*,
@@ -222,22 +231,81 @@ def normalize_compaction_summary(summary: str) -> str:
def _sanitize_tool_messages(msgs: List[Dict]) -> List[Dict]:
"""Drop orphaned `tool` messages and dangling assistant `tool_calls`.
"""Normalize tool-call message ordering so strict providers accept the transcript.
OpenAI's API requires every `role:"tool"` message to immediately
follow an assistant message that carries `tool_calls` (or another
tool message in the same batch). Front-trimming the history can cut
the assistant `tool_calls` parent while keeping its tool responses,
which triggers: "messages with role 'tool' must be a response to a
preceding message with 'tool_calls'". This pass repairs that:
- drops `tool` messages with no valid preceding tool_calls
- drops assistant `tool_calls` messages whose tool responses were
all trimmed away (some providers reject unanswered tool_calls)
tool message in the same batch). Gemini and some other providers
additionally require a function call turn to come immediately after
a user turn or a function response turn. Trimming the history can
break either rule, which triggers errors such as "messages with role
'tool' must be a response to a preceding message with 'tool_calls'"
and "Please ensure that function call turn comes immediately after
a user turn or after a function response turn."
The transcript is normalized against its own effective output: each
pass repairs the violations it can see and the passes repeat until
the result is stable, so the returned transcript itself satisfies
the ordering contract and sanitizing it again is a no-op. A
completed call/result batch whose predecessor is an adjacent
assistant text turn keeps its calls by merging that text into the
call message instead of dropping executed-tool evidence; a batch
that cannot get a user/tool predecessor (e.g. a transcript that
starts mid-history) is the only one dropped.
"""
# Pass 1: drop orphan tool messages.
work = list(msgs)
for _ in range(2 * len(work) + 2):
normalized = _normalize_tool_messages_once(work)
if normalized == work:
break
work = normalized
return work
def _normalize_tool_messages_once(msgs: List[Dict]) -> List[Dict]:
"""One normalization pass over an already-trimmed message list."""
# Merge the run of assistant text turns directly before an assistant
# tool_calls turn into a single assistant message carrying both, so the
# batch inherits the text run's predecessor (a user or tool message)
# instead of losing its tool_calls. Only plain-text content is merged;
# multimodal content stays untouched and falls through to the strip logic.
merged: List[Dict] = []
for m in msgs:
cur_text = m.get("content")
if (
m.get("role") == "assistant"
and m.get("tool_calls")
and (cur_text is None or isinstance(cur_text, str))
):
text_run: List[Dict] = []
while (
merged
and merged[-1].get("role") == "assistant"
and not merged[-1].get("tool_calls")
and isinstance(merged[-1].get("content"), str)
):
text_run.append(merged.pop())
if text_run:
text_parts = [
msg["content"].strip()
for msg in reversed(text_run)
if msg["content"].strip()
]
if isinstance(cur_text, str) and cur_text.strip():
text_parts.append(cur_text.strip())
merged_msg = dict(m)
if text_parts:
merged_msg["content"] = "\n\n".join(text_parts)
merged.append(merged_msg)
continue
merged.append(m)
# Strip tool_calls from assistant messages whose predecessor is not a
# user or tool message, then drop orphan tool messages and dangling
# tool_calls whose responses are gone.
cleaned: List[Dict] = []
in_batch = False # are we right after an assistant tool_calls (or mid-batch)?
for m in msgs:
for m in merged:
role = m.get("role")
if role == "tool":
if in_batch:
@@ -245,13 +313,21 @@ def _sanitize_tool_messages(msgs: List[Dict]) -> List[Dict]:
# else: orphan — drop
continue
if role == "assistant" and m.get("tool_calls"):
prev_role = cleaned[-1].get("role") if cleaned else None
if prev_role not in ("user", "tool"):
# Invalid predecessor — strip tool_calls to avoid provider
# errors; keep the message only if it still carries content.
m = {k: v for k, v in m.items() if k != "tool_calls"}
in_batch = False
if not _has_preservable_content(m.get("content")):
continue # nothing left worth keeping
cleaned.append(m)
continue
in_batch = True
else:
in_batch = False
cleaned.append(m)
# Pass 2: drop assistant tool_calls messages that have NO following
# tool response (dangling) — walk backwards so we know what follows.
out: List[Dict] = []
for i, m in enumerate(cleaned):
if m.get("role") == "assistant" and m.get("tool_calls"):
@@ -261,7 +337,7 @@ def _sanitize_tool_messages(msgs: List[Dict]) -> List[Dict]:
# tool_calls so it's a plain assistant turn (preserves any
# text content the model produced alongside the calls).
m = {k: v for k, v in m.items() if k != "tool_calls"}
if not (m.get("content") or "").strip():
if not _has_preservable_content(m.get("content")):
continue # nothing left worth keeping
out.append(m)
return out
+4 -2
View File
@@ -625,7 +625,8 @@ class DeepResearcher:
# LLM helper
# ------------------------------------------------------------------
async def _llm(self, messages: List[Dict], temperature: float = 0.3,
max_tokens: int = 4096, timeout: int = 60) -> str:
max_tokens: int = 4096, timeout: int = 60,
thinking_mode: Optional[str] = None) -> str:
"""Call the LLM asynchronously and strip thinking tags."""
from src.llm_core import llm_call_async
response = await llm_call_async(
@@ -636,6 +637,7 @@ class DeepResearcher:
max_tokens=max_tokens,
headers=self.llm_headers,
timeout=timeout,
thinking_mode=thinking_mode,
)
return strip_thinking(response)
@@ -696,7 +698,7 @@ class DeepResearcher:
try:
result = await self._llm(
[{"role": "user", "content": prompt}],
temperature=0, max_tokens=20, timeout=15,
temperature=0, max_tokens=20, timeout=15, thinking_mode="off",
)
cat = (result or "").strip().lower()
# Clean one-word answer first.
+13
View File
@@ -0,0 +1,13 @@
"""Shared MIME text decoding for the web email helpers and MCP server."""
def decode_text_part(part) -> str:
payload = part.get_payload(decode=True)
if not payload:
return ""
charset = part.get_content_charset() or "utf-8"
try:
return payload.decode(charset, errors="replace")
except (LookupError, ValueError):
# Broken senders can label valid UTF-8 with an unsupported codec.
return payload.decode("utf-8", errors="replace")
+25 -23
View File
@@ -2321,6 +2321,25 @@ def _apply_explicit_nonstream_reasoning_effort(
payload["reasoning_effort"] = effort
def _nonstream_openai_response_text(msg: Dict, thinking_mode: Optional[str]) -> str:
"""Return the answer text of an OpenAI-compatible non-streaming message.
With thinking off the caller wants only the answer, so an empty answer
stays empty even when the model spent its whole budget on reasoning.
Otherwise reasoning stands in for a missing answer, and Mistral's
structured thinking is kept ahead of its text.
"""
thinking_off = _normalize_thinking_mode(thinking_mode) == "off"
reasoning = "" if thinking_off else msg.get("reasoning_content")
content = msg.get("content")
if not isinstance(content, list):
return content or reasoning or ""
text_part, thinking_part = _normalize_mistral_content(content)
if thinking_part and not thinking_off:
return thinking_part + "\n\n" + (text_part or "")
return text_part or reasoning or ""
def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LLMConfig.DEFAULT_TEMPERATURE,
max_tokens: int = LLMConfig.DEFAULT_MAX_TOKENS, headers: Optional[Dict] = None,
timeout: int = LLMConfig.DEFAULT_TIMEOUT, prompt_type: Optional[str] = None,
@@ -2412,17 +2431,9 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL
elif provider == "ollama":
response = _parse_ollama_response(data)
else:
msg = data["choices"][0]["message"]
content = msg.get("content")
if isinstance(content, list):
# Mistral structured content — extract thinking + text
text_part, thinking_part = _normalize_mistral_content(content)
if thinking_part:
response = thinking_part + "\n\n" + (text_part or "")
else:
response = text_part or msg.get("reasoning_content") or ""
else:
response = content or msg.get("reasoning_content") or ""
response = _nonstream_openai_response_text(
data["choices"][0]["message"], thinking_mode
)
_set_cached_response(cache_key, response)
return response
except Exception:
@@ -2860,18 +2871,9 @@ async def llm_call_async(
elif provider == "ollama":
response = _parse_ollama_response(data)
else:
msg = data["choices"][0]["message"]
content = msg.get("content")
if isinstance(content, list):
# Mistral structured content — extract thinking + text
# (same contract as llm_call / stream_llm; see #5435).
text_part, thinking_part = _normalize_mistral_content(content)
if thinking_part:
response = thinking_part + "\n\n" + (text_part or "")
else:
response = text_part or msg.get("reasoning_content") or ""
else:
response = content or msg.get("reasoning_content") or ""
response = _nonstream_openai_response_text(
data["choices"][0]["message"], thinking_mode
)
_set_cached_response(
cache_key,
response,
+15 -26
View File
@@ -364,32 +364,32 @@ class MemoryManager:
return []
# Define keyword categories for semantic matching
identity_words = ["name", "who", "i", "am", "called", "identity", "myself", "me", "my"]
identity_words = ["name", "called", "identity", "myself"]
contact_words = ["phone", "email", "address", "contact", "number", "where", "located", "reach"]
preference_words = ["like", "prefer", "favorite", "want", "love", "hate", "dislike", "enjoy", "interested"]
task_words = ["todo", "task", "remind", "meeting", "appointment", "schedule", "deadline"]
fact_words = ["what", "when", "where", "how", "why", "explain", "describe", "information", "know"]
query_lower = query.lower()
query_words = set(re.findall(r'\w+', query_lower))
query_tokens = set(tokenize(query_lower))
# Determine query type based on keywords
query_type = None
if any(word in query_lower for word in identity_words):
if query_words.intersection(identity_words) or re.search(r'\bwho\s+am\s+i\b', query_lower):
query_type = "identity"
elif any(word in query_lower for word in contact_words):
elif query_words.intersection(contact_words):
query_type = "contact"
elif any(word in query_lower for word in preference_words):
elif query_words.intersection(preference_words):
query_type = "preference"
elif any(word in query_lower for word in task_words):
elif query_words.intersection(task_words):
query_type = "task"
elif any(word in query_lower for word in fact_words):
elif query_words.intersection(fact_words):
query_type = "fact"
relevant = []
identity_memories = []
other_memories = []
# Separate identity memories from others
# Every memory participates in the same ranking and threshold check.
# Query type is a scoring hint, not an unconditional insertion path.
for memory in memories:
memory_text = memory["text"].lower()
# Check if this is an identity memory (contains name patterns or identity indicators)
@@ -397,22 +397,7 @@ class MemoryManager:
re.search(r'\b[A-Z][a-z]+ [A-Z][a-z]+\b', memory["text"]),
any(word in memory_text for word in ["name is", "i'm", "i am", "called", "my name", "named", "call me"])
])
if is_identity:
identity_memories.append(memory)
else:
other_memories.append(memory)
# For identity queries, include all identity memories regardless of similarity
if query_type == "identity" and identity_memories:
# Give them high scores to ensure they're included first
for memory in identity_memories:
relevant.append((0.9, memory)) # High score for identity memories in identity queries
# Process other memories with similarity scoring
for memory in other_memories:
memory_text = memory["text"].lower()
memory_tokens = set(tokenize(memory_text))
query_tokens = set(tokenize(query_lower))
# Calculate base Jaccard similarity
if not query_tokens or not memory_tokens:
@@ -422,7 +407,11 @@ class MemoryManager:
final_score = base_similarity
# Apply boosts based on semantic matching
if query_type == "contact":
if query_type == "identity" and is_identity:
# Keep explicit name/identity recall useful without outranking
# a closer textual match or bypassing the caller's threshold.
final_score = max(final_score * 1.3, 0.6)
elif query_type == "contact":
# Boost memories with contact information
has_contact_info = any(word in memory_text for word in ["@gmail.com", "@", ".com",
"phone", "number", "address",
+87
View File
@@ -0,0 +1,87 @@
"""Preserve report TeX for markdown.js's existing deferred KaTeX renderer."""
import re
from html.parser import HTMLParser
from xml.etree import ElementTree
from markdown.blockprocessors import BlockProcessor
from markdown.extensions import Extension
from markdown.inlinepatterns import InlineProcessor
from markdown.util import AtomicString
# Keep delimiter and currency rules aligned with mathBlocks in markdown.js.
_ESCAPED_MATH = r"\\\[([\s\S]*?)\\\]|\\\(([^\n]*?)\\\)"
_DOLLAR_MATH = (
r"\$\$([\s\S]*?)\$\$|"
r"(?<![\$\d])\$(?!\$)(?=\S)([^\$\n]+?)(?<=\S)\$(?!\$|\d)"
)
def _placeholder(tex, display):
node = ElementTree.Element("span", {
"class": "ody-math-pending", "data-display": str(display).lower(),
})
node.text = AtomicString(tex)
return node
class _HtmlContext(HTMLParser):
"""Inline HTML is still raw when math runs, including tag attributes."""
def __init__(self):
super().__init__()
self.protected = []
def handle_starttag(self, tag, attrs):
if tag in {"code", "pre", "script", "style", "textarea"}:
self.protected.append(tag)
def handle_endtag(self, tag):
if tag in self.protected:
del self.protected[self.protected.index(tag):]
class _InlineMath(InlineProcessor):
def handleMatch(self, match, data):
prefix = data[:match.start()]
if "<" in prefix:
context = _HtmlContext()
context.feed(prefix)
if context.protected or re.match(r"<(?:/?[A-Za-z]|[!?])", context.rawdata.lstrip()):
return None, None, None
# Backticks run before this processor. Preserve a backslash-escaped
# dollar too; math must run before Markdown consumes \( and \[.
if prefix.endswith("\\") and (len(prefix) - len(prefix.rstrip("\\"))) % 2:
return None, None, None
tex = next(group for group in match.groups() if group is not None)
display = match.group(1) is not None
return _placeholder(tex, display), match.start(), match.end()
class _DisplayMath(BlockProcessor):
def test(self, parent, block):
return re.match(r"^ {0,3}(\$\$|\\\[)", block) is not None
def run(self, parent, blocks):
opening = re.match(r"^ {0,3}(\$\$|\\\[)", blocks[0])
closing = "$$" if opening.group(1) == "$$" else "\\]"
source = blocks[0][opening.end():]
consumed = 1
while closing not in source:
if consumed == len(blocks):
return False # Leave incomplete input to ordinary Markdown.
source += "\n\n" + blocks[consumed]
consumed += 1
tex, _, tail = source.partition(closing)
del blocks[:consumed]
if tail.strip():
blocks.insert(0, tail)
parent.append(_placeholder(tex, True))
class ReportMathExtension(Extension):
def extendMarkdown(self, md):
# Backticks (190) and indented code (80) retain precedence.
md.inlinePatterns.register(_InlineMath(_ESCAPED_MATH, md), "report_math_escaped", 185)
# Parse links before dollar math so URL destinations stay literal.
md.inlinePatterns.register(_InlineMath(_DOLLAR_MATH, md), "report_math_dollar", 155)
md.parser.blockprocessors.register(_DisplayMath(md.parser), "report_math", 79)
+1
View File
@@ -120,6 +120,7 @@ DEFAULT_SETTINGS = {
# Tune via Settings or by editing data/settings.json.
"research_run_timeout_seconds": 1800,
"agent_max_tool_calls": 0,
"tool_approval_mode": "ask",
"agent_max_rounds": 20, # per-message agent step cap (clamped 1..200)
# Soft input-token budget for the agent loop. The DEFAULT value (6000) is the
# "auto" sentinel: it means "scale the budget to the model's context window"
+10
View File
@@ -2562,6 +2562,16 @@ def _compact_binary_like_output(value: object) -> str:
def format_tool_result(description: str, result: Dict) -> str:
"""Format a tool result into text for feeding back to the LLM."""
if (
description.startswith("manage_documents:")
and isinstance(result.get("document"), dict)
and "response" in result
):
# Document reads already budget their complete page, anchor and
# next_offset notice. A heading or a second JSON copy of the content
# would exhaust that budget and obscure the continuation again.
return _truncate(result["response"])
parts = [f"### {description}"]
if "stdout" in result:
+28 -3
View File
@@ -23,6 +23,7 @@ from typing import Dict, List, Optional, Tuple
from bs4 import BeautifulSoup
from src.research_utils import strip_thinking
from src.report_math import ReportMathExtension
from urllib.parse import urlparse
import markdown
@@ -47,6 +48,7 @@ for _t in ("td", "th"):
_REPORT_ALLOWED_ATTRS.setdefault(_t, set()).add("align")
_REPORT_ALLOWED_ATTRS.setdefault("a", set()).update({"href", "title", "target", "rel"})
_REPORT_ALLOWED_ATTRS.setdefault("img", set()).update({"src", "alt", "title"})
_REPORT_ALLOWED_ATTRS.setdefault("span", set()).add("data-display")
# ---------------------------------------------------------------------------
# Helpers
@@ -79,7 +81,7 @@ def _md_to_html(md_text: str) -> str:
md_text = _autolink_urls(md_text)
result = markdown.markdown(
md_text,
extensions=["extra", "codehilite", "toc", "tables", "sane_lists", "md_in_html"],
extensions=["extra", "codehilite", "toc", "tables", "sane_lists", "md_in_html", ReportMathExtension()],
extension_configs={
"codehilite": {"css_class": "code", "guess_lang": False},
"toc": {"marker": "", "toc_depth": "2-3"},
@@ -1021,6 +1023,8 @@ body[class^="standard-report-"] .report-section:nth-of-type(even) {{
.content code {{ font-family: var(--font-mono); font-size: 0.86em; background: var(--bg-surface-alt); padding: 0.15em 0.4em; border-radius: 4px; }}
.content pre {{ background: var(--bg-surface-alt); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.25rem 1.5rem; overflow-x: auto; margin: 1.25rem 0; font-size: 0.86rem; line-height: 1.6; }}
.content pre code {{ background: none; padding: 0; }}
/* Match the chat renderer's handling of wide display equations. */
.content .katex-display {{ overflow-x: auto; overflow-y: hidden; }}
.content table {{ width: 100%; border-collapse: collapse; margin: 1.25rem 0; font-size: 0.9rem; border-radius: var(--radius); overflow: hidden; box-shadow: var(--shadow-sm); }}
.content th {{ text-align: left; padding: 0.7rem 1rem; background: var(--accent-bg); font-weight: 600; border-bottom: 2px solid var(--border-strong); }}
.content td {{ padding: 0.6rem 1rem; border-bottom: 1px solid var(--border); vertical-align: top; }}
@@ -1434,6 +1438,12 @@ body[class^="standard-report-"] .report-section:nth-of-type(even) {{
<script>
(function() {{
// Reuse the chat renderer and vendored assets only for reports with math.
if (document.querySelector('.ody-math-pending')) {{
import('/static/js/markdown.js').then(function(markdown) {{
return markdown.renderMath(document);
}}).catch(function(err) {{ console.warn('Report math render error:', err); }});
}}
// ESC closes the report tab. window.close() works when the tab was
// opened via window.open() (which is how the panel launches it). If the
// browser blocks self-close (rare — e.g. report opened by direct URL),
@@ -2835,11 +2845,23 @@ def generate_visual_report(
spare_images = section_pool[_consumed:]
# Build TOC
math_labels = {}
if 'ody-math-pending' in report_html:
label_soup = BeautifulSoup(report_html, "html.parser")
for heading in label_soup.select('h2[id], h3[id]'):
if not heading.select_one('.ody-math-pending'):
continue
for child in list(heading.find_all(True)):
if 'ody-math-pending' in child.get('class', []):
child['data-display'] = 'false'
else:
child.unwrap()
math_labels[heading['id']] = heading.decode_contents()
toc_lines = []
for h in headings:
depth_class = f"depth-{h['level']}"
toc_lines.append(
f'<a href="#{h["slug"]}" class="{depth_class}">{html.escape(h["text"])}</a>'
f'<a href="#{h["slug"]}" class="{depth_class}">{math_labels.get(h["slug"], html.escape(h["text"]))}</a>'
)
toc_html = "\n ".join(toc_lines) if toc_lines else ""
@@ -2848,8 +2870,11 @@ def generate_visual_report(
options = []
for h in headings:
prefix = "\u2014 " if h["level"] == 3 else ""
text = h["text"]
if h["slug"] in math_labels:
text = BeautifulSoup(math_labels[h["slug"]], "html.parser").get_text()
options.append(
f'<option value="{h["slug"]}">{prefix}{html.escape(h["text"])}</option>'
f'<option value="{h["slug"]}">{prefix}{html.escape(text)}</option>'
)
mobile_toc_html = (
'<div class="mobile-section-nav">'
+13 -3
View File
@@ -561,10 +561,21 @@ function initializeEventListeners() {
// Export: PDF
const exportPdfBtn = el('export-pdf-btn');
let preparingPdf = false;
if (exportPdfBtn) {
exportPdfBtn.addEventListener('click', (e) => {
exportPdfBtn.addEventListener('click', async (e) => {
e.stopPropagation();
exportMenu.classList.remove('open');
if (preparingPdf) return;
preparingPdf = true;
try {
await sessionModule.prepareHistoryForPrint();
} catch (error) {
uiModule.showError(error?.message || 'Could not prepare the chat for PDF export.');
return;
} finally {
preparingPdf = false;
}
const meta = sessionModule.getSessions().find(s => s.id === sessionModule.getCurrentSessionId());
const sessionName = meta ? meta.name : 'Odysseus Chat';
const originalTitle = document.title;
@@ -1308,8 +1319,7 @@ function initializeEventListeners() {
const toolThemeBtn = el('tool-theme-btn');
if (toolThemeBtn) {
toolThemeBtn.addEventListener('click', () => {
const tm = document.getElementById('theme-modal');
if (tm) tm.classList.remove('hidden');
themeModule.openPopup();
});
}
+9 -4
View File
@@ -6481,6 +6481,13 @@ body.bg-pattern-ascii-fireflies {
display:flex; justify-content:flex-end; gap:8px; padding-top:6px;
border-top:1px solid var(--border); margin-top:4px;
}
/* The global `button { height:32px }` would hold a wrapped label (e.g.
"Delete chat and images") to one line's height, so its second line
spills out below the button. Let confirm buttons grow to fit; 32px
stays the minimum, so single-line buttons look the same. */
.styled-confirm-box .confirm-btn {
height:auto; min-height:32px;
}
@media (max-width:768px) {
.styled-confirm-box {
width: 85vw;
@@ -6516,10 +6523,8 @@ body.bg-pattern-ascii-fireflies {
}
.confirm-btn-secondary { background:var(--bg); color:var(--fg); }
.confirm-btn-secondary:hover { background:var(--border); }
#styled-confirm-ok {
position: relative;
top: 2px;
}
#styled-confirm-ok .styled-confirm-btn-label,
#styled-confirm-alt .styled-confirm-btn-label,
#styled-confirm-cancel .styled-confirm-cancel-label {
position:relative;
top:2px;
+1
View File
@@ -1701,6 +1701,7 @@
/* ── Print / PDF Export ── */
@media print {
html, body { height: auto !important; overflow: visible !important; }
body { background: #fff !important; color: #000 !important; }
#sidebar, .sidebar, #icon-rail, .hamburger-btn, #sidebar-backdrop, .chat-input-bar, .input-bar-wrapper,
#welcome-screen, .chat-top-bar, .chat-meta-overlay, .msg-footer,
+7
View File
@@ -339,6 +339,13 @@ mark.doc-find-mark {
height: 100%;
min-height: 0;
}
/* The container now fills the body exactly, so its inline 2px top margin
(spacing for the grid view) would push it 2px past the body and add a
second, outer scrollbar beside the inspector's own. !important is needed
to beat the inline style. */
.gallery-images-container:has(> #gallery-detail[style*="flex"]) {
margin-top: 0 !important;
}
}
.gallery-detail-image:hover .gallery-detail-rotate { opacity: 0.85; }
.gallery-detail-nav-disabled {
+39 -23
View File
@@ -247,32 +247,32 @@
real request is discarded and the font fetched a second time. -->
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-Regular.woff2">
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-SemiBold.woff2">
<link rel="stylesheet" href="/static/css/00-tokens.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/01-agent-chat.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/02-compare.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/03-agent-chat.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/04-memory.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/05-documents.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/06-admin-settings.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/07-documents.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/08-skills.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/09-gallery.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/10-cookbook.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/11-tasks.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/12-gallery.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/13-image-editor.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/14-email.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/15-notes.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/16-calendar.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/17-research.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/00-tokens.css?v=20261021confirmalign1">
<link rel="stylesheet" href="/static/css/01-agent-chat.css?v=20261021confirmalign1">
<link rel="stylesheet" href="/static/css/02-compare.css?v=20261021confirmalign1">
<link rel="stylesheet" href="/static/css/03-agent-chat.css?v=20261021confirmalign1">
<link rel="stylesheet" href="/static/css/04-memory.css?v=20261021confirmalign1">
<link rel="stylesheet" href="/static/css/05-documents.css?v=20261021confirmalign1">
<link rel="stylesheet" href="/static/css/06-admin-settings.css?v=20261021confirmalign1">
<link rel="stylesheet" href="/static/css/07-documents.css?v=20261021confirmalign1">
<link rel="stylesheet" href="/static/css/08-skills.css?v=20261021confirmalign1">
<link rel="stylesheet" href="/static/css/09-gallery.css?v=20261021confirmalign1">
<link rel="stylesheet" href="/static/css/10-cookbook.css?v=20261021confirmalign1">
<link rel="stylesheet" href="/static/css/11-tasks.css?v=20261021confirmalign1">
<link rel="stylesheet" href="/static/css/12-gallery.css?v=20261021confirmalign1">
<link rel="stylesheet" href="/static/css/13-image-editor.css?v=20261021confirmalign1">
<link rel="stylesheet" href="/static/css/14-email.css?v=20261021confirmalign1">
<link rel="stylesheet" href="/static/css/15-notes.css?v=20261021confirmalign1">
<link rel="stylesheet" href="/static/css/16-calendar.css?v=20261021confirmalign1">
<link rel="stylesheet" href="/static/css/17-research.css?v=20261021confirmalign1">
<!-- Panel stylesheets split out of style.css for maintainability. They are
eager and their order is load-bearing: the cascade is the concatenation of
these files in this order, so they must stay in this order here and in the
PRECACHE list in sw.js. None of them is lazy-loaded. -->
<link rel="stylesheet" href="/static/css/documents-gallery-editor.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/email-calendar-notes-tasks.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/cookbook-research-memory-settings.css?v=20260929csssplit">
<link rel="modulepreload" href="/static/app.js?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/documents-gallery-editor.css?v=20261021confirmalign1">
<link rel="stylesheet" href="/static/css/email-calendar-notes-tasks.css?v=20261021confirmalign1">
<link rel="stylesheet" href="/static/css/cookbook-research-memory-settings.css?v=20261021confirmalign1">
<link rel="modulepreload" href="/static/app.js?v=20261021confirmalign1">
<link rel="modulepreload" href="/static/js/chat.js?v=20260917toolttft1">
<link rel="modulepreload" href="/static/js/ui.js?v=20260916largetoolscroll1">
<link rel="modulepreload" href="/static/js/sessions.js">
@@ -2463,6 +2463,22 @@
<label class="settings-label">Max steps per message</label>
<input id="set-agentMaxRounds" type="text" inputmode="numeric" placeholder="20" class="settings-select" style="width:120px;">
</div>
<div class="settings-row">
<label class="settings-label">Tool approval</label>
<select id="set-toolApprovalMode" class="settings-select">
<option value="ask">Always ask</option>
<option value="off">Off (YOLO)</option>
</select>
</div>
<div class="admin-toggle-sub" style="margin-top:4px;">
Auto-approve turns off the tool approval gate (same control as the
ODYSSEUS_TOOL_APPROVAL_GATE env var). Bash, host_shell, send_email and
delete_email then run without a confirmation even after the agent has
seen untrusted external content (email, web pages, documents), so a
prompt-injected instruction can reach them directly.
</div>
<div id="set-toolApprovalEnvNote" class="admin-toggle-sub" style="margin-top:4px;display:none;color:var(--red);"></div>
<div id="set-agentMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div>
</div>
</div>
@@ -2693,7 +2709,7 @@
<script type="module" src="/static/js/censor.js"></script>
<script type="module" src="/static/js/settings.js?v=20260912writingstyle3"></script>
<script type="module" src="/static/js/assistant.js?v=20260912firefoxjscleanup1"></script>
<script type="module" src="/static/app.js?v=20260929csssplit"></script> <!-- app.js must be LAST -->
<script type="module" src="/static/app.js?v=20261021confirmalign1"></script> <!-- app.js must be LAST -->
<script type="module" src="/static/js/init.js?v=20260829chatstyle12"></script>
<script type="module" src="/static/js/a11y.js"></script>
<script nonce="{{CSP_NONCE}}">if('serviceWorker' in navigator){navigator.serviceWorker.register('/static/sw.js?v=20260916autoscroll1').catch(()=>{});}</script>
+16 -91
View File
@@ -400,9 +400,13 @@ function _eventsForDay(dateStr) {
}
// Multi-day timed events: show on each day they span
const startDate = _localDateOf(e.dtstart);
const endDate = _localDateOf(e.dtend);
if (startDate !== endDate) return startDate <= dateStr && endDate >= dateStr;
return startDate === dateStr;
const start = new Date(e.dtstart);
const end = new Date(e.dtend || e.dtstart);
// DTEND is exclusive, including at local midnight and across DST.
const endDate = end > start
? _localDateOf(new Date(end.getTime() - 1).toISOString())
: startDate;
return startDate <= dateStr && endDate >= dateStr;
});
}
@@ -1158,51 +1162,25 @@ async function _renderMonth() {
const dow = _weekStartSun ? first.getDay() : (first.getDay() + 6) % 7;
const gs = new Date(y, m, 1 - dow);
const multiDay = _events.filter(e => {
if (!_eventVisible(e)) return false;
const startD = new Date(e.dtstart), endD = new Date(e.dtend);
return Math.round((endD - startD) / 86400000) > 1 || (!e.all_day && _localDateOf(e.dtstart) !== _localDateOf(e.dtend));
});
const multiUids = new Set(multiDay.map(e => e.uid));
// Render 6 week rows. Each row is a positioned container that holds
// 7 day cells AND any multi-day bars that span the row, drawn as an
// absolute overlay on top of the cells. This avoids the old "each
// bar lives inside its start cell and gets clipped at the cell edge"
// problem so a multi-day event reads as one continuous line across
// every day it covers.
// Use the existing inline event rows for every day an event covers.
for (let row = 0; row < 6; row++) {
// Count how many multi-day bars overlap any column in this row so
// cells can reserve top padding for them — otherwise the bars
// (drawn as absolute overlays) sit on top of the day-number and
// single-event rows below.
const rowStartCd0 = new Date(gs); rowStartCd0.setDate(gs.getDate() + row * 7);
const rowEndCd0 = new Date(gs); rowEndCd0.setDate(gs.getDate() + row * 7 + 6);
const rowStart0 = _ds(rowStartCd0);
const rowEnd0 = _ds(rowEndCd0);
const barsInRow = multiDay.filter(md => {
const mdStart = _localDateOf(md.dtstart);
const mdEnd = _localDateOf(md.dtend);
return !(mdEnd < rowStart0 || mdStart > rowEnd0);
}).length;
h += `<div class="cal-week-row" style="--bars:${barsInRow}">`;
h += '<div class="cal-week-row" style="--bars:0">';
// Day cells for this row
for (let col = 0; col < 7; col++) {
const i = row * 7 + col;
const cd = new Date(gs); cd.setDate(gs.getDate() + i);
const d = _ds(cd);
const isOther = cd.getMonth() !== m;
const singles = _eventsForDay(d).filter(e => !multiUids.has(e.uid));
const dayEvents = _eventsForDay(d);
const isWeekend = cd.getDay() === 0 || cd.getDay() === 6;
const cls = 'cal-day' + (isOther ? ' cal-other' : '') + (isWeekend ? ' cal-weekend' : '') + (!singles.length ? ' cal-empty-day' : '') + (d === today ? ' cal-today' : '') + (d === _selectedDay ? ' cal-selected' : '');
const cls = 'cal-day' + (isOther ? ' cal-other' : '') + (isWeekend ? ' cal-weekend' : '') + (!dayEvents.length ? ' cal-empty-day' : '') + (d === today ? ' cal-today' : '') + (d === _selectedDay ? ' cal-selected' : '');
h += `<div class="${cls}" data-date="${d}"><span class="cal-day-num">${cd.getDate()}</span>`;
// Single events — show up to 3 inline rows (multi-day events are
// drawn separately as an overlay below).
if (singles.length) {
// Reuse the same rows for single-day and continuing events.
if (dayEvents.length) {
const maxInline = window.innerWidth <= 768 ? 2 : 3;
const showInline = singles.slice(0, maxInline);
const showInline = dayEvents.slice(0, maxInline);
for (const ev of showInline) {
const t = ev.all_day ? '' : _fmtTime(ev.dtstart);
const t = ev.all_day || _localDateOf(ev.dtstart) !== d ? '' : _fmtTime(ev.dtstart);
const _impMark = ev.importance === 'critical' ? '<span style="color:var(--red);margin-right:2px" title="critical">!!</span>'
: ev.importance === 'high' ? '<span style="color:var(--orange,#e5a33a);margin-right:2px" title="high">!</span>' : '';
const _typeBadge = ev.event_type ? `<span class="cal-event-type-badge" data-type="${_e(ev.event_type)}" title="${_e(ev.event_type)}"></span>` : '';
@@ -1213,63 +1191,10 @@ async function _renderMonth() {
<span class="cal-event-row-name">${_impMark}${_e(ev.summary)}${_eventReminderHtml(ev)}${_eventSourceHtml(ev)}</span>
</div>`;
}
if (singles.length > maxInline) h += `<div class="cal-event-more">+${singles.length - maxInline} more</div>`;
if (dayEvents.length > maxInline) h += `<div class="cal-event-more">+${dayEvents.length - maxInline} more</div>`;
}
h += '</div>';
}
// Multi-day overlay bars for this row. Stack each bar one slot below
// the previous so two events on the same row don't overlap.
let barSlot = 0;
for (const md of multiDay) {
const mdStart = _localDateOf(md.dtstart);
const mdEnd = _localDateOf(md.dtend);
// Compute the row's date range
const rowStartCd = new Date(gs); rowStartCd.setDate(gs.getDate() + row * 7);
const rowEndCd = new Date(gs); rowEndCd.setDate(gs.getDate() + row * 7 + 6);
const rowStart = _ds(rowStartCd);
const rowEnd = _ds(rowEndCd);
if (mdEnd < rowStart || mdStart > rowEnd) continue; // not in this row
// Column within the row where the bar starts and how many days it spans
const startCol = mdStart < rowStart ? 0 : ((new Date(mdStart + 'T00:00:00') - rowStartCd) / 86400000);
const endCol = mdEnd > rowEnd ? 6 : ((new Date(mdEnd + 'T00:00:00') - rowStartCd) / 86400000);
const startColInt = Math.round(startCol);
const endColInt = Math.round(endCol);
const span = endColInt - startColInt + 1;
// Proportional offsets for timed events that span across midnight
// (e.g. 8 PM Mon → 5 AM Tue). Without this, an overnight serve
// window visually fills the ENTIRE next day even when it only
// covers a few hours. All-day events keep the full-day shape.
// Bar visually spans from column (col+startFrac) to (col+span-1+endFrac),
// so a 8 PM→5 AM run shows ~17% of day 1 + ~21% of day 2, not 200%.
let startFrac = 0;
let endFrac = 1;
if (!md.all_day) {
try {
const sIso = md.dtstart || '';
const eIso = md.dtend || '';
const sDate = sIso ? new Date(sIso) : null;
const eDate = eIso ? new Date(eIso) : null;
// First-visible-day fraction (0 = midnight start). Clamp to 0
// when the event started before this row, so the bar still
// starts at the row's left edge.
if (sDate && !isNaN(sDate) && mdStart >= rowStart) {
const midnight = new Date(sDate); midnight.setHours(0, 0, 0, 0);
startFrac = Math.max(0, Math.min(1, (sDate - midnight) / 86400000));
}
if (eDate && !isNaN(eDate) && mdEnd <= rowEnd) {
const midnight = new Date(eDate); midnight.setHours(0, 0, 0, 0);
endFrac = Math.max(0, Math.min(1, (eDate - midnight) / 86400000));
// CalDAV end-times are exclusive: an event ending at exactly
// 00:00 on day N really ended at end-of-day N-1, so endFrac=0
// would visually paint a zero-width slice. Snap to a small
// visible minimum (5% of a day) so the bar still registers.
if (endFrac === 0) endFrac = 1;
}
} catch (_) { startFrac = 0; endFrac = 1; }
}
h += `<div class="cal-multiday" style="--col:${startColInt};--span:${span};--slot:${barSlot};--start-frac:${startFrac.toFixed(4)};--end-frac:${endFrac.toFixed(4)};background:${_calColor(md)};--cal-event-fg:${_calEventFg(md)}" draggable="true" data-uid="${_e(md.uid)}" title="${_e(md.summary)}">${_e(md.summary)}${_eventReminderHtml(md)}${_eventSourceHtml(md)}</div>`;
barSlot++;
}
h += '</div>';
}
h += '</div>';
+2 -1
View File
@@ -2588,6 +2588,7 @@ import { invalidateSettings } from './appConfig.js';
currentHolder = null;
let abortCtrl = null;
let _isAgent = false; // read by the catch block's timeout message
let streamingTTS = false;
try {
// Re-enable auto-scroll when user sends a message
@@ -2985,7 +2986,7 @@ import { invalidateSettings } from './appConfig.js';
currentAbort = abortCtrl;
const _tState = Storage.loadToggleState();
const _isAgent = (_tState.mode || 'chat') === 'agent' || !!_tState.plan_mode || workspaceAgentIntent;
_isAgent = (_tState.mode || 'chat') === 'agent' || !!_tState.plan_mode || workspaceAgentIntent;
// Timeout: 6 min for research and agent mode, 3 min otherwise
const timeoutMs = el('research-toggle').checked || _isAgent ? RESEARCH_TIMEOUT_MS : DEFAULT_TIMEOUT_MS;
+4 -2
View File
@@ -1762,7 +1762,9 @@ async function _fetchDependencies() {
envPrefix = 'eval "$(conda shell.bash hook)" && conda activate ' + _shellQuote(_envState.envPath);
}
const reqBody = {
repo_id: `${backend} setup`,
// The server validates repo_id as a pip package spec for pip
// installs, so it can't contain a space.
repo_id: `${backend}-setup`,
cmd: cmd,
remote_host: _envState.remoteHost || undefined,
ssh_port: _getPort(_envState.remoteHost) || undefined,
@@ -1780,7 +1782,7 @@ async function _fetchDependencies() {
uiModule.showToast('Run failed: ' + String(data.detail || data.error || `HTTP ${res.status}`).slice(0, 200));
return;
}
const payload = { repo_id: `${backend} setup`, _cmd: cmd, remote_host: _envState.remoteHost || '', _dep: true };
const payload = { repo_id: reqBody.repo_id, _cmd: cmd, remote_host: _envState.remoteHost || '', _dep: true };
_addTask(data.session_id, `${backend} setup`, 'download', payload);
uiModule.showToast(`Running ${backend} setup on ${targetHost}…`);
} catch (err) {
-10
View File
@@ -1062,15 +1062,6 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
}
}
/** Switch chat to agent mode if not already */
function _ensureAgentMode() {
const ab = document.getElementById('mode-agent-btn');
const cb = document.getElementById('mode-chat-btn');
if (ab && !ab.classList.contains('active')) {
ab.click();
}
}
export function init(apiBase) {
API_BASE = apiBase;
initLibrary({
@@ -7415,7 +7406,6 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
// Doc was opened last → it goes in front of the email windows (clears the
// email-front flag; the doc/email z-index alternation lives in CSS).
document.body.classList.remove('email-front');
_ensureAgentMode();
_markDocVisibleState(_lastSessionId, 'open');
document.body.classList.add('doc-view');
+42
View File
@@ -0,0 +1,42 @@
// Own both completion paths of an animated close so neither can outlive it.
const _pendingCloses = new WeakMap();
function cancelClose(modal) {
const pending = _pendingCloses.get(modal);
if (!pending) return;
clearTimeout(pending.timer);
pending.content.removeEventListener('animationend', pending.onEnd);
pending.content.classList.remove('modal-closing');
_pendingCloses.delete(modal);
}
export function showModal(modal) {
if (!modal) return;
cancelClose(modal);
modal.classList.remove('hidden');
}
export function hideModal(modal) {
if (!modal) return;
cancelClose(modal);
const content = modal.querySelector('.modal-content, .settings-modal-content');
// A minimized window has no exit animation. Registering a listener here
// would let its next entrance animation close the newly opened window.
if (!content || modal.classList.contains('hidden')) {
modal.classList.add('hidden');
return;
}
const pending = { content, timer: null, onEnd: null };
const finish = () => {
if (_pendingCloses.get(modal) !== pending) return;
cancelClose(modal);
modal.classList.add('hidden');
};
pending.onEnd = event => {
if (event.target === content) finish();
};
_pendingCloses.set(modal, pending);
content.classList.add('modal-closing');
content.addEventListener('animationend', pending.onEnd);
pending.timer = setTimeout(finish, 250);
}
+11 -10
View File
@@ -128,7 +128,7 @@ async function _ensureDefaultPendingChat() {
if (!_deps || _defaultChatPickInFlight) return;
if (_deps.getCurrentSessionId && _deps.getCurrentSessionId()) return;
const pending = _deps.getPendingChat && _deps.getPendingChat();
if (pending && pending.modelId) return;
if (pending && pending.modelId && pending.source !== 'fallback') return;
_defaultChatPickInFlight = true;
const seq = ++_defaultPendingSeq;
try {
@@ -954,15 +954,6 @@ export function updateModelPicker() {
}
}
const latestPending = _deps.getPendingChat && _deps.getPendingChat();
if (
!currentSessionId &&
!_autoSelectingDefault &&
window.modelsModule &&
window.modelsModule.getCachedItems &&
(!modelId || (latestPending && latestPending.source === 'fallback'))
) {
_ensureDefaultPendingChat();
}
let displayName = modelId ? modelId.split('/').pop() : 'Select model';
const routeItems = window.modelsModule?.getCachedItems?.() || [];
@@ -994,6 +985,16 @@ export function updateModelPicker() {
label.textContent = displayName;
}
_updateReasoningEffortUI(modelId, s, latestPending, selectedEndpoint);
// A cached default can resolve synchronously and re-render the picker.
if (
!currentSessionId &&
!_autoSelectingDefault &&
window.modelsModule &&
window.modelsModule.getCachedItems &&
(!modelId || (latestPending && latestPending.source === 'fallback'))
) {
_ensureDefaultPendingChat();
}
}
// ── Reasoning effort control for models supporting reasoning levels ──
+1
View File
@@ -124,6 +124,7 @@ const _ENDPOINT_LABELS = [
[/(^|\.)perplexity\.ai$/i, "Perplexity"],
[/(^|\.)nvidia\.com$/i, "NVIDIA"],
[/(^|\.)x\.ai$/i, "xAI"],
[/(^|\.)conifer\.build$/i, "Conifer"],
];
/**
+30 -6
View File
@@ -941,13 +941,32 @@ function _exitHistorySelectMode() {
_renderJobs();
}
async function _deleteResearchFromDisk(id) {
const response = await fetch(`${_apiBase}/api/research/${id}`, { method: 'DELETE', credentials: 'same-origin' });
if (!response.ok) throw new Error('Research deletion failed');
}
function _showDeleteFailure(count) {
if (typeof uiModule !== 'undefined' && uiModule?.showError) {
uiModule.showError(`Could not delete ${count} research ${count === 1 ? 'report' : 'reports'}. Please try again.`);
}
}
async function _deleteHistoryJobs(ids) {
const selected = new Set(ids);
const selectedJobs = jobs.getJobs().filter(job => selected.has(job.id));
await Promise.all(selectedJobs.filter(job => job.status === 'done').map(async job => {
try { await fetch(`${_apiBase}/api/research/${job.id}`, { method: 'DELETE', credentials: 'same-origin' }); } catch {}
let failed = 0;
await Promise.all(selectedJobs.map(async job => {
try {
if (job.status === 'done') await _deleteResearchFromDisk(job.id);
_historySelectedIds.delete(job.id);
jobs.removeJob(job.id);
} catch {
failed++;
}
}));
selectedJobs.forEach(job => jobs.removeJob(job.id));
if (failed) _showDeleteFailure(failed);
return failed === 0;
}
async function _bulkDeleteHistory() {
@@ -957,8 +976,8 @@ async function _bulkDeleteHistory() {
const ok = await window.styledConfirm(`Delete ${ids.length} research ${ids.length === 1 ? 'report' : 'reports'}?`, { confirmText: 'Delete', danger: true });
if (!ok) return;
}
await _deleteHistoryJobs(ids);
_exitHistorySelectMode();
if (await _deleteHistoryJobs(ids)) _exitHistorySelectMode();
else _updateHistoryBulkBar();
}
async function _clearHistory() {
@@ -1296,7 +1315,12 @@ function _buildJobCard(job) {
const ok = await window.styledConfirm('Delete this research? This permanently removes it from disk.', { confirmText: 'Delete', danger: true });
if (!ok) return;
}
try { await fetch(`${_apiBase}/api/research/${job.id}`, { method: 'DELETE', credentials: 'same-origin' }); } catch {}
try {
await _deleteResearchFromDisk(job.id);
} catch {
_showDeleteFailure(1);
return;
}
overflow.close();
_animateOutThenRemove(card, () => jobs.removeJob(job.id));
});
+44 -15
View File
@@ -353,36 +353,38 @@ function _installHistoryPager(id, pageInfo, modelName) {
handler: null,
};
const loadOlder = async () => {
if (!_historyPager || _historyPager.loading || _historyPager.done) return;
if (_historyPager.sessionId !== currentSessionId) return;
if (box.scrollTop > 90) return;
const loadOlder = async (force = false) => {
const pager = _historyPager;
if (!pager || pager.loading || pager.done) return;
if (pager.sessionId !== currentSessionId) return;
if (!force && box.scrollTop > 90) return;
const nextOffset = Math.max(0, _historyPager.offset - _historyPager.limit);
const nextLimit = _historyPager.offset - nextOffset;
const nextOffset = Math.max(0, pager.offset - pager.limit);
const nextLimit = pager.offset - nextOffset;
if (nextLimit <= 0) {
_historyPager.done = true;
pager.done = true;
return;
}
_historyPager.loading = true;
pager.loading = true;
const anchor = box.querySelector('.msg, .agent-thread, .gallery-bubble');
const beforeHeight = box.scrollHeight;
try {
const res = await fetch(_historyUrl(_historyPager.sessionId, { limit: nextLimit, offset: nextOffset }));
const res = await fetch(_historyUrl(pager.sessionId, { limit: nextLimit, offset: nextOffset }));
if (!res.ok) throw new Error(`Chat history request failed (HTTP ${res.status})`);
const data = await res.json();
if (!_historyPager || _historyPager.sessionId !== currentSessionId) return;
if (_historyPager !== pager || pager.sessionId !== currentSessionId) return;
const newEls = [];
for (const msg of data.history || []) {
if (msg.role !== 'user' && msg.role !== 'assistant') continue;
const els = _renderHistoryMessage(msg, _historyPager.modelName);
const els = _renderHistoryMessage(msg, pager.modelName);
if (Array.isArray(els)) newEls.push(...els);
}
for (const el of newEls) {
box.insertBefore(el, anchor || box.firstChild);
}
_historyPager.offset = Number(data.offset || nextOffset);
_historyPager.done = !data.has_more_before;
pager.offset = Number(data.offset || nextOffset);
pager.done = !data.has_more_before;
if (window.hljs) {
newEls.forEach(el => el.querySelectorAll('pre code:not(.hljs)').forEach(block => window.hljs.highlightElement(block)));
}
@@ -391,16 +393,42 @@ function _installHistoryPager(id, pageInfo, modelName) {
} catch (e) {
console.warn('Failed to load older chat history:', e);
} finally {
if (_historyPager) _historyPager.loading = false;
pager.loading = false;
}
};
_historyPager.loadOlder = loadOlder;
_historyPager.handler = () => {
if (box.scrollTop <= 90) loadOlder();
if (box.scrollTop <= 90 && _historyPager && !_historyPager.loading) {
_historyPager.pending = loadOlder();
}
};
box.addEventListener('scroll', _historyPager.handler, { passive: true });
}
export async function prepareHistoryForPrint() {
const pager = _historyPager;
if (!pager) return;
const ensureCurrentChat = () => {
if (_historyPager !== pager || currentSessionId !== pager.sessionId) {
throw new Error('Chat changed while preparing the PDF. Export again from the intended chat.');
}
};
while (!pager.done) {
await pager.pending;
ensureCurrentChat();
if (pager.done) break;
const previousOffset = pager.offset;
pager.pending = pager.loadOlder(true);
await pager.pending;
ensureCurrentChat();
if (!pager.done && pager.offset >= previousOffset) {
throw new Error('Could not load the complete chat for PDF export. Please try again.');
}
}
ensureCurrentChat();
}
function _getIncognitoIds() {
try { return JSON.parse(sessionStorage.getItem(_INCOGNITO_SESSIONS_KEY) || '[]'); } catch { return []; }
}
@@ -4061,6 +4089,7 @@ const sessionModule = {
renderSessionList,
loadSessions,
selectSession,
prepareHistoryForPrint,
createDirectChat,
materializePendingSession,
preMaterializePendingSession,
+21
View File
@@ -10,6 +10,7 @@ export async function initAgentSettings() {
var toolsInput = el('set-agentMaxTools');
var roundsInput = el('set-agentMaxRounds');
var supInput = el('set-agentSupervisorLadder');
var approvalModeInput = el('set-toolApprovalMode');
var msg = el('set-agentMsg');
if (!toolsInput) return;
@@ -19,6 +20,21 @@ export async function initAgentSettings() {
if (settings.agent_max_tool_calls) toolsInput.value = settings.agent_max_tool_calls;
if (roundsInput && settings.agent_max_rounds) roundsInput.value = settings.agent_max_rounds;
if (supInput) supInput.checked = !!settings.agent_supervisor_ladder;
if (approvalModeInput) {
approvalModeInput.value =
settings.tool_approval_mode === 'off' ? 'off' : 'ask';
}
var approvalEnvNote = el('set-toolApprovalEnvNote');
if (approvalEnvNote) {
if (settings.tool_approval_gate_env_disabled) {
approvalEnvNote.textContent =
'Forced by deployment: ODYSSEUS_TOOL_APPROVAL_GATE is set to a falsy value on this server, so the approval gate is off for every run regardless of this setting.';
approvalEnvNote.style.display = 'block';
} else {
approvalEnvNote.textContent = '';
approvalEnvNote.style.display = 'none';
}
}
} catch (e) {}
// Clamp + coerce a raw input to an int in [lo, hi]; falls back to `dflt`
@@ -37,6 +53,10 @@ export async function initAgentSettings() {
var payload = { agent_max_tool_calls: tools };
if (rounds != null) payload.agent_max_rounds = rounds;
if (supInput) payload.agent_supervisor_ladder = !!supInput.checked;
if (approvalModeInput) {
payload.tool_approval_mode =
approvalModeInput.value === 'off' ? 'off' : 'ask';
}
try {
await _postSettings(payload);
msg.textContent = (tools > 0 ? 'Limit: ' + tools + ' tool calls' : 'Unlimited tool calls') +
@@ -49,6 +69,7 @@ export async function initAgentSettings() {
toolsInput.addEventListener('change', save);
if (roundsInput) roundsInput.addEventListener('change', save);
if (supInput) supInput.addEventListener('change', save);
if (approvalModeInput) approvalModeInput.addEventListener('change', save);
var cur = parseInt(toolsInput.value, 10) || 0;
var curR = roundsInput ? (parseInt(roundsInput.value, 10) || 20) : null;
msg.textContent = (cur > 0 ? 'Limit: ' + cur + ' tool calls' : 'Unlimited tool calls') +
+8 -20
View File
@@ -6,6 +6,8 @@
import { makeWindowDraggable } from '../windowDrag.js';
import { clearDockSide } from '../modalSnap.js';
import { toggle as restoreMinimizedModal } from '../modalManager.js';
import { showModal, hideModal } from '../modalVisibility.js';
const _dragBound = new WeakSet();
const _closeBound = new WeakSet();
@@ -147,30 +149,16 @@ export function bindSettingsClose(modalEl, options = {}) {
export function showSettingsModal(modalEl) {
if (!modalEl) return;
if (restoreMinimizedModal(modalEl.id)) {
showModal(modalEl);
return;
}
if (modalEl.classList.contains('hidden')) {
resetSettingsWindowPlacement(modalEl);
}
modalEl.classList.remove('hidden');
showModal(modalEl);
}
export function hideSettingsModal(modalEl) {
if (!modalEl) return;
const content = modalEl.querySelector('.modal-content, .settings-modal-content');
if (content && !content.classList.contains('modal-closing')) {
content.classList.add('modal-closing');
content.addEventListener('animationend', () => {
modalEl.classList.add('hidden');
content.classList.remove('modal-closing');
}, { once: true });
setTimeout(() => {
if (!modalEl.classList.contains('hidden')) {
modalEl.classList.add('hidden');
content.classList.remove('modal-closing');
}
}, 250);
return;
}
modalEl.classList.add('hidden');
hideModal(modalEl);
}
+34 -8
View File
@@ -999,6 +999,7 @@ function renderSkillsList() {
// the footer too so it's not buried under the "⋯" menu.
const testBtn = document.createElement('button');
testBtn.className = 'doclib-card-text-btn doclib-card-action-btn';
testBtn.dataset.act = 'test';
testBtn.innerHTML = _svg(_ICON.test, { size: 11 }) + 'Test';
testBtn.title = 'Test this skill — run it + AI judge';
testBtn.addEventListener('click', (e) => {
@@ -1013,14 +1014,10 @@ function renderSkillsList() {
const _origHTML = testBtn.innerHTML;
testBtn.innerHTML = _svg(_ICON.test, { size: 11 }) + 'Starting…';
Promise.resolve(_testSkill(card, name)).finally(() => {
// The preview gets overwritten by _testSkill, which removes the
// testBtn from the DOM. The cleanup below only matters if the
// button still exists (e.g. _testSkill bailed early).
if (document.body.contains(testBtn)) {
testBtn.disabled = false;
testBtn.dataset.busy = '';
testBtn.innerHTML = _origHTML;
}
// Source controls are detached during results and reused by Edit.
testBtn.disabled = false;
testBtn.dataset.busy = '';
testBtn.innerHTML = _origHTML;
});
});
@@ -1285,6 +1282,10 @@ function _toggleSkillEdit(card, name) {
if (!card._mdLoaded) return;
const preview = card.querySelector('.skill-card-preview');
if (!preview) return;
if (card._skillSourceNodes) {
preview.replaceChildren(...card._skillSourceNodes);
card._skillSourceNodes = null;
}
const existing = preview.querySelector('.skill-md-editor');
if (existing) {
// Already editing — treat Edit as Save.
@@ -1305,7 +1306,26 @@ function _toggleSkillEdit(card, name) {
ta.focus();
// Flip the Edit button label to "Save".
const editBtn = [...preview.querySelectorAll('.doclib-card-action-btn')].find(b => /Edit|Save/.test(b.textContent));
const editLabel = editBtn?.innerHTML;
if (editBtn) editBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Save';
const cancel = document.createElement('button');
cancel.type = 'button';
cancel.className = 'doclib-card-text-btn doclib-card-action-btn';
cancel.textContent = 'Cancel';
const testBtn = preview.querySelector('[data-act="test"]');
const testDisplay = testBtn?.style.display;
if (testBtn) testBtn.style.display = 'none';
card._cancelSkillEdit = () => {
ta.remove();
if (pre) pre.style.removeProperty('display');
if (editBtn) editBtn.innerHTML = editLabel;
cancel.remove();
if (testBtn) testBtn.style.display = testDisplay;
card._cancelSkillEdit = null;
card._fillH?.();
};
cancel.addEventListener('click', (e) => { e.stopPropagation(); card._cancelSkillEdit?.(); });
if (editBtn) editBtn.after(cancel);
}
async function _saveSkillEdit(card, name) {
@@ -1454,6 +1474,12 @@ async function _testSkill(card, name, force = false) {
if (!card.classList.contains('doclib-card-expanded')) await _expandSkillCard(card, name);
const preview = card.querySelector('.skill-card-preview');
if (!preview) return;
if (!preview.querySelector('.skill-test')) {
card._cancelSkillEdit?.();
// Preserve the source nodes and handlers. Result actions are nested in
// .skill-test, so they cannot be insertBefore references on the preview.
card._skillSourceNodes = Array.from(preview.childNodes);
}
preview.innerHTML =
'<div class="skill-test"><div class="skill-test-log"></div>' +
'<div class="skill-test-verdict"></div></div>';
+16 -16
View File
@@ -7,6 +7,8 @@ import { initColorPickers, attachColorPicker } from './colorPicker.js?v=20260910
import { hexToRgb } from './color/hex.js';
import { makeWindowDraggable } from './windowDrag.js';
import { snapModalToZone } from './tileManager.js?v=20260910responsivebounds1';
import { toggle as restoreMinimizedModal } from './modalManager.js';
import { showModal, hideModal } from './modalVisibility.js';
export const THEMES = {
dark: { bg:'#282c34', fg:'#9cdef2', panel:'#111111', border:'#355a66', red:'#e06c75' },
@@ -1570,31 +1572,29 @@ export function makeDraggable(el, handle) {
}
// Toggle the popup
export function openPopup() {
const modal = document.getElementById('theme-modal');
if (!modal) return;
restoreMinimizedModal(modal.id);
showModal(modal);
}
export function togglePopup() {
const modal = document.getElementById('theme-modal');
if (!modal) return;
const visible = !modal.classList.contains('hidden');
const visible = !modal.classList.contains('hidden')
&& !modal.classList.contains('modal-minimized')
&& !modal.querySelector('.modal-closing');
if (visible) {
modal.classList.add('hidden');
closePopup();
} else {
modal.classList.remove('hidden');
openPopup();
}
}
export function closePopup() {
const modal = document.getElementById('theme-modal');
if (!modal) return;
const content = modal.querySelector('.modal-content');
if (content && !content.classList.contains('modal-closing')) {
content.classList.add('modal-closing');
content.addEventListener('animationend', () => {
modal.classList.add('hidden');
content.classList.remove('modal-closing');
}, { once: true });
setTimeout(() => { if (!modal.classList.contains('hidden')) { modal.classList.add('hidden'); content.classList.remove('modal-closing'); } }, 250);
} else {
modal.classList.add('hidden');
}
hideModal(modal);
}
// Expose for app.js wiring + AI ui_control
@@ -2278,7 +2278,7 @@ function _initEmbers() {
draw();
}
const themeModule = { initThemeUI, togglePopup, closePopup, makeDraggable,
const themeModule = { initThemeUI, openPopup, togglePopup, closePopup, makeDraggable,
THEMES, applyColors, applyFontDensity, applyBgPattern,
applyBgEffectColor, applyBgEffectIntensity, applyBgEffectSize, applyBgEffectSpeed,
applyFrostedGlass,
+4 -2
View File
@@ -776,14 +776,16 @@ export function styledConfirm(message, { confirmText = 'Confirm', cancelText = '
if (titleEl) titleEl.textContent = title || 'Confirm';
msgEl.textContent = message;
// Labels sit in spans so CSS can nudge the text without moving the button.
const btnLabel = (text) => Object.assign(document.createElement('span'), { className: 'styled-confirm-btn-label', textContent: text });
// Use the affirmative label consistently for generic confirmations.
okBtn.textContent = confirmText === 'OK' ? 'Yes' : confirmText;
okBtn.replaceChildren(btnLabel(confirmText === 'OK' ? 'Yes' : confirmText));
cancelBtn.textContent = '';
const cancelLabel = document.createElement('span');
cancelLabel.className = 'styled-confirm-cancel-label';
cancelLabel.textContent = cancelText;
cancelBtn.appendChild(cancelLabel);
altBtn.textContent = alternateText || '';
altBtn.replaceChildren(btnLabel(alternateText || ''));
okBtn.className = danger ? 'confirm-btn confirm-btn-danger' : 'confirm-btn confirm-btn-primary';
cancelBtn.className = 'confirm-btn confirm-btn-secondary';
altBtn.className = 'confirm-btn confirm-btn-secondary';
+24 -23
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-v1134-pr40-editor-precache';
const CACHE_NAME = 'odysseus-v1137-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,30 +38,31 @@ 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/modalVisibility.js',
'/static/js/compare/sse.js',
'/',
'/static/css/00-tokens.css?v=20260929csssplit',
'/static/css/01-agent-chat.css?v=20260929csssplit',
'/static/css/02-compare.css?v=20260929csssplit',
'/static/css/03-agent-chat.css?v=20260929csssplit',
'/static/css/04-memory.css?v=20260929csssplit',
'/static/css/05-documents.css?v=20260929csssplit',
'/static/css/06-admin-settings.css?v=20260929csssplit',
'/static/css/07-documents.css?v=20260929csssplit',
'/static/css/08-skills.css?v=20260929csssplit',
'/static/css/09-gallery.css?v=20260929csssplit',
'/static/css/10-cookbook.css?v=20260929csssplit',
'/static/css/11-tasks.css?v=20260929csssplit',
'/static/css/12-gallery.css?v=20260929csssplit',
'/static/css/13-image-editor.css?v=20260929csssplit',
'/static/css/14-email.css?v=20260929csssplit',
'/static/css/15-notes.css?v=20260929csssplit',
'/static/css/16-calendar.css?v=20260929csssplit',
'/static/css/17-research.css?v=20260929csssplit',
'/static/css/documents-gallery-editor.css?v=20260929csssplit',
'/static/css/email-calendar-notes-tasks.css?v=20260929csssplit',
'/static/css/cookbook-research-memory-settings.css?v=20260929csssplit',
'/static/app.js?v=20260929csssplit',
'/static/css/00-tokens.css?v=20261021confirmalign1',
'/static/css/01-agent-chat.css?v=20261021confirmalign1',
'/static/css/02-compare.css?v=20261021confirmalign1',
'/static/css/03-agent-chat.css?v=20261021confirmalign1',
'/static/css/04-memory.css?v=20261021confirmalign1',
'/static/css/05-documents.css?v=20261021confirmalign1',
'/static/css/06-admin-settings.css?v=20261021confirmalign1',
'/static/css/07-documents.css?v=20261021confirmalign1',
'/static/css/08-skills.css?v=20261021confirmalign1',
'/static/css/09-gallery.css?v=20261021confirmalign1',
'/static/css/10-cookbook.css?v=20261021confirmalign1',
'/static/css/11-tasks.css?v=20261021confirmalign1',
'/static/css/12-gallery.css?v=20261021confirmalign1',
'/static/css/13-image-editor.css?v=20261021confirmalign1',
'/static/css/14-email.css?v=20261021confirmalign1',
'/static/css/15-notes.css?v=20261021confirmalign1',
'/static/css/16-calendar.css?v=20261021confirmalign1',
'/static/css/17-research.css?v=20261021confirmalign1',
'/static/css/documents-gallery-editor.css?v=20261021confirmalign1',
'/static/css/email-calendar-notes-tasks.css?v=20261021confirmalign1',
'/static/css/cookbook-research-memory-settings.css?v=20261021confirmalign1',
'/static/app.js?v=20261021confirmalign1',
'/static/js/storage.js',
'/static/js/appConfig.js',
'/static/js/ui.js?v=20260916largetoolscroll1',
+1 -1
View File
@@ -3,7 +3,7 @@ from tests.helpers.db_stubs import make_core_db_stub
def test_text_len_ignores_non_string_values(monkeypatch):
make_core_db_stub(monkeypatch, models=["Document", "DocumentVersion"])
make_core_db_stub(monkeypatch, models=["Document", "DocumentVersion", "ChatMessage", "Session"])
cli = load_script("odysseus-docs")
assert cli._text_len("hello") == 5
+103
View File
@@ -0,0 +1,103 @@
"""Regression for #2123 (CLI part): `odysseus-docs search` must match
multi-word queries and treat % / _ literally.
Old code built one ``%{query}%`` LIKE over title/current_content, so
"machine learning" only matched the exact adjacent phrase, and a literal
"%" matched every document.
"""
import json
from types import SimpleNamespace
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import NullPool
import core.database as cdb
from core.database import Document
from tests.helpers.cli_loader import load_script
def _cli_with_documents(monkeypatch, tmp_path):
engine = create_engine(
f"sqlite:///{tmp_path / 'docs_cli_search.db'}",
connect_args={"check_same_thread": False},
poolclass=NullPool,
)
cdb.Base.metadata.create_all(engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
db = session_factory()
try:
db.add_all(
[
Document(
id="doc-multiword",
title="ML notes",
current_content="machine notes on learning",
),
Document(
id="doc-percent",
title="Fabric",
current_content="100% cotton",
),
Document(
id="doc-underscore",
title="sale_2024",
current_content="spring campaign",
),
Document(
id="doc-backslash",
title="Path",
current_content=r"C:\photos\sunset",
),
]
)
db.commit()
finally:
db.close()
cli = load_script("odysseus-docs")
monkeypatch.setattr(cli, "SessionLocal", session_factory)
return cli
def _search_ids(cli, capsys, query):
cli.cmd_search(SimpleNamespace(query=query, limit=50, pretty=False))
return {doc["id"] for doc in json.loads(capsys.readouterr().out)}
def test_search_matches_words_in_any_order(monkeypatch, tmp_path, capsys):
cli = _cli_with_documents(monkeypatch, tmp_path)
assert _search_ids(cli, capsys, "machine learning") == {"doc-multiword"}
def test_search_escapes_percent_wildcard(monkeypatch, tmp_path, capsys):
cli = _cli_with_documents(monkeypatch, tmp_path)
assert _search_ids(cli, capsys, "%") == {"doc-percent"}
def test_search_escapes_underscore_wildcard(monkeypatch, tmp_path, capsys):
cli = _cli_with_documents(monkeypatch, tmp_path)
# Unescaped, "_" matches any single char so every document matches.
assert _search_ids(cli, capsys, "_") == {"doc-underscore"}
def test_search_escapes_backslash(monkeypatch, tmp_path, capsys):
cli = _cli_with_documents(monkeypatch, tmp_path)
assert _search_ids(cli, capsys, "\\") == {"doc-backslash"}
def test_search_whitespace_query_returns_all(monkeypatch, tmp_path, capsys):
cli = _cli_with_documents(monkeypatch, tmp_path)
assert _search_ids(cli, capsys, " ") == {
"doc-multiword",
"doc-percent",
"doc-underscore",
"doc-backslash",
}
+3
View File
@@ -348,6 +348,8 @@ function moduleSource(relativePath) {
},
makeWindowDraggable: (...args) => dragCalls.push(args),
clearDockSide: (...args) => dockCalls.push(args),
restoreMinimizedModal: () => false,
clearTimeout() {},
setTimeout: callback => { callback(); return 1; },
WeakSet,
};
@@ -356,6 +358,7 @@ function moduleSource(relativePath) {
vm.runInContext(moduleSource('search.js'), context, { filename: 'search.js' });
vm.runInContext(moduleSource('sidebar.js'), context, { filename: 'sidebar.js' });
vm.runInContext(moduleSource('navigation.js'), context, { filename: 'navigation.js' });
vm.runInContext(moduleSource('../modalVisibility.js'), context, { filename: 'modalVisibility.js' });
vm.runInContext(moduleSource('lifecycle.js'), context, { filename: 'lifecycle.js' });
vm.runInContext(moduleSource('dom.js'), context, { filename: 'dom.js' });
@@ -20,6 +20,7 @@ const REAL_MODULES = new Set([
path.join(JS, 'settings/sidebar.js'),
path.join(JS, 'settings/navigation.js'),
path.join(JS, 'settings/lifecycle.js'),
path.join(JS, 'modalVisibility.js'),
path.join(JS, 'settings/api.js'),
path.join(JS, 'settings/speech.js'),
path.join(JS, 'settings/writingStyle.js'),
@@ -869,6 +870,7 @@ const STUBS = new Map([
clearDockSide() {},
},
],
[path.join(JS, 'modalManager.js'), { toggle() { return false; } }],
]);
+41
View File
@@ -1,4 +1,7 @@
import pytest
from src.action_intents import classify_tool_intent, message_needs_tools
from src.turn_contract import requested_capabilities
def test_calendar_entry_request_promotes_to_agent():
@@ -97,6 +100,44 @@ def test_page_references_are_not_mistaken_for_named_computers():
assert intent.needs_tools and intent.category == "workspace"
@pytest.mark.parametrize(
"prompt",
[
"Who is Jensen Huang from NVIDIA?",
"Who is Tim Cook from Apple?",
"Who is Satya Nadella from Microsoft?",
"What happened on Monday?",
"What is the best book on Rome?",
"Tell me a story from Japan",
"What is the best GPU from NVIDIA?",
"Who is running from the police?",
],
)
def test_people_and_topics_are_not_mistaken_for_named_computers(prompt):
assert classify_tool_intent(prompt).reason != "named computer task request"
assert "shell_files" not in requested_capabilities(prompt)
@pytest.mark.parametrize(
"prompt",
[
"check disk usage on gpu-box",
"how much free disk on gpu-box?",
"what's the uptime on nas01",
"list running processes on gpu-box",
"is nginx running on gpu-box",
"is ffmpeg installed on workstation",
"what is the gpu usage on gpu-box",
"copy the logs from gpu-box",
"fetch the config file from prod-db",
"download the checkpoint from gpu-box",
],
)
def test_computer_tasks_on_a_named_host_keep_shell_tools(prompt):
assert classify_tool_intent(prompt).category == "workspace"
assert requested_capabilities(prompt) == {"shell_files"}
def test_direct_code_requests_promote_to_workspace_agent():
prompts = [
"write a Python function that parses CSV",
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
"""New usernames must be usable as a URL path segment and not carry control characters."""
import asyncio
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from tests.test_auth_policy import _auth_module, _make_manager, _setup_endpoint, _signup_endpoint
BAD = ["a/b", "../x", "a\\b", "tab\tname", "new\nline", "..", ".", "u" * 65]
@pytest.mark.parametrize("name", BAD)
def test_username_error_rejects(name):
assert _auth_module().username_error(name)
@pytest.mark.parametrize("name", ["alice", "Bob Smith", "j.doe", "user@example.com", "日本語", "u" * 64])
def test_username_error_accepts(name):
assert _auth_module().username_error(name) is None
def test_username_error_requires_a_name():
assert _auth_module().username_error(" ") == "Username is required"
@pytest.mark.parametrize("name", ["a/b", "u" * 65])
def test_setup_rejects_bad_username(tmp_path, name):
mgr = _make_manager(tmp_path)
endpoint, SetupRequest = _setup_endpoint(mgr)
request = SimpleNamespace(client=SimpleNamespace(host="127.0.0.1"))
with pytest.raises(HTTPException) as exc:
asyncio.run(endpoint(body=SetupRequest(username=name, password="long-enough-pw"), request=request))
assert exc.value.status_code == 400
assert not mgr.is_configured
def test_signup_rejects_bad_username(tmp_path):
mgr = _make_manager(tmp_path)
mgr.create_user("admin", "admin-password", is_admin=True)
mgr.signup_enabled = True
endpoint, SignupRequest = _signup_endpoint(mgr)
request = SimpleNamespace(client=SimpleNamespace(host="127.0.0.1"))
with pytest.raises(HTTPException) as exc:
asyncio.run(endpoint(body=SignupRequest(username="a/b", password="long-enough-pw"), request=request))
assert exc.value.status_code == 400
assert "a/b" not in mgr.users
+1 -1
View File
@@ -71,7 +71,7 @@ def test_calendar_event_surfaces_use_computed_foreground_variable():
assert "_calReadableTextColor" in utils_js
assert "function _calEventFg(ev)" in calendar_js
assert "--cal-event-fg:${_calEventFg(md)}" in calendar_js
# Month events share transparent dot rows; remaining solid tiles need contrast.
assert "--cal-event-fg:${_calEventFg(ev)}" in calendar_js
assert "color: var(--cal-event-fg, #fff);" in style_css
assert "color: var(--cal-event-fg, var(--fg));" in style_css
+53
View File
@@ -0,0 +1,53 @@
"""Calendar day membership uses the event's exclusive end instant."""
import json
import os
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]
SOURCE = js_function_source("_eventsForDay", (ROOT / "static/js/calendar.js").read_text())
ZONES = ["UTC", "Europe/Istanbul", "America/New_York", "Asia/Tokyo"]
CASES = [
("2026-10-22T17:00:00", "2026-10-23T00:00:00", False, ["2026-10-22"]),
("2026-10-22T17:00:00", "2026-10-23T00:01:00", False, ["2026-10-22", "2026-10-23"]),
("2026-10-22T17:00:00", "2026-10-22T17:00:00", False, ["2026-10-22"]),
("2026-10-15", "2026-10-17", True, ["2026-10-15", "2026-10-16"]),
("2026-10-15", "2026-10-15", True, ["2026-10-15"]),
("2026-10-31T23:00:00", "2026-11-01T00:00:00", False, ["2026-10-31"]),
]
def _coverage(zone, start, end, all_day, days):
script = """
import { _localDateOf } from './static/js/calendar/utils.js';
const {source,event,days}=JSON.parse(process.argv[1]);
const filter=new Function('_events','_eventVisible','_localDateOf',source+'; return _eventsForDay;')([event],()=>true,_localDateOf);
console.log(JSON.stringify(days.filter(day=>filter(day).length)));
"""
data = dict(source=SOURCE, event=dict(uid="event", dtstart=start, dtend=end, all_day=all_day), days=days)
result = subprocess.run(["node", "--input-type=module", "-e", script, json.dumps(data)],
cwd=ROOT, env={**os.environ, "TZ": zone}, capture_output=True,
text=True, check=True, timeout=15)
return json.loads(result.stdout)
@pytest.mark.skipif(not shutil.which("node"), reason="node not installed")
@pytest.mark.parametrize("zone", ZONES)
@pytest.mark.parametrize("start,end,all_day,expected", CASES)
def test_event_day_coverage(zone, start, end, all_day, expected):
days = [f"2026-10-{day:02d}" for day in range(14, 32)] + ["2026-11-01", "2026-11-02"]
assert _coverage(zone, start, end, all_day, days) == expected
@pytest.mark.skipif(not shutil.which("node"), reason="node not installed")
@pytest.mark.parametrize("zone,start,end,days,expected", [
("Europe/Berlin", "2026-10-24T20:00:00+02:00", "2026-10-26T00:00:00+01:00", ["2026-10-24", "2026-10-25", "2026-10-26"], ["2026-10-24", "2026-10-25"]),
("America/New_York", "2026-10-31T20:00:00-04:00", "2026-11-02T00:00:00-05:00", ["2026-10-31", "2026-11-01", "2026-11-02"], ["2026-10-31", "2026-11-01"]),
])
def test_midnight_end_across_dst(zone, start, end, days, expected):
assert _coverage(zone, start, end, False, days) == expected
+2 -3
View File
@@ -2,7 +2,7 @@ from pathlib import Path
import re
from tests.helpers.stylesheets import app_css
from tests.helpers.js_modules import email_library_source
from tests.helpers.js_modules import email_library_source, js_function_source
ROOT = Path(__file__).resolve().parents[1]
@@ -172,8 +172,7 @@ def test_calendar_event_cards_show_compact_source_badges():
style_src = app_css()
assert "function _eventSourceHtml" in calendar_src
assert "${_eventSourceHtml(ev)}" in calendar_src
assert "${_eventSourceHtml(md)}" in calendar_src
assert "${_eventSourceHtml(ev)}" in js_function_source("_renderMonth", calendar_src)
assert ".cal-event-source" in style_src
+61
View File
@@ -0,0 +1,61 @@
"""The shipped CSS must print every message beyond the screen viewport."""
import json
import os
import shutil
import subprocess
from pathlib import Path
import pytest
from pypdf import PdfReader
ROOT = Path(__file__).resolve().parents[1]
@pytest.mark.skipif(not shutil.which("node"), reason="node is required for Chromium printing")
def test_chat_print_preserves_the_complete_transcript(tmp_path):
script = r'''
import fs from 'node:fs';
import path from 'node:path';
import { chromium } from 'playwright';
const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN;
const source = fs.readFileSync('static/index.html', 'utf8');
const links = [...source.matchAll(/<link\b[^>]*rel="stylesheet"[^>]*>/g)]
.map(match => match[0]).join('\n');
const messages = Array.from({length: 24}, (_, index) => {
const marker = 'EXPORTSENTINEL' + String(index + 1).padStart(2, '0');
const paragraphs = Array.from({length:4}, () =>
'<p>This synthetic message must survive printing beyond the screen viewport. '
+ 'Every paragraph belongs to the saved conversation.</p>').join('');
return `<div class="msg ${index % 2 ? 'msg-ai' : 'msg-user'}"><div class="body"><p>${marker}</p>${paragraphs}</div></div>`;
}).join('');
const browser = await chromium.launch({headless:true});
try {
const results=[];
for (const [layout,viewport] of [['desktop',{width:1440,height:1000}],['mobile',{width:390,height:844}]]) {
const page=await browser.newPage({viewport});
await page.goto(origin+'/static/css/00-tokens.css');
await page.setContent(`<html><head>${links}</head><body><main class="chat-container"><div id="chat-history">${messages}</div></main></body></html>`,{waitUntil:'load'});
await page.evaluate(()=>document.fonts.ready);
const file=path.join(process.env.ODYSSEUS_PDF_OUTPUT_DIR,layout+'.pdf');
await page.pdf({path:file,format:'A4',printBackground:true});
results.push({layout,file});
await page.close();
}
console.log(JSON.stringify(results));
} finally {await browser.close();}
'''
result = subprocess.run(
["node", "--input-type=module", "-e", script], cwd=ROOT,
capture_output=True, text=True, timeout=90,
env={**os.environ, "ODYSSEUS_PDF_OUTPUT_DIR": str(tmp_path)},
)
assert result.returncode == 0, result.stderr
results = json.loads(result.stdout)
assert {row["layout"] for row in results} == {"desktop", "mobile"}
for row in results:
pdf = PdfReader(row["file"])
text = "".join("".join(page.extract_text().split()) for page in pdf.pages)
assert len(pdf.pages) > 1, row["layout"]
for index in range(1, 25):
assert f"EXPORTSENTINEL{index:02d}" in text, (row["layout"], index)
+122
View File
@@ -1,4 +1,5 @@
from unittest.mock import MagicMock
import httpx
from types import SimpleNamespace
from src.chat_processor import ChatProcessor
@@ -93,3 +94,124 @@ def test_build_context_preface_web_search_query_sanitization(monkeypatch):
called_query = mock_web_search.call_args[0][0]
assert len(called_query) <= 150
assert " " not in called_query
def _run_web_preface(monkeypatch, message):
mock_web_search = MagicMock(return_value=("Search Results", []))
monkeypatch.setattr("src.chat_processor.comprehensive_web_search", mock_web_search)
processor = ChatProcessor(memory_manager=MagicMock(), personal_docs_manager=MagicMock())
session = SimpleNamespace(
endpoint_url="http://127.0.0.1:8081/v1/chat/completions", model="gemma4-26b", headers={},
)
processor.build_context_preface(
message=message,
session=session,
use_web=True,
use_rag=False,
use_memory=False,
use_skills=False,
)
return mock_web_search
def test_truncated_reasoning_never_becomes_the_web_search_query(monkeypatch):
"""A reasoning model that exhausts max_tokens falls back to the user's line."""
sent = []
def fake_post(_url, **kwargs):
sent.append(kwargs["json"])
return httpx.Response(
200,
request=httpx.Request("POST", "http://127.0.0.1:8081/v1/chat/completions"),
json={"choices": [{
"finish_reason": "length",
"message": {
"content": "",
"reasoning_content": '* User message: "Who is Jensen?" * Goal: Extract a query.',
},
}]},
)
monkeypatch.setattr("src.llm_core.httpx.post", fake_post)
mock_web_search = _run_web_preface(monkeypatch, "Does he work with Lisa Su?\nThanks")
mock_web_search.assert_called_with(
"Does he work with Lisa Su?", time_filter=None, return_sources=True,
)
assert sent[0]["chat_template_kwargs"] == {"enable_thinking": False}
def test_inline_think_block_never_becomes_the_web_search_query(monkeypatch):
"""Without a reasoning parser, thinking arrives as ordinary content."""
monkeypatch.setattr(
"src.llm_core.llm_call",
MagicMock(return_value="<think>The user is asking about"),
)
mock_web_search = _run_web_preface(monkeypatch, "Who is Jensen Huang?")
mock_web_search.assert_called_with("Who is Jensen Huang?", time_filter=None, return_sources=True)
def _turn(role, content):
return SimpleNamespace(role=role, content=content)
def _extraction_messages(monkeypatch, message, history, **kwargs):
mock_llm_call = MagicMock(return_value="query")
monkeypatch.setattr("src.llm_core.llm_call", mock_llm_call)
monkeypatch.setattr(
"src.chat_processor.comprehensive_web_search",
MagicMock(return_value=("Search Results", [])),
)
processor = ChatProcessor(memory_manager=MagicMock(), personal_docs_manager=MagicMock())
processor.build_context_preface(
message=message,
session=SimpleNamespace(endpoint_url="http://local", model="test", headers={}, history=history),
use_web=True,
use_rag=False,
use_memory=False,
use_skills=False,
**kwargs,
)
return mock_llm_call.call_args.args[2]
def test_follow_up_query_extraction_sees_recent_turns(monkeypatch):
followup = "Does he work with Lisa Su?"
messages = _extraction_messages(monkeypatch, followup, [
_turn("user", "Who is Jensen Huang from NVIDIA?"),
_turn("assistant", "<think>Let me look.</think>Jensen Huang leads NVIDIA."),
_turn("user", followup),
])
assert messages[1]["content"] == (
"Earlier conversation:\n"
"user: Who is Jensen Huang from NVIDIA?\n"
"assistant: Jensen Huang leads NVIDIA.\n\n"
f"Latest message:\n{followup}"
)
def test_first_message_query_extraction_is_only_the_message(monkeypatch):
messages = _extraction_messages(monkeypatch, "Who is Jensen?", [_turn("user", "Who is Jensen?")])
assert messages[1] == {"role": "user", "content": "Who is Jensen?"}
def test_query_extraction_history_is_bounded(monkeypatch):
history = [_turn("user", f"old-{i} " + "x" * 2000) for i in range(10)]
history.append(_turn("assistant", "reply"))
prompt = _extraction_messages(monkeypatch, "next", history)[1]["content"]
assert "old-6" not in prompt
assert "old-7" in prompt and "old-9" in prompt
assert len(prompt) < 2200
def test_incognito_query_extraction_ignores_saved_history(monkeypatch):
messages = _extraction_messages(
monkeypatch, "Does he code?", [_turn("user", "Saved chat"), _turn("assistant", "Reply")],
incognito=True,
)
assert messages[1] == {"role": "user", "content": "Does he code?"}
+13
View File
@@ -0,0 +1,13 @@
from pathlib import Path
import re
ROOT = Path(__file__).resolve().parents[1]
def test_timeout_catch_can_read_is_agent():
"""The catch block reads _isAgent, so it must not be a try-scoped const (#6764)."""
src = (ROOT / "static/js/chat.js").read_text()
assert re.search(r"^\s*let _isAgent = false;", src, re.M)
assert not re.search(r"\bconst _isAgent\b", src)
assert re.search(r"^\s*_isAgent = \(_tState\.mode", src, re.M)
+232
View File
@@ -516,3 +516,235 @@ messages = [
assert '[Image]' in keep_recent_image_turns(messages, keep=1)[0]['content'][0]['text']
'''
subprocess.run([sys.executable, '-c', script], check=True, timeout=8)
class TestSanitizeToolMessagesPredecessor:
"""``_sanitize_tool_messages`` must return a transcript that satisfies the
provider ordering contract and is stable under repeated sanitation.
Contract under test:
- an assistant ``tool_calls`` turn follows a user or tool turn;
- every tool message belongs to a preceding ``tool_calls`` batch;
- an assistant ``tool_calls`` turn has at least one tool response.
"""
@staticmethod
def _violations(msgs):
problems = []
in_batch = False
for i, m in enumerate(msgs):
role = m.get("role")
if role == "tool":
if not in_batch:
problems.append(f"[{i}] orphan tool message")
continue
prev = msgs[i - 1] if i else None
prev_role = prev.get("role") if prev else None
if role == "assistant" and m.get("tool_calls"):
if prev_role not in ("user", "tool"):
problems.append(f"[{i}] tool_calls after {prev_role!r}")
nxt = msgs[i + 1] if i + 1 < len(msgs) else None
if not (nxt and nxt.get("role") == "tool"):
problems.append(f"[{i}] dangling tool_calls")
in_batch = True
else:
in_batch = False
return problems
def _sanitize(self, msgs):
result = cc._sanitize_tool_messages(msgs)
assert self._violations(result) == [], (
"sanitized transcript still violates the ordering contract: "
f"{self._violations(result)}"
)
assert cc._sanitize_tool_messages(result) == result, (
"sanitizing the sanitized transcript changed it"
)
return result
def test_user_then_calls_is_unchanged(self):
msgs = [
{"role": "user", "content": "search for X"},
{"role": "assistant", "content": None,
"tool_calls": [{"id": "c1", "function": {"name": "web_search"}}]},
{"role": "tool", "tool_call_id": "c1", "content": "results"},
]
result = self._sanitize(msgs)
assert len(result) == 3
assert result[1].get("tool_calls") is not None
def test_second_batch_after_tool_response_is_unchanged(self):
"""A call batch whose predecessor is a tool response is valid."""
msgs = [
{"role": "user", "content": "search for X"},
{"role": "assistant", "content": None,
"tool_calls": [{"id": "c1", "function": {"name": "web_search"}}]},
{"role": "tool", "tool_call_id": "c1", "content": "results"},
{"role": "assistant", "content": None,
"tool_calls": [{"id": "c2", "function": {"name": "web_search"}}]},
{"role": "tool", "tool_call_id": "c2", "content": "more results"},
]
result = self._sanitize(msgs)
assert len(result) == 5
assert result[3].get("tool_calls") is not None
def test_two_batch_mid_history_reaches_stable_result(self):
"""A transcript that starts mid-history has no user/tool predecessor
for its first call batch. Dropping that batch leaves the second one
with no predecessor either, so the contract-valid, stable result is
empty; the sanitizer must not return the half-repaired transcript
where the second batch sits at the start without a predecessor.
"""
msgs = [
{"role": "assistant", "content": None,
"tool_calls": [{"id": "c1", "function": {"name": "web_search"}}]},
{"role": "tool", "tool_call_id": "c1", "content": "results"},
{"role": "assistant", "content": None,
"tool_calls": [{"id": "c2", "function": {"name": "web_search"}}]},
{"role": "tool", "tool_call_id": "c2", "content": "more results"},
]
result = self._sanitize(msgs)
assert result == []
def test_assistant_text_then_completed_call_is_preserved(self):
"""An assistant text turn directly before a completed call batch must
not cost the batch its tool_calls: the text is merged into the call
message so the executed call/result pair stays in the transcript.
"""
msgs = [
{"role": "user", "content": "plan my day"},
{"role": "assistant", "content": "I'll check the calendar.",
"tool_calls": [{"id": "c1", "function": {"name": "manage_calendar"}}]},
{"role": "tool", "tool_call_id": "c1", "content": "[]"},
{"role": "assistant", "content": "Found it."},
{"role": "assistant", "content": "Let me look at day two.",
"tool_calls": [{"id": "c2", "function": {"name": "manage_calendar"}}]},
{"role": "tool", "tool_call_id": "c2", "content": "[]"},
]
result = self._sanitize(msgs)
assert len(result) == 5
assert result[1].get("tool_calls") is not None
merged = result[3]
assert merged.get("content") == "Found it.\n\nLet me look at day two."
assert merged.get("tool_calls") is not None
assert merged["tool_calls"][0]["id"] == "c2"
def test_assistant_text_directly_before_calls_merges(self):
msgs = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "Thinking out loud"},
{"role": "assistant", "content": None,
"tool_calls": [{"id": "c1", "function": {"name": "web_search"}}]},
{"role": "tool", "tool_call_id": "c1", "content": "results"},
]
result = self._sanitize(msgs)
assert len(result) == 3
assert result[1]["content"] == "Thinking out loud"
assert result[1]["tool_calls"][0]["id"] == "c1"
def test_chain_of_text_turns_before_calls_merges_into_one(self):
msgs = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "one"},
{"role": "assistant", "content": "two"},
{"role": "assistant", "content": None,
"tool_calls": [{"id": "c1", "function": {"name": "web_search"}}]},
{"role": "tool", "tool_call_id": "c1", "content": "results"},
]
result = self._sanitize(msgs)
assert len(result) == 3
assert result[1]["content"] == "one\n\ntwo"
assert result[1]["tool_calls"][0]["id"] == "c1"
def test_system_predecessor_calls_dropped(self):
msgs = [
{"role": "system", "content": "You are helpful"},
{"role": "assistant", "content": None,
"tool_calls": [{"id": "c1", "function": {"name": "web_search"}}]},
{"role": "tool", "tool_call_id": "c1", "content": "results"},
]
result = self._sanitize(msgs)
assert [m["role"] for m in result] == ["system"]
def test_first_message_calls_dropped(self):
msgs = [
{"role": "assistant", "content": None,
"tool_calls": [{"id": "c1", "function": {"name": "web_search"}}]},
{"role": "tool", "tool_call_id": "c1", "content": "results"},
]
assert self._sanitize(msgs) == []
def test_calls_with_content_after_system_keep_text(self):
msgs = [
{"role": "system", "content": "You are helpful"},
{"role": "assistant", "content": "I'll search for you",
"tool_calls": [{"id": "c1", "function": {"name": "web_search"}}]},
{"role": "tool", "tool_call_id": "c1", "content": "results"},
]
result = self._sanitize(msgs)
assert [m["role"] for m in result] == ["system", "assistant"]
assert result[1].get("tool_calls") is None
assert "I'll search for you" in result[1]["content"]
def test_repeated_sanitation_is_stable_across_shapes(self):
shapes = [
[],
[{"role": "user", "content": "hi"}],
[{"role": "assistant", "content": None,
"tool_calls": [{"id": "c1", "function": {"name": "f"}}]}],
[{"role": "tool", "tool_call_id": "c1", "content": "x"}],
[{"role": "user", "content": "hi"},
{"role": "tool", "tool_call_id": "c1", "content": "x"}],
[{"role": "assistant", "content": "a"},
{"role": "assistant", "content": None,
"tool_calls": [{"id": "c1", "function": {"name": "f"}}]},
{"role": "tool", "tool_call_id": "c1", "content": "x"},
{"role": "assistant", "content": "b"},
{"role": "assistant", "content": "c",
"tool_calls": [{"id": "c2", "function": {"name": "f"}}]},
{"role": "tool", "tool_call_id": "c2", "content": "y"}],
]
for msgs in shapes:
once = cc._sanitize_tool_messages(msgs)
assert self._violations(once) == []
assert cc._sanitize_tool_messages(once) == once
def test_input_messages_are_not_mutated(self):
import copy
msgs = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "text"},
{"role": "assistant", "content": None,
"tool_calls": [{"id": "c1", "function": {"name": "f"}}]},
{"role": "tool", "tool_call_id": "c1", "content": "x"},
]
before = copy.deepcopy(msgs)
cc._sanitize_tool_messages(msgs)
assert msgs == before
@pytest.mark.parametrize("content", [
[{"type": "text", "text": "Keep observation"}],
[{"type": "image_url", "image_url": {"url": "https://example.com/image.png"}}],
[],
])
@pytest.mark.parametrize("dangling", [False, True])
def test_stripped_calls_preserve_multimodal_content(self, content, dangling):
if dangling:
messages = [
{"role": "user", "content": "Inspect image"},
{"role": "assistant", "content": content,
"tool_calls": [{"id": "c1", "function": {"name": "inspect"}}]},
]
else:
messages = [
{"role": "system", "content": "Instructions"},
{"role": "assistant", "content": content,
"tool_calls": [{"id": "c1", "function": {"name": "inspect"}}]},
{"role": "tool", "tool_call_id": "c1", "content": "orphan"},
]
output = self._sanitize(messages)
expected = [messages[0]]
if content:
expected.append({"role": "assistant", "content": content})
assert output == expected
+81 -18
View File
@@ -50,17 +50,10 @@ def _launch_llm(
port: int,
serve_session_id: str | None = None,
) -> str:
import routes.cookbook_routes as cookbook_routes
from routes.cookbook.endpoint_registration import _auto_register_llm_endpoint
from routes.cookbook_helpers import ServeRequest
router = cookbook_routes.setup_cookbook_routes()
serve = next(
route.endpoint for route in router.routes
if route.path == "/api/model/serve" and "POST" in route.methods
)
cells = dict(zip(serve.__code__.co_freevars, serve.__closure__ or ()))
register = cells["_auto_register_llm_endpoint"].cell_contents
endpoint_id = register(
endpoint_id = _auto_register_llm_endpoint(
ServeRequest(repo_id=repo_id, cmd=f"vllm serve {repo_id} --port {port}"),
None,
serve_session_id=serve_session_id,
@@ -76,6 +69,83 @@ def _endpoint(factory, endpoint_id: str):
return db.get(database.ModelEndpoint, endpoint_id)
@pytest.mark.parametrize(
("cmd", "registers", "skips"),
[
(
"python scripts/diffusion_server.py --port 8100",
"_auto_register_image_endpoint",
"_auto_register_llm_endpoint",
),
(
"llama-server -m /models/model.gguf --port 8085",
"_auto_register_llm_endpoint",
"_auto_register_image_endpoint",
),
],
)
@pytest.mark.asyncio
async def test_serve_registers_endpoint_for_its_model_type(monkeypatch, tmp_path, cmd, registers, skips):
from unittest.mock import AsyncMock, MagicMock
from starlette.requests import Request
from routes import cookbook_routes
from routes.cookbook_helpers import ServeRequest
launch =MagicMock(returncode=0, wait=AsyncMock(return_value=0))
monkeypatch.setattr(cookbook_routes, "TMUX_LOG_DIR", tmp_path)
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(cookbook_routes.asyncio, "create_subprocess_shell", AsyncMock(return_value=launch))
# Returning no endpoint id keeps the crash watchdog from being scheduled.
register = MagicMock(return_value=None)
skip = MagicMock(return_value=None)
monkeypatch.setattr(cookbook_routes, registers, register)
monkeypatch.setattr(cookbook_routes, skips, skip)
router = cookbook_routes.setup_cookbook_routes()
serve = next(
route.endpoint for route in router.routes
if route.path == "/api/model/serve" and "POST" in route.methods
)
req = ServeRequest(repo_id="organization-a/Model-A", cmd=cmd, remote_host="gpu-box")
response = await serve(Request({"type": "http", "headers": []}), req)
register.assert_called_once_with(req, "gpu-box", serve_session_id=response["session_id"])
skip.assert_not_called()
def _register_ollama(cmd: str) -> str:
from routes.cookbook.endpoint_registration import _auto_register_llm_endpoint
from routes.cookbook_helpers import ServeRequest
endpoint_id = _auto_register_llm_endpoint(ServeRequest(repo_id="library/model", cmd=cmd), None)
assert endpoint_id
return endpoint_id
@pytest.mark.parametrize("host", ["0.0.0.0", "h" * 253])
def test_registers_port_from_ollama_host(endpoint_db, host):
endpoint_id = _register_ollama(f"OLLAMA_HOST={host}:11435 ollama serve")
assert _endpoint(endpoint_db, endpoint_id).base_url == "http://localhost:11435/v1"
def test_repeated_ollama_host_assignments_register_quickly(endpoint_db):
import time
start = time.perf_counter()
endpoint_id = _register_ollama("OLLAMA_HOST=" * 32000 + " ollama serve")
elapsed = time.perf_counter() - start
# The unbounded pattern took about 30s on this input and blocked the event loop.
assert elapsed < 4.0, f"registration took {elapsed:.2f}s"
assert _endpoint(endpoint_db, endpoint_id).base_url == "http://localhost:11434/v1"
@pytest.mark.parametrize("reuse", [False, True])
def test_launch_keeps_endpoint_of_other_model_with_same_short_name(endpoint_db, reuse):
if reuse:
@@ -448,20 +518,13 @@ async def test_ordinary_admin_delete_does_not_require_serve_owner(
def _launch_image(repo_id: str, port: int, monkeypatch) -> str:
import routes.cookbook_routes as cookbook_routes
import src.settings as settings
from routes.cookbook.endpoint_registration import _auto_register_image_endpoint
from routes.cookbook_helpers import ServeRequest
monkeypatch.setattr(settings, "load_settings", lambda: {"image_gen_enabled": True, "image_model": repo_id})
monkeypatch.setattr(settings, "save_settings", lambda _settings: None)
router = cookbook_routes.setup_cookbook_routes()
serve = next(
route.endpoint for route in router.routes
if route.path == "/api/model/serve" and "POST" in route.methods
)
cells = dict(zip(serve.__code__.co_freevars, serve.__closure__ or ()))
register = cells["_auto_register_image_endpoint"].cell_contents
endpoint_id = register(
endpoint_id = _auto_register_image_endpoint(
ServeRequest(repo_id=repo_id, cmd=f"python diffusion_server.py --model {repo_id} --port {port}"), None
)
assert endpoint_id
+34
View File
@@ -2,6 +2,7 @@ import json
import os
import subprocess
import sys
import re
from pathlib import Path
import pytest
@@ -1026,3 +1027,36 @@ def test_validate_serve_cmd_rejects_unrelated_subshell_pipelines():
]:
with pytest.raises(HTTPException):
_validate_serve_cmd(cmd)
def _dependency_recipe_pip_commands():
src = (Path(__file__).resolve().parents[1] / "static" / "js" / "cookbook-deps-recipes.js").read_text(encoding="utf-8")
return re.findall(r"pip:\s*\{\s*commands:\s*\['((?:[^'\\]|\\.)*)'\]\s*\}", src)
def test_validate_serve_cmd_accepts_single_line_dependency_recipes():
# The Dependencies recipe "Run" button posts these to /api/model/serve.
cmds = _dependency_recipe_pip_commands()
assert 'uv pip install -U vllm --torch-backend auto' in cmds
assert 'uv pip install -U "sglang[all]" --torch-backend auto' in cmds
for cmd in cmds:
assert _validate_serve_cmd(cmd) == cmd
@pytest.mark.parametrize("cmd", [
"uv run python -c 'print(1)'",
"uv tool install ruff",
"uv pip uninstall vllm",
"uv",
])
def test_validate_serve_cmd_allows_uv_only_for_pip_install(cmd):
with pytest.raises(HTTPException):
_validate_serve_cmd(cmd)
def test_dependency_recipe_run_sends_a_valid_pip_repo_id():
# A pip-install repo_id must match the server's package-spec regex; the old
# "<backend> setup" value had a space and every Run got "Invalid pip package name".
src = (Path(__file__).resolve().parents[1] / "static" / "js" / "cookbook.js").read_text(encoding="utf-8")
assert "repo_id: `${backend}-setup`," in src
assert "repo_id: `${backend} setup`" not in src
+2 -20
View File
@@ -156,28 +156,10 @@ async def test_scheduled_stop_requires_session_identity(endpoint_db, endpoint_ht
def _launch_image(repo_id, port, serve_session_id):
import routes.cookbook_routes as cookbook_routes
from routes.cookbook.endpoint_registration import _auto_register_image_endpoint
from routes.cookbook_helpers import ServeRequest
router = cookbook_routes.setup_cookbook_routes()
serve = next(
route.endpoint
for route in router.routes
if route.path == "/api/model/serve"
and "POST" in route.methods
)
cells = dict(
zip(
serve.__code__.co_freevars,
serve.__closure__ or (),
)
)
register = cells["_auto_register_image_endpoint"].cell_contents
endpoint_id = register(
endpoint_id = _auto_register_image_endpoint(
ServeRequest(
repo_id=repo_id,
cmd=f"python scripts/diffusion_server.py --port {port}",
+19
View File
@@ -93,3 +93,22 @@ def test_small_model_detection_uses_parameter_size_in_model_title():
assert DeepResearcher._looks_like_small_local_model("model-9B")
assert DeepResearcher._looks_like_small_local_model("model-10B")
assert not DeepResearcher._looks_like_small_local_model("model-11B")
def test_category_classification_turns_thinking_off(monkeypatch):
"""Reasoning lists every category name, which would defeat the label scan."""
sent = {}
async def fake_call(**kwargs):
sent.update(kwargs)
return "product"
monkeypatch.setattr("src.llm_core.llm_call_async", fake_call)
r = DeepResearcher.__new__(DeepResearcher)
r.llm_endpoint = "http://x"
r.llm_model = "m"
r.llm_headers = {}
assert asyncio.run(r._classify_category("Best ThinkPad to buy?")) == "product"
assert sent["thinking_mode"] == "off"
assert sent["max_tokens"] == 20
@@ -0,0 +1,82 @@
"""Demoting an admin purges their API tokens (issue #6831).
Token management is admin-only and scoped to the token's owner, so once the
owner is demoted nobody can list or revoke their tokens any more.
"""
import contextlib
import sys
import types
import pytest
from tests.test_delete_user_revokes_api_tokens import (
_auth_module,
_FakeApiToken,
_FakeSession,
)
@pytest.fixture
def manager(tmp_path, monkeypatch):
auth_mod = _auth_module()
monkeypatch.setattr(auth_mod, "_hash_password", lambda password: f"hash:{password}")
monkeypatch.setattr(
auth_mod, "_verify_password", lambda password, hashed: hashed == f"hash:{password}"
)
mgr = auth_mod.AuthManager(str(tmp_path / "auth.json"))
assert mgr.create_user("admin", "secret-admin-pw", is_admin=True)
assert mgr.create_user("adm2", "secret-adm2-pw", is_admin=True)
assert mgr.create_user("bob", "secret-bob-pw", is_admin=False)
return mgr
@pytest.fixture
def db_calls(monkeypatch):
calls = []
@contextlib.contextmanager
def _fake_db_session():
yield _FakeSession(calls)
db_stub = types.ModuleType("core.database")
db_stub.get_db_session = _fake_db_session
db_stub.ApiToken = _FakeApiToken
monkeypatch.setitem(sys.modules, "core.database", db_stub)
return calls
def test_demote_revokes_api_tokens(manager, db_calls):
result = manager.set_admin("adm2", False, "admin")
assert result.value == "ok"
assert manager.is_admin("adm2") is False
assert [("owner ==", "adm2")] in db_calls
def test_promote_leaves_api_tokens_alone(manager, db_calls):
manager.set_admin("bob", True, "admin")
assert db_calls == []
def test_refused_demotion_leaves_api_tokens_alone(manager, db_calls):
assert manager.set_admin("adm2", False, "bob").value == "not_authorized"
assert db_calls == []
def test_demote_fails_closed_when_token_purge_fails(manager, monkeypatch):
@contextlib.contextmanager
def _failing_db_session():
raise RuntimeError("database unavailable")
yield
db_stub = types.ModuleType("core.database")
db_stub.get_db_session = _failing_db_session
db_stub.ApiToken = _FakeApiToken
monkeypatch.setitem(sys.modules, "core.database", db_stub)
with pytest.raises(RuntimeError):
manager.set_admin("adm2", False, "admin")
assert manager.is_admin("adm2") is True
+32
View File
@@ -58,6 +58,38 @@ def test_styled_confirm_cancel_or_close_label_is_shifted_without_moving_button()
assert "top:2px;" in rule
def test_styled_confirm_ok_and_alternate_labels_shift_without_moving_buttons():
# Moving #styled-confirm-ok itself (position:relative; top:2px) left it 2px
# below Cancel and the alternate button. The OK and alternate labels get the
# same label nudge as Cancel instead, and no rule moves a footer button.
css = app_css()
assert "okBtn.replaceChildren(btnLabel(" in _UI
assert "altBtn.replaceChildren(btnLabel(" in _UI
rule_start = css.index("#styled-confirm-ok .styled-confirm-btn-label")
rule = css[rule_start:css.index("}", rule_start)]
assert "#styled-confirm-alt .styled-confirm-btn-label" in rule
assert "position:relative;" in rule and "top:2px;" in rule
for selector in ("#styled-confirm-ok {", "#styled-confirm-alt {", "#styled-confirm-cancel {"):
if selector in css:
block = css[css.index(selector):].split("}", 1)[0]
assert "top:" not in block.replace(" ", ""), selector
def test_styled_confirm_buttons_grow_to_fit_wrapped_labels():
# The global `button { height:32px }` held a two-line label (e.g.
# "Delete chat and images") to one line's height, so its second line
# rendered below the button. Confirm buttons must be allowed to grow,
# with 32px kept as the minimum.
css = app_css()
assert re.search(r"(^|\n)\s*button\s*\{\s*height:32px;", css)
rule_start = css.index(".styled-confirm-box .confirm-btn {")
rule = css[rule_start:rule_start + 120]
assert "height:auto;" in rule
assert "min-height:32px;" in rule
def test_styled_dialogs_manage_focus():
# A dialog is only really accessible if it restores focus to the trigger on
# close and traps Tab while open. Both styledConfirm and styledPrompt should
+95
View File
@@ -0,0 +1,95 @@
"""Large document pages survive the model-facing tool-output boundary."""
import asyncio
import json
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from src.constants import MAX_OUTPUT_CHARS
from src.tool_execution import format_tool_result
from core import database
from src.agent_tools import TOOL_HANDLERS
from src.agent_tools.document_tools import get_active_document, set_active_document
@pytest.fixture
def documents(tmp_path, monkeypatch):
engine = create_engine(f"sqlite:///{tmp_path / 'documents.db'}")
database.Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine)
monkeypatch.setattr(database, "SessionLocal", factory)
saved_active = get_active_document()
with factory() as db:
db.add(database.Document(id="owned-document", owner="fixture-owner", title="Owned",
language="text", current_content=""))
db.commit()
yield
set_active_document(saved_active)
engine.dispose()
@pytest.mark.parametrize("limit", [None, 37, 20_000])
def test_formatted_document_pages_reconstruct_the_entire_body(documents, limit):
body = "".join(f"Row {i}: α, quote=\" and slash=\\\n" for i in range(1200))
with database.SessionLocal() as db:
db.query(database.Document).filter_by(id="owned-document").first().current_content = body
db.commit()
offset = 0
chunks = []
while True:
args = {"action": "read", "document_id": "owned-document", "offset": offset}
if limit is not None:
args["limit"] = limit
content = json.dumps(args)
result = asyncio.run(TOOL_HANDLERS["manage_documents"](content, {"owner": "fixture-owner"}))
page = result["document"]
rendered = format_tool_result(f"manage_documents: {content[:80]}", result)
end = page["next_offset"] if page["truncated"] else len(body)
chunk = body[offset:end]
assert chunk in rendered
assert len(rendered) <= MAX_OUTPUT_CHARS
assert rendered.endswith("\n```")
if page["truncated"]:
assert end > offset
assert f"next_offset={end}" in rendered
chunks.append(chunk)
if not page["truncated"]:
break
offset = end
assert "".join(chunks) == body
def test_document_result_cannot_bypass_the_output_cap():
rendered = format_tool_result("manage_documents: read", {
"response": "x" * (MAX_OUTPUT_CHARS * 2),
"document": {"offset": 0, "next_offset": None},
"exit_code": 0,
})
assert len(rendered) < MAX_OUTPUT_CHARS + 100
@pytest.mark.parametrize("body", ["", "short document"])
@pytest.mark.parametrize("offset", [0, 100])
def test_short_or_exhausted_read_has_no_continuation(documents, body, offset):
with database.SessionLocal() as db:
db.query(database.Document).filter_by(id="owned-document").first().current_content = body
db.commit()
result = asyncio.run(TOOL_HANDLERS["manage_documents"](json.dumps({
"action": "read", "document_id": "owned-document", "offset": offset,
}), {"owner": "fixture-owner"}))
assert result["document"]["content"] == body[offset:]
assert result["document"]["next_offset"] is None
assert not result["document"]["truncated"]
assert format_tool_result("manage_documents: read", result) == result["response"]
def test_oversized_header_fails_without_a_nonadvancing_page(documents):
with database.SessionLocal() as db:
db.query(database.Document).filter_by(id="owned-document").first().title = "x" * MAX_OUTPUT_CHARS
db.commit()
result = asyncio.run(TOOL_HANDLERS["manage_documents"](
'{"action":"read","document_id":"owned-document"}', {"owner": "fixture-owner"}))
assert result["exit_code"] == 1
assert "too long" in result["error"]
+10
View File
@@ -344,3 +344,13 @@ def test_hardlinked_effect_state_is_control_plane_without_scanning_the_store(tmp
elsewhere.write_text("y")
os.link(elsewhere, workspace / "pnpm-style.txt")
assert resources._control_plane_path(str(workspace / "pnpm-style.txt")) is True
def test_consecutive_claims_write_lf_only_records(tmp_path, target):
# Raw os.open() is text-mode on Windows unless O_BINARY is set; the \r\n it
# wrote made the next append read a stray empty line as a corrupt tail.
log = EffectLog(RUN, directory=tmp_path / "fx")
claim(log, target, "e1", "act-1")
claim(log, target, "e2", "act-2")
assert b"\r" not in log.path.read_bytes()
assert len(records(log.path)) == 2
+80
View File
@@ -0,0 +1,80 @@
"""MIME alternatives are selected independently of part order and codec labels."""
from email import message_from_bytes
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
import pytest
from mcp_servers.email_server import _extract_text as mcp_text
from routes.email_helpers import _extract_html, _extract_text as route_text
def alternative(order, plain='Body text', html='<p>Body text</p>'):
msg = MIMEMultipart('alternative')
parts = {'plain': MIMEText(plain, 'plain', 'utf-8'), 'html': MIMEText(html, 'html', 'utf-8')}
for kind in order:
msg.attach(parts[kind])
return message_from_bytes(msg.as_bytes())
@pytest.mark.parametrize('extract', [route_text, mcp_text], ids=['route', 'mcp'])
@pytest.mark.parametrize('order', [('html', 'plain'), ('plain', 'html')])
def test_plain_alternative_wins_in_either_order(extract, order):
assert extract(alternative(order)) == 'Body text'
@pytest.mark.parametrize('extract', [route_text, mcp_text], ids=['route', 'mcp'])
def test_empty_plain_allows_html_fallback(extract):
assert extract(alternative(('plain', 'html'), plain='', html='Hello<br>world &amp; all')) == 'Hello\nworld & all'
@pytest.mark.parametrize('extract', [route_text, mcp_text], ids=['route', 'mcp'])
def test_text_attachments_are_not_body_alternatives(extract):
msg = MIMEMultipart('mixed')
attachment = MIMEText('Attachment text', 'plain')
attachment.add_header('Content-Disposition', 'attachment', filename='note.txt')
msg.attach(attachment)
msg.attach(MIMEText('<p>Body text</p>', 'html'))
assert extract(msg) == 'Body text'
@pytest.mark.parametrize('extract', [route_text, mcp_text], ids=['route', 'mcp'])
def test_multiple_plain_body_parts_remain_joined(extract):
msg = MIMEMultipart('mixed')
msg.attach(MIMEText('Part one', 'plain'))
msg.attach(MIMEText('Part two', 'plain'))
assert extract(msg) == 'Part one\nPart two'
@pytest.mark.parametrize('extract', [route_text, mcp_text], ids=['route', 'mcp'])
@pytest.mark.parametrize('kind', ['plain', 'html'])
@pytest.mark.parametrize('multipart', [False, True])
def test_unknown_charset_falls_back_to_utf8(extract, kind, multipart):
body = 'Hello café' if kind == 'plain' else '<p>Hello café</p>'
raw = f'Content-Type: text/{kind}; charset=x-unknown\r\nContent-Transfer-Encoding: 8bit\r\n\r\n'.encode()+body.encode()
part = message_from_bytes(raw)
if multipart:
msg = MIMEMultipart('mixed');msg.attach(part)
else:
msg = part
assert extract(msg) == 'Hello café'
@pytest.mark.parametrize('extract', [route_text, mcp_text], ids=['route', 'mcp'])
def test_declared_latin1_is_preserved(extract):
assert extract(MIMEText('café', 'plain', 'iso-8859-1')) == 'café'
@pytest.mark.parametrize('multipart', [False, True])
def test_raw_html_unknown_charset_is_readable(multipart):
part = message_from_bytes(b'Content-Type: text/html; charset=x-unknown\r\n\r\n<p>Hello caf\xc3\xa9</p>')
if multipart:
msg=MIMEMultipart('mixed');msg.attach(part)
else:msg=part
assert _extract_html(msg) == '<p>Hello café</p>'
@pytest.mark.parametrize('extract', [route_text, mcp_text], ids=['route', 'mcp'])
def test_invalid_bytes_are_replaced_in_unknown_charset(extract):
msg=message_from_bytes(b'Content-Type: text/plain; charset=x-unknown\r\n\r\nHello\xff')
assert extract(msg) == 'Hello\ufffd'
+14
View File
@@ -162,6 +162,20 @@ def test_gallery_body_uses_available_window_height_without_grid_crop():
assert "max-height: 60vh;" not in grid_rule
def test_gallery_detail_container_drops_grid_margin_so_body_does_not_scroll():
# With a photo open on desktop, the modal body, the images container and
# the detail view are each `height: 100%`. The container's inline 2px top
# margin (grid-view spacing) would then overflow the body by 2px, which
# shows a second, outer scrollbar next to the inspector's own.
js = _read("static/js/gallery.js")
css = app_css()
assert 'id="gallery-images-container" style="margin-top:2px"' in js
rule = '.gallery-images-container:has(> #gallery-detail[style*="flex"]) {'
idx = css.index(rule)
assert "margin-top: 0 !important;" in css[idx:idx + 120]
def test_gallery_photo_search_is_not_vertically_offset():
css = app_css()
@@ -0,0 +1,62 @@
import asyncio
import pytest
from fastapi import HTTPException
from tests.helpers.database import isolated_session_database
@pytest.fixture
def manager_database(tmp_path):
with isolated_session_database(tmp_path) as resources:
yield resources
def add_message(sm, monkeypatch, body):
import routes.history.history_routes as history
monkeypatch.setattr(history, '_verify_session_owner', lambda *args, **kwargs: None)
monkeypatch.setattr(history, 'effective_user', lambda request: 'u')
monkeypatch.setattr(history, 'reserve_message_upload_references', lambda *args, **kwargs: None)
route = next(r for r in history.setup_history_routes(sm).routes
if r.path == '/api/session/{session_id}/message')
class Request:
async def json(self):
return body
return asyncio.run(route.endpoint(Request(), 'sid'))
@pytest.mark.parametrize('body', [
{'role': 'bogus', 'content': 'x'},
{'role': 'system', 'content': 'x'},
{'role': 'user', 'content': 123},
{'content': ['a']},
{'role': ['user'], 'content': 'x'},
[1],
])
def test_bad_role_or_content_is_a_client_error(manager_database, monkeypatch, body):
sm, _ = manager_database
sm.create_session(session_id='sid', name='t', endpoint_url='x', model='m', rag=False, owner='u')
with pytest.raises(HTTPException) as exc:
add_message(sm, monkeypatch, body)
assert exc.value.status_code == 400
assert sm.get_session('sid').history == []
@pytest.mark.parametrize('body, role', [
({'role': 'user', 'content': 'hi'}, 'user'),
({'role': 'assistant', 'content': 'hello'}, 'assistant'),
({'content': 'default role'}, 'assistant'),
])
def test_user_and_assistant_text_are_stored(manager_database, monkeypatch, body, role):
sm, _ = manager_database
sm.create_session(session_id='sid', name='t', endpoint_url='x', model='m', rag=False, owner='u')
assert add_message(sm, monkeypatch, body) == {'status': 'ok'}
stored = sm.get_session('sid').history
assert [(m.role, m.content) for m in stored] == [(role, body['content'])]
+87
View File
@@ -0,0 +1,87 @@
"""CPU-only hardware results and SSH probe context agree with GPU paths."""
import json
import pytest
from services.hwfit import hardware
WINDOWS_PROBE = hardware._detect_windows
@pytest.fixture(autouse=True)
def probe(monkeypatch):
monkeypatch.setattr(hardware, "_cache_by_host", {})
for name in ("_remote_host", "_remote_port", "_remote_platform", "_last_gpu_error"):
monkeypatch.setattr(hardware, name, None)
monkeypatch.setattr(hardware, "_get_ram_gb", lambda: 64.0)
monkeypatch.setattr(hardware, "_get_available_ram_gb", lambda: 48.0)
monkeypatch.setattr(hardware, "_get_cpu_count", lambda: 16)
monkeypatch.setattr(hardware, "_get_cpu_name", lambda: "Test CPU")
monkeypatch.setattr(hardware, "_get_cpu_arch", lambda: "x86_64")
monkeypatch.setattr(hardware, "_is_containerized", lambda: False)
for name in ("_detect_windows", "_detect_apple_silicon", "_detect_nvidia", "_detect_amd"):
monkeypatch.setattr(hardware, name, lambda: None)
@pytest.mark.parametrize("arch,backend", [("x86_64", "cpu_x86"), ("arm64", "cpu_arm")])
@pytest.mark.parametrize("remote", [False, True])
def test_cpu_only_result_has_empty_gpu_inventory(monkeypatch, arch, backend, remote):
monkeypatch.setattr(hardware, "_get_cpu_arch", lambda: arch)
options = {"host": "user@cpu-fixture", "ssh_port": "2222", "platform": "linux"} if remote else {}
result = hardware.detect_system(**options)
assert result["has_gpu"] is False
assert result["gpu_count"] == 0
assert result["gpus"] == []
assert result["gpu_groups"] == []
assert result["homogeneous"] is True
assert result["backend"] == backend
assert result["cpu_arch"] == arch
assert hardware.detect_system(**options) == result
def test_windows_cpu_only_result_has_empty_gpu_inventory(monkeypatch):
monkeypatch.setattr(hardware, "_detect_windows", WINDOWS_PROBE)
monkeypatch.setattr(hardware, "_run", lambda _command: json.dumps({
"ram_gb": 64.0, "avail_gb": 48.0, "cpu_cores": 16,
"cpu_name": "Test CPU", "cpu_arch": "AMD64", "gpu_count": 0,
}))
result = hardware.detect_system(host="user@windows-fixture", ssh_port="2222", platform="windows")
assert result["has_gpu"] is False
assert result["gpus"] == []
assert result["gpu_groups"] == []
assert result["homogeneous"] is True
@pytest.mark.parametrize("path", ["cpu", "gpu", "windows", "windows_failure", "connection_failure"])
def test_remote_probe_clears_all_connection_context(monkeypatch, path):
gpu = {
"gpu_name": "Test GPU", "gpu_vram_gb": 16.0, "gpu_count": 1,
"gpus": [{"index": 0, "name": "Test GPU", "vram_gb": 16.0}],
"gpu_groups": [{"name": "Test GPU", "count": 1}],
"homogeneous": True, "backend": "cuda",
}
if path == "gpu":
monkeypatch.setattr(hardware, "_detect_nvidia", lambda: gpu)
elif path == "windows":
monkeypatch.setattr(hardware, "_detect_windows", lambda: {"has_gpu": False, "backend": "cpu_x86"})
elif path == "connection_failure":
monkeypatch.setattr(hardware, "_get_ram_gb", lambda: 0.0)
result = hardware.detect_system(
host="user@hardware-fixture", ssh_port="2222",
platform="windows" if path.startswith("windows") else "linux", fresh=True,
)
if path in {"windows_failure", "connection_failure"}:
assert result["error"]
elif path == "gpu":
assert result["gpus"] == gpu["gpus"]
assert result["gpu_groups"] == gpu["gpu_groups"]
assert result["backend"] == "cuda"
else:
assert result["has_gpu"] is False
assert (hardware._remote_host, hardware._remote_port, hardware._remote_platform) == (None, None, None)
@@ -87,6 +87,58 @@ def test_llm_call_content_wins_over_reasoning_content(monkeypatch):
assert result == "Normal answer"
# ---------------------------------------------------------------------------
# Thinking off: reasoning never stands in for the answer
# ---------------------------------------------------------------------------
def test_llm_call_with_thinking_off_returns_empty_instead_of_reasoning(monkeypatch):
monkeypatch.setattr(
llm_core.httpx, "post",
lambda *a, **kw: _sync_response(_openai_msg("", "* User message: ...")),
)
result = llm_core.llm_call(
"http://test/v1", "qwen3-8b",
[{"role": "user", "content": "extract"}],
thinking_mode="off",
)
assert result == ""
def test_llm_call_async_with_thinking_off_returns_empty_instead_of_reasoning(monkeypatch):
class _FakeAsyncClient:
async def post(self, *a, **kw):
req = httpx.Request("POST", "http://test-async/v1/chat/completions")
return httpx.Response(200, request=req,
json=_openai_msg("", "* User message: ..."))
monkeypatch.setattr(llm_core, "_get_http_client",
lambda: _FakeAsyncClient())
result = asyncio.run(llm_core.llm_call_async(
"http://test-async/v1", "qwen3-8b",
[{"role": "user", "content": "extract"}],
thinking_mode="off",
))
assert result == ""
def test_llm_call_with_thinking_off_drops_mistral_structured_thinking(monkeypatch):
content = [
{"type": "thinking", "thinking": [{"type": "text", "text": "reasoning"}]},
{"type": "text", "text": "answer"},
]
monkeypatch.setattr(
llm_core.httpx, "post",
lambda *a, **kw: _sync_response(_openai_msg(content)),
)
result = llm_core.llm_call(
"http://test/v1", "some-model",
[{"role": "user", "content": "extract"}],
thinking_mode="off",
)
assert result == "answer"
# ---------------------------------------------------------------------------
# Streaming agent path tests (4 and 5)
# These import and test _empty_response_fallback — the real production helper
@@ -94,21 +146,7 @@ def test_llm_call_content_wins_over_reasoning_content(monkeypatch):
# changed, these tests will fail.
# ---------------------------------------------------------------------------
import sys
from unittest.mock import MagicMock
# Mock heavy DB/tool deps before importing agent_loop
for _mod in [
"sqlalchemy", "sqlalchemy.orm", "sqlalchemy.ext",
"sqlalchemy.ext.declarative", "sqlalchemy.ext.hybrid",
"sqlalchemy.sql", "sqlalchemy.sql.expression",
"src.database", "src.agent_tools",
"core.models", "core.database",
]:
if _mod not in sys.modules:
sys.modules[_mod] = MagicMock()
from src.agent_loop import _empty_response_fallback # noqa: E402
from src.agent_loop import _empty_response_fallback
# ---------------------------------------------------------------------------
+52
View File
@@ -0,0 +1,52 @@
"""Native memory ranking must not promote names on substring/first-person guesses."""
import pytest
from src.memory import MemoryManager
@pytest.fixture
def manager(tmp_path):
return MemoryManager(str(tmp_path))
@pytest.mark.parametrize('query', ['bitcoin price', 'list tasks', 'time forecast', 'summary'])
def test_unrelated_query_does_not_force_name_into_results(manager, query):
name = {'id':'name', 'text':'My name is Rowan Example'}
match = {'id':'match', 'text':query}
assert manager.get_relevant_memories(query, [name, match]) == [match]
@pytest.mark.parametrize('query, text', [
('what is my email', 'email is contact@example.test'),
('I like jazz', 'I like jazz music'),
('remind me about dentist', 'dentist appointment reminder'),
])
def test_first_person_words_do_not_override_the_actual_topic(manager, query, text):
name = {'id':'name', 'text':'My name is Rowan Example'}
topic = {'id':'topic', 'text':text}
assert manager.get_relevant_memories(query, [name, topic], max_items=1) == [topic]
@pytest.mark.parametrize('query', ['Who am I?', 'what am I called', 'my name', 'NAME:'])
def test_explicit_identity_query_still_finds_name(manager, query):
name = {'id':'name', 'text':'My name is Rowan Example'}
assert manager.get_relevant_memories(query, [name]) == [name]
def test_closer_match_outranks_identity_signal_and_threshold_still_applies(manager):
name = {'id':'name', 'text':'My name is Rowan Example'}
project = {'id':'project', 'text':'the project name is Polaris'}
assert manager.get_relevant_memories('what is the project name', [name, project], max_items=1) == [project]
assert manager.get_relevant_memories('who am I', [name], threshold=0.7) == []
def test_name_is_still_searchable_by_its_actual_text_without_a_type_keyword(manager):
name = {'id':'name', 'text':'My name is Rowan Example'}
assert manager.get_relevant_memories('Rowan', [name]) == [name]
def test_empty_queries_and_limits_keep_their_contract(manager):
memories = [{'text':'tea preference'}, {'text':'tea recipe'}]
assert manager.get_relevant_memories(' ', memories) == []
assert manager.get_relevant_memories('tea', []) == []
assert len(manager.get_relevant_memories('tea', memories, max_items=1)) == 1
+126
View File
@@ -0,0 +1,126 @@
"""Real ESM/DOM regressions for Settings and Theme close/restore races."""
import json
import subprocess
import pytest
from tests.helpers.stylesheets import stylesheet_link_tags
@pytest.mark.parametrize('modal_id', ['settings-modal', 'theme-modal'])
@pytest.mark.parametrize('width', [1440, 390])
def test_modal_reopen_after_dock_close_and_pending_animation(modal_id, width):
script = r"""
import { chromium } from 'playwright';
const browser = await chromium.launch({headless:true});
try {
const page = await browser.newPage({viewport:{width:WIDTH,height:1000}});
await page.route('**/api/**', route => route.fulfill({json:{value:null}}));
await page.goto(process.env.ODYSSEUS_TEST_STATIC_ORIGIN+'/static/js/storage.js');
await page.setContent('__STYLES__<div id="sidebar"></div>');
const result = await page.evaluate(async id => {
const html = await (await fetch('/static/index.html')).text();
const source = new DOMParser().parseFromString(html,'text/html');
document.body.append(source.getElementById(id));
const modal = document.getElementById(id);
const content = modal.querySelector('.modal-content');
const manager = await import('/static/js/modalManager.js');
let open, close;
if(id === 'settings-modal') {
const lifecycle = await import('/static/js/settings/lifecycle.js');
open = () => lifecycle.showSettingsModal(modal);
close = () => lifecycle.hideSettingsModal(modal);
} else {
const theme = await import('/static/js/theme.js');
// The old sidebar simply removed hidden; the public opener now
// also cancels a pending close and restores manager state.
open = theme.openPopup || (() => modal.classList.remove('hidden'));
close = () => theme.closePopup();
}
const visible = () => getComputedStyle(modal).display !== 'none';
const wait = () => new Promise(resolve=>setTimeout(resolve,350));
const states = {};
if (id === 'theme-modal') {
const theme = await import('/static/js/theme.js');
theme.togglePopup();
theme.togglePopup();
theme.togglePopup();
await wait();
states.fastToggleReopens = visible();
close();
await wait();
}
open();
manager.register(id,{closeFn:close,restoreFn:()=>{}});
manager.minimize(id);
manager.close(id);
await wait();
open();
await wait();
states.afterDockClose = visible();
// An opening/descendant animation must not reach an orphan closer.
content.dispatchEvent(new AnimationEvent('animationend',{bubbles:true,animationName:'modal-enter'}));
states.afterOpeningAnimation = visible();
// Begin a visible close, then reopen before its fallback expires.
close();
open();
await wait();
states.afterFastReopen = visible();
manager.register(id,{closeFn:close,restoreFn:()=>{}});
manager.minimize(id);
open();
states.directOpenRestoresMinimized = visible() && !manager.isMinimized(id);
// A child animation must not terminate a normal parent close.
close();
content.querySelector('button').dispatchEvent(new AnimationEvent('animationend',{bubbles:true}));
states.childAnimationDoesNotClose = !modal.classList.contains('hidden');
content.dispatchEvent(new AnimationEvent('animationend',{bubbles:true,animationName:'modal-exit'}));
states.normalCloseCompletes = modal.classList.contains('hidden') && !content.classList.contains('modal-closing');
// Completing by animation must also remove the timer.
open();
await wait();
states.reopenAfterCompletedAnimation = visible();
close();
await wait();
states.fallbackCompletes = modal.classList.contains('hidden') && !content.classList.contains('modal-closing');
return states;
}, MODAL_ID);
console.log(JSON.stringify(result));
} finally { await browser.close(); }
"""
script = script.replace('WIDTH', str(width)).replace('MODAL_ID', json.dumps(modal_id))
script = script.replace('__STYLES__', stylesheet_link_tags())
result = subprocess.run(['node','--input-type=module','-e',script], capture_output=True, text=True, timeout=60)
assert result.returncode == 0, result.stdout + result.stderr
states = json.loads(result.stdout.strip())
assert all(states.values()), states
def test_modal_visibility_is_available_offline_before_first_open():
script = r"""
import assert from 'node:assert/strict';
import { chromium } from 'playwright';
const browser = await chromium.launch({headless:true});
try {
const context = await browser.newContext({serviceWorkers:'allow'});
const page = await context.newPage();
const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN;
await page.goto(origin+'/static/js/storage.js');
await page.evaluate(async () => {
await navigator.serviceWorker.register('/static/sw.js');
await navigator.serviceWorker.ready;
if(!navigator.serviceWorker.controller) await new Promise(resolve =>
navigator.serviceWorker.addEventListener('controllerchange',resolve,{once:true}));
const response = await caches.match('/static/js/modalVisibility.js');
if(!response?.ok) throw new Error('Modal helper is missing from install-time precache');
});
await context.setOffline(true);
const offline = await context.newPage();
await offline.goto(origin+'/static/js/storage.js');
const exports = await offline.evaluate(async () =>
Object.keys(await import('/static/js/modalVisibility.js')).sort());
assert.deepEqual(exports,['hideModal','showModal']);
} finally { await browser.close(); }
"""
result = subprocess.run(['node','--input-type=module','-e',script], capture_output=True, text=True, timeout=90)
assert result.returncode == 0, result.stdout + result.stderr
@@ -0,0 +1,81 @@
"""An automatic fallback must not prevent resolving the configured default."""
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]
PICKER = (ROOT / 'static/js/modelPicker.js').read_text()
@pytest.mark.skipif(not shutil.which('node'), reason='node not installed')
@pytest.mark.parametrize('case', ['fallback', 'manual', 'default', 'session', 'manual-during-fetch', 'no-default'])
def test_configured_default_and_selection_ownership(case):
source = '\n'.join(js_function_source(name, PICKER) for name in [
'_ensureDefaultPendingChat', '_ensureModelCacheForFallback', '_firstAvailableModel',
])
script = r'''
const source = JSON.parse(process.argv[1]), mode = process.argv[2];
let pending = {url:'http://models/v1/chat/completions',modelId:'first',endpointId:'shared',source:'fallback'};
if (mode === 'manual' || mode === 'default') pending.source = mode;
const initial = {...pending};
const configured = {endpoint_url:pending.url,model:'preferred',endpoint_id:'shared'};
let fetches = 0, updates = 0;
const _deps = {getCurrentSessionId:()=> mode === 'session' ? 'existing' : null,
getPendingChat:()=>pending,setPendingChat:value=>{pending=value;}};
const window = {modelsModule:{getCachedItems:()=>[{url:initial.url,models:['first','preferred'],endpoint_id:'shared'}]}};
const localStorage = {setItem(){}};
const API_BASE = 'http://app';
let _defaultChatPickInFlight = false, _defaultPendingSeq = 0;
function updateModelPicker(){updates++;}
async function fetch(){fetches++; if(mode==='manual-during-fetch') pending={...initial,modelId:'chosen',source:'manual'};
return {ok:true,json:async()=>mode==='no-default'?{}:configured};}
// All helpers share the same lexical dependencies as the production function.
eval(source.replace('async function _ensureDefaultPendingChat','globalThis.ensureDefault = async function'));
await globalThis.ensureDefault();
console.log(JSON.stringify({pending,fetches,updates,inFlight:_defaultChatPickInFlight}));
'''
# Script is a module so async fetch ordering is exercised without a browser shim.
result = subprocess.run(['node', '--input-type=module', '-e', script, json.dumps(source), case],
cwd=ROOT, capture_output=True, text=True, check=True, timeout=15)
data = json.loads(result.stdout)
assert data['inFlight'] is False
if case == 'fallback':
assert data['pending']['modelId'] == 'preferred'
assert data['pending']['source'] == 'default'
assert data['fetches'] == 1
elif case == 'manual-during-fetch':
assert data['pending']['modelId'] == 'chosen'
assert data['pending']['source'] == 'manual'
else:
assert data['pending']['modelId'] == 'first'
assert data['pending']['source'] == (case if case in {'manual', 'default'} else 'fallback')
assert data['fetches'] == (1 if case == 'no-default' else 0)
@pytest.mark.skipif(not shutil.which('node'), reason='node not installed')
def test_cached_default_updates_visible_picker_after_fallback():
names = ['_ensureDefaultPendingChat', '_ensureModelCacheForFallback', '_firstAvailableModel', '_modelExists', 'updateModelPicker']
source = '\n'.join(js_function_source(name, PICKER).removeprefix('export ') for name in names)
script = r'''
let pending={url:'http://models/v1/chat/completions',modelId:'first',endpointId:'shared',source:'fallback'};
const _deps={getCurrentSessionId:()=>null,getSessions:()=>[],getPendingChat:()=>pending,setPendingChat:value=>{pending=value;}};
const label={textContent:''};
const document={getElementById:id=>id==='model-picker-label'?label:null};
const window={__odysseusDefaultChat:{endpoint_url:pending.url,endpoint_id:'shared',model:'preferred'},
modelsModule:{getCachedItems:()=>[{url:pending.url,endpoint_id:'shared',models:['first','preferred']}]}};
const localStorage={setItem(){}};
let _defaultChatPickInFlight=false,_defaultPendingSeq=0,_autoSelectingDefault=false;
function providerLogo(){return null;}function providerLabel(){return 'Fixture';}function _updateReasoningEffortUI(){}
eval(JSON.parse(process.argv[1])+'\nupdateModelPicker();');
console.log(JSON.stringify({pending,label:label.textContent}));
'''
result = subprocess.run(['node', '--input-type=module', '-e', script, json.dumps(source)],
cwd=ROOT, capture_output=True, text=True, check=True, timeout=15)
data = json.loads(result.stdout)
assert data['pending']['modelId'] == 'preferred'
assert 'preferred' in data['label']
+20
View File
@@ -232,6 +232,13 @@ class TestMatchProviderCurated:
def test_kimi_code_url(self):
assert _match_provider_curated("https://api.kimi.com/coding/v1", "openai") == "kimi-code"
def test_conifer_url(self):
assert _match_provider_curated("https://api.conifer.build/v1", "openai") == "openrouter"
assert _match_provider_curated("https://conifer.build/v1", "openai") == "openrouter"
def test_conifer_lookalike_url_not_matched(self):
assert _match_provider_curated("https://conifer.build.attacker.example/v1", "openai") == "openai"
def test_no_url_match_returns_provider(self):
assert _match_provider_curated("https://localhost:1234", "openai") == "openai"
@@ -339,6 +346,19 @@ class TestCurateModels:
assert curated == models
assert extra == []
def test_openrouter_sentinel_returns_all_as_curated(self):
models = ["model-a", "model-b", "any-custom-model"]
curated, extra = _curate_models(models, "openrouter")
assert curated == models
assert extra == []
def test_conifer_curate_models_via_match(self):
models = ["conifer-model-1", "conifer-model-2"]
curated_key = _match_provider_curated("https://api.conifer.build/v1", "openai")
curated, extra = _curate_models(models, curated_key)
assert curated == models
assert extra == []
def test_kimi_code_partitions(self):
models = ["kimi-for-coding", "other-model"]
curated, extra = _curate_models(models, "kimi-code")
+1
View File
@@ -45,6 +45,7 @@ class TestDetectProvider:
("https://api.openai.com/v1", "openai"),
("https://api.x.ai/v1", "openai"),
("https://api.deepseek.com", "openai"),
("https://api.conifer.build/v1", "openai"),
("https://generativelanguage.googleapis.com/v1beta/openai", "openai"),
# Ollama's OpenAI-compatible /v1 surface is generic, not native ollama.
("http://localhost:11434/v1", "openai"),
+11
View File
@@ -52,3 +52,14 @@ def _provider_label(url: str) -> str | None:
])
def test_provider_label_neutral_for_loopback(url, expected):
assert _provider_label(url) == expected
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
@pytest.mark.parametrize("url,expected", [
("https://api.conifer.build/v1", "Conifer"),
("https://conifer.build/v1", "Conifer"),
("https://API.CONIFER.BUILD/v1", "Conifer"),
("https://conifer.build.attacker.example/v1", "conifer.build.attacker.example"),
])
def test_provider_label_conifer(url, expected):
assert _provider_label(url) == expected
@@ -0,0 +1,103 @@
"""History controllers must not dismiss reports the server failed to delete."""
import json
from pathlib import Path
import shutil
import subprocess
import pytest
ROOT = Path(__file__).resolve().parents[1]
def _run(outcomes, *, bulk=False, clear=False, statuses=None):
if not shutil.which("node"):
pytest.skip("node is required to execute research controllers")
script = r"""
const fs = require('fs'), vm = require('vm');
const options = JSON.parse(process.argv[1]);
const saved = new Map();
const errors = [], calls = [];
const context = {
localStorage: {getItem:k=>saved.get(k)||null, setItem:(k,v)=>saved.set(k,v)},
document: {getElementById:()=>null},
uiModule: {showError:message=>errors.push(message)},
window: {styledConfirm:async()=>true},
fetch: async (url, opts) => {
const id = url.split('/').pop();calls.push({id, method:opts.method, credentials:opts.credentials});
const outcome = options.outcomes[id] ?? 200;
if (outcome === 'network') throw new Error('scripted network failure');
return {ok:outcome>=200 && outcome<300, status:outcome};
},
};
vm.createContext(context);
const jobs = fs.readFileSync('static/js/research/jobs.js','utf8');
vm.runInContext(jobs.replace(/export /g, ''), context);
vm.runInContext('globalThis.jobs = {getJobs,removeJob};', context);
context.seed = Object.entries(options.statuses || {a:'done',b:'done',untouched:'done'}).map(([id,status])=>({id,status}));
vm.runInContext('getJobs().push(...seed);', context);
const panel = fs.readFileSync('static/js/research/panel.js','utf8');
const start = panel.indexOf('function _updateHistoryBulkBar(');
const end = panel.indexOf('function _renderJobs(', start);
if (start < 0 || end < 0) throw new Error('history controller boundaries missing');
vm.runInContext("_apiBase=''; let _historySelectMode=true; let _historySelectedIds=new Set(['a','b']); let _visibleHistoryIds=['a','b']; function _renderJobs(){};"+panel.slice(start,end), context);
(async()=>{
const command = options.clear ? '_clearHistory()' : options.bulk ? '_bulkDeleteHistory()' : "_deleteHistoryJobs(['a','b'])";
await vm.runInContext(command, context);
console.log(JSON.stringify({
ids:vm.runInContext('getJobs().map(j=>j.id)',context),
selected:vm.runInContext('[..._historySelectedIds]',context),
selectionMode:vm.runInContext('_historySelectMode',context),
dismissed:JSON.parse(saved.get('odysseus-research-dismissed')||'[]'),
errors,calls,
}));
})().catch(e=>{console.error(e);process.exitCode=1});
"""
args = dict(outcomes=outcomes, bulk=bulk, clear=clear, statuses=statuses)
result = subprocess.run(["node", "-e", script, json.dumps(args)], cwd=ROOT,
capture_output=True, text=True, timeout=30)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
@pytest.mark.parametrize("failure", [403, 404, 500, 503, "network"])
def test_failed_reports_remain_visible_and_undismissed(failure):
result = _run({"a": failure, "b": failure})
assert result["ids"] == ["a", "b", "untouched"]
assert result["dismissed"] == []
assert len(result["errors"]) == 1
assert "2" in result["errors"][0]
assert all(call["method"] == "DELETE" and call["credentials"] == "same-origin"
for call in result["calls"])
@pytest.mark.parametrize("failure", [500, "network"])
def test_mixed_bulk_deletion_keeps_only_failed_selection_for_retry(failure):
result = _run({"a": 200, "b": failure}, bulk=True)
assert result["ids"] == ["b", "untouched"]
assert result["dismissed"] == ["a"]
assert result["selectionMode"] is True
assert result["selected"] == ["b"]
assert len(result["errors"]) == 1
def test_successful_bulk_deletion_exits_selection():
result = _run({"a": 200, "b": 200}, bulk=True)
assert result["ids"] == ["untouched"]
assert result["selectionMode"] is False
assert result["selected"] == []
assert result["dismissed"] == ["a", "b"]
assert result["errors"] == []
def test_local_failed_job_removal_needs_no_server_request():
result = _run({"a": 500}, statuses={"a": "error", "b": "cancelled"})
assert result["ids"] == []
assert result["calls"] == []
assert result["errors"] == []
def test_clear_history_preserves_running_jobs_and_failed_deletions():
result = _run({"a": 500}, clear=True, statuses={"a": "done", "b": "running"})
assert result["ids"] == ["a", "b"]
assert result["dismissed"] == []
assert [call["id"] for call in result["calls"]] == ["a"]
+60
View File
@@ -3,6 +3,7 @@
import asyncio
import json
from types import SimpleNamespace
from fastapi import HTTPException
import pytest
@@ -117,3 +118,62 @@ def test_manage_settings_tombstones_legacy_fallback_key(monkeypatch):
assert save_calls == []
assert store["default_model_fallbacks"] == LEGACY_VALUE
@pytest.mark.asyncio
async def test_tool_approval_mode_accepts_only_supported_values(monkeypatch):
store = dict(settings_mod.DEFAULT_SETTINGS)
monkeypatch.setattr(auth_routes, "migrate_from_settings", lambda: None)
monkeypatch.setattr(auth_routes, "_load_settings", lambda: dict(store))
def save_settings(updated):
store.clear()
store.update(updated)
monkeypatch.setattr(auth_routes, "_save_settings", save_settings)
router = auth_routes.setup_auth_routes(_AuthManager())
set_settings = _route(router, "/api/auth/settings", "POST")
response = await set_settings(
_Request({"tool_approval_mode": "off"}, admin=True)
)
assert response["tool_approval_mode"] == "off"
assert store["tool_approval_mode"] == "off"
response = await set_settings(
_Request({"tool_approval_mode": "ask"}, admin=True)
)
assert response["tool_approval_mode"] == "ask"
assert store["tool_approval_mode"] == "ask"
with pytest.raises(HTTPException) as exc_info:
await set_settings(
_Request({"tool_approval_mode": "banana"}, admin=True)
)
assert exc_info.value.status_code == 400
@pytest.mark.asyncio
async def test_settings_expose_gate_env_kill_switch_to_admins_only(monkeypatch):
store = dict(settings_mod.DEFAULT_SETTINGS)
monkeypatch.setattr(auth_routes, "migrate_from_settings", lambda: None)
monkeypatch.setattr(auth_routes, "_load_settings", lambda: dict(store))
router = auth_routes.setup_auth_routes(_AuthManager())
get_settings = _route(router, "/api/auth/settings", "GET")
monkeypatch.setattr(auth_routes.tool_capabilities, "TOOL_APPROVAL_GATE_ENABLED", True)
response = await get_settings(_Request(admin=True))
assert response["tool_approval_gate_env_disabled"] is False
monkeypatch.setattr(auth_routes.tool_capabilities, "TOOL_APPROVAL_GATE_ENABLED", False)
response = await get_settings(_Request(admin=True))
assert response["tool_approval_gate_env_disabled"] is True
# The stored setting itself is untouched by the env kill-switch.
assert response["tool_approval_mode"] == "ask"
# Unauthenticated/scrubbed callers must not learn the deployment gate state.
response = await get_settings(_Request())
assert "tool_approval_gate_env_disabled" not in response
+42
View File
@@ -0,0 +1,42 @@
"""set_privileges rejects mistyped values instead of storing them (issue #6829)."""
import pytest
from tests.test_set_admin import _fresh_auth_manager
@pytest.mark.parametrize("privs", [
{"can_use_bash": "no"},
{"can_use_bash": 1},
{"max_messages_per_day": "abc"},
{"max_messages_per_day": True},
{"allowed_models": "gpt"},
{"block_all_models": None},
])
def test_set_privileges_rejects_mistyped_values(tmp_path, privs):
_, mgr = _fresh_auth_manager(tmp_path)
mgr.create_user("bob", "pw-123456")
before = dict(mgr.get_privileges("bob"))
with pytest.raises(ValueError):
mgr.set_privileges("bob", privs)
assert mgr.get_privileges("bob") == before
def test_set_privileges_accepts_well_typed_values(tmp_path):
_, mgr = _fresh_auth_manager(tmp_path)
mgr.create_user("bob", "pw-123456")
assert mgr.set_privileges("bob", {
"can_use_bash": True,
"max_messages_per_day": 25,
"allowed_models": ["gpt"],
"unknown_key": "ignored",
}) is True
privs = mgr.get_privileges("bob")
assert privs["can_use_bash"] is True
assert privs["max_messages_per_day"] == 25
assert privs["allowed_models"] == ["gpt"]
assert "unknown_key" not in privs
+112
View File
@@ -0,0 +1,112 @@
"""The real Skills module must leave test results before opening its editor."""
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", ["done", "running", "source"])
def test_skill_editor_transitions(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(4000);
const source='---\nname: fixture-editor\nversion: "1.0"\n---\n\n## Procedure\nOriginal source';
let markdown=source,posts=0,statusCalls=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-editor',description:'Editor fixture',status:'draft',source:'user',confidence:0.9,procedure:['Original source']}]};
else if(path.endsWith('/markdown')){
if(request.method()==='POST'){posts++;markdown=request.postDataJSON().markdown;data={ok:true};}
else data={markdown};
}else if(path.endsWith('/test-status')){
statusCalls++;
const status=mode==='running'&&statusCalls===1?'running':'done';
data={status,log:[{type:'say',text:'Completed fixture output'}],verdict:status==='done'?{verdict:'pass',confidence:0.95,summary:'Fixture verdict',issues:[]}:null};
}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-editor"]');
await card.locator('.skill-card-name').click();
if(mode!=='source'){
await card.getByRole('button',{name:'Test',exact:true}).click();
if(mode==='running'){
await card.getByText('…running (you can close this — it keeps going)',{exact:true}).waitFor();
await card.locator('.skill-chevron-up').click();
await card.locator('.skill-kebab-btn').click();
await page.locator('.skill-kebab-menu:visible').getByRole('button',{name:'Edit',exact:true}).click();
}else{
await card.getByText('Fixture verdict',{exact:true}).waitFor();
await card.locator('[data-act="edit"]').click();
}
}else await card.getByRole('button',{name:'Edit',exact:true}).click();
const editor=card.locator('.skill-md-editor');await editor.waitFor({state:'visible'});
assert.equal(await editor.inputValue(),source);
assert.equal(await card.locator('.skill-test').count(),0);
assert(await card.locator('.doclib-card-expanded-actions button:visible').evaluateAll(buttons=>buttons.every(button=>{
const bounds=button.getBoundingClientRect(),card=button.closest('.skill-card').getBoundingClientRect();
return bounds.left>=card.left&&bounds.right<=card.right;
})), 'Editor actions must fit inside the card');
await editor.fill(source+'\nCancelled draft');
if(mode==='running'){
await page.waitForFunction(()=>document.querySelector('.skill-card')?._testPoll===null);
assert.equal(await editor.inputValue(),source+'\nCancelled draft');
}
await card.getByRole('button',{name:'Cancel',exact:true}).click();
assert.equal(await card.locator('.skill-md-editor').count(),0);
assert.equal(posts,0);assert.equal(markdown,source);
assert.equal(await card.locator('.skill-md-pre').innerText(),source);
if(mode!=='source'){
await card.getByRole('button',{name:'Test',exact:true}).click();
await card.getByText('Fixture verdict',{exact:true}).waitFor();
await card.locator('[data-act="edit"]').click();
}else await card.getByRole('button',{name:'Edit',exact:true}).click();
await editor.fill(source+'\nSaved change');
await card.getByRole('button',{name:'Save',exact:true}).click();
await page.waitForFunction(()=>!document.querySelector('.skill-md-editor'));
assert.equal(posts,1);assert.equal(markdown,source+'\nSaved change');
assert.deepEqual(errors,[]);
console.log(JSON.stringify({mode,width:process.argv[2],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
+30
View File
@@ -17,6 +17,7 @@ clear_fake_database_modules()
import core.database as cdb
import routes.task_routes as task_routes
from core.database import ScheduledTask
from routes.task.task_routes import TaskCreate
_TMPDB = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
_ENGINE = create_engine(
@@ -125,3 +126,32 @@ def test_scheduler_cycle_guard_treats_cross_owner_chain_as_unsafe():
assert scheduler._has_chain_cycle(db, "bob-target-cycle", owner="alice") is True
finally:
db.close()
@pytest.mark.asyncio
async def test_generated_task_name_turns_thinking_off(monkeypatch):
sent = {}
async def fake_call(**kwargs):
sent.update(kwargs)
return "Daily AI News"
monkeypatch.setattr("src.llm_core.llm_call_async", fake_call)
db = _TS()
try:
db.add(cdb.Session(
id="task-name-session", name="s", owner="alice",
endpoint_url="http://127.0.0.1:8081/v1", model="gemma4-26b",
))
db.commit()
finally:
db.close()
create_task = _endpoint("POST", "/api/tasks")
created = await create_task(
SimpleNamespace(state=SimpleNamespace(current_user="alice"), headers={}),
TaskCreate(prompt="Summarize AI news", task_type="llm", trigger_type="webhook"),
)
assert sent["thinking_mode"] == "off"
assert sent["max_tokens"] == 20
assert created["name"] == "Daily AI News"
+1 -1
View File
@@ -340,7 +340,7 @@ def test_route_context_agent_frontend_and_cache_bust_wire_the_contract():
assert "_without_latest_matching_user_message(" not in helpers
assert "selected_tools=approval_selected_tools" in agent
assert "continuation_query=_retrieval_query or _last_user" in agent
assert "approval_gate_bypassed=bool(" in agent
assert "approval_gate_bypassed=_tool_approval_gate_bypassed(" in agent
assert "['approve', 'approve_task', 'deny']" in frontend
assert "input.value = label" not in frontend
assert "const msg = approvalForSend ? '' : el('message').value;" in frontend
+34
View File
@@ -500,3 +500,37 @@ async def test_dispatcher_revalidates_sealed_workspace(monkeypatch, tmp_path):
assert result["blocked"] is True
assert result["policy"] == "exact_tool_approval"
def test_tool_approval_mode_defaults_to_ask(monkeypatch):
import src.agent_loop as agent_loop
monkeypatch.setattr(
agent_loop,
"get_setting",
lambda key, default=None: (
"ask" if key == "tool_approval_mode" else default
),
)
assert agent_loop._tool_approval_gate_bypassed() is False
def test_tool_approval_gate_respects_setting(monkeypatch):
import src.agent_loop as agent_loop
monkeypatch.setattr(
agent_loop,
"get_setting",
lambda key, default=None: (
"ask" if key == "tool_approval_mode" else default
),
)
assert agent_loop._tool_approval_gate_bypassed() is False
monkeypatch.setattr(
agent_loop,
"get_setting",
lambda key, default=None: (
"off" if key == "tool_approval_mode" else default
),
)
assert agent_loop._tool_approval_gate_bypassed() is True
+7
View File
@@ -196,6 +196,13 @@ def test_network_prompts_are_classified_as_local_computer_work():
assert _looks_like_local_computer_request(prompt), prompt
def test_named_targets_need_computer_task_wording_to_be_local_computer_work():
assert _looks_like_local_computer_request("check disk usage on gpu-box")
assert _looks_like_local_computer_request("copy the logs from gpu-box")
assert not _looks_like_local_computer_request("Who is Jensen Huang from NVIDIA?")
assert not _looks_like_local_computer_request("What is the best book on Rome?")
def test_network_lookup_replaces_ping_with_address_evidence():
normalized = _tui_normalize_network_host_command(
"ping -c 4 ajax.local",

Some files were not shown because too many files have changed in this diff Show More