Merge pull request #1437 from pikasTech/codex/1429-1432-workbench-consistency

fix(workbench): enforce project scoped session views
This commit is contained in:
Lyon
2026-06-17 22:38:14 +08:00
committed by GitHub
12 changed files with 350 additions and 8 deletions
+48
View File
@@ -1204,6 +1204,54 @@ test("cloud api stores and restores Code Agent conversations by authenticated ac
}
});
test("cloud api conversation detail rejects a mismatched requested project", async () => {
const server = createCloudApiServer({
env: {
HWLAB_ACCESS_CONTROL_REQUIRED: "1",
HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin",
HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass"
},
now: () => "2026-06-17T00:00:00.000Z"
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const { port } = server.address();
const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" });
const aliceCreate = await postJson(port, "/v1/admin/users", { username: "alice-issue1429", password: "alice-pass" }, adminLogin.cookie);
assert.equal(aliceCreate.status, 201);
const aliceLogin = await postJson(port, "/auth/login", { username: "alice-issue1429", password: "alice-pass" });
const stored = await putJson(port, "/v1/agent/conversations/cnv_issue1429_project_scope", {
projectId: "prj_v02_code_agent",
sessionId: "ses_issue1429_project_scope",
threadId: "thread-issue1429-project-scope",
lastTraceId: "trc_issue1429_project_scope",
sessionStatus: "completed",
messages: [
{ id: "msg_issue1429_user", role: "user", title: "用户", text: "跨项目读取", status: "sent", conversationId: "cnv_issue1429_project_scope", sessionId: "ses_issue1429_project_scope", threadId: "thread-issue1429-project-scope" },
{ id: "msg_issue1429_agent", role: "agent", title: "Agent", text: "ok", status: "completed", traceId: "trc_issue1429_project_scope", conversationId: "cnv_issue1429_project_scope", sessionId: "ses_issue1429_project_scope", threadId: "thread-issue1429-project-scope" }
]
}, aliceLogin.cookie);
assert.equal(stored.status, 200);
const wrongProject = await getJson(port, "/v1/agent/conversations/cnv_issue1429_project_scope?projectId=prj_hwpod_workbench", aliceLogin.cookie);
assert.equal(wrongProject.status, 404);
assert.equal(wrongProject.body.error.code, "conversation_project_mismatch");
const rightProject = await getJson(port, "/v1/agent/conversations/cnv_issue1429_project_scope?projectId=prj_v02_code_agent", aliceLogin.cookie);
assert.equal(rightProject.status, 200);
assert.equal(rightProject.body.conversation.projectId, "prj_v02_code_agent");
const compatibilityDirect = await getJson(port, "/v1/agent/conversations/cnv_issue1429_project_scope", aliceLogin.cookie);
assert.equal(compatibilityDirect.status, 200);
} finally {
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
});
test("cloud api includes an explicitly requested conversation in the conversations list API", async () => {
let now = "2026-06-16T08:00:00.000Z";
const server = createCloudApiServer({
+8 -1
View File
@@ -1073,11 +1073,18 @@ class AccessController {
actorRole: auth.actor.role,
ownerScoped: true,
conversationId,
projectId,
limit: 20,
includeArchived: true,
now: this.now()
}) ?? [];
const conversation = conversationsFromAgentSessions(sessions).find((item) => item.conversationId === conversationId) ?? null;
let conversation = conversationsFromAgentSessions(sessions).find((item) => item.conversationId === conversationId) ?? null;
if (!conversation && projectId) {
const visibleElsewhere = await this.visibleConversationForActor(auth.actor, conversationId, "", { includeArchived: true });
if (visibleElsewhere) {
return sendJson(response, 404, errorPayload("conversation_project_mismatch", "Agent conversation is not visible in the requested project", 404));
}
}
if (!conversation) return sendJson(response, 404, errorPayload("agent_conversation_not_found", "Agent conversation is not visible to the current actor", 404));
return sendJson(response, 200, { ok: true, status: "found", contractVersion: "agent-conversations-v1", actor: publicActor(auth.actor), conversation });
}
+38
View File
@@ -99,6 +99,44 @@ test("cloud api trace resource paginates events by sinceSeq", async () => {
}
});
test("cloud api turn and trace endpoints reject mismatched requested project", async () => {
const traceStore = createCodeAgentTraceStore();
const traceId = "trc_issue1429_project_scope";
traceStore.append(traceId, { type: "trace", status: "completed", label: "turn:completed", terminal: true });
const server = createCloudApiServer({
traceStore,
accessController: {
required: false,
async authenticate() {
return { ok: true, actor: TEST_AGENT_ACTOR, session: TEST_AUTH_SESSION };
},
async getAgentSessionByTraceId(requestedTraceId) {
assert.equal(requestedTraceId, traceId);
return testAgentSessionRecord({ sessionId: "ses_issue1429_project_scope", projectId: "prj_v02_code_agent", traceId, status: "completed" });
}
}
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const { port } = server.address();
const wrongTurn = await fetch(`http://127.0.0.1:${port}/v1/agent/turns/${traceId}?projectId=prj_hwpod_workbench`);
assert.equal(wrongTurn.status, 404);
assert.equal((await wrongTurn.json()).error.code, "trace_project_mismatch");
const wrongTrace = await fetch(`http://127.0.0.1:${port}/v1/agent/traces/${traceId}?projectId=prj_hwpod_workbench`);
assert.equal(wrongTrace.status, 404);
assert.equal((await wrongTrace.json()).error.code, "trace_project_mismatch");
const rightTrace = await fetch(`http://127.0.0.1:${port}/v1/agent/traces/${traceId}?projectId=prj_v02_code_agent`);
assert.equal(rightTrace.status, 200);
} finally {
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
});
test("AgentRun adapter filters resource tools and credentials through access capabilities", async () => {
const calls = [];
const agentRunServer = createHttpServer(async (request, response) => {
+34
View File
@@ -1190,6 +1190,11 @@ export async function handleCodeAgentTurnHttp(request, response, url, options) {
});
return;
}
const projectMismatch = await traceProjectMismatchSnapshot(traceId, url, options, "turn");
if (projectMismatch) {
sendJson(response, projectMismatch.statusCode, projectMismatch.body);
return;
}
const resolved = await resolveCodeAgentTurnStatusSnapshot(traceId, options);
sendJson(response, resolved.statusCode, resolved.body);
}
@@ -1281,6 +1286,30 @@ function forbiddenTurnSnapshot(traceId) {
};
}
async function traceProjectMismatchSnapshot(traceId, url, options, resourceKind) {
const requestedProjectId = textValue(url.searchParams.get("projectId"));
if (!requestedProjectId || !options.accessController?.getAgentSessionByTraceId) return null;
const session = await options.accessController.getAgentSessionByTraceId(traceId);
if (!session) return null;
if (session.ownerUserId && options.actor?.role !== "admin" && session.ownerUserId !== options.actor?.id) return forbiddenTurnSnapshot(traceId);
const actualProjectId = textValue(session.projectId);
if (!actualProjectId || actualProjectId === requestedProjectId) return null;
return {
statusCode: 404,
body: {
ok: false,
status: "not_found",
running: false,
terminal: false,
traceId,
error: {
code: "trace_project_mismatch",
message: `${resourceKind === "turn" ? "Agent turn" : "Agent trace"} is not visible in the requested project`
}
}
};
}
function codeAgentTurnStatusPayload({ traceId, result, snapshot, resultPollError, refreshError, options }) {
const resultObject = result && typeof result === "object" ? result : null;
const snapshotObject = snapshot && typeof snapshot === "object" ? snapshot : null;
@@ -2552,6 +2581,11 @@ export async function handleCodeAgentTraceHttp(request, response, url, options)
});
return;
}
const projectMismatch = await traceProjectMismatchSnapshot(traceId, url, options, "trace");
if (projectMismatch) {
sendJson(response, projectMismatch.statusCode, projectMismatch.body);
return;
}
const result = options.codeAgentChatResults?.get(traceId) ?? null;
if (result && !canAccessOwnedResult(result, options.actor)) {
sendJson(response, 403, {
@@ -78,6 +78,7 @@ 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/workspace" && method === "GET") return json(response, 200, { workspace: workspacePayload() });
if (/^\/v1\/workbench\/workspace\/[^/]+$/u.test(path) && method === "PATCH") {
@@ -104,12 +105,15 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
if (path === "/v1/agent/conversations" && method === "GET") {
await delay(state.conversationDelayMs);
const include = url.searchParams.get("includeConversationId");
const conversations = state.listOmitSelected && include ? state.conversations.filter((item) => item.conversationId !== include) : state.conversations;
const projectId = requestedProjectId(url);
const visibleConversations = state.conversations.filter((item) => conversationProjectId(item) === projectId);
const conversations = state.listOmitSelected && include ? visibleConversations.filter((item) => item.conversationId !== include) : visibleConversations;
return json(response, 200, { conversations: conversations.map((conversation) => conversationSummary(conversation)) });
}
const conversationMatch = path.match(/^\/v1\/agent\/conversations\/([^/]+)$/u);
if (conversationMatch && method === "GET") {
const conversation = conversationById(decodeURIComponent(conversationMatch[1] ?? ""));
if (conversation && conversationProjectMismatch(conversation, url)) return json(response, 404, { ok: false, status: 404, error: { code: "conversation_project_mismatch" } });
return conversation ? json(response, 200, { conversation }) : json(response, 404, { ok: false, status: 404, error: { code: "conversation_not_found" } });
}
if (conversationMatch && ["PUT", "DELETE"].includes(method)) return json(response, 200, { ok: true, workspace: workspacePayload() });
@@ -121,6 +125,7 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
if (turnMatch && method === "GET") {
const traceId = decodeURIComponent(turnMatch[1] ?? "");
if (traceId === state.staleNestedTraceId) return json(response, 502, { ok: false, status: 502, error: { code: "upstream_unavailable", message: "stale trace is unavailable" } });
if (traceProjectMismatch(traceId, url)) return json(response, 404, { ok: false, status: 404, error: { code: "trace_project_mismatch" } });
return json(response, 200, turnPayload(traceId));
}
@@ -128,6 +133,7 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
if (traceMatch && method === "GET") {
const traceId = decodeURIComponent(traceMatch[1] ?? "");
if (traceId === state.staleNestedTraceId) return json(response, 502, { ok: false, status: 502, error: { code: "upstream_unavailable", message: "stale trace is unavailable" } });
if (traceProjectMismatch(traceId, url)) return json(response, 404, { ok: false, status: 404, error: { code: "trace_project_mismatch" } });
return json(response, 200, tracePayload(traceId, url));
}
@@ -144,8 +150,17 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
function createScenarioState(scenarioId: string): ScenarioState {
const base = structuredClone(capture.scenario);
const id = scenarioId || "baseline";
const conversations = base.conversations;
const traces = base.traces;
if (id === "cross-project-detail-boundary") {
conversations.push(crossProjectConversation());
traces.trc_cross_project = crossProjectTrace();
}
if (id === "session-switch-empty-reload") conversations.push(emptyConversation());
const selectedConversationId = id === "deep-link" || id === "stale-nested-trace"
? "cnv_failed"
: id === "terminal-turn-stale-session-active"
? "cnv_completed"
: id === "deep-link-stale-workspace-authority" || id === "stale-submit-restore"
? "cnv_running"
: base.selectedConversationId;
@@ -156,9 +171,9 @@ function createScenarioState(scenarioId: string): ScenarioState {
workspaceId: base.workspaceId,
providerProfile: base.providerProfile,
selectedConversationId,
workspaceJson: initialWorkspaceJson(base.projectId, base.providerProfile, selectedConversationId, base.conversations, staleNestedTraceId),
conversations: base.conversations,
traces: base.traces,
workspaceJson: initialWorkspaceJson(base.projectId, base.providerProfile, selectedConversationId, conversations, staleNestedTraceId),
conversations,
traces,
selectRequests: [],
workspacePatchRequests: [],
chatRequests: [],
@@ -245,6 +260,36 @@ function createConversationFromSelect(body: JsonRecord, conversationId: string):
};
}
function crossProjectConversation(): ConversationRecord {
const now = new Date().toISOString();
return {
conversationId: "cnv_cross_project",
projectId: "prj_v02_code_agent",
sessionId: "ses_cross_project",
threadId: "thr_cross_project",
status: "completed",
lastTraceId: "trc_cross_project",
startedAt: now,
updatedAt: now,
messageCount: 2,
firstUserMessagePreview: "跨项目会话不应泄露",
session: { sessionId: "ses_cross_project", threadId: "thr_cross_project", status: "completed" },
messages: [
{ id: "msg_cross_project_user", role: "user", title: "用户", text: "读取另一个项目", status: "sent", createdAt: now, conversationId: "cnv_cross_project", sessionId: "ses_cross_project", threadId: "thr_cross_project", traceId: "trc_cross_project" },
{ id: "msg_cross_project_agent", role: "agent", title: "Code Agent", text: "另一个项目的结果", status: "completed", createdAt: now, conversationId: "cnv_cross_project", sessionId: "ses_cross_project", threadId: "thr_cross_project", traceId: "trc_cross_project", runnerTrace: crossProjectTrace() }
]
};
}
function crossProjectTrace(): JsonRecord {
return { traceId: "trc_cross_project", projectId: "prj_v02_code_agent", conversationId: "cnv_cross_project", sessionId: "ses_cross_project", threadId: "thr_cross_project", status: "completed", events: [], eventCount: 0, fullTraceLoaded: true, hasMore: false };
}
function emptyConversation(): ConversationRecord {
const now = new Date().toISOString();
return { conversationId: "cnv_empty", projectId: state?.projectId ?? capture.scenario.projectId, sessionId: "ses_empty", threadId: "thr_empty", status: "active", startedAt: now, updatedAt: now, messageCount: 0, firstUserMessagePreview: "空白会话", session: { sessionId: "ses_empty", threadId: "thr_empty", status: "active" }, messages: [] };
}
function acceptChatTurn(body: JsonRecord): JsonRecord {
const conversationId = typeof body.conversationId === "string" ? body.conversationId : state.selectedConversationId;
const sessionId = typeof body.sessionId === "string" ? body.sessionId : conversationById(conversationId)?.sessionId ?? `ses_${conversationId.slice(4)}`;
@@ -278,8 +323,30 @@ function conversationById(id: string): ConversationRecord | null {
return state.conversations.find((conversation) => conversation.conversationId === id) ?? null;
}
function requestedProjectId(url: URL): string {
return url.searchParams.get("projectId") || state.projectId;
}
function conversationProjectId(conversation: ConversationRecord): string {
return typeof conversation.projectId === "string" && conversation.projectId.trim() ? conversation.projectId : state.projectId;
}
function conversationProjectMismatch(conversation: ConversationRecord, url: URL): boolean {
const projectId = url.searchParams.get("projectId");
return Boolean(projectId && conversationProjectId(conversation) !== projectId);
}
function traceProjectMismatch(traceId: string, url: URL): boolean {
const projectId = url.searchParams.get("projectId");
if (!projectId) return false;
const trace = state.traces[traceId];
const traceProjectId = typeof trace?.projectId === "string" && trace.projectId.trim() ? trace.projectId : state.conversations.find((conversation) => conversation.lastTraceId === traceId)?.projectId;
return Boolean(traceProjectId && traceProjectId !== projectId);
}
function sessionPayload(sessionId: string): JsonRecord {
const conversation = state.conversations.find((item) => item.sessionId === sessionId);
if (state.scenarioId === "terminal-turn-stale-session-active" && sessionId === "ses_completed") return { sessionId, status: "active", lastTraceId: "trc_completed", updatedAt: new Date().toISOString() };
return { sessionId, status: conversation?.status ?? "unknown", lastTraceId: conversation?.lastTraceId ?? null, updatedAt: new Date().toISOString() };
}
@@ -356,9 +423,15 @@ function stateSummary(): JsonRecord {
}
function authPayload(): JsonRecord {
if (state.scenarioId === "auth-upstream-unavailable" || state.scenarioId === "auth-invalid-credentials") return { authenticated: false, mode: "server", actor: null, user: null, expiresAt: null };
return { authenticated: true, mode: "server", actor: { id: "usr_e2e_admin", username: "e2e-admin", role: "admin", status: "active" }, user: { id: "usr_e2e_admin", username: "e2e-admin", role: "admin", status: "active" }, expiresAt: null };
}
function authLoginResponse(response: ServerResponse): void {
if (state.scenarioId === "auth-upstream-unavailable") return json(response, 502, { ok: false, status: "failed", error: { code: "upstream_unavailable", message: "auth upstream unavailable", retryable: true, layer: "proxy" } });
return json(response, 401, { ok: false, status: "failed", error: { code: "invalid_credentials", message: "invalid credentials" } });
}
function redactRequestBody(value: JsonRecord): JsonRecord {
const output: JsonRecord = {};
for (const [key, item] of Object.entries(value)) output[key] = /secret|token|key|password|authorization/iu.test(key) ? "<redacted>" : item;
@@ -219,6 +219,24 @@ test("R2 session tabs keep trace metadata but status comes only from session aut
assert.equal(authorityTabs[0]?.running, false);
});
test("R2 terminal conversation evidence overrides stale active session authority", () => {
const conversation: ConversationRecord = {
conversationId: "cnv_terminal",
sessionId: "ses_terminal",
status: "completed",
lastTraceId: "trc_terminal",
firstUserMessagePreview: "终端状态会话",
messages: [
userMessage({ conversationId: "cnv_terminal", createdAt: "2026-01-04T00:00:00.000Z" }),
agentMessage({ conversationId: "cnv_terminal", sessionId: "ses_terminal", traceId: "trc_terminal", status: "completed", updatedAt: "2026-01-04T00:01:00.000Z" })
]
};
const tabs = sortSessionTabs([conversation], "cnv_terminal", { ses_terminal: { sessionId: "ses_terminal", status: "active" } });
assert.equal(tabs[0]?.status, "completed");
assert.equal(tabs[0]?.running, false);
assert.match(tabs[0]?.subtitle ?? "", /trace trc_term/u);
});
test("R2 agent message titles drop transient running wording", () => {
assert.equal(normalizeWorkbenchMessageTitle("agent", "Code Agent 处理中"), "Code Agent");
assert.equal(normalizeWorkbenchMessageTitle("agent", "Code Agent running"), "Code Agent");
+22 -1
View File
@@ -39,7 +39,7 @@ export const useAuthStore = defineStore("auth", () => {
error.value = null;
const response = await authAPI.login(username.trim(), password);
if (!response.ok || response.data?.authenticated !== true) {
error.value = "账号或密码不正确,请重新输入。";
error.value = loginFailureMessage(response);
authState.value = "login";
return;
}
@@ -93,3 +93,24 @@ function workspaceFromUnknown(value: unknown): { workspaceId: string } | null {
const workspaceId = nonEmptyString(record?.workspaceId);
return workspaceId ? { ...record, workspaceId } : null;
}
function loginFailureMessage(response: Awaited<ReturnType<typeof authAPI.login>>): string {
const code = loginErrorCode(response.data) ?? nonEmptyString(response.error);
const normalizedCode = code?.trim().toLowerCase().replace(/_/gu, "-") ?? "";
if (response.status === 401 || response.status === 403 || ["invalid-credentials", "unauthorized", "forbidden"].includes(normalizedCode)) {
return "账号或密码不正确,请重新输入。";
}
if (response.status === 0) {
return "登录服务暂时不可达,请稍后重试。";
}
if (response.status >= 500 || /(?:upstream|unavailable|timeout|proxy|bad-gateway)/u.test(normalizedCode)) {
return code ? `登录服务暂时不可用,请稍后重试。错误码:${code}` : "登录服务暂时不可用,请稍后重试。";
}
return response.error ? `登录失败:${response.error}` : "登录失败,请稍后重试。";
}
function loginErrorCode(payload: unknown): string | null {
const record = asRecord(payload);
const error = asRecord(record?.error);
return nonEmptyString(error?.code) ?? nonEmptyString(record?.code);
}
@@ -147,7 +147,7 @@ export function conversationToSessionTab(conversation: ConversationRecord, activ
const sessionId = firstNonEmptyString(conversation.sessionId, conversation.session?.sessionId);
const conversationId = conversation.conversationId;
const updatedAt = conversationDisplayUpdatedAt(conversation);
const status = normalizeSessionStatus(sessionId ? sessionStatusAuthority[sessionId]?.status : null) ?? "unknown";
const status = resolveSessionTabStatus(conversation, sessionId ? sessionStatusAuthority[sessionId] : null);
const trace = firstNonEmptyString(latestAgentMessage(conversation.messages)?.traceId, conversation.messages?.at(-1)?.traceId, conversation.lastTraceId);
const userMessage = conversation.messages?.find((message) => message.role === "user");
const running = isActiveStatus(status);
@@ -276,6 +276,23 @@ function latestAgentMessage(messages: ChatMessage[] | undefined): ChatMessage |
return [...(messages ?? [])].reverse().find((message) => message.role === "agent") ?? null;
}
function resolveSessionTabStatus(conversation: ConversationRecord, authority: SessionStatusAuthority | null | undefined): string {
const authorityStatus = normalizeSessionStatus(authority?.status);
const terminalStatus = terminalStatusFromConversation(conversation);
if (authorityStatus && isActiveStatus(authorityStatus) && terminalStatus) return terminalStatus;
return authorityStatus ?? terminalStatus ?? "unknown";
}
function terminalStatusFromConversation(conversation: ConversationRecord): string | null {
const agentMessage = latestAgentMessage(conversation.messages);
const candidates = [conversation.status, conversation.session?.status, agentMessage?.status, agentMessage?.runnerTrace?.status];
for (const candidate of candidates) {
const status = normalizeSessionStatus(candidate);
if (status && isTerminalStatus(status)) return status;
}
return null;
}
function messageBelongsToCancelTarget(message: ChatMessage, input: { activeConversationId: string | null; targetSessionId?: string | null; targetThreadId?: string | null }): boolean {
const activeConversationId = firstNonEmptyString(input.activeConversationId);
const messageConversationId = firstNonEmptyString(message.conversationId);
@@ -300,7 +317,7 @@ function latestConversationMessage(messages: ChatMessage[], activeConversationId
function isActiveStatus(value: unknown): boolean {
const status = normalizeSessionStatus(value);
return ["running", "pending", "accepted", "processing", "busy", "creating"].includes(status ?? "");
return ["active", "running", "pending", "accepted", "processing", "busy", "creating"].includes(status ?? "");
}
function isTerminalStatus(value: unknown): boolean {
@@ -0,0 +1,27 @@
import { expect, test } from "../fixtures/test";
test.describe("login error mapping", () => {
test.use({ scenarioId: "auth-upstream-unavailable" });
test("shows service unavailable copy for upstream 502", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("账号或邮箱").fill("alice@example.com");
await page.getByLabel("密码").fill("not-the-issue");
await page.getByRole("button", { name: "登录" }).click();
await expect(page.locator(".form-error")).toContainText("登录服务暂时不可用");
await expect(page.locator(".form-error")).toContainText("upstream_unavailable");
await expect(page.locator(".form-error")).not.toContainText("账号或密码不正确");
});
});
test.describe("login invalid credentials", () => {
test.use({ scenarioId: "auth-invalid-credentials" });
test("keeps credential copy for 401", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("账号或邮箱").fill("alice@example.com");
await page.getByLabel("密码").fill("wrong-password");
await page.getByRole("button", { name: "登录" }).click();
await expect(page.locator(".form-error")).toContainText("账号或密码不正确");
});
});
@@ -0,0 +1,26 @@
import { expect, gotoWorkbench, saveScreenshot, test } from "../fixtures/test";
import { selectors, sessionTab } from "../fixtures/selectors";
test.describe("project-scoped conversation details", () => {
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");
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 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");
await gotoWorkbench(page, `/workbench/sessions/cnv_cross_project?projectId=${projectId}`);
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");
});
});
@@ -19,3 +19,24 @@ test("session switch persists active conversation after reload", async ({ page }
await expect(page.locator(`${selectors.traceTimeline}[data-status="completed"]`)).toBeVisible();
await saveScreenshot(page, testInfo, "session-switch-reload");
});
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 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.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");
await page.reload();
await expect(page.locator(sessionTab("cnv_empty"))).toHaveAttribute("data-active", "true");
await expect(page.locator(selectors.messageCard)).toHaveCount(0);
await saveScreenshot(page, testInfo, "session-switch-empty-reload");
});
});
@@ -21,3 +21,15 @@ test("session rail, messages, composer and trace status stay consistent", async
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="completed"]`)).toContainText("构建和 Trace 渲染检查完成");
await saveScreenshot(page, testInfo, "status-consistency-completed");
});
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 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 渲染检查完成");
await saveScreenshot(page, testInfo, "stale-active-session-terminal-tab");
});
});