From e4de2572a4ce89e04f9b4af5bcbd8dde1cb92bba Mon Sep 17 00:00:00 2001 From: YAN YIXIN Date: Thu, 5 Nov 2026 22:02:24 +0800 Subject: [PATCH] [test] browser: keep an Agent reply running across project switches Add an isolated browser check for #81 using the gated assistant fixture. It sends a message, holds the reply after its first sentence, and switches to another project through the project menu. It checks that the run keeps going while away, that returning re-attaches to the still-running reply instead of showing it as stopped, that the view then receives the full reply without the stop endpoint ever being called, and that an explicit Stop after leaving and returning still stops the run. The check fails on the code before #100 (the reply is saved as stopped after the switch) and passes on main. Refs #81 Co-Authored-By: Claude Opus 5.5 --- docs/development/validation.md | 2 +- tests/browser/assistant_run_navigation.mjs | 182 +++++++++++++++++++++ 2 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 tests/browser/assistant_run_navigation.mjs diff --git a/docs/development/validation.md b/docs/development/validation.md index 715f30fc..7d083d05 100644 --- a/docs/development/validation.md +++ b/docs/development/validation.md @@ -76,7 +76,7 @@ node tests/browser/mp4_export.mjs # 合成 MP4(WebCodecs + 软件 脚本需要可导入的 Playwright 模块和 Chromium。按脚本约定通过 `PLAYWRIGHT_MODULE`、`CHROMIUM_PATH` 指定本机安装;它们不是所有环境中默认可用的项目依赖。以上环境变量语法适用于 POSIX shell,其他终端使用对应设置方式。测试使用的工程入口和前置条件以脚本为准。 -AI 多会话检查使用同一隔离 API,但启动 `creation_server.py` 时额外传入 `--assistant-fixture`,再运行 `node tests/browser/assistant_sessions.mjs`。该检查通过真实 UI、接口与项目文件验证新建、历史搜索、切换、草稿和附件恢复、刷新、失败重试及输入撤销隔离;仅模型回复使用可暂停的替身,不调用真实模型。 +AI 多会话检查使用同一隔离 API,但启动 `creation_server.py` 时额外传入 `--assistant-fixture`,再运行 `node tests/browser/assistant_sessions.mjs`。该检查通过真实 UI、接口与项目文件验证新建、历史搜索、切换、草稿和附件恢复、刷新、失败重试及输入撤销隔离;仅模型回复使用可暂停的替身,不调用真实模型。同一环境下运行 `node tests/browser/assistant_run_navigation.mjs`,验证回复进行中切换项目只断开当前视图:Agent 继续运行,返回后重新接入并收到完整回复,全程不调用停止接口;切走再返回后显式停止仍然有效。 ## 证据记录 diff --git a/tests/browser/assistant_run_navigation.mjs b/tests/browser/assistant_run_navigation.mjs new file mode 100644 index 00000000..6cc99bd7 --- /dev/null +++ b/tests/browser/assistant_run_navigation.mjs @@ -0,0 +1,182 @@ +/** Switching projects while the Agent replies only detaches the view: the run + * keeps going, the reply resumes on return, and only Stop stops it (#81). + * Start creation_server.py with --assistant-fixture before running this check. + */ +import assert from "node:assert/strict"; +import { launchBrowser } from "./engine.mjs"; + +const base = process.env.CREATION_WEB_URL ?? "http://127.0.0.1:5179"; +const headers = { + "X-OpenDirector-Request": "1", + "X-OpenDirector-Session": crypto.randomUUID(), +}; +async function api(path, value) { + const response = await fetch(base + "/api" + path, { + headers: { ...headers, "Content-Type": "application/json" }, + ...(value ? { method: "POST", body: JSON.stringify(value) } : {}), + }); + assert.ok(response.ok, await response.clone().text()); + return response.json(); +} +async function until(read, matches, label) { + const deadline = Date.now() + 15000; + while (true) { + const value = await read(); + if (matches(value)) return value; + assert.ok(Date.now() < deadline, `${label}: ${JSON.stringify(value)}`); + await new Promise((resolve) => setTimeout(resolve, 100)); + } +} +const health = await api("/creation-test-health"); +assert.equal(health.isolated, true); +assert.equal(health.assistantFixture, true); +const create = async (name) => { + const projectId = crypto.randomUUID(); + await api(`/creation/projects/${projectId}/commands`, { + type: "project.create", + projectId, + commandId: crypto.randomUUID(), + payload: { name }, + }); + return projectId; +}; +const tag = crypto.randomUUID().slice(0, 8); +const nameA = `导航 A ${tag}`, + nameB = `导航 B ${tag}`; +const projectA = await create(nameA); +await create(nameB); +const path = `/creation/projects/${projectA}/assistant`; + +const browser = await launchBrowser(); +try { + const page = await browser.newPage({ + viewport: { width: 1440, height: 1000 }, + }); + page.setDefaultTimeout(15000); + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + const stops = []; + page.on("request", (request) => { + if (/\/assistant\/turns\/[^/]+\/stop$/.test(request.url())) + stops.push(request.url()); + }); + await page.route(/\/assistant\/runtimes(?:\?.*)?$/, (route) => + route.fulfill({ + json: [ + { + id: "default", + label: "Default", + group: "default", + modelSource: "settings", + available: true, + status: "ready", + modelOverride: false, + supportsSkills: true, + }, + ], + }), + ); + await page.addInitScript((id) => { + localStorage.setItem("opendirector.lastUnifiedFilesystemProject", id); + localStorage.setItem("opendirector.modelSetup.v1", "dismissed"); + }, projectA); + await page.goto(base); + await page + .getByText("已保存到本机", { exact: true }) + .waitFor({ state: "attached" }); + const panel = page.getByRole("complementary", { + name: "AI 对话", + exact: true, + }); + const editor = panel.getByRole("textbox", { name: "消息内容", exact: true }); + const show = async () => { + if (!(await panel.isVisible())) + await page + .getByRole("button", { name: "与 AI 对话", exact: true }) + .click(); + await editor.waitFor(); + }; + const switchTo = async (name) => { + await page.getByRole("button", { name: "切换项目", exact: true }).click(); + await page.getByRole("menuitemradio", { name, exact: true }).click(); + }; + await show(); + await until( + () => api(path), + (value) => !!value.modelSelections.agent, + "Default model must be saved", + ); + + // Hold the fixture reply after its first sentence. + await api("/creation-test-assistant-gate", { open: false }); + await editor.fill("海边短片"); + await panel.getByRole("button", { name: "发送消息", exact: true }).click(); + await panel.getByText("已收到创作要求。", { exact: false }).waitFor(); + assert.equal((await api(path)).messages.at(-1).status, "streaming"); + + // Leaving the project does not stop the run. + await switchTo(nameB); + await until( + () => + page.evaluate(() => + localStorage.getItem("opendirector.lastUnifiedFilesystemProject"), + ), + (id) => id && id !== projectA, + "Project B must open", + ); + await page.waitForTimeout(1000); + const away = await api(path); + assert.equal(away.messages.at(-1).status, "streaming", "reply after leaving"); + assert.ok(away.activeTurnId, "the run continues while away"); + + // Returning re-attaches: the reply is still running, not stopped. + await switchTo(nameA); + await show(); + await panel.getByText("已收到创作要求。", { exact: false }).waitFor(); + await panel.getByRole("button", { name: "停止回复", exact: true }).waitFor(); + assert.equal(await panel.getByText("已停止回复").count(), 0); + + // The re-attached view receives the rest of the reply. + await api("/creation-test-assistant-gate", { open: true }); + await panel + .getByText("已收到创作要求。这是隔离测试回复。", { exact: true }) + .waitFor(); + const done = await until( + () => api(path), + (value) => value.messages.at(-1).status === "complete", + "Reply must complete", + ); + assert.equal(done.activeTurnId, undefined); + assert.deepEqual(stops, [], "navigation must not call stop"); + + // An explicit stop after leaving and returning still stops the run. + await api("/creation-test-assistant-gate", { open: false }); + await editor.fill("森林短片"); + await panel.getByRole("button", { name: "发送消息", exact: true }).click(); + await until( + () => api(path), + (value) => + value.messages.length === 4 && + value.messages.at(-1).status === "streaming", + "Second reply must start", + ); + await switchTo(nameB); + await page.waitForTimeout(800); + await switchTo(nameA); + await show(); + const stop = panel.getByRole("button", { name: "停止回复", exact: true }); + await until(() => stop.isEnabled(), Boolean, "Stop must be available"); + await stop.click(); + const stopped = await until( + () => api(path), + (value) => value.messages.at(-1).status === "stopped", + "Explicit stop must persist", + ); + assert.equal(stopped.activeTurnId, undefined); + await panel.getByText("已停止回复").waitFor(); + assert.deepEqual(errors, []); + console.log("assistant run navigation ok"); +} finally { + await api("/creation-test-assistant-gate", { open: true }).catch(() => {}); + await browser.close(); +}