diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index a7ac3839..8d0fc6b2 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -1177,12 +1177,15 @@ class MemoryAccessStore { async archiveAgentConversation(input = {}) { const ownerUserId = textOr(input.ownerUserId, ""); const role = textOr(input.actorRole, "user"); + const sessionId = textOr(input.sessionId, ""); const conversationId = textOr(input.conversationId, ""); const projectId = textOr(input.projectId, ""); const now = input.now ?? this.now(); + if (!sessionId && !conversationId) return { count: 0 }; let count = 0; for (const [id, session] of this.agentSessions.entries()) { if ((input.ownerScoped === true || role !== "admin") && session.ownerUserId !== ownerUserId) continue; + if (sessionId && id !== sessionId) continue; if (conversationId && session.conversationId !== conversationId) continue; if (projectId && session.projectId !== projectId) continue; this.agentSessions.set(id, { ...session, status: "archived", endedAt: session.endedAt ?? now, updatedAt: now }); @@ -1345,11 +1348,21 @@ class PostgresAccessStore extends MemoryAccessStore { await this.ensureSchema(); const role = textOr(input.actorRole, "user"); const ownerUserId = textOr(input.ownerUserId, ""); + const sessionId = textOr(input.sessionId, ""); const conversationId = textOr(input.conversationId, ""); const projectId = textOr(input.projectId, ""); const now = input.now ?? this.now(); - const clauses = ["conversation_id = $1"]; - const params = [conversationId]; + if (!sessionId && !conversationId) return { count: 0 }; + const clauses = []; + const params = []; + if (sessionId) { + params.push(sessionId); + clauses.push(`id = $${params.length}`); + } + if (conversationId) { + params.push(conversationId); + clauses.push(`conversation_id = $${params.length}`); + } if (input.ownerScoped === true || role !== "admin") { params.push(ownerUserId); clauses.push(`owner_user_id = $${params.length}`); diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index 24ab54a4..4e0c6f17 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -162,6 +162,10 @@ export async function handleCodeAgentSessionsHttp(request, response, url, option await getManualCodeAgentSession(response, options, sessionId); return; } + if (request.method === "DELETE" && sessionId && !action) { + await archiveManualCodeAgentSession(response, options, sessionId); + return; + } if (request.method === "POST" && sessionId && action === "select") { await selectManualCodeAgentSession(request, response, options, sessionId); return; @@ -249,6 +253,31 @@ async function getManualCodeAgentSession(response, options, sessionId) { }); } +async function archiveManualCodeAgentSession(response, options, sessionId) { + const session = await getVisibleManualAgentSession(options, sessionId); + if (!session) { + sendJson(response, 404, manualSessionErrorPayload({ code: "session_not_found", message: "Code Agent session is not visible to the current actor.", sessionId })); + return; + } + const result = await options.accessController?.store?.archiveAgentConversation?.({ + ownerUserId: options.actor?.id, + actorRole: options.actor?.role, + ownerScoped: true, + sessionId, + conversationId: session.conversationId, + projectId: session.projectId + }) ?? { count: 0 }; + sendJson(response, 200, { + ok: true, + status: "archived", + contractVersion: "code-agent-manual-session-v1", + archivedCount: result.count ?? 0, + session: publicManualAgentSession({ ...session, status: "archived", endedAt: session.endedAt ?? new Date().toISOString(), updatedAt: new Date().toISOString() }), + valuesRedacted: true, + secretMaterialStored: false + }); +} + async function selectManualCodeAgentSession(request, response, options, sessionId) { const body = await readJsonObjectBody(request, options.bodyLimitBytes); if (!body.ok) { diff --git a/internal/cloud/server-workbench-http.ts b/internal/cloud/server-workbench-http.ts index 53937aa8..636913cf 100644 --- a/internal/cloud/server-workbench-http.ts +++ b/internal/cloud/server-workbench-http.ts @@ -7,6 +7,7 @@ import { createHash } from "node:crypto"; import { defaultCodeAgentTraceStore } from "./code-agent-trace-store.ts"; import { parsePositiveInteger, + safeConversationId, safeOpaqueId, safeSessionId, safeTraceId, @@ -200,6 +201,7 @@ async function handleWorkbenchSessionList(response, url, options, actor) { if (url.searchParams.has("projectId") || url.searchParams.has("workspaceId")) return sendJson(response, 400, workbenchError("workbench_authority_removed", "Workbench session list is keyed by sessionId only.")); const limit = boundedLimit(url.searchParams.get("limit")); const includeSessionId = safeSessionId(url.searchParams.get("includeSessionId")); + const includeRouteId = includeSessionId ?? safeConversationId(url.searchParams.get("includeSessionId")); const store = options.accessController?.store; const listInput = { ownerUserId: actor.id, @@ -209,8 +211,8 @@ async function handleWorkbenchSessionList(response, url, options, actor) { includeArchived: false }; let sessions = await store?.listAgentSessionsForUser?.(listInput) ?? []; - if (includeSessionId && !sessions.some((session) => session.id === includeSessionId)) { - const included = await visibleSessionById(store, includeSessionId, actor); + if (includeRouteId && !sessions.some((session) => session.id === includeSessionId)) { + const included = await visibleSessionByRouteId(store, includeRouteId, actor); if (included) sessions = [included, ...sessions]; } const summaries = sessions.map((session) => sessionSummary(session, options)).filter(Boolean); @@ -227,7 +229,7 @@ async function handleWorkbenchSessionList(response, url, options, actor) { } async function handleWorkbenchSessionDetail(response, options, actor, sessionId) { - const session = await visibleSessionById(options.accessController?.store, sessionId, actor); + const session = await visibleSessionByRouteId(options.accessController?.store, sessionId, actor); if (!session) return sendJson(response, 404, workbenchError("workbench_session_not_found", "Workbench session is not visible to the current actor.", { sessionId })); sendJson(response, 200, { ok: true, @@ -240,7 +242,7 @@ async function handleWorkbenchSessionDetail(response, options, actor, sessionId) } async function handleWorkbenchMessagePage(response, url, options, actor, sessionId) { - const session = await visibleSessionById(options.accessController?.store, sessionId, actor); + const session = await visibleSessionByRouteId(options.accessController?.store, sessionId, actor); if (!session) return sendJson(response, 404, workbenchError("workbench_session_not_found", "Workbench session is not visible to the current actor.", { sessionId })); const messages = sessionMessages(session); const limit = boundedLimit(url.searchParams.get("limit")); @@ -320,6 +322,19 @@ async function visibleSessionById(store, sessionId, actor) { return canActorReadSession(session, actor) ? session : null; } +async function visibleSessionByRouteId(store, routeId, actor) { + const sessionId = safeSessionId(routeId); + if (sessionId) return visibleSessionById(store, sessionId, actor); + const conversationId = safeConversationId(routeId); + if (!conversationId) return null; + const listed = await store?.listAgentSessionsForUser?.({ ownerUserId: actor.id, actorRole: actor.role, ownerScoped: true, conversationId, limit: 1, includeArchived: false }) ?? []; + const matched = listed.find((session) => canActorReadSession(session, actor)) ?? null; + if (matched) return matched; + const suffix = conversationId.slice("cnv_".length); + const deterministicSessionId = safeSessionId(`ses_${suffix}`); + return deterministicSessionId ? visibleSessionById(store, deterministicSessionId, actor) : null; +} + async function visibleSessionByTrace(store, traceId, actor) { const safeId = safeTraceId(traceId); if (!safeId) return null; @@ -333,7 +348,7 @@ function sessionSummary(session, options) { const traceId = safeTraceId(session.lastTraceId ?? snapshot.lastTraceId ?? snapshot.currentTraceId) ?? null; const trace = traceId ? traceSnapshotSync(options, traceId) : null; const messages = sessionMessages(session); - const status = normalizeStatus(snapshot.sessionStatus ?? session.status ?? trace?.status); + const status = canonicalWorkbenchStatus(snapshot.sessionStatus ?? session.status, trace?.status); return { sessionId: session.id, threadId: safeOpaqueId(session.threadId) ?? (textValue(session.threadId) || null), @@ -613,18 +628,24 @@ function traceSnapshotLastSeq(snapshot) { } function turnStatus(result, session, trace) { - return normalizeStatus( + return canonicalWorkbenchStatus( result?.status ?? result?.agentRun?.terminalStatus ?? result?.agentRun?.commandState ?? result?.agentRun?.status ?? session?.status ?? - objectValue(session?.session).sessionStatus ?? - trace?.status ?? - "unknown" + objectValue(session?.session).sessionStatus, + trace?.status ); } +function canonicalWorkbenchStatus(primary, traceStatus) { + const primaryStatus = normalizeStatus(primary); + const trace = normalizeStatus(traceStatus); + if (TERMINAL_STATUSES.has(trace) && (!TERMINAL_STATUSES.has(primaryStatus) || RUNNING_STATUSES.has(primaryStatus))) return trace; + return primaryStatus || trace || "unknown"; +} + function canActorReadSession(session, actor) { if (!session || !actor) return false; if (normalizeStatus(session.status) === "archived") return false; diff --git a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts index 04acc6f9..d7bd0e81 100644 --- a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts +++ b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts @@ -101,14 +101,14 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse) const sessionMessagesMatch = path.match(/^\/v1\/workbench\/sessions\/([^/]+)\/messages$/u); if (sessionMessagesMatch && method === "GET") { - const sessionId = decodeURIComponent(sessionMessagesMatch[1] ?? ""); + const sessionId = canonicalSessionId(decodeURIComponent(sessionMessagesMatch[1] ?? "")); const session = visibleSessionById(sessionId); return session ? json(response, 200, workbenchSessionMessagesPayload(session, 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] ?? ""); + const sessionId = canonicalSessionId(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 session = visibleSessionById(sessionId, { includeArchived: true }); @@ -130,6 +130,13 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse) } const agentSessionMatch = path.match(/^\/v1\/agent\/sessions\/([^/]+)$/u); + if (agentSessionMatch && method === "DELETE") { + const session = sessionById(decodeURIComponent(agentSessionMatch[1] ?? "")); + if (!session) return json(response, 404, { ok: false, status: 404, error: { code: "session_not_found" } }); + session.status = "archived"; + session.updatedAt = new Date().toISOString(); + return json(response, 200, { ok: true, status: "archived", session, archivedCount: 1 }); + } if (agentSessionMatch && method === "GET") return json(response, 200, { session: sessionPayload(decodeURIComponent(agentSessionMatch[1] ?? "")) }); if (path === "/v1/provider-profiles") return json(response, 200, { profiles: [{ profile: "codex-api", name: "Codex API", configured: true }, { profile: "deepseek", name: "DeepSeek", configured: true }] }); @@ -148,6 +155,13 @@ function createScenarioState(scenarioId: string): ScenarioState { const sessions = base.sessions; const traces = base.traces; if (id === "cross-project-detail-boundary") sessions.push(hiddenBoundarySession()); + if (id === "legacy-cnv-deeplink-canonical") { + sessions.unshift(legacyCanonicalSession()); + traces.trc_40badc15523146c9 = legacyCanonicalTrace(); + } + if (id === "session-rail-many") { + for (let index = 0; index < 46; index += 1) sessions.push(manyRailSession(index)); + } if (id === "session-switch-detail-404-isolated") { sessions.unshift(staleDetailSession()); sessions.push(emptySession()); @@ -160,6 +174,8 @@ function createScenarioState(scenarioId: string): ScenarioState { if (id === "session-switch-empty-reload") sessions.push(emptySession()); const selectedSessionId = id === "deep-link" || id === "stale-nested-trace" ? "ses_failed" + : id === "legacy-cnv-deeplink-canonical" + ? "ses_running" : id === "session-switch-detail-404-isolated" || id === "completed-replay-detail-404" || id === "deleted-session-deeplink" || id === "terminal-turn-stale-session-active" ? "ses_completed" : id === "terminal-empty-trace" @@ -218,6 +234,45 @@ function terminalEmptyTrace(): JsonRecord { return { traceId: "trc_terminal_empty", status: "completed", sessionId: "ses_terminal_empty", threadId: "thr_terminal_empty", events: [], eventCount: 0, fullTraceLoaded: true, hasMore: false, finalResponse: { text: "终态空 Trace 已完成。" } }; } +function legacyCanonicalSession(): SessionRecord { + const now = new Date().toISOString(); + return { + sessionId: "ses_c5e1330558c94d8e", + conversationId: "cnv_c5e1330558c94d8e", + threadId: "thr_c5e1330558c94d8e", + status: "completed", + lastTraceId: "trc_40badc15523146c9", + startedAt: now, + updatedAt: now, + messageCount: 2, + firstUserMessagePreview: "legacy cnv deep link canonical smoke", + messages: [ + { id: "msg_c5_user", role: "user", title: "用户", text: "legacy cnv deep link canonical smoke", status: "sent", createdAt: now, sessionId: "ses_c5e1330558c94d8e", threadId: "thr_c5e1330558c94d8e", traceId: "trc_40badc15523146c9" }, + { id: "msg_c5_agent", role: "agent", title: "Code Agent", text: "已完成:新增 Python benchmark 脚本。", status: "completed", createdAt: now, sessionId: "ses_c5e1330558c94d8e", threadId: "thr_c5e1330558c94d8e", traceId: "trc_40badc15523146c9", runnerTrace: legacyCanonicalTrace() } + ] + }; +} + +function legacyCanonicalTrace(): JsonRecord { + return { + traceId: "trc_40badc15523146c9", + status: "completed", + sessionId: "ses_c5e1330558c94d8e", + threadId: "thr_c5e1330558c94d8e", + events: [{ seq: 1, label: "agentrun:assistant:message", type: "assistant_message", status: "completed", terminal: true, final: true, message: "已完成:新增 Python benchmark 脚本。" }], + eventCount: 1, + fullTraceLoaded: true, + hasMore: false, + finalResponse: { text: "已完成:新增 Python benchmark 脚本。", status: "completed" } + }; +} + +function manyRailSession(index: number): SessionRecord { + const id = String(index).padStart(2, "0"); + const now = new Date(Date.now() - (index + 1) * 1000).toISOString(); + return { sessionId: `ses_many_${id}`, threadId: `thr_many_${id}`, status: "completed", lastTraceId: `trc_many_${id}`, startedAt: now, updatedAt: now, messageCount: 0, firstUserMessagePreview: `历史 session ${id}`, messages: [] }; +} + function staleDetailSession(): SessionRecord { const now = new Date().toISOString(); return { sessionId: "ses_stale_404", threadId: "thr_stale_404", status: "active", startedAt: now, updatedAt: now, messageCount: 0, firstUserMessagePreview: "已失效的列表项", messages: [] }; @@ -281,6 +336,13 @@ function sessionById(id: string): SessionRecord | null { return state.sessions.find((session) => session.sessionId === id) ?? null; } +function canonicalSessionId(id: string): string { + const text = id.trim(); + if (!text.startsWith("cnv_")) return text; + const conversation = state.sessions.find((session) => session.conversationId === text); + return conversation?.sessionId ?? `ses_${text.slice("cnv_".length)}`; +} + function visibleSessionById(id: string, options: { includeArchived?: boolean } = {}): SessionRecord | null { const session = sessionById(id); if (!session || session.hidden) return null; @@ -296,6 +358,7 @@ function sessionPayload(sessionId: string): JsonRecord { function turnPayload(traceId: string): JsonRecord { const trace = state.traces[traceId] ?? { traceId, status: "unknown", events: [] }; + if (state.scenarioId === "terminal-turn-stale-session-active" && traceId === "trc_completed") return { ...trace, traceId, status: "active", running: true, terminal: false, trace: { status: "completed", eventCount: trace.eventCount ?? 0 } }; const status = String(trace.status ?? "unknown"); return { ...trace, traceId, status, running: ["running", "pending", "accepted"].includes(status), terminal: ["completed", "failed", "blocked", "timeout", "canceled"].includes(status) }; } diff --git a/web/hwlab-cloud-web/src/api/agent.ts b/web/hwlab-cloud-web/src/api/agent.ts index 47fa6c3a..510e12a0 100644 --- a/web/hwlab-cloud-web/src/api/agent.ts +++ b/web/hwlab-cloud-web/src/api/agent.ts @@ -24,20 +24,24 @@ export const agentAPI = { getAgentTrace: async (traceId: string, timeoutMs = 8000, activityRef?: ApiRequestOptions["activityRef"], options?: AgentTraceRequestOptions): Promise> => normalizeWorkbenchTraceResult(await fetchJson>(workbenchTracePath(traceId, options), { timeoutMs, timeoutName: "Code Agent trace", activityRef }), traceId), createAgentSession: (payload: Record = {}): Promise }>> => fetchJson("/v1/agent/sessions", { method: "POST", body: JSON.stringify(payload), timeoutMs: 30000, timeoutName: "Code Agent session create" }), getAgentSession: (sessionId: string): Promise }>> => fetchJson(`/v1/agent/sessions/${encodeURIComponent(sessionId)}`, { timeoutMs: 8000, timeoutName: "Code Agent session" }), + deleteAgentSession: (sessionId: string): Promise; archivedCount?: number }>> => fetchJson(`/v1/agent/sessions/${encodeURIComponent(sessionId)}`, { method: "DELETE", timeoutMs: 30000, timeoutName: "Code Agent session delete" }), cancelAgentMessage: (payload: Record): Promise> => fetchJson("/v1/agent/chat/cancel", { method: "POST", body: JSON.stringify(payload), timeoutMs: 30000, timeoutName: "Code Agent cancel" }) }; function normalizeWorkbenchTurnResult(result: ApiResult>, traceId: string): ApiResult { if (!result.ok || !result.data) return result as ApiResult; const turn = recordValue(result.data.turn) ?? result.data; + const trace = recordValue(turn.trace); + const status = canonicalWorkbenchStatus(textValue(turn.status) ?? textValue(result.data.status) ?? "unknown", textValue(trace?.status, result.data.traceStatus)); + const terminal = turn.terminal === true || isTerminalStatus(status); return { ...result, data: { ...turn, traceId: textValue(turn.traceId) ?? traceId, - status: textValue(turn.status) ?? textValue(result.data.status) ?? "unknown", - running: turn.running === true, - terminal: turn.terminal === true, + status, + running: !terminal && (turn.running === true || isActiveStatus(status)), + terminal, sessionId: textValue(turn.sessionId) ?? undefined, threadId: textValue(turn.threadId) ?? undefined, agentRun: recordValue(turn.agentRun) ?? undefined, @@ -46,6 +50,28 @@ function normalizeWorkbenchTurnResult(result: ApiResult> }; } +function canonicalWorkbenchStatus(primary: string | null, traceStatus: string | null): string { + const primaryStatus = normalizeStatus(primary); + const trace = normalizeStatus(traceStatus); + if (trace && isTerminalStatus(trace) && (!primaryStatus || isActiveStatus(primaryStatus) || !isTerminalStatus(primaryStatus))) return trace; + return primaryStatus ?? trace ?? "unknown"; +} + +function normalizeStatus(value: unknown): string | null { + const text = textValue(value); + if (!text) return null; + const normalized = text.toLowerCase().replace(/_/gu, "-"); + return normalized === "cancelled" ? "canceled" : normalized; +} + +function isActiveStatus(value: unknown): boolean { + return ["accepted", "active", "busy", "creating", "pending", "processing", "running"].includes(normalizeStatus(value) ?? ""); +} + +function isTerminalStatus(value: unknown): boolean { + return ["blocked", "canceled", "completed", "failed", "stale", "thread-resume-failed", "timeout"].includes(normalizeStatus(value) ?? ""); +} + function normalizeWorkbenchTraceResult(result: ApiResult>, traceId: string): ApiResult { if (!result.ok || !result.data) return result as ApiResult; const events = Array.isArray(result.data.events) ? result.data.events : []; diff --git a/web/hwlab-cloud-web/src/stores/workbench-session.ts b/web/hwlab-cloud-web/src/stores/workbench-session.ts index 36b586be..a04f3e87 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-session.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-session.ts @@ -243,8 +243,10 @@ function latestAgentMessage(messages: ChatMessage[] | undefined): ChatMessage | function resolveSessionTabStatus(session: WorkbenchSessionRecord, authority: SessionStatusAuthority | null | undefined): string { const authorityStatus = normalizeSessionStatus(authority?.status); const terminalStatus = terminalStatusFromSession(session); + const sessionStatus = normalizeSessionStatus(session.status); if (authorityStatus && isActiveStatus(authorityStatus) && terminalStatus) return terminalStatus; - return authorityStatus ?? terminalStatus ?? "unknown"; + if (sessionStatus && isActiveStatus(sessionStatus) && terminalStatus) return terminalStatus; + return authorityStatus ?? terminalStatus ?? sessionStatus ?? "unknown"; } function terminalStatusFromSession(session: WorkbenchSessionRecord): string | null { diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index bf2cebc9..c7e6c9e8 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -7,7 +7,7 @@ import { api } from "@/api"; import { connectWorkbenchEvents, type WorkbenchEventStream, type WorkbenchRealtimeEvent } from "@/api/workbench-events"; import { assistantTextFromTraceEvents, mergeRunnerTrace, snapshotToRunnerTrace, type TraceSnapshot } from "@/composables/useTraceSubscription"; import type { AgentChatResponse, AgentChatResultResponse, AgentRunProvenance, ApiResult, ChatMessage, LiveSurface, ProviderProfile, TraceEvent, WorkbenchSessionRecord } from "@/types"; -import { firstNonEmptyString, nextProtocolId, normalizeWorkbenchSessionId } from "@/utils"; +import { firstNonEmptyString, nextProtocolId, normalizeWorkbenchSessionId, normalizeWorkbenchSessionRouteId } from "@/utils"; import { failWorkbenchSessionSwitch, failWorkbenchSubmitJourney, finishWorkbenchSessionSwitchFullLoad, markWorkbenchSubmitApiAccepted, markWorkbenchTraceEventsReceived, markWorkbenchTraceProjected, startWorkbenchSessionSwitch, startWorkbenchSubmitJourney } from "@/utils/workbench-performance"; import { RECENT_DRAFTS_STORAGE_KEY, defaultProviderProfileOptions, isArchivedSession, mergeSessionIntoList, normalizeChatMessageStatus, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, shouldShowSessionListLoading, sortSessionTabs, stableSessionList, type DraftEntry, type ProviderProfileOption, type TurnStatusAuthority } from "./workbench-session"; import { initialWorkbenchSessionIdFromLocation } from "./workbench-projection"; @@ -69,6 +69,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { } async function hydrate(options: HydrateOptions = {}): Promise { + const routeRequestId = normalizeWorkbenchSessionRouteId(options.sessionId); const routeSessionId = normalizeWorkbenchSessionId(options.sessionId); if (routeSessionId) setActiveSessionSelection(routeSessionId); const requestEpoch = beginSessionSelection(); @@ -83,16 +84,17 @@ export const useWorkbenchStore = defineStore("workbench", () => { return; } const listedSessions = workbenchSessionsFromPayload(sessionsResult.data); - const targetSessionId = routeSessionId ?? includeSessionId ?? activeSessionId.value ?? listedSessions[0]?.sessionId ?? null; - if (targetSessionId) { + const targetSessionId = routeRequestId ?? includeSessionId ?? activeSessionId.value ?? listedSessions[0]?.sessionId ?? null; + if (targetSessionId && (routeSessionId || !routeRequestId)) { setActiveSessionSelection(targetSessionId); - sessionDetailLoadingId.value = targetSessionId; } + if (targetSessionId) sessionDetailLoadingId.value = targetSessionId; const selected = targetSessionId ? await loadWorkbenchSession(targetSessionId, listedSessions.find((item) => item.sessionId === targetSessionId) ?? null) : null; clearSessionDetailLoading(targetSessionId); if (selected && isCurrentSessionSelection(requestEpoch, selected.sessionId)) applySelectedSessionDetail(selected, messages.value); - if (options.invalidRouteId || (targetSessionId && !selected)) error.value = "invalid session URL"; - const nextSessions = stableSessionList(sessions.value, listedSessions, targetSessionId, selected); + if (selected && routeRequestId && !isCurrentSessionSelection(requestEpoch, selected.sessionId)) applySelectedSessionDetail(selected, messages.value); + if (options.invalidRouteId || (targetSessionId && !selected)) isolateSessionLoadFailure(targetSessionId ?? options.invalidRouteId ?? "invalid-session", null, [], "invalid session URL", { restorePrevious: false }); + const nextSessions = stableSessionList(sessions.value, listedSessions, selected?.sessionId ?? routeSessionId ?? includeSessionId, selected); sessions.value = nextSessions; rememberSessionList(nextSessions); sessionsReady.value = true; @@ -128,6 +130,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { sessions.value = mergeSessionIntoList(sessions.value, created); rememberSessionDetail(created); sessionsReady.value = true; + setActiveSessionSelection(created.sessionId); await selectSession(created); } @@ -136,8 +139,9 @@ export const useWorkbenchStore = defineStore("workbench", () => { } async function selectSessionById(sessionId: string, seed: WorkbenchSessionRecord | null = null): Promise { + const requestId = normalizeWorkbenchSessionRouteId(sessionId); const normalized = normalizeWorkbenchSessionId(sessionId); - if (!normalized) { + if (!requestId) { error.value = "invalid session URL"; return false; } @@ -145,39 +149,44 @@ export const useWorkbenchStore = defineStore("workbench", () => { const previousMessages = messages.value; const existing = seed ?? sessions.value.find((session) => session.sessionId === normalized) ?? null; const requestEpoch = beginSessionSelection(); - startWorkbenchSessionSwitch({ sessionId: normalized, source: existing ? "rail" : "deeplink", targetState: existing?.status ?? "unknown", cache: (existing?.messages?.length ?? 0) > 0 ? "warm" : "cold" }); - switchingSessionId.value = normalized; - setActiveSessionSelection(normalized); - sessionDetailLoadingId.value = normalized; + startWorkbenchSessionSwitch({ sessionId: normalized ?? requestId, source: existing ? "rail" : "deeplink", targetState: existing?.status ?? "unknown", cache: (existing?.messages?.length ?? 0) > 0 ? "warm" : "cold" }); + switchingSessionId.value = requestId; + if (normalized) setActiveSessionSelection(normalized); + sessionDetailLoadingId.value = requestId; if (existing) { sessions.value = mergeSessionIntoList(sessions.value, existing); rememberSessionDetail(existing); sessionsReady.value = true; - void refreshSessionStatusById(normalized); } loading.value = true; - const selected = await loadWorkbenchSession(normalized, existing); + const selected = await loadWorkbenchSession(requestId, existing); loading.value = false; - clearSessionDetailLoading(normalized); - clearSwitchingSession(normalized); - if (!isCurrentSessionSelection(requestEpoch, normalized)) return true; + clearSessionDetailLoading(requestId); + clearSwitchingSession(requestId); + if (!isCurrentSessionRequest(requestEpoch, requestId, selected?.sessionId ?? normalized)) return true; if (selected && !isArchivedSession(selected)) { applySelectedSessionDetail(selected, previousMessages); error.value = null; - await refreshSelectedSessionStatus(selected); await refreshSessions(selected.sessionId); finishWorkbenchSessionSwitchFullLoad(selected.sessionId, "ok"); - return activeSessionId.value === normalized; + return activeSessionId.value === selected.sessionId; } - isolateSessionLoadFailure(normalized, previousSessionId, previousMessages, isArchivedSession(selected) ? "session archived" : "session URL not found"); - failWorkbenchSessionSwitch(normalized); + isolateSessionLoadFailure(requestId, previousSessionId, previousMessages, isArchivedSession(selected) ? "session archived" : "session URL not found", { restorePrevious: Boolean(seed && normalized) }); + failWorkbenchSessionSwitch(normalized ?? requestId); return false; } async function deleteCurrentSession(): Promise { const sessionId = activeSessionId.value; if (!sessionId) return; + error.value = null; + const response = await api.agent.deleteAgentSession(sessionId); + if (!response.ok) { + error.value = response.error ?? "session delete failed"; + return; + } sessions.value = sessions.value.filter((session) => session.sessionId !== sessionId); + await refreshSessions(null); const next = sessions.value.find((session) => !isArchivedSession(session)) ?? null; replaceActiveSessionSelection(next?.sessionId ?? null); currentRequest.value = null; @@ -224,7 +233,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { } async function refreshSessionStatusAuthority(source: WorkbenchSessionRecord[] = sessions.value): Promise { - await Promise.all(uniqueSessionIds(source).map((sessionId) => refreshSessionStatusById(sessionId))); + void source; } async function refreshSelectedSessionStatus(fallbackSession?: WorkbenchSessionRecord | null): Promise { @@ -787,6 +796,11 @@ export const useWorkbenchStore = defineStore("workbench", () => { return activeSessionId.value === sessionId || switchingSessionId.value === sessionId; } + function isCurrentSessionRequest(epoch: number, requestId: string, selectedSessionId?: string | null): boolean { + if (epoch !== selectionEpoch.value) return false; + return activeSessionId.value === requestId || switchingSessionId.value === requestId || Boolean(selectedSessionId && (activeSessionId.value === selectedSessionId || switchingSessionId.value === selectedSessionId)); + } + function clearSwitchingSession(sessionId: string): void { if (switchingSessionId.value === sessionId) switchingSessionId.value = null; } @@ -809,9 +823,9 @@ export const useWorkbenchStore = defineStore("workbench", () => { restartRealtime("apply-selected-session"); } - function isolateSessionLoadFailure(sessionId: string, previousSessionId: string | null, previousMessages: ChatMessage[], message: string): void { + function isolateSessionLoadFailure(sessionId: string, previousSessionId: string | null, previousMessages: ChatMessage[], message: string, options: { restorePrevious?: boolean } = {}): void { sessions.value = sessions.value.filter((item) => item.sessionId !== sessionId || (item.messages?.length ?? 0) > 0); - if (previousSessionId && previousSessionId !== sessionId) { + if (options.restorePrevious === true && previousSessionId && previousSessionId !== sessionId) { const previous = sessions.value.find((item) => item.sessionId === previousSessionId) ?? null; replaceActiveSessionSelection(previousSessionId); if (previous) { @@ -869,14 +883,14 @@ function sessionFromWorkbenchSession(value: unknown): WorkbenchSessionRecord | n } async function loadWorkbenchSession(sessionId: string, seed: WorkbenchSessionRecord | null = null): Promise { - const id = normalizeWorkbenchSessionId(sessionId); - if (!id) return null; - const [detail, messages] = await Promise.all([ - api.workbench.session(id), - api.workbench.sessionMessages(id, { limit: 100 }) - ]); + const requestId = normalizeWorkbenchSessionRouteId(sessionId); + if (!requestId) return null; + const detail = await api.workbench.session(requestId); if (!detail.ok) return null; const detailSession = detail.ok ? sessionFromWorkbenchSession(detail.data?.session) : null; + const id = detailSession?.sessionId ?? normalizeWorkbenchSessionId(requestId) ?? seed?.sessionId; + if (!id) return null; + const messages = await api.workbench.sessionMessages(id, { limit: 100 }); const base = detailSession ?? seed; if (!base) return null; const page = messages.ok ? messages.data : null; diff --git a/web/hwlab-cloud-web/src/utils/index.ts b/web/hwlab-cloud-web/src/utils/index.ts index 89b5b3dd..40ec5111 100644 --- a/web/hwlab-cloud-web/src/utils/index.ts +++ b/web/hwlab-cloud-web/src/utils/index.ts @@ -28,6 +28,12 @@ export function normalizeWorkbenchSessionId(value: unknown): string | null { return /^ses_[A-Za-z0-9_.:-]+$/u.test(text) ? text : null; } +export function normalizeWorkbenchSessionRouteId(value: unknown): string | null { + const text = nonEmptyString(value); + if (!text) return null; + return normalizeWorkbenchSessionId(text) ?? normalizeWorkbenchConversationId(text); +} + export function workbenchSessionPath(sessionId: string): string { return `/workbench/sessions/${encodeURIComponent(sessionId)}`; } diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/deep-link.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/deep-link.spec.ts index ade6ffed..059ac488 100644 --- a/web/hwlab-cloud-web/tests/workbench-e2e/specs/deep-link.spec.ts +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/deep-link.spec.ts @@ -24,3 +24,18 @@ test.describe("deep link stale session authority", () => { expect((state.legacyRequestLedger as unknown[]).length).toBe(0); }); }); + +test.describe("legacy cnv deep link canonical authority", () => { + test.use({ scenarioId: "legacy-cnv-deeplink-canonical" }); + + test("cnv route resolves through backend to canonical session without stale active fallback", async ({ page }) => { + await gotoWorkbench(page, "/workbench/sessions/cnv_c5e1330558c94d8e"); + await expect(page).toHaveURL(/\/workbench\/sessions\/ses_c5e1330558c94d8e$/u); + await expect(page.locator(sessionTab("ses_c5e1330558c94d8e"))).toHaveAttribute("data-active", "true"); + await expect(page.locator(sessionTab("ses_running"))).not.toHaveAttribute("data-active", "true"); + await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="completed"]`)).toContainText("新增 Python benchmark 脚本"); + await expect(page.locator(`${selectors.traceTimeline}[data-status="completed"]`)).toBeVisible(); + const state = await fakeServerState(page); + expect((state.legacyRequestLedger as unknown[]).length).toBe(0); + }); +}); diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/new-session-thread.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/new-session-thread.spec.ts index 1e6f3a95..47bee4ac 100644 --- a/web/hwlab-cloud-web/tests/workbench-e2e/specs/new-session-thread.spec.ts +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/new-session-thread.spec.ts @@ -50,3 +50,23 @@ test.describe("server authoritative session create", () => { expect((state.legacyRequestLedger as unknown[]).length).toBe(0); }); }); + +test.describe("session create and delete authority", () => { + test.use({ scenarioId: "server-authoritative-create" }); + + test("created session becomes active and deleted session stays archived after reload", async ({ page }) => { + await gotoWorkbench(page); + await page.locator(selectors.sessionCreate).click(); + await expect(page).toHaveURL(/\/workbench\/sessions\/ses_server_created/u); + await expect(page.locator('.session-tab[data-session-id="ses_server_created"]')).toHaveAttribute("data-active", "true"); + await expect(page.locator(selectors.commandSend)).toBeDisabled(); + + await page.locator(selectors.sessionDelete).click(); + await expect(page.locator('.session-tab[data-session-id="ses_server_created"]')).toHaveCount(0); + await page.reload(); + await expect(page.locator('.session-tab[data-session-id="ses_server_created"]')).toHaveCount(0); + const state = await fakeServerState(page); + const deleted = (state.sessions as Array<{ sessionId?: string; status?: string }>).find((item) => item.sessionId === "ses_server_created"); + expect(deleted?.status).toBe("archived"); + }); +}); diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/session-rail-scale.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/session-rail-scale.spec.ts new file mode 100644 index 00000000..0edfd3a2 --- /dev/null +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/session-rail-scale.spec.ts @@ -0,0 +1,18 @@ +import { expect, fakeServerState, gotoWorkbench, test } from "../fixtures/test"; +import { selectors } from "../fixtures/selectors"; + +test.use({ scenarioId: "session-rail-many" }); + +test("session rail does not issue per-tab status requests", async ({ page }) => { + await gotoWorkbench(page); + await expect(page.locator(".session-tab")).toHaveCount(50); + await page.waitForTimeout(1200); + const state = await fakeServerState(page); + const requests = state.requestLedger as Array<{ method?: string; path?: string }>; + const sessionStatusRequests = requests.filter((request) => /^\/v1\/agent\/sessions\/ses_/u.test(String(request.path ?? ""))); + const uniquePaths = new Set(sessionStatusRequests.map((request) => request.path)); + expect(sessionStatusRequests.length).toBeLessThanOrEqual(3); + expect(uniquePaths.size).toBeLessThanOrEqual(1); + expect((state.legacyRequestLedger as unknown[]).length).toBe(0); + await expect(page.locator(selectors.commandInput)).toBeVisible(); +}); diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/status-consistency.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/status-consistency.spec.ts index 22a481ed..99db7805 100644 --- a/web/hwlab-cloud-web/tests/workbench-e2e/specs/status-consistency.spec.ts +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/status-consistency.spec.ts @@ -30,6 +30,9 @@ test.describe("terminal turn with stale active session authority", () => { await expect(page.locator(sessionTab("ses_completed"))).toHaveAttribute("data-status", "completed"); await expect(page.locator(sessionTab("ses_completed"))).toHaveAttribute("data-running", "false"); await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="completed"]`)).toContainText("构建和 Trace 渲染检查完成"); + await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="failed"]`)).toHaveCount(0); + await expect(page.locator(selectors.commandSend)).toHaveAttribute("data-action", "turn"); + await expect(page.locator(".composer-mode")).not.toContainText("运行中"); await saveScreenshot(page, testInfo, "stale-active-session-terminal-tab"); }); });