Merge pull request #1477 from pikasTech/fix/issue-1475-workbench-authority-redline
[#1475] Workbench sessionId authority redline
This commit is contained in:
@@ -101,7 +101,7 @@ assertIncludes(workbenchStoreSource, "scheduleRealtimeGapHydration", "Workbench
|
||||
assertIncludes(workbenchStoreSource, "hydrateRealtimeGap", "Workbench realtime recovery must use REST snapshot/page gap fill");
|
||||
assert.doesNotMatch(workbenchStoreSource, /subscribeToTrace|TRACE_POLL_INTERVAL_MS/u, "Workbench store must not reintroduce active trace polling");
|
||||
assertIncludes(appSource, "/v1/workbench/events", "Workbench realtime client must use the RESTful same-origin events endpoint");
|
||||
assertIncludes(appSource, "/v1/agent/traces/", "trace hydration must use RESTful Cloud Web same-origin trace API");
|
||||
assertIncludes(appSource, "/v1/workbench/traces/", "trace hydration must use Workbench read-model trace API");
|
||||
assert.doesNotMatch(appSource, /\/v1\/agent\/chat\/trace\//u, "Cloud Web must not use the legacy action-style chat trace API");
|
||||
assert.doesNotMatch(appSource, /HWLAB_CLOUD_WEB_EARLY_WORKSPACE_BOOTSTRAP/u, "early workspace bootstrap helper must not remain in Cloud Web app source");
|
||||
assert.doesNotMatch(appSource, /\/auth\/workspace-bootstrap/u, "stale auth workspace bootstrap route must not remain in Cloud Web app source");
|
||||
|
||||
@@ -26,6 +26,8 @@ interface ScenarioState {
|
||||
workspaceJson: JsonRecord;
|
||||
conversations: ConversationRecord[];
|
||||
traces: Record<string, JsonRecord>;
|
||||
requestLedger: JsonRecord[];
|
||||
legacyRequestLedger: JsonRecord[];
|
||||
selectRequests: JsonRecord[];
|
||||
workspacePatchRequests: JsonRecord[];
|
||||
chatRequests: JsonRecord[];
|
||||
@@ -68,6 +70,7 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
|
||||
const method = request.method ?? "GET";
|
||||
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? `127.0.0.1:${port}`}`);
|
||||
const path = url.pathname;
|
||||
recordLedger(method, url);
|
||||
|
||||
if (path === "/__e2e/health") return json(response, 200, { ok: true, scenarioId: state.scenarioId });
|
||||
if (path === "/__e2e/state") return json(response, 200, stateSummary());
|
||||
@@ -84,6 +87,20 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
|
||||
await delay(state.conversationDelayMs);
|
||||
return json(response, 200, workbenchSessionListPayload(url));
|
||||
}
|
||||
const workbenchTurnMatch = path.match(/^\/v1\/workbench\/turns\/([^/]+)$/u);
|
||||
if (workbenchTurnMatch && method === "GET") {
|
||||
const traceId = decodeURIComponent(workbenchTurnMatch[1] ?? "");
|
||||
if (state.scenarioId === "completed-replay-detail-404" && traceId === "trc_completed") return json(response, 404, { ok: false, status: 404, error: { code: "turn_replay_unavailable" } });
|
||||
if (traceId === state.staleNestedTraceId) return json(response, 502, { ok: false, status: 502, error: { code: "upstream_unavailable", message: "stale trace is unavailable" } });
|
||||
return json(response, 200, { ok: true, status: "ok", contractVersion: "workbench-read-model-v1", turn: turnPayload(traceId) });
|
||||
}
|
||||
const workbenchTraceMatch = path.match(/^\/v1\/workbench\/traces\/([^/]+)\/events$/u);
|
||||
if (workbenchTraceMatch && method === "GET") {
|
||||
const traceId = decodeURIComponent(workbenchTraceMatch[1] ?? "");
|
||||
if (state.scenarioId === "completed-replay-detail-404" && traceId === "trc_completed") return json(response, 404, { ok: false, status: 404, error: { code: "trace_replay_unavailable" } });
|
||||
if (traceId === state.staleNestedTraceId) return json(response, 502, { ok: false, status: 502, error: { code: "upstream_unavailable", message: "stale trace is unavailable" } });
|
||||
return json(response, 200, workbenchTracePayload(traceId, url));
|
||||
}
|
||||
const sessionMessagesMatch = path.match(/^\/v1\/workbench\/sessions\/([^/]+)\/messages$/u);
|
||||
if (sessionMessagesMatch && method === "GET") {
|
||||
const sessionId = decodeURIComponent(sessionMessagesMatch[1] ?? "");
|
||||
@@ -98,31 +115,12 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
|
||||
const conversation = workbenchConversationBySessionId(sessionId);
|
||||
return conversation ? json(response, 200, { session: conversation }) : json(response, 404, { ok: false, status: 404, error: { code: "session_not_found" } });
|
||||
}
|
||||
if (path === "/v1/workbench/workspace" && method === "GET") return json(response, 200, { workspace: workspacePayload() });
|
||||
if (path === "/v1/workbench/workspace") return legacyApiResponse(response, method, path);
|
||||
if (/^\/v1\/workbench\/workspace\/[^/]+$/u.test(path) && method === "PATCH") {
|
||||
const body = await readJson(request);
|
||||
state.workspacePatchRequests.push(redactRequestBody(body));
|
||||
applyWorkspacePatch(body);
|
||||
return json(response, 200, { workspace: workspacePayload() });
|
||||
return legacyApiResponse(response, method, path);
|
||||
}
|
||||
if (/^\/v1\/workbench\/workspace\/[^/]+\/select-conversation$/u.test(path) && method === "POST") {
|
||||
const body = await readJson(request);
|
||||
state.selectRequests.push(redactRequestBody(body));
|
||||
const requestedProjectId = typeof body.projectId === "string" && body.projectId.trim() ? body.projectId.trim() : state.projectId;
|
||||
if (requestedProjectId !== state.projectId) return json(response, 409, { ok: false, status: 409, error: { code: "workspace_project_mismatch" } });
|
||||
if (state.scenarioId === "server-authoritative-create" && body.create === true) {
|
||||
const conversationId = "cnv_server_created";
|
||||
if (!conversationById(conversationId)) state.conversations.unshift(createConversationFromSelect({ ...body, sessionId: "ses_server_created", threadId: null }, conversationId));
|
||||
state.selectedConversationId = conversationId;
|
||||
return json(response, 200, { workspace: workspacePayload() });
|
||||
}
|
||||
const conversationId = String(body.conversationId ?? "");
|
||||
const conversation = conversationId ? conversationById(conversationId) : null;
|
||||
if (conversation && conversationProjectId(conversation) !== requestedProjectId) return json(response, 404, { ok: false, status: 404, error: { code: "agent_conversation_not_found" } });
|
||||
if (conversation?.status === "archived") return json(response, 404, { ok: false, status: 404, error: { code: "agent_conversation_not_found" } });
|
||||
if (conversationId && !conversationById(conversationId) && body.create === true) state.conversations.unshift(createConversationFromSelect(body, conversationId));
|
||||
if (conversationId && conversationById(conversationId)) state.selectedConversationId = conversationId;
|
||||
return json(response, 200, { workspace: workspacePayload() });
|
||||
return legacyApiResponse(response, method, path);
|
||||
}
|
||||
|
||||
if (path === "/v1/agent/chat" && method === "POST") {
|
||||
@@ -131,27 +129,24 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
|
||||
return json(response, 200, acceptChatTurn(body));
|
||||
}
|
||||
|
||||
if (path === "/v1/agent/sessions" && method === "POST") {
|
||||
const body = await readJson(request);
|
||||
const conversation = createManualSessionConversation(body);
|
||||
state.selectedConversationId = conversation.conversationId;
|
||||
state.conversations.unshift(conversation);
|
||||
return json(response, 200, { ok: true, status: "ok", session: conversation });
|
||||
}
|
||||
|
||||
if (path === "/v1/agent/conversations" && method === "GET") {
|
||||
await delay(state.conversationDelayMs);
|
||||
const include = url.searchParams.get("includeConversationId");
|
||||
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)) });
|
||||
return legacyApiResponse(response, method, path);
|
||||
}
|
||||
const conversationMatch = path.match(/^\/v1\/agent\/conversations\/([^/]+)$/u);
|
||||
if (conversationMatch && method === "GET") {
|
||||
const conversationId = decodeURIComponent(conversationMatch[1] ?? "");
|
||||
if (state.scenarioId === "session-switch-detail-404-isolated" && conversationId === "cnv_stale_404") return json(response, 404, { ok: false, status: 404, error: { code: "conversation_not_found" } });
|
||||
if (state.scenarioId === "completed-replay-detail-404" && conversationId === "cnv_completed") return json(response, 404, { ok: false, status: 404, error: { code: "conversation_replay_unavailable" } });
|
||||
const conversation = conversationById(conversationId);
|
||||
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" } });
|
||||
return legacyApiResponse(response, method, path);
|
||||
}
|
||||
if (conversationMatch && method === "PUT") return json(response, 200, { ok: true, workspace: workspacePayload() });
|
||||
if (conversationMatch && method === "PUT") return legacyApiResponse(response, method, path);
|
||||
if (conversationMatch && method === "DELETE") {
|
||||
deleteConversationById(decodeURIComponent(conversationMatch[1] ?? ""));
|
||||
return json(response, 200, { ok: true, status: "deleted", workspace: workspacePayload() });
|
||||
return legacyApiResponse(response, method, path);
|
||||
}
|
||||
|
||||
const sessionMatch = path.match(/^\/v1\/agent\/sessions\/([^/]+)$/u);
|
||||
@@ -159,20 +154,12 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
|
||||
|
||||
const turnMatch = path.match(/^\/v1\/agent\/turns\/([^/]+)$/u);
|
||||
if (turnMatch && method === "GET") {
|
||||
const traceId = decodeURIComponent(turnMatch[1] ?? "");
|
||||
if (state.scenarioId === "completed-replay-detail-404" && traceId === "trc_completed") return json(response, 404, { ok: false, status: 404, error: { code: "turn_replay_unavailable" } });
|
||||
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));
|
||||
return legacyApiResponse(response, method, path);
|
||||
}
|
||||
|
||||
const traceMatch = path.match(/^\/v1\/agent\/traces\/([^/]+)$/u);
|
||||
if (traceMatch && method === "GET") {
|
||||
const traceId = decodeURIComponent(traceMatch[1] ?? "");
|
||||
if (state.scenarioId === "completed-replay-detail-404" && traceId === "trc_completed") return json(response, 404, { ok: false, status: 404, error: { code: "trace_replay_unavailable" } });
|
||||
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));
|
||||
return legacyApiResponse(response, method, path);
|
||||
}
|
||||
|
||||
if (path === "/v1/provider-profiles") return json(response, 200, { profiles: [{ profile: "codex-api", name: "Codex API", configured: true }, { profile: "deepseek", name: "DeepSeek", configured: true }] });
|
||||
@@ -227,6 +214,8 @@ function createScenarioState(scenarioId: string): ScenarioState {
|
||||
workspaceJson: initialWorkspaceJson(base.projectId, base.providerProfile, selectedConversationId, conversations, staleNestedTraceId),
|
||||
conversations,
|
||||
traces,
|
||||
requestLedger: [],
|
||||
legacyRequestLedger: [],
|
||||
selectRequests: [],
|
||||
workspacePatchRequests: [],
|
||||
chatRequests: [],
|
||||
@@ -313,6 +302,27 @@ function createConversationFromSelect(body: JsonRecord, conversationId: string):
|
||||
};
|
||||
}
|
||||
|
||||
function createManualSessionConversation(body: JsonRecord): ConversationRecord {
|
||||
const now = new Date().toISOString();
|
||||
const token = state.scenarioId === "server-authoritative-create" ? "server_created" : Date.now().toString(36);
|
||||
const sessionId = typeof body.sessionId === "string" && body.sessionId.trim() ? body.sessionId : `ses_${token}`;
|
||||
const conversationId = typeof body.conversationId === "string" && body.conversationId.trim() ? body.conversationId : `cnv_${token}`;
|
||||
const threadId = typeof body.threadId === "string" && body.threadId.trim() ? body.threadId : null;
|
||||
return {
|
||||
conversationId,
|
||||
projectId: state.projectId,
|
||||
sessionId,
|
||||
threadId,
|
||||
status: "active",
|
||||
startedAt: now,
|
||||
updatedAt: now,
|
||||
messageCount: 0,
|
||||
firstUserMessagePreview: null,
|
||||
session: { sessionId, threadId, status: "active" },
|
||||
messages: []
|
||||
};
|
||||
}
|
||||
|
||||
function crossProjectConversation(): ConversationRecord {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
@@ -403,7 +413,8 @@ function archivedDeletedConversation(): ConversationRecord {
|
||||
}
|
||||
|
||||
function acceptChatTurn(body: JsonRecord): JsonRecord {
|
||||
const conversationId = typeof body.conversationId === "string" ? body.conversationId : state.selectedConversationId;
|
||||
const requestedSession = typeof body.sessionId === "string" ? conversationBySessionId(body.sessionId) : null;
|
||||
const conversationId = typeof body.conversationId === "string" ? body.conversationId : requestedSession?.conversationId ?? state.selectedConversationId;
|
||||
const sessionId = typeof body.sessionId === "string" ? body.sessionId : conversationById(conversationId)?.sessionId ?? `ses_${conversationId.slice(4)}`;
|
||||
const threadId = typeof body.threadId === "string" && body.threadId.trim() ? body.threadId : null;
|
||||
const traceId = typeof body.traceId === "string" && body.traceId.trim() ? body.traceId : `trc_${Date.now().toString(16)}`;
|
||||
@@ -523,6 +534,27 @@ function tracePayload(traceId: string, url: URL): JsonRecord {
|
||||
return { ...turn, events: page, eventCount: events.length, hasMore, fullTraceLoaded: !hasMore, nextSinceSeq: hasMore ? lastSeq : null, range: { sinceSeq, returned: page.length, total: events.length } };
|
||||
}
|
||||
|
||||
function workbenchTracePayload(traceId: string, url: URL): JsonRecord {
|
||||
const payload = tracePayload(traceId, url);
|
||||
return {
|
||||
ok: true,
|
||||
status: "ok",
|
||||
contractVersion: "workbench-read-model-v1",
|
||||
traceId,
|
||||
traceStatus: payload.status,
|
||||
events: payload.events,
|
||||
eventCount: payload.eventCount,
|
||||
hasMore: payload.hasMore,
|
||||
nextSeq: payload.nextSinceSeq,
|
||||
range: payload.range,
|
||||
fullTraceLoaded: payload.fullTraceLoaded,
|
||||
terminalEvidence: payload.terminalEvidence,
|
||||
finalResponse: payload.finalResponse,
|
||||
traceSummary: payload.traceSummary,
|
||||
retention: payload.retention
|
||||
};
|
||||
}
|
||||
|
||||
function sse(response: ServerResponse, url: URL): void {
|
||||
response.writeHead(200, {
|
||||
"content-type": "text/event-stream; charset=utf-8",
|
||||
@@ -567,7 +599,27 @@ function clearActiveTraceForSelection(): void {
|
||||
}
|
||||
|
||||
function stateSummary(): JsonRecord {
|
||||
return { scenarioId: state.scenarioId, selectedConversationId: state.selectedConversationId, selectRequests: state.selectRequests, workspacePatchRequests: state.workspacePatchRequests, chatRequests: state.chatRequests, staleNestedTraceId: state.staleNestedTraceId, workspace: workspacePayload(), conversations: state.conversations.map((item) => ({ conversationId: item.conversationId, sessionId: item.sessionId, threadId: item.threadId, status: item.status, lastTraceId: item.lastTraceId })) };
|
||||
return { scenarioId: state.scenarioId, selectedConversationId: state.selectedConversationId, requestLedger: state.requestLedger, legacyRequestLedger: state.legacyRequestLedger, selectRequests: state.selectRequests, workspacePatchRequests: state.workspacePatchRequests, chatRequests: state.chatRequests, staleNestedTraceId: state.staleNestedTraceId, workspace: workspacePayload(), conversations: state.conversations.map((item) => ({ conversationId: item.conversationId, sessionId: item.sessionId, threadId: item.threadId, status: item.status, lastTraceId: item.lastTraceId })) };
|
||||
}
|
||||
|
||||
function recordLedger(method: string, url: URL): void {
|
||||
if (!url.pathname.startsWith("/v1/")) return;
|
||||
const entry = { method, path: url.pathname, query: Object.fromEntries(url.searchParams.entries()) };
|
||||
state.requestLedger.push(entry);
|
||||
if (isLegacyWorkbenchPath(url.pathname)) state.legacyRequestLedger.push(entry);
|
||||
}
|
||||
|
||||
function isLegacyWorkbenchPath(path: string): boolean {
|
||||
return path === "/v1/workbench/workspace"
|
||||
|| /^\/v1\/workbench\/workspace\/[^/]+(?:\/select-conversation)?$/u.test(path)
|
||||
|| path === "/v1/agent/conversations"
|
||||
|| /^\/v1\/agent\/conversations\/[^/]+$/u.test(path)
|
||||
|| /^\/v1\/agent\/turns\/[^/]+$/u.test(path)
|
||||
|| /^\/v1\/agent\/traces\/[^/]+$/u.test(path);
|
||||
}
|
||||
|
||||
function legacyApiResponse(response: ServerResponse, method: string, path: string): void {
|
||||
return json(response, 410, { ok: false, status: 410, error: { code: "legacy_workbench_endpoint_forbidden", message: `${method} ${path} is not part of the sessionId Workbench contract.` } });
|
||||
}
|
||||
|
||||
function authPayload(): JsonRecord {
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { ChatMessage, ConversationRecord, WorkspaceRecord } from "../src/types/index.ts";
|
||||
import { defaultProviderProfileOptions, mergeConversationIntoList, normalizeChatMessageStatus, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, stableConversationList, workspaceWithClearedActiveTrace } from "../src/stores/workbench-session.ts";
|
||||
import { defaultProviderProfileOptions, mergeConversationIntoList, normalizeChatMessageStatus, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, shouldShowSessionListLoading, sortSessionTabs, stableConversationList } from "../src/stores/workbench-session.ts";
|
||||
|
||||
test("R2 composer ignores stale workspace activeTraceId and workspace session without verified active projection", () => {
|
||||
const workspace = workspaceRecord({ activeTraceId: "trc_stale", sessionStatus: "running" });
|
||||
@@ -67,30 +67,6 @@ test("R2 composer never reuses a stale workspace session during routed selection
|
||||
assert.equal(withTarget.threadId, "thr_route");
|
||||
});
|
||||
|
||||
test("R2 active trace cleanup preserves workspace and records stale reason", () => {
|
||||
const cleared = workspaceWithClearedActiveTrace(workspaceRecord({ activeTraceId: "trc_stale", sessionStatus: "running" }), "trc_stale", "steer-trace-not-found");
|
||||
assert.equal(cleared?.activeTraceId, null);
|
||||
assert.equal(cleared?.workspace?.activeTraceId, null);
|
||||
assert.equal(cleared?.workspace?.staleActiveTraceId, "trc_stale");
|
||||
assert.equal(cleared?.workspace?.staleActiveTraceReason, "steer-trace-not-found");
|
||||
assert.equal(cleared?.workspace?.sessionStatus, "failed");
|
||||
});
|
||||
|
||||
test("R2 workspace snapshots cannot override a newer explicit session selection", () => {
|
||||
assert.equal(shouldApplyWorkspaceSnapshot({
|
||||
requestEpoch: 1,
|
||||
currentEpoch: 2,
|
||||
currentConversationId: "cnv_user_selected",
|
||||
workspace: workspaceRecord({ activeTraceId: null, sessionStatus: "idle" })
|
||||
}), false);
|
||||
assert.equal(shouldApplyWorkspaceSnapshot({
|
||||
requestEpoch: 1,
|
||||
currentEpoch: 2,
|
||||
currentConversationId: "cnv_r2",
|
||||
workspace: workspaceRecord({ activeTraceId: null, sessionStatus: "idle" })
|
||||
}), true);
|
||||
});
|
||||
|
||||
test("R2 session list shows loading until conversations are ready", () => {
|
||||
assert.equal(shouldShowSessionListLoading({ loading: true, conversationsReady: false }), true);
|
||||
assert.equal(shouldShowSessionListLoading({ loading: true, conversationsReady: true }), false);
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { ConversationRecord, WorkspaceRecord } from "../src/types/index.ts";
|
||||
import { initialWorkbenchConversationIdFromLocation, nextExplicitWorkbenchConversationId, resolveWorkbenchActiveConversationId } from "../src/stores/workbench-projection.ts";
|
||||
import { initialWorkbenchSessionIdFromLocation, resolveWorkbenchActiveConversationId } from "../src/stores/workbench-projection.ts";
|
||||
import { createWorkbenchServerState, reduceWorkbenchServerState, selectActiveConversation, selectActiveMessages, selectTraceAuthorityById } from "../src/stores/workbench-server-state.ts";
|
||||
|
||||
test("R5 active conversation comes only from route or explicit UI selection", () => {
|
||||
@@ -12,9 +12,9 @@ test("R5 active conversation comes only from route or explicit UI selection", ()
|
||||
});
|
||||
|
||||
test("R5 initial route parsing ignores workspace fallback", () => {
|
||||
assert.equal(initialWorkbenchConversationIdFromLocation({ pathname: "/workbench/sessions/cnv_route" }), "cnv_route");
|
||||
assert.equal(initialWorkbenchConversationIdFromLocation({ pathname: "/workbench" }), null);
|
||||
assert.equal(nextExplicitWorkbenchConversationId("cnv_existing", "not-a-conversation"), "cnv_existing");
|
||||
assert.equal(initialWorkbenchSessionIdFromLocation({ pathname: "/workbench/sessions/ses_route" }), "ses_route");
|
||||
assert.equal(initialWorkbenchSessionIdFromLocation({ pathname: "/workbench/sessions/cnv_route" }), null);
|
||||
assert.equal(initialWorkbenchSessionIdFromLocation({ pathname: "/workbench" }), null);
|
||||
});
|
||||
|
||||
test("R5 late workspace and trace snapshots update cache without changing active projection", () => {
|
||||
|
||||
@@ -9,18 +9,86 @@ export interface AgentTraceRequestOptions {
|
||||
limit?: number | null;
|
||||
}
|
||||
|
||||
function agentTracePath(traceId: string, projectId: string, options: AgentTraceRequestOptions = {}): string {
|
||||
const params = new URLSearchParams({ projectId });
|
||||
function workbenchTracePath(traceId: string, options: AgentTraceRequestOptions = {}): string {
|
||||
const params = new URLSearchParams();
|
||||
if (Number.isFinite(options.sinceSeq)) params.set("sinceSeq", String(Math.max(0, Math.trunc(Number(options.sinceSeq)))));
|
||||
if (Number.isFinite(options.limit)) params.set("limit", String(Math.max(1, Math.trunc(Number(options.limit)))));
|
||||
return `/v1/agent/traces/${encodeURIComponent(traceId)}?${params.toString()}`;
|
||||
const query = params.toString();
|
||||
return `/v1/workbench/traces/${encodeURIComponent(traceId)}/events${query ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
export const agentAPI = {
|
||||
sendAgentMessage: (payload: Record<string, unknown>, timeoutMs: number, activityRef?: ApiRequestOptions["activityRef"]): Promise<ApiResult<AgentChatResponse>> => fetchJson("/v1/agent/chat", { method: "POST", body: JSON.stringify(payload), timeoutMs, timeoutName: "Code Agent", activityRef }),
|
||||
steerAgentMessage: (payload: Record<string, unknown>, timeoutMs: number, activityRef?: ApiRequestOptions["activityRef"]): Promise<ApiResult<AgentChatResponse>> => fetchJson("/v1/agent/chat/steer", { method: "POST", body: JSON.stringify(payload), timeoutMs, timeoutName: "Code Agent steer", activityRef }),
|
||||
getAgentTurn: (traceId: string, projectId: string, timeoutMs = 8000, activityRef?: ApiRequestOptions["activityRef"]): Promise<ApiResult<AgentChatResultResponse>> => fetchJson(`/v1/agent/turns/${encodeURIComponent(traceId)}?projectId=${encodeURIComponent(projectId)}`, { timeoutMs, timeoutName: "Code Agent turn", activityRef }),
|
||||
getAgentTrace: (traceId: string, projectId: string, timeoutMs = 8000, activityRef?: ApiRequestOptions["activityRef"], options?: AgentTraceRequestOptions): Promise<ApiResult<AgentChatResultResponse>> => fetchJson(agentTracePath(traceId, projectId, options), { timeoutMs, timeoutName: "Code Agent trace", activityRef }),
|
||||
getAgentTurn: async (traceId: string, timeoutMs = 8000, activityRef?: ApiRequestOptions["activityRef"]): Promise<ApiResult<AgentChatResultResponse>> => normalizeWorkbenchTurnResult(await fetchJson<Record<string, unknown>>(`/v1/workbench/turns/${encodeURIComponent(traceId)}`, { timeoutMs, timeoutName: "Code Agent turn", activityRef }), traceId),
|
||||
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" }),
|
||||
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;
|
||||
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,
|
||||
sessionId: textValue(turn.sessionId) ?? undefined,
|
||||
threadId: textValue(turn.threadId) ?? undefined,
|
||||
conversationId: textValue(turn.conversationId) ?? undefined,
|
||||
agentRun: recordValue(turn.agentRun) ?? undefined,
|
||||
updatedAt: textValue(turn.updatedAt) ?? textValue(result.data.observedAt) ?? undefined
|
||||
} as AgentChatResultResponse
|
||||
};
|
||||
}
|
||||
|
||||
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 : [];
|
||||
const nextSeq = Number(result.data.nextSeq ?? result.data.nextSinceSeq);
|
||||
return {
|
||||
...result,
|
||||
data: {
|
||||
...result.data,
|
||||
traceId: textValue(result.data.traceId) ?? traceId,
|
||||
status: textValue(result.data.traceStatus, result.data.status) ?? "unknown",
|
||||
traceStatus: textValue(result.data.traceStatus, result.data.status) ?? undefined,
|
||||
events,
|
||||
traceEvents: events,
|
||||
eventCount: Number.isFinite(Number(result.data.eventCount)) ? Number(result.data.eventCount) : events.length,
|
||||
hasMore: result.data.hasMore === true,
|
||||
fullTraceLoaded: result.data.hasMore !== true,
|
||||
nextSinceSeq: Number.isFinite(nextSeq) ? Math.trunc(nextSeq) : null,
|
||||
range: recordValue(result.data.range) ?? undefined,
|
||||
runnerTrace: {
|
||||
traceId: textValue(result.data.traceId) ?? traceId,
|
||||
status: textValue(result.data.traceStatus, result.data.status) ?? undefined,
|
||||
events,
|
||||
eventCount: Number.isFinite(Number(result.data.eventCount)) ? Number(result.data.eventCount) : events.length,
|
||||
hasMore: result.data.hasMore === true,
|
||||
fullTraceLoaded: result.data.hasMore !== true,
|
||||
nextSinceSeq: Number.isFinite(nextSeq) ? Math.trunc(nextSeq) : null,
|
||||
range: recordValue(result.data.range) ?? undefined,
|
||||
eventSource: "trace-api"
|
||||
}
|
||||
} as AgentChatResultResponse
|
||||
};
|
||||
}
|
||||
|
||||
function recordValue(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" ? value as Record<string, unknown> : null;
|
||||
}
|
||||
|
||||
function textValue(...values: unknown[]): string | null {
|
||||
for (const value of values) {
|
||||
if (typeof value !== "string") continue;
|
||||
const text = value.trim();
|
||||
if (text) return text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -34,9 +34,6 @@ export interface WorkbenchRealtimeEvent {
|
||||
}
|
||||
|
||||
export interface WorkbenchEventStreamOptions {
|
||||
projectId?: string | null;
|
||||
workspaceId?: string | null;
|
||||
conversationId?: string | null;
|
||||
sessionId?: string | null;
|
||||
traceId?: string | null;
|
||||
onEvent: (event: WorkbenchRealtimeEvent, eventName: string) => void;
|
||||
@@ -62,9 +59,6 @@ const WORKBENCH_EVENT_NAMES = [
|
||||
export function connectWorkbenchEvents(options: WorkbenchEventStreamOptions): WorkbenchEventStream | null {
|
||||
if (typeof EventSource === "undefined") return null;
|
||||
const params = new URLSearchParams();
|
||||
appendParam(params, "projectId", options.projectId);
|
||||
appendParam(params, "workspaceId", options.workspaceId);
|
||||
appendParam(params, "conversationId", options.conversationId);
|
||||
appendParam(params, "sessionId", options.sessionId);
|
||||
appendParam(params, "traceId", options.traceId);
|
||||
const source = new EventSource(`/v1/workbench/events?${params.toString()}`, { withCredentials: true });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fetchJson } from "./client";
|
||||
import type { ApiResult, ChatMessage, ConversationRecord, WorkspaceRecord } from "@/types";
|
||||
import type { ApiResult, ChatMessage, ConversationRecord } from "@/types";
|
||||
|
||||
export interface WorkbenchSessionListResponse {
|
||||
projectId?: string | null;
|
||||
@@ -26,16 +26,6 @@ export interface SessionListOptions {
|
||||
timeoutMs?: number | null;
|
||||
}
|
||||
|
||||
export interface ConversationListOptions {
|
||||
includeConversationId?: string | null;
|
||||
timeoutMs?: number | null;
|
||||
}
|
||||
|
||||
export interface ConversationDetailOptions {
|
||||
projectId?: string | null;
|
||||
timeoutMs?: number | null;
|
||||
}
|
||||
|
||||
export interface SessionMessageOptions {
|
||||
limit?: number | null;
|
||||
cursor?: string | null;
|
||||
@@ -44,20 +34,11 @@ export interface SessionMessageOptions {
|
||||
|
||||
const SESSION_LIST_TIMEOUT_MS = 30_000;
|
||||
const SESSION_DETAIL_TIMEOUT_MS = 45_000;
|
||||
const CONVERSATION_LIST_TIMEOUT_MS = 30_000;
|
||||
const CONVERSATION_DETAIL_TIMEOUT_MS = 45_000;
|
||||
|
||||
export const workbenchAPI = {
|
||||
sessions: (options: SessionListOptions = {}): Promise<ApiResult<WorkbenchSessionListResponse>> => fetchJson(sessionListPath(options), { timeoutMs: requestTimeoutMs(options.timeoutMs, SESSION_LIST_TIMEOUT_MS), timeoutName: "workbench sessions" }),
|
||||
session: (sessionId: string, timeoutMs: number | null = null): Promise<ApiResult<WorkbenchSessionDetailResponse>> => fetchJson(`/v1/workbench/sessions/${encodeURIComponent(sessionId)}`, { timeoutMs: requestTimeoutMs(timeoutMs, SESSION_DETAIL_TIMEOUT_MS), timeoutName: "workbench session" }),
|
||||
sessionMessages: (sessionId: string, options: SessionMessageOptions = {}): Promise<ApiResult<WorkbenchMessagePageResponse>> => fetchJson(sessionMessagesPath(sessionId, options), { timeoutMs: requestTimeoutMs(options.timeoutMs, SESSION_DETAIL_TIMEOUT_MS), timeoutName: "workbench session messages" }),
|
||||
workspace: (projectId: string): Promise<ApiResult<{ workspace?: WorkspaceRecord }>> => fetchJson(`/v1/workbench/workspace?projectId=${encodeURIComponent(projectId)}`, { timeoutMs: 8000, timeoutName: "workspace" }),
|
||||
updateWorkspace: (workspaceId: string, payload: Record<string, unknown>): Promise<ApiResult<{ workspace?: WorkspaceRecord }>> => fetchJson(`/v1/workbench/workspace/${encodeURIComponent(workspaceId)}`, { method: "PATCH", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "workspace update" }),
|
||||
selectConversation: (workspaceId: string, payload: Record<string, unknown>): Promise<ApiResult<{ workspace?: WorkspaceRecord }>> => fetchJson(`/v1/workbench/workspace/${encodeURIComponent(workspaceId)}/select-conversation`, { method: "POST", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "select conversation" }),
|
||||
conversations: (projectId: string, options: ConversationListOptions = {}): Promise<ApiResult<{ conversations?: ConversationRecord[] }>> => fetchJson(conversationListPath(projectId, options), { timeoutMs: requestTimeoutMs(options.timeoutMs, CONVERSATION_LIST_TIMEOUT_MS), timeoutName: "conversations" }),
|
||||
conversation: (conversationId: string, options: ConversationDetailOptions = {}): Promise<ApiResult<{ conversation?: ConversationRecord }>> => fetchJson(conversationDetailPath(conversationId, options), { timeoutMs: requestTimeoutMs(options.timeoutMs, CONVERSATION_DETAIL_TIMEOUT_MS), timeoutName: "conversation" }),
|
||||
deleteConversation: (conversationId: string, payload: Record<string, unknown>): Promise<ApiResult<{ workspace?: WorkspaceRecord }>> => fetchJson(`/v1/agent/conversations/${encodeURIComponent(conversationId)}`, { method: "DELETE", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "delete conversation" }),
|
||||
saveConversation: (conversationId: string, payload: Record<string, unknown>): Promise<ApiResult<{ ok?: boolean }>> => fetchJson(`/v1/agent/conversations/${encodeURIComponent(conversationId)}`, { method: "PUT", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "save conversation" })
|
||||
sessionMessages: (sessionId: string, options: SessionMessageOptions = {}): Promise<ApiResult<WorkbenchMessagePageResponse>> => fetchJson(sessionMessagesPath(sessionId, options), { timeoutMs: requestTimeoutMs(options.timeoutMs, SESSION_DETAIL_TIMEOUT_MS), timeoutName: "workbench session messages" })
|
||||
};
|
||||
|
||||
function sessionListPath(options: SessionListOptions): string {
|
||||
@@ -77,21 +58,6 @@ function sessionMessagesPath(sessionId: string, options: SessionMessageOptions):
|
||||
return `/v1/workbench/sessions/${encodeURIComponent(sessionId)}/messages${query ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
function conversationDetailPath(conversationId: string, options: ConversationDetailOptions): string {
|
||||
const params = new URLSearchParams();
|
||||
const projectId = typeof options.projectId === "string" ? options.projectId.trim() : "";
|
||||
if (projectId) params.set("projectId", projectId);
|
||||
const query = params.toString();
|
||||
return `/v1/agent/conversations/${encodeURIComponent(conversationId)}${query ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
function conversationListPath(projectId: string, options: ConversationListOptions): string {
|
||||
const params = new URLSearchParams({ projectId });
|
||||
const includeConversationId = typeof options.includeConversationId === "string" ? options.includeConversationId.trim() : "";
|
||||
if (includeConversationId) params.set("includeConversationId", includeConversationId);
|
||||
return `/v1/agent/conversations?${params.toString()}`;
|
||||
}
|
||||
|
||||
function requestTimeoutMs(value: number | null | undefined, fallback: number): number {
|
||||
const timeout = Number(value);
|
||||
return Number.isFinite(timeout) && timeout > 0 ? Math.trunc(timeout) : fallback;
|
||||
|
||||
@@ -11,7 +11,7 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const traceId = computed(() => messageTraceId(props.message));
|
||||
const traceHref = computed(() => traceId.value ? `/v1/agent/traces/${encodeURIComponent(traceId.value)}` : "#");
|
||||
const traceHref = computed(() => traceId.value ? `/v1/workbench/traces/${encodeURIComponent(traceId.value)}/events` : "#");
|
||||
const canCancel = computed(() => canCancelMessage(props.message));
|
||||
const canRetry = computed(() => canRetryMessage(props.message));
|
||||
const hasTrace = computed(() => props.message.role === "agent" && Boolean(traceId.value));
|
||||
|
||||
@@ -15,11 +15,11 @@ const showSessionListLoading = computed(() => workbench.sessionListLoading);
|
||||
function copyActive(): void {
|
||||
const tab = activeTab.value;
|
||||
if (!tab) return;
|
||||
void copy(`url=${sessionUrl(tab)}\nconversation=${tab.conversationId}\nsession=${tab.sessionId ?? ''}\nthread=${tab.threadId ?? ''}\ntrace=${tab.lastTraceId ?? ''}`);
|
||||
void copy(`url=${sessionUrl(tab)}\nsession=${tab.sessionId ?? ''}\nthread=${tab.threadId ?? ''}\ntrace=${tab.lastTraceId ?? ''}`);
|
||||
}
|
||||
|
||||
function sessionUrl(tab: { sessionId?: string | null; conversationId: string }): string {
|
||||
const url = new URL(workbenchSessionPath(tab.sessionId || tab.conversationId), window.location.origin);
|
||||
const url = new URL(workbenchSessionPath(tab.sessionId || ""), window.location.origin);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ function formatSessionUpdatedTime(value: string | null | undefined): string {
|
||||
<div class="session-list" id="session-tabs" :data-loading="showSessionListLoading">
|
||||
<LoadingState v-if="showSessionListLoading" class="session-list-loading" label="加载中" />
|
||||
<template v-else>
|
||||
<button v-for="tab in workbench.sessionTabs" :key="tab.conversationId" class="session-tab" :data-active="tab.active" :data-running="tab.running" :data-status="tab.status || undefined" :data-session-id="tab.sessionId || undefined" :data-conversation-id="tab.conversationId" type="button" :title="tab.label || tab.conversationId" @click="workbench.selectConversation(tab)">
|
||||
<button v-for="tab in workbench.sessionTabs" :key="tab.sessionId || tab.key" class="session-tab" :data-active="tab.active" :data-running="tab.running" :data-status="tab.status || undefined" :data-session-id="tab.sessionId || undefined" type="button" :title="tab.label || tab.sessionId || tab.conversationId" @click="workbench.selectSession(tab)">
|
||||
<span class="session-tab-title">{{ tab.label || tab.conversationId }}</span>
|
||||
<time class="session-tab-time" :datetime="tab.updatedAt || tab.startedAt || undefined">{{ formatSessionUpdatedTime(tab.updatedAt || tab.startedAt) }}</time>
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { agentAPI, type ActivityRefSource } from "@/api";
|
||||
import type { AgentChatResponse, AgentChatResultResponse, AgentRunProvenance, ChatMessage, TraceEvent } from "@/types";
|
||||
import { DEFAULT_WORKBENCH_PROJECT_ID, firstNonEmptyString, normalizeWorkbenchProjectId } from "@/utils";
|
||||
import { firstNonEmptyString } from "@/utils";
|
||||
|
||||
export interface TraceSnapshot {
|
||||
traceId?: string;
|
||||
@@ -30,7 +30,6 @@ export interface TraceSnapshot {
|
||||
|
||||
export interface TraceSubscriptionConfig {
|
||||
traceId: string;
|
||||
projectId?: string;
|
||||
initial: AgentChatResponse;
|
||||
onActivity: () => void;
|
||||
onSnapshot: (snapshot: TraceSnapshot) => void;
|
||||
@@ -171,7 +170,6 @@ export function mergeTraceResults(terminal: AgentChatResultResponse, trace: Trac
|
||||
|
||||
export async function subscribeToTrace(config: TraceSubscriptionConfig): Promise<void> {
|
||||
const { traceId, initial, onActivity, onSnapshot, onComplete, onInfrastructureError, signal, inactivityTimeoutMs, activityRef } = config;
|
||||
const projectId = normalizeWorkbenchProjectId(config.projectId) ?? DEFAULT_WORKBENCH_PROJECT_ID;
|
||||
if (isTerminalStatus(initial.status)) {
|
||||
const snapshot = resultToTraceSnapshot(traceId, initial as AgentChatResultResponse, "turn-api");
|
||||
onSnapshot(snapshot);
|
||||
@@ -190,7 +188,7 @@ export async function subscribeToTrace(config: TraceSubscriptionConfig): Promise
|
||||
const fetchLiveTracePages = async (): Promise<TraceSnapshot | null> => {
|
||||
for (let page = 0; page < TRACE_LIVE_MAX_PAGES_PER_POLL; page += 1) {
|
||||
const previousSinceSeq = traceSinceSeq;
|
||||
const tracePolled = await agentAPI.getAgentTrace(traceId, projectId, inactivityTimeoutMs, activityRef, { sinceSeq: traceSinceSeq, limit: TRACE_LIVE_PAGE_LIMIT });
|
||||
const tracePolled = await agentAPI.getAgentTrace(traceId, inactivityTimeoutMs, activityRef, { sinceSeq: traceSinceSeq, limit: TRACE_LIVE_PAGE_LIMIT });
|
||||
if (signal.aborted) return accumulatedTrace;
|
||||
if (!tracePolled.ok || !tracePolled.data) return accumulatedTrace;
|
||||
onActivity();
|
||||
@@ -208,7 +206,7 @@ export async function subscribeToTrace(config: TraceSubscriptionConfig): Promise
|
||||
await sleep(TRACE_POLL_INTERVAL_MS);
|
||||
if (signal.aborted) return;
|
||||
|
||||
const turnPolled = await agentAPI.getAgentTurn(traceId, projectId, inactivityTimeoutMs, activityRef);
|
||||
const turnPolled = await agentAPI.getAgentTurn(traceId, inactivityTimeoutMs, activityRef);
|
||||
if (signal.aborted) return;
|
||||
|
||||
if (turnPolled.ok && turnPolled.data) {
|
||||
|
||||
@@ -1,28 +1,16 @@
|
||||
// SPEC: PJ2026-010403 API契约 draft-2026-06-18-r1; PJ2026-010401 Web工作台 draft-2026-06-18-r1.
|
||||
// Responsibility: Workbench UI projection helpers that keep route/explicit selection separate from server snapshots.
|
||||
|
||||
import { firstNonEmptyString, normalizeWorkbenchConversationId, normalizeWorkbenchSessionId } from "@/utils";
|
||||
import { firstNonEmptyString, normalizeWorkbenchSessionId } from "@/utils";
|
||||
|
||||
export interface WorkbenchActiveConversationInput {
|
||||
switchingConversationId?: string | null;
|
||||
explicitConversationId?: string | null;
|
||||
}
|
||||
|
||||
export function resolveWorkbenchActiveConversationId(input: WorkbenchActiveConversationInput): string | null {
|
||||
return firstNonEmptyString(input.switchingConversationId, input.explicitConversationId);
|
||||
}
|
||||
|
||||
export function initialWorkbenchConversationIdFromLocation(location: Pick<Location, "pathname"> | null = typeof window === "undefined" ? null : window.location): string | null {
|
||||
const pathname = location?.pathname ?? "";
|
||||
const match = /^\/(?:workbench|workspace)\/sessions\/([^/?#]+)/u.exec(pathname);
|
||||
if (!match?.[1]) return null;
|
||||
try {
|
||||
return normalizeWorkbenchConversationId(decodeURIComponent(match[1]));
|
||||
} catch {
|
||||
return normalizeWorkbenchConversationId(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
export function initialWorkbenchSessionIdFromLocation(location: Pick<Location, "pathname"> | null = typeof window === "undefined" ? null : window.location): string | null {
|
||||
const pathname = location?.pathname ?? "";
|
||||
const match = /^\/(?:workbench|workspace)\/sessions\/([^/?#]+)/u.exec(pathname);
|
||||
@@ -33,7 +21,3 @@ export function initialWorkbenchSessionIdFromLocation(location: Pick<Location, "
|
||||
return normalizeWorkbenchSessionId(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
export function nextExplicitWorkbenchConversationId(current: string | null, candidate: string | null | undefined): string | null {
|
||||
return normalizeWorkbenchConversationId(candidate) ?? current;
|
||||
}
|
||||
|
||||
@@ -100,49 +100,10 @@ export function resolveCancelableAgentMessage(input: { messages: ChatMessage[];
|
||||
return null;
|
||||
}
|
||||
|
||||
export function activeTraceIdFromWorkspace(workspace: WorkspaceRecord | null): string | null {
|
||||
return firstNonEmptyString(workspace?.activeTraceId, workspace?.workspace?.activeTraceId);
|
||||
}
|
||||
|
||||
export function selectedConversationIdFromWorkspace(workspace: WorkspaceRecord | null): string | null {
|
||||
return firstNonEmptyString(workspace?.selectedConversationId, workspace?.workspace?.selectedConversationId, workspace?.selectedConversation?.conversationId);
|
||||
}
|
||||
|
||||
export function shouldApplyWorkspaceSnapshot(input: { requestEpoch: number; currentEpoch: number; currentConversationId: string | null; workspace: WorkspaceRecord | null }): boolean {
|
||||
if (input.requestEpoch === input.currentEpoch) return true;
|
||||
const currentConversationId = firstNonEmptyString(input.currentConversationId);
|
||||
if (!currentConversationId) return false;
|
||||
return selectedConversationIdFromWorkspace(input.workspace) === currentConversationId;
|
||||
}
|
||||
|
||||
export function shouldShowSessionListLoading(input: { loading: boolean; conversationsReady: boolean }): boolean {
|
||||
return input.loading && !input.conversationsReady;
|
||||
}
|
||||
|
||||
export function workspaceWithClearedActiveTrace(workspace: WorkspaceRecord | null, traceId: string, reason: string, sessionStatus?: string | null): WorkspaceRecord | null {
|
||||
if (!workspace || !traceId) return workspace;
|
||||
const activeTraceId = activeTraceIdFromWorkspace(workspace);
|
||||
if (activeTraceId !== traceId) return workspace;
|
||||
const nested = workspace.workspace ?? {};
|
||||
const nextSessionStatus = normalizeSessionStatus(sessionStatus) ?? (isActiveStatus(nested.sessionStatus) ? "failed" : normalizeSessionStatus(nested.sessionStatus));
|
||||
return {
|
||||
...workspace,
|
||||
activeTraceId: null,
|
||||
workspace: {
|
||||
...nested,
|
||||
activeTraceId: null,
|
||||
lastTraceId: null,
|
||||
staleActiveTraceId: traceId,
|
||||
staleActiveTraceReason: reason,
|
||||
sessionStatus: nextSessionStatus,
|
||||
updatedAt: new Date().toISOString(),
|
||||
source: "cloud-web-vue-active-trace-repair",
|
||||
valuesRedacted: true,
|
||||
secretMaterialStored: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function conversationToSessionTab(conversation: ConversationRecord, activeConversationId: string | null, sessionStatusAuthority: SessionStatusAuthorityMap = {}): SessionTab {
|
||||
const sessionId = firstNonEmptyString(conversation.sessionId, conversation.session?.sessionId);
|
||||
const conversationId = conversation.conversationId;
|
||||
|
||||
@@ -7,10 +7,10 @@ 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, ConversationRecord, LiveSurface, ProviderProfile, TraceEvent, WorkspaceRecord } from "@/types";
|
||||
import { DEFAULT_WORKBENCH_PROJECT_ID, firstNonEmptyString, nextProtocolId, normalizeWorkbenchConversationId, normalizeWorkbenchSessionId, rememberWorkbenchProjectId, resolveInitialWorkbenchProjectId, workspaceProjectId } from "@/utils";
|
||||
import { firstNonEmptyString, nextProtocolId, normalizeWorkbenchConversationId, normalizeWorkbenchSessionId } from "@/utils";
|
||||
import { failWorkbenchSessionSwitch, failWorkbenchSubmitJourney, finishWorkbenchSessionSwitchFullLoad, markWorkbenchSubmitApiAccepted, markWorkbenchTraceEventsReceived, markWorkbenchTraceProjected, startWorkbenchSessionSwitch, startWorkbenchSubmitJourney } from "@/utils/workbench-performance";
|
||||
import { RECENT_DRAFTS_STORAGE_KEY, defaultProviderProfileOptions, isArchivedConversation, mergeConversationIntoList, normalizeChatMessageStatus, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, selectedConversationIdFromWorkspace, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, stableConversationList, workspaceWithClearedActiveTrace, type DraftEntry, type ProviderProfileOption, type SessionStatusAuthority, type TurnStatusAuthority } from "./workbench-session";
|
||||
import { initialWorkbenchConversationIdFromLocation, initialWorkbenchSessionIdFromLocation, nextExplicitWorkbenchConversationId, resolveWorkbenchActiveConversationId } from "./workbench-projection";
|
||||
import { RECENT_DRAFTS_STORAGE_KEY, defaultProviderProfileOptions, isArchivedConversation, mergeConversationIntoList, normalizeChatMessageStatus, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, shouldShowSessionListLoading, sortSessionTabs, stableConversationList, type DraftEntry, type ProviderProfileOption, type SessionStatusAuthority, type TurnStatusAuthority } from "./workbench-session";
|
||||
import { initialWorkbenchSessionIdFromLocation } from "./workbench-projection";
|
||||
import { createWorkbenchServerState, reduceWorkbenchServerState, selectSessionStatusAuthority, selectTraceAuthorityById, selectTurnStatusAuthority, type WorkbenchServerAction } from "./workbench-server-state";
|
||||
|
||||
const DEFAULT_CODE_AGENT_TIMEOUT_MS = 1_800_000;
|
||||
@@ -22,7 +22,7 @@ const TRACE_HYDRATION_RETRY_DELAY_MS = 700;
|
||||
|
||||
interface HydrateOptions {
|
||||
sessionId?: string | null;
|
||||
conversationId?: string | null;
|
||||
invalidRouteId?: string | null;
|
||||
}
|
||||
|
||||
interface SessionCreatePersistenceResult {
|
||||
@@ -31,7 +31,7 @@ interface SessionCreatePersistenceResult {
|
||||
}
|
||||
|
||||
export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
const projectId = ref(resolveInitialWorkbenchProjectId());
|
||||
const projectId = ref("");
|
||||
const workspace = ref<WorkspaceRecord | null>(null);
|
||||
const conversations = ref<ConversationRecord[]>([]);
|
||||
const messages = ref<ChatMessage[]>([]);
|
||||
@@ -49,7 +49,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
const error = ref<string | null>(null);
|
||||
const activityRef = ref({ lastActivityAt: Date.now(), lastActivityIso: new Date().toISOString(), waitingFor: "idle", lastEventLabel: null as string | null });
|
||||
const currentRequest = ref<{ traceId: string; conversationId: string | null; sessionId: string | null; threadId: string | null; status?: string | null } | null>(null);
|
||||
const explicitConversationId = ref<string | null>(initialWorkbenchConversationIdFromLocation());
|
||||
const explicitConversationId = ref<string | null>(null);
|
||||
const explicitSessionId = ref<string | null>(initialWorkbenchSessionIdFromLocation());
|
||||
const workspaceSelectionEpoch = ref(0);
|
||||
const serverState = ref(createWorkbenchServerState());
|
||||
@@ -64,15 +64,16 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
let realtimeGapTimer: number | null = null;
|
||||
|
||||
const visibleConversations = computed(() => conversations.value);
|
||||
const activeConversationId = computed(() => resolveWorkbenchActiveConversationId({ switchingConversationId: switchingConversationId.value, explicitConversationId: explicitConversationId.value }));
|
||||
const activeConversation = computed(() => visibleConversations.value.find((item) => item.conversationId === activeConversationId.value) ?? null);
|
||||
const conversationSelectedBySession = computed(() => explicitSessionId.value ? visibleConversations.value.find((item) => sessionIdFromConversation(item) === explicitSessionId.value) ?? null : null);
|
||||
const activeConversationId = computed(() => firstNonEmptyString(switchingConversationId.value, conversationSelectedBySession.value?.conversationId, explicitConversationId.value));
|
||||
const activeConversation = computed(() => conversationSelectedBySession.value ?? visibleConversations.value.find((item) => item.conversationId === activeConversationId.value) ?? null);
|
||||
const selectedSessionId = computed(() => firstNonEmptyString(activeConversation.value?.sessionId));
|
||||
const activeSessionId = computed(() => firstNonEmptyString(selectedSessionId.value, explicitSessionId.value));
|
||||
const selectedThreadId = computed(() => firstNonEmptyString(activeConversation.value?.threadId));
|
||||
const sessionTabs = computed(() => sortSessionTabs(visibleConversations.value, activeConversationId.value, sessionStatusAuthority.value));
|
||||
const sessionListLoading = computed(() => shouldShowSessionListLoading({ loading: loading.value, conversationsReady: conversationsReady.value }));
|
||||
const conversationDetailLoading = computed(() => Boolean(conversationDetailLoadingId.value || switchingConversationId.value || (loading.value && messages.value.length === 0)));
|
||||
const activeProjectId = computed(() => workspaceProjectId(workspace.value, projectId.value));
|
||||
const activeProjectId = computed(() => firstNonEmptyString(activeConversation.value?.projectId, workspace.value?.projectId, workspace.value?.workspace?.projectId, projectId.value) ?? "");
|
||||
const composer = computed(() => resolveComposerState({ workspace: workspace.value, messages: messages.value, conversations: visibleConversations.value, activeConversationId: activeConversationId.value, chatPending: chatPending.value, currentRequest: currentRequest.value, turnStatusAuthority: turnStatusAuthority.value }));
|
||||
|
||||
function recordActivity(label = "user-activity"): void {
|
||||
@@ -82,49 +83,41 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
|
||||
async function hydrate(options: HydrateOptions = {}): Promise<void> {
|
||||
const routeSessionId = normalizeWorkbenchSessionId(options.sessionId);
|
||||
const routeConversationId = normalizeWorkbenchConversationId(options.conversationId);
|
||||
if (routeSessionId) setActiveSessionSelection(routeSessionId);
|
||||
if (routeConversationId) setActiveConversationSelection(routeConversationId);
|
||||
const requestEpoch = workspaceSelectionEpoch.value;
|
||||
const previousConversationId = activeConversationId.value;
|
||||
if (routeConversationId) conversationDetailLoadingId.value = routeConversationId;
|
||||
conversationsReady.value = conversations.value.length > 0;
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
const includeSessionId = routeSessionId ?? activeSessionId.value;
|
||||
const sessionsResult = await api.workbench.sessions({ includeSessionId });
|
||||
if (!sessionsResult.ok) {
|
||||
clearConversationDetailLoading(routeConversationId);
|
||||
loading.value = false;
|
||||
error.value = sessionsResult.error ?? "session list unavailable";
|
||||
return;
|
||||
}
|
||||
const sessionConversations = workbenchConversationsFromSessionPayload(sessionsResult.data);
|
||||
const selectedConversationId = routeConversationId
|
||||
?? conversationIdForSessionId(sessionConversations, includeSessionId)
|
||||
?? activeConversationId.value
|
||||
?? selectedConversationIdFromWorkspace(workspace.value)
|
||||
?? sessionConversations[0]?.conversationId
|
||||
?? null;
|
||||
bootstrapActiveConversationSelection(selectedConversationId);
|
||||
const selectedSessionIdForDetail = routeSessionId
|
||||
?? includeSessionId
|
||||
?? activeSessionId.value
|
||||
?? sessionIdFromConversation(sessionConversations[0]);
|
||||
const selectedConversationId = conversationIdForSessionId(sessionConversations, selectedSessionIdForDetail) ?? sessionConversations[0]?.conversationId ?? null;
|
||||
if (selectedSessionIdForDetail) setActiveSessionSelection(selectedSessionIdForDetail);
|
||||
if (selectedConversationId) conversationDetailLoadingId.value = selectedConversationId;
|
||||
const selectedSessionIdForDetail = includeSessionId ?? sessionIdFromConversation(sessionConversations.find((item) => item.conversationId === selectedConversationId));
|
||||
const selectedConversation = selectedSessionIdForDetail
|
||||
? await loadWorkbenchSessionConversation(selectedSessionIdForDetail, sessionConversations.find((item) => item.sessionId === selectedSessionIdForDetail) ?? null)
|
||||
: selectedConversationId
|
||||
? await loadLegacyConversationDetail(selectedConversationId, activeProjectId.value)
|
||||
: null;
|
||||
: null;
|
||||
clearConversationDetailLoading(selectedConversationId);
|
||||
rememberConversationDetail(selectedConversation);
|
||||
if (selectedConversation) conversations.value = mergeConversationIntoList(conversations.value, selectedConversation);
|
||||
if (selectedConversationId && !selectedConversation) error.value = "session URL not found";
|
||||
if (options.invalidRouteId || (selectedSessionIdForDetail && !selectedConversation)) error.value = "invalid session URL";
|
||||
const selectedProjectId = conversationProjectId(selectedConversation, activeProjectId.value);
|
||||
if (selectedConversation && isCurrentWorkspaceSelection(requestEpoch, selectedConversation.conversationId)) {
|
||||
workspace.value = workspaceWithSelectedConversation(workspace.value, selectedConversation, selectedProjectId);
|
||||
if (workspace.value) rememberWorkspaceSnapshot(selectedProjectId, workspace.value);
|
||||
setActiveConversationSelection(selectedConversation.conversationId);
|
||||
setActiveSessionSelection(sessionIdFromConversation(selectedConversation));
|
||||
providerProfile.value = firstNonEmptyString(workspace.value?.providerProfile, workspace.value?.workspace?.providerProfile, providerProfile.value) ?? providerProfile.value;
|
||||
rememberWorkbenchProjectId(workspaceProjectId(workspace.value, selectedProjectId));
|
||||
messages.value = restoreMessagesTraceAuthority(messagesFromSelectedConversation(selectedConversation, selectedConversationId, previousConversationId, messages.value), messages.value);
|
||||
void hydrateTurnStatusAuthority(messages.value);
|
||||
void hydrateTerminalMessageDiagnostics();
|
||||
@@ -154,121 +147,75 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
}
|
||||
|
||||
async function createSession(): Promise<void> {
|
||||
const current = await ensureWorkspace();
|
||||
if (!current) return;
|
||||
const requestEpoch = beginWorkspaceSelection();
|
||||
const selectedProjectId = activeProjectId.value;
|
||||
const optimisticConversation = makeOptimisticConversation(selectedProjectId);
|
||||
setActiveConversationSelection(optimisticConversation.conversationId);
|
||||
setActiveSessionSelection(optimisticConversation.sessionId);
|
||||
conversations.value = mergeConversationIntoList(conversations.value, optimisticConversation);
|
||||
rememberConversationDetail(optimisticConversation);
|
||||
conversationsReady.value = true;
|
||||
workspace.value = workspaceWithSelectedConversation(current, optimisticConversation, selectedProjectId);
|
||||
rememberWorkspaceSnapshot(selectedProjectId, workspace.value);
|
||||
messages.value = [];
|
||||
currentRequest.value = null;
|
||||
loading.value = false;
|
||||
beginWorkspaceSelection();
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
restartRealtime("create-session-optimistic");
|
||||
const persistence = persistOptimisticSession(optimisticConversation, selectedProjectId, current.workspaceId, requestEpoch);
|
||||
pendingSessionCreates.set(optimisticConversation.conversationId, persistence);
|
||||
void persistence.then((result) => {
|
||||
if (result.ok && pendingSessionCreates.get(optimisticConversation.conversationId) === persistence) pendingSessionCreates.delete(optimisticConversation.conversationId);
|
||||
});
|
||||
const response = await api.agent.createAgentSession({ providerProfile: providerProfile.value });
|
||||
loading.value = false;
|
||||
const created = response.ok ? conversationFromWorkbenchSession(response.data?.session) : null;
|
||||
if (!response.ok || !created) {
|
||||
error.value = response.error ?? "session create failed";
|
||||
return;
|
||||
}
|
||||
conversations.value = mergeConversationIntoList(conversations.value, created);
|
||||
rememberConversationDetail(created);
|
||||
conversationsReady.value = true;
|
||||
await selectSession(created);
|
||||
}
|
||||
|
||||
async function selectConversation(conversation: ConversationRecord): Promise<void> {
|
||||
const current = await ensureWorkspace();
|
||||
if (!current) return;
|
||||
await selectSession(conversation);
|
||||
}
|
||||
|
||||
async function selectSession(conversation: ConversationRecord): Promise<void> {
|
||||
const sessionId = sessionIdFromConversation(conversation);
|
||||
if (!sessionId) {
|
||||
error.value = "invalid session URL";
|
||||
return;
|
||||
}
|
||||
const previousConversationId = activeConversationId.value;
|
||||
const previousMessages = messages.value;
|
||||
const requestEpoch = beginWorkspaceSelection();
|
||||
startWorkbenchSessionSwitch({ conversationId: conversation.conversationId, source: "rail", targetState: conversation.status, cache: (conversation.messages?.length ?? 0) > 0 ? "warm" : "cold" });
|
||||
const tabProjectId = conversation.projectId ?? activeProjectId.value;
|
||||
switchingConversationId.value = conversation.conversationId;
|
||||
setActiveConversationSelection(conversation.conversationId);
|
||||
setActiveSessionSelection(sessionIdFromConversation(conversation));
|
||||
setActiveSessionSelection(sessionId);
|
||||
conversationDetailLoadingId.value = conversation.conversationId;
|
||||
conversations.value = mergeConversationIntoList(conversations.value, conversation);
|
||||
rememberConversationDetail(conversation);
|
||||
conversationsReady.value = true;
|
||||
workspace.value = optimisticWorkspaceSelection(current, conversation, tabProjectId);
|
||||
rememberWorkspaceSnapshot(tabProjectId, workspace.value);
|
||||
void refreshSessionStatusById(sessionIdFromConversation(conversation));
|
||||
void refreshSessionStatusById(sessionId);
|
||||
loading.value = true;
|
||||
const persistPromise = api.workbench.selectConversation(current.workspaceId, { projectId: tabProjectId, conversationId: conversation.conversationId, sessionId: conversation.sessionId, threadId: providerThreadIdForRequest(conversation.threadId), updatedByClient: "cloud-web-vue" });
|
||||
const detailResponse = await api.workbench.conversation(conversation.conversationId, { projectId: tabProjectId });
|
||||
const response = await persistPromise;
|
||||
const selectedConversation = await loadWorkbenchSessionConversation(sessionId, conversation);
|
||||
loading.value = false;
|
||||
clearConversationDetailLoading(conversation.conversationId);
|
||||
clearSwitchingConversation(conversation.conversationId);
|
||||
if (!isCurrentWorkspaceSelection(requestEpoch, conversation.conversationId)) return;
|
||||
const selectedConversation = detailResponse.ok && !isArchivedConversation(detailResponse.data?.conversation) ? detailResponse.data?.conversation ?? null : null;
|
||||
if (selectedConversation) {
|
||||
const selectedProjectId = conversationProjectId(selectedConversation, tabProjectId);
|
||||
applySelectedConversationDetail(selectedConversation, response.ok ? response.data?.workspace ?? current : workspace.value ?? current, selectedProjectId, null, messages.value);
|
||||
const selectedProjectId = conversationProjectId(selectedConversation, activeProjectId.value);
|
||||
applySelectedConversationDetail(selectedConversation, workspace.value, selectedProjectId, previousConversationId, previousMessages);
|
||||
error.value = null;
|
||||
void persistSelectedConversation(selectedConversation, selectedProjectId, response);
|
||||
await refreshSelectedSessionStatus(selectedConversation);
|
||||
await refreshConversations(conversation.conversationId);
|
||||
finishWorkbenchSessionSwitchFullLoad(conversation.conversationId, "ok");
|
||||
return;
|
||||
}
|
||||
if (response.status === 404 && await retrySelectConversationWithFreshWorkspace(conversation, tabProjectId, requestEpoch)) return;
|
||||
isolateConversationLoadFailure(conversation.conversationId, previousConversationId, previousMessages, isArchivedConversation(detailResponse.data?.conversation) ? "session archived" : detailResponse.error ?? response.error ?? "conversation unavailable");
|
||||
isolateConversationLoadFailure(conversation.conversationId, previousConversationId, previousMessages, "session URL not found");
|
||||
failWorkbenchSessionSwitch(conversation.conversationId);
|
||||
}
|
||||
|
||||
async function selectConversationById(conversationId: string): Promise<boolean> {
|
||||
const normalized = normalizeWorkbenchConversationId(conversationId);
|
||||
if (!normalized) {
|
||||
error.value = "invalid session URL";
|
||||
return false;
|
||||
}
|
||||
const previousConversationId = activeConversationId.value;
|
||||
const previousMessages = messages.value;
|
||||
setActiveConversationSelection(normalized);
|
||||
const current = await ensureWorkspace();
|
||||
if (!current) return false;
|
||||
if (activeConversationId.value === normalized && messages.value.length > 0) {
|
||||
const conversation = activeConversation.value ?? visibleConversations.value.find((item) => item.conversationId === normalized) ?? null;
|
||||
if (conversation) await persistSelectedConversation(conversation, conversationProjectId(conversation, activeProjectId.value));
|
||||
return true;
|
||||
}
|
||||
const requestEpoch = beginWorkspaceSelection();
|
||||
startWorkbenchSessionSwitch({ conversationId: normalized, source: "deeplink", targetState: "unknown", cache: "cold" });
|
||||
switchingConversationId.value = normalized;
|
||||
conversationDetailLoadingId.value = normalized;
|
||||
const existing = visibleConversations.value.find((conversation) => conversation.conversationId === normalized) ?? null;
|
||||
if (existing && (existing.messages?.length ?? 0) > 0) {
|
||||
await selectConversation(existing);
|
||||
return activeConversationId.value === normalized;
|
||||
}
|
||||
const response = await api.workbench.conversation(normalized, { projectId: activeProjectId.value });
|
||||
const conversation = response.data?.conversation ?? null;
|
||||
if (!response.ok || !conversation || isArchivedConversation(conversation)) {
|
||||
if (isCurrentWorkspaceSelection(requestEpoch, normalized)) clearSwitchingConversation(normalized);
|
||||
clearConversationDetailLoading(normalized);
|
||||
isolateConversationLoadFailure(normalized, previousConversationId, previousMessages, isArchivedConversation(conversation) ? "session archived" : response.error ?? "session URL not found");
|
||||
failWorkbenchSessionSwitch(normalized);
|
||||
return false;
|
||||
}
|
||||
const selectedProjectId = conversationProjectId(conversation, activeProjectId.value);
|
||||
applySelectedConversationDetail(conversation, current, selectedProjectId, null, messages.value);
|
||||
clearSwitchingConversation(normalized);
|
||||
clearConversationDetailLoading(normalized);
|
||||
error.value = null;
|
||||
void refreshSessionStatusById(sessionIdFromConversation(conversation));
|
||||
void persistSelectedConversation(conversation, selectedProjectId);
|
||||
void refreshConversations(normalized);
|
||||
finishWorkbenchSessionSwitchFullLoad(normalized, "ok");
|
||||
return activeConversationId.value === normalized;
|
||||
void conversationId;
|
||||
error.value = "invalid session URL";
|
||||
return false;
|
||||
}
|
||||
|
||||
async function selectSessionById(sessionId: string): Promise<boolean> {
|
||||
const normalized = normalizeWorkbenchSessionId(sessionId);
|
||||
if (!normalized) return selectConversationById(sessionId);
|
||||
if (!normalized) {
|
||||
error.value = "invalid session URL";
|
||||
return false;
|
||||
}
|
||||
const previousConversationId = activeConversationId.value;
|
||||
const previousMessages = messages.value;
|
||||
setActiveSessionSelection(normalized);
|
||||
@@ -297,21 +244,19 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
}
|
||||
|
||||
async function deleteCurrentSession(): Promise<void> {
|
||||
const conversationId = activeConversationId.value;
|
||||
if (!conversationId) return;
|
||||
const response = await api.workbench.deleteConversation(conversationId, { projectId: activeProjectId.value, workspaceId: workspace.value?.workspaceId, updatedByClient: "cloud-web-vue" });
|
||||
if (response.ok) {
|
||||
workspace.value = response.data?.workspace ?? workspace.value;
|
||||
rememberWorkspaceSnapshot(activeProjectId.value, workspace.value);
|
||||
const selectedConversationId = selectedConversationIdFromWorkspace(workspace.value);
|
||||
const selectedConversation = workspace.value?.selectedConversation ?? null;
|
||||
replaceActiveConversationSelection(selectedConversationId);
|
||||
rememberConversationDetail(selectedConversation);
|
||||
messages.value = restoreMessagesTraceAuthority(messagesFromSelectedConversation(selectedConversation, selectedConversationId, null, []), messages.value);
|
||||
currentRequest.value = null;
|
||||
void hydrateTurnStatusAuthority(messages.value);
|
||||
await refreshConversations(selectedConversationIdFromWorkspace(workspace.value));
|
||||
const sessionId = activeSessionId.value;
|
||||
if (!sessionId) return;
|
||||
conversations.value = conversations.value.filter((conversation) => sessionIdFromConversation(conversation) !== sessionId);
|
||||
const next = conversations.value.find((conversation) => !isArchivedConversation(conversation)) ?? null;
|
||||
replaceActiveSessionSelection(sessionIdFromConversation(next));
|
||||
replaceActiveConversationSelection(next?.conversationId ?? null);
|
||||
currentRequest.value = null;
|
||||
if (next) {
|
||||
await selectSession(next);
|
||||
return;
|
||||
}
|
||||
messages.value = [];
|
||||
restartRealtime("delete-current-session");
|
||||
}
|
||||
|
||||
async function refreshConversations(includeConversationId: string | null = currentListIncludeConversationId()): Promise<void> {
|
||||
@@ -345,58 +290,11 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
}
|
||||
|
||||
async function persistOptimisticSession(conversation: ConversationRecord, selectedProjectId: string, workspaceId: string, requestEpoch: number): Promise<SessionCreatePersistenceResult> {
|
||||
try {
|
||||
const response = await api.workbench.selectConversation(workspaceId, {
|
||||
projectId: selectedProjectId,
|
||||
create: true,
|
||||
conversationId: conversation.conversationId,
|
||||
sessionId: conversation.sessionId,
|
||||
threadId: providerThreadIdForRequest(conversation.threadId),
|
||||
updatedByClient: "cloud-web-vue-fast-create"
|
||||
});
|
||||
if (!response.ok || !response.data?.workspace) {
|
||||
if (activeConversationId.value === conversation.conversationId) error.value = response.error ?? "session create failed";
|
||||
return { ok: false, error: response.error ?? "session create failed" };
|
||||
}
|
||||
const persistedWorkspace = response.data.workspace;
|
||||
const persistedSelectedId = selectedConversationIdFromWorkspace(persistedWorkspace) ?? conversation.conversationId;
|
||||
let persistedConversation = persistedWorkspace.selectedConversation?.conversationId
|
||||
? persistedWorkspace.selectedConversation
|
||||
: null;
|
||||
if ((!persistedConversation || persistedConversation.conversationId !== persistedSelectedId) && persistedSelectedId !== conversation.conversationId) {
|
||||
const detail = await api.workbench.conversation(persistedSelectedId, { projectId: selectedProjectId });
|
||||
persistedConversation = detail.ok ? detail.data?.conversation ?? persistedConversation : persistedConversation;
|
||||
}
|
||||
persistedConversation = persistedConversation ?? conversation;
|
||||
const authoritativeConversationId = persistedConversation.conversationId;
|
||||
if (authoritativeConversationId !== conversation.conversationId) {
|
||||
pendingSessionCreates.delete(conversation.conversationId);
|
||||
conversations.value = conversations.value.filter((item) => item.conversationId !== conversation.conversationId);
|
||||
}
|
||||
rememberConversationDetail(persistedConversation);
|
||||
conversations.value = mergeConversationIntoList(conversations.value, persistedConversation);
|
||||
if (activeConversationId.value === conversation.conversationId || activeConversationId.value === authoritativeConversationId) {
|
||||
replaceActiveConversationSelection(authoritativeConversationId);
|
||||
setActiveSessionSelection(sessionIdFromConversation(persistedConversation));
|
||||
const persistedProjectId = conversationProjectId(persistedConversation, selectedProjectId);
|
||||
workspace.value = workspaceWithSelectedConversation(persistedWorkspace, persistedConversation, persistedProjectId);
|
||||
rememberWorkspaceSnapshot(persistedProjectId, workspace.value);
|
||||
error.value = null;
|
||||
if (messages.value.length === 0) {
|
||||
messages.value = restoreMessagesTraceAuthority(messagesFromSelectedConversation(persistedConversation, conversation.conversationId, null, []), messages.value);
|
||||
}
|
||||
void hydrateTurnStatusAuthority(messages.value);
|
||||
void hydrateTraceEvents(messages.value);
|
||||
void refreshSelectedSessionStatus(persistedConversation);
|
||||
void refreshConversations(authoritativeConversationId);
|
||||
restartRealtime(requestEpoch === workspaceSelectionEpoch.value ? "create-session" : "create-session-persisted");
|
||||
}
|
||||
return { ok: true, error: null };
|
||||
} catch (cause) {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
if (activeConversationId.value === conversation.conversationId) error.value = message;
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
void conversation;
|
||||
void selectedProjectId;
|
||||
void workspaceId;
|
||||
void requestEpoch;
|
||||
return { ok: true, error: null };
|
||||
}
|
||||
|
||||
async function waitForPendingSessionCreate(conversationId: string): Promise<SessionCreatePersistenceResult> {
|
||||
@@ -415,31 +313,17 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
async function recoverPendingSessionCreate(conversationId: string, originalError: string | null): Promise<SessionCreatePersistenceResult> {
|
||||
const normalized = normalizeWorkbenchConversationId(conversationId);
|
||||
if (!normalized) return { ok: false, error: originalError ?? "session create failed" };
|
||||
const selectedProjectId = activeProjectId.value;
|
||||
const [workspaceResult, conversationResult] = await Promise.all([
|
||||
api.workbench.workspace(selectedProjectId),
|
||||
api.workbench.conversation(normalized, { projectId: selectedProjectId })
|
||||
]);
|
||||
const workspaceSnapshot = workspaceResult.ok ? workspaceResult.data?.workspace ?? null : null;
|
||||
const workspaceSelectedId = selectedConversationIdFromWorkspace(workspaceSnapshot);
|
||||
const workspaceConversation = workspaceSnapshot?.selectedConversation?.conversationId === normalized ? workspaceSnapshot.selectedConversation : null;
|
||||
const conversation = conversationResult.ok && !isArchivedConversation(conversationResult.data?.conversation) ? conversationResult.data?.conversation ?? null : null;
|
||||
if (workspaceSelectedId !== normalized && !workspaceConversation && !conversation) {
|
||||
return { ok: false, error: originalError ?? workspaceResult.error ?? conversationResult.error ?? "session create failed" };
|
||||
}
|
||||
const authoritativeConversation = conversation ?? workspaceConversation ?? visibleConversations.value.find((item) => item.conversationId === normalized) ?? null;
|
||||
const authoritativeConversation = visibleConversations.value.find((item) => item.conversationId === normalized) ?? null;
|
||||
if (!authoritativeConversation) return { ok: false, error: originalError ?? "session create failed" };
|
||||
if (authoritativeConversation) {
|
||||
rememberConversationDetail(authoritativeConversation);
|
||||
conversations.value = mergeConversationIntoList(conversations.value, authoritativeConversation);
|
||||
}
|
||||
if (workspaceSnapshot && activeConversationId.value === normalized) {
|
||||
const persistedProjectId = conversationProjectId(authoritativeConversation, selectedProjectId);
|
||||
workspace.value = authoritativeConversation ? workspaceWithSelectedConversation(workspaceSnapshot, authoritativeConversation, persistedProjectId) : workspaceSnapshot;
|
||||
rememberWorkspaceSnapshot(persistedProjectId, workspace.value);
|
||||
if (activeConversationId.value === normalized) {
|
||||
error.value = null;
|
||||
void hydrateTurnStatusAuthority(messages.value);
|
||||
void hydrateTraceEvents(messages.value);
|
||||
if (authoritativeConversation) void refreshSelectedSessionStatus(authoritativeConversation);
|
||||
void refreshSelectedSessionStatus(authoritativeConversation);
|
||||
void refreshConversations(normalized);
|
||||
restartRealtime("create-session-recovered");
|
||||
}
|
||||
@@ -447,7 +331,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
}
|
||||
|
||||
function setActiveConversationSelection(conversationId: string | null | undefined): void {
|
||||
explicitConversationId.value = nextExplicitWorkbenchConversationId(explicitConversationId.value, conversationId);
|
||||
explicitConversationId.value = normalizeWorkbenchConversationId(conversationId) ?? explicitConversationId.value;
|
||||
}
|
||||
|
||||
function setActiveSessionSelection(sessionId: string | null | undefined): void {
|
||||
@@ -507,7 +391,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
async function refreshTurnStatusByTraceId(traceId: string | null | undefined): Promise<void> {
|
||||
const id = firstNonEmptyString(traceId);
|
||||
if (!id) return;
|
||||
const response = await api.agent.getAgentTurn(id, activeProjectId.value, 8000, () => activityRef.value);
|
||||
const response = await api.agent.getAgentTurn(id, 8000, () => activityRef.value);
|
||||
if (response.ok && response.data) {
|
||||
applyTurnStatusSnapshot(id, response.data);
|
||||
return;
|
||||
@@ -595,7 +479,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
currentRequest.value = null;
|
||||
return;
|
||||
}
|
||||
const payload = { projectId: activeProjectId.value, message: value, prompt: value, conversationId, sessionId, threadId: providerThreadId, traceId, providerProfile: providerProfile.value, gatewayShellTimeoutMs: gatewayShellTimeoutMs.value, workspaceId: workspace.value?.workspaceId, workspaceRevision: workspace.value?.revision, targetTraceId: composer.value.targetTraceId, steerTraceId };
|
||||
const payload = { message: value, prompt: value, sessionId, threadId: providerThreadId, traceId, providerProfile: providerProfile.value, gatewayShellTimeoutMs: gatewayShellTimeoutMs.value, targetTraceId: composer.value.targetTraceId, steerTraceId };
|
||||
const route = steerMode ? api.agent.steerAgentMessage : api.agent.sendAgentMessage;
|
||||
const response = await route(payload, codeAgentTimeoutMs.value, () => activityRef.value);
|
||||
if (!response.ok || !response.data) {
|
||||
@@ -623,7 +507,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
async function cancelAgentMessage(message: ChatMessage): Promise<void> {
|
||||
const traceId = message.traceId ?? message.runnerTrace?.traceId;
|
||||
if (!traceId) return;
|
||||
const response = await api.agent.cancelAgentMessage({ traceId, projectId: activeProjectId.value, sessionId: message.sessionId ?? selectedSessionId.value, threadId: message.threadId ?? selectedThreadId.value, conversationId: message.conversationId ?? activeConversationId.value });
|
||||
const response = await api.agent.cancelAgentMessage({ traceId, sessionId: message.sessionId ?? selectedSessionId.value, threadId: message.threadId ?? selectedThreadId.value });
|
||||
const canceledStatus = workspaceSessionStatusFromChatStatus(firstNonEmptyString((response.data as Record<string, unknown> | null)?.status, "canceled"));
|
||||
applyTurnStatusSnapshot(traceId, { traceId, status: firstNonEmptyString((response.data as Record<string, unknown> | null)?.status, "canceled"), running: false, terminal: true, sessionId: message.sessionId ?? selectedSessionId.value ?? undefined, threadId: message.threadId ?? selectedThreadId.value ?? undefined, conversationId: message.conversationId ?? activeConversationId.value ?? undefined } as AgentChatResultResponse);
|
||||
markMessage(traceId, { status: "canceled", text: "用户已取消该 turn。" });
|
||||
@@ -677,7 +561,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
async function fetchTraceHydrationPage(traceId: string, sinceSeq: number): Promise<ApiResult<AgentChatResultResponse>> {
|
||||
let lastResult: ApiResult<AgentChatResultResponse> | null = null;
|
||||
for (let attempt = 0; attempt < TRACE_HYDRATION_MAX_ATTEMPTS; attempt += 1) {
|
||||
const result = await api.agent.getAgentTrace(traceId, activeProjectId.value, codeAgentTimeoutMs.value, () => activityRef.value, { sinceSeq, limit: TRACE_HYDRATION_PAGE_LIMIT });
|
||||
const result = await api.agent.getAgentTrace(traceId, codeAgentTimeoutMs.value, () => activityRef.value, { sinceSeq, limit: TRACE_HYDRATION_PAGE_LIMIT });
|
||||
if (result.ok && result.data) return result;
|
||||
lastResult = result;
|
||||
if (attempt < TRACE_HYDRATION_MAX_ATTEMPTS - 1) {
|
||||
@@ -767,9 +651,6 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
function setProviderProfile(value: ProviderProfile): void {
|
||||
providerProfile.value = value;
|
||||
localStorage.setItem("hwlab.workbench.providerProfile.v1", value);
|
||||
if (workspace.value?.workspaceId) {
|
||||
void api.workbench.updateWorkspace(workspace.value.workspaceId, { projectId: activeProjectId.value, providerProfile: value, updatedByClient: "cloud-web-vue" });
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshProviderOptions(): Promise<void> {
|
||||
@@ -800,7 +681,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
}
|
||||
|
||||
function reattachTrace(traceId: string): void {
|
||||
const initial: AgentChatResponse = { status: "running", traceId, turnUrl: `/v1/agent/turns/${encodeURIComponent(traceId)}?projectId=${encodeURIComponent(activeProjectId.value)}` };
|
||||
const initial: AgentChatResponse = { status: "running", traceId, turnUrl: `/v1/workbench/turns/${encodeURIComponent(traceId)}` };
|
||||
if (!messages.value.some((message) => message.traceId === traceId)) messages.value.push(makeMessage("agent", "", "running", { traceId, title: "Code Agent", traceAutoLifecycle: "running" }));
|
||||
currentRequest.value = { traceId, conversationId: activeConversationId.value ?? null, sessionId: selectedSessionId.value ?? null, threadId: selectedThreadId.value ?? null, status: initial.status };
|
||||
restartRealtime("reattach");
|
||||
@@ -808,7 +689,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
}
|
||||
|
||||
async function validateAndReattachTrace(traceId: string): Promise<void> {
|
||||
const result = await api.agent.getAgentTurn(traceId, activeProjectId.value, 8000, () => activityRef.value);
|
||||
const result = await api.agent.getAgentTurn(traceId, 8000, () => activityRef.value);
|
||||
if (!result.ok || !result.data) {
|
||||
await clearActiveTrace(traceId, result.status === 404 ? "reattach-result-not-found" : "reattach-result-unavailable");
|
||||
return;
|
||||
@@ -823,15 +704,13 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
|
||||
function restartRealtime(reason: string): void {
|
||||
const traceId = realtimeTraceId();
|
||||
const workspaceId = workspace.value?.workspaceId ?? null;
|
||||
const sessionId = selectedSessionId.value ?? null;
|
||||
const key = [sessionId ?? "", traceId ?? "", workspaceId ?? ""].join("|");
|
||||
const key = [sessionId ?? "", traceId ?? ""].join("|");
|
||||
if (key === realtimeKey && realtimeStream) return;
|
||||
stopRealtime();
|
||||
realtimeKey = key;
|
||||
if (!sessionId && !workspaceId && !traceId) return;
|
||||
if (!sessionId && !traceId) return;
|
||||
realtimeStream = connectWorkbenchEvents({
|
||||
workspaceId,
|
||||
sessionId,
|
||||
traceId,
|
||||
onOpen: () => scheduleRealtimeGapHydration(`${reason}:open`),
|
||||
@@ -854,7 +733,6 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
function applyRealtimeEvent(event: WorkbenchRealtimeEvent, eventName: string): void {
|
||||
recordActivity(event.type ? `realtime:${event.type}` : `realtime:${eventName}`);
|
||||
if (event.type === "workspace.snapshot" && event.workspace) {
|
||||
applyRealtimeWorkspaceSnapshot(event.workspace);
|
||||
return;
|
||||
}
|
||||
if (event.type === "trace.snapshot") {
|
||||
@@ -876,17 +754,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
}
|
||||
|
||||
function applyRealtimeWorkspaceSnapshot(nextWorkspace: WorkspaceRecord): void {
|
||||
if (!workspaceSnapshotTargetsActiveConversation(nextWorkspace)) return;
|
||||
const previousTraceId = realtimeTraceId();
|
||||
workspace.value = nextWorkspace;
|
||||
rememberWorkspaceSnapshot(workspaceProjectId(nextWorkspace, activeProjectId.value), nextWorkspace);
|
||||
providerProfile.value = firstNonEmptyString(workspace.value?.providerProfile, workspace.value?.workspace?.providerProfile, providerProfile.value) ?? providerProfile.value;
|
||||
rememberWorkbenchProjectId(workspaceProjectId(workspace.value, activeProjectId.value));
|
||||
const nextTraceId = realtimeTraceId();
|
||||
if (firstNonEmptyString(previousTraceId) !== firstNonEmptyString(nextTraceId)) {
|
||||
restartRealtime("workspace-snapshot");
|
||||
scheduleRealtimeGapHydration("workspace-snapshot");
|
||||
}
|
||||
void nextWorkspace;
|
||||
}
|
||||
|
||||
function applyRealtimeTraceSnapshot(traceId: string | null | undefined, snapshot: WorkbenchRealtimeEvent["snapshot"]): void {
|
||||
@@ -926,7 +794,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
if (terminalRealtimeRefreshInFlight.has(traceId)) return;
|
||||
terminalRealtimeRefreshInFlight.add(traceId);
|
||||
try {
|
||||
const result = await api.agent.getAgentTurn(traceId, activeProjectId.value, 8000, () => activityRef.value);
|
||||
const result = await api.agent.getAgentTurn(traceId, 8000, () => activityRef.value);
|
||||
if (!result.ok || !result.data) return;
|
||||
applyTurnStatusSnapshot(traceId, result.data);
|
||||
if (result.data.terminal === true || isTerminalMessageStatus(result.data.status)) {
|
||||
@@ -1021,7 +889,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
await Promise.all(targets.map(async (message) => {
|
||||
const traceId = firstNonEmptyString(message.traceId, message.runnerTrace?.traceId);
|
||||
if (!traceId) return;
|
||||
const result = await api.agent.getAgentTurn(traceId, activeProjectId.value, 8000, () => activityRef.value);
|
||||
const result = await api.agent.getAgentTurn(traceId, 8000, () => activityRef.value);
|
||||
if (!result.ok || !result.data) return;
|
||||
applyTurnStatusSnapshot(traceId, result.data);
|
||||
}));
|
||||
@@ -1069,37 +937,13 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
}
|
||||
|
||||
async function ensureWorkspace(): Promise<WorkspaceRecord | null> {
|
||||
if (workspace.value) return workspace.value;
|
||||
const response = await api.workbench.workspace(activeProjectId.value);
|
||||
if (response.ok && response.data?.workspace) {
|
||||
workspace.value = response.data.workspace;
|
||||
rememberWorkspaceSnapshot(activeProjectId.value, workspace.value);
|
||||
return workspace.value;
|
||||
}
|
||||
await hydrate({ sessionId: activeSessionId.value });
|
||||
return workspace.value;
|
||||
}
|
||||
|
||||
async function clearActiveTrace(traceId: string, reason: string, sessionStatus?: string | null): Promise<void> {
|
||||
const requestEpoch = workspaceSelectionEpoch.value;
|
||||
const current = workspaceWithClearedActiveTrace(workspace.value, traceId, reason, sessionStatus);
|
||||
if (current === workspace.value) return;
|
||||
workspace.value = current;
|
||||
if (!current?.workspaceId) return;
|
||||
const response = await api.workbench.updateWorkspace(current.workspaceId, {
|
||||
projectId: activeProjectId.value,
|
||||
activeTraceId: null,
|
||||
lastTraceId: null,
|
||||
staleActiveTraceId: traceId,
|
||||
staleActiveTraceReason: reason,
|
||||
sessionStatus: current.workspace?.sessionStatus,
|
||||
workspace: current.workspace,
|
||||
updatedByClient: "cloud-web-vue-active-trace-repair"
|
||||
});
|
||||
if (response.ok && response.data?.workspace && shouldApplyWorkspaceSnapshot({ requestEpoch, currentEpoch: workspaceSelectionEpoch.value, currentConversationId: activeConversationId.value, workspace: response.data.workspace })) {
|
||||
workspace.value = response.data.workspace;
|
||||
rememberWorkspaceSnapshot(workspaceProjectId(workspace.value, activeProjectId.value), workspace.value);
|
||||
}
|
||||
void reason;
|
||||
void sessionStatus;
|
||||
if (currentRequest.value?.traceId === traceId) currentRequest.value = null;
|
||||
}
|
||||
|
||||
function beginWorkspaceSelection(): number {
|
||||
@@ -1169,82 +1013,25 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
}
|
||||
|
||||
function workspaceSnapshotTargetsActiveConversation(nextWorkspace: WorkspaceRecord): boolean {
|
||||
const currentConversationId = activeConversationId.value;
|
||||
if (!currentConversationId) return true;
|
||||
return selectedConversationIdFromWorkspace(nextWorkspace) === currentConversationId;
|
||||
void nextWorkspace;
|
||||
return false;
|
||||
}
|
||||
|
||||
async function persistSelectedConversation(conversation: ConversationRecord, selectedProjectId: string, existingResponse?: ApiResult<{ workspace?: WorkspaceRecord }>): Promise<void> {
|
||||
const conversationId = conversation.conversationId;
|
||||
let response = existingResponse ?? null;
|
||||
if (!response) {
|
||||
const workspaceId = workspace.value?.workspaceId;
|
||||
if (!workspaceId) return;
|
||||
response = await api.workbench.selectConversation(workspaceId, { projectId: selectedProjectId, conversationId, sessionId: conversation.sessionId, threadId: providerThreadIdForRequest(conversation.threadId), updatedByClient: "cloud-web-vue" });
|
||||
}
|
||||
if (!response.ok && response.status === 404) {
|
||||
const fresh = await api.workbench.workspace(selectedProjectId);
|
||||
const freshWorkspace = fresh.data?.workspace;
|
||||
if (fresh.ok && freshWorkspace?.workspaceId && activeConversationId.value === conversationId) {
|
||||
response = await api.workbench.selectConversation(freshWorkspace.workspaceId, { projectId: selectedProjectId, conversationId, sessionId: conversation.sessionId, threadId: providerThreadIdForRequest(conversation.threadId), updatedByClient: "cloud-web-vue-retry" });
|
||||
}
|
||||
}
|
||||
if (!response.ok || !response.data?.workspace || activeConversationId.value !== conversationId) return;
|
||||
workspace.value = workspaceWithSelectedConversation(response.data.workspace, conversation, selectedProjectId);
|
||||
rememberWorkspaceSnapshot(selectedProjectId, workspace.value);
|
||||
void selectedProjectId;
|
||||
void existingResponse;
|
||||
rememberConversationDetail(conversation);
|
||||
}
|
||||
|
||||
async function retrySelectConversationWithFreshWorkspace(conversation: ConversationRecord, tabProjectId: string, requestEpoch: number): Promise<boolean> {
|
||||
conversationDetailLoadingId.value = conversation.conversationId;
|
||||
const fresh = await api.workbench.workspace(tabProjectId);
|
||||
if (!isCurrentWorkspaceSelection(requestEpoch, conversation.conversationId)) {
|
||||
clearConversationDetailLoading(conversation.conversationId);
|
||||
return true;
|
||||
}
|
||||
const freshWorkspace = fresh.data?.workspace;
|
||||
if (!fresh.ok || !freshWorkspace?.workspaceId) return false;
|
||||
setActiveConversationSelection(conversation.conversationId);
|
||||
workspace.value = optimisticWorkspaceSelection(freshWorkspace, conversation, tabProjectId);
|
||||
rememberWorkspaceSnapshot(tabProjectId, workspace.value);
|
||||
rememberConversationDetail(conversation);
|
||||
const [retried, detailResponse] = await Promise.all([
|
||||
api.workbench.selectConversation(freshWorkspace.workspaceId, { projectId: tabProjectId, conversationId: conversation.conversationId, sessionId: conversation.sessionId, threadId: providerThreadIdForRequest(conversation.threadId), updatedByClient: "cloud-web-vue-retry" }),
|
||||
api.workbench.conversation(conversation.conversationId, { projectId: tabProjectId })
|
||||
]);
|
||||
if (!isCurrentWorkspaceSelection(requestEpoch, conversation.conversationId)) {
|
||||
clearConversationDetailLoading(conversation.conversationId);
|
||||
return true;
|
||||
}
|
||||
clearConversationDetailLoading(conversation.conversationId);
|
||||
if (!retried.ok) {
|
||||
error.value = retried.error ?? "session switch failed";
|
||||
return false;
|
||||
}
|
||||
const selectedConversation = detailResponse.ok && !isArchivedConversation(detailResponse.data?.conversation) ? detailResponse.data?.conversation ?? null : null;
|
||||
if (!selectedConversation) {
|
||||
error.value = isArchivedConversation(detailResponse.data?.conversation) ? "session archived" : detailResponse.error ?? "conversation unavailable";
|
||||
return false;
|
||||
}
|
||||
workspace.value = workspaceWithSelectedConversation(retried.data?.workspace ?? workspace.value, selectedConversation, conversationProjectId(selectedConversation, tabProjectId));
|
||||
rememberWorkspaceSnapshot(tabProjectId, workspace.value);
|
||||
rememberConversationDetail(selectedConversation);
|
||||
messages.value = messagesFromSelectedConversation(selectedConversation, conversation.conversationId, null, []);
|
||||
conversations.value = mergeConversationIntoList(conversations.value, selectedConversation);
|
||||
rememberConversationDetail(selectedConversation);
|
||||
void hydrateTurnStatusAuthority(messages.value);
|
||||
void hydrateTerminalMessageDiagnostics();
|
||||
void hydrateTraceEvents(messages.value);
|
||||
reattachRestoredActiveTrace();
|
||||
currentRequest.value = null;
|
||||
await refreshSelectedSessionStatus(conversation);
|
||||
await refreshConversations(conversation.conversationId);
|
||||
finishWorkbenchSessionSwitchFullLoad(conversation.conversationId, selectedConversation ? "ok" : "partial");
|
||||
return true;
|
||||
void conversation;
|
||||
void tabProjectId;
|
||||
void requestEpoch;
|
||||
return false;
|
||||
}
|
||||
|
||||
function currentListIncludeConversationId(): string | null {
|
||||
return firstNonEmptyString(switchingConversationId.value, activeConversationId.value, selectedConversationIdFromWorkspace(workspace.value));
|
||||
return firstNonEmptyString(switchingConversationId.value, activeConversationId.value);
|
||||
}
|
||||
|
||||
function reattachRestoredActiveTrace(): void {
|
||||
@@ -1255,7 +1042,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
|
||||
installRealtimeVisibilityHandler();
|
||||
|
||||
return { projectId, workspace, conversations, messages, providerProfile, providerOptions, recentDrafts, codeAgentTimeoutMs, gatewayShellTimeoutMs, live, loading, sessionListLoading, conversationDetailLoading, chatPending, error, activeConversationId, activeSessionId, selectedSessionId, selectedThreadId, sessionTabs, composer, hydrate, refreshLive, createSession, selectConversation, selectConversationById, selectSessionById, deleteCurrentSession, refreshConversations, submitMessage, cancelAgentMessage, cancelRunningTrace, retryAgentMessage, setProviderProfile, refreshProviderOptions, pickDraft, clearRecentDrafts, clearConversation, recordActivity };
|
||||
return { projectId, workspace, conversations, messages, providerProfile, providerOptions, recentDrafts, codeAgentTimeoutMs, gatewayShellTimeoutMs, live, loading, sessionListLoading, conversationDetailLoading, chatPending, error, activeConversationId, activeSessionId, selectedSessionId, selectedThreadId, sessionTabs, composer, hydrate, refreshLive, createSession, selectSession, selectConversation, selectConversationById, selectSessionById, deleteCurrentSession, refreshConversations, submitMessage, cancelAgentMessage, cancelRunningTrace, retryAgentMessage, setProviderProfile, refreshProviderOptions, pickDraft, clearRecentDrafts, clearConversation, recordActivity };
|
||||
});
|
||||
|
||||
function workbenchConversationsFromSessionPayload(payload: unknown): ConversationRecord[] {
|
||||
@@ -1315,11 +1102,9 @@ async function loadWorkbenchSessionConversation(sessionId: string, seed: Convers
|
||||
}
|
||||
|
||||
async function loadLegacyConversationDetail(conversationId: string, projectId: string): Promise<ConversationRecord | null> {
|
||||
const id = normalizeWorkbenchConversationId(conversationId);
|
||||
if (!id) return null;
|
||||
const response = await api.workbench.conversation(id, { projectId });
|
||||
const conversation = response.ok && !isArchivedConversation(response.data?.conversation) ? response.data?.conversation ?? null : null;
|
||||
return conversation;
|
||||
void conversationId;
|
||||
void projectId;
|
||||
return null;
|
||||
}
|
||||
|
||||
function sessionIdFromConversation(conversation: ConversationRecord | null | undefined): string | null {
|
||||
@@ -1405,7 +1190,7 @@ function workspaceWithSelectedConversation(current: WorkspaceRecord | null, conv
|
||||
}
|
||||
|
||||
function conversationProjectId(conversation: ConversationRecord | null | undefined, fallback: string): string {
|
||||
return firstNonEmptyString(conversation?.projectId, fallback) ?? DEFAULT_WORKBENCH_PROJECT_ID;
|
||||
return firstNonEmptyString(conversation?.projectId, fallback) ?? "";
|
||||
}
|
||||
|
||||
function messagesFromConversation(conversation: ConversationRecord): ChatMessage[] {
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
export const DEFAULT_WORKBENCH_PROJECT_ID = "prj_hwpod_workbench";
|
||||
const WORKBENCH_PROJECT_STORAGE_KEY = "hwlab.workbench.projectId.v1";
|
||||
|
||||
export function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" ? value as Record<string, unknown> : null;
|
||||
}
|
||||
@@ -19,12 +16,6 @@ export function firstNonEmptyString(...values: unknown[]): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeWorkbenchProjectId(value: unknown): string | null {
|
||||
const text = nonEmptyString(value);
|
||||
if (!text) return null;
|
||||
return /^prj_[A-Za-z0-9_.:-]{3,96}$/u.test(text) ? text : null;
|
||||
}
|
||||
|
||||
export function normalizeWorkbenchConversationId(value: unknown): string | null {
|
||||
const text = nonEmptyString(value);
|
||||
if (!text) return null;
|
||||
@@ -41,30 +32,6 @@ export function workbenchSessionPath(sessionId: string): string {
|
||||
return `/workbench/sessions/${encodeURIComponent(sessionId)}`;
|
||||
}
|
||||
|
||||
export function resolveInitialWorkbenchProjectId(): string {
|
||||
const fromUrl = typeof window !== "undefined" ? normalizeWorkbenchProjectId(new URLSearchParams(window.location.search).get("projectId")) : null;
|
||||
if (fromUrl) return fromUrl;
|
||||
try {
|
||||
const stored = normalizeWorkbenchProjectId(window.localStorage.getItem(WORKBENCH_PROJECT_STORAGE_KEY));
|
||||
if (stored) return stored;
|
||||
} catch {
|
||||
// localStorage may be unavailable in embedded contexts.
|
||||
}
|
||||
return DEFAULT_WORKBENCH_PROJECT_ID;
|
||||
}
|
||||
|
||||
export function rememberWorkbenchProjectId(projectId: string): void {
|
||||
try {
|
||||
window.localStorage.setItem(WORKBENCH_PROJECT_STORAGE_KEY, projectId);
|
||||
} catch {
|
||||
// ignore storage failures
|
||||
}
|
||||
}
|
||||
|
||||
export function workspaceProjectId(workspace: { projectId?: string | null; workspace?: { projectId?: string | null } } | null, fallback = DEFAULT_WORKBENCH_PROJECT_ID): string {
|
||||
return normalizeWorkbenchProjectId(workspace?.projectId) ?? normalizeWorkbenchProjectId(workspace?.workspace?.projectId) ?? fallback;
|
||||
}
|
||||
|
||||
export function nextProtocolId(prefix: string): string {
|
||||
const cryptoApi = typeof crypto !== "undefined" ? crypto : null;
|
||||
const suffix = cryptoApi?.randomUUID ? cryptoApi.randomUUID().replace(/-/gu, "").slice(0, 16) : `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
@@ -11,23 +11,23 @@ import HwpodNodeOpsPanel from "@/components/hwpod/HwpodNodeOpsPanel.vue";
|
||||
import { useAutoRefresh } from "@/composables/useAutoRefresh";
|
||||
import { shouldReflectWorkbenchSessionUrl } from "@/router/workbench-navigation";
|
||||
import { useWorkbenchStore } from "@/stores/workbench";
|
||||
import { normalizeWorkbenchConversationId, normalizeWorkbenchSessionId } from "@/utils";
|
||||
import { normalizeWorkbenchSessionId } from "@/utils";
|
||||
import { finishWorkbenchOpenFullLoad, startWorkbenchOpenJourney } from "@/utils/workbench-performance";
|
||||
|
||||
const workbench = useWorkbenchStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const routeSessionId = computed(() => normalizeWorkbenchSessionId(route.params.sessionId));
|
||||
const routeLegacyConversationId = computed(() => normalizeWorkbenchConversationId(route.params.sessionId));
|
||||
const applyingRouteSession = ref(Boolean(routeSessionId.value || routeLegacyConversationId.value));
|
||||
const rawRouteSessionId = computed(() => typeof route.params.sessionId === "string" ? route.params.sessionId.trim() : "");
|
||||
const applyingRouteSession = ref(Boolean(rawRouteSessionId.value));
|
||||
const componentActive = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
componentActive.value = true;
|
||||
applyingRouteSession.value = Boolean(routeSessionId.value || routeLegacyConversationId.value);
|
||||
startWorkbenchOpenJourney({ route: route.path, cache: routeSessionId.value || routeLegacyConversationId.value ? "cold" : "warm", authState: "warm" });
|
||||
applyingRouteSession.value = Boolean(rawRouteSessionId.value);
|
||||
startWorkbenchOpenJourney({ route: route.path, cache: routeSessionId.value ? "cold" : "warm", authState: "warm" });
|
||||
try {
|
||||
await workbench.hydrate({ sessionId: routeSessionId.value, conversationId: routeLegacyConversationId.value });
|
||||
await workbench.hydrate({ sessionId: routeSessionId.value, invalidRouteId: rawRouteSessionId.value && !routeSessionId.value ? rawRouteSessionId.value : null });
|
||||
await applyRouteSession();
|
||||
finishWorkbenchOpenFullLoad(workbench.error ? "error" : "ok");
|
||||
} finally {
|
||||
@@ -44,11 +44,10 @@ useAutoRefresh(() => workbench.refreshLive(), 30_000);
|
||||
|
||||
async function applyRouteSession(): Promise<boolean> {
|
||||
const sessionId = routeSessionId.value;
|
||||
const legacyConversationId = routeLegacyConversationId.value;
|
||||
if (!sessionId && !legacyConversationId) return true;
|
||||
if (!sessionId && !rawRouteSessionId.value) return true;
|
||||
applyingRouteSession.value = true;
|
||||
try {
|
||||
return sessionId ? await workbench.selectSessionById(sessionId) : await workbench.selectConversationById(legacyConversationId ?? "");
|
||||
return sessionId ? await workbench.selectSessionById(sessionId) : await workbench.selectSessionById(rawRouteSessionId.value);
|
||||
} finally {
|
||||
applyingRouteSession.value = false;
|
||||
await reflectActiveSessionInUrl(workbench.activeSessionId);
|
||||
|
||||
@@ -17,8 +17,8 @@ export const selectors = {
|
||||
messageDetailDialog: "#message-detail-dialog"
|
||||
} as const;
|
||||
|
||||
export function sessionTab(conversationId: string): string {
|
||||
return `.session-tab[data-conversation-id="${cssEscape(conversationId)}"]`;
|
||||
export function sessionTab(sessionId: string): string {
|
||||
return `.session-tab[data-session-id="${cssEscape(sessionId)}"]`;
|
||||
}
|
||||
|
||||
function cssEscape(value: string): string {
|
||||
|
||||
@@ -5,7 +5,7 @@ test.use({ scenarioId: "deep-link" });
|
||||
|
||||
test("deep link hydrates the same authority path as tab selection", async ({ page }, testInfo) => {
|
||||
await gotoWorkbench(page, "/workbench/sessions/ses_failed");
|
||||
await expect(page.locator(sessionTab("cnv_failed"))).toHaveAttribute("data-active", "true");
|
||||
await expect(page.locator(sessionTab("ses_failed"))).toHaveAttribute("data-active", "true");
|
||||
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="failed"]`)).toContainText("缺少受控依赖");
|
||||
await expect(page.locator(`${selectors.traceTimeline}[data-status="failed"]`)).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/workbench\/sessions\/ses_failed$/u);
|
||||
@@ -17,11 +17,10 @@ test.describe("deep link stale workspace authority", () => {
|
||||
|
||||
test("deep link persists route session over stale workspace selection", async ({ page }) => {
|
||||
await gotoWorkbench(page, "/workbench/sessions/ses_failed");
|
||||
await expect(page.locator(sessionTab("cnv_failed"))).toHaveAttribute("data-active", "true");
|
||||
await expect(page.locator(sessionTab("ses_failed"))).toHaveAttribute("data-active", "true");
|
||||
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="failed"]`)).toContainText("缺少受控依赖");
|
||||
await expect(page).toHaveURL(/\/workbench\/sessions\/ses_failed$/u);
|
||||
const state = await fakeServerState(page);
|
||||
const selectRequests = state.selectRequests as Array<{ conversationId?: unknown }>;
|
||||
expect(selectRequests.some((request) => request.conversationId === "cnv_failed")).toBeFalsy();
|
||||
expect((state.legacyRequestLedger as unknown[]).length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,8 +13,8 @@ test("fresh deep link does not restore an archived session as active", async ({
|
||||
expect(((await list.json()).conversations as Array<{ conversationId: string; status?: string }>).some((item) => item.conversationId === "cnv_deleted" && item.status === "archived")).toBe(true);
|
||||
|
||||
await gotoWorkbench(page, "/workbench/sessions/ses_deleted");
|
||||
await expect(page.locator(sessionTab("cnv_deleted"))).toHaveCount(0);
|
||||
await expect(page.locator(sessionTab("cnv_completed"))).toHaveCount(1);
|
||||
await expect(page.locator(sessionTab("ses_deleted"))).toHaveCount(0);
|
||||
await expect(page.locator(sessionTab("ses_completed"))).toHaveCount(1);
|
||||
await expect(page.locator(".conversation-error-hint")).toContainText(/archived|归档|session/u);
|
||||
await expect(page.locator(selectors.commandSend)).toBeDisabled();
|
||||
await saveScreenshot(page, testInfo, "deleted-session-deeplink");
|
||||
|
||||
@@ -24,16 +24,16 @@ test("fresh deep link replays completed conversation from the session authority"
|
||||
const detailBody = await detail.json();
|
||||
expect(detailBody.session.sessionId).toBe("ses_completed");
|
||||
|
||||
const turn = await page.request.get("/v1/agent/turns/trc_completed");
|
||||
const turn = await page.request.get("/v1/workbench/turns/trc_completed");
|
||||
expect(turn.status()).toBe(200);
|
||||
expect((await turn.json()).status).toBe("completed");
|
||||
expect((await turn.json()).turn.status).toBe("completed");
|
||||
|
||||
const trace = await page.request.get("/v1/agent/traces/trc_completed");
|
||||
const trace = await page.request.get("/v1/workbench/traces/trc_completed/events");
|
||||
expect(trace.status()).toBe(200);
|
||||
expect((await trace.json()).status).toBe("completed");
|
||||
expect((await trace.json()).traceStatus).toBe("completed");
|
||||
|
||||
await gotoWorkbench(page, "/workbench/sessions/ses_completed");
|
||||
await expect(page.locator(sessionTab("cnv_completed"))).toHaveAttribute("data-active", "true");
|
||||
await expect(page.locator(sessionTab("ses_completed"))).toHaveAttribute("data-active", "true");
|
||||
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="completed"]`)).toContainText("构建和 Trace 渲染检查完成");
|
||||
await expect(page.locator(`${selectors.traceTimeline}[data-status="completed"]`)).toBeVisible();
|
||||
await expect.poll(async () => await page.locator(selectors.traceRow).count()).toBeGreaterThan(0);
|
||||
|
||||
@@ -16,7 +16,7 @@ test("mobile Workbench exposes visible session management actions", async ({ pag
|
||||
expect(box?.height ?? 0).toBeGreaterThan(24);
|
||||
await page.locator(selectors.sessionCopy).click();
|
||||
await create.click();
|
||||
await expect(page.locator('.session-tab[data-active="true"]')).toHaveAttribute("data-conversation-id", /^cnv_/u);
|
||||
await expect(page.locator('.session-tab[data-active="true"]')).toHaveAttribute("data-session-id", /^ses_/u);
|
||||
await page.locator(selectors.sessionDelete).click();
|
||||
await expect(page.locator(selectors.sessionTabs)).not.toContainText("没有 session");
|
||||
});
|
||||
|
||||
@@ -5,9 +5,9 @@ test("new optimistic session first turn does not submit local thr_* as upstream
|
||||
await gotoWorkbench(page);
|
||||
await page.locator(selectors.sessionCreate).click();
|
||||
const activeTab = page.locator('.session-tab[data-active="true"]');
|
||||
await expect(activeTab).toHaveAttribute("data-conversation-id", /^cnv_/u);
|
||||
const conversationId = await activeTab.getAttribute("data-conversation-id");
|
||||
expect(conversationId).toBeTruthy();
|
||||
await expect(activeTab).toHaveAttribute("data-session-id", /^ses_/u);
|
||||
const sessionId = await activeTab.getAttribute("data-session-id");
|
||||
expect(sessionId).toBeTruthy();
|
||||
|
||||
await page.locator(selectors.commandInput).fill("web-probe D601 v0.3 smoke: reply with exactly OK.");
|
||||
await expect(page.locator(selectors.commandSend)).toBeEnabled();
|
||||
@@ -17,9 +17,12 @@ test("new optimistic session first turn does not submit local thr_* as upstream
|
||||
const state = await fakeServerState(page);
|
||||
const request = (state.chatRequests as Record<string, unknown>[])[0];
|
||||
if (!request) throw new Error("expected one chat request");
|
||||
expect(request.conversationId).toBe(conversationId);
|
||||
expect(String(request.sessionId)).toMatch(/^ses_/u);
|
||||
expect(request.conversationId).toBeUndefined();
|
||||
expect(request.projectId).toBeUndefined();
|
||||
expect(request.workspaceId).toBeUndefined();
|
||||
expect(request.sessionId).toBe(sessionId);
|
||||
expect(request.threadId ?? null).toBeNull();
|
||||
expect((state.legacyRequestLedger as unknown[]).length).toBe(0);
|
||||
});
|
||||
|
||||
test.describe("server authoritative session create", () => {
|
||||
@@ -30,8 +33,8 @@ test.describe("server authoritative session create", () => {
|
||||
await page.locator(selectors.sessionCreate).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/workbench\/sessions\/ses_server_created/u);
|
||||
await expect(page.locator('.session-tab[data-conversation-id="cnv_server_created"]')).toHaveAttribute("data-active", "true");
|
||||
await expect(page.locator('.session-tab[data-conversation-id="cnv_server_created"]')).toHaveAttribute("data-running", "false");
|
||||
await expect(page.locator('.session-tab[data-session-id="ses_server_created"]')).toHaveAttribute("data-active", "true");
|
||||
await expect(page.locator('.session-tab[data-session-id="ses_server_created"]')).toHaveAttribute("data-running", "false");
|
||||
|
||||
await page.locator(selectors.commandInput).fill("server authoritative create smoke");
|
||||
await expect(page.locator(selectors.commandSend)).toBeEnabled();
|
||||
@@ -40,7 +43,10 @@ test.describe("server authoritative session create", () => {
|
||||
await expect.poll(async () => ((await fakeServerState(page)).chatRequests as unknown[]).length).toBe(1);
|
||||
const state = await fakeServerState(page);
|
||||
const request = (state.chatRequests as Record<string, unknown>[])[0];
|
||||
expect(request?.conversationId).toBe("cnv_server_created");
|
||||
expect(request?.conversationId).toBeUndefined();
|
||||
expect(request?.projectId).toBeUndefined();
|
||||
expect(request?.workspaceId).toBeUndefined();
|
||||
expect(request?.sessionId).toBe("ses_server_created");
|
||||
expect((state.legacyRequestLedger as unknown[]).length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,8 +14,8 @@ test.describe("session authority boundary", () => {
|
||||
await expect((await messages.json()).error.code).toBe("session_not_found");
|
||||
|
||||
await gotoWorkbench(page, "/workbench/sessions/ses_cross_project");
|
||||
await expect(page.locator(sessionTab("cnv_cross_project"))).toHaveCount(0);
|
||||
await expect(page.locator(`${selectors.messageCard}[data-conversation-id="cnv_cross_project"]`)).toHaveCount(0);
|
||||
await expect(page.locator(sessionTab("ses_cross_project"))).toHaveCount(0);
|
||||
await expect(page.locator(selectors.messageCard)).toHaveCount(0);
|
||||
await saveScreenshot(page, testInfo, "project-boundary-cross-detail");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,18 +3,18 @@ import { selectors, sessionTab } from "../fixtures/selectors";
|
||||
|
||||
test("session switch persists active conversation after reload", async ({ page }, testInfo) => {
|
||||
await gotoWorkbench(page);
|
||||
await expect(page.locator(sessionTab("cnv_running"))).toHaveAttribute("data-active", "true");
|
||||
await expect(page.locator(sessionTab("ses_running"))).toHaveAttribute("data-active", "true");
|
||||
await saveScreenshot(page, testInfo, "session-switch-before");
|
||||
|
||||
await page.locator(sessionTab("cnv_completed")).click();
|
||||
await page.locator(sessionTab("ses_completed")).click();
|
||||
await expect(page).toHaveURL(/\/workbench\/sessions\/ses_completed/u);
|
||||
await expect(page.locator(sessionTab("cnv_completed"))).toHaveAttribute("data-active", "true");
|
||||
await expect(page.locator(sessionTab("ses_completed"))).toHaveAttribute("data-active", "true");
|
||||
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="completed"]`)).toContainText("构建和 Trace 渲染检查完成");
|
||||
await expect.poll(async () => (await fakeServerState(page)).selectedConversationId).toBe("cnv_completed");
|
||||
await expect.poll(async () => (await fakeServerState(page)).legacyRequestLedger as unknown[]).toHaveLength(0);
|
||||
await saveScreenshot(page, testInfo, "session-switch-after");
|
||||
|
||||
await page.reload();
|
||||
await expect(page.locator(sessionTab("cnv_completed"))).toHaveAttribute("data-active", "true");
|
||||
await expect(page.locator(sessionTab("ses_completed"))).toHaveAttribute("data-active", "true");
|
||||
await expect(page.locator(selectors.sessionStatus)).toContainText("ses_completed");
|
||||
await expect(page.locator(`${selectors.traceTimeline}[data-status="completed"]`)).toBeVisible();
|
||||
await saveScreenshot(page, testInfo, "session-switch-reload");
|
||||
@@ -25,37 +25,37 @@ test.describe("empty session switch target", () => {
|
||||
|
||||
test("switching from a filled session to an empty session updates URL and reload target", async ({ page }, testInfo) => {
|
||||
await gotoWorkbench(page, "/workbench/sessions/ses_completed");
|
||||
await expect(page.locator(sessionTab("cnv_completed"))).toHaveAttribute("data-active", "true");
|
||||
await expect(page.locator(sessionTab("ses_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 page.locator(sessionTab("ses_empty")).click();
|
||||
await expect(page).toHaveURL(/\/workbench\/sessions\/ses_empty$/u);
|
||||
await expect(page.locator(sessionTab("cnv_empty"))).toHaveAttribute("data-active", "true");
|
||||
await expect(page.locator(sessionTab("ses_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 expect.poll(async () => (await fakeServerState(page)).legacyRequestLedger as unknown[]).toHaveLength(0);
|
||||
|
||||
await page.reload();
|
||||
await expect(page.locator(sessionTab("cnv_empty"))).toHaveAttribute("data-active", "true");
|
||||
await expect(page.locator(sessionTab("ses_empty"))).toHaveAttribute("data-active", "true");
|
||||
await expect(page.locator(selectors.messageCard)).toHaveCount(0);
|
||||
await saveScreenshot(page, testInfo, "session-switch-empty-reload");
|
||||
});
|
||||
|
||||
test("switching back to a running session reflects URL before reload", async ({ page }) => {
|
||||
await gotoWorkbench(page, "/workbench/sessions/ses_running");
|
||||
await expect(page.locator(sessionTab("cnv_running"))).toHaveAttribute("data-active", "true");
|
||||
await expect(page.locator(sessionTab("ses_running"))).toHaveAttribute("data-active", "true");
|
||||
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="running"]`)).toBeVisible();
|
||||
|
||||
await page.locator(sessionTab("cnv_empty")).click();
|
||||
await page.locator(sessionTab("ses_empty")).click();
|
||||
await expect(page).toHaveURL(/\/workbench\/sessions\/ses_empty/u);
|
||||
await expect(page.locator(selectors.messageCard)).toHaveCount(0);
|
||||
|
||||
await page.locator(sessionTab("cnv_running")).click();
|
||||
await page.locator(sessionTab("ses_running")).click();
|
||||
await expect(page).toHaveURL(/\/workbench\/sessions\/ses_running/u);
|
||||
await expect(page.locator(sessionTab("cnv_running"))).toHaveAttribute("data-active", "true");
|
||||
await expect(page.locator(sessionTab("ses_running"))).toHaveAttribute("data-active", "true");
|
||||
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="running"]`)).toBeVisible();
|
||||
|
||||
await page.reload();
|
||||
await expect(page.locator(sessionTab("cnv_running"))).toHaveAttribute("data-active", "true");
|
||||
await expect(page.locator(sessionTab("ses_running"))).toHaveAttribute("data-active", "true");
|
||||
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="running"]`)).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -65,22 +65,22 @@ test.describe("stale session detail isolation", () => {
|
||||
|
||||
test("a visible tab with detail 404 does not clear rail or block later switches", async ({ page }) => {
|
||||
await gotoWorkbench(page, "/workbench/sessions/ses_completed");
|
||||
await expect(page.locator(sessionTab("cnv_completed"))).toHaveAttribute("data-active", "true");
|
||||
await expect(page.locator(sessionTab("cnv_stale_404"))).toHaveCount(1);
|
||||
await expect(page.locator(sessionTab("ses_completed"))).toHaveAttribute("data-active", "true");
|
||||
await expect(page.locator(sessionTab("ses_stale_404"))).toHaveCount(1);
|
||||
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="completed"]`)).toBeVisible();
|
||||
|
||||
await page.locator(sessionTab("cnv_stale_404")).click();
|
||||
await page.locator(sessionTab("ses_stale_404")).click();
|
||||
await expect(page.locator(".conversation-error-hint")).toContainText(/session URL not found|unavailable/u);
|
||||
await expect(page.locator(sessionTab("cnv_completed"))).toHaveCount(1);
|
||||
await expect(page.locator(sessionTab("cnv_stale_404"))).toHaveCount(1);
|
||||
await expect(page.locator(sessionTab("ses_completed"))).toHaveCount(1);
|
||||
await expect(page.locator(sessionTab("ses_stale_404"))).toHaveCount(1);
|
||||
|
||||
await page.locator(sessionTab("cnv_empty")).click();
|
||||
await page.locator(sessionTab("ses_empty")).click();
|
||||
await expect(page).toHaveURL(/\/workbench\/sessions\/ses_empty/u);
|
||||
await expect(page.locator(sessionTab("cnv_empty"))).toHaveAttribute("data-active", "true");
|
||||
await expect(page.locator(sessionTab("ses_empty"))).toHaveAttribute("data-active", "true");
|
||||
await expect(page.locator(selectors.messageCard)).toHaveCount(0);
|
||||
|
||||
await page.reload();
|
||||
await expect(page.locator(sessionTab("cnv_empty"))).toHaveAttribute("data-active", "true");
|
||||
await expect(page.locator(sessionTab("ses_empty"))).toHaveAttribute("data-active", "true");
|
||||
await expect(page.locator(selectors.sessionTabs)).not.toHaveAttribute("data-loading", "true");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,11 +7,11 @@ test.describe("stale nested workspace trace", () => {
|
||||
test("session restore ignores stale nested workspace lastTraceId", async ({ page }) => {
|
||||
const staleTraceRequests: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
if (/\/v1\/agent\/(?:turns|traces)\/trc_stale_502/u.test(request.url())) staleTraceRequests.push(request.url());
|
||||
if (/\/v1\/workbench\/(?:turns\/trc_stale_502|traces\/trc_stale_502\/events)/u.test(request.url())) staleTraceRequests.push(request.url());
|
||||
});
|
||||
|
||||
await gotoWorkbench(page, "/workbench/sessions/ses_failed");
|
||||
await expect(page.locator(sessionTab("cnv_failed"))).toHaveAttribute("data-active", "true");
|
||||
await expect(page.locator(sessionTab("ses_failed"))).toHaveAttribute("data-active", "true");
|
||||
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="failed"]`)).toContainText("缺少受控依赖");
|
||||
await expect(page.locator(`${selectors.traceTimeline}[data-status="failed"]`)).toBeVisible();
|
||||
await page.waitForTimeout(500);
|
||||
@@ -25,11 +25,11 @@ test.describe("stale nested workspace trace after terminal restore", () => {
|
||||
test("terminal recovery clears stale nested workspace lastTraceId before persisting", async ({ page }) => {
|
||||
const staleTraceRequests: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
if (/\/v1\/agent\/(?:turns|traces)\/trc_stale_502/u.test(request.url())) staleTraceRequests.push(request.url());
|
||||
if (/\/v1\/workbench\/(?:turns\/trc_stale_502|traces\/trc_stale_502\/events)/u.test(request.url())) staleTraceRequests.push(request.url());
|
||||
});
|
||||
|
||||
await gotoWorkbench(page, "/workbench/sessions/ses_running");
|
||||
await expect(page.locator(sessionTab("cnv_running"))).toHaveAttribute("data-running", "true");
|
||||
await expect(page.locator(sessionTab("ses_running"))).toHaveAttribute("data-running", "true");
|
||||
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="failed"]`)).toContainText("恢复后失败");
|
||||
await expect(page.locator(`${selectors.traceTimeline}[data-status="failed"]`)).toBeVisible();
|
||||
await expect(page.locator(selectors.commandSend)).toHaveAttribute("data-action", "turn");
|
||||
|
||||
@@ -3,21 +3,21 @@ import { selectors, sessionTab } from "../fixtures/selectors";
|
||||
|
||||
test("session rail, messages, composer and trace status stay consistent", async ({ page }, testInfo) => {
|
||||
await gotoWorkbench(page);
|
||||
await expect(page.locator(sessionTab("cnv_running"))).toHaveAttribute("data-running", "true");
|
||||
await expect(page.locator(sessionTab("ses_running"))).toHaveAttribute("data-running", "true");
|
||||
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="running"]`)).toBeVisible();
|
||||
await expect(page.locator(selectors.commandSend)).toHaveAttribute("data-action", "cancel");
|
||||
|
||||
await page.locator(sessionTab("cnv_failed")).click();
|
||||
await expect(page.locator(sessionTab("cnv_failed"))).toHaveAttribute("data-status", "failed");
|
||||
await page.locator(sessionTab("ses_failed")).click();
|
||||
await expect(page.locator(sessionTab("ses_failed"))).toHaveAttribute("data-status", "failed");
|
||||
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="failed"]`)).toContainText("缺少受控依赖");
|
||||
await expect(page.locator(`${selectors.traceTimeline}[data-status="failed"]`)).toBeVisible();
|
||||
|
||||
await page.locator(sessionTab("cnv_canceled")).click();
|
||||
await expect(page.locator(sessionTab("cnv_canceled"))).toHaveAttribute("data-status", "canceled");
|
||||
await page.locator(sessionTab("ses_canceled")).click();
|
||||
await expect(page.locator(sessionTab("ses_canceled"))).toHaveAttribute("data-status", "canceled");
|
||||
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="canceled"]`)).toContainText("运行已取消");
|
||||
|
||||
await page.locator(sessionTab("cnv_completed")).click();
|
||||
await expect(page.locator(sessionTab("cnv_completed"))).toHaveAttribute("data-running", "false");
|
||||
await page.locator(sessionTab("ses_completed")).click();
|
||||
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 saveScreenshot(page, testInfo, "status-consistency-completed");
|
||||
});
|
||||
@@ -27,8 +27,8 @@ test.describe("terminal turn with stale active session authority", () => {
|
||||
|
||||
test("session rail shows terminal status instead of stale active", async ({ page }, testInfo) => {
|
||||
await gotoWorkbench(page, "/workbench/sessions/ses_completed");
|
||||
await expect(page.locator(sessionTab("cnv_completed"))).toHaveAttribute("data-status", "completed");
|
||||
await expect(page.locator(sessionTab("cnv_completed"))).toHaveAttribute("data-running", "false");
|
||||
await expect(page.locator(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 saveScreenshot(page, testInfo, "stale-active-session-terminal-tab");
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { selectors, sessionTab } from "../fixtures/selectors";
|
||||
|
||||
test("TraceTimeline renders readable rows and hides backend noise", async ({ page }, testInfo) => {
|
||||
await gotoWorkbench(page, "/workbench/sessions/ses_completed");
|
||||
await expect(page.locator(sessionTab("cnv_completed"))).toHaveAttribute("data-active", "true");
|
||||
await expect(page.locator(sessionTab("ses_completed"))).toHaveAttribute("data-active", "true");
|
||||
const trace = page.locator(`${selectors.traceTimeline}[data-status="completed"]`);
|
||||
await expect(trace).toBeVisible();
|
||||
await trace.locator("summary.trace-disclosure-summary").click();
|
||||
|
||||
Reference in New Issue
Block a user