Skip to content

Commit aaa2bb9

Browse files
committed
fix(tui): prewarm agent runtime before first send
1 parent 961a0f6 commit aaa2bb9

6 files changed

Lines changed: 111 additions & 3 deletions

File tree

src/tui/embedded-backend.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
import { randomUUID } from "node:crypto";
22
import { agentCommandFromIngress } from "../agents/agent-command.js";
3-
import { resolveSessionAgentId } from "../agents/agent-scope.js";
3+
import {
4+
resolveAgentDir,
5+
resolveAgentWorkspaceDir,
6+
resolveSessionAgentId,
7+
} from "../agents/agent-scope.js";
48
import { ensureContextWindowCacheLoaded } from "../agents/context.js";
59
import { DEFAULT_PROVIDER } from "../agents/defaults.js";
10+
import { prewarmAgentHarnessRuntime } from "../agents/harness/prewarm.js";
611
import {
712
buildAllowedModelSet,
813
buildConfiguredModelCatalog,
@@ -385,7 +390,6 @@ export class EmbeddedTuiBackend implements TuiBackend {
385390
catalog,
386391
});
387392
}
388-
389393
return {
390394
sessionKey: opts.sessionKey,
391395
sessionId,
@@ -396,6 +400,28 @@ export class EmbeddedTuiBackend implements TuiBackend {
396400
};
397401
}
398402

403+
async prewarmAgentRuntime(opts: { sessionKey: string }) {
404+
const { cfg, entry } = loadSessionEntry(opts.sessionKey);
405+
const sessionId = entry?.sessionId;
406+
const sessionAgentId = resolveSessionAgentId({ sessionKey: opts.sessionKey, config: cfg });
407+
const resolvedSessionModel = resolveSessionModelRef(cfg, entry, sessionAgentId);
408+
const agentRuntimePrewarm = await prewarmAgentHarnessRuntime({
409+
cfg,
410+
provider: resolvedSessionModel.provider,
411+
modelId: resolvedSessionModel.model,
412+
agentId: sessionAgentId,
413+
sessionKey: opts.sessionKey,
414+
sessionId,
415+
sessionFile: entry?.sessionFile,
416+
agentDir: resolveAgentDir(cfg, sessionAgentId),
417+
workspaceDir: entry?.spawnedWorkspaceDir ?? resolveAgentWorkspaceDir(cfg, sessionAgentId),
418+
authProfileId: entry?.authProfileOverride,
419+
authProfileIdSource: entry?.authProfileOverrideSource,
420+
reason: "tui-startup",
421+
});
422+
return { agentRuntimePrewarm };
423+
}
424+
399425
async listSessions(opts?: Parameters<TuiBackend["listSessions"]>[0]): Promise<TuiSessionList> {
400426
const cfg = getRuntimeConfig();
401427
const { storePath, store } = loadCombinedSessionStoreForGateway(cfg);

src/tui/tui-backend.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,11 @@ export type TuiBackend = {
114114
sessionKey: string;
115115
runId: string;
116116
}) => Promise<{ ok: boolean; aborted: boolean }>;
117-
loadHistory: (opts: { sessionKey: string; limit?: number }) => Promise<unknown>;
117+
loadHistory: (opts: {
118+
sessionKey: string;
119+
limit?: number;
120+
}) => Promise<unknown>;
121+
prewarmAgentRuntime?: (opts: { sessionKey: string }) => Promise<unknown>;
118122
listSessions: (opts?: SessionsListParams) => Promise<TuiSessionList>;
119123
listAgents: () => Promise<TuiAgentsList>;
120124
patchSession: (opts: SessionsPatchParams) => Promise<SessionsPatchResult>;

