Skip to content

Commit 18dc6e5

Browse files
committed
perf: speed up tui session refresh
1 parent 9a4b631 commit 18dc6e5

10 files changed

Lines changed: 421 additions & 36 deletions

src/gateway/session-utils.search.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,30 @@ describe("listSessionsFromStore search", () => {
237237
}
238238
});
239239

240+
test("keeps derived model search for colon model ids", () => {
241+
const now = Date.now();
242+
const cfg = createModelDefaultsConfig({
243+
primary: "ollama/qwen3:0.6b",
244+
});
245+
const result = listSessionsFromStore({
246+
cfg,
247+
storePath: "/tmp/sessions.json",
248+
store: {
249+
"agent:main:inherited-local-model": {
250+
sessionId: "sess-inherited-local-model",
251+
updatedAt: now,
252+
label: "Inherited local model",
253+
} as SessionEntry,
254+
},
255+
opts: { search: "qwen3:0.6b" },
256+
});
257+
258+
expect(result.sessions.map((session) => session.key)).toEqual([
259+
"agent:main:inherited-local-model",
260+
]);
261+
expect(result.totalCount).toBe(1);
262+
});
263+
240264
test("hides cron run alias session keys from sessions list", () => {
241265
const now = Date.now();
242266
const store: Record<string, SessionEntry> = {

src/gateway/session-utils.ts

Lines changed: 101 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,8 @@ type SessionListRowContext = {
445445
modelCostConfigByModelRef: Map<string, ModelCostConfig | undefined>;
446446
};
447447

448+
type SessionListRowContextProvider = () => SessionListRowContext;
449+
448450
type SingleRowChildSessionCandidateCacheEntry = {
449451
store: Record<string, SessionEntry>;
450452
storeVersion: number;
@@ -671,16 +673,33 @@ function buildSessionListRowContext(params: {
671673
now: number;
672674
}): SessionListRowContext {
673675
const subagentRuns = buildSubagentRunReadIndex(params.now);
674-
return {
676+
return buildSessionListRowContextFromParts({
675677
subagentRuns,
676678
storeChildSessionsByKey: buildStoreChildSessionIndex(params.store, params.now, subagentRuns),
679+
});
680+
}
681+
682+
function buildSessionListRowContextFromParts(params: {
683+
subagentRuns: ReturnType<typeof buildSubagentRunReadIndex>;
684+
storeChildSessionsByKey: Map<string, string[]>;
685+
}): SessionListRowContext {
686+
return {
687+
subagentRuns: params.subagentRuns,
688+
storeChildSessionsByKey: params.storeChildSessionsByKey,
677689
selectedModelByOverrideRef: new Map(),
678690
thinkingMetadataByModelRef: new Map(),
679691
displayModelIdentityByKey: new Map(),
680692
modelCostConfigByModelRef: new Map(),
681693
};
682694
}
683695

696+
function buildSessionListRowMetadataContext(params: { now: number }): SessionListRowContext {
697+
return buildSessionListRowContextFromParts({
698+
subagentRuns: buildSubagentRunReadIndex(params.now),
699+
storeChildSessionsByKey: new Map(),
700+
});
701+
}
702+
684703
function buildSingleRowStoreChildSessionsByKey(params: {
685704
store: Record<string, SessionEntry>;
686705
storePath: string;
@@ -2180,6 +2199,37 @@ function addSessionListSearchModelFields(
21802199
}
21812200
}
21822201

2202+
function matchesSessionListSearch(fields: Array<string | undefined>, search: string): boolean {
2203+
return fields.some(
2204+
(field) => typeof field === "string" && normalizeLowercaseStringOrEmpty(field).includes(search),
2205+
);
2206+
}
2207+
2208+
function appendStoredSessionModelSearchFields(
2209+
fields: Array<string | undefined>,
2210+
entry?: SessionEntry,
2211+
) {
2212+
const provider = normalizeOptionalString(entry?.modelProvider);
2213+
const model = normalizeOptionalString(entry?.model);
2214+
fields.push(provider, model);
2215+
if (provider && model) {
2216+
fields.push(`${provider}/${model}`);
2217+
}
2218+
}
2219+
2220+
function shouldResolveDerivedSessionModelSearchFields(search: string): boolean {
2221+
// Agent session-key searches are already covered by cheap key fields; do not
2222+
// hydrate model metadata for every non-matching row on hot TUI lookups.
2223+
return !search.startsWith("agent:");
2224+
}
2225+
2226+
function resolveSessionListRowContext(params: {
2227+
rowContext?: SessionListRowContext;
2228+
getRowContext?: SessionListRowContextProvider;
2229+
}): SessionListRowContext | undefined {
2230+
return params.rowContext ?? params.getRowContext?.();
2231+
}
2232+
21832233
function resolveSessionListSearchModelFields(params: {
21842234
cfg: OpenClawConfig;
21852235
key: string;
@@ -2356,9 +2406,9 @@ function filterSessionEntries(params: {
23562406
opts: SessionsListParams;
23572407
now: number;
23582408
rowContext?: SessionListRowContext;
2409+
getRowContext?: SessionListRowContextProvider;
23592410
}): SessionEntryPair[] {
23602411
const { cfg, store, opts, now } = params;
2361-
const rowContext = params.rowContext;
23622412
const includeGlobal = opts.includeGlobal === true;
23632413
const includeUnknown = opts.includeUnknown === true;
23642414
const spawnedBy = typeof opts.spawnedBy === "string" ? opts.spawnedBy : "";
@@ -2403,8 +2453,9 @@ function filterSessionEntries(params: {
24032453
if (key === "unknown" || key === "global") {
24042454
return false;
24052455
}
2406-
const latest = rowContext
2407-
? rowContext.subagentRuns.getDisplaySubagentRun(key)
2456+
const filterRowContext = resolveSessionListRowContext(params);
2457+
const latest = filterRowContext
2458+
? filterRowContext.subagentRuns.getDisplaySubagentRun(key)
24082459
: getSessionDisplaySubagentRunByChildSessionKey(key);
24092460
if (latest) {
24102461
const latestControllerSessionKey =
@@ -2413,8 +2464,8 @@ function filterSessionEntries(params: {
24132464
return (
24142465
latestControllerSessionKey === spawnedBy &&
24152466
shouldKeepSubagentRunChildLink(latest, {
2416-
activeDescendants: rowContext
2417-
? rowContext.subagentRuns.countActiveDescendantRuns(key)
2467+
activeDescendants: filterRowContext
2468+
? filterRowContext.subagentRuns.countActiveDescendantRuns(key)
24182469
: countActiveDescendantRuns(key),
24192470
now,
24202471
})
@@ -2434,21 +2485,29 @@ function filterSessionEntries(params: {
24342485

24352486
if (search) {
24362487
entries = entries.filter(([key, entry]) => {
2437-
const fields = [
2488+
const cheapFields = [
24382489
resolveSessionListSearchDisplayName(key, entry),
24392490
entry?.label,
24402491
entry?.subject,
24412492
entry?.sessionId,
24422493
key,
2443-
...resolveSessionListSearchModelFields({
2494+
];
2495+
appendStoredSessionModelSearchFields(cheapFields, entry);
2496+
if (matchesSessionListSearch(cheapFields, search)) {
2497+
return true;
2498+
}
2499+
if (!shouldResolveDerivedSessionModelSearchFields(search)) {
2500+
return false;
2501+
}
2502+
const searchRowContext = resolveSessionListRowContext(params);
2503+
return matchesSessionListSearch(
2504+
resolveSessionListSearchModelFields({
24442505
cfg,
24452506
key,
24462507
entry,
2447-
rowContext,
2508+
rowContext: searchRowContext,
24482509
}),
2449-
];
2450-
return fields.some(
2451-
(f) => typeof f === "string" && normalizeLowercaseStringOrEmpty(f).includes(search),
2510+
search,
24522511
);
24532512
});
24542513
}
@@ -2467,6 +2526,7 @@ function selectSessionEntries(params: {
24672526
opts: SessionsListParams;
24682527
now: number;
24692528
rowContext?: SessionListRowContext;
2529+
getRowContext?: SessionListRowContextProvider;
24702530
defaultLimit?: number;
24712531
}): SessionEntrySelection {
24722532
const filtered = filterSessionEntries(params);
@@ -2494,6 +2554,7 @@ export function filterAndSortSessionEntries(params: {
24942554
opts: SessionsListParams;
24952555
now: number;
24962556
rowContext?: SessionListRowContext;
2557+
getRowContext?: SessionListRowContextProvider;
24972558
}): [string, SessionEntry][] {
24982559
return selectSessionEntries(params).entries;
24992560
}
@@ -2523,20 +2584,30 @@ export function listSessionsFromStore(params: {
25232584
store,
25242585
opts,
25252586
now,
2526-
rowContext:
2587+
getRowContext:
25272588
hasSpawnedByFilter || Boolean(normalizeOptionalString(opts.search))
2528-
? getRowContext()
2589+
? getRowContext
25292590
: undefined,
25302591
defaultLimit: SESSIONS_LIST_DEFAULT_LIMIT,
25312592
});
25322593
const { entries, totalCount, limitApplied, offset, nextOffset, hasMore } = selection;
2594+
const fullRowContext =
2595+
rowContext || hasSpawnedByFilter || entries.length > SESSIONS_LIST_YIELD_BATCH_SIZE
2596+
? getRowContext()
2597+
: undefined;
2598+
const sharedRowContext =
2599+
fullRowContext ??
2600+
(entries.length > 1 ? buildSessionListRowMetadataContext({ now }) : undefined);
25332601

25342602
const sessions = entries.map(([key, entry], index) => {
25352603
const includeTranscriptFields = index < sessionListTranscriptFieldRows;
25362604
const rowAgentId =
25372605
key === "global" && typeof opts.agentId === "string"
25382606
? normalizeAgentId(opts.agentId)
25392607
: undefined;
2608+
const storeChildSessionsByKey =
2609+
fullRowContext?.storeChildSessionsByKey ??
2610+
buildSingleRowStoreChildSessionsByKey({ store, storePath, key, now });
25402611
return buildGatewaySessionRow({
25412612
cfg,
25422613
storePath,
@@ -2549,8 +2620,8 @@ export function listSessionsFromStore(params: {
25492620
includeDerivedTitles: includeTranscriptFields && includeDerivedTitles,
25502621
includeLastMessage: includeTranscriptFields && includeLastMessage,
25512622
transcriptUsageMaxBytes: sessionListTranscriptUsageMaxBytes,
2552-
storeChildSessionsByKey: getRowContext().storeChildSessionsByKey,
2553-
rowContext: getRowContext(),
2623+
storeChildSessionsByKey,
2624+
rowContext: sharedRowContext,
25542625
});
25552626
});
25562627

@@ -2603,13 +2674,20 @@ export async function listSessionsFromStoreAsync(params: {
26032674
store,
26042675
opts,
26052676
now,
2606-
rowContext:
2677+
getRowContext:
26072678
hasSpawnedByFilter || Boolean(normalizeOptionalString(opts.search))
2608-
? getRowContext()
2679+
? getRowContext
26092680
: undefined,
26102681
defaultLimit: SESSIONS_LIST_DEFAULT_LIMIT,
26112682
});
26122683
const { entries, totalCount, limitApplied, offset, nextOffset, hasMore } = selection;
2684+
const fullRowContext =
2685+
rowContext || hasSpawnedByFilter || entries.length > SESSIONS_LIST_YIELD_BATCH_SIZE
2686+
? getRowContext()
2687+
: undefined;
2688+
const sharedRowContext =
2689+
fullRowContext ??
2690+
(entries.length > 1 ? buildSessionListRowMetadataContext({ now }) : undefined);
26132691

26142692
const sessions: GatewaySessionRow[] = [];
26152693
for (let i = 0; i < entries.length; i++) {
@@ -2619,6 +2697,9 @@ export async function listSessionsFromStoreAsync(params: {
26192697
key === "global" && typeof opts.agentId === "string"
26202698
? normalizeAgentId(opts.agentId)
26212699
: undefined;
2700+
const storeChildSessionsByKey =
2701+
fullRowContext?.storeChildSessionsByKey ??
2702+
buildSingleRowStoreChildSessionsByKey({ store, storePath, key, now });
26222703
const row = buildGatewaySessionRow({
26232704
cfg,
26242705
storePath,
@@ -2631,8 +2712,8 @@ export async function listSessionsFromStoreAsync(params: {
26312712
includeDerivedTitles: false,
26322713
includeLastMessage: false,
26332714
transcriptUsageMaxBytes: sessionListTranscriptUsageMaxBytes,
2634-
storeChildSessionsByKey: getRowContext().storeChildSessionsByKey,
2635-
rowContext: getRowContext(),
2715+
storeChildSessionsByKey,
2716+
rowContext: sharedRowContext,
26362717
skipTranscriptUsageFallback: true,
26372718
lightweightListRow: true,
26382719
});

src/tui/embedded-backend.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -539,7 +539,7 @@ export class EmbeddedTuiBackend implements TuiBackend {
539539
if (!result.ok) {
540540
throw new Error(result.error.message);
541541
}
542-
return { ok: true, key: result.key, entry: result.entry };
542+
return { ok: true as const, key: result.key, entry: result.entry };
543543
}
544544

545545
async getGatewayStatus() {

src/tui/gateway-chat.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import type {
3636
TuiEvent,
3737
TuiModelChoice,
3838
TuiSessionList,
39+
TuiSessionMutationResult,
3940
} from "./tui-backend.js";
4041

4142
export type GatewayConnectionOptions = {
@@ -242,8 +243,12 @@ export class GatewayChatClient implements TuiBackend {
242243
return await this.client.request<SessionsPatchResult>("sessions.patch", opts);
243244
}
244245

245-
async resetSession(key: string, reason?: "new" | "reset", opts?: { agentId?: string }) {
246-
return await this.client.request("sessions.reset", {
246+
async resetSession(
247+
key: string,
248+
reason?: "new" | "reset",
249+
opts?: { agentId?: string },
250+
): Promise<TuiSessionMutationResult> {
251+
return await this.client.request<TuiSessionMutationResult>("sessions.reset", {
247252
key,
248253
...(opts?.agentId ? { agentId: opts.agentId } : {}),
249254
...(reason ? { reason } : {}),

src/tui/tui-backend.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,19 @@ export type TuiModelChoice = {
105105
reasoning?: boolean;
106106
};
107107

108+
export type TuiSessionMutationResult = {
109+
ok?: boolean;
110+
key?: string;
111+
entry?: Partial<SessionInfo> & {
112+
sessionId?: string;
113+
updatedAt?: number | null;
114+
};
115+
resolved?: {
116+
modelProvider?: string;
117+
model?: string;
118+
};
119+
};
120+
108121
export type TuiBackend = {
109122
connection: {
110123
url: string;
@@ -131,7 +144,7 @@ export type TuiBackend = {
131144
key: string,
132145
reason?: "new" | "reset",
133146
opts?: { agentId?: string },
134-
) => Promise<unknown>;
147+
) => Promise<TuiSessionMutationResult>;
135148
getGatewayStatus: () => Promise<unknown>;
136149
listModels: () => Promise<TuiModelChoice[]>;
137150
listCommands?: (opts?: CommandsListParams) => Promise<CommandEntry[]>;

0 commit comments

Comments
 (0)