Merge pull request #1584 from pikasTech/fix/1582-session-create-authority
fix: 新建 session 后立即接管 Workbench active
This commit is contained in:
@@ -152,7 +152,7 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
|
||||
const session = createManualSession(body);
|
||||
state.selectedSessionId = session.sessionId;
|
||||
state.sessions.unshift(session);
|
||||
return json(response, 200, { ok: true, status: "ok", session });
|
||||
return json(response, state.scenarioId === "create-while-route-loading" ? 201 : 200, { ok: true, status: "ok", session });
|
||||
}
|
||||
|
||||
const agentSessionMatch = path.match(/^\/v1\/agent\/sessions\/([^/]+)$/u);
|
||||
@@ -305,7 +305,9 @@ function createScenarioState(scenarioId: string): ScenarioState {
|
||||
? "ses_terminal_empty"
|
||||
: id === "progress-only-final-response" || id === "terminal-completed-no-final-response" || id === "tool-completed-projection-running"
|
||||
? "ses_running"
|
||||
: base.selectedSessionId;
|
||||
: id === "create-while-route-loading"
|
||||
? "ses_running"
|
||||
: base.selectedSessionId;
|
||||
const staleTraceId = id === "stale-nested-trace" || id === "stale-submit-restore" ? "trc_stale_502" : null;
|
||||
return {
|
||||
scenarioId: id,
|
||||
@@ -318,7 +320,7 @@ 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 : id === "session-switch-delayed-detail-frame" ? 900 : 0,
|
||||
sessionDetailDelayMs: id === "legacy-cnv-deeplink-canonical" ? 1_500 : id === "session-switch-delayed-detail-frame" ? 900 : 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" || id === "progress-only-final-response" || id === "terminal-completed-no-final-response",
|
||||
terminalFailureScript: id === "stale-submit-restore",
|
||||
@@ -511,7 +513,11 @@ function appendScrollFollowEvents(count: number): JsonRecord {
|
||||
|
||||
function createManualSession(body: JsonRecord): SessionRecord {
|
||||
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"
|
||||
: state.scenarioId === "create-while-route-loading"
|
||||
? "created_while_running"
|
||||
: Date.now().toString(36);
|
||||
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;
|
||||
return { sessionId, threadId, status: "active", startedAt: now, updatedAt: now, messageCount: 0, firstUserMessagePreview: null, messages: [] };
|
||||
@@ -692,6 +698,7 @@ function crossSessionLateTraceA(): JsonRecord {
|
||||
|
||||
function sessionDetailDelayMs(sessionId: string): number {
|
||||
if (state.scenarioId === "cross-session-late-events" && sessionId === "ses_late_A") return 900;
|
||||
if (state.scenarioId === "create-while-route-loading" && (sessionId === "ses_running" || sessionId === "ses_created_while_running")) return 4_000;
|
||||
return state.sessionDetailDelayMs;
|
||||
}
|
||||
|
||||
@@ -837,7 +844,10 @@ function workbenchSessionListPayload(url: URL): JsonRecord {
|
||||
const limit = boundedPageLimit(url.searchParams.get("limit"));
|
||||
const offset = cursorOffset(url.searchParams.get("cursor") || url.searchParams.get("after"));
|
||||
const visible = state.sessions.filter((item) => !item.hidden && (item.status !== "archived" || item.sessionId === includeSessionId));
|
||||
const listed = state.listOmitSelected && includeSessionId ? visible.filter((item) => item.sessionId !== includeSessionId) : visible;
|
||||
const authorityVisible = state.scenarioId === "create-while-route-loading" && includeSessionId === "ses_running"
|
||||
? visible.filter((item) => item.sessionId !== "ses_created_while_running")
|
||||
: visible;
|
||||
const listed = state.listOmitSelected && includeSessionId ? authorityVisible.filter((item) => item.sessionId !== includeSessionId) : authorityVisible;
|
||||
const page = listed.slice(offset, offset + limit);
|
||||
const included = includeSessionId && !page.some((session) => session.sessionId === includeSessionId) ? listed.find((session) => session.sessionId === includeSessionId) ?? null : null;
|
||||
const summaries = [...(included ? [included] : []), ...page].map((session) => sessionSummary(session));
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface WorkbenchNavigationContext {
|
||||
routeSection?: unknown;
|
||||
routeSessionId?: string | null;
|
||||
sessionId?: string | null;
|
||||
activeSelectionSource?: unknown;
|
||||
applyingRouteSession?: boolean;
|
||||
}
|
||||
|
||||
@@ -26,7 +27,7 @@ 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 && !isCanonicalRoutePair(input.routeSessionId, input.sessionId)) return false;
|
||||
if (input.applyingRouteSession && input.routeSessionId && input.routeSessionId !== input.sessionId && input.activeSelectionSource !== "user" && !isCanonicalRoutePair(input.routeSessionId, input.sessionId)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,12 @@ interface HydrateOptions {
|
||||
invalidRouteId?: string | null;
|
||||
}
|
||||
|
||||
type SessionSelectionSource = "route" | "user" | "system";
|
||||
|
||||
interface SelectSessionOptions {
|
||||
source?: SessionSelectionSource;
|
||||
}
|
||||
|
||||
export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
const providerProfile = ref<ProviderProfile>(readString("hwlab.workbench.providerProfile.v1", "codex"));
|
||||
const providerOptions = ref<ProviderProfileOption[]>(defaultProviderProfileOptions(providerProfile.value));
|
||||
@@ -48,6 +54,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
const activityRef = ref({ lastActivityAt: Date.now(), lastActivityIso: new Date().toISOString(), waitingFor: "idle", lastEventLabel: null as string | null });
|
||||
const currentRequest = ref<{ traceId: string; sessionId: string | null; threadId: string | null; status?: string | null } | null>(null);
|
||||
const explicitSessionId = ref<string | null>(initialWorkbenchSessionIdFromLocation());
|
||||
const activeSelectionSource = ref<SessionSelectionSource>(explicitSessionId.value ? "route" : "system");
|
||||
const selectionEpoch = ref(0);
|
||||
const serverState = ref(createWorkbenchServerState());
|
||||
const sessions = computed(() => selectSessionList(serverState.value));
|
||||
@@ -63,6 +70,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
let activeTurnGapTimer: number | null = null;
|
||||
|
||||
const activeSession = computed(() => selectActiveSession(serverState.value, explicitSessionId.value));
|
||||
const activeSessionSelectionSource = computed(() => activeSelectionSource.value);
|
||||
const selectedSessionId = computed(() => firstNonEmptyString(activeSession.value?.sessionId, explicitSessionId.value));
|
||||
const activeSessionId = computed(() => selectedSessionId.value);
|
||||
const activeMessages = computed(() => selectActiveMessages(serverState.value, activeSessionId.value));
|
||||
@@ -89,7 +97,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
const includeSessionId = routeSessionId ?? activeSessionId.value;
|
||||
if (routeSessionId) {
|
||||
sessionDetailLoadingId.value = routeSessionId;
|
||||
setActiveSessionSelection(routeSessionId);
|
||||
setActiveSessionSelection(routeSessionId, "route");
|
||||
}
|
||||
const sessionsResult = await api.workbench.sessions({ includeSessionId, limit: SESSION_LIST_PAGE_LIMIT });
|
||||
if (!sessionsResult.ok) {
|
||||
@@ -102,16 +110,18 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
const listedSessions = workbenchSessionsFromPayload(sessionsResult.data);
|
||||
const targetSessionId = routeRequestId ?? includeSessionId ?? activeSessionId.value ?? listedSessions[0]?.sessionId ?? null;
|
||||
if (targetSessionId && (routeSessionId || !routeRequestId)) {
|
||||
setActiveSessionSelection(targetSessionId);
|
||||
setActiveSessionSelection(targetSessionId, routeSessionId ? "route" : "system");
|
||||
}
|
||||
if (targetSessionId) sessionDetailLoadingId.value = targetSessionId;
|
||||
const selected = targetSessionId ? await loadWorkbenchSession(targetSessionId, listedSessions.find((item) => item.sessionId === targetSessionId) ?? null) : null;
|
||||
if (selected && !isArchivedSession(selected) && routeRequestId && !routeSessionId && requestEpoch === selectionEpoch.value) setActiveSessionSelection(selected.sessionId);
|
||||
if (selected && !isArchivedSession(selected) && isCurrentSessionSelection(requestEpoch, selected.sessionId)) applySelectedSessionDetail(selected);
|
||||
if (selected && !isArchivedSession(selected) && routeRequestId && !isCurrentSessionSelection(requestEpoch, selected.sessionId)) rememberSessionDetail(selected);
|
||||
if (selected && !isArchivedSession(selected) && routeRequestId && !routeSessionId && requestEpoch === selectionEpoch.value) setActiveSessionSelection(selected.sessionId, "route");
|
||||
const selectedIsCurrent = Boolean(selected && !isArchivedSession(selected) && isCurrentSessionSelection(requestEpoch, selected.sessionId));
|
||||
if (selectedIsCurrent && selected) applySelectedSessionDetail(selected, "route");
|
||||
if (selected && !isArchivedSession(selected) && routeRequestId && !isCurrentSessionSelection(requestEpoch, selected.sessionId) && selected.sessionId !== activeSessionId.value) rememberSessionDetail(selected);
|
||||
if (selected && isArchivedSession(selected)) isolateSessionLoadFailure(selected.sessionId, "session archived");
|
||||
if (options.invalidRouteId || (targetSessionId && !selected)) isolateSessionLoadFailure(targetSessionId ?? options.invalidRouteId ?? "invalid-session", "session URL not found");
|
||||
const nextSessions = stableSessionList(sessions.value, listedSessions, selected?.sessionId ?? routeSessionId ?? includeSessionId, selected);
|
||||
const stableSelected = selectedIsCurrent && selected ? selected : activeSessionListSeed();
|
||||
const nextSessions = stableSessionList(sessions.value, listedSessions, stableSelected?.sessionId ?? selected?.sessionId ?? routeSessionId ?? includeSessionId, stableSelected);
|
||||
rememberSessionList(nextSessions);
|
||||
sessionsReady.value = true;
|
||||
void refreshSessionStatusAuthority(nextSessions);
|
||||
@@ -146,15 +156,16 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
}
|
||||
rememberSessionList(mergeSessionIntoList(sessions.value, created));
|
||||
sessionsReady.value = true;
|
||||
setActiveSessionSelection(created.sessionId);
|
||||
setActiveSessionSelection(created.sessionId, "user");
|
||||
await selectSession(created);
|
||||
}
|
||||
|
||||
async function selectSession(session: WorkbenchSessionRecord): Promise<void> {
|
||||
await selectSessionById(session.sessionId, session);
|
||||
async function selectSession(session: WorkbenchSessionRecord, options: SelectSessionOptions = {}): Promise<void> {
|
||||
await selectSessionById(session.sessionId, session, options);
|
||||
}
|
||||
|
||||
async function selectSessionById(sessionId: string, seed: WorkbenchSessionRecord | null = null): Promise<boolean> {
|
||||
async function selectSessionById(sessionId: string, seed: WorkbenchSessionRecord | null = null, options: SelectSessionOptions = {}): Promise<boolean> {
|
||||
const source = options.source ?? "user";
|
||||
const requestId = normalizeWorkbenchSessionRouteId(sessionId);
|
||||
const normalized = normalizeWorkbenchSessionId(sessionId);
|
||||
if (!requestId) {
|
||||
@@ -167,20 +178,20 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
switchingSessionId.value = requestId;
|
||||
sessionDetailLoadingId.value = requestId;
|
||||
loading.value = true;
|
||||
if (normalized) setActiveSessionSelection(normalized);
|
||||
if (normalized) setActiveSessionSelection(normalized, source);
|
||||
if (existing) {
|
||||
rememberSessionList(mergeSessionIntoList(sessions.value, existing));
|
||||
sessionsReady.value = true;
|
||||
}
|
||||
const selected = await loadWorkbenchSession(requestId, existing);
|
||||
if (selected && !isArchivedSession(selected) && !normalized && requestEpoch === selectionEpoch.value) setActiveSessionSelection(selected.sessionId);
|
||||
if (selected && !isArchivedSession(selected) && !normalized && requestEpoch === selectionEpoch.value) setActiveSessionSelection(selected.sessionId, source);
|
||||
if (!isCurrentSessionRequest(requestEpoch, requestId, selected?.sessionId ?? normalized)) {
|
||||
clearSessionDetailLoading(requestId);
|
||||
clearSwitchingSession(requestId);
|
||||
return true;
|
||||
}
|
||||
if (selected && !isArchivedSession(selected)) {
|
||||
applySelectedSessionDetail(selected);
|
||||
applySelectedSessionDetail(selected, source);
|
||||
await nextTick();
|
||||
loading.value = false;
|
||||
clearSessionDetailLoading(requestId);
|
||||
@@ -213,7 +224,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
replaceActiveSessionSelection(next?.sessionId ?? null);
|
||||
currentRequest.value = null;
|
||||
if (next) {
|
||||
await selectSession(next);
|
||||
await selectSession(next, { source: "system" });
|
||||
return;
|
||||
}
|
||||
restartRealtime("delete-current-session");
|
||||
@@ -223,7 +234,8 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
const response = await api.workbench.sessions({ includeSessionId, limit: currentSessionListLimit() });
|
||||
if (response.ok) {
|
||||
const listed = workbenchSessionsFromPayload(response.data);
|
||||
const refreshed = stableSessionList(sessions.value, listed, includeSessionId, activeSession.value);
|
||||
const activeSeed = activeSessionListSeed();
|
||||
const refreshed = stableSessionList(sessions.value, listed, activeSeed?.sessionId ?? includeSessionId, activeSeed);
|
||||
const next = sessions.value.length > refreshed.length ? appendSessionPage(sessions.value, refreshed) : refreshed;
|
||||
rememberSessionList(next);
|
||||
applySessionPagination(response.data);
|
||||
@@ -300,12 +312,20 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
replaceActiveMessages([...messages.value, ...items]);
|
||||
}
|
||||
|
||||
function setActiveSessionSelection(sessionId: string | null | undefined): void {
|
||||
explicitSessionId.value = normalizeWorkbenchSessionId(sessionId) ?? explicitSessionId.value;
|
||||
function setActiveSessionSelection(sessionId: string | null | undefined, source: SessionSelectionSource = "system"): void {
|
||||
const normalized = normalizeWorkbenchSessionId(sessionId);
|
||||
if (!normalized) return;
|
||||
explicitSessionId.value = normalized;
|
||||
activeSelectionSource.value = source;
|
||||
}
|
||||
|
||||
function replaceActiveSessionSelection(sessionId: string | null | undefined): void {
|
||||
function replaceActiveSessionSelection(sessionId: string | null | undefined, source: SessionSelectionSource = "system"): void {
|
||||
explicitSessionId.value = normalizeWorkbenchSessionId(sessionId);
|
||||
activeSelectionSource.value = source;
|
||||
}
|
||||
|
||||
function activeSessionListSeed(): WorkbenchSessionRecord | null {
|
||||
return activeSession.value ?? null;
|
||||
}
|
||||
|
||||
async function refreshSessionStatusAuthority(source: WorkbenchSessionRecord[] = sessions.value): Promise<void> {
|
||||
@@ -987,8 +1007,8 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
if (!sessionId || sessionDetailLoadingId.value === sessionId) sessionDetailLoadingId.value = null;
|
||||
}
|
||||
|
||||
function applySelectedSessionDetail(session: WorkbenchSessionRecord): void {
|
||||
setActiveSessionSelection(session.sessionId);
|
||||
function applySelectedSessionDetail(session: WorkbenchSessionRecord, source: SessionSelectionSource = "system"): void {
|
||||
setActiveSessionSelection(session.sessionId, source);
|
||||
const projectedMessages = messagesWithTraceAuthority(messagesFromSession(session));
|
||||
rememberSessionDetail({ ...session, messages: projectedMessages, messageCount: session.messageCount ?? projectedMessages.length });
|
||||
sessionsReady.value = true;
|
||||
@@ -1017,7 +1037,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
|
||||
installRealtimeVisibilityHandler();
|
||||
|
||||
return { sessions, messages, activeMessages, providerProfile, providerOptions, recentDrafts, codeAgentTimeoutMs, gatewayShellTimeoutMs, live, loading, sessionListLoading, sessionDetailLoading, sessionListLoadedCount, sessionListHasMore, sessionListNextCursor, sessionListLoadingMore, sessionListLoadMoreError, chatPending, error, activeSessionId, selectedSessionId, selectedThreadId, sessionTabs, composer, hydrate, refreshLive, createSession, selectSession, selectSessionById, deleteCurrentSession, refreshSessions, loadMoreSessions, submitMessage, cancelAgentMessage, cancelRunningTrace, retryAgentMessage, setProviderProfile, refreshProviderOptions, pickDraft, clearRecentDrafts, clearSessionMessages, recordActivity };
|
||||
return { sessions, messages, activeMessages, providerProfile, providerOptions, recentDrafts, codeAgentTimeoutMs, gatewayShellTimeoutMs, live, loading, sessionListLoading, sessionDetailLoading, sessionListLoadedCount, sessionListHasMore, sessionListNextCursor, sessionListLoadingMore, sessionListLoadMoreError, chatPending, error, activeSessionId, activeSessionSelectionSource, selectedSessionId, selectedThreadId, sessionTabs, composer, hydrate, refreshLive, createSession, selectSession, selectSessionById, deleteCurrentSession, refreshSessions, loadMoreSessions, submitMessage, cancelAgentMessage, cancelRunningTrace, retryAgentMessage, setProviderProfile, refreshProviderOptions, pickDraft, clearRecentDrafts, clearSessionMessages, recordActivity };
|
||||
});
|
||||
|
||||
function workbenchSessionsFromPayload(payload: unknown): WorkbenchSessionRecord[] {
|
||||
|
||||
@@ -50,7 +50,7 @@ async function applyRouteSession(): Promise<boolean> {
|
||||
applyingRouteSession.value = true;
|
||||
let selected = false;
|
||||
try {
|
||||
selected = sessionId ? await workbench.selectSessionById(sessionId) : await workbench.selectSessionById(rawRouteSessionId.value);
|
||||
selected = sessionId ? await workbench.selectSessionById(sessionId, null, { source: "route" }) : await workbench.selectSessionById(rawRouteSessionId.value, null, { source: "route" });
|
||||
return selected;
|
||||
} finally {
|
||||
applyingRouteSession.value = false;
|
||||
@@ -68,6 +68,7 @@ async function reflectActiveSessionInUrl(value: string | null): Promise<void> {
|
||||
routeSection: route.meta.section,
|
||||
routeSessionId: routeTarget,
|
||||
sessionId,
|
||||
activeSelectionSource: workbench.activeSessionSelectionSource,
|
||||
applyingRouteSession: applyingRouteSession.value
|
||||
})) return;
|
||||
await router.replace({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, fakeServerState, gotoWorkbench, test } from "../fixtures/test";
|
||||
import { selectors } from "../fixtures/selectors";
|
||||
import { selectors, sessionTab } from "../fixtures/selectors";
|
||||
|
||||
test("new optimistic session first turn does not submit local thr_* as upstream thread", async ({ page }) => {
|
||||
await gotoWorkbench(page);
|
||||
@@ -51,6 +51,27 @@ test.describe("server authoritative session create", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("session create while old route is still loading", () => {
|
||||
test.use({ scenarioId: "create-while-route-loading" });
|
||||
|
||||
test("created session owns URL, active tab, and composer while old running session refreshes", async ({ page }) => {
|
||||
await gotoWorkbench(page, "/workbench/sessions/ses_running");
|
||||
|
||||
const created = page.waitForResponse((response) => response.request().method() === "POST" && new URL(response.url()).pathname === "/v1/agent/sessions");
|
||||
await page.locator(selectors.sessionCreate).click();
|
||||
expect((await created).status()).toBe(201);
|
||||
|
||||
await expect(page).toHaveURL(/\/workbench\/sessions\/ses_created_while_running/u, { timeout: 500 });
|
||||
await expect(page.locator(sessionTab("ses_created_while_running"))).toHaveAttribute("data-active", "true");
|
||||
await expect(page.locator(sessionTab("ses_running"))).toHaveAttribute("data-active", "false");
|
||||
await expect(page.locator(sessionTab("ses_running"))).toHaveAttribute("data-running", "true");
|
||||
await expect(page.locator(selectors.commandSend)).toHaveAttribute("data-action", "turn");
|
||||
|
||||
const state = await fakeServerState(page);
|
||||
expect((state.legacyRequestLedger as unknown[]).length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("session create and delete authority", () => {
|
||||
test.use({ scenarioId: "server-authoritative-create" });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user