Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 583e899

Browse files
committed
Merge PR tintinweb#181: RPC agent activity
1 parent 1de6ca6 commit 583e899

2 files changed

Lines changed: 76 additions & 10 deletions

File tree

src/index.ts

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, normalizeMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
2626
import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getFallbackSubagent, isDefaultsDisabled, NO_FALLBACK, registerAgents, resolveSpawnType, resolveType, setDefaultsDisabled, setFallbackSubagent } from "./agent-types.js";
2727
import { inChildSessionContext } from "./child-context.js";
28-
import { type RpcHandle, registerRpcHandlers } from "./cross-extension-rpc.js";
28+
import { type RpcHandle, registerRpcHandlers, type SpawnCapable } from "./cross-extension-rpc.js";
2929
import { loadCustomAgents } from "./custom-agents.js";
3030
import { GroupJoinManager } from "./group-join.js";
3131
import { resolveAgentInvocationConfig, resolveJoinMode } from "./invocation-config.js";
@@ -565,6 +565,35 @@ export default function (pi: ExtensionAPI) {
565565
(globalThis as any)[MANAGER_KEY] = registryEntry;
566566
}
567567

568+
// Cross-extension callers such as pi-tasks bypass the Agent tool, so they do
569+
// not create its UI activity tracker. Wrap only that programmatic spawn path
570+
// with the same callbacks before AgentManager starts the child session.
571+
const rpcManager: SpawnCapable = {
572+
spawn(piRef, ctxRef, type, prompt, options) {
573+
const { state, callbacks } = createActivityTracker(options.maxTurns);
574+
// Wrap spawnTopLevel (not raw manager.spawn) so RPC-spawned agents keep
575+
// the #164 internal-option sanitization while gaining activity tracking.
576+
const id = spawnTopLevel(
577+
piRef,
578+
ctxRef,
579+
type,
580+
prompt,
581+
{ ...options, ...callbacks },
582+
);
583+
agentActivity.set(id, state);
584+
widget.ensureTimer();
585+
widget.update();
586+
fleet.ensureTimer();
587+
fleet.update();
588+
return id;
589+
},
590+
// Preserve the #164 guard: RPC must not abort nested (child) agents.
591+
abort: (id) => {
592+
const record = manager.getRecord(id);
593+
return !record?.parentAgentId && manager.abort(id);
594+
},
595+
};
596+
568597
// --- Cross-extension RPC via pi.events ---
569598
let currentCtx: ExtensionContext | undefined;
570599
// RPC handlers + the `subagents:ready` broadcast are wired on `session_start`
@@ -610,13 +639,7 @@ export default function (pi: ExtensionAPI) {
610639
events: pi.events,
611640
pi,
612641
getCtx: () => currentCtx,
613-
manager: {
614-
spawn: spawnTopLevel,
615-
abort: (id) => {
616-
const record = manager.getRecord(id);
617-
return !record?.parentAgentId && manager.abort(id);
618-
},
619-
},
642+
manager: rpcManager,
620643
});
621644
// Broadcast readiness so extensions loaded alongside us can discover us.
622645
// Emitting after all factories have run (rather than at factory time)

test/rpc-lifecycle-gating.test.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,16 @@ function makePi() {
5252
return { pi, tools, lifecycle, busHandlers };
5353
}
5454

55-
function ctx() {
55+
function ctx(setWidget = vi.fn()) {
5656
return {
5757
hasUI: false,
58-
ui: { setStatus: vi.fn(), setWidget: vi.fn(), notify: vi.fn() },
58+
ui: {
59+
setStatus: vi.fn(),
60+
setWidget,
61+
notify: vi.fn(),
62+
onTerminalInput: vi.fn(() => vi.fn()),
63+
getEditorText: vi.fn(() => ""),
64+
},
5965
cwd: process.cwd(),
6066
model: undefined,
6167
modelRegistry: { find: vi.fn(), getAvailable: vi.fn(() => []) },
@@ -146,6 +152,43 @@ describe("issue #142: RPC handlers + subagents:ready are gated on session_start"
146152
expect(reply![1].data.id).toBeTruthy();
147153
});
148154

155+
it("shows live tool activity for an RPC-spawned background agent", async () => {
156+
const { pi, lifecycle, busHandlers } = makePi();
157+
let widgetFactory: any;
158+
const setWidget = vi.fn((key: string, content: any) => {
159+
if (key === "agents" && content) widgetFactory = content;
160+
});
161+
const extensionCtx = ctx(setWidget);
162+
let onToolActivity: ((activity: { type: "start" | "end"; toolName: string }) => void) | undefined;
163+
vi.mocked(runAgent).mockImplementation((_ctx, _type, _prompt, options: any) => {
164+
onToolActivity = options.onToolActivity;
165+
options.onSessionCreated?.({ subscribe: () => vi.fn() });
166+
return new Promise(() => {}) as any;
167+
});
168+
subagentsExtension(pi);
169+
170+
await lifecycle.get("session_start")({}, extensionCtx);
171+
// TaskExecute runs inside a root tool call, so the extension already has
172+
// the UI context before pi-tasks sends its cross-extension spawn request.
173+
await lifecycle.get("tool_execution_start")({}, extensionCtx);
174+
await busHandlers.get("subagents:rpc:spawn")!({
175+
requestId: "req-activity",
176+
type: "general-purpose",
177+
prompt: "go",
178+
options: { description: "rpc activity test", isBackground: true },
179+
});
180+
await vi.waitFor(() => expect(onToolActivity).toBeTypeOf("function"));
181+
onToolActivity!({ type: "start", toolName: "bash" });
182+
183+
expect(widgetFactory).toBeTypeOf("function");
184+
const lines = widgetFactory(
185+
{ terminal: { columns: 120 }, requestRender: vi.fn() },
186+
{ fg: (_color: string, text: string) => text, bold: (text: string) => text },
187+
).render().join("\n");
188+
expect(lines).toContain("running command…");
189+
expect(lines).not.toContain("thinking…");
190+
});
191+
149192
it("is idempotent — a second session_start does not re-advertise or double-register", async () => {
150193
const { pi, lifecycle } = makePi();
151194
subagentsExtension(pi);

0 commit comments

Comments
 (0)