Skip to content

Commit 1c20853

Browse files
committed
fix(coding-agent): agents-view scope stability, crash-path backSession, summary refresh, pulse, and settings cycling
- Finding 18: stale saved-catalog readiness could destructively drop scope during refresh; reset readiness when refresh starts. origin: PR #586 - Finding 19: resolved scope roots were lost across view remounts, swallowing early Left navigation; persist and restore the root summary. origin: PR #586 - Finding 20: post-attach crashes left backSession pointing at the prior session; assign the opened summary before interactive mode runs. origin: PR #586 - Findings 21 and 24: startup seeding could leave subagent counts stale and background subagents drove an unused render pulse; refresh every seed and pulse only while streaming. origin: PR #587 - Finding 27: custom idle-eviction values cycled directly to off; include custom numeric values in the sorted cycle. origin: PR #588
1 parent eb11e97 commit 1c20853

7 files changed

Lines changed: 328 additions & 20 deletions

File tree

‎packages/coding-agent/src/modes/agents-view/agents-view-mode.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ export type AgentsViewPersistentState = {
151151
selectedRowIdentity?: string;
152152
backSession?: SessionSummary;
153153
scopeFrames?: AgentsViewScopeFrame[];
154+
scopeRootSummary?: SessionSummary;
154155
selectedSessionKey?: AgentsViewSelectionKey;
155156
// Ancestor chain to re-expand on return to a nested agent. Kept by sessionId,
156157
// not row identity, so it survives an active→persisted identity flip.
@@ -395,6 +396,7 @@ export async function runAgentsViewMode(options: AgentsViewModeOptions): Promise
395396
let opened: OpenedAgentsViewSession | undefined;
396397
try {
397398
opened = await openAgentsViewSession(options, result.summary);
399+
persistentState.backSession = opened.summary;
398400
if (opened.cwdFallbackNotice) persistentState.statusMessage = opened.cwdFallbackNotice;
399401
const uiServices = await resolveAgentsViewSessionUiServices(options, opened.summary);
400402
const interactiveMode = new InteractiveMode({
@@ -626,6 +628,7 @@ export class AgentsViewMode implements Component, Focusable {
626628
persistentState.scopeFrames ?? (options.initialScopeKey ? [{ scope: options.initialScopeKey }] : []);
627629
persistentState.scopeFrames = initialFrames;
628630
this.scopeKey = initialFrames.at(-1)?.scope;
631+
this.scopeRootSummary = persistentState.scopeRootSummary;
629632
this.selectedRowIdentity = persistentState.selectedRowIdentity;
630633
this.selectedSessionKey = persistentState.selectedSessionKey;
631634
this.selectedActiveSessionId = persistentState.selectedSessionKey?.activeSessionId;
@@ -2061,6 +2064,7 @@ export class AgentsViewMode implements Component, Focusable {
20612064
this.persistentState.scopeFrames = resolution.frames;
20622065
this.scopeKey = resolution.frames.at(-1)?.scope;
20632066
this.scopeRootSummary = resolution.root ? summaryForUnifiedRecord(resolution.root) : undefined;
2067+
this.persistentState.scopeRootSummary = this.scopeRootSummary;
20642068
if (resolution.droppedFrames > 0) {
20652069
const destination = resolution.root ? "the nearest available parent" : "the global view";
20662070
this.setStatusMessage(`Scope is no longer available; returned to ${destination}`, { render: false });
@@ -2094,6 +2098,7 @@ export class AgentsViewMode implements Component, Focusable {
20942098
const generation = ++this.savedCatalogGeneration;
20952099
this.persistentState.savedCatalogGeneration = generation;
20962100
this.savedCatalogRefreshPending = true;
2101+
this.savedCatalogReady = false;
20972102
const successfulSessions = this.lastSuccessfulSavedSessions;
20982103
const progressiveSessions = new Map(
20992104
successfulSessions.map((session) => [resolvePath(canonicalizePath(session.path)), session]),

‎packages/coding-agent/src/modes/interactive/components/settings-selector.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,11 @@ export class SettingsSelectorComponent extends Container {
203203
super();
204204

205205
let currentWarnings = { ...config.warnings };
206+
const idleEvictionValues = [30, 60, 90, 180, 360];
207+
if (typeof config.idleEvictionMinutes === "number" && !idleEvictionValues.includes(config.idleEvictionMinutes)) {
208+
idleEvictionValues.push(config.idleEvictionMinutes);
209+
idleEvictionValues.sort((a, b) => a - b);
210+
}
206211

207212
const items: SettingItem[] = [
208213
{
@@ -217,7 +222,7 @@ export class SettingsSelectorComponent extends Container {
217222
label: "Idle worker eviction",
218223
description: "Stop fully idle agent trees after this many minutes (global daemon policy)",
219224
currentValue: String(config.idleEvictionMinutes),
220-
values: ["off", "30", "60", "90", "180", "360"],
225+
values: ["off", ...idleEvictionValues.map(String)],
221226
},
222227
{
223228
id: "steering-mode",

‎packages/coding-agent/src/modes/interactive/interactive-mode.ts‎

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3280,7 +3280,7 @@ export class InteractiveMode {
32803280
}
32813281

32823282
private updateWorkingPulse(): void {
3283-
const active = this.isAgentStreaming() || this.countRunningSubagents() > 0;
3283+
const active = this.isAgentStreaming();
32843284
if (!active) {
32853285
this.stopWorkingPulse();
32863286
return;
@@ -3304,16 +3304,6 @@ export class InteractiveMode {
33043304
}
33053305
}
33063306

3307-
private countRunningSubagents(): number {
3308-
let count = 0;
3309-
for (const child of this.subagentSnapshots.values()) {
3310-
if (child.status === "running") {
3311-
count += 1;
3312-
}
3313-
}
3314-
return count;
3315-
}
3316-
33173307
private shouldShowWorkingLoader(): boolean {
33183308
// Background subagents (agent turn done, asyncio tasks still running) would
33193309
// otherwise show a textless spinner; the subagent tree above the loader carries
@@ -5799,16 +5789,14 @@ export class InteractiveMode {
57995789
}
58005790

58015791
private seedSubagentSummary(children: readonly AgentConnectionRlmChildAgentSnapshot[] | undefined): void {
5802-
let changed = false;
58035792
for (const child of children ?? []) {
58045793
// Live updates can arrive before the initial snapshot; do not replace them
58055794
// with the snapshot's older state.
58065795
if (!this.subagentSnapshots.has(child.id) && child.status !== "cancelled") {
58075796
this.subagentSnapshots.set(child.id, child);
5808-
changed = true;
58095797
}
58105798
}
5811-
if (changed) this.refreshSubagentSummary();
5799+
this.refreshSubagentSummary();
58125800
}
58135801

58145802
private replaceSubagentSummary(children: readonly AgentConnectionRlmChildAgentSnapshot[] | undefined): void {
Lines changed: 243 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,88 @@
1-
import { describe, expect, it, vi } from "vitest";
2-
import { AgentsViewMode } from "../src/modes/agents-view/agents-view-mode.js";
1+
import { setKeybindings } from "@earendil-works/pi-tui";
2+
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
3+
import { KeybindingsManager } from "../src/core/keybindings.js";
4+
import type { AgentConnectionSavedSessionInfo } from "../src/modes/agent-connection/types.js";
5+
import {
6+
AgentsViewMode,
7+
type AgentsViewPersistentState,
8+
runAgentsViewMode,
9+
} from "../src/modes/agents-view/agents-view-mode.js";
10+
import { resolveAgentsViewLeftResult } from "../src/modes/agents-view/agents-view-state.js";
11+
import type { SessionSummary } from "../src/modes/daemon/daemon-session-list.js";
12+
13+
const modeMocks = vi.hoisted(() => ({
14+
interactiveRun: vi.fn<() => Promise<never>>(),
15+
teardownSessionUi: vi.fn(async () => undefined),
16+
dispose: vi.fn(async () => undefined),
17+
}));
18+
19+
vi.mock("../src/config.js", async (importOriginal) => {
20+
const actual = await importOriginal<typeof import("../src/config.js")>();
21+
return { ...actual, appendRotatingLog: vi.fn() };
22+
});
23+
24+
vi.mock("../src/modes/daemon/daemon-client.js", () => ({
25+
DaemonClient: class {
26+
connect = vi.fn(async () => undefined);
27+
close = vi.fn();
28+
},
29+
getDaemonSocketCloseReason: vi.fn(),
30+
}));
31+
32+
vi.mock("../src/modes/agent-connection/daemon-agent-connection.js", () => ({
33+
DaemonAgentConnection: Object.assign(function DaemonAgentConnection() {}, {
34+
attach: vi.fn(async () => ({ dispose: modeMocks.dispose })),
35+
}),
36+
}));
37+
38+
vi.mock("../src/modes/interactive/interactive-mode.js", async (importOriginal) => {
39+
const actual = await importOriginal<typeof import("../src/modes/interactive/interactive-mode.js")>();
40+
return {
41+
...actual,
42+
InteractiveMode: class {
43+
run = modeMocks.interactiveRun;
44+
teardownSessionUi = modeMocks.teardownSessionUi;
45+
},
46+
};
47+
});
48+
49+
function summary(overrides: Partial<SessionSummary> = {}): SessionSummary {
50+
return {
51+
id: "scope-active",
52+
activeSessionId: "scope-active",
53+
lifecycle: "live",
54+
activity: "idle",
55+
isSessionActive: false,
56+
sessionId: "scope-session",
57+
sessionFile: "/tmp/scope.jsonl",
58+
cwd: "/tmp",
59+
isStreaming: false,
60+
isCompacting: false,
61+
attachedClients: 0,
62+
messageCount: 1,
63+
sessionActions: { queuedCount: 0, steering: [], followUps: [] },
64+
...overrides,
65+
};
66+
}
67+
68+
function invoke(method: string, self: object, ...args: unknown[]): unknown {
69+
const member = Reflect.get(AgentsViewMode.prototype, method) as ((...a: unknown[]) => unknown) | undefined;
70+
if (typeof member !== "function") throw new Error(`AgentsViewMode.${method} no longer exists`);
71+
return member.call(self, ...args);
72+
}
73+
74+
const settingsManager = {
75+
getTheme: () => "dark",
76+
getShowHardwareCursor: () => false,
77+
getClearOnShrink: () => false,
78+
getEditorPaddingX: () => 0,
79+
getAutocompleteMaxVisible: () => 5,
80+
};
81+
82+
describe("AgentsViewMode", () => {
83+
beforeAll(() => setKeybindings(new KeybindingsManager()));
84+
beforeEach(() => vi.clearAllMocks());
385

4-
describe("AgentsViewMode search selection", () => {
586
it("keeps the selection chosen by row rebuilding when the query changes", () => {
687
const self = {
788
editor: { getText: () => "matching query" },
@@ -12,10 +93,168 @@ describe("AgentsViewMode search selection", () => {
1293
ui: { requestRender: vi.fn() },
1394
};
1495

15-
(AgentsViewMode.prototype as unknown as { queryChanged(this: typeof self): void }).queryChanged.call(self);
96+
invoke("queryChanged", self);
1697

1798
expect(self.persistentState.query).toBe("matching query");
1899
expect(self.rebuildRows).toHaveBeenCalledOnce();
19100
expect(self.selectedIndex).toBe(4);
20101
});
102+
103+
it("uses the opened session as the crash-path back target", async () => {
104+
const opened = summary({ sessionName: "opened" });
105+
const previous = summary({ id: "previous", activeSessionId: "previous", sessionId: "previous" });
106+
const runView = vi
107+
.spyOn(AgentsViewMode.prototype, "run")
108+
.mockResolvedValueOnce({ type: "open", summary: opened })
109+
.mockImplementationOnce(function (this: AgentsViewMode) {
110+
const state = (this as unknown as { persistentState: AgentsViewPersistentState }).persistentState;
111+
expect(state.backSession).toMatchObject({ sessionId: opened.sessionId });
112+
return Promise.resolve({ type: "exit" });
113+
});
114+
modeMocks.interactiveRun.mockRejectedValueOnce(new Error("post-attach crash"));
115+
116+
await runAgentsViewMode({
117+
socketPath: "/tmp/fake-daemon.sock",
118+
config: { cwd: "/tmp" } as never,
119+
initialSession: previous,
120+
uiServices: {
121+
settingsManager: settingsManager as never,
122+
modelRegistry: {} as never,
123+
getInitialCwd: () => "/tmp",
124+
getInitialSessionName: () => undefined,
125+
getThemes: () => [],
126+
},
127+
});
128+
129+
expect(modeMocks.teardownSessionUi).toHaveBeenCalledWith({ preserveAltScreen: true });
130+
expect(modeMocks.dispose).toHaveBeenCalledOnce();
131+
runView.mockRestore();
132+
});
133+
134+
it("does not discard scope while the saved-session refresh is in flight", async () => {
135+
let finishRefresh: ((value: { success: true; data: { sessions: unknown[] } }) => void) | undefined;
136+
const request = vi.fn(
137+
() =>
138+
new Promise<{ success: true; data: { sessions: unknown[] } }>((resolve) => {
139+
finishRefresh = resolve;
140+
}),
141+
);
142+
const scopeSummary = summary();
143+
const persistentState: AgentsViewPersistentState = {
144+
scopeFrames: [{ scope: { sessionId: scopeSummary.sessionId, activeSessionId: scopeSummary.activeSessionId } }],
145+
};
146+
const self: Record<string, unknown> = {
147+
options: { config: { cwd: "/tmp" } },
148+
persistentState,
149+
savedCatalogGeneration: 0,
150+
savedCatalogReady: true,
151+
savedCatalogRefreshPending: false,
152+
lastSuccessfulSavedSessions: [],
153+
savedSessions: [],
154+
requireClient: () => ({ request }),
155+
getSavedSessionCatalogContext: () => ({ cwd: "/tmp" }),
156+
reconcileCatalogs: vi.fn(),
157+
resolveMissingSelectionAnchor: vi.fn(),
158+
};
159+
160+
const refresh = invoke("refreshSavedSessions", self) as Promise<boolean>;
161+
await vi.waitFor(() => expect(request).toHaveBeenCalledOnce());
162+
expect(self.savedCatalogReady).toBe(false);
163+
164+
Object.assign(self, {
165+
lastListedSummaries: [],
166+
heartbeats: [],
167+
inactiveAgentIdentities: new Set(),
168+
pendingDeleteAgent: undefined,
169+
liveCatalogReady: true,
170+
liveCatalogRefreshPending: false,
171+
scopeKey: persistentState.scopeFrames?.[0]?.scope,
172+
expandedSubagentParents: new Set(),
173+
programShownParents: new Set(),
174+
editor: { getText: () => "" },
175+
getFilteredRecords: () => Reflect.get(self, "scopedRecords"),
176+
applyPendingAncestorExpansion: vi.fn(),
177+
restoreSelection: vi.fn(),
178+
ui: { requestRender: vi.fn() },
179+
setStatusMessage: vi.fn(),
180+
withPendingDeleteSession: (sessions: SessionSummary[]) => sessions,
181+
});
182+
self.reconcileCatalogs = () => invoke("reconcileCatalogs", self);
183+
invoke("reconcileCatalogs", self);
184+
expect(persistentState.scopeFrames).toHaveLength(1);
185+
186+
const saved: AgentConnectionSavedSessionInfo = {
187+
path: "/tmp/scope.jsonl",
188+
id: "scope-session",
189+
cwd: "/tmp",
190+
created: new Date("2026-01-01T00:00:00Z"),
191+
modified: new Date("2026-01-01T00:00:00Z"),
192+
messageCount: 1,
193+
firstMessage: "scope",
194+
allMessagesText: "scope",
195+
};
196+
finishRefresh?.({
197+
success: true,
198+
data: {
199+
sessions: [
200+
{
201+
...saved,
202+
created: saved.created.toISOString(),
203+
modified: saved.modified.toISOString(),
204+
},
205+
],
206+
},
207+
});
208+
await expect(refresh).resolves.toBe(true);
209+
expect(persistentState.scopeFrames).toHaveLength(1);
210+
});
211+
212+
it("carries the resolved scope root across view remounts", () => {
213+
const root = summary({ sessionName: "Scoped root" });
214+
const persistentState: AgentsViewPersistentState = {
215+
scopeFrames: [{ scope: { sessionId: root.sessionId, activeSessionId: root.activeSessionId } }],
216+
};
217+
const self: Record<string, unknown> = {
218+
persistentState,
219+
lastListedSummaries: [root],
220+
savedSessions: [],
221+
heartbeats: [],
222+
inactiveAgentIdentities: new Set(),
223+
pendingDeleteAgent: undefined,
224+
liveCatalogReady: true,
225+
savedCatalogReady: true,
226+
scopeKey: persistentState.scopeFrames?.[0]?.scope,
227+
expandedSubagentParents: new Set(),
228+
programShownParents: new Set(),
229+
editor: { getText: () => "" },
230+
getFilteredRecords: () => Reflect.get(self, "scopedRecords"),
231+
applyPendingAncestorExpansion: vi.fn(),
232+
restoreSelection: vi.fn(),
233+
ui: { requestRender: vi.fn() },
234+
setStatusMessage: vi.fn(),
235+
withPendingDeleteSession: (sessions: SessionSummary[]) => sessions,
236+
};
237+
invoke("reconcileCatalogs", self);
238+
expect(persistentState.scopeRootSummary).toMatchObject({ sessionId: root.sessionId });
239+
240+
const remount = new AgentsViewMode(
241+
{
242+
config: { cwd: "/tmp" } as never,
243+
uiServices: {
244+
settingsManager: settingsManager as never,
245+
modelRegistry: {} as never,
246+
getInitialCwd: () => "/tmp",
247+
getInitialSessionName: () => undefined,
248+
getThemes: () => [],
249+
},
250+
},
251+
persistentState,
252+
) as AgentsViewMode & Record<string, unknown>;
253+
const remountedRoot = Reflect.get(remount, "scopeRootSummary") as SessionSummary;
254+
expect(remountedRoot).toMatchObject({ sessionName: "Scoped root" });
255+
expect(resolveAgentsViewLeftResult(remountedRoot)).toMatchObject({
256+
type: "scope_back",
257+
selection: { sessionId: root.sessionId },
258+
});
259+
});
21260
});

‎packages/coding-agent/test/interactive-mode-streaming.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,4 +325,29 @@ describe("InteractiveMode streaming events", () => {
325325
expect(fakeThis.focusEditor).not.toHaveBeenCalled();
326326
expect(fakeThis.editor.handleInput).not.toHaveBeenCalled();
327327
});
328+
329+
test("does not pulse renders for background-only subagent work", () => {
330+
vi.useFakeTimers();
331+
try {
332+
const requestRender = vi.fn();
333+
const mode = Object.create(InteractiveMode.prototype) as InteractiveMode & Record<string, unknown>;
334+
Object.assign(mode, {
335+
connectionState: { isStreaming: false },
336+
subagentSnapshots: new Map([["worker", { id: "worker", status: "running" }]]),
337+
pulseTimer: undefined,
338+
ui: { requestRender },
339+
});
340+
const updatePulse = Reflect.get(InteractiveMode.prototype, "updateWorkingPulse") as (
341+
this: typeof mode,
342+
) => void;
343+
344+
updatePulse.call(mode);
345+
vi.advanceTimersByTime(1000);
346+
347+
expect(requestRender).not.toHaveBeenCalled();
348+
expect(Reflect.get(mode, "pulseTimer")).toBeUndefined();
349+
} finally {
350+
vi.useRealTimers();
351+
}
352+
});
328353
});

0 commit comments

Comments
 (0)