Skip to content

Commit 89b0e88

Browse files
committed
feat: add prompt-template delegation bridge and fork task preamble
Add event-bus bridge (prompt-template-bridge.ts) for delegated subagent execution from prompt templates, with progress streaming, cwd safety checks, and race-safe cancellation. Apply fork context preamble to all execution paths (single/parallel/chain, sync/async) so forked subagents stay anchored to their task. Deduplicate [fork] badge to result-only. Includes unit tests for bridge lifecycle and wrapForkTask idempotence.
1 parent 1962353 commit 89b0e88

File tree

8 files changed

+549
-13
lines changed

8 files changed

+549
-13
lines changed

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@
22

33
## [Unreleased]
44

5+
## [0.11.5] - 2026-03-20
6+
7+
### Added
8+
- Added fork context preamble: tasks run with `context: "fork"` are now wrapped with a default preamble that anchors the subagent to its task, preventing it from continuing the parent conversation. The default is `DEFAULT_FORK_PREAMBLE` in `types.ts`. Internal/programmatic callers can use `wrapForkTask(task, false)` to disable it or pass a custom string (this is not exposed as a tool parameter).
9+
- Added a prompt-template delegation bridge (`prompt-template-bridge.ts`) on the shared extension event bus. The subagent extension now listens for `prompt-template:subagent:request` and emits correlated `started`/`response`/`update` events, with cwd safety checks and race-safe cancellation handling.
10+
- Added delegated progress streaming via `prompt-template:subagent:update`, mapped from subagent executor `onUpdate` progress payloads.
11+
12+
### Changed
13+
- Session lifecycle reset now preserves the latest extension context for event-bus delegated runs.
14+
- `[fork]` badge is now shown only on the result row, not duplicated on both the tool-call and result rows.
15+
516
## [0.11.4] - 2026-03-19
617

718
### Added

index.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { createSubagentExecutor } from "./subagent-executor.js";
2727
import { createAsyncJobTracker } from "./async-job-tracker.js";
2828
import { createResultWatcher } from "./result-watcher.js";
2929
import { registerSlashCommands } from "./slash-commands.js";
30+
import { registerPromptTemplateDelegationBridge } from "./prompt-template-bridge.js";
3031
import {
3132
type Details,
3233
type ExtensionConfig,
@@ -138,6 +139,27 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
138139
discoverAgents,
139140
});
140141

