Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions packages/coding-agent/src/modes/agents-view/agents-view-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,27 @@ export interface AgentsViewRow {
}

export function classifyAgentsViewSession(summary: SessionSummary): AgentsViewSection {
// LOCAL PATCH(agents-view-done-in-running): drop when upstream fixes #1873 / lands #1967.
// The supervisor ledger only re-publishes a row when a summarizer verdict text changes, so a
// finished agent keeps activity "working" (label "classifying") and a frozen rosterStatus
// "running" forever. Trust "running" only when a hard busy signal still backs it; the
// classifying-only case is idle. Mirrors isSessionSummaryBusy() plus the queued/heartbeat
// reasons classifyAgentStatus() has for "running".
// DROP TEST: delete this block (through "END LOCAL PATCH"), then run
// npx vitest --run packages/coding-agent/test/agents-view-done-in-running.test.ts
// If that test still passes, upstream classifies finished agents correctly and this workaround is
// dead: delete this block AND packages/coding-agent/test/agents-view-done-in-running.test.ts.
if (
summary.rosterStatus === "running" &&
summary.statusLabel !== "queued" &&
summary.hasActiveHeartbeat !== true &&
!summary.isSessionActive &&
summary.hasRunningRlmChildren !== true &&
summary.activeSessionId
) {
return "idle";
}
// END LOCAL PATCH
return summary.rosterStatus ?? classifySessionRosterStatus(summary);
}

Expand Down Expand Up @@ -706,6 +727,9 @@ export function buildAgentsViewRows(
childrenByParent.set(parent, siblings);
}
propagateHeartbeatStateToAncestors(baseRows, parentByChild);
// LOCAL PATCH(agents-view-active-ancestors): an idle session with a busy descendant is Running.
promoteAncestorsOfRunningRows(baseRows, rowsByKey);
// END LOCAL PATCH

const roots = baseRows.filter((row) => !nestedRows.has(row));
const flattened: AgentsViewRow[] = [];
Expand Down Expand Up @@ -951,6 +975,57 @@ function sectionRank(section: AgentsViewSection): number {
}
}

// LOCAL PATCH(agents-view-active-ancestors): drop when upstream puts a session with a busy
// descendant in the Running section by itself (see discussion #1873 / PR #1967).
// A parent learns about its subtree only through summary.hasRunningRlmChildren. That flag is a
// snapshot taken the last time the parent's own roster row was flushed, and
// AgentSession.hasRunningRlmChildren() only reports child *runs* still in "running" or "queued":
// a child that streams or runs a tool after its spawn run settled, a child admitted but not yet
// bound, and a busy grandchild under a settled child all leave the parent's snapshot false, so the
// parent renders under Idle while its subtree works. The view already holds one live row per
// descendant, so classify the ancestor from those rows instead of from that snapshot.
// Kept out of propagateHeartbeatStateToAncestors on purpose: PR #1967 deletes that helper whole.
// DROP TEST, one command: delete this function and the marked call site in buildAgentsViewRows,
// then run
// npx vitest --run packages/coding-agent/test/agents-view-active-ancestors.test.ts
// If it still passes without them, upstream does this natively and the workaround is dead: delete
// both blocks AND packages/coding-agent/test/agents-view-active-ancestors.test.ts.
function promoteAncestorsOfRunningRows(
rows: readonly MutableAgentsViewRow[],
rowsByKey: ReadonlyMap<string, MutableAgentsViewRow>,
): void {
// Same nesting the row loop above built: only rows that stayed "subagent" are nested children.
const parentOf = (row: MutableAgentsViewRow): MutableAgentsViewRow | undefined => {
if (row.kind !== "subagent") {
return undefined;
}
const parent = findParentRow(row.summary, rowsByKey);
return parent && parent !== row ? parent : undefined;
};
for (const row of rows) {
if (row.section !== "running") {
continue;
}
const visited = new Set<MutableAgentsViewRow>([row]);
let ancestor = parentOf(row);
while (ancestor && !visited.has(ancestor)) {
visited.add(ancestor);
// Only Idle is promoted; an Inactive ancestor is not resident and must stay listed as such.
if (ancestor.section === "idle") {
ancestor.section = "running";
ancestor.statusLabel = getSessionStatusLabel({ ...ancestor.summary, hasRunningRlmChildren: true });
// The direct-child tally in buildAgentsViewRows ran before this promotion.
const parent = parentOf(ancestor);
if (parent) {
parent.runningSubagentCount += 1;
}
}
ancestor = parentOf(ancestor);
}
}
}
// END LOCAL PATCH

