fix: dedupe stale subagent rows in reply views

This commit is contained in:
Tak Hoffman
2026-03-24 16:01:58 -05:00
parent 3031f061fc
commit 69d6e95c2a
4 changed files with 157 additions and 9 deletions

View File

@@ -998,6 +998,60 @@ describe("sessions tools", () => {
expect(details.text).toContain("active (waiting on 1 child)");
});
it("subagents list dedupes stale rows for the same child session", async () => {
resetSubagentRegistryForTests();
const now = Date.now();
const childSessionKey = "agent:main:subagent:list-dedupe-worker";
addSubagentRunForTests({
runId: "run-list-current",
childSessionKey,
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "current worker label",
cleanup: "keep",
createdAt: now - 60_000,
startedAt: now - 60_000,
});
addSubagentRunForTests({
runId: "run-list-stale",
childSessionKey,
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "stale worker label",
cleanup: "keep",
createdAt: now - 120_000,
startedAt: now - 120_000,
endedAt: now - 90_000,
outcome: { status: "ok" },
});
const tool = createOpenClawTools({
agentSessionKey: "agent:main:main",
}).find((candidate) => candidate.name === "subagents");
expect(tool).toBeDefined();
if (!tool) {
throw new Error("missing subagents tool");
}
const result = await tool.execute("call-subagents-list-dedupe", { action: "list" });
const details = result.details as {
status?: string;
active?: Array<{ runId?: string }>;
recent?: Array<{ runId?: string }>;
text?: string;
};
expect(details.status).toBe("ok");
expect(details.active).toEqual([
expect.objectContaining({
runId: "run-list-current",
}),
]);
expect(details.recent?.find((entry) => entry.runId === "run-list-stale")).toBeFalsy();
expect(details.text).toContain("current worker label");
expect(details.text).not.toContain("stale worker label");
});
it("subagents list usage separates io tokens from prompt/cache", async () => {
resetSubagentRegistryForTests();
const now = Date.now();

View File

@@ -272,6 +272,15 @@ export function buildSubagentList(params: {
}): BuiltSubagentList {
const now = Date.now();
const recentCutoff = now - params.recentMinutes * 60_000;
const dedupedRuns: SubagentRunRecord[] = [];
const seenChildSessionKeys = new Set<string>();
for (const entry of sortSubagentRuns(params.runs)) {
if (seenChildSessionKeys.has(entry.childSessionKey)) {
continue;
}
seenChildSessionKeys.add(entry.childSessionKey);
dedupedRuns.push(entry);
}
const cache = new Map<string, Record<string, SessionEntry>>();
const pendingDescendantCount = createPendingDescendantCounter();
let index = 1;
@@ -316,10 +325,10 @@ export function buildSubagentList(params: {
index += 1;
return view;
};
const active = params.runs
const active = dedupedRuns
.filter((entry) => isActiveSubagentRun(entry, pendingDescendantCount))
.map((entry) => buildListEntry(entry, getSubagentSessionRuntimeMs(entry, now) ?? 0));
const recent = params.runs
const recent = dedupedRuns
.filter(
(entry) =>
!isActiveSubagentRun(entry, pendingDescendantCount) &&

View File

@@ -0,0 +1,78 @@
import { describe, expect, it, vi } from "vitest";
const { listBySessionMock } = vi.hoisted(() => ({
listBySessionMock: vi.fn(),
}));
vi.mock("../../../infra/outbound/session-binding-service.js", () => ({
getSessionBindingService: () => ({
listBySession: listBySessionMock,
}),
}));
import { handleSubagentsAgentsAction } from "./action-agents.js";
describe("handleSubagentsAgentsAction", () => {
it("dedupes stale bound rows for the same child session", () => {
const childSessionKey = "agent:main:subagent:worker";
listBySessionMock.mockImplementation((sessionKey: string) =>
sessionKey === childSessionKey
? [
{
bindingId: "binding-1",
targetSessionKey: childSessionKey,
targetKind: "subagent",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "thread-1",
},
status: "active",
boundAt: Date.now() - 20_000,
},
]
: [],
);
const result = handleSubagentsAgentsAction({
params: {
ctx: {
Provider: "discord",
Surface: "discord",
},
command: {
channel: "discord",
},
},
requesterKey: "agent:main:main",
runs: [
{
runId: "run-current",
childSessionKey,
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "current worker label",
cleanup: "keep",
createdAt: Date.now() - 10_000,
startedAt: Date.now() - 10_000,
},
{
runId: "run-stale",
childSessionKey,
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "stale worker label",
cleanup: "keep",
createdAt: Date.now() - 20_000,
startedAt: Date.now() - 20_000,
endedAt: Date.now() - 15_000,
outcome: { status: "ok" },
},
],
restTokens: [],
} as never);
expect(result.reply?.text).toContain("current worker label");
expect(result.reply?.text).not.toContain("stale worker label");
});
});

View File

@@ -46,15 +46,22 @@ export function handleSubagentsAgentsAction(ctx: SubagentsCommandContext): Comma
return resolved;
};
const visibleRuns = sortSubagentRuns(runs).filter((entry) => {
if (!entry.endedAt) {
return true;
const visibleRuns: typeof runs = [];
const seenChildSessionKeys = new Set<string>();
for (const entry of sortSubagentRuns(runs)) {
if (seenChildSessionKeys.has(entry.childSessionKey)) {
continue;
}
if (countPendingDescendantRuns(entry.childSessionKey) > 0) {
return true;
const visible =
!entry.endedAt ||
countPendingDescendantRuns(entry.childSessionKey) > 0 ||
resolveSessionBindings(entry.childSessionKey).length > 0;
if (!visible) {
continue;
}
return resolveSessionBindings(entry.childSessionKey).length > 0;
});
seenChildSessionKeys.add(entry.childSessionKey);
visibleRuns.push(entry);
}
const lines = ["agents:", "-----"];
if (visibleRuns.length === 0) {