142+
const promptTemplateBridge = registerPromptTemplateDelegationBridge({
143+
events: pi.events,
144+
getContext: () => state.lastUiContext,
145+
execute: async (requestId, request, signal, ctx, onUpdate) =>
146+
executor.execute(
147+
requestId,
148+
{
149+
agent: request.agent,
150+
task: request.task,
151+
context: request.context,
152+
cwd: request.cwd,
153+
model: request.model,
154+
async: false,
155+
clarify: false,
156+
},
157+
signal,
158+
onUpdate,
159+
ctx,
160+
),
161+
});
162+
141163
const tool: ToolDefinition<typeof SubagentParams, Details> = {
142164
name: "subagent",
143165
label: "Subagent",
@@ -184,21 +206,20 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
184206
}
185207
const isParallel = (args.tasks?.length ?? 0) > 0;
186208
const asyncLabel = args.async === true && !isParallel ? theme.fg("warning", " [async]") : "";
187-
const contextLabel = args.context === "fork" ? theme.fg("warning", " [fork]") : "";
188209
if (args.chain?.length)
189210
return new Text(
190-
`${theme.fg("toolTitle", theme.bold("subagent "))}chain (${args.chain.length})${asyncLabel}${contextLabel}`,
211+
`${theme.fg("toolTitle", theme.bold("subagent "))}chain (${args.chain.length})${asyncLabel}`,
191212
0,
192213
0,
193214
);
194215
if (isParallel)
195216
return new Text(
196-
`${theme.fg("toolTitle", theme.bold("subagent "))}parallel (${args.tasks!.length})${contextLabel}`,
217+
`${theme.fg("toolTitle", theme.bold("subagent "))}parallel (${args.tasks!.length})`,
197218
0,
198219
0,
199220
);
200221
return new Text(
201-
`${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", args.agent || "?")}${asyncLabel}${contextLabel}`,
222+
`${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", args.agent || "?")}${asyncLabel}`,
202223
0,
203224
0,
204225
);
@@ -332,6 +353,7 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
332353
const resetSessionState = (ctx: ExtensionContext) => {
333354
state.baseCwd = ctx.cwd;
334355
state.currentSessionId = ctx.sessionManager.getSessionFile() ?? `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
356+
state.lastUiContext = ctx;
335357
cleanupSessionArtifacts(ctx);
336358
resetJobs(ctx);
337359
};
@@ -354,6 +376,8 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
354376
}
355377
state.cleanupTimers.clear();
356378
state.asyncJobs.clear();
379+
promptTemplateBridge.cancelAll();
380+
promptTemplateBridge.dispose();
357381
if (state.lastUiContext?.hasUI) {
358382
state.lastUiContext.ui.setWidget(WIDGET_KEY, undefined);
359383
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "pi-subagents",
3-
"version": "0.11.4",
3+
"version": "0.11.5",
44
"description": "Pi extension for delegating tasks to subagents with chains, parallel execution, and TUI clarification",
55
"author": "Nico Bailon",
66
"license": "MIT",

prompt-template-bridge.test.ts

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import assert from "node:assert/strict";
2+
import { describe, it } from "node:test";
3+
4+
import {
5+
PROMPT_TEMPLATE_SUBAGENT_CANCEL_EVENT,
6+
PROMPT_TEMPLATE_SUBAGENT_REQUEST_EVENT,
7+
PROMPT_TEMPLATE_SUBAGENT_RESPONSE_EVENT,
8+
PROMPT_TEMPLATE_SUBAGENT_STARTED_EVENT,
9+
PROMPT_TEMPLATE_SUBAGENT_UPDATE_EVENT,
10+
registerPromptTemplateDelegationBridge,
11+
type PromptTemplateBridgeEvents,
12+
} from "./prompt-template-bridge.ts";
13+
14+
class FakeEvents implements PromptTemplateBridgeEvents {
15+
private handlers = new Map<string, Array<(data: unknown) => void>>();
16+
17+
on(event: string, handler: (data: unknown) => void): () => void {
18+
const list = this.handlers.get(event) ?? [];
19+
list.push(handler);
20+
this.handlers.set(event, list);
21+
return () => {
22+
const current = this.handlers.get(event) ?? [];
23+
this.handlers.set(event, current.filter((h) => h !== handler));
24+
};
25+
}
26+
27+
emit(event: string, data: unknown): void {
28+
const list = this.handlers.get(event) ?? [];
29+
for (const handler of [...list]) handler(data);
30+
}
31+
}
32+
33+
function once(events: FakeEvents, event: string): Promise<unknown> {
34+
return new Promise((resolve) => {
35+
const unsubscribe = events.on(event, (payload) => {
36+
unsubscribe();
37+
resolve(payload);
38+
});
39+
});
40+
}
41+
42+
describe("prompt-template delegation bridge", () => {
43+
it("emits started/update/response on successful request", async () => {
44+
const events = new FakeEvents();
45+
let executeCalls = 0;
46+
const bridge = registerPromptTemplateDelegationBridge({
47+
events,
48+
getContext: () => ({ cwd: "/repo" }),
49+
execute: async (_requestId, _request, _signal, _ctx, onUpdate) => {
50+
executeCalls++;
51+
onUpdate({
52+
details: {
53+
progress: [{ currentTool: "read", currentToolArgs: "index.ts", recentOutput: ["line 1"], toolCount: 1, durationMs: 10, tokens: 42 }],
54+
},
55+
});
56+
return {
57+
details: {
58+
results: [{ messages: [{ role: "assistant", content: [{ type: "text", text: "ok" }] }] }],
59+
},
60+
};
61+
},
62+
});
63+
64+
const startedPromise = once(events, PROMPT_TEMPLATE_SUBAGENT_STARTED_EVENT);
65+
const updatePromise = once(events, PROMPT_TEMPLATE_SUBAGENT_UPDATE_EVENT);
66+
const responsePromise = once(events, PROMPT_TEMPLATE_SUBAGENT_RESPONSE_EVENT);
67+
68+
events.emit(PROMPT_TEMPLATE_SUBAGENT_REQUEST_EVENT, {
69+
requestId: "r1",
70+
agent: "worker",
71+
task: "do work",
72+
context: "fresh",
73+
model: "openai/gpt-5",
74+
cwd: "/repo",
75+
});
76+
77+
const started = await startedPromise as { requestId: string };
78+
assert.equal(started.requestId, "r1");
79+
80+
const update = await updatePromise as { requestId: string; currentTool?: string; toolCount?: number };
81+
assert.equal(update.requestId, "r1");
82+
assert.equal(update.currentTool, "read");
83+
assert.equal(update.toolCount, 1);
84+
85+
const response = await responsePromise as { requestId: string; isError: boolean; messages: unknown[] };
86+
assert.equal(response.requestId, "r1");
87+
assert.equal(response.isError, false);
88+
assert.equal(Array.isArray(response.messages), true);
89+
assert.equal(executeCalls, 1);
90+
91+
bridge.dispose();
92+
});
93+
94+
it("returns structured error when no active context", async () => {
95+
const events = new FakeEvents();
96+
const bridge = registerPromptTemplateDelegationBridge({
97+
events,
98+
getContext: () => null,
99+
execute: async () => ({ details: { results: [{ messages: [] }] } }),
100+
});
101+
102+
const responsePromise = once(events, PROMPT_TEMPLATE_SUBAGENT_RESPONSE_EVENT);
103+
events.emit(PROMPT_TEMPLATE_SUBAGENT_REQUEST_EVENT, {
104+
requestId: "r2",
105+
agent: "worker",
106+
task: "do work",
107+
context: "fresh",
108+
model: "openai/gpt-5",
109+
cwd: "/repo",
110+
});
111+
112+
const response = await responsePromise as { isError: boolean; errorText?: string };
113+
assert.equal(response.isError, true);
114+
assert.match(response.errorText ?? "", /No active extension context/);
115+
116+
bridge.dispose();
117+
});
118+
119+
it("rejects cwd mismatch", async () => {
120+
const events = new FakeEvents();
121+
const bridge = registerPromptTemplateDelegationBridge({
122+
events,
123+
getContext: () => ({ cwd: "/actual" }),
124+
execute: async () => ({ details: { results: [{ messages: [] }] } }),
125+
});
126+
127+
const responsePromise = once(events, PROMPT_TEMPLATE_SUBAGENT_RESPONSE_EVENT);
128+
events.emit(PROMPT_TEMPLATE_SUBAGENT_REQUEST_EVENT, {
129+
requestId: "r3",
130+
agent: "worker",
131+
task: "do work",
132+
context: "fresh",
133+
model: "openai/gpt-5",
134+
cwd: "/repo",
135+
});
136+
137+
const response = await responsePromise as { isError: boolean; errorText?: string };
138+
assert.equal(response.isError, true);
139+
assert.match(response.errorText ?? "", /cwd mismatch/);
140+
141+
bridge.dispose();
142+
});
143+
144+
it("applies pending cancel when cancel arrives before request", async () => {
145+
const events = new FakeEvents();
146+
let executeCalls = 0;
147+
const bridge = registerPromptTemplateDelegationBridge({
148+
events,
149+
getContext: () => ({ cwd: "/repo" }),
150+
execute: async () => {
151+
executeCalls++;
152+
return { details: { results: [{ messages: [] }] } };
153+
},
154+
});
155+
156+
events.emit(PROMPT_TEMPLATE_SUBAGENT_CANCEL_EVENT, { requestId: "r4" });
157+
const responsePromise = once(events, PROMPT_TEMPLATE_SUBAGENT_RESPONSE_EVENT);
158+
159+
events.emit(PROMPT_TEMPLATE_SUBAGENT_REQUEST_EVENT, {
160+
requestId: "r4",
161+
agent: "worker",
162+
task: "do work",
163+
context: "fresh",
164+
model: "openai/gpt-5",
165+
cwd: "/repo",
166+
});
167+
168+
const response = await responsePromise as { isError: boolean; errorText?: string };
169+
assert.equal(response.isError, true);
170+
assert.equal(response.errorText, "Delegated prompt cancelled.");
171+
assert.equal(executeCalls, 0);
172+
173+
bridge.dispose();
174+
});
175+
176+
it("cancels in-flight delegated execution", async () => {
177+
const events = new FakeEvents();
178+
const bridge = registerPromptTemplateDelegationBridge({
179+
events,
180+
getContext: () => ({ cwd: "/repo" }),
181+
execute: async (_requestId, _request, signal) =>
182+
await new Promise((_resolve, reject) => {
183+
signal.addEventListener("abort", () => reject(new Error("aborted")), { once: true });
184+
}),
185+
});
186+
187+
const startedPromise = once(events, PROMPT_TEMPLATE_SUBAGENT_STARTED_EVENT);
188+
const responsePromise = once(events, PROMPT_TEMPLATE_SUBAGENT_RESPONSE_EVENT);
189+
190+
events.emit(PROMPT_TEMPLATE_SUBAGENT_REQUEST_EVENT, {
191+
requestId: "r5",
192+
agent: "worker",
193+
task: "do work",
194+
context: "fresh",
195+
model: "openai/gpt-5",
196+
cwd: "/repo",
197+
});
198+
199+
await startedPromise;
200+
events.emit(PROMPT_TEMPLATE_SUBAGENT_CANCEL_EVENT, { requestId: "r5" });
201+
202+
const response = await responsePromise as { isError: boolean; errorText?: string };
203+
assert.equal(response.isError, true);
204+
assert.match(response.errorText ?? "", /aborted/i);
205+
206+
bridge.dispose();
207+
});
208+
});

0 commit comments

Comments
 (0)