function getTimestamp(value: string | undefined): number {
if (!value) {
return 0;
Expand Down
156 changes: 156 additions & 0 deletions packages/coding-agent/test/agents-view-active-ancestors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// LOCAL PATCH(agents-view-active-ancestors): whole file is local. Delete it together with the
// promoteAncestorsOfRunningRows block in agents-view-state.ts once upstream puts a session with a
// busy descendant in the Running section by itself.
// DROP TEST, one command: delete the LOCAL PATCH function and its marked call site in
// agents-view-state.ts, then run
// npx vitest --run packages/coding-agent/test/agents-view-active-ancestors.test.ts
// If this file still passes without them, upstream does this natively and the workaround is dead:
// delete both blocks AND this whole file.
import { describe, expect, test } from "vitest";
import { buildAgentsViewRows } from "../src/modes/agents-view/agents-view-state.js";
import type { SessionSummary } from "../src/modes/index.js";

function makeSummary(overrides: Partial<SessionSummary>): SessionSummary {
return {
id: overrides.activeSessionId ?? "active-parent",
lifecycle: "live",
activity: "idle",
isSessionActive: false,
sessionId: "session-parent",
cwd: "/tmp/project",
isStreaming: false,
isCompacting: false,
attachedClients: 0,
messageCount: 1,
sessionActions: { queuedCount: 0, steering: [], followUps: [] },
...overrides,
};
}

// A parent whose own roster row says idle: nothing in its snapshot knows about a busy subtree,
// because hasRunningRlmChildren only counts child runs that are still running or queued.
const idleParent = makeSummary({
activeSessionId: "active-parent",
sessionId: "session-parent",
sessionName: "parent",
rosterStatus: "idle",
});

function child(overrides: Partial<SessionSummary>): SessionSummary {
return makeSummary({
activeSessionId: "active-child",
sessionId: "session-child",
sessionName: "child",
runtimeKind: "subagent",
rlmChildId: "c1",
parentActiveSessionId: "active-parent",
parentSessionId: "session-parent",
...overrides,
});
}

function grandchild(overrides: Partial<SessionSummary>): SessionSummary {
return makeSummary({
activeSessionId: "active-grandchild",
sessionId: "session-grandchild",
sessionName: "grandchild",
runtimeKind: "subagent",
rlmChildId: "c2",
parentActiveSessionId: "active-child",
parentSessionId: "session-child",
...overrides,
});
}

const streamingChild = child({
activity: "working",
isSessionActive: true,
isStreaming: true,
rosterStatus: "running",
});
const toolChild = child({ activity: "working", isSessionActive: true, isRunningTools: true, rosterStatus: "running" });
// An admitted child run has no active session yet; the roster still classifies it as running.
const queuedChild = child({
id: "queued-child",
activeSessionId: undefined,
rosterStatus: "running",
statusLabel: "queued",
});
const idleChild = child({ rosterStatus: "idle" });

// Nested rows are only emitted under an expanded parent, so expand every identity the tree exposes.
function expandedRows(summaries: readonly SessionSummary[]): ReturnType<typeof buildAgentsViewRows> {
const expanded = new Set<string>();
let rows = buildAgentsViewRows(summaries, expanded);
for (let pass = 0; pass < 5; pass += 1) {
const before = expanded.size;
for (const row of rows) expanded.add(row.identity);
if (expanded.size === before) break;
rows = buildAgentsViewRows(summaries, expanded);
}
return rows;
}

function sessionRow(summaries: readonly SessionSummary[], sessionId: string) {
return expandedRows(summaries).find(
(row) => (row.kind === "agent" || row.kind === "subagent") && row.summary.sessionId === sessionId,
);
}

function sectionOf(summaries: readonly SessionSummary[], sessionId: string): string | undefined {
return sessionRow(summaries, sessionId)?.section;
}

describe("agents view: a session with a busy descendant renders as running", () => {
test("an idle parent of a streaming child is running", () => {
expect(sectionOf([idleParent, streamingChild], "session-parent")).toBe("running");
});

test("an idle parent of a child running tools is running", () => {
expect(sectionOf([idleParent, toolChild], "session-parent")).toBe("running");
});

test("an idle parent of a queued child is running", () => {
expect(sectionOf([idleParent, queuedChild], "session-parent")).toBe("running");
});

test("a busy grandchild promotes both the idle child and the idle parent", () => {
const rows = [
idleParent,
idleChild,
grandchild({ activity: "working", isSessionActive: true, isStreaming: true, rosterStatus: "running" }),
];
expect(sectionOf(rows, "session-parent")).toBe("running");
expect(sectionOf(rows, "session-child")).toBe("running");
});

test("a promoted ancestor reports its busy subtree in the status label and the child tally", () => {
const summaries = [
idleParent,
idleChild,
grandchild({ isSessionActive: true, isStreaming: true, rosterStatus: "running" }),
];
const parentRow = sessionRow(summaries, "session-parent");
expect(parentRow?.statusLabel).toBe("subagents running");
expect(parentRow?.runningSubagentCount).toBe(1);
});

test("an idle subtree leaves the parent idle", () => {
expect(sectionOf([idleParent, idleChild], "session-parent")).toBe("idle");
expect(sectionOf([idleParent, idleChild], "session-child")).toBe("idle");
});

test("an inactive ancestor is not pulled into running", () => {
const inactiveParent = makeSummary({
activeSessionId: undefined,
sessionId: "session-parent",
sessionName: "parent",
rosterStatus: "inactive",
});
expect(sectionOf([inactiveParent, streamingChild], "session-parent")).toBe("inactive");
});

test("a session with no children is left alone", () => {
expect(sectionOf([idleParent], "session-parent")).toBe("idle");
});
});
79 changes: 79 additions & 0 deletions packages/coding-agent/test/agents-view-done-in-running.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// LOCAL PATCH(agents-view-done-in-running): whole file is local. Delete together with the
// guard in agents-view-state.ts once upstream fixes #1873 / lands #1967.
// DROP TEST: delete the LOCAL PATCH block in agents-view-state.ts, then run
// npx vitest --run packages/coding-agent/test/agents-view-done-in-running.test.ts
// If this test still passes without that block, upstream classifies finished agents correctly and this
// workaround is dead: delete that block AND this whole file.
import { describe, expect, test } from "vitest";
import { classifyAgentsViewSession, classifyUnifiedSession } from "../src/modes/agents-view/agents-view-state.js";
import type { SessionSummary } from "../src/modes/index.js";

function makeSummary(overrides: Partial<SessionSummary>): SessionSummary {
return {
id: "active-1",
activeSessionId: "active-1",
lifecycle: "live",
activity: "idle",
isSessionActive: false,
sessionId: "session-1",
cwd: "/tmp/project",
isStreaming: false,
isCompacting: false,
attachedClients: 0,
messageCount: 1,
sessionActions: { queuedCount: 0, steering: [], followUps: [] },
...overrides,
};
}

// A finished top-level agent whose roster row was frozen at turn_end: the summarizer verdict never
// changed, so no roster flush recomposed the row and rosterStatus stayed "running".
const staleFinished = makeSummary({ activity: "working", rosterStatus: "running" });

describe("agents view: finished agents leave the Running section", () => {
test("a frozen running rosterStatus with no live busy signal classifies as idle", () => {
expect(classifyAgentsViewSession(staleFinished)).toBe("idle");
expect(classifyUnifiedSession({ daemon: staleFinished })).toBe("idle");
});

test("a streaming or tool-running agent stays running", () => {
const streaming = makeSummary({
activity: "working",
isSessionActive: true,
isStreaming: true,
rosterStatus: "running",
});
expect(classifyAgentsViewSession(streaming)).toBe("running");
});

test("an agent with running rlm children stays running", () => {
const withChildren = makeSummary({
activity: "working",
hasRunningRlmChildren: true,
rosterStatus: "running",
});
expect(classifyAgentsViewSession(withChildren)).toBe("running");
});

test("a queued child row stays running", () => {
const queued = makeSummary({
activity: "idle",
activeSessionId: undefined,
rosterStatus: "running",
statusLabel: "queued",
});
expect(classifyAgentsViewSession(queued)).toBe("running");
});

test("a heartbeat-armed session stays running", () => {
const armed = makeSummary({ activity: "idle", hasActiveHeartbeat: true, rosterStatus: "running" });
expect(classifyAgentsViewSession(armed)).toBe("running");
expect(classifyUnifiedSession({ daemon: staleFinished, heartbeat: { activeCount: 1 } })).toBe("running");
});

test("idle and inactive roster statuses are passed through untouched", () => {
expect(classifyAgentsViewSession(makeSummary({ rosterStatus: "idle" }))).toBe("idle");
expect(classifyAgentsViewSession(makeSummary({ rosterStatus: "inactive" }))).toBe("inactive");
expect(classifyAgentsViewSession(makeSummary({ activity: "working" }))).toBe("running");
});
});
Loading