4912 lines
200 KiB
Python
4912 lines
200 KiB
Python
"""Opt-in real-browser chat surface e2e without provider spend."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import textwrap
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
pytestmark = pytest.mark.webui_browser
|
|
|
|
|
|
def _free_port() -> int:
|
|
with socket.socket() as sock:
|
|
sock.bind(("127.0.0.1", 0))
|
|
return int(sock.getsockname()[1])
|
|
|
|
|
|
def _npm() -> str:
|
|
return "npm.cmd" if os.name == "nt" else "npm"
|
|
|
|
|
|
def _node() -> str:
|
|
return "node.exe" if os.name == "nt" else "node"
|
|
|
|
|
|
def _install_playwright(work_dir: Path) -> None:
|
|
result = subprocess.run(
|
|
[_npm(), "--prefix", str(work_dir), "install", "playwright"],
|
|
cwd=Path.cwd(),
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=120,
|
|
)
|
|
assert result.returncode == 0, result.stderr or result.stdout
|
|
|
|
|
|
def _wait_for_health(port: int, server: subprocess.Popen[str]) -> None:
|
|
url = f"http://127.0.0.1:{port}/health"
|
|
deadline = time.monotonic() + 20.0
|
|
last_error = ""
|
|
while time.monotonic() < deadline:
|
|
if server.poll() is not None:
|
|
stdout = server.stdout.read() if server.stdout else ""
|
|
stderr = server.stderr.read() if server.stderr else ""
|
|
raise AssertionError(
|
|
f"gateway exited early code={server.returncode}\nstdout={stdout}\nstderr={stderr}"
|
|
)
|
|
try:
|
|
response = httpx.get(url, timeout=1.0)
|
|
if response.status_code == 200 and response.json().get("ok") is True:
|
|
return
|
|
except Exception as exc: # noqa: BLE001 - surfaced on timeout.
|
|
last_error = str(exc)
|
|
time.sleep(0.1)
|
|
raise AssertionError(f"gateway did not become healthy: {last_error}")
|
|
|
|
|
|
def _stop_process(process: subprocess.Popen[str]) -> None:
|
|
process.terminate()
|
|
try:
|
|
process.wait(timeout=8)
|
|
except subprocess.TimeoutExpired:
|
|
process.kill()
|
|
process.wait(timeout=8)
|
|
|
|
|
|
def test_per_turn_bubble_chip_differs_across_turns_in_real_browser(tmp_path: Path) -> None:
|
|
"""P4-AC6: per-turn .msg-meta__tokens chip reflects per-turn token counts.
|
|
|
|
Two synthetic turns are injected via the RPC event bus (no LLM spend).
|
|
Turn 1 uses input_tokens=11; turn 2 uses input_tokens=19. The test
|
|
asserts that:
|
|
- chip[0].input != chip[1].input (per-turn semantics, values differ)
|
|
- chip[1].input >= chip[0].input (monotonic across a session with growing
|
|
context, satisfied trivially because 19 > 11)
|
|
"""
|
|
if os.environ.get("OPENSQUILLA_WEBUI_BROWSER_CHAT_E2E") != "1":
|
|
pytest.skip("set OPENSQUILLA_WEBUI_BROWSER_CHAT_E2E=1 to run chat browser e2e")
|
|
|
|
port = _free_port()
|
|
server_script = tmp_path / "webui_bubble_server.py"
|
|
browser_script = tmp_path / "webui_bubble_browser.js"
|
|
server_script.write_text(
|
|
textwrap.dedent(
|
|
f"""
|
|
import uvicorn
|
|
|
|
from opensquilla.gateway.app import create_gateway_app
|
|
from opensquilla.gateway.config import AuthConfig, GatewayConfig
|
|
|
|
config = GatewayConfig(
|
|
host="127.0.0.1",
|
|
port={port},
|
|
auth=AuthConfig(mode="none"),
|
|
)
|
|
app = create_gateway_app(config)
|
|
|
|
if __name__ == "__main__":
|
|
uvicorn.run(app, host="127.0.0.1", port={port}, log_level="warning")
|
|
"""
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
browser_script.write_text(
|
|
textwrap.dedent(
|
|
r"""
|
|
const { chromium } = require("playwright");
|
|
|
|
// Inject a synthetic streaming turn into the chat JS event bus.
|
|
// text_delta fires through the named listener; done fires through the
|
|
// wildcard (*) listener which is how chat.js processes terminal events.
|
|
async function injectTurn(page, inputTokens, outputTokens) {
|
|
await page.evaluate(
|
|
({ inputTokens, outputTokens }) => {
|
|
const rpc = App.getRpc();
|
|
const ls = rpc._listeners;
|
|
|
|
// 1. text_delta — named listener — creates the stream bubble
|
|
const deltaHandlers = ls.get("session.event.text_delta");
|
|
if (deltaHandlers) {
|
|
deltaHandlers.forEach(h => h({ text: "hi" }));
|
|
}
|
|
|
|
// 2. session.event.done — wildcard (*) listener — attaches
|
|
// the .msg-meta__tokens chip to the finished bubble
|
|
const wildHandlers = ls.get("*");
|
|
if (wildHandlers) {
|
|
wildHandlers.forEach(h =>
|
|
h("session.event.done", {
|
|
input_tokens: inputTokens,
|
|
output_tokens: outputTokens,
|
|
text: "hi",
|
|
})
|
|
);
|
|
}
|
|
},
|
|
{ inputTokens, outputTokens }
|
|
);
|
|
}
|
|
|
|
(async () => {
|
|
const browser = await chromium.launch({ headless: true });
|
|
const page = await browser.newPage();
|
|
const errors = [];
|
|
page.on("pageerror", err => errors.push(String(err)));
|
|
|
|
await page.goto(process.env.TARGET_URL, {
|
|
waitUntil: "domcontentloaded",
|
|
timeout: 30000,
|
|
});
|
|
await page.waitForSelector("#chat-textarea", { timeout: 15000 });
|
|
|
|
// Wait for WebSocket RPC connection (chat.js needs it to register listeners)
|
|
await page.waitForFunction(
|
|
() =>
|
|
typeof App !== "undefined" &&
|
|
App.getRpc &&
|
|
App.getRpc()?.state === "connected",
|
|
{ timeout: 15000 }
|
|
);
|
|
|
|
// Turn 1: input=11, output=5
|
|
await injectTurn(page, 11, 5);
|
|
await page.waitForSelector(".msg-meta__tokens", { timeout: 5000 });
|
|
|
|
// Turn 2: input=19, output=7
|
|
await injectTurn(page, 19, 7);
|
|
// Wait for the second chip to appear
|
|
await page.waitForFunction(
|
|
() => document.querySelectorAll(".msg-meta__tokens").length >= 2,
|
|
null, { timeout: 5000 }
|
|
);
|
|
|
|
const chips = await page.evaluate(() =>
|
|
Array.from(document.querySelectorAll(".msg-meta__tokens")).map(el => el.textContent)
|
|
);
|
|
|
|
// Parse "↑X ↓Y" into { input: X, output: Y }
|
|
function parseChip(text) {
|
|
const m = text.match(/↑(\d+(?:\.\d+)?[KMk]?)\s*↓(\d+(?:\.\d+)?[KMk]?)/);
|
|
if (!m) return null;
|
|
function tok(s) {
|
|
const n = parseFloat(s);
|
|
if (s.endsWith("K") || s.endsWith("k")) return Math.round(n * 1000);
|
|
if (s.endsWith("M")) return Math.round(n * 1000000);
|
|
return n;
|
|
}
|
|
return { input: tok(m[1]), output: tok(m[2]) };
|
|
}
|
|
|
|
const parsed = chips.slice(0, 2).map(parseChip);
|
|
const result = {
|
|
chipCount: chips.length,
|
|
chip0: parsed[0],
|
|
chip1: parsed[1],
|
|
pageErrors: errors,
|
|
};
|
|
await browser.close();
|
|
console.log(JSON.stringify(result));
|
|
})().catch(err => {
|
|
console.error(err && err.stack ? err.stack : String(err));
|
|
process.exit(1);
|
|
});
|
|
"""
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
env = os.environ.copy()
|
|
env["OPENSQUILLA_STATE_DIR"] = str(tmp_path / "state")
|
|
env["OPENSQUILLA_LOG_DIR"] = str(tmp_path / "logs")
|
|
server = subprocess.Popen(
|
|
[sys.executable, str(server_script)],
|
|
cwd=Path.cwd(),
|
|
env=env,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
)
|
|
try:
|
|
_wait_for_health(port, server)
|
|
_install_playwright(tmp_path)
|
|
result = subprocess.run(
|
|
[_node(), str(browser_script)],
|
|
cwd=tmp_path,
|
|
env=dict(env, TARGET_URL=f"http://127.0.0.1:{port}/control/chat"),
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=90,
|
|
)
|
|
assert result.returncode == 0, result.stderr or result.stdout
|
|
payload = json.loads(result.stdout.strip().splitlines()[-1])
|
|
finally:
|
|
_stop_process(server)
|
|
|
|
assert payload["pageErrors"] == [], payload["pageErrors"]
|
|
assert payload["chipCount"] >= 2, f"expected >=2 chips, got {payload['chipCount']}"
|
|
chip0 = payload["chip0"]
|
|
chip1 = payload["chip1"]
|
|
assert chip0 is not None, "chip0 did not parse"
|
|
assert chip1 is not None, "chip1 did not parse"
|
|
# Per-turn semantics: each bubble shows the tokens for that turn, not the
|
|
# session accumulator, so consecutive turns with different token counts must
|
|
# produce different chip values.
|
|
assert chip0["input"] != chip1["input"], (
|
|
f"chip input_tokens should differ between turns: chip0={chip0}, chip1={chip1}"
|
|
)
|
|
# Monotonic: second turn's per-turn input >= first turn's (19 > 11 by construction)
|
|
assert chip1["input"] >= chip0["input"], (
|
|
f"chip1.input ({chip1['input']}) should be >= chip0.input ({chip0['input']})"
|
|
)
|
|
|
|
|
|
def test_chat_view_loads_and_reaches_gateway_http_status_in_real_browser(tmp_path: Path) -> None:
|
|
if os.environ.get("OPENSQUILLA_WEBUI_BROWSER_CHAT_E2E") != "1":
|
|
pytest.skip("set OPENSQUILLA_WEBUI_BROWSER_CHAT_E2E=1 to run chat browser e2e")
|
|
|
|
port = _free_port()
|
|
server_script = tmp_path / "webui_chat_server.py"
|
|
browser_script = tmp_path / "webui_chat_browser.js"
|
|
server_script.write_text(
|
|
textwrap.dedent(
|
|
f"""
|
|
import uvicorn
|
|
|
|
from opensquilla.gateway.app import create_gateway_app
|
|
from opensquilla.gateway.config import AuthConfig, GatewayConfig
|
|
|
|
config = GatewayConfig(
|
|
host="127.0.0.1",
|
|
port={port},
|
|
auth=AuthConfig(mode="none"),
|
|
)
|
|
app = create_gateway_app(config)
|
|
|
|
if __name__ == "__main__":
|
|
uvicorn.run(app, host="127.0.0.1", port={port}, log_level="warning")
|
|
"""
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
browser_script.write_text(
|
|
textwrap.dedent(
|
|
"""
|
|
const { chromium } = require("playwright");
|
|
|
|
(async () => {
|
|
const browser = await chromium.launch({ headless: true });
|
|
const page = await browser.newPage();
|
|
const errors = [];
|
|
page.on("pageerror", err => errors.push(String(err)));
|
|
const response = await page.goto(process.env.TARGET_URL, {
|
|
waitUntil: "domcontentloaded",
|
|
timeout: 30000,
|
|
});
|
|
await page.waitForSelector("#chat-textarea", { timeout: 15000 });
|
|
const status = await page.evaluate(async () => {
|
|
const res = await fetch("/api/system/status");
|
|
return await res.json();
|
|
});
|
|
const bodyText = await page.locator("body").innerText();
|
|
const result = {
|
|
statusCode: response ? response.status() : 0,
|
|
title: await page.title(),
|
|
textareaCount: await page.locator("#chat-textarea").count(),
|
|
sendButtonCount: await page.locator("#chat-btn-send").count(),
|
|
activeChatNav: await page.locator('.nav-item.is-active[data-path="/chat"]').count(),
|
|
gatewayStatus: status.status,
|
|
authMode: status.auth_mode,
|
|
hasRemovedToolName:
|
|
bodyText.includes("generate_image") ||
|
|
bodyText.includes("spawn_subagent") ||
|
|
bodyText.includes("send_message"),
|
|
pageErrors: errors,
|
|
};
|
|
await browser.close();
|
|
console.log(JSON.stringify(result));
|
|
})().catch(err => {
|
|
console.error(err && err.stack ? err.stack : String(err));
|
|
process.exit(1);
|
|
});
|
|
"""
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
env = os.environ.copy()
|
|
env["OPENSQUILLA_STATE_DIR"] = str(tmp_path / "state")
|
|
env["OPENSQUILLA_LOG_DIR"] = str(tmp_path / "logs")
|
|
server = subprocess.Popen(
|
|
[sys.executable, str(server_script)],
|
|
cwd=Path.cwd(),
|
|
env=env,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
)
|
|
try:
|
|
_wait_for_health(port, server)
|
|
_install_playwright(tmp_path)
|
|
result = subprocess.run(
|
|
[_node(), str(browser_script)],
|
|
cwd=tmp_path,
|
|
env=dict(env, TARGET_URL=f"http://127.0.0.1:{port}/control/chat"),
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=60,
|
|
)
|
|
assert result.returncode == 0, result.stderr or result.stdout
|
|
payload = json.loads(result.stdout.strip().splitlines()[-1])
|
|
finally:
|
|
_stop_process(server)
|
|
|
|
assert payload == {
|
|
"statusCode": 200,
|
|
"title": "OpenSquilla Control",
|
|
"textareaCount": 1,
|
|
"sendButtonCount": 1,
|
|
"activeChatNav": 1,
|
|
"gatewayStatus": "running",
|
|
"authMode": "none",
|
|
"hasRemovedToolName": False,
|
|
"pageErrors": [],
|
|
}
|
|
|
|
|
|
def test_replay_gap_history_refresh_preserves_scroll_in_real_browser(tmp_path: Path) -> None:
|
|
if os.environ.get("OPENSQUILLA_WEBUI_BROWSER_CHAT_E2E") != "1":
|
|
pytest.skip("set OPENSQUILLA_WEBUI_BROWSER_CHAT_E2E=1 to run chat browser e2e")
|
|
|
|
port = _free_port()
|
|
server_script = tmp_path / "webui_gap_scroll_server.py"
|
|
browser_script = tmp_path / "webui_gap_scroll_browser.js"
|
|
server_script.write_text(
|
|
textwrap.dedent(
|
|
f"""
|
|
import uvicorn
|
|
|
|
from opensquilla.gateway.app import create_gateway_app
|
|
from opensquilla.gateway.config import AuthConfig, GatewayConfig
|
|
|
|
config = GatewayConfig(
|
|
host="127.0.0.1",
|
|
port={port},
|
|
auth=AuthConfig(mode="none"),
|
|
)
|
|
app = create_gateway_app(config)
|
|
|
|
if __name__ == "__main__":
|
|
uvicorn.run(app, host="127.0.0.1", port={port}, log_level="warning")
|
|
"""
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
browser_script.write_text(
|
|
textwrap.dedent(
|
|
r"""
|
|
const { chromium } = require("playwright");
|
|
|
|
(async () => {
|
|
const browser = await chromium.launch({ headless: true });
|
|
const page = await browser.newPage({ viewport: { width: 1365, height: 768 } });
|
|
const errors = [];
|
|
page.on("pageerror", err => errors.push(String(err)));
|
|
page.on("console", msg => {
|
|
if (msg.type() === "error") errors.push(msg.text());
|
|
});
|
|
|
|
await page.goto(process.env.TARGET_URL, {
|
|
waitUntil: "domcontentloaded",
|
|
timeout: 30000,
|
|
});
|
|
await page.waitForFunction(
|
|
() =>
|
|
typeof App !== "undefined" &&
|
|
App.getRpc &&
|
|
App.getRpc()?.state === "connected",
|
|
{ timeout: 15000 }
|
|
);
|
|
|
|
await page.evaluate(() => {
|
|
const rpc = App.getRpc();
|
|
const originalCall = rpc.call.bind(rpc);
|
|
const messages = [];
|
|
for (let i = 1; i <= 70; i += 1) {
|
|
const label = i % 2 ? "User" : "Assistant";
|
|
messages.push({
|
|
role: i % 2 ? "user" : "assistant",
|
|
text: `${label} history row ${String(i).padStart(2, "0")} `.repeat(8),
|
|
timestamp: `2026-06-03T00:${String(i % 60).padStart(2, "0")}:00.000Z`,
|
|
message_id: `gap-probe-${i}`,
|
|
});
|
|
}
|
|
window.__gapProbe = { subscribeCount: 0, historyCount: 0, gapInjected: false };
|
|
rpc.call = async (method, params) => {
|
|
if (method === "chat.history") {
|
|
window.__gapProbe.historyCount += 1;
|
|
return {
|
|
messages,
|
|
has_more: false,
|
|
history_scope: "complete",
|
|
oldest_cursor: null,
|
|
newest_cursor: null,
|
|
compaction_summaries: [],
|
|
};
|
|
}
|
|
if (method === "sessions.messages.subscribe") {
|
|
window.__gapProbe.subscribeCount += 1;
|
|
if (window.__gapProbe.subscribeCount <= 1) {
|
|
return {
|
|
subscribed: true,
|
|
key: params && params.key,
|
|
current_stream_seq: 12,
|
|
replay_complete: true,
|
|
replayed_count: 0,
|
|
run_status: "idle",
|
|
};
|
|
}
|
|
window.__gapProbe.gapInjected = true;
|
|
return {
|
|
subscribed: true,
|
|
key: params && params.key,
|
|
current_stream_seq: 18,
|
|
replay_complete: false,
|
|
replay_gap_reason: "stream_buffer_empty",
|
|
replayed_count: 0,
|
|
run_status: "idle",
|
|
};
|
|
}
|
|
return originalCall(method, params);
|
|
};
|
|
});
|
|
|
|
await page.evaluate(() =>
|
|
Router.navigate(
|
|
"/chat?session=" +
|
|
encodeURIComponent("agent:main:webchat:gap-playwright-regression")
|
|
)
|
|
);
|
|
await page.waitForSelector("#chat-thread .msg", { timeout: 15000 });
|
|
await page.waitForFunction(
|
|
() => document.querySelectorAll("#chat-thread .msg").length >= 60,
|
|
{ timeout: 15000 }
|
|
);
|
|
|
|
const before = await page.evaluate(() => {
|
|
const thread = document.querySelector("#chat-thread");
|
|
thread.scrollTop = 120;
|
|
return {
|
|
scrollTop: thread.scrollTop,
|
|
scrollHeight: thread.scrollHeight,
|
|
clientHeight: thread.clientHeight,
|
|
bottomDistance: thread.scrollHeight - thread.clientHeight - thread.scrollTop,
|
|
messageCount: document.querySelectorAll("#chat-thread .msg").length,
|
|
};
|
|
});
|
|
|
|
await page.evaluate(() => {
|
|
const handlers = App.getRpc()._listeners.get("_state");
|
|
if (handlers) handlers.forEach(h => h("connected"));
|
|
});
|
|
await page.waitForFunction(
|
|
() => window.__gapProbe && window.__gapProbe.gapInjected === true,
|
|
{ timeout: 15000 }
|
|
);
|
|
await page.waitForFunction(
|
|
() => window.__gapProbe && window.__gapProbe.historyCount >= 3,
|
|
{ timeout: 15000 }
|
|
);
|
|
|
|
const after = await page.evaluate(() => {
|
|
const thread = document.querySelector("#chat-thread");
|
|
return {
|
|
scrollTop: thread.scrollTop,
|
|
scrollHeight: thread.scrollHeight,
|
|
clientHeight: thread.clientHeight,
|
|
bottomDistance: thread.scrollHeight - thread.clientHeight - thread.scrollTop,
|
|
messageCount: document.querySelectorAll("#chat-thread .msg").length,
|
|
toasts: Array.from(document.querySelectorAll(".toast")).map(el =>
|
|
el.textContent.trim()
|
|
),
|
|
probe: window.__gapProbe,
|
|
};
|
|
});
|
|
|
|
await browser.close();
|
|
console.log(JSON.stringify({ before, after, errors }));
|
|
})().catch(err => {
|
|
console.error(err && err.stack ? err.stack : String(err));
|
|
process.exit(1);
|
|
});
|
|
"""
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
env = os.environ.copy()
|
|
env["OPENSQUILLA_STATE_DIR"] = str(tmp_path / "state")
|
|
env["OPENSQUILLA_LOG_DIR"] = str(tmp_path / "logs")
|
|
server = subprocess.Popen(
|
|
[sys.executable, str(server_script)],
|
|
cwd=Path.cwd(),
|
|
env=env,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
)
|
|
try:
|
|
_wait_for_health(port, server)
|
|
_install_playwright(tmp_path)
|
|
result = subprocess.run(
|
|
[_node(), str(browser_script)],
|
|
cwd=tmp_path,
|
|
env=dict(env, TARGET_URL=f"http://127.0.0.1:{port}/control/overview"),
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=90,
|
|
)
|
|
assert result.returncode == 0, result.stderr or result.stdout
|
|
payload = json.loads(result.stdout.strip().splitlines()[-1])
|
|
finally:
|
|
_stop_process(server)
|
|
|
|
assert payload["errors"] == [], payload["errors"]
|
|
assert payload["before"]["messageCount"] >= 60
|
|
assert payload["after"]["messageCount"] == payload["before"]["messageCount"]
|
|
assert payload["after"]["probe"]["gapInjected"] is True
|
|
assert payload["after"]["bottomDistance"] > 1000, payload
|
|
assert payload["after"]["scrollTop"] <= payload["before"]["scrollTop"] + 80, payload
|
|
assert "Session stream gap detected; reloading transcript." not in payload["after"]["toasts"]
|
|
|
|
|
|
def test_chat_topbar_one_row_layout_in_real_browser(tmp_path: Path) -> None:
|
|
if os.environ.get("OPENSQUILLA_WEBUI_BROWSER_CHAT_E2E") != "1":
|
|
pytest.skip("set OPENSQUILLA_WEBUI_BROWSER_CHAT_E2E=1 to run chat browser e2e")
|
|
|
|
port = _free_port()
|
|
server_script = tmp_path / "webui_chat_topbar_server.py"
|
|
browser_script = tmp_path / "webui_chat_topbar_browser.js"
|
|
server_script.write_text(
|
|
textwrap.dedent(
|
|
f"""
|
|
import uvicorn
|
|
|
|
from opensquilla.gateway.app import create_gateway_app
|
|
from opensquilla.gateway.config import AuthConfig, GatewayConfig
|
|
|
|
config = GatewayConfig(
|
|
host="127.0.0.1",
|
|
port={port},
|
|
auth=AuthConfig(mode="none"),
|
|
)
|
|
app = create_gateway_app(config)
|
|
|
|
if __name__ == "__main__":
|
|
uvicorn.run(app, host="127.0.0.1", port={port}, log_level="warning")
|
|
"""
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
browser_script.write_text(
|
|
textwrap.dedent(
|
|
r"""
|
|
const { chromium } = require("playwright");
|
|
|
|
async function emit(page, event, payload, meta = {}) {
|
|
await page.evaluate(
|
|
({ event, payload, meta }) => {
|
|
const rpc = App.getRpc();
|
|
const handlers = rpc._listeners.get(event);
|
|
if (handlers) handlers.forEach(h => h(payload, meta));
|
|
const wild = rpc._listeners.get("*");
|
|
if (wild) wild.forEach(h => h(event, payload, meta));
|
|
},
|
|
{ event, payload, meta }
|
|
);
|
|
}
|
|
|
|
async function measure(page, viewport) {
|
|
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
|
const session =
|
|
"agent:main:webchat:very-long-session-key-with-extra-segments-1234567890";
|
|
await page.goto(process.env.TARGET_URL + "?session=" + encodeURIComponent(session), {
|
|
waitUntil: "domcontentloaded",
|
|
timeout: 30000,
|
|
});
|
|
await page.waitForSelector("#chat-textarea", { timeout: 15000 });
|
|
await page.waitForSelector("#topbar-center:not(.hidden)", { timeout: 5000 });
|
|
|
|
await emit(page, "session.event.done", {
|
|
text: "",
|
|
contextStatus: {
|
|
contextTokens: 90000,
|
|
contextWindowTokens: 100000,
|
|
pressure: 0.9,
|
|
},
|
|
});
|
|
await emit(page, "task.running", { task_id: "topbar-task", session_key: session });
|
|
await page.waitForFunction(() => {
|
|
const el = document.querySelector("#chat-run-status");
|
|
return el && el.textContent.trim() === "Running";
|
|
});
|
|
if (viewport.showApproval) {
|
|
await page.evaluate(() => {
|
|
const inline = document.querySelector("#approval-inline");
|
|
inline.textContent = "Approval required";
|
|
inline.setAttribute("aria-label", "Approval required");
|
|
inline.title = "Approval required";
|
|
inline.classList.remove("hidden");
|
|
});
|
|
}
|
|
await page.waitForTimeout(80);
|
|
|
|
const snapshot = await page.evaluate(() => {
|
|
const box = (selector) => {
|
|
const el = document.querySelector(selector);
|
|
if (!el) return null;
|
|
const r = el.getBoundingClientRect();
|
|
return {
|
|
left: r.left,
|
|
right: r.right,
|
|
top: r.top,
|
|
bottom: r.bottom,
|
|
width: r.width,
|
|
height: r.height,
|
|
visible:
|
|
r.width > 0 &&
|
|
r.height > 0 &&
|
|
getComputedStyle(el).display !== "none" &&
|
|
getComputedStyle(el).visibility !== "hidden",
|
|
};
|
|
};
|
|
const center = document.querySelector("#topbar-center");
|
|
const label = document.querySelector(".topbar-center .chat-label");
|
|
const warning = document.querySelector("#chat-ctx-warn");
|
|
const chipKey = document.querySelector("#chat-session-chip-key");
|
|
const centerBox = box("#topbar-center");
|
|
const topbarBox = box(".topbar");
|
|
const topbarRightBox = box(".topbar-right");
|
|
const childBoxes = Array.from(center ? center.children : []).map((el) => {
|
|
const r = el.getBoundingClientRect();
|
|
return {
|
|
id: el.id || el.className || el.tagName,
|
|
left: r.left,
|
|
right: r.right,
|
|
width: r.width,
|
|
display: getComputedStyle(el).display,
|
|
};
|
|
});
|
|
return {
|
|
width: window.innerWidth,
|
|
scrollWidth: document.documentElement.scrollWidth,
|
|
topbarCount: document.querySelectorAll(".topbar").length,
|
|
chatHeaderCount: document.querySelectorAll(".chat-header").length,
|
|
centerHidden: center ? center.classList.contains("hidden") : true,
|
|
centerBox,
|
|
topbarBox,
|
|
topbarRightBox,
|
|
chip: box("#chat-session-chip"),
|
|
chipKeyText: chipKey ? chipKey.textContent : "",
|
|
copy: box("#chat-session-copy"),
|
|
runStatus: box("#chat-run-status"),
|
|
runStatusText: document.querySelector("#chat-run-status")?.textContent || "",
|
|
warning: box("#chat-ctx-warn"),
|
|
warningText: warning ? warning.textContent : "",
|
|
labelDisplay: label ? getComputedStyle(label).display : "",
|
|
theme: box("#theme-toggle"),
|
|
approval: box("#approval-inline"),
|
|
approvalText: document.querySelector("#approval-inline")?.textContent || "",
|
|
approvalLabel:
|
|
document.querySelector("#approval-inline")?.getAttribute("aria-label") || "",
|
|
approvalCount: document.querySelectorAll("#approval-inline").length,
|
|
topbarChildren: childBoxes,
|
|
};
|
|
});
|
|
|
|
await page.locator("#theme-toggle").click();
|
|
const themeAfterClick = await page.evaluate(
|
|
() => document.documentElement.getAttribute("data-theme")
|
|
);
|
|
return { name: viewport.name, snapshot, themeAfterClick };
|
|
}
|
|
|
|
(async () => {
|
|
const browser = await chromium.launch({ headless: true });
|
|
const page = await browser.newPage();
|
|
const errors = [];
|
|
page.on("pageerror", err => errors.push(String(err)));
|
|
|
|
const viewports = [
|
|
{ name: "desktop", width: 1365, height: 768 },
|
|
{ name: "wide", width: 1600, height: 900 },
|
|
{ name: "tablet", width: 768, height: 900 },
|
|
{ name: "mobile", width: 390, height: 844, showApproval: true },
|
|
];
|
|
const layouts = [];
|
|
for (const viewport of viewports) layouts.push(await measure(page, viewport));
|
|
|
|
await page.setViewportSize({ width: 1365, height: 768 });
|
|
await page.goto(process.env.TARGET_URL, {
|
|
waitUntil: "domcontentloaded",
|
|
timeout: 30000,
|
|
});
|
|
await page.waitForSelector("#chat-textarea", { timeout: 15000 });
|
|
await page.evaluate(() => Router.navigate("/overview"));
|
|
await page.waitForSelector(".ov-stage__title", { timeout: 5000 });
|
|
const cleanup = await page.evaluate(() => ({
|
|
centerHidden:
|
|
document.querySelector("#topbar-center")?.classList.contains("hidden"),
|
|
centerText: document.querySelector("#topbar-center")?.textContent.trim(),
|
|
chatIds:
|
|
document.querySelectorAll(
|
|
"#chat-session-chip, #chat-session-chip-key, " +
|
|
"#chat-session-copy, #chat-run-status, #chat-ctx-warn"
|
|
).length,
|
|
}));
|
|
|
|
await browser.close();
|
|
console.log(JSON.stringify({ layouts, cleanup, pageErrors: errors }));
|
|
})().catch(err => {
|
|
console.error(err && err.stack ? err.stack : String(err));
|
|
process.exit(1);
|
|
});
|
|
"""
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
env = os.environ.copy()
|
|
env["OPENSQUILLA_STATE_DIR"] = str(tmp_path / "state")
|
|
env["OPENSQUILLA_LOG_DIR"] = str(tmp_path / "logs")
|
|
server = subprocess.Popen(
|
|
[sys.executable, str(server_script)],
|
|
cwd=Path.cwd(),
|
|
env=env,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
)
|
|
try:
|
|
_wait_for_health(port, server)
|
|
_install_playwright(tmp_path)
|
|
result = subprocess.run(
|
|
[_node(), str(browser_script)],
|
|
cwd=tmp_path,
|
|
env=dict(env, TARGET_URL=f"http://127.0.0.1:{port}/control/chat"),
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=120,
|
|
)
|
|
assert result.returncode == 0, result.stderr or result.stdout
|
|
payload = json.loads(result.stdout.strip().splitlines()[-1])
|
|
finally:
|
|
_stop_process(server)
|
|
|
|
assert payload["pageErrors"] == []
|
|
assert payload["cleanup"] == {"centerHidden": True, "centerText": "", "chatIds": 0}
|
|
|
|
layouts = {item["name"]: item["snapshot"] for item in payload["layouts"]}
|
|
for name, snap in layouts.items():
|
|
assert snap["scrollWidth"] <= snap["width"], (name, snap)
|
|
assert snap["topbarCount"] == 1, (name, snap)
|
|
assert snap["chatHeaderCount"] == 0, (name, snap)
|
|
assert snap["centerHidden"] is False, (name, snap)
|
|
assert snap["chip"]["visible"], (name, snap)
|
|
assert snap["copy"]["visible"], (name, snap)
|
|
assert snap["runStatus"]["visible"], (name, snap)
|
|
assert snap["runStatusText"] == "Running", (name, snap)
|
|
assert snap["theme"]["visible"], (name, snap)
|
|
assert snap["approvalCount"] == 1, (name, snap)
|
|
assert snap["chipKeyText"].startswith("agent:main:webchat:very-long-session-key")
|
|
assert snap["centerBox"]["right"] <= snap["topbarRightBox"]["left"] + 1, (name, snap)
|
|
center = snap["centerBox"]
|
|
for child in snap["topbarChildren"]:
|
|
if child["display"] == "none":
|
|
continue
|
|
assert child["left"] >= center["left"] - 1, (name, child, center)
|
|
assert child["right"] <= center["right"] + 1, (name, child, center)
|
|
|
|
for name in ("desktop", "wide"):
|
|
snap = layouts[name]
|
|
assert snap["labelDisplay"] != "none"
|
|
assert snap["warning"]["visible"], (name, snap)
|
|
assert snap["warningText"].startswith("Request ctx 90%"), (name, snap)
|
|
|
|
assert layouts["tablet"]["labelDisplay"] == "none"
|
|
assert layouts["mobile"]["labelDisplay"] == "none"
|
|
assert layouts["mobile"]["approval"]["visible"], layouts["mobile"]
|
|
assert layouts["mobile"]["approval"]["width"] <= 40, layouts["mobile"]
|
|
assert layouts["mobile"]["approvalText"] == "Approval required"
|
|
assert layouts["mobile"]["approvalLabel"] == "Approval required"
|
|
assert layouts["mobile"]["warning"]["visible"] is False
|
|
assert all(item["themeAfterClick"] in {"dark", "light"} for item in payload["layouts"])
|
|
|
|
|
|
def test_chat_slash_menu_aligns_with_composer_in_real_browser(tmp_path: Path) -> None:
|
|
if os.environ.get("OPENSQUILLA_WEBUI_BROWSER_CHAT_E2E") != "1":
|
|
pytest.skip("set OPENSQUILLA_WEBUI_BROWSER_CHAT_E2E=1 to run chat browser e2e")
|
|
|
|
port = _free_port()
|
|
server_script = tmp_path / "webui_chat_slash_server.py"
|
|
browser_script = tmp_path / "webui_chat_slash_browser.js"
|
|
server_script.write_text(
|
|
textwrap.dedent(
|
|
f"""
|
|
import uvicorn
|
|
|
|
from opensquilla.gateway.app import create_gateway_app
|
|
from opensquilla.gateway.config import AuthConfig, GatewayConfig
|
|
|
|
config = GatewayConfig(
|
|
host="127.0.0.1",
|
|
port={port},
|
|
auth=AuthConfig(mode="none"),
|
|
)
|
|
app = create_gateway_app(config)
|
|
|
|
if __name__ == "__main__":
|
|
uvicorn.run(app, host="127.0.0.1", port={port}, log_level="warning")
|
|
"""
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
browser_script.write_text(
|
|
textwrap.dedent(
|
|
r"""
|
|
const { chromium } = require("playwright");
|
|
|
|
async function waitRpc(page) {
|
|
await page.waitForFunction(
|
|
() =>
|
|
typeof App !== "undefined" &&
|
|
App.getRpc &&
|
|
App.getRpc()?.state === "connected",
|
|
{ timeout: 15000 }
|
|
);
|
|
}
|
|
|
|
async function installMocks(page) {
|
|
await page.evaluate(() => {
|
|
const rpc = App.getRpc();
|
|
const originalCall = rpc.call.bind(rpc);
|
|
rpc.call = (method, params = {}) => {
|
|
if (method === "tools.search_provider") {
|
|
return Promise.resolve({ provider: "none" });
|
|
}
|
|
if (method === "config.get") {
|
|
return Promise.resolve({
|
|
permissions: { default_mode: "ask" },
|
|
squilla_router: { enabled: true, rollout_phase: "full", tiers: {} },
|
|
});
|
|
}
|
|
if (method === "chat.history") {
|
|
return Promise.resolve({
|
|
messages: [],
|
|
history_scope: "complete",
|
|
has_more: false,
|
|
});
|
|
}
|
|
if (method === "sessions.messages.subscribe") {
|
|
return Promise.resolve({
|
|
subscribed: true,
|
|
key: params.key,
|
|
current_stream_seq: 0,
|
|
replay_complete: true,
|
|
replayed_count: 0,
|
|
run_status: "idle",
|
|
});
|
|
}
|
|
if (method === "commands.list_for_surface") {
|
|
return Promise.resolve({
|
|
commands: [
|
|
{
|
|
name: "/compact",
|
|
description: [
|
|
"Compact older context in the current session with an intentionally",
|
|
"long command description that must truncate instead of stretching",
|
|
"the composer tray.",
|
|
].join(" "),
|
|
execution: { action: "sessions.contextCompact" },
|
|
},
|
|
],
|
|
});
|
|
}
|
|
if (method === "sessions.contextCompact") {
|
|
return Promise.resolve({ compacted: false, reason: "no_entries" });
|
|
}
|
|
return originalCall(method, params);
|
|
};
|
|
});
|
|
}
|
|
|
|
async function openChat(page, viewport) {
|
|
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
|
await page.goto(process.env.TARGET_URL, {
|
|
waitUntil: "domcontentloaded",
|
|
timeout: 30000,
|
|
});
|
|
await waitRpc(page);
|
|
await installMocks(page);
|
|
await page.evaluate(() =>
|
|
Router.navigate("/chat?session=agent:main:webchat:slash-layout")
|
|
);
|
|
await page.waitForSelector("#chat-textarea", { timeout: 15000 });
|
|
await page.locator("#chat-textarea").fill("/compact");
|
|
await page.waitForSelector(".chat-slash:not(.hidden)", { timeout: 5000 });
|
|
await page.waitForTimeout(80);
|
|
}
|
|
|
|
async function measure(page, viewport) {
|
|
await openChat(page, viewport);
|
|
const layout = await page.evaluate(() => {
|
|
const box = (selector) => {
|
|
const el = document.querySelector(selector);
|
|
const r = el.getBoundingClientRect();
|
|
return {
|
|
left: r.left,
|
|
right: r.right,
|
|
width: r.width,
|
|
height: r.height,
|
|
visible:
|
|
r.width > 0 &&
|
|
r.height > 0 &&
|
|
getComputedStyle(el).display !== "none" &&
|
|
getComputedStyle(el).visibility !== "hidden",
|
|
};
|
|
};
|
|
const desc = document.querySelector(".chat-slash-desc");
|
|
const slash = document.querySelector(".chat-slash");
|
|
const input = document.querySelector(".chat-input-bar");
|
|
return {
|
|
width: window.innerWidth,
|
|
scrollWidth: document.documentElement.scrollWidth,
|
|
slash: box(".chat-slash"),
|
|
input: box(".chat-input-bar"),
|
|
composer: box("#chat-composer"),
|
|
attach: box("#chat-btn-attach"),
|
|
newChat: box("#chat-btn-new"),
|
|
exportButton: box("#chat-btn-export"),
|
|
send: box("#chat-btn-send"),
|
|
slashParentId: slash?.parentElement?.id || "",
|
|
slashBeforeInput: !!(
|
|
slash &&
|
|
input &&
|
|
slash.compareDocumentPosition(input) &
|
|
Node.DOCUMENT_POSITION_FOLLOWING
|
|
),
|
|
descClientWidth: desc ? desc.clientWidth : 0,
|
|
descScrollWidth: desc ? desc.scrollWidth : 0,
|
|
};
|
|
});
|
|
|
|
await page.locator("#chat-textarea").press("ArrowDown");
|
|
const afterArrow = await page.locator(".chat-slash:not(.hidden)").count();
|
|
await page.locator("#chat-textarea").press("Escape");
|
|
const afterEscape = await page.locator(".chat-slash:not(.hidden)").count();
|
|
await page.locator("#chat-textarea").fill("/compact");
|
|
await page.waitForSelector(".chat-slash:not(.hidden)", { timeout: 5000 });
|
|
await page.locator("#chat-textarea").press("Enter");
|
|
await page.waitForFunction(
|
|
() => document.querySelector("#chat-textarea").value === ""
|
|
);
|
|
const afterEnterValue = await page.locator("#chat-textarea").inputValue();
|
|
|
|
return {
|
|
name: viewport.name,
|
|
layout,
|
|
afterArrow,
|
|
afterEscape,
|
|
afterEnterValue,
|
|
};
|
|
}
|
|
|
|
(async () => {
|
|
const browser = await chromium.launch({ headless: true });
|
|
const page = await browser.newPage();
|
|
const errors = [];
|
|
page.on("pageerror", err => errors.push(String(err)));
|
|
const viewports = [
|
|
{ name: "desktop", width: 1365, height: 768 },
|
|
{ name: "wide", width: 2048, height: 900 },
|
|
{ name: "mobile", width: 390, height: 844 },
|
|
];
|
|
const layouts = [];
|
|
for (const viewport of viewports) layouts.push(await measure(page, viewport));
|
|
await browser.close();
|
|
console.log(JSON.stringify({ layouts, pageErrors: errors }));
|
|
})().catch(err => {
|
|
console.error(err && err.stack ? err.stack : String(err));
|
|
process.exit(1);
|
|
});
|
|
"""
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
env = os.environ.copy()
|
|
env["OPENSQUILLA_STATE_DIR"] = str(tmp_path / "state")
|
|
env["OPENSQUILLA_LOG_DIR"] = str(tmp_path / "logs")
|
|
server = subprocess.Popen(
|
|
[sys.executable, str(server_script)],
|
|
cwd=Path.cwd(),
|
|
env=env,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
)
|
|
try:
|
|
_wait_for_health(port, server)
|
|
_install_playwright(tmp_path)
|
|
result = subprocess.run(
|
|
[_node(), str(browser_script)],
|
|
cwd=tmp_path,
|
|
env=dict(env, TARGET_URL=f"http://127.0.0.1:{port}/control/"),
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=120,
|
|
)
|
|
assert result.returncode == 0, result.stderr or result.stdout
|
|
payload = json.loads(result.stdout.strip().splitlines()[-1])
|
|
finally:
|
|
_stop_process(server)
|
|
|
|
assert payload["pageErrors"] == []
|
|
layouts = {item["name"]: item for item in payload["layouts"]}
|
|
for name, item in layouts.items():
|
|
layout = item["layout"]
|
|
assert layout["scrollWidth"] <= layout["width"], (name, layout)
|
|
assert layout["slash"]["visible"], (name, layout)
|
|
assert layout["slashParentId"] == "chat-composer", (name, layout)
|
|
assert layout["slashBeforeInput"] is True, (name, layout)
|
|
if name == "mobile":
|
|
assert layout["slash"]["left"] >= layout["composer"]["left"], (name, layout)
|
|
assert layout["slash"]["right"] <= layout["composer"]["right"], (name, layout)
|
|
else:
|
|
assert abs(layout["slash"]["left"] - layout["input"]["left"]) <= 1, (name, layout)
|
|
assert abs(layout["slash"]["right"] - layout["input"]["right"]) <= 1, (name, layout)
|
|
assert layout["descScrollWidth"] > layout["descClientWidth"], (name, layout)
|
|
assert item["afterArrow"] == 1, item
|
|
assert item["afterEscape"] == 0, item
|
|
assert item["afterEnterValue"] == "", item
|
|
assert layout["attach"]["visible"], (name, layout)
|
|
assert layout["newChat"]["visible"], (name, layout)
|
|
assert layout["send"]["visible"], (name, layout)
|
|
|
|
assert layouts["desktop"]["layout"]["slash"]["width"] < 1000
|
|
assert layouts["wide"]["layout"]["slash"]["width"] < 1000
|
|
assert layouts["mobile"]["layout"]["exportButton"]["visible"] is False
|
|
|
|
|
|
def test_chat_escape_in_composer_aborts_streaming_turn_in_real_browser(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
if os.environ.get("OPENSQUILLA_WEBUI_BROWSER_CHAT_E2E") != "1":
|
|
pytest.skip("set OPENSQUILLA_WEBUI_BROWSER_CHAT_E2E=1 to run chat browser e2e")
|
|
|
|
port = _free_port()
|
|
server_script = tmp_path / "webui_escape_abort_server.py"
|
|
browser_script = tmp_path / "webui_escape_abort_browser.js"
|
|
server_script.write_text(
|
|
textwrap.dedent(
|
|
f"""
|
|
import uvicorn
|
|
|
|
from opensquilla.gateway.app import create_gateway_app
|
|
from opensquilla.gateway.config import AuthConfig, GatewayConfig
|
|
|
|
config = GatewayConfig(
|
|
host="127.0.0.1",
|
|
port={port},
|
|
auth=AuthConfig(mode="none"),
|
|
)
|
|
app = create_gateway_app(config)
|
|
|
|
if __name__ == "__main__":
|
|
uvicorn.run(app, host="127.0.0.1", port={port}, log_level="warning")
|
|
"""
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
browser_script.write_text(
|
|
textwrap.dedent(
|
|
r"""
|
|
const { chromium } = require("playwright");
|
|
|
|
async function waitRpc(page) {
|
|
await page.waitForFunction(
|
|
() =>
|
|
typeof App !== "undefined" &&
|
|
App.getRpc &&
|
|
App.getRpc()?.state === "connected",
|
|
{ timeout: 15000 }
|
|
);
|
|
}
|
|
|
|
(async () => {
|
|
const browser = await chromium.launch({ headless: true });
|
|
const page = await browser.newPage();
|
|
const errors = [];
|
|
page.on("pageerror", err => errors.push(String(err)));
|
|
|
|
await page.goto(process.env.TARGET_URL, {
|
|
waitUntil: "domcontentloaded",
|
|
timeout: 30000,
|
|
});
|
|
await page.waitForSelector("#chat-textarea", { timeout: 15000 });
|
|
await waitRpc(page);
|
|
|
|
await page.evaluate(() => {
|
|
const rpc = App.getRpc();
|
|
const originalCall = rpc.call.bind(rpc);
|
|
window.__escapeAbort = { sendCalls: [], abortCalls: [] };
|
|
rpc.call = (method, params = {}) => {
|
|
if (method === "tools.search_provider") {
|
|
return Promise.resolve({ provider: "none" });
|
|
}
|
|
if (method === "config.get") {
|
|
return Promise.resolve({
|
|
permissions: { default_mode: "ask" },
|
|
squilla_router: { enabled: true, rollout_phase: "full", tiers: {} },
|
|
});
|
|
}
|
|
if (method === "chat.history") {
|
|
return Promise.resolve({
|
|
messages: [],
|
|
history_scope: "complete",
|
|
has_more: false,
|
|
});
|
|
}
|
|
if (method === "sessions.messages.subscribe") {
|
|
return Promise.resolve({
|
|
subscribed: true,
|
|
key: params.key,
|
|
current_stream_seq: 0,
|
|
replay_complete: true,
|
|
replayed_count: 0,
|
|
run_status: "idle",
|
|
});
|
|
}
|
|
if (method === "chat.send") {
|
|
window.__escapeAbort.sendCalls.push(params);
|
|
return Promise.resolve({ task_id: "escape-abort-task" });
|
|
}
|
|
if (method === "chat.abort") {
|
|
window.__escapeAbort.abortCalls.push(params);
|
|
return Promise.resolve({ aborted: true, key: params.sessionKey });
|
|
}
|
|
return originalCall(method, params);
|
|
};
|
|
});
|
|
|
|
await page.evaluate(() =>
|
|
Router.navigate("/chat?session=agent:main:webchat:escape-abort")
|
|
);
|
|
await page.waitForSelector("#chat-textarea", { timeout: 15000 });
|
|
await page.fill("#chat-textarea", "turn to abort from textarea");
|
|
await page.click("#chat-btn-send");
|
|
await page.waitForFunction(
|
|
() =>
|
|
window.__escapeAbort.sendCalls.length === 1 &&
|
|
OpenSquillaChatDiag.snapshot().isStreaming === true,
|
|
null, { timeout: 5000 }
|
|
);
|
|
|
|
await page.focus("#chat-textarea");
|
|
const before = await page.evaluate(() => ({
|
|
focusId: document.activeElement?.id || "",
|
|
isStreaming: OpenSquillaChatDiag.snapshot().isStreaming,
|
|
stopVisible:
|
|
!document.querySelector("#chat-btn-stop")?.classList.contains("hidden"),
|
|
}));
|
|
await page.locator("#chat-textarea").press("Escape");
|
|
await page.waitForFunction(
|
|
() => window.__escapeAbort.abortCalls.length === 1,
|
|
null, { timeout: 5000 }
|
|
);
|
|
await page.waitForFunction(
|
|
() => OpenSquillaChatDiag.snapshot().isStreaming === false,
|
|
null, { timeout: 5000 }
|
|
);
|
|
const afterState = await page.evaluate(() => ({
|
|
isStreaming: OpenSquillaChatDiag.snapshot().isStreaming,
|
|
streamingCount: document.querySelectorAll(".msg.streaming").length,
|
|
thinkingCount: document.querySelectorAll(".msg.thinking").length,
|
|
stopVisible:
|
|
!document.querySelector("#chat-btn-stop")?.classList.contains("hidden"),
|
|
abortCalls: window.__escapeAbort.abortCalls,
|
|
}));
|
|
const after = { ...afterState, pageErrors: errors };
|
|
|
|
await browser.close();
|
|
console.log(JSON.stringify({ before, after }));
|
|
})().catch(err => {
|
|
console.error(err && err.stack ? err.stack : String(err));
|
|
process.exit(1);
|
|
});
|
|
"""
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
env = os.environ.copy()
|
|
env["OPENSQUILLA_STATE_DIR"] = str(tmp_path / "state")
|
|
env["OPENSQUILLA_LOG_DIR"] = str(tmp_path / "logs")
|
|
server = subprocess.Popen(
|
|
[sys.executable, str(server_script)],
|
|
cwd=Path.cwd(),
|
|
env=env,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
)
|
|
try:
|
|
_wait_for_health(port, server)
|
|
_install_playwright(tmp_path)
|
|
result = subprocess.run(
|
|
[_node(), str(browser_script)],
|
|
cwd=tmp_path,
|
|
env=dict(env, TARGET_URL=f"http://127.0.0.1:{port}/control/chat"),
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=90,
|
|
)
|
|
assert result.returncode == 0, result.stderr or result.stdout
|
|
payload = json.loads(result.stdout.strip().splitlines()[-1])
|
|
finally:
|
|
_stop_process(server)
|
|
|
|
before = payload["before"]
|
|
after = payload["after"]
|
|
assert before["focusId"] == "chat-textarea", before
|
|
assert before["isStreaming"] is True, before
|
|
assert before["stopVisible"] is True, before
|
|
assert after["abortCalls"] == [
|
|
{
|
|
"sessionKey": "agent:main:webchat:escape-abort",
|
|
"source": "webui_escape",
|
|
}
|
|
], after
|
|
assert after["isStreaming"] is False, after
|
|
assert after["streamingCount"] == 0, after
|
|
assert after["thinkingCount"] == 0, after
|
|
assert after["stopVisible"] is False, after
|
|
assert after["pageErrors"] == [], after
|
|
|
|
|
|
def test_chat_tool_result_full_view_handles_large_payloads_in_real_browser(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
if os.environ.get("OPENSQUILLA_WEBUI_BROWSER_CHAT_E2E") != "1":
|
|
pytest.skip("set OPENSQUILLA_WEBUI_BROWSER_CHAT_E2E=1 to run chat browser e2e")
|
|
|
|
port = _free_port()
|
|
server_script = tmp_path / "webui_chat_tool_result_server.py"
|
|
browser_script = tmp_path / "webui_chat_tool_result_browser.js"
|
|
server_script.write_text(
|
|
textwrap.dedent(
|
|
f"""
|
|
import uvicorn
|
|
|
|
from opensquilla.gateway.app import create_gateway_app
|
|
from opensquilla.gateway.config import AuthConfig, GatewayConfig
|
|
|
|
config = GatewayConfig(
|
|
host="127.0.0.1",
|
|
port={port},
|
|
auth=AuthConfig(mode="none"),
|
|
)
|
|
app = create_gateway_app(config)
|
|
|
|
if __name__ == "__main__":
|
|
uvicorn.run(app, host="127.0.0.1", port={port}, log_level="warning")
|
|
"""
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
browser_script.write_text(
|
|
textwrap.dedent(
|
|
r"""
|
|
const { chromium } = require("playwright");
|
|
|
|
async function waitRpc(page) {
|
|
await page.waitForFunction(
|
|
() =>
|
|
typeof App !== "undefined" &&
|
|
App.getRpc &&
|
|
App.getRpc()?.state === "connected",
|
|
{ timeout: 15000 }
|
|
);
|
|
}
|
|
|
|
async function installMocks(page) {
|
|
await page.evaluate(() => {
|
|
const rpc = App.getRpc();
|
|
const originalCall = rpc.call.bind(rpc);
|
|
rpc.call = (method, params = {}) => {
|
|
if (method === "tools.search_provider") {
|
|
return Promise.resolve({ provider: "none" });
|
|
}
|
|
if (method === "config.get") {
|
|
return Promise.resolve({
|
|
permissions: { default_mode: "ask" },
|
|
squilla_router: { enabled: true, rollout_phase: "full", tiers: {} },
|
|
});
|
|
}
|
|
if (method === "chat.history") {
|
|
const longOutput = [
|
|
"labubu-1.jpg -> 1788872 chars base64",
|
|
"labubu-2.jpg -> 836304 chars base64",
|
|
"labubu-3.jpg -> 2109592 chars base64",
|
|
"single-unbroken-token-" + "x".repeat(1800),
|
|
].join("\\n");
|
|
return Promise.resolve({
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
text: "",
|
|
timestamp: "2026-05-31T00:00:00Z",
|
|
tool_calls: [
|
|
{
|
|
type: "tool_use",
|
|
name: "execute_code",
|
|
tool_use_id: "tool-large-result",
|
|
input: { code: "print_large_payload()" },
|
|
},
|
|
{
|
|
type: "tool_result",
|
|
tool_use_id: "tool-large-result",
|
|
result: {
|
|
exit_code: 0,
|
|
stdout: longOutput,
|
|
stderr: "",
|
|
timed_out: false,
|
|
elapsed_ms: 213,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
history_scope: "complete",
|
|
has_more: false,
|
|
});
|
|
}
|
|
if (method === "sessions.messages.subscribe") {
|
|
return Promise.resolve({
|
|
subscribed: true,
|
|
key: params.key,
|
|
current_stream_seq: 0,
|
|
replay_complete: true,
|
|
replayed_count: 0,
|
|
run_status: "idle",
|
|
});
|
|
}
|
|
return originalCall(method, params);
|
|
};
|
|
});
|
|
}
|
|
|
|
async function measure(page, viewport) {
|
|
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
|
await page.goto(process.env.TARGET_URL, {
|
|
waitUntil: "domcontentloaded",
|
|
timeout: 30000,
|
|
});
|
|
await waitRpc(page);
|
|
await installMocks(page);
|
|
await page.evaluate(() =>
|
|
Router.navigate("/chat?session=agent:main:webchat:tool-result-full")
|
|
);
|
|
await page.waitForSelector(".chat-tools-summary", { timeout: 15000 });
|
|
await page.locator(".chat-tools-summary").first().click();
|
|
await page.waitForSelector(".chat-tool-view-btn", {
|
|
state: "visible",
|
|
timeout: 5000,
|
|
});
|
|
await page.locator(".chat-tool-view-btn").first().click();
|
|
await page.waitForSelector(".modal .chat-tool-result-full", { timeout: 5000 });
|
|
const layout = await page.evaluate(() => {
|
|
const rect = (selector) => {
|
|
const el = document.querySelector(selector);
|
|
const r = el.getBoundingClientRect();
|
|
return {
|
|
left: r.left,
|
|
right: r.right,
|
|
top: r.top,
|
|
bottom: r.bottom,
|
|
width: r.width,
|
|
height: r.height,
|
|
};
|
|
};
|
|
const pre = document.querySelector(".chat-tool-result-full");
|
|
const button = document.querySelector(".chat-tool-view-btn");
|
|
return {
|
|
width: window.innerWidth,
|
|
scrollWidth: document.documentElement.scrollWidth,
|
|
modal: rect(".modal"),
|
|
pre: rect(".chat-tool-result-full"),
|
|
preScrollWidth: pre.scrollWidth,
|
|
preClientWidth: pre.clientWidth,
|
|
buttonType: button ? button.getAttribute("type") : "",
|
|
text: pre ? pre.textContent.slice(0, 80) : "",
|
|
};
|
|
});
|
|
await page.locator(".modal-foot button", { hasText: "Close" }).click();
|
|
await page.waitForSelector(".modal-backdrop", { state: "detached", timeout: 5000 });
|
|
const afterClose = await page.locator(".modal-backdrop").count();
|
|
return { name: viewport.name, layout, afterClose };
|
|
}
|
|
|
|
(async () => {
|
|
const browser = await chromium.launch({ headless: true });
|
|
const page = await browser.newPage();
|
|
const errors = [];
|
|
page.on("pageerror", err => errors.push(String(err)));
|
|
const viewports = [
|
|
{ name: "desktop", width: 1365, height: 768 },
|
|
{ name: "mobile", width: 390, height: 844 },
|
|
];
|
|
const layouts = [];
|
|
for (const viewport of viewports) layouts.push(await measure(page, viewport));
|
|
await browser.close();
|
|
console.log(JSON.stringify({ layouts, pageErrors: errors }));
|
|
})().catch(err => {
|
|
console.error(err && err.stack ? err.stack : String(err));
|
|
process.exit(1);
|
|
});
|
|
"""
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
env = os.environ.copy()
|
|
env["OPENSQUILLA_STATE_DIR"] = str(tmp_path / "state")
|
|
env["OPENSQUILLA_LOG_DIR"] = str(tmp_path / "logs")
|
|
server = subprocess.Popen(
|
|
[sys.executable, str(server_script)],
|
|
cwd=Path.cwd(),
|
|
env=env,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
)
|
|
try:
|
|
_wait_for_health(port, server)
|
|
_install_playwright(tmp_path)
|
|
result = subprocess.run(
|
|
[_node(), str(browser_script)],
|
|
cwd=tmp_path,
|
|
env=dict(env, TARGET_URL=f"http://127.0.0.1:{port}/control/"),
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=120,
|
|
)
|
|
assert result.returncode == 0, result.stderr or result.stdout
|
|
payload = json.loads(result.stdout.strip().splitlines()[-1])
|
|
finally:
|
|
_stop_process(server)
|
|
|
|
assert payload["pageErrors"] == []
|
|
layouts = {item["name"]: item for item in payload["layouts"]}
|
|
for name, item in layouts.items():
|
|
layout = item["layout"]
|
|
assert item["afterClose"] == 0, item
|
|
assert layout["scrollWidth"] <= layout["width"], (name, layout)
|
|
assert layout["buttonType"] == "button", (name, layout)
|
|
assert "labubu-1.jpg" in layout["text"], (name, layout)
|
|
assert layout["modal"]["left"] >= 0, (name, layout)
|
|
assert layout["modal"]["right"] <= layout["width"], (name, layout)
|
|
assert layout["pre"]["right"] <= layout["modal"]["right"], (name, layout)
|
|
assert layout["preScrollWidth"] <= layout["preClientWidth"] + 2, (name, layout)
|
|
|
|
|
|
def test_chat_compaction_events_render_recoverable_toasts_in_real_browser(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
if os.environ.get("OPENSQUILLA_WEBUI_BROWSER_CHAT_E2E") != "1":
|
|
pytest.skip("set OPENSQUILLA_WEBUI_BROWSER_CHAT_E2E=1 to run chat browser e2e")
|
|
|
|
port = _free_port()
|
|
server_script = tmp_path / "webui_chat_compaction_server.py"
|
|
browser_script = tmp_path / "webui_chat_compaction_browser.js"
|
|
server_script.write_text(
|
|
textwrap.dedent(
|
|
f"""
|
|
import uvicorn
|
|
|
|
from opensquilla.gateway.app import create_gateway_app
|
|
from opensquilla.gateway.config import AuthConfig, GatewayConfig
|
|
|
|
config = GatewayConfig(
|
|
host="127.0.0.1",
|
|
port={port},
|
|
auth=AuthConfig(mode="none"),
|
|
)
|
|
app = create_gateway_app(config)
|
|
|
|
if __name__ == "__main__":
|
|
uvicorn.run(app, host="127.0.0.1", port={port}, log_level="warning")
|
|
"""
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
browser_script.write_text(
|
|
textwrap.dedent(
|
|
r"""
|
|
const { chromium } = require("playwright");
|
|
|
|
async function emitEvent(page, event, payload, meta = {}) {
|
|
await page.evaluate(
|
|
({ event, payload, meta }) => {
|
|
const rpc = App.getRpc();
|
|
const handlers = rpc._listeners.get(event);
|
|
if (handlers) {
|
|
handlers.forEach(h => h(payload, meta));
|
|
}
|
|
const wild = rpc._listeners.get("*");
|
|
if (wild) wild.forEach(h => h(event, payload, meta));
|
|
},
|
|
{ event, payload, meta }
|
|
);
|
|
}
|
|
|
|
async function emitCompaction(page, payload, meta = {}) {
|
|
return emitEvent(page, "session.event.compaction", payload, meta);
|
|
}
|
|
|
|
async function lastSeparatorText(page) {
|
|
return await page.evaluate(() => {
|
|
const separators = document.querySelectorAll(".chat-context-separator");
|
|
return separators.length
|
|
? separators[separators.length - 1].textContent
|
|
: "";
|
|
});
|
|
}
|
|
|
|
async function lastSeparatorPlacement(page) {
|
|
return await page.evaluate(() => {
|
|
const thread = document.querySelector("#chat-thread");
|
|
const separators = document.querySelectorAll(".chat-context-separator");
|
|
const separator = separators[separators.length - 1];
|
|
if (!thread || !separator) return null;
|
|
const threadBox = thread.getBoundingClientRect();
|
|
const separatorBox = separator.getBoundingClientRect();
|
|
return {
|
|
bottomGap: Math.round(threadBox.bottom - separatorBox.bottom),
|
|
topGap: Math.round(separatorBox.top - threadBox.top),
|
|
className: separator.className,
|
|
scrollTop: Math.round(thread.scrollTop),
|
|
scrollHeight: Math.round(thread.scrollHeight),
|
|
clientHeight: Math.round(thread.clientHeight),
|
|
};
|
|
});
|
|
}
|
|
|
|
|
|
(async () => {
|
|
const browser = await chromium.launch({ headless: true });
|
|
const page = await browser.newPage();
|
|
const errors = [];
|
|
page.on("pageerror", err => errors.push(String(err)));
|
|
|
|
await page.goto(process.env.TARGET_URL, {
|
|
waitUntil: "domcontentloaded",
|
|
timeout: 30000,
|
|
});
|
|
await page.waitForSelector("#chat-textarea", { timeout: 15000 });
|
|
await page.waitForFunction(
|
|
() =>
|
|
typeof App !== "undefined" &&
|
|
App.getRpc &&
|
|
App.getRpc()?.state === "connected",
|
|
{ timeout: 15000 }
|
|
);
|
|
await page.evaluate(() => {
|
|
const rpc = App.getRpc();
|
|
const originalCall = rpc.call.bind(rpc);
|
|
const originalToast = UI.toast.bind(UI);
|
|
window.__compactUx = { chatCalls: [], toastCalls: [] };
|
|
UI.toast = (message, type = "info", duration = 3000) => {
|
|
window.__compactUx.toastCalls.push({ message, type, duration });
|
|
return originalToast(message, type, duration);
|
|
};
|
|
rpc.call = (method, params = {}) => {
|
|
if (method === "chat.send") {
|
|
window.__compactUx.chatCalls.push({ method, params });
|
|
return Promise.resolve({
|
|
task_id: "compact-ux-" + window.__compactUx.chatCalls.length,
|
|
});
|
|
}
|
|
return originalCall(method, params);
|
|
};
|
|
});
|
|
|
|
await emitCompaction(page, { status: "started", source: "manual" });
|
|
await page.waitForSelector(".chat-context-separator", { timeout: 5000 });
|
|
await page.waitForTimeout(600);
|
|
const startedStatusVisible = await lastSeparatorText(page);
|
|
const manualSeparatorStarted = startedStatusVisible;
|
|
const manualSeparatorStartedPlacement = await lastSeparatorPlacement(page);
|
|
await emitCompaction(page, { status: "skipped", source: "manual" });
|
|
await page.waitForTimeout(250);
|
|
const skippedStatusVisible = await lastSeparatorText(page);
|
|
|
|
await emitCompaction(page, {
|
|
status: "completed",
|
|
source: "manual",
|
|
tokens_before: 3500,
|
|
tokens_after: 1300,
|
|
});
|
|
await page.waitForFunction(
|
|
() => document.body.textContent.includes("context compacted"),
|
|
null, { timeout: 5000 }
|
|
);
|
|
|
|
await emitCompaction(
|
|
page,
|
|
{ status: "failed", source: "manual", message: "old replay" },
|
|
{ replayed: true }
|
|
);
|
|
await page.waitForTimeout(250);
|
|
|
|
await emitCompaction(page, { status: "started", source: "manual" });
|
|
await page.fill("#chat-textarea", "queued during compact");
|
|
await page.click("#chat-btn-send");
|
|
await page.waitForTimeout(150);
|
|
const queuedBeforeSkipped = await page.evaluate(
|
|
() => window.__compactUx.chatCalls.length
|
|
);
|
|
await emitCompaction(page, { status: "skipped", source: "manual" });
|
|
await page.waitForFunction(
|
|
() =>
|
|
window.__compactUx.chatCalls
|
|
.some(c => c.params.message === "queued during compact"),
|
|
null, { timeout: 5000 }
|
|
);
|
|
await emitEvent(page, "session.event.done", { text: "compact queued answer" });
|
|
await page.waitForTimeout(150);
|
|
|
|
await page.fill("#chat-textarea", "seed automatic compact turn");
|
|
await page.click("#chat-btn-send");
|
|
await page.waitForTimeout(150);
|
|
const callsBeforeAutomaticCompact = await page.evaluate(
|
|
() => window.__compactUx.chatCalls.length
|
|
);
|
|
await emitCompaction(page, {
|
|
status: "started",
|
|
source: "automatic",
|
|
phase: "preflight",
|
|
});
|
|
await page.waitForTimeout(800);
|
|
const automaticStartedStatusVisible = await lastSeparatorText(page);
|
|
const automaticSeparatorStarted = automaticStartedStatusVisible;
|
|
const automaticSeparatorStartedPlacement = await lastSeparatorPlacement(page);
|
|
await page.fill("#chat-textarea", "queued during automatic compact");
|
|
await page.click("#chat-btn-send");
|
|
await page.waitForTimeout(150);
|
|
const automaticQueuedBeforeCompleted = await page.evaluate(
|
|
() => window.__compactUx.chatCalls.length
|
|
);
|
|
await emitCompaction(page, {
|
|
status: "observed",
|
|
source: "automatic",
|
|
event: "compaction.chunk_summarized",
|
|
tokens_before: 5000,
|
|
tokens_after: 2600,
|
|
});
|
|
await page.waitForTimeout(800);
|
|
const automaticObservedStatusVisible = await lastSeparatorText(page);
|
|
const automaticSeparatorObserved = automaticObservedStatusVisible;
|
|
await emitCompaction(page, {
|
|
status: "completed",
|
|
source: "automatic",
|
|
tokens_before: 5000,
|
|
tokens_after: 1800,
|
|
});
|
|
await page.waitForTimeout(800);
|
|
const automaticSeparatorCompleted = await lastSeparatorText(page);
|
|
const automaticDidNotDrainBeforeDone = await page.evaluate(
|
|
expected => window.__compactUx.chatCalls.length === expected,
|
|
callsBeforeAutomaticCompact
|
|
);
|
|
await emitEvent(page, "session.event.done", { text: "automatic compact answer" });
|
|
await page.waitForFunction(
|
|
() =>
|
|
window.__compactUx.chatCalls
|
|
.some(c => c.params.message === "queued during automatic compact"),
|
|
null, { timeout: 5000 }
|
|
);
|
|
|
|
// Wait >1500ms so the dedup guard resets before emitting another
|
|
// "started" event with the same source signature.
|
|
await page.waitForTimeout(1600);
|
|
await emitCompaction(page, {
|
|
status: "started",
|
|
source: "automatic",
|
|
phase: "preflight",
|
|
});
|
|
await page.waitForTimeout(800);
|
|
await emitCompaction(page, {
|
|
status: "skipped",
|
|
source: "automatic",
|
|
phase: "preflight",
|
|
reason: "structured_content_noop",
|
|
user_visible: false,
|
|
});
|
|
await page.waitForTimeout(300);
|
|
const automaticNoopSeparatorHidden = await page
|
|
.locator(".chat-context-separator")
|
|
.count();
|
|
const automaticNoopBodyText = await page.evaluate(
|
|
() => document.body.textContent
|
|
);
|
|
|
|
await page.waitForTimeout(1600);
|
|
await emitCompaction(page, {
|
|
status: "started",
|
|
source: "automatic",
|
|
phase: "preflight",
|
|
});
|
|
await page.waitForTimeout(800);
|
|
await emitCompaction(page, {
|
|
status: "skipped",
|
|
source: "automatic",
|
|
phase: "preflight",
|
|
reason: "empty_summary",
|
|
user_visible: true,
|
|
});
|
|
await page.waitForTimeout(800);
|
|
const automaticNonBenignSkipStatus = await lastSeparatorText(page);
|
|
|
|
await page.waitForTimeout(1600);
|
|
await emitCompaction(page, {
|
|
status: "started",
|
|
source: "automatic",
|
|
phase: "preflight",
|
|
});
|
|
await page.waitForTimeout(800);
|
|
await emitCompaction(page, {
|
|
status: "emergency_ephemeral",
|
|
source: "automatic",
|
|
phase: "preflight",
|
|
reason: "empty_summary",
|
|
tokens_before: 5000,
|
|
tokens_after: 2400,
|
|
});
|
|
await page.waitForTimeout(800);
|
|
const emergencyStatusVisible = await lastSeparatorText(page);
|
|
|
|
await page.waitForTimeout(1600);
|
|
await emitCompaction(page, { status: "started", source: "manual" });
|
|
await page.waitForTimeout(800);
|
|
await page.fill("#chat-textarea", "queued after blocking compact failure");
|
|
await page.click("#chat-btn-send");
|
|
await page.waitForTimeout(150);
|
|
await emitCompaction(page, {
|
|
status: "failed",
|
|
source: "manual",
|
|
reason: "compaction_insufficient",
|
|
message: "still over budget",
|
|
refused: true,
|
|
});
|
|
await page.waitForTimeout(800);
|
|
const failedStatusVisible = await lastSeparatorText(page);
|
|
|
|
const bodyText = await page.locator("body").innerText();
|
|
const toastMessages = await page.evaluate(
|
|
() => window.__compactUx.toastCalls.map(t => t.message)
|
|
);
|
|
// Diagnostic: log actual rail text for debugging
|
|
console.error("DIAG_EMERGENCY:", JSON.stringify(emergencyStatusVisible));
|
|
console.error("DIAG_FAILED:", JSON.stringify(failedStatusVisible));
|
|
const result = {
|
|
hasStartedToast: toastMessages.includes("Compacting context..."),
|
|
hasSkippedToast: toastMessages.includes(
|
|
"Already within context budget; no compact was applied."
|
|
),
|
|
hasCompletedToast: toastMessages.some(m => m.includes("Context compacted")),
|
|
hasEmergencyToast: toastMessages.includes(
|
|
"Continuing with temporary context compaction for this turn"
|
|
),
|
|
hasFailedToast: toastMessages.some(
|
|
m => m.includes("Compact failed: still over budget")
|
|
),
|
|
hasReplayedFailureToast: toastMessages.some(m => m.includes("old replay")),
|
|
startedStatusVisible: startedStatusVisible.includes("context compacting"),
|
|
manualRailStarted:
|
|
manualSeparatorStarted.includes("context compacting"),
|
|
manualRailBottomAnchored:
|
|
manualSeparatorStartedPlacement &&
|
|
manualSeparatorStartedPlacement.bottomGap <= 60 &&
|
|
manualSeparatorStartedPlacement.className
|
|
.includes("chat-context-separator--session"),
|
|
skippedStatusVisible: skippedStatusVisible.includes(
|
|
"no compaction needed"
|
|
),
|
|
failedStatusVisible:
|
|
failedStatusVisible.includes("compaction failed"),
|
|
queuedBeforeSkipped,
|
|
skippedDrainedQueuedSend: await page.evaluate(
|
|
() => window.__compactUx.chatCalls
|
|
.some(c => c.params.message === "queued during compact")
|
|
),
|
|
automaticStartedStatusVisible: automaticStartedStatusVisible.includes(
|
|
"context compacting"
|
|
),
|
|
automaticRailStarted:
|
|
automaticSeparatorStarted.includes("context compacting"),
|
|
automaticRailBottomAnchored:
|
|
automaticSeparatorStartedPlacement &&
|
|
automaticSeparatorStartedPlacement.bottomGap <= 60 &&
|
|
automaticSeparatorStartedPlacement.className
|
|
.includes("chat-context-separator--session"),
|
|
automaticObservedStatusVisible: automaticObservedStatusVisible.includes(
|
|
"context compacting"
|
|
),
|
|
automaticRailObserved:
|
|
automaticSeparatorObserved.includes("context compacting"),
|
|
automaticRailCompleted:
|
|
automaticSeparatorCompleted.includes("context compacted"),
|
|
automaticQueuedBeforeCompleted:
|
|
automaticQueuedBeforeCompleted === callsBeforeAutomaticCompact,
|
|
automaticDidNotDrainBeforeDone,
|
|
automaticDrainedAfterDone: await page.evaluate(
|
|
() => window.__compactUx.chatCalls
|
|
.some(c => c.params.message === "queued during automatic compact")
|
|
),
|
|
emergencyStatusVisible:
|
|
emergencyStatusVisible.includes("temporary compaction") &&
|
|
!emergencyStatusVisible.includes("empty summary"),
|
|
automaticNoopRailHidden: automaticNoopSeparatorHidden === 0,
|
|
automaticNoopSkippedHidden:
|
|
!automaticNoopBodyText.includes("Context compaction skipped") &&
|
|
!automaticNoopBodyText.includes("structured content noop"),
|
|
automaticNonBenignSkipVisible:
|
|
automaticNonBenignSkipStatus.includes("compaction skipped") &&
|
|
!automaticNonBenignSkipStatus.includes("empty summary"),
|
|
blockingFailureKeptPending:
|
|
(await page.locator("#chat-pending").innerText())
|
|
.includes("queued after blocking compact"),
|
|
blockingFailureDidNotDrain: await page.evaluate(
|
|
() => !window.__compactUx.chatCalls
|
|
.some(c => c.params.message === "queued after blocking compact failure")
|
|
),
|
|
blockingFailureDidNotRecoverComposer:
|
|
(await page.locator("#chat-textarea").inputValue()) === "",
|
|
pageErrors: errors,
|
|
};
|
|
await browser.close();
|
|
console.log(JSON.stringify(result));
|
|
})().catch(err => {
|
|
console.error(err && err.stack ? err.stack : String(err));
|
|
process.exit(1);
|
|
});
|
|
"""
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
env = os.environ.copy()
|
|
env["OPENSQUILLA_STATE_DIR"] = str(tmp_path / "state")
|
|
env["OPENSQUILLA_LOG_DIR"] = str(tmp_path / "logs")
|
|
server = subprocess.Popen(
|
|
[sys.executable, str(server_script)],
|
|
cwd=Path.cwd(),
|
|
env=env,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
)
|
|
try:
|
|
_wait_for_health(port, server)
|
|
_install_playwright(tmp_path)
|
|
result = subprocess.run(
|
|
[_node(), str(browser_script)],
|
|
cwd=tmp_path,
|
|
env=dict(env, TARGET_URL=f"http://127.0.0.1:{port}/control/chat"),
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=90,
|
|
)
|
|
assert result.returncode == 0, result.stderr or result.stdout
|
|
payload = json.loads(result.stdout.strip().splitlines()[-1])
|
|
finally:
|
|
_stop_process(server)
|
|
|
|
assert payload == {
|
|
"hasStartedToast": False,
|
|
"hasSkippedToast": False,
|
|
"hasCompletedToast": False,
|
|
"hasEmergencyToast": True,
|
|
"hasFailedToast": True,
|
|
"hasReplayedFailureToast": False,
|
|
"startedStatusVisible": True,
|
|
"manualRailStarted": True,
|
|
"manualRailBottomAnchored": True,
|
|
"skippedStatusVisible": True,
|
|
"failedStatusVisible": True,
|
|
"queuedBeforeSkipped": 0,
|
|
"skippedDrainedQueuedSend": True,
|
|
"automaticStartedStatusVisible": True,
|
|
"automaticRailStarted": True,
|
|
"automaticRailBottomAnchored": True,
|
|
"automaticObservedStatusVisible": True,
|
|
"automaticRailObserved": True,
|
|
"automaticRailCompleted": True,
|
|
"automaticQueuedBeforeCompleted": True,
|
|
"automaticDidNotDrainBeforeDone": True,
|
|
"automaticDrainedAfterDone": True,
|
|
"emergencyStatusVisible": True,
|
|
"automaticNoopRailHidden": True,
|
|
"automaticNoopSkippedHidden": True,
|
|
"automaticNonBenignSkipVisible": True,
|
|
"blockingFailureKeptPending": True,
|
|
"blockingFailureDidNotDrain": True,
|
|
"blockingFailureDidNotRecoverComposer": True,
|
|
"pageErrors": [],
|
|
}
|
|
|
|
|
|
def test_router_fx_live_then_reopen_stays_settled_in_real_browser(tmp_path: Path) -> None:
|
|
if os.environ.get("OPENSQUILLA_WEBUI_BROWSER_CHAT_E2E") != "1":
|
|
pytest.skip("set OPENSQUILLA_WEBUI_BROWSER_CHAT_E2E=1 to run chat browser e2e")
|
|
|
|
port = _free_port()
|
|
server_script = tmp_path / "webui_router_fx_server.py"
|
|
browser_script = tmp_path / "webui_router_fx_browser.js"
|
|
server_script.write_text(
|
|
textwrap.dedent(
|
|
f"""
|
|
import uvicorn
|
|
|
|
from opensquilla.gateway.app import create_gateway_app
|
|
from opensquilla.gateway.config import AuthConfig, GatewayConfig
|
|
|
|
config = GatewayConfig(
|
|
host="127.0.0.1",
|
|
port={port},
|
|
auth=AuthConfig(mode="none"),
|
|
)
|
|
app = create_gateway_app(config)
|
|
|
|
if __name__ == "__main__":
|
|
uvicorn.run(app, host="127.0.0.1", port={port}, log_level="warning")
|
|
"""
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
browser_script.write_text(
|
|
textwrap.dedent(
|
|
r"""
|
|
const { chromium } = require("playwright");
|
|
|
|
async function waitRpc(page) {
|
|
await page.waitForFunction(
|
|
() =>
|
|
typeof App !== "undefined" &&
|
|
App.getRpc &&
|
|
App.getRpc()?.state === "connected",
|
|
{ timeout: 15000 }
|
|
);
|
|
}
|
|
|
|
async function emit(page, event, payload, meta = {}) {
|
|
await page.evaluate(
|
|
({ event, payload, meta }) => {
|
|
const rpc = App.getRpc();
|
|
const named = rpc._listeners.get(event);
|
|
if (named) named.forEach(h => h(payload, meta));
|
|
const wild = rpc._listeners.get("*");
|
|
if (wild) wild.forEach(h => h(event, payload, meta));
|
|
},
|
|
{ event, payload, meta }
|
|
);
|
|
}
|
|
|
|
async function snapshot(page) {
|
|
return await page.evaluate(() => {
|
|
const strips = Array.from(document.querySelectorAll(".router-fx"));
|
|
const strip = strips[0] || null;
|
|
const cells = Array.from(document.querySelectorAll(".router-fx-cell .nm"));
|
|
const animations = strips.flatMap(el =>
|
|
el.getAnimations({ subtree: true })
|
|
.filter(anim => anim.playState !== "finished" && anim.playState !== "idle")
|
|
.map(anim => anim.animationName || "")
|
|
);
|
|
const overflows = cells.filter(el => el.scrollWidth > el.clientWidth + 1)
|
|
.map(el => el.textContent);
|
|
const grid = document.querySelector(".router-fx-grid");
|
|
const gridRect = grid ? grid.getBoundingClientRect() : null;
|
|
return {
|
|
count: strips.length,
|
|
state: strip?.dataset.state || "",
|
|
renderMode: strip?.dataset.renderMode || "",
|
|
liveCount: document.querySelectorAll(".router-fx[data-live='true']").length,
|
|
scanningCount: document.querySelectorAll(
|
|
".router-fx[data-scanning='true']"
|
|
).length,
|
|
selectorVisible: document.querySelectorAll(".router-fx-selector.visible").length,
|
|
bursts: document.querySelectorAll(".router-fx-burst").length,
|
|
pinging: document.querySelectorAll(".router-fx-cell.pinging").length,
|
|
animations,
|
|
aria: strip?.getAttribute("aria-label") || "",
|
|
winner: document.querySelector(".router-fx-cell.win .nm")?.textContent || "",
|
|
winners: strips.map(
|
|
el => el.querySelector(".router-fx-cell.win .nm")?.textContent || ""
|
|
),
|
|
cellCount: cells.length,
|
|
cellLabels: cells.map(el => el.textContent || ""),
|
|
hasLongLabel: cells.some(el => el.textContent === "gemini-3.1-flash-lite"),
|
|
overflows,
|
|
gridWithinViewport: gridRect
|
|
? gridRect.left >= -1 && gridRect.right <= window.innerWidth + 1
|
|
: false,
|
|
};
|
|
});
|
|
}
|
|
|
|
(async () => {
|
|
const browser = await chromium.launch({ headless: true });
|
|
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
|
|
const errors = [];
|
|
page.on("pageerror", err => errors.push(String(err)));
|
|
|
|
await page.goto(process.env.TARGET_URL, {
|
|
waitUntil: "domcontentloaded",
|
|
timeout: 30000,
|
|
});
|
|
await waitRpc(page);
|
|
|
|
await page.evaluate(() => {
|
|
localStorage.setItem(
|
|
"opensquilla-router-fx",
|
|
JSON.stringify({ enabled: true, variant: "default" })
|
|
);
|
|
window.__routerFxTest = {
|
|
sessionKey: "agent:main:webchat:router-fx-panel",
|
|
historyMessages: [],
|
|
chatCalls: [],
|
|
routerConfig: {
|
|
enabled: true,
|
|
rollout_phase: "full",
|
|
tiers: {
|
|
c1: { model: "openrouter/deepseek-v4-flash" },
|
|
c2: { model: "openrouter/gemini-3.1-flash-lite" },
|
|
c3: { model: "openrouter/qwen3.6-max" },
|
|
image_model: {
|
|
model: "openrouter/kimi-k2.6",
|
|
supports_image: true,
|
|
image_only: true,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const rpc = App.getRpc();
|
|
const originalCall = rpc.call.bind(rpc);
|
|
rpc.call = (method, params = {}) => {
|
|
if (method === "tools.search_provider") {
|
|
return Promise.resolve({ provider: "none" });
|
|
}
|
|
if (method === "config.get") {
|
|
return Promise.resolve({
|
|
permissions: { default_mode: "ask" },
|
|
squilla_router: window.__routerFxTest.routerConfig,
|
|
});
|
|
}
|
|
if (method === "chat.history") {
|
|
if (window.__routerFxTest.deferHistory) {
|
|
return new Promise(resolve => {
|
|
window.__routerFxTest.resolveHistory = () => {
|
|
window.__routerFxTest.deferHistory = false;
|
|
resolve({
|
|
messages: window.__routerFxTest.historyMessages,
|
|
history_scope: "complete",
|
|
has_more: false,
|
|
});
|
|
};
|
|
});
|
|
}
|
|
return Promise.resolve({
|
|
messages: window.__routerFxTest.historyMessages,
|
|
history_scope: "complete",
|
|
has_more: false,
|
|
});
|
|
}
|
|
if (method === "sessions.messages.subscribe") {
|
|
return Promise.resolve({
|
|
subscribed: true,
|
|
key: params.key,
|
|
current_stream_seq: 100,
|
|
replay_complete: true,
|
|
replayed_count: 0,
|
|
run_status: "idle",
|
|
});
|
|
}
|
|
if (method === "chat.send") {
|
|
window.__routerFxTest.chatCalls.push({ method, params });
|
|
return Promise.resolve({ task_id: "router-fx-task" });
|
|
}
|
|
return originalCall(method, params);
|
|
};
|
|
});
|
|
|
|
await page.evaluate(() =>
|
|
Router.navigate("/chat?session=agent:main:webchat:router-fx-panel")
|
|
);
|
|
await page.waitForSelector("#chat-textarea", { timeout: 15000 });
|
|
await page.waitForFunction(
|
|
() => document.querySelector("#toggle-router")?.checked === true,
|
|
{ timeout: 15000 }
|
|
);
|
|
|
|
await page.fill("#chat-textarea", "route this with a long label");
|
|
await page.click("#chat-btn-send");
|
|
await page.waitForSelector(".router-fx[data-live='true'][data-scanning='true']", {
|
|
timeout: 5000,
|
|
});
|
|
const sessionKey = await page.locator("#chat-session-chip-key").innerText();
|
|
await emit(page, "session.event.router_decision", {
|
|
session_key: sessionKey,
|
|
stream_seq: 101,
|
|
tier: "c1",
|
|
model: "openrouter/deepseek-v4-flash",
|
|
routing_source: "squilla_router",
|
|
routing_applied: true,
|
|
});
|
|
await page.waitForSelector(".router-fx[data-state='settled'] .router-fx-cell.win", {
|
|
timeout: 5000,
|
|
});
|
|
const liveSettled = await snapshot(page);
|
|
|
|
await page.evaluate(() => {
|
|
window.__routerFxTest.historyMessages = [
|
|
{
|
|
role: "user",
|
|
text: "route this with a long label",
|
|
message_id: "router-fx-u1",
|
|
timestamp: "2026-05-30T00:00:00Z",
|
|
},
|
|
{
|
|
role: "assistant",
|
|
text: "done",
|
|
message_id: "router-fx-a1",
|
|
timestamp: "2026-05-30T00:00:01Z",
|
|
model: "openrouter/deepseek-v4-flash",
|
|
usage: {
|
|
model: "openrouter/deepseek-v4-flash",
|
|
routed_model: "openrouter/deepseek-v4-flash",
|
|
routed_tier: "c1",
|
|
routing_source: "squilla_router",
|
|
routing_applied: true,
|
|
input_tokens: 11,
|
|
output_tokens: 2,
|
|
},
|
|
},
|
|
];
|
|
Router.navigate("/overview");
|
|
Router.navigate("/chat?session=agent:main:webchat:router-fx-panel");
|
|
});
|
|
await page.waitForSelector(
|
|
".router-fx[data-render-mode='history'][data-state='settled']",
|
|
{ timeout: 15000 }
|
|
);
|
|
const reopened = await snapshot(page);
|
|
|
|
await emit(page, "session.event.router_decision", {
|
|
session_key: sessionKey,
|
|
stream_seq: 102,
|
|
tier: "c1",
|
|
model: "openrouter/deepseek-v4-flash",
|
|
routing_source: "squilla_router",
|
|
routing_applied: true,
|
|
}, { replayed: true });
|
|
await page.waitForTimeout(700);
|
|
const afterReplay = await snapshot(page);
|
|
|
|
await page.setViewportSize({ width: 520, height: 720 });
|
|
await page.waitForTimeout(300);
|
|
const narrow = await snapshot(page);
|
|
|
|
await page.setViewportSize({ width: 1280, height: 720 });
|
|
await page.evaluate(() => {
|
|
window.__routerFxTest.historyMessages = [
|
|
{
|
|
role: "user",
|
|
text: "first routed turn",
|
|
message_id: "router-fx-u-race-1",
|
|
timestamp: "2026-05-30T00:00:00Z",
|
|
},
|
|
{
|
|
role: "assistant",
|
|
text: "first done",
|
|
message_id: "router-fx-a-race-1",
|
|
timestamp: "2026-05-30T00:00:01Z",
|
|
model: "openrouter/deepseek-v4-flash",
|
|
usage: {
|
|
model: "openrouter/deepseek-v4-flash",
|
|
routed_model: "openrouter/deepseek-v4-flash",
|
|
routed_tier: "c1",
|
|
routing_source: "squilla_router",
|
|
routing_applied: true,
|
|
input_tokens: 11,
|
|
output_tokens: 2,
|
|
},
|
|
},
|
|
];
|
|
Router.navigate("/overview");
|
|
Router.navigate("/chat?session=agent:main:webchat:router-fx-panel");
|
|
});
|
|
await page.waitForFunction(
|
|
() =>
|
|
document.querySelectorAll(".router-fx").length === 1 &&
|
|
document.querySelector(".router-fx-cell.win .nm")?.textContent ===
|
|
"deepseek-v4-flash",
|
|
{ timeout: 15000 }
|
|
);
|
|
await page.evaluate(() => {
|
|
window.__routerFxTest.historyMessages = [
|
|
{
|
|
role: "user",
|
|
text: "first routed turn",
|
|
message_id: "router-fx-u-race-1",
|
|
timestamp: "2026-05-30T00:00:00Z",
|
|
},
|
|
{
|
|
role: "assistant",
|
|
text: "first done",
|
|
message_id: "router-fx-a-race-1",
|
|
timestamp: "2026-05-30T00:00:01Z",
|
|
model: "openrouter/deepseek-v4-flash",
|
|
usage: {
|
|
model: "openrouter/deepseek-v4-flash",
|
|
routed_model: "openrouter/deepseek-v4-flash",
|
|
routed_tier: "c1",
|
|
routing_source: "squilla_router",
|
|
routing_applied: true,
|
|
input_tokens: 11,
|
|
output_tokens: 2,
|
|
},
|
|
},
|
|
{
|
|
role: "user",
|
|
text: "second routed turn",
|
|
message_id: "router-fx-u-race-2",
|
|
timestamp: "2026-05-30T00:00:02Z",
|
|
},
|
|
{
|
|
role: "assistant",
|
|
text: "second done",
|
|
message_id: "router-fx-a-race-2",
|
|
timestamp: "2026-05-30T00:00:03Z",
|
|
model: "openrouter/gemini-3.1-flash-lite",
|
|
usage: {
|
|
model: "openrouter/gemini-3.1-flash-lite",
|
|
routed_model: "openrouter/gemini-3.1-flash-lite",
|
|
routed_tier: "c2",
|
|
routing_source: "squilla_router",
|
|
routing_applied: true,
|
|
input_tokens: 13,
|
|
output_tokens: 3,
|
|
},
|
|
},
|
|
];
|
|
window.__routerFxTest.deferHistory = true;
|
|
Router.navigate("/overview");
|
|
Router.navigate("/chat?session=agent:main:webchat:router-fx-panel");
|
|
});
|
|
await emit(page, "session.event.router_decision", {
|
|
session_key: sessionKey,
|
|
stream_seq: 103,
|
|
tier: "c2",
|
|
model: "openrouter/gemini-3.1-flash-lite",
|
|
routing_source: "squilla_router",
|
|
routing_applied: true,
|
|
}, { replayed: true });
|
|
await page.waitForTimeout(250);
|
|
const duringHistoryHydrationReplay = await snapshot(page);
|
|
await page.evaluate(() => window.__routerFxTest.resolveHistory());
|
|
await page.waitForFunction(
|
|
() => {
|
|
const winners = Array.from(document.querySelectorAll(".router-fx"))
|
|
.map(el => el.querySelector(".router-fx-cell.win .nm")?.textContent || "");
|
|
return winners.length === 2 &&
|
|
winners.includes("deepseek-v4-flash") &&
|
|
winners.includes("gemini-3.1-flash-lite");
|
|
},
|
|
{ timeout: 15000 }
|
|
);
|
|
await page.waitForTimeout(300);
|
|
const afterHistoryHydrationReplay = await snapshot(page);
|
|
|
|
await page.evaluate(() => {
|
|
window.__routerFxTest.historyMessages = [
|
|
{
|
|
role: "user",
|
|
text: "describe image",
|
|
message_id: "router-fx-u-image",
|
|
timestamp: "2026-05-30T00:00:04Z",
|
|
attachments: [{
|
|
mime: "image/png",
|
|
type: "image/png",
|
|
name: "preview.png",
|
|
data: "data:image/png;base64,iVBORw0KGgo=",
|
|
}],
|
|
},
|
|
{
|
|
role: "assistant",
|
|
text: "image done",
|
|
message_id: "router-fx-a-image",
|
|
timestamp: "2026-05-30T00:00:05Z",
|
|
model: "openrouter/kimi-k2.6",
|
|
usage: {
|
|
model: "openrouter/kimi-k2.6",
|
|
routed_model: "openrouter/kimi-k2.6",
|
|
routed_tier: "image_model",
|
|
routing_source: "image_route",
|
|
routing_applied: true,
|
|
input_tokens: 21,
|
|
output_tokens: 5,
|
|
},
|
|
},
|
|
];
|
|
Router.navigate("/overview");
|
|
Router.navigate("/chat?session=agent:main:webchat:router-fx-panel");
|
|
});
|
|
await page.waitForFunction(
|
|
() =>
|
|
document.querySelector(".msg.user .msg-attachments") &&
|
|
document.querySelectorAll(".router-fx").length === 0,
|
|
{ timeout: 15000 }
|
|
);
|
|
const singleImageCandidate = await snapshot(page);
|
|
|
|
await page.evaluate(() => {
|
|
window.__routerFxTest.routerConfig = {
|
|
enabled: true,
|
|
rollout_phase: "full",
|
|
tiers: {
|
|
t1: { model: "openrouter/shared-model" },
|
|
t2: { model: "other-provider/shared-model" },
|
|
},
|
|
};
|
|
window.__routerFxTest.historyMessages = [
|
|
{
|
|
role: "user",
|
|
text: "same model different provider",
|
|
message_id: "router-fx-u-same-display",
|
|
timestamp: "2026-05-30T00:00:06Z",
|
|
},
|
|
{
|
|
role: "assistant",
|
|
text: "same done",
|
|
message_id: "router-fx-a-same-display",
|
|
timestamp: "2026-05-30T00:00:07Z",
|
|
model: "openrouter/shared-model",
|
|
usage: {
|
|
model: "openrouter/shared-model",
|
|
routed_model: "openrouter/shared-model",
|
|
routed_tier: "t1",
|
|
routing_source: "squilla_router",
|
|
routing_applied: true,
|
|
input_tokens: 8,
|
|
output_tokens: 2,
|
|
},
|
|
},
|
|
];
|
|
Router.navigate("/overview");
|
|
Router.navigate("/chat?session=agent:main:webchat:router-fx-panel");
|
|
});
|
|
await page.waitForFunction(
|
|
() =>
|
|
document.querySelector(".msg.user")?.textContent.includes(
|
|
"same model different provider"
|
|
) &&
|
|
document.querySelectorAll(".router-fx").length === 0,
|
|
{ timeout: 15000 }
|
|
);
|
|
const sameDisplaySingleCandidate = await snapshot(page);
|
|
|
|
const result = {
|
|
liveSettled,
|
|
reopened,
|
|
afterReplay,
|
|
narrow,
|
|
duringHistoryHydrationReplay,
|
|
afterHistoryHydrationReplay,
|
|
singleImageCandidate,
|
|
sameDisplaySingleCandidate,
|
|
pageErrors: errors,
|
|
};
|
|
await browser.close();
|
|
console.log(JSON.stringify(result));
|
|
})().catch(err => {
|
|
console.error(err && err.stack ? err.stack : String(err));
|
|
process.exit(1);
|
|
});
|
|
"""
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
env = os.environ.copy()
|
|
env["OPENSQUILLA_STATE_DIR"] = str(tmp_path / "state")
|
|
env["OPENSQUILLA_LOG_DIR"] = str(tmp_path / "logs")
|
|
server = subprocess.Popen(
|
|
[sys.executable, str(server_script)],
|
|
cwd=Path.cwd(),
|
|
env=env,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
)
|
|
try:
|
|
_wait_for_health(port, server)
|
|
_install_playwright(tmp_path)
|
|
result = subprocess.run(
|
|
[_node(), str(browser_script)],
|
|
cwd=tmp_path,
|
|
env=dict(env, TARGET_URL=f"http://127.0.0.1:{port}/control/"),
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=90,
|
|
)
|
|
assert result.returncode == 0, result.stderr or result.stdout
|
|
payload = json.loads(result.stdout.strip().splitlines()[-1])
|
|
finally:
|
|
_stop_process(server)
|
|
|
|
assert payload["pageErrors"] == [], payload["pageErrors"]
|
|
assert payload["liveSettled"]["state"] == "settled"
|
|
assert payload["liveSettled"]["renderMode"] == "live"
|
|
assert payload["liveSettled"]["liveCount"] == 0
|
|
assert payload["liveSettled"]["scanningCount"] == 0
|
|
assert payload["liveSettled"]["selectorVisible"] == 0
|
|
assert payload["liveSettled"]["winner"] == "deepseek-v4-flash"
|
|
assert "Router selected deepseek-v4-flash" in payload["liveSettled"]["aria"]
|
|
assert payload["liveSettled"]["cellCount"] == 3
|
|
assert set(payload["liveSettled"]["cellLabels"]) == {
|
|
"deepseek-v4-flash",
|
|
"gemini-3.1-flash-lite",
|
|
"qwen3.6-max",
|
|
}
|
|
assert "kimi-k2.6" not in payload["liveSettled"]["cellLabels"]
|
|
|
|
for key in ("reopened", "afterReplay", "narrow"):
|
|
snap = payload[key]
|
|
assert snap["count"] == 1, snap
|
|
assert snap["state"] == "settled", snap
|
|
assert snap["renderMode"] == "history", snap
|
|
assert snap["liveCount"] == 0, snap
|
|
assert snap["scanningCount"] == 0, snap
|
|
assert snap["selectorVisible"] == 0, snap
|
|
assert snap["bursts"] == 0, snap
|
|
assert snap["pinging"] == 0, snap
|
|
assert snap["animations"] == [], snap
|
|
assert snap["winner"] == "deepseek-v4-flash", snap
|
|
assert snap["hasLongLabel"] is True, snap
|
|
assert snap["cellCount"] == 3, snap
|
|
assert "kimi-k2.6" not in snap["cellLabels"], snap
|
|
assert snap["overflows"] == [], snap
|
|
assert snap["gridWithinViewport"] is True, snap
|
|
|
|
during = payload["duringHistoryHydrationReplay"]
|
|
assert during["count"] in (0, 1), during
|
|
if during["count"] == 1:
|
|
assert during["winners"] == ["deepseek-v4-flash"], during
|
|
assert during["liveCount"] == 0, during
|
|
assert during["scanningCount"] == 0, during
|
|
assert during["selectorVisible"] == 0, during
|
|
assert during["animations"] == [], during
|
|
|
|
after_hydration = payload["afterHistoryHydrationReplay"]
|
|
assert after_hydration["count"] == 2, after_hydration
|
|
assert after_hydration["renderMode"] == "history", after_hydration
|
|
assert after_hydration["liveCount"] == 0, after_hydration
|
|
assert after_hydration["scanningCount"] == 0, after_hydration
|
|
assert after_hydration["selectorVisible"] == 0, after_hydration
|
|
assert after_hydration["bursts"] == 0, after_hydration
|
|
assert after_hydration["pinging"] == 0, after_hydration
|
|
assert after_hydration["animations"] == [], after_hydration
|
|
assert after_hydration["overflows"] == [], after_hydration
|
|
assert set(after_hydration["winners"]) == {
|
|
"deepseek-v4-flash",
|
|
"gemini-3.1-flash-lite",
|
|
}, after_hydration
|
|
|
|
image_single = payload["singleImageCandidate"]
|
|
assert image_single["count"] == 0, image_single
|
|
assert image_single["cellCount"] == 0, image_single
|
|
assert image_single["liveCount"] == 0, image_single
|
|
assert image_single["scanningCount"] == 0, image_single
|
|
|
|
same_display_single = payload["sameDisplaySingleCandidate"]
|
|
assert same_display_single["count"] == 0, same_display_single
|
|
assert same_display_single["cellCount"] == 0, same_display_single
|
|
assert same_display_single["liveCount"] == 0, same_display_single
|
|
assert same_display_single["scanningCount"] == 0, same_display_single
|
|
|
|
|
|
def test_webchat_large_paste_auto_attaches_text_in_real_browser(tmp_path: Path) -> None:
|
|
if os.environ.get("OPENSQUILLA_WEBUI_BROWSER_CHAT_E2E") != "1":
|
|
pytest.skip("set OPENSQUILLA_WEBUI_BROWSER_CHAT_E2E=1 to run chat browser e2e")
|
|
|
|
port = _free_port()
|
|
server_script = tmp_path / "webui_large_paste_server.py"
|
|
browser_script = tmp_path / "webui_large_paste_browser.js"
|
|
server_script.write_text(
|
|
textwrap.dedent(
|
|
f"""
|
|
import uvicorn
|
|
|
|
from opensquilla.gateway.app import create_gateway_app
|
|
from opensquilla.gateway.config import AuthConfig, GatewayConfig
|
|
|
|
config = GatewayConfig(
|
|
host="127.0.0.1",
|
|
port={port},
|
|
auth=AuthConfig(mode="none"),
|
|
)
|
|
app = create_gateway_app(config)
|
|
|
|
if __name__ == "__main__":
|
|
uvicorn.run(app, host="127.0.0.1", port={port}, log_level="warning")
|
|
"""
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
browser_script.write_text(
|
|
textwrap.dedent(
|
|
r"""
|
|
const { chromium } = require("playwright");
|
|
|
|
async function waitRpc(page) {
|
|
await page.waitForFunction(
|
|
() =>
|
|
typeof App !== "undefined" &&
|
|
App.getRpc &&
|
|
App.getRpc()?.state === "connected",
|
|
{ timeout: 15000 }
|
|
);
|
|
}
|
|
|
|
(async () => {
|
|
const browser = await chromium.launch({ headless: true });
|
|
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
|
|
const errors = [];
|
|
page.on("pageerror", err => errors.push(String(err)));
|
|
|
|
await page.goto(process.env.TARGET_URL, {
|
|
waitUntil: "domcontentloaded",
|
|
timeout: 30000,
|
|
});
|
|
await page.waitForSelector("#chat-textarea", { timeout: 15000 });
|
|
await waitRpc(page);
|
|
|
|
await page.evaluate(() => {
|
|
window.__largePaste = { calls: [] };
|
|
const rpc = App.getRpc();
|
|
const originalCall = rpc.call.bind(rpc);
|
|
rpc.call = (method, params = {}) => {
|
|
if (method === "tools.search_provider") {
|
|
return Promise.resolve({ provider: "none" });
|
|
}
|
|
if (method === "config.get") {
|
|
return Promise.resolve({
|
|
permissions: { default_mode: "ask" },
|
|
squilla_router: { enabled: false, rollout_phase: "off", tiers: {} },
|
|
});
|
|
}
|
|
if (method === "chat.history") {
|
|
return Promise.resolve({
|
|
messages: [],
|
|
history_scope: "complete",
|
|
has_more: false,
|
|
});
|
|
}
|
|
if (method === "sessions.messages.subscribe") {
|
|
return Promise.resolve({
|
|
subscribed: true,
|
|
key: params.key,
|
|
current_stream_seq: 0,
|
|
replay_complete: true,
|
|
replayed_count: 0,
|
|
run_status: "idle",
|
|
});
|
|
}
|
|
if (method === "chat.send") {
|
|
window.__largePaste.calls.push({ method, params });
|
|
return Promise.resolve({ task_id: "large-paste-task" });
|
|
}
|
|
return originalCall(method, params);
|
|
};
|
|
});
|
|
|
|
await page.fill("#chat-textarea", "x".repeat(20000));
|
|
await page.click("#chat-btn-send");
|
|
await page.waitForFunction(
|
|
() => window.__largePaste?.calls?.length === 1,
|
|
null, { timeout: 5000 }
|
|
);
|
|
|
|
const payload = await page.evaluate(() => {
|
|
const call = window.__largePaste.calls[0];
|
|
const params = call.params;
|
|
const attachment = params.attachments?.[0] || {};
|
|
const attachmentData = String(attachment.data || "");
|
|
const inputProvenance = params.inputProvenance || {};
|
|
const inputNormalization = inputProvenance.input_normalization || {};
|
|
let decodedAttachmentText = "";
|
|
try {
|
|
const bytes = Uint8Array.from(atob(attachmentData), char =>
|
|
char.charCodeAt(0)
|
|
);
|
|
decodedAttachmentText = new TextDecoder().decode(bytes);
|
|
} catch (_err) {
|
|
decodedAttachmentText = "";
|
|
}
|
|
const userBubbleText = document.querySelector(".msg.user")?.textContent || "";
|
|
const attachmentText =
|
|
document.querySelector(".msg.user .msg-attachments")?.textContent || "";
|
|
const attachmentChip = document.querySelector(".msg.user .msg-file-chip");
|
|
return {
|
|
callMethod: call.method,
|
|
message: params.message,
|
|
messageLength: String(params.message || "").length,
|
|
attachmentCount: Array.isArray(params.attachments)
|
|
? params.attachments.length
|
|
: 0,
|
|
attachmentName: attachment.name || "",
|
|
attachmentMime: attachment.mime || attachment.type || "",
|
|
attachmentDataLength: attachmentData.length,
|
|
attachmentDataLooksBase64: /^[A-Za-z0-9+/]+={0,2}$/.test(attachmentData),
|
|
attachmentDecodedLength: decodedAttachmentText.length,
|
|
attachmentDecodedMatchesPaste: decodedAttachmentText === "x".repeat(20000),
|
|
inputProvenanceKind: inputProvenance.kind || "",
|
|
inputProvenanceSource: inputProvenance.source || "",
|
|
inputNormalizationGuardAction: inputNormalization.guard_action || "",
|
|
inputNormalizationOriginalChars: inputNormalization.original_chars || 0,
|
|
inputNormalizationMaterialEstimatedTokens:
|
|
inputNormalization.material_estimated_tokens || 0,
|
|
inputNormalizationGeneratedAttachmentCount:
|
|
inputNormalization.generated_attachment_count || 0,
|
|
bubbleHasPlaceholder: userBubbleText.includes(
|
|
"Please process the attached pasted text."
|
|
),
|
|
bubbleHasLongRawPaste: userBubbleText.includes("x".repeat(500)),
|
|
attachmentChipHasGeneratedName: attachmentText.includes("webchat-paste-"),
|
|
attachmentChipTag: attachmentChip?.tagName || "",
|
|
attachmentChipDownload: attachmentChip?.getAttribute("download") || "",
|
|
attachmentChipHref: attachmentChip?.getAttribute("href") || "",
|
|
};
|
|
});
|
|
await page.evaluate((attachmentName) => {
|
|
const rpc = App.getRpc();
|
|
rpc.call = (method, params = {}) => {
|
|
if (method === "tools.search_provider") {
|
|
return Promise.resolve({ provider: "none" });
|
|
}
|
|
if (method === "config.get") {
|
|
return Promise.resolve({
|
|
permissions: { default_mode: "ask" },
|
|
squilla_router: { enabled: false, rollout_phase: "off", tiers: {} },
|
|
});
|
|
}
|
|
if (method === "sessions.messages.subscribe") {
|
|
return Promise.resolve({
|
|
subscribed: true,
|
|
key: params.key,
|
|
current_stream_seq: 0,
|
|
replay_complete: true,
|
|
replayed_count: 0,
|
|
run_status: "idle",
|
|
});
|
|
}
|
|
if (method === "chat.history") {
|
|
return Promise.resolve({
|
|
messages: [{
|
|
role: "user",
|
|
text: "Please process the attached pasted text.",
|
|
timestamp: new Date().toISOString(),
|
|
attachments: [{
|
|
sha256_ref: "d".repeat(64),
|
|
name: attachmentName,
|
|
mime: "text/plain",
|
|
size: 20000,
|
|
download_url:
|
|
`/api/v1/attachments/${"d".repeat(64)}`
|
|
+ "?sessionKey=agent%3Amain%3Awebchat%3Atest"
|
|
+ `&name=${encodeURIComponent(attachmentName)}`
|
|
+ "&mime=text%2Fplain",
|
|
}],
|
|
}],
|
|
history_scope: "complete",
|
|
has_more: false,
|
|
});
|
|
}
|
|
return Promise.resolve({});
|
|
};
|
|
ChatView.destroy();
|
|
ChatView.render(document.getElementById("content"));
|
|
}, payload.attachmentName);
|
|
await page.waitForSelector(".msg.user .msg-file-chip[href^='/api/v1/attachments/']", {
|
|
timeout: 5000,
|
|
});
|
|
const historyPayload = await page.evaluate(() => {
|
|
const chip = document.querySelector(".msg.user .msg-file-chip");
|
|
return {
|
|
tag: chip?.tagName || "",
|
|
download: chip?.getAttribute("download") || "",
|
|
href: chip?.getAttribute("href") || "",
|
|
};
|
|
});
|
|
Object.assign(payload, {
|
|
historyAttachmentChipTag: historyPayload.tag,
|
|
historyAttachmentChipDownload: historyPayload.download,
|
|
historyAttachmentChipHref: historyPayload.href,
|
|
});
|
|
payload.pageErrors = errors;
|
|
|
|
await browser.close();
|
|
console.log(JSON.stringify(payload));
|
|
})().catch(err => {
|
|
console.error(err && err.stack ? err.stack : String(err));
|
|
process.exit(1);
|
|
});
|
|
"""
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
env = os.environ.copy()
|
|
env["OPENSQUILLA_STATE_DIR"] = str(tmp_path / "state")
|
|
env["OPENSQUILLA_LOG_DIR"] = str(tmp_path / "logs")
|
|
server = subprocess.Popen(
|
|
[sys.executable, str(server_script)],
|
|
cwd=Path.cwd(),
|
|
env=env,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
)
|
|
try:
|
|
_wait_for_health(port, server)
|
|
_install_playwright(tmp_path)
|
|
result = subprocess.run(
|
|
[_node(), str(browser_script)],
|
|
cwd=tmp_path,
|
|
env=dict(env, TARGET_URL=f"http://127.0.0.1:{port}/control/chat"),
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=90,
|
|
)
|
|
assert result.returncode == 0, result.stderr or result.stdout
|
|
payload = json.loads(result.stdout.strip().splitlines()[-1])
|
|
finally:
|
|
_stop_process(server)
|
|
|
|
assert payload["callMethod"] == "chat.send", payload
|
|
assert payload["message"] == "Please process the attached pasted text.", payload
|
|
assert payload["messageLength"] < 20000, payload
|
|
assert payload["attachmentCount"] == 1, payload
|
|
assert payload["attachmentName"].startswith("webchat-paste-"), payload
|
|
assert payload["attachmentName"].endswith(".txt"), payload
|
|
assert payload["attachmentMime"] == "text/plain", payload
|
|
assert payload["attachmentDataLength"] > 1000, payload
|
|
assert payload["attachmentDataLooksBase64"] is True, payload
|
|
assert payload["attachmentDecodedLength"] == 20000, payload
|
|
assert payload["attachmentDecodedMatchesPaste"] is True, payload
|
|
assert payload["inputProvenanceKind"] == "web_message", payload
|
|
assert payload["inputProvenanceSource"] == "WebChat", payload
|
|
assert payload["inputNormalizationGuardAction"] == "generated_text_attachment", payload
|
|
assert payload["inputNormalizationOriginalChars"] == 20000, payload
|
|
assert payload["inputNormalizationMaterialEstimatedTokens"] == 5000, payload
|
|
assert payload["inputNormalizationGeneratedAttachmentCount"] == 1, payload
|
|
assert payload["bubbleHasPlaceholder"] is True, payload
|
|
assert payload["bubbleHasLongRawPaste"] is False, payload
|
|
assert payload["attachmentChipHasGeneratedName"] is True, payload
|
|
assert payload["attachmentChipTag"] == "A", payload
|
|
assert payload["attachmentChipDownload"].startswith("webchat-paste-"), payload
|
|
assert payload["attachmentChipHref"].startswith("data:text/plain;base64,"), payload
|
|
assert payload["historyAttachmentChipTag"] == "A", payload
|
|
assert payload["historyAttachmentChipDownload"].startswith("webchat-paste-"), payload
|
|
assert payload["historyAttachmentChipHref"].startswith("/api/v1/attachments/"), payload
|
|
assert payload["pageErrors"] == [], payload
|
|
|
|
|
|
def test_image_paste_consumes_attachment_without_inserting_wsl_path_text_in_real_browser(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
if os.environ.get("OPENSQUILLA_WEBUI_BROWSER_CHAT_E2E") != "1":
|
|
pytest.skip("set OPENSQUILLA_WEBUI_BROWSER_CHAT_E2E=1 to run chat browser e2e")
|
|
|
|
port = _free_port()
|
|
server_script = tmp_path / "webui_image_paste_server.py"
|
|
browser_script = tmp_path / "webui_image_paste_browser.js"
|
|
server_script.write_text(
|
|
textwrap.dedent(
|
|
f"""
|
|
import uvicorn
|
|
|
|
from opensquilla.gateway.app import create_gateway_app
|
|
from opensquilla.gateway.config import AuthConfig, GatewayConfig
|
|
|
|
config = GatewayConfig(
|
|
host="127.0.0.1",
|
|
port={port},
|
|
auth=AuthConfig(mode="none"),
|
|
)
|
|
app = create_gateway_app(config)
|
|
|
|
if __name__ == "__main__":
|
|
uvicorn.run(app, host="127.0.0.1", port={port}, log_level="warning")
|
|
"""
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
browser_script.write_text(
|
|
textwrap.dedent(
|
|
r"""
|
|
const { chromium } = require("playwright");
|
|
|
|
async function waitRpc(page) {
|
|
await page.waitForFunction(
|
|
() =>
|
|
typeof App !== "undefined" &&
|
|
App.getRpc &&
|
|
App.getRpc()?.state === "connected",
|
|
{ timeout: 15000 }
|
|
);
|
|
}
|
|
|
|
async function dispatchSyntheticPaste(page, { includeImage, text }) {
|
|
return await page.evaluate(async ({ includeImage, text }) => {
|
|
const textarea = document.querySelector("#chat-textarea");
|
|
const items = [];
|
|
if (includeImage) {
|
|
const b64 =
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lHE7+QAAAABJRU5ErkJggg==";
|
|
const bytes = Uint8Array.from(atob(b64), ch => ch.charCodeAt(0));
|
|
const file = new File([bytes], "361d381f.png", { type: "image/png" });
|
|
items.push({
|
|
type: "image/png",
|
|
getAsFile: () => file,
|
|
});
|
|
}
|
|
items.push({
|
|
type: "text/plain",
|
|
getAsFile: () => null,
|
|
});
|
|
const event = new Event("paste", { bubbles: true, cancelable: true });
|
|
Object.defineProperty(event, "clipboardData", {
|
|
value: {
|
|
items,
|
|
getData: type => (type === "text/plain" ? text : ""),
|
|
},
|
|
});
|
|
document.dispatchEvent(event);
|
|
if (!event.defaultPrevented) {
|
|
textarea.value += text;
|
|
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
|
}
|
|
await new Promise(resolve => setTimeout(resolve, 250));
|
|
return {
|
|
defaultPrevented: event.defaultPrevented,
|
|
textareaValue: textarea.value,
|
|
previewHidden: document
|
|
.querySelector("#chat-attach-preview")
|
|
?.classList.contains("hidden"),
|
|
attachmentThumbs: document
|
|
.querySelectorAll("#chat-attach-preview .attachment-thumb")
|
|
.length,
|
|
attachmentChips: document
|
|
.querySelectorAll("#chat-attach-preview .attachment-chip")
|
|
.length,
|
|
};
|
|
}, { includeImage, text });
|
|
}
|
|
|
|
(async () => {
|
|
const browser = await chromium.launch({ headless: true });
|
|
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
|
|
const errors = [];
|
|
page.on("pageerror", err => errors.push(String(err)));
|
|
|
|
await page.goto(process.env.TARGET_URL, {
|
|
waitUntil: "domcontentloaded",
|
|
timeout: 30000,
|
|
});
|
|
await page.waitForSelector("#chat-textarea", { timeout: 15000 });
|
|
await waitRpc(page);
|
|
|
|
const wslPath = "/tmp/.wsl-screenshot-cli/"
|
|
+ "361d381f306061eb91d29b48ca876da8ab0ee3374a57bcfaa9212c3b7105858.png";
|
|
const imagePaste = await dispatchSyntheticPaste(page, {
|
|
includeImage: true,
|
|
text: wslPath,
|
|
});
|
|
|
|
await page.locator(".attachment-remove").click();
|
|
await page.fill("#chat-textarea", "");
|
|
|
|
const textOnlyPaste = await dispatchSyntheticPaste(page, {
|
|
includeImage: false,
|
|
text: wslPath,
|
|
});
|
|
|
|
await browser.close();
|
|
console.log(JSON.stringify({
|
|
imagePaste,
|
|
textOnlyPaste,
|
|
pageErrors: errors,
|
|
}));
|
|
})().catch(err => {
|
|
console.error(err && err.stack ? err.stack : String(err));
|
|
process.exit(1);
|
|
});
|
|
"""
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
env = os.environ.copy()
|
|
env["OPENSQUILLA_STATE_DIR"] = str(tmp_path / "state")
|
|
env["OPENSQUILLA_LOG_DIR"] = str(tmp_path / "logs")
|
|
server = subprocess.Popen(
|
|
[sys.executable, str(server_script)],
|
|
cwd=Path.cwd(),
|
|
env=env,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
)
|
|
try:
|
|
_wait_for_health(port, server)
|
|
_install_playwright(tmp_path)
|
|
result = subprocess.run(
|
|
[_node(), str(browser_script)],
|
|
cwd=tmp_path,
|
|
env=dict(env, TARGET_URL=f"http://127.0.0.1:{port}/control/chat"),
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=90,
|
|
)
|
|
assert result.returncode == 0, result.stderr or result.stdout
|
|
payload = json.loads(result.stdout.strip().splitlines()[-1])
|
|
finally:
|
|
_stop_process(server)
|
|
|
|
assert payload["imagePaste"]["defaultPrevented"] is True, payload
|
|
assert payload["imagePaste"]["textareaValue"] == "", payload
|
|
assert payload["imagePaste"]["previewHidden"] is False, payload
|
|
assert payload["imagePaste"]["attachmentThumbs"] == 1, payload
|
|
assert payload["textOnlyPaste"]["defaultPrevented"] is False, payload
|
|
assert payload["textOnlyPaste"]["textareaValue"].startswith(
|
|
"/tmp/.wsl-screenshot-cli/"
|
|
), payload
|
|
assert payload["pageErrors"] == [], payload
|
|
|
|
|
|
def test_chat_image_attachment_layout_stays_aligned_in_real_browser(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
if os.environ.get("OPENSQUILLA_WEBUI_BROWSER_CHAT_E2E") != "1":
|
|
pytest.skip("set OPENSQUILLA_WEBUI_BROWSER_CHAT_E2E=1 to run chat browser e2e")
|
|
|
|
port = _free_port()
|
|
server_script = tmp_path / "webui_image_attachment_layout_server.py"
|
|
browser_script = tmp_path / "webui_image_attachment_layout_browser.js"
|
|
server_script.write_text(
|
|
textwrap.dedent(
|
|
f"""
|
|
import uvicorn
|
|
|
|
from opensquilla.gateway.app import create_gateway_app
|
|
from opensquilla.gateway.config import AuthConfig, GatewayConfig
|
|
|
|
config = GatewayConfig(
|
|
host="127.0.0.1",
|
|
port={port},
|
|
auth=AuthConfig(mode="none"),
|
|
)
|
|
app = create_gateway_app(config)
|
|
|
|
if __name__ == "__main__":
|
|
uvicorn.run(app, host="127.0.0.1", port={port}, log_level="warning")
|
|
"""
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
browser_script.write_text(
|
|
textwrap.dedent(
|
|
r"""
|
|
const { chromium } = require("playwright");
|
|
|
|
async function waitRpc(page) {
|
|
await page.waitForFunction(
|
|
() =>
|
|
typeof App !== "undefined" &&
|
|
App.getRpc &&
|
|
App.getRpc()?.state === "connected",
|
|
{ timeout: 15000 }
|
|
);
|
|
}
|
|
|
|
async function installMocks(page) {
|
|
await page.evaluate(() => {
|
|
window.__attachmentLayout = { sends: [] };
|
|
const rpc = App.getRpc();
|
|
const originalCall = rpc.call.bind(rpc);
|
|
rpc.call = (method, params = {}) => {
|
|
if (method === "tools.search_provider") {
|
|
return Promise.resolve({ provider: "none" });
|
|
}
|
|
if (method === "config.get") {
|
|
return Promise.resolve({
|
|
permissions: { default_mode: "ask" },
|
|
squilla_router: { enabled: false, rollout_phase: "off", tiers: {} },
|
|
});
|
|
}
|
|
if (method === "chat.history") {
|
|
return Promise.resolve({
|
|
messages: [],
|
|
history_scope: "complete",
|
|
has_more: false,
|
|
});
|
|
}
|
|
if (method === "sessions.messages.subscribe") {
|
|
return Promise.resolve({
|
|
subscribed: true,
|
|
key: params.key,
|
|
current_stream_seq: 0,
|
|
replay_complete: true,
|
|
replayed_count: 0,
|
|
run_status: "idle",
|
|
});
|
|
}
|
|
if (method === "chat.send") {
|
|
window.__attachmentLayout.sends.push(params);
|
|
return Promise.resolve({ task_id: "attachment-layout-task" });
|
|
}
|
|
return originalCall(method, params);
|
|
};
|
|
});
|
|
}
|
|
|
|
async function pasteWideImage(page) {
|
|
await page.evaluate(async () => {
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = 1600;
|
|
canvas.height = 120;
|
|
const ctx = canvas.getContext("2d");
|
|
ctx.fillStyle = "#101014";
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
ctx.fillStyle = "#ff6a00";
|
|
ctx.fillRect(0, 54, canvas.width, 12);
|
|
ctx.fillStyle = "#ffffff";
|
|
ctx.font = "28px sans-serif";
|
|
ctx.fillText("ultra wide screenshot", 40, 48);
|
|
const blob = await new Promise(resolve => canvas.toBlob(resolve, "image/png"));
|
|
const file = new File([blob], "wide-screenshot.png", { type: "image/png" });
|
|
const event = new Event("paste", { bubbles: true, cancelable: true });
|
|
Object.defineProperty(event, "clipboardData", {
|
|
value: {
|
|
items: [
|
|
{ type: "image/png", getAsFile: () => file },
|
|
],
|
|
getData: () => "",
|
|
},
|
|
});
|
|
document.dispatchEvent(event);
|
|
});
|
|
await page.waitForSelector("#chat-attach-preview:not(.hidden) .attachment-thumb", {
|
|
timeout: 5000,
|
|
});
|
|
}
|
|
|
|
async function measure(page, viewport) {
|
|
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
|
await page.goto(process.env.TARGET_URL, {
|
|
waitUntil: "domcontentloaded",
|
|
timeout: 30000,
|
|
});
|
|
await waitRpc(page);
|
|
await installMocks(page);
|
|
await page.evaluate(() =>
|
|
Router.navigate("/chat?session=agent:main:webchat:attachment-layout")
|
|
);
|
|
await page.waitForSelector("#chat-textarea", { timeout: 15000 });
|
|
await pasteWideImage(page);
|
|
const pending = await page.evaluate(() => {
|
|
const box = (selector) => {
|
|
const el = document.querySelector(selector);
|
|
const r = el.getBoundingClientRect();
|
|
return {
|
|
left: r.left,
|
|
right: r.right,
|
|
top: r.top,
|
|
bottom: r.bottom,
|
|
width: r.width,
|
|
height: r.height,
|
|
visible:
|
|
r.width > 0 &&
|
|
r.height > 0 &&
|
|
getComputedStyle(el).display !== "none" &&
|
|
getComputedStyle(el).visibility !== "hidden",
|
|
};
|
|
};
|
|
return {
|
|
width: window.innerWidth,
|
|
scrollWidth: document.documentElement.scrollWidth,
|
|
preview: box("#chat-attach-preview"),
|
|
input: box(".chat-input-bar"),
|
|
composer: box("#chat-composer"),
|
|
thumb: box("#chat-attach-preview .attachment-thumb"),
|
|
remove: box("#chat-attach-preview .attachment-remove"),
|
|
};
|
|
});
|
|
|
|
await page.locator("#chat-textarea").fill("这是什么");
|
|
await page.locator("#chat-btn-send").click();
|
|
await page.waitForFunction(
|
|
() => window.__attachmentLayout?.sends?.length === 1,
|
|
{ timeout: 5000 }
|
|
);
|
|
await page.waitForSelector(".msg.user .msg-thumb", { timeout: 5000 });
|
|
const sent = await page.evaluate(() => {
|
|
const box = (selector) => {
|
|
const el = document.querySelector(selector);
|
|
const r = el.getBoundingClientRect();
|
|
return {
|
|
left: r.left,
|
|
right: r.right,
|
|
top: r.top,
|
|
bottom: r.bottom,
|
|
width: r.width,
|
|
height: r.height,
|
|
visible:
|
|
r.width > 0 &&
|
|
r.height > 0 &&
|
|
getComputedStyle(el).display !== "none" &&
|
|
getComputedStyle(el).visibility !== "hidden",
|
|
};
|
|
};
|
|
return {
|
|
width: window.innerWidth,
|
|
scrollWidth: document.documentElement.scrollWidth,
|
|
body: box(".msg.user .msg-body--has-attachments"),
|
|
text: box(".msg.user .msg-attachment-text"),
|
|
attachments: box(".msg.user .msg-attachments"),
|
|
thumb: box(".msg.user .msg-thumb"),
|
|
sendAttachmentCount: window.__attachmentLayout.sends[0].attachments?.length || 0,
|
|
sendMessage: window.__attachmentLayout.sends[0].message || "",
|
|
};
|
|
});
|
|
return { name: viewport.name, pending, sent };
|
|
}
|
|
|
|
(async () => {
|
|
const browser = await chromium.launch({ headless: true });
|
|
const page = await browser.newPage();
|
|
const errors = [];
|
|
page.on("pageerror", err => errors.push(String(err)));
|
|
const viewports = [
|
|
{ name: "desktop", width: 1365, height: 768 },
|
|
{ name: "wide", width: 2048, height: 900 },
|
|
{ name: "mobile", width: 390, height: 844 },
|
|
];
|
|
const layouts = [];
|
|
for (const viewport of viewports) layouts.push(await measure(page, viewport));
|
|
await browser.close();
|
|
console.log(JSON.stringify({ layouts, pageErrors: errors }));
|
|
})().catch(err => {
|
|
console.error(err && err.stack ? err.stack : String(err));
|
|
process.exit(1);
|
|
});
|
|
"""
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
env = os.environ.copy()
|
|
env["OPENSQUILLA_STATE_DIR"] = str(tmp_path / "state")
|
|
env["OPENSQUILLA_LOG_DIR"] = str(tmp_path / "logs")
|
|
server = subprocess.Popen(
|
|
[sys.executable, str(server_script)],
|
|
cwd=Path.cwd(),
|
|
env=env,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
)
|
|
try:
|
|
_wait_for_health(port, server)
|
|
_install_playwright(tmp_path)
|
|
result = subprocess.run(
|
|
[_node(), str(browser_script)],
|
|
cwd=tmp_path,
|
|
env=dict(env, TARGET_URL=f"http://127.0.0.1:{port}/control/chat"),
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=120,
|
|
)
|
|
assert result.returncode == 0, result.stderr or result.stdout
|
|
payload = json.loads(result.stdout.strip().splitlines()[-1])
|
|
finally:
|
|
_stop_process(server)
|
|
|
|
assert payload["pageErrors"] == []
|
|
layouts = {item["name"]: item for item in payload["layouts"]}
|
|
for name, item in layouts.items():
|
|
pending = item["pending"]
|
|
sent = item["sent"]
|
|
assert pending["scrollWidth"] <= pending["width"], (name, pending)
|
|
assert sent["scrollWidth"] <= sent["width"], (name, sent)
|
|
assert pending["preview"]["visible"], (name, pending)
|
|
assert pending["remove"]["visible"], (name, pending)
|
|
assert pending["preview"]["left"] >= 0, (name, pending)
|
|
assert pending["preview"]["right"] <= pending["width"], (name, pending)
|
|
assert pending["thumb"]["right"] <= pending["preview"]["right"], (name, pending)
|
|
assert sent["sendAttachmentCount"] == 1, (name, sent)
|
|
assert sent["sendMessage"] == "这是什么", (name, sent)
|
|
assert sent["text"]["right"] <= sent["body"]["right"] + 1, (name, sent)
|
|
assert sent["thumb"]["width"] <= 280, (name, sent)
|
|
assert sent["thumb"]["height"] <= 160, (name, sent)
|
|
assert sent["thumb"]["right"] <= sent["width"], (name, sent)
|
|
assert sent["attachments"]["right"] <= sent["body"]["right"] + 1, (name, sent)
|
|
if name == "mobile":
|
|
assert pending["preview"]["left"] >= pending["composer"]["left"], (name, pending)
|
|
assert pending["preview"]["right"] <= pending["composer"]["right"], (name, pending)
|
|
else:
|
|
assert abs(pending["preview"]["left"] - pending["input"]["left"]) <= 1, (
|
|
name,
|
|
pending,
|
|
)
|
|
assert abs(pending["preview"]["right"] - pending["input"]["right"]) <= 1, (
|
|
name,
|
|
pending,
|
|
)
|
|
|
|
|
|
def test_completed_reconnect_without_replay_refreshes_history_in_real_browser(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
if os.environ.get("OPENSQUILLA_WEBUI_BROWSER_CHAT_E2E") != "1":
|
|
pytest.skip("set OPENSQUILLA_WEBUI_BROWSER_CHAT_E2E=1 to run chat browser e2e")
|
|
|
|
port = _free_port()
|
|
server_script = tmp_path / "webui_completed_reconnect_server.py"
|
|
browser_script = tmp_path / "webui_completed_reconnect_browser.js"
|
|
server_script.write_text(
|
|
textwrap.dedent(
|
|
f"""
|
|
import uvicorn
|
|
|
|
from opensquilla.gateway.app import create_gateway_app
|
|
from opensquilla.gateway.config import AuthConfig, GatewayConfig
|
|
|
|
config = GatewayConfig(
|
|
host="127.0.0.1",
|
|
port={port},
|
|
auth=AuthConfig(mode="none"),
|
|
)
|
|
app = create_gateway_app(config)
|
|
|
|
if __name__ == "__main__":
|
|
uvicorn.run(app, host="127.0.0.1", port={port}, log_level="warning")
|
|
"""
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
browser_script.write_text(
|
|
textwrap.dedent(
|
|
r"""
|
|
const { chromium } = require("playwright");
|
|
|
|
async function waitRpc(page) {
|
|
await page.waitForFunction(
|
|
() =>
|
|
typeof App !== "undefined" &&
|
|
App.getRpc &&
|
|
App.getRpc()?.state === "connected",
|
|
{ timeout: 15000 }
|
|
);
|
|
}
|
|
|
|
(async () => {
|
|
const browser = await chromium.launch({ headless: true });
|
|
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
|
|
const errors = [];
|
|
page.on("pageerror", err => errors.push(String(err)));
|
|
|
|
await page.goto(process.env.TARGET_URL, {
|
|
waitUntil: "domcontentloaded",
|
|
timeout: 30000,
|
|
});
|
|
await waitRpc(page);
|
|
|
|
await page.evaluate(() => {
|
|
window.__completedReconnectTest = {
|
|
sessionKey: "agent:main:webchat:completed-reconnect",
|
|
subscribeCalls: 0,
|
|
historyCalls: 0,
|
|
historyMessages: [
|
|
{
|
|
role: "user",
|
|
text: "late single chunk",
|
|
message_id: "completed-reconnect-u1",
|
|
timestamp: "2026-05-31T00:38:14Z",
|
|
},
|
|
],
|
|
};
|
|
const rpc = App.getRpc();
|
|
const originalCall = rpc.call.bind(rpc);
|
|
rpc.call = (method, params = {}) => {
|
|
if (method === "tools.search_provider") {
|
|
return Promise.resolve({ provider: "none" });
|
|
}
|
|
if (method === "config.get") {
|
|
return Promise.resolve({
|
|
permissions: { default_mode: "ask" },
|
|
squilla_router: {
|
|
enabled: true,
|
|
rollout_phase: "full",
|
|
tiers: {
|
|
c1: { model: "openrouter/deepseek-v4-flash" },
|
|
},
|
|
},
|
|
});
|
|
}
|
|
if (method === "chat.history") {
|
|
window.__completedReconnectTest.historyCalls += 1;
|
|
return Promise.resolve({
|
|
messages: window.__completedReconnectTest.historyMessages,
|
|
history_scope: "complete",
|
|
has_more: false,
|
|
});
|
|
}
|
|
if (method === "sessions.messages.subscribe") {
|
|
window.__completedReconnectTest.subscribeCalls += 1;
|
|
return new Promise(resolve => {
|
|
setTimeout(() => {
|
|
window.__completedReconnectTest.historyMessages = [
|
|
{
|
|
role: "user",
|
|
text: "late single chunk",
|
|
message_id: "completed-reconnect-u1",
|
|
timestamp: "2026-05-31T00:38:14Z",
|
|
},
|
|
{
|
|
role: "assistant",
|
|
text:
|
|
"未能解析回复:\n - field 'intent': " +
|
|
"'不要用这个skill实施' not in choices",
|
|
message_id: "completed-reconnect-a1",
|
|
timestamp: "2026-05-31T00:38:28Z",
|
|
model: "openrouter/deepseek-v4-flash",
|
|
usage: {
|
|
model: "openrouter/deepseek-v4-flash",
|
|
routed_model: "openrouter/deepseek-v4-flash",
|
|
routed_tier: "c1",
|
|
routing_source: "squilla_router",
|
|
routing_applied: true,
|
|
input_tokens: 0,
|
|
output_tokens: 0,
|
|
},
|
|
},
|
|
];
|
|
resolve({
|
|
subscribed: true,
|
|
key: params.key,
|
|
current_stream_seq: 0,
|
|
replay_complete: true,
|
|
replayed_count: 0,
|
|
run_status: "idle",
|
|
last_task: {
|
|
task_id: "completed-reconnect-task",
|
|
status: "succeeded",
|
|
terminal_reason: "completed",
|
|
},
|
|
});
|
|
}, 800);
|
|
});
|
|
}
|
|
return originalCall(method, params);
|
|
};
|
|
});
|
|
|
|
await page.evaluate(() =>
|
|
Router.navigate("/chat?session=agent:main:webchat:completed-reconnect")
|
|
);
|
|
await page.waitForSelector("#chat-textarea", { timeout: 15000 });
|
|
await page.waitForFunction(
|
|
() =>
|
|
document.querySelector(".msg.user")?.textContent.includes(
|
|
"late single chunk"
|
|
),
|
|
null, { timeout: 5000 }
|
|
);
|
|
const userOnly = await page.evaluate(() => ({
|
|
assistantHasReply: Array.from(document.querySelectorAll(".msg.assistant"))
|
|
.some(el => el.textContent.includes("未能解析回复")),
|
|
historyCalls: window.__completedReconnectTest.historyCalls,
|
|
subscribeCalls: window.__completedReconnectTest.subscribeCalls,
|
|
}));
|
|
|
|
await page.waitForFunction(
|
|
() => Array.from(document.querySelectorAll(".msg.assistant"))
|
|
.some(el => el.textContent.includes("未能解析回复")),
|
|
null, { timeout: 5000 }
|
|
);
|
|
const afterTerminalSubscribe = await page.evaluate(() => ({
|
|
assistantHasReply: Array.from(document.querySelectorAll(".msg.assistant"))
|
|
.some(el => el.textContent.includes("未能解析回复")),
|
|
thinkingCount: document.querySelectorAll(".msg.thinking").length,
|
|
streamingCount: document.querySelectorAll(".msg.streaming").length,
|
|
routerWinner: document.querySelector(".router-fx-cell.win .nm")?.textContent || "",
|
|
historyCalls: window.__completedReconnectTest.historyCalls,
|
|
subscribeCalls: window.__completedReconnectTest.subscribeCalls,
|
|
}));
|
|
|
|
await browser.close();
|
|
console.log(JSON.stringify({
|
|
userOnly,
|
|
afterTerminalSubscribe: {
|
|
...afterTerminalSubscribe,
|
|
pageErrors: errors,
|
|
},
|
|
}));
|
|
})().catch(err => {
|
|
console.error(err && err.stack ? err.stack : String(err));
|
|
process.exit(1);
|
|
});
|
|
"""
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
env = os.environ.copy()
|
|
env["OPENSQUILLA_STATE_DIR"] = str(tmp_path / "state")
|
|
env["OPENSQUILLA_LOG_DIR"] = str(tmp_path / "logs")
|
|
server = subprocess.Popen(
|
|
[sys.executable, str(server_script)],
|
|
cwd=Path.cwd(),
|
|
env=env,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
)
|
|
try:
|
|
_wait_for_health(port, server)
|
|
_install_playwright(tmp_path)
|
|
result = subprocess.run(
|
|
[_node(), str(browser_script)],
|
|
cwd=tmp_path,
|
|
env=dict(env, TARGET_URL=f"http://127.0.0.1:{port}/control/"),
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=90,
|
|
)
|
|
assert result.returncode == 0, result.stderr or result.stdout
|
|
payload = json.loads(result.stdout.strip().splitlines()[-1])
|
|
finally:
|
|
_stop_process(server)
|
|
|
|
assert payload["userOnly"]["assistantHasReply"] is False, payload
|
|
after = payload["afterTerminalSubscribe"]
|
|
assert after["assistantHasReply"] is True, after
|
|
assert after["thinkingCount"] == 0, after
|
|
assert after["streamingCount"] == 0, after
|
|
assert after["routerWinner"] == "deepseek-v4-flash", after
|
|
assert after["historyCalls"] >= 2, after
|
|
assert after["pageErrors"] == [], after
|
|
|
|
|
|
def test_replayed_savings_done_restores_reply_without_replaying_popup_in_real_browser(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
if os.environ.get("OPENSQUILLA_WEBUI_BROWSER_CHAT_E2E") != "1":
|
|
pytest.skip("set OPENSQUILLA_WEBUI_BROWSER_CHAT_E2E=1 to run chat browser e2e")
|
|
|
|
port = _free_port()
|
|
server_script = tmp_path / "webui_savings_replay_server.py"
|
|
browser_script = tmp_path / "webui_savings_replay_browser.js"
|
|
server_script.write_text(
|
|
textwrap.dedent(
|
|
f"""
|
|
import uvicorn
|
|
|
|
from opensquilla.gateway.app import create_gateway_app
|
|
from opensquilla.gateway.config import AuthConfig, GatewayConfig
|
|
|
|
config = GatewayConfig(
|
|
host="127.0.0.1",
|
|
port={port},
|
|
auth=AuthConfig(mode="none"),
|
|
)
|
|
app = create_gateway_app(config)
|
|
|
|
if __name__ == "__main__":
|
|
uvicorn.run(app, host="127.0.0.1", port={port}, log_level="warning")
|
|
"""
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
browser_script.write_text(
|
|
textwrap.dedent(
|
|
r"""
|
|
const { chromium } = require("playwright");
|
|
|
|
async function waitRpc(page) {
|
|
await page.waitForFunction(
|
|
() =>
|
|
typeof App !== "undefined" &&
|
|
App.getRpc &&
|
|
App.getRpc()?.state === "connected",
|
|
{ timeout: 15000 }
|
|
);
|
|
}
|
|
|
|
(async () => {
|
|
const browser = await chromium.launch({ headless: true });
|
|
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
|
|
const errors = [];
|
|
page.on("pageerror", err => errors.push(String(err)));
|
|
|
|
await page.goto(process.env.TARGET_URL, {
|
|
waitUntil: "domcontentloaded",
|
|
timeout: 30000,
|
|
});
|
|
await waitRpc(page);
|
|
|
|
await page.evaluate(() => {
|
|
window.__savingsReplayTest = {
|
|
fireCalls: 0,
|
|
sessionKey: "agent:main:webchat:savings-replay",
|
|
};
|
|
const originalFire = window.SavingsFX.fire.bind(window.SavingsFX);
|
|
window.SavingsFX.fire = (...args) => {
|
|
window.__savingsReplayTest.fireCalls += 1;
|
|
return originalFire(...args);
|
|
};
|
|
const rpc = App.getRpc();
|
|
const originalCall = rpc.call.bind(rpc);
|
|
rpc.call = (method, params = {}) => {
|
|
if (method === "tools.search_provider") {
|
|
return Promise.resolve({ provider: "none" });
|
|
}
|
|
if (method === "config.get") {
|
|
return Promise.resolve({
|
|
permissions: { default_mode: "ask" },
|
|
squilla_router: {
|
|
enabled: true,
|
|
rollout_phase: "full",
|
|
tiers: {
|
|
c1: { model: "openrouter/deepseek-v4-flash" },
|
|
},
|
|
},
|
|
});
|
|
}
|
|
if (method === "chat.history") {
|
|
return Promise.resolve({
|
|
messages: [],
|
|
history_scope: "complete",
|
|
has_more: false,
|
|
});
|
|
}
|
|
if (method === "sessions.messages.subscribe") {
|
|
return Promise.resolve({
|
|
subscribed: true,
|
|
key: params.key,
|
|
current_stream_seq: 0,
|
|
replay_complete: true,
|
|
replayed_count: 0,
|
|
run_status: "idle",
|
|
});
|
|
}
|
|
return originalCall(method, params);
|
|
};
|
|
});
|
|
|
|
await page.evaluate(() =>
|
|
Router.navigate("/chat?session=agent:main:webchat:savings-replay")
|
|
);
|
|
await page.waitForSelector("#chat-textarea", { timeout: 15000 });
|
|
|
|
await page.evaluate(() => {
|
|
const rpc = App.getRpc();
|
|
const ls = rpc._listeners;
|
|
const sessionKey = window.__savingsReplayTest.sessionKey;
|
|
const deltaHandlers = ls.get("session.event.text_delta");
|
|
if (deltaHandlers) {
|
|
deltaHandlers.forEach(h => h({
|
|
session_key: sessionKey,
|
|
stream_seq: 41,
|
|
text: "replayed savings answer",
|
|
}));
|
|
}
|
|
const wildHandlers = ls.get("*");
|
|
if (wildHandlers) {
|
|
wildHandlers.forEach(h => h("session.event.done", {
|
|
session_key: sessionKey,
|
|
stream_seq: 42,
|
|
text: "replayed savings answer",
|
|
input_tokens: 17800,
|
|
output_tokens: 661,
|
|
model: "openrouter/deepseek-v4-flash",
|
|
routed_model: "openrouter/deepseek-v4-flash",
|
|
routed_tier: "c1",
|
|
routing_source: "squilla_router",
|
|
routing_applied: true,
|
|
total_savings_pct: 97,
|
|
total_savings_usd: 0.01,
|
|
}, { replayed: true }));
|
|
}
|
|
});
|
|
|
|
await page.waitForFunction(
|
|
() => Array.from(document.querySelectorAll(".msg.assistant"))
|
|
.some(el => el.textContent.includes("replayed savings answer")),
|
|
null, { timeout: 5000 }
|
|
);
|
|
await page.waitForTimeout(300);
|
|
const payload = await page.evaluate(() => ({
|
|
assistantHasReply: Array.from(document.querySelectorAll(".msg.assistant"))
|
|
.some(el => el.textContent.includes("replayed savings answer")),
|
|
savedChipVisible: Array.from(document.querySelectorAll(".msg-meta__saved"))
|
|
.some(el => el.textContent.includes("Saved")),
|
|
fireCalls: window.__savingsReplayTest.fireCalls,
|
|
floatingLabels: document.querySelectorAll(".savings-float").length,
|
|
}));
|
|
|
|
await browser.close();
|
|
console.log(JSON.stringify({ ...payload, pageErrors: errors }));
|
|
})().catch(err => {
|
|
console.error(err && err.stack ? err.stack : String(err));
|
|
process.exit(1);
|
|
});
|
|
"""
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
env = os.environ.copy()
|
|
env["OPENSQUILLA_STATE_DIR"] = str(tmp_path / "state")
|
|
env["OPENSQUILLA_LOG_DIR"] = str(tmp_path / "logs")
|
|
server = subprocess.Popen(
|
|
[sys.executable, str(server_script)],
|
|
cwd=Path.cwd(),
|
|
env=env,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
)
|
|
try:
|
|
_wait_for_health(port, server)
|
|
_install_playwright(tmp_path)
|
|
result = subprocess.run(
|
|
[_node(), str(browser_script)],
|
|
cwd=tmp_path,
|
|
env=dict(env, TARGET_URL=f"http://127.0.0.1:{port}/control/"),
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=90,
|
|
)
|
|
assert result.returncode == 0, result.stderr or result.stdout
|
|
payload = json.loads(result.stdout.strip().splitlines()[-1])
|
|
finally:
|
|
_stop_process(server)
|
|
|
|
assert payload["assistantHasReply"] is True, payload
|
|
assert payload["savedChipVisible"] is True, payload
|
|
assert payload["fireCalls"] == 0, payload
|
|
assert payload["floatingLabels"] == 0, payload
|
|
assert payload["pageErrors"] == [], payload
|
|
|
|
|
|
def test_webui_edit_middle_message_forks_without_history_leak(tmp_path: Path) -> None:
|
|
if os.environ.get("OPENSQUILLA_WEBUI_BROWSER_CHAT_E2E") != "1":
|
|
pytest.skip("set OPENSQUILLA_WEBUI_BROWSER_CHAT_E2E=1 to run chat browser e2e")
|
|
|
|
port = _free_port()
|
|
server_script = tmp_path / "webui_branch_edit_server.py"
|
|
browser_script = tmp_path / "webui_branch_edit_browser.js"
|
|
server_script.write_text(
|
|
textwrap.dedent(
|
|
f"""
|
|
import uvicorn
|
|
|
|
from opensquilla.gateway.app import create_gateway_app
|
|
from opensquilla.gateway.config import AuthConfig, GatewayConfig
|
|
|
|
config = GatewayConfig(
|
|
host="127.0.0.1",
|
|
port={port},
|
|
auth=AuthConfig(mode="none"),
|
|
control_ui={{"frontend": "legacy"}},
|
|
)
|
|
app = create_gateway_app(config)
|
|
|
|
if __name__ == "__main__":
|
|
uvicorn.run(app, host="127.0.0.1", port={port}, log_level="warning")
|
|
"""
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
browser_script.write_text(
|
|
textwrap.dedent(
|
|
r"""
|
|
const { chromium } = require("playwright");
|
|
|
|
async function waitRpc(page) {
|
|
await page.waitForFunction(
|
|
() =>
|
|
typeof App !== "undefined" &&
|
|
App.getRpc &&
|
|
App.getRpc()?.state === "connected",
|
|
{ timeout: 15000 }
|
|
);
|
|
}
|
|
|
|
(async () => {
|
|
const browser = await chromium.launch({ headless: true });
|
|
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
|
|
const errors = [];
|
|
page.on("pageerror", err => errors.push(String(err)));
|
|
|
|
await page.goto(process.env.TARGET_URL, {
|
|
waitUntil: "domcontentloaded",
|
|
timeout: 30000,
|
|
});
|
|
await waitRpc(page);
|
|
|
|
await page.evaluate(() => {
|
|
const parentKey = "agent:main:webchat:branch-edit-parent";
|
|
const childKey = "agent:main:webchat:branch-edit-child";
|
|
const parentMessages = [
|
|
{
|
|
role: "user",
|
|
text: "A marker",
|
|
timestamp: "2026-07-03T00:00:01.000Z",
|
|
message_id: "msg-A",
|
|
},
|
|
{
|
|
role: "assistant",
|
|
text: "ack A",
|
|
timestamp: "2026-07-03T00:00:02.000Z",
|
|
message_id: "msg-ack-A",
|
|
},
|
|
{
|
|
role: "user",
|
|
text: "B marker",
|
|
timestamp: "2026-07-03T00:00:03.000Z",
|
|
message_id: "msg-B",
|
|
},
|
|
{
|
|
role: "assistant",
|
|
text: "ack B",
|
|
timestamp: "2026-07-03T00:00:04.000Z",
|
|
message_id: "msg-ack-B",
|
|
},
|
|
{
|
|
role: "user",
|
|
text: "C marker must stay only on parent",
|
|
timestamp: "2026-07-03T00:00:05.000Z",
|
|
message_id: "msg-C",
|
|
},
|
|
];
|
|
const childMessages = [
|
|
{
|
|
role: "user",
|
|
text: "A marker",
|
|
timestamp: "2026-07-03T00:00:01.000Z",
|
|
message_id: "child-msg-A",
|
|
},
|
|
{
|
|
role: "assistant",
|
|
text: "ack A",
|
|
timestamp: "2026-07-03T00:00:02.000Z",
|
|
message_id: "child-msg-ack-A",
|
|
},
|
|
{
|
|
role: "user",
|
|
text: "B edited",
|
|
timestamp: "2026-07-03T00:00:06.000Z",
|
|
message_id: "child-msg-B-edited",
|
|
},
|
|
];
|
|
|
|
const rpc = App.getRpc();
|
|
const originalCall = rpc.call.bind(rpc);
|
|
window.__branchEdit = {
|
|
parentKey,
|
|
childKey,
|
|
parentMessages,
|
|
childMessages,
|
|
chatCalls: [],
|
|
historyCalls: [],
|
|
};
|
|
rpc.call = (method, params = {}) => {
|
|
if (method === "tools.search_provider") {
|
|
return Promise.resolve({ provider: "none" });
|
|
}
|
|
if (method === "config.get") {
|
|
return Promise.resolve({
|
|
permissions: { default_mode: "ask" },
|
|
squilla_router: { enabled: false, rollout_phase: "off", tiers: {} },
|
|
});
|
|
}
|
|
if (method === "sessions.messages.subscribe") {
|
|
return Promise.resolve({
|
|
subscribed: true,
|
|
key: params.key,
|
|
current_stream_seq: 0,
|
|
replay_complete: true,
|
|
replayed_count: 0,
|
|
run_status: "idle",
|
|
});
|
|
}
|
|
if (method === "chat.history") {
|
|
const key = params.sessionKey || parentKey;
|
|
window.__branchEdit.historyCalls.push(key);
|
|
return Promise.resolve({
|
|
messages: key === childKey ? childMessages : parentMessages,
|
|
history_scope: "complete",
|
|
has_more: false,
|
|
});
|
|
}
|
|
if (method === "chat.send") {
|
|
window.__branchEdit.chatCalls.push(params);
|
|
return Promise.resolve({
|
|
sessionKey: childKey,
|
|
key: childKey,
|
|
task_id: "branch-edit-task",
|
|
});
|
|
}
|
|
return originalCall(method, params);
|
|
};
|
|
});
|
|
|
|
await page.evaluate(() =>
|
|
Router.navigate(
|
|
"/chat?session=" +
|
|
encodeURIComponent(window.__branchEdit.parentKey)
|
|
)
|
|
);
|
|
await page.waitForSelector("#chat-textarea", { timeout: 15000 });
|
|
await page.waitForFunction(
|
|
() => document.querySelectorAll("#chat-thread .msg.user").length >= 3,
|
|
null, { timeout: 15000 }
|
|
);
|
|
|
|
await page.locator("#chat-thread .msg.user").nth(1).hover();
|
|
await page.locator("#chat-thread .msg.user")
|
|
.nth(1)
|
|
.locator('[data-action="edit"]')
|
|
.click();
|
|
await page.waitForFunction(
|
|
() => document.querySelector("#chat-textarea")?.value.includes("B marker"),
|
|
null, { timeout: 5000 }
|
|
);
|
|
await page.fill("#chat-textarea", "B edited");
|
|
await page.click("#chat-btn-send");
|
|
await page.waitForFunction(
|
|
() => window.__branchEdit.chatCalls.length === 1,
|
|
null, { timeout: 5000 }
|
|
);
|
|
await page.waitForFunction(
|
|
() =>
|
|
window.__branchEdit.chatCalls[0].forkBeforeMessageId === "msg-B",
|
|
null, { timeout: 5000 }
|
|
);
|
|
|
|
await page.evaluate(() => {
|
|
ChatView.destroy();
|
|
ChatView.render(document.getElementById("content"));
|
|
});
|
|
await page.waitForFunction(
|
|
() =>
|
|
window.__branchEdit.historyCalls
|
|
.some(key => key === window.__branchEdit.childKey),
|
|
null, { timeout: 15000 }
|
|
);
|
|
await page.waitForSelector("#chat-thread .msg.user", { timeout: 15000 });
|
|
|
|
const payload = await page.evaluate(() => {
|
|
const bodyText = document.querySelector("#chat-thread")?.innerText || "";
|
|
return {
|
|
chatSendPayload: window.__branchEdit.chatCalls[0],
|
|
historyCalls: window.__branchEdit.historyCalls,
|
|
parentHistoryHasC: window.__branchEdit.parentMessages
|
|
.some(msg => String(msg.text || "").includes("C marker")),
|
|
childHistoryHasC: window.__branchEdit.childMessages
|
|
.some(msg => String(msg.text || "").includes("C marker")),
|
|
childUiHasC: bodyText.includes("C marker"),
|
|
childUiHasEditedB: bodyText.includes("B edited"),
|
|
sessionChip: document.querySelector("#chat-session-chip-key")?.innerText || "",
|
|
};
|
|
});
|
|
payload.pageErrors = errors;
|
|
|
|
await browser.close();
|
|
console.log(JSON.stringify(payload));
|
|
})().catch(err => {
|
|
console.error(err && err.stack ? err.stack : String(err));
|
|
process.exit(1);
|
|
});
|
|
"""
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
env = os.environ.copy()
|
|
env["OPENSQUILLA_STATE_DIR"] = str(tmp_path / "state")
|
|
env["OPENSQUILLA_LOG_DIR"] = str(tmp_path / "logs")
|
|
server = subprocess.Popen(
|
|
[sys.executable, str(server_script)],
|
|
cwd=Path.cwd(),
|
|
env=env,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
)
|
|
try:
|
|
_wait_for_health(port, server)
|
|
_install_playwright(tmp_path)
|
|
result = subprocess.run(
|
|
[_node(), str(browser_script)],
|
|
cwd=tmp_path,
|
|
env=dict(env, TARGET_URL=f"http://127.0.0.1:{port}/control/"),
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=90,
|
|
)
|
|
assert result.returncode == 0, result.stderr or result.stdout
|
|
payload = json.loads(result.stdout.strip().splitlines()[-1])
|
|
finally:
|
|
_stop_process(server)
|
|
|
|
assert payload["pageErrors"] == [], payload["pageErrors"]
|
|
assert payload["chatSendPayload"]["message"] == "B edited"
|
|
assert payload["chatSendPayload"]["forkBeforeMessageId"] == "msg-B"
|
|
assert payload["parentHistoryHasC"] is True
|
|
assert payload["childHistoryHasC"] is False
|
|
assert payload["childUiHasC"] is False
|
|
assert payload["childUiHasEditedB"] is True
|
|
assert payload["sessionChip"] == "agent:main:webchat:branch-edit-child"
|
|
|
|
|
|
def test_webui_hotfix_flows_in_real_browser(tmp_path: Path) -> None:
|
|
if os.environ.get("OPENSQUILLA_WEBUI_BROWSER_CHAT_E2E") != "1":
|
|
pytest.skip("set OPENSQUILLA_WEBUI_BROWSER_CHAT_E2E=1 to run chat browser e2e")
|
|
|
|
port = _free_port()
|
|
server_script = tmp_path / "webui_hotfix_server.py"
|
|
browser_script = tmp_path / "webui_hotfix_browser.js"
|
|
server_script.write_text(
|
|
textwrap.dedent(
|
|
f"""
|
|
import uvicorn
|
|
|
|
from opensquilla.gateway.app import create_gateway_app
|
|
from opensquilla.gateway.config import AuthConfig, GatewayConfig
|
|
|
|
config = GatewayConfig(
|
|
host="127.0.0.1",
|
|
port={port},
|
|
auth=AuthConfig(mode="none"),
|
|
)
|
|
app = create_gateway_app(config)
|
|
|
|
if __name__ == "__main__":
|
|
uvicorn.run(app, host="127.0.0.1", port={port}, log_level="warning")
|
|
"""
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
browser_script.write_text(
|
|
textwrap.dedent(
|
|
r"""
|
|
const { chromium } = require("playwright");
|
|
|
|
async function waitRpc(page) {
|
|
await page.waitForFunction(
|
|
() =>
|
|
typeof App !== "undefined" &&
|
|
App.getRpc &&
|
|
App.getRpc()?.state === "connected",
|
|
{ timeout: 15000 }
|
|
);
|
|
}
|
|
|
|
async function emit(page, event, payload) {
|
|
await page.evaluate(
|
|
({ event, payload }) => {
|
|
const rpc = App.getRpc();
|
|
const named = rpc._listeners.get(event);
|
|
if (named) named.forEach(h => h(payload));
|
|
const wild = rpc._listeners.get("*");
|
|
if (wild) wild.forEach(h => h(event, payload));
|
|
},
|
|
{ event, payload }
|
|
);
|
|
}
|
|
|
|
(async () => {
|
|
const browser = await chromium.launch({ headless: true });
|
|
const page = await browser.newPage();
|
|
const errors = [];
|
|
page.on("pageerror", err => errors.push(String(err)));
|
|
|
|
await page.goto(process.env.TARGET_URL, {
|
|
waitUntil: "domcontentloaded",
|
|
timeout: 30000,
|
|
});
|
|
await page.waitForSelector("#chat-textarea", { timeout: 15000 });
|
|
await waitRpc(page);
|
|
|
|
await page.evaluate(() => {
|
|
const rpc = App.getRpc();
|
|
const originalCall = rpc.call.bind(rpc);
|
|
window.__hotfix = {
|
|
chatCalls: [],
|
|
channelStatusCalls: 0,
|
|
delayNextWait: false,
|
|
logInFlight: 0,
|
|
logMaxInFlight: 0,
|
|
logTailCalls: 0,
|
|
};
|
|
const originalWait = rpc.waitForConnection.bind(rpc);
|
|
rpc.waitForConnection = () => {
|
|
if (!window.__hotfix.delayNextWait) return originalWait();
|
|
window.__hotfix.delayNextWait = false;
|
|
return new Promise(resolve => setTimeout(resolve, 300));
|
|
};
|
|
rpc.call = (method, params = {}) => {
|
|
if (method === "chat.send") {
|
|
window.__hotfix.chatCalls.push({ method, params });
|
|
return Promise.resolve({ task_id: "task-" + window.__hotfix.chatCalls.length });
|
|
}
|
|
if (method === "sessions.list") {
|
|
return Promise.resolve({
|
|
sessions: [{
|
|
key: "fake-session",
|
|
title: "Fake session",
|
|
updated_at: new Date().toISOString(),
|
|
message_count: 1,
|
|
}],
|
|
});
|
|
}
|
|
if (method === "agents.list") {
|
|
return Promise.resolve({ agents: [] });
|
|
}
|
|
if (method === "sessions.delete") {
|
|
return Promise.resolve({ deleted: [], errors: ["denied"] });
|
|
}
|
|
if (method === "channels.status") {
|
|
window.__hotfix.channelStatusCalls += 1;
|
|
return Promise.resolve({ channels: [] });
|
|
}
|
|
if (method === "logs.status") {
|
|
return Promise.resolve({ enabled: true, path: "debug.log" });
|
|
}
|
|
if (method === "logs.tail") {
|
|
const callNo = ++window.__hotfix.logTailCalls;
|
|
window.__hotfix.logInFlight += 1;
|
|
window.__hotfix.logMaxInFlight = Math.max(
|
|
window.__hotfix.logMaxInFlight,
|
|
window.__hotfix.logInFlight
|
|
);
|
|
return new Promise((resolve, reject) => {
|
|
const delay = callNo === 1 ? 200 : 4500;
|
|
setTimeout(() => {
|
|
window.__hotfix.logInFlight -= 1;
|
|
if (callNo === 1) {
|
|
reject(new Error("tail down"));
|
|
return;
|
|
}
|
|
resolve({
|
|
cursor: callNo,
|
|
lines: [{ level: "INFO", message: "log line " + callNo }],
|
|
});
|
|
}, delay);
|
|
});
|
|
}
|
|
return originalCall(method, params);
|
|
};
|
|
});
|
|
|
|
const sessionKey = await page.locator("#chat-session-chip-key").innerText();
|
|
await emit(
|
|
page,
|
|
"task.queued",
|
|
{ task_id: "old-task", session_key: "other-session" }
|
|
);
|
|
const statusAfterOtherTask = await page.locator("#chat-run-status").innerText();
|
|
await emit(page, "task.queued", { task_id: "current-task", session_key: sessionKey });
|
|
const statusAfterCurrentTask = await page.locator("#chat-run-status").innerText();
|
|
await emit(page, "sessions.changed", {
|
|
key: sessionKey,
|
|
run_status: "idle",
|
|
last_task: { status: "succeeded" },
|
|
reason: "turn_complete",
|
|
});
|
|
await emit(page, "sessions.changed", {
|
|
key: sessionKey,
|
|
run_status: "running",
|
|
active_task: { task_id: "running-task", status: "running" },
|
|
reason: "task_running",
|
|
});
|
|
await page.waitForFunction(
|
|
() => document.querySelector("#chat-run-status")?.innerText === "Running",
|
|
null, { timeout: 5000 }
|
|
);
|
|
await emit(page, "task.queued", {
|
|
task_id: "queued-behind-running",
|
|
session_key: sessionKey,
|
|
queue_depth: 1,
|
|
queue_position: 2,
|
|
});
|
|
await page.waitForTimeout(150);
|
|
const statusAfterQueuedBehindRunning =
|
|
await page.locator("#chat-run-status").innerText();
|
|
await emit(page, "sessions.changed", {
|
|
key: sessionKey,
|
|
run_status: "idle",
|
|
last_task: { status: "succeeded" },
|
|
reason: "turn_complete",
|
|
});
|
|
|
|
await page.fill("#chat-textarea", "first prompt");
|
|
await page.click("#chat-btn-send");
|
|
await page.waitForFunction(
|
|
() => window.__hotfix.chatCalls.some(c => c.params.message === "first prompt"),
|
|
null, { timeout: 5000 }
|
|
);
|
|
await emit(page, "session.event.done", { text: "first answer" });
|
|
await page.waitForFunction(
|
|
() => document.querySelectorAll(".msg.assistant").length >= 1,
|
|
null, { timeout: 5000 }
|
|
);
|
|
|
|
await page.fill("#chat-textarea", "second prompt");
|
|
await page.click("#chat-btn-send");
|
|
await page.waitForFunction(
|
|
() => window.__hotfix.chatCalls.some(c => c.params.message === "second prompt"),
|
|
null, { timeout: 5000 }
|
|
);
|
|
await emit(page, "session.event.done", { text: "second answer" });
|
|
await page.waitForFunction(
|
|
() => document.querySelectorAll(".msg.assistant").length >= 2,
|
|
null, { timeout: 5000 }
|
|
);
|
|
|
|
await page.locator(".msg.assistant").first().hover();
|
|
await page.locator(".msg.assistant")
|
|
.first()
|
|
.locator('[data-action="regenerate"]')
|
|
.click();
|
|
await page.waitForFunction(
|
|
() =>
|
|
window.__hotfix.chatCalls
|
|
.filter(c => c.params.message === "first prompt")
|
|
.length >= 2,
|
|
null, { timeout: 5000 }
|
|
);
|
|
const regenerateMessages = await page.evaluate(
|
|
() => window.__hotfix.chatCalls.map(c => c.params.message)
|
|
);
|
|
const bubblesAfterRegenerate = await page.locator(".msg").count();
|
|
|
|
await page.fill("#chat-textarea", "queued while streaming");
|
|
await page.click("#chat-btn-send");
|
|
await page.fill("#chat-textarea", "draft typed during stream");
|
|
await emit(page, "session.event.done", { text: "regenerated answer" });
|
|
await page.waitForFunction(
|
|
() =>
|
|
window.__hotfix.chatCalls
|
|
.some(c => c.params.message === "queued while streaming"),
|
|
null, { timeout: 5000 }
|
|
);
|
|
const draftAfterQueueDrain = await page.locator("#chat-textarea").inputValue();
|
|
|
|
await page.fill("#chat-textarea", "queued from terminal session change");
|
|
await page.click("#chat-btn-send");
|
|
await emit(page, "sessions.changed", {
|
|
key: sessionKey,
|
|
run_status: "idle",
|
|
last_task: { status: "succeeded" },
|
|
reason: "turn_complete",
|
|
});
|
|
await page.waitForFunction(
|
|
() =>
|
|
window.__hotfix.chatCalls
|
|
.some(c => c.params.message === "queued from terminal session change"),
|
|
null, { timeout: 5000 }
|
|
);
|
|
await emit(page, "session.event.done", { text: "terminal queued answer" });
|
|
await page.waitForFunction(
|
|
() => document.querySelector("#chat-run-status")?.innerText === "Idle",
|
|
null, { timeout: 5000 }
|
|
);
|
|
|
|
await page.fill("#chat-textarea", "first before error");
|
|
await page.click("#chat-btn-send");
|
|
await page.waitForFunction(
|
|
() => window.__hotfix.chatCalls
|
|
.some(c => c.params.message === "first before error"),
|
|
null, { timeout: 5000 }
|
|
);
|
|
await page.fill("#chat-textarea", "queued before error");
|
|
await page.click("#chat-btn-send");
|
|
await page.waitForFunction(
|
|
() => document.querySelector("#chat-pending")
|
|
?.innerText.includes("queued before error"),
|
|
null, { timeout: 5000 }
|
|
);
|
|
const queuedBeforeErrorSentPrematurely = await page.evaluate(
|
|
() => window.__hotfix.chatCalls
|
|
.some(c => c.params.message === "queued before error")
|
|
);
|
|
await emit(page, "session.event.error", {
|
|
session_key: sessionKey,
|
|
message: "synthetic failure",
|
|
code: "failed",
|
|
});
|
|
await page.waitForFunction(
|
|
() => document.querySelector("#chat-textarea")
|
|
?.value.includes("queued before error"),
|
|
null, { timeout: 5000 }
|
|
);
|
|
const errorRecoveredComposer = await page.locator("#chat-textarea").inputValue();
|
|
const queuedBeforeErrorSentAfterFailure = await page.evaluate(
|
|
() => window.__hotfix.chatCalls
|
|
.some(c => c.params.message === "queued before error")
|
|
);
|
|
await page.fill("#chat-textarea", "");
|
|
|
|
await page.fill("#chat-textarea", "approval turn");
|
|
await page.click("#chat-btn-send");
|
|
await page.waitForFunction(
|
|
() => window.__hotfix.chatCalls.some(c => c.params.message === "approval turn"),
|
|
null, { timeout: 5000 }
|
|
);
|
|
await page.evaluate((sessionKey) => {
|
|
window.dispatchEvent(new CustomEvent("opensquilla:approvals-pending", {
|
|
detail: {
|
|
pending: [{ id: "approval-1", namespace: "exec", sessionKey }],
|
|
count: 1,
|
|
},
|
|
}));
|
|
}, sessionKey);
|
|
await page.waitForFunction(
|
|
() => document.querySelector("#chat-run-status")
|
|
?.innerText === "Waiting for approval",
|
|
null, { timeout: 5000 }
|
|
);
|
|
const approvalStatusDuring = await page.locator("#chat-run-status").innerText();
|
|
await page.evaluate(() => {
|
|
window.dispatchEvent(new CustomEvent("opensquilla:approvals-pending", {
|
|
detail: { pending: [], count: 0 },
|
|
}));
|
|
});
|
|
await page.waitForFunction(
|
|
() => document.querySelector("#chat-run-status")?.innerText === "Running",
|
|
null, { timeout: 5000 }
|
|
);
|
|
const approvalStatusAfterResolve = await page.locator("#chat-run-status").innerText();
|
|
await emit(page, "session.event.done", { text: "approval answer" });
|
|
await page.waitForFunction(
|
|
() => document.querySelector("#chat-run-status")?.innerText === "Idle",
|
|
null, { timeout: 5000 }
|
|
);
|
|
|
|
await page.evaluate(() => Router.navigate("/config"));
|
|
await page.waitForSelector("#cfg-yaml-area", { state: "attached", timeout: 15000 });
|
|
await page.click('[data-cfg-mode="yaml"]');
|
|
const yamlBefore = await page.locator("#cfg-yaml-area").inputValue();
|
|
const yamlDraft = yamlBefore + "\n# hotfix draft";
|
|
await page.fill("#cfg-yaml-area", yamlDraft);
|
|
await page.click('[data-cfg-mode="form"]');
|
|
await page.click('[data-cfg-mode="yaml"]');
|
|
const yamlAfterToggle = await page.locator("#cfg-yaml-area").inputValue();
|
|
await page.evaluate(() => Router.navigate("/chat"));
|
|
await page.waitForSelector("#chat-textarea", { timeout: 15000 });
|
|
await page.evaluate(() => Router.navigate("/config"));
|
|
await page.waitForSelector("#cfg-yaml-area", { state: "attached", timeout: 15000 });
|
|
const configFormDisplay = await page
|
|
.locator("#cfg-form-view")
|
|
.evaluate(el => getComputedStyle(el).display);
|
|
const configYamlDisplay = await page
|
|
.locator("#cfg-yaml-view")
|
|
.evaluate(el => getComputedStyle(el).display);
|
|
const formButtonActive = await page
|
|
.locator('[data-cfg-mode="form"]')
|
|
.evaluate(el => el.classList.contains("is-active"));
|
|
|
|
await page.evaluate(() => {
|
|
window.__hotfix.delayNextWait = true;
|
|
window.__hotfix.channelGuardStartedAt = Date.now();
|
|
Router.navigate("/channels");
|
|
Router.navigate("/chat");
|
|
});
|
|
await page.waitForSelector("#chat-textarea", { timeout: 15000 });
|
|
await page.waitForFunction(
|
|
() =>
|
|
window.__hotfix.channelStatusCalls === 0 &&
|
|
Date.now() - window.__hotfix.channelGuardStartedAt >= 600,
|
|
{ timeout: 2000 }
|
|
);
|
|
const channelStatusCallsAfterDestroyedWait = await page.evaluate(
|
|
() => window.__hotfix.channelStatusCalls
|
|
);
|
|
|
|
await page.evaluate(() => Router.navigate("/sessions"));
|
|
await page.waitForSelector('[data-del-key="fake-session"]', { timeout: 15000 });
|
|
await page.click('[data-del-key="fake-session"]');
|
|
await page.locator(".modal .btn-danger").click();
|
|
await page.waitForFunction(
|
|
() => document.body.innerText.includes("Delete failed: denied"),
|
|
null, { timeout: 5000 }
|
|
);
|
|
const sessionDeleteBody = await page.locator("body").innerText();
|
|
|
|
await page.evaluate(() => Router.navigate("/logs"));
|
|
await page.waitForSelector("#logs-display", { timeout: 15000 });
|
|
await page.waitForFunction(
|
|
() => document.body.innerText.includes("Log refresh failed"),
|
|
null, { timeout: 5000 }
|
|
);
|
|
await page.waitForFunction(
|
|
() => window.__hotfix.logTailCalls >= 2 && window.__hotfix.logInFlight === 0,
|
|
{ timeout: 10000 }
|
|
);
|
|
const logState = await page.evaluate(() => ({
|
|
maxInFlight: window.__hotfix.logMaxInFlight,
|
|
tailCalls: window.__hotfix.logTailCalls,
|
|
}));
|
|
|
|
const result = {
|
|
statusAfterOtherTask,
|
|
statusAfterCurrentTask,
|
|
statusAfterQueuedBehindRunning,
|
|
regenerateMessages,
|
|
bubblesAfterRegenerate,
|
|
draftAfterQueueDrain,
|
|
queuedBeforeErrorSentPrematurely,
|
|
errorRecoveredComposer,
|
|
queuedBeforeErrorSentAfterFailure,
|
|
approvalStatusDuring,
|
|
approvalStatusAfterResolve,
|
|
yamlDraftPreserved: yamlAfterToggle === yamlDraft,
|
|
configResetToForm:
|
|
configFormDisplay !== "none" &&
|
|
configYamlDisplay === "none" &&
|
|
formButtonActive,
|
|
channelStatusCallsAfterDestroyedWait,
|
|
sessionDeleteFailed: sessionDeleteBody.includes("Delete failed: denied"),
|
|
sessionDeleteFalseSuccess: sessionDeleteBody.includes("Session deleted"),
|
|
logState,
|
|
pageErrors: errors,
|
|
};
|
|
await browser.close();
|
|
console.log(JSON.stringify(result));
|
|
})().catch(err => {
|
|
console.error(err && err.stack ? err.stack : String(err));
|
|
process.exit(1);
|
|
});
|
|
"""
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
env = os.environ.copy()
|
|
env["OPENSQUILLA_STATE_DIR"] = str(tmp_path / "state")
|
|
env["OPENSQUILLA_LOG_DIR"] = str(tmp_path / "logs")
|
|
server = subprocess.Popen(
|
|
[sys.executable, str(server_script)],
|
|
cwd=Path.cwd(),
|
|
env=env,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
)
|
|
try:
|
|
_wait_for_health(port, server)
|
|
_install_playwright(tmp_path)
|
|
result = subprocess.run(
|
|
[_node(), str(browser_script)],
|
|
cwd=tmp_path,
|
|
env=dict(env, TARGET_URL=f"http://127.0.0.1:{port}/control/chat"),
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=120,
|
|
)
|
|
assert result.returncode == 0, result.stderr or result.stdout
|
|
payload = json.loads(result.stdout.strip().splitlines()[-1])
|
|
finally:
|
|
_stop_process(server)
|
|
|
|
assert payload["pageErrors"] == [], payload["pageErrors"]
|
|
assert payload["statusAfterOtherTask"] == "Idle"
|
|
assert payload["statusAfterCurrentTask"] == "Queued"
|
|
assert payload["statusAfterQueuedBehindRunning"] == "Running"
|
|
assert payload["regenerateMessages"].count("first prompt") >= 2
|
|
first_regenerate = payload["regenerateMessages"].index("first prompt")
|
|
second_send = payload["regenerateMessages"].index("second prompt")
|
|
assert first_regenerate < second_send
|
|
assert payload["bubblesAfterRegenerate"] <= 3
|
|
assert payload["draftAfterQueueDrain"] == "draft typed during stream"
|
|
assert payload["queuedBeforeErrorSentPrematurely"] is False
|
|
assert "queued before error" in payload["errorRecoveredComposer"]
|
|
assert payload["queuedBeforeErrorSentAfterFailure"] is False
|
|
assert payload["approvalStatusDuring"] == "Waiting for approval"
|
|
assert payload["approvalStatusAfterResolve"] == "Running"
|
|
assert payload["yamlDraftPreserved"] is True
|
|
assert payload["configResetToForm"] is True
|
|
assert payload["channelStatusCallsAfterDestroyedWait"] == 0
|
|
assert payload["sessionDeleteFailed"] is True
|
|
assert payload["sessionDeleteFalseSuccess"] is False
|
|
assert payload["logState"]["tailCalls"] >= 2
|
|
assert payload["logState"]["maxInFlight"] == 1
|
|
|
|
|
|
def test_meta_skill_ribbon_renders_and_progresses_in_real_browser(tmp_path: Path) -> None:
|
|
"""P0-1 ribbon: inject meta-skill events through the RPC bus and assert
|
|
the ribbon renders, chip states advance through running → succeeded, and
|
|
the counter reaches its declared total.
|
|
|
|
We bypass the meta-skill scheduler — the events are pushed directly to
|
|
chat.js's ``_rpc.on`` listeners. This isolates the front-end contract
|
|
(announce → state → completed) from scheduler timing.
|
|
"""
|
|
if os.environ.get("OPENSQUILLA_WEBUI_BROWSER_CHAT_E2E") != "1":
|
|
pytest.skip("set OPENSQUILLA_WEBUI_BROWSER_CHAT_E2E=1 to run chat browser e2e")
|
|
|
|
port = _free_port()
|
|
server_script = tmp_path / "webui_meta_ribbon_server.py"
|
|
browser_script = tmp_path / "webui_meta_ribbon_browser.js"
|
|
out_path = tmp_path / "meta_ribbon_payload.json"
|
|
|
|
server_script.write_text(
|
|
textwrap.dedent(
|
|
f"""
|
|
import uvicorn
|
|
|
|
from opensquilla.gateway.app import create_gateway_app
|
|
from opensquilla.gateway.config import AuthConfig, GatewayConfig
|
|
|
|
config = GatewayConfig(
|
|
host="127.0.0.1",
|
|
port={port},
|
|
auth=AuthConfig(mode="none"),
|
|
)
|
|
app = create_gateway_app(config)
|
|
|
|
if __name__ == "__main__":
|
|
uvicorn.run(app, host="127.0.0.1", port={port}, log_level="warning")
|
|
"""
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
browser_script.write_text(
|
|
textwrap.dedent(
|
|
r"""
|
|
const { chromium } = require("playwright");
|
|
const fs = require("fs");
|
|
|
|
function fire(page, name, payload) {
|
|
return page.evaluate(
|
|
({ name, payload }) => {
|
|
const rpc = App.getRpc();
|
|
const ls = rpc._listeners;
|
|
const handlers = ls.get(name);
|
|
if (handlers) handlers.forEach(h => h(payload));
|
|
},
|
|
{ name, payload }
|
|
);
|
|
}
|
|
|
|
(async () => {
|
|
const browser = await chromium.launch({ headless: true });
|
|
const page = await browser.newPage();
|
|
const errors = [];
|
|
page.on("pageerror", err => errors.push(String(err)));
|
|
|
|
await page.goto(process.env.TARGET_URL, {
|
|
waitUntil: "domcontentloaded",
|
|
timeout: 30000,
|
|
});
|
|
await page.waitForSelector("#chat-textarea", { timeout: 15000 });
|
|
await page.waitForFunction(
|
|
() =>
|
|
typeof App !== "undefined" &&
|
|
App.getRpc &&
|
|
App.getRpc()?.state === "connected",
|
|
{ timeout: 15000 }
|
|
);
|
|
|
|
const RUN_ID = "r-meta-1";
|
|
await fire(page, "session.event.meta_run_announced", {
|
|
run_id: RUN_ID,
|
|
meta_skill_name: "meta-stub-two-step",
|
|
steps: [
|
|
{ id: "intake", label: "意图提取", kind: "llm_chat", depends_on: [] },
|
|
{ id: "summary", label: "总结", kind: "llm_chat", depends_on: ["intake"] },
|
|
],
|
|
total: 2,
|
|
parent_run_id: null,
|
|
});
|
|
|
|
await page.waitForSelector(".meta-ribbon", { timeout: 5000 });
|
|
const chipsAfterAnnounce = await page.$$eval(
|
|
".meta-ribbon-chips .chip",
|
|
els => els.map(e => e.className)
|
|
);
|
|
|
|
await fire(page, "session.event.meta_step_state", {
|
|
run_id: RUN_ID, step_id: "intake", state: "running",
|
|
});
|
|
await fire(page, "session.event.meta_step_state", {
|
|
run_id: RUN_ID, step_id: "intake", state: "succeeded",
|
|
});
|
|
await fire(page, "session.event.meta_step_state", {
|
|
run_id: RUN_ID, step_id: "summary", state: "running",
|
|
});
|
|
await fire(page, "session.event.meta_step_state", {
|
|
run_id: RUN_ID, step_id: "summary", state: "succeeded",
|
|
});
|
|
await fire(page, "session.event.meta_run_completed", {
|
|
run_id: RUN_ID,
|
|
outcome: "ok",
|
|
completed_steps: ["intake", "summary"],
|
|
failed_steps: [],
|
|
skipped_steps: [],
|
|
});
|
|
|
|
await page.waitForFunction(
|
|
() =>
|
|
document.querySelector(".meta-ribbon-counter")?.textContent === "2/2",
|
|
{ timeout: 5000 }
|
|
);
|
|
|
|
const finalCounter = await page.$eval(
|
|
".meta-ribbon-counter",
|
|
el => el.textContent
|
|
);
|
|
const finalChips = await page.$$eval(
|
|
".meta-ribbon-chips .chip",
|
|
els => els.map(e => e.className)
|
|
);
|
|
const title = await page.$eval(
|
|
".meta-ribbon-title",
|
|
el => el.textContent
|
|
);
|
|
|
|
fs.writeFileSync(process.env.OUT_PATH, JSON.stringify({
|
|
chipsAfterAnnounce,
|
|
finalCounter,
|
|
finalChips,
|
|
title,
|
|
errors,
|
|
}));
|
|
|
|
await browser.close();
|
|
})();
|
|
"""
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
proc = subprocess.Popen(
|
|
[sys.executable, str(server_script)],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
try:
|
|
deadline = time.time() + 30
|
|
ready = False
|
|
while time.time() < deadline:
|
|
try:
|
|
r = httpx.get(f"http://127.0.0.1:{port}/health", timeout=1.0)
|
|
if r.status_code < 500:
|
|
ready = True
|
|
break
|
|
except httpx.HTTPError:
|
|
pass
|
|
time.sleep(0.1)
|
|
if not ready:
|
|
pytest.skip("gateway did not start in time")
|
|
|
|
subprocess.run(
|
|
["node", str(browser_script)],
|
|
check=True,
|
|
env={
|
|
**os.environ,
|
|
"TARGET_URL": f"http://127.0.0.1:{port}/",
|
|
"OUT_PATH": str(out_path),
|
|
},
|
|
timeout=60,
|
|
)
|
|
finally:
|
|
proc.terminate()
|
|
try:
|
|
proc.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
|
|
payload = json.loads(out_path.read_text(encoding="utf-8"))
|
|
|
|
assert not payload["errors"], f"page errors: {payload['errors']}"
|
|
assert payload["title"] == "meta-stub-two-step"
|
|
assert payload["finalCounter"] == "2/2"
|
|
# All chips landed in succeeded class on the success path.
|
|
assert len(payload["chipsAfterAnnounce"]) == 2
|
|
for cls in payload["finalChips"]:
|
|
assert "succeeded" in cls, f"chip class did not reach succeeded: {cls}"
|
|
|
|
|
|
def test_issue344_stale_task_events_do_not_leak_into_active_turn_in_real_browser(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""issue 344: a stale task's late events must not leak into the active turn.
|
|
|
|
Legacy static chat UI (frontend=legacy, the surface hosting chat.js). task-B
|
|
owns the live stream; we then inject task-A's late tool_use_start and terminal
|
|
failure. With the task-id guard they are dropped (recorded as
|
|
``*.drop.foreign_task`` in the chat diagnostics log) and never render, while
|
|
task-B's own tool and an untagged legacy event still render. Without the guard
|
|
the stale tool card and "turn failed" error leaked into task-B's turn.
|
|
"""
|
|
if os.environ.get("OPENSQUILLA_WEBUI_BROWSER_CHAT_E2E") != "1":
|
|
pytest.skip("set OPENSQUILLA_WEBUI_BROWSER_CHAT_E2E=1 to run chat browser e2e")
|
|
|
|
port = _free_port()
|
|
server_script = tmp_path / "issue344_server.py"
|
|
browser_script = tmp_path / "issue344_browser.js"
|
|
server_script.write_text(
|
|
textwrap.dedent(
|
|
f"""
|
|
import uvicorn
|
|
|
|
from opensquilla.gateway.app import create_gateway_app
|
|
from opensquilla.gateway.config import AuthConfig, GatewayConfig
|
|
|
|
config = GatewayConfig(
|
|
host="127.0.0.1",
|
|
port={port},
|
|
auth=AuthConfig(mode="none"),
|
|
)
|
|
app = create_gateway_app(config)
|
|
|
|
if __name__ == "__main__":
|
|
uvicorn.run(app, host="127.0.0.1", port={port}, log_level="warning")
|
|
"""
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
browser_script.write_text(
|
|
textwrap.dedent(
|
|
r"""
|
|
const { chromium } = require("playwright");
|
|
|
|
// Dispatch an RPC frame through chat.js's own listeners: named events
|
|
// hit their dedicated handler; terminal events ride the wildcard (*)
|
|
// listener, exactly as the live RPC bus delivers them.
|
|
async function emit(page, event, payload, wild) {
|
|
await page.evaluate(({ event, payload, wild }) => {
|
|
const ls = App.getRpc()._listeners;
|
|
if (wild) {
|
|
const h = ls.get("*");
|
|
if (h) h.forEach(fn => fn(event, payload));
|
|
} else {
|
|
const h = ls.get(event);
|
|
if (h) h.forEach(fn => fn(payload));
|
|
}
|
|
}, { event, payload, wild: !!wild });
|
|
}
|
|
|
|
(async () => {
|
|
const browser = await chromium.launch({ headless: true });
|
|
const page = await browser.newPage();
|
|
const pageErrors = [];
|
|
page.on("pageerror", e => pageErrors.push(String(e)));
|
|
|
|
await page.goto(process.env.TARGET_URL, {
|
|
waitUntil: "domcontentloaded",
|
|
timeout: 30000,
|
|
});
|
|
await page.waitForSelector("#chat-textarea", { timeout: 15000 });
|
|
await page.waitForFunction(
|
|
() =>
|
|
typeof App !== "undefined" &&
|
|
App.getRpc &&
|
|
App.getRpc()?.state === "connected",
|
|
{ timeout: 15000 }
|
|
);
|
|
|
|
// Record guard drops in the chat diagnostics log.
|
|
await page.evaluate(() =>
|
|
localStorage.setItem("opensquilla.chat.debug.enabled", "1")
|
|
);
|
|
|
|
// task-B takes over the live stream and renders its own work.
|
|
await emit(page, "task.running", { task_id: "task-B" });
|
|
await emit(page, "session.event.text_delta", {
|
|
text: "Generating HTML",
|
|
task_id: "task-B",
|
|
});
|
|
await emit(page, "session.event.tool_use_start", {
|
|
task_id: "task-B",
|
|
tool_use_id: "tb1",
|
|
tool_name: "write_html",
|
|
});
|
|
// Stale task-A tool — must be dropped.
|
|
await emit(page, "session.event.tool_use_start", {
|
|
task_id: "task-A",
|
|
tool_use_id: "ta1",
|
|
tool_name: "create_pdf_py_stale",
|
|
});
|
|
// Untagged legacy event — must still render (lenient guard).
|
|
await emit(page, "session.event.tool_use_start", {
|
|
tool_use_id: "leg1",
|
|
tool_name: "legacy_untagged_tool",
|
|
});
|
|
// Stale task-A terminal failure — must not end the stream or render.
|
|
await emit(
|
|
page,
|
|
"task.failed",
|
|
{ task_id: "task-A", terminal_message: "STALE_PPTX_FAILURE_MARKER" },
|
|
true
|
|
);
|
|
|
|
await page.waitForTimeout(300);
|
|
|
|
const result = await page.evaluate(() => {
|
|
const log = JSON.parse(
|
|
localStorage.getItem("opensquilla.chat.debugLog") || "[]"
|
|
);
|
|
const labels = log.map(e => e.label);
|
|
const appendNames = log
|
|
.filter(e => e.label === "tool_call.append.done")
|
|
.map(e => (e.data && e.data.name) || "");
|
|
const bodyText = document.body ? document.body.textContent : "";
|
|
return {
|
|
droppedStaleTool: labels.includes(
|
|
"event.tool_use_start.drop.foreign_task"
|
|
),
|
|
droppedStaleTerminal: labels.includes(
|
|
"event.terminal.drop.foreign_task"
|
|
),
|
|
appendNames,
|
|
bodyHasActiveTool: bodyText.includes("write_html"),
|
|
bodyHasUntaggedTool: bodyText.includes("legacy_untagged_tool"),
|
|
bodyHasStaleTool: bodyText.includes("create_pdf_py_stale"),
|
|
bodyHasStaleError: bodyText.includes("STALE_PPTX_FAILURE_MARKER"),
|
|
};
|
|
});
|
|
result.pageErrors = pageErrors;
|
|
console.log(JSON.stringify(result));
|
|
await browser.close();
|
|
})().catch(err => {
|
|
console.error(err && err.stack ? err.stack : String(err));
|
|
process.exit(1);
|
|
});
|
|
"""
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
env = os.environ.copy()
|
|
env["OPENSQUILLA_STATE_DIR"] = str(tmp_path / "state")
|
|
env["OPENSQUILLA_LOG_DIR"] = str(tmp_path / "logs")
|
|
# Serve the legacy static chat UI (the surface that hosts chat.js).
|
|
env["OPENSQUILLA_CONTROL_UI_FRONTEND"] = "legacy"
|
|
server = subprocess.Popen(
|
|
[sys.executable, str(server_script)],
|
|
cwd=Path.cwd(),
|
|
env=env,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
)
|
|
try:
|
|
_wait_for_health(port, server)
|
|
_install_playwright(tmp_path)
|
|
result = subprocess.run(
|
|
[_node(), str(browser_script)],
|
|
cwd=tmp_path,
|
|
env=dict(env, TARGET_URL=f"http://127.0.0.1:{port}/control/chat"),
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=90,
|
|
)
|
|
assert result.returncode == 0, result.stderr or result.stdout
|
|
payload = json.loads(result.stdout.strip().splitlines()[-1])
|
|
finally:
|
|
_stop_process(server)
|
|
|
|
assert payload["pageErrors"] == [], payload["pageErrors"]
|
|
# Guard fired: the stale task's late tool + terminal were dropped.
|
|
assert payload["droppedStaleTool"], "stale task-A tool_use_start was not dropped"
|
|
assert payload["droppedStaleTerminal"], "stale task-A terminal was not dropped"
|
|
# Stale task-A artifacts never rendered into task-B's turn.
|
|
assert not payload["bodyHasStaleTool"], "stale tool card leaked into the turn"
|
|
assert not payload["bodyHasStaleError"], "stale terminal error leaked into the turn"
|
|
assert "create_pdf_py_stale" not in payload["appendNames"]
|
|
# task-B's own tool and untagged legacy events still flow.
|
|
assert payload["bodyHasActiveTool"], "active task tool did not render"
|
|
assert payload["bodyHasUntaggedTool"], "untagged legacy tool was wrongly dropped"
|
|
assert "write_html" in payload["appendNames"]
|
|
assert "legacy_untagged_tool" in payload["appendNames"]
|