/** * The subagent (run_subagent) card survives a **reload**: the parent Trace only stores a single * session_meta pointer for the child session; the server's readMessages expands the child Trace * from that pointer and fills in the origin chain, and the frontend reattaches the child session * to the tool card from that. Also verifies the child session's title is generated by the model * from **the child session's own conversation** (the mock only replies with "Subagent TODO * summary" when the title request sees the child's own answer), and that discovery-based * backfill must not collapse it to NULL. * * Standalone spec: shares one server with chat.spec.mjs, so it registers its own user here * (not the first user; registration auto-provisions a `project-<8hex>`), independent of * chat.spec's execution order. */ 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 = "subuser"; const P = "password123"; test("subagent card survives a reload; child session title is generated from its own conversation", async ({ page, }) => { await provisionAndLogin(page.request, U, P); // Account creation auto-provisions a default Project; must use it (the frontend selects it // too) — don't create another one. const projects = await (await page.request.get(`${BASE}/api/projects`)).json(); expect(projects.projects, "auto-provisioned project").toHaveLength(1); 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 agentId = "default_agent"; // Approval defaults to allow-all: child sessions inherit the parent's approval mode, no // manual approval needed. const sessRes = await page.request.post( `${BASE}/api/projects/${projectId}/agents/${agentId}/sessions`, { data: { provider: "custom", modelId: "claude-4-8" } }, ); expect(sessRes.ok(), `create session: ${await sessRes.text()}`).toBeTruthy(); const sess = await sessRes.json(); const sessionId = sess.session.sessionId; await page.goto(`${BASE}/chat/${sessionId}`); const ta = page.getByPlaceholder(/输入消息/); await ta.waitFor(); await ta.fill("run a subagent"); await page.getByRole("button", { name: "发送" }).click(); // Parent's final answer (turn 2): the whole turn has ended. Nothing changes with streaming // after this, so assertions are deterministic (turn end collapses the "Reasoning & tools" // group, and child session end collapses the child session card). await expect(page.getByText("Command finished; the result looks as expected.")).toBeVisible(); /** Expand the "Reasoning & tools" group → expand the child session card → child session body visible. */ const openSubagent = async () => { await page .getByRole("button", { name: /运行完毕/ }) .first() .click(); await expect(page.getByText("run_subagent").first()).toBeVisible(); const card = page.getByRole("button", { name: /子会话/ }).first(); await expect(card).toBeVisible(); await card.click(); await expect(page.getByText("Subagent report: 3 TODOs").first()).toBeVisible(); }; // Under live streaming, the child session card is nested inside the run_subagent tool card. await openSubagent(); // --- DOM order: the subagent expansion renders BELOW the tool's own content --- // Expand the run_subagent tool card itself so its arguments block is in the DOM, then assert // the child-session card comes after it in document order (it used to render between the tool // header and the expanded arguments/output). const toolRow = page.locator("button[aria-expanded]").filter({ hasText: "run_subagent" }).first(); await toolRow.click(); await expect(toolRow).toHaveAttribute("aria-expanded", "true"); const argsPre = page.locator("pre", { hasText: '"prompt"' }).first(); await expect(argsPre).toBeVisible(); const subagentBtn = page.getByRole("button", { name: /子会话/ }).first(); const cardFollowsArgs = await argsPre.evaluate( (pre, card) => Boolean(pre.compareDocumentPosition(card) & Node.DOCUMENT_POSITION_FOLLOWING), await subagentBtn.elementHandle(), ); expect(cardFollowsArgs, "subagent expansion renders below the tool arguments/output").toBe(true); // --- Must still hold after reload (the fix's goal: parent Trace's child session pointer + server-side expansion) --- await page.reload(); await openSubagent(); // --- Child session title: generated by the model from the child session's own conversation (async, poll until persisted) --- const childOf = async () => { const list = await ( await page.request.get(`${BASE}/api/projects/${projectId}/agents/${agentId}/sessions`) ).json(); return list.sessions.find((s) => s.sessionId !== sessionId) ?? null; }; await expect .poll(async () => (await childOf())?.title ?? null, { timeout: 10000 }) .toBe("Subagent TODO summary"); const child = await childOf(); // --- The child's OWN Trace session_meta records source=subagent: written by core's spawn // site, the single source of truth (the server's registration fallback cannot mask this — // it never writes the child Trace), and what the derived list source ultimately rests on. --- const childMessages = await ( await page.request.get(`${BASE}/api/sessions/${child.sessionId}/messages`) ).json(); const childMeta = childMessages.messages.find( (m) => m.type === "session_meta" && !m.origin?.length, ); expect(childMeta, "child trace session_meta").toBeTruthy(); expect(childMeta.payload.source).toBe("subagent"); // --- Sidebar: the child session (source=subagent) nests inside the collapsed "Subagents" // folder (per-origin folders sit parallel to "Archived" within the same temp-workspace // group). Reload first so the sidebar list carries the persisted title/source, and the // folder is back to its default collapsed state. --- await page.reload(); const sidebar = page.getByRole("complementary"); const subagentFolder = sidebar.getByRole("button", { name: "子智能体(1)" }); await expect(subagentFolder, "collapsed Subagents folder").toBeVisible(); // Collapsed by default: the child row is not rendered until the folder is expanded. await expect(sidebar.getByText("Subagent TODO summary")).toHaveCount(0); await subagentFolder.click(); await expect(sidebar.getByText("Subagent TODO summary")).toBeVisible(); });