From 03961d42651f7781e336016cd285a54177eea8db Mon Sep 17 00:00:00 2001 From: Alan-TheGentleman Date: Fri, 2 Oct 2026 14:24:35 +0200 Subject: [PATCH 1/3] fix(agents): show continuation notifications only for Claude Bridge --- extensions/gentle-agents.ts | 26 ++++-- odd/tasks/bridge-only-agent-wake.md | 35 ++++++++ tests/gentle-agents.test.ts | 134 ++++++++++++++++++++++++++-- 3 files changed, 179 insertions(+), 16 deletions(-) create mode 100644 odd/tasks/bridge-only-agent-wake.md diff --git a/extensions/gentle-agents.ts b/extensions/gentle-agents.ts index 45b145256..23d07f742 100644 --- a/extensions/gentle-agents.ts +++ b/extensions/gentle-agents.ts @@ -63,6 +63,8 @@ const TOOL_PREFIX = "subagent_"; // Wakes an idle parent after child content was stored as a custom message. // It names itself as automated so the model never attributes it to the human. const PARENT_WAKE_TEXT = "[System-generated Gentle Agents notification, not written by the user] Subagent output was delivered to this session above. Review it and continue."; +const PARENT_WAKE_TYPE = "gentle-agents.wake"; +const NATIVE_PARENT_WAKE_TEXT = "Review the delivered subagent output and continue."; // How long a dispatched wake may take to start a parent run before a later // delivery may send another one. export const PARENT_WAKE_GRACE_MS = 30_000; @@ -629,13 +631,10 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = // branch, so a parent that keeps calling tools would see the content only // when the whole run ends — the original #867 delay. // - // An idle parent must not get triggerTurn: the host would run the custom - // message as a direct turn that skips the prompt lifecycle - // (before_agent_start and the prompt refresh), and prompt-capture - // integrations such as the Claude bridge reject that turn. The structured - // message is stored durably without a turn instead, and a short - // system-generated user message wakes the parent through the normal prompt - // path. The wake never repeats child content, so the model sees it once. + // Idle child content is stored durably without a turn, then a separate + // coalesced wake requests continuation without repeating that content. + // Claude Bridge needs a user wake through the prompt lifecycle for capture; + // native providers can use a hidden custom-message turn instead. // // A parent that is busy without a run (compaction, or a prompt's pre-run // compaction) is not streaming, so steer + triggerTurn would also start a @@ -688,7 +687,18 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = try { // "steer" matters only when a run started in between: the wake is then // queued into it instead of being rejected as a concurrent prompt. - pi.sendUserMessage(PARENT_WAKE_TEXT, { deliverAs: "steer" }); + // Read the live selection at dispatch, not when child content arrived. + // Only Claude Bridge is currently evidenced to require prompt capture; + // registering a custom provider alone does not make it a bridge. + if (parentCtx?.model?.provider === "claude-bridge") { + pi.sendUserMessage(PARENT_WAKE_TEXT, { deliverAs: "steer" }); + } else { + pi.sendMessage({ + customType: PARENT_WAKE_TYPE, + content: NATIVE_PARENT_WAKE_TEXT, + display: false, + }, { deliverAs: "steer", triggerTurn: true }); + } } catch { // A stale runtime fails closed instead of throwing from a microtask. endPromptStart(); diff --git a/odd/tasks/bridge-only-agent-wake.md b/odd/tasks/bridge-only-agent-wake.md new file mode 100644 index 000000000..644d239e6 --- /dev/null +++ b/odd/tasks/bridge-only-agent-wake.md @@ -0,0 +1,35 @@ +# Bridge-only agent wake + +## Intent +- Objective: show the synthetic Gentle Agents user notification only when the orchestrator's currently selected provider comes from a bridge plugin (for example Claude Bridge). +- Problem: idle background subagent delivery unconditionally emits a visible synthetic user turn. +- Why: native Pi providers should continue automatically without this chat noise. +- Authorized scope: parent wake routing and focused regression tests. Preserve result delivery, coalescing, streaming steering, and prompt-start grace. Do not change installer work or bridge plugins. +- Acceptance: bridge selected => existing user wake; native selected => hidden custom wake that still triggers a turn; runtime provider switching is respected; background completion and query behavior remains correct. + +## Plan +- [ ] T1 — Route idle wakes by the live selected provider and prove regressions. Route: delegated (non-trivial extension and test edits, SDK exploration). Risk: medium initially; native assessment will determine independent checks. Test-first: observe focused regression RED, implement GREEN, rerun wake-related tests and typecheck. Keep tests with behavior in one work-unit commit. + +## Delivery +- Strategy: ask-on-risk. Forecast: approximately 120 authored additions/deletions, excluding generated output. No publishing or PR authorized. +- Branch: `fix/bridge-only-agent-wake`; unrelated dirty installer files preserved. +- Work-unit commit: pending. +- Running authored count: 0. + +## Progress and evidence +- T1: in progress; read-only mapping found unconditional `sendUserMessage` in `extensions/gentle-agents.ts` dispatchWake. +- SDK supports custom hidden messages with `triggerTurn:true`; simply dropping the user message would lose continuation. +- Bridge means selected model provider, not RPC/UI mode or arbitrary extension registration. +- Exact surfaces: `extensions/gentle-agents.ts`, `tests/gentle-agents.test.ts`. Current evidenced bridge ID: `claude-bridge`; no SDK bridge semantic marker. Do not classify arbitrary extension providers as bridges. +- Writer `muqwuqx6-2-bzz2` returned partial: extension and tests changed (144 additions, 16 deletions). RED: 175 pass / 11 intended failures. GREEN: 186 pass / 0 fail. Fourteen deterministic cases added. +- `node --experimental-strip-types --test tests/gentle-agents.test.ts`: passed 186/186 for writer, independent verifier and parent. `pnpm run typecheck`: failed with two TS2345 errors in unchanged `tests/installer-posix-bootstrap.test.ts:235,238` from an untyped heterogeneous tuple loop (:226–231). Statements are identical in HEAD and do not depend on candidate code; clean-HEAD compilation unverified. +- Independent verifier `muqx30qs-3-6rgo`: focused tests passed 186/186. Typecheck failed with the two TS2345 errors above. Full `pnpm test` failed: 4684 tests, 4648 passed, 1 failed, 35 skipped; provider-contract and runtime-harness stages passed. Failure: `tests/gentle-ai-dev-binary-surfacing.test.ts:189` (assertion :202), `session start defers the active-override announcement to the shell card`. Causal inspection completed: inherited `GENTLE_PI_AGENTS_CHILD=1` disables shell in unchanged `lib/shell-bar.ts:109–112`, causing the fallback warning in unchanged `extensions/gentle-ai.ts:9371–9374`. Test clears GENTLE_PI_SHELL but not child flag. No unrelated fixes or reruns performed. +- Parent spot check: focused command passed 186/186 (~20.5 seconds). +- RDD inspect performed; workspace scope mixes installer changes with this candidate and requires intended-untracked selection. No START performed; avoid freezing unrelated work. +- Native ASSESS returned unassessable due to unrelated untracked scope; plan requires independent verifier (treated as high). Isolate this work unit as a committed range before review; do not review accumulated installer work. +- Required checks: focused deterministic tests, related agent delivery tests, typecheck, parent spot check, RDD for this candidate under the user-owned switch. +- Independent verifier final: no candidate-caused blocker found, but overall checks not green. SDK confirms hidden custom wake starts a model turn; tests mock calls rather than run a live model. Writer RED/GREEN observed; parent inspected extension diff and reran focused tests. Native review and commit pending. T1 remains incomplete until delivery/check decisions are resolved. +- [ ] B1 — Resolve verification blockers and isolate review scope. Investigate causality read-only; unrelated fixes require separate authorization. Do not silently accept failed checks. + +## Next step +Needs user decision: authorize an isolated work-unit commit to review only this change, with unrelated check failures explicitly retained, or leave implementation uncommitted and pause. No publishing/PR/merge. Do not change installer or shell tests without separate scope authorization. diff --git a/tests/gentle-agents.test.ts b/tests/gentle-agents.test.ts index a9e46685f..dbcd4457f 100644 --- a/tests/gentle-agents.test.ts +++ b/tests/gentle-agents.test.ts @@ -110,19 +110,21 @@ mkdirSync(nonGitCwd, { recursive: true }); writeFileSync(join(home, ".pi", "agent", "agents", "explore.md"), "---\ndescription: maps things\nmodel: openai-codex/gpt-5.6-terra\nthinking: high\ntools: [read, grep]\n---\nYou map things."); writeFileSync(join(home, ".pi", "agent", "subagents.json"), JSON.stringify({ max_concurrency: 2, model_profiles: { explore: { effort: "low" } } })); -function fakePi() { +// Existing prompt-lifecycle regressions exercise the bridge route by default. +function fakePi(initialProvider: string = "claude-bridge") { const handlers = new Map(); const tools = new Map(); const shortcuts = new Map }>(); const commands = new Map }>(); const sent: Array<{ message: Record; options: Record }> = []; - // Idle wake-ups go through pi.sendUserMessage; `delivery` records the - // interleaving of custom messages and wake-ups so ordering stays provable. + // `delivery` records custom messages and user wakes in dispatch order. const userMessages: Array<{ content: unknown; options: Record | undefined }> = []; const delivery: string[] = []; // A live idle flag shared by every context fired through this host, like // Pi's ctx.isIdle(): busy from agent_start until agent_settled. Tests can // force it to simulate compaction or other non-run busy states. + let selectedProvider: string | undefined = initialProvider; + const setProvider = (provider: string | undefined) => { selectedProvider = provider; }; let parentIdle = true; const setIdle = (idle: boolean) => { parentIdle = idle; }; const renderers = new Map { render(width: number): string[] }>(); @@ -157,6 +159,10 @@ function fakePi() { const fire = async (event: string, ctx: ExtensionContext, payload: unknown = {}) => { const results: unknown[] = []; if (!("isIdle" in ctx)) Object.assign(ctx, { isIdle: () => parentIdle }); + if (!("model" in ctx)) Object.defineProperty(ctx, "model", { + configurable: true, + get: () => selectedProvider === undefined ? undefined : { provider: selectedProvider }, + }); if (event === "agent_start") parentIdle = false; else if (event === "agent_settled") parentIdle = true; try { @@ -172,7 +178,7 @@ function fakePi() { } } }; - return { pi, tools, shortcuts, commands, fire, sent, userMessages, delivery, setIdle, renderers, entryRenderers, entries, events, listeners }; + return { pi, tools, shortcuts, commands, fire, sent, userMessages, delivery, setIdle, setProvider, 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]) { @@ -236,6 +242,14 @@ function recordTimers(target: Partial) { const due = (ms: number) => pending.filter((timer) => timer.ms === ms && !timer.cancelled); return { pending: (ms: number) => due(ms).length, + // Capture a known newly armed timer before other same-duration timers + // (for example child query expiry) are added. + takeLast: (ms: number) => { + const timer = due(ms).at(-1); + assert.ok(timer, `expected a pending ${ms}ms timer`); + pending.splice(pending.indexOf(timer), 1); + return () => { if (!timer.cancelled) timer.fn(); }; + }, run: (ms: number) => { const timers = due(ms); for (const timer of timers) { @@ -4143,10 +4157,114 @@ test("a background completion settling while the parent agent runs is delivered await fire("session_shutdown", ctx); }); -// A direct custom-message turn (sendMessage + triggerTurn while idle) skips -// Pi's prompt lifecycle, so before_agent_start never runs and prompt-capture -// integrations such as the Claude bridge reject the turn. An idle parent must -// instead store the structured result durably and be woken by a user message. +for (const provider of ["openai", "anthropic", "custom-extension", undefined, "claude-bridge"]) { + for (const kind of ["completion", "query"] as const) { + test(`idle ${kind} wake uses the selected ${provider ?? "missing"} provider route`, async () => { + const h = fakePi(); + h.setProvider(provider); + const harness = deps(); + gentleAgents(h.pi, {}, harness.deps); + const { ctx } = fakeContext(); + await h.fire("session_start", ctx); + await h.tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Route wake", mode: "background" }, undefined, undefined, ctx); + await tick(); + if (kind === "query") harness.children[0].message({ id: "q1", kind: "query", message: "Child payload" }); + else { + harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Child payload" }] }] }); + harness.children[0].emit({ type: "agent_settled" }); + } + await tick(); + assert.deepEqual(h.sent[0]!.options, { triggerTurn: false }); + assert.match(String(h.sent[0]!.message.content), /Child payload/); + if (provider === "claude-bridge") { + assert.equal(h.sent.length, 1); + assert.deepEqual(h.userMessages, [{ + content: "[System-generated Gentle Agents notification, not written by the user] Subagent output was delivered to this session above. Review it and continue.", + options: { deliverAs: "steer" }, + }]); + assert.equal(h.delivery.at(-1), "user"); + } else { + assert.equal(h.userMessages.length, 0, "native and unclassified providers never receive a synthetic user turn"); + assert.equal(h.sent.length, 2, "the stored payload still gets a continuation turn"); + assert.deepEqual(h.sent[1]!.options, { deliverAs: "steer", triggerTurn: true }); + assert.equal(h.sent[1]!.message.display, false); + assert.equal(h.sent[1]!.message.customType, "gentle-agents.wake"); + assert.equal(h.sent[1]!.message.content, "Review the delivered subagent output and continue."); + assert.deepEqual(h.delivery, [`custom:gentle-agents.${kind === "query" ? "message" : "result"}`, "custom:gentle-agents.wake"]); + } + await h.fire("session_shutdown", ctx); + }); + } +} + +for (const [from, to] of [["openai", "claude-bridge"], ["claude-bridge", "openai"]]) { + test(`wake reads the live provider after storage: ${from} to ${to}`, async () => { + const h = fakePi(from); + const harness = deps(); + gentleAgents(h.pi, {}, harness.deps); + const { ctx } = fakeContext(); + await h.fire("session_start", ctx); + await h.tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Switch before dispatch", mode: "background" }, undefined, undefined, ctx); + await tick(); + const sendMessage = h.pi.sendMessage; + Object.assign(h.pi, { sendMessage: (...args: Parameters) => { + sendMessage(...args); + // Storage happens synchronously, dispatch in the queued microtask. + if (args[0].customType === "gentle-agents.message") h.setProvider(to); + } }); + harness.children[0].message({ id: "q1", kind: "query", message: "Switch?" }); + await tick(); + assert.equal(h.userMessages.length, to === "claude-bridge" ? 1 : 0); + assert.equal(h.sent.filter((entry) => entry.message.customType === "gentle-agents.wake").length, to === "openai" ? 1 : 0); + await h.fire("session_shutdown", ctx); + }); +} + +for (const boundary of ["session_compact", "session_compact_failed"] as const) { + test(`native wake preserves holds, coalescing and grace after ${boundary}`, async () => { + const h = fakePi("openai"); + const harness = deps(); + const timers = recordTimers(harness.deps); + gentleAgents(h.pi, {}, harness.deps); + const { ctx } = fakeContext(); + await h.fire("session_start", ctx); + for (const task of ["Ask", "Finish"]) await h.tools.get("subagent_run")!.execute(task, { agent: "explore", task, mode: "background" }, undefined, undefined, ctx); + await tick(); + h.setIdle(false); + harness.children[0].message({ id: "q1", kind: "query", message: "Held question" }); + harness.children[1].emit({ type: "agent_settled" }); + await tick(); + assert.equal(h.sent.length, 0); + await h.fire(boundary, ctx); + assert.equal(h.sent.length, 0); + h.setIdle(true); + assert.equal(timers.run(0), 1); + await tick(); + assert.deepEqual(h.delivery, ["custom:gentle-agents.message", "custom:gentle-agents.result", "custom:gentle-agents.wake"]); + assert.deepEqual(h.sent.map((entry) => entry.options), [{ triggerTurn: false }, { triggerTurn: false }, { deliverAs: "steer", triggerTurn: true }]); + const expireWakeGrace = timers.takeLast(PARENT_WAKE_GRACE_MS); + harness.children[0].message({ id: "q2", kind: "query", message: "Later question" }); + await tick(); + assert.equal(h.sent.length, 4, "content arriving in the grace window shares the pending wake"); + expireWakeGrace(); + await tick(); + assert.equal(h.sent.length, 5, "an unstarted wake cannot suppress new content beyond grace"); + assert.equal(h.sent[4]!.message.display, false); + assert.deepEqual(h.sent[4]!.options, { deliverAs: "steer", triggerTurn: true }); + await h.fire("agent_start", ctx); + harness.children[0].message({ id: "q3", kind: "query", message: "Busy question" }); + await tick(); + assert.equal(h.sent.length, 5, "busy content stays queued until the turn boundary"); + await h.fire("turn_end", ctx); + assert.equal(h.sent.at(-1)!.message.customType, "gentle-agents.message"); + assert.deepEqual(h.sent.at(-1)!.options, { deliverAs: "steer", triggerTurn: true }, "busy native delivery keeps the original steering route"); + assert.equal(h.userMessages.length, 0); + await h.fire("session_shutdown", ctx); + }); +} + +// Bridge custom-message turns skip the prompt lifecycle needed for capture. +// Store the structured result durably and wake the bridge by a user message. test("an idle parent stores the structured completion and is woken through the normal prompt lifecycle", async () => { const { pi, tools, fire, sent, userMessages, delivery, renderers } = fakePi(); const harness = deps(); From 8f0cfea2c4e301d5400c2833aa8596061dd3ae12 Mon Sep 17 00:00:00 2001 From: Alan-TheGentleman Date: Fri, 2 Oct 2026 16:54:16 +0200 Subject: [PATCH 2/3] fix(agents): hide lifecycle-preserving Claude Bridge wakes --- docs/gentle-shell.md | 6 ++ extensions/gentle-agents.ts | 68 +++++++++++++-- tests/bridge-wake-lifecycle.test.ts | 128 ++++++++++++++++++++++++++++ tests/bridge-wake-rendering.test.ts | 45 ++++++++++ tests/gentle-agents.test.ts | 80 ++++++++++++++--- 5 files changed, 308 insertions(+), 19 deletions(-) create mode 100644 tests/bridge-wake-lifecycle.test.ts create mode 100644 tests/bridge-wake-rendering.test.ts diff --git a/docs/gentle-shell.md b/docs/gentle-shell.md index fda812207..eae09089f 100644 --- a/docs/gentle-shell.md +++ b/docs/gentle-shell.md @@ -279,6 +279,12 @@ A finished list stays on screen for the turn it finished in and clears at the ne ### Bridge providers +Idle Claude Bridge continuation uses the normal user-prompt lifecycle so prompt preparation and tool declarations are retained. Gentle Agents hides only its exact, uniquely generated reserved wake in Pi's interactive transcript; child result and query cards remain visible. Native providers keep their hidden `display: false` custom wake. + +The reserved identity is stored once per session as a non-context custom entry and reconstructed on reload/resume, including identities on abandoned branches. It is session-owned: switching sessions releases the previous identity. Ordinary text quoting the old notification, prefixes, substrings, and whitespace variations is not hidden. Pi's transformer exposes no author/message ID, so an exact copy of the current reserved identity is indistinguishable from its generated wake. + +This is **TUI-only suppression**: the generated user-role message remains in session history, model context, and RPC. It does not fix the SDK's idle custom-message lifecycle. Older runtimes without the Markdown transformer API, or failed identity persistence, retain visible continuation and emit a warning rather than dropping the wake. Existing unreserved wakes are not retroactively hidden. + The Gentle AI harness (ODD workflow, identity, review contract) and the open-tasks block are appended to `before_agent_start`'s `systemPromptOptions.appendSystemPrompt` instead of being returned as a replacement `systemPrompt` (gentle-shell#1485). Provider bridges such as `pi-claude-bridge` forward only those structured sections after their own preset and drop a returned `systemPrompt`, so this route reaches every provider, bridged or not. Set `GENTLE_PI_SHELL=0` to keep pi's built-in footer and editor. diff --git a/extensions/gentle-agents.ts b/extensions/gentle-agents.ts index 23d07f742..dc6bb7f69 100644 --- a/extensions/gentle-agents.ts +++ b/extensions/gentle-agents.ts @@ -64,6 +64,13 @@ const TOOL_PREFIX = "subagent_"; // It names itself as automated so the model never attributes it to the human. const PARENT_WAKE_TEXT = "[System-generated Gentle Agents notification, not written by the user] Subagent output was delivered to this session above. Review it and continue."; const PARENT_WAKE_TYPE = "gentle-agents.wake"; +const BRIDGE_WAKE_IDENTITY_TYPE = "gentle-agents.wake-identity"; +interface BridgeWakeIdentity { + sessionId: string; + nonce: string; + text: string; +} +const bridgeWakeText = (nonce: string): string => `${PARENT_WAKE_TEXT} [gentle-agents wake: ${nonce}]`; const NATIVE_PARENT_WAKE_TEXT = "Review the delivered subagent output and continue."; // How long a dispatched wake may take to start a parent run before a later // delivery may send another one. @@ -597,6 +604,53 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = // session_start context is kept for it and dropped at shutdown; a stale // context throws instead of answering, so delivery fails closed. let parentCtx: ExtensionContext | undefined; + let bridgeWakeIdentity: BridgeWakeIdentity | undefined; + let wakeVisibilityWarning = false; + const restoreBridgeWakeIdentity = (ctx: ExtensionContext | undefined) => { + bridgeWakeIdentity = undefined; + if (!ctx) return; + // Rendering is session-wide, not model/branch state: an identity on an + // abandoned branch still owns its generated bubbles in the session tree. + for (const entry of ctx.sessionManager.getEntries()) { + if (entry.type !== "custom" || entry.customType !== BRIDGE_WAKE_IDENTITY_TYPE) continue; + const data = entry.data as Partial | undefined; + if (data?.sessionId === ctx.sessionManager.getSessionId() && typeof data.nonce === "string" + && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(data.nonce) + && data.text === bridgeWakeText(data.nonce)) { + bridgeWakeIdentity = data as BridgeWakeIdentity; + break; + } + } + }; + const hasWakeTransformer = typeof pi.registerMarkdownTransformer === "function"; + if (hasWakeTransformer) { + // Register once in this runtime. Reload replaces the runtime; session + // replacement changes the identity, never the transformer registration. + pi.registerMarkdownTransformer((markdown, context) => + context.messageType === "user" && bridgeWakeIdentity?.sessionId === parentCtx?.sessionManager.getSessionId() + && markdown === bridgeWakeIdentity?.text ? "" : markdown); + } + const bridgeWake = (): string => { + if (!parentCtx) return PARENT_WAKE_TEXT; + try { + if (!hasWakeTransformer) throw new Error("Markdown transformer API unavailable"); + if (!bridgeWakeIdentity || bridgeWakeIdentity.sessionId !== parentCtx.sessionManager.getSessionId()) { + const nonce = randomUUID(); + const identity = { sessionId: parentCtx.sessionManager.getSessionId(), nonce, text: bridgeWakeText(nonce) }; + // Persist before sending so a restart can reconstruct exact ownership. + pi.appendEntry(BRIDGE_WAKE_IDENTITY_TYPE, identity); + bridgeWakeIdentity = identity; + } + return bridgeWakeIdentity.text; + } catch { + // Compatibility fallback preserves continuation, not invisibility. + if (!wakeVisibilityWarning) { + wakeVisibilityWarning = true; + try { parentCtx.ui.notify("Gentle Agents cannot hide Claude Bridge continuation on this runtime; the generated user wake remains visible.", "warning"); } catch { /* UI failure must not drop continuation. */ } + } + return PARENT_WAKE_TEXT; + } + }; // Mirrors the host's agent run, which spans agent_start through // agent_settled, including post-run retries and in-run compaction. Unlike // activeAgentRuns it stays set between agent_end and agent_settled, where @@ -691,7 +745,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = // Only Claude Bridge is currently evidenced to require prompt capture; // registering a custom provider alone does not make it a bridge. if (parentCtx?.model?.provider === "claude-bridge") { - pi.sendUserMessage(PARENT_WAKE_TEXT, { deliverAs: "steer" }); + pi.sendUserMessage(bridgeWake(), { deliverAs: "steer" }); } else { pi.sendMessage({ customType: PARENT_WAKE_TYPE, @@ -794,6 +848,8 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = // Session changes discard every pending wake and boundary flush. const resetParentDelivery = (ctx: ExtensionContext | undefined) => { parentCtx = ctx; + restoreBridgeWakeIdentity(ctx); + wakeVisibilityWarning = false; parentRunActive = false; wakeOwed = false; endPromptStart(); @@ -1075,12 +1131,12 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = } catch { return; } - // The session may have moved on while disk was read; a stale restore - // must never land in the wrong session's store. - if (ctx.sessionManager.getSessionId() !== sessionId) return; - // Fire-and-forget from session_start: a throwing summary subscriber must - // never surface as an unhandled rejection. History is best-effort. + // Fire-and-forget from session_start: a stale SDK context or throwing + // summary subscriber must never surface as an unhandled rejection. try { + // The session may have moved on while disk was read; a stale restore + // must never land in the wrong session's store. + if (ctx.sessionManager.getSessionId() !== sessionId) return; for (const { task, thread } of history) { if (task.parentSessionId !== sessionId) continue; if (store.restore(task, thread)) restoredTaskIds.add(task.id); diff --git a/tests/bridge-wake-lifecycle.test.ts b/tests/bridge-wake-lifecycle.test.ts new file mode 100644 index 000000000..f91689af3 --- /dev/null +++ b/tests/bridge-wake-lifecycle.test.ts @@ -0,0 +1,128 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import test from "node:test"; +import { createAssistantMessageEventStream, getCurrentSystemPrompt, getCurrentTools, type AssistantMessage, type TranscriptContext } from "@earendil-works/pi-ai"; +import { createAgentSession, DefaultResourceLoader, ModelRuntime, SettingsManager, SessionManager } from "@earendil-works/pi-coding-agent"; +import gentleAgents, { type SessionTransportFactory } from "../extensions/gentle-agents.ts"; +import { fakeChild, type FakeChild } from "./agents-fake-child.ts"; + +const sdk = import.meta.resolve("@earendil-works/pi-coding-agent"); +const { UserMessageComponent } = await import(new URL("./modes/interactive/components/user-message.js", sdk).href); +const { initTheme } = await import(new URL("./modes/interactive/theme/theme.js", sdk).href); +initTheme("dark"); +const transport: SessionTransportFactory = { + createRegistry: async () => ({ list: async () => [], listActivations: async () => [] }), + createListener: registry => ({ registry, start: async () => {}, close: async () => {} }), + createClient: () => ({ close() {}, sendNotification: async () => { throw new Error("Offline fixture forbids transport"); } }), +}; +const tick = () => new Promise(resolve => setImmediate(resolve)); +async function until(predicate: () => boolean) { + const deadline = Date.now() + 2000; + while (!predicate() && Date.now() < deadline) await tick(); + assert.ok(predicate(), "offline lifecycle did not settle"); +} + +test("actual SDK Bridge wake retains preparation, history, reload and resumed rendering offline", async () => { + const root = mkdtempSync(join(process.env.TMPDIR!, "bridge-lifecycle-")); + const home = join(root, "home"), cwd = join(root, "project"), agentDir = join(root, "agent"); + mkdirSync(home, { recursive: true }); + mkdirSync(cwd); mkdirSync(join(agentDir, "agents"), { recursive: true }); + writeFileSync(join(agentDir, "agents", "explore.md"), "---\ndescription: Offline fixture\n---\nReturn a fixture answer."); + const children: FakeChild[] = [], requests: TranscriptContext[] = []; + let preparations = 0; + const modelRuntime = await ModelRuntime.create({ authPath: join(agentDir, "offline-auth.json"), modelsPath: null, refreshOnCreate: false }); + // The provider name tests routing, but this model has no live implementation. + modelRuntime.registerProvider("claude-bridge", { + api: "bridge-offline-test", apiKey: "explicit-offline-dummy", baseUrl: "http://offline.invalid", + models: [{ id: "offline-recording", name: "Offline recording", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 100000, maxTokens: 1000 }], + streamSimple: (model, context) => { + assert.equal(model.id, "offline-recording"); + requests.push(structuredClone(context)); + const stream = createAssistantMessageEventStream(); + const message: AssistantMessage = { role: "assistant", api: model.api, provider: model.provider, model: model.id, timestamp: Date.now(), content: [{ type: "text", text: "Offline continued" }], stopReason: "stop", usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } }; + queueMicrotask(() => { stream.push({ type: "start", partial: message }); stream.push({ type: "done", reason: "stop", message }); stream.end(); }); + return stream; + }, + }); + const settingsManager = SettingsManager.inMemory({ compaction: { enabled: false }, retry: { enabled: false } }); + const loader = new DefaultResourceLoader({ cwd, agentDir, settingsManager, noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true, noContextFiles: true, + extensionFactories: [pi => { + gentleAgents(pi, {}, { home, agentHome: agentDir, env: { PATH: "/usr/bin:/bin" }, pi: { command: "offline-child", args: [] }, spawn: () => { const child = fakeChild(); children.push(child); return child.child; }, schedule: () => () => {}, sessionTransport: transport, resolveWorktree: () => undefined }); + pi.registerTool({ name: "sentinel", label: "Sentinel", description: "Offline sentinel tool", parameters: { type: "object", properties: {} } as never, execute: async () => ({ content: [], details: undefined }) }); + pi.on("before_agent_start", event => { + preparations++; + pi.setActiveTools(["sentinel"]); + event.systemPromptOptions.appendSystemPrompt += `\nBRIDGE_PREPARATION_SENTINEL_${preparations}`; + event.systemPromptOptions.selectedTools = ["sentinel"]; + }); + }], + }); + await loader.reload(); + const manager = SessionManager.create(cwd, join(root, "sessions")); + const model = modelRuntime.getModel("claude-bridge", "offline-recording")!; + const errors: string[] = []; + const create = async (sessionManager: SessionManager) => { + const result = await createAgentSession({ cwd, agentDir, modelRuntime, model, tools: ["sentinel"], settingsManager, resourceLoader: loader, sessionManager }); + await result.session.bindExtensions({ mode: "rpc", onError: error => errors.push(error.error) }); + return result.session; + }; + let session = await create(manager); + try { + await session.prompt("Ordinary human turn"); + const launch = async (id: string) => { + const tool = session.extensionRunner.getAllRegisteredTools().find(tool => tool.definition.name === "subagent_run")!.definition; + const count = children.length; + const result = await tool.execute(id, { agent: "explore", task: "Offline work", mode: "background" }, undefined, undefined, session.extensionRunner.createToolContext(id, undefined)); + assert.ok(JSON.stringify(result).includes("taskId"), JSON.stringify(result)); + await until(() => children.length > count); + return children.at(-1)!; + }; + const child = await launch("completion"); + child.emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "OFFLINE_CHILD_RESULT" }] }] }); + child.emit({ type: "agent_settled" }); + await until(() => requests.length === 2 && session.isIdle); + assert.equal(preparations, 2, "wake traverses normal prompt/before_agent_start"); + assert.match(getCurrentSystemPrompt(requests[1].messages), /BRIDGE_PREPARATION_SENTINEL_2/); + assert.deepEqual(getCurrentTools(requests[1].messages).map(tool => tool.name), ["sentinel"]); + const wakeEntries = manager.getEntries().filter(entry => entry.type === "custom" && entry.customType === "gentle-agents.wake-identity"); + assert.equal(wakeEntries.length, 1); + const identity = (wakeEntries[0] as { data: { text: string } }).data; + assert.ok(session.messages.some(message => message.role === "user" && JSON.stringify(message.content).includes(identity.text)), "wake is still user-role history"); + assert.ok(JSON.stringify(requests[1].messages).includes(identity.text), "wake remains model-visible"); + assert.equal(manager.getEntries().filter(entry => entry.type === "custom_message" && entry.customType === "gentle-agents.result").length, 1); + const render = (text: string) => new UserMessageComponent(text, undefined, 1, session.extensionRunner.getMarkdownTransformers()).render(80); + assert.deepEqual(render(identity.text), []); + assert.ok(render("Ordinary human turn").length > 0); + await session.reload(); + assert.equal(session.extensionRunner.getMarkdownTransformers().length, 1); + assert.deepEqual(render(identity.text), [], "reload reconstructs the durable identity"); + const file = manager.getSessionFile()!; + await session.extensionRunner.emit({ type: "session_shutdown", reason: "quit" }); + session.dispose(); + await loader.reload(); + session = await create(SessionManager.open(file)); + assert.equal(session.extensionRunner.getMarkdownTransformers().length, 1); + assert.deepEqual(render(identity.text), [], "opening saved history restores suppression"); + const queryChild = await launch("query"); + queryChild.message({ id: "q1", kind: "query", message: "OFFLINE_CHILD_QUERY" }); + await until(() => requests.length === 3 && session.isIdle); + assert.equal(preparations, 3); + assert.match(getCurrentSystemPrompt(requests[2].messages), /BRIDGE_PREPARATION_SENTINEL_3/); + assert.deepEqual(getCurrentTools(requests[2].messages).map(tool => tool.name), ["sentinel"]); + assert.equal(session.sessionManager.getEntries().filter(entry => entry.type === "custom" && entry.customType === "gentle-agents.wake-identity").length, 1, "resume reuses one identity"); + assert.equal(session.sessionManager.getEntries().filter(entry => entry.type === "custom_message" && entry.customType === "gentle-agents.message").length, 1, "query stored once"); + await session.extensionRunner.emit({ type: "session_shutdown", reason: "quit" }); + session.dispose(); + await loader.reload(); + session = await create(SessionManager.inMemory(cwd)); + assert.equal(session.extensionRunner.getMarkdownTransformers().length, 1); + assert.ok(render(identity.text).length > 0, "switching to another session releases the old identity"); + assert.ok(render("Ordinary human turn").length > 0); + assert.deepEqual(errors, []); + } finally { + await session.extensionRunner.emit({ type: "session_shutdown", reason: "quit" }); + session.dispose(); + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tests/bridge-wake-rendering.test.ts b/tests/bridge-wake-rendering.test.ts new file mode 100644 index 000000000..30fa5514d --- /dev/null +++ b/tests/bridge-wake-rendering.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createAgentSession, ModelRuntime, DefaultResourceLoader, SettingsManager, SessionManager } from "@earendil-works/pi-coding-agent"; +import gentleAgents from "../extensions/gentle-agents.ts"; + +// Resolve from the installed SDK entry, never from a vendored runtime copy. +const sdk = import.meta.resolve("@earendil-works/pi-coding-agent"); +const { UserMessageComponent } = await import(new URL("./modes/interactive/components/user-message.js", sdk).href); +const { initTheme } = await import(new URL("./modes/interactive/theme/theme.js", sdk).href); +initTheme("dark"); +const oldWake = "[System-generated Gentle Agents notification, not written by the user] Subagent output was delivered to this session above. Review it and continue."; + +test("real user renderer preserves ordinary content and removes only the owned reserved wake", async () => { + const manager = SessionManager.inMemory(process.env.TMPDIR!); + const wake = `${oldWake} [gentle-agents wake: 67da57d9-7fbe-47b6-9187-c31109fe4eba]`; + manager.appendCustomEntry("gentle-agents.wake-identity", { sessionId: manager.getSessionId(), nonce: "invalid", text: "Human continuation" }); + manager.appendCustomEntry("gentle-agents.wake-identity", { sessionId: "other-session", nonce: "67da57d9-7fbe-47b6-9187-c31109fe4eba", text: "Human continuation" }); + manager.appendCustomEntry("gentle-agents.wake-identity", { sessionId: manager.getSessionId(), nonce: "67da57d9-7fbe-47b6-9187-c31109fe4eba", text: wake }); + const settingsManager = SettingsManager.inMemory(); + const loader = new DefaultResourceLoader({ + cwd: process.env.TMPDIR!, agentDir: process.env.PI_CODING_AGENT_DIR!, settingsManager, + noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true, noContextFiles: true, + extensionFactories: [pi => gentleAgents(pi, {}, { home: process.env.HOME!, schedule: () => () => {} })], + }); + await loader.reload(); + const loaded = loader.getExtensions(); + assert.deepEqual(loaded.errors, []); + const modelRuntime = await ModelRuntime.create({ authPath: `${process.env.PI_CODING_AGENT_DIR}/offline-auth.json`, modelsPath: null, refreshOnCreate: false }); + const { session } = await createAgentSession({ cwd: process.env.TMPDIR!, agentDir: process.env.PI_CODING_AGENT_DIR!, resourceLoader: loader, sessionManager: manager, settingsManager, modelRuntime, tools: [] }); + await session.bindExtensions({}); + try { + const transformers = session.extensionRunner.getMarkdownTransformers(); + assert.equal(transformers.length, 1); + for (const width of [12, 80, 140]) { + assert.deepEqual(new UserMessageComponent(wake, undefined, 1, transformers).render(width), [], "no lines, padding, bubble, or OSC markers"); + for (const ordinary of [oldWake, `Quoted: ${wake}`, ` ${wake}`, `${wake}\n`, "Human continuation"]) { + assert.ok(new UserMessageComponent(ordinary, undefined, 1, transformers).render(width).length > 0); + } + } + assert.equal(transformers[0](wake, { messageType: "assistant", isStreaming: false, availableWidth: 80 }), wake); + } finally { + await session.extensionRunner.emit({ type: "session_shutdown", reason: "quit" }); + session.dispose(); + } +}); diff --git a/tests/gentle-agents.test.ts b/tests/gentle-agents.test.ts index dbcd4457f..5e1838a78 100644 --- a/tests/gentle-agents.test.ts +++ b/tests/gentle-agents.test.ts @@ -112,6 +112,7 @@ writeFileSync(join(home, ".pi", "agent", "subagents.json"), JSON.stringify({ max // Existing prompt-lifecycle regressions exercise the bridge route by default. function fakePi(initialProvider: string = "claude-bridge") { + const transformers: Array[0]> = []; const handlers = new Map(); const tools = new Map(); const shortcuts = new Map }>(); @@ -134,6 +135,7 @@ function fakePi(initialProvider: string = "claude-bridge") { const listeners = new Map void>>(); const pi = { appendEntry: (customType: string, data: unknown) => entries.push({ type: "custom", customType, data }), + registerMarkdownTransformer: (transformer: Parameters[0]) => transformers.push(transformer), events: { emit: (name: string, data: unknown) => { events.push({ name, data }); for (const listener of listeners.get(name) ?? []) listener(data); }, on: (name: string, listener: (data: unknown) => void) => { @@ -178,7 +180,7 @@ function fakePi(initialProvider: string = "claude-bridge") { } } }; - return { pi, tools, shortcuts, commands, fire, sent, userMessages, delivery, setIdle, setProvider, renderers, entryRenderers, entries, events, listeners }; + return { pi, tools, shortcuts, commands, fire, sent, userMessages, delivery, setIdle, setProvider, renderers, entryRenderers, entries, events, listeners, transformers }; } 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]) { @@ -2579,12 +2581,12 @@ test("explicit child roots launch and continue in the actual cwd, persist withou await tick(); assert.deepEqual(launched, [childRoot, childRoot]); spawnEvents[1](); - assert.equal(h.entries.length, 1, "continuation dedupes the original root"); + assert.equal(h.entries.filter(entry => entry.customType === SESSION_WORKTREE_ENTRY).length, 1, "continuation dedupes the original root"); const status = await h.tools.get("subagent_status")!.execute("status", { task_id: details.taskId }, undefined, undefined, ctx); assert.match(status.content[0].text, /cwd:/); await h.fire("session_shutdown", ctx); spawnEvents[1](); - assert.equal(h.entries.length, 1, "late process events after shutdown cannot write session state"); + assert.equal(h.entries.filter(entry => entry.customType === SESSION_WORKTREE_ENTRY).length, 1, "late process events after shutdown cannot register roots"); await tick(); }); @@ -2771,7 +2773,7 @@ test("ordinary non-Git tasks still continue in their original cwd without regist await h.tools.get("subagent_continue")!.execute("continue", { task_id: taskId, prompt: "Follow up", mode: "background" }, undefined, undefined, ctx); await tick(); assert.equal(runtime.children.length, 2); - assert.deepEqual(h.entries, []); + assert.deepEqual(h.entries.filter(entry => entry.customType !== "gentle-agents.wake-identity"), []); await h.fire("session_shutdown", ctx); await tick(); }); @@ -2802,7 +2804,7 @@ for (const sameId of [false, true]) { await h.fire("session_start", next.ctx); spawnEvents[0](); await tick(); - assert.deepEqual(h.entries, [], "captured registry is closed instead of appending to the new bound API"); + assert.deepEqual(h.entries.filter(entry => entry.customType !== "gentle-agents.wake-identity"), [], "captured registry is closed instead of appending worktree state to the new bound API"); await h.fire("session_shutdown", next.ctx); }); } @@ -3343,7 +3345,7 @@ test("AgentsView production footer uses rendered bounds and invalidates them bef const harness = deps(); gentleAgents(pi, {}, harness.deps); const { ctx, overlays, customCompletions } = fakeContext(); - (ctx as unknown as { sessionManager: { getSessionId(): string; getCwd(): string } }).sessionManager = { getSessionId: () => "footer-session", getCwd: () => cwd }; + (ctx as unknown as { sessionManager: { getSessionId(): string; getCwd(): string; getEntries(): [] } }).sessionManager = { getSessionId: () => "footer-session", getCwd: () => cwd, getEntries: () => [] }; await fire("session_start", ctx); await tools.get("subagent_run")!.execute("c1", { agent: "寿司", task: "Footer target", mode: "background" }, undefined, undefined, ctx); await tick(); @@ -3704,8 +3706,8 @@ test("the card follows the active session: after /new the earlier session's task await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Long job", mode: "background" }, undefined, undefined, ctx); await tick(); assert.match(widget()![1], /◐ explore Long job/); - const sessions = ctx as unknown as { sessionManager: { getSessionId(): string; getCwd(): string } }; - sessions.sessionManager = { getSessionId: () => "s2", getCwd: () => cwd }; + const sessions = ctx as unknown as { sessionManager: { getSessionId(): string; getCwd(): string; getEntries(): [] } }; + sessions.sessionManager = { getSessionId: () => "s2", getCwd: () => cwd, getEntries: () => [] }; await fire("session_start", ctx, { type: "session_start", reason: "new" }); assert.deepEqual(widget(), [], "the new session starts with an empty card"); assert.match((await tools.get("subagent_list_tasks")!.execute("c2", {}, undefined, undefined, ctx)).content[0].text, /No subagent tasks in this session/); @@ -3717,7 +3719,7 @@ test("the card follows the active session: after /new the earlier session's task assert.doesNotMatch(overlay.render(80).map(stripAnsi).join("\n"), /◐ Subagent explore/, "retained children of a replaced session do not imply an open orchestrator"); overlay.handleInput("\x1b"); await opened; - sessions.sessionManager = { getSessionId: () => "s1", getCwd: () => cwd }; + sessions.sessionManager = { getSessionId: () => "s1", getCwd: () => cwd, getEntries: () => [] }; await fire("session_start", ctx, { type: "session_start", reason: "resume" }); assert.match(widget()![1], /◐ explore Long job/, "resuming the first session shows its task again"); }); @@ -4157,6 +4159,58 @@ test("a background completion settling while the parent agent runs is delivered await fire("session_shutdown", ctx); }); +test("Bridge identity is durable, session-owned and registered once across session changes", async () => { + const h = fakePi(); + const runtime = deps(); + gentleAgents(h.pi, {}, runtime.deps); + const { ctx } = fakeContext(); + ctx.sessionManager.getEntries = () => h.entries as unknown as ReturnType; + await h.fire("session_start", ctx); + await h.tools.get("subagent_run")!.execute("one", { agent: "explore", task: "Wake identity", mode: "background" }, undefined, undefined, ctx); + await tick(); + runtime.children[0].message({ id: "q1", kind: "query", message: "Question" }); + await tick(); + const wake = String(h.userMessages[0].content); + const transform = h.transformers[0]; + const context = { messageType: "user" as const, isStreaming: false, availableWidth: 80 }; + assert.equal(transform(wake, context), ""); + assert.equal(h.entries.filter(entry => entry.customType === "gentle-agents.wake-identity").length, 1); + await h.fire("session_start", ctx, { reason: "reload" }); + assert.equal(transform(wake, context), "", "reload reconstructs ownership"); + ctx.sessionManager.getSessionId = () => "other-session"; + await h.fire("session_start", ctx, { reason: "new" }); + assert.equal(transform(wake, context), wake, "another session cannot claim the old identity"); + ctx.sessionManager.getSessionId = () => "s1"; + await h.fire("session_start", ctx, { reason: "resume" }); + assert.equal(transform(wake, context), ""); + assert.equal(h.transformers.length, 1, "session changes do not register again"); + assert.equal(transform(`Quoted: ${wake}`, context), `Quoted: ${wake}`); + assert.equal(transform(` ${wake}`, context), ` ${wake}`); + await h.fire("session_shutdown", ctx); + assert.equal(transform(wake, context), wake, "shutdown releases the old owner"); +}); + +for (const failure of ["missing-api", "persistence", "warning-ui"] as const) { + test(`Bridge ${failure} fallback preserves continuation and flags visible wake`, async () => { + const h = fakePi(); + const runtime = deps(); + if (failure !== "persistence") delete (h.pi as Partial).registerMarkdownTransformer; + gentleAgents(h.pi, {}, runtime.deps); + const { ctx, dialogs } = fakeContext(); + if (failure === "warning-ui") ctx.ui.notify = () => { throw new Error("UI unavailable"); }; + await h.fire("session_start", ctx); + if (failure === "persistence") h.pi.appendEntry = () => { throw new Error("Persistence unavailable"); }; + await h.tools.get("subagent_run")!.execute("fallback", { agent: "explore", task: "Fallback", mode: "background" }, undefined, undefined, ctx); + await tick(); + runtime.children[0].message({ id: "q1", kind: "query", message: "Question" }); + await tick(); + assert.equal(h.userMessages.length, 1); + assert.equal(h.sent.length, 1, "payload is stored once despite compatibility fallback"); + assert.doesNotMatch(String(h.userMessages[0].content), /gentle-agents wake:/); + if (failure !== "warning-ui") assert.ok(dialogs.some(message => message.includes("cannot hide Claude Bridge"))); + }); +} + for (const provider of ["openai", "anthropic", "custom-extension", undefined, "claude-bridge"]) { for (const kind of ["completion", "query"] as const) { test(`idle ${kind} wake uses the selected ${provider ?? "missing"} provider route`, async () => { @@ -4178,10 +4232,10 @@ for (const provider of ["openai", "anthropic", "custom-extension", undefined, "c assert.match(String(h.sent[0]!.message.content), /Child payload/); if (provider === "claude-bridge") { assert.equal(h.sent.length, 1); - assert.deepEqual(h.userMessages, [{ - content: "[System-generated Gentle Agents notification, not written by the user] Subagent output was delivered to this session above. Review it and continue.", - options: { deliverAs: "steer" }, - }]); + assert.equal(h.userMessages.length, 1); + assert.match(String(h.userMessages[0].content), /\[gentle-agents wake: [0-9a-f-]+\]$/); + assert.deepEqual(h.userMessages[0].options, { deliverAs: "steer" }); + assert.equal(h.transformers[0](String(h.userMessages[0].content), { messageType: "user", isStreaming: false, availableWidth: 80 }), ""); assert.equal(h.delivery.at(-1), "user"); } else { assert.equal(h.userMessages.length, 0, "native and unclassified providers never receive a synthetic user turn"); From 85be8b30fe97766e3c7aa744525e43fc4d3516b5 Mon Sep 17 00:00:00 2001 From: Alan-TheGentleman Date: Sat, 3 Oct 2026 15:37:00 +0200 Subject: [PATCH 3/3] test(bridge): isolate wake fixtures from ambient paths --- tests/bridge-wake-lifecycle.test.ts | 3 +- tests/bridge-wake-rendering.test.ts | 63 +++++++++++++++++------------ 2 files changed, 39 insertions(+), 27 deletions(-) diff --git a/tests/bridge-wake-lifecycle.test.ts b/tests/bridge-wake-lifecycle.test.ts index f91689af3..20fae7da2 100644 --- a/tests/bridge-wake-lifecycle.test.ts +++ b/tests/bridge-wake-lifecycle.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { createAssistantMessageEventStream, getCurrentSystemPrompt, getCurrentTools, type AssistantMessage, type TranscriptContext } from "@earendil-works/pi-ai"; @@ -24,7 +25,7 @@ async function until(predicate: () => boolean) { } test("actual SDK Bridge wake retains preparation, history, reload and resumed rendering offline", async () => { - const root = mkdtempSync(join(process.env.TMPDIR!, "bridge-lifecycle-")); + const root = mkdtempSync(join(tmpdir(), "bridge-lifecycle-")); const home = join(root, "home"), cwd = join(root, "project"), agentDir = join(root, "agent"); mkdirSync(home, { recursive: true }); mkdirSync(cwd); mkdirSync(join(agentDir, "agents"), { recursive: true }); diff --git a/tests/bridge-wake-rendering.test.ts b/tests/bridge-wake-rendering.test.ts index 30fa5514d..3b2eadbae 100644 --- a/tests/bridge-wake-rendering.test.ts +++ b/tests/bridge-wake-rendering.test.ts @@ -1,4 +1,7 @@ import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import test from "node:test"; import { createAgentSession, ModelRuntime, DefaultResourceLoader, SettingsManager, SessionManager } from "@earendil-works/pi-coding-agent"; import gentleAgents from "../extensions/gentle-agents.ts"; @@ -11,35 +14,43 @@ initTheme("dark"); const oldWake = "[System-generated Gentle Agents notification, not written by the user] Subagent output was delivered to this session above. Review it and continue."; test("real user renderer preserves ordinary content and removes only the owned reserved wake", async () => { - const manager = SessionManager.inMemory(process.env.TMPDIR!); - const wake = `${oldWake} [gentle-agents wake: 67da57d9-7fbe-47b6-9187-c31109fe4eba]`; - manager.appendCustomEntry("gentle-agents.wake-identity", { sessionId: manager.getSessionId(), nonce: "invalid", text: "Human continuation" }); - manager.appendCustomEntry("gentle-agents.wake-identity", { sessionId: "other-session", nonce: "67da57d9-7fbe-47b6-9187-c31109fe4eba", text: "Human continuation" }); - manager.appendCustomEntry("gentle-agents.wake-identity", { sessionId: manager.getSessionId(), nonce: "67da57d9-7fbe-47b6-9187-c31109fe4eba", text: wake }); - const settingsManager = SettingsManager.inMemory(); - const loader = new DefaultResourceLoader({ - cwd: process.env.TMPDIR!, agentDir: process.env.PI_CODING_AGENT_DIR!, settingsManager, - noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true, noContextFiles: true, - extensionFactories: [pi => gentleAgents(pi, {}, { home: process.env.HOME!, schedule: () => () => {} })], - }); - await loader.reload(); - const loaded = loader.getExtensions(); - assert.deepEqual(loaded.errors, []); - const modelRuntime = await ModelRuntime.create({ authPath: `${process.env.PI_CODING_AGENT_DIR}/offline-auth.json`, modelsPath: null, refreshOnCreate: false }); - const { session } = await createAgentSession({ cwd: process.env.TMPDIR!, agentDir: process.env.PI_CODING_AGENT_DIR!, resourceLoader: loader, sessionManager: manager, settingsManager, modelRuntime, tools: [] }); - await session.bindExtensions({}); + // Every path is owned by this test so CI never depends on ambient TMPDIR, HOME, or agent directories. + const root = mkdtempSync(join(tmpdir(), "bridge-rendering-")); try { - const transformers = session.extensionRunner.getMarkdownTransformers(); - assert.equal(transformers.length, 1); - for (const width of [12, 80, 140]) { - assert.deepEqual(new UserMessageComponent(wake, undefined, 1, transformers).render(width), [], "no lines, padding, bubble, or OSC markers"); - for (const ordinary of [oldWake, `Quoted: ${wake}`, ` ${wake}`, `${wake}\n`, "Human continuation"]) { - assert.ok(new UserMessageComponent(ordinary, undefined, 1, transformers).render(width).length > 0); + const home = join(root, "home"), cwd = join(root, "project"), agentDir = join(root, "agent"); + mkdirSync(home); mkdirSync(cwd); mkdirSync(agentDir); + const manager = SessionManager.inMemory(cwd); + const wake = `${oldWake} [gentle-agents wake: 67da57d9-7fbe-47b6-9187-c31109fe4eba]`; + manager.appendCustomEntry("gentle-agents.wake-identity", { sessionId: manager.getSessionId(), nonce: "invalid", text: "Human continuation" }); + manager.appendCustomEntry("gentle-agents.wake-identity", { sessionId: "other-session", nonce: "67da57d9-7fbe-47b6-9187-c31109fe4eba", text: "Human continuation" }); + manager.appendCustomEntry("gentle-agents.wake-identity", { sessionId: manager.getSessionId(), nonce: "67da57d9-7fbe-47b6-9187-c31109fe4eba", text: wake }); + const settingsManager = SettingsManager.inMemory(); + const loader = new DefaultResourceLoader({ + cwd, agentDir, settingsManager, + noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true, noContextFiles: true, + extensionFactories: [pi => gentleAgents(pi, {}, { home, agentHome: agentDir, schedule: () => () => {} })], + }); + await loader.reload(); + const loaded = loader.getExtensions(); + assert.deepEqual(loaded.errors, []); + const modelRuntime = await ModelRuntime.create({ authPath: join(agentDir, "offline-auth.json"), modelsPath: null, refreshOnCreate: false }); + const { session } = await createAgentSession({ cwd, agentDir, resourceLoader: loader, sessionManager: manager, settingsManager, modelRuntime, tools: [] }); + await session.bindExtensions({}); + try { + const transformers = session.extensionRunner.getMarkdownTransformers(); + assert.equal(transformers.length, 1); + for (const width of [12, 80, 140]) { + assert.deepEqual(new UserMessageComponent(wake, undefined, 1, transformers).render(width), [], "no lines, padding, bubble, or OSC markers"); + for (const ordinary of [oldWake, `Quoted: ${wake}`, ` ${wake}`, `${wake}\n`, "Human continuation"]) { + assert.ok(new UserMessageComponent(ordinary, undefined, 1, transformers).render(width).length > 0); + } } + assert.equal(transformers[0](wake, { messageType: "assistant", isStreaming: false, availableWidth: 80 }), wake); + } finally { + await session.extensionRunner.emit({ type: "session_shutdown", reason: "quit" }); + session.dispose(); } - assert.equal(transformers[0](wake, { messageType: "assistant", isStreaming: false, availableWidth: 80 }), wake); } finally { - await session.extensionRunner.emit({ type: "session_shutdown", reason: "quit" }); - session.dispose(); + rmSync(root, { recursive: true, force: true }); } });