From 9fe8b2e881d3d72a868c356f017b50862a467b32 Mon Sep 17 00:00:00 2001 From: BGamboa13 Date: Fri, 2 Oct 2026 11:25:39 -0400 Subject: [PATCH 1/2] fix(agents): keep an unread stale completion actionable for the parent (#1092) A stale completion was delivered only as a TUI transcript entry. An unread first completion that settled during one long parent tool call therefore never reached the model and requested no wake, so the parent could report the work as still running. The stale path now also sends one compact model-facing notice, without the report, telling the parent to pull the result. The notice takes the same idle/run route as other child content (#1528), so an idle parent gets a coalesced wake through the prompt lifecycle instead of a direct turn. Closes #1092 --- extensions/gentle-agents.ts | 28 +++-- lib/agents-completion-delivery.ts | 13 +-- tests/gentle-agents.test.ts | 167 +++++++++++++++++++++++++++++- 3 files changed, 195 insertions(+), 13 deletions(-) diff --git a/extensions/gentle-agents.ts b/extensions/gentle-agents.ts index 45b145256..2bd5658b9 100644 --- a/extensions/gentle-agents.ts +++ b/extensions/gentle-agents.ts @@ -57,6 +57,7 @@ export const AGENTS_RESULT_TYPE = "gentle-agents.result"; export const AGENTS_MESSAGE_TYPE = "gentle-agents.message"; export const AGENTS_ORCHESTRATOR_MESSAGE_TYPE = "gentle-agents.orchestrator-message"; export const AGENTS_STALE_RESULT_TYPE = "gentle-agents.stale-result"; +export const AGENTS_STALE_NOTICE_TYPE = "gentle-agents.stale-notice"; const RENDER_COALESCE_MS = 400; const CLOCK_TICK_MS = 1000; const TOOL_PREFIX = "subagent_"; @@ -585,8 +586,8 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = // calling tools entirely, so in a long orchestrator run the notification // could land nearly an hour after the parent pulled the same result (#867). // Gentle Agents now owns the pending completions: they settle here, are - // flushed at the next turn boundary, and a stale one never re-enters the - // conversation. + // flushed at the next turn boundary, and a stale one never replays its + // report into the conversation (it gets a compact pull notice instead). const completions = createCompletionQueue(); const messages = createAgentMessageQueue(); let activeAgentRuns = 0; @@ -712,12 +713,27 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = sendToParent({ customType: AGENTS_RESULT_TYPE, content: completionText(task), display: true, details: taskDetails(task) }, route); }; - // A stale completion must not re-enter the LLM conversation, so it is - // delivered as durable TUI-only content and the human still sees it. - const deliverStale = (task: TaskRecord, settledAt: number) => { + // A stale completion never replays its report into the LLM conversation + // (the parent may have used it already; consume() suppresses that case, so + // anything reaching here is unread). A single parent tool call can outlast + // the window, so the human gets the transcript entry and the model gets one + // compact notice, without the report, telling it to pull the result. The + // notice takes the same route as any child content (see the router above, + // #1528): an idle parent stores it with triggerTurn:false and gets one + // coalesced wake instead of a direct turn that skips before_agent_start. + const deliverStale = (task: TaskRecord, settledAt: number, route: Exclude) => { if (activeSessionId() !== task.parentSessionId) return; const ageSeconds = Math.max(0, Math.round((deps.now() - settledAt) / 1000)); pi.appendEntry(AGENTS_STALE_RESULT_TYPE, { taskId: task.id, agent: task.agent, label: task.label, status: task.status, ageSeconds }); + const outcome = task.status === "completed" ? "finished" : task.status.replace("_", " "); + sendToParent( + { + customType: AGENTS_STALE_NOTICE_TYPE, + content: `Subagent ${task.agent} (task ${task.id}, "${task.label}") ${outcome} ${ageSeconds}s ago and its result was not read yet. Call subagent_result or subagent_status with task_id ${task.id}.`, + display: false, + }, + route, + ); }; const deliverMessage = (msg: PendingAgentMessage, route: Exclude) => { @@ -756,7 +772,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = if (!route) return; for (const { task, settledAt, stale } of completions.takeDeliverable(deps.now())) { try { - if (stale) deliverStale(task, settledAt); + if (stale) deliverStale(task, settledAt, route); else deliver(task, route); } catch { /* Best-effort delivery: at most once, even if forwarding fails. */ } } diff --git a/lib/agents-completion-delivery.ts b/lib/agents-completion-delivery.ts index 90a8f4d99..fac5828ee 100644 --- a/lib/agents-completion-delivery.ts +++ b/lib/agents-completion-delivery.ts @@ -11,12 +11,13 @@ // behavior is unit-testable against a fake clock. /** - * A completion older than this at flush time is stale: the parent had many - * turns to pull the result with subagent_result, so replaying it into the - * model context would re-enter state the conversation may already have used. - * Ninety seconds comfortably covers one slow orchestrator turn (model latency - * plus a few tool calls) while staying orders of magnitude below the 50-58 - * minute followUp delays measured in issue #867. + * A completion older than this at flush time is stale: its full report is + * never replayed into model context. Consumed results are already suppressed + * by consume(), so this only bounds how late a full replay may land; an + * unread stale completion still gets a compact pull notice from the extension, + * because one long parent tool call can exceed the window before any flush. + * Ninety seconds stays orders of magnitude below the 50-58 minute followUp + * delays measured in issue #867. */ export const STALE_COMPLETION_MS = 90_000; diff --git a/tests/gentle-agents.test.ts b/tests/gentle-agents.test.ts index a9e46685f..51007e61a 100644 --- a/tests/gentle-agents.test.ts +++ b/tests/gentle-agents.test.ts @@ -4407,7 +4407,18 @@ test("a completion held past the stale window becomes transcript-only content an await tick(); clock += STALE_COMPLETION_MS + 1_000; await fire("turn_end", ctx); - assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 0, "a stale completion never enters the model context"); + assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 0, "a stale completion never replays the full result into the model context"); + // #1092: the model still learns the task finished, via one compact notice without the report. + const notices = sent.filter((entry) => entry.message.customType === "gentle-agents.stale-notice"); + assert.equal(notices.length, 1, "an unread stale completion yields exactly one model-facing notice"); + assert.deepEqual(notices[0]!.options, { deliverAs: "steer", triggerTurn: true }); + assert.equal(notices[0]!.message.display, false); + assert.match(String(notices[0]!.message.content), new RegExp(id)); + assert.match(String(notices[0]!.message.content), /subagent_result/); + assert.doesNotMatch(String(notices[0]!.message.content), /Late answer\./, "the notice never carries the report"); + await fire("agent_end", ctx); + await fire("agent_settled", ctx); + assert.equal(sent.length, 1, "no later flush replays or re-notifies"); const stale = entries.filter((entry) => entry.customType === "gentle-agents.stale-result"); assert.equal(stale.length, 1, "the human still sees the stale completion as durable transcript content"); assert.match(JSON.stringify(stale[0]!.data), new RegExp(id), "the stale notice names the task"); @@ -4419,6 +4430,160 @@ test("a completion held past the stale window becomes transcript-only content an await fire("session_shutdown", ctx); }); +// A stale notice is child content like any other (#1528): it follows the +// idle/run/hold router, so an idle parent is woken through the prompt +// lifecycle instead of a direct turn that skips before_agent_start. +const finishBackground = (harness: ReturnType, index: number, text: string) => { + harness.children[index].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text }] }] }); + harness.children[index].emit({ type: "agent_settled" }); +}; + +test("issue #1092: a stale notice for an idle parent is stored without a direct turn and woken once", async () => { + const { pi, tools, fire, sent, entries, userMessages } = fakePi(); + const harness = deps(); + let clock = 1000; + harness.deps.now = () => clock; + gentleAgents(pi, {}, harness.deps); + const { ctx } = fakeContext(); + await fire("session_start", ctx); + const started = await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Idle stale", mode: "background" }, undefined, undefined, ctx); + const id = (started.details.gentleAgents as { taskId: string }).taskId; + await tick(); + await fire("agent_start", ctx); + finishBackground(harness, 0, "Idle answer."); + await tick(); + clock += STALE_COMPLETION_MS + 1_000; + // agent_settled leaves the parent idle, so the held completion flushes as stale to an idle parent. + await fire("agent_settled", ctx); + await tick(); + const notices = sent.filter((entry) => entry.message.customType === "gentle-agents.stale-notice"); + assert.equal(notices.length, 1); + assert.deepEqual(notices[0]!.options, { triggerTurn: false }, "an idle parent never gets a direct triggerTurn message"); + assert.equal(sent.some((entry) => entry.options.triggerTurn === true), false); + assert.equal(userMessages.length, 1, "exactly one wake follows the stored notice"); + assert.doesNotMatch(String(userMessages[0]!.content), new RegExp(`${id}|Idle answer\\.|subagent_result`), "the wake repeats neither the notice nor the report"); + assert.equal(entries.filter((entry) => entry.customType === "gentle-agents.stale-result").length, 1, "the transcript entry is still appended"); + await fire("session_shutdown", ctx); +}); + +test("issue #1092: a stale notice while the parent run is active is steered into that run without a wake", async () => { + const { pi, tools, fire, sent, userMessages } = fakePi(); + const harness = deps(); + let clock = 1000; + harness.deps.now = () => clock; + gentleAgents(pi, {}, harness.deps); + const { ctx } = fakeContext(); + await fire("session_start", ctx); + await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Run stale", mode: "background" }, undefined, undefined, ctx); + await tick(); + await fire("agent_start", ctx); + finishBackground(harness, 0, "Run answer."); + await tick(); + clock += STALE_COMPLETION_MS + 1_000; + await fire("turn_end", ctx); + assert.deepEqual(sent.map((entry) => [entry.message.customType, entry.options]), [["gentle-agents.stale-notice", { deliverAs: "steer", triggerTurn: true }]]); + assert.equal(userMessages.length, 0); + await fire("session_shutdown", ctx); +}); + +test("issue #1092: a stale completion held while the parent compacts outside a run is routed only at the boundary", async () => { + const { pi, tools, fire, sent, entries, userMessages, setIdle } = fakePi(); + const harness = deps(); + const timers = recordTimers(harness.deps); + let clock = 1000; + harness.deps.now = () => clock; + gentleAgents(pi, {}, harness.deps); + const { ctx } = fakeContext(); + await fire("session_start", ctx); + await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Held stale", mode: "background" }, undefined, undefined, ctx); + await tick(); + setIdle(false); + finishBackground(harness, 0, "Held answer."); + await tick(); + clock += STALE_COMPLETION_MS + 1_000; + await fire("session_compact", ctx); + assert.equal(sent.length, 0, "nothing is sent while the parent is busy without a run"); + assert.equal(userMessages.length, 0); + assert.equal(entries.length, 0, "the stale entry waits for the boundary too"); + setIdle(true); + assert.equal(timers.run(0), 1); + await tick(); + assert.deepEqual(sent.map((entry) => [entry.message.customType, entry.options]), [["gentle-agents.stale-notice", { triggerTurn: false }]]); + assert.equal(userMessages.length, 1); + assert.equal(entries.filter((entry) => entry.customType === "gentle-agents.stale-result").length, 1); + await fire("session_shutdown", ctx); +}); + +test("issue #1092: a stale and a fresh completion in one idle flush share a single wake", async () => { + const { pi, tools, fire, sent, userMessages, delivery } = fakePi(); + const harness = deps(); + let clock = 1000; + harness.deps.now = () => clock; + gentleAgents(pi, {}, harness.deps); + const { ctx } = fakeContext(); + await fire("session_start", ctx); + await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Old", mode: "background" }, undefined, undefined, ctx); + await tools.get("subagent_run")!.execute("c2", { agent: "explore", task: "Fresh", mode: "background" }, undefined, undefined, ctx); + await tick(); + await fire("agent_start", ctx); + finishBackground(harness, 0, "Old answer."); + await tick(); + clock += STALE_COMPLETION_MS + 1_000; + finishBackground(harness, 1, "Fresh answer."); + await tick(); + await fire("agent_settled", ctx); + await tick(); + assert.deepEqual(sent.map((entry) => [entry.message.customType, entry.options]), [["gentle-agents.stale-notice", { triggerTurn: false }], ["gentle-agents.result", { triggerTurn: false }]]); + assert.equal(userMessages.length, 1, "one coalesced wake covers both stored messages"); + assert.deepEqual(delivery.slice(-1), ["user"]); + await fire("session_shutdown", ctx); +}); + +test("issue #1092: a completion consumed before it went stale sends no notice and no entry", async () => { + const { pi, tools, fire, sent, entries } = fakePi(); + const harness = deps(); + let clock = 1000; + harness.deps.now = () => clock; + gentleAgents(pi, {}, harness.deps); + const { ctx } = fakeContext(); + await fire("session_start", ctx); + const started = await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Pulled late", mode: "background" }, undefined, undefined, ctx); + const id = (started.details.gentleAgents as { taskId: string }).taskId; + await tick(); + await fire("agent_start", ctx); + harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Pulled answer." }] }] }); + harness.children[0].emit({ type: "agent_settled" }); + await tick(); + await tools.get("subagent_result")!.execute("c2", { task_id: id }, undefined, undefined, ctx); + clock += STALE_COMPLETION_MS + 1_000; + await fire("turn_end", ctx); + assert.equal(sent.length, 0, "a consumed stale completion sends nothing"); + assert.equal(entries.filter((entry) => entry.customType === "gentle-agents.stale-result").length, 0); + await fire("session_shutdown", ctx); +}); + +test("issue #1092: a stale completion owned by another session sends no notice and no entry", async () => { + const { pi, tools, fire, sent, entries } = fakePi(); + const harness = deps(); + let clock = 1000; + harness.deps.now = () => clock; + gentleAgents(pi, {}, harness.deps); + const { ctx } = fakeContext(); + await fire("session_start", ctx); + await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Foreign stale", mode: "background" }, undefined, undefined, ctx); + await tick(); + await fire("agent_start", ctx); + harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Foreign answer." }] }] }); + harness.children[0].emit({ type: "agent_settled" }); + await tick(); + (ctx.sessionManager as { getSessionId(): string }).getSessionId = () => "s2"; + clock += STALE_COMPLETION_MS + 1_000; + await fire("turn_end", ctx); + assert.equal(sent.length, 0); + assert.equal(entries.length, 0); + await fire("session_shutdown", ctx); +}); + test("a completion the parent already pulled is dropped silently at the next turn end", async () => { const { pi, tools, fire, sent } = fakePi(); const harness = deps(); From 5762f930d3874a361493a798f1c1fbd1870c1282 Mon Sep 17 00:00:00 2001 From: BGamboa13 Date: Tue, 6 Oct 2026 14:03:51 -0400 Subject: [PATCH 2/2] fix(agents): point the stale-completion notice only at subagent_result (#1092) subagent_status returns task metadata, not the report, so a parent that followed it would still leave the result unread, with no further notice. Suggested in review. --- extensions/gentle-agents.ts | 2 +- tests/gentle-agents.test.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/extensions/gentle-agents.ts b/extensions/gentle-agents.ts index 47b5e8edf..8b1a1bd63 100644 --- a/extensions/gentle-agents.ts +++ b/extensions/gentle-agents.ts @@ -793,7 +793,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = sendToParent( { customType: AGENTS_STALE_NOTICE_TYPE, - content: `Subagent ${task.agent} (task ${task.id}, "${task.label}") ${outcome} ${ageSeconds}s ago and its result was not read yet. Call subagent_result or subagent_status with task_id ${task.id}.`, + content: `Subagent ${task.agent} (task ${task.id}, "${task.label}") ${outcome} ${ageSeconds}s ago and its result was not read yet. Call subagent_result with task_id ${task.id}.`, display: false, }, route, diff --git a/tests/gentle-agents.test.ts b/tests/gentle-agents.test.ts index 184b5ade4..648702e33 100644 --- a/tests/gentle-agents.test.ts +++ b/tests/gentle-agents.test.ts @@ -4591,7 +4591,9 @@ test("a completion held past the stale window becomes transcript-only content an assert.deepEqual(notices[0]!.options, { deliverAs: "steer", triggerTurn: true }); assert.equal(notices[0]!.message.display, false); assert.match(String(notices[0]!.message.content), new RegExp(id)); - assert.match(String(notices[0]!.message.content), /subagent_result/); + assert.match(String(notices[0]!.message.content), new RegExp(`Call subagent_result with task_id ${id}\\.$`)); + // subagent_status returns metadata, not the report, so following it would leave the result unread. + assert.doesNotMatch(String(notices[0]!.message.content), /subagent_status/, "the notice points only at the tool that returns the report"); assert.doesNotMatch(String(notices[0]!.message.content), /Late answer\./, "the notice never carries the report"); await fire("agent_end", ctx); await fire("agent_settled", ctx);