mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-08 10:12:54 +00:00
perf: speed up tui session refresh
This commit is contained in:
@@ -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> = {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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[]>;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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))}`);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user