src/tui/tui-session-actions.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,4 +492,43 @@ describe("tui session actions", () => {
492492
expect(state.currentSessionId).toBe("session-main");
493493
expect(rememberSessionKey).toHaveBeenCalledWith("agent:main:main");
494494
});
495+
496+
it("requests runtime prewarm while loading the first history snapshot", async () => {
497+
const loadHistory = vi.fn().mockResolvedValue({
498+
sessionId: "session-main",
499+
messages: [],
500+
});
501+
const prewarmAgentRuntime = vi.fn().mockResolvedValue({
502+
agentRuntimePrewarm: { status: "warmed" },
503+
});
504+
const setActivityStatus = vi.fn();
505+
const state = createBaseState({ historyLoaded: false });
506+
507+
const { loadHistory: runLoadHistory } = createTestSessionActions({
508+
client: {
509+
listSessions: vi.fn().mockResolvedValue({
510+
ts: Date.now(),
511+
path: "/tmp/sessions.json",
512+
count: 0,
513+
defaults: {},
514+
sessions: [],
515+
}),
516+
loadHistory,
517+
prewarmAgentRuntime,
518+
} as unknown as TuiBackend,
519+
state,
520+
setActivityStatus,
521+
});
522+
523+
await runLoadHistory();
524+
525+
expect(loadHistory).toHaveBeenCalledWith({
526+
sessionKey: "agent:main:main",
527+
limit: 200,
528+
});
529+
expect(prewarmAgentRuntime).toHaveBeenCalledWith({ sessionKey: "agent:main:main" });
530+
expect(setActivityStatus).toHaveBeenNthCalledWith(1, "warming runtime");
531+
expect(setActivityStatus).toHaveBeenLastCalledWith("idle");
532+
});
533+
495534
});

src/tui/tui-session-actions.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,16 @@ export function createSessionActions(context: SessionActionContext) {
295295
};
296296

297297
const loadHistory = async () => {
298+
const shouldPrewarmRuntime = !state.historyLoaded;
299+
if (shouldPrewarmRuntime) {
300+
setActivityStatus("warming runtime");
301+
}
302+
const prewarmPromise =
303+
shouldPrewarmRuntime && client.prewarmAgentRuntime
304+
? client
305+
.prewarmAgentRuntime({ sessionKey: state.currentSessionKey })
306+
.catch((err) => ({ agentRuntimePrewarm: { status: "failed", error: String(err) } }))
307+
: undefined;
298308
try {
299309
const history = await client.loadHistory({
300310
sessionKey: state.currentSessionKey,
@@ -367,11 +377,23 @@ export function createSessionActions(context: SessionActionContext) {
367377
}
368378
}
369379
state.historyLoaded = true;
380+
tui.requestRender();
381+
const prewarm = (await prewarmPromise) as
382+
| { agentRuntimePrewarm?: { status?: string; error?: string } }
383+
| undefined;
384+
if (prewarm?.agentRuntimePrewarm?.status === "failed") {
385+
chatLog.addSystem(
386+
`runtime prewarm failed: ${prewarm.agentRuntimePrewarm.error ?? "unknown"}`,
387+
);
388+
}
370389
void rememberSessionKey?.(state.currentSessionKey);
371390
} catch (err) {
372391
chatLog.addSystem(`history failed: ${String(err)}`);
373392
}
374393
await refreshSessionInfo();
394+
if (shouldPrewarmRuntime) {
395+
setActivityStatus("idle");
396+
}
375397
tui.requestRender();
376398
};
377399

src/tui/tui.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,12 +118,25 @@ describe("canSubmitTuiChatMessage", () => {
118118
}),
119119
).toBe(false);
120120
});
121+
122+
it("blocks the first submit while runtime prewarm is active", () => {
123+
expect(
124+
canSubmitTuiChatMessage({
125+
local: false,
126+
activityStatus: "warming runtime",
127+
}),
128+
).toBe(false);
129+
});
121130
});
122131

123132
describe("isTuiBusyActivityStatus", () => {
124133
it("treats finishing context as a visible busy status", () => {
125134
expect(isTuiBusyActivityStatus("finishing context")).toBe(true);
126135
});
136+
137+
it("treats runtime warmup as a visible busy status", () => {
138+
expect(isTuiBusyActivityStatus("warming runtime")).toBe(true);
139+
});
127140
});
128141

129142
describe("resolveTuiShutdownHardExitMs", () => {

src/tui/tui.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,9 @@ export function canSubmitTuiChatMessage(params: {
374374
pendingOptimisticUserMessage?: boolean;
375375
}): boolean {
376376
const pending = Boolean(params.pendingChatRunId) || params.pendingOptimisticUserMessage === true;
377+
if (params.activityStatus === "warming runtime") {
378+
return false;
379+
}
377380
if (params.activeChatRunId) {
378381
return params.local === true && params.activityStatus === "finishing context" && !pending;
379382
}
@@ -385,6 +388,7 @@ const TUI_BUSY_ACTIVITY_STATUSES = new Set([
385388
"waiting",
386389
"streaming",
387390
"running",
391+
"warming runtime",
388392
"finishing context",
389393
]);
390394

0 commit comments

Comments
 (0)