094ebd6118
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
159 lines
6.9 KiB
JavaScript
159 lines
6.9 KiB
JavaScript
/**
|
|
* Reload mid-stream: while a Task streams a long tool output (or a long assistant text),
|
|
* refreshing the page must bring the in-progress message straight back — the already
|
|
* streamed prefix visible promptly (before the stream finishes), the content still
|
|
* growing live, and the final state identical to a run that was never reloaded.
|
|
*
|
|
* Mechanics under test: GET /messages returns `live` ({cursor, fragments}) while the
|
|
* Task runs; the frontend seeds the synthetic `partial_* start` fragments on top of
|
|
* history and drops the buffered partials the snapshot already covers.
|
|
*
|
|
* The LLM is mock-llm.mjs: "slow stream test" makes it call exec_command with a command
|
|
* that prints one line every 200ms for ~8s (real tool execution, really streamed);
|
|
* "slow text test" streams a 40-chunk text one delta every 200ms.
|
|
*/
|
|
import { test, expect } from "@playwright/test";
|
|
import { provisionAndLogin } from "./auth.mjs";
|
|
|
|
const BASE = process.env.BASE_URL;
|
|
const MOCK = process.env.MOCK_URL;
|
|
const U = "reloaduser";
|
|
const P = "password123";
|
|
|
|
/** Create a session for the user's auto-provisioned project (models PUT is idempotent). */
|
|
async function createSession(page, approvalMode) {
|
|
const projects = await (await page.request.get(`${BASE}/api/projects`)).json();
|
|
const projectId = projects.projects[0].projectId;
|
|
const put = await page.request.put(`${BASE}/api/projects/${projectId}/models`, {
|
|
data: {
|
|
defaultModel: { provider: "custom", modelId: "claude-4-8" },
|
|
models: [
|
|
{
|
|
provider: "custom",
|
|
modelId: "claude-4-8",
|
|
apiKey: "sk-mock",
|
|
baseUrl: MOCK,
|
|
contextWindow: 200000,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
expect(put.ok(), "put models").toBeTruthy();
|
|
const res = await page.request.post(
|
|
`${BASE}/api/projects/${projectId}/agents/default_agent/sessions`,
|
|
{ data: { provider: "custom", modelId: "claude-4-8", approvalMode } },
|
|
);
|
|
expect(res.ok(), `create session: ${await res.text()}`).toBeTruthy();
|
|
return (await res.json()).session.sessionId;
|
|
}
|
|
|
|
/**
|
|
* Keep the running work group open past the end of the turn (it auto-collapses at turn
|
|
* end unless the user toggled it — same trick as chat.spec), then expand the
|
|
* exec_command tool card and return the output <pre> locator.
|
|
*/
|
|
async function openToolOutput(page) {
|
|
const group = page
|
|
.locator("button[aria-expanded]")
|
|
.filter({ hasText: /运行中|运行完毕/ })
|
|
.first();
|
|
await expect(group).toBeVisible();
|
|
if ((await group.textContent())?.includes("运行中")) {
|
|
await group.click(); // toggle → marks the group user-toggled
|
|
await group.click(); // toggle back → deliberately kept open, survives turn end
|
|
} else if ((await group.getAttribute("aria-expanded")) !== "true") {
|
|
await group.click(); // finished and collapsed: open it to reach the card
|
|
}
|
|
const toolCard = page
|
|
.locator("button[aria-expanded]")
|
|
.filter({ hasText: "exec_command" })
|
|
.first();
|
|
await expect(toolCard).toBeVisible();
|
|
if ((await toolCard.getAttribute("aria-expanded")) !== "true") await toolCard.click();
|
|
await expect(toolCard).toHaveAttribute("aria-expanded", "true");
|
|
// Two <pre>s live in the expanded card (arguments, then output); the output one is the
|
|
// only one containing a literal "line 1".
|
|
return page.locator("pre", { hasText: /line 1\b/ }).first();
|
|
}
|
|
|
|
/** Send the "slow stream test" prompt, approve exec_command, and return the output <pre>. */
|
|
async function startSlowToolRun(page, sessionId) {
|
|
await page.goto(`${BASE}/chat/${sessionId}`);
|
|
const ta = page.getByPlaceholder(/输入消息/);
|
|
await ta.waitFor();
|
|
await ta.fill("slow stream test");
|
|
await page.getByRole("button", { name: "发送" }).click();
|
|
await expect(page.getByText("exec_command").first()).toBeVisible();
|
|
await page.getByRole("button", { name: "允许" }).click();
|
|
const outputPre = await openToolOutput(page);
|
|
await expect(outputPre).toContainText("line 3");
|
|
return outputPre;
|
|
}
|
|
|
|
test("in-progress tool output survives a reload: prefix back promptly, still growing, final state matches a never-reloaded run", async ({
|
|
page,
|
|
}) => {
|
|
await provisionAndLogin(page.request, U, P);
|
|
|
|
// --- Control run (never reloaded): capture the final tool output to compare against ---
|
|
const controlId = await createSession(page, "always-ask");
|
|
await startSlowToolRun(page, controlId);
|
|
await expect(page.getByText("Command finished; the result looks as expected.")).toBeVisible({
|
|
timeout: 30_000,
|
|
});
|
|
const controlPre = await openToolOutput(page);
|
|
const controlOutput = await controlPre.textContent();
|
|
expect(controlOutput).toContain("line 40");
|
|
|
|
// --- The run under test: reload while the output is streaming ---
|
|
const sessionId = await createSession(page, "always-ask");
|
|
await startSlowToolRun(page, sessionId);
|
|
await page.reload();
|
|
|
|
// (a) The already-streamed prefix is back promptly — well before the ~8s command ends.
|
|
const outputPre = await openToolOutput(page);
|
|
await expect(outputPre).toContainText("line 2", { timeout: 4000 });
|
|
// Not finished yet: what we see is genuinely the in-progress stream, not the final state.
|
|
await expect(outputPre).not.toContainText("line 40");
|
|
|
|
// (b) The output keeps growing live on the same connection.
|
|
await expect(outputPre).toContainText("line 35", { timeout: 15_000 });
|
|
|
|
// (c) After completion the final state matches the never-reloaded run exactly
|
|
// (every line exactly once — no lost prefix, no duplicated overlap).
|
|
await expect(page.getByText("Command finished; the result looks as expected.")).toBeVisible({
|
|
timeout: 30_000,
|
|
});
|
|
const reloadedOutput = await (await openToolOutput(page)).textContent();
|
|
expect(reloadedOutput).toBe(controlOutput);
|
|
});
|
|
|
|
test("in-progress assistant TEXT survives a reload and keeps streaming", async ({ page }) => {
|
|
await provisionAndLogin(page.request, U, P);
|
|
const sessionId = await createSession(page, "allow-all");
|
|
|
|
await page.goto(`${BASE}/chat/${sessionId}`);
|
|
const ta = page.getByPlaceholder(/输入消息/);
|
|
await ta.waitFor();
|
|
await ta.fill("slow text test");
|
|
await page.getByRole("button", { name: "发送" }).click();
|
|
|
|
const reply = page.locator(".md-body", { hasText: /chunk-1\s/ }).first();
|
|
await expect(reply).toContainText("chunk-3 ");
|
|
await page.reload();
|
|
|
|
// (a) The streamed prefix is back promptly, while the ~8s stream is still going.
|
|
const reloaded = page.locator(".md-body", { hasText: /chunk-1\s/ }).first();
|
|
await expect(reloaded).toContainText("chunk-2 ", { timeout: 4000 });
|
|
await expect(reloaded).not.toContainText("chunk-40");
|
|
|
|
// (b) It keeps streaming live, up to the full reply.
|
|
await expect(reloaded).toContainText("chunk-40", { timeout: 15_000 });
|
|
|
|
// (c) No duplicated prefix: the buffered partials the snapshot covered were dropped,
|
|
// so every chunk appears exactly once.
|
|
const text = await reloaded.textContent();
|
|
expect(text.match(/chunk-5 /g)).toHaveLength(1);
|
|
expect(text.match(/chunk-1 /g)).toHaveLength(1);
|
|
});
|