Merge pull request #1540 from pikasTech/codex/1536-session-switch-projection
fix(workbench): gate session switch detail projection
This commit is contained in:
@@ -247,6 +247,10 @@ function createScenarioState(scenarioId: string): ScenarioState {
|
|||||||
sessions.unshift(submitSplitSession());
|
sessions.unshift(submitSplitSession());
|
||||||
traces.trc_submit_split = submitSplitTrace();
|
traces.trc_submit_split = submitSplitTrace();
|
||||||
}
|
}
|
||||||
|
if (id === "session-switch-delayed-detail-frame") {
|
||||||
|
markSessionText(sessions, "ses_running", "SESSION_A_ONLY", "SESSION_A_ONLY running source session");
|
||||||
|
markSessionText(sessions, "ses_completed", "SESSION_B_ONLY", "SESSION_B_ONLY completed target session");
|
||||||
|
}
|
||||||
if (id === "session-rail-many") {
|
if (id === "session-rail-many") {
|
||||||
for (let index = 0; index < 46; index += 1) sessions.push(manyRailSession(index));
|
for (let index = 0; index < 46; index += 1) sessions.push(manyRailSession(index));
|
||||||
}
|
}
|
||||||
@@ -285,7 +289,7 @@ function createScenarioState(scenarioId: string): ScenarioState {
|
|||||||
chatRequests: [],
|
chatRequests: [],
|
||||||
listOmitSelected: id === "selected-missing-from-list",
|
listOmitSelected: id === "selected-missing-from-list",
|
||||||
sessionDelayMs: id === "loading" ? 2_500 : 0,
|
sessionDelayMs: id === "loading" ? 2_500 : 0,
|
||||||
sessionDetailDelayMs: id === "legacy-cnv-deeplink-canonical" ? 1_500 : 0,
|
sessionDetailDelayMs: id === "legacy-cnv-deeplink-canonical" ? 1_500 : id === "session-switch-delayed-detail-frame" ? 900 : 0,
|
||||||
chatDelayMs: id === "submit-authority-race" ? 1_000 : 0,
|
chatDelayMs: id === "submit-authority-race" ? 1_000 : 0,
|
||||||
terminalScript: id === "event-replay" || id === "running-to-terminal" || id === "stale-submit-restore",
|
terminalScript: id === "event-replay" || id === "running-to-terminal" || id === "stale-submit-restore",
|
||||||
terminalFailureScript: id === "stale-submit-restore",
|
terminalFailureScript: id === "stale-submit-restore",
|
||||||
@@ -295,6 +299,17 @@ function createScenarioState(scenarioId: string): ScenarioState {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function markSessionText(sessions: SessionRecord[], sessionId: string, userText: string, agentText: string): void {
|
||||||
|
const session = sessions.find((item) => item.sessionId === sessionId);
|
||||||
|
if (!session) return;
|
||||||
|
session.firstUserMessagePreview = userText;
|
||||||
|
session.messages = (session.messages ?? []).map((message) => {
|
||||||
|
if (message.role === "user") return { ...message, text: userText };
|
||||||
|
if (message.role === "agent") return { ...message, text: agentText };
|
||||||
|
return message;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function createManualSession(body: JsonRecord): SessionRecord {
|
function createManualSession(body: JsonRecord): SessionRecord {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const token = state.scenarioId === "server-authoritative-create" ? "server_created" : Date.now().toString(36);
|
const token = state.scenarioId === "server-authoritative-create" ? "server_created" : Date.now().toString(36);
|
||||||
|
|||||||
@@ -75,14 +75,18 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
|||||||
async function hydrate(options: HydrateOptions = {}): Promise<void> {
|
async function hydrate(options: HydrateOptions = {}): Promise<void> {
|
||||||
const routeRequestId = normalizeWorkbenchSessionRouteId(options.sessionId);
|
const routeRequestId = normalizeWorkbenchSessionRouteId(options.sessionId);
|
||||||
const routeSessionId = normalizeWorkbenchSessionId(options.sessionId);
|
const routeSessionId = normalizeWorkbenchSessionId(options.sessionId);
|
||||||
if (routeSessionId) setActiveSessionSelection(routeSessionId);
|
|
||||||
const requestEpoch = beginSessionSelection();
|
const requestEpoch = beginSessionSelection();
|
||||||
sessionsReady.value = sessions.value.length > 0;
|
sessionsReady.value = sessions.value.length > 0;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
const includeSessionId = routeSessionId ?? activeSessionId.value;
|
const includeSessionId = routeSessionId ?? activeSessionId.value;
|
||||||
|
if (routeSessionId) {
|
||||||
|
sessionDetailLoadingId.value = routeSessionId;
|
||||||
|
setActiveSessionSelection(routeSessionId);
|
||||||
|
}
|
||||||
const sessionsResult = await api.workbench.sessions({ includeSessionId });
|
const sessionsResult = await api.workbench.sessions({ includeSessionId });
|
||||||
if (!sessionsResult.ok) {
|
if (!sessionsResult.ok) {
|
||||||
|
clearSessionDetailLoading(includeSessionId);
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
error.value = sessionsResult.error ?? "session list unavailable";
|
error.value = sessionsResult.error ?? "session list unavailable";
|
||||||
return;
|
return;
|
||||||
@@ -93,9 +97,9 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
|||||||
setActiveSessionSelection(targetSessionId);
|
setActiveSessionSelection(targetSessionId);
|
||||||
}
|
}
|
||||||
if (targetSessionId) sessionDetailLoadingId.value = targetSessionId;
|
if (targetSessionId) sessionDetailLoadingId.value = targetSessionId;
|
||||||
|
const previousMessages = messages.value;
|
||||||
const selected = targetSessionId ? await loadWorkbenchSession(targetSessionId, listedSessions.find((item) => item.sessionId === targetSessionId) ?? null) : null;
|
const selected = targetSessionId ? await loadWorkbenchSession(targetSessionId, listedSessions.find((item) => item.sessionId === targetSessionId) ?? null) : null;
|
||||||
clearSessionDetailLoading(targetSessionId);
|
if (selected && !isArchivedSession(selected) && isCurrentSessionSelection(requestEpoch, selected.sessionId)) applySelectedSessionDetail(selected, previousMessages);
|
||||||
if (selected && !isArchivedSession(selected) && isCurrentSessionSelection(requestEpoch, selected.sessionId)) applySelectedSessionDetail(selected, messages.value);
|
|
||||||
if (selected && !isArchivedSession(selected) && routeRequestId && !isCurrentSessionSelection(requestEpoch, selected.sessionId)) rememberSessionDetail(selected);
|
if (selected && !isArchivedSession(selected) && routeRequestId && !isCurrentSessionSelection(requestEpoch, selected.sessionId)) rememberSessionDetail(selected);
|
||||||
if (selected && isArchivedSession(selected)) isolateSessionLoadFailure(selected.sessionId, null, [], "session archived", { restorePrevious: false });
|
if (selected && isArchivedSession(selected)) isolateSessionLoadFailure(selected.sessionId, null, [], "session archived", { restorePrevious: false });
|
||||||
if (options.invalidRouteId || (targetSessionId && !selected)) isolateSessionLoadFailure(targetSessionId ?? options.invalidRouteId ?? "invalid-session", null, [], "session URL not found", { restorePrevious: false });
|
if (options.invalidRouteId || (targetSessionId && !selected)) isolateSessionLoadFailure(targetSessionId ?? options.invalidRouteId ?? "invalid-session", null, [], "session URL not found", { restorePrevious: false });
|
||||||
@@ -104,6 +108,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
|||||||
rememberSessionList(nextSessions);
|
rememberSessionList(nextSessions);
|
||||||
sessionsReady.value = true;
|
sessionsReady.value = true;
|
||||||
void refreshSessionStatusAuthority(nextSessions);
|
void refreshSessionStatusAuthority(nextSessions);
|
||||||
|
clearSessionDetailLoading(targetSessionId);
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
await refreshProviderOptions();
|
await refreshProviderOptions();
|
||||||
restartRealtime("hydrate");
|
restartRealtime("hydrate");
|
||||||
@@ -156,27 +161,30 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
|||||||
const requestEpoch = beginSessionSelection();
|
const requestEpoch = beginSessionSelection();
|
||||||
startWorkbenchSessionSwitch({ sessionId: normalized ?? requestId, source: existing ? "rail" : "deeplink", targetState: existing?.status ?? "unknown", cache: (existing?.messages?.length ?? 0) > 0 ? "warm" : "cold" });
|
startWorkbenchSessionSwitch({ sessionId: normalized ?? requestId, source: existing ? "rail" : "deeplink", targetState: existing?.status ?? "unknown", cache: (existing?.messages?.length ?? 0) > 0 ? "warm" : "cold" });
|
||||||
switchingSessionId.value = requestId;
|
switchingSessionId.value = requestId;
|
||||||
if (normalized) setActiveSessionSelection(normalized);
|
|
||||||
sessionDetailLoadingId.value = requestId;
|
sessionDetailLoadingId.value = requestId;
|
||||||
|
loading.value = true;
|
||||||
|
if (normalized) setActiveSessionSelection(normalized);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
sessions.value = mergeSessionIntoList(sessions.value, existing);
|
sessions.value = mergeSessionIntoList(sessions.value, existing);
|
||||||
rememberSessionDetail(existing);
|
rememberSessionDetail(existing);
|
||||||
sessionsReady.value = true;
|
sessionsReady.value = true;
|
||||||
}
|
}
|
||||||
loading.value = true;
|
|
||||||
const selected = await loadWorkbenchSession(requestId, existing);
|
const selected = await loadWorkbenchSession(requestId, existing);
|
||||||
loading.value = false;
|
|
||||||
clearSessionDetailLoading(requestId);
|
|
||||||
clearSwitchingSession(requestId);
|
|
||||||
if (!isCurrentSessionRequest(requestEpoch, requestId, selected?.sessionId ?? normalized)) return true;
|
if (!isCurrentSessionRequest(requestEpoch, requestId, selected?.sessionId ?? normalized)) return true;
|
||||||
if (selected && !isArchivedSession(selected)) {
|
if (selected && !isArchivedSession(selected)) {
|
||||||
applySelectedSessionDetail(selected, previousMessages);
|
applySelectedSessionDetail(selected, previousMessages);
|
||||||
|
clearSessionDetailLoading(requestId);
|
||||||
|
clearSwitchingSession(requestId);
|
||||||
|
loading.value = false;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
await refreshSessions(selected.sessionId);
|
await refreshSessions(selected.sessionId);
|
||||||
finishWorkbenchSessionSwitchFullLoad(selected.sessionId, "ok");
|
finishWorkbenchSessionSwitchFullLoad(selected.sessionId, "ok");
|
||||||
return activeSessionId.value === selected.sessionId;
|
return activeSessionId.value === selected.sessionId;
|
||||||
}
|
}
|
||||||
isolateSessionLoadFailure(requestId, previousSessionId, previousMessages, isArchivedSession(selected) ? "session archived" : "session URL not found", { restorePrevious: Boolean(seed && normalized) });
|
isolateSessionLoadFailure(requestId, previousSessionId, previousMessages, isArchivedSession(selected) ? "session archived" : "session URL not found", { restorePrevious: Boolean(seed && normalized) });
|
||||||
|
clearSessionDetailLoading(requestId);
|
||||||
|
clearSwitchingSession(requestId);
|
||||||
|
loading.value = false;
|
||||||
failWorkbenchSessionSwitch(normalized ?? requestId);
|
failWorkbenchSessionSwitch(normalized ?? requestId);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,21 @@
|
|||||||
import { expect, fakeServerState, gotoWorkbench, saveScreenshot, test } from "../fixtures/test";
|
import { expect, fakeServerState, gotoWorkbench, saveScreenshot, test } from "../fixtures/test";
|
||||||
import { selectors, sessionTab } from "../fixtures/selectors";
|
import { selectors, sessionTab } from "../fixtures/selectors";
|
||||||
|
|
||||||
|
type FrameSample = {
|
||||||
|
activeSessionId: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
hasA: boolean;
|
||||||
|
hasB: boolean;
|
||||||
|
textPreview: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__issue1536Sampling?: boolean;
|
||||||
|
__issue1536Samples?: FrameSample[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
test("session switch persists active session after reload", async ({ page }, testInfo) => {
|
test("session switch persists active session after reload", async ({ page }, testInfo) => {
|
||||||
await gotoWorkbench(page);
|
await gotoWorkbench(page);
|
||||||
await expect(page.locator(sessionTab("ses_running"))).toHaveAttribute("data-active", "true");
|
await expect(page.locator(sessionTab("ses_running"))).toHaveAttribute("data-active", "true");
|
||||||
@@ -20,6 +35,66 @@ test("session switch persists active session after reload", async ({ page }, tes
|
|||||||
await saveScreenshot(page, testInfo, "session-switch-reload");
|
await saveScreenshot(page, testInfo, "session-switch-reload");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe("delayed session detail projection", () => {
|
||||||
|
test.use({ scenarioId: "session-switch-delayed-detail-frame" });
|
||||||
|
|
||||||
|
test("loading exit never flashes previous session messages under target session", async ({ page }, testInfo) => {
|
||||||
|
await gotoWorkbench(page, "/workbench/sessions/ses_running");
|
||||||
|
await expect(page.locator(sessionTab("ses_running"))).toHaveAttribute("data-active", "true");
|
||||||
|
await expect(page.locator(selectors.conversationList)).toContainText("SESSION_A_ONLY");
|
||||||
|
await saveScreenshot(page, testInfo, "session-switch-delayed-before");
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const samples: FrameSample[] = [];
|
||||||
|
const read = (): void => {
|
||||||
|
const active = document.querySelector('.session-tab[data-active="true"]');
|
||||||
|
const panel = document.querySelector("#conversation-list");
|
||||||
|
const text = panel?.textContent ?? "";
|
||||||
|
samples.push({
|
||||||
|
activeSessionId: active?.getAttribute("data-session-id") ?? null,
|
||||||
|
loading: Boolean(document.querySelector("#conversation-list .conversation-detail-loading")),
|
||||||
|
hasA: text.includes("SESSION_A_ONLY"),
|
||||||
|
hasB: text.includes("SESSION_B_ONLY"),
|
||||||
|
textPreview: text.replace(/\s+/gu, " ").trim().slice(0, 160)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const loop = (): void => {
|
||||||
|
read();
|
||||||
|
if (window.__issue1536Sampling === true) requestAnimationFrame(loop);
|
||||||
|
};
|
||||||
|
window.__issue1536Samples = samples;
|
||||||
|
window.__issue1536Sampling = true;
|
||||||
|
requestAnimationFrame(loop);
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.locator(sessionTab("ses_completed")).click();
|
||||||
|
await expect(page.locator(sessionTab("ses_completed"))).toHaveAttribute("data-active", "true");
|
||||||
|
await expect(page.locator("#conversation-list .conversation-detail-loading")).toBeVisible();
|
||||||
|
await saveScreenshot(page, testInfo, "session-switch-delayed-loading");
|
||||||
|
|
||||||
|
await expect(page.locator("#conversation-list .conversation-detail-loading")).toHaveCount(0);
|
||||||
|
await saveScreenshot(page, testInfo, "session-switch-delayed-first-non-loading");
|
||||||
|
await expect(page.locator(selectors.conversationList)).toContainText("SESSION_B_ONLY");
|
||||||
|
await saveScreenshot(page, testInfo, "session-switch-delayed-final");
|
||||||
|
|
||||||
|
const samples = await page.evaluate(() => {
|
||||||
|
window.__issue1536Sampling = false;
|
||||||
|
return window.__issue1536Samples ?? [];
|
||||||
|
}) as FrameSample[];
|
||||||
|
await testInfo.attach("session-switch-delayed-frame-samples.json", {
|
||||||
|
body: JSON.stringify(samples, null, 2),
|
||||||
|
contentType: "application/json"
|
||||||
|
});
|
||||||
|
const targetFrames = samples.filter((sample) => sample.activeSessionId === "ses_completed");
|
||||||
|
const leakedFrames = targetFrames.filter((sample) => sample.hasA);
|
||||||
|
const firstNonLoadingTarget = targetFrames.find((sample) => !sample.loading);
|
||||||
|
|
||||||
|
expect(leakedFrames, JSON.stringify(leakedFrames.slice(0, 5), null, 2)).toEqual([]);
|
||||||
|
expect(firstNonLoadingTarget?.hasB, JSON.stringify(firstNonLoadingTarget ?? null, null, 2)).toBe(true);
|
||||||
|
expect(samples.filter((sample) => sample.hasA && sample.hasB), "old and target messages must never share one frame").toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test.describe("empty session switch target", () => {
|
test.describe("empty session switch target", () => {
|
||||||
test.use({ scenarioId: "session-switch-empty-reload" });
|
test.use({ scenarioId: "session-switch-empty-reload" });
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user