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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pewdiepie-archdaemon
2026-10-13 13:41:53 +00:00
co-authored by Claude Opus 5.5
parent a8c147b238
commit 364229f2dc
60 changed files with 2778 additions and 696 deletions
+3 -3
View File
@@ -101,9 +101,9 @@ SEARXNG_INSTANCE=http://localhost:8080
# Skip the external-context exact-approval pause for unattended local agents. # Skip the external-context exact-approval pause for unattended local agents.
# Keep false for shared or internet-exposed deployments. # Keep false for shared or internet-exposed deployments.
# Post-external-context tool approval gate. On by default; set to 0 only for # Optional post-external-context tool approval gate. Off by default because it
# trusted single-user setups that accept injected content reaching side-effect tools. # can block normal agent work; enable only for deployments that want this fence.
# ODYSSEUS_TOOL_APPROVAL_GATE=1 # ODYSSEUS_TOOL_APPROVAL_GATE=0
# Mark session cookies Secure. Left unset, this follows the request scheme: # Mark session cookies Secure. Left unset, this follows the request scheme:
# an HTTPS login gets a Secure cookie, a plain-HTTP one does not. Set true to # an HTTPS login gets a Secure cookie, a plain-HTTP one does not. Set true to
+2
View File
@@ -53,6 +53,8 @@ services/data/
# Test capture artifacts (browser session dumps may contain personal content) # Test capture artifacts (browser session dumps may contain personal content)
.playwright-mcp/ .playwright-mcp/
# Local editor bench/probe artifacts (sessions.json holds session tokens)
bench-out/
# OS # OS
.DS_Store .DS_Store
+3
View File
@@ -0,0 +1,3 @@
[submodule "vendor/photocraft"]
path = vendor/photocraft
url = https://github.com/storytold/photocraft
+19 -13
View File
@@ -57,10 +57,14 @@ Vendored in `static/lib/` and served directly:
| Library | Purpose | License | | Library | Purpose | License |
|---|---|---| |---|---|---|
| [highlight.js](https://github.com/highlightjs/highlight.js) v11.9.0 | Code syntax highlighting | BSD-3-Clause ([full notice](licenses/highlightjs-BSD-3-Clause.txt)) | | [highlight.js](https://github.com/highlightjs/highlight.js) v11.9.0 | Code syntax highlighting | BSD-3-Clause |
| [SheetJS / xlsx](https://github.com/SheetJS/sheetjs) v0.20.3 (`xlsx.full.min.js`) | Spreadsheet (`.xlsx`) read/write | Apache-2.0 ([full notice](licenses/SheetJS-Apache-2.0.txt)) | | [SheetJS / xlsx](https://github.com/SheetJS/sheetjs) (`xlsx.full.min.js`) | Spreadsheet (`.xlsx`) read/write | Apache-2.0 |
| [docx](https://github.com/dolanmiu/docx) v8.5.0 (`docx.umd.min.js`) | Generate `.docx` documents | MIT and bundled permissive notices ([full notices](licenses/docx-8.5.0-NOTICES.txt)) | | [docx](https://github.com/dolanmiu/docx) (`docx.umd.min.js`) | Generate `.docx` documents | MIT |
| [mammoth.js](https://github.com/mwilliamson/mammoth.js) v1.8.0 | Convert `.docx` → HTML | BSD-2-Clause and bundled permissive notices ([full notices](licenses/mammoth-1.8.0-NOTICES.txt)) | | [mammoth.js](https://github.com/mwilliamson/mammoth.js) | Convert `.docx` → HTML | BSD-2-Clause |
| [html2pdf.js](https://github.com/eKoopmans/html2pdf.js) | HTML → PDF export (bundles jsPDF + html2canvas) | MIT |
| [jsPDF](https://github.com/parallax/jsPDF) (bundled in html2pdf) | PDF generation | MIT |
| [html2canvas](https://github.com/niklasvh/html2canvas) (bundled in html2pdf) | DOM → canvas rasterization | MIT |
| [node-qrcode](https://github.com/soldair/node-qrcode) (`qrcode.min.js`) | QR-code rendering (2FA setup) | MIT |
| [KaTeX](https://github.com/KaTeX/KaTeX) v0.16.22 (`katex/katex.min.{js,css}` + `katex/fonts/*.woff2`) | Math typesetting | MIT ([`licenses/KaTeX-MIT-LICENSE.txt`](licenses/KaTeX-MIT-LICENSE.txt)) | | [KaTeX](https://github.com/KaTeX/KaTeX) v0.16.22 (`katex/katex.min.{js,css}` + `katex/fonts/*.woff2`) | Math typesetting | MIT ([`licenses/KaTeX-MIT-LICENSE.txt`](licenses/KaTeX-MIT-LICENSE.txt)) |
| [Mermaid](https://github.com/mermaid-js/mermaid) v11.16.1 (`mermaid.min.js`) | Diagrams from text | MIT ([`licenses/Mermaid-MIT-LICENSE.txt`](licenses/Mermaid-MIT-LICENSE.txt)) | | [Mermaid](https://github.com/mermaid-js/mermaid) v11.16.1 (`mermaid.min.js`) | Diagrams from text | MIT ([`licenses/Mermaid-MIT-LICENSE.txt`](licenses/Mermaid-MIT-LICENSE.txt)) |
@@ -72,13 +76,6 @@ browser that supports `woff2`. The bundles are the published npm artifacts,
unmodified — `.gitattributes` turns the whitespace check off for `static/lib/` unmodified — `.gitattributes` turns the whitespace check off for `static/lib/`
so they can stay byte-identical to upstream. so they can stay byte-identical to upstream.
Exact artifact hashes, upstream archive members, local filename mappings and
notice sources are recorded in [THIRD_PARTY_PROVENANCE.json](THIRD_PARTY_PROVENANCE.json).
SheetJS copyright and attribution are preserved in its full distribution license;
highlight.js attribution is Copyright 2006 Ivan Sagalaev.
Browser printing supplies the client Print / save PDF flow. 2FA QR images are
generated by the Python qrcode dependency listed below.
## Front-end libraries loaded at runtime (CDN) ## Front-end libraries loaded at runtime (CDN)
Referenced from `cdn.jsdelivr.net` / `cdnjs.cloudflare.com` at runtime — not vendored: Referenced from `cdn.jsdelivr.net` / `cdnjs.cloudflare.com` at runtime — not vendored:
@@ -94,8 +91,9 @@ Bundled in `static/fonts/`:
| Font | License | Author | | Font | License | Author |
|---|---|---| |---|---|---|
| [Fira Code](https://github.com/tonsky/FiraCode) 6.2 | SIL Open Font License 1.1 ([full notice](licenses/FiraCode-OFL-1.1.txt)) | Nikita Prokopov & contributors | | [Fira Code](https://github.com/tonsky/FiraCode) | SIL Open Font License 1.1 | Nikita Prokopov & contributors |
| [Inter](https://github.com/rsms/inter) 4.1 (hinted WOFF2) | SIL Open Font License 1.1 ([full notice](licenses/Inter-OFL-1.1.txt)) | Rasmus Andersson | | [Inter](https://github.com/rsms/inter) | SIL Open Font License 1.1 | Rasmus Andersson |
| [GohuFont](https://font.gohu.org/) (`fonts/custom/GohuFont.ttf`) | WTFPL | Hugo Chargois |
| [OpenDyslexic](https://opendyslexic.org/) (`fonts/OpenDyslexic-{Regular,Bold}.woff2`) | SIL Open Font License 1.1 ([`licenses/OpenDyslexic-OFL.txt`](licenses/OpenDyslexic-OFL.txt)) | Abbie Gonzalez | | [OpenDyslexic](https://opendyslexic.org/) (`fonts/OpenDyslexic-{Regular,Bold}.woff2`) | SIL Open Font License 1.1 ([`licenses/OpenDyslexic-OFL.txt`](licenses/OpenDyslexic-OFL.txt)) | Abbie Gonzalez |
## Python dependencies ## Python dependencies
@@ -172,4 +170,12 @@ concerns from earlier are resolved:
## Thanks to ## Thanks to
Most of Odysseus's code was written *with* AI models, not just by a human.
The project would not exist without them — credit where credit is due:
- **gpt-oss-120b** — the legend that kicked this project off.
- **Qwen3-235B**
- **DeepSeek V3.1 · DeepSeek V4 Pro · DeepSeek V4 Flash**
- **Claude** (Anthropic)
- **Codex** (OpenAI)
- Friends, for helping me debug. - Friends, for helping me debug.
+1 -1
View File
@@ -1 +1 @@
0.20.19 0.20.84
+1 -1
View File
@@ -5,7 +5,7 @@ a = Analysis(
['launcher.py'], ['launcher.py'],
pathex=[], pathex=[],
binaries=[], binaries=[],
datas=[('licenses', 'licenses'), ('THIRD_PARTY_PROVENANCE.json', '.'), ('ACKNOWLEDGMENTS.md', '.'), ('static', 'static'), ('scripts', 'scripts'), ('mcp_servers', 'mcp_servers'), ('services/hwfit/data', 'services/hwfit/data'), ('config', 'config'), ('.env.example', '.env.example')], datas=[('static', 'static'), ('scripts', 'scripts'), ('mcp_servers', 'mcp_servers'), ('services/hwfit/data', 'services/hwfit/data'), ('config', 'config'), ('.env.example', '.env.example')],
hiddenimports=[], hiddenimports=[],
hookspath=[], hookspath=[],
hooksconfig={}, hooksconfig={},
+5 -3
View File
@@ -60,11 +60,13 @@ External content that reaches the LLM is treated as untrusted via `src/prompt_se
**Untrusted surfaces that must go through this wrapper:** web search results, fetched URLs, emails (read), saved memories, skill text, notes, and any tool output sourced from outside the server. Injecting untrusted content directly into the system role is a security bug. **Untrusted surfaces that must go through this wrapper:** web search results, fetched URLs, emails (read), saved memories, skill text, notes, and any tool output sourced from outside the server. Injecting untrusted content directly into the system role is a security bug.
### Post-external-context tool approval gate — on by default ### Post-external-context tool approval gate — off by default
`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. `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 disabled unless `ODYSSEUS_TOOL_APPROVAL_GATE` is set** (`1`/`true`/`yes`/`on`). The default is off because the gate is conservative enough to interrupt ordinary agent work. That is a deliberate usability trade, and it means a default deployment relies on the wrapper above — not on the gate — to contain injected instructions.
Operators who run the agent against untrusted web or email content with side-effecting tools enabled should turn it on. With the gate off, a successful injection can reach `bash`, `host_shell`, `send_email` and `delete_email` without a separate confirmation; with it on, each of those is refused until approved.
Two exemptions apply even when the gate is on, both deliberate: Two exemptions apply even when the gate is on, both deliberate:
@@ -83,7 +85,7 @@ Two exemptions apply even when the gate is on, both deliberate:
These are open, acknowledged, and contributor help is welcome: These are open, acknowledged, and contributor help is welcome:
1. **No shell/filesystem sandbox.** The agent `bash` and `read_file`/`write_file` tools run as the app process user with no network egress filtering or filesystem confinement. A successful prompt-injection reaching a shell-enabled admin session can make outbound requests to internal services. See #1058 for the sandbox proposal. The tool approval gate above is the compensating control and is on by default; turning it off leaves this gap unmitigated beyond the untrusted-context wrapper. 1. **No shell/filesystem sandbox.** The agent `bash` and `read_file`/`write_file` tools run as the app process user with no network egress filtering or filesystem confinement. A successful prompt-injection reaching a shell-enabled admin session can make outbound requests to internal services. See #1058 for the sandbox proposal. The tool approval gate above is the compensating control, and it is off by default — so on a default deployment this gap is unmitigated beyond the untrusted-context wrapper.
2. **SSRF via `/api/v1/chat` `base_url` parameter.** A chat-scoped API token can supply an arbitrary `base_url`; the server forwards the LLM request to that host without validating the scheme or address. PR #1039 fixes this. 2. **SSRF via `/api/v1/chat` `base_url` parameter.** A chat-scoped API token can supply an arbitrary `base_url`; the server forwards the LLM request to that host without validating the scheme or address. PR #1039 fixes this.
+37 -37
View File
@@ -1185,6 +1185,9 @@ def _fixture_email_record(row: dict, uid_num: int, owner: str) -> dict:
"subject": subject, "subject": subject,
"from": sender_name or sender_addr or sender, "from": sender_name or sender_addr or sender,
"from_address": sender_addr, "from_address": sender_addr,
"to": str(row.get("to") or headers.get("To") or ""),
"cc": str(row.get("cc") or headers.get("Cc") or ""),
"references": str(row.get("references") or headers.get("References") or ""),
"date": date_str, "date": date_str,
"date_epoch": date_epoch, "date_epoch": date_epoch,
"summary": body[:240], "summary": body[:240],
@@ -1198,7 +1201,7 @@ def _fixture_email_record(row: dict, uid_num: int, owner: str) -> dict:
"folder": folder, "folder": folder,
"is_read": bool(row.get("read")), "is_read": bool(row.get("read")),
"is_done": bool(row.get("done") or row.get("answered")), "is_done": bool(row.get("done") or row.get("answered")),
"is_favorite": bool(row.get("favorite") or row.get("flagged") or row.get("starred")), "is_favorite": bool(row.get("favorite", row.get("flagged") or row.get("starred"))),
"spam_label": str(row.get("spam_label") or ""), "spam_label": str(row.get("spam_label") or ""),
"spam_score": int(row.get("spam_score") or 0), "spam_score": int(row.get("spam_score") or 0),
"list_unsubscribe": str(row.get("list_unsubscribe") or headers.get("List-Unsubscribe") or ""), "list_unsubscribe": str(row.get("list_unsubscribe") or headers.get("List-Unsubscribe") or ""),
@@ -1522,15 +1525,17 @@ def _fixture_read_email(uid=None, message_id=None, folder="INBOX", account=None)
return None return None
if not _fixture_owner_has_rows(_current_owner()): if not _fixture_owner_has_rows(_current_owner()):
return None return None
for item in _fixture_email_rows(_current_owner()): if not uid and not message_id:
if not _fixture_row_matches_account(item, account): return {"error": "Email UID or Message-ID is required"}
continue matches = [item for item in _fixture_email_rows(_current_owner())
if not _fixture_folder_matches(item.get("folder"), folder): if _fixture_row_matches_account(item, account)
continue and _fixture_folder_matches(item.get("folder"), folder)
if uid and str(item.get("uid")) == str(uid): and (not uid or str(item.get("uid")) == str(uid))
return item and (not message_id or str(item.get("message_id")) == str(message_id))]
if message_id and str(item.get("message_id")) == str(message_id): if len(matches) > 1:
return item return {"error": "Email identity is ambiguous; specify its account and folder"}
if matches:
return matches[0]
return {"error": f"Email not found with UID/Message-ID: {uid or message_id}"} return {"error": f"Email not found with UID/Message-ID: {uid or message_id}"}
@@ -1557,6 +1562,7 @@ def _fixture_update_email(uid=None, source_folder="INBOX", account=None, **updat
if not isinstance(rows, list): if not isinstance(rows, list):
return False return False
owner = _current_owner() owner = _current_owner()
matches = []
for index, row in enumerate(rows, start=1): for index, row in enumerate(rows, start=1):
if not isinstance(row, dict): if not isinstance(row, dict):
continue continue
@@ -1568,14 +1574,19 @@ def _fixture_update_email(uid=None, source_folder="INBOX", account=None, **updat
continue continue
if not _fixture_folder_matches(row.get("folder") or "INBOX", source_folder): if not _fixture_folder_matches(row.get("folder") or "INBOX", source_folder):
continue continue
for key, value in updates.items(): if not _fixture_row_matches_account(_fixture_email_record(row, index, owner or row_owner), account):
if value is None: continue
row.pop(key, None) matches.append(row)
else: if len(matches) != 1:
row[key] = value return False
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") row = matches[0]
return True for key, value in updates.items():
return False if value is None:
row.pop(key, None)
else:
row[key] = value
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
return True
# ── Tool implementations ── # ── Tool implementations ──
@@ -2925,7 +2936,9 @@ def _create_email_draft_document(
def _draft_reply_to_email(uid, body, folder="INBOX", reply_all=False, account=None, title=None): def _draft_reply_to_email(uid, body, folder="INBOX", reply_all=False, account=None, title=None):
"""Create a threaded Odysseus reply draft document. Does not send.""" """Create a threaded Odysseus reply draft document. Does not send."""
fixture = _fixture_email_action_target(uid=uid, folder=folder, account=account) fixture = _fixture_read_email(uid=uid, folder=folder, account=account)
if _fixture_email_enabled() and (fixture is None or fixture.get("error")):
return fixture or {"error": "No email fixture is available for the authenticated owner"}
if fixture is not None: if fixture is not None:
sender = str(fixture.get("from_address") or fixture.get("from") or "") sender = str(fixture.get("from_address") or fixture.get("from") or "")
_, sender_addr = email.utils.parseaddr(sender) _, sender_addr = email.utils.parseaddr(sender)
@@ -3178,7 +3191,9 @@ def _reply_to_email(uid, body, folder="INBOX", reply_all=False, account=None):
def _set_flag(uid, folder, flag, add=True, account=None): def _set_flag(uid, folder, flag, add=True, account=None):
"""Add or remove an IMAP flag (e.g. \\Seen, \\Answered, \\Deleted).""" """Add or remove an IMAP flag (e.g. \\Seen, \\Answered, \\Deleted)."""
if _fixture_email_action_target(uid=uid, folder=folder, account=account) is not None: if _fixture_email_enabled():
if _fixture_email_action_target(uid=uid, folder=folder, account=account) is None:
return False
if flag == "\\Seen": if flag == "\\Seen":
return _fixture_update_email(uid=uid, source_folder=folder, account=account, read=bool(add)) return _fixture_update_email(uid=uid, source_folder=folder, account=account, read=bool(add))
if flag == "\\Answered": if flag == "\\Answered":
@@ -3504,21 +3519,6 @@ def _block_sender(sender=None, uids=None, folder="INBOX", account=None, reason="
} }
def _attachment_dir(folder, uid, account_id) -> Path:
"""Owner/account-scoped extraction directory (see src.mail_attachment_paths).
`folder` comes from tool arguments and the IMAP server's own mailbox names
(`/` hierarchies, absolute or `..` segments), so it must not become a path;
the old `MAIL_ATTACHMENTS_DIR/{folder}_{uid}` wrote outside the root and
shared one directory between every owner's "INBOX 42".
"""
from src.mail_attachment_paths import attachment_scope_dir
return attachment_scope_dir(
MAIL_ATTACHMENTS_DIR, folder, uid, owner=_current_owner(), account_id=account_id,
)
def _download_attachment(uid, index, folder="INBOX", account=None): def _download_attachment(uid, index, folder="INBOX", account=None):
"""Extract a specific attachment to disk and return its local path.""" """Extract a specific attachment to disk and return its local path."""
fixture = _fixture_attachment_source(uid, index, folder=folder, account=account) fixture = _fixture_attachment_source(uid, index, folder=folder, account=account)
@@ -3527,7 +3527,7 @@ def _download_attachment(uid, index, folder="INBOX", account=None):
filename = str(att.get("filename") or f"attachment-{index}.txt") filename = str(att.get("filename") or f"attachment-{index}.txt")
safe_name = re.sub(r"[^\w\s\-.]", "_", filename).strip() or f"attachment-{index}.txt" safe_name = re.sub(r"[^\w\s\-.]", "_", filename).strip() or f"attachment-{index}.txt"
content = str(att.get("content") or "") content = str(att.get("content") or "")
target_dir = _attachment_dir(folder, uid, _row.get("account_id") or account) target_dir = Path(MAIL_ATTACHMENTS_DIR) / re.sub(r"[^A-Za-z0-9._-]", "_", f"{folder}_{uid}")
path = target_dir / safe_name path = target_dir / safe_name
size = len(content.encode("utf-8")) size = len(content.encode("utf-8"))
try: try:
@@ -3560,7 +3560,7 @@ def _download_attachment(uid, index, folder="INBOX", account=None):
raw = msg_data[0][1] raw = msg_data[0][1]
msg = email.message_from_bytes(raw) msg = email.message_from_bytes(raw)
target_dir = _attachment_dir(folder, uid, _load_config(account).get("account_id") or account) target_dir = Path(MAIL_ATTACHMENTS_DIR) / f"{folder}_{uid}"
filepath = _extract_attachment_to_disk(msg, index, target_dir) filepath = _extract_attachment_to_disk(msg, index, target_dir)
if not filepath: if not filepath:
return {"error": f"Attachment index {index} not found"} return {"error": f"Attachment index {index} not found"}
+4 -4
View File
@@ -409,7 +409,7 @@ def parse_due_for_user(s: str) -> str:
lower = s.lower().strip() lower = s.lower().strip()
def _parse_time(t): def _parse_time(t):
t = _re.sub(r'\b([ap])(?:\s*\.)?\s*m\.?\b', r'\1m', t.strip(), flags=_re.IGNORECASE) t = _re.sub(r'\b([ap])\s*\.?\s*m\.?\b', r'\1m', t.strip(), flags=_re.IGNORECASE)
m = _re.match(r'^\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*$', t, _re.IGNORECASE) m = _re.match(r'^\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*$', t, _re.IGNORECASE)
if not m: return None if not m: return None
h = int(m.group(1)); mn = int(m.group(2) or 0); ampm = (m.group(3) or "").lower() h = int(m.group(1)); mn = int(m.group(2) or 0); ampm = (m.group(3) or "").lower()
@@ -433,7 +433,7 @@ def parse_due_for_user(s: str) -> str:
return base.replace(hour=t[0], minute=t[1]).isoformat() return base.replace(hour=t[0], minute=t[1]).isoformat()
# Time-first: "3pm today", "11pm today", "9am tomorrow" # Time-first: "3pm today", "11pm today", "9am tomorrow"
m = _re.match(r'^(.*\S)\s+(today|tonight|tomorrow|tmrw|yesterday)$', lower) m = _re.match(r'^(.+?)\s+(today|tonight|tomorrow|tmrw|yesterday)$', lower)
if m: if m:
time_part, word = m.group(1).strip(), m.group(2) time_part, word = m.group(1).strip(), m.group(2)
base = today base = today
@@ -530,7 +530,7 @@ def _parse_dt(s: str) -> datetime:
def _parse_time(t: str): def _parse_time(t: str):
"""Return (hour, minute) from '1pm', '1:30 PM', '13:00', etc., or None.""" """Return (hour, minute) from '1pm', '1:30 PM', '13:00', etc., or None."""
t = _re.sub(r'\b([ap])(?:\s*\.)?\s*m\.?\b', r'\1m', t.strip(), flags=_re.IGNORECASE) t = _re.sub(r'\b([ap])\s*\.?\s*m\.?\b', r'\1m', t.strip(), flags=_re.IGNORECASE)
m = _re.match(r'^\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*$', t, _re.IGNORECASE) m = _re.match(r'^\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*$', t, _re.IGNORECASE)
if not m: if not m:
return None return None
@@ -562,7 +562,7 @@ def _parse_dt(s: str) -> datetime:
# time-first: "3pm today", "9am tomorrow", "11pm tonight" # time-first: "3pm today", "9am tomorrow", "11pm tonight"
# (parity with parse_due_for_user, which handles these via the same form) # (parity with parse_due_for_user, which handles these via the same form)
m = _re.match(r'^(.*\S)\s+(today|tonight|tomorrow|tmrw|yesterday)$', lower) m = _re.match(r'^(.+?)\s+(today|tonight|tomorrow|tmrw|yesterday)$', lower)
if m: if m:
time_part, word = m.group(1).strip(), m.group(2) time_part, word = m.group(1).strip(), m.group(2)
base = today base = today
+116 -81
View File
@@ -11,7 +11,7 @@ from urllib.parse import urlparse
from datetime import datetime from datetime import datetime
from typing import Dict, Any, AsyncGenerator, List, Optional from typing import Dict, Any, AsyncGenerator, List, Optional
from fastapi import APIRouter, Request, HTTPException, Form, Query, Depends from fastapi import APIRouter, Request, HTTPException, Form, Query
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import ValidationError from pydantic import ValidationError
@@ -32,6 +32,7 @@ from src.agent_loop import (
) )
from src.agent_loop import _normalize_ody_qwen_text_artifacts from src.agent_loop import _normalize_ody_qwen_text_artifacts
from src import agent_runs from src import agent_runs
from src import shutdown as _shutdown
from src.model_context import estimate_tokens from src.model_context import estimate_tokens
from src.context_compactor import ( from src.context_compactor import (
apply_compaction_state, apply_compaction_state,
@@ -48,13 +49,7 @@ from src.foreground_model_routing import (
from src.session_search import search_session_messages from src.session_search import search_session_messages
from src.prompt_security import untrusted_context_message from src.prompt_security import untrusted_context_message
from core.exceptions import SessionNotFoundError from core.exceptions import SessionNotFoundError
from src.auth_helpers import ( from src.auth_helpers import effective_user, get_current_user
effective_user,
get_current_user,
is_delegated_credential,
require_api_token_scope,
require_chat_api_token_scope,
)
from routes.session_routes import _verify_session_owner from routes.session_routes import _verify_session_owner
from routes.document_helpers import _owner_session_filter from routes.document_helpers import _owner_session_filter
from core.database import SessionLocal, get_session_mode, set_session_mode from core.database import SessionLocal, get_session_mode, set_session_mode
@@ -85,8 +80,6 @@ from src.tool_policy import (
web_search_enabled_for_turn, web_search_enabled_for_turn,
) )
from src.tool_approvals import tool_approval_store from src.tool_approvals import tool_approval_store
from src.tool_approval_scopes import stamp_chat_session_grant
from src.tool_security import delegated_credential_blocked_tools
from src.workspace_paths import backend_workspace_path from src.workspace_paths import backend_workspace_path
from src.client_tool_contract import TUI_CLIENT_TOOL_NAMES from src.client_tool_contract import TUI_CLIENT_TOOL_NAMES
from src.model_profiles import ( from src.model_profiles import (
@@ -327,31 +320,13 @@ def _is_external_discovery_request(text: str) -> bool:
)) ))
_URL_SCHEME_RE = re.compile(r"https?://", re.I)
_PDF_URL_TAIL_RE = re.compile(r"\.pdf\b|/pdf/", re.I)
def _mentions_pdf_url(value: str) -> bool:
"""Same as ``re.search(r"https?://[^\\s]+(?:\\.pdf\\b|/pdf/)", value, re.I)``.
Checked per whitespace-free token from its first scheme only (a later
scheme's tail is a suffix of the first's), so a token packed with
`http://` repeats is scanned once instead of once per repeat (ReDoS).
"""
for token in value.split():
scheme = _URL_SCHEME_RE.search(token)
if scheme and _PDF_URL_TAIL_RE.search(token, scheme.end() + 1):
return True
return False
def _prefers_structured_document_tools(text: str) -> bool: def _prefers_structured_document_tools(text: str) -> bool:
"""Identify external paper/PDF extraction where shell is a bad source route.""" """Identify external paper/PDF extraction where shell is a bad source route."""
value = str(text or "") value = str(text or "")
if re.search(r"(?:^|\s)(?:file://)?/workspace/[^\s`\"']+\.pdf\b", value, re.I): if re.search(r"(?:^|\s)(?:file://)?/workspace/[^\s`\"']+\.pdf\b", value, re.I):
return False return False
return bool( return bool(
_mentions_pdf_url(value) re.search(r"https?://[^\s]+(?:\.pdf\b|/pdf/)", value, re.I)
or re.search( or re.search(
r"\b(?:paper|report|study)\b[\s\S]{0,1200}?" r"\b(?:paper|report|study)\b[\s\S]{0,1200}?"
r"\b(?:tables?|figures?|benchmarks?|scores?|metrics?)\b", r"\b(?:tables?|figures?|benchmarks?|scores?|metrics?)\b",
@@ -1290,23 +1265,6 @@ def _stream_failure_status(chunk: str) -> Optional[int]:
return None return None
def _reject_delegated_tool_approval(request: Request) -> None:
"""Refuse an approval answered by a bearer API token.
A tool approval records that a HUMAN authorized one dangerous action. A
token is a delegated credential handed to an integration, so when it
answers the prompt it triggered, nobody is asked and the gate collapses
into an extra round trip. Owner and session already match here: the token
is answering on behalf of the account that minted it.
"""
if is_delegated_credential(request):
raise HTTPException(
403,
"Tool approvals require an interactive session. "
"API tokens cannot authorize a gated action.",
)
def _mark_tool_approval_resolved(sess, approval_id: Any, decision: Any) -> bool: def _mark_tool_approval_resolved(sess, approval_id: Any, decision: Any) -> bool:
"""Persist a consumed approval decision on its existing tool event.""" """Persist a consumed approval decision on its existing tool event."""
@@ -1331,11 +1289,6 @@ def _mark_tool_approval_resolved(sess, approval_id: Any, decision: Any) -> bool:
if str(ask_user.get("approval_id") or "") != approval_key: if str(ask_user.get("approval_id") or "") != approval_key:
continue continue
ask_user["resolved"] = normalized_decision ask_user["resolved"] = normalized_decision
stamp_chat_session_grant(
ask_user,
getattr(sess, "id", ""),
normalized_decision,
)
message_id = metadata.get("_db_id") message_id = metadata.get("_db_id")
resolved_metadata = { resolved_metadata = {
key: value for key, value in metadata.items() if key != "_db_id" key: value for key, value in metadata.items() if key != "_db_id"
@@ -2178,17 +2131,13 @@ def setup_chat_routes(
webhook_manager=None, webhook_manager=None,
skills_manager=None, skills_manager=None,
) -> APIRouter: ) -> APIRouter:
router = APIRouter( router = APIRouter(tags=["chat"])
tags=["chat"],
dependencies=[Depends(require_chat_api_token_scope)],
)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# POST /api/chat (non-streaming) # POST /api/chat (non-streaming)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@router.post("/api/chat", response_model=Dict[str, Any]) @router.post("/api/chat", response_model=Dict[str, Any])
async def chat_endpoint(request: Request, chat_request: ChatRequest) -> Dict[str, Any]: async def chat_endpoint(request: Request, chat_request: ChatRequest) -> Dict[str, Any]:
require_api_token_scope(request, "chat")
_set_user_time_from_request(request) _set_user_time_from_request(request)
message = chat_request.message message = chat_request.message
@@ -2405,7 +2354,6 @@ def setup_chat_routes(
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@router.post("/api/chat_stream") @router.post("/api/chat_stream")
async def chat_stream(request: Request) -> StreamingResponse: async def chat_stream(request: Request) -> StreamingResponse:
require_api_token_scope(request, "chat")
body = None body = None
try: try:
if request.headers.get("content-type", "").startswith("application/json"): if request.headers.get("content-type", "").startswith("application/json"):
@@ -2525,7 +2473,7 @@ def setup_chat_routes(
_explicit_web_intent = ( _explicit_web_intent = (
_explicit_url_target _explicit_url_target
or bool(re.search( or bool(re.search(
r"\b(search|look\s+(?:(?:this|that|it|them|these|those)\s*)?up|lookup|find\s*out|google|browse|web|online|latest|current|today|news|weather|forecast|rate|exchange\s+rate)\b", r"\b(search|look\s+(?:this|that|it|them|these|those)?\s*up|lookup|find\s*out|google|browse|web|online|latest|current|today|news|weather|forecast|rate|exchange\s+rate)\b",
_msg_l, _msg_l,
)) ))
or requires_external_web_verification(message) or requires_external_web_verification(message)
@@ -2624,6 +2572,21 @@ def setup_chat_routes(
logger.info("chat→agent auto-escalation: explicit private browser workflow") logger.info("chat→agent auto-escalation: explicit private browser workflow")
active_doc_id = form_data.get("active_doc_id", "").strip() active_doc_id = form_data.get("active_doc_id", "").strip()
active_doc_state = form_data.get("active_doc_state", "").strip().casefold() active_doc_state = form_data.get("active_doc_state", "").strip().casefold()
# Scoped photo_editor agent context: the Quick Edit popup (and any
# image-edit chat turn) may carry the open image-editor session id and a
# canvas snapshot so the editor tool family can drive it in-process.
editor_session = (
form_data.get("editor_session")
or form_data.get("editor_session_id")
or ""
).strip()
editor_state = form_data.get("editor_state") or ""
editor_screenshot = form_data.get("editor_screenshot") or ""
photo_editor_requested = (
str(form_data.get("photo_editor") or "").strip().lower()
in {"1", "true", "yes", "on"}
or bool(editor_session)
)
logger.info( logger.info(
"[doc-inject] chat_mode=%s, active_doc_id=%r, active_doc_state=%r", "[doc-inject] chat_mode=%s, active_doc_id=%r, active_doc_state=%r",
chat_mode, active_doc_id, active_doc_state, chat_mode, active_doc_id, active_doc_state,
@@ -2731,7 +2694,6 @@ def setup_chat_routes(
) )
owner = effective_user(request) owner = effective_user(request)
if tool_approval_id: if tool_approval_id:
_reject_delegated_tool_approval(request)
from src.agent_runtime.authority import require_user_approval_request from src.agent_runtime.authority import require_user_approval_request
require_user_approval_request(request) require_user_approval_request(request)
pending_tool_approval = tool_approval_store.peek(tool_approval_id) pending_tool_approval = tool_approval_store.peek(tool_approval_id)
@@ -3189,7 +3151,38 @@ def setup_chat_routes(
active_document=bool(active_doc), workspace=bool(workspace), active_document=bool(active_doc), workspace=bool(workspace),
image_attachment=any(str(a.get('mime') or '').startswith('image/') for a in (ctx.preprocessed.attachment_meta or [])), image_attachment=any(str(a.get('mime') or '').startswith('image/') for a in (ctx.preprocessed.attachment_meta or [])),
) if _use_turn_contract else frozenset() ) if _use_turn_contract else frozenset()
if 'image_editing' in _turn_capabilities and ctx.preprocessed.attachment_meta: # Scoped photo_editor family: offered ONLY for an explicit editor
# request (Quick Edit popup / editor session) or an image-edit chat
# turn carrying an image. When active the turn offers just the editor
# tools plus ask_user, never the general tool list.
_photo_editor_active = bool(_use_turn_contract) and (
photo_editor_requested
or (
"image_editing" in _turn_capabilities
and bool(ctx.preprocessed.attachment_meta)
)
)
if _photo_editor_active:
# The editor tool family drives the browser-side image editor; it
# replaces the generic edit_image instruction. Without an open
# editor session the tools cannot reach the canvas, so tell the
# user to open the image instead of emitting failing tool calls.
if editor_session:
editor_context = {'role': 'system', 'content':
'The image editor is open (session ' + editor_session + '). Use run_editor_command, '
'plan_edit and get_editor_screenshot to inspect and change the current canvas, and '
'ai_edit_image for model-based edits. Apply the user\'s requested edit to the open '
'editor and confirm the result with a screenshot.'}
else:
editor_context = {'role': 'system', 'content':
'The user attached an image but no image editor is open, so the editor tools cannot '
'reach the canvas. Do not call editor tools. Reply briefly telling the user to open '
'the attached image in the editor (Quick Edit) and repeat the request; the editor '
'tools will then apply it.'}
ctx.messages.insert(0, editor_context)
if foreground_policy.enabled:
getattr(ctx, 'route_messages', ctx.messages).insert(0, dict(editor_context))
elif 'image_editing' in _turn_capabilities and ctx.preprocessed.attachment_meta:
image_refs = ['odysseus://attachment/' + str(a['id']) for a in ctx.preprocessed.attachment_meta image_refs = ['odysseus://attachment/' + str(a['id']) for a in ctx.preprocessed.attachment_meta
if a.get('id') and str(a.get('mime') or '').startswith('image/')] if a.get('id') and str(a.get('mime') or '').startswith('image/')]
if image_refs: if image_refs:
@@ -3226,6 +3219,11 @@ def setup_chat_routes(
# pasted revision request. This only offers permitted schemas; # pasted revision request. This only offers permitted schemas;
# it never requires or performs a document mutation. # it never requires or performs a document mutation.
_turn_capabilities = _turn_capabilities | {"documents"} _turn_capabilities = _turn_capabilities | {"documents"}
# Scoped photo_editor family (see above): when active the turn offers
# just the editor tools plus ask_user, never the general tool list.
if _photo_editor_active:
_turn_capabilities = frozenset({"photo_editor"})
_active_turn_capabilities = _turn_capabilities
# Same decision that prepared the turn; it only stamps the contract # Same decision that prepared the turn; it only stamps the contract
# inside the agent-contract branch below. # inside the agent-contract branch below.
_clean_v3_preview = _compact_preview_turn _clean_v3_preview = _compact_preview_turn
@@ -3238,12 +3236,6 @@ def setup_chat_routes(
if _use_turn_contract and _turn_capabilities and "search_browser" not in _turn_capabilities: if _use_turn_contract and _turn_capabilities and "search_browser" not in _turn_capabilities:
_explicit_web_intent = False _explicit_web_intent = False
disabled_tools = set() disabled_tools = set()
# Minting is admin-only, so every owner-keyed check below answers
# "admin" for a token. Cap it at the non-admin policy instead.
# stream_agent_loop repeats this from delegated_credential.
_delegated_credential = is_delegated_credential(request)
if _delegated_credential:
disabled_tools.update(delegated_credential_blocked_tools())
# Only disable bash when the caller *explicitly* set it to a falsy # Only disable bash when the caller *explicitly* set it to a falsy
# value. When unset (None), defer to per-user privilege checks below. # value. When unset (None), defer to per-user privilege checks below.
# Web search is per-turn opt-in: either the chat pre-search setting # Web search is per-turn opt-in: either the chat pre-search setting
@@ -3475,9 +3467,22 @@ def setup_chat_routes(
"document_editor": not { "document_editor": not {
"manage_documents", "create_document", "edit_document", "update_document", "manage_documents", "create_document", "edit_document", "update_document",
}.issubset(disabled_tools), }.issubset(disabled_tools),
# A photo-editing chat turn can only reach a canvas when the image
# editor is actually open. The compact runtime uses this to reply
# (open the image in the editor) instead of looping on editor tools
# that have no canvas to drive.
"photo_editor_open": bool(editor_session),
} }
# Capture permission state before schema selection/reconciliation. # Capture permission state before schema selection/reconciliation.
# Only deterministic request intent supplies grants, never inventory. # Only deterministic request intent supplies grants, never inventory.
# The strict router (spec) surface picks its tools in src/spec_router.py
# AFTER authority is built, so its admission is keyed off the surface.
try:
_spec_surface = _configured_model_tool_surface(
getattr(sess, "endpoint_url", ""), getattr(sess, "model", ""), _user
) == "spec"
except Exception:
_spec_surface = False
_request_authority = request_authority_for_http( _request_authority = request_authority_for_http(
request, message, owner=_user, session_id=session, workspace=workspace, request, message, owner=_user, session_id=session, workspace=workspace,
history=_turn_history, policy=tool_policy, history=_turn_history, policy=tool_policy,
@@ -3485,9 +3490,16 @@ def setup_chat_routes(
active_document=bool(active_doc), active_document=bool(active_doc),
image_attachment=any(str(a.get('mime') or '').startswith('image/') image_attachment=any(str(a.get('mime') or '').startswith('image/')
for a in (ctx.preprocessed.attachment_meta or [])), for a in (ctx.preprocessed.attachment_meta or [])),
capabilities=({'search_browser'} if ( spec_surface=_spec_surface,
_explicit_browser_intent or _external_discovery_intent capabilities=(({"photo_editor"} if _photo_editor_active else set()) | ({'search_browser'} if (
) else ()), _explicit_browser_intent
or _external_discovery_intent
# Grant search/browser tools whenever the turn actually offers
# them (Web toggle on / not denied), not only for explicit
# browser wording. Otherwise the model sees web_search in its
# schema but every call is rejected by request authority.
or "web_search" not in disabled_tools
) else set())),
) )
if exact_tool_approval is not None: if exact_tool_approval is not None:
from src.agent_runtime.authority import RequestAuthority from src.agent_runtime.authority import RequestAuthority
@@ -3513,8 +3525,6 @@ def setup_chat_routes(
if s["function"]["name"] != "host_shell"] if s["function"]["name"] != "host_shell"]
_contract_mgr = get_mcp_manager() _contract_mgr = get_mcp_manager()
_owner_blocked = blocked_tools_for_owner(_user) _owner_blocked = blocked_tools_for_owner(_user)
if _delegated_credential:
_owner_blocked.update(delegated_credential_blocked_tools())
if ( if (
_workspace_tools_disabled_for_owner(_user) _workspace_tools_disabled_for_owner(_user)
and not _native_workspace_contract and not _native_workspace_contract
@@ -3677,7 +3687,9 @@ def setup_chat_routes(
# personal manager; core shell/Web tools are not task # personal manager; core shell/Web tools are not task
# fallbacks. Other turns retain warm and workspace tools. # fallbacks. Other turns retain warm and workspace tools.
extra_tools=( extra_tools=(
frozenset() frozenset({"ask_user"})
if _active_turn_capabilities == frozenset({"photo_editor"})
else frozenset()
if _exact_selected_native_chain or _active_turn_capabilities == frozenset({"tasks"}) or ( if _exact_selected_native_chain or _active_turn_capabilities == frozenset({"tasks"}) or (
_active_turn_capabilities in ( _active_turn_capabilities in (
frozenset({"transcription"}), frozenset({"ocr"}), frozenset({"transcription"}), frozenset({"ocr"}),
@@ -3711,10 +3723,17 @@ def setup_chat_routes(
) )
# Every recovery path receives the same scope denial. The central # Every recovery path receives the same scope denial. The central
# dispatcher also checks the immutable contract after rewrites. # dispatcher also checks the immutable contract after rewrites.
disabled_tools.update( # The strict router (spec) surface picks its own tools AFTER this
s["function"]["name"] for s in _contract_schemas # contract is resolved, so scoping disabled_tools to the regex
if not _turn_contract.permits(s["function"]["name"]) # contract would empty its menu (e.g. all email/notes tools denied
) # for a plain "search for the appointment email" turn). On spec
# turns keep only the pre-contract denials (user/privilege/global/
# plan/compare) and let src/spec_router.py + the spec surface decide.
if not _spec_surface:
disabled_tools.update(
s["function"]["name"] for s in _contract_schemas
if not _turn_contract.permits(s["function"]["name"])
)
# Contract resolution is the final policy-and-routing authority. # Contract resolution is the final policy-and-routing authority.
# Some legacy/API-model paths arrive with a stale disabled snapshot # Some legacy/API-model paths arrive with a stale disabled snapshot
# assembled before routing. The scope-denial pass above may retain # assembled before routing. The scope-denial pass above may retain
@@ -3879,7 +3898,7 @@ def setup_chat_routes(
_heartbeat_counter = 0 _heartbeat_counter = 0
_last_progress = {} _last_progress = {}
_sent_avg = False _sent_avg = False
while True: while not _shutdown.is_shutting_down():
status = research_handler.get_status(session) status = research_handler.get_status(session)
if not status or status["status"] != "running": if not status or status["status"] != "running":
break break
@@ -4027,9 +4046,10 @@ def setup_chat_routes(
owner=_user, owner=_user,
size="auto", size="auto",
progress_callback=_image_progress_callback, progress_callback=_image_progress_callback,
incognito=incognito,
)) ))
else: else:
_img_task = asyncio.create_task(do_generate_image(f"{_user_msg}\n{sess.model}\n512x512", session, owner=_user)) _img_task = asyncio.create_task(do_generate_image(f"{_user_msg}\n{sess.model}\n512x512", session, owner=_user, incognito=incognito))
_img_started = time.time() _img_started = time.time()
_img_tick = 0 _img_tick = 0
while not _img_task.done(): while not _img_task.done():
@@ -4568,7 +4588,6 @@ def setup_chat_routes(
uploaded_files=ctx.uploaded_files, uploaded_files=ctx.uploaded_files,
defer_context_shaping=_foreground_policy.enabled, defer_context_shaping=_foreground_policy.enabled,
external_untrusted_context_seen=external_untrusted_context_seen, external_untrusted_context_seen=external_untrusted_context_seen,
delegated_credential=_delegated_credential,
exact_approval=exact_tool_approval, exact_approval=exact_tool_approval,
client_runtime_context=client_runtime_context, client_runtime_context=client_runtime_context,
thinking_mode=thinking_mode, thinking_mode=thinking_mode,
@@ -4879,11 +4898,20 @@ def setup_chat_routes(
async def _safe_stream() -> AsyncGenerator[str, None]: async def _safe_stream() -> AsyncGenerator[str, None]:
"""Wrapper that guarantees _active_streams cleanup even if stream_with_save """Wrapper that guarantees _active_streams cleanup even if stream_with_save
raises before reaching a mode-specific finally block.""" raises before reaching a mode-specific finally block."""
_gen = stream_with_save()
try: try:
async for chunk in stream_with_save(): while not _shutdown.is_shutting_down():
try:
chunk = await _gen.__anext__()
except StopAsyncIteration:
break
yield chunk yield chunk
finally: finally:
_active_streams.pop(session, None) _active_streams.pop(session, None)
try:
await _gen.aclose()
except Exception:
pass
# Compare panes and explicitly unattended native clients are # Compare panes and explicitly unattended native clients are
# short-lived, single-shot generations with nobody to resume them. # short-lived, single-shot generations with nobody to resume them.
@@ -4975,7 +5003,12 @@ def setup_chat_routes(
if rec is None: if rec is None:
if agent_runs.is_active(session_id): if agent_runs.is_active(session_id):
return {"status": "streaming", "detached": True} return {"status": "streaming", "detached": True}
raise HTTPException(404, "No active stream for this session") # No active stream: report an explicit idle state with HTTP 200.
# A 404 here made every session load look like a failed request in
# the browser console. The client distinguishes "no stream" by the
# body (status != "streaming"), and still tolerates a 404 from an
# older server, so this stays backward compatible.
return {"status": "idle"}
return rec return rec
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -5074,6 +5107,8 @@ def setup_chat_routes(
max_tokens=0, max_tokens=0,
tools=None, tools=None,
): ):
if _shutdown.is_shutting_down():
break
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"): if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
try: try:
data = json.loads(chunk[6:]) data = json.loads(chunk[6:])
+15 -3
View File
@@ -17,6 +17,7 @@ from src.endpoint_resolver import resolve_endpoint
from src.auth_helpers import _auth_disabled, get_current_user from src.auth_helpers import _auth_disabled, get_current_user
from src.owner_identity import REQUEST_SENTINEL_OWNERS from src.owner_identity import REQUEST_SENTINEL_OWNERS
from src.constants import DEEP_RESEARCH_DIR from src.constants import DEEP_RESEARCH_DIR
from src import shutdown as _shutdown
_SESSION_ID_RE = re.compile(r"^[a-zA-Z0-9-]{1,128}$") _SESSION_ID_RE = re.compile(r"^[a-zA-Z0-9-]{1,128}$")
@@ -285,11 +286,22 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
async def research_status(session_id: str, request: Request): async def research_status(session_id: str, request: Request):
user = _require_user(request) user = _require_user(request)
_validate_session_id(session_id) _validate_session_id(session_id)
# No in-flight or persisted research for this session is a normal state
# (most sessions have none), not an error. Return HTTP 200 with an
# explicit empty status so loading a session does not log a spurious
# 404; every client treats a non-"running" status as idle anyway.
# A session whose research exists but belongs to ANOTHER user stays a
# 404, so we do not leak the existence of someone else's research.
if not _owns_in_memory(session_id, user): if not _owns_in_memory(session_id, user):
raise HTTPException(404, "No research found for this session") exists = session_id in getattr(research_handler, "_active_tasks", {})
if not exists:
exists = _find_research_path(session_id) is not None
if exists:
raise HTTPException(404, "No research found for this session")
return {"status": "none"}
status = research_handler.get_status(session_id) status = research_handler.get_status(session_id)
if status is None: if status is None:
raise HTTPException(404, "No research found for this session") return {"status": "none"}
try: try:
source_state = research_handler.get_source_state(session_id) source_state = research_handler.get_source_state(session_id)
if isinstance(source_state, str) and source_state: if isinstance(source_state, str) and source_state:
@@ -657,7 +669,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
raise HTTPException(404, "No research found for this session") raise HTTPException(404, "No research found for this session")
async def _generate(): async def _generate():
last_payload = None last_payload = None
while True: while not _shutdown.is_shutting_down():
status = research_handler.get_status(session_id) status = research_handler.get_status(session_id)
if status is None: if status is None:
yield f"data: {json.dumps({'status': 'not_found'})}\n\n" yield f"data: {json.dumps({'status': 'not_found'})}\n\n"
+4 -3
View File
@@ -26,6 +26,7 @@ from src.host_docker_access import (
) )
from src.optional_deps import prepare_optional_dependency_import from src.optional_deps import prepare_optional_dependency_import
from src.auth_helpers import _auth_disabled from src.auth_helpers import _auth_disabled
from src import shutdown as _shutdown
from src import process_lifecycle from src import process_lifecycle
# POSIX-only: `pty`/`fcntl` transitively import `termios`, which does NOT exist # POSIX-only: `pty`/`fcntl` transitively import `termios`, which does NOT exist
@@ -1025,7 +1026,7 @@ async def _generate_tmux(cmd: str, request: Request):
lines_sent = 0 lines_sent = 0
exit_code = None exit_code = None
while True: while not _shutdown.is_shutting_down():
# Check client disconnect # Check client disconnect
if await request.is_disconnected(): if await request.is_disconnected():
# tmux keeps running — that's the whole point # tmux keeps running — that's the whole point
@@ -1142,7 +1143,7 @@ async def _generate_win_detached(cmd: str, request: Request):
lines_sent = 0 lines_sent = 0
exit_code = None exit_code = None
while True: while not _shutdown.is_shutting_down():
if await request.is_disconnected(): if await request.is_disconnected():
yield f"data: {json.dumps({'stream': 'stdout', 'data': f'Disconnected. Background job {session_id} continues running.'})}\n\n" yield f"data: {json.dumps({'stream': 'stdout', 'data': f'Disconnected. Background job {session_id} continues running.'})}\n\n"
return return
@@ -1267,7 +1268,7 @@ def setup_shell_routes() -> APIRouter:
"""Read chunks, split on \\n or \\r for progress bar support.""" """Read chunks, split on \\n or \\r for progress bar support."""
try: try:
buf = b"" buf = b""
while True: while not _shutdown.is_shutting_down():
chunk = await stream.read(4096) chunk = await stream.read(4096)
if not chunk: if not chunk:
if buf: if buf:
+57
View File
@@ -54,6 +54,7 @@ def workspace_artifact_is_usable(path: Path) -> bool:
class EvidenceKind(str, Enum): class EvidenceKind(str, Enum):
TOOL_RESULT = "tool_result" TOOL_RESULT = "tool_result"
ARTIFACT_MUTATION = "artifact_mutation" ARTIFACT_MUTATION = "artifact_mutation"
DOCUMENT_MUTATION = "document_mutation"
ARTIFACT_VALIDATION = "artifact_validation" ARTIFACT_VALIDATION = "artifact_validation"
VERIFIER_RESULT = "verifier_result" VERIFIER_RESULT = "verifier_result"
MEDIA_INGRESS = "media_ingress" MEDIA_INGRESS = "media_ingress"
@@ -131,6 +132,20 @@ class CompletionDecision:
_ARTIFACT_PATH = r"(?:/|\./|\.\./)?[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*\.[A-Za-z0-9]{1,12}" _ARTIFACT_PATH = r"(?:/|\./|\.\./)?[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*\.[A-Za-z0-9]{1,12}"
# Document-editor tools mutate a server-side document rather than a filesystem
# path, so their successful results are recorded as a distinct evidence kind.
_DOCUMENT_MUTATION_TOOLS = frozenset({"create_document", "update_document", "edit_document"})
# Owned private-action writes (calendar, tasks, notes, memory, contacts) mutate
# server-side state that carries no filesystem path, so a successful
# authoritative result is their mutation evidence. Without this, a verified
# calendar/task create is discarded as an "unsupported execution claim".
_PRIVATE_WRITE_ACTIONS: Mapping[str, frozenset[str]] = {
"manage_calendar": frozenset({"create_event", "update_event", "delete_event"}),
"manage_contact": frozenset({"add", "update", "edit", "delete"}),
"manage_memory": frozenset({"add", "edit", "delete"}),
"manage_notes": frozenset({"add", "update", "delete", "toggle_item"}),
"manage_tasks": frozenset({"create", "edit", "delete", "pause", "resume", "run"}),
}
_ARTIFACT_REQUEST_RE = re.compile( _ARTIFACT_REQUEST_RE = re.compile(
rf"\b(?:writ(?:e|ten)|creat(?:e|ed)|make|made|sav(?:e|ed)|produc(?:e|ed)|" rf"\b(?:writ(?:e|ten)|creat(?:e|ed)|make|made|sav(?:e|ed)|produc(?:e|ed)|"
rf"generat(?:e|ed)|export(?:ed)?|edit(?:ed)?|modif(?:y|ied)|updat(?:e|ed)|" rf"generat(?:e|ed)|export(?:ed)?|edit(?:ed)?|modif(?:y|ied)|updat(?:e|ed)|"
@@ -639,6 +654,9 @@ class EvidenceLedger:
# order of actions so receipt evidence and effects share one ordering. # order of actions so receipt evidence and effects share one ordering.
self.effects: list[dict[str, Any]] = [] self.effects: list[dict[str, Any]] = []
self._action_order: dict[str, int] = {} self._action_order: dict[str, int] = {}
# Successful authoritative owned-state writes (calendar/tasks/notes/
# memory/contacts) that an unnamed mutation claim may cite.
self._private_action_mutations: list[str] = []
def record_effects(self, entries: Iterable[Mapping[str, Any]], action_order: Mapping[str, int], def record_effects(self, entries: Iterable[Mapping[str, Any]], action_order: Mapping[str, int],
partial_reads: Iterable[str] = ()) -> None: partial_reads: Iterable[str] = ()) -> None:
@@ -898,6 +916,29 @@ class EvidenceLedger:
detail="post-write artifact inspection", detail="post-write artifact inspection",
) )
if tool in _DOCUMENT_MUTATION_TOOLS:
# A successful, authoritative document write is evidence of a
# document mutation even though it has no filesystem path.
self._append(
kind=EvidenceKind.DOCUMENT_MUTATION,
success=success,
authoritative=authoritative,
source=event,
detail=tool,
)
if tool in _PRIVATE_WRITE_ACTIONS:
try:
write_args = json.loads(command or "{}")
except (TypeError, json.JSONDecodeError):
write_args = None
action = (
str(write_args.get("action") or "").strip().lower()
if isinstance(write_args, Mapping) else ""
)
if action in _PRIVATE_WRITE_ACTIONS[tool] and authoritative and success:
self._private_action_mutations.append(f"{tool}:{action}")
if tool in {"bash", "host_shell"} and (command_is_test(_command_text(command)) or _matches_declared_verifier( if tool in {"bash", "host_shell"} and (command_is_test(_command_text(command)) or _matches_declared_verifier(
command, command,
self.requirements.verifier_commands, self.requirements.verifier_commands,
@@ -969,6 +1010,22 @@ class EvidenceLedger:
return False return False
return True return True
def _supports_document_mutation(self) -> bool:
"""A successful authoritative document write supports an unnamed
document-mutation claim, which carries no filesystem path to match."""
return any(event.kind == EvidenceKind.DOCUMENT_MUTATION
and event.authoritative and event.success
for event in self.events)
def _supports_mutation_claim(self, paths: Sequence[str]) -> bool:
"""Match a mutation claim to a file edit, a document write, or a
successful owned-state action when the claim names no file path."""
if self._supports_artifact_claim(EvidenceKind.ARTIFACT_MUTATION, paths):
return True
if paths:
return False
return self._supports_document_mutation() or bool(self._private_action_mutations)
def record_media_ingress(self, metadata: Mapping[str, Any]) -> None: def record_media_ingress(self, metadata: Mapping[str, Any]) -> None:
for artifact in metadata.get("artifacts") or []: for artifact in metadata.get("artifacts") or []:
if not isinstance(artifact, Mapping): if not isinstance(artifact, Mapping):
+43 -7
View File
@@ -316,7 +316,12 @@ class RequestAuthority:
_BROWSER_READ_ACTIONS = frozenset({"open", "navigate", "snapshot", "text", "read", "find", _BROWSER_READ_ACTIONS = frozenset({"open", "navigate", "snapshot", "text", "read", "find",
"screenshot", "scroll", "back", "forward", "wait", "status", "close", "tabs", "session_info"}) "screenshot", "scroll", "back", "forward", "wait", "status", "close", "tabs", "session_info",
"batch"})
# Interaction classes are admitted on a strict router (spec) surface whenever
# the server itself offered private_browser: the router decides the tool set,
# so requiring the verb in the text would break "browse X then click Y".
_BROWSER_INTERACTION_ACTIONS = frozenset({"click", "fill", "type", "press", "evaluate", "select"})
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -363,7 +368,8 @@ def interpret_request(request_text, *, history=(), workspace=None, active_docume
def create_request_authority(request_text, *, owner=None, session_id=None, workspace=None, def create_request_authority(request_text, *, owner=None, session_id=None, workspace=None,
history=(), policy=None, active_document=False, history=(), policy=None, active_document=False,
image_attachment=False, capabilities=None, client_runtime_context=None): image_attachment=False, capabilities=None, client_runtime_context=None,
spec_surface=False):
"""Deterministic server policy over semantic facts, never schema inventory.""" """Deterministic server policy over semantic facts, never schema inventory."""
if not isinstance(request_text, str): if not isinstance(request_text, str):
raise TypeError("Authority requires trusted request text") raise TypeError("Authority requires trusted request text")
@@ -372,10 +378,25 @@ def create_request_authority(request_text, *, owner=None, session_id=None, works
families = intent.capabilities families = intent.capabilities
if capabilities is not None: if capabilities is not None:
families |= frozenset(capabilities) families |= frozenset(capabilities)
if spec_surface:
# Strict router (spec) surface: agent_loop builds the final tool set in
# src/spec_router.py AFTER this runs, so turn_contract.offered is still
# the compact inventory here. The router already limits schemas to its
# picks + sticky prior tools + ask_user/update_plan, and
# _resolve_tool_blocks drops calls to unoffered tools, so admit every
# family and let the spec surface be the boundary. Selected-tool
# narrowing is skipped for the same reason.
families |= frozenset(FAMILY_TOOLS)
tools = set().union(*(FAMILY_TOOLS.get(f, ()) for f in families)) tools = set().union(*(FAMILY_TOOLS.get(f, ()) for f in families))
selected = intent.selected_tools selected = None if spec_surface else intent.selected_tools
if selected is not None: if selected is not None:
tools = tools & set(selected) if families else set(selected) tools = tools & set(selected) if families else set(selected)
if "web_search" in tools:
# Search selection narrows to the query tool, but reading a source
# it surfaces is the same bounded read operation. Without the
# sibling reader every discovered result is a dead end and the
# turn fails closed on the first web_fetch of a found URL.
tools.add("web_fetch")
if tools & {"web_search", "web_fetch"}: if tools & {"web_search", "web_fetch"}:
tools.add("private_browser") tools.add("private_browser")
operation = intent.required_read operation = intent.required_read
@@ -389,13 +410,28 @@ def create_request_authority(request_text, *, owner=None, session_id=None, works
actions = inputs = None actions = inputs = None
if name == "private_browser": if name == "private_browser":
actions = _BROWSER_READ_ACTIONS actions = _BROWSER_READ_ACTIONS
# Explicit interaction intent admits its operation class. A if spec_surface:
# browser offered only as static-Web fallback gets no such grant. # The strict router itself offered a working browser: admit its
if re.search(r"\b(?:browser|browse|private_browser)\b", request_text, re.I): # interaction classes too, not only when the verb is in the text.
actions |= _BROWSER_INTERACTION_ACTIONS
elif re.search(r"\b(?:browser|browse|private_browser)\b", request_text, re.I):
# Explicit interaction intent admits its operation class. A
# browser offered only as static-Web fallback gets no such grant.
actions |= frozenset(action for action in ("click", "fill", "type", "press", "evaluate", "select") actions |= frozenset(action for action in ("click", "fill", "type", "press", "evaluate", "select")
if re.search(r"\b" + action + r"\b", request_text, re.I)) if re.search(r"\b" + action + r"\b", request_text, re.I))
if operation is not None and name == canonical_tool(operation.tool): if operation is not None and name == canonical_tool(operation.tool):
inputs = frozenset({ExactOperation.normalize(name, _json(dict(operation.args))).input}) required_args = dict(operation.args)
if set(required_args) <= {"action"}:
# An action-only read ("list my tasks/memories") pins the action
# class, not the exact JSON: a model that adds benign arguments
# (limit/offset/filter) still performs the same admitted read.
# Reads that name an identity keep the exact-input seal below.
required_action = str(required_args.get("action") or "").strip().casefold() or None
if required_action:
actions = (frozenset({required_action}) if actions is None
else actions & frozenset({required_action}))
else:
inputs = frozenset({ExactOperation.normalize(name, _json(required_args)).input})
grants.append(OperationGrant(name, actions, inputs)) grants.append(OperationGrant(name, actions, inputs))
authority = RequestAuthority(uuid4().hex, _owner(owner), str(session_id or ""), authority = RequestAuthority(uuid4().hex, _owner(owner), str(session_id or ""),
str(workspace or ""), tuple(grants)) str(workspace or ""), tuple(grants))
+70 -11
View File
@@ -1,7 +1,8 @@
"""One presentation gate between agent execution and externally visible prose. """One presentation gate between agent execution and externally visible prose.
Tool, progress and interaction events stay live. Answer deltas are held until Tool, progress and interaction events stay live. Evidence-safe complete
the generator unwinds so a later replacement cannot conceal an earlier false sentences from ordinary answers may stream before the terminal check; execution
answers remain gated so a later replacement cannot conceal an earlier false
claim. This consumes no provider calls. Cancellation closes the inner generator claim. This consumes no provider calls. Cancellation closes the inner generator
under the same journal/turn authority; it never emits a successful terminal event. under the same journal/turn authority; it never emits a successful terminal event.
""" """
@@ -123,7 +124,7 @@ def _supported_prose(text: str, ledger: EvidenceLedger, decision: CompletionDeci
or not ledger._supports_verifier_claim(identities, paths)): or not ledger._supports_verifier_claim(identities, paths)):
why = 'no current passing executable verification supports the claim' why = 'no current passing executable verification supports the claim'
elif claim == 'mutation': elif claim == 'mutation':
if not ledger._supports_artifact_claim(EvidenceKind.ARTIFACT_MUTATION, paths): if not ledger._supports_mutation_claim(paths):
why = 'no matching artifact mutation supports the execution claim' why = 'no matching artifact mutation supports the execution claim'
elif claim == 'execution': elif claim == 'execution':
# A generic assertion cannot be tied confidently to a receipt. # A generic assertion cannot be tied confidently to a receipt.
@@ -167,6 +168,16 @@ def _disclosure(answer: str, ledger: EvidenceLedger) -> str:
return ('\n\n' + summary) if answer.strip() else summary return ('\n\n' + summary) if answer.strip() else summary
def _complete_prose_prefix(text: str) -> str:
"""Return complete statement segments, keeping the current sentence pending."""
statements = list(_unquoted_statements(text))
if not statements:
return ''
if statements[-1][0].strip():
statements.pop()
return ''.join(statement for statement, _ in statements)
def _completion_answer(text: str, ledger: EvidenceLedger, decision: CompletionDecision) -> tuple[str, str]: def _completion_answer(text: str, ledger: EvidenceLedger, decision: CompletionDecision) -> tuple[str, str]:
incomplete = decision.reason if not decision.can_complete and decision.status != CompletionStatus.AWAITING_USER else '' incomplete = decision.reason if not decision.can_complete and decision.status != CompletionStatus.AWAITING_USER else ''
execution_required = _execution_obligation(ledger.requirements) execution_required = _execution_obligation(ledger.requirements)
@@ -232,6 +243,10 @@ def with_completion_gate(func):
journal.effects = (parent.effects if parent is not None and parent.effects is not None journal.effects = (parent.effects if parent is not None and parent.effects is not None
else EffectLog(journal.run_id)) else EffectLog(journal.run_id))
answer_events: list[dict] = [] answer_events: list[dict] = []
streamed_answer_events: set[int] = set()
streamed_safe_text = ''
stream_conflict = False
first_visible_answer_at = None
metrics_events: list[dict] = [] metrics_events: list[dict] = []
answer = '' answer = ''
has_final = False has_final = False
@@ -297,18 +312,36 @@ def with_completion_gate(func):
first_answer_at = perf_counter() first_answer_at = perf_counter()
# Boolean thinking=True marks a reasoning-only delta; # Boolean thinking=True marks a reasoning-only delta;
# a textual thinking companion must not hide an answer # a textual thinking companion must not hide an answer
# delta. Both shapes remain buffered until the gate. # delta. Thinking remains buffered until terminal review.
if isinstance(data.get('thinking'), str): if isinstance(data.get('thinking'), str):
answer_events.append({'delta': data['thinking'], 'thinking': True}) thinking_event = {'delta': data['thinking'], 'thinking': True}
answer_events.append(thinking_event)
data = {key: value for key, value in data.items() if key != 'thinking'} data = {key: value for key, value in data.items() if key != 'thinking'}
if 'delta' not in data: if 'delta' not in data:
continue continue
if data.get('thinking') is not True and 'delta' in data: if data.get('thinking') is not True and 'delta' in data:
if has_final: if has_final:
stream_conflict |= bool(streamed_safe_text)
streamed_safe_text = ''
answer = '' answer = ''
has_final = False has_final = False
answer += str(data.get('delta') or '') answer += str(data.get('delta') or '')
answer_events.append(data) answer_events.append(data)
if data.get('thinking') is not True and 'delta' in data \
and not _execution_obligation(requirements):
prefix = _complete_prose_prefix(answer)
if prefix:
ledger_now = _ledger(journal, requirements)
decision_now = ledger_now.evaluate(exhausted=exhausted, awaiting_user=awaiting)
safe_prefix, _ = _supported_prose(prefix, ledger_now, decision_now)
if safe_prefix.startswith(streamed_safe_text):
delta = safe_prefix[len(streamed_safe_text):]
if delta:
first_visible_answer_at = first_visible_answer_at or perf_counter()
yield _event({'delta': delta})
streamed_safe_text = safe_prefix
elif streamed_safe_text:
stream_conflict = True
continue continue
yield chunk yield chunk
if provider_error and not answer_events and not metrics_events: if provider_error and not answer_events and not metrics_events:
@@ -361,7 +394,10 @@ def with_completion_gate(func):
disclosure_only = bool(disclosure) and not (presentation_replaced or reason or unsafe_draft disclosure_only = bool(disclosure) and not (presentation_replaced or reason or unsafe_draft
or filtered_answer != answer) or filtered_answer != answer)
replaced_answer = not disclosure_only and bool( replaced_answer = not disclosure_only and bool(
presentation_replaced or reason or unsafe_draft or safe_answer != answer) presentation_replaced or reason or unsafe_draft or safe_answer != answer or stream_conflict
or (provider_error and streamed_safe_text))
if streamed_safe_text and not answer.startswith(streamed_safe_text):
replaced_answer = True
if replaced_answer: if replaced_answer:
reasoning = [event for event in answer_events if event.get('thinking') is True] reasoning = [event for event in answer_events if event.get('thinking') is True]
_, unsafe_reasoning = completion_answer( _, unsafe_reasoning = completion_answer(
@@ -369,11 +405,31 @@ def with_completion_gate(func):
replace(presentation_decision, can_complete=True)) replace(presentation_decision, can_complete=True))
if not unsafe_reasoning: if not unsafe_reasoning:
for event in reasoning: for event in reasoning:
yield _event(event) if id(event) not in streamed_answer_events:
yield _event({'type': 'final_response', 'content': safe_answer}) yield _event(event)
yield _event({
'type': 'final_response',
'content': safe_answer,
'render_owner': 'streamed',
'replacement_scope': 'turn',
})
else: else:
for event in answer_events: for event in answer_events:
yield _event(event) if event.get('thinking') is True:
if id(event) not in streamed_answer_events:
streamed_answer_events.add(id(event))
first_visible_answer_at = first_visible_answer_at or perf_counter()
yield _event(event)
elif event.get('type') == 'final_response':
if streamed_safe_text:
event = {**event, 'replacement_scope': 'turn'}
first_visible_answer_at = first_visible_answer_at or perf_counter()
yield _event(event)
if not has_final:
remainder = answer[len(streamed_safe_text):] if answer.startswith(streamed_safe_text) else answer
if remainder:
first_visible_answer_at = first_visible_answer_at or perf_counter()
yield _event({'delta': remainder})
if disclosure_only: if disclosure_only:
yield _event({'delta': disclosure}) yield _event({'delta': disclosure})
if provider_error: if provider_error:
@@ -386,8 +442,11 @@ def with_completion_gate(func):
if ledger.effects: if ledger.effects:
metadata['effect_assessments'] = [entry['assessment'].to_dict() for entry in ledger.effects] metadata['effect_assessments'] = [entry['assessment'].to_dict() for entry in ledger.effects]
metadata['completion_gate'] = { metadata['completion_gate'] = {
'buffer_seconds': released_at - first_answer_at if first_answer_at is not None else 0, 'buffer_seconds': max(0, (first_visible_answer_at or released_at) - first_answer_at)
'first_visible_answer_seconds': released_at - started, if first_answer_at is not None else 0,
'evaluation_seconds': released_at - first_answer_at if first_answer_at is not None else 0,
'first_visible_answer_seconds': first_visible_answer_at - started
if first_visible_answer_at is not None else 0,
'additional_provider_calls': 0, 'additional_provider_calls': 0,
'answer_replaced': replaced_answer, 'answer_replaced': replaced_answer,
} }
+5
View File
@@ -29,6 +29,7 @@ from .interaction_tools import AskUserTool, UpdatePlanTool
from .model_interaction_tools import ChatWithModelTool, AskTeacherTool, ListModelsTool from .model_interaction_tools import ChatWithModelTool, AskTeacherTool, ListModelsTool
from .bg_job_tools import ManageBgJobsTool from .bg_job_tools import ManageBgJobsTool
from .session_tools import CreateSessionTool, ListSessionsTool, SendToSessionTool, ManageSessionTool from .session_tools import CreateSessionTool, ListSessionsTool, SendToSessionTool, ManageSessionTool
from .editor_tools import PlanEditTool, RunEditorCommandTool, GetEditorScreenshotTool, AiEditImageTool
from .admin_tools import ( from .admin_tools import (
ADMIN_TOOL_HANDLERS, ADMIN_TOOL_HANDLERS,
do_manage_endpoints, do_manage_mcp, do_manage_webhooks, do_manage_endpoints, do_manage_mcp, do_manage_webhooks,
@@ -72,6 +73,10 @@ TOOL_HANDLERS = {
"list_sessions": ListSessionsTool().execute, "list_sessions": ListSessionsTool().execute,
"send_to_session": SendToSessionTool().execute, "send_to_session": SendToSessionTool().execute,
"manage_session": ManageSessionTool().execute, "manage_session": ManageSessionTool().execute,
"plan_edit": PlanEditTool().execute,
"run_editor_command": RunEditorCommandTool().execute,
"get_editor_screenshot": GetEditorScreenshotTool().execute,
"ai_edit_image": AiEditImageTool().execute,
} }
# Config/integration admin tools (manage_endpoints/mcp/webhooks/tokens/settings). # Config/integration admin tools (manage_endpoints/mcp/webhooks/tokens/settings).
TOOL_HANDLERS.update(ADMIN_TOOL_HANDLERS) TOOL_HANDLERS.update(ADMIN_TOOL_HANDLERS)
+6
View File
@@ -496,6 +496,7 @@ def _create_pdf_text_derivative(db, *, source_doc, content: str, owner: Optional
"content": clean, "content": clean,
"version": 1, "version": 1,
"source_doc_id": getattr(source_doc, "id", None), "source_doc_id": getattr(source_doc, "id", None),
"exit_code": 0,
} }
@@ -618,6 +619,7 @@ class CreateDocumentTool:
"language": language, "language": language,
"content": content, "content": content,
"version": 1, "version": 1,
"exit_code": 0,
} }
except Exception as e: except Exception as e:
db.rollback() db.rollback()
@@ -708,6 +710,7 @@ class UpdateDocumentTool:
"language": doc.language, "language": doc.language,
"content": new_content, "content": new_content,
"version": new_ver, "version": new_ver,
"exit_code": 0,
} }
except Exception as e: except Exception as e:
db.rollback() db.rollback()
@@ -863,6 +866,7 @@ class EditDocumentTool:
"version": new_ver, "version": new_ver,
"applied": applied, "applied": applied,
"skipped": skipped, "skipped": skipped,
"exit_code": 0,
} }
return {"error": "No edits applied — FIND text cannot be blank"} return {"error": "No edits applied — FIND text cannot be blank"}
@@ -968,6 +972,7 @@ class EditDocumentTool:
"version": new_ver, "version": new_ver,
"applied": applied, "applied": applied,
"skipped": skipped, "skipped": skipped,
"exit_code": 0,
} }
if partial_edits: if partial_edits:
result.update({ result.update({
@@ -1059,6 +1064,7 @@ class SuggestDocumentTool:
"partial": bool(invalid), "partial": bool(invalid),
"rejected": len(invalid), "rejected": len(invalid),
"invalid_suggestions": invalid, "invalid_suggestions": invalid,
"exit_code": 0,
} }
finally: finally:
db.close() db.close()
+5 -1
View File
@@ -14,6 +14,7 @@ import tempfile
import time import time
import html import html
import hashlib import hashlib
import pwd
import urllib.parse import urllib.parse
import urllib.request import urllib.request
import uuid import uuid
@@ -31,7 +32,10 @@ _ACTIVE_BROWSER_SESSIONS: set[str] = set()
def _service_home() -> Path: def _service_home() -> Path:
"""Return the account home even when a task overrides ``HOME``.""" """Return the account home even when a task overrides ``HOME``."""
return platform_compat.service_home() try:
return Path(pwd.getpwuid(os.getuid()).pw_dir)
except (KeyError, OSError):
return Path.home()
def _host_npm_roots() -> list[Path]: def _host_npm_roots() -> list[Path]:
+60 -19
View File
@@ -464,8 +464,6 @@ async def do_manage_memory(content: str, session_id: Optional[str] = None, owner
return {"error": "Edit needs line 2: memory_id, line 3: new text"} return {"error": "Edit needs line 2: memory_id, line 3: new text"}
memory_id = lines[1].strip() memory_id = lines[1].strip()
new_text = lines[2].strip() new_text = lines[2].strip()
if not memory_id:
return {"error": "Edit needs line 2: memory_id"}
if not new_text: if not new_text:
return {"error": "New text cannot be empty"} return {"error": "New text cannot be empty"}
@@ -499,8 +497,6 @@ async def do_manage_memory(content: str, session_id: Optional[str] = None, owner
if len(lines) < 2: if len(lines) < 2:
return {"error": "Delete needs line 2: memory_id"} return {"error": "Delete needs line 2: memory_id"}
memory_id = lines[1].strip() memory_id = lines[1].strip()
if not memory_id:
return {"error": "Delete needs line 2: memory_id"}
memories = _memory_manager.load_all() memories = _memory_manager.load_all()
original_len = len(memories) original_len = len(memories)
@@ -1155,7 +1151,50 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
# Image generation # Image generation
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def do_generate_image(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict: # Message used whenever a generative image edit (object add/remove/replace,
# image generation) is requested but no image provider is configured. The
# photo-editor agent and the image tools surface this verbatim so the user gets
# a plain, actionable explanation instead of a silent substitute edit.
IMAGE_PROVIDER_REQUIRED_MESSAGE = (
"Removing/adding objects needs an image generation provider; enable one in "
"Settings > Models."
)
def image_editing_provider_available(owner: Optional[str] = None) -> bool:
"""True when an enabled, owner-visible image endpoint can serve AI edits.
Generative operations (object add/remove/replace, image generation) require
a configured image model. Local transforms (remove-bg, upscale, denoise,
enhance-face) do not, but the photo-editor agent gates the whole generative
request on this so it never falls back to deterministic approximations.
"""
try:
from src.settings import load_settings
if not load_settings().get("image_gen_enabled", True):
return False
except Exception:
pass
try:
from src.database import SessionLocal, ModelEndpoint
from src.auth_helpers import owner_filter
db = SessionLocal()
try:
q = db.query(ModelEndpoint).filter(
ModelEndpoint.is_enabled == True, # noqa: E712
ModelEndpoint.model_type == "image",
)
q = owner_filter(q, ModelEndpoint, owner)
return q.first() is not None
finally:
db.close()
except Exception:
return False
async def do_generate_image(content: str, session_id: Optional[str] = None, owner: Optional[str] = None, *, incognito: bool = False) -> Dict:
"""Generate an image using an image-capable model (e.g. gpt-image-1). """Generate an image using an image-capable model (e.g. gpt-image-1).
Content format: Content format:
@@ -1314,7 +1353,7 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
else: else:
payload["quality"] = "medium" payload["quality"] = "medium"
logger.info(f"Image generation: model={model_id}, size={size}, quality={quality}, prompt={prompt[:80]}") logger.info(f"Image generation: model={model_id}, size={size}, quality={quality}, prompt={'[private]' if incognito else prompt[:80]}")
try: try:
# GPT image models can take 30-120s+ depending on quality # GPT image models can take 30-120s+ depending on quality
@@ -1365,15 +1404,19 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
logger.warning(f"Failed to save gallery record: {_ge}") logger.warning(f"Failed to save gallery record: {_ge}")
return "" return ""
# GPT image models always return b64_json; DALL-E may return url def _store_image(image_bytes):
if img.get("b64_json"): if incognito:
return 'data:image/png;base64,' + base64.b64encode(image_bytes).decode('ascii'), None
img_dir = Path(GENERATED_IMAGES_DIR) img_dir = Path(GENERATED_IMAGES_DIR)
img_dir.mkdir(parents=True, exist_ok=True) img_dir.mkdir(parents=True, exist_ok=True)
filename = f"{uuid.uuid4().hex[:12]}.png" filename = f"{uuid.uuid4().hex[:12]}.png"
img_path = img_dir / filename img_path = img_dir / filename
img_path.write_bytes(base64.b64decode(img.get("b64_json"))) img_path.write_bytes(image_bytes)
image_url = f"/api/generated-image/{filename}" return f"/api/generated-image/{filename}", _save_to_gallery(filename)
image_id = _save_to_gallery(filename)
# Private results stay in the response, never in the gallery or on disk.
if img.get("b64_json"):
image_url, image_id = _store_image(base64.b64decode(img['b64_json']))
elif img.get("url"): elif img.get("url"):
# Download external URL and save locally (DALL-E returns temp URLs) # Download external URL and save locally (DALL-E returns temp URLs)
@@ -1387,13 +1430,7 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
try: try:
dl_resp = httpx.get(result_url, timeout=60) dl_resp = httpx.get(result_url, timeout=60)
if dl_resp.status_code == 200: if dl_resp.status_code == 200:
img_dir = Path(GENERATED_IMAGES_DIR) image_url, image_id = _store_image(dl_resp.content)
img_dir.mkdir(parents=True, exist_ok=True)
filename = f"{uuid.uuid4().hex[:12]}.png"
img_path = img_dir / filename
img_path.write_bytes(dl_resp.content)
image_url = f"/api/generated-image/{filename}"
image_id = _save_to_gallery(filename)
else: else:
image_url = result_url # fallback to external URL image_url = result_url # fallback to external URL
except Exception as _dl_e: except Exception as _dl_e:
@@ -1430,6 +1467,7 @@ async def do_edit_image(
size: str = "auto", size: str = "auto",
quality: str = "medium", quality: str = "medium",
progress_callback: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None, progress_callback: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None,
incognito: bool = False,
) -> Dict: ) -> Dict:
"""Edit an uploaded image using the configured image endpoint.""" """Edit an uploaded image using the configured image endpoint."""
import base64 import base64
@@ -1503,7 +1541,7 @@ async def do_edit_image(
request_id = uuid.uuid4().hex request_id = uuid.uuid4().hex
payload["request_id"] = request_id payload["request_id"] = request_id
logger.info("Image edit: model=%s, size=%s, quality=%s, image=%s, prompt=%s", model_id, size, quality, path.name, prompt[:80]) logger.info("Image edit: model=%s, size=%s, quality=%s, image=%s, prompt=%s", model_id, size, quality, '[private]' if incognito else path.name, '[private]' if incognito else prompt[:80])
def _save_edited_image_to_gallery(filename: str) -> str: def _save_edited_image_to_gallery(filename: str) -> str:
try: try:
@@ -1533,6 +1571,9 @@ async def do_edit_image(
from PIL import Image from PIL import Image
with Image.open(BytesIO(image_bytes)) as output: with Image.open(BytesIO(image_bytes)) as output:
size = f"{output.width}x{output.height}" size = f"{output.width}x{output.height}"
image_mime = Image.MIME.get(output.format, 'image/png')
if incognito:
return 'data:' + image_mime + ';base64,' + base64.b64encode(image_bytes).decode('ascii'), ''
img_dir = Path(GENERATED_IMAGES_DIR) img_dir = Path(GENERATED_IMAGES_DIR)
img_dir.mkdir(parents=True, exist_ok=True) img_dir.mkdir(parents=True, exist_ok=True)
filename = f"{uuid.uuid4().hex[:12]}{suffix}" filename = f"{uuid.uuid4().hex[:12]}{suffix}"
+6 -3
View File
@@ -17,10 +17,13 @@ def compact_browser_observation(value, budget=8000):
notices.append('Exit code: ' + str(item['exit_code'])) notices.append('Exit code: ' + str(item['exit_code']))
if item.get('success') is False: if item.get('success') is False:
notices.append('Browser command failed.') notices.append('Browser command failed.')
snapshot = item.get('snapshot') or item.get('text') snapshot = item.get('snapshot')
if item.get('title'): if not isinstance(snapshot, str) or not snapshot.strip():
notices.append('Title: ' + str(item['title'])) snapshot = item.get('text')
url = item.get('url') or item.get('origin') url = item.get('url') or item.get('origin')
title = item.get('title')
if isinstance(title, str) and title.strip():
notices.append('Title: ' + title.strip())
if isinstance(snapshot, str) and snapshot.strip(): if isinstance(snapshot, str) and snapshot.strip():
# Snapshot text already contains labels and refs in DOM order. # Snapshot text already contains labels and refs in DOM order.
# The refs mapping repeats them and buries menus in raw JSON. # The refs mapping repeats them and buries menus in raw JSON.
+471 -406
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -105,7 +105,7 @@ def auto_compact_threshold_percent() -> int:
return max(50, min(95, value)) return max(50, min(95, value))
# Cursor-style self-summarization prompt — produces structured, dense summaries # Cursor-style self-summarization prompt — produces structured, dense summaries
SELF_SUMMARY_SYSTEM_PROMPT = """You are summarizing a conversation to preserve context after compaction. Produce a structured summary that lets the conversation continue seamlessly. SELF_SUMMARY_SYSTEM_PROMPT = """You are summarizing a conversation with Ajax, the user's AI assistant, to preserve context after compaction. Produce a structured summary that lets Ajax continue seamlessly. Ajax is the assistant's name; Odysseus is the app name.
Use this format: Use this format:
+4 -4
View File
@@ -102,15 +102,15 @@ class MemoryVectorStore:
return collections return collections
def add(self, memory_id: str, text: str): def add(self, memory_id: str, text: str):
"""Add a single memory entry to the vector index.""" """Insert or refresh a memory entry, preserving unchanged embeddings."""
if not self._healthy: if not self._healthy:
return return
for lane in self._lanes: for lane in self._lanes:
try: try:
existing = lane.collection.get(ids=[memory_id]) existing = lane.collection.get(ids=[memory_id], include=["documents"])
if existing["ids"]: if existing["ids"] and existing["documents"] == [text]:
continue continue
lane.collection.add( lane.collection.upsert(
ids=[memory_id], ids=[memory_id],
embeddings=lane.encode([text]), embeddings=lane.encode([text]),
documents=[text], documents=[text],
+1 -1
View File
@@ -56,7 +56,7 @@ def supports_user_thinking_toggle(value: object) -> bool:
return False return False
return any(pattern in leaf for pattern in ( return any(pattern in leaf for pattern in (
"kimi-k2.5", "kimi-k2.6", "kimi-k3", "kimi-k2.5", "kimi-k2.6", "kimi-k3",
"qwen3", "qwq", "deepseek-r1", "deepseek-reasoner", "qwen3", "qwq", "deepseek-r1", "deepseek-reasoner", "deepseek-v4",
"minimax", "m2-reap", "gemma", "stepfun", "step-3", "step3", "minimax", "m2-reap", "gemma", "stepfun", "step-3", "step3",
"magistral", "mistral-small", "mistral-medium", "magistral", "mistral-small", "mistral-medium",
)) ))
+7
View File
@@ -47,6 +47,13 @@ DEFAULT_SETTINGS = {
"image_gen_enabled": False, "image_gen_enabled": False,
"image_model": "", "image_model": "",
"image_quality": "medium", "image_quality": "medium",
# Local editor-agent/planner model (Quick Edit NL planner). When empty the
# server auto-detects the local Qwen3.5-9B vision server on seneca and falls
# back to the hosted chat model. See src/editor_agent_model.py.
"editor_agent_base_url": "",
"editor_agent_model": "",
"editor_caption_base_url": "",
"editor_caption_model": "",
"vision_model": "", "vision_model": "",
"vision_enabled": True, "vision_enabled": True,
# Ordered fallback chain for the Vision model (image analysis, OCR, tagging). # Ordered fallback chain for the Vision model (image analysis, OCR, tagging).
+59 -22
View File
@@ -1969,20 +1969,21 @@ class TaskScheduler:
messages.append(datetime_context_msg) messages.append(datetime_context_msg)
messages.append({"role": "user", "content": user_content}) messages.append({"role": "user", "content": user_content})
# Recover the registered runtime URL and credentials for this chat route. # Resolve headers from the endpoint's API key
headers = {} headers = {}
try: try:
from core.database import SessionLocal from core.database import SessionLocal, ModelEndpoint
from src.endpoint_resolver import ( from src.endpoint_resolver import normalize_base, build_headers, same_endpoint_base
build_chat_url, build_headers, resolve_endpoint_runtime, from src.auth_helpers import owner_filter
resolve_owner_registered_endpoint,
)
db2 = SessionLocal() db2 = SessionLocal()
try: try:
ep = resolve_owner_registered_endpoint(db2, endpoint_url, task.owner or None) ep_q = db2.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
base, api_key = resolve_endpoint_runtime(ep, owner=task.owner or None) ep_q = owner_filter(ep_q, ModelEndpoint, task.owner or None)
endpoint_url = build_chat_url(base) eps = ep_q.all()
headers = build_headers(api_key, base) for ep in eps:
if same_endpoint_base(endpoint_url, ep.base_url):
headers = build_headers(ep.api_key, normalize_base(ep.base_url))
break
finally: finally:
db2.close() db2.close()
except Exception: except Exception:
@@ -2169,23 +2170,26 @@ class TaskScheduler:
endpoint_url, model = self._resolve_defaults(db, task.owner) endpoint_url, model = self._resolve_defaults(db, task.owner)
if not endpoint_url or not model: if not endpoint_url or not model:
raise RuntimeError("No model/endpoint configured for research") raise RuntimeError("No model/endpoint configured for research")
endpoint_url = _normalize_chat_endpoint(endpoint_url)
# Record the resolved model for the run record (see _execute_task_locked). # Record the resolved model for the run record (see _execute_task_locked).
self._last_run_model = model self._last_run_model = model
# Authorize the selected URL before normalization can collapse service paths. # Resolve headers
try: try:
from src.endpoint_resolver import ( from core.database import ModelEndpoint
build_chat_url, build_headers, resolve_endpoint_runtime, from src.endpoint_resolver import normalize_base, build_headers, same_endpoint_base
resolve_owner_registered_endpoint, from src.auth_helpers import owner_filter
) db2 = db
if not headers_from_resolver: if not headers_from_resolver:
ep = resolve_owner_registered_endpoint(db, endpoint_url, task.owner or None) ep_q = db2.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
base, api_key = resolve_endpoint_runtime(ep, owner=task.owner or None) ep_q = owner_filter(ep_q, ModelEndpoint, task.owner or None)
endpoint_url = build_chat_url(base) eps = ep_q.all()
headers = build_headers(api_key, base) for ep in eps:
if same_endpoint_base(endpoint_url, ep.base_url):
headers = build_headers(ep.api_key, normalize_base(ep.base_url))
break
except Exception: except Exception:
pass pass
endpoint_url = _normalize_chat_endpoint(endpoint_url)
max_tokens = int(get_setting("research_max_tokens", 8192)) max_tokens = int(get_setting("research_max_tokens", 8192))
extraction_timeout = int(get_setting("research_extraction_timeout_seconds", 90) or 90) extraction_timeout = int(get_setting("research_extraction_timeout_seconds", 90) or 90)
@@ -2501,6 +2505,36 @@ class TaskScheduler:
ScheduledTask.task_type == "action", ScheduledTask.task_type == "action",
).all() if row[0] ).all() if row[0]
} }
# Built-in housekeeping rows created before the request-authority
# seal carry a NULL snapshot, so every scheduled run failed with
# "Scheduled action has no matching server request authority".
# Replace them: drop the unsealed row and let the seed loop below
# recreate an authorized copy (server-owned ingress), preserving the
# user's enable/pause choice. We never grant authority to the
# existing row, so a model-origin row can never gain a built-in
# grant this way.
replaced_actions = {}
stale_rows = db.query(ScheduledTask).filter(
ScheduledTask.owner == owner,
ScheduledTask.task_type == "action",
ScheduledTask.action.in_(list(HOUSEKEEPING_DEFAULTS.keys())),
).all()
sealed_actions = {r.action for r in stale_rows
if (r.request_authority_json or "").strip()}
removed_status = {}
for stale in stale_rows:
if (stale.request_authority_json or "").strip():
continue
removed_status.setdefault(stale.action, stale.status)
db.delete(stale)
for action, status in removed_status.items():
if action in sealed_actions:
# A sealed copy already exists; the NULL one was just a
# stale duplicate. Keep the sealed row and let the seed
# loop skip this action.
continue
replaced_actions[action] = status
existing_actions.discard(action)
renamed = [] renamed = []
builtin_tasks = db.query(ScheduledTask).filter( builtin_tasks = db.query(ScheduledTask).filter(
ScheduledTask.owner == owner, ScheduledTask.owner == owner,
@@ -2661,12 +2695,15 @@ class TaskScheduler:
output_target=defs.get("output_target", "none"), output_target=defs.get("output_target", "none"),
notifications_enabled=False, notifications_enabled=False,
) )
if action in replaced_actions and replaced_actions[action] in ("active", "paused"):
task.status = replaced_actions[action]
db.add(task) db.add(task)
seeded.append(action) seeded.append(action)
if seeded or renamed or removed_dupes or retired_count: if seeded or renamed or removed_dupes or retired_count or replaced_actions:
logger.info( logger.info(
"Housekeeping defaults for %s: seeded=%s renamed=%s deduped=%s retired=%s", "Housekeeping defaults for %s: seeded=%s renamed=%s deduped=%s retired=%s authority_replaced=%s",
owner, seeded, sorted(set(renamed)), sorted(set(removed_dupes)), retired_count, owner, seeded, sorted(set(renamed)), sorted(set(removed_dupes)), retired_count,
sorted(replaced_actions),
) )
# Always commit — the orphan-run sweep above may have produced # Always commit — the orphan-run sweep above may have produced
# pending deletes even when no defaults changed. # pending deletes even when no defaults changed.
+6 -2
View File
@@ -111,7 +111,7 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
"get_weather": "Get current weather and a three-day forecast for a city or place from Open-Meteo without an API key. Use for weather lookups before web_search.", "get_weather": "Get current weather and a three-day forecast for a city or place from Open-Meteo without an API key. Use for weather lookups before web_search.",
"web_fetch": "Fetch and read the text content of a specific URL/website the user names (e.g. 'check example.com', 'open this link'). Use when you have a concrete URL; for open-ended lookups use web_search instead.", "web_fetch": "Fetch and read the text content of a specific URL/website the user names (e.g. 'check example.com', 'open this link'). Use when you have a concrete URL; for open-ended lookups use web_search instead.",
"pdf_extract": "Extract focused, source-attributed passages and exact table values from an online PDF or task-local /workspace/*.pdf. Use for arXiv papers, reports, manuals, PDF tables, evaluation metrics, and multi-document PDF extraction. Prefer this over Python requests, curl, downloading, pdftotext, or guessing. Include target model names, metrics, and table headings in query.", "pdf_extract": "Extract focused, source-attributed passages and exact table values from an online PDF or task-local /workspace/*.pdf. Use for arXiv papers, reports, manuals, PDF tables, evaluation metrics, and multi-document PDF extraction. Prefer this over Python requests, curl, downloading, pdftotext, or guessing. Include target model names, metrics, and table headings in query.",
"youtube_tool": "Read YouTube-specific data without fighting the JS page: video comments, transcripts, metadata, or latest video from a channel. Use for YouTube comments/transcript/channel latest-video tasks.", "youtube_tool": "Read YouTube-specific data without fighting the JS page: video comments, transcripts, metadata, or latest video from a channel. YouTube ONLY — accepts only youtube.com or youtu.be links (video/shorts URL or 11-char video_id, or a channel /@handle/videos URL); for any other website use web_fetch or web_search instead.",
"private_browser": "Trusted metadata for an existing server-registered browser session only. Page/document reads and interactions are unavailable because the configured producer cannot guarantee exact target binding. No model batch or raw browser commands. Use web_search or web_fetch for supported web access.", "private_browser": "Trusted metadata for an existing server-registered browser session only. Page/document reads and interactions are unavailable because the configured producer cannot guarantee exact target binding. No model batch or raw browser commands. Use web_search or web_fetch for supported web access.",
"inspect_media": "Inspect local workspace images, SVGs, videos, and PDF pages with the current multimodal model. Samples bounded timestamped video frames uniformly, at scene cuts, or from temporally diverse motion peaks; renders SVG to PNG; exports stills or clips; concatenates ranges; changes clip speed while preserving audio pitch; and renders query-relevant PDF pages. Prefer these native operations over raw ffmpeg. Increase max_dimension only for small visual details; saved exports keep source quality.", "inspect_media": "Inspect local workspace images, SVGs, videos, and PDF pages with the current multimodal model. Samples bounded timestamped video frames uniformly, at scene cuts, or from temporally diverse motion peaks; renders SVG to PNG; exports stills or clips; concatenates ranges; changes clip speed while preserving audio pitch; and renders query-relevant PDF pages. Prefer these native operations over raw ffmpeg. Increase max_dimension only for small visual details; saved exports keep source quality.",
"extract_text": "Extract exact visible text, confidence, and pixel centers from a local workspace image with Odysseus local OCR. Use for screenshots, scans, labels, numbers, receipts, and text-location tasks; use inspect_media for general visual understanding.", "extract_text": "Extract exact visible text, confidence, and pixel centers from a local workspace image with Odysseus local OCR. Use for screenshots, scans, labels, numbers, receipts, and text-location tasks; use inspect_media for general visual understanding.",
@@ -129,7 +129,7 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
"edit_document": "Preferred tool for editing an existing document — targeted find-and-replace. Use for any small change: add a function, fix a bug, tweak a section, rename things.", "edit_document": "Preferred tool for editing an existing document — targeted find-and-replace. Use for any small change: add a function, fix a bug, tweak a section, rename things.",
"update_document": "Replace the entire active document content. ONLY for full rewrites (>50% changed). Do not use for small edits — use edit_document instead.", "update_document": "Replace the entire active document content. ONLY for full rewrites (>50% changed). Do not use for small edits — use edit_document instead.",
"suggest_document": "Suggest changes to the active document with explanations. For code review, proofreading, feedback requests.", "suggest_document": "Suggest changes to the active document with explanations. For code review, proofreading, feedback requests.",
"generate_image": "Generate an AI image from a text prompt. Specify model, size, and quality. Art, illustrations, photos.", "generate_image": "Generate a NEW AI image from a text prompt (add content not already in the photo, e.g. a moon). Specify model, size, and quality. Art, illustrations, photos. Not for tone/filter edits.",
"chat_with_model": "Send a message to a different AI model. Compare responses, get specialized help, delegate tasks.", "chat_with_model": "Send a message to a different AI model. Compare responses, get specialized help, delegate tasks.",
"ask_teacher": "Ask a more capable model for help with a difficult problem. Escalate complex tasks.", "ask_teacher": "Ask a more capable model for help with a difficult problem. Escalate complex tasks.",
"pipeline": "Run a multi-step AI pipeline with multiple models. Chain tasks together in sequence.", "pipeline": "Run a multi-step AI pipeline with multiple models. Chain tasks together in sequence.",
@@ -191,6 +191,10 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
"list_cookbook_servers": "List the cookbook's configured servers (remote GPU boxes + local) and which is the current default. Use this BEFORE download_model/serve_model when the user didn't name a host — to decide where to run, or to ask the user which server when ambiguous. Downloads/serves default to the cookbook's selected server, NOT localhost.", "list_cookbook_servers": "List the cookbook's configured servers (remote GPU boxes + local) and which is the current default. Use this BEFORE download_model/serve_model when the user didn't name a host — to decide where to run, or to ask the user which server when ambiguous. Downloads/serves default to the cookbook's selected server, NOT localhost.",
"app_api": "Generic loopback to allowed Odysseus internal endpoints. Use this when the user wants something the UI can do but there's no named tool for it. Covers calendar, gallery, library/documents, memory, notes, tasks, settings, research, compare, cookbook GPUs/state — allowed UI buttons hit /api/* endpoints and you can hit them too. Sensitive auth/user/admin/shell paths and host-control Cookbook mutation routes are blocked; do NOT use app_api for shell commands, package installs, engine rebuilds, or PID signalling. Use named command tooling for shell commands. action='endpoints' with filter=<keyword> lists available endpoints. action='call' takes method+path+body. Hits same routes the UI uses — auth flows free. NOTE: themes are NOT an API endpoint — use the ui_control tool (create_theme / set_theme), not app_api. SESSIONS/CHATS: do NOT use app_api for these — GET /api/sessions returns EMPTY for tool calls (it's owner-filtered and tool calls authenticate as a different identity). EMAIL ACCOUNTS: do NOT use /api/email/accounts via app_api; use list_email_accounts, list_emails, and read_email instead. To list/rename/archive/delete/fork chats use the list_sessions and manage_session tools instead.", "app_api": "Generic loopback to allowed Odysseus internal endpoints. Use this when the user wants something the UI can do but there's no named tool for it. Covers calendar, gallery, library/documents, memory, notes, tasks, settings, research, compare, cookbook GPUs/state — allowed UI buttons hit /api/* endpoints and you can hit them too. Sensitive auth/user/admin/shell paths and host-control Cookbook mutation routes are blocked; do NOT use app_api for shell commands, package installs, engine rebuilds, or PID signalling. Use named command tooling for shell commands. action='endpoints' with filter=<keyword> lists available endpoints. action='call' takes method+path+body. Hits same routes the UI uses — auth flows free. NOTE: themes are NOT an API endpoint — use the ui_control tool (create_theme / set_theme), not app_api. SESSIONS/CHATS: do NOT use app_api for these — GET /api/sessions returns EMPTY for tool calls (it's owner-filtered and tool calls authenticate as a different identity). EMAIL ACCOUNTS: do NOT use /api/email/accounts via app_api; use list_email_accounts, list_emails, and read_email instead. To list/rename/archive/delete/fork chats use the list_sessions and manage_session tools instead.",
"edit_image": "Edit an image in the gallery: upscale (increase resolution), remove background (rembg), inpaint (fill selected area), or harmonize (blend edits). Specify image ID and action.", "edit_image": "Edit an image in the gallery: upscale (increase resolution), remove background (rembg), inpaint (fill selected area), or harmonize (blend edits). Specify image ID and action.",
"plan_edit": "Plan a multi-step edit for the open image editor and return ordered editor ops to run.",
"run_editor_command": "Run one deterministic editor op (adjustments, masked filters, selection, layers, import/place, export, screenshot) on the open image editor.",
"get_editor_screenshot": "Capture the open image editor's current canvas as a PNG data URL for inspection.",
"ai_edit_image": "Apply an AI image edit (remove/inpaint objects, generative replace, remove-bg, upscale, denoise, enhance-face) to the open image and place the result as a new layer. Use for object removal/addition/replacement; never approximate those with selection/adjustment/filter ops.",
"trigger_research": "Start a deep research job on any topic — appears in the Deep Research sidebar, streams progress, produces a detailed report. Use for 'research X', 'look into Y', 'do deep research on Z', 'investigate'. NOT a scheduled task — it runs now and surfaces in the sidebar.", "trigger_research": "Start a deep research job on any topic — appears in the Deep Research sidebar, streams progress, produces a detailed report. Use for 'research X', 'look into Y', 'do deep research on Z', 'investigate'. NOT a scheduled task — it runs now and surfaces in the sidebar.",
"manage_bg_jobs": "Inspect and control detached background `bash` jobs (the ones started with a `#!bg` marker). action='list' shows this chat's jobs (id/status/age/command); action='output' returns a job's captured output so far (check on a long-running job, or re-read a finished one); action='kill' stops a runaway job by id. Use for 'is the background job done', 'check on that job', 'show the build output', 'kill the background job', 'stop the bg task'. output/kill need a job_id from list.", "manage_bg_jobs": "Inspect and control detached background `bash` jobs (the ones started with a `#!bg` marker). action='list' shows this chat's jobs (id/status/age/command); action='output' returns a job's captured output so far (check on a long-running job, or re-read a finished one); action='kill' stops a runaway job by id. Use for 'is the background job done', 'check on that job', 'show the build output', 'kill the background job', 'stop the bg task'. output/kill need a job_id from list.",
} }
+76 -13
View File
@@ -70,7 +70,7 @@ def _repair_function_arg_aliases(tool_type: str, args: dict[str, Any]) -> dict[s
command = args.get("command") command = args.get("command")
if isinstance(command, str): if isinstance(command, str):
path, separator, content = command.partition("\n") path, separator, content = command.partition("\n")
if separator and path.strip() and content.strip(): if separator and path.strip() and content:
args["path"] = path.strip() args["path"] = path.strip()
args["content"] = content args["content"] = content
logger.warning("Normalized legacy `command` write shape to path/content") logger.warning("Normalized legacy `command` write shape to path/content")
@@ -494,14 +494,14 @@ FUNCTION_TOOL_SCHEMAS = [
"type": "function", "type": "function",
"function": { "function": {
"name": "youtube_tool", "name": "youtube_tool",
"description": "Read YouTube-specific data without fighting the JS UI. Use for YouTube video comments, transcripts, metadata, or latest video from a channel. For general web lookup use web_search; for interacting with the visible site use private_browser.", "description": "Read YouTube-specific data (comments, transcripts, metadata, or latest video from a channel) without fighting the YouTube JS UI. YOUTUBE ONLY: it accepts only youtube.com or youtu.be links — a video URL, a shorts/live link, or an 11-character video_id, or a channel /@handle/videos URL. It does NOT accept any other website. For a non-YouTube URL do not use this tool: use web_fetch for one specific page or web_search for an open-ended lookup; use private_browser only to drive the visible browser.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"action": {"type": "string", "enum": ["comments", "transcript", "metadata", "latest_channel_video"], "description": "What YouTube data to retrieve. Use latest_channel_video with max_results for latest N uploads from a channel."}, "action": {"type": "string", "enum": ["comments", "transcript", "metadata", "latest_channel_video"], "description": "What YouTube data to retrieve. Use latest_channel_video with max_results for latest N uploads from a channel."},
"url": {"type": "string", "description": "YouTube video or channel URL"}, "url": {"type": "string", "description": "A youtube.com or youtu.be URL (video, watch?v=, shorts, or a channel /@handle/videos URL). No other hosts."},
"video_url": {"type": "string", "description": "YouTube video URL"}, "video_url": {"type": "string", "description": "A youtube.com or youtu.be video URL"},
"video_id": {"type": "string", "description": "YouTube video id"}, "video_id": {"type": "string", "description": "The 11-character YouTube video id (e.g. jNQXAC9IVRw)"},
"channel_url": {"type": "string", "description": "YouTube channel URL or /@handle/videos URL"}, "channel_url": {"type": "string", "description": "YouTube channel URL or /@handle/videos URL"},
"handle": {"type": "string", "description": "YouTube handle, e.g. @OpenAI"}, "handle": {"type": "string", "description": "YouTube handle, e.g. @OpenAI"},
"max_results": {"type": "integer", "description": "Maximum comments, uploads, or results to return, capped at 50"} "max_results": {"type": "integer", "description": "Maximum comments, uploads, or results to return, capped at 50"}
@@ -1497,7 +1497,7 @@ FUNCTION_TOOL_SCHEMAS = [
"type": "function", "type": "function",
"function": { "function": {
"name": "generate_image", "name": "generate_image",
"description": "Generate an AI image from a text prompt and save it to the gallery. If this tool reports that no image model or endpoint is configured, report that limitation directly; do not substitute Bash, Python, SVG, or another tool.", "description": "Generate a NEW image from a text prompt and place it as a layer in the open editor or save it to the gallery. Use for adding content that is not already in the photo (e.g. 'add a full moon in the sky'). If this tool reports that no image model or endpoint is configured, report that limitation directly; do NOT substitute deterministic editor ops (selection/adjustments/filters), Bash, Python, or SVG.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1527,6 +1527,75 @@ FUNCTION_TOOL_SCHEMAS = [
} }
} }
}, },
{
"type": "function",
"function": {
"name": "plan_edit",
"description": "Plan a photo-editing program for the currently open image editor. Returns an ordered list of editor ops to run against the canvas. Route object removal, added content, or replaced content to ai_edit_image/generate_image; use deterministic ops only for tone/colour/filter/crop/text/selection edits. If no image provider is configured for a generative request, say so instead of planning a substitute deterministic edit. Use the canonical adjustment/filter shapes (adjustments.apply/applyMasked params {\"type\", \"params\"}; filters.apply/applyMasked params {\"id\"}); never invent adjustment types or filter ids. New ops: photo.autoTone; retouch.skin/blemish/teeth; selection.face/background. Portrait/look-better ('fix the skin', 'fix my teeth') -> selection.face then retouch.skin (and retouch.teeth for teeth) then ONE gentle photo.autoTone; never a timid single step. Low-key/'fix the lighting' on a dark image: at most ONE global lift (prefer photo.autoTone once or shadows-highlights); never stack autoTone/brightness/exposure/levels, and keep highlights <= 0. Temperature sign: moody/cool/cinematic -> white-balance temperature NEGATIVE; warm/golden/sunset -> POSITIVE. Background softer -> selection.background then filters.applyMasked {id:'blur-gaussian'} (never a plain global blur). A vague prompt with no concrete defect ('clean this up') -> return {\"reply\": <question>, \"steps\": [], \"ask_user\": {\"question\":..., \"options\":[...]}} with 2-3 concrete options instead of guessing.",
"parameters": {
"type": "object",
"properties": {
"prompt": {"type": "string", "description": "What the user wants done to the image"},
"state": {"type": "object", "description": "Optional current editor state (layers, selection, document info)"}
},
"required": ["prompt"]
}
}
},
{
"type": "function",
"function": {
"name": "run_editor_command",
"description": "Run a single deterministic operation on the open image editor and return its result. Use catalog ops such as adjustments.*, filters.applyMasked, selection.*, layers.*, document.importImage, layers.placeImage, export.png, screenshot. Do NOT use for AI/masked edits (use ai_edit_image) or new imagery (use generate_image). Canonical shapes: adjustments.apply/applyMasked params = {\"type\": <brightness-contrast|exposure|white-balance|hue-saturation|vibrance|black-white|shadows-highlights|levels|curves|color-balance|photo-filter|selective-color|gradient-map|invert|posterize|threshold|channel-mixer|color-lookup>, \"params\": {...}}; filters.apply/applyMasked params = {\"id\": <blur-gaussian|blur-motion|blur-zoom|sharpen|sharpen-edges|unsharp-mask|noise-add|noise-despeckle|pixelate-mosaic|pixelate-crystallize|stylize-emboss|stylize-find-edges|stylize-solarize|distort-twirl|distort-pinch|distort-spherize|distort-ripple|render-clouds>}. Never nest a numeric bag under {\"adjustments\": {...}} and never invent adjustment types or filter ids (no \"vignette\" or \"clarity\"). Additional canonical ops: photo.autoTone {}; retouch.skin {smooth,opacity,blemish,radius,threshold}; retouch.blemish {radius,threshold}; retouch.teeth {desat,bright}; selection.face {mode}; selection.background {mode,subject,fuzziness}. At most one global brightness/exposure lift (prefer shadows-highlights; keep highlights <= 0). Never stack global lifts.",
"parameters": {
"type": "object",
"properties": {
"op": {"type": "string", "description": "Editor op name from the catalog, e.g. adjustments.applyMasked"},
"params": {"type": "object", "description": "Parameters for the op"},
"session": {"type": "string", "description": "Editor session id (defaults to the open session)"},
"wait": {"type": "boolean", "description": "Wait for the editor result (default true)"}
},
"required": ["op"]
}
}
},
{
"type": "function",
"function": {
"name": "get_editor_screenshot",
"description": "Capture the open image editor's current canvas as a PNG data URL. Use to inspect the result before choosing the next edit.",
"parameters": {
"type": "object",
"properties": {
"session": {"type": "string", "description": "Editor session id (defaults to the open session)"}
}
}
}
},
{
"type": "function",
"function": {
"name": "ai_edit_image",
"description": "Apply an AI image edit and place the result as a NEW layer, never overwriting the original. Two modes. mode='instruction' (default): send the WHOLE image with a clear prompt and no mask — use a rephrased instruction that NAMES what must stay unchanged, e.g. 'replace the background with a sunny beach, keep the woman exactly the same'. Use this for background/scene replacement, changing the season or style, adding an object to the scene (a moon, a person), and removing diffuse distractions. mode='masked': masked inpaint on a selected region — use ONLY for an explicit regional removal of a clearly identified object ('remove the person on the left'); select the object first so the mask is tight. NEVER approximate object removal/addition/replacement with selection, adjustment, filter or transform ops; if no image provider is configured, report that instead of substituting a deterministic edit. Other ops: remove-bg, upscale (2x/4x), denoise, enhance-face.",
"parameters": {
"type": "object",
"properties": {
"op": {"type": "string", "enum": ["remove-bg", "upscale", "denoise", "enhance-face", "inpaint", "generative"], "description": "AI edit operation"},
"mode": {"type": "string", "enum": ["instruction", "masked"], "description": "instruction = whole-image edit with no mask (background/season/style/add-object/diffuse removal); masked = tight-mask regional removal of a selected object. Default instruction."},
"prompt": {"type": "string", "description": "Instruction for inpaint/generative (in instruction mode, name what must stay unchanged)"},
"image": {"type": "string", "description": "Optional source (data URL or gallery image id); defaults to the current canvas/screenshot"},
"mask": {"type": "string", "description": "Optional base64 PNG mask (white = region to edit); required for mode='masked'"},
"scale": {"type": "number", "description": "Upscale factor (2 or 4)"},
"strength": {"type": "number", "description": "Denoise strength (0-1)"},
"steps": {"type": "integer", "description": "Optional sampler steps. Use 8 for removal/expand edits (default 4 is too weak for those)."},
"place": {"type": "boolean", "description": "Place the result as a new layer (default true)"},
"model": {"type": "string", "description": "Optional image model id to route this edit to (from GET /api/editor/image-models); omit to use the configured default"},
"session": {"type": "string", "description": "Editor session id"}
},
"required": ["op"]
}
}
},
{ {
"type": "function", "type": "function",
"function": { "function": {
@@ -2200,13 +2269,7 @@ def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock
elif tool_type == "get_workspace": elif tool_type == "get_workspace":
content = "" content = ""
elif tool_type == "write_file": elif tool_type == "write_file":
body = args.get("content") content = args.get("path", "") + "\n" + args.get("content", "")
if isinstance(body, str) and body.strip():
content = args.get("path", "") + "\n" + body
else:
# Preserve missing/empty intent for WriteFileTool instead of folding
# all empty shapes into the same path-plus-newline representation.
content = json.dumps(args)
elif tool_type == "edit_file": elif tool_type == "edit_file":
content = json.dumps(args) content = json.dumps(args)
elif tool_type == "apply_patch": elif tool_type == "apply_patch":
+1
View File
@@ -28,5 +28,6 @@ TOOL_TAGS = {
"tail_serve_output", "list_downloads", "cancel_download", "search_hf_models", "tail_serve_output", "list_downloads", "cancel_download", "search_hf_models",
"list_cached_models", "list_serve_presets", "serve_preset", "adopt_served_model", "list_cached_models", "list_serve_presets", "serve_preset", "adopt_served_model",
"list_cookbook_servers", "edit_image", "trigger_research", "manage_research", "list_cookbook_servers", "edit_image", "trigger_research", "manage_research",
"plan_edit", "run_editor_command", "get_editor_screenshot", "ai_edit_image",
"app_api", "app_api",
} | BUILTIN_EMAIL_TOOLS } | BUILTIN_EMAIL_TOOLS
+6 -6
View File
@@ -227,7 +227,7 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None, *, impor
desc = str(raw_args.get("description") or "") desc = str(raw_args.get("description") or "")
if re.search(r"\b(remind|reminder|alarm)\b", desc, re.I): if re.search(r"\b(remind|reminder|alarm)\b", desc, re.I):
raw = desc raw = desc
if raw in (None, "", False): if raw is None or raw == "" or raw is False:
return None return None
if raw is True: if raw is True:
return 10 return 10
@@ -236,10 +236,10 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None, *, impor
text = str(raw).strip().lower() text = str(raw).strip().lower()
if text in {"none", "no", "off", "false"}: if text in {"none", "no", "off", "false"}:
return None return None
m = re.search(r"(?<!\d)(\d+)\s*(?:minutes?|mins?|m)\b", text) m = re.search(r"(\d+)\s*(?:minutes?|mins?|m)\b", text)
if m: if m:
return max(0, int(m.group(1))) return max(0, int(m.group(1)))
m = re.search(r"(?<!\d)(\d+)\s*(?:hours?|hrs?|h)\b", text) m = re.search(r"(\d+)\s*(?:hours?|hrs?|h)\b", text)
if m: if m:
return max(0, int(m.group(1)) * 60) return max(0, int(m.group(1)) * 60)
if text.isdigit(): if text.isdigit():
@@ -251,7 +251,7 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None, *, impor
if minutes_before is None: if minutes_before is None:
return desc return desc
reminder_only = re.compile( reminder_only = re.compile(
r"^\s*(?:remind(?:er)?|alarm)\s*(?::\s*)?\d+\s*" r"^\s*(?:remind(?:er)?|alarm)\s*:?\s*\d+\s*"
r"(?:minutes?|mins?|m|hours?|hrs?|h)\b.*$", r"(?:minutes?|mins?|m|hours?|hrs?|h)\b.*$",
re.I, re.I,
) )
@@ -497,8 +497,8 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None, *, impor
delta = None delta = None
if dur: if dur:
import re as _re_d import re as _re_d
h = _re_d.search(r'(?<!\d)(\d+)\s*(?:h|hr|hours?)', dur) h = _re_d.search(r'(\d+)\s*(?:h|hr|hours?)', dur)
m = _re_d.search(r'(?<!\d)(\d+)\s*(?:m|min|minutes?)', dur) m = _re_d.search(r'(\d+)\s*(?:m|min|minutes?)', dur)
secs = (int(h.group(1)) * 3600 if h else 0) + (int(m.group(1)) * 60 if m else 0) secs = (int(h.group(1)) * 3600 if h else 0) + (int(m.group(1)) * 60 if m else 0)
if secs > 0: if secs > 0:
delta = timedelta(seconds=secs) delta = timedelta(seconds=secs)
+1 -1
View File
@@ -322,7 +322,7 @@ async def do_manage_notes(content: str, owner: Optional[str] = None) -> Dict:
if looks_like_reminder: if looks_like_reminder:
temporal = re.search( temporal = re.search(
r"\b(?:today|tonight|tomorrow|tmrw|yesterday)\b(?:\s+(?:at\s+)?\d{1,2}(?::\d{2})?\s*(?:am|pm)?)?" r"\b(?:today|tonight|tomorrow|tmrw|yesterday)\b(?:\s+(?:at\s+)?\d{1,2}(?::\d{2})?\s*(?:am|pm)?)?"
r"|\b\d{1,2}(?::\d{2})?(?:\s*(?:am|pm))?\s+(?:today|tonight|tomorrow|tmrw|yesterday)\b" r"|\b\d{1,2}(?::\d{2})?\s*(?:am|pm)?\s+(?:today|tonight|tomorrow|tmrw|yesterday)\b"
r"|\bin\s+\d+\s*(?:hour|hr|minute|min|day)s?\b", r"|\bin\s+\d+\s*(?:hour|hr|minute|min|day)s?\b",
lower_combined, lower_combined,
) )
+1
View File
@@ -61,3 +61,4 @@ def backend_workspace_path(path: str | None) -> str:
return container_root return container_root
return str(PurePosixPath(container_root) / rel) return str(PurePosixPath(container_root) / rel)
return expanded return expanded
+369
View File
@@ -0,0 +1,369 @@
# Wave 6 test-isolation foundation
## Provenance and boundary
- Worktree: `/home/alteixeira20/Projects/Odysseus/odysseus-wave6-test-isolation`.
- Starting branch: `wave6/test-isolation-foundation`.
- Starting HEAD: `b1666951faf8285054e1ca90f11533b0fb53fb57`.
- Starting TREE: `a3dee24e3c56c22e1d3f9805f409bf16b086c3d4`.
- `git status --short --branch`: `## wave6/test-isolation-foundation` only.
- `git status --porcelain`: empty. Starting worktree clean.
- No rebase, merge, push, PR edit, or access to the Wave 4 worktree.
- All changes are test code, test helpers, or this test validation report.
No production code changed; no STOP-level exception was needed.
## Contamination map
| Source | Mutation / binding | Previous cleanup | Escaping state / effect |
| --- | --- | --- | --- |
| Both tests in `tests/test_truncate_message_count_regression.py`, through `_make_manager` | Creates an unowned `mkstemp` DB; directly sets `os.environ["DATABASE_URL"]`; reloads `core.database` then `core.session_manager` | Only the assertion's ORM session is closed | Environment, engine, factory, declarative Base, ORM classes, manager globals, and registered engine listeners survive; database files are never deleted |
| Collection of `tests/test_manage_tasks_owner_scope.py` | Creates a module-lifetime temporary DB and assigns `_TS` permanently to `core.database.SessionLocal` | Some individual rows deleted; ORM sessions closed | The collection-time factory assignment leaks to unrelated tests; truncation reload replaces it, so production handlers and `_seed` / `_get` use different databases |
| `test_checkin_digest_owner_scope.py`, `test_gallery_owner_filter_single_user.py`, `test_document_session_owner_scope.py` | Create module-lifetime DB files / engines and retain local factories | Some rows deleted / route factory restored / ORM sessions closed | Owned files and engines lack teardown; retained imported ORM identities can disagree with reloaded module globals |
| `tests/conftest.py` bootstrap | `DATABASE_URL` was set only if absent before importing `core.database` | No environment restoration | An inherited developer URL could be opened and initialized during collection |
`importlib.reload` updates the existing module dictionary in place; it does
not change the `sys.modules` module object's identity. Previously imported
symbols nevertheless keep their old class/factory identities. In particular,
`src.database` re-exports old objects, while reloaded `core.session_manager`
rebinds its `DbSession`, `DbChatMessage`, `DbDocument`, `SessionLocal`, and
`SessionManager`. Old functions still resolve globals through those changed
module dictionaries. Each database reload also registers a new SQLite connect
listener on SQLAlchemy's Engine class.
The current failures are principally in `test_manage_tasks_owner_scope.py`:
`_seed` writes through its retained `_TS`, but `do_manage_tasks` imports the
now-replaced factory from `core.database` at call time. Ownerless / cross-owner
mutations incorrectly observe "task not found"; successful mutation/list/create
witnesses likewise read or write the wrong test database. Four invalid-date
cases still pass, so the observed contamination count is 16 rather than all
20 task-owner cases.
`src.settings` has TTL caches, but those caches do not resolve DATABASE_URL.
The contaminating helper neither patches nor reloads settings. The database
URL is read directly from the environment at database import/reload time;
settings cache invalidation is not the correction for this leak.
## Baseline reproduction before editing
All baseline commands explicitly selected in-memory bootstrap storage. Python
with installed pytest/SQLAlchemy was
`/tmp/pr40-final-validation-venv/bin/python` (pytest 9.1.1, SQLAlchemy 2.1.1).
```sh
DATABASE_URL=sqlite:///:memory: /tmp/pr40-final-validation-venv/bin/python -m pytest -q -p no:cacheprovider tests/test_manage_tasks_owner_scope.py tests/test_checkin_digest_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_document_session_owner_scope.py tests/test_truncate_message_count_regression.py
# 30 passed, 9 warnings in 2.48s
DATABASE_URL=sqlite:///:memory: /tmp/pr40-final-validation-venv/bin/python -m pytest -q -p no:cacheprovider tests/test_truncate_message_count_regression.py tests/test_manage_tasks_owner_scope.py tests/test_checkin_digest_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_document_session_owner_scope.py
# 16 failed, 14 passed, 9 warnings in 2.40s
DATABASE_URL=sqlite:///:memory: /tmp/pr40-final-validation-venv/bin/python -m pytest -q -p no:cacheprovider -rx --runxfail tests/test_runtime_behavior_regressions.py -k negative_web
# 2 failed (IndexError), 1 passed, 13 deselected, 1 warning in 1.61s
```
The same 30 database tests shuffled with `random.Random(42)` before editing
reported **9 failed, 21 passed**, 9 warnings in 2.37s. Exact command:
```sh
/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py seed:42 before-seed42 tests/test_manage_tasks_owner_scope.py tests/test_checkin_digest_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_document_session_owner_scope.py tests/test_truncate_message_count_regression.py
```
## Isolation correction and regression evidence
- `tests/helpers/database.py` owns a fresh TemporaryDirectory, file-backed
SQLite engine with NullPool, real schema, and factory for each invocation.
Teardown disposes the engine and removes the directory including sidecars.
- The truncation fixture temporarily patches only resource bindings in
`core.database`, `src.database`, and `core.session_manager`, with a matching
temporary environment URL. Patches are undone before resource disposal.
No module reload, module eviction, new ORM class, or listener registration.
- Four owner modules now own a disposable database per test rather than at
collection. Only the task-owner module patches the canonical factory; that
patch is scoped to the test and restored during teardown. The other modules
retain their existing handler/route wiring and ownership assertions.
- The existing pytest bootstrap always chooses in-memory storage before
production database imports and registers environment restoration with the
pytest configuration lifecycle. No new process-wide autouse cleanup fixture.
- `tests/test_database_test_isolation.py` checks real persistent truncation,
exact class/factory restoration, file removal, clean subsequent database
state, and exception teardown. Child pytest runs execute the actual original
witness entry points in both directions and check process-visible bindings
after collection and after every test. An inherited developer-DB sentinel
remains byte-identical, with no database sidecars created, and the inherited
environment URL is restored after pytest exits.
Mutation checks temporarily restored one original file at a time, ran
`test_actual_tests_restore_process_state_and_ignore_inherited_database[True]`,
and restored the corrected file in `finally`. Both mutations made the new
regression fail (pytest exit 1): original truncation was caught as
"test leaked database or module state", original task-owner collection as
"collection changed database bindings". No mutation was retained.
## Historical negative-web witnesses and production blocker
Both parameter cases in
`test_negative_web_wording_withholds_the_web_tools_unhandled` intended to prove
that inferred negative-web wording can receive a memory-only answer without
`web_search` or `web_fetch` being offered to the model:
1. `Summarise what you already know. Do not search the web.`
2. `No web search please, just tell me what you know about Python decorators.`
Both previously indexed an empty fake-model call list and xfailed on unrelated
`IndexError`. The fake also emitted provider-shaped `choices` JSON at a seam
that now consumes normalized `{"delta": ...}` SSE.
The repaired witnesses drive the unmodified public `stream_agent_loop`, mock
only model output and existing infrastructure edges, capture the actual SSE,
and require one model call, withheld web schemas, and delivered fake answer.
The existing passing "don't search online" control and positive search
control remain. The fake now emits normalized SSE.
The two historical xfails remain **strict**, with
`raises=_MemoryOnlyTurnBlocked`. That exception is emitted only when the real
loop makes zero model calls and returns the exact known disabled-web response.
IndexError, arbitrary assertions, or other exceptions no longer qualify.
This is a genuine production defect outside the lane: negative-web detection
is true for both phrasings, but `_classify_agent_request` returns `{'web'}` and
`_web_search_unavailable_for_turn` returns true after the negative has disabled
web tools. The loop short-circuits the requested memory-only response with:
> Web access is disabled for this turn. Enable web search and resend the request.
The passing "Answer from memory only, don't search online." control has
`{'memory', 'web'}` intent domains and does not short-circuit. A minimized
reproduction using those three production functions requires neither a model
nor a mock. Relevant production locations (intentionally untouched):
`src/agent_loop.py` negative detector near line 864, classifier near 10371,
negative-tool disabling near 20735, unavailable calculation near 22233, and
short-circuit response near 25101.
```sh
/tmp/pr40-final-validation-venv/bin/python -m pytest -q -p no:cacheprovider --runxfail tests/test_runtime_behavior_regressions.py -k negative_web_wording_withholds_the_web_tools_unhandled
# 2 failed with _MemoryOnlyTurnBlocked, 14 deselected, 1 warning in 0.75s
```
No production repair was attempted. Runtime intent/availability behavior,
authority, completion/evidence, and Wave 4 effects files remain untouched.
This blocker prevents a READY release-foundation verdict despite passing test
isolation validation.
## Final order validation
The focused manifest has 50 cases: the five baseline database modules plus
`test_database_test_isolation.py` and `test_runtime_behavior_regressions.py`.
Normal collection order is alphabetical by the seven explicitly selected files.
Reversed and seeded runs reorder **individual collected test cases**, including
parameterized cases. Both contamination directions also vary file collection
order. Every execution manifest is retained as JSON in the evidence directory.
| Run | Result |
| --- | --- |
| Normal | 48 passed, 2 xfailed, 0 skipped, 11 warnings in 9.01s |
| Reverse of all collected cases | 48 passed, 2 xfailed, 0 skipped, 11 warnings in 8.47s |
| Truncation module first, owner witnesses afterward | 48 passed, 2 xfailed, 0 skipped, 11 warnings in 8.00s |
| All four owner modules first, truncation afterward | 48 passed, 2 xfailed, 0 skipped, 11 warnings in 8.98s |
| Seed 1 | 48 passed, 2 xfailed, 0 skipped, 11 warnings in 9.01s |
| Seed 42 | 48 passed, 2 xfailed, 0 skipped, 11 warnings in 8.98s |
| Seed 2026 | 48 passed, 2 xfailed, 0 skipped, 11 warnings in 8.97s |
Exact executed commands:
```sh
/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py normal after-normal tests/test_checkin_digest_owner_scope.py tests/test_database_test_isolation.py tests/test_document_session_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_manage_tasks_owner_scope.py tests/test_runtime_behavior_regressions.py tests/test_truncate_message_count_regression.py
/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py reverse after-reverse tests/test_checkin_digest_owner_scope.py tests/test_database_test_isolation.py tests/test_document_session_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_manage_tasks_owner_scope.py tests/test_runtime_behavior_regressions.py tests/test_truncate_message_count_regression.py
/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py normal after-truncation-first tests/test_truncate_message_count_regression.py tests/test_checkin_digest_owner_scope.py tests/test_database_test_isolation.py tests/test_document_session_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_manage_tasks_owner_scope.py tests/test_runtime_behavior_regressions.py
/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py normal after-owners-first tests/test_checkin_digest_owner_scope.py tests/test_document_session_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_manage_tasks_owner_scope.py tests/test_truncate_message_count_regression.py tests/test_database_test_isolation.py tests/test_runtime_behavior_regressions.py
/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py seed:1 after-seed1 tests/test_checkin_digest_owner_scope.py tests/test_database_test_isolation.py tests/test_document_session_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_manage_tasks_owner_scope.py tests/test_runtime_behavior_regressions.py tests/test_truncate_message_count_regression.py
/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py seed:42 after-seed42 tests/test_checkin_digest_owner_scope.py tests/test_database_test_isolation.py tests/test_document_session_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_manage_tasks_owner_scope.py tests/test_runtime_behavior_regressions.py tests/test_truncate_message_count_regression.py
/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py seed:2026 after-seed2026 tests/test_checkin_digest_owner_scope.py tests/test_database_test_isolation.py tests/test_document_session_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_manage_tasks_owner_scope.py tests/test_runtime_behavior_regressions.py tests/test_truncate_message_count_regression.py
```
The order driver is test-session-local; it is not installed into conftest or
production and never changes the normal suite's ordering. Its exact source
(for recreating `/tmp/odysseus-wave6-isolation-evidence/run_order.py`) is:
```python
import json
import os
from pathlib import Path
import random
import sys
import pytest
out = Path('/tmp/odysseus-wave6-isolation-evidence')
mode, label, *modules = sys.argv[1:]
class Order:
@pytest.hookimpl(trylast=True)
def pytest_collection_modifyitems(self, items):
if mode == 'reverse':
items.reverse()
elif mode.startswith('seed:'):
random.Random(int(mode.split(':')[1])).shuffle(items)
(out / f'{label}-manifest.json').write_text(json.dumps([i.nodeid for i in items], indent=2) + '\n')
os.environ['DATABASE_URL'] = 'sqlite:///:memory:'
args = ['-q', '-p', 'no:cacheprovider', '-rsx', *modules]
(out / f'{label}-command.json').write_text(json.dumps({'argv':sys.argv,'pytest_args':args,'DATABASE_URL':os.environ['DATABASE_URL']},indent=2) + '\n')
raise SystemExit(pytest.main(args, plugins=[Order()]))
```
## Adjacent and complete validation
The first complete run finished with **44 failed, 12509 passed, 62 skipped,
2 xfailed**, 184 warnings and 6 passing subtests in 391.85s. Its failure
inventory was 42 missing-Playwright-package failures and two generated
configuration-reference freshness failures. The local worktree had not had
`npm ci` run; the generator includes test-source line references, and the
bootstrap edit moved the existing references by one line.
`npm ci --ignore-scripts` installed the four already-locked packages; package
and lock files were unchanged. Cached Chromium launched successfully
(version 151.0.7922.34). A bootstrap comment now explains why isolation must
precede collection and preserves the existing referenced line locations,
without editing the generated website or the generator. This follow-up is
commit `f67d0388fd805867a0920dbffd4da056b1361e38`
(`fix(tests): preserve bootstrap reference locations`).
```sh
npm ci --ignore-scripts
# exit 0; 4 packages installed, lockfile and package policy unchanged
python scripts/generate_env_reference.py --check
# exit 0; website/configuration-reference.md is up to date (112 variables)
/tmp/pr40-final-validation-venv/bin/python -m pytest -q -p no:cacheprovider tests/test_document_module_api.py tests/test_env_reference.py
# 16 passed, 1 warning in 17.00s
```
The first full run and the 44-case focused rerun are retained separately from
the final complete run. The 44-case rerun reported **1 failed, 43 passed**
in 73.61s: the existing font/color browser witness observed its font-size
icon still showing `13` after its text had changed to size 5 (expected `24`).
An isolated reproduction of that unchanged test passed in 2.13s. This is an
observed intermittent browser UI witness; no database failure was involved,
and a production root cause has not been established. The test and editor
implementation remain unchanged, with both observations retained rather
than suppressing the failed run. No failure was hidden by skipping or deleting a test.
```sh
/tmp/pr40-final-validation-venv/bin/python -m pytest -q -p no:cacheprovider -rsx tests/test_session_manager.py tests/test_session_manager_cleanup.py tests/test_session_manager_persist_guard.py tests/test_session_mutation_lookup.py tests/test_session_discovery_message_count.py tests/test_history_order_by_timestamp_regression.py tests/test_history_display_model_hydration.py tests/test_session_search.py tests/test_session_search_batch_fetch.py tests/test_session_list_owner_scope.py tests/test_session_owner_attribution.py tests/test_session_endpoint_owner_scope.py tests/test_session_concurrent.py tests/test_app_db_permissions.py tests/test_database_utcnow.py tests/test_tool_policy.py tests/test_turn_contract.py tests/test_chat_preprocess_tool_policy.py tests/test_chat_route_tool_policy.py tests/test_resend_message_nondestructive.py
# 1262 passed, 0 skipped, 0 xfailed, 7 warnings in 40.56s
/tmp/pr40-final-validation-venv/bin/python -m pytest -q -p no:cacheprovider -rsx
# 12556 passed, 59 skipped, 2 xfailed, 184 warnings, 6 subtests passed in 468.49s (0:07:48); exit 0
python -m compileall -q app.py core routes src services scripts tests
# exit 0
git diff --check
# exit 0, no output
git diff --check b1666951faf8285054e1ca90f11533b0fb53fb57
# exit 0, no output
git diff --name-only --diff-filter=U
# exit 0, no output
rg -n '^(<<<<<<< |=======$|>>>>>>> )' <each modified file>
# exit 1, no matches
```
No test was deleted, no skip was added, and no unrelated xfail was added. The
same two historical parameter cases remain xfailed for the demonstrated
production blocker, now restricted to that actual contract failure.
The isolation commit is
`e2b847c84d27ce785565757e81ca5293896eaab3`
(`fix(tests): isolate database and module state`). The final bounded commit
contains the repaired web witnesses and this validation report. Final commit,
HEAD, TREE and clean status are supplied in the accompanying final report to
avoid placing a commit's own hash inside its committed contents.
Full logs, commands, and per-run manifests from this session are in
`/tmp/odysseus-wave6-isolation-evidence/`.
The follow-up normal focused run after preserving bootstrap reference
locations is recorded in `after-bootstrap-followup.log`. It reported **48 passed, 2 xfailed**, 11 warnings in 9.55s. Its exact command is:
```sh
/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py normal after-bootstrap-followup tests/test_checkin_digest_owner_scope.py tests/test_database_test_isolation.py tests/test_document_session_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_manage_tasks_owner_scope.py tests/test_runtime_behavior_regressions.py tests/test_truncate_message_count_regression.py
```
The additional browser observation came from the 44-case manifest retained in
`full-failures-manifest.json`; every exact node ID in that diagnostic command is
retained in `full-failures-rerun-command.json`. The isolated diagnostic command:
```sh
/tmp/pr40-final-validation-venv/bin/python -m pytest -q -p no:cacheprovider tests/test_document_rich_font_color_controls.py::test_numeric_font_size_and_custom_colors_work_on_desktop_and_mobile
# 1 passed, 1 warning in 2.13s
```
## Independent review disposition
A read-only review of isolation commit e2b847c8 raised two concerns, which
were checked against the frozen source rather than accepted as proven leaks:
- `core.models._SESSION_MANAGER_INSTANCE`: `SessionManager.__init__` only
initializes its owned cache and loads metadata; it does not call the global
setter. `SessionManager.add_message` explicitly persists through its own
manager instead of calling `Session.add_message` / the singleton. Neither
truncation witness nor the helper invokes a singleton setter or API route
setup. Thus the asserted singleton mutation is absent from this lane's real
seam. Resetting that unrelated singleton would add cleanup without a proven
owned mutation and could hide a genuine leak elsewhere.
- Eager real-database import versus `clear_fake_database_modules`: the exact
pre-import block already exists on frozen b1666951. The clearing helper
intentionally leaves real modules with a string `__file__` intact and evicts
replacement fake modules lacking it. The correction neither adds that
pre-import nor changes fake-module identification. Its "real module" early
return is intended behavior, not a newly introduced cleanup conflict.
Neither concern supplied a failing reproducer. Both were resolved by the
existing source paths and the actual collection/teardown identity regression;
no speculative global cleanup was introduced.
The minimized production reproduction command was:
```sh
DATABASE_URL=sqlite:///:memory: /tmp/pr40-final-validation-venv/bin/python - <<'PYTHON'
from src.agent_loop import _explicitly_avoids_web_lookup, _classify_agent_request, _web_search_unavailable_for_turn
from src.tool_policy import WEB_TOOL_NAMES
for text in (
"Answer from memory only, don't search online.",
'Summarise what you already know. Do not search the web.',
'No web search please, just tell me what you know about Python decorators.',
):
intent = _classify_agent_request([{'role': 'user', 'content': text}], text)
domains = set(intent['domains'])
print(repr(text))
print('negative_web:', _explicitly_avoids_web_lookup(text))
print('intent_domains:', sorted(domains))
print('web_disabled_short_circuit:', _web_search_unavailable_for_turn(domains, set(WEB_TOOL_NAMES), text, None, None))
PYTHON
```
Results: negative-web detection true for all three; intent domains respectively
`['memory', 'web']`, `['web']`, `['web']`; short-circuit false, true, true.
## Final validation result and verdict
The final complete run in `full-pytest-final.log` exited 0:
**12556 passed, 59 skipped, 2 xfailed**, 184 warnings and 6 passing subtests in
468.49s. All 12617 collected cases are accounted for. The existing optional,
platform-specific and opt-in skips remain; no skip marker was added. Installing
the already-locked Node dependencies enabled three previously skipped cases,
so the skip count decreased from 62 in the first run to 59. No tests were
deleted and no unrelated xfail was introduced.
The font/color browser witness passed in the final complete run. Its failed
44-case diagnostic and passing isolated result remain documented as an
intermittent observation; no production root cause was established or fixed.
Database/environment/module isolation validation is green. Both historical
negative-web cases remain narrowly expected failures for a reproduced genuine
runtime defect outside the test-isolation boundary. These are integration
blockers; production intent/short-circuit behavior must be corrected in an
appropriate lane before removing their strict markers.
WAVE 6 TEST-ISOLATION FOUNDATION NOT READY
+45
View File
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import { chromium } from 'playwright';
const browser = await chromium.launch({ headless: true });
try {
for (const width of [390, 900]) {
const page = await browser.newPage({ viewport: { width, height: 844 }, acceptDownloads: true });
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
await page.setContent('<link rel="stylesheet" href="/static/style.css"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?mobile-export-regression=1');
mod.init('/api');
mod.injectFreshDoc({ id: 'export-test', title: 'Export test', language: 'markdown', version_count: 1, current_content: '# Mobile export\n\nActual document content.' });
});
await page.locator('#doc-footer-export-btn').waitFor({ state: 'visible' });
await page.evaluate(() => {
document.querySelector('.doc-editor-pane').style.setProperty('z-index', '12000', 'important');
});
await page.locator('#doc-footer-export-btn').click();
const menu = page.locator('#doc-export-menu');
await menu.waitFor({ state: 'visible' });
const bounds = await menu.boundingBox();
assert(bounds.x >= 0 && bounds.y >= 0);
assert(bounds.x + bounds.width <= width && bounds.y + bounds.height <= 844);
assert(await menu.evaluate(el => Number(getComputedStyle(el).zIndex) > 12000));
await page.screenshot({ path: `/tmp/document-export-${width}.png` });
// A second click closes the menu, then reopening must still allow export.
await page.locator('#doc-footer-export-btn').click();
assert.equal(await menu.count(), 0);
await page.locator('#doc-footer-export-btn').click();
const pending = page.waitForEvent('download');
await page.locator('#doc-export-menu button').filter({ hasText: /^Export Markdown$/ }).click();
const download = await pending;
assert(download.suggestedFilename().endsWith('.md'));
assert.equal(await download.failure(), null);
const stream = await download.createReadStream();
let content = '';
for await (const chunk of stream) content += chunk;
assert.equal(content, '# Mobile export\n\nActual document content.');
await page.close();
console.log(`PASS ${width}px: visible menu, toggle, download contents`);
}
} finally {
await browser.close();
}
+58
View File
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { chromium } from 'playwright';
const source = readFileSync(new URL('../static/js/document.js', import.meta.url), 'utf8');
const build = source.slice(source.indexOf(' function _buildAnnotation('), source.indexOf(' // Prompt user for an instruction'));
const state = source.slice(source.indexOf(' let _pdfAnnotationDragging'), source.indexOf(' // Per-doc last-used line spacing'));
const placement = source.slice(source.indexOf(' const isBlankPageTarget'), source.indexOf(' pane.appendChild(pageWrap);', source.indexOf(' const isBlankPageTarget')));
const browser = await chromium.launch({ headless: true });
try {
for (const touch of [true, false]) {
const page = await browser.newPage({ viewport: { width: 600, height: 800 }, hasTouch: touch });
await page.setContent('<div id="page" style="position:relative;width:500px;height:700px;background:white"></div>');
await page.evaluate(({ build, state, placement }) => {
new Function(`
const activeDocId = 'test', docId = activeDocId;
const _pdfPaneAnnotationsByDoc = new Map([['test', []]]);
const annotationRefs = _pdfPaneAnnotationsByDoc.get('test');
const _pdfLastLineHeight = new Map(), _pdfLastFontSize = new Map();
let _pdfDropMode = 'check', serial = 0;
const _newAnnotationId = () => 'new-' + (++serial);
const _pushPdfUndoSnapshot = () => {};
const _schedulePdfPaneSave = () => {};
const pageWrap = document.getElementById('page');
const page = {page: 1};
${state}
${build}
${placement}
for (let i = 0; i < 2; i++) {
const {ref} = _buildAnnotation(pageWrap, {id:'a'+i, page:1, x:20+i*35, y:30, w:5, h:5, kind:'check'});
annotationRefs.push(ref);
ref._setHandlesVisible(true);
}
window.refs = annotationRefs;
`)();
}, { build, state, placement });
const grip = page.locator('[data-ann-id="a0"] [title="Drag to move"]');
const box = await grip.boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(300, 220);
assert(await page.evaluate(() => refs.every(ref => [...ref.wrap.querySelectorAll('[title]')].every(el => el.style.display === 'none'))));
// Proximity updates must not bring controls back while dragging.
await page.evaluate(() => refs.forEach(ref => ref._setHandlesVisible(true)));
assert(await page.locator('[data-ann-id="a1"] [title="Delete annotation"]').isHidden());
await page.mouse.up();
assert.equal(await page.locator('.pdf-annotation-wrap').count(), 2);
assert(await page.locator('[data-ann-id="a1"] [title="Delete annotation"]').isVisible());
await page.locator('[data-ann-id="a1"] [title="Delete annotation"]').click();
assert.equal(await page.locator('.pdf-annotation-wrap').count(), 1);
await page.locator('#page').click({ position: { x: 100, y: 450 } });
assert.equal(await page.locator('.pdf-annotation-wrap').count(), 2);
console.log(`PASS ${touch ? 'touch' : 'mouse'}: drag controls hidden, delete only deletes, blank page still places`);
await page.close();
}
} finally {
await browser.close();
}
+1 -1
View File
@@ -89,7 +89,7 @@ a status code. `scripts/odysseus-smoke --areas` prints the current list.
| Email | the fixture inbox lists, opens with its body, and the unread count drops on mark-read | | Email | the fixture inbox lists, opens with its body, and the unread count drops on mark-read |
| Memory | a fact is listed, found by search, and gone after delete | | Memory | a fact is listed, found by search, and gone after delete |
| Uploads | an attachment reads back byte for byte | | Uploads | an attachment reads back byte for byte |
| Cookbook | hardware is detected; a fresh install's recommendation request returns the explicit empty-catalog Rescan guidance; state persists | | Cookbook | hardware is detected and recommendations come back sized against it; state persists |
| Settings | a preference written on one session is still there after a new login | | Settings | a preference written on one session is still there after a new login |
## What is not covered, and why ## What is not covered, and why
+1
View File
@@ -20,3 +20,4 @@ def test_terse_timed_calendar_create_promotes_to_calendar():
assert intent.needs_tools assert intent.needs_tools
assert intent.category == "calendar" assert intent.category == "calendar"
+65
View File
@@ -1,5 +1,7 @@
import json import json
import pytest
from src.agent_evidence import ( from src.agent_evidence import (
CompletionRequirements, CompletionRequirements,
CompletionStatus, CompletionStatus,
@@ -934,3 +936,66 @@ def test_exhaustion_and_awaiting_user_are_non_completion_states():
ledger = EvidenceLedger() ledger = EvidenceLedger()
assert ledger.evaluate(exhausted=True).status == CompletionStatus.EXHAUSTED assert ledger.evaluate(exhausted=True).status == CompletionStatus.EXHAUSTED
assert ledger.evaluate(awaiting_user=True).status == CompletionStatus.AWAITING_USER assert ledger.evaluate(awaiting_user=True).status == CompletionStatus.AWAITING_USER
@pytest.mark.parametrize("tool", ["create_document", "update_document", "edit_document"])
def test_authoritative_document_write_records_document_mutation_evidence(tool):
ledger = EvidenceLedger.from_tool_events([
{"tool": tool, "command": "fix grammar", "exit_code": 0},
])
mutations = [event for event in ledger.events
if event.kind == EvidenceKind.DOCUMENT_MUTATION]
assert len(mutations) == 1
assert mutations[0].authoritative and mutations[0].success
assert ledger._supports_document_mutation() is True
def test_non_authoritative_document_write_is_not_mutation_evidence():
ledger = EvidenceLedger.from_tool_events([
{"tool": "update_document", "command": "fix grammar"},
])
assert ledger._supports_document_mutation() is False
def test_successful_private_write_supports_mutation_claim_without_artifact():
# manage_calendar:create_event has no filesystem artifact, so the completion
# verifier previously erased the model's "I created the meeting" claim. A
# successful authoritative owned-state write must now support it.
from src.agent_runtime.completion import completion_answer
ledger = EvidenceLedger.from_tool_events([
{
"round": 1,
"tool": "manage_calendar",
"command": json.dumps({
"action": "create_event",
"title": "Meeting with Sam",
"start": "2026-10-16T15:00:00",
}),
"output": "Created event [Meeting with Sam]",
"exit_code": 0,
"execution_attempted": True,
},
])
assert ledger._private_action_mutations == ["manage_calendar:create_event"]
answer, _ = completion_answer(
"I created the meeting with Sam.", ledger, ledger.evaluate())
assert "created the meeting with Sam" in answer
assert not answer.startswith("Unsupported execution claims")
def test_failed_private_write_does_not_support_mutation_claim():
ledger = EvidenceLedger.from_tool_events([
{
"round": 1,
"tool": "manage_calendar",
"command": json.dumps({"action": "create_event", "title": "x"}),
"output": "denied",
"exit_code": 1,
"execution_attempted": True,
},
])
assert ledger._private_action_mutations == []
+85
View File
@@ -87,6 +87,91 @@ def test_ask_user_is_emitted_last_and_persisted(monkeypatch):
assert metrics["tool_events"][0]["ask_user"] == payload assert metrics["tool_events"][0]["ask_user"] == payload
def test_clean_preview_ask_user_ends_turn_without_second_round(monkeypatch):
"""The compact ``stream_preview`` runtime must treat ask_user as a turn boundary.
Regression for chat d8dca715: the preview fed the ask_user tool result
back to the model, so it burned a second provider round and persisted the
model's follow-up prose instead of the question.
"""
import src.clean_agent_preview as module
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
from src.tool_policy import ToolPolicy
from src.turn_contract import resolve_full_inventory_contract
payload = {
"question": "What would be most useful to start with?",
"options": [
{"label": "A 30-day meditation plan"},
{"label": "How to measure brain activity"},
],
"multi": False,
}
requests = []
responses = iter([
{'choices': [{'delta': {'tool_calls': [{'index': 0, 'id': 'ask-1',
'function': {'name': 'ask_user',
'arguments': json.dumps(payload, ensure_ascii=False)}}]}}]},
{'choices': [{'delta': {'content': 'SHOULD NOT BE REQUESTED'}}]},
])
class Response:
def __init__(self, body): self.body = body
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
def raise_for_status(self): pass
async def aiter_lines(self):
yield 'data: ' + json.dumps(self.body)
yield 'data: [DONE]'
class Client:
def __init__(self, **kwargs): pass
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
def stream(self, *args, **kwargs):
requests.append(kwargs['json'])
return Response(next(responses))
async def execute(block, **kwargs):
parsed = json.loads(block.content)
return 'ask_user', {
'ask_user': parsed,
'output': 'Awaiting their selection.',
'exit_code': 0,
}
monkeypatch.setattr(module.httpx, 'AsyncClient', Client)
monkeypatch.setattr(module, 'execute_tool_block', execute)
schema = next(s for s in FUNCTION_TOOL_SCHEMAS if s['function']['name'] == 'ask_user')
contract = resolve_full_inventory_contract(schemas=[schema], policy=ToolPolicy())
raw = _collect(module.stream_preview(
endpoint_url='http://test', model='test', headers={},
messages=[{'role': 'user', 'content': 'Help me pick a project.'}],
turn_contract=contract, session_id='test', owner='test',
disabled_tools=set(), tool_policy=ToolPolicy(),
))
events = _events(raw)
# Ending the turn means no second provider round is requested.
assert len(requests) == 1
ask_user_events = [event for event in events if event.get('type') == 'ask_user']
assert len(ask_user_events) == 1
assert ask_user_events[0]['data'] == payload
# The saved assistant text is the question, not the model's follow-up prose.
final = next(event for event in events if event.get('type') == 'final_response')
assert final['content'] == payload['question']
types = [event.get('type') for event in events]
assert types.index('ask_user') < types.index('metrics')
# Reconstructable from persisted metadata, and no phantom extra rounds.
metrics = next(event['data'] for event in events if event.get('type') == 'metrics')
assert metrics['tool_events'][0]['ask_user'] == payload
assert metrics['agent_rounds'] == 1
assert metrics['clean_v3_turn'][-1] == {
'role': 'assistant', 'content': payload['question'],
}
def test_frontend_uses_one_renderer_for_live_and_restored_cards(): def test_frontend_uses_one_renderer_for_live_and_restored_cards():
chat = (ROOT / "static" / "js" / "chat.js").read_text(encoding="utf-8") chat = (ROOT / "static" / "js" / "chat.js").read_text(encoding="utf-8")
renderer = (ROOT / "static" / "js" / "chatRenderer.js").read_text(encoding="utf-8") renderer = (ROOT / "static" / "js" / "chatRenderer.js").read_text(encoding="utf-8")
+1
View File
@@ -60,6 +60,7 @@ def test_login_page_static_assets_resolve_under_mount_path():
mounted_login_url = "https://example.test/odysseus/login" mounted_login_url = "https://example.test/odysseus/login"
for relative_asset, mounted_path in ( for relative_asset, mounted_path in (
("static/manifest.json", "/odysseus/static/manifest.json"), ("static/manifest.json", "/odysseus/static/manifest.json"),
("static/icons/icon-192.png", "/odysseus/static/icons/icon-192.png"),
( (
"static/fonts/FiraCode-Regular.woff2", "static/fonts/FiraCode-Regular.woff2",
"/odysseus/static/fonts/FiraCode-Regular.woff2", "/odysseus/static/fonts/FiraCode-Regular.woff2",
+31
View File
@@ -0,0 +1,31 @@
"""A valid login session cookie must survive a deploy restart of the server.
AuthManager keeps tokens in memory but persists them to sessions.json on create
and reloads them at startup, so a restart must NOT log the user out (the 302 the
bench saw was the restart gap / a client-side artifact, not session loss).
"""
from core import auth
def test_session_token_survives_a_restart(tmp_path):
auth_path = str(tmp_path / "auth.json")
first = auth.AuthManager(auth_path)
assert first.create_user("pewds", "hunter2", is_admin=True)
token = first.create_session_trusted("pewds")
assert token and first.validate_token(token)
# Simulate a process restart: a brand-new AuthManager on the same data dir.
second = auth.AuthManager(auth_path)
assert second.validate_token(token) is True
assert second.get_username_for_token(token) == "pewds"
def test_revoked_token_does_not_survive_restart(tmp_path):
auth_path = str(tmp_path / "auth.json")
first = auth.AuthManager(auth_path)
first.create_user("pewds", "hunter2", is_admin=True)
token = first.create_session_trusted("pewds")
first.revoke_token(token)
second = auth.AuthManager(auth_path)
assert second.validate_token(token) is False
+42
View File
@@ -0,0 +1,42 @@
"""Spec-surface admission: the strict router picks the tools after authority is
built, so spec turns admit the router's families; compact/full stay unchanged."""
import json
from src.agent_runtime.authority import create_request_authority, ExactOperation
def _permits(auth, tool, args=None):
return auth.permits(ExactOperation.normalize(tool, json.dumps(args or {})))
def _auth(text, **kw):
return create_request_authority(text, owner="pewds", session_id="s1", workspace="", **kw)
def test_spec_surface_admits_a_router_chosen_family_tool():
auth = _auth("Search for the appointment email", spec_surface=True)
assert _permits(auth, "mcp__email__search_emails", {"action": "list", "query": "appointment"})
def test_non_spec_turn_does_not_admit_the_same_call():
auth = _auth("Search for the appointment email")
assert not _permits(auth, "mcp__email__search_emails", {"action": "list", "query": "appointment"})
def test_spec_surface_admits_browser_interaction_and_batch():
auth = _auth("do the thing", spec_surface=True)
assert _permits(auth, "private_browser", {"action": "batch"})
assert _permits(auth, "private_browser", {"action": "click", "selector": "#go"})
assert _permits(auth, "private_browser", {"action": "fill", "selector": "#q", "text": "x"})
def test_batch_is_read_but_click_stays_verb_gated_off_spec():
auth = _auth("search the web for the appointment")
assert _permits(auth, "private_browser", {"action": "batch"})
assert not _permits(auth, "private_browser", {"action": "click", "selector": "#go"})
def test_spec_surface_keeps_the_exact_read_input_seal():
auth = _auth("Read note id abc123", spec_surface=True)
assert _permits(auth, "manage_notes", {"action": "view", "id": "abc123"})
assert not _permits(auth, "manage_notes", {"action": "view", "id": "zzz999"})
+20
View File
@@ -1,8 +1,28 @@
import json import json
import pytest
from src.clean_agent_preview import preview_tool_result_text from src.clean_agent_preview import preview_tool_result_text
@pytest.mark.parametrize('snapshot', [None, '', ' '])
def test_text_and_title_survive_without_snapshot(snapshot):
result = {'url': 'https://example.org', 'title': 'Access denied',
'text': 'Unusual traffic. Complete the CAPTCHA.', 'snapshot': snapshot}
output = preview_tool_result_text({'output': json.dumps(result)}, 'private_browser', {})
assert 'Access denied' in output
assert 'Unusual traffic. Complete the CAPTCHA.' in output
from src.clean_agent_preview import browser_observation_access_blocked
assert browser_observation_access_blocked(output)
def test_snapshot_takes_precedence_over_duplicate_page_text():
output = preview_tool_result_text({'output': json.dumps({
'snapshot': '- link "Report" [ref=e1]', 'text': 'Duplicated page body',
})}, 'private_browser', {})
assert '[ref=e1]' in output
assert 'Duplicated page body' not in output
def test_legacy_page_text_retains_access_block_evidence(): def test_legacy_page_text_retains_access_block_evidence():
result = {'url': 'https://example.org', 'title': 'Security verification', result = {'url': 'https://example.org', 'title': 'Security verification',
'text': 'Unusual traffic. Complete the CAPTCHA.'} 'text': 'Unusual traffic. Complete the CAPTCHA.'}
+37
View File
@@ -231,6 +231,25 @@ def test_disabled_tools_respects_missing_vs_explicit_toggles():
) )
def test_request_authority_grants_web_when_turn_offers_web_tools():
"""Web tools offered for a turn must carry request authority too.
Regression: the authority capabilities gate only recognized explicit
browser wording (``_explicit_browser_intent``/``_external_discovery_intent``),
so an implicit research question with the Web toggle on offered
``web_search`` in the model's schema but rejected every call with
``request_authority_denied``. The gate must also grant ``search_browser``
whenever web tools are not disabled for the turn.
"""
source = _CHAT_ROUTES.read_text(encoding="utf-8")
assert "({'search_browser'} if (" in source, (
"request authority must be built with a search_browser capabilities gate"
)
assert "or \"web_search\" not in disabled_tools" in source, (
"authority must grant search_browser whenever web_search is offered"
)
def test_explicit_private_browser_workflow_survives_web_search_disabled(): def test_explicit_private_browser_workflow_survives_web_search_disabled():
source = _CHAT_ROUTES.read_text(encoding="utf-8") source = _CHAT_ROUTES.read_text(encoding="utf-8")
assert "_explicit_private_browser_intent" in source assert "_explicit_private_browser_intent" in source
@@ -748,3 +767,21 @@ def test_frontend_sends_explicit_allow_web_search_false_in_agent_mode():
assert "fd.append('allow_web_search', el('web-toggle').checked ? 'true' : 'false')" in source, ( assert "fd.append('allow_web_search', el('web-toggle').checked ? 'true' : 'false')" in source, (
"Frontend must send explicit allow_web_search=false in agent mode when toggle is off" "Frontend must send explicit allow_web_search=false in agent mode when toggle is off"
) )
def test_spec_surface_skips_the_contract_scope_tool_denial():
"""On the strict-router (spec) surface the regex turn contract must NOT
scope disabled_tools, or the spec router menu (email/notes/...) is emptied
before src/spec_router.py runs. Compact/full/none keep the denial."""
source = _CHAT_ROUTES.read_text(encoding="utf-8")
assert "if not _spec_surface:" in source, (
"the contract-scope disabled_tools.update must be guarded for spec turns"
)
idx = source.index("if not _spec_surface:")
segment = source[idx:idx + 500]
assert "disabled_tools.update(" in segment, (
"the contract-scope denial must live inside the spec guard"
)
assert "if not _turn_contract.permits(" in segment, (
"the guarded block must be the contract-scope denial"
)
+424 -34
View File
@@ -109,12 +109,37 @@ def test_deepseek_flash_keeps_tools_but_drops_unsupported_forced_choice():
} }
compatible = provider_compatible_tool_choice_request(request, 'deepseek-flash') compatible = provider_compatible_tool_choice_request(request, 'deepseek-flash')
assert [tool['function']['name'] for tool in compatible['tools']] == ['inspect_media'] # A forced named choice must not hide the other offered tools.
assert [tool['function']['name'] for tool in compatible['tools']] == [
'inspect_media', 'python'
]
assert 'tool_choice' not in compatible assert 'tool_choice' not in compatible
assert request['tool_choice']['function']['name'] == 'inspect_media' assert request['tool_choice']['function']['name'] == 'inspect_media'
assert provider_compatible_tool_choice_request(request, 'qwen3.5-9b') is request assert provider_compatible_tool_choice_request(request, 'qwen3.5-9b') is request
def test_deepseek_forced_read_does_not_hide_other_offered_tools():
# Regression: a required calendar read forced tool_choice=manage_calendar on
# a turn that also offered the email tools; the DeepSeek compatibility path
# used to shrink `tools` to manage_calendar only, so the model reported the
# email tools the user asked for were unavailable.
request = {
'model': 'deepseek-v4-flash',
'tools': [
{'type': 'function', 'function': {'name': 'manage_calendar'}},
{'type': 'function', 'function': {'name': 'mcp__email__search_emails'}},
{'type': 'function', 'function': {'name': 'mcp__email__read_email'}},
],
'tool_choice': {'type': 'function', 'function': {'name': 'manage_calendar'}},
}
compatible = provider_compatible_tool_choice_request(request, 'deepseek-v4-flash')
names = [tool['function']['name'] for tool in compatible['tools']]
assert names == [
'manage_calendar', 'mcp__email__search_emails', 'mcp__email__read_email'
]
assert 'tool_choice' not in compatible
def test_deepseek_v4_flash_drops_named_choice_for_thinking_mode(): def test_deepseek_v4_flash_drops_named_choice_for_thinking_mode():
request = { request = {
'tools': [{'type': 'function', 'function': {'name': 'write_file'}}], 'tools': [{'type': 'function', 'function': {'name': 'write_file'}}],
@@ -136,8 +161,10 @@ def test_kimi_k3_drops_named_choice_incompatible_with_thinking_mode():
compatible = provider_compatible_tool_choice_request(request, 'kimi-k3') compatible = provider_compatible_tool_choice_request(request, 'kimi-k3')
# The full tool surface is preserved; only the unsupported forced choice
# is dropped.
assert [tool['function']['name'] for tool in compatible['tools']] == [ assert [tool['function']['name'] for tool in compatible['tools']] == [
'inspect_media' 'inspect_media', 'write_file'
] ]
assert 'tool_choice' not in compatible assert 'tool_choice' not in compatible
@@ -210,8 +237,8 @@ def test_private_browser_covered_click_does_not_advance_dom_revision():
def test_compact_notes_preserves_create_vs_edit_and_replacement_semantics(): def test_compact_notes_preserves_create_vs_edit_and_replacement_semantics():
schema = next(s for s in FUNCTION_TOOL_SCHEMAS if s['function']['name'] == 'manage_notes') schema = next(s for s in FUNCTION_TOOL_SCHEMAS if s['function']['name'] == 'manage_notes')
compact = compact_schemas([schema])[0]['function'] compact = compact_schemas([schema])[0]['function']
assert 'add creates a new note' in compact['description'] assert 'ONE add call' in compact['description']
assert 'update with id' in compact['description'] assert 'update with id edits an existing note, never add' in compact['description']
assert 'replaces the whole checklist' in compact['parameters']['properties']['checklist_items']['description'] assert 'replaces the whole checklist' in compact['parameters']['properties']['checklist_items']['description']
@@ -3511,6 +3538,10 @@ def test_every_compactly_offered_preview_tool_has_valid_policy_permitted_call():
'web_fetch': ({'url': 'https://example.com'}, 'read this page'), 'web_fetch': ({'url': 'https://example.com'}, 'read this page'),
'web_search': ({'query': 'current AI news'}, 'search the web'), 'web_search': ({'query': 'current AI news'}, 'search the web'),
'youtube_tool': ({'action': 'metadata', 'video_url': 'https://youtube.com/watch?v=x'}, 'read YouTube metadata'), 'youtube_tool': ({'action': 'metadata', 'video_url': 'https://youtube.com/watch?v=x'}, 'read YouTube metadata'),
'plan_edit': ({'prompt': 'make it black and white'}, 'make it black and white'),
'run_editor_command': ({'op': 'document.info'}, 'inspect the document'),
'get_editor_screenshot': ({}, 'take a screenshot of the editor'),
'ai_edit_image': ({'op': 'remove-bg'}, 'remove the background'),
} }
schemas = { schemas = {
schema['function']['name']: schema schema['function']['name']: schema
@@ -3537,6 +3568,9 @@ def test_every_compactly_offered_preview_tool_has_valid_policy_permitted_call():
} }
else {'image_editing'} if name == 'edit_image' else {'image_editing'} if name == 'edit_image'
else {'image_generation'} if name == 'generate_image' else {'image_generation'} if name == 'generate_image'
else {'photo_editor'} if name in {
'plan_edit', 'run_editor_command', 'get_editor_screenshot', 'ai_edit_image',
}
else frozenset() else frozenset()
), ),
contract_required_tools={name}, contract_required_tools={name},
@@ -4366,21 +4400,20 @@ def test_v3_schema_uses_configured_versioned_contract_root(tmp_path, monkeypatch
(contract_root / 'eval_alltools_unseen_compare.py').write_text( (contract_root / 'eval_alltools_unseen_compare.py').write_text(
"def tools_for_mode(tools, mode):\n" "def tools_for_mode(tools, mode):\n"
" assert mode == 'compact_contract_v5'\n" " assert mode == 'compact_contract_v5'\n"
" tools[0]['function']['description'] = 'versioned-contract-loaded'\n" " tools[0]['function']['parameters']['properties']['action']['description'] = 'versioned-contract-loaded'\n"
" return tools\n", " return tools\n",
encoding='utf-8', encoding='utf-8',
) )
monkeypatch.setenv('ODYSSEUS_TOOL_CONTRACT_ROOT', str(contract_root)) monkeypatch.setenv('ODYSSEUS_TOOL_CONTRACT_ROOT', str(contract_root))
module.contract_builder.cache_clear() module.contract_builder.cache_clear()
try: try:
# Probe a tool whose compact description the harness does not notes = next(
# replace; manage_notes now carries a full harness-owned override.
search = next(
s for s in FUNCTION_TOOL_SCHEMAS s for s in FUNCTION_TOOL_SCHEMAS
if s['function']['name'] == 'web_search' if s['function']['name'] == 'manage_notes'
) )
compact = module.compact_schemas([search])[0] compact = module.compact_schemas([notes])[0]
assert compact['function']['description'].startswith('versioned-contract-loaded') assert compact['function']['parameters']['properties']['action']['description'] == 'versioned-contract-loaded'
assert 'update with id edits an existing note, never add' in compact['function']['description']
finally: finally:
module.contract_builder.cache_clear() module.contract_builder.cache_clear()
@@ -4392,6 +4425,7 @@ def test_v3_document_edit_schema_has_one_unambiguous_structured_form():
assert parameters['required'] == ['edits'] assert parameters['required'] == ['edits']
assert set(parameters['properties']) == {'edits', 'more'} assert set(parameters['properties']) == {'edits', 'more'}
assert parameters['properties']['more']['type'] == 'boolean' assert parameters['properties']['more']['type'] == 'boolean'
assert 'more' not in parameters['required']
def test_v3_ui_schema_advertises_only_policy_executable_client_local_actions(): def test_v3_ui_schema_advertises_only_policy_executable_client_local_actions():
@@ -4526,7 +4560,7 @@ async def test_stream_emits_incremental_text_and_persistable_history(monkeypatch
raw = [chunk async for chunk in stream_preview(endpoint_url='http://test', model='test', messages=[{'role': 'user', 'content': 'hi'}], headers={}, turn_contract=contract, session_id='test', owner='test', disabled_tools=set(), tool_policy=ToolPolicy())] raw = [chunk async for chunk in stream_preview(endpoint_url='http://test', model='test', messages=[{'role': 'user', 'content': 'hi'}], headers={}, turn_contract=contract, session_id='test', owner='test', disabled_tools=set(), tool_policy=ToolPolicy())]
events = [json.loads(s[6:]) for s in raw if '[DONE]' not in s] events = [json.loads(s[6:]) for s in raw if '[DONE]' not in s]
assert [e['delta'] for e in events if 'delta' in e] == ['Hello', ' there'] assert [e['delta'] for e in events if 'delta' in e] == ['Hello', ' there']
assert requests[0]['messages'][0]['content'].startswith('You are Odysseus.') assert requests[0]['messages'][0]['content'].startswith('You are Ajax, the assistant in Odysseus.')
metrics = next(e['data'] for e in events if e.get('type') == 'metrics') metrics = next(e['data'] for e in events if e.get('type') == 'metrics')
assert metrics['clean_v3_turn'] == [{'role': 'assistant', 'content': 'Hello there'}] assert metrics['clean_v3_turn'] == [{'role': 'assistant', 'content': 'Hello there'}]
assert raw[-1] == 'data: [DONE]\n\n' assert raw[-1] == 'data: [DONE]\n\n'
@@ -4562,9 +4596,7 @@ async def test_preview_provider_stream_error_is_terminal_not_empty_answer(monkey
disabled_tools=set(), tool_policy=ToolPolicy(), disabled_tools=set(), tool_policy=ToolPolicy(),
)] )]
assert raw[-1].startswith('event: error\ndata: ') assert raw[-1].startswith('event: error\ndata: ')
assert 'selected model provider failed' in raw[-1] assert 'Qwen3_5MTPDraftModel' in raw[-1]
assert 'provider_stream_error' in raw[-1]
assert 'Qwen3_5MTPDraftModel' not in raw[-1]
assert all('returned no answer' not in chunk for chunk in raw) assert all('returned no answer' not in chunk for chunk in raw)
assert all('"type": "metrics"' not in chunk for chunk in raw) assert all('"type": "metrics"' not in chunk for chunk in raw)
@@ -4664,6 +4696,74 @@ def test_ajax_c375_tool_surface_disables_progressive_thinking():
) )
@pytest.mark.parametrize('name,args,reason', [
('python', {}, 'code'),
('apply_patch', {}, 'code'),
('edit_document', {}, 'document_edit'),
('draft_email_reply', {}, 'email_draft'),
('manage_tasks', {'action': 'create'}, 'task_design'),
('manage_skills', {'action': 'publish'}, 'skill_evaluation'),
('manage_tasks', {'action': 'list'}, ''),
('manage_tasks', {'action': 'pause'}, ''),
('manage_skills', {'action': 'view'}, ''),
('read_file', {}, ''),
('web_search', {}, ''),
('manage_notes', {'action': 'add'}, ''),
])
def test_tool_deliberation_is_operation_specific(name, args, reason):
from src.clean_agent_preview import tool_deliberation_reason, progressive_thinking_for_turn
assert tool_deliberation_reason(name, args) == reason
schemas = [{'function': {'name': name}}]
assert progressive_thinking_for_turn('ajax_c375', schemas, deliberate=bool(reason)) == bool(reason)
assert not progressive_thinking_for_turn('other-model', schemas, deliberate=True)
@pytest.mark.asyncio
async def test_task_creation_deliberates_before_executing_once(monkeypatch):
import src.clean_agent_preview as module
requests, executions = [], []
arguments = {'action': 'create', 'name': 'Morning news', 'prompt': 'Summarize news', 'schedule': 'daily', 'time': '09:00'}
proposal = {'choices': [{'delta': {'tool_calls': [{'index': 0, 'id': 'task-call',
'function': {'name': 'manage_tasks', 'arguments': json.dumps(arguments)}}]}}]}
responses = iter([proposal, proposal, {'choices': [{'delta': {'content': 'Created the task.'}}]}])
class Response:
def __init__(self, payload): self.payload = payload
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
def raise_for_status(self): pass
async def aiter_lines(self):
yield 'data: ' + json.dumps(self.payload)
yield 'data: [DONE]'
class Client:
def __init__(self, **kwargs): pass
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
def stream(self, *args, **kwargs):
requests.append(kwargs['json'])
return Response(next(responses))
async def execute(block, **kwargs):
executions.append(block)
return 'manage_tasks', {'output': 'Created task task-1', 'exit_code': 0}
monkeypatch.setattr(module.httpx, 'AsyncClient', Client)
monkeypatch.setattr(module, 'execute_tool_block', execute)
schemas = [s for s in FUNCTION_TOOL_SCHEMAS if s['function']['name'] == 'manage_tasks']
contract = resolve_full_inventory_contract(schemas=schemas, policy=ToolPolicy())
raw = [chunk async for chunk in stream_preview(
endpoint_url='http://test', model='ajax_c375',
messages=[{'role': 'user', 'content': 'Create a daily task at 9am to summarize news.'}],
headers={}, turn_contract=contract, session_id='test', owner='test',
disabled_tools=set(), tool_policy=ToolPolicy(), max_rounds=4,
)]
assert len(executions) == 1, '\n'.join(raw)
assert [r['chat_template_kwargs']['enable_thinking'] for r in requests] == [False, True, False]
assert not any(m.get('tool_calls') for m in requests[1]['messages'])
assert not any('event: error' in chunk for chunk in raw)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_calendar_list_structured_result_owns_linked_terminal_render(monkeypatch): async def test_calendar_list_structured_result_owns_linked_terminal_render(monkeypatch):
import src.clean_agent_preview as module import src.clean_agent_preview as module
@@ -5427,7 +5527,9 @@ async def test_model_choice_budget_preserves_evidence_for_final_answer_without_e
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_failed_static_fetch_recovers_once_through_rendered_browser(monkeypatch): @pytest.mark.parametrize('thinking_mode', ['auto', 'off', 'on'])
@pytest.mark.parametrize('token_limit', [None, 1024])
async def test_failed_static_fetch_recovers_once_through_rendered_browser(monkeypatch, thinking_mode, token_limit):
from dataclasses import replace from dataclasses import replace
import src.clean_agent_preview as module import src.clean_agent_preview as module
@@ -5489,13 +5591,17 @@ async def test_failed_static_fetch_recovers_once_through_rendered_browser(monkey
routing_experiment='recent_model_choice', routing_experiment='recent_model_choice',
) )
raw = [chunk async for chunk in stream_preview( raw = [chunk async for chunk in stream_preview(
endpoint_url='http://test', model='test', endpoint_url='http://test', model='ajax_c375', thinking_mode=thinking_mode,
messages=[{'role': 'user', 'content': 'Tell me more about that Reuters report.'}], messages=[{'role': 'user', 'content': 'Tell me more about that Reuters report.'}],
headers={}, turn_contract=contract, session_id='test', owner='test', headers={}, turn_contract=contract, session_id='test', owner='test',
disabled_tools=set(), tool_policy=ToolPolicy(), max_rounds=4, disabled_tools=set(), tool_policy=ToolPolicy(), max_rounds=4, max_tokens=token_limit,
)] )]
assert executions == ['web_fetch', 'private_browser', 'web_fetch'] assert executions == ['web_fetch', 'private_browser', 'web_fetch']
thinking = [r['chat_template_kwargs']['enable_thinking'] for r in requests]
# Compact owns the protocol, including for old sessions with On/Off saved.
assert thinking == [False, True, True, False]
assert requests[1]['max_tokens'] == (token_limit or 4096)
assert all( assert all(
schema['function']['name'] != 'web_fetch' schema['function']['name'] != 'web_fetch'
for schema in requests[1].get('tools', []) for schema in requests[1].get('tools', [])
@@ -5572,7 +5678,8 @@ async def test_duplicate_fetch_does_not_hide_distinct_pagination_request(monkeyp
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_blocked_search_engine_browser_forces_native_web_search(monkeypatch): @pytest.mark.parametrize('empty_challenge', [False, True])
async def test_blocked_search_engine_browser_forces_native_web_search(monkeypatch, empty_challenge):
import src.clean_agent_preview as module import src.clean_agent_preview as module
responses = iter([ responses = iter([
@@ -5594,10 +5701,8 @@ async def test_blocked_search_engine_browser_forces_native_web_search(monkeypatc
}, },
}]}}]}, }]}}]},
{'choices': [{'delta': {'content': ( {'choices': [{'delta': {'content': (
'Recent AI developments include new model releases and policy updates. ' 'I found the [AI update](https://example.com/ai) page. '
'The search results identify the relevant primary sources and dates for each item, ' 'The browser search was blocked, so I used native search to locate it.'
'including publication details, concrete findings, and links readers can inspect '
'for the full context behind each development.'
)}}]}, )}}]},
]) ])
requests = [] requests = []
@@ -5626,6 +5731,9 @@ async def test_blocked_search_engine_browser_forces_native_web_search(monkeypatc
if block.tool_type == 'private_browser': if block.tool_type == 'private_browser':
return 'private_browser', { return 'private_browser', {
'output': json.dumps({ 'output': json.dumps({
'url': 'https://www.google.com/sorry/index?continue=search',
'title': 'Client Challenge', 'snapshot': '(empty page)',
} if empty_challenge else {
'url': 'https://www.google.com/sorry/index?continue=search', 'url': 'https://www.google.com/sorry/index?continue=search',
'text': 'Our systems have detected unusual traffic. Complete the reCAPTCHA.', 'text': 'Our systems have detected unusual traffic. Complete the reCAPTCHA.',
}), }),
@@ -5651,7 +5759,7 @@ async def test_blocked_search_engine_browser_forces_native_web_search(monkeypatc
) )
raw = [chunk async for chunk in stream_preview( raw = [chunk async for chunk in stream_preview(
endpoint_url='http://test', model='test', endpoint_url='http://test', model='test',
messages=[{'role': 'user', 'content': 'Open browser and find an AI model release.'}], messages=[{'role': 'user', 'content': 'Open browser and find the AI update page on example.com. Give me its link.'}],
headers={}, turn_contract=contract, session_id='test', owner='test', headers={}, turn_contract=contract, session_id='test', owner='test',
disabled_tools=set(), tool_policy=ToolPolicy(), max_rounds=4, disabled_tools=set(), tool_policy=ToolPolicy(), max_rounds=4,
)] )]
@@ -5659,6 +5767,7 @@ async def test_blocked_search_engine_browser_forces_native_web_search(monkeypatc
assert executions == ['private_browser', 'web_search'] assert executions == ['private_browser', 'web_search']
assert requests[1]['tool_choice'] == 'required' assert requests[1]['tool_choice'] == 'required'
assert [s['function']['name'] for s in requests[1]['tools']] == ['web_search'] assert [s['function']['name'] for s in requests[1]['tools']] == ['web_search']
assert not any(chunk.startswith('event: error') for chunk in raw), raw
events = [json.loads(chunk[6:]) for chunk in raw if '[DONE]' not in chunk] events = [json.loads(chunk[6:]) for chunk in raw if '[DONE]' not in chunk]
assert any( assert any(
event.get('type') == 'tool_loop_recovery' event.get('type') == 'tool_loop_recovery'
@@ -5721,9 +5830,10 @@ async def test_native_stream_reserves_remaining_budget_for_required_artifact(mon
async def execute(block, **kwargs): async def execute(block, **kwargs):
executed.append(block.tool_type) executed.append(block.tool_type)
return block.tool_type, {"output": "ok", "exit_code": 0, if block.tool_type == 'python' and '/tmp_workspace/results/out.md' in block.content:
"materialized_artifacts": ["/tmp_workspace/results"] return block.tool_type, {'output': 'ok', 'exit_code': 0,
if block.tool_type == "python" and "out.md" in block.content else []} 'materialized_artifacts': ['/tmp_workspace/results']}
return block.tool_type, {"output": "ok", "exit_code": 0}
monkeypatch.setattr(module.httpx, "AsyncClient", Client) monkeypatch.setattr(module.httpx, "AsyncClient", Client)
monkeypatch.setattr(module, "execute_tool_block", execute) monkeypatch.setattr(module, "execute_tool_block", execute)
@@ -5835,9 +5945,10 @@ async def test_native_stream_reserves_wall_time_for_required_artifact(monkeypatc
executed.append(block.tool_type) executed.append(block.tool_type)
if block.tool_type == "web_search": if block.tool_type == "web_search":
now[0] = 450.0 now[0] = 450.0
return block.tool_type, {"output": "ok", "exit_code": 0, if block.tool_type == 'python' and '/tmp_workspace/results/out.md' in block.content:
"materialized_artifacts": ["/tmp_workspace/results"] return block.tool_type, {'output': 'ok', 'exit_code': 0,
if block.tool_type == "python" and "out.md" in block.content else []} 'materialized_artifacts': ['/tmp_workspace/results']}
return block.tool_type, {"output": "ok", "exit_code": 0}
monkeypatch.setattr(module.time, "monotonic", lambda: now[0]) monkeypatch.setattr(module.time, "monotonic", lambda: now[0])
monkeypatch.setattr(module.httpx, "AsyncClient", Client) monkeypatch.setattr(module.httpx, "AsyncClient", Client)
@@ -6429,14 +6540,15 @@ async def test_native_stream_permanently_withholds_tool_when_model_ignores_dupli
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_native_stream_terminates_after_calling_a_permanently_suppressed_tool(monkeypatch): @pytest.mark.parametrize('ignores_final_instruction', [False, True])
async def test_native_stream_terminates_after_calling_a_permanently_suppressed_tool(monkeypatch, ignores_final_instruction):
import src.clean_agent_preview as module import src.clean_agent_preview as module
arguments = json.dumps({"path": "/workspace/fixtures/video.mp4"}) arguments = json.dumps({"path": "/workspace/fixtures/video.mp4"})
responses = iter([ responses = iter([
{"choices": [{"delta": {"tool_calls": [{"index": 0, "id": f"inspect-{index}", "function": { {"choices": [{"delta": {"tool_calls": [{"index": 0, "id": f"inspect-{index}", "function": {
"name": "inspect_media", "arguments": arguments, "name": "inspect_media", "arguments": arguments,
}}]}}]} }}]}}]}
for index in range(1, 4) for index in range(1, 5 if ignores_final_instruction else 4)
] + [{"choices": [{"delta": {"content": "Final answer from existing evidence."}}]}]) ] + [{"choices": [{"delta": {"content": "Final answer from existing evidence."}}]}])
class Response: class Response:
@@ -6488,9 +6600,15 @@ async def test_native_stream_terminates_after_calling_a_permanently_suppressed_t
assert 'tools' not in requests[3] assert 'tools' not in requests[3]
assert 'best concise final answer' in requests[3]['messages'][-1]['content'].lower() assert 'best concise final answer' in requests[3]['messages'][-1]['content'].lower()
final = [event for event in events if event.get("type") == "final_response"] final = [event for event in events if event.get("type") == "final_response"]
assert final == []
metrics = next(event['data'] for event in events if event.get('type') == 'metrics') metrics = next(event['data'] for event in events if event.get('type') == 'metrics')
assert metrics['clean_v3_turn'][-1]['content'] == 'Final answer from existing evidence.' if ignores_final_instruction:
assert len(final) == 1
assert 'model repeated a tool call' in final[0]['content']
assert metrics['clean_v3_turn'][-1]['content'] == final[0]['content']
else:
assert final == []
assert metrics['clean_v3_turn'][-1]['content'] == 'Final answer from existing evidence.'
assert raw[-1] == 'data: [DONE]\n\n'
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -7284,3 +7402,275 @@ async def test_tool_round_leadin_is_replaced_by_terminal_synthesis(monkeypatch):
"I'll browse IKEA and look at their chairs.", "I'll browse IKEA and look at their chairs.",
'The most epic option is the DYVLINGE swivel chair.', 'The most epic option is the DYVLINGE swivel chair.',
] ]
@pytest.mark.asyncio
async def test_denied_call_after_successful_evidence_recovers_instead_of_refusing(monkeypatch):
"""A blocked call must not discard evidence an earlier call already produced."""
import src.clean_agent_preview as module
def call(index, name, arguments):
return {'index': index, 'id': f'call-{index}', 'function': {
'name': name, 'arguments': json.dumps(arguments),
}}
packets = iter([
{'choices': [{'delta': {'tool_calls': [
call(0, 'web_search', {'query': 'EEG headset prices'})]}}]},
{'choices': [{'delta': {'tool_calls': [
call(0, 'web_fetch', {'url': 'https://vendor.example/eeg'})]}}]},
{'choices': [{'delta': {'content': (
'Based on the search result, the EEG headset starts at $299 from the '
'vendor. I could not open the vendor page because that operation was '
'not permitted.'
)}}]},
])
requests = []
class Response:
def __init__(self, payload): self.payload = payload
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
def raise_for_status(self): pass
async def aiter_lines(self):
yield 'data: ' + json.dumps(self.payload)
yield 'data: [DONE]'
class Client:
def __init__(self, **kwargs): pass
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
def stream(self, *args, **kwargs):
requests.append(kwargs['json'])
return Response(next(packets))
async def execute(block, **kwargs):
if block.tool_type == 'web_search':
return 'web_search', {
'output': '[1] EEG Headset Prices\n https://vendor.example/eeg',
'exit_code': 0,
'evidence_status': 'available',
}
return 'web_fetch', {
'output': 'The exact operation is outside server request authority.',
'exit_code': 1,
'blocked': True,
'failure_kind': 'request_authority_denied',
}
monkeypatch.setattr(module.httpx, 'AsyncClient', Client)
monkeypatch.setattr(module, 'execute_tool_block', execute)
schemas = [
schema for schema in FUNCTION_TOOL_SCHEMAS
if schema['function']['name'] in {'web_search', 'web_fetch'}
]
contract = resolve_full_inventory_contract(schemas=schemas, policy=ToolPolicy())
raw = [chunk async for chunk in stream_preview(
endpoint_url='http://test', model='test',
messages=[{'role': 'user', 'content': (
'Search the web for the latest EEG headset prices and open the vendor page.'
)}],
headers={}, turn_contract=contract, session_id='test', owner='test',
disabled_tools=set(), tool_policy=ToolPolicy(), max_rounds=5,
)]
events = [json.loads(chunk[6:]) for chunk in raw if '[DONE]' not in chunk]
assert any(
event.get('type') == 'completion_recovery'
and event.get('reason') == 'denial_after_successful_evidence'
for event in events
)
visible = ''.join(
str(e.get('delta') or e.get('content') or '') for e in events
)
assert '$299' in visible
assert denied_response() not in visible
# The recovery round must synthesize without tools.
assert requests[-1].get('tools') in (None, [])
@pytest.mark.asyncio
@pytest.mark.parametrize('thinking_mode, expected_thinking', [
('on', {'type': 'enabled'}),
(None, {'type': 'enabled'}),
('off', {'type': 'disabled'}),
])
async def test_deepseek_v4_preview_reserves_reasoning_budget_and_honors_toggle(
monkeypatch, thinking_mode, expected_thinking,
):
import src.clean_agent_preview as module
requests = []
class Response:
def __init__(self, payload): self.payload = payload
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
def raise_for_status(self): pass
async def aiter_lines(self):
yield 'data: ' + json.dumps(self.payload)
yield 'data: [DONE]'
class Client:
def __init__(self, **kwargs): pass
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
def stream(self, *args, **kwargs):
requests.append(kwargs['json'])
return Response({'choices': [{'delta': {
'reasoning_content': 'Let me reason about this.',
'content': 'The answer is 42.',
}}]})
monkeypatch.setattr(module.httpx, 'AsyncClient', Client)
contract = resolve_full_inventory_contract(schemas=[], policy=ToolPolicy())
kwargs = {} if thinking_mode is None else {'thinking_mode': thinking_mode}
raw = [chunk async for chunk in stream_preview(
endpoint_url='https://api.deepseek.com/v1/chat/completions',
model='deepseek-v4-flash',
messages=[{'role': 'user', 'content': 'What is the answer?'}],
headers={}, turn_contract=contract, session_id='test', owner='test',
disabled_tools=set(), tool_policy=ToolPolicy(), **kwargs,
)]
assert requests, 'a provider request should have been sent'
sent = requests[0]
if expected_thinking == {'type': 'disabled'}:
assert sent.get('reasoning_effort') == 'none'
else:
# Reasoning-capable models must reserve budget above the 768 default.
assert sent['max_tokens'] >= 4096
assert sent.get('reasoning_effort')
assert sent.get('thinking') == expected_thinking
assert any('The answer is 42.' in chunk for chunk in raw)
def test_workspace_process_denial_uses_an_honest_refusal(tmp_path):
from src.clean_agent_preview import (
workspace_process_denial,
WORKSPACE_REQUIRED_REFUSAL,
denied_response,
)
# A resource-identity / sealed-workspace denial is recognised from any of
# the tool-event fields (error / output JSON / failure_kind).
assert workspace_process_denial([
{'tool': 'bash', 'exit_code': 1, 'blocked': True,
'output': '{"error":"Process creation requires a sealed workspace and launch scope",'
'"failure_kind":"resource_identity_denied"}'},
])
assert workspace_process_denial([{'failure_kind': 'resource_identity_denied'}])
# A plain request-authority denial is not a workspace denial.
assert not workspace_process_denial([
{'tool': 'bash', 'blocked': True, 'failure_kind': 'request_authority_denied',
'error': 'The exact operation is outside server request authority.'},
])
assert not workspace_process_denial([])
assert 'workspace' in WORKSPACE_REQUIRED_REFUSAL.lower()
assert WORKSPACE_REQUIRED_REFUSAL != denied_response()
# The terminal-denial branch picks the workspace message when appropriate.
source = (Path(__file__).resolve().parents[1] / 'src/clean_agent_preview.py').read_text()
assert 'if workspace_process_denial(executions)' in source
assert 'WORKSPACE_REQUIRED_REFUSAL' in source
def test_spec_router_think_decision_and_reason(monkeypatch):
import sys
import types
from src.clean_agent_preview import (
spec_router_thinking_decision,
spec_router_think_reason,
)
# --- router disabled -> no decision, no reason (default OFF) -------------
fake = types.ModuleType('src.spec_router')
fake.think_router_enabled = lambda: False
fake.route_thinking = lambda text: {'think': True, 'p_think': 0.9, 'ms': 1.0}
monkeypatch.setitem(sys.modules, 'src.spec_router', fake)
assert spec_router_thinking_decision('u', 'm', 'o', {}, 'hi') is None
assert spec_router_think_reason(1, None, '') == ''
# --- enabled but not a spec surface -> no decision ----------------------
fake.think_router_enabled = lambda: True
monkeypatch.setattr(
'src.agent_loop._configured_model_tool_surface_raw',
lambda *a, **k: 'native', raising=False,
)
assert spec_router_thinking_decision('u', 'm', 'o', {}, 'hi') is None
# --- enabled on spec + think=True -> reason only on round 1 when no other
monkeypatch.setattr(
'src.agent_loop._configured_model_tool_surface_raw',
lambda *a, **k: 'spec', raising=False,
)
decision = spec_router_thinking_decision('u', 'm', 'o', {}, 'plan the refactor')
assert decision == {'think': True, 'p_think': 0.9, 'ms': 1.0}
assert spec_router_think_reason(1, decision, '') == 'router_think'
# existing rule wins; only round 1; think=False never fires
assert spec_router_think_reason(1, decision, 'code') == ''
assert spec_router_think_reason(2, decision, '') == ''
assert spec_router_think_reason(1, {'think': False}, '') == ''
# --- route_thinking raises -> fail open (None), rules only --------------
def _boom(text):
raise RuntimeError('router down')
fake.route_thinking = _boom
assert spec_router_thinking_decision('u', 'm', 'o', {}, 'hi') is None
assert spec_router_think_reason(1, None, '') == ''
# --- wiring present in stream_preview -----------------------------------
source = (Path(__file__).resolve().parents[1] / 'src/clean_agent_preview.py').read_text()
assert 'spec_router_thinking_decision(' in source
assert 'spec_router_think_reason(' in source
def test_qwen_think_split_streams_thinking_then_answer_without_buffering():
from src.clean_agent_preview import qwen_think_split_events
state = {'think_emitted': 0, 'answer_emitted': 0, 'closed': False}
events = []
content = ''
for piece in ['<think>', 'abc', '</think>', 'Hello']:
content += piece
events.extend(qwen_think_split_events(content, state))
thinking = ''.join(e['delta'] for e in events if e.get('thinking'))
answer = ''.join(e['delta'] for e in events if not e.get('thinking'))
assert thinking == 'abc'
assert answer == 'Hello'
# every emitted delta is non-empty and typed
assert all(e.get('delta') for e in events)
assert [e.get('thinking') for e in events] == [True, None]
# the stored answer stays identical to visible_content_after_qwen_thinking
from src.clean_agent_preview import visible_content_after_qwen_thinking
assert visible_content_after_qwen_thinking(content) == 'Hello'
def test_qwen_think_split_holds_back_a_partial_close_tag():
from src.clean_agent_preview import qwen_think_split_events
state = {'think_emitted': 0, 'answer_emitted': 0, 'closed': False}
events = []
content = ''
for piece in ['<think>a', '</thi', 'nk>', 'B']:
content += piece
events.extend(qwen_think_split_events(content, state))
thinking = ''.join(e['delta'] for e in events if e.get('thinking'))
answer = ''.join(e['delta'] for e in events if not e.get('thinking'))
assert thinking == 'a' # the partial '</thi' is never leaked
assert answer == 'B'
assert all(e.get('delta') for e in events)
def test_qwen_think_split_is_inert_without_a_think_block():
from src.clean_agent_preview import qwen_think_split_events
state = {'think_emitted': 0, 'answer_emitted': 0, 'closed': False}
assert qwen_think_split_events('plain answer', state) == []
assert qwen_think_split_events('', state) == []
assert state == {'think_emitted': 0, 'answer_emitted': 0, 'closed': False}
# source-level: stream_preview wires the incremental split
source = (Path(__file__).resolve().parents[1] / 'src/clean_agent_preview.py').read_text()
assert 'qwen_think_split_events(' in source
assert "think_answer_streamed" in source
+36
View File
@@ -17,6 +17,42 @@ ERROR = 'event: error\ndata: {"status": 504, "error": {"message": "stream timeou
DONE = 'data: [DONE]\n\n' DONE = 'data: [DONE]\n\n'
@pytest.mark.asyncio
async def test_answer_deltas_are_visible_before_terminal_completion_check():
finish = asyncio.Event()
@with_completion_gate
async def stream(messages):
yield _event({'delta': 'Live answer starts. '})
await finish.wait()
yield _event({'type': 'metrics', 'data': {}})
yield DONE
output = stream([])
first = await asyncio.wait_for(anext(output), timeout=0.2)
assert json.loads(first[6:])['delta'] == 'Live answer starts. '
finish.set()
remaining = [chunk async for chunk in output]
assert _decision([first, *remaining])['can_complete'] is True
@pytest.mark.asyncio
async def test_completion_replacement_replaces_already_streamed_answer_for_turn():
@with_completion_gate
async def stream(messages):
yield _event({'delta': 'The function returns a boolean. I ran pytest and all tests passed.'})
yield _event({'type': 'metrics', 'data': {}})
yield DONE
chunks = [chunk async for chunk in stream([{'role': 'user', 'content': 'Explain this code.'}])]
frames = [data for event, data in _frames(chunks) if event == 'message' and isinstance(data, dict)]
assert any(item.get('delta') == 'The function returns a boolean.' for item in frames)
assert not any('I ran pytest' in str(item.get('delta', '')) for item in frames)
replacement = next(item for item in frames if item.get('type') == 'final_response')
assert 'I ran pytest' not in replacement['content']
assert replacement['replacement_scope'] == 'turn'
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.parametrize('terminal_texts', [[], ['', ''], ['Recovered answer with literal [DONE] text.']]) @pytest.mark.parametrize('terminal_texts', [[], ['', ''], ['Recovered answer with literal [DONE] text.']])
async def test_terminal_round_retraction_does_not_resurrect_buffered_drafts(terminal_texts): async def test_terminal_round_retraction_does_not_resurrect_buffered_drafts(terminal_texts):
+2 -2
View File
@@ -338,8 +338,8 @@ def test_hardlinked_effect_state_is_control_plane_without_scanning_the_store(tmp
assert str(store) not in listed assert str(store) not in listed
assert resources._control_plane_path(str(alias), snapshot=snapshot) is True assert resources._control_plane_path(str(alias), snapshot=snapshot) is True
assert str(store) not in globbed, "the effect store is listed one level, never recursively inventoried" assert str(store) not in globbed, "the effect store is listed one level, never recursively inventoried"
# Multi-linked regular files are treated as control-plane resources to prevent hardlink race/escape behavior. # Multiply linked files elsewhere stay ordinary.
elsewhere = tmp_path / "other.txt" elsewhere = tmp_path / "other.txt"
elsewhere.write_text("y") elsewhere.write_text("y")
os.link(elsewhere, workspace / "pnpm-style.txt") os.link(elsewhere, workspace / "pnpm-style.txt")
assert resources._control_plane_path(str(workspace / "pnpm-style.txt")) is True assert resources._control_plane_path(str(workspace / "pnpm-style.txt")) is False
+2 -1
View File
@@ -148,6 +148,7 @@ def _chat_stream_endpoint(
endpoint_url="https://selected.example/v1", endpoint_url="https://selected.example/v1",
session_model="selected-model", session_model="selected-model",
session_history=(), session_history=(),
attachment_meta=(),
): ):
def add_message(message): def add_message(message):
captured.setdefault("added_messages", []).append(message) captured.setdefault("added_messages", []).append(message)
@@ -172,7 +173,7 @@ def _chat_stream_endpoint(
{"role": "assistant", "content": "old answer"}, {"role": "assistant", "content": "old answer"},
{"role": "user", "content": "hello"}, {"role": "user", "content": "hello"},
], ],
preprocessed=SimpleNamespace(attachment_meta=[]), preprocessed=SimpleNamespace(attachment_meta=list(attachment_meta)),
auto_opened_docs=[], auto_opened_docs=[],
rag_sources=[], rag_sources=[],
web_sources=[], web_sources=[],
+14
View File
@@ -7,6 +7,7 @@ os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
import pytest import pytest
from src.llm_core import _supports_thinking from src.llm_core import _supports_thinking
from src.model_profiles import supports_user_thinking_toggle
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -25,3 +26,16 @@ def test_deepseek_v4_models_support_thinking(model):
@pytest.mark.parametrize("model", ["deepseek-v3", "deepseek-chat"]) @pytest.mark.parametrize("model", ["deepseek-v3", "deepseek-chat"])
def test_other_deepseek_models_are_not_promoted_to_thinking(model): def test_other_deepseek_models_are_not_promoted_to_thinking(model):
assert _supports_thinking(model) is False assert _supports_thinking(model) is False
@pytest.mark.parametrize(
"model",
[
"deepseek-v4",
"deepseek-v4-flash",
"DeepSeek-V4-Flash",
"deepseek/deepseek-v4-flash",
],
)
def test_deepseek_v4_models_expose_the_user_thinking_toggle(model):
assert supports_user_thinking_toggle(model) is True
+3 -3
View File
@@ -368,7 +368,7 @@ def test_detached_container_math_typesets_with_the_real_renderer(node_available)
mdToHtml defers math to a document-scoped flush, which cannot reach a mdToHtml defers math to a document-scoped flush, which cannot reach a
detached node, so the export has to typeset its own container before detached node, so the export has to typeset its own container before
handing it to browser printing. This is that container: pending spans in, real handing it to html2pdf. This is that container: pending spans in, real
KaTeX markup out, no .katex-error and nothing left pending. KaTeX markup out, no .katex-error and nothing left pending.
""" """
out = _run_node( out = _run_node(
@@ -400,7 +400,7 @@ def test_detached_container_math_typesets_with_the_real_renderer(node_available)
assert "ody-math-pending" not in out["written"] assert "ody-math-pending" not in out["written"]
def test_pdf_export_typesets_its_container_before_print(): def test_pdf_export_typesets_its_container_before_html2pdf():
"""Ordering in a call site, so pin the call site. No node needed.""" """Ordering in a call site, so pin the call site. No node needed."""
source = document_source() source = document_source()
match = re.search(r"\n async function exportAsPdf\(\) \{(.*?)\n \}\n", source, re.S) match = re.search(r"\n async function exportAsPdf\(\) \{(.*?)\n \}\n", source, re.S)
@@ -410,7 +410,7 @@ def test_pdf_export_typesets_its_container_before_print():
render = "await markdownModule.renderMath(container);" render = "await markdownModule.renderMath(container);"
assert render in body, "the export never typesets its detached container" assert render in body, "the export never typesets its detached container"
assert body.index("container.innerHTML = html;") < body.index(render) assert body.index("container.innerHTML = html;") < body.index(render)
assert body.index(render) < body.index("frame.contentWindow.print()") assert body.index(render) < body.index("window.html2pdf()")
def test_md_to_html_renders_inline_once_katex_is_loaded(node_available): def test_md_to_html_renders_inline_once_katex_is_loaded(node_available):
+145
View File
@@ -0,0 +1,145 @@
"""Model-picker backend: a slow/dead endpoint must not stall /api/models refresh.
Covers the fixes in routes/model_routes.py:
- the local endpoint refresh timeout default is 2s (was 10s);
- a recently failed/timed-out endpoint keeps a ~60s cooldown even on a
user-forced refresh, so a forced refresh does not re-probe a dead endpoint
on every picker open;
- probing is parallel, so one slow endpoint does not push the whole refresh
past its own timeout.
"""
import json
import sys
import time
import types
from types import SimpleNamespace
from unittest.mock import MagicMock
from tests.helpers.import_state import (
clear_fake_endpoint_resolver_modules,
preserve_import_state,
)
def _load_model_routes():
with preserve_import_state(
"core.database", "src.database", "core.session_manager", "routes.model_routes"
):
clear_fake_endpoint_resolver_modules()
if "core.database" not in sys.modules:
stub = types.ModuleType("core.database")
for name in (
"SessionLocal", "ModelEndpoint", "Session", "ChatMessage", "Document",
"DocumentVersion", "GalleryImage", "GalleryAlbum", "Note", "CalendarCal",
"CalendarEvent", "ScheduledTask", "TaskRun", "McpServer",
"ProviderAuthSession", "Base",
):
setattr(stub, name, MagicMock())
stub.utcnow_naive = MagicMock()
sys.modules["core.database"] = stub
import routes.model_routes as model_routes
return model_routes
def _endpoint(**kw):
base = dict(
id="ep", base_url="http://127.0.0.1:9/v1", api_key="k",
provider_auth_id=None, cached_models="[]", pinned_models=[],
is_enabled=True, model_refresh_mode="auto",
)
base.update(kw)
return SimpleNamespace(**base)
def test_local_refresh_timeout_is_two_seconds():
model_routes = _load_model_routes()
ep = _endpoint(base_url="http://100.67.207.85:8071/v1")
assert model_routes._endpoint_refresh_timeout(ep, "local") == 2.0
assert model_routes._endpoint_refresh_timeout(ep, "remote") == 2.0
def test_recent_failure_is_not_reprobed_even_when_forced():
model_routes = _load_model_routes()
router = model_routes.setup_model_routes(model_discovery=None)
should = router._should_refresh_endpoint
ep = _endpoint(id="remote-dead", base_url="http://100.67.207.85:8071/v1")
now = time.time()
ok, info = should(ep, now, force=True)
assert ok is True and info.get("base")
key = info["key"]
# A failure one second ago keeps a 60s cooldown even on a forced refresh.
router._refresh_state[key] = {
"fail_count": 1, "last_failure": now - 1.0, "inflight": False,
}
ok_recent, _ = should(ep, time.time(), force=True)
assert ok_recent is False
# A failure long past the 60s floor is re-probed again.
router._refresh_state[key] = {
"fail_count": 1, "last_failure": now - 120.0, "inflight": False,
}
ok_old, _ = should(ep, time.time(), force=True)
assert ok_old is True
class _FakeQuery:
def __init__(self, rows, first_value):
self._rows = rows
self._first = first_value
def filter(self, *args, **kwargs):
return self
def all(self):
return list(self._rows)
def first(self):
return self._first
class _FakeDB:
def __init__(self, rows, first_value):
self._rows = rows
self._first = first_value
self.committed = False
def query(self, model):
return _FakeQuery(self._rows, self._first)
def commit(self):
self.committed = True
def close(self):
pass
def test_one_slow_endpoint_does_not_stall_the_forced_refresh(monkeypatch):
model_routes = _load_model_routes()
router = model_routes.setup_model_routes(model_discovery=None)
healthy = _endpoint(id="healthy", base_url="http://127.0.0.1:7021/v1")
dead = _endpoint(id="dead", base_url="http://127.0.0.1:9/v1")
monkeypatch.setattr(model_routes, "SessionLocal", lambda: _FakeDB([healthy, dead], healthy))
monkeypatch.setattr(
model_routes, "_disable_stale_cookbook_local_endpoints", lambda db: False
)
def _fake_probe(base, api_key=None, timeout=5):
if "127.0.0.1:9" in base:
time.sleep(min(float(timeout or 2), 2.0))
raise RuntimeError("timed out")
return ["healthy-model"]
monkeypatch.setattr(model_routes, "_probe_endpoint", _fake_probe)
started = time.time()
done = router._refresh_caches_bg(force=True)
assert done is not None
assert done.wait(5) is True
elapsed = time.time() - started
assert elapsed <= 3.0, f"forced refresh took {elapsed:.2f}s"
assert json.loads(healthy.cached_models) == ["healthy-model"]
@@ -8,6 +8,6 @@ ROOT = Path(__file__).resolve().parents[1]
def test_pdf_backed_documents_do_not_offer_destructive_html_pdf_export(): def test_pdf_backed_documents_do_not_offer_destructive_html_pdf_export():
source = document_source() source = document_source()
assert "if (!isForm && (_isDocxLang(lang) || typeof window.print === 'function')) {" in source assert "if (!isForm) {" in source
assert "label: _isDocxLang(lang) ? 'Convert to PDF' : 'Print / save PDF'" in source assert "label: _isDocxLang(lang) ? 'Convert to PDF' : 'Print as PDF'" in source
assert "destroy the original page layout, images, and form structure" in source assert "destroy the original page layout, images, and form structure" in source
+4 -4
View File
@@ -240,7 +240,8 @@ def test_non_qwen_fallback_keeps_requested_temperature(monkeypatch):
def test_qwen_notes_fallback_reenables_personal_managers(monkeypatch): def test_qwen_notes_fallback_reenables_personal_managers(monkeypatch):
"""Model/route fallback cannot bypass caller-disabled tools; denials persist.""" """The answering candidate's notes mode must unblock the managers for
execution, not just enable them in its own route schemas."""
_install_route_probe(monkeypatch) _install_route_probe(monkeypatch)
stream_round = 0 stream_round = 0
@@ -298,9 +299,8 @@ def test_qwen_notes_fallback_reenables_personal_managers(monkeypatch):
) )
) )
# Caller hard denials are absolute: route fallback cannot re-enable disabled tools. assert seen_exec["disabled_tools"].isdisjoint(
assert {"manage_notes", "manage_calendar", "manage_tasks"}.issubset( {"manage_notes", "manage_calendar", "manage_tasks"}
seen_exec["disabled_tools"]
) )
+8 -2
View File
@@ -1,7 +1,11 @@
"""Regression guard for the README title presentation. """Regression guard for the README title presentation.
The original ASCII-art banner needed a code fence to preserve whitespace. Originally (#1390) the README opened with an ASCII-art banner that had to live
Keep the wordmark title identity and the historical ASCII-fencing guard. inside a ``` code fence, otherwise GitHub's markdown collapsed its leading
whitespace and box-drawing rules and rendered it misaligned. The README refresh
(#4306) dropped that banner in favour of a centered wordmark image, so the guard
now pins the wordmark identity instead, while still catching the original failure
mode if an un-fenced ASCII banner is ever reintroduced.
""" """
from pathlib import Path from pathlib import Path
@@ -19,6 +23,8 @@ def _fenced_segments(text: str):
def test_readme_opens_with_wordmark_title(): def test_readme_opens_with_wordmark_title():
# The README must still open with a recognizable Odysseus title: now the
# centered wordmark image rather than an H1 / ASCII banner.
head = "\n".join(README.read_text(encoding="utf-8").splitlines()[:15]) head = "\n".join(README.read_text(encoding="utf-8").splitlines()[:15])
assert 'alt="Odysseus"' in head, "README must open with the Odysseus wordmark image" assert 'alt="Odysseus"' in head, "README must open with the Odysseus wordmark image"
+81
View File
@@ -64,12 +64,57 @@ def test_explicit_request_classes_remain_narrow(user_text, allowed, denied):
assert not grant.permits(ExactOperation.normalize(denied, '{}')) assert not grant.permits(ExactOperation.normalize(denied, '{}'))
def test_search_selection_keeps_the_sibling_web_reader_authorized():
# "Search the web ..." narrows selection to web_search alone, but the turn
# must be able to read a URL the search surfaces; otherwise every result is
# a dead end and the turn fails closed with request_authority_denied.
grant = create_request_authority(
"Search the web for the latest 2025 consumer EEG headset prices "
"under $500 and cite sources")
assert grant.permits(ExactOperation.normalize("web_search", '{"query":"eeg"}'))
assert grant.permits(
ExactOperation.normalize("web_fetch", '{"url":"https://example.com/report"}'))
def test_fetch_only_selection_does_not_grant_search():
grant = create_request_authority("Fetch https://example.com and summarize it")
assert grant.permits(
ExactOperation.normalize("web_fetch", '{"url":"https://example.com"}'))
assert not grant.permits(ExactOperation.normalize("web_search", '{"query":"x"}'))
def test_implicit_editor_revision_authority_requires_active_document():
prompt = ('ga mer ut pa att vi tanker pa framtid for bjorn och att vi faktist '
'trivs sa bra har med framtid planerad')
edit = ExactOperation.normalize("edit_document", '{"edits":[]}')
with_editor = create_request_authority(prompt, active_document=True)
without_editor = create_request_authority(prompt, active_document=False)
assert with_editor.permits(edit)
assert not without_editor.permits(edit)
def test_safe_task_read_does_not_authorize_same_tool_mutation(): def test_safe_task_read_does_not_authorize_same_tool_mutation():
grant = create_request_authority("List my tasks") grant = create_request_authority("List my tasks")
assert grant.permits(ExactOperation.normalize("manage_tasks", '{"action":"list"}')) assert grant.permits(ExactOperation.normalize("manage_tasks", '{"action":"list"}'))
assert not grant.permits(ExactOperation.normalize("manage_tasks", '{"action":"create","prompt":"run bash"}')) assert not grant.permits(ExactOperation.normalize("manage_tasks", '{"action":"create","prompt":"run bash"}'))
def test_action_only_read_tolerates_benign_model_arguments():
# "What do you remember about me?" pins manage_memory to action=list. The
# model must not be denied merely for adding an innocuous limit/cursor, while
# a different (mutating) action stays outside the read grant.
grant = create_request_authority("What do you remember about me?")
memory = next(g for g in grant.grants if g.tool == "manage_memory")
assert memory.inputs is None
assert grant.permits(ExactOperation.normalize("manage_memory", '{"action":"list"}'))
assert grant.permits(
ExactOperation.normalize("manage_memory", '{"action":"list","limit":10}'))
assert not grant.permits(
ExactOperation.normalize("manage_memory", '{"action":"delete","id":"x"}'))
def test_exact_read_identifiers_cannot_be_changed_by_model(): def test_exact_read_identifiers_cannot_be_changed_by_model():
grant = create_request_authority("Read note id abc123") grant = create_request_authority("Read note id abc123")
read = next(g for g in grant.grants if g.tool == "manage_notes") read = next(g for g in grant.grants if g.tool == "manage_notes")
@@ -413,6 +458,42 @@ async def test_server_seeded_defaults_have_authority_but_legacy_rows_do_not_gain
engine.dispose() engine.dispose()
@pytest.mark.asyncio
async def test_unsealed_housekeeping_row_is_replaced_with_an_authorized_copy(monkeypatch, tmp_path):
"""A built-in row that predates the authority seal (NULL snapshot) must be
replaced by a freshly seeded, authorized copy, not left unrunnable. This is
the 'Email Tags' -> 'no matching server request authority' failure."""
import core.database as database
import routes.prefs_routes as preferences
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from src.task_scheduler import TaskScheduler
engine = create_engine(f"sqlite:///{tmp_path / 'tasks.db'}")
database.Base.metadata.create_all(engine)
sessions = sessionmaker(bind=engine)
monkeypatch.setattr(database, "SessionLocal", sessions)
monkeypatch.setattr(preferences, "_load_for_user", lambda owner: {})
scheduler = TaskScheduler(session_manager=None)
monkeypatch.setattr(scheduler, "ensure_assistant_defaults", AsyncMock())
with sessions() as db:
db.add(database.ScheduledTask(id="broken", owner="alice", name="Email Tags",
task_type="action", action="check_email_urgency",
status="active", request_authority_json=None))
db.commit()
await scheduler.ensure_defaults("alice")
with sessions() as db:
rows = db.query(database.ScheduledTask).filter(
database.ScheduledTask.action == "check_email_urgency").all()
assert len(rows) == 1, "the unsealed row must be replaced, not duplicated"
task = rows[0]
assert task.id != "broken"
assert task.status == "active", "the user's enable choice must survive the reseed"
grant = restore_task_authority(task.request_authority_json, task.prompt,
task.task_type, task.action, owner=task.owner)
assert grant.permits(task_operation(task.task_type, task.action, task.prompt))
engine.dispose()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_dispatch_binds_explicit_authority_for_nested_handler(monkeypatch): async def test_dispatch_binds_explicit_authority_for_nested_handler(monkeypatch):
from src import tool_execution as execution from src import tool_execution as execution
+1 -1
View File
@@ -139,7 +139,7 @@ def run_terminal_branch(monkeypatch, native_calls, result, operation=None, *, ma
_required_read_native_id=al._required_read_native_id, _required_read_native_id=al._required_read_native_id,
_dispatch_required_safe_read=al._dispatch_required_safe_read, _dispatch_required_safe_read=al._dispatch_required_safe_read,
_compute_final_metrics=lambda *a, **kw: {"tool_events": a[8]}, _compute_final_metrics=lambda *a, **kw: {"tool_events": a[8]},
session_id="fixture", disabled_tools=set(), _caller_hard_denials=frozenset(), tool_policy=policy, session_id="fixture", disabled_tools=set(), tool_policy=policy,
owner="fixture", workspace=None, run_security=context, active_document=None, owner="fixture", workspace=None, run_security=context, active_document=None,
client_runtime_context=None, round_num=1, model="fixture-model", client_runtime_context=None, round_num=1, model="fixture-model",
actual_endpoint_id="fixture-endpoint", actual_endpoint_label="fixture", actual_endpoint_id="fixture-endpoint", actual_endpoint_label="fixture",
+31
View File
@@ -549,3 +549,34 @@ def test_host_shell_native_arguments_use_the_same_verifier_classification(comman
encoded = json.dumps({'command': command, 'timeout': 120}) encoded = json.dumps({'command': command, 'timeout': 120})
assert command_is_test(encoded) is expected assert command_is_test(encoded) is expected
assert command_is_validation(encoded) is expected assert command_is_validation(encoded) is expected
@pytest.mark.parametrize('tool', ['create_document', 'update_document', 'edit_document'])
def test_successful_document_write_supports_unnamed_mutation_claim(tool):
ledger = EvidenceLedger.from_tool_events([
{'tool': tool, 'command': 'fix grammar', 'exit_code': 0},
])
claim = 'I updated the document with the grammar fix.'
answer, reason = completion_answer(claim, ledger, ledger.evaluate())
assert claim in answer
assert not reason
def test_document_write_without_authoritative_receipt_cannot_support_claim():
ledger = EvidenceLedger.from_tool_events([
{'tool': 'update_document', 'command': 'fix grammar'},
])
claim = 'I updated the document with the grammar fix.'
answer, reason = completion_answer(claim, ledger, ledger.evaluate())
assert reason
assert claim not in answer
def test_document_evidence_does_not_support_named_file_claim():
ledger = EvidenceLedger.from_tool_events([
{'tool': 'update_document', 'command': 'fix grammar', 'exit_code': 0},
])
claim = 'I updated config.py.'
answer, reason = completion_answer(claim, ledger, ledger.evaluate())
assert reason
assert claim not in answer
+108
View File
@@ -0,0 +1,108 @@
"""Session-load 404 regression (passover item #9).
Loading a chat session used to fire two benign 404s in the browser console:
GET /api/research/status/<id> (no research for this session)
GET /api/chat/stream_status/<id> (no active stream for this session)
Both are now a normal "empty" state: HTTP 200 with an explicit status
("none" / "idle"). The clients already branch on the body status, so this is
backward compatible (an older server's 404 is still tolerated).
"""
import asyncio
import inspect
from types import SimpleNamespace
from unittest.mock import MagicMock
from routes import chat_routes
from routes import research_routes
def _route_endpoint(router, path, method="GET"):
for route in router.routes:
if getattr(route, "path", "") != path:
continue
if method in getattr(route, "methods", set()):
return route.endpoint
raise AssertionError(f"{method} {path} not registered")
def _req(user="alice"):
return SimpleNamespace(state=SimpleNamespace(current_user=user))
# --- /api/research/status/<id> -------------------------------------------
def test_research_status_returns_200_none_when_absent(tmp_path, monkeypatch):
monkeypatch.setattr(research_routes, "DEEP_RESEARCH_DIR", str(tmp_path / "deep_research"))
handler = MagicMock()
handler._active_tasks = {}
router = research_routes.setup_research_routes(handler)
endpoint = _route_endpoint(router, "/api/research/status/{session_id}")
out = asyncio.run(endpoint(session_id="sess-abc", request=_req()))
assert out == {"status": "none"}
def test_research_status_returns_status_when_present(tmp_path, monkeypatch):
monkeypatch.setattr(research_routes, "DEEP_RESEARCH_DIR", str(tmp_path / "deep_research"))
handler = MagicMock()
handler._active_tasks = {"sess-abc": {"owner": "alice"}}
handler.get_status.return_value = {"status": "running", "progress": {"phase": "searching"}}
handler.get_source_state.return_value = None
handler.get_source_coverage.return_value = None
handler.get_navigation_trace.return_value = None
handler.get_action_trace.return_value = None
router = research_routes.setup_research_routes(handler)
endpoint = _route_endpoint(router, "/api/research/status/{session_id}")
out = asyncio.run(endpoint(session_id="sess-abc", request=_req()))
assert out["status"] == "running"
# --- /api/chat/stream_status/<id> ----------------------------------------
def _chat_stream_status_endpoint(monkeypatch):
monkeypatch.setattr(chat_routes, "_verify_session_owner", lambda request, session_id: None)
router = chat_routes.setup_chat_routes(
MagicMock(), MagicMock(), MagicMock(), MagicMock(), MagicMock(), MagicMock()
)
return _route_endpoint(router, "/api/chat/stream_status/{session_id}")
def test_stream_status_returns_200_idle_when_no_stream(monkeypatch):
endpoint = _chat_stream_status_endpoint(monkeypatch)
chat_routes._active_streams.pop("sess-idle", None)
out = asyncio.run(endpoint(SimpleNamespace(), "sess-idle"))
assert out == {"status": "idle"}
def test_stream_status_returns_record_when_streaming(monkeypatch):
endpoint = _chat_stream_status_endpoint(monkeypatch)
chat_routes._active_streams["sess-live"] = {"status": "streaming", "mode": "agent"}
try:
out = asyncio.run(endpoint(SimpleNamespace(), "sess-live"))
finally:
chat_routes._active_streams.pop("sess-live", None)
assert out["status"] == "streaming"
# --- client compatibility (source-level) ---------------------------------
def test_chat_watchdog_accepts_200_idle_body():
src = inspect.getsource(chat_routes) # ensure chat_routes imported
chatjs = open("static/js/chat.js", encoding="utf-8").read()
# The stale-stream watchdog must key off the body status, not only a 404.
assert "_noActiveStream" in chatjs
assert "_probe.status !== 'streaming'" in chatjs
assert src # keep flake happy
def test_sessions_check_uses_body_status_not_ok_only():
sj = open("static/js/sessions.js", encoding="utf-8").read()
assert "info.status !== 'streaming'" in sj
assert "status !== 'streaming'" in sj
@@ -0,0 +1,33 @@
"""Health audit: SSE client disconnects (e.g. /api/editor/bridge/events) surface
as anyio.EndOfStream through Starlette's BaseHTTPMiddleware. The slow-request
middleware must map that to a client-side 499, not a logged 500 traceback."""
import asyncio
import anyio
from starlette.requests import Request
from app import _SlowRequestLogMiddleware
def _request(path):
return Request({"type": "http", "method": "GET", "path": path, "headers": []})
def test_stream_disconnect_is_mapped_to_499():
async def call_next(_request):
raise anyio.EndOfStream
mw = _SlowRequestLogMiddleware(lambda *a: None)
resp = asyncio.run(mw.dispatch(_request("/api/editor/bridge/events"), call_next))
assert resp.status_code == 499
def test_real_responses_pass_through():
from starlette.responses import JSONResponse
async def call_next(_request):
return JSONResponse({"ok": True}, status_code=200)
mw = _SlowRequestLogMiddleware(lambda *a: None)
resp = asyncio.run(mw.dispatch(_request("/api/health"), call_next))
assert resp.status_code == 200
+33
View File
@@ -328,6 +328,30 @@ def test_conversational_request_wrappers_preserve_explicit_family(message, expec
assert requested_capabilities(message) == expected assert requested_capabilities(message) == expected
@pytest.mark.parametrize("message", [
"30-day task + protocol doc",
"I wanna work on a project where I test meditation for 30 days. But I test my brain activity before and after so I can compare.",
"Protocol doc only",
"Make a 30-day meditation plan",
"help me build a habit tracker for the project",
])
def test_bare_project_task_noun_does_not_inject_task_tools(message):
# A project description that merely contains the noun "task" must not offer
# the background-task scheduler or let the model auto-create a task.
assert "tasks" not in requested_capabilities(message)
assert selected_tools_for_request(message) != frozenset({"manage_tasks"})
@pytest.mark.parametrize("message", [
"Create a task to call the dentist",
"List my scheduled tasks",
"show me my automations",
"what scheduled tasks do i have?",
])
def test_explicit_task_management_still_routes_to_tasks(message):
assert "tasks" in requested_capabilities(message)
@pytest.mark.parametrize("message", [ @pytest.mark.parametrize("message", [
"can u also pop the notes panel open for me?", "can u also pop the notes panel open for me?",
"k leave it for now - also pop open the calendar panel", "k leave it for now - also pop open the calendar panel",
@@ -4413,3 +4437,12 @@ def test_chinese_video_to_image_artifact_keeps_media_and_workspace_tools():
"请完整浏览 /workspace/input/video.mp4,为每款零食截图并拼成清单图," "请完整浏览 /workspace/input/video.mp4,为每款零食截图并拼成清单图,"
"输出到 /workspace/snack_checklist.jpg,交付前打开成品图检查。" "输出到 /workspace/snack_checklist.jpg,交付前打开成品图检查。"
) == {"media_inspection", "shell_files"} ) == {"media_inspection", "shell_files"}
def test_named_shell_selects_shell_family_even_for_a_bare_command():
# An explicitly named shell is a request to use the shell even when the
# rest of the message is a bare/typo'd command; admission must match the
# always-offered bash tool.
assert requested_capabilities("Bash commabd Ls la") == {"shell_files"}
assert requested_capabilities("bash ls") == {"shell_files"}
assert requested_capabilities("use the terminal to show me the files") == {"shell_files"}