fix: converge workbench session state projection
This commit is contained in:
@@ -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}`);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -24,20 +24,24 @@ export const agentAPI = {
|
||||
getAgentTrace: async (traceId: string, timeoutMs = 8000, activityRef?: ApiRequestOptions["activityRef"], options?: AgentTraceRequestOptions): Promise<ApiResult<AgentChatResultResponse>> => normalizeWorkbenchTraceResult(await fetchJson<Record<string, unknown>>(workbenchTracePath(traceId, options), { timeoutMs, timeoutName: "Code Agent trace", activityRef }), traceId),
|
||||
createAgentSession: (payload: Record<string, unknown> = {}): Promise<ApiResult<{ session?: Record<string, unknown> }>> => fetchJson("/v1/agent/sessions", { method: "POST", body: JSON.stringify(payload), timeoutMs: 30000, timeoutName: "Code Agent session create" }),
|
||||
getAgentSession: (sessionId: string): Promise<ApiResult<{ session?: Record<string, unknown> }>> => fetchJson(`/v1/agent/sessions/${encodeURIComponent(sessionId)}`, { timeoutMs: 8000, timeoutName: "Code Agent session" }),
|
||||
deleteAgentSession: (sessionId: string): Promise<ApiResult<{ ok?: boolean; status?: string; session?: Record<string, unknown>; archivedCount?: number }>> => fetchJson(`/v1/agent/sessions/${encodeURIComponent(sessionId)}`, { method: "DELETE", timeoutMs: 30000, timeoutName: "Code Agent session delete" }),
|
||||
cancelAgentMessage: (payload: Record<string, unknown>): Promise<ApiResult<{ ok?: boolean; canceled?: boolean; error?: { code?: string; message?: string; userMessage?: string } }>> => fetchJson("/v1/agent/chat/cancel", { method: "POST", body: JSON.stringify(payload), timeoutMs: 30000, timeoutName: "Code Agent cancel" })
|
||||
};
|
||||
|
||||
function normalizeWorkbenchTurnResult(result: ApiResult<Record<string, unknown>>, traceId: string): ApiResult<AgentChatResultResponse> {
|
||||
if (!result.ok || !result.data) return result as ApiResult<AgentChatResultResponse>;
|
||||
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<Record<string, unknown>>
|
||||
};
|
||||
}
|
||||
|
||||
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<Record<string, unknown>>, traceId: string): ApiResult<AgentChatResultResponse> {
|
||||
if (!result.ok || !result.data) return result as ApiResult<AgentChatResultResponse>;
|
||||
const events = Array.isArray(result.data.events) ? result.data.events : [];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await Promise.all(uniqueSessionIds(source).map((sessionId) => refreshSessionStatusById(sessionId)));
|
||||
void source;
|
||||
}
|
||||
|
||||
async function refreshSelectedSessionStatus(fallbackSession?: WorkbenchSessionRecord | null): Promise<void> {
|
||||
@@ -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<WorkbenchSessionRecord | null> {
|
||||
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;
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user