c380113e26
Co-authored-by: Alice <alice@prismshadow.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
144 lines
6.7 KiB
JavaScript
144 lines
6.7 KiB
JavaScript
/**
|
||
* 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();
|
||
});
|