Merge pull request #1499 from pikasTech/fix/issue-1496-workbench-authority

fix: converge Workbench submit authority
This commit is contained in:
Lyon
2026-06-18 17:43:38 +08:00
committed by GitHub
6 changed files with 133 additions and 38 deletions
@@ -28,6 +28,8 @@ interface ScenarioState {
chatRequests: JsonRecord[];
listOmitSelected: boolean;
sessionDelayMs: number;
sessionDetailDelayMs: number;
chatDelayMs: number;
terminalScript: boolean;
terminalFailureScript: boolean;
staleTraceId: string | null;
@@ -101,6 +103,7 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
const sessionMessagesMatch = path.match(/^\/v1\/workbench\/sessions\/([^/]+)\/messages$/u);
if (sessionMessagesMatch && method === "GET") {
await delay(state.sessionDetailDelayMs);
const sessionId = canonicalSessionId(decodeURIComponent(sessionMessagesMatch[1] ?? ""));
const session = visibleSessionById(sessionId);
return session ? json(response, 200, workbenchSessionMessagesPayload(session, url)) : json(response, 404, { ok: false, status: 404, error: { code: "session_not_found" } });
@@ -108,6 +111,7 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
const workbenchSessionMatch = path.match(/^\/v1\/workbench\/sessions\/([^/]+)$/u);
if (workbenchSessionMatch && method === "GET") {
await delay(state.sessionDetailDelayMs);
const sessionId = canonicalSessionId(decodeURIComponent(workbenchSessionMatch[1] ?? ""));
if (state.scenarioId === "session-switch-detail-404-isolated" && sessionId === "ses_stale_404") return json(response, 404, { ok: false, status: 404, error: { code: "session_not_found" } });
if (state.scenarioId === "completed-replay-detail-404" && sessionId === "ses_completed") return json(response, 404, { ok: false, status: 404, error: { code: "session_replay_unavailable" } });
@@ -118,6 +122,7 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
if (path === "/v1/agent/chat" && method === "POST") {
const body = await readJson(request);
state.chatRequests.push(redactRequestBody(body));
await delay(state.chatDelayMs);
return json(response, 200, acceptChatTurn(body));
}
@@ -233,6 +238,10 @@ function createScenarioState(scenarioId: string): ScenarioState {
sessions.unshift(legacyCanonicalSession());
traces.trc_40badc15523146c9 = legacyCanonicalTrace();
}
if (id === "submit-authority-race") {
sessions.unshift(submitSplitSession());
traces.trc_submit_split = submitSplitTrace();
}
if (id === "session-rail-many") {
for (let index = 0; index < 46; index += 1) sessions.push(manyRailSession(index));
}
@@ -267,6 +276,8 @@ function createScenarioState(scenarioId: string): ScenarioState {
chatRequests: [],
listOmitSelected: id === "selected-missing-from-list",
sessionDelayMs: id === "loading" ? 2_500 : 0,
sessionDetailDelayMs: id === "legacy-cnv-deeplink-canonical" ? 1_500 : 0,
chatDelayMs: id === "submit-authority-race" ? 1_000 : 0,
terminalScript: id === "event-replay" || id === "running-to-terminal" || id === "stale-submit-restore",
terminalFailureScript: id === "stale-submit-restore",
staleTraceId
@@ -362,6 +373,28 @@ function archivedDeletedSession(): SessionRecord {
return { sessionId: "ses_deleted", threadId: "thr_deleted", status: "archived", startedAt: now, updatedAt: now, messageCount: 0, firstUserMessagePreview: "已删除 session", messages: [] };
}
function submitSplitSession(): SessionRecord {
const now = new Date().toISOString();
return {
sessionId: "ses_submit_split",
threadId: "thr_submit_split",
status: "idle",
lastTraceId: "trc_submit_split",
startedAt: now,
updatedAt: now,
messageCount: 2,
firstUserMessagePreview: "workbench submit race probe redacted: 请只回复 OK。",
messages: [
{ id: "msg_submit_split_user", messageId: "msg_submit_split_user", role: "user", title: "用户", text: "", status: "sent", createdAt: now, sessionId: "ses_submit_split", threadId: "thr_submit_split", traceId: "trc_submit_split", turnId: "turn_submit_split" },
{ id: "msg_submit_split_agent", messageId: "msg_submit_split_agent", role: "agent", title: "Code Agent", text: "", status: "running", createdAt: now, sessionId: "ses_submit_split", threadId: "thr_submit_split", traceId: "trc_submit_split", turnId: "turn_submit_split", runnerTrace: { traceId: "trc_submit_split", status: "running", sessionId: "ses_submit_split", threadId: "thr_submit_split", events: [], eventCount: 0, fullTraceLoaded: false, hasMore: false } }
]
};
}
function submitSplitTrace(): JsonRecord {
return { traceId: "trc_submit_split", status: "running", sessionId: "ses_submit_split", threadId: "thr_submit_split", turnId: "turn_submit_split", events: [], eventCount: 0, fullTraceLoaded: false, hasMore: false };
}
function acceptChatTurn(body: JsonRecord): JsonRecord {
const sessionId = typeof body.sessionId === "string" && body.sessionId.trim() ? body.sessionId : state.selectedSessionId;
const session = sessionById(sessionId) ?? createManualSession({ sessionId });
@@ -26,6 +26,12 @@ export function shouldReflectWorkbenchSessionUrl(input: WorkbenchNavigationConte
if (!input.sessionId) return false;
if (!isWorkbenchNavigationContext(input)) return false;
if (input.routeSessionId === input.sessionId) return false;
if (input.applyingRouteSession && input.routeSessionId && input.routeSessionId !== input.sessionId) return false;
if (input.applyingRouteSession && input.routeSessionId && input.routeSessionId !== input.sessionId && !isCanonicalRoutePair(input.routeSessionId, input.sessionId)) return false;
return true;
}
function isCanonicalRoutePair(routeSessionId: string | null | undefined, sessionId: string | null | undefined): boolean {
if (!routeSessionId || !sessionId) return false;
if (!routeSessionId.startsWith("cnv_") || !sessionId.startsWith("ses_")) return false;
return routeSessionId.slice("cnv_".length) === sessionId.slice("ses_".length);
}
@@ -76,7 +76,9 @@ export function resolveComposerState(input: { messages: ChatMessage[]; sessions?
const latestTraceId = firstNonEmptyString(latestMessage?.traceId, latestMessage?.runnerTrace?.traceId);
const activeTraceId = firstNonEmptyString(currentRequest?.traceId, latestTraceId);
const turn = activeTraceId ? input.turnStatusAuthority?.[activeTraceId] : null;
const activeByStatus = turn?.running === true || isActiveStatus(turn?.status);
const activeByRequest = Boolean(currentRequest && isActiveStatus(currentRequest.status ?? "running"));
const activeByMessage = latestMessage?.role === "agent" && isActiveStatus(latestMessage.status);
const activeByStatus = turn?.running === true || isActiveStatus(turn?.status) || activeByRequest || activeByMessage;
const terminal = turn?.terminal === true || isTerminalStatus(turn?.status);
const active = activeSession(input.sessions ?? [], sessionId);
const effectiveSessionId = firstNonEmptyString(turn?.sessionId, currentRequest?.sessionId, active?.sessionId, sessionId);
+46 -36
View File
@@ -236,33 +236,6 @@ export const useWorkbenchStore = defineStore("workbench", () => {
void source;
}
async function refreshSelectedSessionStatus(fallbackSession?: WorkbenchSessionRecord | null): Promise<void> {
await refreshSessionStatusById(firstNonEmptyString(fallbackSession?.sessionId, selectedSessionId.value));
}
async function refreshSessionStatusById(sessionId: string | null | undefined): Promise<void> {
const id = firstNonEmptyString(sessionId);
if (!id) return;
const response = await api.agent.getAgentSession(id);
const session = response.data?.session && typeof response.data.session === "object" ? response.data.session : null;
if (!response.ok || !session) {
reduceServerState({ type: "session.forget", sessionId: id });
return;
}
const returnedSessionId = firstNonEmptyString(session.sessionId, id);
if (returnedSessionId !== id) return;
reduceServerState({
type: "session.status",
session: {
sessionId: id,
status: firstNonEmptyString(session.status) ?? null,
updatedAt: firstNonEmptyString(session.updatedAt) ?? null,
lastTraceId: firstNonEmptyString(session.lastTraceId) ?? null,
loadedAt: new Date().toISOString()
}
});
}
async function hydrateTurnStatusAuthority(source: ChatMessage[] = messages.value): Promise<void> {
await Promise.all(uniqueTraceIds(source).slice(-12).map((traceId) => refreshTurnStatusByTraceId(traceId)));
}
@@ -342,9 +315,9 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const pending = makeMessage("agent", "", "running", { traceId, sessionId, threadId, title: "Code Agent", retryInput: value, traceAutoLifecycle: "running" });
messages.value.push(user, pending);
startWorkbenchSubmitJourney({ traceId, sessionId, entry: submitEntry, backend: providerProfile.value, transport: "sse" });
void refreshSessionStatusById(sessionId);
chatPending.value = true;
currentRequest.value = { traceId, sessionId, threadId, status: "running" };
projectOptimisticRunningTurn({ sessionId, threadId, traceId, userText: value });
rememberRecentDraft(value);
recordActivity("submit");
const payload = { message: value, prompt: value, sessionId, threadId: providerThreadId, traceId, providerProfile: providerProfile.value, gatewayShellTimeoutMs: gatewayShellTimeoutMs.value, targetTraceId: composer.value.targetTraceId, steerTraceId };
@@ -356,11 +329,9 @@ export const useWorkbenchStore = defineStore("workbench", () => {
if (steerMode && response.status === 404) void clearActiveTrace(traceId, "steer-trace-not-found");
chatPending.value = false;
currentRequest.value = null;
void refreshSessionStatusById(sessionId);
return;
}
markWorkbenchSubmitApiAccepted(traceId);
void refreshSessionStatusById(sessionId);
alignOptimisticTurnMessages(traceId, response.data);
applyTurnStatusSnapshot(traceId, response.data);
void refreshSessions(sessionId);
@@ -381,7 +352,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
void clearActiveTrace(traceId, "cancel-agent-message");
if (message.status === "running") chatPending.value = false;
currentRequest.value = null;
void refreshSessionStatusById(message.sessionId ?? selectedSessionId.value);
void refreshSessions(message.sessionId ?? selectedSessionId.value);
}
async function cancelRunningTrace(): Promise<void> {
@@ -701,7 +672,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
return { ...message, status, text: nextText, traceAutoLifecycle: terminal ? "terminal" : "running", runnerTrace, error: error ?? message.error ?? null, updatedAt: new Date().toISOString() };
});
markWorkbenchTraceProjected(traceId);
void refreshSessionStatusById(trace.sessionId ?? selectedSessionId.value);
void refreshSessions(trace.sessionId ?? selectedSessionId.value);
}
function completeTrace(traceId: string, result: AgentChatResultResponse): void {
@@ -723,7 +694,6 @@ export const useWorkbenchStore = defineStore("workbench", () => {
chatPending.value = false;
currentRequest.value = null;
void clearActiveTrace(traceId, "trace-terminal");
void refreshSessionStatusById(result.sessionId ?? selectedSessionId.value);
void refreshSessions(result.sessionId ?? activeSessionId.value);
restartRealtime("trace-terminal");
}
@@ -751,7 +721,6 @@ export const useWorkbenchStore = defineStore("workbench", () => {
return { ...message, status, title: normalizeWorkbenchMessageTitle(message.role, message.title), runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() };
});
void hydrateTraceEvents(messages.value);
void refreshSessionStatusById(result.sessionId ?? selectedSessionId.value);
void refreshSessions(result.sessionId ?? activeSessionId.value);
}
@@ -760,7 +729,26 @@ export const useWorkbenchStore = defineStore("workbench", () => {
chatPending.value = false;
currentRequest.value = null;
void clearActiveTrace(traceId, "trace-infrastructure-error");
void refreshSelectedSessionStatus();
void refreshSessions(selectedSessionId.value);
}
function projectOptimisticRunningTurn(input: { sessionId: string; threadId: string | null; traceId: string; userText: string }): void {
const now = new Date().toISOString();
reduceServerState({ type: "turn.status", turn: { traceId: input.traceId, status: "running", running: true, terminal: false, sessionId: input.sessionId, threadId: input.threadId, updatedAt: now, loadedAt: now } });
reduceServerState({ type: "session.status", session: { sessionId: input.sessionId, status: "running", updatedAt: now, lastTraceId: input.traceId, loadedAt: now } });
const existing = sessions.value.find((session) => session.sessionId === input.sessionId) ?? null;
sessions.value = mergeSessionIntoList(sessions.value, {
...(existing ?? {}),
sessionId: input.sessionId,
threadId: firstNonEmptyString(input.threadId, existing?.threadId),
status: "running",
updatedAt: now,
lastTraceId: input.traceId,
firstUserMessagePreview: firstNonEmptyString(existing?.firstUserMessagePreview, input.userText),
messageCount: Math.max(existing?.messageCount ?? 0, messages.value.filter((message) => firstNonEmptyString(message.sessionId, message.runnerTrace?.sessionId) === input.sessionId).length),
messages: messages.value.filter((message) => firstNonEmptyString(message.sessionId, message.runnerTrace?.sessionId) === input.sessionId)
} as WorkbenchSessionRecord);
rememberSessionList(sessions.value);
}
function markMessage(traceId: string, patch: Partial<ChatMessage>): void {
@@ -894,10 +882,32 @@ async function loadWorkbenchSession(sessionId: string, seed: WorkbenchSessionRec
const base = detailSession ?? seed;
if (!base) return null;
const page = messages.ok ? messages.data : null;
const pageMessages = Array.isArray(page?.messages) ? page.messages.map((message) => normalizeChatMessage(message as ChatMessage)) : base.messages;
const pageMessages = Array.isArray(page?.messages) ? repairWorkbenchMessagePage(page.messages.map((message) => normalizeChatMessage(message as ChatMessage)), base) : base.messages;
return { ...base, sessionId: id, messages: pageMessages, messageCount: page?.total ?? pageMessages?.length ?? base.messageCount };
}
function repairWorkbenchMessagePage(messages: ChatMessage[], session: WorkbenchSessionRecord): ChatMessage[] {
const userFallback = firstNonEmptyString(session.firstUserMessagePreview, session.snapshot?.firstUserMessagePreview, session.userPreview, session.title, session.name);
return messages.map((message, index) => {
if (message.role !== "user" || firstNonEmptyString(message.text)) return message;
const matched = findMessageTextFallback(message, index, session.messages ?? []);
const text = firstNonEmptyString(matched, index === 0 ? userFallback : null);
return text ? { ...message, text } : message;
});
}
function findMessageTextFallback(message: ChatMessage, index: number, candidates: ChatMessage[]): string | null {
const messageId = firstNonEmptyString(message.messageId, message.id);
const traceId = firstNonEmptyString(message.traceId, message.runnerTrace?.traceId);
const turnId = firstNonEmptyString(message.turnId);
const matched = candidates.find((candidate) => candidate.role === message.role && Boolean(
(messageId && firstNonEmptyString(candidate.messageId, candidate.id) === messageId)
|| (traceId && firstNonEmptyString(candidate.traceId, candidate.runnerTrace?.traceId) === traceId)
|| (turnId && firstNonEmptyString(candidate.turnId) === turnId)
));
return firstNonEmptyString(matched?.text, candidates.filter((candidate) => candidate.role === message.role)[index]?.text);
}
function providerThreadIdForRequest(threadId: string | null | undefined): string | null {
const value = firstNonEmptyString(threadId) ?? null;
if (!value) return null;
@@ -41,4 +41,13 @@ test.describe("legacy cnv deep link canonical authority", () => {
expect(detailRequests.some((item) => item.path === "/v1/workbench/sessions/ses_running")).toBe(false);
expect((state.legacyRequestLedger as unknown[]).length).toBe(0);
});
test("cnv route reflects canonical URL as soon as canonical content is visible", async ({ page }) => {
await gotoWorkbench(page, "/workbench/sessions/cnv_c5e1330558c94d8e");
await expect(page.locator(selectors.sessionStatus)).toContainText("当前 ses_c5e1330558c94d8e");
await expect(page).toHaveURL(/\/workbench\/sessions\/ses_c5e1330558c94d8e$/u, { timeout: 500 });
const state = await fakeServerState(page);
const legacyGets = (state.requestLedger as Array<{ method?: string; path?: string }>).filter((item) => item.method === "GET" && /^\/v1\/agent\/sessions\/[^/]+$/u.test(item.path ?? ""));
expect(legacyGets).toEqual([]);
});
});
@@ -0,0 +1,35 @@
import { expect, fakeServerState, gotoWorkbench, test } from "../fixtures/test";
import { selectors, sessionTab } from "../fixtures/selectors";
test.describe("submit session authority", () => {
test.use({ scenarioId: "submit-authority-race" });
test("optimistic submit immediately projects running state to tab and composer without legacy session detail", async ({ page }) => {
await gotoWorkbench(page, "/workbench/sessions/ses_running");
await page.locator(selectors.commandInput).fill("workbench submit race probe redacted: 请只回复 OK。");
await expect(page.locator(selectors.commandSend)).toBeEnabled();
await page.locator(selectors.commandSend).click();
await page.waitForTimeout(250);
await expect(page.locator(sessionTab("ses_running"))).toHaveAttribute("data-running", "true");
await expect(page.locator(sessionTab("ses_running"))).toHaveAttribute("data-status", /^(running|pending)$/u);
await expect(page.locator(`${selectors.messageCard}[data-role="user"]`).last()).toContainText("workbench submit race probe redacted");
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="running"]`).last()).toBeVisible();
await expect(page.locator(selectors.commandSend)).toHaveAttribute("data-action", "cancel");
const state = await fakeServerState(page);
const legacyGets = (state.requestLedger as Array<{ method?: string; path?: string }>).filter((item) => item.method === "GET" && /^\/v1\/agent\/sessions\/[^/]+$/u.test(item.path ?? ""));
expect(legacyGets).toEqual([]);
});
test("replay preserves user prompt when messages page has empty user text but list preview has authority", async ({ page }) => {
await gotoWorkbench(page, "/workbench/sessions/ses_submit_split");
await expect(page.locator(sessionTab("ses_submit_split"))).toContainText("workbench submit race probe redacted");
await expect(page.locator(`${selectors.messageCard}[data-role="user"]`).first()).toContainText("workbench submit race probe redacted");
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="running"]`)).toBeVisible();
const state = await fakeServerState(page);
const legacyGets = (state.requestLedger as Array<{ method?: string; path?: string }>).filter((item) => item.method === "GET" && /^\/v1\/agent\/sessions\/[^/]+$/u.test(item.path ?? ""));
expect(legacyGets).toEqual([]);
});
});