diff --git a/src/gateway/session-utils.search.test.ts b/src/gateway/session-utils.search.test.ts index 0c3d44c0ef6..993f95a2841 100644 --- a/src/gateway/session-utils.search.test.ts +++ b/src/gateway/session-utils.search.test.ts @@ -237,6 +237,30 @@ describe("listSessionsFromStore search", () => { } }); + test("keeps derived model search for colon model ids", () => { + const now = Date.now(); + const cfg = createModelDefaultsConfig({ + primary: "ollama/qwen3:0.6b", + }); + const result = listSessionsFromStore({ + cfg, + storePath: "/tmp/sessions.json", + store: { + "agent:main:inherited-local-model": { + sessionId: "sess-inherited-local-model", + updatedAt: now, + label: "Inherited local model", + } as SessionEntry, + }, + opts: { search: "qwen3:0.6b" }, + }); + + expect(result.sessions.map((session) => session.key)).toEqual([ + "agent:main:inherited-local-model", + ]); + expect(result.totalCount).toBe(1); + }); + test("hides cron run alias session keys from sessions list", () => { const now = Date.now(); const store: Record = { diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index e779466ffb6..cfa501f59e5 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -445,6 +445,8 @@ type SessionListRowContext = { modelCostConfigByModelRef: Map; }; +type SessionListRowContextProvider = () => SessionListRowContext; + type SingleRowChildSessionCandidateCacheEntry = { store: Record; storeVersion: number; @@ -671,9 +673,19 @@ function buildSessionListRowContext(params: { now: number; }): SessionListRowContext { const subagentRuns = buildSubagentRunReadIndex(params.now); - return { + return buildSessionListRowContextFromParts({ subagentRuns, storeChildSessionsByKey: buildStoreChildSessionIndex(params.store, params.now, subagentRuns), + }); +} + +function buildSessionListRowContextFromParts(params: { + subagentRuns: ReturnType; + storeChildSessionsByKey: Map; +}): SessionListRowContext { + return { + subagentRuns: params.subagentRuns, + storeChildSessionsByKey: params.storeChildSessionsByKey, selectedModelByOverrideRef: new Map(), thinkingMetadataByModelRef: new Map(), displayModelIdentityByKey: new Map(), @@ -681,6 +693,13 @@ function buildSessionListRowContext(params: { }; } +function buildSessionListRowMetadataContext(params: { now: number }): SessionListRowContext { + return buildSessionListRowContextFromParts({ + subagentRuns: buildSubagentRunReadIndex(params.now), + storeChildSessionsByKey: new Map(), + }); +} + function buildSingleRowStoreChildSessionsByKey(params: { store: Record; storePath: string; @@ -2180,6 +2199,37 @@ function addSessionListSearchModelFields( } } +function matchesSessionListSearch(fields: Array, search: string): boolean { + return fields.some( + (field) => typeof field === "string" && normalizeLowercaseStringOrEmpty(field).includes(search), + ); +} + +function appendStoredSessionModelSearchFields( + fields: Array, + entry?: SessionEntry, +) { + const provider = normalizeOptionalString(entry?.modelProvider); + const model = normalizeOptionalString(entry?.model); + fields.push(provider, model); + if (provider && model) { + fields.push(`${provider}/${model}`); + } +} + +function shouldResolveDerivedSessionModelSearchFields(search: string): boolean { + // Agent session-key searches are already covered by cheap key fields; do not + // hydrate model metadata for every non-matching row on hot TUI lookups. + return !search.startsWith("agent:"); +} + +function resolveSessionListRowContext(params: { + rowContext?: SessionListRowContext; + getRowContext?: SessionListRowContextProvider; +}): SessionListRowContext | undefined { + return params.rowContext ?? params.getRowContext?.(); +} + function resolveSessionListSearchModelFields(params: { cfg: OpenClawConfig; key: string; @@ -2356,9 +2406,9 @@ function filterSessionEntries(params: { opts: SessionsListParams; now: number; rowContext?: SessionListRowContext; + getRowContext?: SessionListRowContextProvider; }): SessionEntryPair[] { const { cfg, store, opts, now } = params; - const rowContext = params.rowContext; const includeGlobal = opts.includeGlobal === true; const includeUnknown = opts.includeUnknown === true; const spawnedBy = typeof opts.spawnedBy === "string" ? opts.spawnedBy : ""; @@ -2403,8 +2453,9 @@ function filterSessionEntries(params: { if (key === "unknown" || key === "global") { return false; } - const latest = rowContext - ? rowContext.subagentRuns.getDisplaySubagentRun(key) + const filterRowContext = resolveSessionListRowContext(params); + const latest = filterRowContext + ? filterRowContext.subagentRuns.getDisplaySubagentRun(key) : getSessionDisplaySubagentRunByChildSessionKey(key); if (latest) { const latestControllerSessionKey = @@ -2413,8 +2464,8 @@ function filterSessionEntries(params: { return ( latestControllerSessionKey === spawnedBy && shouldKeepSubagentRunChildLink(latest, { - activeDescendants: rowContext - ? rowContext.subagentRuns.countActiveDescendantRuns(key) + activeDescendants: filterRowContext + ? filterRowContext.subagentRuns.countActiveDescendantRuns(key) : countActiveDescendantRuns(key), now, }) @@ -2434,21 +2485,29 @@ function filterSessionEntries(params: { if (search) { entries = entries.filter(([key, entry]) => { - const fields = [ + const cheapFields = [ resolveSessionListSearchDisplayName(key, entry), entry?.label, entry?.subject, entry?.sessionId, key, - ...resolveSessionListSearchModelFields({ + ]; + appendStoredSessionModelSearchFields(cheapFields, entry); + if (matchesSessionListSearch(cheapFields, search)) { + return true; + } + if (!shouldResolveDerivedSessionModelSearchFields(search)) { + return false; + } + const searchRowContext = resolveSessionListRowContext(params); + return matchesSessionListSearch( + resolveSessionListSearchModelFields({ cfg, key, entry, - rowContext, + rowContext: searchRowContext, }), - ]; - return fields.some( - (f) => typeof f === "string" && normalizeLowercaseStringOrEmpty(f).includes(search), + search, ); }); } @@ -2467,6 +2526,7 @@ function selectSessionEntries(params: { opts: SessionsListParams; now: number; rowContext?: SessionListRowContext; + getRowContext?: SessionListRowContextProvider; defaultLimit?: number; }): SessionEntrySelection { const filtered = filterSessionEntries(params); @@ -2494,6 +2554,7 @@ export function filterAndSortSessionEntries(params: { opts: SessionsListParams; now: number; rowContext?: SessionListRowContext; + getRowContext?: SessionListRowContextProvider; }): [string, SessionEntry][] { return selectSessionEntries(params).entries; } @@ -2523,13 +2584,20 @@ export function listSessionsFromStore(params: { store, opts, now, - rowContext: + getRowContext: hasSpawnedByFilter || Boolean(normalizeOptionalString(opts.search)) - ? getRowContext() + ? getRowContext : undefined, defaultLimit: SESSIONS_LIST_DEFAULT_LIMIT, }); const { entries, totalCount, limitApplied, offset, nextOffset, hasMore } = selection; + const fullRowContext = + rowContext || hasSpawnedByFilter || entries.length > SESSIONS_LIST_YIELD_BATCH_SIZE + ? getRowContext() + : undefined; + const sharedRowContext = + fullRowContext ?? + (entries.length > 1 ? buildSessionListRowMetadataContext({ now }) : undefined); const sessions = entries.map(([key, entry], index) => { const includeTranscriptFields = index < sessionListTranscriptFieldRows; @@ -2537,6 +2605,9 @@ export function listSessionsFromStore(params: { key === "global" && typeof opts.agentId === "string" ? normalizeAgentId(opts.agentId) : undefined; + const storeChildSessionsByKey = + fullRowContext?.storeChildSessionsByKey ?? + buildSingleRowStoreChildSessionsByKey({ store, storePath, key, now }); return buildGatewaySessionRow({ cfg, storePath, @@ -2549,8 +2620,8 @@ export function listSessionsFromStore(params: { includeDerivedTitles: includeTranscriptFields && includeDerivedTitles, includeLastMessage: includeTranscriptFields && includeLastMessage, transcriptUsageMaxBytes: sessionListTranscriptUsageMaxBytes, - storeChildSessionsByKey: getRowContext().storeChildSessionsByKey, - rowContext: getRowContext(), + storeChildSessionsByKey, + rowContext: sharedRowContext, }); }); @@ -2603,13 +2674,20 @@ export async function listSessionsFromStoreAsync(params: { store, opts, now, - rowContext: + getRowContext: hasSpawnedByFilter || Boolean(normalizeOptionalString(opts.search)) - ? getRowContext() + ? getRowContext : undefined, defaultLimit: SESSIONS_LIST_DEFAULT_LIMIT, }); const { entries, totalCount, limitApplied, offset, nextOffset, hasMore } = selection; + const fullRowContext = + rowContext || hasSpawnedByFilter || entries.length > SESSIONS_LIST_YIELD_BATCH_SIZE + ? getRowContext() + : undefined; + const sharedRowContext = + fullRowContext ?? + (entries.length > 1 ? buildSessionListRowMetadataContext({ now }) : undefined); const sessions: GatewaySessionRow[] = []; for (let i = 0; i < entries.length; i++) { @@ -2619,6 +2697,9 @@ export async function listSessionsFromStoreAsync(params: { key === "global" && typeof opts.agentId === "string" ? normalizeAgentId(opts.agentId) : undefined; + const storeChildSessionsByKey = + fullRowContext?.storeChildSessionsByKey ?? + buildSingleRowStoreChildSessionsByKey({ store, storePath, key, now }); const row = buildGatewaySessionRow({ cfg, storePath, @@ -2631,8 +2712,8 @@ export async function listSessionsFromStoreAsync(params: { includeDerivedTitles: false, includeLastMessage: false, transcriptUsageMaxBytes: sessionListTranscriptUsageMaxBytes, - storeChildSessionsByKey: getRowContext().storeChildSessionsByKey, - rowContext: getRowContext(), + storeChildSessionsByKey, + rowContext: sharedRowContext, skipTranscriptUsageFallback: true, lightweightListRow: true, }); diff --git a/src/tui/embedded-backend.ts b/src/tui/embedded-backend.ts index 2ae2c60eac5..91c19f1851a 100644 --- a/src/tui/embedded-backend.ts +++ b/src/tui/embedded-backend.ts @@ -539,7 +539,7 @@ export class EmbeddedTuiBackend implements TuiBackend { if (!result.ok) { throw new Error(result.error.message); } - return { ok: true, key: result.key, entry: result.entry }; + return { ok: true as const, key: result.key, entry: result.entry }; } async getGatewayStatus() { diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index f7c0057896a..0f01db83f7c 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -36,6 +36,7 @@ import type { TuiEvent, TuiModelChoice, TuiSessionList, + TuiSessionMutationResult, } from "./tui-backend.js"; export type GatewayConnectionOptions = { @@ -242,8 +243,12 @@ export class GatewayChatClient implements TuiBackend { return await this.client.request("sessions.patch", opts); } - async resetSession(key: string, reason?: "new" | "reset", opts?: { agentId?: string }) { - return await this.client.request("sessions.reset", { + async resetSession( + key: string, + reason?: "new" | "reset", + opts?: { agentId?: string }, + ): Promise { + return await this.client.request("sessions.reset", { key, ...(opts?.agentId ? { agentId: opts.agentId } : {}), ...(reason ? { reason } : {}), diff --git a/src/tui/tui-backend.ts b/src/tui/tui-backend.ts index c340e43536d..b0edec88dc3 100644 --- a/src/tui/tui-backend.ts +++ b/src/tui/tui-backend.ts @@ -105,6 +105,19 @@ export type TuiModelChoice = { reasoning?: boolean; }; +export type TuiSessionMutationResult = { + ok?: boolean; + key?: string; + entry?: Partial & { + sessionId?: string; + updatedAt?: number | null; + }; + resolved?: { + modelProvider?: string; + model?: string; + }; +}; + export type TuiBackend = { connection: { url: string; @@ -131,7 +144,7 @@ export type TuiBackend = { key: string, reason?: "new" | "reset", opts?: { agentId?: string }, - ) => Promise; + ) => Promise; getGatewayStatus: () => Promise; listModels: () => Promise; listCommands?: (opts?: CommandsListParams) => Promise; diff --git a/src/tui/tui-command-handlers.test.ts b/src/tui/tui-command-handlers.test.ts index e5d5fbe1968..82403b4d69b 100644 --- a/src/tui/tui-command-handlers.test.ts +++ b/src/tui/tui-command-handlers.test.ts @@ -15,6 +15,7 @@ type SelectableOverlay = { }; type SetActivityStatusMock = ReturnType & ((text: string) => void); type SetSessionMock = ReturnType & ((key: string) => Promise); +type SetEmptySessionMock = ReturnType & ((key: string) => Promise); type ConsumeCompletedRunMock = ReturnType & ((runId: string) => boolean); type FlushPendingHistoryRefreshMock = ReturnType & (() => void); @@ -69,9 +70,11 @@ function createHarness(params?: { runGoalCommand?: ReturnType; runAuthFlow?: RunAuthFlow; setSession?: SetSessionMock; + setEmptySession?: SetEmptySessionMock; loadHistory?: LoadHistoryMock; refreshSessionInfo?: ReturnType; applySessionInfoFromPatch?: ReturnType; + applySessionMutationResult?: ReturnType; setActivityStatus?: SetActivityStatusMock; isConnected?: boolean; activeChatRunId?: string | null; @@ -94,6 +97,8 @@ function createHarness(params?: { const resetSession = params?.resetSession ?? vi.fn().mockResolvedValue({ ok: true }); const runGoalCommand = params?.runGoalCommand ?? vi.fn().mockResolvedValue({ text: "Goal" }); const setSession = params?.setSession ?? (vi.fn().mockResolvedValue(undefined) as SetSessionMock); + const setEmptySession = + params?.setEmptySession ?? (vi.fn().mockResolvedValue(undefined) as SetEmptySessionMock); const addUser = vi.fn(); const addSystem = vi.fn(); const reserveAssistantSlot = vi.fn(); @@ -104,6 +109,7 @@ function createHarness(params?: { params?.loadHistory ?? (vi.fn().mockResolvedValue(undefined) as LoadHistoryMock); const refreshSessionInfo = params?.refreshSessionInfo ?? vi.fn().mockResolvedValue(undefined); const applySessionInfoFromPatch = params?.applySessionInfoFromPatch ?? vi.fn(); + const applySessionMutationResult = params?.applySessionMutationResult ?? vi.fn(); const setActivityStatus = params?.setActivityStatus ?? (vi.fn() as SetActivityStatusMock); const forgetLocalRunId = vi.fn(); const openOverlay = vi.fn(); @@ -148,11 +154,13 @@ function createHarness(params?: { refreshSessionInfo: refreshSessionInfo as never, loadHistory, setSession, + setEmptySession, refreshAgents: vi.fn(), abortActive, setActivityStatus, formatSessionKey: vi.fn(), applySessionInfoFromPatch: applySessionInfoFromPatch as never, + applySessionMutationResult: applySessionMutationResult as never, noteLocalRunId, noteLocalBtwRunId, forgetLocalRunId, @@ -176,6 +184,7 @@ function createHarness(params?: { resetSession, runGoalCommand, setSession, + setEmptySession, addUser, addSystem, reserveAssistantSlot, @@ -183,6 +192,7 @@ function createHarness(params?: { loadHistory, refreshSessionInfo, applySessionInfoFromPatch, + applySessionMutationResult, runAuthFlow, setActivityStatus, noteLocalRunId, @@ -638,17 +648,32 @@ describe("tui command handlers", () => { it("creates unique session for /new and resets shared session for /reset", async () => { const loadHistory = vi.fn().mockResolvedValue(undefined); const setSessionMock = vi.fn().mockResolvedValue(undefined) as SetSessionMock; + const setEmptySessionMock = vi.fn().mockResolvedValue(undefined) as SetEmptySessionMock; + const applySessionMutationResult = vi.fn().mockReturnValue(true); + const refreshSessionInfo = vi.fn().mockResolvedValue(undefined); + const resetResult = { + ok: true as const, + key: "agent:main:main", + entry: { sessionId: "reset-session" }, + }; const { handleCommand, resetSession } = createHarness({ loadHistory, setSession: setSessionMock, + setEmptySession: setEmptySessionMock, + applySessionMutationResult, + refreshSessionInfo, + resetSession: vi.fn().mockResolvedValue(resetResult), }); await handleCommand("/new"); await handleCommand("/reset"); // /new creates a unique session key (isolates TUI client) (#39217) - expect(setSessionMock).toHaveBeenCalledTimes(1); - const newSessionKey = firstMockArg(setSessionMock, "setSession") as string | undefined; + expect(setSessionMock).not.toHaveBeenCalled(); + expect(setEmptySessionMock).toHaveBeenCalledTimes(1); + const newSessionKey = firstMockArg(setEmptySessionMock, "setEmptySession") as + | string + | undefined; if (!newSessionKey) { throw new Error("expected /new to set a TUI session key"); } @@ -659,7 +684,24 @@ describe("tui command handlers", () => { // /reset still resets the shared session expect(resetSession).toHaveBeenCalledTimes(1); expect(resetSession).toHaveBeenCalledWith("agent:main:main", "reset", undefined); - expect(loadHistory).toHaveBeenCalledTimes(1); // /reset calls loadHistory directly; /new does so indirectly via setSession + expect(applySessionMutationResult).toHaveBeenCalledWith(resetResult); + expect(refreshSessionInfo).toHaveBeenCalledTimes(1); + expect(loadHistory).not.toHaveBeenCalled(); + }); + + it("reloads history after /reset when the backend does not return a session entry", async () => { + const loadHistory = vi.fn().mockResolvedValue(undefined); + const applySessionMutationResult = vi.fn().mockReturnValue(false); + const { handleCommand } = createHarness({ + loadHistory, + applySessionMutationResult, + resetSession: vi.fn().mockResolvedValue({ ok: true }), + }); + + await handleCommand("/reset"); + + expect(applySessionMutationResult).toHaveBeenCalledWith({ ok: true }); + expect(loadHistory).toHaveBeenCalledTimes(1); }); it("scopes /reset for the selected global agent", async () => { @@ -705,10 +747,10 @@ describe("tui command handlers", () => { }); it("sanitizes control sequences in /new and /reset failures", async () => { - const setSession = vi.fn().mockRejectedValue(new Error("\u001b[31mboom\u001b[0m")); + const setEmptySession = vi.fn().mockRejectedValue(new Error("\u001b[31mboom\u001b[0m")); const resetSession = vi.fn().mockRejectedValue(new Error("\u001b[31mboom\u001b[0m")); const { handleCommand, addSystem } = createHarness({ - setSession, + setEmptySession, resetSession, }); diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index b306e8014fd..0ea738df454 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -23,7 +23,7 @@ import { createSearchableSelectList, createSettingsList, } from "./components/selectors.js"; -import type { TuiBackend } from "./tui-backend.js"; +import type { TuiBackend, TuiSessionMutationResult } from "./tui-backend.js"; import { sanitizeRenderableText } from "./tui-formatters.js"; import { TUI_RECENT_SESSIONS_ACTIVE_MINUTES, @@ -50,11 +50,13 @@ type CommandHandlerContext = { refreshSessionInfo: () => Promise; loadHistory: () => Promise; setSession: (key: string) => Promise; + setEmptySession: (key: string) => Promise; refreshAgents: () => Promise; abortActive: (params?: { preferActive?: boolean }) => Promise; setActivityStatus: (text: string) => void; formatSessionKey: (key: string) => string; applySessionInfoFromPatch: (result: SessionsPatchResult) => void; + applySessionMutationResult: (result?: TuiSessionMutationResult | null) => boolean; noteLocalRunId?: (runId: string) => void; noteLocalBtwRunId?: (runId: string) => void; forgetLocalRunId?: (runId: string) => void; @@ -104,11 +106,13 @@ export function createCommandHandlers(context: CommandHandlerContext) { refreshSessionInfo, loadHistory, setSession, + setEmptySession, refreshAgents, abortActive, setActivityStatus, formatSessionKey, applySessionInfoFromPatch, + applySessionMutationResult, noteLocalRunId, noteLocalBtwRunId, forgetLocalRunId, @@ -642,7 +646,7 @@ export function createCommandHandlers(context: CommandHandlerContext) { // This ensures /new creates a fresh session that doesn't broadcast // to other connected TUI clients sharing the original session key. const uniqueKey = `tui-${randomUUID()}`; - await setSession(uniqueKey); + await setEmptySession(uniqueKey); chatLog.addSystem(`new session: ${uniqueKey}`); } catch (err) { chatLog.addSystem(`new session failed: ${sanitizeRenderableText(String(err))}`); @@ -656,13 +660,17 @@ export function createCommandHandlers(context: CommandHandlerContext) { state.sessionInfo.totalTokens = null; tui.requestRender(); - await client.resetSession( + const result = await client.resetSession( state.currentSessionKey, name, state.currentSessionKey === "global" ? { agentId: state.currentAgentId } : undefined, ); + if (applySessionMutationResult(result)) { + await refreshSessionInfo(); + } else { + await loadHistory(); + } chatLog.addSystem(`session ${state.currentSessionKey} reset`); - await loadHistory(); } catch (err) { chatLog.addSystem(`reset failed: ${sanitizeRenderableText(String(err))}`); } diff --git a/src/tui/tui-session-actions.test.ts b/src/tui/tui-session-actions.test.ts index 9a344d9b8db..3c7b4c8b0ee 100644 --- a/src/tui/tui-session-actions.test.ts +++ b/src/tui/tui-session-actions.test.ts @@ -472,6 +472,144 @@ describe("tui session actions", () => { expect(state.pendingChatRunId).toBeNull(); }); + it("starts an empty session without loading gateway history", async () => { + const loadHistory = vi.fn().mockResolvedValue({ messages: [] }); + const listSessions = vi.fn().mockResolvedValue({ sessions: [] }); + const addSystem = vi.fn(); + const clearAll = vi.fn(); + const requestRender = vi.fn(); + const rememberSessionKey = vi.fn(); + const state = createBaseState({ + activeChatRunId: "run-1", + pendingChatRunId: "run-2", + pendingOptimisticUserMessage: true, + currentSessionId: "old-session", + historyLoaded: false, + sessionInfo: { + model: "old-model", + modelProvider: "old-provider", + contextTokens: 99, + thinkingLevel: "high", + fastMode: false, + verboseLevel: "debug", + inputTokens: 1, + outputTokens: 2, + totalTokens: 3, + }, + }); + + const { setEmptySession } = createTestSessionActions({ + client: { listSessions, loadHistory } as unknown as TuiBackend, + chatLog: { + addSystem, + clearAll, + } as unknown as import("./components/chat-log.js").ChatLog, + tui: { requestRender } as unknown as import("@earendil-works/pi-tui").TUI, + state, + rememberSessionKey, + emptySessionInfoDefaults: { + fastMode: true, + verboseLevel: "on", + }, + }); + + await setEmptySession("agent:main:tui-empty"); + + expect(loadHistory).not.toHaveBeenCalled(); + expect(listSessions).not.toHaveBeenCalled(); + expect(state.currentSessionKey).toBe("agent:main:tui-empty"); + expect(state.currentSessionId).toBeNull(); + expect(state.activeChatRunId).toBeNull(); + expect(state.pendingChatRunId).toBeNull(); + expect(state.pendingOptimisticUserMessage).toBe(false); + expect(state.historyLoaded).toBe(true); + expect(state.sessionInfo.model).toBeUndefined(); + expect(state.sessionInfo.modelProvider).toBeUndefined(); + expect(state.sessionInfo.contextTokens).toBeNull(); + expect(state.sessionInfo.thinkingLevel).toBeUndefined(); + expect(state.sessionInfo.fastMode).toBe(true); + expect(state.sessionInfo.verboseLevel).toBe("on"); + expect(state.sessionInfo.inputTokens).toBeNull(); + expect(state.sessionInfo.outputTokens).toBeNull(); + expect(state.sessionInfo.totalTokens).toBeNull(); + expect(clearAll).toHaveBeenCalled(); + expect(addSystem).toHaveBeenCalledWith("session agent:main:tui-empty"); + expect(rememberSessionKey).toHaveBeenCalledWith("agent:main:tui-empty"); + expect(requestRender).toHaveBeenCalled(); + }); + + it("applies reset mutation result without reloading gateway history", () => { + const loadHistory = vi.fn().mockResolvedValue({ messages: [] }); + const addSystem = vi.fn(); + const clearAll = vi.fn(); + const state = createBaseState({ + currentSessionKey: "agent:main:old", + currentSessionId: "old-session", + sessionInfo: { + model: "old-model", + modelProvider: "old-provider", + }, + }); + + const { applySessionMutationResult } = createTestSessionActions({ + client: { loadHistory } as unknown as TuiBackend, + chatLog: { + addSystem, + clearAll, + } as unknown as import("./components/chat-log.js").ChatLog, + state, + }); + + const applied = applySessionMutationResult({ + ok: true, + key: "agent:main:new", + entry: { + sessionId: "new-session", + model: "new-model", + modelProvider: "openai", + updatedAt: 123, + }, + }); + + expect(applied).toBe(true); + expect(loadHistory).not.toHaveBeenCalled(); + expect(state.currentSessionKey).toBe("agent:main:new"); + expect(state.currentSessionId).toBe("new-session"); + expect(state.sessionInfo.model).toBe("new-model"); + expect(state.sessionInfo.modelProvider).toBe("openai"); + expect(state.sessionInfo.updatedAt).toBe(123); + expect(state.historyLoaded).toBe(true); + expect(clearAll).toHaveBeenCalled(); + expect(addSystem).toHaveBeenCalledWith("session agent:main:new"); + }); + + it("does not fast-clear reset results without a replacement entry", () => { + const addSystem = vi.fn(); + const clearAll = vi.fn(); + const state = createBaseState({ + currentSessionKey: "agent:main:old", + currentSessionId: "old-session", + historyLoaded: false, + }); + + const { applySessionMutationResult } = createTestSessionActions({ + chatLog: { + addSystem, + clearAll, + } as unknown as import("./components/chat-log.js").ChatLog, + state, + }); + + const applied = applySessionMutationResult({ ok: true }); + + expect(applied).toBe(false); + expect(state.currentSessionKey).toBe("agent:main:old"); + expect(state.currentSessionId).toBe("old-session"); + expect(state.historyLoaded).toBe(false); + expect(clearAll).not.toHaveBeenCalled(); + expect(addSystem).not.toHaveBeenCalled(); + }); + it("aborts the in-flight runId when only pendingChatRunId is set", async () => { const abortChat = vi.fn().mockResolvedValue({ ok: true, aborted: true }); const addSystem = vi.fn(); diff --git a/src/tui/tui-session-actions.ts b/src/tui/tui-session-actions.ts index 6fd8963ca4d..94e70780c05 100644 --- a/src/tui/tui-session-actions.ts +++ b/src/tui/tui-session-actions.ts @@ -8,7 +8,7 @@ import { parseAgentSessionKey, } from "../routing/session-key.js"; import type { ChatLog } from "./components/chat-log.js"; -import type { TuiAgentsList, TuiBackend } from "./tui-backend.js"; +import type { TuiAgentsList, TuiBackend, TuiSessionMutationResult } from "./tui-backend.js"; import { asString, extractTextFromMessage, isCommandMessage } from "./tui-formatters.js"; import { TUI_SESSION_LOOKUP_LIMIT } from "./tui-session-list-policy.js"; import type { SessionInfo, TuiOptions, TuiStateAccess } from "./tui-types.js"; @@ -34,6 +34,7 @@ type SessionActionContext = { setActivityStatus: (text: string) => void; clearLocalRunIds?: () => void; rememberSessionKey?: (sessionKey: string) => void | Promise; + emptySessionInfoDefaults?: SessionInfo; }; type SessionInfoDefaults = { @@ -66,6 +67,7 @@ export function createSessionActions(context: SessionActionContext) { setActivityStatus, clearLocalRunIds, rememberSessionKey, + emptySessionInfoDefaults, } = context; let refreshSessionInfoPromise: Promise = Promise.resolve(); let lastSessionDefaults: SessionInfoDefaults | null = null; @@ -280,7 +282,9 @@ export function createSessionActions(context: SessionActionContext) { await refreshSessionInfoPromise; }; - const applySessionInfoFromPatch = (result?: SessionsPatchResult | null) => { + const applySessionInfoFromPatch = ( + result?: SessionsPatchResult | TuiSessionMutationResult | null, + ) => { if (!result?.entry) { return; } @@ -301,6 +305,31 @@ export function createSessionActions(context: SessionActionContext) { applySessionInfo({ entry, force: true }); }; + const clearDisplayedSession = (key = state.currentSessionKey) => { + chatLog.clearAll(); + btw.clear(); + chatLog.addSystem(`session ${key}`); + state.historyLoaded = true; + void rememberSessionKey?.(key); + tui.requestRender(); + }; + + const applySessionMutationResult = (result?: TuiSessionMutationResult | null): boolean => { + if (!result?.entry) { + return false; + } + if (result.key && result.key !== state.currentSessionKey) { + updateAgentFromSessionKey(result.key); + state.currentSessionKey = result.key; + updateHeader(); + } + const sessionId = result.entry.sessionId; + state.currentSessionId = typeof sessionId === "string" ? sessionId : null; + applySessionInfoFromPatch(result); + clearDisplayedSession(); + return true; + }; + const loadHistory = async () => { try { const history = await client.loadHistory({ @@ -403,6 +432,36 @@ export function createSessionActions(context: SessionActionContext) { await loadHistory(); }; + const setEmptySession = async (rawKey: string) => { + const nextKey = resolveSessionKey(rawKey); + updateAgentFromSessionKey(nextKey); + state.currentSessionKey = nextKey; + state.activeChatRunId = null; + state.pendingChatRunId = null; + state.pendingOptimisticUserMessage = false; + setActivityStatus("idle"); + state.currentSessionId = null; + const defaults = lastSessionDefaults; + state.sessionInfo = { + ...emptySessionInfoDefaults, + modelProvider: defaults?.modelProvider ?? undefined, + model: defaults?.model ?? undefined, + contextTokens: defaults?.contextTokens ?? null, + thinkingLevels: defaults?.thinkingLevels ?? emptySessionInfoDefaults?.thinkingLevels, + inputTokens: null, + outputTokens: null, + totalTokens: null, + goal: undefined, + updatedAt: null, + displayName: undefined, + }; + clearLocalRunIds?.(); + updateHeader(); + updateAutocompleteProvider(); + updateFooter(); + clearDisplayedSession(); + }; + const abortActive = async (params?: { preferActive?: boolean }) => { if ( opts.local === true && @@ -455,8 +514,10 @@ export function createSessionActions(context: SessionActionContext) { refreshAgents, refreshSessionInfo, applySessionInfoFromPatch, + applySessionMutationResult, loadHistory, setSession, + setEmptySession, abortActive, }; } diff --git a/src/tui/tui.ts b/src/tui/tui.ts index 07b21d52ece..ee771dd6011 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -476,9 +476,17 @@ export function resolveTuiCtrlCAction(params: { return resolveCtrlCAction(params); } +function resolveEmptySessionInfoDefaults(config: OpenClawConfig): SessionInfo { + return { + fastMode: config.agents?.defaults?.fastModeDefault, + verboseLevel: config.agents?.defaults?.verboseDefault, + }; +} + export async function runTui(opts: RunTuiOptions): Promise { const isLocalMode = opts.local === true || opts.backend !== undefined; const config = opts.config ?? getRuntimeConfig({ skipPluginValidation: !isLocalMode }); + const emptySessionInfoDefaults = resolveEmptySessionInfoDefaults(config); const initialSessionInput = (opts.session ?? "").trim(); let sessionScope: SessionScope = (config.session?.scope ?? "per-sender") as SessionScope; let sessionMainKey = normalizeMainKey(config.session?.mainKey); @@ -510,7 +518,7 @@ export async function runTui(opts: RunTuiOptions): Promise { const deliverDefault = opts.deliver ?? false; const autoMessage = opts.message?.trim(); let autoMessageSent = false; - let sessionInfo: SessionInfo = {}; + let sessionInfo: SessionInfo = { ...emptySessionInfoDefaults }; let dynamicSlashCommands: CommandEntry[] = []; let dynamicSlashCommandsKey: string | null = null; let dynamicSlashCommandsInFlightKey: string | null = null; @@ -1209,13 +1217,16 @@ export async function runTui(opts: RunTuiOptions): Promise { setActivityStatus, clearLocalRunIds, rememberSessionKey: rememberCurrentSessionKey, + emptySessionInfoDefaults, }); const { refreshAgents, refreshSessionInfo, applySessionInfoFromPatch, + applySessionMutationResult, loadHistory, setSession, + setEmptySession, abortActive, } = sessionActions; @@ -1306,8 +1317,10 @@ export async function runTui(opts: RunTuiOptions): Promise { closeOverlay, refreshSessionInfo, applySessionInfoFromPatch, + applySessionMutationResult, loadHistory, setSession, + setEmptySession, refreshAgents, abortActive, setActivityStatus,