diff --git a/src/gateway/server.chat.gateway-server-chat-b.test.ts b/src/gateway/server.chat.gateway-server-chat-b.test.ts index 48343c6ff58..1adebd209b6 100644 --- a/src/gateway/server.chat.gateway-server-chat-b.test.ts +++ b/src/gateway/server.chat.gateway-server-chat-b.test.ts @@ -288,6 +288,81 @@ describe("gateway server chat", () => { } }); + test("chat.history exposes persisted and synthetic session metadata for startup hydration", async () => { + await withGatewayChatHarness(async ({ ws, createSessionDir }) => { + await connectOk(ws); + const sessionDir = await createSessionDir(); + const updatedAt = Date.now(); + await writeSessionStore({ + entries: { + main: { + sessionId: "sess-main", + updatedAt, + modelProvider: "openai", + model: "gpt-5", + contextTokens: 128_000, + }, + }, + }); + await writeMainSessionTranscript(sessionDir, [ + JSON.stringify({ + message: { + role: "user", + content: [{ type: "text", text: "persisted metadata" }], + timestamp: updatedAt, + }, + }), + ]); + + const persisted = await rpcReq<{ + defaults?: { modelProvider?: string | null; model?: string | null }; + sessionInfo?: { + key?: string; + sessionId?: string; + updatedAt?: number | null; + modelProvider?: string | null; + model?: string | null; + contextTokens?: number | null; + }; + }>(ws, "chat.history", { sessionKey: "main" }); + + expect(persisted.ok).toBe(true); + expect(persisted.payload?.defaults?.modelProvider).toBeTruthy(); + expect(persisted.payload?.defaults?.model).toBeTruthy(); + expect(persisted.payload?.sessionInfo).toMatchObject({ + key: "agent:main:main", + sessionId: "sess-main", + updatedAt, + modelProvider: "openai", + model: "gpt-5", + contextTokens: 128_000, + }); + + await writeSessionStore({ entries: {} }); + const synthetic = await rpcReq<{ + defaults?: { modelProvider?: string | null; model?: string | null }; + sessionInfo?: { + key?: string; + sessionId?: string; + updatedAt?: number | null; + modelProvider?: string | null; + model?: string | null; + contextTokens?: number | null; + }; + }>(ws, "chat.history", { sessionKey: "main" }); + + expect(synthetic.ok).toBe(true); + expect(synthetic.payload?.defaults?.modelProvider).toBeTruthy(); + expect(synthetic.payload?.defaults?.model).toBeTruthy(); + expect(synthetic.payload?.sessionInfo?.key).toBe("agent:main:main"); + expect(synthetic.payload?.sessionInfo?.sessionId).toBeUndefined(); + expect(synthetic.payload?.sessionInfo?.updatedAt).toBeNull(); + expect(synthetic.payload?.sessionInfo?.modelProvider).toBeTruthy(); + expect(synthetic.payload?.sessionInfo?.model).toBeTruthy(); + expect(synthetic.payload?.sessionInfo?.contextTokens).toEqual(expect.any(Number)); + }); + }); + test("chat.send returns in_flight when duplicate attachment send wins parsing race", async () => { const sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-")); const dispatchRelease = createDeferred(); diff --git a/src/routing/session-key.test.ts b/src/routing/session-key.test.ts index 7415a77c6f7..b60d8d9e758 100644 --- a/src/routing/session-key.test.ts +++ b/src/routing/session-key.test.ts @@ -7,6 +7,7 @@ import { resolveThreadParentSessionKey, } from "../sessions/session-key-utils.js"; import { + agentSessionKeysMatchByRequestKey, buildAgentPeerSessionKey, buildGroupHistoryKey, classifySessionKeyShape, @@ -76,6 +77,14 @@ describe("isUnscopedSessionKeySentinel", () => { }); }); +describe("agentSessionKeysMatchByRequestKey", () => { + it("matches canonical agent keys against their request-key aliases", () => { + expect(agentSessionKeysMatchByRequestKey("agent:main:main", "main")).toBe(true); + expect(agentSessionKeysMatchByRequestKey("agent:ops:incident-42", "incident-42")).toBe(true); + expect(agentSessionKeysMatchByRequestKey("agent:ops:incident-42", "main")).toBe(false); + }); +}); + describe("session key backward compatibility", () => { function expectBackwardCompatibleDirectSessionKey(key: string) { expect(classifySessionKeyShape(key)).toBe("agent"); diff --git a/src/routing/session-key.ts b/src/routing/session-key.ts index 79bcdb144e5..1bbfb822efa 100644 --- a/src/routing/session-key.ts +++ b/src/routing/session-key.ts @@ -92,6 +92,20 @@ export function toAgentRequestSessionKey(storeKey: string | undefined | null): s return parseAgentSessionKey(raw)?.rest ?? raw; } +export function agentSessionKeysMatchByRequestKey( + left: string | undefined | null, + right: string | undefined | null, +): boolean { + const leftRaw = (left ?? "").trim(); + const rightRaw = (right ?? "").trim(); + if (!leftRaw || !rightRaw) { + return false; + } + return ( + leftRaw === rightRaw || toAgentRequestSessionKey(leftRaw) === toAgentRequestSessionKey(rightRaw) + ); +} + export function toAgentStoreSessionKey(params: { agentId: string; requestKey: string | undefined | null; diff --git a/src/tui/tui-session-actions.ts b/src/tui/tui-session-actions.ts index 5116c661752..4d5fc021e9a 100644 --- a/src/tui/tui-session-actions.ts +++ b/src/tui/tui-session-actions.ts @@ -3,6 +3,7 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe import type { SessionsPatchResult } from "../../packages/gateway-protocol/src/index.js"; import { resolveSessionInfoModelSelection } from "../agents/model-selection-display.js"; import { + agentSessionKeysMatchByRequestKey, normalizeAgentId, normalizeMainKey, parseAgentSessionKey, @@ -312,15 +313,8 @@ export function createSessionActions(context: SessionActionContext) { includeUnknown: state.currentSessionKey === "unknown", agentId: listAgentId, }); - const normalizeMatchKey = (key: string) => parseAgentSessionKey(key)?.rest ?? key; - const currentMatchKey = normalizeMatchKey(state.currentSessionKey); const entry = result.sessions.find((row) => { - // Exact match - if (row.key === state.currentSessionKey) { - return true; - } - // Also match canonical keys like "agent:default:main" against "main" - return normalizeMatchKey(row.key) === currentMatchKey; + return agentSessionKeysMatchByRequestKey(row.key, state.currentSessionKey); }); if (entry?.key && entry.key !== state.currentSessionKey) { updateAgentFromSessionKey(entry.key); diff --git a/ui/src/ui/app-chat.test.ts b/ui/src/ui/app-chat.test.ts index 16351ba14a0..34db4a1a191 100644 --- a/ui/src/ui/app-chat.test.ts +++ b/ui/src/ui/app-chat.test.ts @@ -1847,6 +1847,43 @@ describe("handleSendChat", () => { expect(host.chatStream).toBeNull(); }); + it("defers queued global send agent selection until defaults are known", async () => { + const request = vi.fn((method: string) => { + if (method === "chat.send") { + return Promise.resolve({ runId: "run-work", status: "started" }); + } + throw new Error(`Unexpected request: ${method}`); + }); + const host = makeHost({ + client: null, + connected: false, + sessionKey: "global", + chatMessage: "send to default later", + }); + + await handleSendChat(host); + + expect(host.chatQueue[0]).toMatchObject({ + text: "send to default later", + sessionKey: "global", + sendState: "waiting-reconnect", + }); + expect(host.chatQueue[0]?.agentId).toBeUndefined(); + + host.agentsList = { defaultId: "work" }; + host.client = { request } as unknown as ChatHost["client"]; + host.connected = true; + await retryReconnectableQueuedChatSends(host); + + const payload = findRequestPayload( + request as unknown as MockCallSource, + "chat.send", + "queued global send payload", + ); + expect(payload.sessionKey).toBe("global"); + expect(payload.agentId).toBe("work"); + }); + it("marks saved session queued sends waiting after a disconnect", () => { const host = makeHost({ chatQueue: [], diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index 37ed8b8248d..576ab94a34c 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -45,8 +45,13 @@ import { normalizeBasePath } from "./navigation.ts"; import { areUiSessionKeysEquivalent, DEFAULT_AGENT_ID, + isUiGlobalSessionKey, normalizeAgentId, parseAgentSessionKey, + resolveUiDefaultAgentId, + resolveUiGlobalAliasAgentId, + resolveUiKnownSelectedGlobalAgentId, + resolveUiSelectedGlobalAgentId, } from "./session-key.ts"; import { isSessionRunActive } from "./session-run-state.ts"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "./string-coerce.ts"; @@ -219,10 +224,6 @@ function isBtwCommand(text: string) { return /^\/(?:btw|side)(?::|\s|$)/i.test(text.trim()); } -function isGlobalSessionKey(sessionKey: string | undefined | null): boolean { - return normalizeLowercaseStringOrEmpty(sessionKey) === "global"; -} - function readHelloDefaultAgentId(host: Pick): string | undefined { const snapshot = host.hello?.snapshot as | { sessionDefaults?: SessionDefaultsSnapshot } @@ -230,50 +231,10 @@ function readHelloDefaultAgentId(host: Pick): string | undefi return snapshot?.sessionDefaults?.defaultAgentId?.trim() || undefined; } -function readHelloMainKey(host: Pick): string | undefined { - const snapshot = host.hello?.snapshot as - | { sessionDefaults?: SessionDefaultsSnapshot } - | undefined; - return snapshot?.sessionDefaults?.mainKey?.trim() || undefined; -} - -function resolveGlobalAliasAgentId( - host: Pick, - sessionKey: string | undefined | null, -): string | undefined { - const parsed = parseAgentSessionKey(sessionKey); - if (!parsed) { - return undefined; - } - const rest = normalizeLowercaseStringOrEmpty(parsed.rest); - const configuredMainKey = normalizeLowercaseStringOrEmpty( - host.agentsList?.mainKey ?? readHelloMainKey(host) ?? "main", - ); - return rest === "global" || rest === "main" || rest === configuredMainKey - ? normalizeAgentId(parsed.agentId) - : undefined; -} - -function resolveSelectedGlobalAgentId( - host: Pick, -): string | undefined { - const agentId = - host.assistantAgentId?.trim() || - host.agentsList?.defaultId?.trim() || - readHelloDefaultAgentId(host); - return agentId ? normalizeAgentId(agentId) : undefined; -} - -function resolveDefaultAgentIdForList(host: Pick): string { - return normalizeAgentId( - host.agentsList?.defaultId ?? readHelloDefaultAgentId(host) ?? DEFAULT_AGENT_ID, - ); -} - function scopedAgentIdForSession(host: ChatHost, sessionKey: string | undefined | null) { - return isGlobalSessionKey(sessionKey) - ? resolveSelectedGlobalAgentId(host) - : resolveGlobalAliasAgentId(host, sessionKey); + return isUiGlobalSessionKey(sessionKey) + ? resolveUiKnownSelectedGlobalAgentId(host) + : (resolveUiGlobalAliasAgentId(host, sessionKey) ?? undefined); } function visibleSessionMatches( @@ -282,8 +243,8 @@ function visibleSessionMatches( agentId: string | undefined, ): boolean { if (host.sessionKey !== sessionKey) { - const hostAliasAgentId = resolveGlobalAliasAgentId(host, host.sessionKey); - if (!hostAliasAgentId || !isGlobalSessionKey(sessionKey)) { + const hostAliasAgentId = resolveUiGlobalAliasAgentId(host, host.sessionKey); + if (!hostAliasAgentId || !isUiGlobalSessionKey(sessionKey)) { return false; } const expectedAgentId = agentId ?? host.agentsList?.defaultId ?? readHelloDefaultAgentId(host); @@ -291,10 +252,10 @@ function visibleSessionMatches( ? hostAliasAgentId === normalizeAgentId(expectedAgentId) : hostAliasAgentId === normalizeAgentId("main"); } - if (!isGlobalSessionKey(sessionKey)) { + if (!isUiGlobalSessionKey(sessionKey)) { return true; } - const selectedAgentId = resolveSelectedGlobalAgentId(host); + const selectedAgentId = resolveUiKnownSelectedGlobalAgentId(host); const expectedAgentId = agentId ?? host.agentsList?.defaultId ?? readHelloDefaultAgentId(host); return expectedAgentId ? selectedAgentId === normalizeAgentId(expectedAgentId) @@ -305,9 +266,9 @@ export function scopedAgentParamsForSession( host: Pick, sessionKey: string, ) { - const agentId = isGlobalSessionKey(sessionKey) - ? resolveSelectedGlobalAgentId(host) - : resolveGlobalAliasAgentId(host, sessionKey); + const agentId = isUiGlobalSessionKey(sessionKey) + ? resolveUiKnownSelectedGlobalAgentId(host) + : resolveUiGlobalAliasAgentId(host, sessionKey); return agentId ? { agentId } : {}; } @@ -320,10 +281,10 @@ export function scopedAgentListParamsForSession( const agentId = parsed?.agentId ?? (normalizedSessionKey === "global" - ? resolveSelectedGlobalAgentId(host) + ? resolveUiKnownSelectedGlobalAgentId(host) : normalizedSessionKey === "unknown" ? undefined - : resolveDefaultAgentIdForList(host)); + : resolveUiDefaultAgentId(host)); return agentId ? { agentId: normalizeAgentId(agentId) } : {}; } @@ -986,8 +947,8 @@ function isHistorySessionInfoForRequestedSession( } return Boolean( historySessionKey && - isGlobalSessionKey(historySessionKey) && - resolveGlobalAliasAgentId(host, requestedSessionKey), + isUiGlobalSessionKey(historySessionKey) && + resolveUiGlobalAliasAgentId(host, requestedSessionKey), ); } @@ -998,8 +959,8 @@ function findSelectedSessionRow( historySessionKey: string | undefined, ): GatewaySessionRow | undefined { const requestedGlobalAgentId = - historySessionKey && isGlobalSessionKey(historySessionKey) - ? resolveGlobalAliasAgentId(host, sessionKey) + historySessionKey && isUiGlobalSessionKey(historySessionKey) + ? resolveUiGlobalAliasAgentId(host, sessionKey) : undefined; return sessionsResult?.sessions.find((session) => { if (areUiSessionKeysEquivalent(session.key, sessionKey)) { @@ -1007,7 +968,7 @@ function findSelectedSessionRow( } return ( requestedGlobalAgentId != null && - resolveGlobalAliasAgentId(host, session.key) === requestedGlobalAgentId + resolveUiGlobalAliasAgentId(host, session.key) === requestedGlobalAgentId ); }); } @@ -1558,8 +1519,8 @@ function resolveAgentIdForSession(host: ChatHost): string | null { if (parsed?.agentId) { return parsed.agentId; } - if (isGlobalSessionKey(host.sessionKey)) { - return resolveSelectedGlobalAgentId(host) || DEFAULT_AGENT_ID; + if (isUiGlobalSessionKey(host.sessionKey)) { + return resolveUiSelectedGlobalAgentId(host) || DEFAULT_AGENT_ID; } return readHelloDefaultAgentId(host) || DEFAULT_AGENT_ID; } diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index aa3ee5091cf..baefb00112c 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -76,8 +76,12 @@ import type { Tab } from "./navigation.ts"; import { areUiSessionKeysEquivalent, buildAgentMainSessionKey, + isUiGlobalSessionKey, normalizeAgentId, parseAgentSessionKey, + resolveUiDefaultAgentId, + resolveUiGlobalAliasAgentId, + resolveUiSelectedGlobalAgentId, } from "./session-key.ts"; import type { UiSettings } from "./storage.ts"; import type { @@ -470,19 +474,8 @@ function resolveMainSessionFallback(host: GatewayHost): string { }); } -function isGlobalSessionKey(sessionKey: string | undefined | null): boolean { - return sessionKey?.trim().toLowerCase() === "global"; -} - function resolveDefaultAgentId(host: GatewayHost): string { - const snapshot = host.hello?.snapshot as - | { sessionDefaults?: SessionDefaultsSnapshot } - | undefined; - return normalizeAgentId( - host.agentsList?.defaultId?.trim() || - snapshot?.sessionDefaults?.defaultAgentId?.trim() || - "main", - ); + return resolveUiDefaultAgentId(host); } function resolveFreshDefaultAgentId(host: GatewayHost): string | undefined { @@ -499,20 +492,7 @@ function resolveFreshDefaultAgentId(host: GatewayHost): string | undefined { } function resolveSelectedGlobalAgentId(host: GatewayHost): string { - return normalizeAgentId(host.assistantAgentId?.trim() || resolveDefaultAgentId(host)); -} - -function resolveGlobalAliasAgentId(host: GatewayHost, sessionKey: string | undefined | null) { - const parsed = parseAgentSessionKey(sessionKey ?? ""); - if (!parsed) { - return undefined; - } - const rest = parsed.rest.trim().toLowerCase(); - const snapshot = host.hello?.snapshot as - | { sessionDefaults?: SessionDefaultsSnapshot } - | undefined; - const mainKey = snapshot?.sessionDefaults?.mainKey?.trim().toLowerCase() || "main"; - return rest === "main" || rest === mainKey ? normalizeAgentId(parsed.agentId) : undefined; + return resolveUiSelectedGlobalAgentId(host); } function resolveSelectedGlobalEventAgentId( @@ -527,12 +507,12 @@ function globalAgentScopeMatches( sessionKey: string | undefined | null, agentId: string | undefined | null, ): boolean { - if (!isGlobalSessionKey(sessionKey)) { + if (!isUiGlobalSessionKey(sessionKey)) { return true; } - const selectedAgentId = isGlobalSessionKey(host.sessionKey) + const selectedAgentId = isUiGlobalSessionKey(host.sessionKey) ? resolveSelectedGlobalAgentId(host) - : resolveGlobalAliasAgentId(host, host.sessionKey); + : resolveUiGlobalAliasAgentId(host, host.sessionKey); if (!selectedAgentId) { return true; } @@ -550,10 +530,10 @@ function sessionMessageMatchesHost( if (areUiSessionKeysEquivalent(sessionKey, host.sessionKey)) { return true; } - const hostAliasAgentId = resolveGlobalAliasAgentId(host, host.sessionKey); + const hostAliasAgentId = resolveUiGlobalAliasAgentId(host, host.sessionKey); return Boolean( hostAliasAgentId && - isGlobalSessionKey(sessionKey) && + isUiGlobalSessionKey(sessionKey) && resolveSelectedGlobalEventAgentId(host, agentId) === hostAliasAgentId, ); } @@ -592,7 +572,7 @@ function canRefreshActiveTabBeforeAgents(host: GatewayHost): boolean { if (host.tab !== "chat") { return false; } - if (isGlobalSessionKey(host.sessionKey)) { + if (isUiGlobalSessionKey(host.sessionKey)) { return false; } const parsed = parseAgentSessionKey(host.sessionKey); @@ -610,9 +590,11 @@ async function loadAgentsThenRefreshActiveTab(host: GatewayHost) { let initialRefreshError: Error | undefined; const refreshBeforeAgents = canRefreshActiveTabBeforeAgents(host); const initialRefresh = refreshBeforeAgents - ? refreshActiveTab(host as unknown as Parameters[0]).catch((err: unknown) => { - initialRefreshError = normalizeStartupRefreshError(err); - }) + ? refreshActiveTab(host as unknown as Parameters[0]).catch( + (err: unknown) => { + initialRefreshError = normalizeStartupRefreshError(err); + }, + ) : Promise.resolve(); let refreshAfterAgents = !refreshBeforeAgents; let agentsError: Error | undefined; diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index 609ab262b29..a3f002cea90 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -491,13 +491,15 @@ export async function refreshActiveTab(host: SettingsHost) { ]); break; case "chat": { - const modelAuthRefresh = loadModelAuthStatusState(app).catch(() => undefined); - await refreshChat(host as unknown as Parameters[0]); - scheduleChatScroll( - host as unknown as Parameters[0], - !host.chatHasAutoScrolled, - ); - void modelAuthRefresh; + try { + await refreshChat(host as unknown as Parameters[0]); + scheduleChatScroll( + host as unknown as Parameters[0], + !host.chatHasAutoScrolled, + ); + } finally { + void loadModelAuthStatusState(app).catch(() => undefined); + } break; } case "debug": diff --git a/ui/src/ui/chat/run-lifecycle.ts b/ui/src/ui/chat/run-lifecycle.ts index b76d5ac8f6d..67d4dd5d9cb 100644 --- a/ui/src/ui/chat/run-lifecycle.ts +++ b/ui/src/ui/chat/run-lifecycle.ts @@ -1,9 +1,5 @@ import { resetToolStream, type CompactionStatus, type FallbackStatus } from "../app-tool-stream.ts"; -import { - areUiSessionKeysEquivalent, - DEFAULT_MAIN_KEY, - parseAgentSessionKey, -} from "../session-key.ts"; +import { uiSessionRowMatchesSelectedChat } from "../session-key.ts"; import { isSessionRunActive } from "../session-run-state.ts"; import type { GatewaySessionRow, SessionRunStatus, SessionsListResult } from "../types.ts"; @@ -280,35 +276,12 @@ export function reconcileChatRunFromCurrentSessionRow( return reconcileChatRunFromSessionRow(host, row, options); } -function configuredMainKey(host: RunLifecycleHost): string { - const snapshot = - host.hello?.snapshot && typeof host.hello.snapshot === "object" - ? (host.hello.snapshot as { sessionDefaults?: { mainKey?: string | null } }) - : undefined; - return ( - host.agentsList?.mainKey?.trim() || - snapshot?.sessionDefaults?.mainKey?.trim() || - DEFAULT_MAIN_KEY - ).toLowerCase(); -} - function isSessionRowForSelectedChat( host: RunLifecycleHost, rowKey: string, sessionKey: string, ): boolean { - if (areUiSessionKeysEquivalent(rowKey, sessionKey)) { - return true; - } - if (rowKey !== "global") { - return false; - } - const parsed = parseAgentSessionKey(sessionKey); - return ( - parsed?.rest === "global" || - parsed?.rest === DEFAULT_MAIN_KEY || - parsed?.rest === configuredMainKey(host) - ); + return uiSessionRowMatchesSelectedChat(host, rowKey, sessionKey); } export function reconcileChatRunFromSessionRow( diff --git a/ui/src/ui/controllers/sessions.ts b/ui/src/ui/controllers/sessions.ts index da0aaf31790..01e57bf091b 100644 --- a/ui/src/ui/controllers/sessions.ts +++ b/ui/src/ui/controllers/sessions.ts @@ -6,9 +6,13 @@ import { import type { GatewayBrowserClient, GatewayHelloOk } from "../gateway.ts"; import { areUiSessionKeysEquivalent, + isUiGlobalSessionKey, isSubagentSessionKey, normalizeAgentId, parseAgentSessionKey, + resolveUiDefaultAgentId, + resolveUiGlobalAliasAgentId, + resolveUiSelectedGlobalAgentId, } from "../session-key.ts"; import { isSessionRunActive } from "../session-run-state.ts"; import type { @@ -104,47 +108,22 @@ function normalizeSubscriptionKey(value: string | null | undefined): string | nu return normalized ? normalized : null; } -function isGlobalSessionKey(value: string | null | undefined): boolean { - return (value ?? "").trim().toLowerCase() === "global"; -} - -function resolveConfiguredMainKey(state: SessionsState): string { - const snapshot = state.hello?.snapshot as { sessionDefaults?: { mainKey?: string } } | undefined; - const mainKey = - typeof state.agentsList?.mainKey === "string" && state.agentsList.mainKey.trim() - ? state.agentsList.mainKey - : typeof snapshot?.sessionDefaults?.mainKey === "string" && - snapshot.sessionDefaults.mainKey.trim() - ? snapshot.sessionDefaults.mainKey - : "main"; - return mainKey.trim().toLowerCase(); -} - function resolveSelectedGlobalAliasAgentId( state: SessionsState, key: string | null | undefined, ): string | null { - const parsed = parseAgentSessionKey(key); - if (!parsed?.agentId) { - return null; - } - const rest = parsed.rest.toLowerCase(); - if (rest === "global") { - return normalizeAgentId(parsed.agentId); - } - const configuredMainKey = resolveConfiguredMainKey(state); - if (rest !== "main" && rest !== configuredMainKey) { - return null; - } const row = state.sessionsResult?.sessions.find((session) => session.key === key); - return row?.kind === "global" ? normalizeAgentId(parsed.agentId) : null; + return resolveUiGlobalAliasAgentId(state, key, { + rowKind: row?.kind, + requireGlobalRowForMainAlias: true, + }); } function resolveSelectedSessionMessageSubscriptionAgentId( state: SessionsState, key: string, ): string | null { - if (isGlobalSessionKey(key)) { + if (isUiGlobalSessionKey(key)) { return resolveSelectedGlobalAgentId(state); } return resolveSelectedGlobalAliasAgentId(state, key); @@ -155,23 +134,7 @@ function resolveSelectedGlobalAgentId(state: SessionsState): string { if (parsed?.agentId) { return normalizeAgentId(parsed.agentId); } - const snapshot = state.hello?.snapshot as - | { sessionDefaults?: { defaultAgentId?: string } } - | undefined; - const assistantAgentId = - typeof state.assistantAgentId === "string" && state.assistantAgentId.trim() - ? state.assistantAgentId - : undefined; - const defaultAgentId = - typeof state.agentsList?.defaultId === "string" && state.agentsList.defaultId.trim() - ? state.agentsList.defaultId - : undefined; - const helloDefaultAgentId = - typeof snapshot?.sessionDefaults?.defaultAgentId === "string" && - snapshot.sessionDefaults.defaultAgentId.trim() - ? snapshot.sessionDefaults.defaultAgentId - : undefined; - return normalizeAgentId(assistantAgentId ?? defaultAgentId ?? helloDefaultAgentId ?? "main"); + return resolveUiSelectedGlobalAgentId(state); } function resolveChatHistorySessionResultAgentId( @@ -182,21 +145,11 @@ function resolveChatHistorySessionResultAgentId( if (parsed?.agentId) { return normalizeAgentId(parsed.agentId); } - return isGlobalSessionKey(row.key) ? resolveSelectedGlobalAgentId(state) : null; + return isUiGlobalSessionKey(row.key) ? resolveSelectedGlobalAgentId(state) : null; } function resolveDefaultGlobalAgentId(state: SessionsState): string { - const snapshot = state.hello?.snapshot as - | { sessionDefaults?: { defaultAgentId?: string } } - | undefined; - const defaultAgentId = - typeof state.agentsList?.defaultId === "string" && state.agentsList.defaultId.trim() - ? state.agentsList.defaultId - : typeof snapshot?.sessionDefaults?.defaultAgentId === "string" && - snapshot.sessionDefaults.defaultAgentId.trim() - ? snapshot.sessionDefaults.defaultAgentId - : "main"; - return normalizeAgentId(defaultAgentId); + return resolveUiDefaultAgentId(state); } function sessionsChangedGlobalAgentMatches( @@ -204,7 +157,7 @@ function sessionsChangedGlobalAgentMatches( payload: Record, key: string, ): boolean { - if (!isGlobalSessionKey(key)) { + if (!isUiGlobalSessionKey(key)) { return true; } const eventSession = isRecord(payload.session) ? payload.session : null; @@ -309,7 +262,7 @@ async function unsubscribeSelectedSessionMessageBestEffort( try { await client.request("sessions.messages.unsubscribe", { key, - ...(isGlobalSessionKey(key) && agentId ? { agentId } : {}), + ...(isUiGlobalSessionKey(key) && agentId ? { agentId } : {}), }); } catch { // Best-effort cleanup for stale async subscription completions. @@ -576,7 +529,7 @@ function sessionRowMatchesChatHistoryRow( return true; } return ( - isGlobalSessionKey(incoming.key) && + isUiGlobalSessionKey(incoming.key) && resolveSelectedGlobalAliasAgentId(state, existing.key) === resolveSelectedGlobalAgentId(state) ); } @@ -729,7 +682,7 @@ async function runCompactionMutation( }); await loadSessions( state, - isGlobalSessionKey(key) ? { agentId: resolveSelectedGlobalAgentId(state) } : undefined, + isUiGlobalSessionKey(key) ? { agentId: resolveSelectedGlobalAgentId(state) } : undefined, ); return result; } catch (err) { @@ -983,7 +936,7 @@ export function applyChatHistorySessionInfo( const applied = applySessionsChangedEvent(state, { session: visibleSession, sessionKey: visibleSession.key, - ...(isGlobalSessionKey(visibleSession.key) + ...(isUiGlobalSessionKey(visibleSession.key) ? { agentId: resolveSelectedGlobalAgentId(state) } : {}), }); @@ -1066,7 +1019,8 @@ export async function syncSelectedSessionMessageSubscription( if (shouldUnsubscribePrevious && previousCanonicalKey) { await client.request("sessions.messages.unsubscribe", { key: previousCanonicalKey, - ...(isGlobalSessionKey(previousCanonicalKey) && state.chatSessionMessageSubscriptionAgentId + ...(isUiGlobalSessionKey(previousCanonicalKey) && + state.chatSessionMessageSubscriptionAgentId ? { agentId: state.chatSessionMessageSubscriptionAgentId } : {}), }); @@ -1087,7 +1041,7 @@ export async function syncSelectedSessionMessageSubscription( const staleKeyChanged = normalizeSubscriptionKey(state.chatSessionMessageSubscriptionKey) !== subscribedKey; const staleAgentChanged = - isGlobalSessionKey(subscribedKey) && + isUiGlobalSessionKey(subscribedKey) && (state.chatSessionMessageSubscriptionAgentId ?? null) !== subscribedAgentId; if (staleKeyChanged || staleAgentChanged) { await unsubscribeSelectedSessionMessageBestEffort(client, subscribedKey, subscribedAgentId); @@ -1254,7 +1208,7 @@ export async function patchSession( } const params: Record = { key, - ...(isGlobalSessionKey(key) ? { agentId: resolveSelectedGlobalAgentId(state) } : {}), + ...(isUiGlobalSessionKey(key) ? { agentId: resolveSelectedGlobalAgentId(state) } : {}), }; for (const field of [ "label", @@ -1271,7 +1225,7 @@ export async function patchSession( await state.client.request("sessions.patch", params); await loadSessions( state, - isGlobalSessionKey(key) ? { agentId: resolveSelectedGlobalAgentId(state) } : undefined, + isUiGlobalSessionKey(key) ? { agentId: resolveSelectedGlobalAgentId(state) } : undefined, ); } catch (err) { state.sessionsError = String(err); @@ -1329,7 +1283,7 @@ export async function deleteSessionsAndRefresh( try { await client.request("sessions.delete", { key, - ...(isGlobalSessionKey(key) ? { agentId: resolveSelectedGlobalAgentId(state) } : {}), + ...(isUiGlobalSessionKey(key) ? { agentId: resolveSelectedGlobalAgentId(state) } : {}), deleteTranscript: true, }); deleted.push(key); @@ -1339,7 +1293,7 @@ export async function deleteSessionsAndRefresh( } }); if (deleted.length > 0 && !refreshedDuringDelete) { - const selectedGlobalDeleted = deleted.some((key) => isGlobalSessionKey(key)); + const selectedGlobalDeleted = deleted.some((key) => isUiGlobalSessionKey(key)); await loadSessions( state, selectedGlobalDeleted ? { agentId: resolveSelectedGlobalAgentId(state) } : undefined, diff --git a/ui/src/ui/session-key.ts b/ui/src/ui/session-key.ts index 0030008be22..ebb856d49bd 100644 --- a/ui/src/ui/session-key.ts +++ b/ui/src/ui/session-key.ts @@ -12,6 +12,12 @@ export type ParsedAgentSessionKey = { export const DEFAULT_AGENT_ID = "main"; export const DEFAULT_MAIN_KEY = "main"; +export type UiSessionDefaultsHost = { + assistantAgentId?: string | null; + agentsList?: { defaultId?: string | null; mainKey?: string | null } | null; + hello?: { snapshot?: unknown } | null; +}; + const VALID_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i; const INVALID_CHARS_RE = /[^a-z0-9_-]+/g; const LEADING_DASH_RE = /^-+/; @@ -40,6 +46,88 @@ function normalizeMainKey(value: string | undefined | null): string { return normalizeOptionalLowercaseString(value) ?? DEFAULT_MAIN_KEY; } +function readSessionDefaults( + host: Pick, +): { defaultAgentId?: string | null; mainKey?: string | null } | undefined { + const snapshot = host.hello?.snapshot; + if (!snapshot || typeof snapshot !== "object" || !("sessionDefaults" in snapshot)) { + return undefined; + } + const defaults = snapshot.sessionDefaults; + return defaults && typeof defaults === "object" + ? (defaults as { defaultAgentId?: string | null; mainKey?: string | null }) + : undefined; +} + +export function resolveUiConfiguredMainKey( + host: Pick, +): string { + return normalizeMainKey(host.agentsList?.mainKey ?? readSessionDefaults(host)?.mainKey); +} + +export function resolveUiDefaultAgentId( + host: Pick, +): string { + return normalizeAgentId( + host.agentsList?.defaultId ?? readSessionDefaults(host)?.defaultAgentId ?? DEFAULT_AGENT_ID, + ); +} + +export function resolveUiKnownSelectedGlobalAgentId( + host: Pick, +): string | undefined { + const selectedAgentId = + host.assistantAgentId ?? + host.agentsList?.defaultId ?? + readSessionDefaults(host)?.defaultAgentId; + return selectedAgentId ? normalizeAgentId(selectedAgentId) : undefined; +} + +export function resolveUiSelectedGlobalAgentId( + host: Pick, +): string { + return resolveUiKnownSelectedGlobalAgentId(host) ?? DEFAULT_AGENT_ID; +} + +export function resolveUiGlobalAliasAgentId( + host: Pick, + sessionKey: string | undefined | null, + opts?: { rowKind?: string | null; requireGlobalRowForMainAlias?: boolean }, +): string | null { + const parsed = parseAgentSessionKey(sessionKey); + if (!parsed) { + return null; + } + const rest = normalizeLowercaseStringOrEmpty(parsed.rest); + if (rest === "global") { + return normalizeAgentId(parsed.agentId); + } + if (rest !== DEFAULT_MAIN_KEY && rest !== resolveUiConfiguredMainKey(host)) { + return null; + } + if (opts?.requireGlobalRowForMainAlias && opts.rowKind !== "global") { + return null; + } + return normalizeAgentId(parsed.agentId); +} + +export function isUiGlobalSessionKey(sessionKey: string | undefined | null): boolean { + return normalizeLowercaseStringOrEmpty(sessionKey) === "global"; +} + +export function uiSessionRowMatchesSelectedChat( + host: Pick, + rowKey: string | undefined | null, + selectedSessionKey: string | undefined | null, +): boolean { + if (areUiSessionKeysEquivalent(rowKey, selectedSessionKey)) { + return true; + } + return Boolean( + isUiGlobalSessionKey(rowKey) && resolveUiGlobalAliasAgentId(host, selectedSessionKey), + ); +} + export function normalizeAgentId(value: string | undefined | null): string { const trimmed = normalizeOptionalString(value) ?? ""; if (!trimmed) {