Merge pull request #1473 from pikasTech/fix/issue-1467-workbench-fake-server

[1467-R4] Workbench fake-server session authority regression
This commit is contained in:
Lyon
2026-06-18 12:42:11 +08:00
committed by GitHub
12 changed files with 118 additions and 85 deletions
@@ -1,4 +1,4 @@
// SPEC: PJ2026-010401 Web工作台 draft-2026-06-17-r0; PJ2026-010403 API契约 draft-2026-06-17-r0.
// SPEC: PJ2026-010401 Web工作台 draft-2026-06-18-r1; PJ2026-010403 API契约 draft-2026-06-18-r1.
// Responsibility: Same-origin fake Workbench API and static server for Playwright browser regression tests.
import { createReadStream, existsSync, statSync } from "node:fs";
@@ -79,7 +79,25 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
if (path === "/auth/session" || path === "/auth/bootstrap") return json(response, 200, authPayload());
if (path === "/auth/login" && method === "POST") return authLoginResponse(response);
if (path === "/v1/workbench/events" && method === "GET") return sse(response);
if (path === "/v1/workbench/events" && method === "GET") return sse(response, url);
if (path === "/v1/workbench/sessions" && method === "GET") {
await delay(state.conversationDelayMs);
return json(response, 200, workbenchSessionListPayload(url));
}
const sessionMessagesMatch = path.match(/^\/v1\/workbench\/sessions\/([^/]+)\/messages$/u);
if (sessionMessagesMatch && method === "GET") {
const sessionId = decodeURIComponent(sessionMessagesMatch[1] ?? "");
const conversation = workbenchConversationBySessionId(sessionId);
return conversation ? json(response, 200, workbenchSessionMessagesPayload(conversation, url)) : json(response, 404, { ok: false, status: 404, error: { code: "session_not_found" } });
}
const workbenchSessionMatch = path.match(/^\/v1\/workbench\/sessions\/([^/]+)$/u);
if (workbenchSessionMatch && method === "GET") {
const sessionId = decodeURIComponent(workbenchSessionMatch[1] ?? "");
if (state.scenarioId === "session-switch-detail-404-isolated" && sessionId === "ses_stale_404") return json(response, 404, { ok: false, status: 404, error: { code: "session_not_found" } });
if (state.scenarioId === "completed-replay-detail-404" && sessionId === "ses_completed") return json(response, 404, { ok: false, status: 404, error: { code: "session_replay_unavailable" } });
const conversation = workbenchConversationBySessionId(sessionId);
return conversation ? json(response, 200, { session: conversation }) : json(response, 404, { ok: false, status: 404, error: { code: "session_not_found" } });
}
if (path === "/v1/workbench/workspace" && method === "GET") return json(response, 200, { workspace: workspacePayload() });
if (/^\/v1\/workbench\/workspace\/[^/]+$/u.test(path) && method === "PATCH") {
const body = await readJson(request);
@@ -413,10 +431,35 @@ function conversationSummary(conversation: ConversationRecord): ConversationReco
return rest as ConversationRecord;
}
function workbenchSessionListPayload(url: URL): JsonRecord {
const includeSessionId = url.searchParams.get("includeSessionId") || "";
const visibleConversations = state.conversations.filter((item) => conversationProjectId(item) === state.projectId);
const conversations = state.listOmitSelected && includeSessionId ? visibleConversations.filter((item) => item.sessionId !== includeSessionId) : visibleConversations;
const summaries = conversations.map((conversation) => conversationSummary(conversation));
return { projectId: null, sessions: summaries, conversations: summaries, count: summaries.length };
}
function workbenchSessionMessagesPayload(conversation: ConversationRecord, url: URL): JsonRecord {
const messages = Array.isArray(conversation.messages) ? conversation.messages : [];
const requestedLimit = Number(url.searchParams.get("limit") ?? messages.length);
const limit = Math.max(1, Number.isFinite(requestedLimit) ? Math.trunc(requestedLimit) : messages.length || 100);
const page = messages.slice(0, limit);
return { sessionId: conversation.sessionId ?? null, conversationId: conversation.conversationId, messages: page, count: page.length, total: messages.length, hasMore: messages.length > page.length };
}
function conversationById(id: string): ConversationRecord | null {
return state.conversations.find((conversation) => conversation.conversationId === id) ?? null;
}
function conversationBySessionId(id: string): ConversationRecord | null {
return state.conversations.find((conversation) => conversation.sessionId === id) ?? null;
}
function workbenchConversationBySessionId(id: string): ConversationRecord | null {
const conversation = conversationBySessionId(id);
return conversation && conversationProjectId(conversation) === state.projectId ? conversation : null;
}
function deleteConversationById(id: string): void {
const conversation = conversationById(id);
if (!conversation) return;
@@ -480,13 +523,13 @@ function tracePayload(traceId: string, url: URL): JsonRecord {
return { ...turn, events: page, eventCount: events.length, hasMore, fullTraceLoaded: !hasMore, nextSinceSeq: hasMore ? lastSeq : null, range: { sinceSeq, returned: page.length, total: events.length } };
}
function sse(response: ServerResponse): void {
function sse(response: ServerResponse, url: URL): void {
response.writeHead(200, {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache",
connection: "keep-alive"
});
writeSse(response, "workbench.connected", { type: "connected", projectId: state.projectId, workspaceId: state.workspaceId });
writeSse(response, "workbench.connected", { type: "connected", sessionId: url.searchParams.get("sessionId"), workspaceId: state.workspaceId });
if (state.terminalScript) {
const scenarioId = state.scenarioId;
setTimeout(() => {
+17 -2
View File
@@ -377,6 +377,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
conversations.value = mergeConversationIntoList(conversations.value, persistedConversation);
if (activeConversationId.value === conversation.conversationId || activeConversationId.value === authoritativeConversationId) {
replaceActiveConversationSelection(authoritativeConversationId);
setActiveSessionSelection(sessionIdFromConversation(persistedConversation));
const persistedProjectId = conversationProjectId(persistedConversation, selectedProjectId);
workspace.value = workspaceWithSelectedConversation(persistedWorkspace, persistedConversation, persistedProjectId);
rememberWorkspaceSnapshot(persistedProjectId, workspace.value);
@@ -387,7 +388,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
void hydrateTurnStatusAuthority(messages.value);
void hydrateTraceEvents(messages.value);
void refreshSelectedSessionStatus(persistedConversation);
void refreshConversations(conversation.conversationId);
void refreshConversations(authoritativeConversationId);
restartRealtime(requestEpoch === workspaceSelectionEpoch.value ? "create-session" : "create-session-persisted");
}
return { ok: true, error: null };
@@ -453,6 +454,10 @@ export const useWorkbenchStore = defineStore("workbench", () => {
explicitSessionId.value = normalizeWorkbenchSessionId(sessionId) ?? explicitSessionId.value;
}
function replaceActiveSessionSelection(sessionId: string | null | undefined): void {
explicitSessionId.value = normalizeWorkbenchSessionId(sessionId);
}
function replaceActiveConversationSelection(conversationId: string | null | undefined): void {
explicitConversationId.value = normalizeWorkbenchConversationId(conversationId);
}
@@ -1118,6 +1123,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
function applySelectedConversationDetail(conversation: ConversationRecord, baseWorkspace: WorkspaceRecord | null, selectedProjectId: string, previousConversationId: string | null, previousMessages: ChatMessage[]): void {
setActiveConversationSelection(conversation.conversationId);
setActiveSessionSelection(sessionIdFromConversation(conversation));
workspace.value = workspaceWithSelectedConversation(baseWorkspace, conversation, selectedProjectId);
rememberWorkspaceSnapshot(selectedProjectId, workspace.value);
rememberConversationDetail(conversation);
@@ -1139,13 +1145,21 @@ export const useWorkbenchStore = defineStore("workbench", () => {
if (previousId && previousId !== targetId) {
const previousConversation = conversations.value.find((item) => item.conversationId === previousId) ?? null;
replaceActiveConversationSelection(previousId);
replaceActiveSessionSelection(sessionIdFromConversation(previousConversation));
if (previousConversation) {
const previousProjectId = conversationProjectId(previousConversation, activeProjectId.value);
workspace.value = workspaceWithSelectedConversation(workspace.value, previousConversation, previousProjectId);
rememberWorkspaceSnapshot(previousProjectId, workspace.value);
rememberConversationDetail(previousConversation);
messages.value = restoreMessagesTraceAuthority(messagesFromSelectedConversation(previousConversation, previousId, targetId, previousMessages), previousMessages);
void hydrateTurnStatusAuthority(messages.value);
void hydrateTraceEvents(messages.value);
reattachRestoredActiveTrace();
} else {
messages.value = previousMessages;
}
messages.value = previousMessages;
} else {
replaceActiveSessionSelection(null);
messages.value = [];
}
currentRequest.value = null;
@@ -1285,6 +1299,7 @@ async function loadWorkbenchSessionConversation(sessionId: string, seed: Convers
api.workbench.session(id),
api.workbench.sessionMessages(id, { limit: 100 })
]);
if (!detail.ok) return null;
const detailConversation = detail.ok ? conversationFromWorkbenchSession(detail.data?.session) : null;
const base = detailConversation ?? seed;
if (!base) return null;
@@ -8,15 +8,16 @@
"workspace": "/home/ubuntu/workspace/hwlab-v03",
"source": "controlled Workbench regression sample, redacted and minimized from live API shape"
},
"capturedAt": "2026-06-17T09:40:00.000Z",
"capturedAt": "2026-06-18T04:20:00.000Z",
"sourceProbe": {
"probedAt": "2026-06-17T10:24:00.000Z",
"probedAt": "2026-06-18T04:20:00.000Z",
"credentialSourceRef": "/root/.config/hwlab-v03/master-server-admin-api-key.env",
"valuesPrinted": false,
"endpoints": [
{ "path": "/auth/session", "status": 200, "topLevelKeys": ["actor", "authMethod", "authenticated", "contractVersion", "session", "sessionExpiresAt", "sessionTtlSeconds", "setupRequired"] },
{ "path": "/v1/workbench/workspace?projectId=prj_hwpod_workbench", "status": 200, "topLevelKeys": ["actor", "contractVersion", "ok", "status", "workspace"] },
{ "path": "/v1/agent/conversations?projectId=prj_hwpod_workbench", "status": 200, "topLevelKeys": ["actor", "contractVersion", "conversations", "count", "defaultConversation", "ok", "projectId", "status"] }
{ "path": "/v1/workbench/sessions", "status": 200, "topLevelKeys": ["conversations", "count", "ok", "projectId", "sessions", "status"] },
{ "path": "/v1/workbench/sessions/ses_running", "status": 200, "topLevelKeys": ["ok", "session", "status"] },
{ "path": "/v1/workbench/sessions/ses_running/messages?limit=100", "status": 200, "topLevelKeys": ["conversationId", "count", "hasMore", "messages", "ok", "sessionId", "status", "total"] }
]
},
"schemaVersion": "workbench-e2e-capture.v1",
@@ -4,24 +4,24 @@ import { selectors, sessionTab } from "../fixtures/selectors";
test.use({ scenarioId: "deep-link" });
test("deep link hydrates the same authority path as tab selection", async ({ page }, testInfo) => {
await gotoWorkbench(page, "/workbench/sessions/cnv_failed?projectId=prj_hwpod_workbench");
await gotoWorkbench(page, "/workbench/sessions/ses_failed");
await expect(page.locator(sessionTab("cnv_failed"))).toHaveAttribute("data-active", "true");
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="failed"]`)).toContainText("缺少受控依赖");
await expect(page.locator(`${selectors.traceTimeline}[data-status="failed"]`)).toBeVisible();
await expect.poll(async () => (await fakeServerState(page)).selectedConversationId).toBe("cnv_failed");
await expect(page).toHaveURL(/\/workbench\/sessions\/ses_failed$/u);
await saveScreenshot(page, testInfo, "deep-link-failed");
});
test.describe("deep link stale workspace authority", () => {
test.use({ scenarioId: "deep-link-stale-workspace-authority" });
test("deep link persists route conversation over stale workspace selection", async ({ page }) => {
await gotoWorkbench(page, "/workbench/sessions/cnv_failed?projectId=prj_hwpod_workbench");
test("deep link persists route session over stale workspace selection", async ({ page }) => {
await gotoWorkbench(page, "/workbench/sessions/ses_failed");
await expect(page.locator(sessionTab("cnv_failed"))).toHaveAttribute("data-active", "true");
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="failed"]`)).toContainText("缺少受控依赖");
await expect.poll(async () => (await fakeServerState(page)).selectedConversationId).toBe("cnv_failed");
await expect(page).toHaveURL(/\/workbench\/sessions\/ses_failed$/u);
const state = await fakeServerState(page);
const selectRequests = state.selectRequests as Array<{ conversationId?: unknown }>;
expect(selectRequests.some((request) => request.conversationId === "cnv_failed")).toBeTruthy();
expect(selectRequests.some((request) => request.conversationId === "cnv_failed")).toBeFalsy();
});
});
@@ -4,25 +4,15 @@ import { selectors, sessionTab } from "../fixtures/selectors";
test.use({ scenarioId: "deleted-session-deeplink" });
test("fresh deep link does not restore an archived session as active", async ({ page }, testInfo) => {
const projectId = "prj_hwpod_workbench";
const workspace = await page.request.get(`/v1/workbench/workspace?projectId=${projectId}`);
expect(workspace.status()).toBe(200);
const workspaceBody = await workspace.json() as { workspace: { workspaceId: string } };
const detail = await page.request.get(`/v1/agent/conversations/cnv_deleted?projectId=${projectId}`);
const detail = await page.request.get("/v1/workbench/sessions/ses_deleted");
expect(detail.status()).toBe(200);
expect((await detail.json()).conversation.status).toBe("archived");
expect((await detail.json()).session.status).toBe("archived");
const list = await page.request.get(`/v1/agent/conversations?projectId=${projectId}&includeConversationId=cnv_deleted`);
const list = await page.request.get("/v1/workbench/sessions?includeSessionId=ses_deleted");
expect(list.status()).toBe(200);
expect(((await list.json()).conversations as Array<{ conversationId: string; status?: string }>).some((item) => item.conversationId === "cnv_deleted" && item.status === "archived")).toBe(true);
const select = await page.request.post(`/v1/workbench/workspace/${workspaceBody.workspace.workspaceId}/select-conversation`, {
data: { projectId, conversationId: "cnv_deleted", sessionId: "ses_deleted", updatedByClient: "playwright" }
});
expect(select.status()).toBe(404);
await gotoWorkbench(page, `/workbench/sessions/cnv_deleted?projectId=${projectId}`);
await gotoWorkbench(page, "/workbench/sessions/ses_deleted");
await expect(page.locator(sessionTab("cnv_deleted"))).toHaveCount(0);
await expect(page.locator(sessionTab("cnv_completed"))).toHaveCount(1);
await expect(page.locator(".conversation-error-hint")).toContainText(/archived||session/u);
@@ -18,22 +18,21 @@ test("SSE terminal event and REST gap fill replay to the same terminal UI", asyn
await expect(page.locator(`${selectors.traceTimeline}[data-status="completed"]`)).toBeVisible();
});
test("fresh deep link replays completed conversation from the project-scoped authority", async ({ page }, testInfo) => {
const projectId = "prj_hwpod_workbench";
const detail = await page.request.get(`/v1/agent/conversations/cnv_completed?projectId=${projectId}`);
test("fresh deep link replays completed conversation from the session authority", async ({ page }, testInfo) => {
const detail = await page.request.get("/v1/workbench/sessions/ses_completed");
expect(detail.status()).toBe(200);
const detailBody = await detail.json();
expect(detailBody.conversation.projectId).toBe(projectId);
expect(detailBody.session.sessionId).toBe("ses_completed");
const turn = await page.request.get(`/v1/agent/turns/trc_completed?projectId=${projectId}`);
const turn = await page.request.get("/v1/agent/turns/trc_completed");
expect(turn.status()).toBe(200);
expect((await turn.json()).status).toBe("completed");
const trace = await page.request.get(`/v1/agent/traces/trc_completed?projectId=${projectId}`);
const trace = await page.request.get("/v1/agent/traces/trc_completed");
expect(trace.status()).toBe(200);
expect((await trace.json()).status).toBe("completed");
await gotoWorkbench(page, `/workbench/sessions/cnv_completed?projectId=${projectId}`);
await gotoWorkbench(page, "/workbench/sessions/ses_completed");
await expect(page.locator(sessionTab("cnv_completed"))).toHaveAttribute("data-active", "true");
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="completed"]`)).toContainText("构建和 Trace 渲染检查完成");
await expect(page.locator(`${selectors.traceTimeline}[data-status="completed"]`)).toBeVisible();
@@ -45,8 +44,8 @@ test.describe("completed replay unavailable", () => {
test.use({ scenarioId: "completed-replay-detail-404" });
test("detail or turn 404 renders diagnostic state instead of an empty conversation", async ({ page }) => {
await gotoWorkbench(page, "/workbench/sessions/cnv_completed?projectId=prj_hwpod_workbench");
await expect(page.locator(".conversation-error-hint")).toContainText(/conversation|replay|unavailable/u);
await gotoWorkbench(page, "/workbench/sessions/ses_completed");
await expect(page.locator(".conversation-error-hint")).toContainText(/session URL not found|conversation|replay|unavailable/u);
await expect(page.locator(selectors.messageCard)).toHaveCount(0);
await expect(page.locator(selectors.sessionTabs)).not.toHaveAttribute("data-loading", "true");
});
@@ -29,7 +29,7 @@ test.describe("server authoritative session create", () => {
await gotoWorkbench(page);
await page.locator(selectors.sessionCreate).click();
await expect(page).toHaveURL(/\/workbench\/sessions\/cnv_server_created/u);
await expect(page).toHaveURL(/\/workbench\/sessions\/ses_server_created/u);
await expect(page.locator('.session-tab[data-conversation-id="cnv_server_created"]')).toHaveAttribute("data-active", "true");
await expect(page.locator('.session-tab[data-conversation-id="cnv_server_created"]')).toHaveAttribute("data-running", "false");
@@ -1,33 +1,19 @@
import { expect, gotoWorkbench, saveScreenshot, test } from "../fixtures/test";
import { selectors, sessionTab } from "../fixtures/selectors";
test.describe("project-scoped conversation details", () => {
test.describe("session authority boundary", () => {
test.use({ scenarioId: "cross-project-detail-boundary" });
test("rejects conversation, turn and trace details outside the requested project", async ({ page }, testInfo) => {
const projectId = "prj_hwpod_workbench";
const conversation = await page.request.get(`/v1/agent/conversations/cnv_cross_project?projectId=${projectId}`);
expect(conversation.status()).toBe(404);
await expect((await conversation.json()).error.code).toBe("conversation_project_mismatch");
test("does not expose a cross-project session through Workbench session routes", async ({ page }, testInfo) => {
const session = await page.request.get("/v1/workbench/sessions/ses_cross_project");
expect(session.status()).toBe(404);
await expect((await session.json()).error.code).toBe("session_not_found");
const turn = await page.request.get(`/v1/agent/turns/trc_cross_project?projectId=${projectId}`);
expect(turn.status()).toBe(404);
await expect((await turn.json()).error.code).toBe("trace_project_mismatch");
const messages = await page.request.get("/v1/workbench/sessions/ses_cross_project/messages");
expect(messages.status()).toBe(404);
await expect((await messages.json()).error.code).toBe("session_not_found");
const trace = await page.request.get(`/v1/agent/traces/trc_cross_project?projectId=${projectId}`);
expect(trace.status()).toBe(404);
await expect((await trace.json()).error.code).toBe("trace_project_mismatch");
const workspace = await page.request.get(`/v1/workbench/workspace?projectId=${projectId}`);
expect(workspace.status()).toBe(200);
const workspaceId = (await workspace.json()).workspace.workspaceId;
const selected = await page.request.post(`/v1/workbench/workspace/${workspaceId}/select-conversation`, {
data: { projectId, conversationId: "cnv_cross_project", sessionId: "ses_cross_project" }
});
expect(selected.status()).toBe(404);
await expect((await selected.json()).error.code).toBe("agent_conversation_not_found");
await gotoWorkbench(page, `/workbench/sessions/cnv_cross_project?projectId=${projectId}`);
await gotoWorkbench(page, "/workbench/sessions/ses_cross_project");
await expect(page.locator(sessionTab("cnv_cross_project"))).toHaveCount(0);
await expect(page.locator(`${selectors.messageCard}[data-conversation-id="cnv_cross_project"]`)).toHaveCount(0);
await saveScreenshot(page, testInfo, "project-boundary-cross-detail");
@@ -7,7 +7,7 @@ test("session switch persists active conversation after reload", async ({ page }
await saveScreenshot(page, testInfo, "session-switch-before");
await page.locator(sessionTab("cnv_completed")).click();
await expect(page).toHaveURL(/\/workbench\/sessions\/cnv_completed/u);
await expect(page).toHaveURL(/\/workbench\/sessions\/ses_completed/u);
await expect(page.locator(sessionTab("cnv_completed"))).toHaveAttribute("data-active", "true");
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="completed"]`)).toContainText("构建和 Trace 渲染检查完成");
await expect.poll(async () => (await fakeServerState(page)).selectedConversationId).toBe("cnv_completed");
@@ -24,12 +24,12 @@ test.describe("empty session switch target", () => {
test.use({ scenarioId: "session-switch-empty-reload" });
test("switching from a filled session to an empty session updates URL and reload target", async ({ page }, testInfo) => {
await gotoWorkbench(page, "/workbench/sessions/cnv_completed?projectId=prj_hwpod_workbench");
await gotoWorkbench(page, "/workbench/sessions/ses_completed");
await expect(page.locator(sessionTab("cnv_completed"))).toHaveAttribute("data-active", "true");
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="completed"]`)).toBeVisible();
await page.locator(sessionTab("cnv_empty")).click();
await expect(page).toHaveURL(/\/workbench\/sessions\/cnv_empty(?:\?projectId=prj_hwpod_workbench)?$/u);
await expect(page).toHaveURL(/\/workbench\/sessions\/ses_empty$/u);
await expect(page.locator(sessionTab("cnv_empty"))).toHaveAttribute("data-active", "true");
await expect(page.locator(selectors.messageCard)).toHaveCount(0);
await expect.poll(async () => (await fakeServerState(page)).selectedConversationId).toBe("cnv_empty");
@@ -41,16 +41,16 @@ test.describe("empty session switch target", () => {
});
test("switching back to a running session reflects URL before reload", async ({ page }) => {
await gotoWorkbench(page, "/workbench/sessions/cnv_running?projectId=prj_hwpod_workbench");
await gotoWorkbench(page, "/workbench/sessions/ses_running");
await expect(page.locator(sessionTab("cnv_running"))).toHaveAttribute("data-active", "true");
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="running"]`)).toBeVisible();
await page.locator(sessionTab("cnv_empty")).click();
await expect(page).toHaveURL(/\/workbench\/sessions\/cnv_empty/u);
await expect(page).toHaveURL(/\/workbench\/sessions\/ses_empty/u);
await expect(page.locator(selectors.messageCard)).toHaveCount(0);
await page.locator(sessionTab("cnv_running")).click();
await expect(page).toHaveURL(/\/workbench\/sessions\/cnv_running/u);
await expect(page).toHaveURL(/\/workbench\/sessions\/ses_running/u);
await expect(page.locator(sessionTab("cnv_running"))).toHaveAttribute("data-active", "true");
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="running"]`)).toBeVisible();
@@ -64,17 +64,18 @@ test.describe("stale session detail isolation", () => {
test.use({ scenarioId: "session-switch-detail-404-isolated" });
test("a visible tab with detail 404 does not clear rail or block later switches", async ({ page }) => {
await gotoWorkbench(page, "/workbench/sessions/cnv_completed?projectId=prj_hwpod_workbench");
await gotoWorkbench(page, "/workbench/sessions/ses_completed");
await expect(page.locator(sessionTab("cnv_completed"))).toHaveAttribute("data-active", "true");
await expect(page.locator(sessionTab("cnv_stale_404"))).toHaveCount(1);
await page.locator(sessionTab("cnv_stale_404")).click();
await expect(page.locator(sessionTab("cnv_completed"))).toHaveAttribute("data-active", "true");
await expect(page.locator(sessionTab("cnv_stale_404"))).toHaveCount(0);
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="completed"]`)).toBeVisible();
await page.locator(sessionTab("cnv_stale_404")).click();
await expect(page.locator(".conversation-error-hint")).toContainText(/session URL not found|unavailable/u);
await expect(page.locator(sessionTab("cnv_completed"))).toHaveCount(1);
await expect(page.locator(sessionTab("cnv_stale_404"))).toHaveCount(1);
await page.locator(sessionTab("cnv_empty")).click();
await expect(page).toHaveURL(/\/workbench\/sessions\/cnv_empty/u);
await expect(page).toHaveURL(/\/workbench\/sessions\/ses_empty/u);
await expect(page.locator(sessionTab("cnv_empty"))).toHaveAttribute("data-active", "true");
await expect(page.locator(selectors.messageCard)).toHaveCount(0);
@@ -10,7 +10,7 @@ test.describe("stale nested workspace trace", () => {
if (/\/v1\/agent\/(?:turns|traces)\/trc_stale_502/u.test(request.url())) staleTraceRequests.push(request.url());
});
await gotoWorkbench(page, "/workbench/sessions/cnv_failed?projectId=prj_hwpod_workbench");
await gotoWorkbench(page, "/workbench/sessions/ses_failed");
await expect(page.locator(sessionTab("cnv_failed"))).toHaveAttribute("data-active", "true");
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="failed"]`)).toContainText("缺少受控依赖");
await expect(page.locator(`${selectors.traceTimeline}[data-status="failed"]`)).toBeVisible();
@@ -28,17 +28,15 @@ test.describe("stale nested workspace trace after terminal restore", () => {
if (/\/v1\/agent\/(?:turns|traces)\/trc_stale_502/u.test(request.url())) staleTraceRequests.push(request.url());
});
await gotoWorkbench(page, "/workbench/sessions/cnv_running?projectId=prj_hwpod_workbench");
await gotoWorkbench(page, "/workbench/sessions/ses_running");
await expect(page.locator(sessionTab("cnv_running"))).toHaveAttribute("data-running", "true");
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="failed"]`)).toContainText("恢复后失败");
await expect(page.locator(`${selectors.traceTimeline}[data-status="failed"]`)).toBeVisible();
await expect(page.locator(selectors.commandSend)).toHaveAttribute("data-action", "turn");
await expect.poll(async () => ((await fakeServerState(page)).workspacePatchRequests as unknown[]).length).toBeGreaterThan(0);
await expect.poll(async () => nestedWorkspaceLastTraceId(await fakeServerState(page))).toBeNull();
const state = await fakeServerState(page);
const patchRequests = state.workspacePatchRequests as Array<{ workspace?: { lastTraceId?: unknown } }>;
expect(patchRequests.at(-1)?.workspace?.lastTraceId ?? null).toBeNull();
expect((state.workspacePatchRequests as unknown[]).length).toBe(0);
expect(nestedWorkspaceLastTraceId(state)).toBe("trc_stale_502");
expect(staleTraceRequests).toEqual([]);
await page.reload();
@@ -26,7 +26,7 @@ test.describe("terminal turn with stale active session authority", () => {
test.use({ scenarioId: "terminal-turn-stale-session-active" });
test("session rail shows terminal status instead of stale active", async ({ page }, testInfo) => {
await gotoWorkbench(page);
await gotoWorkbench(page, "/workbench/sessions/ses_completed");
await expect(page.locator(sessionTab("cnv_completed"))).toHaveAttribute("data-status", "completed");
await expect(page.locator(sessionTab("cnv_completed"))).toHaveAttribute("data-running", "false");
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="completed"]`)).toContainText("构建和 Trace 渲染检查完成");
@@ -2,7 +2,7 @@ import { expect, gotoWorkbench, saveScreenshot, test } from "../fixtures/test";
import { selectors, sessionTab } from "../fixtures/selectors";
test("TraceTimeline renders readable rows and hides backend noise", async ({ page }, testInfo) => {
await gotoWorkbench(page, "/workbench/sessions/cnv_completed");
await gotoWorkbench(page, "/workbench/sessions/ses_completed");
await expect(page.locator(sessionTab("cnv_completed"))).toHaveAttribute("data-active", "true");
const trace = page.locator(`${selectors.traceTimeline}[data-status="completed"]`);
await expect(trace).toBeVisible();
@@ -17,7 +17,7 @@ test("TraceTimeline renders readable rows and hides backend noise", async ({ pag
});
test("failed trace exposes readable failure row", async ({ page }, testInfo) => {
await gotoWorkbench(page, "/workbench/sessions/cnv_failed");
await gotoWorkbench(page, "/workbench/sessions/ses_failed");
const trace = page.locator(`${selectors.traceTimeline}[data-status="failed"]`);
await expect(trace).toBeVisible();
await trace.locator("summary.trace-disclosure-summary").click();
@@ -30,7 +30,7 @@ test.describe("terminal empty trace copy", () => {
test.use({ scenarioId: "terminal-empty-trace" });
test("completed message details do not retain thinking placeholder", async ({ page }) => {
await gotoWorkbench(page, "/workbench/sessions/cnv_terminal_empty");
await gotoWorkbench(page, "/workbench/sessions/ses_terminal_empty");
const card = page.locator(`${selectors.messageCard}[data-status="completed"]`).last();
await expect(card).toContainText("终态空 Trace 已完成。");
await expect(card.locator(".trace-empty")).toContainText("运行已完成,暂无可读 Trace");