/** * End-to-end test for the draft-state new-conversation flow: * - /chat/new lets you pick Model / approval mode up front; the Session is only created when * the first message is sent, and all four selections land faithfully in its meta; * - the draft auto-caches (body persisted via debounce): after a page reload, both the body and * the selections are restored, and the cache clears once sending succeeds; * - the sidebar defaults to grouping by Workspace: temporary workspaces merge into one * "临时工作区" group, a named directory groups under its basename, and that group header's * "+" pre-fills the draft's Workspace (via router state, applied once per navigation — a * manual change made afterwards survives a reload instead of being re-overridden); * - the group header's hover pin toggle lifts a group above the unpinned ones in its mode and * persists per Project (order re-checked after a reload); * - after switching the sidebar to agent mode (toggle persisted in localStorage), the agent * group header's "+" creates a draft scoped to that group's Agent (explicitly set via router * state, overriding the cache); * - both switch commands are **staged**: `/model` and `/agent` pin a chip and send nothing, the * chip is cached with the body text (it survives a reload), and pressing Enter afterwards is * what actually forks the conversation onto the picked model / hands it to the picked Agent — * carrying the text typed after the pick into the new conversation. */ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, dirname, join } from "node:path"; 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 = "draftuser"; const P = "password123"; test("draft: pick model/approval -> reload restores them -> send creates the session and clears the cache -> sidebar + scopes the Agent", async ({ page, }) => { // Draft keys are isolated by user x Project/Session (#68); building the key needs userId (i.e. the username). const userId = (await provisionAndLogin(page.request, U, P)).userId; const projects = await (await page.request.get(`${BASE}/api/projects`)).json(); const projectId = projects.projects[0].projectId; // Two models: claude-4-8 as default, plus claude-4-8-mini for the draft to switch to (both point at the mock). 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, }, { provider: "custom", modelId: "claude-4-8-mini", apiKey: "sk-mock", baseUrl: MOCK, contextWindow: 100000, }, ], }, }); expect(put.ok(), "put models").toBeTruthy(); // The only builtin Agent is default_agent, so the /agent handoff and sidebar group-header "+" targets use a custom-created Agent. const created = await page.request.post(`${BASE}/api/projects/${projectId}/agents`, { data: { agentId: "agent_helper", name: "Helper Agent" }, }); expect(created.ok(), "create helper agent").toBeTruthy(); // No Session exists yet: entering the site lands on the draft page (the brand heading marks the draft page). await page.goto(`${BASE}/chat`); await expect(page.getByRole("heading", { name: "PenguinHarness" })).toBeVisible(); const ta = page.getByPlaceholder(/输入消息/); // `/agent` is a SESSION command: a draft has no conversation to hand over, and its Agent is // chosen by the draft page's own selector — so the slash menu must not offer it here (the // staged-handoff flow itself is covered in the session section below). The menu does open on // the same prefix, matching the installed `/agent-*` skills — which is what makes the absent // command row a real assertion rather than a menu that simply never appeared. await ta.fill("/agent"); await expect(page.getByRole("button", { name: /^\/agent-creation/ })).toBeVisible(); await expect( page.getByRole("button", { name: "/agent 交给其他 Agent,发送时开启新会话" }), ).toHaveCount(0); await ta.fill("Draft body must not be lost"); // Switch the model: the selector sits to the left of the send button, opens downward, with a quick-search field at the top. await page.getByRole("button", { name: "选择模型" }).click(); await page.getByPlaceholder(/搜索模型/).fill("mini"); await page.getByRole("button", { name: /claude-4-8-mini/ }).click(); // Switch the approval mode to read-only (the trigger button shows the Chinese description). await page.getByRole("button", { name: "审批模式" }).click(); await page.getByRole("button", { name: /放行只读/ }).click(); await expect(page.getByRole("button", { name: "审批模式" })).toContainText("放行只读"); // Conversation-time thinking level (backed by the Agent settings): the picker shows the // seeded default (medium, short name 中); the menu carries a title bar and the short-name // rows 低/中/高/极高 only — no descriptions, no default row, and no 无 (many models cannot // disable thinking); picking 高 writes straight through to the Agent config, so the session // created on send runs with it and it becomes the Agent's new default. const thinkingBtn = page.getByRole("button", { name: "思考等级" }); await expect(thinkingBtn).toContainText("中"); await thinkingBtn.click(); await expect(page.getByText("思考等级", { exact: true })).toBeVisible(); // menu title bar await expect(page.getByRole("button", { name: "低", exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: "无", exact: true })).toHaveCount(0); await page.getByRole("button", { name: "高", exact: true }).click(); await expect(thinkingBtn).toContainText("高"); await expect .poll(async () => { const cfg = await ( await page.request.get(`${BASE}/api/projects/${projectId}/agents/default_agent/config`) ).json(); return cfg.config.model?.thinkingLevel; }) .toBe("high"); // Reload only after the body is persisted via debounce: both the body and the two selections should restore from the cache. const draftKey = `penguin.chatDraft.${userId}.${projectId}`; await expect .poll(() => page.evaluate((k) => localStorage.getItem(k), draftKey)) .toContain("Draft body must not be lost"); await page.reload(); await expect(ta).toHaveValue("Draft body must not be lost"); // After restoring, the cursor lands at the end of the draft (focus defaults to the start, so it must be explicitly moved to the end to keep typing). await expect .poll(() => page.evaluate(() => { const el = document.querySelector("textarea"); return el ? el.selectionStart === el.value.length && el.value.length > 0 : false; }), ) .toBe(true); await expect(page.getByRole("button", { name: "选择模型" })).toContainText("claude-4-8-mini"); await expect(page.getByRole("button", { name: "审批模式" })).toContainText("放行只读"); // The thinking level is NOT draft state: it restores from the Agent config (written through above), not the cache. await expect(page.getByRole("button", { name: "思考等级" })).toContainText("高"); // Send: the Session is only created now, and the selections land faithfully in its meta. await page.getByRole("button", { name: "发送" }).click(); await page.waitForURL(/\/chat\/session-/); const firstSessionId = page.url().split("/chat/")[1]; const first = await (await page.request.get(`${BASE}/api/sessions/${firstSessionId}`)).json(); expect(first.session.agentId).toBe("default_agent"); expect(first.session.modelId).toBe("claude-4-8-mini"); expect(first.session.provider).toBe("custom"); expect(first.session.approvalMode).toBe("read-only"); // session_meta holds per-session invariants only: the thinking level became a per-turn // Task parameter and is no longer recorded in the trace meta. The session composer shows // the editable per-turn picker instead of the old read-only tag: while the user hasn't // picked, it displays the Agent config's level (auto-follow — "high" was written through // above) and sends omit the level; a pick sticks and rides on every subsequent send. const replay = await ( await page.request.get(`${BASE}/api/sessions/${firstSessionId}/messages`) ).json(); const meta = replay.messages.find((m) => m.type === "session_meta"); expect(meta?.payload?.thinking_level).toBeUndefined(); await expect(page.getByTitle("思考等级:高")).toBeVisible(); // On a successful send the cache clears — except the model selection, which carries over as // the next conversation's default (switch-becomes-default, like the thinking level above). await expect .poll(() => page.evaluate((k) => localStorage.getItem(k), draftKey)) .toContain("claude-4-8-mini"); expect(await page.evaluate((k) => localStorage.getItem(k), draftKey)).not.toContain( "Draft body must not be lost", ); // —— Default grouping: the sidebar groups Sessions by Workspace — the session just created // used an auto-created temporary workspace, so it lands in the merged "临时工作区" group. // Scoped to the sidebar (