Merge pull request #1437 from pikasTech/codex/1429-1432-workbench-consistency
fix(workbench): enforce project scoped session views
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user