Merge pull request #1417 from pikasTech/codex/1412-1414-workbench

修复 Workbench 新会话与移动端恢复问题
This commit is contained in:
Lyon
2026-06-17 19:09:53 +08:00
committed by GitHub
9 changed files with 195 additions and 19 deletions
+2 -1
View File
@@ -717,7 +717,8 @@ test("workbench workspace read preserves a newer selected conversation without t
assert.equal(restored.body.workspace.selectedConversation.sessionId, "ses_issue808_new");
assert.equal(restored.body.workspace.workspace.selectedConversationId, "cnv_issue808_new");
assert.equal(restored.body.workspace.workspace.selectedAgentSessionId, "ses_issue808_new");
assert.equal(restored.body.workspace.workspace.lastTraceId, "trc_issue808_old_done");
assert.equal(restored.body.workspace.workspace.lastTraceId, undefined);
assert.notEqual(restored.body.workspace.workspace.sessionStatus, "running");
assert.equal(agentRunCalls.length, 0);
const selectedOld = await postJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}/select-conversation`, {
+15 -1
View File
@@ -2101,7 +2101,7 @@ function publicWorkbenchWorkspace(workspace, { conversation = null } = {}) {
activeTraceId: workspace.activeTraceId,
providerProfile: workspace.providerProfile,
selectedConversation: conversation,
workspace: redactedWorkspaceJson(workspace.workspace),
workspace: redactedWorkspaceJson(workspaceJsonForSelectedConversation(workspace, conversation)),
updatedBySessionId: workspace.updatedBySessionId,
updatedByClient: workspace.updatedByClient,
createdAt: workspace.createdAt,
@@ -2110,6 +2110,20 @@ function publicWorkbenchWorkspace(workspace, { conversation = null } = {}) {
secretMaterialStored: false
};
}
function workspaceJsonForSelectedConversation(workspace, conversation = null) {
const snapshot = normalizeObject(workspace?.workspace);
const selectedConversationId = textOr(workspace?.selectedConversationId, "");
if (!conversation || textOr(conversation.conversationId, "") !== selectedConversationId) return snapshot;
return {
...snapshot,
selectedConversationId,
selectedAgentSessionId: textOr(workspace.selectedAgentSessionId ?? conversation.sessionId, ""),
activeTraceId: textOr(workspace.activeTraceId, ""),
providerProfile: textOr(workspace.providerProfile, snapshot.providerProfile),
sessionStatus: textOr(conversation.status, ""),
lastTraceId: textOr(conversation.lastTraceId, "")
};
}
function redactedWorkspaceJson(value = {}) {
const workspace = normalizeObject(value);
return pruneEmpty({
@@ -26,9 +26,11 @@ interface ScenarioState {
conversations: ConversationRecord[];
traces: Record<string, JsonRecord>;
selectRequests: JsonRecord[];
chatRequests: JsonRecord[];
listOmitSelected: boolean;
conversationDelayMs: number;
terminalScript: boolean;
staleNestedTraceId: string | null;
}
const cwd = process.cwd();
@@ -85,10 +87,17 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
const body = await readJson(request);
state.selectRequests.push(redactRequestBody(body));
const conversationId = String(body.conversationId ?? "");
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() });
}
if (path === "/v1/agent/chat" && method === "POST") {
const body = await readJson(request);
state.chatRequests.push(redactRequestBody(body));
return json(response, 200, acceptChatTurn(body));
}
if (path === "/v1/agent/conversations" && method === "GET") {
await delay(state.conversationDelayMs);
const include = url.searchParams.get("includeConversationId");
@@ -106,10 +115,18 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
if (sessionMatch && method === "GET") return json(response, 200, { session: sessionPayload(decodeURIComponent(sessionMatch[1] ?? "")) });
const turnMatch = path.match(/^\/v1\/agent\/turns\/([^/]+)$/u);
if (turnMatch && method === "GET") return json(response, 200, turnPayload(decodeURIComponent(turnMatch[1] ?? "")));
if (turnMatch && method === "GET") {
const traceId = decodeURIComponent(turnMatch[1] ?? "");
if (traceId === state.staleNestedTraceId) return json(response, 502, { ok: false, status: 502, error: { code: "upstream_unavailable", message: "stale trace is unavailable" } });
return json(response, 200, turnPayload(traceId));
}
const traceMatch = path.match(/^\/v1\/agent\/traces\/([^/]+)$/u);
if (traceMatch && method === "GET") return json(response, 200, tracePayload(decodeURIComponent(traceMatch[1] ?? ""), url));
if (traceMatch && method === "GET") {
const traceId = decodeURIComponent(traceMatch[1] ?? "");
if (traceId === state.staleNestedTraceId) return json(response, 502, { ok: false, status: 502, error: { code: "upstream_unavailable", message: "stale trace is unavailable" } });
return json(response, 200, tracePayload(traceId, url));
}
if (path === "/v1/provider-profiles") return json(response, 200, { profiles: [{ profile: "codex-api", name: "Codex API", configured: true }, { profile: "deepseek", name: "DeepSeek", configured: true }] });
if (path === "/health/live" || path === "/health" || path === "/v1") return json(response, 200, { status: "ok", serviceId: "workbench-e2e", codeAgent: { ready: true, status: "ready" } });
@@ -124,7 +141,7 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
function createScenarioState(scenarioId: string): ScenarioState {
const base = structuredClone(capture.scenario);
const id = scenarioId || "baseline";
const selectedConversationId = id === "deep-link" ? "cnv_failed" : base.selectedConversationId;
const selectedConversationId = id === "deep-link" || id === "stale-nested-trace" ? "cnv_failed" : base.selectedConversationId;
return {
scenarioId: id,
projectId: base.projectId,
@@ -134,14 +151,17 @@ function createScenarioState(scenarioId: string): ScenarioState {
conversations: base.conversations,
traces: base.traces,
selectRequests: [],
chatRequests: [],
listOmitSelected: id === "selected-missing-from-list",
conversationDelayMs: id === "loading" ? 2_500 : 0,
terminalScript: id === "event-replay" || id === "running-to-terminal"
terminalScript: id === "event-replay" || id === "running-to-terminal",
staleNestedTraceId: id === "stale-nested-trace" ? "trc_stale_502" : null
};
}
function workspacePayload(): JsonRecord {
const selected = conversationById(state.selectedConversationId);
const nestedLastTraceId = state.staleNestedTraceId ?? selected?.lastTraceId ?? null;
return {
workspaceId: state.workspaceId,
projectId: state.projectId,
@@ -151,6 +171,7 @@ function workspacePayload(): JsonRecord {
selectedAgentSessionId: selected?.sessionId ?? null,
activeTraceId: selected?.status === "running" ? selected.lastTraceId ?? null : null,
providerProfile: state.providerProfile,
selectedConversation: selected ?? null,
workspace: {
projectId: state.projectId,
selectedConversationId: state.selectedConversationId,
@@ -158,12 +179,56 @@ function workspacePayload(): JsonRecord {
threadId: selected?.threadId ?? null,
sessionStatus: selected?.status ?? null,
providerProfile: state.providerProfile,
lastTraceId: selected?.lastTraceId ?? null,
lastTraceId: nestedLastTraceId,
updatedAt: new Date().toISOString()
}
};
}
function createConversationFromSelect(body: JsonRecord, conversationId: string): ConversationRecord {
const token = conversationId.slice(4);
const sessionId = typeof body.sessionId === "string" && body.sessionId.trim() ? body.sessionId : `ses_${token}`;
const threadId = typeof body.threadId === "string" && body.threadId.trim() ? body.threadId : null;
const now = new Date().toISOString();
return {
conversationId,
projectId: state.projectId,
sessionId,
threadId,
status: "active",
startedAt: now,
updatedAt: now,
messageCount: 0,
firstUserMessagePreview: null,
session: { sessionId, threadId, status: "active" },
messages: []
};
}
function acceptChatTurn(body: JsonRecord): JsonRecord {
const conversationId = typeof body.conversationId === "string" ? body.conversationId : state.selectedConversationId;
const sessionId = typeof body.sessionId === "string" ? body.sessionId : conversationById(conversationId)?.sessionId ?? `ses_${conversationId.slice(4)}`;
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)}`;
const message = typeof body.message === "string" ? body.message : "";
const conversation = conversationById(conversationId);
const now = new Date().toISOString();
if (conversation) {
conversation.status = "running";
conversation.lastTraceId = traceId;
conversation.updatedAt = now;
conversation.firstUserMessagePreview = conversation.firstUserMessagePreview ?? message;
conversation.messageCount = (conversation.messages?.length ?? 0) + 2;
conversation.messages = [
...(conversation.messages ?? []),
{ id: `msg_${traceId}_user`, role: "user", title: "用户", text: message, status: "sent", createdAt: now, conversationId, sessionId, threadId, traceId },
{ id: `msg_${traceId}_agent`, role: "agent", title: "Code Agent", text: "", status: "running", createdAt: now, conversationId, sessionId, threadId, traceId, runnerTrace: { traceId, status: "running", sessionId, threadId, events: [], eventCount: 0, fullTraceLoaded: false, hasMore: false } }
];
}
state.traces[traceId] = { traceId, status: "running", conversationId, sessionId, threadId, events: [], eventCount: 0, fullTraceLoaded: false, hasMore: false };
return { status: "running", accepted: true, running: true, terminal: false, traceId, conversationId, sessionId, threadId, turnId: traceId };
}
function conversationSummary(conversation: ConversationRecord): ConversationRecord {
const { messages: _messages, ...rest } = conversation;
return rest as ConversationRecord;
@@ -245,7 +310,7 @@ function clearActiveTraceForSelection(): void {
}
function stateSummary(): JsonRecord {
return { scenarioId: state.scenarioId, selectedConversationId: state.selectedConversationId, selectRequests: state.selectRequests, conversations: state.conversations.map((item) => ({ conversationId: item.conversationId, sessionId: item.sessionId, status: item.status, lastTraceId: item.lastTraceId })) };
return { scenarioId: state.scenarioId, selectedConversationId: state.selectedConversationId, selectRequests: state.selectRequests, 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 authPayload(): JsonRecord {
+15 -9
View File
@@ -197,7 +197,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
rememberWorkspaceSnapshot(tabProjectId, workspace.value);
void refreshSessionStatusById(sessionIdFromConversation(conversation));
loading.value = true;
const persistPromise = api.workbench.selectConversation(current.workspaceId, { projectId: tabProjectId, conversationId: conversation.conversationId, sessionId: conversation.sessionId, threadId: conversation.threadId, updatedByClient: "cloud-web-vue" });
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;
loading.value = false;
@@ -315,7 +315,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
create: true,
conversationId: conversation.conversationId,
sessionId: conversation.sessionId,
threadId: conversation.threadId,
threadId: providerThreadIdForRequest(conversation.threadId),
updatedByClient: "cloud-web-vue-fast-create"
});
if (!response.ok || !response.data?.workspace) {
@@ -478,6 +478,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
setActiveConversationSelection(conversationId);
const sessionId = composer.value.sessionId;
const threadId = composer.value.threadId;
const providerThreadId = providerThreadIdForRequest(threadId);
const user = makeMessage("user", value, "sent", { traceId: steerTraceId ?? traceId, conversationId, sessionId, threadId, title: steerMode ? "用户引导" : "用户" });
const pending = makeMessage("agent", "", "running", { traceId, conversationId, sessionId, threadId, title: "Code Agent", retryInput: value, traceAutoLifecycle: "running" });
messages.value.push(user, pending);
@@ -495,7 +496,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
currentRequest.value = null;
return;
}
const payload = { projectId: activeProjectId.value, message: value, prompt: value, conversationId, sessionId, threadId, traceId, providerProfile: providerProfile.value, gatewayShellTimeoutMs: gatewayShellTimeoutMs.value, workspaceId: workspace.value?.workspaceId, workspaceRevision: workspace.value?.revision, targetTraceId: composer.value.targetTraceId, steerTraceId };
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 route = steerMode ? api.agent.steerAgentMessage : api.agent.sendAgentMessage;
const response = await route(payload, codeAgentTimeoutMs.value, () => activityRef.value);
if (!response.ok || !response.data) {
@@ -1028,13 +1029,13 @@ export const useWorkbenchStore = defineStore("workbench", () => {
if (!response) {
const workspaceId = workspace.value?.workspaceId;
if (!workspaceId) return;
response = await api.workbench.selectConversation(workspaceId, { projectId: selectedProjectId, conversationId, sessionId: conversation.sessionId, threadId: conversation.threadId, updatedByClient: "cloud-web-vue" });
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: conversation.threadId, updatedByClient: "cloud-web-vue-retry" });
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;
@@ -1057,7 +1058,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
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: conversation.threadId, updatedByClient: "cloud-web-vue-retry" }),
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)) {
@@ -1109,24 +1110,29 @@ function sessionIdFromConversation(conversation: ConversationRecord | null | und
return firstNonEmptyString(conversation?.sessionId, conversation?.session?.sessionId) ?? null;
}
function providerThreadIdForRequest(threadId: string | null | undefined): string | null {
const value = firstNonEmptyString(threadId) ?? null;
if (!value) return null;
return /^thr_[A-Za-z0-9]{12,32}$/u.test(value) ? null : value;
}
function makeOptimisticConversation(projectId: string, now = new Date()): ConversationRecord {
const token = nextProtocolId("cnv").slice(4);
const title = `新会话 ${shortSessionTimestamp(now)}`;
const createdAt = now.toISOString();
const sessionId = `ses_${token}`;
const threadId = `thr_${token}`;
return {
conversationId: `cnv_${token}`,
projectId,
sessionId,
threadId,
threadId: null,
status: "active",
title,
name: title,
startedAt: createdAt,
updatedAt: createdAt,
messageCount: 0,
session: { sessionId, threadId, status: "active" },
session: { sessionId, status: "active" },
messages: []
};
}
+35 -2
View File
@@ -2452,14 +2452,47 @@
.workbench-grid {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr);
}
.session-rail,
.hwpod-panel {
display: none;
}
.session-rail {
display: grid;
width: 100% !important;
max-height: 168px;
grid-template-rows: auto minmax(0, 1fr);
align-content: stretch;
overflow: hidden;
}
.session-rail .panel-header.compact {
min-height: 34px;
}
.session-rail .panel-header.compact .page-eyebrow,
.session-rail .session-model-channel,
.session-rail .session-active-metadata,
.session-rail .session-rail-actions,
.session-rail .session-resize-handle {
display: none;
}
.session-rail .panel-header.compact h2 {
font-size: 14px;
}
#session-create {
min-width: 56px;
min-height: 32px;
}
.session-list {
max-height: 104px;
}
.command-composer {
gap: 8px;
padding: 10px;
@@ -3,6 +3,7 @@
export const selectors = {
workspace: "#workspace",
sessionCreate: "#session-create",
sessionTabs: "#session-tabs",
sessionStatus: "#session-status",
commandInput: "#command-input",
@@ -0,0 +1,15 @@
import { expect, gotoWorkbench, test } from "../fixtures/test";
import { selectors } from "../fixtures/selectors";
test.use({ viewport: { width: 390, height: 844 } });
test("mobile Workbench exposes a visible create session entry", async ({ page }) => {
await gotoWorkbench(page);
const create = page.locator(selectors.sessionCreate);
await expect(create).toBeVisible();
const box = await create.boundingBox();
expect(box?.width ?? 0).toBeGreaterThan(32);
expect(box?.height ?? 0).toBeGreaterThan(24);
await create.click();
await expect(page.locator('.session-tab[data-active="true"]')).toHaveAttribute("data-conversation-id", /^cnv_/u);
});
@@ -0,0 +1,23 @@
import { expect, fakeServerState, gotoWorkbench, test } from "../fixtures/test";
import { selectors } from "../fixtures/selectors";
test("new optimistic session first turn does not submit local thr_* as upstream thread", async ({ page }) => {
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 page.locator(selectors.commandInput).fill("web-probe D601 v0.3 smoke: reply with exactly OK.");
await expect(page.locator(selectors.commandSend)).toBeEnabled();
await page.locator(selectors.commandSend).click();
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];
if (!request) throw new Error("expected one chat request");
expect(request.conversationId).toBe(conversationId);
expect(String(request.sessionId)).toMatch(/^ses_/u);
expect(request.threadId ?? null).toBeNull();
});
@@ -0,0 +1,18 @@
import { expect, gotoWorkbench, test } from "../fixtures/test";
import { selectors, sessionTab } from "../fixtures/selectors";
test.use({ scenarioId: "stale-nested-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());
});
await gotoWorkbench(page, "/workbench/sessions/cnv_failed?projectId=prj_hwpod_workbench");
await expect(page.locator(sessionTab("cnv_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);
expect(staleTraceRequests).toEqual([]);
});