From a678d7eba3146a0c3f3265899f3dd3ad956d678d Mon Sep 17 00:00:00 2001 From: ci-check Date: Fri, 6 Nov 2026 16:26:15 +0800 Subject: [PATCH 1/5] feat(pi): opt-in structured acp_delegate notification envelope Deliver delegate completion/failure notices as a typed custom message (customType billion-context:delegate-notification) whose versioned details carry stable run identity plus launch provenance (originToolCallId/sourceSessionId), while content stays byte-identical to the legacy steering user message. The model sees the same text either way; pi's convertToLlm drops details so they never reach upstream. Gated behind delegate.notifyStructured (default false -> plain path untouched); falls back to the plain path automatically when the host lacks ExtensionAPI.sendMessage. Lets third-party UIs (e.g. pi-tidy #59) tie a notice to its launching turn without parsing prose. Refs #5750 --- pi-subagents/package-lock.json | 4 +- pi-subagents/package.json | 2 +- pi-subagents/src/config.ts | 14 +- pi-subagents/src/delegate-tool.ts | 164 +++++++++----- .../tests/delegate-structured-notify.test.ts | 214 ++++++++++++++++++ 5 files changed, 333 insertions(+), 65 deletions(-) create mode 100644 pi-subagents/tests/delegate-structured-notify.test.ts diff --git a/pi-subagents/package-lock.json b/pi-subagents/package-lock.json index f8d8f21ea5..282521428a 100644 --- a/pi-subagents/package-lock.json +++ b/pi-subagents/package-lock.json @@ -1,12 +1,12 @@ { "name": "billion-context-pi-subagents", - "version": "0.2.2", + "version": "0.2.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "billion-context-pi-subagents", - "version": "0.2.2", + "version": "0.2.4", "license": "MIT", "devDependencies": { "@earendil-works/pi-coding-agent": "0.83.0", diff --git a/pi-subagents/package.json b/pi-subagents/package.json index 4a7a958a52..5e764e3c19 100644 --- a/pi-subagents/package.json +++ b/pi-subagents/package.json @@ -1,6 +1,6 @@ { "name": "billion-context-pi-subagents", - "version": "0.2.3", + "version": "0.2.4", "description": "acp_delegate sub-agent tools for the Pi coding agent — split out of billion-context-pi (#612); vendored in-repo (#2384).", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/pi-subagents/src/config.ts b/pi-subagents/src/config.ts index 18b57d2f5c..4d70b3d6c0 100644 --- a/pi-subagents/src/config.ts +++ b/pi-subagents/src/config.ts @@ -83,6 +83,14 @@ export interface DelegateConfig { * saw the result, so re-injecting it would only waste context. * "always" — always inject the notification (previous behavior). */ notifyIfRead?: "skip" | "always"; + /** Deliver delegate notifications as a typed custom message that carries a + * versioned `details` envelope (runId/status/agent plus launch provenance: + * originToolCallId/sourceSessionId) alongside the SAME model-visible text. + * Lets third-party UIs tie a notice to its launching turn without parsing + * prose. Default: false — plain user-message delivery (byte-identical to + * before). When true but the host lacks ExtensionAPI.sendMessage, delivery + * falls back to the plain path automatically. See #5750. */ + notifyStructured?: boolean; /** Keybinding for the interactive TUI shortcut that opens the acp_delegate * fleet inspector (live list + transcript). Default: "ctrl+alt+d". Set to * "" (empty string) to disable keyboard registration entirely — the @@ -114,6 +122,9 @@ export interface DelegatePolicy { /** Whether to suppress the completion notification when the model already * read the result file after the run finished. Always resolved ("skip" default). */ notifyIfRead: "skip" | "always"; + /** Whether notifications go out as a typed custom message with a versioned + * details envelope (default false = plain user-message delivery). */ + notifyStructured: boolean; /** Resolved TUI shortcut for the fleet inspector ("" = registration disabled). */ fleetShortcut: string; } @@ -128,6 +139,7 @@ export const DEFAULT_DELEGATE_POLICY: DelegatePolicy = { asyncTimeoutMs: 30 * 60_000, maxConcurrent: Infinity, notifyIfRead: "skip", + notifyStructured: false, fleetShortcut: DEFAULT_FLEET_SHORTCUT, }; @@ -183,7 +195,7 @@ export function resolveDelegate(adapter: SubagentsAdapterConfig): DelegatePolicy hint: "no-output watchdog is off; hung async runs must be cancelled manually via acp_delegate_cancel", }); } - return { enabled, forceEnable, displayUsage, maxDepth, syncTimeoutMs, idleMs, asyncTimeoutMs, maxConcurrent, thinkingLevel: cfg.thinkingLevel, agents: cfg.agents, notifyIfRead: cfg.notifyIfRead ?? "skip", fleetShortcut: resolveFleetShortcut(cfg.fleetShortcut) }; + return { enabled, forceEnable, displayUsage, maxDepth, syncTimeoutMs, idleMs, asyncTimeoutMs, maxConcurrent, thinkingLevel: cfg.thinkingLevel, agents: cfg.agents, notifyIfRead: cfg.notifyIfRead ?? "skip", notifyStructured: cfg.notifyStructured === true, fleetShortcut: resolveFleetShortcut(cfg.fleetShortcut) }; } /** Resolve the fleet-inspector TUI shortcut: a string passes through verbatim diff --git a/pi-subagents/src/delegate-tool.ts b/pi-subagents/src/delegate-tool.ts index 7b5fb99eb3..e21b990f16 100644 --- a/pi-subagents/src/delegate-tool.ts +++ b/pi-subagents/src/delegate-tool.ts @@ -213,6 +213,13 @@ interface DelegateRun { activityFile?: string; /** runId of the run this run resumed from (resumeFrom). */ resumedFrom?: string; + /** Tool-call id of the acp_delegate call that launched this run, captured at + * launch so a LATE notification still ties back to its launching turn + * (structured-details envelope, #5750). */ + originToolCallId?: string; + /** Host session id at launch (main-session provenance for nested/codemode + * runs); best-effort — undefined when the host exposes no session id. */ + sourceSessionId?: string; consumed?: boolean; /** True once the close handler injected the result as a system * notification (sendUserMessage succeeded). Lets a later wait() avoid @@ -275,6 +282,64 @@ export function setDelegatePolicy(policy: DelegatePolicy): void { delegatePolicy = policy; } +// ─── Structured delegate-notification envelope (#5750) ─────────────────────── +// Opt-in via delegate.notifyStructured (default false): notifications go out as a +// typed custom message whose `details` carries versioned run identity + launch +// provenance, while `content` stays byte-identical to the plain user message (the +// model sees the same text + steer either way). Lets third-party UIs fold a notice +// onto its launching turn without parsing prose. Falls back to the plain path when +// the host lacks ExtensionAPI.sendMessage. +const DELEGATE_NOTIFY_CUSTOM_TYPE = "billion-context:delegate-notification"; +const DELEGATE_NOTIFY_SCHEMA_VERSION = 1; + +interface DelegateNotificationRunRef { + runId: string; + status: RunStatus; + agent?: string; + sourceSessionId?: string; + originToolCallId?: string; +} + +interface DelegateNotificationDetails { + version: number; + kind: "terminal" | "usage"; + runs: DelegateNotificationRunRef[]; +} + +function refOfRun(r: DelegateRun): DelegateNotificationRunRef { + return { runId: r.runId, status: r.status, agent: r.agent, sourceSessionId: r.sourceSessionId, originToolCallId: r.originToolCallId }; +} + +function buildNotificationDetails(kind: "terminal" | "usage", runsList: DelegateRun[]): DelegateNotificationDetails { + return { version: DELEGATE_NOTIFY_SCHEMA_VERSION, kind, runs: runsList.map(refOfRun) }; +} + +/** Send one notification. Delivered as steer (not followUp — consumed at the next + * safe boundary of the current task AND persisted in-session, #2320/#2546). + * Structured (typed custom message, same text, details extension-only) when + * notifyStructured is on AND the host exposes sendMessage; otherwise the plain + * user message. Returns true only when the host accepted it, so a failed/unavailable + * send leaves the run undelivered for later recovery. */ +function sendDelegateNotification(pi: ExtensionAPI, text: string, details: DelegateNotificationDetails): boolean { + const structured = delegatePolicy.notifyStructured && typeof pi.sendMessage === "function"; + try { + if (structured) { + pi.sendMessage.call(pi, { customType: DELEGATE_NOTIFY_CUSTOM_TYPE, content: text, display: true, details }, { deliverAs: "steer" }); + return true; + } + const send = pi.sendUserMessage; + if (typeof send === "function") { + send.call(pi, text, { deliverAs: "steer" }); + return true; + } + logWarn("delegate", { event: "notify-no-channel", reason: "no send channel available" }); + return false; + } catch (err) { + logError("delegate", { event: "notify-send-error", error: String(err), structured }); + return false; + } +} + // ─── Delegate concurrency gate (#294) ─────────────────────────────────────── // At most `capacity` background children run at once (Infinity = unlimited); // extra launches wait in a FIFO queue. Cancelling a QUEUED launch marks it @@ -758,7 +823,7 @@ The delegate runs in its own clean pi process — it does NOT see this conversat renderResult: (result, options, theme, ctx) => renderDelegateResult(result, options, theme, ctx), async execute(toolCallId, params, signal, _onUpdate, ctx): Promise> { const args = params as DelegateArgs; - const { text, meta } = await runDelegate(pi, args, ctx, signal); + const { text, meta } = await runDelegate(pi, args, ctx, signal, toolCallId); const notice = undeliveredNotice(); const outcome = notice ? `${notice}\n\n${text}` : text; return { details: meta, content: [{ type: "text", text: outcome }] }; @@ -993,21 +1058,10 @@ function commitAtTurnEnd(pi: ExtensionAPI, st: HostNotificationState): void { } text = buildBatchText(deliverable, mode, () => true); } - const send = pi.sendUserMessage; - let sent = false; - if (typeof send === "function") { - try { - // Steering, not follow-up (#2320/#2546): consumed at the next safe - // boundary of the current task AND persisted in the session, unlike the - // request-local context append this replaces. - send.call(pi, text, { deliverAs: "steer" }); - sent = true; - } catch (err) { - logError("delegate", { event: "notify-turn-end-error", error: String(err), runIds: deliverable.map((r) => r.runId).join(",") }); - } - } else { - logWarn("delegate", { event: "notify-turn-end-skipped", reason: "sendUserMessage unavailable" }); - } + // Envelope lists every run in the message: primary + recovery-folded runs for a + // single, the full batch otherwise. + const represented = deliverable.length === 1 ? [deliverable[0]!, ...covered] : deliverable; + const sent = sendDelegateNotification(pi, text, buildNotificationDetails("terminal", represented)); if (sent) { for (const r of deliverable) r.injected = true; for (const c of covered) c.injected = true; @@ -1091,22 +1145,10 @@ function flushHostNotifications(pi: ExtensionAPI): void { } text = buildBatchText(deliverable, mode, (r) => queued.has(r)); } - const send = pi.sendUserMessage; - let sent = false; - if (typeof send === "function") { - try { - // Steering, not follow-up (#2320): a follow-up parked here would wait for - // the WHOLE task to end before reaching the model; steering is consumed at - // the next safe boundary of the current task — which, at the idle tier, is - // immediately (an idle host starts a turn right away). - send.call(pi, text, { deliverAs: "steer" }); - sent = true; - } catch (err) { - logError("delegate", { event: "notify-batch-error", error: String(err), runIds: deliverable.map((r) => r.runId).join(",") }); - } - } else { - logWarn("delegate", { event: "notify-batch-skipped", reason: "sendUserMessage unavailable" }); - } + // Envelope lists every run in the message: primary + recovery-folded runs for a + // single, the full batch otherwise. + const represented = deliverable.length === 1 ? [deliverable[0]!, ...covered] : deliverable; + const sent = sendDelegateNotification(pi, text, buildNotificationDetails("terminal", represented)); if (sent) { for (const r of deliverable) r.injected = true; for (const c of covered) c.injected = true; @@ -1425,11 +1467,22 @@ function spawnDelegateChild(opts: { return child; } +/** Host session id for launch provenance; best-effort (undefined when the host + * exposes no session id or the call throws) so capture never breaks a launch. */ +function safeSourceSessionId(ctx: ExtensionContext): string | undefined { + try { + return ctx.sessionManager?.getSessionId?.(); + } catch { + return undefined; + } +} + async function runDelegate( pi: ExtensionAPI, args: DelegateArgs, ctx: ExtensionContext, signal: AbortSignal | undefined, + originToolCallId: string, ): Promise<{ text: string; meta: DelegateRenderMeta }> { const errorResult = (text: string): { text: string; meta: DelegateRenderMeta } => ({ text, meta: { kind: "delegate", agent: args.agent } }); const agent = AGENTS[args.agent]; @@ -1505,6 +1558,8 @@ async function runDelegate( startedAt: Date.now(), status: "queued", resumedFrom: args.resumeFrom, + originToolCallId, + sourceSessionId: safeSourceSessionId(ctx), }; runs.set(runId, run); delegateStatusWidget.poke(); @@ -2206,27 +2261,24 @@ export function injectResult( activityFile?: string, signal?: NodeJS.Signals | null, ): boolean { - const send = pi.sendUserMessage; - if (typeof send !== "function") { - debug.event("delegate-inject-skipped", { runId, reason: "sendUserMessage unavailable" }); - logWarn("delegate", { event: "inject-skipped", runId, reason: "sendUserMessage unavailable" }); + // Preserve the cheap no-op when the host has no message channel at all: + // buildInjectText accumulates usage (separate mode), so don't run it unless we + // can actually deliver. + const canSend = typeof pi.sendUserMessage === "function" || (delegatePolicy.notifyStructured && typeof pi.sendMessage === "function"); + if (!canSend) { + debug.event("delegate-inject-skipped", { runId, reason: "no send channel available" }); + logWarn("delegate", { event: "inject-skipped", runId, reason: "no send channel available" }); return false; } const { text, covered } = buildInjectText(agent, runId, task, status, code, file, timedOut, usage, mode, usageAlreadyReported, body, activityFile, signal); - try { - // Steering, not follow-up (#2320): a follow-up parked here would wait out - // the whole task before reaching the model; steering is consumed at the - // next safe boundary (immediately when the host is idle). - send.call(pi, text, { deliverAs: "steer" }); - // Commit the recovery marking only now: a thrown send above must leave the - // covered runs undelivered so a later carrier can still recover them. - for (const r of covered) r.injected = true; - return true; - } catch (err) { - debug.event("delegate-inject-error", { runId, error: String(err) }); - logError("delegate", { event: "inject-error", runId, agent, error: String(err) }); - return false; - } + // Envelope lists the primary run plus any recovery runs folded into the text. + const primary = runs.get(runId); + const represented = primary ? [primary, ...covered] : covered; + const sent = sendDelegateNotification(pi, text, buildNotificationDetails("terminal", represented)); + // Commit the recovery marking only on success: a failed send leaves the covered + // runs undelivered so a later carrier can still recover them. + if (sent) for (const r of covered) r.injected = true; + return sent; } /** Send the inline-fetch usage note (#2913). When a delegate's result reaches @@ -2246,19 +2298,9 @@ export function emitInlineUsageNote(pi: ExtensionAPI, run: DelegateRun, mode: "m if (!block.trim()) return; const label = run.status === "failed" ? "FAILED ⚠️" : run.status === "cancelled" ? "cancelled" : "completed"; const text = `[acp_delegate ${label}] **${run.agent}** (runId \`${run.runId}\`) — usage summary\n${block.replace(/^\s+/, "")}\nThis is an automated system message, NOT a user request.`; - const send = pi.sendUserMessage; - if (typeof send !== "function") { - debug.event("delegate-inline-usage-skipped", { runId: run.runId, reason: "sendUserMessage unavailable" }); - logWarn("delegate", { event: "inline-usage-skipped", runId: run.runId, reason: "sendUserMessage unavailable" }); - return; - } - try { - send.call(pi, text, { deliverAs: "steer" }); + if (sendDelegateNotification(pi, text, buildNotificationDetails("usage", [run]))) { run.usageNoted = true; debug.event("delegate-inline-usage-note", { runId: run.runId, status: run.status }); - } catch (err) { - debug.event("delegate-inline-usage-error", { runId: run.runId, error: String(err) }); - logError("delegate", { event: "inline-usage-error", runId: run.runId, error: String(err) }); } } diff --git a/pi-subagents/tests/delegate-structured-notify.test.ts b/pi-subagents/tests/delegate-structured-notify.test.ts new file mode 100644 index 0000000000..a612b165fe --- /dev/null +++ b/pi-subagents/tests/delegate-structured-notify.test.ts @@ -0,0 +1,214 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + injectResult, + makeDelegateTool, + scheduleRunNotification, + setDelegatePolicy, +} from "../src/delegate-tool.js"; +import { DEFAULT_DELEGATE_POLICY } from "../src/config.js"; + +// #5750 structured-notification envelope. When delegate.notifyStructured is ON and +// the host exposes ExtensionAPI.sendMessage, each delegate notice is delivered as a +// typed custom message whose `details` carry stable run identity + launch provenance, +// while `content` stays byte-identical to the legacy plain steering message (the model +// sees the same text either way; pi's convertToLlm drops `details`). Default OFF keeps +// the plain path untouched; a host without sendMessage falls back to it. These tests +// drive the same host lifecycle surface as delegate-turn-end-notify.test.ts. + +type PiLike = Parameters[0]; + +const CUSTOM_TYPE = "billion-context:delegate-notification"; + +function mkRun(runId: string, status: "completed" | "failed", over: Record = {}): any { + return { + runId, + agent: "reviewer", + task: "review X", + cwd: "/tmp", + startedAt: 0, + finishedAt: 1000, + status, + result: { code: status === "completed" ? 0 : 1, file: `/tmp/${runId}.out`, body: "boom" }, + ...over, + }; +} + +type UserEntry = { t: string; o?: { deliverAs?: string } }; +type CustomEntry = { + m: { customType: string; content: string; display: boolean; details: unknown }; + o?: { deliverAs?: string }; +}; + +/** Mock ExtensionAPI exposing BOTH send channels so a test can assert which fired. + * Pass `withSendMessage:false` to model a host lacking the structured channel. */ +function mockPi(withSendMessage = true) { + const user: UserEntry[] = []; + const custom: CustomEntry[] = []; + const handlers = new Map void>>(); + const pi: Record = { + sendUserMessage: (t: string, o?: { deliverAs?: string }) => user.push({ t, o }), + on: (event: string, handler: (...a: unknown[]) => void) => { + const list = handlers.get(event) ?? []; + list.push(handler); + handlers.set(event, list); + }, + }; + if (withSendMessage) { + pi.sendMessage = (m: CustomEntry["m"], o?: { deliverAs?: string }) => custom.push({ m, o }); + } + const emit = (event: string) => { + for (const h of handlers.get(event) ?? []) h({}); + }; + return { pi: pi as unknown as PiLike, user, custom, emit }; +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +async function waitFor(cond: () => boolean, what: string, ms = 1000): Promise { + const deadline = Date.now() + ms; + while (!cond()) { + if (Date.now() > deadline) throw new Error(`timed out waiting: ${what}`); + await sleep(5); + } +} + +const structuredOn = () => setDelegatePolicy({ ...DEFAULT_DELEGATE_POLICY, notifyStructured: true }); +const structuredOff = () => setDelegatePolicy(DEFAULT_DELEGATE_POLICY); + +type Details = { version: number; kind: string; runs: Array> }; + +// ─── default OFF: legacy plain path, byte-for-byte ──────────────────────────── + +test("default off: notice stays a plain steering user message; sendMessage never touched", async () => { + structuredOff(); + const { pi, user, custom, emit } = mockPi(); + makeDelegateTool(pi); + emit("agent_start"); + const run = mkRun("del_off", "completed"); + scheduleRunNotification(pi, run); + await sleep(30); + emit("turn_end"); + assert.equal(custom.length, 0, "no typed message while the switch is off"); + assert.equal(user.length, 1, "plain user message delivered"); + assert.deepEqual(user[0]!.o, { deliverAs: "steer" }, "steering delivery unchanged"); + assert.ok(user[0]!.t.includes("`del_off`"), "names the run"); + assert.equal(run.injected, true); +}); + +// ─── ON + host supports sendMessage: typed envelope, content byte-identical ─── + +test("on + sendMessage present: typed envelope, content byte-identical to the legacy message", async () => { + // Baseline: exactly what the legacy plain path sends for this run shape. + structuredOff(); + const base = mockPi(); + makeDelegateTool(base.pi); + base.emit("agent_start"); + scheduleRunNotification(base.pi, mkRun("del_par", "completed")); + await sleep(30); + base.emit("turn_end"); + assert.equal(base.user.length, 1); + const plainText = base.user[0]!.t; + + // Structured path for the same run shape. + structuredOn(); + const { pi, user, custom, emit } = mockPi(); + makeDelegateTool(pi); + emit("agent_start"); + const run = mkRun("del_par", "completed"); + scheduleRunNotification(pi, run); + await sleep(30); + emit("turn_end"); + assert.equal(user.length, 0, "plain path unused when the host supports sendMessage"); + assert.equal(custom.length, 1, "typed message delivered once"); + const entry = custom[0]!; + assert.equal(entry.m.customType, CUSTOM_TYPE); + assert.equal(entry.m.display, true); + assert.deepEqual(entry.o, { deliverAs: "steer" }, "still steering delivery"); + assert.equal(entry.m.content, plainText, "model-visible text byte-identical to the legacy message"); + const d = entry.m.details as Details; + assert.equal(d.version, 1); + assert.equal(d.kind, "terminal"); + assert.equal(d.runs.length, 1); + assert.equal(d.runs[0]!.runId, "del_par"); + assert.equal(d.runs[0]!.status, "completed"); + assert.equal(run.injected, true, "marked delivered on successful send"); +}); + +// ─── ON + host lacks sendMessage: capability gate falls back to plain ───────── + +test("on + no sendMessage: capability gate falls back to the plain user message", async () => { + structuredOn(); + const { pi, user, custom, emit } = mockPi(false); + makeDelegateTool(pi); + emit("agent_start"); + const run = mkRun("del_fb", "completed"); + scheduleRunNotification(pi, run); + await sleep(30); + emit("turn_end"); + assert.equal(custom.length, 0, "nothing typed — host has no sendMessage"); + assert.equal(user.length, 1, "plain fallback delivered"); + assert.deepEqual(user[0]!.o, { deliverAs: "steer" }); + assert.ok(user[0]!.t.includes("`del_fb`")); + assert.equal(run.injected, true, "fallback still marks delivered"); +}); + +// ─── batch lists every run explicitly + provenance carried / cleanly absent ─── + +test("batch envelope lists every run; launch provenance carried when set, absent when not", async () => { + structuredOn(); + const { pi, custom, emit } = mockPi(); + makeDelegateTool(pi); + emit("agent_start"); + scheduleRunNotification(pi, mkRun("del_prov", "completed", { originToolCallId: "call_abc", sourceSessionId: "sess_1" })); + scheduleRunNotification(pi, mkRun("del_noprov", "failed")); + emit("turn_end"); + assert.equal(custom.length, 1, "close finishes merge into ONE message"); + const d = custom[0]!.m.details as Details; + assert.equal(d.kind, "terminal"); + assert.equal(d.runs.length, 2, "every run in the batch is listed explicitly"); + const byId: Record> = {}; + for (const r of d.runs) byId[String(r.runId)] = r; + assert.equal(byId["del_prov"]?.originToolCallId, "call_abc", "launching tool-call anchor preserved"); + assert.equal(byId["del_prov"]?.sourceSessionId, "sess_1", "source session preserved"); + assert.equal(byId["del_noprov"]?.originToolCallId, undefined, "absent provenance stays absent, never fabricated"); + assert.equal(byId["del_noprov"]?.sourceSessionId, undefined); +}); + +// ─── idle tier also routes through the structured envelope ───────────────────── + +test("idle wake: single finish delivers the typed envelope immediately", async () => { + structuredOn(); + const { pi, user, custom } = mockPi(); + makeDelegateTool(pi); + scheduleRunNotification(pi, mkRun("del_idle", "completed")); + await waitFor(() => custom.length === 1 || user.length === 1, "idle wake"); + assert.equal(user.length, 0); + assert.equal(custom.length, 1); + assert.equal(custom[0]!.m.customType, CUSTOM_TYPE); + assert.deepEqual(custom[0]!.o, { deliverAs: "steer" }); + assert.equal((custom[0]!.m.details as Details).kind, "terminal"); +}); + +// ─── injectResult (wait/cancel direct path) dispatches through the envelope ─── + +test("on: injectResult delivers the typed envelope and reports success", () => { + structuredOn(); + const { pi, user, custom } = mockPi(); + const ok = injectResult(pi, "reviewer", "del_inj", "review X", "completed", 0, "/tmp/del_inj.out"); + assert.equal(ok, true, "structured send reported as delivered"); + assert.equal(user.length, 0); + assert.equal(custom.length, 1); + assert.equal(custom[0]!.m.customType, CUSTOM_TYPE); + assert.deepEqual(custom[0]!.o, { deliverAs: "steer" }); + assert.ok(custom[0]!.m.content.includes("`del_inj`"), "same notification text"); +}); + +test("off: injectResult keeps the plain path", () => { + structuredOff(); + const { pi, user, custom } = mockPi(); + const ok = injectResult(pi, "reviewer", "del_inj2", "review X", "failed", 1, "/tmp/del_inj2.out"); + assert.equal(ok, true); + assert.equal(custom.length, 0); + assert.equal(user.length, 1); + assert.deepEqual(user[0]!.o, { deliverAs: "steer" }); +}); From 9d8cc746ef71fe9cd2989f4f874c6f8a91a4d5b6 Mon Sep 17 00:00:00 2001 From: ci-engineer Date: Fri, 6 Nov 2026 16:36:38 +0800 Subject: [PATCH 2/5] fix(#5790): sync root package-lock.json with pi-subagents 0.2.4 bump The PR bumped pi-subagents/package.json 0.2.3 -> 0.2.4 but did not regenerate the root package-lock.json, which still recorded the file: link at 0.2.3. The lock-sync drift guard (#2704/#3782) regenerates the root lock and fails on any residual diff, so the check went red. Regenerating updates exactly one line: the billion-context-pi-subagents version record 0.2.3 -> 0.2.4. Kernel lock verified clean (the PR does not touch kernel/). --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 206ead55ad..edb94471b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9283,7 +9283,7 @@ }, "pi-subagents": { "name": "billion-context-pi-subagents", - "version": "0.2.3", + "version": "0.2.4", "dev": true, "license": "MIT", "devDependencies": { From aa4d9336f49be9bdcb1f2132ca02d00387c7eede Mon Sep 17 00:00:00 2001 From: ci-check Date: Fri, 6 Nov 2026 16:55:54 +0800 Subject: [PATCH 3/5] fix(#5790): keep idle-host wake parity on the structured notification path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The opt-in structured envelope (customType billion-context:delegate-notification) switched delivery from sendUserMessage to sendMessage, but pi's sendCustomMessage only starts a turn on an IDLE host when triggerTurn is set — without it an idle-tier notice sat dormant until the next activity instead of waking the model like the plain path does (#5750 acceptance: wake-up behavior intact). Pass {deliverAs:"steer", triggerTurn:true}: streaming delivery is unchanged (steer either way), idle hosts gain parity with the legacy plain message. Also restore per-carrier identity in the unified send-failure logs: the refactor folded four carriers' error/warn events into notify-send-error/notify-no-channel without saying which tier failed — add a `carrier` field ("turn-end" | "idle-batch" | "inject-result" | "inline-usage"). Tests: pin triggerTurn on every structured dispatch; add coverage for the usage-note carrier (kind:"usage" envelope on, plain path off) which was missing. --- pi-subagents/src/delegate-tool.ts | 26 ++++++---- .../tests/delegate-structured-notify.test.ts | 48 +++++++++++++++++-- 2 files changed, 59 insertions(+), 15 deletions(-) diff --git a/pi-subagents/src/delegate-tool.ts b/pi-subagents/src/delegate-tool.ts index e21b990f16..ad28738c53 100644 --- a/pi-subagents/src/delegate-tool.ts +++ b/pi-subagents/src/delegate-tool.ts @@ -318,13 +318,19 @@ function buildNotificationDetails(kind: "terminal" | "usage", runsList: Delegate * safe boundary of the current task AND persisted in-session, #2320/#2546). * Structured (typed custom message, same text, details extension-only) when * notifyStructured is on AND the host exposes sendMessage; otherwise the plain - * user message. Returns true only when the host accepted it, so a failed/unavailable - * send leaves the run undelivered for later recovery. */ -function sendDelegateNotification(pi: ExtensionAPI, text: string, details: DelegateNotificationDetails): boolean { + * user message. triggerTurn keeps the structured path wake-equivalent to the + * plain one: pi's sendCustomMessage starts a turn on an IDLE host only when + * triggerTurn is set — without it an idle-tier notice would sit dormant until + * the next activity instead of waking the model like sendUserMessage does + * (#5750 acceptance: wake-up behavior intact). Streaming delivery is unaffected + * (steer either way). `carrier` names the calling site so a failed send stays + * debuggable per tier. Returns true only when the host accepted it, so a + * failed/unavailable send leaves the run undelivered for later recovery. */ +function sendDelegateNotification(pi: ExtensionAPI, text: string, details: DelegateNotificationDetails, carrier: string): boolean { const structured = delegatePolicy.notifyStructured && typeof pi.sendMessage === "function"; try { if (structured) { - pi.sendMessage.call(pi, { customType: DELEGATE_NOTIFY_CUSTOM_TYPE, content: text, display: true, details }, { deliverAs: "steer" }); + pi.sendMessage.call(pi, { customType: DELEGATE_NOTIFY_CUSTOM_TYPE, content: text, display: true, details }, { deliverAs: "steer", triggerTurn: true }); return true; } const send = pi.sendUserMessage; @@ -332,10 +338,10 @@ function sendDelegateNotification(pi: ExtensionAPI, text: string, details: Deleg send.call(pi, text, { deliverAs: "steer" }); return true; } - logWarn("delegate", { event: "notify-no-channel", reason: "no send channel available" }); + logWarn("delegate", { event: "notify-no-channel", carrier, reason: "no send channel available" }); return false; } catch (err) { - logError("delegate", { event: "notify-send-error", error: String(err), structured }); + logError("delegate", { event: "notify-send-error", carrier, error: String(err), structured }); return false; } } @@ -1061,7 +1067,7 @@ function commitAtTurnEnd(pi: ExtensionAPI, st: HostNotificationState): void { // Envelope lists every run in the message: primary + recovery-folded runs for a // single, the full batch otherwise. const represented = deliverable.length === 1 ? [deliverable[0]!, ...covered] : deliverable; - const sent = sendDelegateNotification(pi, text, buildNotificationDetails("terminal", represented)); + const sent = sendDelegateNotification(pi, text, buildNotificationDetails("terminal", represented), "turn-end"); if (sent) { for (const r of deliverable) r.injected = true; for (const c of covered) c.injected = true; @@ -1148,7 +1154,7 @@ function flushHostNotifications(pi: ExtensionAPI): void { // Envelope lists every run in the message: primary + recovery-folded runs for a // single, the full batch otherwise. const represented = deliverable.length === 1 ? [deliverable[0]!, ...covered] : deliverable; - const sent = sendDelegateNotification(pi, text, buildNotificationDetails("terminal", represented)); + const sent = sendDelegateNotification(pi, text, buildNotificationDetails("terminal", represented), "idle-batch"); if (sent) { for (const r of deliverable) r.injected = true; for (const c of covered) c.injected = true; @@ -2274,7 +2280,7 @@ export function injectResult( // Envelope lists the primary run plus any recovery runs folded into the text. const primary = runs.get(runId); const represented = primary ? [primary, ...covered] : covered; - const sent = sendDelegateNotification(pi, text, buildNotificationDetails("terminal", represented)); + const sent = sendDelegateNotification(pi, text, buildNotificationDetails("terminal", represented), "inject-result"); // Commit the recovery marking only on success: a failed send leaves the covered // runs undelivered so a later carrier can still recover them. if (sent) for (const r of covered) r.injected = true; @@ -2298,7 +2304,7 @@ export function emitInlineUsageNote(pi: ExtensionAPI, run: DelegateRun, mode: "m if (!block.trim()) return; const label = run.status === "failed" ? "FAILED ⚠️" : run.status === "cancelled" ? "cancelled" : "completed"; const text = `[acp_delegate ${label}] **${run.agent}** (runId \`${run.runId}\`) — usage summary\n${block.replace(/^\s+/, "")}\nThis is an automated system message, NOT a user request.`; - if (sendDelegateNotification(pi, text, buildNotificationDetails("usage", [run]))) { + if (sendDelegateNotification(pi, text, buildNotificationDetails("usage", [run]), "inline-usage")) { run.usageNoted = true; debug.event("delegate-inline-usage-note", { runId: run.runId, status: run.status }); } diff --git a/pi-subagents/tests/delegate-structured-notify.test.ts b/pi-subagents/tests/delegate-structured-notify.test.ts index a612b165fe..ddbb18661e 100644 --- a/pi-subagents/tests/delegate-structured-notify.test.ts +++ b/pi-subagents/tests/delegate-structured-notify.test.ts @@ -1,8 +1,11 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { + addDelegateUsage, + emitInlineUsageNote, injectResult, makeDelegateTool, + resetDelegateUsage, scheduleRunNotification, setDelegatePolicy, } from "../src/delegate-tool.js"; @@ -37,7 +40,7 @@ function mkRun(runId: string, status: "completed" | "failed", over: Record custom.push({ m, o }); + pi.sendMessage = (m: CustomEntry["m"], o?: { deliverAs?: string; triggerTurn?: boolean }) => custom.push({ m, o }); } const emit = (event: string) => { for (const h of handlers.get(event) ?? []) h({}); @@ -123,7 +126,7 @@ test("on + sendMessage present: typed envelope, content byte-identical to the le const entry = custom[0]!; assert.equal(entry.m.customType, CUSTOM_TYPE); assert.equal(entry.m.display, true); - assert.deepEqual(entry.o, { deliverAs: "steer" }, "still steering delivery"); + assert.deepEqual(entry.o, { deliverAs: "steer", triggerTurn: true }, "steering delivery + idle-host wake parity"); assert.equal(entry.m.content, plainText, "model-visible text byte-identical to the legacy message"); const d = entry.m.details as Details; assert.equal(d.version, 1); @@ -185,7 +188,7 @@ test("idle wake: single finish delivers the typed envelope immediately", async ( assert.equal(user.length, 0); assert.equal(custom.length, 1); assert.equal(custom[0]!.m.customType, CUSTOM_TYPE); - assert.deepEqual(custom[0]!.o, { deliverAs: "steer" }); + assert.deepEqual(custom[0]!.o, { deliverAs: "steer", triggerTurn: true }); assert.equal((custom[0]!.m.details as Details).kind, "terminal"); }); @@ -199,7 +202,7 @@ test("on: injectResult delivers the typed envelope and reports success", () => { assert.equal(user.length, 0); assert.equal(custom.length, 1); assert.equal(custom[0]!.m.customType, CUSTOM_TYPE); - assert.deepEqual(custom[0]!.o, { deliverAs: "steer" }); + assert.deepEqual(custom[0]!.o, { deliverAs: "steer", triggerTurn: true }); assert.ok(custom[0]!.m.content.includes("`del_inj`"), "same notification text"); }); @@ -212,3 +215,38 @@ test("off: injectResult keeps the plain path", () => { assert.equal(user.length, 1); assert.deepEqual(user[0]!.o, { deliverAs: "steer" }); }); + +// ─── usage summaries are kind:"usage" (dedup-friendly vs terminal notices) ──── + +const USAGE_FIXTURE = { input: 150, output: 80, cacheRead: 0, cacheWrite: 0, totalTokens: 230, cost: { input: 0.0015, output: 0.0008, cacheRead: 0, cacheWrite: 0, total: 0.0023 } }; + +test("on: inline usage note is a typed envelope with kind usage", () => { + structuredOn(); + resetDelegateUsage(); + addDelegateUsage(USAGE_FIXTURE); + const { pi, user, custom } = mockPi(); + const run = mkRun("del_usage", "completed"); + emitInlineUsageNote(pi, run, "separate"); + assert.equal(user.length, 0); + assert.equal(custom.length, 1, "typed usage note delivered once"); + const entry = custom[0]!; + assert.equal(entry.m.customType, CUSTOM_TYPE); + assert.ok(entry.m.content.includes("usage summary"), "same usage-summary text as before"); + assert.ok(entry.m.content.includes("`del_usage`"), "names the run"); + const d = entry.m.details as Details; + assert.equal(d.kind, "usage", "distinguishable from terminal notices (dedup-friendly)"); + assert.equal(d.runs.length, 1); + assert.equal(d.runs[0]!.runId, "del_usage"); + assert.equal(run.usageNoted, true, "marked noted only after successful send"); +}); + +test("off: inline usage note stays a plain user message", () => { + structuredOff(); + resetDelegateUsage(); + addDelegateUsage(USAGE_FIXTURE); + const { pi, user, custom } = mockPi(); + emitInlineUsageNote(pi, mkRun("del_usage_off", "completed"), "separate"); + assert.equal(custom.length, 0); + assert.equal(user.length, 1); + assert.deepEqual(user[0]!.o, { deliverAs: "steer" }); +}); From e51e764700591c2e82dcfd48c0aeb1feeca9fa7d Mon Sep 17 00:00:00 2001 From: ci-check Date: Fri, 6 Nov 2026 16:56:11 +0800 Subject: [PATCH 4/5] docs(#5790): declare pi.subagents.notifyStructured in the canonical config home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The structured-notify knob works de facto through the piSubagentsAdapter spread passthrough, but the host schema (PiSubagentsFileConfig) did not declare it and CONFIGURATION.md never mentioned it — and the PR description pointed users at the deprecated ~/.pi/acp.json delegate.* fallback, which is ignored whenever the canonical pi.subagents section exists. Declare the field in the host schema (precedent: notifyIfRead is declared in both schemas) and document it in both jsonc examples. No runtime change. --- docs/configuration.md | 1 + docs/configuration.zh-CN.md | 1 + src/config.ts | 7 +++++++ 3 files changed, 9 insertions(+) diff --git a/docs/configuration.md b/docs/configuration.md index 4055124b2f..a353fdb72c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -916,6 +916,7 @@ Top-level keys that control how the proxy listens and behaves globally. "reviewer": { "model": "anthropic/claude-sonnet-4-5", "thinkingLevel": "high" } }, "notifyIfRead": "skip", // "skip" — no completion nudge if the model already read the result file + "notifyStructured": false, // true — notices become typed custom messages carrying a versioned details envelope (runId/status + launch provenance) for third-party UIs; model-visible text unchanged (#5750) "fleetShortcut": "ctrl+alt+d", // "" disables the keyboard shortcut (/acp-fleet still works) "prompt": null, // replace (string) or remove (null) the ACP_DELEGATE NOTIFICATIONS appendix "debug": false // debug events in ~/.pi/acp.log, scoped to sub-agents diff --git a/docs/configuration.zh-CN.md b/docs/configuration.zh-CN.md index 7aa2403927..13caef25d8 100644 --- a/docs/configuration.zh-CN.md +++ b/docs/configuration.zh-CN.md @@ -910,6 +910,7 @@ "reviewer": { "model": "anthropic/claude-sonnet-4-5", "thinkingLevel": "high" } }, "notifyIfRead": "skip", // "skip" —— 模型已读结果文件则不再补发完成通知 + "notifyStructured": false, // true —— 通知改为带版本化 details 信封(runId/状态+发起溯源)的类型化消息,供第三方 UI 使用;模型可见文本不变(#5750) "fleetShortcut": "ctrl+alt+d", // "" 关闭快捷键(/acp-fleet 仍可用) "prompt": null, // 替换(字符串)或移除(null)ACP_DELEGATE NOTIFICATIONS 附录 "debug": false // ~/.pi/acp.log 调试事件,仅限子代理子系统 diff --git a/src/config.ts b/src/config.ts index bd3bc0b428..dd629d7507 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1959,6 +1959,13 @@ export interface PiSubagentsFileConfig { /** "skip" (default) — suppress the completion notification when the * model already read the result file; "always" — always inject. */ notifyIfRead?: "skip" | "always"; + /** Deliver acp_delegate notifications as a typed custom message carrying a + * versioned details envelope (run identity + launch provenance) alongside + * the SAME model-visible text, so third-party UIs tie a notice to its + * launching turn without parsing prose (#5750). Default false — plain + * user-message delivery, byte-identical to before; hosts without + * ExtensionAPI.sendMessage fall back to the plain path automatically. */ + notifyStructured?: boolean; /** TUI shortcut for the fleet inspector (default "ctrl+alt+d"); "" * disables keyboard registration (/acp-fleet still works). */ fleetShortcut?: string; From 31c7824cda1982c5a8c968ea0eca5783d394bfe4 Mon Sep 17 00:00:00 2001 From: ci-check Date: Fri, 6 Nov 2026 17:03:05 +0800 Subject: [PATCH 5/5] docs(#5790): document delegate.notifyStructured in pi-subagents READMEs The new opt-in key (#5750) was declared in CONFIGURATION.md/zh and both config types, but the standalone-facing pi-subagents READMEs (example block + key table, en/zh) never listed it. --- pi-subagents/README.md | 2 ++ pi-subagents/README.zh-CN.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/pi-subagents/README.md b/pi-subagents/README.md index 73ab2ab91f..f7251a3196 100644 --- a/pi-subagents/README.md +++ b/pi-subagents/README.md @@ -87,6 +87,7 @@ or object form: "asyncTimeoutMinutes": 30, "thinkingLevel": "medium", "notifyIfRead": "skip", + "notifyStructured": false, "displayUsage": "separate", "forceEnable": false, "fleetShortcut": "ctrl+alt+d", @@ -112,6 +113,7 @@ or object form: | `delegate.thinkingLevel` | string | unset | Global default thinking level (off\|minimal\|low\|medium\|high\|xhigh\|max). Priority: per-call > role > global > Pi default. | | `delegate.agents` | object | unset | Per-role defaults: `{ "": { "model": "provider/id", "thinkingLevel": "…" } }`. Invalid models fall back to the parent model with a warning (never fail). | | `delegate.notifyIfRead` | `"skip" \| "always"` | `"skip"` | Skip the completion notification when the model already read the result file after the run finished. | +| `delegate.notifyStructured` | boolean | `false` | Deliver notifications as typed custom messages carrying a versioned `details` envelope (runId/status/agent + launch provenance: originToolCallId/sourceSessionId), so third-party UIs tie each notice to its launching turn without parsing prose (#5750). Model-visible text and steer delivery are unchanged; hosts without `sendMessage` fall back to plain text automatically. | | `delegate.fleetShortcut` | string | `"ctrl+alt+d"` | TUI shortcut for the fleet inspector. `""` disables keyboard registration (`/acp-fleet` still works). Moved off `ctrl+alt+f`, which pi-subagents also claims (#412). | | `delegatePrompt` | string \| null | built-in | Replace (string) or remove (`null`) the `ACP_DELEGATE NOTIFICATIONS` system-prompt appendix. | | `debug` | boolean | `false` | Debug-level events in `~/.pi/acp.log` (shared with billion-context-pi). Env `ACP_DEBUG=1` too. | diff --git a/pi-subagents/README.zh-CN.md b/pi-subagents/README.zh-CN.md index 2499c53a4e..b344c42c48 100644 --- a/pi-subagents/README.zh-CN.md +++ b/pi-subagents/README.zh-CN.md @@ -90,6 +90,7 @@ pi install npm:billion-context-pi-subagents "asyncTimeoutMinutes": 30, "thinkingLevel": "medium", "notifyIfRead": "skip", + "notifyStructured": false, "displayUsage": "separate", "forceEnable": false, "fleetShortcut": "ctrl+alt+d", @@ -115,6 +116,7 @@ pi install npm:billion-context-pi-subagents | `delegate.thinkingLevel` | string | unset | 全局默认思考级别(off\|minimal\|low\|medium\|high\|xhigh\|max)。优先级:单次调用 > 角色 > 全局 > Pi 默认值。 | | `delegate.agents` | object | unset | 按角色设置默认值:`{ "": { "model": "provider/id", "thinkingLevel": "…" } }`。无效模型回退到父模型并给出警告(不会导致失败)。 | | `delegate.notifyIfRead` | `"skip" \| "always"` | `"skip"` | 若模型在运行结束后已读取结果文件,则跳过完成通知。 | +| `delegate.notifyStructured` | boolean | `false` | 将通知投递为带版本化 `details` 信封(runId/状态/agent + 发起溯源:originToolCallId/sourceSessionId)的类型化自定义消息,供第三方 UI 无需解析措辞即可把每条通知归属到发起轮次(#5750)。模型可见文本与 steer 投递不变;宿主无 `sendMessage` 时自动回退纯文本。 | | `delegate.fleetShortcut` | string | `"ctrl+alt+d"` | fleet 检查器的 TUI 快捷键。`""` 禁用键盘注册(`/acp-fleet` 仍然可用)。已从 `ctrl+alt+f` 移开,因为 pi-subagents 也占用了该组合键(#412)。 | | `delegatePrompt` | string \| null | built-in | 替换(字符串)或移除(`null`)系统提示词附录中的 `ACP_DELEGATE NOTIFICATIONS` 部分。 | | `debug` | boolean | `false` | 在 `~/.pi/acp.log` 中记录调试级别事件(与 billion-context-pi 共享)。也可通过环境变量 `ACP_DEBUG=1` 开启。 |