diff --git a/extensions/gentle-agents.ts b/extensions/gentle-agents.ts index 4528cd2fb..c371b9c20 100644 --- a/extensions/gentle-agents.ts +++ b/extensions/gentle-agents.ts @@ -19,6 +19,7 @@ import { VISUAL_SETTINGS_CHANGED } from "../lib/shell-sidebar.ts"; import { resolveVisualSettings } from "../lib/visual-customization-policy.ts"; import { createCompletionQueue } from "../lib/agents-completion-delivery.ts"; import { createAgentMessageQueue, type PendingAgentMessage } from "../lib/agents-message-delivery.ts"; +import { createIdleWakeSender } from "../lib/idle-wake.ts"; import { AGENT_MODE, discoverAgents, formatModelRef, loadAgentsConfig, resolveAgentProfile, withPinnedModelProfiles, type AgentDefinition, type AgentMode } from "../lib/agents-config.ts"; import { resolveBackgroundSubagentsPolicy } from "../lib/background-subagents-policy.ts"; import { installBackgroundCacheWarming } from "../lib/background-cache-warming.ts"; @@ -315,6 +316,9 @@ export async function answerThroughUi(ui: ExtensionContext["ui"] | undefined, as } export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = process.env, overrides: Partial = {}): void { + // Every triggerTurn wake-up goes through this sender so an idle parent is + // started via prompt() and keeps its before_agent_start harness (#1528). + const wakeSend = createIdleWakeSender(pi); const childIpc = ownedChildIpc(env, overrides.childIpc ?? (process.send ? process as unknown as IpcEndpoint : undefined)); if (env.GENTLE_PI_AGENTS_CHILD === "1") { // A stale managed-SDD child must never inherit unrestricted ordinary tools. @@ -479,7 +483,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = listener = sessionTransport.createListener(registry, sessionId, async (notification) => { const active = activeSessionTransport; if (!active || active.generation !== generation || active.sessionManager !== sessionManager || active.sessionId !== sessionId || sessions !== sessionManager || activeSessionId() !== sessionId) throw new Error("stale session transport"); - pi.sendMessage({ customType: AGENTS_ORCHESTRATOR_MESSAGE_TYPE, content: `Session message from ${notification.senderSessionId} (correlation ${notification.id}): ${notification.message}`, display: true, details: { gentleAgents: { senderSessionId: notification.senderSessionId, recipientSessionId: sessionId, correlationId: notification.id, direction: "incoming" } } }, { deliverAs: "followUp", triggerTurn: true }); + wakeSend({ customType: AGENTS_ORCHESTRATOR_MESSAGE_TYPE, content: `Session message from ${notification.senderSessionId} (correlation ${notification.id}): ${notification.message}`, display: true, details: { gentleAgents: { senderSessionId: notification.senderSessionId, recipientSessionId: sessionId, correlationId: notification.id, direction: "incoming" } } }, { deliverAs: "followUp", triggerTurn: true }); }); client = sessionTransport.createClient(registry, sessionId); if (sessions !== sessionManager || generation !== transportGeneration || activeSessionId() !== sessionId) { @@ -588,7 +592,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = // branch, so a parent that keeps calling tools would see the completion // only when the whole run ends — the original #867 delay. When the parent // is idle, triggerTurn runs the prompt immediately, preserving wake-up. - pi.sendMessage({ customType: AGENTS_RESULT_TYPE, content: completionText(task), display: true, details: taskDetails(task) }, { deliverAs: "steer", triggerTurn: true }); + wakeSend({ customType: AGENTS_RESULT_TYPE, content: completionText(task), display: true, details: taskDetails(task) }, { deliverAs: "steer", triggerTurn: true }); }; // A stale completion must not re-enter the LLM conversation, so it is @@ -601,7 +605,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = const deliverMessage = (msg: PendingAgentMessage) => { if (activeSessionId() !== msg.parentSessionId) return; - pi.sendMessage( + wakeSend( { customType: AGENTS_MESSAGE_TYPE, content: msg.content, display: msg.display, details: msg.details }, { deliverAs: "steer", triggerTurn: true }, ); diff --git a/lib/idle-wake.ts b/lib/idle-wake.ts new file mode 100644 index 000000000..894e2f120 --- /dev/null +++ b/lib/idle-wake.ts @@ -0,0 +1,75 @@ +// Idle wake-up that keeps the harness (issue #1528). +// +// Pi starts an idle `pi.sendMessage(..., { triggerTurn: true })` turn through +// `_runAgentPrompt` directly, without `before_agent_start` (earendil-works/pi#5581, +// still open). Everything an extension adds to the system prompt in +// `before_agent_start` — the whole Gentle harness — is missing on that turn: +// claude-bridge refuses to run it (prompt-capture miss), and other providers +// silently run a turn without the harness. +// +// When the host is idle, the message is queued with `deliverAs: "nextTurn"` and +// the turn is started through `pi.sendUserMessage(" ")`, i.e. the normal +// `prompt()` path: `before_agent_start` runs and the queued message is injected +// alongside that prompt. When the host is running, the original +// `sendMessage(message, options)` is kept unchanged. +// +// Idleness is read from the host (`ctx.isIdle()`, the same run flag +// `sendCustomMessage` routes by). Counting `agent_start`/`agent_end` does not +// work: the host emits that pair once per inner `prompt()`/`continue()` call and +// chains several of them in one run, so a counter reads 0 in the gap before the +// next `continue()` — exactly where completions are flushed on `agent_end`. +// Taking the idle path there queues the message as `nextTurn` while +// `sendUserMessage(" ")` throws "Agent is already processing", stranding the +// message until the next user prompt. +// +// One wake at a time. `prompt()` awaits input handlers, auth, compaction and +// `before_agent_start` before it marks the run active, so the host still reads +// idle while a wake prompt is being prepared. A second `sendUserMessage(" ")` in +// that window would reach `Agent.prompt()` while the first runs and be rejected, +// and its settlement would clear the active-run flag under the first run. The +// first wake therefore reserves the turn synchronously; later idle messages are +// only queued as `nextTurn`. They are carried by the pending prompt: the host +// takes the `nextTurn` queue and marks the run active in one synchronous step, +// so while the host still reads idle, the pending prompt has not taken it yet. +// The reservation ends when the run starts or the session is replaced. The host +// reports a failed extension prompt only to its error channel, so a reservation +// also expires after WAKE_RESERVATION_MS; the queued messages stay in `nextTurn` +// and the next wake (or user prompt) carries them. + +import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; + +type WakeMessage = Parameters[0]; +export type WakeOptions = { deliverAs: "steer" | "followUp"; triggerTurn: true }; +export type WakeHost = Pick; + +/** How long a submitted wake prompt may stay unstarted before another wake may submit a new one. */ +export const WAKE_RESERVATION_MS = 60_000; + +export function createIdleWakeSender(pi: WakeHost, now: () => number = Date.now): (message: WakeMessage, options: WakeOptions) => void { + let latest: Pick | undefined; + let wakeReservedAt: number | undefined; + const track = (_event: unknown, ctx: ExtensionContext) => { latest = ctx; }; + const trackAndRelease = (_event: unknown, ctx: ExtensionContext) => { latest = ctx; wakeReservedAt = undefined; }; + pi.on("session_start", trackAndRelease); + pi.on("agent_start", trackAndRelease); + pi.on("turn_end", track); + pi.on("agent_end", track); + + // A missing or stale ctx (after session replacement or reload) is not proof of + // idleness: fall back to the host's own delivery path, never to "nextTurn", + // which could strand the message. + const hostIdle = (): boolean => { + try { return latest?.isIdle() === true; } catch { return false; } + }; + + return (message, options) => { + if (!hostIdle()) { + pi.sendMessage(message, options); + return; + } + pi.sendMessage(message, { deliverAs: "nextTurn" }); + if (wakeReservedAt !== undefined && now() - wakeReservedAt < WAKE_RESERVATION_MS) return; + wakeReservedAt = now(); + pi.sendUserMessage(" "); + }; +} diff --git a/tests/gentle-agents.test.ts b/tests/gentle-agents.test.ts index db71a9754..6dac835a7 100644 --- a/tests/gentle-agents.test.ts +++ b/tests/gentle-agents.test.ts @@ -110,6 +110,7 @@ function fakePi() { const shortcuts = new Map }>(); const commands = new Map }>(); const sent: Array<{ message: Record; options: Record }> = []; + const userMessages: unknown[] = []; const renderers = new Map { render(width: number): string[] }>(); const entryRenderers = new Map { render(width: number): string[] }>(); const entries: Array<{ type: string; customType: string; data: unknown }> = []; @@ -125,6 +126,7 @@ function fakePi() { }, }, sendMessage: (message: Record, options: Record) => sent.push({ message, options }), + sendUserMessage: (content: unknown) => userMessages.push(content), registerMessageRenderer: (type: string, renderer: (message: unknown, options: { expanded: boolean }, theme: unknown) => { render(width: number): string[] }) => renderers.set(type, renderer), registerEntryRenderer: (type: string, renderer: (entry: { type: string; customType: string; data: unknown }, options: { expanded: boolean }, theme: unknown) => { render(width: number): string[] }) => entryRenderers.set(type, renderer), on: (event: string, handler: Handler) => handlers.set(event, [...(handlers.get(event) ?? []), handler]), @@ -153,7 +155,7 @@ function fakePi() { } } }; - return { pi, tools, shortcuts, commands, fire, sent, renderers, entryRenderers, entries, events, listeners }; + return { pi, tools, shortcuts, commands, fire, sent, userMessages, renderers, entryRenderers, entries, events, listeners }; } function fakeContext(tui: { requestRender(): void } = fakeTui, confirmResult: (title: string, message: string) => Promise = async () => true, inputResult: (title: string, placeholder: string | undefined) => Promise = async () => undefined, overlayTui: { terminal: { rows: number }; requestRender(): void } = { terminal: { rows: 30 }, requestRender() {} }, selectResult: (title: string, options: string[]) => Promise = async (_title, options) => options[0]) { @@ -4331,3 +4333,24 @@ test("children receive the child-context extension, and a missing file is omitte } } }); + +test("a completion for an idle parent starts the turn through prompt(), not a bare triggerTurn (#1528)", async () => { + // pi#5581: an idle sendMessage(..., { triggerTurn: true }) skips + // before_agent_start, so the woken turn would run without the harness. The + // completion is queued as nextTurn and the turn is started with a prompt. + const { pi, tools, fire, sent, userMessages } = fakePi(); + const harness = deps(); + gentleAgents(pi, {}, harness.deps); + const { ctx } = fakeContext(); + const idleCtx = { ...ctx, isIdle: () => true } as unknown as ExtensionContext; + await fire("session_start", idleCtx); + await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Idle job", mode: "background" }, undefined, undefined, idleCtx); + await tick(); + harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Done while idle." }] }] }); + harness.children[0].emit({ type: "agent_settled" }); + await tick(); + assert.equal(sent.length, 1, "the completion is delivered once"); + assert.equal(sent[0].message.customType, "gentle-agents.result"); + assert.deepEqual(sent[0].options, { deliverAs: "nextTurn" }); + assert.deepEqual(userMessages, [" "], "the idle parent is started through the prompt path"); +}); diff --git a/tests/idle-wake.test.ts b/tests/idle-wake.test.ts new file mode 100644 index 000000000..e3ff71a00 --- /dev/null +++ b/tests/idle-wake.test.ts @@ -0,0 +1,136 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createIdleWakeSender, WAKE_RESERVATION_MS, type WakeHost } from "../lib/idle-wake.ts"; + +// An idle wake-up must go through prompt() so before_agent_start runs +// (issue #1528, earendil-works/pi#5581); a running host keeps the original +// sendMessage call. + +type Call = [string, unknown]; + +function host(now: () => number = () => 0) { + const handlers = new Map void>>(); + const calls: Call[] = []; + const pi = { + on: (event: string, handler: (event: unknown, ctx: unknown) => void) => { handlers.set(event, [...(handlers.get(event) ?? []), handler]); }, + sendMessage: (_message: unknown, options: unknown) => { calls.push(["sendMessage", options]); }, + sendUserMessage: (content: unknown) => { calls.push(["sendUserMessage", content]); }, + } as unknown as WakeHost; + const emit = (event: string, ctx: unknown) => { for (const handler of handlers.get(event) ?? []) handler({ type: event }, ctx); }; + return { send: createIdleWakeSender(pi, now), emit, calls }; +} + +const ctx = (idle: boolean) => ({ isIdle: () => idle }); +const message = { customType: "gentle-agents.result", content: "done", display: true }; + +test("an idle host is woken through prompt() so before_agent_start runs", () => { + const h = host(); + h.emit("session_start", ctx(true)); + h.send(message, { deliverAs: "steer", triggerTurn: true }); + assert.deepEqual(h.calls, [["sendMessage", { deliverAs: "nextTurn" }], ["sendUserMessage", " "]]); +}); + +test("a running host keeps the original steer/followUp delivery", () => { + const h = host(); + h.emit("session_start", ctx(false)); + h.send(message, { deliverAs: "steer", triggerTurn: true }); + h.send(message, { deliverAs: "followUp", triggerTurn: true }); + assert.deepEqual(h.calls, [["sendMessage", { deliverAs: "steer", triggerTurn: true }], ["sendMessage", { deliverAs: "followUp", triggerTurn: true }]]); +}); + +test("a run that is still active after an inner agent_end is not treated as idle", () => { + // The host emits agent_start/agent_end per inner prompt()/continue(); a + // counter would read 0 here and strand the message as nextTurn. + const h = host(); + const running = ctx(false); + h.emit("session_start", running); + h.emit("agent_start", running); + h.emit("turn_end", running); + h.emit("agent_end", running); + h.send(message, { deliverAs: "steer", triggerTurn: true }); + assert.deepEqual(h.calls, [["sendMessage", { deliverAs: "steer", triggerTurn: true }]]); +}); + +test("idleness is read live, not frozen at the last event", () => { + const h = host(); + let idle = false; + h.emit("agent_end", { isIdle: () => idle }); + h.send(message, { deliverAs: "steer", triggerTurn: true }); + idle = true; + h.send(message, { deliverAs: "steer", triggerTurn: true }); + assert.deepEqual(h.calls, [ + ["sendMessage", { deliverAs: "steer", triggerTurn: true }], + ["sendMessage", { deliverAs: "nextTurn" }], + ["sendUserMessage", " "], + ]); +}); + +test("two wakes while the first prompt is still being prepared submit one prompt", () => { + // prompt() awaits input and before_agent_start handlers before the run is + // active, so the host keeps reading idle; a second prompt would be rejected + // and its settlement would clear the active-run flag under the first run. + const h = host(); + h.emit("session_start", ctx(true)); + h.send(message, { deliverAs: "steer", triggerTurn: true }); + h.send(message, { deliverAs: "followUp", triggerTurn: true }); + assert.deepEqual(h.calls, [ + ["sendMessage", { deliverAs: "nextTurn" }], + ["sendUserMessage", " "], + ["sendMessage", { deliverAs: "nextTurn" }], + ], "the second message rides the pending prompt's nextTurn queue"); +}); + +test("the reservation ends when the woken run starts", () => { + const h = host(); + let idle = true; + const live = { isIdle: () => idle }; + h.emit("session_start", live); + h.send(message, { deliverAs: "steer", triggerTurn: true }); + idle = false; + h.emit("agent_start", live); + h.send(message, { deliverAs: "steer", triggerTurn: true }); + idle = true; + h.emit("agent_end", live); + h.send(message, { deliverAs: "steer", triggerTurn: true }); + assert.deepEqual(h.calls, [ + ["sendMessage", { deliverAs: "nextTurn" }], + ["sendUserMessage", " "], + ["sendMessage", { deliverAs: "steer", triggerTurn: true }], + ["sendMessage", { deliverAs: "nextTurn" }], + ["sendUserMessage", " "], + ]); +}); + +test("session replacement releases a pending reservation", () => { + const h = host(); + h.emit("session_start", ctx(true)); + h.send(message, { deliverAs: "steer", triggerTurn: true }); + h.emit("session_start", ctx(true)); + h.send(message, { deliverAs: "steer", triggerTurn: true }); + assert.deepEqual(h.calls.filter(([name]) => name === "sendUserMessage").length, 2); +}); + +test("a wake prompt that never starts stops blocking after WAKE_RESERVATION_MS", () => { + // The host reports a failed extension prompt only to its error channel; the + // queued messages stay in nextTurn and the next wake submits a new prompt. + let clock = 0; + const h = host(() => clock); + h.emit("session_start", ctx(true)); + h.send(message, { deliverAs: "steer", triggerTurn: true }); + clock = WAKE_RESERVATION_MS - 1; + h.send(message, { deliverAs: "steer", triggerTurn: true }); + clock = WAKE_RESERVATION_MS; + h.send(message, { deliverAs: "steer", triggerTurn: true }); + assert.deepEqual(h.calls.map(([name]) => name), ["sendMessage", "sendUserMessage", "sendMessage", "sendMessage", "sendUserMessage"]); +}); + +test("a missing or stale ctx falls back to the host path, never to nextTurn", () => { + const noCtx = host(); + noCtx.send(message, { deliverAs: "steer", triggerTurn: true }); + assert.deepEqual(noCtx.calls, [["sendMessage", { deliverAs: "steer", triggerTurn: true }]]); + + const stale = host(); + stale.emit("session_start", { isIdle: () => { throw new Error("This extension ctx is stale after session replacement or reload."); } }); + stale.send(message, { deliverAs: "followUp", triggerTurn: true }); + assert.deepEqual(stale.calls, [["sendMessage", { deliverAs: "followUp", triggerTurn: true }]]); +});