perf: speed up tui session refresh

This commit is contained in:
Peter Steinberger
2026-05-31 15:26:53 +01:00
parent 9a4b631a1d
commit 18dc6e5cd4
10 changed files with 421 additions and 36 deletions

View File

@@ -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<string, SessionEntry> = {

View File

@@ -445,6 +445,8 @@ type SessionListRowContext = {
modelCostConfigByModelRef: Map<string, ModelCostConfig | undefined>;
};
type SessionListRowContextProvider = () => SessionListRowContext;
type SingleRowChildSessionCandidateCacheEntry = {
store: Record<string, SessionEntry>;
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<typeof buildSubagentRunReadIndex>;
storeChildSessionsByKey: Map<string, string[]>;
}): 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<string, SessionEntry>;
storePath: string;
@@ -2180,6 +2199,37 @@ function addSessionListSearchModelFields(
}
}
function matchesSessionListSearch(fields: Array<string | undefined>, search: string): boolean {
return fields.some(
(field) => typeof field === "string" && normalizeLowercaseStringOrEmpty(field).includes(search),
);
}
function appendStoredSessionModelSearchFields(
fields: Array<string | undefined>,
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,
});

View File

@@ -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() {

View File

@@ -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<SessionsPatchResult>("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<TuiSessionMutationResult> {
return await this.client.request<TuiSessionMutationResult>("sessions.reset", {
key,
...(opts?.agentId ? { agentId: opts.agentId } : {}),
...(reason ? { reason } : {}),

View File

@@ -105,6 +105,19 @@ export type TuiModelChoice = {
reasoning?: boolean;
};
export type TuiSessionMutationResult = {
ok?: boolean;
key?: string;
entry?: Partial<SessionInfo> & {
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<unknown>;
) => Promise<TuiSessionMutationResult>;
getGatewayStatus: () => Promise<unknown>;
listModels: () => Promise<TuiModelChoice[]>;
listCommands?: (opts?: CommandsListParams) => Promise<CommandEntry[]>;

View File

@@ -15,6 +15,7 @@ type SelectableOverlay = {
};
type SetActivityStatusMock = ReturnType<typeof vi.fn> & ((text: string) => void);
type SetSessionMock = ReturnType<typeof vi.fn> & ((key: string) => Promise<void>);
type SetEmptySessionMock = ReturnType<typeof vi.fn> & ((key: string) => Promise<void>);
type ConsumeCompletedRunMock = ReturnType<typeof vi.fn> & ((runId: string) => boolean);
type FlushPendingHistoryRefreshMock = ReturnType<typeof vi.fn> & (() => void);
@@ -69,9 +70,11 @@ function createHarness(params?: {
runGoalCommand?: ReturnType<typeof vi.fn>;
runAuthFlow?: RunAuthFlow;
setSession?: SetSessionMock;
setEmptySession?: SetEmptySessionMock;
loadHistory?: LoadHistoryMock;
refreshSessionInfo?: ReturnType<typeof vi.fn>;
applySessionInfoFromPatch?: ReturnType<typeof vi.fn>;
applySessionMutationResult?: ReturnType<typeof vi.fn>;
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,
});

View File

@@ -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<void>;
loadHistory: () => Promise<void>;
setSession: (key: string) => Promise<void>;
setEmptySession: (key: string) => Promise<void>;
refreshAgents: () => Promise<void>;
abortActive: (params?: { preferActive?: boolean }) => Promise<void>;
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))}`);
}

View File

@@ -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();

View File

@@ -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<void>;
emptySessionInfoDefaults?: SessionInfo;
};
type SessionInfoDefaults = {
@@ -66,6 +67,7 @@ export function createSessionActions(context: SessionActionContext) {
setActivityStatus,
clearLocalRunIds,
rememberSessionKey,
emptySessionInfoDefaults,
} = context;
let refreshSessionInfoPromise: Promise<void> = 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,
};
}

View File

@@ -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<TuiResult> {
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<TuiResult> {
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<TuiResult> {
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<TuiResult> {
closeOverlay,
refreshSessionInfo,
applySessionInfoFromPatch,
applySessionMutationResult,
loadHistory,
setSession,
setEmptySession,
refreshAgents,
abortActive,
setActivityStatus,