diff --git a/.coverage b/.coverage
index 5b04d9ff..c3abd146 100644
Binary files a/.coverage and b/.coverage differ
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1b46d09c..4901e7f4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -39,14 +39,14 @@ jobs:
run: |
pytest tests/test_categories_roadmap.py tests/test_report_categories_golden.py \
tests/test_categories_coverage.py tests/test_indexation_coverage.py tests/test_crawl_segments.py \
- tests/test_terminology.py \
+ tests/test_terminology.py tests/test_compare_payload.py \
--cov=website_profiling.reporting --cov-config=.coveragerc.reporting \
--cov-report=term-missing --cov-fail-under=100 -q -o addopts=
- name: Pytest (tools coverage gate)
run: |
pytest tests/test_alert_checker.py tests/test_schedule_runner.py tests/test_export_audit.py \
tests/test_export_audit_coverage.py tests/test_audit_tools.py tests/test_audit_tools_expanded.py \
- tests/test_compare_payload.py tests/test_audit_tools_coverage.py \
+ tests/test_audit_tools_coverage.py \
tests/test_mcp_registry.py tests/test_mcp_resources.py \
--cov=website_profiling.tools --cov-config=.coveragerc.tools \
--cov-report=term-missing --cov-fail-under=95 -q -o addopts=
diff --git a/local-test.ps1 b/local-test.ps1
new file mode 100644
index 00000000..f7180beb
--- /dev/null
+++ b/local-test.ps1
@@ -0,0 +1,2 @@
+# Wrapper — run from repo root: .\local-test.ps1 [command]
+& "$PSScriptRoot\scripts\local-test.ps1" @args
diff --git a/scripts/local-run.ps1 b/scripts/local-run.ps1
index 057feb41..95b26265 100644
--- a/scripts/local-run.ps1
+++ b/scripts/local-run.ps1
@@ -301,7 +301,7 @@ Environment overrides (optional):
After start, open: http://localhost:3000/home
Run audits via sidebar "Run audit" (bottom-right FAB).
-Run CI-style tests: ./local-test (bash/Git Bash/WSL) or see scripts/local-test.sh.
+Run CI-style tests: .\local-test.ps1 or ./local-test (bash/Git Bash/WSL).
"@
}
diff --git a/scripts/local-test.ps1 b/scripts/local-test.ps1
new file mode 100644
index 00000000..a04218d9
--- /dev/null
+++ b/scripts/local-test.ps1
@@ -0,0 +1,379 @@
+# Local test runner — mirrors .github/workflows/ci.yml on Windows.
+# Usage: .\scripts\local-test.ps1 [command] [-NoCov]
+# (default) all — Postgres + migrations + Python + web checks
+# python — DB + pytest + CLI smoke only
+# reporting — reporting module 100% coverage gate (CI step)
+# tools — tools module coverage gate
+# web — typecheck, lint, vitest (no Postgres)
+# quick — pytest -NoCov + web (DB must already be running)
+# help — show commands
+# Requires: PowerShell 5.1+ (PowerShell 7+ recommended for reliable exit codes)
+
+$ErrorActionPreference = "Stop"
+
+$ROOT = Split-Path -Parent $PSScriptRoot
+Set-Location $ROOT
+
+$PG_CONTAINER = if ($env:WP_PG_CONTAINER) { $env:WP_PG_CONTAINER } else { "wp-pg" }
+$PG_IMAGE = if ($env:WP_PG_IMAGE) { $env:WP_PG_IMAGE } else { "postgres:16-alpine" }
+$PG_PORT = if ($env:WP_PG_PORT) { $env:WP_PG_PORT } else { "5432" }
+$PG_USER = if ($env:WP_PG_USER) { $env:WP_PG_USER } else { "postgres" }
+$PG_PASSWORD = if ($env:WP_PG_PASSWORD) { $env:WP_PG_PASSWORD } else { "dev" }
+$PG_DB = if ($env:WP_PG_DB) { $env:WP_PG_DB } else { "website_profiling" }
+
+if (-not $env:DATABASE_URL) {
+ $env:DATABASE_URL = "postgres://${PG_USER}:${PG_PASSWORD}@127.0.0.1:${PG_PORT}/${PG_DB}"
+}
+if (-not $env:DATA_DIR) {
+ $env:DATA_DIR = Join-Path $ROOT "data"
+}
+
+$VENV = Join-Path $ROOT ".venv"
+$VENV_PYTHON = Join-Path $VENV "Scripts\python.exe"
+$VENV_PIP = Join-Path $VENV "Scripts\pip.exe"
+$VENV_PYTEST = Join-Path $VENV "Scripts\pytest.exe"
+$VENV_ALEMBIC = Join-Path $VENV "Scripts\alembic.exe"
+$WEB = Join-Path $ROOT "web"
+
+$env:WEBSITE_PROFILING_ROOT = $ROOT
+if ($env:PYTHONPATH) {
+ $env:PYTHONPATH = "$($env:PYTHONPATH);$(Join-Path $ROOT 'src')"
+} else {
+ $env:PYTHONPATH = Join-Path $ROOT "src"
+}
+
+$PytestNoCov = $false
+
+function Write-Log([string]$Message) {
+ Write-Host "-> $Message" -ForegroundColor Cyan
+}
+
+function Write-Ok([string]$Message) {
+ Write-Host "OK $Message" -ForegroundColor Green
+}
+
+function Write-Warn([string]$Message) {
+ Write-Host "! $Message" -ForegroundColor Yellow
+}
+
+function Write-Die([string]$Message) {
+ Write-Host "X $Message" -ForegroundColor Red
+ exit 1
+}
+
+function Assert-LastExitCode([string]$Message) {
+ $failed = $false
+ if ($PSVersionTable.PSVersion.Major -ge 7) {
+ $failed = ($LASTEXITCODE -ne 0)
+ } else {
+ $failed = (-not $?)
+ }
+ if ($failed) {
+ Write-Die $Message
+ }
+}
+
+function Test-Command([string]$Name) {
+ if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
+ Write-Die "Missing required command: $Name"
+ }
+}
+
+function Get-PythonLauncher {
+ foreach ($cmd in @("python", "python3", "py")) {
+ if (Get-Command $cmd -ErrorAction SilentlyContinue) {
+ if ($cmd -eq "py") {
+ return ,@("py", "-3")
+ }
+ return ,@($cmd)
+ }
+ }
+ Write-Die "Missing required command: python (install Python 3.11+ and ensure it is on PATH)"
+}
+
+function Invoke-PythonLauncher {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string[]]$Launcher,
+ [Parameter(ValueFromRemainingArguments = $true)]
+ [string[]]$PythonArgs
+ )
+
+ if ($Launcher.Count -gt 1) {
+ & $Launcher[0] $Launcher[1] @PythonArgs
+ } else {
+ & $Launcher[0] @PythonArgs
+ }
+ Assert-LastExitCode "Python command failed: $($Launcher -join ' ') $($PythonArgs -join ' ')"
+}
+
+function Get-DockerContainerNames {
+ param([switch]$All)
+
+ $dockerArgs = if ($All) { @("ps", "-a") } else { @("ps") }
+ $output = & docker @dockerArgs --format "{{.Names}}" 2>$null
+ if (-not $output) {
+ return @()
+ }
+ return @($output | ForEach-Object { "$_".Trim() } | Where-Object { $_ })
+}
+
+function Test-DockerRunning {
+ Test-Command docker
+ $prevErrorAction = $ErrorActionPreference
+ $ErrorActionPreference = "Continue"
+ try {
+ cmd /c "docker info >nul 2>&1"
+ } finally {
+ $ErrorActionPreference = $prevErrorAction
+ }
+ Assert-LastExitCode "Docker is not running. Start Docker Desktop, then retry."
+}
+
+function Test-ContainerExists([string]$Name) {
+ return (Get-DockerContainerNames -All) -contains $Name
+}
+
+function Test-ContainerRunning([string]$Name) {
+ return (Get-DockerContainerNames) -contains $Name
+}
+
+function Wait-ForPostgres {
+ for ($i = 1; $i -le 30; $i++) {
+ & docker exec $PG_CONTAINER pg_isready -U $PG_USER -d $PG_DB *> $null
+ if ($PSVersionTable.PSVersion.Major -ge 7) {
+ if ($LASTEXITCODE -eq 0) { return }
+ } elseif ($?) {
+ return
+ }
+ Start-Sleep -Seconds 1
+ }
+ Write-Die "Postgres did not become ready in time (container: $PG_CONTAINER)"
+}
+
+function Invoke-Db {
+ Test-DockerRunning
+ if (Test-ContainerExists $PG_CONTAINER) {
+ if (Test-ContainerRunning $PG_CONTAINER) {
+ Write-Log "Postgres already running ($PG_CONTAINER)"
+ } else {
+ Write-Log "Starting existing container $PG_CONTAINER"
+ & docker start $PG_CONTAINER *> $null
+ Assert-LastExitCode "Failed to start container $PG_CONTAINER"
+ }
+ } else {
+ Write-Log "Creating Postgres container $PG_CONTAINER on port $PG_PORT"
+ & docker run -d --name $PG_CONTAINER `
+ -e "POSTGRES_PASSWORD=$PG_PASSWORD" `
+ -e "POSTGRES_DB=$PG_DB" `
+ -p "${PG_PORT}:5432" `
+ $PG_IMAGE *> $null
+ Assert-LastExitCode "Failed to create Postgres container $PG_CONTAINER"
+ }
+ Wait-ForPostgres
+ Write-Log "DATABASE_URL=$($env:DATABASE_URL)"
+}
+
+function Invoke-Venv {
+ $pyLauncher = Get-PythonLauncher
+ if (-not (Test-Path $VENV_PYTHON)) {
+ Write-Log "Creating Python venv at .venv"
+ Invoke-PythonLauncher -Launcher $pyLauncher -PythonArgs @("-m", "venv", $VENV)
+ }
+ if (-not (Test-Path $VENV_PYTEST)) {
+ Write-Log "Installing Python dependencies"
+ & $VENV_PIP install -q -r (Join-Path $ROOT "requirements.txt")
+ Assert-LastExitCode "Failed to install requirements.txt"
+ & $VENV_PIP install -q -r (Join-Path $ROOT "requirements-browser.txt")
+ Assert-LastExitCode "Failed to install requirements-browser.txt"
+ }
+}
+
+function Invoke-Migrate {
+ Invoke-Db
+ if (-not (Test-Path $VENV_ALEMBIC)) {
+ Invoke-Venv
+ }
+ Write-Log "Applying database migrations (alembic upgrade head)"
+ & $VENV_ALEMBIC upgrade head
+ Assert-LastExitCode "Database migration failed (alembic upgrade head)"
+}
+
+function Invoke-WebDeps {
+ Test-Command npm
+ $nodeModules = Join-Path $WEB "node_modules"
+ if (-not (Test-Path $nodeModules)) {
+ Write-Log "Installing web dependencies (npm ci)"
+ Push-Location $WEB
+ try {
+ & npm ci
+ Assert-LastExitCode "Failed to install web dependencies (npm ci)"
+ } finally {
+ Pop-Location
+ }
+ }
+}
+
+function Invoke-PytestCore {
+ if ($PytestNoCov) {
+ Write-Log "Pytest (tests/ -q -m not browser --no-cov)"
+ & $VENV_PYTEST tests/ -q -m "not browser" --no-cov
+ } else {
+ Write-Log "Pytest (tests/ -q -m not browser, 100% coverage gate)"
+ & $VENV_PYTEST tests/ -q -m "not browser"
+ }
+ Assert-LastExitCode "Core pytest failed"
+}
+
+function Invoke-PytestReporting {
+ Write-Log "Pytest (reporting coverage gate, 100%)"
+ & $VENV_PYTEST `
+ tests/test_categories_roadmap.py `
+ tests/test_report_categories_golden.py `
+ tests/test_categories_coverage.py `
+ tests/test_indexation_coverage.py `
+ tests/test_crawl_segments.py `
+ tests/test_terminology.py `
+ tests/test_compare_payload.py `
+ --cov=website_profiling.reporting `
+ --cov-config=.coveragerc.reporting `
+ --cov-report=term-missing `
+ --cov-fail-under=100 `
+ -q `
+ -o addopts=
+ Assert-LastExitCode "Reporting coverage gate failed"
+}
+
+function Invoke-PytestTools {
+ Write-Log "Pytest (tools coverage gate, 95%)"
+ & $VENV_PYTEST `
+ tests/test_alert_checker.py `
+ tests/test_schedule_runner.py `
+ tests/test_export_audit.py `
+ tests/test_export_audit_coverage.py `
+ tests/test_audit_tools.py `
+ tests/test_audit_tools_expanded.py `
+ tests/test_audit_tools_coverage.py `
+ tests/test_mcp_registry.py `
+ tests/test_mcp_resources.py `
+ --cov=website_profiling.tools `
+ --cov-config=.coveragerc.tools `
+ --cov-report=term-missing `
+ --cov-fail-under=95 `
+ -q `
+ -o addopts=
+ Assert-LastExitCode "Tools coverage gate failed"
+}
+
+function Invoke-PythonChecks {
+ Invoke-Db
+ Invoke-Venv
+ Invoke-Migrate
+ Invoke-PytestCore
+ Invoke-PytestReporting
+ Invoke-PytestTools
+ Write-Log "CLI smoke (python -m src --help)"
+ & $VENV_PYTHON -m src --help *> $null
+ Assert-LastExitCode "CLI smoke failed"
+ Write-Ok "Python checks passed"
+}
+
+function Invoke-WebChecks {
+ Invoke-WebDeps
+ Write-Log "Web typecheck"
+ Push-Location $WEB
+ try {
+ & npm run typecheck
+ Assert-LastExitCode "Web typecheck failed"
+ Write-Log "Web lint"
+ & npm run lint
+ Assert-LastExitCode "Web lint failed"
+ Write-Log "Web tests (vitest)"
+ & npm test
+ Assert-LastExitCode "Web tests failed"
+ } finally {
+ Pop-Location
+ }
+ Write-Ok "Web checks passed"
+}
+
+function Invoke-Quick {
+ if (-not $env:DATABASE_URL) {
+ Write-Die "DATABASE_URL is not set. Export it or run .\scripts\local-test.ps1 all"
+ }
+ Invoke-Venv
+ Invoke-WebDeps
+ Write-Warn "quick: assuming Postgres is up and migrated (.\local-run.ps1 db; .\local-run.ps1 migrate)"
+ $PytestNoCov = $true
+ Invoke-PytestCore
+ Write-Log "CLI smoke (python -m src --help)"
+ & $VENV_PYTHON -m src --help *> $null
+ Assert-LastExitCode "CLI smoke failed"
+ Invoke-WebChecks
+ Write-Ok "Quick test run passed"
+}
+
+function Show-Help {
+ Write-Host @"
+Local test runner — mirrors CI (python + web jobs)
+
+ .\scripts\local-test.ps1 Same as: all
+ .\scripts\local-test.ps1 all Postgres + migrations + full pytest + web
+ .\scripts\local-test.ps1 python DB + pytest (core + reporting + tools) + CLI
+ .\scripts\local-test.ps1 reporting Reporting module 100% coverage gate only
+ .\scripts\local-test.ps1 tools Tools module coverage gate only
+ .\scripts\local-test.ps1 web typecheck, lint, vitest (no Docker)
+ .\scripts\local-test.ps1 quick pytest -NoCov + web (DB must be ready)
+
+ .\scripts\local-test.ps1 all -NoCov skip pytest coverage gates (faster)
+
+Environment (same as .\local-run.ps1):
+ DATABASE_URL, DATA_DIR, WP_PG_CONTAINER, WP_PG_PORT, ...
+
+One-time dev setup: .\local-run.ps1 setup
+"@
+}
+
+$cmd = "all"
+$argList = @($args)
+if ($argList.Count -gt 0) {
+ $cmd = $argList[0]
+ $argList = if ($argList.Count -gt 1) { $argList[1..($argList.Count - 1)] } else { @() }
+}
+
+foreach ($arg in $argList) {
+ switch ($arg) {
+ "-NoCov" { $PytestNoCov = $true }
+ "-h" { Show-Help; exit 0 }
+ "--help" { Show-Help; exit 0 }
+ default { Write-Die "Unknown argument: $arg (try: .\scripts\local-test.ps1 help)" }
+ }
+}
+
+switch ($cmd) {
+ "all" {
+ Invoke-PythonChecks
+ Invoke-WebChecks
+ Write-Ok "All local tests passed (CI python + web jobs)"
+ }
+ "python" { Invoke-PythonChecks }
+ "reporting" {
+ Invoke-Venv
+ Invoke-PytestReporting
+ Write-Ok "Reporting coverage gate passed"
+ }
+ "tools" {
+ Invoke-Venv
+ Invoke-PytestTools
+ Write-Ok "Tools coverage gate passed"
+ }
+ "web" { Invoke-WebChecks }
+ "quick" {
+ $PytestNoCov = $true
+ Invoke-Quick
+ }
+ "help" { Show-Help }
+ "-h" { Show-Help }
+ "--help" { Show-Help }
+ default { Write-Die "Unknown command: $cmd (try: .\scripts\local-test.ps1 help)" }
+}
diff --git a/src/website_profiling/commands/chat_cmd.py b/src/website_profiling/commands/chat_cmd.py
index 5345226e..25c63b96 100644
--- a/src/website_profiling/commands/chat_cmd.py
+++ b/src/website_profiling/commands/chat_cmd.py
@@ -5,6 +5,7 @@
import json
import sys
+from ..text_sanitize import sanitize_unicode_deep
from ..tools.audit_tools import AuditToolContext
from ..llm.agent import run_agent_turn
@@ -38,7 +39,7 @@ def run(_cfg: dict, args: argparse.Namespace) -> None:
ctx = AuditToolContext(property_id=pid, report_id=rid)
def on_event(event: dict) -> None:
- print(json.dumps(event, default=str), flush=True)
+ print(json.dumps(sanitize_unicode_deep(event), default=str), flush=True)
try:
result = run_agent_turn(messages, ctx, on_event=on_event)
diff --git a/src/website_profiling/db/_common.py b/src/website_profiling/db/_common.py
index 860f7c24..635267e3 100644
--- a/src/website_profiling/db/_common.py
+++ b/src/website_profiling/db/_common.py
@@ -10,6 +10,8 @@
from psycopg import Connection
from psycopg.types.json import Json
+from ..text_sanitize import strip_surrogates
+
def _now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
@@ -59,8 +61,10 @@ def _sanitize_for_json(obj: Any) -> Any:
"""Recursively replace NaN/Inf and numpy types so JSON is valid."""
if obj is None:
return None
- if isinstance(obj, (bool, str)):
+ if isinstance(obj, bool):
return obj
+ if isinstance(obj, str):
+ return strip_surrogates(obj)
if isinstance(obj, int):
return int(obj)
if isinstance(obj, float):
diff --git a/src/website_profiling/llm/agent.py b/src/website_profiling/llm/agent.py
index c3930374..236dcc18 100644
--- a/src/website_profiling/llm/agent.py
+++ b/src/website_profiling/llm/agent.py
@@ -5,6 +5,7 @@
from typing import Any, Callable
from ..llm_config import llm_is_enabled, load_llm_config_from_db
+from ..text_sanitize import sanitize_unicode_deep, strip_surrogates
from ..tools.audit_tools import AuditToolContext
from ..tools.audit_tools.registry import TOOL_DEFINITIONS, dispatch_tool, openai_tools_schema
from .base import ChatResult, ToolCall, get_llm_client
@@ -60,7 +61,7 @@
def _emit(on_event: Callable[[dict], None] | None, event: dict[str, Any]) -> None:
if on_event:
- on_event(event)
+ on_event(sanitize_unicode_deep(event))
def _supports_native_tools(client: Any) -> bool:
@@ -112,7 +113,7 @@ def _build_openai_messages(history: list[dict[str, str]]) -> list[dict[str, Any]
out: list[dict[str, Any]] = [{"role": "system", "content": SYSTEM_PROMPT}]
for msg in history:
role = msg.get("role")
- content = str(msg.get("content") or "")
+ content = strip_surrogates(str(msg.get("content") or ""))
if role in ("user", "assistant"):
out.append({"role": role, "content": content})
return out
@@ -147,7 +148,7 @@ def run_agent_turn(
final_message = ""
def on_token(text: str) -> None:
- _emit(on_event, {"type": "token", "text": text})
+ _emit(on_event, {"type": "token", "text": strip_surrogates(text)})
for _round in range(MAX_TOOL_ROUNDS):
_emit(on_event, {
@@ -156,10 +157,11 @@ def on_token(text: str) -> None:
"detail": f"Thinking (step {_round + 1}/{MAX_TOOL_ROUNDS})…",
})
try:
+ llm_messages = sanitize_unicode_deep(openai_messages)
if _supports_native_tools(client):
- result = client.chat_with_tools(openai_messages, tools, on_token=on_token)
+ result = client.chat_with_tools(llm_messages, tools, on_token=on_token)
else:
- result = _react_step(client, openai_messages, _tools_description(compact=True), on_token)
+ result = _react_step(client, llm_messages, _tools_description(compact=True), on_token)
except Exception as e:
msg = str(e).strip() or type(e).__name__
if "httpx" in msg.lower() or "requirements-llm" in msg.lower():
@@ -180,7 +182,7 @@ def on_token(text: str) -> None:
"function": {
"index": i,
"name": tc.name,
- "arguments": tc.arguments,
+ "arguments": sanitize_unicode_deep(tc.arguments),
},
})
else:
@@ -193,7 +195,7 @@ def on_token(text: str) -> None:
if _supports_native_tools(client):
openai_messages.append({
"role": "assistant",
- "content": result.content or "",
+ "content": strip_surrogates(result.content or ""),
"tool_calls": assistant_tool_calls,
})
else:
@@ -204,25 +206,28 @@ def on_token(text: str) -> None:
for tc in result.tool_calls:
_emit(on_event, {"type": "tool_start", "name": tc.name, "args": tc.arguments})
- tool_result = dispatch_tool(tc.name, tc.arguments, context=context)
+ tool_result = sanitize_unicode_deep(
+ dispatch_tool(tc.name, tc.arguments, context=context),
+ )
_emit(on_event, {"type": "tool_end", "name": tc.name, "result": tool_result})
tool_events.append({"name": tc.name, "args": tc.arguments, "result": tool_result})
+ tool_content = json.dumps(tool_result, default=str)
if ollama_format:
openai_messages.append({
"role": "tool",
"tool_name": tc.name,
- "content": json.dumps(tool_result, default=str),
+ "content": tool_content,
})
else:
openai_messages.append({
"role": "tool",
"tool_call_id": tc.id,
- "content": json.dumps(tool_result, default=str),
+ "content": tool_content,
})
continue
- final_message = result.content.strip()
+ final_message = strip_surrogates(result.content).strip()
if final_message:
_emit(on_event, {"type": "done", "message": final_message})
return {"ok": True, "message": final_message, "tool_events": tool_events}
diff --git a/src/website_profiling/text_sanitize.py b/src/website_profiling/text_sanitize.py
new file mode 100644
index 00000000..3cb9bfe5
--- /dev/null
+++ b/src/website_profiling/text_sanitize.py
@@ -0,0 +1,24 @@
+"""Strip lone UTF-16 surrogates so strings are safe for UTF-8 JSON/HTTP."""
+from __future__ import annotations
+
+from typing import Any
+
+
+def strip_surrogates(text: str) -> str:
+ """Replace lone surrogates (invalid in UTF-8) with U+FFFD."""
+ if not text:
+ return text
+ return text.encode("utf-8", errors="replace").decode("utf-8")
+
+
+def sanitize_unicode_deep(obj: Any) -> Any:
+ """Recursively sanitize strings in nested dicts/lists."""
+ if isinstance(obj, str):
+ return strip_surrogates(obj)
+ if isinstance(obj, dict):
+ return {k: sanitize_unicode_deep(v) for k, v in obj.items()}
+ if isinstance(obj, list):
+ return [sanitize_unicode_deep(v) for v in obj]
+ if isinstance(obj, tuple):
+ return tuple(sanitize_unicode_deep(v) for v in obj)
+ return obj
diff --git a/tests/test_audit_tools_expanded.py b/tests/test_audit_tools_expanded.py
index 9db60c49..00fbd9e4 100644
--- a/tests/test_audit_tools_expanded.py
+++ b/tests/test_audit_tools_expanded.py
@@ -161,7 +161,7 @@ def conn() -> MagicMock:
def test_handler_schema_parity() -> None:
names = {t["name"] for t in TOOL_DEFINITIONS}
assert names == tool_handler_names()
- assert len(TOOL_DEFINITIONS) == 121
+ assert len(TOOL_DEFINITIONS) == 123
def test_slice_helpers() -> None:
diff --git a/tests/test_chat_cmd.py b/tests/test_chat_cmd.py
new file mode 100644
index 00000000..50f5a5c7
--- /dev/null
+++ b/tests/test_chat_cmd.py
@@ -0,0 +1,99 @@
+"""CLI chat command tests."""
+from __future__ import annotations
+
+import argparse
+import io
+import json
+from unittest.mock import patch
+
+import pytest
+
+from website_profiling.commands import chat_cmd
+
+
+def test_chat_cmd_requires_stdin_json() -> None:
+ with pytest.raises(SystemExit) as exc:
+ chat_cmd.run({}, argparse.Namespace(stdin_json=False))
+ assert exc.value.code == 1
+
+
+def test_chat_cmd_invalid_stdin_json(capsys) -> None:
+ with patch("sys.stdin", io.StringIO("not-json")):
+ with pytest.raises(SystemExit) as exc:
+ chat_cmd.run({}, argparse.Namespace(stdin_json=True))
+ assert exc.value.code == 1
+ assert "error" in capsys.readouterr().out
+
+
+def test_chat_cmd_success(capsys) -> None:
+ payload = json.dumps({"messages": [{"role": "user", "content": "Hi"}], "property_id": 1})
+ with patch("sys.stdin", io.StringIO(payload)):
+ with patch(
+ "website_profiling.commands.chat_cmd.run_agent_turn",
+ return_value={"ok": True, "message": "Done"},
+ ) as mock_turn:
+ with pytest.raises(SystemExit) as exc:
+ chat_cmd.run({}, argparse.Namespace(stdin_json=True))
+ assert exc.value.code == 0
+ mock_turn.assert_called_once()
+ assert mock_turn.call_args[0][1].property_id == 1
+
+
+def test_chat_cmd_streams_sanitized_events(capsys) -> None:
+ payload = json.dumps({"messages": [{"role": "user", "content": "Hi"}]})
+
+ def fake_turn(_messages, _ctx, on_event=None):
+ if on_event:
+ on_event({"type": "token", "content": "bad\udc9d"})
+ return {"ok": True}
+
+ with patch("sys.stdin", io.StringIO(payload)):
+ with patch("website_profiling.commands.chat_cmd.run_agent_turn", side_effect=fake_turn):
+ with pytest.raises(SystemExit) as exc:
+ chat_cmd.run({}, argparse.Namespace(stdin_json=True))
+ assert exc.value.code == 0
+ out = capsys.readouterr().out
+ assert "\udc9d" not in out
+ assert "token" in out
+
+
+def test_chat_cmd_coerces_invalid_ids_and_messages(capsys) -> None:
+ payload = json.dumps({"messages": "bad", "property_id": "x", "report_id": "y"})
+ with patch("sys.stdin", io.StringIO(payload)):
+ with patch(
+ "website_profiling.commands.chat_cmd.run_agent_turn",
+ return_value={"ok": True},
+ ) as mock_turn:
+ with pytest.raises(SystemExit) as exc:
+ chat_cmd.run({}, argparse.Namespace(stdin_json=True))
+ assert exc.value.code == 0
+ ctx = mock_turn.call_args[0][1]
+ assert ctx.property_id is None
+ assert ctx.report_id is None
+ assert mock_turn.call_args[0][0] == []
+
+
+def test_chat_cmd_agent_failure(capsys) -> None:
+ payload = json.dumps({"messages": []})
+ with patch("sys.stdin", io.StringIO(payload)):
+ with patch(
+ "website_profiling.commands.chat_cmd.run_agent_turn",
+ return_value={"ok": False, "error": "LLM disabled"},
+ ):
+ with pytest.raises(SystemExit) as exc:
+ chat_cmd.run({}, argparse.Namespace(stdin_json=True))
+ assert exc.value.code == 1
+ assert "LLM disabled" in capsys.readouterr().out
+
+
+def test_chat_cmd_exception(capsys) -> None:
+ payload = json.dumps({"messages": []})
+ with patch("sys.stdin", io.StringIO(payload)):
+ with patch(
+ "website_profiling.commands.chat_cmd.run_agent_turn",
+ side_effect=RuntimeError("boom"),
+ ):
+ with pytest.raises(SystemExit) as exc:
+ chat_cmd.run({}, argparse.Namespace(stdin_json=True))
+ assert exc.value.code == 1
+ assert "boom" in capsys.readouterr().out
diff --git a/tests/test_chat_store.py b/tests/test_chat_store.py
index f6f54625..cf37b7a7 100644
--- a/tests/test_chat_store.py
+++ b/tests/test_chat_store.py
@@ -40,12 +40,76 @@ def test_list_sessions() -> None:
assert rows[0]["property_id"] == 7
+def test_get_session_found() -> None:
+ conn = FakeConn()
+ now = datetime.now(timezone.utc)
+ conn.set_next_cursor(
+ FakeCursor(
+ fetchone_value={
+ "id": 5,
+ "property_id": 7,
+ "title": "Found",
+ "created_at": now,
+ "updated_at": now,
+ },
+ ),
+ )
+ row = get_session(conn, 5)
+ assert row is not None
+ assert row["title"] == "Found"
+
+
def test_get_session_missing() -> None:
conn = FakeConn()
conn.set_next_cursor(FakeCursor(fetchone_value=None))
assert get_session(conn, 99) is None
+def test_get_messages_parses_json_fields() -> None:
+ conn = FakeConn()
+ now = datetime.now(timezone.utc)
+ conn.set_next_cursor(
+ FakeCursor(
+ fetchall_value=[
+ {
+ "id": 1,
+ "role": "tool",
+ "content": "",
+ "tool_name": "list_issues",
+ "tool_args": '{"limit": 5}',
+ "tool_result": "not-json",
+ "created_at": now,
+ },
+ ],
+ ),
+ )
+ msgs = get_messages(conn, 5)
+ assert msgs[0]["tool_args"] == {"limit": 5}
+ assert msgs[0]["tool_result"] == "not-json"
+
+
+def test_get_messages_keeps_invalid_tool_args_json() -> None:
+ conn = FakeConn()
+ now = datetime.now(timezone.utc)
+ conn.set_next_cursor(
+ FakeCursor(
+ fetchall_value=[
+ {
+ "id": 2,
+ "role": "tool",
+ "content": "",
+ "tool_name": "list_issues",
+ "tool_args": "not-json",
+ "tool_result": None,
+ "created_at": now,
+ },
+ ],
+ ),
+ )
+ msgs = get_messages(conn, 5)
+ assert msgs[0]["tool_args"] == "not-json"
+
+
def test_get_messages() -> None:
conn = FakeConn()
now = datetime.now(timezone.utc)
diff --git a/tests/test_compare_payload.py b/tests/test_compare_payload.py
index e9bb1843..0d6723a9 100644
--- a/tests/test_compare_payload.py
+++ b/tests/test_compare_payload.py
@@ -1,13 +1,17 @@
"""Tests for reporting/compare_payload.py — parity with web compare."""
from __future__ import annotations
+from unittest.mock import patch
+
from website_profiling.reporting.compare_payload import (
+ build_category_scores,
build_content_metrics,
build_duplicate_deltas,
build_full_compare,
build_google_metrics,
build_issue_deltas,
build_lighthouse_url_deltas,
+ build_link_metric_deltas,
build_priority_counts,
build_redirect_deltas,
build_security_deltas,
@@ -49,6 +53,11 @@ def _payload(**overrides) -> dict:
def test_norm_report_url() -> None:
assert norm_report_url("https://Ex.COM/page/") == "ex.com/page"
+ assert norm_report_url("") == ""
+ assert norm_report_url(" ") == ""
+ assert norm_report_url("relative/path/") == "relative/path"
+ with patch("website_profiling.reporting.compare_payload.urlparse", side_effect=ValueError("bad")):
+ assert norm_report_url("https://ex.com/x") == "https://ex.com/x"
def test_issue_and_priority_deltas() -> None:
@@ -98,3 +107,172 @@ def test_url_set_diff() -> None:
assert diff["removed_count"] >= 1
assert diff["new_urls"][0].startswith("https://")
assert diff["removed_urls"][0].startswith("https://")
+ assert build_url_set_diff({"links": ["not-a-dict"]}, {}) == {
+ "new_urls": [],
+ "removed_urls": [],
+ "new_count": 0,
+ "removed_count": 0,
+ }
+
+
+def test_issue_deltas_edge_cases() -> None:
+ cur = {
+ "categories": [
+ "skip",
+ {"name": "SEO", "issues": [
+ "skip",
+ {"url": "", "message": ""},
+ {"url": "https://ex.com/a", "message": "Fix", "priority": "Critical"},
+ ]},
+ ],
+ }
+ base = {
+ "categories": [
+ {"name": "SEO", "issues": [
+ {"url": "https://ex.com/b", "message": "Gone", "priority": "Low"},
+ ]},
+ ],
+ }
+ issues = build_issue_deltas(cur, base)
+ kinds = {i["kind"] for i in issues}
+ assert "new" in kinds
+ assert "resolved" in kinds
+
+
+def test_priority_counts_skips_invalid_entries() -> None:
+ cur = {"categories": ["skip", {"issues": ["skip", {"priority": "High"}]}]}
+ base = {"categories": []}
+ counts = build_priority_counts(cur, base)
+ assert counts[1]["current"] == 1
+
+
+def test_lighthouse_from_links_and_skips() -> None:
+ cur = {
+ "lighthouse_by_url": {
+ "": {"performance": 50},
+ "https://ex.com/a": "skip",
+ },
+ "links": [
+ {"url": "https://ex.com/b", "lighthouse": {"median_metrics": {"performance_score": 70, "seo_score": 90}}},
+ "skip",
+ {"url": "https://ex.com/a", "lighthouse": {"median_metrics": {"performance_score": 80}}},
+ ],
+ }
+ base = {"lighthouse_by_url": {"https://ex.com/c": {"median_metrics": {"performance_score": 50, "seo_score": 50}}}}
+ assert build_lighthouse_url_deltas(cur, base) == []
+
+
+def test_link_metric_deltas_edge_cases() -> None:
+ cur = {"links": [{"url": "https://ex.com/a", "inlinks": 10, "outlinks": 5}]}
+ base = {
+ "links": [
+ "skip",
+ {"url": ""},
+ {"url": "https://ex.com/missing"},
+ {"url": "https://ex.com/a", "inlinks": 5, "outlinks": 5, "word_count": "x"},
+ {"url": "https://ex.com/b", "inlinks": 1, "outlinks": 1},
+ ],
+ }
+ deltas = build_link_metric_deltas(cur, base)
+ assert len(deltas) == 1
+ assert deltas[0]["metric"] == "inlinks"
+ assert build_link_metric_deltas({"links": []}, {"links": []}) == []
+
+
+def test_redirect_deltas_removed_and_skips() -> None:
+ cur = {"redirects": ["skip", {"url": "", "from": ""}]}
+ base = {"redirects": [{"url": "https://ex.com/gone", "status": "301"}]}
+ deltas = build_redirect_deltas(cur, base)
+ assert any(d["kind"] == "removed" for d in deltas)
+
+
+def test_security_deltas_resolved_and_skips() -> None:
+ cur = {"security_findings": []}
+ base = {
+ "security_findings": [
+ "skip",
+ {"url": "https://ex.com", "finding_type": "csp", "message": "missing"},
+ ],
+ }
+ deltas = build_security_deltas(cur, base)
+ assert len(deltas) == 1
+ assert deltas[0]["kind"] == "resolved"
+
+
+def test_duplicate_deltas_all_kinds() -> None:
+ cur = {
+ "content_duplicates": [
+ "skip",
+ {"representative_url": ""},
+ {"id": "new1", "representative_url": "https://ex.com/n", "member_count": 3},
+ {"id": "chg", "representative_url": "https://ex.com/c", "member_urls": ["a", "b", "c"]},
+ ],
+ }
+ base = {
+ "content_duplicates": [
+ {"id": "chg", "representative_url": "https://ex.com/c", "member_count": 2},
+ {"id": "rm", "representative_url": "https://ex.com/r", "member_count": 4},
+ ],
+ }
+ deltas = build_duplicate_deltas(cur, base)
+ kinds = {d["kind"] for d in deltas}
+ assert kinds == {"new", "changed", "removed"}
+
+
+def test_tech_deltas_removed_and_skips() -> None:
+ cur = {"tech_stack_summary": {"technologies": ["skip", {"name": "React", "count": 2}]}}
+ base = {"tech_stack_summary": {"technologies": [{"name": "jQuery", "count": 1}]}}
+ deltas = build_tech_deltas(cur, base)
+ kinds = {d["kind"] for d in deltas}
+ assert kinds == {"added", "removed"}
+
+
+def test_google_metrics_unavailable() -> None:
+ assert build_google_metrics({}, {}) == {"available": False, "metrics": []}
+
+
+def test_category_scores_skips_invalid() -> None:
+ cur = {"categories": ["skip", {"id": "", "score": 90}, {"id": "perf", "name": "Performance", "score": 75}]}
+ base = {"categories": [{"id": "perf", "score": 80}]}
+ scores = build_category_scores(cur, base)
+ assert len(scores) == 1
+ assert scores[0]["id"] == "perf"
+ assert scores[0]["delta"] == -5
+
+
+def test_full_compare_truncation() -> None:
+ many_issues = [
+ {"priority": "Low", "message": f"issue-{i}", "url": f"https://ex.com/p{i}"}
+ for i in range(105)
+ ]
+ cur = {"categories": [{"id": "x", "name": "X", "score": 50, "issues": many_issues}]}
+ base = {"categories": []}
+ full = build_full_compare(cur, base)
+ assert full["truncated_sections"].get("issue_deltas") is True
+ assert len(full["issue_deltas"]) == 100
+
+ links = []
+ for i in range(210):
+ links.append({
+ "url": f"https://ex.com/l{i}",
+ "inlinks": i + 10,
+ "outlinks": 1,
+ "word_count": 100,
+ "response_time_ms": 100,
+ })
+ cur_links = {"links": links}
+ base_links = {
+ "links": [
+ {
+ "url": f"https://ex.com/l{i}",
+ "inlinks": i,
+ "outlinks": 1,
+ "word_count": 100,
+ "response_time_ms": 100,
+ }
+ for i in range(210)
+ ],
+ }
+ full_links = build_full_compare(cur_links, base_links)
+ assert full_links["truncated_sections"].get("link_metric_deltas") is True
+ assert len(full_links["link_metric_deltas"]) == 200
diff --git a/tests/test_mcp_registry.py b/tests/test_mcp_registry.py
index 89f4bfb6..95aada69 100644
--- a/tests/test_mcp_registry.py
+++ b/tests/test_mcp_registry.py
@@ -7,7 +7,7 @@
def test_tool_definitions_schema() -> None:
- assert len(TOOL_DEFINITIONS) == 121
+ assert len(TOOL_DEFINITIONS) == 123
for tool in TOOL_DEFINITIONS:
assert tool.get("name")
assert tool.get("description")
diff --git a/tests/test_mcp_resources.py b/tests/test_mcp_resources.py
index 9c181fbf..55abd6f4 100644
--- a/tests/test_mcp_resources.py
+++ b/tests/test_mcp_resources.py
@@ -12,6 +12,15 @@ def test_resolve_properties_resource() -> None:
assert "properties" in text
+def test_resolve_report_latest_missing_payload() -> None:
+ with patch("website_profiling.mcp.server.db_session") as mock_db, patch.object(
+ mcp_server.AuditToolContext, "load_payload", return_value=None,
+ ):
+ mock_db.return_value.__enter__.return_value = object()
+ text = mcp_server._resolve_resource("audit://property/1/report/latest")
+ assert "error" in text
+
+
def test_resolve_glossary_and_tools() -> None:
text = mcp_server._resolve_resource("audit://tools")
assert "tool_count" in text
diff --git a/tests/test_mcp_server_helpers.py b/tests/test_mcp_server_helpers.py
new file mode 100644
index 00000000..9e19e99d
--- /dev/null
+++ b/tests/test_mcp_server_helpers.py
@@ -0,0 +1,190 @@
+"""MCP server helper and main() coverage."""
+from __future__ import annotations
+
+import asyncio
+import json
+import os
+import runpy
+import sys
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from website_profiling.mcp import server as mcp_server
+
+
+def test_default_property_id_env() -> None:
+ with patch.dict(os.environ, {"WP_PROPERTY_ID": "12"}):
+ assert mcp_server._default_property_id() == 12
+ with patch.dict(os.environ, {"WP_PROPERTY_ID": "0"}):
+ assert mcp_server._default_property_id() is None
+ with patch.dict(os.environ, {"WP_PROPERTY_ID": "bad"}):
+ assert mcp_server._default_property_id() is None
+ with patch.dict(os.environ, {}, clear=True):
+ assert mcp_server._default_property_id() is None
+
+
+def test_merge_context() -> None:
+ with patch.dict(os.environ, {"WP_PROPERTY_ID": "3"}):
+ ctx = mcp_server._merge_context({"property_id": 9, "report_id": 4})
+ assert ctx.property_id == 9
+ assert ctx.report_id == 4
+
+ with patch.dict(os.environ, {"WP_PROPERTY_ID": "3"}):
+ ctx = mcp_server._merge_context({"property_id": "bad", "report_id": "bad"})
+ assert ctx.property_id == 3
+ assert ctx.report_id is None
+
+
+def test_payload_index_variants() -> None:
+ index = mcp_server._payload_index({
+ "items": [1, 2],
+ "meta": {"a": 1},
+ "score": 88,
+ })
+ assert index["items"]["count"] == 2
+ assert index["meta"]["type"] == "object"
+ assert index["score"]["type"] == "int"
+
+
+def test_read_glossary_excerpt() -> None:
+ text = mcp_server._read_glossary_excerpt()
+ assert isinstance(text, str)
+ assert text
+
+
+def test_read_glossary_excerpt_missing(monkeypatch) -> None:
+ monkeypatch.setattr(Path, "is_file", lambda _self: False)
+ assert mcp_server._read_glossary_excerpt() == "Glossary file not found."
+
+
+def test_tools_catalog_json_includes_security_tools() -> None:
+ catalog = json.loads(mcp_server._tools_catalog_json())
+ assert catalog["tool_count"] >= 123
+ assert "get_security_findings" in catalog["domains"]["security"]
+
+
+def test_tools_catalog_json_backlinks_domain() -> None:
+ fake_tools = [
+ {
+ "name": "get_bing_overview",
+ "description": "Bing overview without link in name.",
+ "inputSchema": {"type": "object", "properties": {}},
+ },
+ ]
+ with patch("website_profiling.mcp.server.TOOL_DEFINITIONS", fake_tools):
+ catalog = json.loads(mcp_server._tools_catalog_json())
+ assert catalog["domains"]["backlinks"] == ["get_bing_overview"]
+
+
+def test_resolve_glossary_and_report_by_id() -> None:
+ glossary = mcp_server._resolve_resource("audit://glossary")
+ assert isinstance(glossary, str)
+
+ with patch("website_profiling.mcp.server.db_session") as mock_db, patch.object(
+ mcp_server.AuditToolContext, "load_payload", return_value=None,
+ ):
+ mock_db.return_value.__enter__.return_value = object()
+ missing = mcp_server._resolve_resource("audit://property/1/report/99")
+ assert "error" in missing
+
+ with patch("website_profiling.mcp.server.db_session") as mock_db, patch.object(
+ mcp_server.AuditToolContext, "load_payload", return_value={"summary": {"score": 1}, "pages": [1, 2]},
+ ):
+ mock_db.return_value.__enter__.return_value = object()
+ found = mcp_server._resolve_resource("audit://property/1/report/99")
+ payload = json.loads(found)
+ assert payload["summary"]["type"] == "object"
+
+
+def test_mcp_main_missing_sdk() -> None:
+ with patch.dict(sys.modules, {"mcp.server": None, "mcp.server.stdio": None, "mcp.types": None}):
+ with pytest.raises(SystemExit, match="MCP SDK"):
+ mcp_server.main()
+
+
+def test_mcp_main_registers_handlers(monkeypatch) -> None:
+ captured: dict[str, object] = {}
+
+ class FakeServer:
+ def __init__(self, name: str) -> None:
+ captured["name"] = name
+
+ def list_tools(self):
+ def decorator(fn):
+ captured["list_tools"] = fn
+ return fn
+ return decorator
+
+ def call_tool(self):
+ def decorator(fn):
+ captured["call_tool"] = fn
+ return fn
+ return decorator
+
+ def list_resources(self):
+ def decorator(fn):
+ captured["list_resources"] = fn
+ return fn
+ return decorator
+
+ def read_resource(self):
+ def decorator(fn):
+ captured["read_resource"] = fn
+ return fn
+ return decorator
+
+ def create_initialization_options(self):
+ return {}
+
+ async def run(self, *_args, **_kwargs) -> None:
+ captured["ran"] = True
+
+ class FakeStdioCM:
+ async def __aenter__(self):
+ return (MagicMock(), MagicMock())
+
+ async def __aexit__(self, *_args):
+ return False
+
+ fake_server_mod = MagicMock()
+ fake_server_mod.Server = FakeServer
+ fake_stdio_mod = MagicMock()
+ fake_stdio_mod.stdio_server = MagicMock(return_value=FakeStdioCM())
+ fake_types_mod = MagicMock()
+ fake_types_mod.Tool = lambda **kwargs: kwargs
+ fake_types_mod.TextContent = lambda **kwargs: kwargs
+ fake_types_mod.Resource = lambda **kwargs: kwargs
+
+ monkeypatch.setitem(sys.modules, "mcp", MagicMock())
+ monkeypatch.setitem(sys.modules, "mcp.server", fake_server_mod)
+ monkeypatch.setitem(sys.modules, "mcp.server.stdio", fake_stdio_mod)
+ monkeypatch.setitem(sys.modules, "mcp.types", fake_types_mod)
+
+ with patch.dict(os.environ, {"WP_PROPERTY_ID": "7"}, clear=False):
+ mcp_server.main()
+
+ assert captured["name"] == "site-audit"
+ assert captured["ran"] is True
+ tools = asyncio.run(captured["list_tools"]()) # type: ignore[arg-type]
+ assert len(tools) >= 123
+ resources = asyncio.run(captured["list_resources"]()) # type: ignore[arg-type]
+ assert any(r["uri"] == "audit://property/7" for r in resources)
+
+ with patch("website_profiling.mcp.server.dispatch_tool", return_value={"ok": True}):
+ content = asyncio.run(captured["call_tool"]("list_properties", {"property_id": 1})) # type: ignore[arg-type]
+ assert content[0]["text"] == json.dumps({"ok": True}, indent=2, default=str)
+ read_text = asyncio.run(captured["read_resource"]("audit://tools")) # type: ignore[arg-type]
+ assert read_text.startswith("{")
+
+
+def test_mcp_package_main(monkeypatch) -> None:
+ with patch("website_profiling.mcp.server.main") as mock_main:
+ runpy.run_module("website_profiling.mcp", run_name="__main__")
+ mock_main.assert_called_once()
+
+
+def test_mcp_server_main_guard() -> None:
+ with pytest.raises(SystemExit, match="MCP SDK"):
+ runpy.run_module("website_profiling.mcp.server", run_name="__main__")
diff --git a/tests/test_text_sanitize.py b/tests/test_text_sanitize.py
new file mode 100644
index 00000000..a73d18ea
--- /dev/null
+++ b/tests/test_text_sanitize.py
@@ -0,0 +1,89 @@
+"""Tests for surrogate stripping in chat/JSON paths."""
+from __future__ import annotations
+
+import json
+from unittest.mock import patch
+
+from website_profiling.llm.agent import run_agent_turn
+from website_profiling.llm.base import ChatResult, ToolCall
+from website_profiling.text_sanitize import sanitize_unicode_deep, strip_surrogates
+from website_profiling.tools.audit_tools import AuditToolContext
+
+
+def test_strip_surrogates_replaces_lone_surrogate() -> None:
+ bad = "URL issue\udc9d here"
+ cleaned = strip_surrogates(bad)
+ assert "\udc9d" not in cleaned
+ cleaned.encode("utf-8")
+
+
+def test_sanitize_unicode_deep_nested() -> None:
+ payload = {
+ "issues": [{"message": "broken\udc9d", "url": "https://example.com"}],
+ }
+ cleaned = sanitize_unicode_deep(payload)
+ serialized = json.dumps(cleaned, ensure_ascii=False)
+ serialized.encode("utf-8")
+
+
+def test_sanitize_unicode_deep_tuple() -> None:
+ cleaned = sanitize_unicode_deep(("ok\udc9d", {"nested": "x\udc9d"}))
+ assert isinstance(cleaned, tuple)
+ assert "\udc9d" not in cleaned[0]
+ assert "\udc9d" not in cleaned[1]["nested"]
+
+
+def test_agent_surrogate_tool_result_does_not_break_llm_request() -> None:
+ surrogate = "\udc9d"
+ tool_payload = {
+ "issues": [
+ {
+ "category": "Technical SEO",
+ "priority": "High",
+ "message": f"URL in sitemap but not crawled: https://codefrydev.in/2048{surrogate}",
+ "url": "https://codefrydev.in/2048",
+ },
+ ],
+ "total": 1,
+ "truncated": False,
+ }
+
+ class RecordingClient:
+ def __init__(self) -> None:
+ self.last_messages: list[dict] | None = None
+
+ def chat_with_tools(self, messages, tools, *, on_token=None):
+ self.last_messages = messages
+ if self.last_messages and any(
+ m.get("role") == "tool" for m in self.last_messages
+ ):
+ return ChatResult(content="Summary with no further tools.")
+ return ChatResult(
+ tool_calls=[ToolCall(id="tc1", name="list_issues", arguments={"priority": "High"})],
+ )
+
+ client = RecordingClient()
+ events: list[dict] = []
+
+ with patch("website_profiling.llm.agent.load_llm_config_from_db", return_value={
+ "llm_enabled": True, "llm_provider": "openai", "llm_api_key": "sk-test",
+ }):
+ with patch("website_profiling.llm.agent.get_llm_client", return_value=client):
+ with patch(
+ "website_profiling.llm.agent.dispatch_tool",
+ return_value=tool_payload,
+ ):
+ result = run_agent_turn(
+ [{"role": "user", "content": "high risk audit issues"}],
+ AuditToolContext(property_id=1),
+ on_event=events.append,
+ )
+
+ assert result["ok"] is True
+ assert client.last_messages is not None
+ json.dumps(
+ {"messages": client.last_messages, "tools": [], "stream": True},
+ ensure_ascii=False,
+ ).encode("utf-8")
+ tool_end = next(e for e in events if e["type"] == "tool_end")
+ assert "\udc9d" not in json.dumps(tool_end["result"])
diff --git a/web/app/api/jobs/[id]/cancel/route.ts b/web/app/api/jobs/[id]/cancel/route.ts
new file mode 100644
index 00000000..44f99111
--- /dev/null
+++ b/web/app/api/jobs/[id]/cancel/route.ts
@@ -0,0 +1,32 @@
+import { NextResponse, type NextRequest } from 'next/server';
+import { forbiddenIfNotLocal } from '@/server/localOnly';
+import { requireApiAuth } from '@/server/auth';
+import { cancelPipelineJob } from '@/server/pipelineJobs';
+import type { ApiRouteHandlerWithParams } from '@/types/api';
+
+export const runtime = 'nodejs';
+
+/**
+ * POST /api/jobs/:id/cancel — stop a running pipeline job.
+ */
+export const POST: ApiRouteHandlerWithParams<{ id: string }> = async (
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> },
+): Promise {s.hint}
+ {s.maxPagesLabel}
+
+ {preview.maxCrawlPages != null ? preview.maxCrawlPages.toLocaleString() : '—'}
+
+ {s.lighthousePagesLabel}
+
+ {preview.lighthousePages != null ? preview.lighthousePages.toLocaleString() : '—'}
+
+ {s.stepsLabel}
+
+ {preview.phases.length}
+
+ {phase.label} {phase.detail}{s.title}
+
+ {preview.phases.map((phase) => (
+
+
+ {preview.summaryLines.map((line) => (
+
+
+ {preview.configRows.length > 0 ? (
+
+ {preview.configRows.map((row) => (
+
+ ) : null}
+