Something went wrong. Try again.
fork of prime agent with changes i want but are not upstreamable probqbly (and some fixes)
Something went wrong. Try again.
87 kB · 2286 lines
TypeScript
at main
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287import { getModel } from "@earendil-works/pi-ai";import { setKeybindings } from "@earendil-works/pi-tui";import stripAnsi from "strip-ansi";import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";import { KeybindingsManager } from "../src/core/keybindings.js";import { MANUAL_CONTINUATION_CUSTOM_TYPE, MANUAL_CONTINUATION_PROMPT } from "../src/core/messages.js";import type { ModelRegistry } from "../src/core/model-registry.js";import { SettingsManager } from "../src/core/settings-manager.js";import { DaemonAgentConnection } from "../src/modes/agent-connection/daemon-agent-connection.js";import type { AgentConnectionSavedSessionInfo } from "../src/modes/agent-connection/types.js";import { AgentsViewMode, type AgentsViewPersistentState, buildCompactAgentsViewLayout, createInitialAgentsViewPersistentState, runAgentsViewMode,} from "../src/modes/agents-view/agents-view-mode.js";import * as agentsViewState from "../src/modes/agents-view/agents-view-state.js";import { type AgentsViewRow, buildAgentsViewRows, reconcileUnifiedSessions, resolveAgentsViewLeftResult, type UnifiedSessionRecord,} from "../src/modes/agents-view/agents-view-state.js";import type { SessionSummary } from "../src/modes/daemon/daemon-session-list.js";import * as savedSessionCatalog from "../src/modes/daemon/saved-session-catalog.js";import type { InteractiveModeUiServices } from "../src/modes/interactive/interactive-mode-services.js";import { initTheme, stopThemeWatcher, theme } from "../src/modes/interactive/theme/theme.js";import { WORKING_ICON_INTERVAL_MS } from "../src/modes/interactive/theme/working-icon.js";
const modeMocks = vi.hoisted(() => ({ interactiveRun: vi.fn<() => Promise<never>>(), teardownSessionUi: vi.fn(async () => undefined), dispose: vi.fn(async () => undefined), connectionPrompt: vi.fn(async () => undefined), clientRequest: vi.fn<() => Promise<unknown>>(),}));
vi.mock("../src/config.js", async (importOriginal) => { const actual = await importOriginal<typeof import("../src/config.js")>(); return { ...actual, appendRotatingLog: vi.fn() };});
vi.mock("../src/modes/daemon/daemon-client.js", () => ({ DaemonClient: class { connect = vi.fn(async () => undefined); close = vi.fn(); request = modeMocks.clientRequest; }, getDaemonSocketCloseReason: vi.fn(),}));
vi.mock("../src/modes/agent-connection/daemon-agent-connection.js", () => ({ DaemonAgentConnection: Object.assign(function DaemonAgentConnection() {}, { attach: vi.fn(async () => ({ prompt: modeMocks.connectionPrompt, dispose: modeMocks.dispose })), }),}));
vi.mock("../src/modes/interactive/interactive-mode.js", async (importOriginal) => { const actual = await importOriginal<typeof import("../src/modes/interactive/interactive-mode.js")>(); return { ...actual, InteractiveMode: class { run = modeMocks.interactiveRun; teardownSessionUi = modeMocks.teardownSessionUi; }, };});
function summary(overrides: Partial<SessionSummary> = {}): SessionSummary { return { id: "scope-active", activeSessionId: "scope-active", lifecycle: "live", activity: "idle", isSessionActive: false, sessionId: "scope-session", sessionFile: "/tmp/scope.jsonl", cwd: "/tmp", isStreaming: false, isCompacting: false, attachedClients: 0, messageCount: 1, sessionActions: { queuedCount: 0, steering: [], followUps: [] }, ...overrides, };}
function invoke(method: string, self: object, ...args: unknown[]): unknown { const member = Reflect.get(AgentsViewMode.prototype, method) as ((...a: unknown[]) => unknown) | undefined; if (typeof member !== "function") throw new Error(`AgentsViewMode.${method} no longer exists`); return member.call(self, ...args);}
function savedSession(id: string): AgentConnectionSavedSessionInfo { return { id, path: `/tmp/${id}.jsonl`, cwd: "/tmp", created: new Date(0), modified: new Date(0), messageCount: 1, firstMessage: id, allMessagesText: id, };}
function deferredSavedCatalog() { let resolve!: (sessions: AgentConnectionSavedSessionInfo[]) => void; let reject!: (error: Error) => void; let onSession: ((session: AgentConnectionSavedSessionInfo) => void) | undefined; const promise = new Promise<AgentConnectionSavedSessionInfo[]>((res, rej) => { resolve = res; reject = rej; }); vi.spyOn(savedSessionCatalog, "listDaemonSavedSessions").mockImplementationOnce( async (_client, _context, _scope, callbacks) => { onSession = callbacks?.onSession; return promise; }, ); return { resolve, reject, emit(session: AgentConnectionSavedSessionInfo): void { if (!onSession) throw new Error("Saved catalog refresh has not started"); onSession(session); }, };}
function catalogHarness(saved: AgentConnectionSavedSessionInfo[] = [], live: SessionSummary[] = []) { const persistentState: AgentsViewPersistentState = { savedSessions: saved, lastSuccessfulSavedSessions: saved, savedCatalogLoaded: true, }; const self = { persistentState, lastListedSummaries: live, savedSessions: saved, lastSuccessfulSavedSessions: saved, heartbeats: [], rows: [] as AgentsViewRow[], selectedIndex: 0, savedCatalogGeneration: 0, heartbeatCatalogGeneration: 0, savedCatalogReady: true, savedCatalogRefreshPending: false, savedCatalogReconcileTimer: undefined as ReturnType<typeof setTimeout> | undefined, stopped: false, inactiveAgentIdentities: new Set<string>(), expandedSubagentParents: new Set<string>(), programShownParents: new Set<string>(), editor: { getText: vi.fn(() => "") }, ui: { requestRender: vi.fn(), stop: vi.fn() }, requireClient: () => ({}), getSavedSessionCatalogContext: () => ({ cwd: "/tmp" }), withPendingDeleteSession: (sessions: SessionSummary[]) => sessions, getFilteredRecords(): UnifiedSessionRecord[] { return invoke("getFilteredRecords", self) as UnifiedSessionRecord[]; }, reconcileCatalogs: vi.fn((): void => { invoke("reconcileCatalogs", self); }), rebuildRows: vi.fn((): void => { invoke("rebuildRows", self); }), rearmSavedSearchFetch(): void { invoke("rearmSavedSearchFetch", self); }, armSavedSearchFetch: vi.fn(), applyPendingAncestorExpansion: vi.fn(), restoreSelection: vi.fn(), syncSelectedRowState: vi.fn(), resolveMissingSelectionAnchor: vi.fn(), clearCtrlCExitHint: vi.fn(), clearDeleteConfirmation: vi.fn(), setStatusMessage: vi.fn(), }; return self;}
const settingsManager = { getTheme: () => "dark", getShowHardwareCursor: () => false, getClearOnShrink: () => false, getEditorPaddingX: () => 0, getAutocompleteMaxVisible: () => 5,};
describe("AgentsViewMode", () => { beforeAll(() => setKeybindings(new KeybindingsManager())); beforeEach(() => vi.clearAllMocks());
it("keeps the queried selection when a remembered session arrives later", () => { const remembered = summary({ id: "remembered", activeSessionId: "remembered", sessionId: "remembered-session", sessionFile: "/tmp/remembered.jsonl", sessionName: "match remembered", }); const fallback = summary({ sessionName: "match fallback" }); const persistentState = createInitialAgentsViewPersistentState({ initialSession: remembered }); persistentState.savedCatalogLoaded = true; const view = new AgentsViewMode({ config: {}, uiServices: createUiServices() }, persistentState); try { Reflect.set(view, "lastListedSummaries", [fallback]); invoke("reconcileCatalogs", view); expect(Reflect.get(view, "selectionAnchorPending")).toBe(true);
invoke("setSearchQuery", view, "match"); expect(Reflect.get(view, "selectionAnchorPending")).toBe(false); expect(persistentState.selectedSessionKey?.sessionId).toBe(fallback.sessionId);
Reflect.set(view, "lastListedSummaries", [remembered, fallback]); invoke("reconcileCatalogs", view); const rows = Reflect.get(view, "rows") as AgentsViewRow[]; expect(rows[Reflect.get(view, "selectedIndex") as number]?.summary.sessionId).toBe(fallback.sessionId); } finally { stopThemeWatcher(); } });
it("loads the saved catalog on view entry without a search query", () => { const self = { savedSearchFetchStarted: false, persistentState: {}, refreshSavedSessions: vi.fn(async () => true), };
invoke("armSavedSearchFetch", self);
expect(self.refreshSavedSessions).toHaveBeenCalledOnce(); expect(self.savedSearchFetchStarted).toBe(true); }); // [name, row state, daemon delete capability, cancel result, warning] // A subagent row is only ever stopped, never deleted, while its subtree still works. it.each([ [ "an idle row whose subtree still works", { kind: "subagent", section: "idle", runningSubagentCount: 1, summary: summary({ id: "crew-parent" }) }, true, true, undefined, ], ["a subagent that started running during confirmation", { section: "running" }, true, true, undefined], [ "a daemon that cannot delete subagents", { section: "inactive", runningSubagentCount: 0, summary: summary() }, false, false, "The daemon cannot delete subagents; it was left unchanged", ], ] as const)("stops instead of deleting for %s", async (_name, row, supportsDelete, cancelled, warning) => { const request = vi.fn(async () => ({ success: true as const, data: { cancelled } })); const self = { requireClient: () => ({ request, supportsServerCapability: () => supportsDelete }), setStatusMessage: vi.fn(), refreshSessions: vi.fn(async () => true), };
await invoke( "killSubagent", self, { identity: "child-row", rootActiveSessionId: "root-active", childId: "passive-child" }, row, );
expect(request).toHaveBeenCalledWith({ type: "cancel_rlm_child", activeSessionId: "root-active", childId: "passive-child", }); expect(request).not.toHaveBeenCalledWith(expect.objectContaining({ type: "delete_rlm_subagent" })); if (warning) { expect(self.setStatusMessage).toHaveBeenCalledWith(warning, { render: false, tone: "warning" }); } });
it("re-resolves subagent state before choosing stop or delete intent", async () => { const child = summary({ id: "passive-child-session", activeSessionId: undefined, sessionId: "passive-child-session", runtimeKind: "subagent", rlmChildId: "passive-child", }); const request = vi.fn(async (command: { type: string }) => ({ success: true as const, data: command.type === "cancel_rlm_child" ? { cancelled: false } : { deleted: true }, })); const client = { request, supportsServerCapability: vi.fn(() => true) }; const self = { rows: [ { kind: "subagent", section: "running", summary: child, selectable: true, identity: "child-row", parentIdentity: "root-row", }, { kind: "agent", section: "idle", summary: summary({ id: "root-active", activeSessionId: "root-active", sessionId: "root-session" }), selectable: true, identity: "root-row", }, ], selectedIndex: 0, pendingDeleteAgent: undefined, pendingKillSubagent: undefined, deleteConfirmExpiresAt: 0, deleteConfirmTimer: undefined, ui: { requestRender: vi.fn() }, requireClient: () => client, setStatusMessage: vi.fn(), refreshSessions: vi.fn(async () => true), handleKillSubagentSelected(row: unknown) { return invoke("handleKillSubagentSelected", self, row); }, findSubagentRootRow(row: unknown) { return invoke("findSubagentRootRow", self, row); }, isDeleteConfirmationVisible() { return invoke("isDeleteConfirmationVisible", self); }, showDeleteConfirmation() { return invoke("showDeleteConfirmation", self); }, clearDeleteConfirmation(options: unknown) { return invoke("clearDeleteConfirmation", self, options); }, killSubagent(pending: unknown, row: unknown) { return invoke("killSubagent", self, pending, row); }, };
await invoke("handleDeleteSelected", self); expect(request).not.toHaveBeenCalled();
// The child finishes during confirmation. The second keypress must use the // current row state rather than the original running state. self.rows[0]!.section = "inactive"; await invoke("handleDeleteSelected", self); expect(request).toHaveBeenCalledWith({ type: "delete_rlm_subagent", activeSessionId: "root-active", childId: "passive-child", }); expect(request).not.toHaveBeenCalledWith(expect.objectContaining({ type: "cancel_rlm_child" })); expect(self.setStatusMessage).toHaveBeenCalledWith("Subagent deleted", { render: false }); });
it("uses cancel when an inactive subagent starts running during confirmation", async () => { const request = vi.fn(async () => ({ success: true as const, data: { cancelled: true } })); const self = { requireClient: () => ({ request, supportsServerCapability: () => true }), setStatusMessage: vi.fn(), refreshSessions: vi.fn(async () => true), }; await invoke( "killSubagent", self, { identity: "child-row", rootActiveSessionId: "root-active", childId: "passive-child" }, { section: "running" }, ); expect(request).toHaveBeenCalledWith({ type: "cancel_rlm_child", activeSessionId: "root-active", childId: "passive-child", }); expect(request).not.toHaveBeenCalledWith(expect.objectContaining({ type: "delete_rlm_subagent" })); });
it("falls back to cancel-only when subagent deletion is unsupported", async () => { const request = vi.fn(async () => ({ success: true as const, data: { cancelled: false } })); const self = { requireClient: () => ({ request, supportsServerCapability: () => false }), setStatusMessage: vi.fn(), refreshSessions: vi.fn(async () => true), }; await invoke( "killSubagent", self, { identity: "child-row", rootActiveSessionId: "root-active", childId: "passive-child" }, { section: "inactive", runningSubagentCount: 0, summary: summary() }, ); expect(request).toHaveBeenCalledWith({ type: "cancel_rlm_child", activeSessionId: "root-active", childId: "passive-child", }); expect(self.setStatusMessage).toHaveBeenCalledWith("The daemon cannot delete subagents; it was left unchanged", { render: false, tone: "warning", }); });
it("checks telemetry policy before replying from an opted-out agents view", async () => { const client = { close: vi.fn() }; const connectDedicatedClient = vi.fn(async () => client); const self = { options: { config: { telemetryDisabled: true }, recoverDaemon: vi.fn(async () => undefined), reconnectTimeoutMs: 1234, }, connectDedicatedClient, };
await invoke("sendPrompt", self, "active-1", "private prompt", "followUp");
expect(connectDedicatedClient).toHaveBeenCalledOnce(); expect(DaemonAgentConnection.attach).toHaveBeenCalledWith(client, "active-1", { closeClientOnDispose: true, supportsExtensionUi: false, recoverDaemon: self.options.recoverDaemon, reconnectTimeoutMs: 1234, telemetryDisabled: true, }); expect(modeMocks.connectionPrompt).toHaveBeenCalledWith("private prompt", { streamingBehavior: "followUp", }); expect(modeMocks.dispose).toHaveBeenCalledOnce(); });
it("keeps direct agents-view replies when telemetry is enabled", async () => { const request = vi.fn(async () => ({ success: true as const, data: undefined })); const self = { options: { config: {} }, requireClient: () => ({ request }), };
await invoke("sendPrompt", self, "active-1", "private prompt", "steer");
expect(request).toHaveBeenCalledWith({ type: "prompt", activeSessionId: "active-1", message: "private prompt", streamingBehavior: "steer", }); expect(DaemonAgentConnection.attach).not.toHaveBeenCalled(); });
it("sends a bare dot as a hidden continuation from the agents view", async () => { const request = vi.fn(async () => ({ success: true as const, data: undefined })); const self = { options: { config: {} }, requireClient: () => ({ request }), };
await invoke("sendPrompt", self, "active-1", ".", "steer");
expect(request).toHaveBeenCalledWith({ type: "prompt", activeSessionId: "active-1", message: ".", streamingBehavior: "steer", expandPromptTemplates: false, customMessage: expect.objectContaining({ role: "custom", customType: MANUAL_CONTINUATION_CUSTOM_TYPE, content: MANUAL_CONTINUATION_PROMPT, display: false, }), }); }); it("uses the opened session as the crash-path back target", async () => { const opened = summary({ sessionName: "opened" }); const previous = summary({ id: "previous", activeSessionId: "previous", sessionId: "previous" }); const runView = vi .spyOn(AgentsViewMode.prototype, "run") .mockResolvedValueOnce({ type: "open", summary: opened }) .mockImplementationOnce(function (this: AgentsViewMode) { const state = (this as unknown as { persistentState: AgentsViewPersistentState }).persistentState; expect(state.backSession).toMatchObject({ sessionId: opened.sessionId }); return Promise.resolve({ type: "exit" }); }); modeMocks.interactiveRun.mockRejectedValueOnce(new Error("post-attach crash"));
await runAgentsViewMode({ socketPath: "/tmp/fake-daemon.sock", config: { cwd: "/tmp", telemetryDisabled: true } as never, initialSession: previous, uiServices: { settingsManager: settingsManager as never, modelRegistry: {} as never, getInitialCwd: () => "/tmp", getInitialSessionName: () => undefined, getThemes: () => [], }, });
expect(modeMocks.teardownSessionUi).toHaveBeenCalledWith({ preserveAltScreen: true }); expect(modeMocks.dispose).toHaveBeenCalledOnce(); expect(DaemonAgentConnection.attach).toHaveBeenCalledWith( expect.anything(), opened.activeSessionId, expect.objectContaining({ telemetryDisabled: true }), ); runView.mockRestore(); });
// A frame change in either direction must invalidate the persisted scope root so the // next mount re-resolves it from the live catalog. it.each([ ["popping", "scope_back", 2, 1], ["pushing", "open", 1, 2], ] as const)( "invalidates the persisted scope root after %s a scope frame", async (_direction, resultType, initialFrames, expectedFrames) => { const parent = summary({ id: "parent", activeSessionId: "parent", sessionId: "parent" }); const child = summary({ id: "child", activeSessionId: "child", sessionId: "child" }); const frameOf = (session: SessionSummary) => ({ scope: { sessionId: session.sessionId, activeSessionId: session.activeSessionId }, }); const stateOf = (view: AgentsViewMode) => (view as unknown as { persistentState: AgentsViewPersistentState }).persistentState; const runView = vi .spyOn(AgentsViewMode.prototype, "run") .mockImplementationOnce(function (this: AgentsViewMode) { const state = stateOf(this); state.scopeFrames = initialFrames === 2 ? [frameOf(parent), frameOf(child)] : [frameOf(parent)]; state.scopeRootSummary = initialFrames === 2 ? child : parent; return Promise.resolve( resultType === "scope_back" ? { type: "scope_back" as const, selection: child, expandedAncestorSessionIds: [], hasChildren: false, } : { type: "open" as const, summary: child }, ); }) .mockImplementationOnce(function (this: AgentsViewMode) { const state = stateOf(this); expect(state.scopeFrames).toHaveLength(expectedFrames); expect(state.scopeRootSummary).toBeUndefined(); return Promise.resolve({ type: "exit" as const }); }); if (resultType === "open") { modeMocks.interactiveRun.mockResolvedValueOnce({ type: "scoped_agents_view", source: { activeSessionId: child.activeSessionId, sessionFile: child.sessionFile, sessionId: child.sessionId, sessionName: child.sessionName, cwd: child.cwd, }, } as never); }
await runAgentsViewMode({ socketPath: "/tmp/fake-daemon.sock", config: { cwd: "/tmp" } as never, uiServices: createUiServices(), });
runView.mockRestore(); }, );
it("does not discard scope while the saved-session refresh is in flight", async () => { let finishRefresh: ((value: { success: true; data: { sessions: unknown[] } }) => void) | undefined; const request = vi.fn( () => new Promise<{ success: true; data: { sessions: unknown[] } }>((resolve) => { finishRefresh = resolve; }), ); const scopeSummary = summary(); const persistentState: AgentsViewPersistentState = { scopeFrames: [{ scope: { sessionId: scopeSummary.sessionId, activeSessionId: scopeSummary.activeSessionId } }], }; const self: Record<string, unknown> = { options: { config: { cwd: "/tmp" } }, persistentState, savedCatalogGeneration: 0, savedCatalogReady: true, savedCatalogRefreshPending: false, lastSuccessfulSavedSessions: [], savedSessions: [], requireClient: () => ({ request, supportsServerCapability: () => false }), getSavedSessionCatalogContext: () => ({ cwd: "/tmp" }), reconcileCatalogs: vi.fn(), resolveMissingSelectionAnchor: vi.fn(), };
const refresh = invoke("refreshSavedSessions", self) as Promise<boolean>; await vi.waitFor(() => expect(request).toHaveBeenCalledOnce()); expect(self.savedCatalogReady).toBe(false);
Object.assign(self, { lastListedSummaries: [], heartbeats: [], inactiveAgentIdentities: new Set(), pendingDeleteAgent: undefined, scopeKey: persistentState.scopeFrames?.[0]?.scope, expandedSubagentParents: new Set(), programShownParents: new Set(), editor: { getText: () => "" }, getFilteredRecords: () => Reflect.get(self, "scopedRecords"), applyPendingAncestorExpansion: vi.fn(), restoreSelection: vi.fn(), ui: { requestRender: vi.fn() }, setStatusMessage: vi.fn(), withPendingDeleteSession: (sessions: SessionSummary[]) => sessions, }); self.reconcileCatalogs = () => invoke("reconcileCatalogs", self); invoke("reconcileCatalogs", self); expect(persistentState.scopeFrames).toHaveLength(1);
const saved: AgentConnectionSavedSessionInfo = { path: "/tmp/scope.jsonl", id: "scope-session", cwd: "/tmp", created: new Date("2026-01-01T00:00:00Z"), modified: new Date("2026-01-01T00:00:00Z"), messageCount: 1, firstMessage: "scope", allMessagesText: "scope", }; finishRefresh?.({ success: true, data: { sessions: [ { ...saved, created: saved.created.toISOString(), modified: saved.modified.toISOString(), }, ], }, }); await expect(refresh).resolves.toBe(true); expect(persistentState.scopeFrames).toHaveLength(1); });
it("carries the resolved scope root across view remounts", () => { const root = summary({ sessionName: "Scoped root" }); const persistentState: AgentsViewPersistentState = { scopeFrames: [{ scope: { sessionId: root.sessionId, activeSessionId: root.activeSessionId } }], }; const self: Record<string, unknown> = { persistentState, lastListedSummaries: [root], savedSessions: [], heartbeats: [], inactiveAgentIdentities: new Set(), pendingDeleteAgent: undefined, savedCatalogReady: true, scopeKey: persistentState.scopeFrames?.[0]?.scope, expandedSubagentParents: new Set(), programShownParents: new Set(), editor: { getText: () => "" }, getFilteredRecords: () => Reflect.get(self, "scopedRecords"), applyPendingAncestorExpansion: vi.fn(), restoreSelection: vi.fn(), ui: { requestRender: vi.fn() }, setStatusMessage: vi.fn(), withPendingDeleteSession: (sessions: SessionSummary[]) => sessions, }; invoke("reconcileCatalogs", self); expect(persistentState.scopeRootSummary).toMatchObject({ sessionId: root.sessionId });
const remount = new AgentsViewMode( { config: { cwd: "/tmp" } as never, uiServices: { settingsManager: settingsManager as never, modelRegistry: {} as never, getInitialCwd: () => "/tmp", getInitialSessionName: () => undefined, getThemes: () => [], }, }, persistentState, ) as AgentsViewMode & Record<string, unknown>; const remountedRoot = Reflect.get(remount, "scopeRootSummary") as SessionSummary; expect(remountedRoot).toMatchObject({ sessionName: "Scoped root" }); expect(resolveAgentsViewLeftResult(remountedRoot)).toMatchObject({ type: "scope_back", selection: { sessionId: root.sessionId }, }); });
it("restores expanded subagent lists across view remounts", () => { const persistentState: AgentsViewPersistentState = {}; const view = new AgentsViewMode({ config: {}, uiServices: createUiServices() }, persistentState); try { (Reflect.get(view, "expandedSubagentParents") as Set<string>).add("file:/tmp/root.jsonl"); (Reflect.get(view, "programShownParents") as Set<string>).add("file:/tmp/root.jsonl");
const remount = new AgentsViewMode({ config: {}, uiServices: createUiServices() }, persistentState); expect((Reflect.get(remount, "expandedSubagentParents") as Set<string>).has("file:/tmp/root.jsonl")).toBe( true, ); expect((Reflect.get(remount, "programShownParents") as Set<string>).has("file:/tmp/root.jsonl")).toBe(true);
// A collapsed-back list persists that way too. (Reflect.get(remount, "expandedSubagentParents") as Set<string>).delete("file:/tmp/root.jsonl"); const collapsedRemount = new AgentsViewMode({ config: {}, uiServices: createUiServices() }, persistentState); expect( (Reflect.get(collapsedRemount, "expandedSubagentParents") as Set<string>).has("file:/tmp/root.jsonl"), ).toBe(false); } finally { stopThemeWatcher(); } });
it("keeps subagent expansion across the active-to-persisted identity flip", () => { const rowsOf = (self: Record<string, unknown>) => Reflect.get(self, "rows") as AgentsViewRow[]; const buildView = (expand: boolean) => { const parent = summary({ id: "root-active", activeSessionId: "root-active", sessionId: "root-session", sessionFile: undefined, runtimeKind: "top-level", }); const child = summary({ id: "child-active", activeSessionId: "child-active", sessionId: "child-session", sessionFile: undefined, runtimeKind: "subagent", parentSessionId: "root-session", parentActiveSessionId: "root-active", }); const expandedSubagentParents = new Set<string>(); const self: Record<string, unknown> = { persistentState: {}, lastListedSummaries: [parent, child], savedSessions: [], heartbeats: [], inactiveAgentIdentities: new Set(), pendingDeleteAgent: undefined, savedCatalogReady: true, expandedSubagentParents, programShownParents: new Set(), editor: { getText: () => "" }, getFilteredRecords: () => Reflect.get(self, "scopedRecords"), applyPendingAncestorExpansion: vi.fn(), restoreSelection: vi.fn(), ui: { requestRender: vi.fn() }, setStatusMessage: vi.fn(), withPendingDeleteSession: (sessions: SessionSummary[]) => sessions, }; invoke("reconcileCatalogs", self); if (expand) { const parentRow = rowsOf(self).find( (row) => row.kind === "agent" && row.summary.sessionId === "root-session", ); expect(parentRow?.identity).toBe("session:root-session"); expandedSubagentParents.add(parentRow!.identity); invoke("reconcileCatalogs", self); expect(rowsOf(self).some((row) => row.kind === "subagent")).toBe(true); } // The runtime flushes the session file; the record identity flips to file:. self.lastListedSummaries = [{ ...parent, sessionFile: "/tmp/root.jsonl" }, child]; invoke("reconcileCatalogs", self); return { self, expandedSubagentParents }; };
const expandedView = buildView(true); const expandedRows = rowsOf(expandedView.self); expect( expandedRows.find((row) => row.kind === "agent" && row.summary.sessionId === "root-session")?.identity, ).toBe("file:/tmp/root.jsonl"); expect(expandedRows.some((row) => row.kind === "subagent-summary")).toBe(false); expect(expandedRows.some((row) => row.kind === "subagent" && row.summary.sessionId === "child-session")).toBe( true, ); expect([...expandedView.expandedSubagentParents]).toEqual(["file:/tmp/root.jsonl"]);
const collapsedView = buildView(false); const collapsedRows = rowsOf(collapsedView.self); expect(collapsedRows.some((row) => row.kind === "subagent-summary" && row.expanded)).toBe(false); expect(collapsedRows.some((row) => row.kind === "subagent")).toBe(false); expect(collapsedView.expandedSubagentParents.size).toBe(0); });
it("toggles subagent list expansion from the parent row", () => { const expandedSubagentParents = new Set(["root-row"]); const programShownParents = new Set(["root-row"]); const persistentState: AgentsViewPersistentState = { expandedSubagentParents, programShownParents, }; const self: Record<string, unknown> = { persistentState, expandedSubagentParents, programShownParents, rebuildRows: vi.fn(), syncSelectedRowState: vi.fn(), ui: { requestRender: vi.fn() }, }; const summaryRow = { kind: "agent", identity: "root-row", expanded: true };
invoke("toggleSubagentList", self, summaryRow); expect(expandedSubagentParents.size).toBe(0); // Collapsing the list hides its revealed program too. expect(programShownParents.size).toBe(0); expect(self.rebuildRows).toHaveBeenCalledTimes(1);
invoke("toggleSubagentList", self, { ...summaryRow, expanded: false }); expect(expandedSubagentParents).toEqual(new Set(["root-row"])); expect(programShownParents.size).toBe(0); expect(self.rebuildRows).toHaveBeenCalledTimes(2); });
it("renders roster recovery and stale-worker status labels", () => { const rows = buildAgentsViewRows([ summary({ id: "recovering", sessionId: "recovering", statusLabel: "recovering" }), summary({ id: "stale", sessionId: "stale", lastHeardFromAt: new Date(Date.now() - 60_000).toISOString(), }), ]); const view = new AgentsViewMode({ config: {}, uiServices: createUiServices() }, {}); Reflect.set(view, "rows", rows);
try { expect(invoke("renderRow", view, rows[0], 160)).toContain("recovering"); expect(invoke("renderRow", view, rows[1], 160)).toContain("last heard"); } finally { stopThemeWatcher(); } });
it("shows each session model and aligned total cost including collapsed descendants", () => { const created = new Date(Date.now() - 120_000).toISOString(); const parent = summary({ id: "spender", activeSessionId: "spender", sessionId: "spender-session", sessionName: "spender", model: { ...getModel("openai", "gpt-4o"), id: "gpt-5.6-sol" }, created, summary: "Analyzing runtime composition", usage: { inputTokens: 12437, outputTokens: 1234, cost: 0.42 }, }); const child = summary({ id: "child", activeSessionId: "child", sessionId: "child-session", sessionFile: "/tmp/child.jsonl", runtimeKind: "subagent", parentActiveSessionId: "spender", model: { ...getModel("openai", "gpt-4o"), provider: "prime-inference", id: "glm-5.2-fast" }, created, usage: { inputTokens: 500, outputTokens: 50, cost: 0.68 }, }); const inactive = summary({ id: "saved", activeSessionId: undefined, sessionId: "saved-session", sessionFile: "/tmp/saved.jsonl", rosterStatus: "inactive", created, model: { ...getModel("openai", "gpt-4o"), provider: "prime-inference", id: "glm-5.2-fast" }, usage: { inputTokens: 900, outputTokens: 80, cost: 123.45 }, }); const rows = buildAgentsViewRows([parent, child, inactive]); const view = new AgentsViewMode({ config: {}, uiServices: createUiServices() }, {}); Reflect.set(view, "rows", rows); Reflect.set(view, "selectedIndex", -1); try { const parentRow = rows.find((row) => row.summary.sessionId === parent.sessionId)!; const savedRow = rows.find((row) => row.summary.sessionId === inactive.sessionId)!; const render = (row: AgentsViewRow, width: number) => stripAnsi(invoke("renderRow", view, row, width, buildCompactAgentsViewLayout(rows, width)) as string); const parentLine = render(parentRow, 120); const savedLine = render(savedRow, 120); expect(parentLine).toContain("gpt-5.6-sol"); expect(savedLine).toContain("glm-5.2-fast"); expect(parentLine).toContain("$1.10"); expect(parentLine).not.toContain("$0.42"); expect(parentLine).not.toMatch(/[↑↓]/); expect(parentLine).toMatch(/2m\s*$/); expect(parentLine.indexOf("$1.10") + "$1.10".length).toBe(savedLine.indexOf("$123.45") + "$123.45".length); for (const width of [60, 80]) { const narrow = render(parentRow, width); expect(narrow).toContain("gpt-5.6-sol"); expect(narrow).toContain("$1.10"); expect(narrow).toMatch(/2m\s*$/); expect(narrow.length).toBeLessThanOrEqual(width); } } finally { stopThemeWatcher(); } });
it("shows the recorded model on inactive saved sessions and keeps '-' without one", () => { const saved = (id: string, model?: { provider: string; modelId: string }) => ({ path: `/tmp/${id}.jsonl`, id, cwd: "/tmp", created: new Date("2026-01-01T00:00:00Z"), modified: new Date("2026-01-01T00:00:00Z"), messageCount: 1, firstMessage: "hello", allMessagesText: "hello", ...(model ? { model } : {}), }); const records = reconcileUnifiedSessions( [], [saved("with-model", { provider: "prime-inference", modelId: "glm-4.7" }), saved("bare")], ); const rows = buildAgentsViewRows(records); const view = new AgentsViewMode({ config: {}, uiServices: createUiServices() }, {}); Reflect.set(view, "rows", rows); Reflect.set(view, "selectedIndex", -1); try { const render = (id: string) => stripAnsi(invoke("renderRow", view, rows.find((row) => row.summary.sessionId === id)!, 120) as string); expect(render("with-model")).toContain("glm-4.7"); expect(render("bare")).toMatch(/\s-\s/); expect(render("bare")).not.toContain("glm-4.7"); // The actions panel shows the same recorded model instead of "unknown". Reflect.set( view, "selectedIndex", rows.findIndex((row) => row.summary.sessionId === "with-model"), ); const actions = (invoke("renderActions", view, 120) as string[]).map(stripAnsi).join("\n"); expect(actions).toContain("Model: prime-inference/glm-4.7"); } finally { stopThemeWatcher(); } });
it("renders remote host on session list row and actions pane", () => { const remoteSession = summary({ id: "remote", activeSessionId: "remote", sessionId: "remote-session", sessionName: "remote-task", model: { ...getModel("anthropic", "claude-sonnet-4-5"), provider: "anthropic", id: "claude-sonnet-4-5" }, remoteHost: "chernobog", }); const rows = buildAgentsViewRows([remoteSession]); const view = new AgentsViewMode({ config: {}, uiServices: createUiServices() }, {}); Reflect.set(view, "rows", rows); Reflect.set(view, "selectedIndex", 0); try { const render = (row: AgentsViewRow, width: number) => stripAnsi(invoke("renderRow", view, row, width, buildCompactAgentsViewLayout(rows, width)) as string); const line = render(rows[0]!, 120); expect(line).toContain("claude-sonnet-4-5@chernobog");
const actions = (invoke("renderActions", view, 120) as string[]).map((l) => stripAnsi(l)); expect(actions.some((l) => l.includes("Host: chernobog"))).toBe(true); } finally { stopThemeWatcher(); } });
it("renders one column header across status groups without repeating subagent hints", () => { const summaries = [ summary({ id: "busy", activeSessionId: "busy", sessionId: "busy-session", sessionName: "busy", activity: "working", isStreaming: true, }), summary({ id: "idle", activeSessionId: "idle", sessionId: "idle-session", sessionName: "idle", sessionFile: "/tmp/idle.jsonl", }), summary({ id: "child", sessionId: "child-session", sessionFile: "/tmp/child.jsonl", runtimeKind: "subagent", parentActiveSessionId: "busy", }), ]; const view = new AgentsViewMode({ config: {}, uiServices: createUiServices() }, {}); try { Reflect.set(view, "lastListedSummaries", summaries); invoke("reconcileCatalogs", view); Reflect.set(view, "selectedIndex", -1); Reflect.set(view, "ui", { terminal: { rows: 60 }, requestRender: () => {} }); const rendered = invoke("renderSessionRows", view, 120, 40) as string[]; const lines = rendered.map(stripAnsi); expect(lines.filter((line) => /Model/.test(line) && /Age/i.test(line))).toHaveLength(1); expect(lines.some((line) => line.startsWith("Running"))).toBe(true); expect(lines.some((line) => line.startsWith("Idle"))).toBe(true); expect(lines.join("\n")).not.toMatch(/show program|#sub|\$agent|↑in|↓out/); const rows = Reflect.get(view, "rows") as AgentsViewRow[]; expect(rows.filter((row) => row.kind === "subagent-summary")).toHaveLength(0); for (const line of rendered) { expect(invoke("finalizeRenderedLine", view, line, 120)).not.toContain("\x1b[48"); } } finally { stopThemeWatcher(); } });
it("always renders inactive sessions; search is the only filter", () => { const live = summary({ sessionName: "live" }); const saved = summary({ id: "saved", activeSessionId: undefined, sessionId: "saved-session", sessionName: "archive-match", sessionFile: "/tmp/saved.jsonl", rosterStatus: "inactive", lifecycle: "archived", }); // The stale pre-removal collapse flag must be ignored. const persistentState = { savedCatalogLoaded: true, inactiveExpanded: false } as AgentsViewPersistentState; const view = new AgentsViewMode({ config: {}, uiServices: createUiServices() }, persistentState); const rows = () => Reflect.get(view, "rows") as AgentsViewRow[]; const showsSaved = () => rows().some((row) => row.summary.sessionId === saved.sessionId); try { Reflect.set(view, "lastListedSummaries", [live]); Reflect.set(view, "savedSessions", [ { path: saved.sessionFile!, id: saved.sessionId, cwd: saved.cwd, name: saved.sessionName, created: new Date(), modified: new Date(), messageCount: 1, firstMessage: "archive-match", allMessagesText: "archive-match", }, ]); invoke("reconcileCatalogs", view); expect(showsSaved()).toBe(true); // The removed Alt+I chord must not hide anything. view.handleInput("\x1bi"); expect(showsSaved()).toBe(true); invoke("setSearchQuery", view, "no-such-session"); expect(showsSaved()).toBe(false); } finally { stopThemeWatcher(); } });
it("opens a parent with Enter and reveals its spawn program only on request", () => { const parent = summary({ sessionName: "parent" }); const child = summary({ id: "child", activeSessionId: "child", sessionId: "child-session", sessionFile: "/tmp/child.jsonl", runtimeKind: "subagent", parentActiveSessionId: parent.activeSessionId, spawnCode: 'await rlm("Inspect the code")', }); const view = new AgentsViewMode({ config: {}, uiServices: createUiServices() }, {}); const rows = () => Reflect.get(view, "rows") as AgentsViewRow[]; try { Reflect.set(view, "lastListedSummaries", [parent, child]); invoke("reconcileCatalogs", view); expect(rows().map((row) => row.kind)).toEqual(["agent"]); const finish = vi.fn(); Reflect.set(view, "finish", finish); invoke("openSelected", view); expect(finish).toHaveBeenCalledWith( expect.objectContaining({ type: "open", summary: expect.objectContaining({ sessionId: parent.sessionId }), }), ); invoke("cycleProgramForSelected", view); expect(rows().some((row) => row.kind === "subagent-code" && row.code === child.spawnCode)).toBe(true); expect(rows().some((row) => row.kind === "subagent" && row.summary.sessionId === child.sessionId)).toBe(true); expect(rows().some((row) => row.kind === "subagent-summary")).toBe(false); invoke("cycleProgramForSelected", view); expect(rows().some((row) => row.kind === "subagent-code")).toBe(false); } finally { stopThemeWatcher(); } });
it("keeps a selection at the end of the list visible when the leading ellipsis is shown", () => { const summaries = Array.from({ length: 12 }, (_, index) => summary({ id: `saved-${index}`, activeSessionId: undefined, sessionId: `saved-${index}-session`, sessionName: `saved-${index}`, sessionFile: `/tmp/saved-${index}.jsonl`, rosterStatus: "inactive" as const, created: `2026-01-${String(index + 1).padStart(2, "0")}T00:00:00Z`, }), ); const rows = buildAgentsViewRows(summaries); const view = new AgentsViewMode({ config: {}, uiServices: createUiServices() }, {});
try { Reflect.set(view, "rows", rows); Reflect.set(view, "selectedIndex", rows.length - 1); Reflect.set(view, "ui", { terminal: { rows: 13 }, requestRender: () => {} }); const lines = (invoke("renderSessionRows", view, 120, 4) as string[]).map(stripAnsi); expect(lines[1]).toContain("..."); expect(lines).toHaveLength(4); const lastTitle = rows.at(-1)!.title; expect(lines.some((line) => line.includes(lastTitle))).toBe(true); } finally { stopThemeWatcher(); } });
it("reveals usage details through actions and closes them before searching", () => { const view = new AgentsViewMode({ config: {}, uiServices: createUiServices() }, { savedCatalogLoaded: true }); try { Reflect.set(view, "lastListedSummaries", [ summary({ sessionName: "parent", usage: { inputTokens: 1234, outputTokens: 56, cost: 1.23 } }), ]); invoke("reconcileCatalogs", view); view.handleInput("?"); const actions = (invoke("renderSessionRows", view, 120, 20) as string[]).map(stripAnsi).join("\n"); expect(actions).toContain("1234 in"); expect(actions).toContain("$1.23"); view.handleInput("p"); expect(Reflect.get(view, "showActions")).toBe(false); const rows = (invoke("renderSessionRows", view, 120, 20) as string[]).map(stripAnsi).join("\n"); expect(rows).toContain("parent"); expect(rows).not.toContain("1234 in"); } finally { stopThemeWatcher(); } });
it("shows running-subagent counts only while collapsed and work remains", () => { const parent = summary({ sessionName: "parent" }); const child = summary({ id: "child", activeSessionId: "child", sessionId: "child-session", sessionFile: "/tmp/child.jsonl", runtimeKind: "subagent", parentActiveSessionId: parent.activeSessionId, activity: "working", isStreaming: true, }); const secondChild = { ...child, id: "child-2", activeSessionId: "child-2", sessionId: "child-session-2", sessionFile: "/tmp/child-2.jsonl", }; const view = new AgentsViewMode({ config: {}, uiServices: createUiServices() }, {}); const rows = () => Reflect.get(view, "rows") as AgentsViewRow[]; const lines = () => (invoke("renderSessionRows", view, 120, 20) as string[]).map(stripAnsi); try { Reflect.set(view, "lastListedSummaries", [parent, child, secondChild]); invoke("reconcileCatalogs", view); expect(rows()).toHaveLength(1); expect(invoke("renderRow", view, rows()[0], 120)).toContain("▸"); const collapsed = lines(); const parentIndex = collapsed.findIndex((line) => line.includes("parent")); expect(collapsed[parentIndex + 1]).toBe(" 2 subagents running"); invoke("moveSelection", view, 1); expect(Reflect.get(view, "selectedIndex")).toBe(0); view.handleInput("\x1b[1;3C"); expect(rows().map((row) => row.kind)).toEqual(["agent", "subagent", "subagent"]); expect(lines().join("\n")).not.toContain("subagents running"); expect(invoke("renderRow", view, rows()[0], 120)).toContain("▾"); view.handleInput("\x1b[1;3C"); expect(rows()).toHaveLength(1); expect(lines()).toContain(" 2 subagents running"); const idleChild = { ...child, activity: "idle", isStreaming: false }; Reflect.set(view, "lastListedSummaries", [parent, idleChild, secondChild]); invoke("reconcileCatalogs", view); expect(lines()).toContain(" 1 subagent running"); Reflect.set(view, "lastListedSummaries", [ parent, idleChild, { ...secondChild, activity: "idle", isStreaming: false }, ]); invoke("reconcileCatalogs", view); expect(lines().join("\n")).not.toMatch(/subagents? running/); } finally { stopThemeWatcher(); } });
it("dims a paused-only heartbeat badge and keeps active badges in the error color", () => { const job = (status: "active" | "paused") => ({ job: { id: `${status}-job`, status, activeSessionId: "scope-active", sessionId: "scope-session", sessionFile: "/tmp/scope.jsonl", cwd: "/tmp", prompt: "tick", schedule: { kind: "interval" as const, expression: "5m" }, createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z", runCount: 0, }, }); const view = new AgentsViewMode({ config: {}, uiServices: createUiServices() }, {});
try { const [pausedRow] = buildAgentsViewRows(reconcileUnifiedSessions([summary()], [], [job("paused")])); Reflect.set(view, "rows", [pausedRow]); const pausedLine = invoke("renderRow", view, pausedRow, 160) as string; expect(pausedLine).toContain(theme.fg("dim", "♥ 1")); expect(pausedRow).toMatchObject({ section: "idle" });
const [activeRow] = buildAgentsViewRows(reconcileUnifiedSessions([summary()], [], [job("active")])); Reflect.set(view, "rows", [activeRow]); const activeLine = invoke("renderRow", view, activeRow, 160) as string; expect(activeLine).toContain(theme.fg("error", "♥ 1")); } finally { stopThemeWatcher(); } });
it("warns about the armed heartbeat in the delete confirmation", () => { const view = new AgentsViewMode({ config: {}, uiServices: createUiServices() }, {}); Reflect.set(view, "deleteConfirmExpiresAt", Date.now() + 10_000); const confirmLine = (row: AgentsViewRow): string => { Reflect.set(view, "rows", [row]); Reflect.set(view, "pendingDeleteAgent", { identity: row.identity, summary: row.summary, stopped: false }); return stripAnsi(invoke("renderRow", view, row, 160) as string); };
try { const [armedRow] = buildAgentsViewRows([summary({ hasActiveHeartbeat: true })]); expect(confirmLine(armedRow!)).toContain("has an armed heartbeat — "); const [plainRow] = buildAgentsViewRows([summary()]); expect(confirmLine(plainRow!)).not.toContain("armed heartbeat"); expect(confirmLine(plainRow!)).toContain("again to remove"); } finally { stopThemeWatcher(); } });});
function createUiServices(): InteractiveModeUiServices { return { settingsManager: SettingsManager.inMemory({ theme: "dark" }), modelRegistry: {} as ModelRegistry, getInitialCwd: () => process.cwd(), getInitialSessionName: () => undefined, getThemes: () => [], };}
afterEach(() => { vi.restoreAllMocks();});
describe("AgentsViewMode persistent catalog state", () => { it("applies an initial handoff scope from the first pushed roster refresh", async () => { const root = summary(); const scope = { sessionId: root.sessionId, activeSessionId: root.activeSessionId }; const persistentState = createInitialAgentsViewPersistentState({ initialScopeKey: scope, initialSession: root, }); persistentState.lastSuccessfulSavedSessions = []; const view = new AgentsViewMode({ config: {}, uiServices: createUiServices() }, persistentState); Reflect.set(view, "rosterStore", { summaries: () => [root] }); Reflect.set(view, "savedCatalogReady", true);
try { await expect(invoke("refreshSessions", view)).resolves.toBeUndefined(); expect(persistentState.scopeFrames).toEqual([{ scope, returnChat: root }]); expect(persistentState.lastSuccessfulLiveSummaries).toEqual([root]); } finally { stopThemeWatcher(); } });
it("re-arms reconnect from the heartbeat poll over a dead socket and never overwrites a sticky notice", async () => { const harness = (isConnected: boolean, statusMessageSticky: boolean) => { const client = { isConnected, request: vi.fn(async () => { throw new Error("heartbeats unavailable"); }), }; return { client, heartbeatCatalogGeneration: 0, reconnectPromise: undefined, daemonShutdownReceived: false, statusMessageSticky, requireClient: () => client, startClientReconnect: vi.fn(), setStatusMessage: vi.fn(), }; };
const reconnecting = harness(false, false); await expect(invoke("refreshHeartbeats", reconnecting)).resolves.toBe(false); expect(reconnecting.startClientReconnect).toHaveBeenCalledWith(reconnecting.client, expect.any(Error)); expect(reconnecting.setStatusMessage).not.toHaveBeenCalled();
const sticky = harness(true, true); await expect(invoke("refreshHeartbeats", sticky)).resolves.toBe(false); expect(sticky.startClientReconnect).not.toHaveBeenCalled(); expect(sticky.setStatusMessage).not.toHaveBeenCalled(); });
it("keeps a live-only scope after a fresh instance's first live poll fails", async () => { const root = summary({ id: "root-active", activeSessionId: "root-active", isSessionActive: true, runtimeKind: "top-level", sessionId: "root-session", cwd: process.cwd(), }); const persistentState: AgentsViewPersistentState = { scopeFrames: [{ scope: { sessionId: root.sessionId, activeSessionId: root.activeSessionId } }], lastSuccessfulLiveSummaries: [root], lastSuccessfulSavedSessions: [], }; const view = new AgentsViewMode({ config: {}, uiServices: createUiServices() }, persistentState); Reflect.set(view, "client", { isConnected: true, request: vi.fn(async () => { throw new Error("transient list failure"); }), });
try { await expect(invoke("refreshSessions", view, { preserveStatusOnError: true })).resolves.toBeUndefined(); expect(persistentState.scopeFrames).toEqual([ { scope: { sessionId: root.sessionId, activeSessionId: root.activeSessionId } }, ]); } finally { stopThemeWatcher(); } });
it("keeps a live-only scope through reconnect timeout and settles it on the next successful list", async () => { vi.useFakeTimers(); const root = summary(); const frame = { scope: { sessionId: root.sessionId, activeSessionId: root.activeSessionId } }; const persistentState: AgentsViewPersistentState = { scopeFrames: [frame], lastSuccessfulLiveSummaries: [root], lastSuccessfulSavedSessions: [], }; const view = new AgentsViewMode( { config: {}, uiServices: createUiServices(), reconnectTimeoutMs: 0 }, persistentState, ); const client = { isConnected: false, reconnect: vi.fn() }; Reflect.set(view, "client", client); Reflect.set(view, "savedCatalogReady", true);
try { await expect(invoke("reconnectClient", view, client, new Error("disconnected"))).resolves.toBeUndefined(); expect(persistentState.scopeFrames).toEqual([frame]); expect(Reflect.get(view, "lastListedSummaries")).toEqual([root]);
Reflect.set(view, "client", { isConnected: true }); Reflect.set(view, "rosterStore", { summaries: () => [] }); await expect(invoke("refreshSessions", view)).resolves.toBeUndefined(); expect(persistentState.scopeFrames).toEqual([]); } finally { vi.useRealTimers(); } });
it("applies the roster snapshot produced during reconnect heartbeat refresh", async () => { const beforeRefresh = summary({ id: "before", sessionId: "before" }); const afterRefresh = summary({ id: "after", sessionId: "after" }); let current = [beforeRefresh]; let finishHeartbeatRefresh: (() => void) | undefined; const heartbeatRefresh = new Promise<void>((resolve) => { finishHeartbeatRefresh = resolve; }); const client = { reconnect: vi.fn(async () => undefined) }; const self = { options: { recoverDaemon: vi.fn(async () => undefined) }, client, rosterStore: { attach: vi.fn(async () => true), summaries: vi.fn(() => current), }, refreshHeartbeats: vi.fn(async () => { await heartbeatRefresh; return true; }), daemonShutdownReceived: false, reconnectTimedOut: true, setStatusMessage: vi.fn(), applySessionList: vi.fn(), armSavedSearchFetch: vi.fn(), };
const reconnect = invoke("reconnectClient", self, client, new Error("disconnected")) as Promise<void>; await vi.waitFor(() => expect(self.refreshHeartbeats).toHaveBeenCalledOnce()); current = [afterRefresh]; finishHeartbeatRefresh?.(); await reconnect;
expect(self.applySessionList).toHaveBeenCalledWith([afterRefresh], true); });
it("keeps a newly pushed scope and the existing live cache when its first poll fails", async () => { const root = summary(); const other = summary({ id: "other-active", activeSessionId: "other-active", sessionId: "other-session" }); const returnedRoot = { ...root, sessionName: "Updated root" }; const scope = { sessionId: root.sessionId, activeSessionId: root.activeSessionId }; let runs = 0; vi.spyOn(AgentsViewMode.prototype, "run").mockImplementation(async function (this: AgentsViewMode) { runs += 1; const persistentState = Reflect.get(this, "persistentState") as AgentsViewPersistentState; if (runs === 1) { persistentState.lastSuccessfulLiveSummaries = [other]; persistentState.lastSuccessfulSavedSessions = []; return { type: "open", summary: root, hasChildren: false }; }
expect(persistentState.lastSuccessfulLiveSummaries).toEqual([other, returnedRoot]); Reflect.set(this, "client", { isConnected: true, request: vi.fn(async () => { throw new Error("transient list failure"); }), }); await expect(invoke("refreshSessions", this, { preserveStatusOnError: true })).resolves.toBeUndefined(); expect(persistentState.scopeFrames).toEqual([{ scope, returnChat: returnedRoot }]); return { type: "exit" }; }); modeMocks.interactiveRun.mockResolvedValue({ type: "scoped_agents_view", source: { activeSessionId: root.activeSessionId!, sessionId: root.sessionId, sessionName: returnedRoot.sessionName, cwd: root.cwd, }, } as never);
await runAgentsViewMode({ config: { cwd: process.cwd() }, socketPath: "/tmp/agents-view-test.sock", uiServices: createUiServices(), });
expect(runs).toBe(2); });});
describe("AgentsViewMode catalog performance", () => { beforeEach(() => vi.useFakeTimers()); afterEach(() => vi.useRealTimers());
it("coalesces bursts without copying snapshots per item and flushes during a continuous stream", async () => { const previous = [savedSession("previous")]; const self = catalogHarness(previous); const catalog = deferredSavedCatalog(); const refresh = invoke("refreshSavedSessions", self) as Promise<boolean>; const first = savedSession("first"); const updatedFirst = { ...first, path: "/tmp/./first.jsonl", name: "updated first" }; const second = savedSession("second"); const third = savedSession("third"); catalog.emit(first); const firstTimer = self.savedCatalogReconcileTimer; await vi.advanceTimersByTimeAsync(25); catalog.emit(updatedFirst); catalog.emit(second); await vi.advanceTimersByTimeAsync(25); catalog.emit(third); await vi.advanceTimersByTimeAsync(24);
expect(self.savedSessions).toBe(previous); expect(self.persistentState.savedSessions).toBe(previous); expect(self.savedCatalogReconcileTimer).toBe(firstTimer); expect(self.reconcileCatalogs).not.toHaveBeenCalled(); expect(vi.getTimerCount()).toBe(1);
await vi.advanceTimersByTimeAsync(1); expect(self.reconcileCatalogs).toHaveBeenCalledOnce(); expect(self.savedSessions).toEqual([previous[0], updatedFirst, second, third]); expect(self.persistentState.savedSessions).toBe(self.savedSessions); expect(self.rows.map((row) => row.summary.sessionId).sort()).toEqual(["first", "previous", "second", "third"]); expect(self.rows.find((row) => row.summary.sessionId === "first")?.title).toBe("updated first"); expect(self.savedCatalogReady).toBe(false); expect(self.savedCatalogRefreshPending).toBe(true); expect(self.lastSuccessfulSavedSessions).toBe(previous); expect(self.persistentState.lastSuccessfulSavedSessions).toBe(previous); expect(self.savedCatalogReconcileTimer).toBeUndefined();
const firstSnapshot = self.savedSessions; const fourth = savedSession("fourth"); const fifth = savedSession("fifth"); catalog.emit(fourth); await vi.advanceTimersByTimeAsync(50); catalog.emit(fifth); await vi.advanceTimersByTimeAsync(24); expect(self.savedSessions).toBe(firstSnapshot); expect(self.persistentState.savedSessions).toBe(firstSnapshot); expect(self.reconcileCatalogs).toHaveBeenCalledOnce(); await vi.advanceTimersByTimeAsync(1); expect(self.reconcileCatalogs).toHaveBeenCalledTimes(2); expect(self.savedSessions).toEqual([...firstSnapshot, fourth, fifth]); expect(self.rows).toHaveLength(6); expect(self.savedCatalogRefreshPending).toBe(true);
catalog.resolve([updatedFirst, second, third, fourth, fifth]); await expect(refresh).resolves.toBe(true); });
it("publishes the final canonical catalog immediately and cancels its pending batch", async () => { const self = catalogHarness([savedSession("previous")]); const catalog = deferredSavedCatalog(); const refresh = invoke("refreshSavedSessions", self) as Promise<boolean>; catalog.emit(savedSession("partial")); const final = [savedSession("canonical")]; catalog.resolve(final); await expect(refresh).resolves.toBe(true);
expect(self.savedSessions).toBe(final); expect(self.lastSuccessfulSavedSessions).toBe(final); expect(self.persistentState.savedSessions).toBe(final); expect(self.persistentState.lastSuccessfulSavedSessions).toBe(final); expect(self.persistentState.savedCatalogLoaded).toBe(true); expect(self.savedCatalogReady).toBe(true); expect(self.savedCatalogRefreshPending).toBe(false); expect(self.rows.map((row) => row.summary.sessionId)).toEqual(["canonical"]); expect(self.resolveMissingSelectionAnchor).toHaveBeenCalledOnce(); expect(self.savedCatalogReconcileTimer).toBeUndefined(); expect(vi.getTimerCount()).toBe(0); await vi.advanceTimersByTimeAsync(150); expect(self.savedSessions).toBe(final); expect(self.reconcileCatalogs).toHaveBeenCalledOnce(); });
it.each([false, true])("restores the last successful catalog after failure (batch flushed: %s)", async (flushed) => { const previous = [savedSession("previous")]; const self = catalogHarness(previous); const catalog = deferredSavedCatalog(); const refresh = invoke("refreshSavedSessions", self) as Promise<boolean>; catalog.emit(savedSession("partial")); if (flushed) { await vi.advanceTimersByTimeAsync(75); expect(self.rows.map((row) => row.summary.sessionId).sort()).toEqual(["partial", "previous"]); catalog.emit(savedSession("pending")); } catalog.reject(new Error("scan failed")); await expect(refresh).resolves.toBe(false);
expect(self.savedSessions).toBe(previous); expect(self.persistentState.savedSessions).toBe(previous); expect(self.lastSuccessfulSavedSessions).toBe(previous); expect(self.persistentState.lastSuccessfulSavedSessions).toBe(previous); expect(self.rows.map((row) => row.summary.sessionId)).toEqual(["previous"]); expect(self.savedCatalogReady).toBe(true); expect(self.savedCatalogRefreshPending).toBe(false); expect(self.setStatusMessage).toHaveBeenCalledWith("Failed to load saved sessions: scan failed"); expect(self.savedCatalogReconcileTimer).toBeUndefined(); expect(vi.getTimerCount()).toBe(0); await vi.advanceTimersByTimeAsync(150); expect(self.savedSessions).toBe(previous); expect(self.reconcileCatalogs).toHaveBeenCalledTimes(flushed ? 2 : 1); });
it.each(["success", "failure"])("fences a superseded scan's timer, callback, and terminal %s", async (outcome) => { const previous = [savedSession("previous")]; const self = catalogHarness(previous); const older = deferredSavedCatalog(); const newer = deferredSavedCatalog(); const oldRefresh = invoke("refreshSavedSessions", self) as Promise<boolean>; older.emit(savedSession("old-partial")); await vi.advanceTimersByTimeAsync(25); const newRefresh = invoke("refreshSavedSessions", self) as Promise<boolean>; expect(self.savedCatalogGeneration).toBe(2); expect(self.persistentState.savedCatalogGeneration).toBe(2); expect(self.savedCatalogReconcileTimer).toBeUndefined(); expect(vi.getTimerCount()).toBe(0); older.emit(savedSession("old-late")); expect(vi.getTimerCount()).toBe(0); const replacement = savedSession("new-partial"); newer.emit(replacement); const newTimer = self.savedCatalogReconcileTimer; if (outcome === "success") older.resolve([savedSession("old-final")]); else older.reject(new Error("old scan failed")); await expect(oldRefresh).resolves.toBe(false); expect(self.savedCatalogReconcileTimer).toBe(newTimer); expect(self.savedCatalogRefreshPending).toBe(true); expect(self.savedCatalogReady).toBe(false); expect(self.resolveMissingSelectionAnchor).not.toHaveBeenCalled(); expect(self.setStatusMessage).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(50); expect(self.savedSessions).toBe(previous); expect(self.reconcileCatalogs).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(25); expect(self.savedSessions).toEqual([...previous, replacement]); expect(self.rows.map((row) => row.summary.sessionId).sort()).toEqual(["new-partial", "previous"]); expect(self.reconcileCatalogs).toHaveBeenCalledOnce(); const final = [savedSession("new-final")]; newer.resolve(final); await expect(newRefresh).resolves.toBe(true); await vi.advanceTimersByTimeAsync(150); expect(self.savedSessions).toBe(final); expect(self.persistentState.savedSessions).toBe(final); expect(self.reconcileCatalogs).toHaveBeenCalledTimes(2); });
it.each(["success", "failure"])("finish cancels pending batches and ignores late catalog %s", async (outcome) => { const previous = [savedSession("previous")]; const self = catalogHarness(previous); const catalog = deferredSavedCatalog(); const refresh = invoke("refreshSavedSessions", self) as Promise<boolean>; catalog.emit(savedSession("partial")); expect(vi.getTimerCount()).toBe(1); invoke("finish", self, { type: "exit" }); expect(self.stopped).toBe(true); expect(self.savedCatalogReconcileTimer).toBeUndefined(); expect(vi.getTimerCount()).toBe(0); catalog.emit(savedSession("late")); if (outcome === "success") catalog.resolve([savedSession("final")]); else catalog.reject(new Error("late failure")); await expect(refresh).resolves.toBe(false); await vi.advanceTimersByTimeAsync(150);
expect(self.savedSessions).toBe(previous); expect(self.persistentState.savedSessions).toBe(previous); expect(self.lastSuccessfulSavedSessions).toBe(previous); expect(self.reconcileCatalogs).not.toHaveBeenCalled(); expect(self.ui.requestRender).not.toHaveBeenCalled(); expect(self.resolveMissingSelectionAnchor).not.toHaveBeenCalled(); expect(vi.getTimerCount()).toBe(0); });
it("ticks stale ages and working icons without rebuilding rows or rendering an unchanged idle list", async () => { initTheme("dark"); vi.setSystemTime(new Date("2026-01-01T00:00:10Z")); const rows = buildAgentsViewRows([summary({ lastHeardFromAt: "2026-01-01T00:00:00Z" })]); const self = { rows, selectedIndex: -1, workingIconFrame: 0, savedCatalogGeneration: 0, heartbeatCatalogGeneration: 0, persistentState: { rosterClient: { isConnected: true, onMessage: vi.fn(() => vi.fn()) }, rosterStore: { attach: vi.fn(async () => true), onUpdate: vi.fn(() => vi.fn()), summaries: () => [], }, }, ui: { addChild: vi.fn(), setFocus: vi.fn(), start: vi.fn(), enterFullscreen: vi.fn(), requestRender: vi.fn(), invalidate: vi.fn(), stop: vi.fn(), }, subscribeToClientClose: vi.fn(), applySessionList: vi.fn(), armSavedSearchFetch: vi.fn(), resolveMissingSelectionAnchor: vi.fn(), refreshHeartbeats: vi.fn(async () => true), loadStartupNotices: vi.fn(), rebuildRows: vi.fn(), clearCtrlCExitHint: vi.fn(), clearDeleteConfirmation: vi.fn(), setStatusMessage: vi.fn(), isPendingDeleteRow: () => false, isPendingKillSubagentRow: () => false, getRowIcon(section: AgentsViewRow["section"]): string { return invoke("getRowIcon", self, section) as string; }, formatRowIcon(section: AgentsViewRow["section"], icon: string): string { return invoke("formatRowIcon", self, section, icon) as string; }, }; const render = (row = self.rows[0]!) => stripAnsi(invoke("renderRow", self, row, 160) as string); const run = invoke("run", self) as Promise<unknown>; try { await vi.advanceTimersByTimeAsync(0); expect(self.ui.start).toHaveBeenCalledOnce(); expect(render()).toContain("last heard 10s ago"); self.ui.requestRender.mockClear(); await vi.advanceTimersByTimeAsync(1000); expect(self.ui.requestRender).toHaveBeenCalledTimes(1000 / WORKING_ICON_INTERVAL_MS); expect(self.rebuildRows).not.toHaveBeenCalled(); expect(self.rows).toBe(rows); expect(rows[0]?.statusLabel).toBe("last heard 10s ago"); expect(render()).toContain("last heard 11s ago"); expect(self.workingIconFrame).toBe(0);
self.rows = buildAgentsViewRows([summary({ activity: "working", isStreaming: true })]); self.ui.requestRender.mockClear(); await vi.advanceTimersByTimeAsync(WORKING_ICON_INTERVAL_MS); expect(self.workingIconFrame).toBe(1); expect(self.ui.requestRender).toHaveBeenCalledOnce(); expect(render()).toContain("◈"); expect(render()).not.toContain("thinking");
self.rows = buildAgentsViewRows([summary()]); self.ui.requestRender.mockClear(); await vi.advanceTimersByTimeAsync(WORKING_ICON_INTERVAL_MS); expect(self.workingIconFrame).toBe(1); expect(self.ui.requestRender).not.toHaveBeenCalled(); expect(self.rebuildRows).not.toHaveBeenCalled();
const recovering = buildAgentsViewRows([ summary({ statusLabel: "recovering", lastHeardFromAt: "2026-01-01T00:00:00Z" }), ])[0]!; recovering.heartbeat = { activeCount: 1 }; const failed = buildAgentsViewRows([summary({ statusLabel: "failed" })])[0]!; const statusLabel = vi.spyOn(agentsViewState, "getSessionStatusLabel"); expect(render(recovering)).toContain("recovering"); expect(render(recovering)).not.toContain("last heard"); expect(render(failed)).toContain("failed"); expect(render()).not.toContain("needs input"); expect(statusLabel).toHaveBeenCalledTimes(2); expect(statusLabel).toHaveBeenCalledWith(recovering.summary, recovering.heartbeat); } finally { invoke("finish", self, { type: "exit" }); await run; } expect(vi.getTimerCount()).toBe(0); });
it("reuses the catalog index and recursive totals across searches until reconciliation", () => { const parent = summary({ sessionName: "parent", usage: { inputTokens: 0, outputTokens: 0, cost: 1 } }); const child = summary({ id: "child", activeSessionId: "child", sessionId: "child", sessionFile: "/tmp/child.jsonl", sessionName: "child", runtimeKind: "subagent", parentActiveSessionId: parent.activeSessionId, usage: { inputTokens: 0, outputTokens: 0, cost: 2 }, }); const grandchild = summary({ id: "grandchild", activeSessionId: "grandchild", sessionId: "grandchild", sessionFile: "/tmp/grandchild.jsonl", sessionName: "grandchild", runtimeKind: "subagent", parentActiveSessionId: child.activeSessionId, usage: { inputTokens: 0, outputTokens: 0, cost: 4 }, }); const self = catalogHarness([], [parent, child, grandchild]); const computeRollups = vi.spyOn(agentsViewState, "computeRecursiveRollups"); const buildIndex = vi.spyOn(agentsViewState, "buildUnifiedSessionIndex"); const filterEmpty = vi.spyOn(agentsViewState, "filterEmptyAgentsViewSessions"); const filterSearch = vi.spyOn(agentsViewState, "filterUnifiedSessions"); const buildRows = vi.spyOn(agentsViewState, "buildAgentsViewRows"); self.reconcileCatalogs(); const index = Reflect.get(self, "unifiedIndex"); const rollups = Reflect.get(self, "recursiveRollups"); expect(rollups).toBeInstanceOf(Map); const parentRow = () => self.rows.find((row) => row.summary.sessionId === parent.sessionId); expect(parentRow()).toMatchObject({ recursiveCost: 7, descendantCount: 2 });
for (const query of ["parent", "grandchild", "no match", ""]) { self.editor.getText.mockReturnValue(query); invoke("queryChanged", self); expect(Reflect.get(self, "unifiedIndex")).toBe(index); expect(Reflect.get(self, "recursiveRollups")).toBe(rollups); if (query === "no match") expect(self.rows).toEqual([]); else expect(parentRow()).toMatchObject({ recursiveCost: 7, descendantCount: 2 }); } expect(computeRollups).toHaveBeenCalledOnce(); expect(buildIndex).toHaveBeenCalledOnce(); expect(filterEmpty).toHaveBeenCalledTimes(5); expect(filterSearch).toHaveBeenCalledTimes(3); for (const call of [...filterEmpty.mock.calls, ...filterSearch.mock.calls]) expect(call[2]).toBe(index); for (const call of buildRows.mock.calls) expect(call[4]).toBe(rollups);
self.lastListedSummaries = [ parent, child, { ...grandchild, usage: { inputTokens: 0, outputTokens: 0, cost: 10 } }, { ...child, id: "new", activeSessionId: "new", sessionId: "new", sessionFile: "/tmp/new.jsonl" }, ]; self.reconcileCatalogs(); expect(Reflect.get(self, "unifiedIndex")).not.toBe(index); expect(Reflect.get(self, "recursiveRollups")).not.toBe(rollups); expect(computeRollups).toHaveBeenCalledTimes(2); expect(buildIndex).toHaveBeenCalledTimes(2); expect(parentRow()).toMatchObject({ recursiveCost: 15, descendantCount: 3 }); self.editor.getText.mockReturnValue("parent"); invoke("queryChanged", self); expect(parentRow()).toMatchObject({ recursiveCost: 15, descendantCount: 3 }); expect(computeRollups).toHaveBeenCalledTimes(2); });});
describe("agents view reply delivery on inactive sessions", () => { function replySummary(overrides: Partial<SessionSummary>): SessionSummary { return { id: "saved-1", lifecycle: "archived", activity: "idle", sessionId: "saved-1", cwd: process.cwd(), isStreaming: false, isCompacting: false, attachedClients: 0, messageCount: 3, sessionActions: { queuedCount: 0, steering: [], followUps: [] }, ...overrides, isSessionActive: overrides.isSessionActive ?? false, }; }
function editorWithText(initial: string) { let text = initial; return { getText: () => text, setText: vi.fn((next: string) => { text = next; }), }; }
const savedSummary = replySummary({ sessionFile: "/tmp/sessions/saved-1.jsonl", cwd: process.cwd(), summary: "Persisted recap text", firstMessage: "opener", });
it("arms a saved reply from its persisted recap and lets ctrl+c disarm it", async () => { const requestRender = vi.fn(); const handleCtrlC = vi.fn(); const self: Record<string, unknown> = { rows: [ { kind: "agent", selectable: true, identity: "file:/tmp/sessions/saved-1.jsonl", summary: savedSummary }, ], selectedIndex: 0, pendingDeleteAgent: undefined, replyTarget: undefined, renameTarget: undefined, setReplyTarget: vi.fn((target: unknown) => { self.replyTarget = target; }), ui: { requestRender }, clearStickyStatusMessage: vi.fn(), keybindings: { matches: (_data: string, action: string) => action === "app.clear" }, handleCtrlC, };
await invoke("toggleReplyTarget", self); expect(self.replyTarget).toEqual({ key: "saved-1", summary: savedSummary }); expect(self.replyLastAssistantText).toBe("Persisted recap text"); expect(requestRender).toHaveBeenCalledOnce();
invoke("handleInput", self, "\x03"); expect(self.replyTarget).toBeUndefined(); expect(handleCtrlC).not.toHaveBeenCalled(); });
it("keeps the cwd-fallback notice visible after the reply is sent", async () => { const savedWithMissingCwd = { ...savedSummary, cwd: "/definitely/not/a/real/dir/for/this/test" }; const request = vi.fn(async () => ({ success: true, data: { ...savedWithMissingCwd, lifecycle: "live", activeSessionId: "active-9" }, })); const setStatusMessage = vi.fn(); const self: Record<string, unknown> = { options: { config: { cwd: process.cwd() } }, requireClient: () => ({ request }), findSummaryByActiveSessionId: () => undefined, inactiveAgentIdentities: new Set(["file:/tmp/sessions/saved-1.jsonl"]), setStatusMessage, selectSummary: vi.fn(), sendPrompt: vi.fn(async () => {}), };
await invoke("sendReply", self, { key: "saved-1", summary: savedWithMissingCwd }, "wake up");
expect(setStatusMessage).toHaveBeenLastCalledWith(expect.stringContaining("Original directory is missing"), { sticky: true, }); });
it("submits alt+enter as a follow-up only for an armed non-empty reply", () => { const submit = vi.fn(async () => {}); invoke("handleReplyFollowUp", { replyTarget: undefined, editor: { getExpandedText: () => "text" }, submit }); invoke("handleReplyFollowUp", { replyTarget: { key: "active-1", summary: savedSummary }, editor: { getExpandedText: () => " " }, submit, }); expect(submit).not.toHaveBeenCalled();
invoke("handleReplyFollowUp", { replyTarget: { key: "active-1", summary: savedSummary }, editor: { getExpandedText: () => "expanded paste body" }, submit, }); expect(submit).toHaveBeenCalledWith("expanded paste body", "followUp"); });
it("creates a new daemon session over a dedicated connection and opens it", async () => { const created = replySummary({ id: "active-new", activeSessionId: "active-new", lifecycle: "live" }); const request = vi.fn(async () => ({ success: true, data: created })); const close = vi.fn(); const self: Record<string, unknown> = { creatingNewSession: false, stopped: false, options: { config: { cwd: process.cwd() } }, connectDedicatedClient: vi.fn(async () => ({ request, close })), setStatusMessage: vi.fn(), selectSummary: vi.fn(), finish: vi.fn(), };
await expect(invoke("createNewSession", self)).resolves.toBe(true); expect(request).toHaveBeenCalledWith(expect.objectContaining({ type: "create" })); expect(self.selectSummary).toHaveBeenCalledWith(created); expect(self.finish).toHaveBeenCalledWith({ type: "open", summary: created }); expect(close).toHaveBeenCalledOnce(); expect(self.creatingNewSession).toBe(false); });
it("resumes a saved session before delivering the reply", async () => { const request = vi.fn(async (command: { type: string }) => { if (command.type === "create") { return { success: true, data: { ...savedSummary, lifecycle: "live", activeSessionId: "active-9", isStreaming: true }, }; } return { success: true, data: {} }; }); const target = { key: "saved-1", summary: savedSummary }; const self: Record<string, unknown> = { options: { config: { cwd: process.cwd() } }, requireClient: () => ({ request }), replyTarget: target, // Stale pre-resume rows do not know the resumed session; scheduling must // come from the resume response instead. findSummaryByActiveSessionId: () => undefined, inactiveAgentIdentities: new Set(["file:/tmp/sessions/saved-1.jsonl"]), setStatusMessage: vi.fn(), setReplyTarget: vi.fn(), refreshSessions: vi.fn(async () => true), selectSummary: vi.fn(), sendPrompt: vi.fn(async () => {}), };
await invoke("sendReply", self, target, "wake up");
expect(request).toHaveBeenCalledWith( expect.objectContaining({ type: "create", sessionPath: savedSummary.sessionFile }), ); expect(self.sendPrompt).toHaveBeenCalledWith("active-9", "wake up", "steer"); expect(self.selectSummary).toHaveBeenCalledWith(expect.objectContaining({ activeSessionId: "active-9" })); expect(self.inactiveAgentIdentities).not.toContain("file:/tmp/sessions/saved-1.jsonl"); expect(self.setReplyTarget).not.toHaveBeenCalled(); });
it("does not select a resumed session after its reply target is cancelled", async () => { let finishResume: ((result: { success: true; data: SessionSummary }) => void) | undefined; let signalResumeStarted!: () => void; const resumeStarted = new Promise<void>((resolve) => { signalResumeStarted = resolve; }); const request = vi.fn( () => new Promise<{ success: true; data: SessionSummary }>((resolve) => { finishResume = resolve; signalResumeStarted(); }), ); const target = { key: "saved-1", summary: savedSummary }; const selection = { activeSessionId: "active-2" }; const selectSummary = vi.fn((next: SessionSummary) => { selection.activeSessionId = next.activeSessionId ?? next.id; }); const sendPrompt = vi.fn(async () => {}); const self: Record<string, unknown> = { options: { config: { cwd: process.cwd() } }, requireClient: () => ({ request }), findSummaryByActiveSessionId: () => undefined, inactiveAgentIdentities: new Set(["file:/tmp/sessions/saved-1.jsonl"]), replyTarget: target, setStatusMessage: vi.fn(), selectSummary, sendPrompt, };
const reply = invoke("sendReply", self, target, "wake up") as Promise<boolean>; await resumeStarted; expect(request).toHaveBeenCalledOnce(); self.replyTarget = undefined; finishResume?.({ success: true, data: { ...savedSummary, lifecycle: "live", activeSessionId: "active-9" }, });
await expect(reply).resolves.toBe(true); expect(selectSummary).not.toHaveBeenCalled(); expect(selection.activeSessionId).toBe("active-2"); expect(sendPrompt).toHaveBeenCalledWith("active-9", "wake up", undefined); expect(self.inactiveAgentIdentities).not.toContain("file:/tmp/sessions/saved-1.jsonl"); });
it("preserves a replacement composer when an older reply succeeds", async () => { const editor = editorWithText("old reply"); const oldTarget = { key: "saved-1", summary: savedSummary }; const newTarget = { key: "active-2", summary: replySummary({ id: "active-2", activeSessionId: "active-2", lifecycle: "live" }), }; const self: Record<string, unknown> = { replyTarget: oldTarget, options: {}, editor, setReplyTarget: vi.fn(), refreshSessions: vi.fn(async () => true), sendReply: vi.fn(async () => { self.replyTarget = newTarget; editor.setText("new reply"); return true; }), };
await invoke("submit", self, "old reply");
expect(self.replyTarget).toBe(newTarget); expect(editor.getText()).toBe("new reply"); expect(self.setReplyTarget).not.toHaveBeenCalled(); expect(self.refreshSessions).toHaveBeenCalledWith(); });
it("preserves new text entered while the same reply succeeds", async () => { const editor = editorWithText("first reply"); const target = { key: "saved-1", summary: savedSummary }; const self: Record<string, unknown> = { replyTarget: target, options: {}, editor, setReplyTarget: vi.fn(), refreshSessions: vi.fn(async () => true), sendReply: vi.fn(async () => { editor.setText("next reply"); return true; }), };
await invoke("submit", self, "first reply");
expect(self.replyTarget).toBe(target); expect(editor.getText()).toBe("next reply"); expect(self.setReplyTarget).not.toHaveBeenCalled(); });
it.each([ { name: "resume failure", failure: "resume", remainsInactive: true }, { name: "send failure", failure: "send", remainsInactive: false }, { name: "replacement text entered during send failure", failure: "send", replacement: "replacement", remainsInactive: false, }, ] as const)("handles $name", async ({ failure, replacement, remainsInactive }) => { const editor = editorWithText("wake up"); const target = { key: "saved-1", summary: savedSummary }; const inactiveAgentIdentities = new Set(["file:/tmp/sessions/saved-1.jsonl"]); const request = vi.fn(async () => { if (failure === "resume") throw new Error("resume failed"); return { success: true, data: { ...savedSummary, lifecycle: "live", activeSessionId: "active-9" }, }; }); const sendPrompt = vi.fn(async () => { if (replacement) editor.setText(replacement); throw new Error("send failed"); }); const self: Record<string, unknown> = { options: { config: { cwd: process.cwd() } }, requireClient: () => ({ request }), findSummaryByActiveSessionId: () => undefined, inactiveAgentIdentities, replyTarget: target, editor, setStatusMessage: vi.fn(), setReplyTarget: vi.fn(), refreshSessions: vi.fn(async () => true), selectSummary: vi.fn(), sendPrompt, sendReply: (replyTarget: unknown, text: string) => invoke("sendReply", self, replyTarget, text), };
await invoke("submit", self, "wake up");
expect(request).toHaveBeenCalledOnce(); expect(request).toHaveBeenCalledWith( expect.objectContaining({ type: "create", sessionPath: savedSummary.sessionFile }), ); expect(sendPrompt).toHaveBeenCalledTimes(failure === "resume" ? 0 : 1); expect(self.selectSummary).toHaveBeenCalledTimes(failure === "resume" ? 0 : 1); expect(self.setStatusMessage).toHaveBeenLastCalledWith(`Failed to send reply: ${failure} failed`); expect(self.replyTarget).toBe(target); expect(self.setReplyTarget).not.toHaveBeenCalled(); expect(editor.setText).toHaveBeenNthCalledWith(1, ""); expect(editor.getText()).toBe(replacement ?? "wake up"); expect(inactiveAgentIdentities.has("file:/tmp/sessions/saved-1.jsonl")).toBe(remainsInactive); expect(self.refreshSessions).toHaveBeenCalledTimes(remainsInactive ? 0 : 1); if (!remainsInactive) { expect(self.refreshSessions).toHaveBeenCalledWith(); } });
it("resumes the current saved row when an armed live target becomes inactive", async () => { const capturedLive = replySummary({ activeSessionId: "active-dead", lifecycle: "live", sessionFile: savedSummary.sessionFile, }); const currentSaved = { ...savedSummary, activeSessionId: undefined }; const request = vi.fn(async () => ({ success: true, data: { ...currentSaved, lifecycle: "live", activeSessionId: "active-new" }, })); const self: Record<string, unknown> = { options: { config: { cwd: process.cwd() } }, requireClient: () => ({ request }), unifiedRecords: [ { daemon: currentSaved, identity: "file:/tmp/sessions/saved-1.jsonl", identityAliases: ["file:/tmp/sessions/saved-1.jsonl"], section: "inactive", searchableText: "", }, ], findSummaryByActiveSessionId: () => undefined, inactiveAgentIdentities: new Set<string>(), replyTarget: undefined, setStatusMessage: vi.fn(), selectSummary: vi.fn(), sendPrompt: vi.fn(async () => {}), };
await invoke("sendReply", self, { key: "active-dead", summary: capturedLive }, "wake up");
expect(request).toHaveBeenCalledWith( expect.objectContaining({ type: "create", sessionPath: savedSummary.sessionFile }), ); expect(self.sendPrompt).toHaveBeenCalledWith("active-new", "wake up", undefined); expect(self.sendPrompt).not.toHaveBeenCalledWith("active-dead", expect.anything(), expect.anything()); });
it("replies to live sessions without resuming, steering or queueing per delivery", async () => { let liveSummary = replySummary({ activeSessionId: "active-1", lifecycle: "live" }); const request = vi.fn(); const self: Record<string, unknown> = { options: { config: {} }, requireClient: () => ({ request }), findSummaryByActiveSessionId: () => liveSummary, setStatusMessage: vi.fn(), selectSummary: vi.fn(), sendPrompt: vi.fn(async () => {}), }; const target = () => ({ key: "active-1", summary: liveSummary });
await invoke("sendReply", self, target(), "hello"); expect(self.sendPrompt).toHaveBeenCalledWith("active-1", "hello", undefined);
liveSummary = replySummary({ activeSessionId: "active-1", lifecycle: "live", isStreaming: true }); await invoke("sendReply", self, target(), "change course"); expect(self.sendPrompt).toHaveBeenCalledWith("active-1", "change course", "steer");
await invoke("sendReply", self, target(), "later please", "followUp"); expect(self.sendPrompt).toHaveBeenCalledWith("active-1", "later please", "followUp");
expect(request).not.toHaveBeenCalled(); expect(self.selectSummary).not.toHaveBeenCalled(); });
it("kills a session created after the view already finished", async () => { const created = replySummary({ id: "active-new", activeSessionId: "active-new", lifecycle: "live" }); const requests: { type: string }[] = []; const self: Record<string, unknown> = { creatingNewSession: false, stopped: false, options: { config: {} }, connectDedicatedClient: vi.fn(async () => ({ close: vi.fn(), request: vi.fn(async (command: { type: string }) => { requests.push(command); // The view finishes while create is in flight. self.stopped = true; return { success: true, data: created }; }), })), setStatusMessage: vi.fn(), selectSummary: vi.fn(), finish: vi.fn(), };
await invoke("createNewSession", self);
expect(requests.map((r) => r.type)).toEqual(["create", "kill"]); expect(self.finish).not.toHaveBeenCalled(); expect(self.selectSummary).not.toHaveBeenCalled(); });
it("does not disarm a composer that was re-armed during the command", async () => { const live = replySummary({ activeSessionId: "active-1", lifecycle: "live" }); const originalTarget = { key: "active-1", summary: live }; const newTarget = { key: "active-2", summary: replySummary({ activeSessionId: "active-2" }) }; const setReplyTarget = vi.fn(); const self: Record<string, unknown> = { requireClient: () => ({ request: vi.fn(async () => { // The user re-arms against a different agent mid-RPC. self.replyTarget = newTarget; return { success: true, data: {} }; }), }), editor: editorWithText(""), setStatusMessage: vi.fn(), setReplyTarget, replyTarget: originalTarget, refreshSessions: vi.fn(async () => true), };
await invoke("runAgentsViewCommand", self, { name: "kill", args: "" }, live);
expect(setReplyTarget).not.toHaveBeenCalled(); });
it("re-resolves the armed target before dispatching a view command", async () => { const stale = replySummary({ sessionFile: "/tmp/sessions/saved-1.jsonl" }); const liveNow = replySummary({ sessionFile: "/tmp/sessions/saved-1.jsonl", activeSessionId: "active-9", lifecycle: "live", }); const runAgentsViewCommand = vi.fn(async () => true); const self: Record<string, unknown> = { replyTarget: { key: "saved-1", summary: stale }, options: {}, unifiedRecords: [ { daemon: liveNow, identity: "file:/tmp/sessions/saved-1.jsonl", identityAliases: ["file:/tmp/sessions/saved-1.jsonl"], section: "idle", searchableText: "", }, ], findSummaryByActiveSessionId: () => undefined, editor: editorWithText(""), runAgentsViewCommand, };
await invoke("submit", self, "/kill");
expect(runAgentsViewCommand).toHaveBeenCalledWith( { name: "kill", args: "" }, expect.objectContaining({ activeSessionId: "active-9" }), ); });});