diff --git a/packages/host/src/agent-notices.test.ts b/packages/host/src/agent-notices.test.ts index 91bddd4dcc..397a6aea1a 100644 --- a/packages/host/src/agent-notices.test.ts +++ b/packages/host/src/agent-notices.test.ts @@ -277,6 +277,63 @@ it.effect( }), ); +it.effect( + "an agent the followed list no longer names on a plan it still holds, as one archived, takes its dot with it", + () => + Effect.gen(function* () { + const f = fixture(); + const notices = yield* f.notices; + f.focus(false); + + notices.observeFollowed([agent(CODING_AGENT_STATUS.RUNNING)], [TASK_ID]); + notices.observeFollowed([agent(CODING_AGENT_STATUS.COMPLETED)], [TASK_ID]); + yield* settled; + assert.deepEqual(f.unseen.at(-1), [AGENT_ID]); + + notices.observeFollowed([], [TASK_ID]); + assert.deepEqual(f.unseen.at(-1), []); + }), +); + +it.effect( + "an agent a Start answered is kept through a push that does not name it yet, so its quick end is still announced", + () => + Effect.gen(function* () { + const f = fixture(); + const notices = yield* f.notices; + f.focus(false); + + notices.observeAgents([agent(CODING_AGENT_STATUS.RUNNING)]); + notices.observeFollowed([], [TASK_ID]); + notices.observeFollowed([agent(CODING_AGENT_STATUS.COMPLETED)], [TASK_ID]); + yield* settled; + + assert.deepEqual(f.said(), [{ title: TASK_NAME, body: "Opus 5.5 finished" }]); + assert.deepEqual(f.unseen.at(-1), [AGENT_ID]); + }), +); + +it.effect( + "an agent a Start answered that the list never names, as one archived before it did, is let go after a few pushes with its dot", + () => + Effect.gen(function* () { + const f = fixture(); + const notices = yield* f.notices; + f.focus(false); + + notices.observeAgents([agent(CODING_AGENT_STATUS.RUNNING)]); + notices.observeAgents([agent(CODING_AGENT_STATUS.COMPLETED)]); + yield* settled; + assert.deepEqual(f.unseen.at(-1), [AGENT_ID]); + + notices.observeFollowed([], [TASK_ID]); + notices.observeFollowed([], [TASK_ID]); + assert.deepEqual(f.unseen.at(-1), [AGENT_ID], "kept through a push or two that predate it"); + notices.observeFollowed([], [TASK_ID]); + assert.deepEqual(f.unseen.at(-1), []); + }), +); + /** * Every step the inbox read has to take, taken: the accounts' stream hands * the account on, the read it switched to asks, and the look writes what diff --git a/packages/host/src/agent-notices.ts b/packages/host/src/agent-notices.ts index aa3e504126..e8244e0a9a 100644 --- a/packages/host/src/agent-notices.ts +++ b/packages/host/src/agent-notices.ts @@ -137,6 +137,9 @@ const ENDED_WORD = { [CODING_AGENT_STATUS.FAILED]: "failed", } as const; +/** How many pushes of the followed list in a row may leave out an agent only a Start answered before the ledger lets it go. */ +const UNNAMED_PUSHES_KEPT = 2; + /** What kind of event a notice tells of. */ export const NOTICE_KIND = { AGENT_END: "agent-end", @@ -351,7 +354,7 @@ export interface AgentNotices { observeModels(models: readonly CatalogModel[]): void; /** A task the host no longer holds: its agents are forgotten. */ observeTaskGone(taskId: string): void; - /** Every agent on a plan as the list followed live carries them, and the plans it holds: an agent of any other plan is forgotten. */ + /** Every agent on a plan as the list followed live carries them, and the plans it holds: an agent of any other plan, or one of a held plan the list no longer names, as one archived, is forgotten. */ observeFollowed(agents: readonly CodingAgentSummary[], taskIds: readonly string[]): void; /** The panel said which agent's tab it shows, or none. */ shown(agentId: string | null): void; @@ -617,6 +620,10 @@ export const createAgentNotices = /* @__PURE__ */ Effect.fn("desktop/createAgent /** What each agent with no plan is announced under, by agent: its automation's name, or its repository where the reader may not read the automation. */ const tasklessSubjects = new Map(); const unseen = new Set(); + /** The agents on plans the followed list last named, so one it stops naming is known to have left it rather than not reached it yet. */ + let followedAgents = new Set(); + /** How many pushes in a row have left out each agent the list has not named yet, as one only a Start answered. */ + const unnamedPushes = new Map(); const ends = yield* Queue.unbounded(); let models: readonly CatalogModel[] | undefined; let shownAgent: string | null = null; @@ -1088,7 +1095,24 @@ export const createAgentNotices = /* @__PURE__ */ Effect.fn("desktop/createAgent observeTaskGone: (taskId) => forget((agent) => agent.taskId !== taskId), observeFollowed: (agents, taskIds) => { const held = new Set(taskIds); - forget((agent) => agent.taskId === null || held.has(agent.taskId)); + // The list names every agent on its plans but the archived, so one it named and no longer + // does takes its dot with it. One only a Start answered is kept through a push or two that + // leave it out, since a push of the plans may carry agents from before that Start, and let go + // after that, as one archived before the list ever named it. + const named = new Set(agents.map((agent) => agent.id)); + forget((agent, agentId) => { + if (agent.taskId === null) return true; + if (!held.has(agent.taskId)) return false; + if (named.has(agentId)) return true; + if (followedAgents.has(agentId)) return false; + const pushes = (unnamedPushes.get(agentId) ?? 0) + 1; + unnamedPushes.set(agentId, pushes); + return pushes <= UNNAMED_PUSHES_KEPT; + }); + for (const agentId of unnamedPushes.keys()) { + if (named.has(agentId) || !ledger.has(agentId)) unnamedPushes.delete(agentId); + } + followedAgents = named; notices.observeAgents(agents); }, shown: (agentId) => {