From c081e0350d15c10d0897610ce7a9e14727e7d408 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 26 Sep 2026 19:39:46 +0200 Subject: [PATCH] Support popular Pi extensions through per-extension adapters (#5309) * refactor(server): give Pi extension handling a registry * feat(server): map Pi task extensions to Paseo task lists * feat(server): map Pi subagent extensions to the Subagents track * refactor(server): hydrate Pi child sessions asynchronously through the extension host Child session JSONL reads blocked the daemon during live events and replay. The extension host now prepares immediate upserts and bounded async timeline hydration; replay awaits it, and closed sessions discard it. Preserve claimed notification text so completion details remain visible in the parent conversation. * feat(server): present Pi question extensions as one question form Keep the extension questionnaire in one Paseo permission while Pi walks its RPC dialogs. The host defers an early dialog until the form is answered, then replies to each dialog using the collected choices. * fix(server): satisfy Pi extension start hook contract The server build checks the session interface more strictly than the fast typecheck. Return the optional permission explicitly so the adapter satisfies the published extension contract. * docs: document Pi extension adapters * chore(server): format Pi subagent captures These real RPC fixtures were added before the formatter ran over the captured JSON. Normalize whitespace so repository format checks pass without changing the captured data. * test(server): resolve Pi child fixtures as local paths on Windows File URL pathnames retain a leading slash before a Windows drive letter. Convert fixture URLs to filesystem paths and escape those paths when inserting them into captured JSON, so child timeline assertions exercise the real file on every CI platform. * refactor(server): remove Oh My Pi handling from the Pi provider * fix(server): keep Pi extension adapter failures out of core session handling * fix(server): bound Pi child-session hydration during history replay --- docs/providers.md | 2 + .../server/agent/providers/pi/agent.test.ts | 154 +++-- .../src/server/agent/providers/pi/agent.ts | 282 +++----- .../providers/pi/extensions/child-session.ts | 59 ++ .../agent/providers/pi/extensions/contract.ts | 66 ++ .../fixtures/background.json | 248 +++++++ .../fixtures/child-session.jsonl | 4 + .../fixtures/foreground.json | 111 ++++ .../gotgenes-pi-subagents/index.test.ts | 86 +++ .../extensions/gotgenes-pi-subagents/index.ts | 157 +++++ .../agent/providers/pi/extensions/host.ts | 151 +++++ .../providers/pi/extensions/index.test.ts | 203 ++++++ .../agent/providers/pi/extensions/index.ts | 15 + .../pi/extensions/pi-ask-user/index.test.ts | 70 ++ .../pi/extensions/pi-ask-user/index.ts | 158 +++++ .../pi-example-todo/fixtures/rpc-session.json | 250 +++++++ .../extensions/pi-example-todo/index.test.ts | 16 + .../pi/extensions/pi-example-todo/index.ts | 41 ++ .../pi-goal-x/fixtures/rpc-session.json | 422 ++++++++++++ .../pi/extensions/pi-goal-x/index.test.ts | 15 + .../pi/extensions/pi-goal-x/index.ts | 73 +++ .../extensions/pi-mcp-adapter/index.test.ts | 49 ++ .../pi/extensions/pi-mcp-adapter/index.ts | 46 ++ .../pi-subagents/fixtures/background.json | 238 +++++++ .../pi-subagents/fixtures/child-session.jsonl | 6 + .../pi-subagents/fixtures/foreground.json | 619 ++++++++++++++++++ .../pi-subagents/fixtures/wait.json | 412 ++++++++++++ .../pi/extensions/pi-subagents/index.test.ts | 174 +++++ .../pi/extensions/pi-subagents/index.ts | 137 ++++ .../agent/providers/pi/extensions/registry.ts | 21 + .../fixtures/cancel-result.json | 23 + .../fixtures/rpc-session.json | 134 ++++ .../rpiv-ask-user-question/index.test.ts | 171 +++++ .../rpiv-ask-user-question/index.ts | 212 ++++++ .../rpiv-todo/fixtures/rpc-session.json | 380 +++++++++++ .../pi/extensions/rpiv-todo/index.test.ts | 19 + .../pi/extensions/rpiv-todo/index.ts | 55 ++ .../pi/extensions/subagent-fixture-test.ts | 91 +++ .../pi/extensions/task-fixture-test.ts | 105 +++ .../fixtures/background.json | 154 +++++ .../fixtures/child-session.jsonl | 4 + .../fixtures/foreground.json | 117 ++++ .../tintinweb-pi-subagents/index.test.ts | 47 ++ .../tintinweb-pi-subagents/index.ts | 147 +++++ .../agent/providers/pi/history-mapper.ts | 113 +++- .../server/agent/providers/pi/rpc-types.ts | 3 + .../providers/pi/tool-call-mapper.test.ts | 139 +--- .../agent/providers/pi/tool-call-mapper.ts | 121 +--- 48 files changed, 5773 insertions(+), 547 deletions(-) create mode 100644 packages/server/src/server/agent/providers/pi/extensions/child-session.ts create mode 100644 packages/server/src/server/agent/providers/pi/extensions/contract.ts create mode 100644 packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/fixtures/background.json create mode 100644 packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/fixtures/child-session.jsonl create mode 100644 packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/fixtures/foreground.json create mode 100644 packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.test.ts create mode 100644 packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.ts create mode 100644 packages/server/src/server/agent/providers/pi/extensions/host.ts create mode 100644 packages/server/src/server/agent/providers/pi/extensions/index.test.ts create mode 100644 packages/server/src/server/agent/providers/pi/extensions/index.ts create mode 100644 packages/server/src/server/agent/providers/pi/extensions/pi-ask-user/index.test.ts create mode 100644 packages/server/src/server/agent/providers/pi/extensions/pi-ask-user/index.ts create mode 100644 packages/server/src/server/agent/providers/pi/extensions/pi-example-todo/fixtures/rpc-session.json create mode 100644 packages/server/src/server/agent/providers/pi/extensions/pi-example-todo/index.test.ts create mode 100644 packages/server/src/server/agent/providers/pi/extensions/pi-example-todo/index.ts create mode 100644 packages/server/src/server/agent/providers/pi/extensions/pi-goal-x/fixtures/rpc-session.json create mode 100644 packages/server/src/server/agent/providers/pi/extensions/pi-goal-x/index.test.ts create mode 100644 packages/server/src/server/agent/providers/pi/extensions/pi-goal-x/index.ts create mode 100644 packages/server/src/server/agent/providers/pi/extensions/pi-mcp-adapter/index.test.ts create mode 100644 packages/server/src/server/agent/providers/pi/extensions/pi-mcp-adapter/index.ts create mode 100644 packages/server/src/server/agent/providers/pi/extensions/pi-subagents/fixtures/background.json create mode 100644 packages/server/src/server/agent/providers/pi/extensions/pi-subagents/fixtures/child-session.jsonl create mode 100644 packages/server/src/server/agent/providers/pi/extensions/pi-subagents/fixtures/foreground.json create mode 100644 packages/server/src/server/agent/providers/pi/extensions/pi-subagents/fixtures/wait.json create mode 100644 packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts create mode 100644 packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.ts create mode 100644 packages/server/src/server/agent/providers/pi/extensions/registry.ts create mode 100644 packages/server/src/server/agent/providers/pi/extensions/rpiv-ask-user-question/fixtures/cancel-result.json create mode 100644 packages/server/src/server/agent/providers/pi/extensions/rpiv-ask-user-question/fixtures/rpc-session.json create mode 100644 packages/server/src/server/agent/providers/pi/extensions/rpiv-ask-user-question/index.test.ts create mode 100644 packages/server/src/server/agent/providers/pi/extensions/rpiv-ask-user-question/index.ts create mode 100644 packages/server/src/server/agent/providers/pi/extensions/rpiv-todo/fixtures/rpc-session.json create mode 100644 packages/server/src/server/agent/providers/pi/extensions/rpiv-todo/index.test.ts create mode 100644 packages/server/src/server/agent/providers/pi/extensions/rpiv-todo/index.ts create mode 100644 packages/server/src/server/agent/providers/pi/extensions/subagent-fixture-test.ts create mode 100644 packages/server/src/server/agent/providers/pi/extensions/task-fixture-test.ts create mode 100644 packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/fixtures/background.json create mode 100644 packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/fixtures/child-session.jsonl create mode 100644 packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/fixtures/foreground.json create mode 100644 packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.test.ts create mode 100644 packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.ts diff --git a/docs/providers.md b/docs/providers.md index c7fedb71b9..88cbb3b7d6 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -79,6 +79,8 @@ A provider that can register runtime tools directly should set `supportsNativePa Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages. +Pi extension adapters live under `packages/server/src/server/agent/providers/pi/extensions//`. Each adapter turns Pi RPC facts into Paseo tool, timeline, subagent, or question mappings through the [extension contract](../packages/server/src/server/agent/providers/pi/extensions/contract.ts). To add one, create its directory, add one entry to `extensions/registry.ts`, and test it with fixtures captured from the real extension in Pi that record package, version, source commit, Pi version, and capture date. `agent.ts`, `history-mapper.ts`, and `tool-call-mapper.ts` never name an extension. + Paseo's per-agent and daemon-wide system prompts are appended by its generated Pi integration extension. Paseo deliberately does not pass `--append-system-prompt`, because that flag replaces Pi's automatic `APPEND_SYSTEM.md` discovery instead of composing with it. Pi model records expose input capabilities through `model.input`. Only send raw RPC `images` when the current model explicitly includes `"image"` in that list. Text-only Pi/OMP models reject image content and persist the rejected image in JSONL history, so image prompts for those models must be materialized to a local file and passed as a text path hint instead. diff --git a/packages/server/src/server/agent/providers/pi/agent.test.ts b/packages/server/src/server/agent/providers/pi/agent.test.ts index 917829908e..969b221f02 100644 --- a/packages/server/src/server/agent/providers/pi/agent.test.ts +++ b/packages/server/src/server/agent/providers/pi/agent.test.ts @@ -24,6 +24,8 @@ import { transformPiModels, } from "./agent.js"; import { FakePi } from "./test-utils/fake-pi.js"; +import { createPiExtensionHost } from "./extensions/index.js"; +import { PiExtensionHost } from "./extensions/host.js"; import type { PiModel, PiThinkingLevel } from "./rpc-types.js"; import type { PiUsagePollScheduler } from "./usage-poller.js"; @@ -417,6 +419,103 @@ class SessionEvents { } describe("PiRpcAgentSession", () => { + test("completes a turn and answers a dialog when an adapter throws", async () => { + const { pi, session, events } = await createSession(); + Object.assign(session, { + extensionHost: createPiExtensionHost(undefined, [ + { + id: "throwing-test-adapter", + createSession: () => ({ + mapToolCall: () => { + throw new Error("tool failed"); + }, + onToolStart: () => { + throw new Error("start failed"); + }, + onToolEnd: () => { + throw new Error("end failed"); + }, + mapDialog: () => { + throw new Error("dialog failed"); + }, + respondToPermission: () => { + throw new Error("response failed"); + }, + }), + }, + ]), + }); + const fakeSession = pi.latestSession(); + await session.startTurn("run"); + fakeSession.emit({ + type: "tool_execution_start", + toolCallId: "x", + toolName: "other", + args: {}, + }); + fakeSession.emit({ + type: "tool_execution_end", + toolCallId: "x", + toolName: "other", + result: { content: [{ type: "text", text: "ok" }] }, + isError: false, + }); + fakeSession.emit({ + type: "extension_ui_request", + id: "ui-1", + method: "select", + title: "Pick", + options: ["A", "B"], + }); + const permission = await events.nextPermissionRequest(); + expect(permission.request.kind).toBe("question"); + await session.respondToPermission("ui-1", { + behavior: "allow", + updatedInput: { answers: { Response: "B" } }, + }); + expect(fakeSession.extensionUiResponses).toEqual([{ id: "ui-1", response: { value: "B" } }]); + fakeSession.finishTurn(); + await events.nextTurnCompletion(); + expect(events.timelineItems()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "tool_call", + name: "other", + status: "completed", + detail: expect.objectContaining({ type: "unknown" }), + }), + ]), + ); + }); + + test("close resolves after rejected child hydration", async () => { + const { pi, session } = await createSession(); + Object.assign(session, { + extensionHost: new PiExtensionHost( + [ + { + id: "child-test", + createSession: () => ({ + mapToolCall: () => ({ childSessions: [{ id: "child-1", file: "unused" }] }), + }), + }, + ], + undefined, + 2 * 1024 * 1024, + async () => { + throw new Error("read failed"); + }, + ), + }); + await session.startTurn("run"); + pi.latestSession().emit({ + type: "tool_execution_start", + toolCallId: "x", + toolName: "other", + args: {}, + }); + await expect(session.close()).resolves.toBeUndefined(); + }); test("bridges Pi RPC select extension UI requests through question permissions", async () => { const { pi, session, events } = await createSession(); const fakeSession = pi.latestSession(); @@ -760,61 +859,6 @@ describe("PiRpcAgentSession", () => { ]); }); - test("streams Pi task calls as sub-agent cards with lifecycle status", async () => { - const { pi, session, events } = await createSession(); - const fakeSession = pi.latestSession(); - - await session.startTurn("delegate this"); - fakeSession.emit({ - type: "tool_execution_start", - toolCallId: "task-1", - toolName: "task", - args: { - agent: "explore", - task: "Trace the Pi provider tool mapper", - }, - }); - fakeSession.emit({ - type: "tool_execution_end", - toolCallId: "task-1", - toolName: "task", - result: { content: [{ type: "text", text: "Found the mapper." }] }, - isError: false, - }); - fakeSession.finishTurn(); - - await events.nextTurnCompletion(); - - expect(events.timelineItems()).toEqual([ - { - type: "tool_call", - callId: "task-1", - name: "task", - status: "running", - detail: { - type: "sub_agent", - subAgentType: "explore", - description: "Trace the Pi provider tool mapper", - log: "", - }, - error: null, - }, - { - type: "tool_call", - callId: "task-1", - name: "task", - status: "completed", - detail: { - type: "sub_agent", - subAgentType: "explore", - description: "Trace the Pi provider tool mapper", - log: "Found the mapper.", - }, - error: null, - }, - ]); - }); - test("keeps one generated message id when Pi omits message start and response id", async () => { const { pi, session, events } = await createSession(); const fakeSession = pi.latestSession(); diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index e774f65f26..5fd7b051ef 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -39,7 +39,6 @@ import { type ListImportableSessionsOptions, type ProviderCatalog, type ProviderRefreshContext, - type ToolCallDetail, } from "../../agent-sdk-types.js"; import { importSessionFromPersistence } from "../../provider-session-import.js"; import { runProviderRefreshActivity } from "../../provider-refresh-deadline.js"; @@ -66,6 +65,7 @@ import { } from "./history-mapper.js"; import { materializeProviderImage } from "../provider-image-output.js"; import { PiCliRuntime } from "./cli-runtime.js"; +import { createPiExtensionHost, type PiExtensionEventOutput } from "./extensions/index.js"; import { revertPiConversation } from "./rewind.js"; import { listPiImportableSessions, readPiImportSessionConfig } from "./session-descriptor.js"; import type { PiRuntime, PiRuntimeSession, PiStartSessionInput } from "./runtime.js"; @@ -84,7 +84,6 @@ import { mapToolDetail, parseToolArgs, parseToolResult, - resolveToolCallName, type PiToolResult, type PiTrackedToolCall, } from "./tool-call-mapper.js"; @@ -101,9 +100,6 @@ const PASEO_PI_COMMAND_RESULT_MARKER = "PASEO_COMMAND_RESULT"; const DEFAULT_PI_EXTENSION_RESULT_TIMEOUT_MS = 30_000; const DEFAULT_PI_RPC_TIMEOUT_MS = 60_000; const QUESTION_RESPONSE_HEADER = "Response"; -const QUESTION_COMMENT_HEADER = "Comment"; -const PI_ASK_USER_FREEFORM_SENTINEL = "✏️ Type custom response..."; -const COMBINED_ASK_USER_METADATA = "ask_user_select_optional_comment"; export const PiProviderParamsSchema = z .object({ @@ -286,22 +282,9 @@ interface PendingExtensionResult { timer: NodeJS.Timeout; } -interface ActiveAskUserDialog { - allowComment: boolean; - allowFreeform: boolean; - allowMultiple: boolean; -} - -interface PendingCombinedAskUserResponse { - comment: string; - freeform: string | null; -} - interface ExtensionUiMappingOptions { provider?: AgentProvider; label?: string; - combineOptionalComment?: boolean; - allowFreeform?: boolean; } interface PiSlashCommandInvocation { @@ -888,21 +871,6 @@ function parseCapturedEntries(value: unknown): PiCapturedEntry[] { }); } -function optionalBoolean(value: unknown): boolean | undefined { - return typeof value === "boolean" ? value : undefined; -} - -function readActiveAskUserDialog(toolName: string, args: unknown): ActiveAskUserDialog | null { - if (toolName !== "ask_user" || !isRecord(args)) { - return null; - } - return { - allowComment: optionalBoolean(args.allowComment) ?? false, - allowFreeform: optionalBoolean(args.allowFreeform) ?? true, - allowMultiple: optionalBoolean(args.allowMultiple) ?? false, - }; -} - function isOptionalInputPlaceholder(placeholder: string | undefined): boolean { return /\boptional\b|\bskip\b/i.test(placeholder ?? ""); } @@ -923,10 +891,6 @@ function readStringArray(value: unknown): string[] { : []; } -function isPiAskUserFreeformOption(option: string): boolean { - return option === PI_ASK_USER_FREEFORM_SENTINEL; -} - function mapExtensionUiRequestToPermission( event: Extract, options: ExtensionUiMappingOptions = {}, @@ -936,15 +900,6 @@ function mapExtensionUiRequestToPermission( switch (event.method) { case "select": { const selectOptions = readStringArray(event.options); - if (options.combineOptionalComment) { - return buildCombinedAskUserQuestionPermission(event, { - provider, - label, - question: optionalString(event.title) ?? "Select an option", - options: selectOptions, - allowFreeform: options.allowFreeform === true, - }); - } return buildExtensionUiQuestionPermission(event, { provider, label, @@ -1062,63 +1017,6 @@ function buildExtensionUiQuestionPermission( }; } -function buildCombinedAskUserQuestionPermission( - event: Extract, - input: { - provider: AgentProvider; - label: string; - question: string; - options: string[]; - allowFreeform: boolean; - }, -): AgentPermissionRequest { - const visibleOptions = input.options.filter((option) => !isPiAskUserFreeformOption(option)); - const allowOther = input.allowFreeform || visibleOptions.length !== input.options.length; - return { - id: event.id, - provider: input.provider, - name: `${input.label} ask_user`, - kind: "question", - title: input.question, - input: { - questions: [ - { - question: input.question, - header: QUESTION_RESPONSE_HEADER, - options: visibleOptions.map((label) => ({ label })), - multiSelect: false, - ...(allowOther ? { allowOther: true } : {}), - }, - { - question: "Optional comment", - header: QUESTION_COMMENT_HEADER, - options: [], - multiSelect: false, - placeholder: "Optional comment (press Enter to skip)...", - allowEmpty: true, - }, - ], - }, - metadata: { - extensionUiMethod: event.method, - answerHeader: QUESTION_RESPONSE_HEADER, - commentHeader: QUESTION_COMMENT_HEADER, - combinedAskUser: COMBINED_ASK_USER_METADATA, - selectOptions: visibleOptions, - ...(allowOther ? { freeformSentinel: PI_ASK_USER_FREEFORM_SENTINEL } : {}), - }, - }; -} - -function permissionAnswer(input: AgentMetadata | undefined, header: string): string | null { - const answers = isRecord(input?.answers) ? input.answers : null; - if (!answers) { - return null; - } - const answer = answers[header]; - return typeof answer === "string" ? answer : null; -} - function firstPermissionAnswer(input: AgentMetadata | undefined): string | null { const answers = isRecord(input?.answers) ? input.answers : null; if (!answers) { @@ -1128,39 +1026,6 @@ function firstPermissionAnswer(input: AgentMetadata | undefined): string | null return typeof first === "string" ? first : null; } -function isCombinedAskUserPermission(request: AgentPermissionRequest): boolean { - return request.metadata?.combinedAskUser === COMBINED_ASK_USER_METADATA; -} - -function buildCombinedAskUserSelectionResponse( - request: AgentPermissionRequest, - response: AgentPermissionResponse, -): { - uiResponse: { value?: string; cancelled?: boolean }; - pendingResponse: PendingCombinedAskUserResponse | null; -} { - if (response.behavior === "deny") { - return { uiResponse: { cancelled: true }, pendingResponse: null }; - } - - const answer = permissionAnswer(response.updatedInput, QUESTION_RESPONSE_HEADER); - if (answer === null) { - return { uiResponse: { cancelled: true }, pendingResponse: null }; - } - - const selectOptions = readStringArray(request.metadata?.selectOptions); - const freeformSentinel = optionalString(request.metadata?.freeformSentinel); - const isFreeform = Boolean(freeformSentinel) && !selectOptions.includes(answer); - const comment = permissionAnswer(response.updatedInput, QUESTION_COMMENT_HEADER) ?? ""; - return { - uiResponse: { value: isFreeform ? freeformSentinel : answer }, - pendingResponse: { - comment, - freeform: isFreeform ? answer : null, - }, - }; -} - function buildExtensionUiResponse( request: AgentPermissionRequest, response: AgentPermissionResponse, @@ -1260,8 +1125,7 @@ export class PiRpcAgentSession implements AgentSession { private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private readonly activeToolCalls = new Map(); private readonly pendingExtensionUiRequests = new Map(); - private activeAskUserDialog: ActiveAskUserDialog | null = null; - private pendingCombinedAskUserResponse: PendingCombinedAskUserResponse | null = null; + private readonly extensionHost: ReturnType; private activeTurnId: string | null = null; private activeClientMessageId: string | null = null; private activeAssistantMessageId: string | null = null; @@ -1286,6 +1150,8 @@ export class PiRpcAgentSession implements AgentSession { private readonly logger: Logger; private readonly usagePoller: PiUsagePoller; private closed = false; + private readonly closeController = new AbortController(); + private readonly pendingExtensionHydrations = new Set>(); // Pi publishes the terminal before acknowledging abort. Autonomous runs have no // turn ID; retain their errors too until the cancellation request settles. private interruptingTurn: { turnId: string | undefined; error: string | null } | null = null; @@ -1301,6 +1167,7 @@ export class PiRpcAgentSession implements AgentSession { this.cleanup = options.cleanup; this.extensionTimeoutMs = options.extensionTimeoutMs ?? DEFAULT_PI_EXTENSION_RESULT_TIMEOUT_MS; this.logger = options.logger; + this.extensionHost = createPiExtensionHost(this.logger); this.usagePoller = new PiUsagePoller({ scheduler: options.usagePollScheduler, readStats: () => this.runtimeSession.getSessionStats(), @@ -1485,6 +1352,10 @@ export class PiRpcAgentSession implements AgentSession { this.provider, await this.runtimeSession.getMessages(), this.contextUserEntries, + {}, + // At most eight 2 MiB child files per replay; later cards retain their summaries. + createPiExtensionHost(this.logger, undefined, 16 * 1024 * 1024), + this.closeController.signal, ); } @@ -1522,15 +1393,11 @@ export class PiRpcAgentSession implements AgentSession { } this.pendingExtensionUiRequests.delete(requestId); - if (isCombinedAskUserPermission(request)) { - const combined = buildCombinedAskUserSelectionResponse(request, response); - this.pendingCombinedAskUserResponse = combined.pendingResponse; - this.runtimeSession.respondToExtensionUiRequest(requestId, combined.uiResponse); - } else { - this.runtimeSession.respondToExtensionUiRequest( - requestId, - buildExtensionUiResponse(request, response), - ); + const mapped = this.extensionHost.respondToPermission(request, response); + for (const reply of mapped?.responses ?? [ + { id: requestId, response: buildExtensionUiResponse(request, response) }, + ]) { + this.runtimeSession.respondToExtensionUiRequest(reply.id, reply.response); } this.emit({ type: "permission_resolved", @@ -1655,10 +1522,12 @@ export class PiRpcAgentSession implements AgentSession { return; } this.closed = true; + this.closeController.abort(); this.usagePoller.close(); try { await this.runtimeSession.close(); } finally { + await Promise.all(this.pendingExtensionHydrations); this.rejectAllExtensionResults(new Error("Pi session closed")); this.cleanup?.(); } @@ -1727,6 +1596,24 @@ export class PiRpcAgentSession implements AgentSession { } } + private emitExtensionOutput(output: PiExtensionEventOutput | undefined, turnId?: string): void { + if (!output || this.closed) return; + for (const event of output.events) { + this.emit(event.type === "timeline" ? { ...event, turnId } : event); + } + const pending = output.hydration + .then((events) => { + if (this.closeController.signal.aborted) return; + for (const event of events) this.emit(event); + return undefined; + }) + .catch((error) => { + this.logger.warn({ err: error }, "Pi extension hydration failed"); + }); + this.pendingExtensionHydrations.add(pending); + void pending.then(() => this.pendingExtensionHydrations.delete(pending)); + } + private currentTurnIdForEvent(): string | undefined { return this.activeTurnId ?? undefined; } @@ -2069,20 +1956,16 @@ export class PiRpcAgentSession implements AgentSession { return; } - if (this.respondToCombinedAskUserFollowUp(event)) { + const mapped = this.extensionHost.mapDialog(event, this.provider); + if (mapped?.type === "response") { + this.runtimeSession.respondToExtensionUiRequest(event.id, mapped.response); return; } - - const shouldCombineOptionalComment = - event.method === "select" && - this.activeAskUserDialog?.allowComment === true && - this.activeAskUserDialog.allowMultiple === false; - const request = mapExtensionUiRequestToPermission(event, { - provider: this.provider, - label: "Pi", - combineOptionalComment: shouldCombineOptionalComment, - allowFreeform: this.activeAskUserDialog?.allowFreeform, - }); + if (mapped?.type === "deferred") return; + const request = + mapped?.type === "permission" + ? mapped.request + : mapExtensionUiRequestToPermission(event, { provider: this.provider, label: "Pi" }); if (!request) { return; } @@ -2096,33 +1979,6 @@ export class PiRpcAgentSession implements AgentSession { }); } - private respondToCombinedAskUserFollowUp( - event: Extract, - ): boolean { - const pending = this.pendingCombinedAskUserResponse; - if (!pending || event.method !== "input") { - return false; - } - - const placeholder = optionalString(event.placeholder); - if (pending.freeform !== null && !isOptionalInputPlaceholder(placeholder)) { - this.pendingCombinedAskUserResponse = { - ...pending, - freeform: null, - }; - this.runtimeSession.respondToExtensionUiRequest(event.id, { value: pending.freeform }); - return true; - } - - if (isOptionalInputPlaceholder(placeholder)) { - this.pendingCombinedAskUserResponse = null; - this.runtimeSession.respondToExtensionUiRequest(event.id, { value: pending.comment }); - return true; - } - - return false; - } - private handleCommandOutput(textValue: unknown): void { if (!this.activeTurnId) { return; @@ -2246,7 +2102,25 @@ export class PiRpcAgentSession implements AgentSession { case "tool_execution_start": { const toolCall = parseToolArgs(event.toolName, event.args); this.activeToolCalls.set(event.toolCallId, toolCall); - this.activeAskUserDialog = readActiveAskUserDialog(event.toolName, event.args); + const request = this.extensionHost.onToolStart( + { + callId: event.toolCallId, + toolName: event.toolName, + args: toolCall.args, + status: "running", + result: null, + }, + this.provider, + ); + if (request) { + this.pendingExtensionUiRequests.set(request.id, request); + this.emit({ + type: "permission_requested", + provider: this.provider, + request, + turnId: this.currentTurnIdForEvent(), + }); + } this.emitToolCallEvent(event.toolCallId, toolCall, "running", null, null); return; } @@ -2330,14 +2204,16 @@ export class PiRpcAgentSession implements AgentSession { this.activeToolCalls.get(event.toolCallId) ?? parseToolArgs(event.toolName, null); this.activeToolCalls.delete(event.toolCallId); - if (event.toolName === "ask_user") { - this.activeAskUserDialog = null; - this.pendingCombinedAskUserResponse = null; - } - const result = parseToolResult(event.result); const error = event.isError ? event.result : null; const status = event.isError ? "failed" : "completed"; + this.extensionHost.onToolEnd({ + callId: event.toolCallId, + toolName: toolCall.toolName, + args: toolCall.args, + status, + result, + }); this.emitToolCallEvent(event.toolCallId, toolCall, status, result, error); } @@ -2422,6 +2298,8 @@ export class PiRpcAgentSession implements AgentSession { return; } if (event.message.role === "custom") { + const customMapping = this.extensionHost.mapCustomMessage(event.message); + this.emitExtensionOutput(customMapping, turnId); const text = getUserMessageText(event.message.content); if (text) { this.emit({ @@ -2446,14 +2324,21 @@ export class PiRpcAgentSession implements AgentSession { error: unknown, ): boolean { const turnId = this.currentTurnIdForEvent(); - const detail = this.mapToolDetail(toolCallId, toolCall, result); + const mapping = this.extensionHost.mapToolCall({ + callId: toolCallId, + toolName: toolCall.toolName, + args: toolCall.args, + status, + result, + }); + const detail = mapping?.detail ?? mapToolDetail(toolCall, result); if (!detail) { return false; } const baseItem = { type: "tool_call" as const, callId: toolCallId, - name: resolveToolCallName(toolCall, result), + name: mapping?.name ?? toolCall.toolName, detail, }; const item = @@ -2464,17 +2349,10 @@ export class PiRpcAgentSession implements AgentSession { turnId, item, }); + this.emitExtensionOutput(mapping, turnId); return true; } - private mapToolDetail( - _toolCallId: string, - toolCall: PiTrackedToolCall, - result: PiToolResult, - ): ToolCallDetail | null { - return mapToolDetail(toolCall, result); - } - private completeTurn(turnId: string | undefined, messages: PiAgentMessage[]): void { const errorMessage = latestPiErrorMessage(messages); if ( diff --git a/packages/server/src/server/agent/providers/pi/extensions/child-session.ts b/packages/server/src/server/agent/providers/pi/extensions/child-session.ts new file mode 100644 index 0000000000..d2dfa0e99b --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/child-session.ts @@ -0,0 +1,59 @@ +import { open } from "node:fs/promises"; +import { limitAgentTimelineItemContent } from "../../../agent-timeline-content.js"; +import type { ProviderSubagentInputEvent } from "../../../provider-subagents/store.js"; +import { PiHistoryMapper } from "../history-mapper.js"; +import type { PiAgentMessage } from "../rpc-types.js"; + +const MAX_BYTES = 2 * 1024 * 1024; +const MAX_ITEMS = 200; + +/** Child session files are immutable once the completed result exposes their path. */ +export async function mapPiChildSession( + id: string, + file: string, + maxBytes = MAX_BYTES, +): Promise { + try { + const handle = await open(file, "r"); + try { + const fileSize = (await handle.stat()).size; + const size = Math.min(fileSize, MAX_BYTES, maxBytes); + if (!size) return []; + const buffer = Buffer.alloc(size); + const { bytesRead } = await handle.read(buffer, 0, size, 0); + const text = buffer.toString("utf8", 0, bytesRead); + const completeText = bytesRead < fileSize ? text.slice(0, text.lastIndexOf("\n")) : text; + return parseChildTimeline(id, completeText); + } finally { + await handle.close(); + } + } catch { + return []; + } +} + +function parseChildTimeline(id: string, text: string): ProviderSubagentInputEvent[] { + const mapper = new PiHistoryMapper("pi"); + const events: ProviderSubagentInputEvent[] = []; + for (const line of text.split("\n")) { + if (!line || events.length >= MAX_ITEMS) break; + let entry: { message?: PiAgentMessage; timestamp?: string }; + try { + entry = JSON.parse(line); + } catch { + continue; + } + if (!entry.message || typeof entry.message !== "object" || !("role" in entry.message)) continue; + for (const mapped of mapper.mapMessages([entry.message])) { + if (mapped.type !== "timeline") continue; + events.push({ + type: "timeline", + id, + item: limitAgentTimelineItemContent(mapped.item), + ...(entry.timestamp ? { timestamp: entry.timestamp } : {}), + }); + if (events.length >= MAX_ITEMS) break; + } + } + return events; +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/contract.ts b/packages/server/src/server/agent/providers/pi/extensions/contract.ts new file mode 100644 index 0000000000..7f9330eaeb --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/contract.ts @@ -0,0 +1,66 @@ +import type { + AgentPermissionRequest, + AgentPermissionResponse, + ToolCallDetail, + AgentTimelineItem, +} from "../../../agent-sdk-types.js"; +import type { PiRuntimeEvent } from "../rpc-types.js"; +import type { PiToolResult } from "../tool-call-mapper.js"; +import type { ProviderSubagentInputEvent } from "../../../provider-subagents/store.js"; +import type { PiAgentMessage } from "../rpc-types.js"; + +export interface PiExtensionToolCall { + callId: string; + toolName: string; + args: unknown; + status: "running" | "completed" | "failed"; + result: PiToolResult; +} + +export interface PiExtensionToolMapping { + name?: string; + detail?: ToolCallDetail; + timeline?: AgentTimelineItem[]; + subagents?: ProviderSubagentInputEvent[]; + /** Completed Pi child sessions to hydrate through the normal Pi history mapper. */ + childSessions?: Array<{ id: string; file: string }>; +} + +export interface PiExtensionCustomMapping { + subagents: ProviderSubagentInputEvent[]; + childSessions?: Array<{ id: string; file: string }>; +} + +export type PiExtensionDialog = Extract; +export interface PiExtensionUiResponse { + value?: string; + cancelled?: boolean; + confirmed?: boolean; +} +export type PiExtensionDialogMapping = + | { type: "permission"; request: AgentPermissionRequest } + | { type: "response"; response: PiExtensionUiResponse } + | { type: "deferred" }; + +export interface PiExtensionUiReply { + responses: Array<{ id: string; response: PiExtensionUiResponse }>; +} + +export interface PiExtensionSession { + mapToolCall?(call: PiExtensionToolCall): PiExtensionToolMapping | undefined; + mapCustomMessage?( + message: Extract, + ): PiExtensionCustomMapping | undefined; + onToolStart?(call: PiExtensionToolCall, provider: string): AgentPermissionRequest | undefined; + onToolEnd?(call: PiExtensionToolCall): void; + mapDialog?(dialog: PiExtensionDialog, provider: string): PiExtensionDialogMapping | undefined; + respondToPermission?( + request: AgentPermissionRequest, + response: AgentPermissionResponse, + ): PiExtensionUiReply | undefined; +} + +export interface PiExtension { + id: string; + createSession(): PiExtensionSession; +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/fixtures/background.json b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/fixtures/background.json new file mode 100644 index 0000000000..3da491c8ca --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/fixtures/background.json @@ -0,0 +1,248 @@ +{ + "provenance": { + "package": "@gotgenes/pi-subagents", + "version": "21.7.6", + "sourceCommit": "1ac6bc6a36e6da5d00f1cd2482f1b971f5ea1fdf", + "piVersion": "0.84.4", + "captureDate": "2026-09-23" + }, + "events": [ + { + "type": "tool_execution_start", + "toolCallId": "call_403820", + "toolName": "subagent", + "args": { + "description": "list files", + "subagent_type": "general-purpose", + "prompt": "list files in this empty workspace", + "run_in_background": true + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_403820", + "toolName": "subagent", + "result": { + "content": [ + { + "type": "text", + "text": "Agent started in background.\nAgent ID: b913f6d2-2b1e-4be\nType: Agent\nDescription: list files\n\nYou will be notified when this agent completes.\nUse get_subagent_result to retrieve full results, or steer_subagent to send it messages.\nDo not duplicate this agent's work." + } + ], + "details": { + "displayName": "Agent", + "description": "list files", + "subagentType": "general-purpose", + "tags": ["twin", "background"], + "toolUses": 0, + "tokens": "", + "durationMs": 0, + "status": "background", + "agentId": "b913f6d2-2b1e-4be" + } + }, + "isError": false + }, + { + "type": "message_end", + "message": { + "role": "custom", + "customType": "subagent-notification", + "content": "\nb913f6d2-2b1e-4be\ncall_403820\n/tmp/paseo-pi-extension-phase3/sessions-gotgenes/2026-09-23T21-08-13-733Z_01a0d018-fd25-783b-b9e9-27f4bd6c8851/tasks/2026-09-23T21-08-17-217Z_01a0d019-0ac1-72e0-a277-0fea25d9da6c.jsonl\nDone\nSubagent \"list files\" completed\nThe workspace `/tmp/paseo-pi-extension-phase3/workspace` is currently empty.\n8843113714\n\nFull transcript available at: /tmp/paseo-pi-extension-phase3/sessions-gotgenes/2026-09-23T21-08-13-733Z_01a0d018-fd25-783b-b9e9-27f4bd6c8851/tasks/2026-09-23T21-08-17-217Z_01a0d019-0ac1-72e0-a277-0fea25d9da6c.jsonl\nCall get_subagent_result(\"b913f6d2-2b1e-4be\") to collect the full result.", + "display": true, + "details": { + "id": "b913f6d2-2b1e-4be", + "description": "list files", + "status": "completed", + "toolUses": 1, + "turnCount": 3, + "totalTokens": 8843, + "durationMs": 3714, + "outputFile": "/tmp/paseo-pi-extension-phase3/sessions-gotgenes/2026-09-23T21-08-13-733Z_01a0d018-fd25-783b-b9e9-27f4bd6c8851/tasks/2026-09-23T21-08-17-217Z_01a0d019-0ac1-72e0-a277-0fea25d9da6c.jsonl", + "resultPreview": "The workspace `/tmp/paseo-pi-extension-phase3/workspace` is currently empty." + }, + "timestamp": 1790197700057 + } + }, + { + "type": "tool_execution_start", + "toolCallId": "call_380752", + "toolName": "get_subagent_result", + "args": { + "agent_id": "b913f6d2-2b1e-4be" + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_380752", + "toolName": "get_subagent_result", + "result": { + "content": [ + { + "type": "text", + "text": "Agent: b913f6d2-2b1e-4be\nType: Agent | Status: completed | Tool uses: 1 | 8.8k token | Context: 1% | Duration: 3.7s\nDescription: list files\n\nThe workspace `/tmp/paseo-pi-extension-phase3/workspace` is currently empty.\n\nFull transcript available at: /tmp/paseo-pi-extension-phase3/sessions-gotgenes/2026-09-23T21-08-13-733Z_01a0d018-fd25-783b-b9e9-27f4bd6c8851/tasks/2026-09-23T21-08-17-217Z_01a0d019-0ac1-72e0-a277-0fea25d9da6c.jsonl" + } + ], + "details": { + "agentId": "b913f6d2-2b1e-4be", + "displayName": "Agent", + "status": "completed", + "description": "list files", + "toolUses": 1, + "tokens": "8.8k token", + "contextPercent": 0.6055831909179688, + "compactionCount": 0, + "duration": "3.7s", + "preview": "The workspace `/tmp/paseo-pi-extension-phase3/workspace` is currently empty.", + "verbose": false, + "transcriptPath": "/tmp/paseo-pi-extension-phase3/sessions-gotgenes/2026-09-23T21-08-13-733Z_01a0d018-fd25-783b-b9e9-27f4bd6c8851/tasks/2026-09-23T21-08-17-217Z_01a0d019-0ac1-72e0-a277-0fea25d9da6c.jsonl" + } + }, + "isError": false + } + ], + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "call_403820", + "name": "subagent", + "arguments": { + "description": "list files", + "subagent_type": "general-purpose", + "prompt": "list files in this empty workspace", + "run_in_background": true + } + } + ], + "api": "openai-completions", + "provider": "openrouter", + "model": "google/gemini-3.6-flash", + "usage": { + "input": 6590, + "output": 46, + "cacheRead": 0, + "cacheWrite": 0, + "reasoning": 0, + "totalTokens": 6636, + "cost": { + "input": 0.0049425, + "output": 0.0001725, + "cacheRead": 0, + "cacheWrite": 0, + "total": 0.005115 + } + }, + "stopReason": "toolUse", + "timestamp": 1790197694784, + "responseId": "gen-1790197695-1oeKbQMynKXs4RNlIMQ7", + "rawStopReason": "tool_calls" + }, + { + "role": "toolResult", + "toolCallId": "call_403820", + "toolName": "subagent", + "content": [ + { + "type": "text", + "text": "Agent started in background.\nAgent ID: b913f6d2-2b1e-4be\nType: Agent\nDescription: list files\n\nYou will be notified when this agent completes.\nUse get_subagent_result to retrieve full results, or steer_subagent to send it messages.\nDo not duplicate this agent's work." + } + ], + "details": { + "displayName": "Agent", + "description": "list files", + "subagentType": "general-purpose", + "tags": ["twin", "background"], + "toolUses": 0, + "tokens": "", + "durationMs": 0, + "status": "background", + "agentId": "b913f6d2-2b1e-4be" + }, + "isError": false, + "timestamp": 1790197696346 + }, + { + "role": "custom", + "customType": "subagent-notification", + "content": "\nb913f6d2-2b1e-4be\ncall_403820\n/tmp/paseo-pi-extension-phase3/sessions-gotgenes/2026-09-23T21-08-13-733Z_01a0d018-fd25-783b-b9e9-27f4bd6c8851/tasks/2026-09-23T21-08-17-217Z_01a0d019-0ac1-72e0-a277-0fea25d9da6c.jsonl\nDone\nSubagent \"list files\" completed\nThe workspace `/tmp/paseo-pi-extension-phase3/workspace` is currently empty.\n8843113714\n\nFull transcript available at: /tmp/paseo-pi-extension-phase3/sessions-gotgenes/2026-09-23T21-08-13-733Z_01a0d018-fd25-783b-b9e9-27f4bd6c8851/tasks/2026-09-23T21-08-17-217Z_01a0d019-0ac1-72e0-a277-0fea25d9da6c.jsonl\nCall get_subagent_result(\"b913f6d2-2b1e-4be\") to collect the full result.", + "display": true, + "details": { + "id": "b913f6d2-2b1e-4be", + "description": "list files", + "status": "completed", + "toolUses": 1, + "turnCount": 3, + "totalTokens": 8843, + "durationMs": 3714, + "outputFile": "/tmp/paseo-pi-extension-phase3/sessions-gotgenes/2026-09-23T21-08-13-733Z_01a0d018-fd25-783b-b9e9-27f4bd6c8851/tasks/2026-09-23T21-08-17-217Z_01a0d019-0ac1-72e0-a277-0fea25d9da6c.jsonl", + "resultPreview": "The workspace `/tmp/paseo-pi-extension-phase3/workspace` is currently empty." + }, + "timestamp": 1790197700057 + }, + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "call_380752", + "name": "get_subagent_result", + "arguments": { + "agent_id": "b913f6d2-2b1e-4be" + } + } + ], + "api": "openai-completions", + "provider": "openrouter", + "model": "google/gemini-3.6-flash", + "usage": { + "input": 3383, + "output": 36, + "cacheRead": 3846, + "cacheWrite": 0, + "reasoning": 0, + "totalTokens": 7265, + "cost": { + "input": 0.00253725, + "output": 0.000135, + "cacheRead": 0.00028845, + "cacheWrite": 0, + "total": 0.0029607 + } + }, + "stopReason": "toolUse", + "timestamp": 1790197700058, + "responseId": "gen-1790197700-cKcLwp60V13TphEI0fPk", + "rawStopReason": "tool_calls" + }, + { + "role": "toolResult", + "toolCallId": "call_380752", + "toolName": "get_subagent_result", + "content": [ + { + "type": "text", + "text": "Agent: b913f6d2-2b1e-4be\nType: Agent | Status: completed | Tool uses: 1 | 8.8k token | Context: 1% | Duration: 3.7s\nDescription: list files\n\nThe workspace `/tmp/paseo-pi-extension-phase3/workspace` is currently empty.\n\nFull transcript available at: /tmp/paseo-pi-extension-phase3/sessions-gotgenes/2026-09-23T21-08-13-733Z_01a0d018-fd25-783b-b9e9-27f4bd6c8851/tasks/2026-09-23T21-08-17-217Z_01a0d019-0ac1-72e0-a277-0fea25d9da6c.jsonl" + } + ], + "details": { + "agentId": "b913f6d2-2b1e-4be", + "displayName": "Agent", + "status": "completed", + "description": "list files", + "toolUses": 1, + "tokens": "8.8k token", + "contextPercent": 0.6055831909179688, + "compactionCount": 0, + "duration": "3.7s", + "preview": "The workspace `/tmp/paseo-pi-extension-phase3/workspace` is currently empty.", + "verbose": false, + "transcriptPath": "/tmp/paseo-pi-extension-phase3/sessions-gotgenes/2026-09-23T21-08-13-733Z_01a0d018-fd25-783b-b9e9-27f4bd6c8851/tasks/2026-09-23T21-08-17-217Z_01a0d019-0ac1-72e0-a277-0fea25d9da6c.jsonl" + }, + "isError": false, + "timestamp": 1790197701316 + } + ] +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/fixtures/child-session.jsonl b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/fixtures/child-session.jsonl new file mode 100644 index 0000000000..16da52431b --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/fixtures/child-session.jsonl @@ -0,0 +1,4 @@ +{"type":"message","id":"813a565d","parentId":"2837e00c","timestamp":"2026-09-23T21:08:17.356Z","message":{"role":"user","content":[{"type":"text","text":"list files in this empty workspace"}],"timestamp":1790197697355}} +{"type":"message","id":"869bc287","parentId":"813a565d","timestamp":"2026-09-23T21:08:18.797Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"","thinkingSignature":"[{\"type\":\"reasoning.encrypted\",\"data\":\"AY89a19A4eeufqsAJet9RoM5pf8bwBMUrJ4+R+m99YFQJ3pNYkkRTPI+5rHRCLH4sAKmyqJiSFTcFR+qKfVFQN91nm2RkncAczuy2UwYJ+mcvU1bG42y9Ca3gcGSZcBhP6IU6cu4KdfkqeEuBCx+bZQwylVRZlpiarhlv+fLQD/nE+m9KeFW+eZUJrIkd6nZYIdK0xAGVAxcbjDCh4rbZKMzCE7RB0G7adwu4ZxY2Sw1IVmIXz2Hgob+U1BXcm1YYNutIIDCN3q343v7QBEvmYpCm/ugM4C4KUrZHet8wudZUGmnAxDkVJxzBUjeUdmJiLDbJ/uCuRwjrvmxnaixsq0yeiSq4EisboKHtgWTMflaAAary+ClXQgY8sh00LjLQdO++Cp+Sn3per2ehlGW7680KBncXSDNiZXcjPJPRju6TRYseddNfdJdW6eDelqm6/GuBKX9hc8cVUT33kdkOqQDHbcHCw==\",\"format\":\"google-gemini-v1\",\"id\":\"call_342819\",\"index\":0}]"},{"type":"toolCall","id":"call_342819","name":"ls","arguments":{}}],"api":"openai-completions","provider":"openrouter","model":"google/gemini-3.6-flash","usage":{"input":6230,"output":76,"cacheRead":0,"cacheWrite":0,"reasoning":68,"totalTokens":6306,"cost":{"input":0.0046725000000000004,"output":0.000285,"cacheRead":0,"cacheWrite":0,"total":0.0049575}},"stopReason":"toolUse","timestamp":1790197697356,"responseId":"gen-1790197697-mVNTI1th8irocnyQ3PSP","rawStopReason":"tool_calls"}} +{"type":"message","id":"e2c2c160","parentId":"869bc287","timestamp":"2026-09-23T21:08:18.799Z","message":{"role":"toolResult","toolCallId":"call_342819","toolName":"ls","content":[{"type":"text","text":"(empty directory)"}],"isError":false,"timestamp":1790197698799}} +{"type":"message","id":"97009824","parentId":"e2c2c160","timestamp":"2026-09-23T21:08:20.054Z","message":{"role":"assistant","content":[{"type":"text","text":"The workspace `/tmp/paseo-pi-extension-phase3/workspace` is currently empty."}],"api":"openai-completions","provider":"openrouter","model":"google/gemini-3.6-flash","usage":{"input":2500,"output":37,"cacheRead":3813,"cacheWrite":0,"reasoning":16,"totalTokens":6350,"cost":{"input":0.0018750000000000001,"output":0.00013875,"cacheRead":0.000285975,"cacheWrite":0,"total":0.0022997250000000003}},"stopReason":"stop","timestamp":1790197698800,"responseId":"gen-1790197698-HsngQmcltNO0wKvZKyQe","rawStopReason":"stop"}} diff --git a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/fixtures/foreground.json b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/fixtures/foreground.json new file mode 100644 index 0000000000..45e4d39429 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/fixtures/foreground.json @@ -0,0 +1,111 @@ +{ + "provenance": { + "package": "@gotgenes/pi-subagents", + "version": "21.7.6", + "sourceCommit": "1ac6bc6a36e6da5d00f1cd2482f1b971f5ea1fdf", + "piVersion": "0.84.4", + "captureDate": "2026-09-23" + }, + "events": [ + { + "type": "tool_execution_start", + "toolCallId": "call_502323", + "toolName": "subagent", + "args": { + "subagent_type": "general-purpose", + "description": "List workspace files", + "prompt": "list the files in this empty workspace." + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_502323", + "toolName": "subagent", + "result": { + "content": [ + { + "type": "text", + "text": "Agent completed in 4.7s (1 tool uses, 9.0k token).\nAgent ID: 848583f5-6749-4a1\n\nThe workspace is empty." + } + ], + "details": { + "displayName": "Agent", + "description": "List workspace files", + "subagentType": "general-purpose", + "tags": ["twin"], + "toolUses": 1, + "tokens": "9.0k token", + "turnCount": 3, + "durationMs": 4723, + "status": "completed", + "agentId": "848583f5-6749-4a1" + } + }, + "isError": false + } + ], + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "call_502323", + "name": "subagent", + "arguments": { + "subagent_type": "general-purpose", + "description": "List workspace files", + "prompt": "list the files in this empty workspace." + } + } + ], + "api": "openai-completions", + "provider": "openrouter", + "model": "google/gemini-3.6-flash", + "usage": { + "input": 6584, + "output": 96, + "cacheRead": 0, + "cacheWrite": 0, + "reasoning": 55, + "totalTokens": 6680, + "cost": { + "input": 0.0049380000000000005, + "output": 0.00036, + "cacheRead": 0, + "cacheWrite": 0, + "total": 0.005298000000000001 + } + }, + "stopReason": "toolUse", + "timestamp": 1790197658835, + "responseId": "gen-1790197658-Jx3ylOivC0gHsP3uBU0f", + "rawStopReason": "tool_calls" + }, + { + "role": "toolResult", + "toolCallId": "call_502323", + "toolName": "subagent", + "content": [ + { + "type": "text", + "text": "Agent completed in 4.7s (1 tool uses, 9.0k token).\nAgent ID: 848583f5-6749-4a1\n\nThe workspace is empty." + } + ], + "details": { + "displayName": "Agent", + "description": "List workspace files", + "subagentType": "general-purpose", + "tags": ["twin"], + "toolUses": 1, + "tokens": "9.0k token", + "turnCount": 3, + "durationMs": 4723, + "status": "completed", + "agentId": "848583f5-6749-4a1" + }, + "isError": false, + "timestamp": 1790197665254 + } + ] +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.test.ts new file mode 100644 index 0000000000..0e15438384 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "vitest"; +import { fileURLToPath } from "node:url"; +import { createPiExtensionHost } from "../index.js"; +import { readSubagentFixture, verifySubagentFixture } from "../subagent-fixture-test.js"; +import { streamPiHistory } from "../../history-mapper.js"; + +describe("@gotgenes/pi-subagents adapter", () => { + test("maps captured foreground lifecycle live and on replay", async () => { + const events = await verifySubagentFixture( + readSubagentFixture(new URL("./fixtures/foreground.json", import.meta.url)), + ); + expect( + events + .filter((event) => event.event.type === "upsert") + .map((event) => (event.event.type === "upsert" ? event.event.status : null)), + ).toEqual(["running", "completed"]); + }); + test("correlates a background notification and result collection", async () => { + const source = readSubagentFixture(new URL("./fixtures/background.json", import.meta.url)); + const file = ( + source.messages.find((message) => message.role === "custom") as { + details: { outputFile: string }; + } + ).details.outputFile; + const fixture = readSubagentFixture(new URL("./fixtures/background.json", import.meta.url), { + from: file, + to: fileURLToPath(new URL("./fixtures/child-session.jsonl", import.meta.url)), + }); + const events = await verifySubagentFixture(fixture); + const upserts = events + .filter((event) => event.event.type === "upsert") + .map((event) => (event.event.type === "upsert" ? event.event : null)); + expect(upserts.map((event) => event?.status)).toEqual([ + "running", + "running", + "completed", + "completed", + ]); + expect(new Set(upserts.map((event) => event?.id)).size).toBe(1); + expect(events.filter((event) => event.event.type === "timeline").length).toBeGreaterThan(0); + }); + test("keeps claimed notification text visible in history", async () => { + const fixture = readSubagentFixture(new URL("./fixtures/background.json", import.meta.url)); + const text = fixture.messages.find((message) => message.role === "custom")?.content; + const events = []; + for await (const event of streamPiHistory("pi", fixture.messages)) events.push(event); + expect(events).toContainEqual({ + type: "timeline", + provider: "pi", + item: { type: "assistant_message", text }, + }); + expect(events.some((event) => event.type === "provider_subagent")).toBe(true); + }); + test("shares the subagent tool name with Nico without claiming Nico's args", () => { + const host = createPiExtensionHost(); + const nico = host.mapToolCall({ + callId: "nico", + toolName: "subagent", + args: { agent: "scout", task: "Inspect" }, + status: "running", + result: null, + }); + const gotgenes = host.mapToolCall({ + callId: "gotgenes", + toolName: "subagent", + args: { subagent_type: "general-purpose", prompt: "Inspect" }, + status: "running", + result: null, + }); + expect(nico?.detail).toEqual( + expect.objectContaining({ type: "sub_agent", subAgentType: "scout" }), + ); + expect(gotgenes?.detail).toEqual( + expect.objectContaining({ type: "sub_agent", subAgentType: "general-purpose" }), + ); + expect( + host.mapToolCall({ + callId: "foreign", + toolName: "subagent", + args: { description: "Inspect" }, + status: "running", + result: null, + }), + ).toBeUndefined(); + }); +}); diff --git a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.ts b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.ts new file mode 100644 index 0000000000..bf7d2813fb --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.ts @@ -0,0 +1,157 @@ +import { z } from "zod"; +import { extractTextFromToolResult } from "../../tool-call-mapper.js"; +import type { PiExtension, PiExtensionToolCall } from "../contract.js"; + +const SpawnArgs = z + .object({ + subagent_type: z.string().trim().min(1), + prompt: z.string().trim().min(1), + description: z.string().optional(), + }) + .passthrough(); +const FollowupArgs = z.object({ agent_id: z.string().trim().min(1) }).passthrough(); +const Details = z + .object({ + agentId: z.string().trim().min(1), + status: z.string(), + displayName: z.string().optional(), + description: z.string().optional(), + transcriptPath: z.string().trim().min(1).optional(), + }) + .passthrough(); +const Notification = z + .object({ + id: z.string().trim().min(1), + status: z.string(), + description: z.string().optional(), + outputFile: z.string().trim().min(1).optional(), + }) + .passthrough(); +const status = (value: string): "running" | "completed" | "failed" | "canceled" => { + if (value === "completed") return "completed"; + if (value === "error") return "failed"; + if (value === "aborted" || value === "stopped") return "canceled"; + return "running"; +}; + +export const gotgenesPiSubagents: PiExtension = { + id: "@gotgenes/pi-subagents", + createSession: () => { + const callsByAgent = new Map(); + const readSessions = new Set(); + const mapSpawn = (call: PiExtensionToolCall) => { + const args = SpawnArgs.safeParse(call.args); + if (!args.success) return undefined; + const details = Details.safeParse( + typeof call.result === "object" ? call.result?.details : null, + ); + const title = args.data.subagent_type; + const description = args.data.description ?? args.data.prompt; + const detail = { + type: "sub_agent" as const, + subAgentType: title, + description, + log: extractTextFromToolResult(call.result)?.trim() ?? "", + }; + if (call.status === "running") + return { + detail, + subagents: [ + { + type: "upsert" as const, + id: call.callId, + title, + description, + toolCallId: call.callId, + status: "running" as const, + }, + ], + }; + if (!details.success) + return call.status === "failed" + ? { + detail, + subagents: [{ type: "upsert" as const, id: call.callId, status: "failed" as const }], + } + : { detail }; + callsByAgent.set(details.data.agentId, call.callId); + return { + detail, + subagents: [ + { + type: "upsert" as const, + id: call.callId, + title, + description, + toolCallId: call.callId, + status: status(details.data.status), + }, + ], + }; + }; + const mapFollowup = (call: PiExtensionToolCall) => { + const args = FollowupArgs.safeParse(call.args); + if (!args.success) return undefined; + const id = callsByAgent.get(args.data.agent_id); + if (!id) return undefined; + const details = Details.safeParse( + typeof call.result === "object" ? call.result?.details : null, + ); + const detail = { + type: "sub_agent" as const, + description: details.success ? details.data.description : undefined, + log: extractTextFromToolResult(call.result)?.trim() ?? "", + }; + if (call.status === "running" || !details.success) return { detail }; + const file = details.data.transcriptPath; + const childSessions = + file && status(details.data.status) !== "running" && !readSessions.has(file) + ? [{ id, file }] + : []; + if (childSessions.length && file) readSessions.add(file); + return { + detail, + subagents: [{ type: "upsert" as const, id, status: status(details.data.status) }], + childSessions, + }; + }; + return { + mapToolCall(call) { + // This fork's result details resemble tintinweb's, but its spawn tool is `subagent` and its + // follow-up result shape differs. Keep each parser at its own package boundary. + if (call.toolName === "subagent") return mapSpawn(call); + if (call.toolName === "get_subagent_result" || call.toolName === "steer_subagent") + return mapFollowup(call); + return undefined; + }, + mapCustomMessage(message) { + if ( + message.customType !== "subagent-notification" && + message.customType !== "subagent-update" + ) + return undefined; + const details = Notification.safeParse(message.details); + if (!details.success) return undefined; + const id = callsByAgent.get(details.data.id); + if (!id) return undefined; + const file = details.data.outputFile; + const childSessions = + file && status(details.data.status) !== "running" && !readSessions.has(file) + ? [{ id, file }] + : []; + if (childSessions.length && file) readSessions.add(file); + return { + subagents: [ + { + type: "upsert", + id, + description: details.data.description, + status: status(details.data.status), + }, + ], + childSessions, + }; + }, + }; + }, +}; diff --git a/packages/server/src/server/agent/providers/pi/extensions/host.ts b/packages/server/src/server/agent/providers/pi/extensions/host.ts new file mode 100644 index 0000000000..ae0f307393 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/host.ts @@ -0,0 +1,151 @@ +import type { Logger } from "pino"; +import type { + AgentPermissionRequest, + AgentPermissionResponse, + AgentStreamEvent, +} from "../../../agent-sdk-types.js"; +import type { PiAgentMessage } from "../rpc-types.js"; +import { mapPiChildSession } from "./child-session.js"; +import type { + PiExtension, + PiExtensionCustomMapping, + PiExtensionDialog, + PiExtensionDialogMapping, + PiExtensionSession, + PiExtensionToolCall, + PiExtensionToolMapping, + PiExtensionUiReply, +} from "./contract.js"; + +export type PiExtensionOutput = T & { + events: AgentStreamEvent[]; + hydration: Promise; +}; +export type PiExtensionEventOutput = PiExtensionOutput< + PiExtensionToolMapping | PiExtensionCustomMapping +>; + +interface Session { + id: string; + adapter: PiExtensionSession; +} + +export class PiExtensionHost { + private readonly sessions: Session[] = []; + private remainingHydrationBytes: number; + + constructor( + extensions: readonly PiExtension[], + private readonly logger?: Pick, + hydrationByteBudget = Number.POSITIVE_INFINITY, + private readonly readChildSession: typeof mapPiChildSession = mapPiChildSession, + ) { + this.remainingHydrationBytes = hydrationByteBudget; + for (const extension of extensions) { + const adapter = this.safe(extension.id, "createSession", () => extension.createSession()); + if (adapter) this.sessions.push({ id: extension.id, adapter }); + } + } + + private safe(id: string, operation: string, call: () => T): T | undefined { + try { + return call(); + } catch (error) { + this.logger?.warn({ err: error, extensionId: id, operation }, "Pi extension adapter failed"); + return undefined; + } + } + + mapToolCall(call: PiExtensionToolCall): PiExtensionOutput | undefined { + for (const { id, adapter } of this.sessions) { + const mapping = this.safe(id, "mapToolCall", () => adapter.mapToolCall?.(call)); + if (mapping) { + const prepared = this.safe(id, "prepare", () => this.prepare(id, mapping)); + if (prepared) return prepared; + } + } + return undefined; + } + + mapCustomMessage( + message: Extract, + ): PiExtensionOutput | undefined { + for (const { id, adapter } of this.sessions) { + const mapping = this.safe(id, "mapCustomMessage", () => adapter.mapCustomMessage?.(message)); + if (mapping) { + const prepared = this.safe(id, "prepare", () => this.prepare(id, mapping)); + if (prepared) return prepared; + } + } + return undefined; + } + + private prepare( + id: string, + mapping: T, + ): PiExtensionOutput { + const events: AgentStreamEvent[] = [ + ...("timeline" in mapping ? (mapping.timeline ?? []) : []).map( + (item): AgentStreamEvent => ({ type: "timeline", provider: "pi", item }), + ), + ...(mapping.subagents ?? []).map( + (event): AgentStreamEvent => ({ type: "provider_subagent", provider: "pi", event }), + ), + ]; + const hydration = Promise.all( + (mapping.childSessions ?? []).map(async ({ id: childId, file }) => { + const bytes = Math.min(this.remainingHydrationBytes, 2 * 1024 * 1024); + if (bytes <= 0) return []; + this.remainingHydrationBytes -= bytes; + return (await this.readChildSession(childId, file, bytes)).map( + (event): AgentStreamEvent => ({ type: "provider_subagent", provider: "pi", event }), + ); + }), + ) + .then((groups) => groups.flat()) + .catch((error): AgentStreamEvent[] => { + this.logger?.warn( + { err: error, extensionId: id, operation: "hydrate" }, + "Pi extension adapter failed", + ); + return []; + }); + return { ...mapping, events, hydration }; + } + + onToolStart(call: PiExtensionToolCall, provider = "pi"): AgentPermissionRequest | undefined { + let request: AgentPermissionRequest | undefined; + for (const { id, adapter } of this.sessions) { + const candidate = this.safe(id, "onToolStart", () => adapter.onToolStart?.(call, provider)); + request ??= candidate; + } + return request; + } + + onToolEnd(call: PiExtensionToolCall): void { + for (const { id, adapter } of this.sessions) { + this.safe(id, "onToolEnd", () => adapter.onToolEnd?.(call)); + } + } + + mapDialog(dialog: PiExtensionDialog, provider: string): PiExtensionDialogMapping | undefined { + for (const { id, adapter } of this.sessions) { + const mapping = this.safe(id, "mapDialog", () => adapter.mapDialog?.(dialog, provider)); + if (mapping) return mapping; + } + return undefined; + } + + respondToPermission( + request: AgentPermissionRequest, + response: AgentPermissionResponse, + ): PiExtensionUiReply | undefined { + for (const { id, adapter } of this.sessions) { + const mapped = this.safe(id, "respondToPermission", () => + adapter.respondToPermission?.(request, response), + ); + if (mapped) return mapped; + } + return undefined; + } +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/index.test.ts new file mode 100644 index 0000000000..dd5d76601a --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/index.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, test } from "vitest"; +import { createPiExtensionHost } from "./index.js"; +import { PiHistoryMapper } from "../history-mapper.js"; +import { parseToolArgs, parseToolResult } from "../tool-call-mapper.js"; +import type { PiExtension } from "./contract.js"; +import { PiExtensionHost } from "./host.js"; + +const throwingAdapter: PiExtension = { + id: "throwing-test-adapter", + createSession: () => ({ + mapToolCall: () => { + throw new Error("tool failed"); + }, + mapDialog: () => { + throw new Error("dialog failed"); + }, + respondToPermission: () => { + throw new Error("response failed"); + }, + onToolStart: () => { + throw new Error("start failed"); + }, + onToolEnd: () => { + throw new Error("end failed"); + }, + mapCustomMessage: () => { + throw new Error("custom failed"); + }, + }), +}; + +describe("Pi extension host", () => { + test("declines a throwing adapter and preserves generic history", async () => { + const host = createPiExtensionHost(undefined, [throwingAdapter]); + const mapper = new PiHistoryMapper("pi", [], {}, host); + const events = mapper.mapMessages([ + { + role: "assistant", + content: [{ type: "toolCall", id: "call-1", name: "other", arguments: {} }], + }, + { + role: "toolResult", + toolCallId: "call-1", + toolName: "other", + content: [{ type: "text", text: "ok" }], + }, + { role: "assistant", content: [{ type: "text", text: "done" }] }, + ]); + expect(events.filter((event) => event.type === "timeline")).toHaveLength(3); + expect(await mapper.hydrate()).toEqual([]); + }); + + test("declines throwing lifecycle and dialog hooks", () => { + const host = createPiExtensionHost(undefined, [throwingAdapter]); + const call = { + callId: "x", + toolName: "other", + args: {}, + status: "running" as const, + result: null, + }; + expect(host.onToolStart(call)).toBeUndefined(); + expect(() => host.onToolEnd(call)).not.toThrow(); + expect( + host.mapDialog( + { + type: "extension_ui_request", + id: "ui-1", + method: "select", + title: "Pick", + options: ["A"], + }, + "pi", + ), + ).toBeUndefined(); + expect( + host.respondToPermission( + { id: "ui-1", provider: "pi", kind: "question", title: "Pick", input: { questions: [] } }, + { behavior: "allow" }, + ), + ).toBeUndefined(); + expect( + host.mapCustomMessage({ role: "custom", customType: "other", content: "hello" }), + ).toBeUndefined(); + }); + + test("rejected child hydration emits no events", async () => { + const host = new PiExtensionHost( + [ + { + id: "child", + createSession: () => ({ + mapToolCall: () => ({ childSessions: [{ id: "child-1", file: "unused" }] }), + }), + }, + ], + undefined, + 2 * 1024 * 1024, + async () => { + throw new Error("read failed"); + }, + ); + const output = host.mapToolCall({ + callId: "x", + toolName: "other", + args: {}, + status: "completed", + result: null, + }); + expect(await output?.hydration).toEqual([]); + }); + + test("limits total child reads during replay", async () => { + const reads: string[] = []; + const host = new PiExtensionHost( + [ + { + id: "child", + createSession: () => ({ + mapToolCall: () => ({ + childSessions: Array.from({ length: 10 }, (_, index) => ({ + id: String(index), + file: String(index), + })), + }), + }), + }, + ], + undefined, + 16 * 1024 * 1024, + async (id) => { + reads.push(id); + return []; + }, + ); + const output = host.mapToolCall({ + callId: "x", + toolName: "other", + args: {}, + status: "completed", + result: null, + }); + await output?.hydration; + expect(reads).toHaveLength(8); + }); + test("claims a tool, declines an unrelated one, and maps history like live events", () => { + const host = createPiExtensionHost(); + const args = { agent: "scout", task: "Inspect files" }; + const result = parseToolResult({ content: [{ type: "text", text: "Found two files" }] }); + const live = host.mapToolCall({ + callId: "call-1", + toolName: "subagent", + args, + status: "completed", + result, + }); + expect(live).toEqual( + expect.objectContaining({ + detail: { + type: "sub_agent", + subAgentType: "scout", + description: "Inspect files", + log: "Found two files", + }, + }), + ); + expect( + host.mapToolCall({ + callId: "call-2", + toolName: "unrelated", + args: {}, + status: "completed", + result, + }), + ).toBeUndefined(); + + const mapper = new PiHistoryMapper("pi"); + const events = mapper.mapMessages([ + { + role: "assistant", + content: [{ type: "toolCall", id: "call-1", name: "subagent", arguments: args }], + }, + { + role: "toolResult", + toolCallId: "call-1", + toolName: "subagent", + content: [{ type: "text", text: "Found two files" }], + }, + ]); + const completed = events.find( + (event) => + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.status === "completed", + ); + expect( + completed?.type === "timeline" && completed.item.type === "tool_call" + ? completed.item.detail + : null, + ).toEqual(live?.detail); + expect(parseToolArgs("subagent", args).toolName).toBe("subagent"); + }); +}); diff --git a/packages/server/src/server/agent/providers/pi/extensions/index.ts b/packages/server/src/server/agent/providers/pi/extensions/index.ts new file mode 100644 index 0000000000..eb4ecda059 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/index.ts @@ -0,0 +1,15 @@ +import { PiExtensionHost } from "./host.js"; +import { piExtensions } from "./registry.js"; +import type { Logger } from "pino"; +import type { PiExtension } from "./contract.js"; + +export type { PiExtensionHost } from "./host.js"; +export type { PiExtensionEventOutput } from "./host.js"; + +export function createPiExtensionHost( + logger?: Pick, + extensions: readonly PiExtension[] = piExtensions, + hydrationByteBudget?: number, +): PiExtensionHost { + return new PiExtensionHost(extensions, logger, hydrationByteBudget); +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-ask-user/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/pi-ask-user/index.test.ts new file mode 100644 index 0000000000..4d6991495a --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/pi-ask-user/index.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "vitest"; +import { createPiExtensionHost } from "../index.js"; + +describe("pi-ask-user adapter", () => { + test("combines selection and comment, then answers the follow-up dialog", () => { + const host = createPiExtensionHost(); + host.onToolStart({ + callId: "ask-1", + toolName: "ask_user", + args: { allowComment: true, allowFreeform: false }, + status: "running", + result: null, + }); + const mapped = host.mapDialog( + { + type: "extension_ui_request", + id: "select-1", + method: "select", + title: "Pick one", + options: ["A", "B"], + }, + "pi", + ); + expect(mapped?.type).toBe("permission"); + if (mapped?.type !== "permission") throw new Error("Expected a combined permission"); + expect(mapped.request.input?.questions).toHaveLength(2); + expect( + host.respondToPermission(mapped.request, { + behavior: "allow", + updatedInput: { answers: { Response: "B", Comment: "Looks good" } }, + }), + ).toEqual({ responses: [{ id: "select-1", response: { value: "B" } }] }); + expect( + host.mapDialog( + { + type: "extension_ui_request", + id: "comment-1", + method: "input", + placeholder: "Optional comment (press Enter to skip)...", + }, + "pi", + ), + ).toEqual({ type: "response", response: { value: "Looks good" } }); + }); + + test("declines unrelated dialogs and cancels denied selection", () => { + const host = createPiExtensionHost(); + expect( + host.mapDialog( + { type: "extension_ui_request", id: "other", method: "select", options: ["A"] }, + "pi", + ), + ).toBeUndefined(); + host.onToolStart({ + callId: "ask-2", + toolName: "ask_user", + args: { allowComment: true }, + status: "running", + result: null, + }); + const mapped = host.mapDialog( + { type: "extension_ui_request", id: "select-2", method: "select", options: ["A"] }, + "pi", + ); + if (mapped?.type !== "permission") throw new Error("Expected a combined permission"); + expect(host.respondToPermission(mapped.request, { behavior: "deny" })).toEqual({ + responses: [{ id: "select-2", response: { cancelled: true } }], + }); + }); +}); diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-ask-user/index.ts b/packages/server/src/server/agent/providers/pi/extensions/pi-ask-user/index.ts new file mode 100644 index 0000000000..4de58c86f8 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/pi-ask-user/index.ts @@ -0,0 +1,158 @@ +import { z } from "zod"; +import type { + AgentMetadata, + AgentPermissionRequest, + AgentPermissionResponse, +} from "../../../../agent-sdk-types.js"; +import type { PiExtension, PiExtensionDialog, PiExtensionUiReply } from "../contract.js"; + +const QUESTION_RESPONSE_HEADER = "Response"; +const QUESTION_COMMENT_HEADER = "Comment"; +const FREEFORM_SENTINEL = "✏️ Type custom response..."; +const COMBINED_METADATA = "ask_user_select_optional_comment"; +const Args = z + .object({ + allowComment: z.boolean().catch(false), + allowFreeform: z.boolean().catch(true), + allowMultiple: z.boolean().catch(false), + }) + .passthrough(); +const Select = z + .object({ title: z.string().optional(), options: z.array(z.string()).optional() }) + .passthrough(); +const Input = z.object({ placeholder: z.string().optional() }).passthrough(); + +function answer(input: AgentMetadata | undefined, header: string): string | null { + const answers = input?.answers; + if (!answers || typeof answers !== "object" || Array.isArray(answers)) return null; + const value = Reflect.get(answers, header); + return typeof value === "string" ? value : null; +} + +function optionalInput(placeholder: string | undefined): boolean { + return /\boptional\b|\bskip\b/i.test(placeholder ?? ""); +} + +function combinedPermission( + dialog: PiExtensionDialog, + provider: string, + options: string[], + allowFreeform: boolean, + question: string, +): AgentPermissionRequest { + const visibleOptions = options.filter((option) => option !== FREEFORM_SENTINEL); + const allowOther = allowFreeform || visibleOptions.length !== options.length; + return { + id: dialog.id, + provider, + name: "Pi ask_user", + kind: "question", + title: question, + input: { + questions: [ + { + question, + header: QUESTION_RESPONSE_HEADER, + options: visibleOptions.map((label) => ({ label })), + multiSelect: false, + ...(allowOther ? { allowOther: true } : {}), + }, + { + question: "Optional comment", + header: QUESTION_COMMENT_HEADER, + options: [], + multiSelect: false, + placeholder: "Optional comment (press Enter to skip)...", + allowEmpty: true, + }, + ], + }, + metadata: { + extensionUiMethod: dialog.method, + answerHeader: QUESTION_RESPONSE_HEADER, + commentHeader: QUESTION_COMMENT_HEADER, + combinedAskUser: COMBINED_METADATA, + selectOptions: visibleOptions, + ...(allowOther ? { freeformSentinel: FREEFORM_SENTINEL } : {}), + }, + }; +} + +export const piAskUser: PiExtension = { + id: "pi-ask-user", + createSession() { + let active: z.infer | null = null; + let pending: { comment: string; freeform: string | null } | null = null; + return { + onToolStart(call) { + active = call.toolName === "ask_user" ? (Args.safeParse(call.args).data ?? null) : null; + return undefined; + }, + onToolEnd(call) { + if (call.toolName === "ask_user") { + active = null; + pending = null; + } + }, + mapDialog(dialog, provider) { + if (pending && dialog.method === "input") { + const input = Input.safeParse(dialog); + const placeholder = input.success ? input.data.placeholder : undefined; + if (pending.freeform !== null && !optionalInput(placeholder)) { + const value = pending.freeform; + pending = { ...pending, freeform: null }; + return { type: "response", response: { value } }; + } + if (optionalInput(placeholder)) { + const value = pending.comment; + pending = null; + return { type: "response", response: { value } }; + } + } + if (dialog.method !== "select" || !active?.allowComment || active.allowMultiple) + return undefined; + const select = Select.safeParse(dialog); + const options = select.success ? (select.data.options ?? []) : []; + const question = select.success + ? (select.data.title ?? "Select an option") + : "Select an option"; + return { + type: "permission", + request: combinedPermission(dialog, provider, options, active.allowFreeform, question), + }; + }, + respondToPermission( + request: AgentPermissionRequest, + response: AgentPermissionResponse, + ): PiExtensionUiReply | undefined { + if (request.metadata?.combinedAskUser !== COMBINED_METADATA) return undefined; + if (response.behavior === "deny") { + pending = null; + return { responses: [{ id: request.id, response: { cancelled: true } }] }; + } + const selected = answer(response.updatedInput, QUESTION_RESPONSE_HEADER); + if (selected === null) { + pending = null; + return { responses: [{ id: request.id, response: { cancelled: true } }] }; + } + const selectOptions = Array.isArray(request.metadata?.selectOptions) + ? request.metadata.selectOptions.filter( + (item): item is string => typeof item === "string", + ) + : []; + const sentinel = + typeof request.metadata?.freeformSentinel === "string" + ? request.metadata.freeformSentinel + : undefined; + const isFreeform = Boolean(sentinel) && !selectOptions.includes(selected); + pending = { + comment: answer(response.updatedInput, QUESTION_COMMENT_HEADER) ?? "", + freeform: isFreeform ? selected : null, + }; + return { + responses: [{ id: request.id, response: { value: isFreeform ? sentinel : selected } }], + }; + }, + }; + }, +}; diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-example-todo/fixtures/rpc-session.json b/packages/server/src/server/agent/providers/pi/extensions/pi-example-todo/fixtures/rpc-session.json new file mode 100644 index 0000000000..46465d196e --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/pi-example-todo/fixtures/rpc-session.json @@ -0,0 +1,250 @@ +{ + "provenance": { + "package": "@earendil-works/pi-coding-agent examples/extensions/todo.ts", + "version": "0.87.1", + "sourceCommit": "7fd564cbb78f35f3de14d5382fea692b87ec4026", + "piVersion": "0.84.4", + "captureDate": "2026-09-23", + "mode": "pi --mode rpc; OpenRouter google/gemini-3.6-flash" + }, + "events": [ + { + "type": "tool_execution_start", + "toolCallId": "call_433291", + "toolName": "todo", + "args": { + "action": "add", + "text": "Inspect workspace" + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_433291", + "toolName": "todo", + "result": { + "content": [ + { + "type": "text", + "text": "Added todo #1: Inspect workspace" + } + ], + "details": { + "action": "add", + "todos": [ + { + "id": 1, + "text": "Inspect workspace", + "done": false + } + ], + "nextId": 2 + } + }, + "isError": false + }, + { + "type": "tool_execution_start", + "toolCallId": "call_453655", + "toolName": "todo", + "args": { + "action": "add", + "text": "Summarize findings" + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_453655", + "toolName": "todo", + "result": { + "content": [ + { + "type": "text", + "text": "Added todo #2: Summarize findings" + } + ], + "details": { + "action": "add", + "todos": [ + { + "id": 1, + "text": "Inspect workspace", + "done": false + }, + { + "id": 2, + "text": "Summarize findings", + "done": false + } + ], + "nextId": 3 + } + }, + "isError": false + }, + { + "type": "tool_execution_start", + "toolCallId": "call_525797", + "toolName": "todo", + "args": { + "action": "toggle", + "id": 1 + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_525797", + "toolName": "todo", + "result": { + "content": [ + { + "type": "text", + "text": "Todo #1 completed" + } + ], + "details": { + "action": "toggle", + "todos": [ + { + "id": 1, + "text": "Inspect workspace", + "done": true + }, + { + "id": 2, + "text": "Summarize findings", + "done": false + } + ], + "nextId": 3 + } + }, + "isError": false + } + ], + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "call_433291", + "name": "todo", + "arguments": { + "action": "add", + "text": "Inspect workspace" + } + } + ] + }, + { + "role": "toolResult", + "toolCallId": "call_433291", + "toolName": "todo", + "content": [ + { + "type": "text", + "text": "Added todo #1: Inspect workspace" + } + ], + "details": { + "action": "add", + "todos": [ + { + "id": 1, + "text": "Inspect workspace", + "done": false + } + ], + "nextId": 2 + }, + "isError": false, + "timestamp": 1790195609694 + }, + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "call_453655", + "name": "todo", + "arguments": { + "action": "add", + "text": "Summarize findings" + } + } + ] + }, + { + "role": "toolResult", + "toolCallId": "call_453655", + "toolName": "todo", + "content": [ + { + "type": "text", + "text": "Added todo #2: Summarize findings" + } + ], + "details": { + "action": "add", + "todos": [ + { + "id": 1, + "text": "Inspect workspace", + "done": false + }, + { + "id": 2, + "text": "Summarize findings", + "done": false + } + ], + "nextId": 3 + }, + "isError": false, + "timestamp": 1790195610962 + }, + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "call_525797", + "name": "todo", + "arguments": { + "action": "toggle", + "id": 1 + } + } + ] + }, + { + "role": "toolResult", + "toolCallId": "call_525797", + "toolName": "todo", + "content": [ + { + "type": "text", + "text": "Todo #1 completed" + } + ], + "details": { + "action": "toggle", + "todos": [ + { + "id": 1, + "text": "Inspect workspace", + "done": true + }, + { + "id": 2, + "text": "Summarize findings", + "done": false + } + ], + "nextId": 3 + }, + "isError": false, + "timestamp": 1790195612441 + } + ] +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-example-todo/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/pi-example-todo/index.test.ts new file mode 100644 index 0000000000..e2a858fd6c --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/pi-example-todo/index.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from "vitest"; +import { capturedToolCall, verifyTaskFixture } from "../task-fixture-test.js"; +import { piExampleTodo } from "./index.js"; + +test("Pi example todo maps captured RPC tasks through live and history", async () => { + await verifyTaskFixture(new URL("./fixtures/rpc-session.json", import.meta.url), [ + "pending", + "pending,pending", + "completed,pending", + ]); +}); + +test("example todo declines rpiv-todo's shape", () => { + const call = capturedToolCall(new URL("../rpiv-todo/fixtures/rpc-session.json", import.meta.url)); + expect(piExampleTodo.createSession().mapToolCall?.(call)).toBeUndefined(); +}); diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-example-todo/index.ts b/packages/server/src/server/agent/providers/pi/extensions/pi-example-todo/index.ts new file mode 100644 index 0000000000..31d7e21199 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/pi-example-todo/index.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; +import type { PiExtension } from "../contract.js"; + +const Details = z.object({ + action: z.enum(["list", "add", "toggle", "clear"]), + nextId: z.number().int(), + todos: z.array(z.object({ id: z.number().int(), text: z.string(), done: z.boolean() })), + error: z.string().optional(), +}); + +export const piExampleTodo: PiExtension = { + id: "pi-example-todo", + createSession: () => ({ + mapToolCall(call) { + if ( + call.toolName !== "todo" || + call.status !== "completed" || + !call.result || + typeof call.result === "string" + ) + return undefined; + const parsed = Details.safeParse(call.result.details); + if (!parsed.success) return undefined; + return { + timeline: parsed.data.error + ? [] + : [ + { + type: "todo", + items: parsed.data.todos.map((todo) => ({ + id: String(todo.id), + text: todo.text, + status: todo.done ? "completed" : "pending", + completed: todo.done, + })), + }, + ], + }; + }, + }), +}; diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-goal-x/fixtures/rpc-session.json b/packages/server/src/server/agent/providers/pi/extensions/pi-goal-x/fixtures/rpc-session.json new file mode 100644 index 0000000000..2ef2df977f --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/pi-goal-x/fixtures/rpc-session.json @@ -0,0 +1,422 @@ +{ + "provenance": { + "package": "pi-goal-x", + "version": "0.31.8", + "sourceCommit": "f366b5cdf36e9329c980f8c44b0d300c910e2f41", + "piVersion": "0.84.4", + "captureDate": "2026-09-23", + "mode": "pi --mode rpc; OpenRouter google/gemini-3.6-flash" + }, + "events": [ + { + "type": "tool_execution_start", + "toolCallId": "call_476725", + "toolName": "create_goal", + "args": { + "objective": "Inspect the empty workspace" + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_476725", + "toolName": "create_goal", + "result": { + "content": [ + { + "type": "text", + "text": "Goal confirmed and created.\n\nFinalized goal:\n\nInspect the empty workspace\n\nGoal details:\nBudget: none\nGoal: Inspect the empty workspace\nStatus: running\nAuto-continue: on\nTime spent: 0s\nTokens used: 0 tokens\nFile: .pi/goals/active_goal_2026092322350609_muekaval-uhhfg8.md\n1 other open goal remain in .pi/goals — this goal is now the session focus." + } + ], + "details": { + "version": 3, + "goal": { + "id": "muekaval-uhhfg8", + "objective": "Inspect the empty workspace", + "status": "active", + "autoContinue": true, + "usage": { + "tokensUsed": 0, + "activeSeconds": 0 + }, + "sisyphus": false, + "revision": 0, + "createdAt": "2026-09-23T20:35:06.093Z", + "updatedAt": "2026-09-23T20:35:06.093Z", + "scheduler": { + "version": 1, + "owner": "01a0cffa-99a6-7e64-888a-debde9407a25", + "generation": "048961d8-6a89-471e-9562-0c09798cc070", + "used": 0, + "phase": "idle", + "repairUsed": false + }, + "activePath": ".pi/goals/active_goal_2026092322350609_muekaval-uhhfg8.md" + } + }, + "terminate": true, + "addedToolNames": ["update_goal", "set_goal_tasks"] + }, + "isError": false + }, + { + "type": "tool_execution_start", + "toolCallId": "call_434120", + "toolName": "set_goal_tasks", + "args": { + "tasks": [ + { + "title": "Inspect workspace", + "id": "task-1" + }, + { + "title": "Summarize findings", + "id": "task-2" + } + ] + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_434120", + "toolName": "set_goal_tasks", + "result": { + "content": [ + { + "type": "text", + "text": "Task list set and confirmed. 2 tasks." + } + ], + "details": { + "version": 3, + "goal": { + "id": "muekaval-uhhfg8", + "objective": "Inspect the empty workspace", + "status": "active", + "autoContinue": true, + "usage": { + "tokensUsed": 6287, + "activeSeconds": 0 + }, + "sisyphus": false, + "revision": 5, + "createdAt": "2026-09-23T20:35:06.093Z", + "updatedAt": "2026-09-23T20:35:08.560Z", + "scheduler": { + "version": 1, + "owner": "01a0cffa-99a6-7e64-888a-debde9407a25", + "generation": "3eb8953c-0d95-44d9-b319-b4c4ab9d2cfe", + "used": 0, + "phase": "idle", + "repairUsed": false + }, + "activePath": ".pi/goals/active_goal_2026092322350609_muekaval-uhhfg8.md", + "taskList": { + "tasks": [ + { + "id": "task-1", + "title": "Inspect workspace", + "status": "pending" + }, + { + "id": "task-2", + "title": "Summarize findings", + "status": "pending" + } + ], + "blockCompletion": false, + "proposedAt": "2026-09-23T20:35:08.559Z" + } + } + }, + "terminate": true, + "addedToolNames": ["update_goal_task"] + }, + "isError": false + }, + { + "type": "tool_execution_start", + "toolCallId": "call_23432", + "toolName": "update_goal_task", + "args": { + "updates": [ + { + "status": "start", + "task_id": "task-1" + }, + { + "status": "complete", + "task_id": "task-1" + } + ] + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_23432", + "toolName": "update_goal_task", + "result": { + "content": [ + { + "type": "text", + "text": "task-1 start; task-1 complete. 1/2 tasks complete." + } + ], + "details": { + "version": 3, + "goal": { + "id": "muekaval-uhhfg8", + "objective": "Inspect the empty workspace", + "status": "active", + "autoContinue": true, + "usage": { + "tokensUsed": 13828, + "activeSeconds": 2 + }, + "sisyphus": false, + "createdAt": "2026-09-23T20:35:06.093Z", + "updatedAt": "2026-09-23T20:35:10.977Z", + "activePath": ".pi/goals/active_goal_2026092322350609_muekaval-uhhfg8.md", + "revision": 11, + "scheduler": { + "version": 1, + "owner": "01a0cffa-99a6-7e64-888a-debde9407a25", + "generation": "d4598e43-6c04-47ab-a004-0f335da65426", + "used": 0, + "phase": "idle", + "repairUsed": false + }, + "taskList": { + "tasks": [ + { + "id": "task-1", + "title": "Inspect workspace", + "status": "complete", + "completedAt": "2026-09-23T20:35:10.976Z" + }, + { + "id": "task-2", + "title": "Summarize findings", + "status": "pending" + } + ], + "blockCompletion": false, + "proposedAt": "2026-09-23T20:35:08.559Z" + } + } + } + }, + "isError": false + } + ], + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "call_476725", + "name": "create_goal", + "arguments": { + "objective": "Inspect the empty workspace" + } + } + ] + }, + { + "role": "toolResult", + "toolCallId": "call_476725", + "toolName": "create_goal", + "content": [ + { + "type": "text", + "text": "Goal confirmed and created.\n\nFinalized goal:\n\nInspect the empty workspace\n\nGoal details:\nBudget: none\nGoal: Inspect the empty workspace\nStatus: running\nAuto-continue: on\nTime spent: 0s\nTokens used: 0 tokens\nFile: .pi/goals/active_goal_2026092322350609_muekaval-uhhfg8.md\n1 other open goal remain in .pi/goals — this goal is now the session focus." + } + ], + "details": { + "version": 3, + "goal": { + "id": "muekaval-uhhfg8", + "objective": "Inspect the empty workspace", + "status": "active", + "autoContinue": true, + "usage": { + "tokensUsed": 0, + "activeSeconds": 0 + }, + "sisyphus": false, + "revision": 0, + "createdAt": "2026-09-23T20:35:06.093Z", + "updatedAt": "2026-09-23T20:35:06.093Z", + "scheduler": { + "version": 1, + "owner": "01a0cffa-99a6-7e64-888a-debde9407a25", + "generation": "048961d8-6a89-471e-9562-0c09798cc070", + "used": 0, + "phase": "idle", + "repairUsed": false + }, + "activePath": ".pi/goals/active_goal_2026092322350609_muekaval-uhhfg8.md" + } + }, + "addedToolNames": ["update_goal", "set_goal_tasks"], + "isError": false, + "timestamp": 1790195706099 + }, + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "call_434120", + "name": "set_goal_tasks", + "arguments": { + "tasks": [ + { + "title": "Inspect workspace", + "id": "task-1" + }, + { + "title": "Summarize findings", + "id": "task-2" + } + ] + } + } + ] + }, + { + "role": "toolResult", + "toolCallId": "call_434120", + "toolName": "set_goal_tasks", + "content": [ + { + "type": "text", + "text": "Task list set and confirmed. 2 tasks." + } + ], + "details": { + "version": 3, + "goal": { + "id": "muekaval-uhhfg8", + "objective": "Inspect the empty workspace", + "status": "active", + "autoContinue": true, + "usage": { + "tokensUsed": 6287, + "activeSeconds": 0 + }, + "sisyphus": false, + "revision": 5, + "createdAt": "2026-09-23T20:35:06.093Z", + "updatedAt": "2026-09-23T20:35:08.560Z", + "scheduler": { + "version": 1, + "owner": "01a0cffa-99a6-7e64-888a-debde9407a25", + "generation": "3eb8953c-0d95-44d9-b319-b4c4ab9d2cfe", + "used": 0, + "phase": "idle", + "repairUsed": false + }, + "activePath": ".pi/goals/active_goal_2026092322350609_muekaval-uhhfg8.md", + "taskList": { + "tasks": [ + { + "id": "task-1", + "title": "Inspect workspace", + "status": "pending" + }, + { + "id": "task-2", + "title": "Summarize findings", + "status": "pending" + } + ], + "blockCompletion": false, + "proposedAt": "2026-09-23T20:35:08.559Z" + } + } + }, + "addedToolNames": ["update_goal_task"], + "isError": false, + "timestamp": 1790195708561 + }, + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "call_23432", + "name": "update_goal_task", + "arguments": { + "updates": [ + { + "status": "start", + "task_id": "task-1" + }, + { + "status": "complete", + "task_id": "task-1" + } + ] + } + } + ] + }, + { + "role": "toolResult", + "toolCallId": "call_23432", + "toolName": "update_goal_task", + "content": [ + { + "type": "text", + "text": "task-1 start; task-1 complete. 1/2 tasks complete." + } + ], + "details": { + "version": 3, + "goal": { + "id": "muekaval-uhhfg8", + "objective": "Inspect the empty workspace", + "status": "active", + "autoContinue": true, + "usage": { + "tokensUsed": 13828, + "activeSeconds": 2 + }, + "sisyphus": false, + "createdAt": "2026-09-23T20:35:06.093Z", + "updatedAt": "2026-09-23T20:35:10.977Z", + "activePath": ".pi/goals/active_goal_2026092322350609_muekaval-uhhfg8.md", + "revision": 11, + "scheduler": { + "version": 1, + "owner": "01a0cffa-99a6-7e64-888a-debde9407a25", + "generation": "d4598e43-6c04-47ab-a004-0f335da65426", + "used": 0, + "phase": "idle", + "repairUsed": false + }, + "taskList": { + "tasks": [ + { + "id": "task-1", + "title": "Inspect workspace", + "status": "complete", + "completedAt": "2026-09-23T20:35:10.976Z" + }, + { + "id": "task-2", + "title": "Summarize findings", + "status": "pending" + } + ], + "blockCompletion": false, + "proposedAt": "2026-09-23T20:35:08.559Z" + } + } + }, + "isError": false, + "timestamp": 1790195710978 + } + ] +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-goal-x/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/pi-goal-x/index.test.ts new file mode 100644 index 0000000000..14de2391a3 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/pi-goal-x/index.test.ts @@ -0,0 +1,15 @@ +import { expect, test } from "vitest"; +import { capturedToolCall, verifyTaskFixture } from "../task-fixture-test.js"; +import { piGoalX } from "./index.js"; + +test("pi-goal-x maps captured RPC task tree through live and history", async () => { + await verifyTaskFixture(new URL("./fixtures/rpc-session.json", import.meta.url), [ + "pending,pending", + "completed,pending", + ]); +}); + +test("pi-goal-x declines a foreign goal shape", () => { + const call = capturedToolCall(new URL("../rpiv-todo/fixtures/rpc-session.json", import.meta.url)); + expect(piGoalX.createSession().mapToolCall?.({ ...call, toolName: "get_goal" })).toBeUndefined(); +}); diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-goal-x/index.ts b/packages/server/src/server/agent/providers/pi/extensions/pi-goal-x/index.ts new file mode 100644 index 0000000000..581d7096d1 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/pi-goal-x/index.ts @@ -0,0 +1,73 @@ +import { z } from "zod"; +import type { AgentTaskItem } from "../../../../agent-sdk-types.js"; +import type { PiExtension } from "../contract.js"; + +interface GoalTask { + id: string; + title: string; + status: "pending" | "complete" | "skipped"; + subtasks?: GoalTask[]; +} +const Task: z.ZodType = z.lazy(() => + z.object({ + id: z.string(), + title: z.string(), + status: z.enum(["pending", "complete", "skipped"]), + subtasks: z.array(Task).optional(), + }), +); +const Details = z.object({ + version: z.literal(3), + goal: z + .object({ + currentTaskId: z.string().optional(), + taskList: z.object({ tasks: z.array(Task) }).optional(), + }) + .nullable(), +}); +const toolNames = new Set([ + "create_goal", + "get_goal", + "update_goal", + "set_goal_tasks", + "update_goal_task", +]); + +function toItems(tasks: GoalTask[], currentTaskId?: string): AgentTaskItem[] { + return tasks.flatMap((task) => { + let status: AgentTaskItem["status"] = "pending"; + if (task.status !== "pending") status = "completed"; + else if (task.id === currentTaskId) status = "in_progress"; + return [ + { + id: task.id, + text: task.title, + status, + completed: task.status !== "pending", + }, + ...toItems(task.subtasks ?? [], currentTaskId), + ]; + }); +} + +export const piGoalX: PiExtension = { + id: "pi-goal-x", + createSession: () => ({ + mapToolCall(call) { + if ( + !toolNames.has(call.toolName) || + call.status !== "completed" || + !call.result || + typeof call.result === "string" + ) + return undefined; + const parsed = Details.safeParse(call.result.details); + if (!parsed.success) return undefined; + const goal = parsed.data.goal; + if (!goal?.taskList) return undefined; + return { + timeline: [{ type: "todo", items: toItems(goal.taskList.tasks, goal.currentTaskId) }], + }; + }, + }), +}; diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-mcp-adapter/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/pi-mcp-adapter/index.test.ts new file mode 100644 index 0000000000..5316d6f6be --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/pi-mcp-adapter/index.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "vitest"; +import { createPiExtensionHost } from "../index.js"; +import { + parseToolArgs, + parseToolResult, + type PiToolResult, + type PiTrackedToolCall, +} from "../../tool-call-mapper.js"; + +function mapping(toolCall: PiTrackedToolCall, result: PiToolResult) { + return createPiExtensionHost().mapToolCall({ + callId: "test-call", + toolName: toolCall.toolName, + args: toolCall.args, + status: result ? "completed" : "running", + result, + }); +} +function resolveToolCallName(toolCall: PiTrackedToolCall, result: PiToolResult) { + return mapping(toolCall, result)?.name ?? toolCall.toolName; +} + +describe("pi-mcp-adapter adapter", () => { + test("normalizes Pi MCP proxy calls from requested tool args while running", () => { + const toolCall = parseToolArgs("mcp", { + tool: "paseo_list_models", + args: '{"provider":"pi"}', + }); + + expect(resolveToolCallName(toolCall, null)).toBe("paseo.list_models"); + }); + + test("normalizes Pi MCP proxy calls from result details when completed", () => { + const toolCall = parseToolArgs("mcp", { + tool: "paseo_list_models", + args: '{"provider":"pi"}', + }); + const result = parseToolResult({ + content: [{ type: "text", text: "(empty result)" }], + details: { + mode: "call", + server: "paseo", + tool: "list_models", + }, + }); + + expect(resolveToolCallName(toolCall, result)).toBe("paseo.list_models"); + }); +}); diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-mcp-adapter/index.ts b/packages/server/src/server/agent/providers/pi/extensions/pi-mcp-adapter/index.ts new file mode 100644 index 0000000000..c36e18731f --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/pi-mcp-adapter/index.ts @@ -0,0 +1,46 @@ +import { z } from "zod"; +import type { PiExtension } from "../contract.js"; + +const Args = z + .object({ + tool: z.string().optional().catch(undefined), + server: z.string().optional().catch(undefined), + }) + .passthrough(); +const Details = z + .object({ + server: z.string().optional().catch(undefined), + tool: z.string().optional().catch(undefined), + }) + .passthrough(); +const nonEmpty = (value: string | undefined) => value?.trim() || undefined; + +export const piMcpAdapter: PiExtension = { + id: "pi-mcp-adapter", + createSession: () => ({ + mapToolCall(call) { + if (call.toolName !== "mcp") return undefined; + if (call.result && typeof call.result !== "string") { + const details = Details.safeParse(call.result.details); + if (details.success) { + const server = nonEmpty(details.data.server); + const tool = nonEmpty(details.data.tool); + if (server && tool) return { name: `${server}.${tool}` }; + } + } + const args = Args.safeParse(call.args); + if (!args.success) return undefined; + const tool = nonEmpty(args.data.tool); + const server = nonEmpty(args.data.server); + if (tool && server) + return { + name: `${server}.${tool.startsWith(`${server}_`) ? tool.slice(server.length + 1) : tool}`, + }; + if (tool) { + const [prefix, ...rest] = tool.split("_"); + if (prefix && rest.length > 0) return { name: `${prefix}.${rest.join("_")}` }; + } + return undefined; + }, + }), +}; diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/fixtures/background.json b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/fixtures/background.json new file mode 100644 index 0000000000..e5f9c9f770 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/fixtures/background.json @@ -0,0 +1,238 @@ +{ + "provenance": { + "package": "pi-subagents", + "version": "0.71.0", + "sourceCommit": "4af5e85a427b9f87334585ae8d0eb365d4dd2a1e", + "piVersion": "0.84.4", + "captureDate": "2026-09-23" + }, + "events": [ + { + "type": "tool_execution_start", + "toolCallId": "call_1334451", + "toolName": "subagent", + "args": { + "task": "list files in this empty workspace and report", + "agent": "scout", + "async": true + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_1334451", + "toolName": "subagent", + "result": { + "content": [ + { + "type": "text", + "text": "Run fan-out: 1/64 used, 63 remaining\nAsync: scout [005cfec2-1376-4e96-b622-5ad8f4c15cb5]\n\nThe async run is detached and running in the background.\nYou are in an interactive session. Return control to the user now; Pi will wake you through the native completion notification when this subagent completes or needs attention. Do not run sleep/polling loops to wait for this async subagent; it does not need a wait call.\nUse bg_wait only for provider, detached, or other background work that lacks a native completion notification.\nIf the current turn must receive results from work without a native notification before it ends, call blocking bg_wait(); ordinary async subagent runs do not need a wait call because their completion is delivered natively.\nOtherwise, continue any independent work or return control to the user. Use subagent({ action: \"status\", id: \"...\" }) for a one-shot status/result or to inspect a blocked/stale run, never as a wait loop.\nMission: bc1a1172-b106-4b35-9325-9fd304278827 (active)" + } + ], + "details": { + "mode": "single", + "runId": "005cfec2-1376-4e96-b622-5ad8f4c15cb5", + "results": [], + "asyncId": "005cfec2-1376-4e96-b622-5ad8f4c15cb5", + "asyncDir": "/tmp/pi-subagents-uid-1000/async-subagent-runs/005cfec2-1376-4e96-b622-5ad8f4c15cb5", + "launchContractDigest": "d7ed0cb6c99feb265efdaf7294fdac2e9eceacf3560c3cf8fd5a7fa22ea4dac1", + "launchResolvedExtensions": { + "version": 1, + "source": "launch-resolved", + "disableAmbientExtensions": false, + "runtime": ["sha256:c41552bd9557eaab"], + "configured": [], + "required": [], + "effective": ["sha256:c41552bd9557eaab"], + "omitted": { + "runtime": 0, + "configured": 0, + "required": 0, + "effective": 0 + } + }, + "context": "fresh", + "timeoutMs": 1800000, + "deadlineAt": 1790199499998, + "runFanoutBudget": { + "used": 1, + "limit": 64, + "remaining": 63 + }, + "missionId": "bc1a1172-b106-4b35-9325-9fd304278827", + "missionPath": "/tmp/paseo-pi-extension-phase3/pi-agent/missions/projects/8618955a5c85eb731bdb33d90016edaaf64963be032225b8a26a6936621a378d/bc1a1172-b106-4b35-9325-9fd304278827.json", + "mission": { + "schemaVersion": 1, + "id": "bc1a1172-b106-4b35-9325-9fd304278827", + "title": "[prompt redacted]", + "objective": "[prompt redacted]", + "status": "active", + "createdAt": "2026-09-23T21:08:19.994Z", + "updatedAt": "2026-09-23T21:08:20.008Z", + "runs": [ + { + "runId": "005cfec2-1376-4e96-b622-5ad8f4c15cb5", + "mode": "single", + "asyncDir": "/tmp/pi-subagents-uid-1000/async-subagent-runs/005cfec2-1376-4e96-b622-5ad8f4c15cb5", + "status": "active", + "startedAt": "2026-09-23T21:08:20.008Z" + } + ], + "workflowChildren": [], + "decisions": [], + "artifacts": [ + { + "kind": "status", + "path": "/tmp/pi-subagents-uid-1000/async-subagent-runs/005cfec2-1376-4e96-b622-5ad8f4c15cb5/status.json" + }, + { + "kind": "other", + "path": "/tmp/pi-subagents-uid-1000/async-subagent-runs/005cfec2-1376-4e96-b622-5ad8f4c15cb5/events.jsonl", + "description": "Lifecycle events" + } + ], + "receipts": [], + "cwd": "/tmp/paseo-pi-extension-phase3/workspace", + "ownerSessionId": "/tmp/paseo-pi-extension-phase3/sessions-nico/2026-09-23T21-08-13-722Z_01a0d018-fd1a-7711-9c17-933fd34f0cff.jsonl" + } + } + }, + "isError": false + }, + { + "type": "message_end", + "message": { + "role": "custom", + "customType": "subagent-notify", + "content": "Background task completed: **scout**\n\nscout:\n# Code Context\n\n## Files Retrieved\n(No files found; the workspace directory `/tmp/paseo-pi-extension-phase3/workspace` is empty.)\n\n## Key Code\nN/A - Empty repository / workspace.\n\n## Architecture\nN/A - Empty repository / workspace.\n\n## Start Here\nThe workspace is currently empty. Initialize or populate the workspace with source code to begin development.\n\nOutput saved to: /tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/005cfec2-1376-4e96-b622-5ad8f4c15cb5/context.md (356 B, 13 lines). Read this file if needed.\n\nRetention-managed async directory: /tmp/pi-subagents-uid-1000/async-subagent-runs/005cfec2-1376-4e96-b622-5ad8f4c15cb5\n\nSession file: /tmp/paseo-pi-extension-phase3/sessions-nico/2026-09-23T21-08-13-722Z_01a0d018-fd1a-7711-9c17-933fd34f0cff/2a37ef22-e1f9-4f75-b9a0-510e82ffd422/run-0/session.jsonl", + "display": false, + "timestamp": 1790197711046 + } + } + ], + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "call_1334451", + "name": "subagent", + "arguments": { + "task": "list files in this empty workspace and report", + "agent": "scout", + "async": true + } + } + ], + "api": "openai-completions", + "provider": "openrouter", + "model": "google/gemini-3.6-flash", + "usage": { + "input": 3303, + "output": 114, + "cacheRead": 7053, + "cacheWrite": 0, + "reasoning": 81, + "totalTokens": 10470, + "cost": { + "input": 0.00247725, + "output": 0.00042750000000000004, + "cacheRead": 0.000528975, + "cacheWrite": 0, + "total": 0.003433725 + } + }, + "stopReason": "toolUse", + "timestamp": 1790197697608, + "responseId": "gen-1790197697-lxluaTrDkdaxjSXsO957", + "rawStopReason": "tool_calls" + }, + { + "role": "toolResult", + "toolCallId": "call_1334451", + "toolName": "subagent", + "content": [ + { + "type": "text", + "text": "Run fan-out: 1/64 used, 63 remaining\nAsync: scout [005cfec2-1376-4e96-b622-5ad8f4c15cb5]\n\nThe async run is detached and running in the background.\nYou are in an interactive session. Return control to the user now; Pi will wake you through the native completion notification when this subagent completes or needs attention. Do not run sleep/polling loops to wait for this async subagent; it does not need a wait call.\nUse bg_wait only for provider, detached, or other background work that lacks a native completion notification.\nIf the current turn must receive results from work without a native notification before it ends, call blocking bg_wait(); ordinary async subagent runs do not need a wait call because their completion is delivered natively.\nOtherwise, continue any independent work or return control to the user. Use subagent({ action: \"status\", id: \"...\" }) for a one-shot status/result or to inspect a blocked/stale run, never as a wait loop.\nMission: bc1a1172-b106-4b35-9325-9fd304278827 (active)" + } + ], + "details": { + "mode": "single", + "runId": "005cfec2-1376-4e96-b622-5ad8f4c15cb5", + "results": [], + "asyncId": "005cfec2-1376-4e96-b622-5ad8f4c15cb5", + "asyncDir": "/tmp/pi-subagents-uid-1000/async-subagent-runs/005cfec2-1376-4e96-b622-5ad8f4c15cb5", + "launchContractDigest": "d7ed0cb6c99feb265efdaf7294fdac2e9eceacf3560c3cf8fd5a7fa22ea4dac1", + "launchResolvedExtensions": { + "version": 1, + "source": "launch-resolved", + "disableAmbientExtensions": false, + "runtime": ["sha256:c41552bd9557eaab"], + "configured": [], + "required": [], + "effective": ["sha256:c41552bd9557eaab"], + "omitted": { + "runtime": 0, + "configured": 0, + "required": 0, + "effective": 0 + } + }, + "context": "fresh", + "timeoutMs": 1800000, + "deadlineAt": 1790199499998, + "runFanoutBudget": { + "used": 1, + "limit": 64, + "remaining": 63 + }, + "missionId": "bc1a1172-b106-4b35-9325-9fd304278827", + "missionPath": "/tmp/paseo-pi-extension-phase3/pi-agent/missions/projects/8618955a5c85eb731bdb33d90016edaaf64963be032225b8a26a6936621a378d/bc1a1172-b106-4b35-9325-9fd304278827.json", + "mission": { + "schemaVersion": 1, + "id": "bc1a1172-b106-4b35-9325-9fd304278827", + "title": "[prompt redacted]", + "objective": "[prompt redacted]", + "status": "active", + "createdAt": "2026-09-23T21:08:19.994Z", + "updatedAt": "2026-09-23T21:08:20.008Z", + "runs": [ + { + "runId": "005cfec2-1376-4e96-b622-5ad8f4c15cb5", + "mode": "single", + "asyncDir": "/tmp/pi-subagents-uid-1000/async-subagent-runs/005cfec2-1376-4e96-b622-5ad8f4c15cb5", + "status": "active", + "startedAt": "2026-09-23T21:08:20.008Z" + } + ], + "workflowChildren": [], + "decisions": [], + "artifacts": [ + { + "kind": "status", + "path": "/tmp/pi-subagents-uid-1000/async-subagent-runs/005cfec2-1376-4e96-b622-5ad8f4c15cb5/status.json" + }, + { + "kind": "other", + "path": "/tmp/pi-subagents-uid-1000/async-subagent-runs/005cfec2-1376-4e96-b622-5ad8f4c15cb5/events.jsonl", + "description": "Lifecycle events" + } + ], + "receipts": [], + "cwd": "/tmp/paseo-pi-extension-phase3/workspace", + "ownerSessionId": "/tmp/paseo-pi-extension-phase3/sessions-nico/2026-09-23T21-08-13-722Z_01a0d018-fd1a-7711-9c17-933fd34f0cff.jsonl" + } + }, + "isError": false, + "timestamp": 1790197700011 + }, + { + "role": "custom", + "customType": "subagent-notify", + "content": "Background task completed: **scout**\n\nscout:\n# Code Context\n\n## Files Retrieved\n(No files found; the workspace directory `/tmp/paseo-pi-extension-phase3/workspace` is empty.)\n\n## Key Code\nN/A - Empty repository / workspace.\n\n## Architecture\nN/A - Empty repository / workspace.\n\n## Start Here\nThe workspace is currently empty. Initialize or populate the workspace with source code to begin development.\n\nOutput saved to: /tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/005cfec2-1376-4e96-b622-5ad8f4c15cb5/context.md (356 B, 13 lines). Read this file if needed.\n\nRetention-managed async directory: /tmp/pi-subagents-uid-1000/async-subagent-runs/005cfec2-1376-4e96-b622-5ad8f4c15cb5\n\nSession file: /tmp/paseo-pi-extension-phase3/sessions-nico/2026-09-23T21-08-13-722Z_01a0d018-fd1a-7711-9c17-933fd34f0cff/2a37ef22-e1f9-4f75-b9a0-510e82ffd422/run-0/session.jsonl", + "display": false, + "timestamp": 1790197711046 + } + ] +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/fixtures/child-session.jsonl b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/fixtures/child-session.jsonl new file mode 100644 index 0000000000..4c91517bc7 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/fixtures/child-session.jsonl @@ -0,0 +1,6 @@ +{"type":"message","id":"e795eaf5","parentId":"1246b701","timestamp":"2026-09-23T21:07:15.911Z","message":{"role":"user","content":[{"type":"text","text":"Task: list the files in the current empty workspace\n\n---\n**Output:**\nWrite your findings to exactly this path: /tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/679ba95f-8fae-466c-a889-6717de1dc2f0/context.md\nThis path is authoritative for this run.\nIgnore any other output filename or output path mentioned elsewhere, including output destinations in the base agent prompt, system prompt, or task instructions."}],"timestamp":1790197635909}} +{"type":"message","id":"9c3e635a","parentId":"e795eaf5","timestamp":"2026-09-23T21:07:17.023Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"","thinkingSignature":"[{\"type\":\"reasoning.encrypted\",\"data\":\"AY89a18r+r0Pz/qlYHWE7rzBu05Qld2Nrf1tWvryqCbkr58iwJN1Xd5cBJXIWN9eGQldY3pt9HBzDHMx7+0/FrcUJrNRI9pMFmZ1+RX/\",\"format\":\"google-gemini-v1\",\"id\":\"call_1077973\",\"index\":0}]"},{"type":"toolCall","id":"call_1077973","name":"ls","arguments":{"path":"."}}],"api":"openai-completions","provider":"openrouter","model":"google/gemini-3.6-flash","usage":{"input":2269,"output":14,"cacheRead":0,"cacheWrite":0,"reasoning":0,"totalTokens":2283,"cost":{"input":0.0017017500000000001,"output":0.0000525,"cacheRead":0,"cacheWrite":0,"total":0.0017542500000000002}},"stopReason":"toolUse","timestamp":1790197635958,"responseId":"gen-1790197636-iGm6QGfRbBF1JVOGTV3F","rawStopReason":"tool_calls"}} +{"type":"message","id":"5dc50c52","parentId":"9c3e635a","timestamp":"2026-09-23T21:07:17.025Z","message":{"role":"toolResult","toolCallId":"call_1077973","toolName":"ls","content":[{"type":"text","text":"(empty directory)"}],"isError":false,"timestamp":1790197637025}} +{"type":"message","id":"ccd5b53b","parentId":"5dc50c52","timestamp":"2026-09-23T21:07:18.699Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"","thinkingSignature":"[{\"type\":\"reasoning.encrypted\",\"data\":\"AY89a1/cR3gdBgtKF94+BF+RZ/GhtIJDc1LiOKIgJzdWTU0PUyGthIMRtW3RcTe+vmD6jxfZpiQnLfpVHFF2u0nRbYKUS2d2JdYvEYoP\",\"format\":\"google-gemini-v1\",\"id\":\"call_509063\",\"index\":0}]"},{"type":"toolCall","id":"call_509063","name":"write","arguments":{"content":"# Code Context\n\n## Files Retrieved\nWorkspace directory `/tmp/paseo-pi-extension-phase3/workspace` was inspected using `ls`. No files found.\n\n## Key Code\nN/A - The workspace is currently empty.\n\n## Architecture\nN/A - No code structure or files exist in the workspace yet.\n\n## Start Here\nN/A - Workspace is empty.\n","path":"/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/679ba95f-8fae-466c-a889-6717de1dc2f0/context.md"}}],"api":"openai-completions","provider":"openrouter","model":"google/gemini-3.6-flash","usage":{"input":2286,"output":161,"cacheRead":0,"cacheWrite":0,"reasoning":0,"totalTokens":2447,"cost":{"input":0.0017145,"output":0.00060375,"cacheRead":0,"cacheWrite":0,"total":0.00231825}},"stopReason":"toolUse","timestamp":1790197637026,"responseId":"gen-1790197637-JwIo6AVv6AVYwzaK9hmY","rawStopReason":"tool_calls"}} +{"type":"message","id":"5ae9c4b7","parentId":"ccd5b53b","timestamp":"2026-09-23T21:07:18.701Z","message":{"role":"toolResult","toolCallId":"call_509063","toolName":"write","content":[{"type":"text","text":"Successfully wrote 312 bytes to /tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/679ba95f-8fae-466c-a889-6717de1dc2f0/context.md"}],"isError":false,"timestamp":1790197638701}} +{"type":"message","id":"034e2862","parentId":"5ae9c4b7","timestamp":"2026-09-23T21:07:21.572Z","message":{"role":"assistant","content":[{"type":"text","text":"I have inspected the current workspace and confirmed that it is an empty directory. The scouting context documentation has been written to `/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/679ba95f-8fae-466c-a889-6717de1dc2f0/context.md`.\n\n# Code Context Summary\n\n## Files Retrieved\n- `.`: Directory contents checked via `ls`, confirmed empty directory.\n\n## Key Code\n- None (workspace is empty).\n\n## Architecture\n- N/A\n\n## Start Here\n- N/A\n\n```acceptance-report\n{\n \"criteriaSatisfied\": [\n {\n \"id\": \"criterion-1\",\n \"status\": \"satisfied\",\n \"evidence\": \"Listed current workspace using ls and confirmed it is empty; output written to designated context path.\"\n }\n ],\n \"changedFiles\": [],\n \"testsAddedOrUpdated\": [],\n \"commandsRun\": [],\n \"validationOutput\": [\n \"Directory contents: (empty directory)\"\n ],\n \"residualRisks\": [\n \"Workspace is empty\"\n ],\n \"noStagedFiles\": true,\n \"diffSummary\": \"No source files changed. Output written to subagent artifacts path.\",\n \"reviewFindings\": [\n \"No code issues; directory is empty.\"\n ],\n \"manualNotes\": \"Listed current workspace directory and generated required context artifact.\"\n}\n```"}],"api":"openai-completions","provider":"openrouter","model":"google/gemini-3.6-flash","usage":{"input":2510,"output":336,"cacheRead":0,"cacheWrite":0,"reasoning":0,"totalTokens":2846,"cost":{"input":0.0018825,"output":0.00126,"cacheRead":0,"cacheWrite":0,"total":0.0031425000000000003}},"stopReason":"stop","timestamp":1790197638701,"responseId":"gen-1790197638-a2hJ13SAsm2PpAbEH64L","rawStopReason":"stop"}} diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/fixtures/foreground.json b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/fixtures/foreground.json new file mode 100644 index 0000000000..e48353293e --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/fixtures/foreground.json @@ -0,0 +1,619 @@ +{ + "provenance": { + "package": "pi-subagents", + "version": "0.71.0", + "sourceCommit": "4af5e85a427b9f87334585ae8d0eb365d4dd2a1e", + "piVersion": "0.84.4", + "captureDate": "2026-09-23" + }, + "events": [ + { + "type": "tool_execution_start", + "toolCallId": "call_930237", + "toolName": "subagent", + "args": { + "async": false, + "agent": "scout", + "task": "list the files in the current empty workspace" + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_930237", + "toolName": "subagent", + "result": { + "content": [ + { + "type": "text", + "text": "Run fan-out: 1/64 used, 63 remaining\n# Code Context\n\n## Files Retrieved\nWorkspace directory `/tmp/paseo-pi-extension-phase3/workspace` was inspected using `ls`. No files found.\n\n## Key Code\nN/A - The workspace is currently empty.\n\n## Architecture\nN/A - No code structure or files exist in the workspace yet.\n\n## Start Here\nN/A - Workspace is empty.\n\nOutput saved to: /tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/679ba95f-8fae-466c-a889-6717de1dc2f0/context.md (311 B, 13 lines). Read this file if needed.\nMission: 17cc387f-50eb-4a34-bb98-4452434c1274 (completed)" + } + ], + "details": { + "mode": "single", + "runId": "679ba95f-8fae-466c-a889-6717de1dc2f0", + "timeoutMs": 1800000, + "results": [ + { + "index": 0, + "agent": "scout", + "task": "[prompt redacted]", + "sessionName": "scout: list the files in the current empty workspace --- **Outpu...", + "launchContractDigest": "017d16009783e723e7ceb19f4895d8a91d9ac4b1cade1ca0f5f50c8fbbac8a41", + "launchResolvedExtensions": { + "version": 1, + "source": "launch-resolved", + "disableAmbientExtensions": true, + "runtime": ["sha256:c41552bd9557eaab"], + "configured": [], + "required": [], + "effective": ["sha256:c41552bd9557eaab"], + "omitted": { + "runtime": 0, + "configured": 0, + "required": 0, + "effective": 0 + } + }, + "exitCode": 0, + "outputState": "present", + "usage": { + "input": 7065, + "output": 511, + "cacheRead": 0, + "cacheWrite": 0, + "cost": 0.007215, + "turns": 3 + }, + "model": "openrouter/google/gemini-3.6-flash:low", + "thinking": "low", + "artifactPaths": { + "inputPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_input.md", + "outputPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_output.md", + "jsonlPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0.jsonl", + "transcriptPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_transcript.jsonl", + "metadataPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_meta.json" + }, + "transcriptPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_transcript.jsonl", + "context": "fresh", + "progressSummary": { + "sessionName": "scout: list the files in the current empty workspace --- **Outpu...", + "toolCount": 2, + "tokens": 7576, + "durationMs": 6330 + }, + "savedOutputPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/679ba95f-8fae-466c-a889-6717de1dc2f0/context.md", + "outputReference": { + "path": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/679ba95f-8fae-466c-a889-6717de1dc2f0/context.md", + "bytes": 311, + "lines": 13, + "message": "Output saved to: /tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/679ba95f-8fae-466c-a889-6717de1dc2f0/context.md (311 B, 13 lines). Read this file if needed." + }, + "outputMode": "inline", + "finalOutput": "# Code Context\n\n## Files Retrieved\nWorkspace directory `/tmp/paseo-pi-extension-phase3/workspace` was inspected using `ls`. No files found.\n\n## Key Code\nN/A - The workspace is currently empty.\n\n## Architecture\nN/A - No code structure or files exist in the workspace yet.\n\n## Start Here\nN/A - Workspace is empty.", + "sessionFile": "/tmp/paseo-pi-extension-phase3/sessions-nico/2026-09-23T21-07-08-019Z_01a0d017-fc73-7e52-b822-9235801e5403/679ba95f-8fae-466c-a889-6717de1dc2f0/run-0/session.jsonl", + "acceptance": { + "status": "attested", + "evidenceStatus": "attested", + "explicit": false, + "effectiveAcceptance": { + "level": "attested", + "explicit": false, + "inferredReason": ["default lightweight attestation"], + "criteria": [ + { + "id": "criterion-1", + "must": "Return a concise result and residual risks when applicable", + "evidence": ["manual-notes", "residual-risks"], + "severity": "required" + } + ], + "evidence": ["manual-notes", "residual-risks"], + "verify": [], + "stopRules": [] + }, + "inferredReason": ["default lightweight attestation"], + "criteria": [ + { + "id": "criterion-1", + "must": "Return a concise result and residual risks when applicable", + "evidence": ["manual-notes", "residual-risks"], + "severity": "required" + } + ], + "runtimeChecks": [], + "verifyRuns": [], + "childReport": { + "criteriaSatisfied": [ + { + "id": "criterion-1", + "status": "satisfied", + "evidence": "Listed current workspace using ls and confirmed it is empty; output written to designated context path." + } + ], + "changedFiles": [], + "testsAddedOrUpdated": [], + "commandsRun": [], + "validationOutput": ["Directory contents: (empty directory)"], + "residualRisks": ["Workspace is empty"], + "noStagedFiles": true, + "diffSummary": "No source files changed. Output written to subagent artifacts path.", + "reviewFindings": ["No code issues; directory is empty."], + "manualNotes": "Listed current workspace directory and generated required context artifact." + } + }, + "toolCalls": [ + { + "text": "ls {\"path\":\".\"}", + "expandedText": "ls {\"path\":\".\"}" + }, + { + "text": "write /tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/679ba95f-8fae-466c-a889-6717de1dc2f0/context.md", + "expandedText": "write /tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/679ba95f-8fae-466c-a889-6717de1dc2f0/context.md" + } + ] + } + ], + "artifacts": { + "dir": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts", + "files": [ + { + "inputPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_input.md", + "outputPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_output.md", + "jsonlPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0.jsonl", + "transcriptPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_transcript.jsonl", + "metadataPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_meta.json" + } + ] + }, + "totalChildUsage": { + "input": 7065, + "output": 511, + "cacheRead": 0, + "cacheWrite": 0, + "cost": 0.007215, + "turns": 3 + }, + "totalCost": { + "inputTokens": 7065, + "outputTokens": 511, + "costUsd": 0.007215 + }, + "context": "fresh", + "runFanoutBudget": { + "used": 1, + "limit": 64, + "remaining": 63 + }, + "missionId": "17cc387f-50eb-4a34-bb98-4452434c1274", + "missionPath": "/tmp/paseo-pi-extension-phase3/pi-agent/missions/projects/8618955a5c85eb731bdb33d90016edaaf64963be032225b8a26a6936621a378d/17cc387f-50eb-4a34-bb98-4452434c1274.json", + "mission": { + "schemaVersion": 1, + "id": "17cc387f-50eb-4a34-bb98-4452434c1274", + "title": "[prompt redacted]", + "objective": "[prompt redacted]", + "status": "completed", + "createdAt": "2026-09-23T21:07:15.238Z", + "updatedAt": "2026-09-23T21:07:21.578Z", + "runs": [ + { + "runId": "679ba95f-8fae-466c-a889-6717de1dc2f0", + "mode": "single", + "agent": "scout", + "status": "completed", + "startedAt": "2026-09-23T21:07:21.578Z", + "completedAt": "2026-09-23T21:07:21.578Z", + "usage": { + "tokens": 7576 + } + } + ], + "workflowChildren": [], + "decisions": [], + "artifacts": [ + { + "kind": "output", + "path": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_output.md" + }, + { + "kind": "output", + "path": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/679ba95f-8fae-466c-a889-6717de1dc2f0/context.md" + }, + { + "kind": "other", + "path": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_transcript.jsonl", + "description": "Child transcript" + } + ], + "receipts": [], + "cwd": "/tmp/paseo-pi-extension-phase3/workspace", + "ownerSessionId": "/tmp/paseo-pi-extension-phase3/sessions-nico/2026-09-23T21-07-08-019Z_01a0d017-fc73-7e52-b822-9235801e5403.jsonl", + "summary": "Run fan-out: 1/64 used, 63 remaining\n# Code Context\n\n## Files Retrieved\nWorkspace directory `/tmp/paseo-pi-extension-phase3/workspace` was inspected using `ls`. No files found.\n\n## Key Code\nN/A - The workspace is currently empty.\n\n## Architecture\nN/A - No code structure or files exist in the workspace yet.\n\n## Start Here\nN/A - Workspace is empty.\n\nOutput saved to: /tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/679ba95f-8fae-466c-a889-6717de1dc2f0/context.md (311 B, 13 lines). Read this file if needed.", + "acceptance": { + "status": "attested", + "evidenceStatus": "attested", + "explicit": false, + "effectiveAcceptance": { + "level": "attested", + "explicit": false, + "inferredReason": ["default lightweight attestation"], + "criteria": [ + { + "id": "criterion-1", + "must": "Return a concise result and residual risks when applicable", + "evidence": ["manual-notes", "residual-risks"], + "severity": "required" + } + ], + "evidence": ["manual-notes", "residual-risks"], + "verify": [], + "stopRules": [] + }, + "inferredReason": ["default lightweight attestation"], + "criteria": [ + { + "id": "criterion-1", + "must": "Return a concise result and residual risks when applicable", + "evidence": ["manual-notes", "residual-risks"], + "severity": "required" + } + ], + "runtimeChecks": [], + "verifyRuns": [], + "childReport": { + "criteriaSatisfied": [ + { + "id": "criterion-1", + "status": "satisfied", + "evidence": "Listed current workspace using ls and confirmed it is empty; output written to designated context path." + } + ], + "changedFiles": [], + "testsAddedOrUpdated": [], + "commandsRun": [], + "validationOutput": ["Directory contents: (empty directory)"], + "residualRisks": ["Workspace is empty"], + "noStagedFiles": true, + "diffSummary": "No source files changed. Output written to subagent artifacts path.", + "reviewFindings": ["No code issues; directory is empty."], + "manualNotes": "Listed current workspace directory and generated required context artifact." + } + } + } + }, + "usage": { + "input": 7065, + "output": 511, + "cacheRead": 0, + "cacheWrite": 0, + "totalTokens": 7576, + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0, + "total": 0.007215 + } + } + }, + "isError": false + } + ], + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "call_930237", + "name": "subagent", + "arguments": { + "async": false, + "agent": "scout", + "task": "list the files in the current empty workspace" + } + } + ], + "api": "openai-completions", + "provider": "openrouter", + "model": "google/gemini-3.6-flash", + "usage": { + "input": 3368, + "output": 130, + "cacheRead": 7060, + "cacheWrite": 0, + "reasoning": 97, + "totalTokens": 10558, + "cost": { + "input": 0.002526, + "output": 0.00048750000000000003, + "cacheRead": 0.0005295, + "cacheWrite": 0, + "total": 0.003543 + } + }, + "stopReason": "toolUse", + "timestamp": 1790197632837, + "responseId": "gen-1790197632-7WtHYojgcq3aQeXi5a9Y", + "rawStopReason": "tool_calls" + }, + { + "role": "toolResult", + "toolCallId": "call_930237", + "toolName": "subagent", + "content": [ + { + "type": "text", + "text": "Run fan-out: 1/64 used, 63 remaining\n# Code Context\n\n## Files Retrieved\nWorkspace directory `/tmp/paseo-pi-extension-phase3/workspace` was inspected using `ls`. No files found.\n\n## Key Code\nN/A - The workspace is currently empty.\n\n## Architecture\nN/A - No code structure or files exist in the workspace yet.\n\n## Start Here\nN/A - Workspace is empty.\n\nOutput saved to: /tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/679ba95f-8fae-466c-a889-6717de1dc2f0/context.md (311 B, 13 lines). Read this file if needed.\nMission: 17cc387f-50eb-4a34-bb98-4452434c1274 (completed)" + } + ], + "details": { + "mode": "single", + "runId": "679ba95f-8fae-466c-a889-6717de1dc2f0", + "timeoutMs": 1800000, + "results": [ + { + "index": 0, + "agent": "scout", + "task": "[prompt redacted]", + "sessionName": "scout: list the files in the current empty workspace --- **Outpu...", + "launchContractDigest": "017d16009783e723e7ceb19f4895d8a91d9ac4b1cade1ca0f5f50c8fbbac8a41", + "launchResolvedExtensions": { + "version": 1, + "source": "launch-resolved", + "disableAmbientExtensions": true, + "runtime": ["sha256:c41552bd9557eaab"], + "configured": [], + "required": [], + "effective": ["sha256:c41552bd9557eaab"], + "omitted": { + "runtime": 0, + "configured": 0, + "required": 0, + "effective": 0 + } + }, + "exitCode": 0, + "outputState": "present", + "usage": { + "input": 7065, + "output": 511, + "cacheRead": 0, + "cacheWrite": 0, + "cost": 0.007215, + "turns": 3 + }, + "model": "openrouter/google/gemini-3.6-flash:low", + "thinking": "low", + "artifactPaths": { + "inputPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_input.md", + "outputPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_output.md", + "jsonlPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0.jsonl", + "transcriptPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_transcript.jsonl", + "metadataPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_meta.json" + }, + "transcriptPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_transcript.jsonl", + "context": "fresh", + "progressSummary": { + "sessionName": "scout: list the files in the current empty workspace --- **Outpu...", + "toolCount": 2, + "tokens": 7576, + "durationMs": 6330 + }, + "savedOutputPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/679ba95f-8fae-466c-a889-6717de1dc2f0/context.md", + "outputReference": { + "path": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/679ba95f-8fae-466c-a889-6717de1dc2f0/context.md", + "bytes": 311, + "lines": 13, + "message": "Output saved to: /tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/679ba95f-8fae-466c-a889-6717de1dc2f0/context.md (311 B, 13 lines). Read this file if needed." + }, + "outputMode": "inline", + "finalOutput": "# Code Context\n\n## Files Retrieved\nWorkspace directory `/tmp/paseo-pi-extension-phase3/workspace` was inspected using `ls`. No files found.\n\n## Key Code\nN/A - The workspace is currently empty.\n\n## Architecture\nN/A - No code structure or files exist in the workspace yet.\n\n## Start Here\nN/A - Workspace is empty.", + "sessionFile": "/tmp/paseo-pi-extension-phase3/sessions-nico/2026-09-23T21-07-08-019Z_01a0d017-fc73-7e52-b822-9235801e5403/679ba95f-8fae-466c-a889-6717de1dc2f0/run-0/session.jsonl", + "acceptance": { + "status": "attested", + "evidenceStatus": "attested", + "explicit": false, + "effectiveAcceptance": { + "level": "attested", + "explicit": false, + "inferredReason": ["default lightweight attestation"], + "criteria": [ + { + "id": "criterion-1", + "must": "Return a concise result and residual risks when applicable", + "evidence": ["manual-notes", "residual-risks"], + "severity": "required" + } + ], + "evidence": ["manual-notes", "residual-risks"], + "verify": [], + "stopRules": [] + }, + "inferredReason": ["default lightweight attestation"], + "criteria": [ + { + "id": "criterion-1", + "must": "Return a concise result and residual risks when applicable", + "evidence": ["manual-notes", "residual-risks"], + "severity": "required" + } + ], + "runtimeChecks": [], + "verifyRuns": [], + "childReport": { + "criteriaSatisfied": [ + { + "id": "criterion-1", + "status": "satisfied", + "evidence": "Listed current workspace using ls and confirmed it is empty; output written to designated context path." + } + ], + "changedFiles": [], + "testsAddedOrUpdated": [], + "commandsRun": [], + "validationOutput": ["Directory contents: (empty directory)"], + "residualRisks": ["Workspace is empty"], + "noStagedFiles": true, + "diffSummary": "No source files changed. Output written to subagent artifacts path.", + "reviewFindings": ["No code issues; directory is empty."], + "manualNotes": "Listed current workspace directory and generated required context artifact." + } + }, + "toolCalls": [ + { + "text": "ls {\"path\":\".\"}", + "expandedText": "ls {\"path\":\".\"}" + }, + { + "text": "write /tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/679ba95f-8fae-466c-a889-6717de1dc2f0/context.md", + "expandedText": "write /tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/679ba95f-8fae-466c-a889-6717de1dc2f0/context.md" + } + ] + } + ], + "artifacts": { + "dir": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts", + "files": [ + { + "inputPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_input.md", + "outputPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_output.md", + "jsonlPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0.jsonl", + "transcriptPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_transcript.jsonl", + "metadataPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_meta.json" + } + ] + }, + "totalChildUsage": { + "input": 7065, + "output": 511, + "cacheRead": 0, + "cacheWrite": 0, + "cost": 0.007215, + "turns": 3 + }, + "totalCost": { + "inputTokens": 7065, + "outputTokens": 511, + "costUsd": 0.007215 + }, + "context": "fresh", + "runFanoutBudget": { + "used": 1, + "limit": 64, + "remaining": 63 + }, + "missionId": "17cc387f-50eb-4a34-bb98-4452434c1274", + "missionPath": "/tmp/paseo-pi-extension-phase3/pi-agent/missions/projects/8618955a5c85eb731bdb33d90016edaaf64963be032225b8a26a6936621a378d/17cc387f-50eb-4a34-bb98-4452434c1274.json", + "mission": { + "schemaVersion": 1, + "id": "17cc387f-50eb-4a34-bb98-4452434c1274", + "title": "[prompt redacted]", + "objective": "[prompt redacted]", + "status": "completed", + "createdAt": "2026-09-23T21:07:15.238Z", + "updatedAt": "2026-09-23T21:07:21.578Z", + "runs": [ + { + "runId": "679ba95f-8fae-466c-a889-6717de1dc2f0", + "mode": "single", + "agent": "scout", + "status": "completed", + "startedAt": "2026-09-23T21:07:21.578Z", + "completedAt": "2026-09-23T21:07:21.578Z", + "usage": { + "tokens": 7576 + } + } + ], + "workflowChildren": [], + "decisions": [], + "artifacts": [ + { + "kind": "output", + "path": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_output.md" + }, + { + "kind": "output", + "path": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/679ba95f-8fae-466c-a889-6717de1dc2f0/context.md" + }, + { + "kind": "other", + "path": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_transcript.jsonl", + "description": "Child transcript" + } + ], + "receipts": [], + "cwd": "/tmp/paseo-pi-extension-phase3/workspace", + "ownerSessionId": "/tmp/paseo-pi-extension-phase3/sessions-nico/2026-09-23T21-07-08-019Z_01a0d017-fc73-7e52-b822-9235801e5403.jsonl", + "summary": "Run fan-out: 1/64 used, 63 remaining\n# Code Context\n\n## Files Retrieved\nWorkspace directory `/tmp/paseo-pi-extension-phase3/workspace` was inspected using `ls`. No files found.\n\n## Key Code\nN/A - The workspace is currently empty.\n\n## Architecture\nN/A - No code structure or files exist in the workspace yet.\n\n## Start Here\nN/A - Workspace is empty.\n\nOutput saved to: /tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/679ba95f-8fae-466c-a889-6717de1dc2f0/context.md (311 B, 13 lines). Read this file if needed.", + "acceptance": { + "status": "attested", + "evidenceStatus": "attested", + "explicit": false, + "effectiveAcceptance": { + "level": "attested", + "explicit": false, + "inferredReason": ["default lightweight attestation"], + "criteria": [ + { + "id": "criterion-1", + "must": "Return a concise result and residual risks when applicable", + "evidence": ["manual-notes", "residual-risks"], + "severity": "required" + } + ], + "evidence": ["manual-notes", "residual-risks"], + "verify": [], + "stopRules": [] + }, + "inferredReason": ["default lightweight attestation"], + "criteria": [ + { + "id": "criterion-1", + "must": "Return a concise result and residual risks when applicable", + "evidence": ["manual-notes", "residual-risks"], + "severity": "required" + } + ], + "runtimeChecks": [], + "verifyRuns": [], + "childReport": { + "criteriaSatisfied": [ + { + "id": "criterion-1", + "status": "satisfied", + "evidence": "Listed current workspace using ls and confirmed it is empty; output written to designated context path." + } + ], + "changedFiles": [], + "testsAddedOrUpdated": [], + "commandsRun": [], + "validationOutput": ["Directory contents: (empty directory)"], + "residualRisks": ["Workspace is empty"], + "noStagedFiles": true, + "diffSummary": "No source files changed. Output written to subagent artifacts path.", + "reviewFindings": ["No code issues; directory is empty."], + "manualNotes": "Listed current workspace directory and generated required context artifact." + } + } + } + }, + "usage": { + "input": 7065, + "output": 511, + "cacheRead": 0, + "cacheWrite": 0, + "totalTokens": 7576, + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0, + "total": 0.007215 + } + }, + "isError": false, + "timestamp": 1790197641579 + } + ] +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/fixtures/wait.json b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/fixtures/wait.json new file mode 100644 index 0000000000..0e43c8dd25 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/fixtures/wait.json @@ -0,0 +1,412 @@ +{ + "provenance": { + "package": "pi-subagents", + "version": "0.71.0", + "sourceCommit": "4af5e85a427b9f87334585ae8d0eb365d4dd2a1e", + "piVersion": "0.84.4", + "captureDate": "2026-09-23" + }, + "events": [ + { + "type": "tool_execution_start", + "toolCallId": "call_451394", + "toolName": "subagent", + "args": { + "agent": "scout", + "task": "list files in this empty workspace", + "async": true + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_451394", + "toolName": "subagent", + "result": { + "content": [ + { + "type": "text", + "text": "Run fan-out: 1/64 used, 63 remaining\nAsync: scout [33b193f7-7519-4395-a09c-e45e8f5ce4fa]\n\nThe async run is detached and running in the background.\nYou are in an interactive session. Return control to the user now; Pi will wake you through the native completion notification when this subagent completes or needs attention. Do not run sleep/polling loops to wait for this async subagent; it does not need a wait call.\nUse bg_wait only for provider, detached, or other background work that lacks a native completion notification.\nIf the current turn must receive results from work without a native notification before it ends, call blocking bg_wait(); ordinary async subagent runs do not need a wait call because their completion is delivered natively.\nOtherwise, continue any independent work or return control to the user. Use subagent({ action: \"status\", id: \"...\" }) for a one-shot status/result or to inspect a blocked/stale run, never as a wait loop.\nMission: 812446a0-b883-48e0-ac3c-f810ffbcb7a9 (active)" + } + ], + "details": { + "mode": "single", + "runId": "33b193f7-7519-4395-a09c-e45e8f5ce4fa", + "results": [], + "asyncId": "33b193f7-7519-4395-a09c-e45e8f5ce4fa", + "asyncDir": "/tmp/pi-subagents-uid-1000/async-subagent-runs/33b193f7-7519-4395-a09c-e45e8f5ce4fa", + "launchContractDigest": "1c2799c32119977a96c1e4987cea70fccbe931d8cd1904fd2da8d8689dd0fa08", + "launchResolvedExtensions": { + "version": 1, + "source": "launch-resolved", + "disableAmbientExtensions": false, + "runtime": ["sha256:c41552bd9557eaab"], + "configured": [], + "required": [], + "effective": ["sha256:c41552bd9557eaab"], + "omitted": { + "runtime": 0, + "configured": 0, + "required": 0, + "effective": 0 + } + }, + "context": "fresh", + "timeoutMs": 1800000, + "deadlineAt": 1790200012953, + "runFanoutBudget": { + "used": 1, + "limit": 64, + "remaining": 63 + }, + "missionId": "812446a0-b883-48e0-ac3c-f810ffbcb7a9", + "missionPath": "/tmp/paseo-pi-extension-phase3/pi-agent/missions/projects/8618955a5c85eb731bdb33d90016edaaf64963be032225b8a26a6936621a378d/812446a0-b883-48e0-ac3c-f810ffbcb7a9.json", + "mission": { + "schemaVersion": 1, + "id": "812446a0-b883-48e0-ac3c-f810ffbcb7a9", + "title": "[prompt redacted]", + "objective": "[prompt redacted]", + "status": "active", + "createdAt": "2026-09-23T21:16:52.950Z", + "updatedAt": "2026-09-23T21:16:52.962Z", + "runs": [ + { + "runId": "33b193f7-7519-4395-a09c-e45e8f5ce4fa", + "mode": "single", + "asyncDir": "/tmp/pi-subagents-uid-1000/async-subagent-runs/33b193f7-7519-4395-a09c-e45e8f5ce4fa", + "status": "active", + "startedAt": "2026-09-23T21:16:52.962Z" + } + ], + "workflowChildren": [], + "decisions": [], + "artifacts": [ + { + "kind": "status", + "path": "/tmp/pi-subagents-uid-1000/async-subagent-runs/33b193f7-7519-4395-a09c-e45e8f5ce4fa/status.json" + }, + { + "kind": "other", + "path": "/tmp/pi-subagents-uid-1000/async-subagent-runs/33b193f7-7519-4395-a09c-e45e8f5ce4fa/events.jsonl", + "description": "Lifecycle events" + } + ], + "receipts": [], + "cwd": "/tmp/paseo-pi-extension-phase3/workspace", + "ownerSessionId": "/tmp/paseo-pi-extension-phase3/sessions-nico/2026-09-23T21-16-49-809Z_01a0d020-dd11-7a6e-a902-107f51c3c198.jsonl" + } + } + }, + "isError": false + }, + { + "type": "tool_execution_start", + "toolCallId": "call_512773", + "toolName": "bg_wait", + "args": { + "all": true + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_512773", + "toolName": "bg_wait", + "result": { + "content": [ + { + "type": "text", + "text": "Waited 7.8s for 1 async run(s); done. Outcome: 1 complete.\nResult [33b193f7-7519-4395-a09c-e45e8f5ce4fa]: /tmp/pi-subagents-uid-1000/async-subagent-results/output-archives/33b193f7-7519-4395-a09c-e45e8f5ce4fa.json\n Completion/control events have been observed; inspect status if a notification is not visible yet." + } + ], + "usage": { + "input": 7435, + "output": 884, + "cacheRead": 0, + "cacheWrite": 0, + "totalTokens": 8319, + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0, + "total": 0.00889125 + } + }, + "details": { + "mode": "management", + "results": [], + "completions": [ + { + "runId": "33b193f7-7519-4395-a09c-e45e8f5ce4fa", + "agent": "scout", + "mode": "single", + "state": "complete", + "success": true, + "results": [ + { + "agent": "scout", + "usage": { + "input": 7435, + "output": 884, + "cacheRead": 0, + "cacheWrite": 0, + "cost": 0.00889125, + "turns": 3 + }, + "sessionFile": "/tmp/paseo-pi-extension-phase3/sessions-nico/2026-09-23T21-16-49-809Z_01a0d020-dd11-7a6e-a902-107f51c3c198/53dcdf6f-7871-44ae-bf72-7744df7845f2/run-0/session.jsonl", + "success": true, + "outputState": "present", + "model": "openrouter/google/gemini-3.6-flash:low", + "artifactPaths": { + "inputPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/33b193f7-7519-4395-a09c-e45e8f5ce4fa_scout_input.md", + "outputPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/33b193f7-7519-4395-a09c-e45e8f5ce4fa_scout_output.md", + "jsonlPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/33b193f7-7519-4395-a09c-e45e8f5ce4fa_scout.jsonl", + "transcriptPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/33b193f7-7519-4395-a09c-e45e8f5ce4fa_scout_transcript.jsonl", + "metadataPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/33b193f7-7519-4395-a09c-e45e8f5ce4fa_scout_meta.json" + } + } + ], + "archivePath": "/tmp/pi-subagents-uid-1000/async-subagent-results/output-archives/33b193f7-7519-4395-a09c-e45e8f5ce4fa.json" + } + ] + } + }, + "isError": false + }, + { + "type": "message_end", + "message": { + "role": "custom", + "customType": "subagent-notify", + "content": "Background task completed: **scout**\n\nscout:\n# Code Context\n\n## Files Retrieved\nNo files found in workspace `/tmp/paseo-pi-extension-phase3/workspace`.\n\n## Key Code\nNone. The directory is empty.\n\n## Architecture\nN/A - Empty workspace.\n\n## Start Here\n`/tmp/paseo-pi-extension-phase3/workspace` - Root workspace directory.\n\nOutput saved to: /tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/33b193f7-7519-4395-a09c-e45e8f5ce4fa/context.md (275 B, 13 lines). Read this file if needed.\n\nRetention-managed async directory: /tmp/pi-subagents-uid-1000/async-subagent-runs/33b193f7-7519-4395-a09c-e45e8f5ce4fa\n\nSession file: /tmp/paseo-pi-extension-phase3/sessions-nico/2026-09-23T21-16-49-809Z_01a0d020-dd11-7a6e-a902-107f51c3c198/53dcdf6f-7871-44ae-bf72-7744df7845f2/run-0/session.jsonl", + "display": false, + "timestamp": 1790198222119 + } + } + ], + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "call_451394", + "name": "subagent", + "arguments": { + "agent": "scout", + "task": "list files in this empty workspace", + "async": true + } + } + ], + "api": "openai-completions", + "provider": "openrouter", + "model": "google/gemini-3.6-flash", + "usage": { + "input": 9469, + "output": 127, + "cacheRead": 0, + "cacheWrite": 0, + "reasoning": 96, + "totalTokens": 9596, + "cost": { + "input": 0.00710175, + "output": 0.00047625, + "cacheRead": 0, + "cacheWrite": 0, + "total": 0.0075780000000000005 + } + }, + "stopReason": "toolUse", + "timestamp": 1790198210866, + "responseId": "gen-1790198211-rIrfe6SMT2s34Gp8iGou", + "rawStopReason": "tool_calls" + }, + { + "role": "toolResult", + "toolCallId": "call_451394", + "toolName": "subagent", + "content": [ + { + "type": "text", + "text": "Run fan-out: 1/64 used, 63 remaining\nAsync: scout [33b193f7-7519-4395-a09c-e45e8f5ce4fa]\n\nThe async run is detached and running in the background.\nYou are in an interactive session. Return control to the user now; Pi will wake you through the native completion notification when this subagent completes or needs attention. Do not run sleep/polling loops to wait for this async subagent; it does not need a wait call.\nUse bg_wait only for provider, detached, or other background work that lacks a native completion notification.\nIf the current turn must receive results from work without a native notification before it ends, call blocking bg_wait(); ordinary async subagent runs do not need a wait call because their completion is delivered natively.\nOtherwise, continue any independent work or return control to the user. Use subagent({ action: \"status\", id: \"...\" }) for a one-shot status/result or to inspect a blocked/stale run, never as a wait loop.\nMission: 812446a0-b883-48e0-ac3c-f810ffbcb7a9 (active)" + } + ], + "details": { + "mode": "single", + "runId": "33b193f7-7519-4395-a09c-e45e8f5ce4fa", + "results": [], + "asyncId": "33b193f7-7519-4395-a09c-e45e8f5ce4fa", + "asyncDir": "/tmp/pi-subagents-uid-1000/async-subagent-runs/33b193f7-7519-4395-a09c-e45e8f5ce4fa", + "launchContractDigest": "1c2799c32119977a96c1e4987cea70fccbe931d8cd1904fd2da8d8689dd0fa08", + "launchResolvedExtensions": { + "version": 1, + "source": "launch-resolved", + "disableAmbientExtensions": false, + "runtime": ["sha256:c41552bd9557eaab"], + "configured": [], + "required": [], + "effective": ["sha256:c41552bd9557eaab"], + "omitted": { + "runtime": 0, + "configured": 0, + "required": 0, + "effective": 0 + } + }, + "context": "fresh", + "timeoutMs": 1800000, + "deadlineAt": 1790200012953, + "runFanoutBudget": { + "used": 1, + "limit": 64, + "remaining": 63 + }, + "missionId": "812446a0-b883-48e0-ac3c-f810ffbcb7a9", + "missionPath": "/tmp/paseo-pi-extension-phase3/pi-agent/missions/projects/8618955a5c85eb731bdb33d90016edaaf64963be032225b8a26a6936621a378d/812446a0-b883-48e0-ac3c-f810ffbcb7a9.json", + "mission": { + "schemaVersion": 1, + "id": "812446a0-b883-48e0-ac3c-f810ffbcb7a9", + "title": "[prompt redacted]", + "objective": "[prompt redacted]", + "status": "active", + "createdAt": "2026-09-23T21:16:52.950Z", + "updatedAt": "2026-09-23T21:16:52.962Z", + "runs": [ + { + "runId": "33b193f7-7519-4395-a09c-e45e8f5ce4fa", + "mode": "single", + "asyncDir": "/tmp/pi-subagents-uid-1000/async-subagent-runs/33b193f7-7519-4395-a09c-e45e8f5ce4fa", + "status": "active", + "startedAt": "2026-09-23T21:16:52.962Z" + } + ], + "workflowChildren": [], + "decisions": [], + "artifacts": [ + { + "kind": "status", + "path": "/tmp/pi-subagents-uid-1000/async-subagent-runs/33b193f7-7519-4395-a09c-e45e8f5ce4fa/status.json" + }, + { + "kind": "other", + "path": "/tmp/pi-subagents-uid-1000/async-subagent-runs/33b193f7-7519-4395-a09c-e45e8f5ce4fa/events.jsonl", + "description": "Lifecycle events" + } + ], + "receipts": [], + "cwd": "/tmp/paseo-pi-extension-phase3/workspace", + "ownerSessionId": "/tmp/paseo-pi-extension-phase3/sessions-nico/2026-09-23T21-16-49-809Z_01a0d020-dd11-7a6e-a902-107f51c3c198.jsonl" + } + }, + "isError": false, + "timestamp": 1790198212965 + }, + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "call_512773", + "name": "bg_wait", + "arguments": { + "all": true + } + } + ], + "api": "openai-completions", + "provider": "openrouter", + "model": "google/gemini-3.6-flash", + "usage": { + "input": 2622, + "output": 32, + "cacheRead": 6971, + "cacheWrite": 0, + "reasoning": 18, + "totalTokens": 9625, + "cost": { + "input": 0.0019665, + "output": 0.00012, + "cacheRead": 0.000522825, + "cacheWrite": 0, + "total": 0.002609325 + } + }, + "stopReason": "toolUse", + "timestamp": 1790198212966, + "responseId": "gen-1790198213-Dg2TqjSl8UQiCKWHWbRE", + "rawStopReason": "tool_calls" + }, + { + "role": "toolResult", + "toolCallId": "call_512773", + "toolName": "bg_wait", + "content": [ + { + "type": "text", + "text": "Waited 7.8s for 1 async run(s); done. Outcome: 1 complete.\nResult [33b193f7-7519-4395-a09c-e45e8f5ce4fa]: /tmp/pi-subagents-uid-1000/async-subagent-results/output-archives/33b193f7-7519-4395-a09c-e45e8f5ce4fa.json\n Completion/control events have been observed; inspect status if a notification is not visible yet." + } + ], + "details": { + "mode": "management", + "results": [], + "completions": [ + { + "runId": "33b193f7-7519-4395-a09c-e45e8f5ce4fa", + "agent": "scout", + "mode": "single", + "state": "complete", + "success": true, + "results": [ + { + "agent": "scout", + "usage": { + "input": 7435, + "output": 884, + "cacheRead": 0, + "cacheWrite": 0, + "cost": 0.00889125, + "turns": 3 + }, + "sessionFile": "/tmp/paseo-pi-extension-phase3/sessions-nico/2026-09-23T21-16-49-809Z_01a0d020-dd11-7a6e-a902-107f51c3c198/53dcdf6f-7871-44ae-bf72-7744df7845f2/run-0/session.jsonl", + "success": true, + "outputState": "present", + "model": "openrouter/google/gemini-3.6-flash:low", + "artifactPaths": { + "inputPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/33b193f7-7519-4395-a09c-e45e8f5ce4fa_scout_input.md", + "outputPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/33b193f7-7519-4395-a09c-e45e8f5ce4fa_scout_output.md", + "jsonlPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/33b193f7-7519-4395-a09c-e45e8f5ce4fa_scout.jsonl", + "transcriptPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/33b193f7-7519-4395-a09c-e45e8f5ce4fa_scout_transcript.jsonl", + "metadataPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/33b193f7-7519-4395-a09c-e45e8f5ce4fa_scout_meta.json" + } + } + ], + "archivePath": "/tmp/pi-subagents-uid-1000/async-subagent-results/output-archives/33b193f7-7519-4395-a09c-e45e8f5ce4fa.json" + } + ] + }, + "usage": { + "input": 7435, + "output": 884, + "cacheRead": 0, + "cacheWrite": 0, + "totalTokens": 8319, + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0, + "total": 0.00889125 + } + }, + "isError": false, + "timestamp": 1790198222122 + }, + { + "role": "custom", + "customType": "subagent-notify", + "content": "Background task completed: **scout**\n\nscout:\n# Code Context\n\n## Files Retrieved\nNo files found in workspace `/tmp/paseo-pi-extension-phase3/workspace`.\n\n## Key Code\nNone. The directory is empty.\n\n## Architecture\nN/A - Empty workspace.\n\n## Start Here\n`/tmp/paseo-pi-extension-phase3/workspace` - Root workspace directory.\n\nOutput saved to: /tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/33b193f7-7519-4395-a09c-e45e8f5ce4fa/context.md (275 B, 13 lines). Read this file if needed.\n\nRetention-managed async directory: /tmp/pi-subagents-uid-1000/async-subagent-runs/33b193f7-7519-4395-a09c-e45e8f5ce4fa\n\nSession file: /tmp/paseo-pi-extension-phase3/sessions-nico/2026-09-23T21-16-49-809Z_01a0d020-dd11-7a6e-a902-107f51c3c198/53dcdf6f-7871-44ae-bf72-7744df7845f2/run-0/session.jsonl", + "display": false, + "timestamp": 1790198222119 + } + ] +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts new file mode 100644 index 0000000000..2f3293f8c1 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, test } from "vitest"; +import { fileURLToPath } from "node:url"; +import { createPiExtensionHost } from "../index.js"; +import { + parseToolArgs, + parseToolResult, + type PiToolResult, + type PiTrackedToolCall, +} from "../../tool-call-mapper.js"; +import { mapPiChildSession } from "../child-session.js"; +import { readSubagentFixture, verifySubagentFixture } from "../subagent-fixture-test.js"; +import pino from "pino"; +import { PiRpcAgentClient } from "../../agent.js"; +import { FakePi } from "../../test-utils/fake-pi.js"; +import type { AgentStreamEvent } from "../../../../agent-sdk-types.js"; + +function mapping(toolCall: PiTrackedToolCall, result: PiToolResult) { + return createPiExtensionHost().mapToolCall({ + callId: "test-call", + toolName: toolCall.toolName, + args: toolCall.args, + status: result ? "completed" : "running", + result, + }); +} +function mapToolDetail(toolCall: PiTrackedToolCall, result: PiToolResult) { + return mapping(toolCall, result)?.detail; +} + +describe("pi-subagents adapter", () => { + test("maps completed subagent calls with task input to sub-agent detail", () => { + const toolCall = parseToolArgs("subagent", { + agent: "reviewer", + task: "Review the Pi mapper change", + }); + const result = parseToolResult({ + content: [{ type: "text", text: "The mapper change preserves provider status." }], + }); + + expect(mapToolDetail(toolCall, result)).toEqual({ + type: "sub_agent", + subAgentType: "reviewer", + description: "Review the Pi mapper change", + log: "The mapper change preserves provider status.", + }); + }); + + test("maps the captured foreground run and child Pi timeline live and on replay", async () => { + const raw = readSubagentFixture(new URL("./fixtures/foreground.json", import.meta.url)); + const result = raw.events.find( + (event) => + event.type === "tool_execution_end" && + event.toolName === "subagent" && + event.result && + typeof event.result === "object" && + "details" in event.result && + Array.isArray(event.result.details?.results) && + event.result.details.results.length > 0, + ); + if (!result || result.type !== "tool_execution_end") + throw new Error("Missing captured child session"); + const original = (result.result as { details: { results: Array<{ sessionFile: string }> } }) + .details.results[0].sessionFile; + const fixture = readSubagentFixture(new URL("./fixtures/foreground.json", import.meta.url), { + from: original, + to: fileURLToPath(new URL("./fixtures/child-session.jsonl", import.meta.url)), + }); + const events = await verifySubagentFixture(fixture); + expect( + events + .filter((event) => event.event.type === "upsert") + .map((event) => (event.event.type === "upsert" ? event.event.status : null)), + ).toContain("completed"); + expect(events.filter((event) => event.event.type === "timeline").length).toBeGreaterThan(0); + }); + + test("keeps captured async runs running until structured completion", async () => { + const fixture = readSubagentFixture(new URL("./fixtures/background.json", import.meta.url)); + const events = await verifySubagentFixture(fixture); + expect(events.findLast((event) => event.event.type === "upsert")?.event).toEqual( + expect.objectContaining({ status: "running" }), + ); + }); + + test("maps bg_wait completion to the original async child", async () => { + const raw = readSubagentFixture(new URL("./fixtures/wait.json", import.meta.url)); + const wait = raw.events.find( + (event) => event.type === "tool_execution_end" && event.toolName === "bg_wait", + ); + if (!wait || wait.type !== "tool_execution_end") + throw new Error("Missing captured bg_wait result"); + const original = ( + wait.result as { + details: { completions: Array<{ results: Array<{ sessionFile: string }> }> }; + } + ).details.completions[0].results[0].sessionFile; + const fixture = readSubagentFixture(new URL("./fixtures/wait.json", import.meta.url), { + from: original, + to: fileURLToPath(new URL("./fixtures/child-session.jsonl", import.meta.url)), + }); + const events = await verifySubagentFixture(fixture); + const upserts = events + .filter((event) => event.event.type === "upsert") + .map((event) => (event.event.type === "upsert" ? event.event : null)); + expect(upserts.map((event) => event?.status)).toEqual(["running", "running", "completed"]); + expect(new Set(upserts.map((event) => event?.id)).size).toBe(1); + expect(events.filter((event) => event.event.type === "timeline").length).toBeGreaterThan(0); + }); + + test("missing child file produces no timeline", async () => { + expect(await mapPiChildSession("child", "/does-not-exist/pi-child.jsonl")).toEqual([]); + }); + + test("does not emit child timeline after session close", async () => { + const raw = readSubagentFixture(new URL("./fixtures/foreground.json", import.meta.url)); + const completion = raw.events.find( + (event) => event.type === "tool_execution_end" && event.toolName === "subagent", + ); + if (!completion || completion.type !== "tool_execution_end") throw new Error("No completion"); + const original = (completion.result as { details: { results: Array<{ sessionFile: string }> } }) + .details.results[0].sessionFile; + const fixture = readSubagentFixture(new URL("./fixtures/foreground.json", import.meta.url), { + from: original, + to: fileURLToPath(new URL("./fixtures/child-session.jsonl", import.meta.url)), + }); + const pi = new FakePi(); + const client = new PiRpcAgentClient({ logger: pino({ level: "silent" }), runtime: pi }); + const session = await client.createSession({ + provider: "pi", + cwd: "/tmp/paseo-pi-child-close", + }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + await session.startTurn("Delegate work"); + for (const event of fixture.events) pi.latestSession().emit(event); + await session.close(); + expect( + events.some((event) => event.type === "provider_subagent" && event.event.type === "upsert"), + ).toBe(true); + expect( + events.some((event) => event.type === "provider_subagent" && event.event.type === "timeline"), + ).toBe(false); + }); + + test("a failed spawn without result rows finishes its descriptor", () => { + const host = createPiExtensionHost(); + const args = { agent: "scout", task: "Inspect" }; + host.mapToolCall({ + callId: "failed", + toolName: "subagent", + args, + status: "running", + result: null, + }); + expect( + host.mapToolCall({ + callId: "failed", + toolName: "subagent", + args, + status: "failed", + result: { content: [{ type: "text", text: "spawn failed" }] }, + })?.subagents, + ).toEqual([ + { + type: "upsert", + id: "failed", + title: "scout", + description: "Inspect", + toolCallId: "failed", + status: "failed", + }, + ]); + }); +}); diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.ts b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.ts new file mode 100644 index 0000000000..0ec0f0ab47 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.ts @@ -0,0 +1,137 @@ +import { z } from "zod"; +import { extractTextFromToolResult } from "../../tool-call-mapper.js"; +import type { PiExtension, PiExtensionToolCall, PiExtensionToolMapping } from "../contract.js"; + +const Args = z + .object({ + agent: z.string().trim().min(1).optional(), + task: z.string().trim().min(1).optional(), + async: z.boolean().optional(), + action: z.unknown().optional(), + }) + .passthrough(); +const Row = z + .object({ + index: z.number().int().nonnegative().optional(), + agent: z.string().trim().min(1), + exitCode: z.number().optional(), + success: z.boolean().optional(), + sessionFile: z.string().trim().min(1).optional(), + }) + .passthrough(); +const Completion = z + .object({ runId: z.string().trim().min(1), results: z.array(Row) }) + .passthrough(); +const Details = z + .object({ + mode: z.string(), + runId: z.string().optional(), + asyncId: z.string().optional(), + results: z.array(Row), + completions: z.array(Completion).optional(), + }) + .passthrough(); + +export const piSubagents: PiExtension = { + id: "pi-subagents", + createSession: () => { + const callsByRun = new Map(); + const readSessions = new Set(); + const collectRows = ( + owner: string, + rows: z.infer[], + description?: string, + failed = false, + ) => { + const subagents: NonNullable = []; + const childSessions: NonNullable = []; + for (const [index, row] of rows.entries()) { + const id = rows.length === 1 ? owner : `${owner}:${row.index ?? index}`; + subagents.push({ + type: "upsert", + id, + title: row.agent, + description, + toolCallId: owner, + status: + failed || row.success === false || (row.success !== true && row.exitCode !== 0) + ? "failed" + : "completed", + }); + if (row.sessionFile && !readSessions.has(row.sessionFile)) { + readSessions.add(row.sessionFile); + childSessions.push({ id, file: row.sessionFile }); + } + } + return { subagents, childSessions }; + }; + const mapWait = (call: PiExtensionToolCall) => { + if (call.status === "running") return undefined; + const details = Details.safeParse( + typeof call.result === "object" ? call.result?.details : null, + ); + if (!details.success || !details.data.completions) return undefined; + const subagents: NonNullable = []; + const childSessions: NonNullable = []; + for (const completion of details.data.completions) { + const owner = callsByRun.get(completion.runId); + if (!owner) continue; + const mapped = collectRows(owner, completion.results); + subagents.push(...mapped.subagents); + childSessions.push(...mapped.childSessions); + } + return subagents.length ? { subagents, childSessions } : undefined; + }; + const mapSpawn = (call: PiExtensionToolCall) => { + const args = Args.safeParse(call.args); + if (!args.success || args.data.action !== undefined || !args.data.agent || !args.data.task) + return undefined; + const detail = { + type: "sub_agent" as const, + subAgentType: args.data.agent, + description: args.data.task, + log: extractTextFromToolResult(call.result)?.trim() ?? "", + }; + const base = { + type: "upsert" as const, + id: call.callId, + title: args.data.agent, + description: args.data.task, + toolCallId: call.callId, + }; + if (call.status === "running") + return { detail, subagents: [{ ...base, status: "running" as const }] }; + const details = Details.safeParse( + typeof call.result === "object" ? call.result?.details : null, + ); + if (!details.success || details.data.mode === "management") + return call.status === "failed" + ? { detail, subagents: [{ ...base, status: "failed" as const }] } + : { detail }; + if (details.data.runId) callsByRun.set(details.data.runId, call.callId); + if (details.data.results.length === 0 && (details.data.asyncId || args.data.async)) + return { + detail, + subagents: [ + { + ...base, + status: call.status === "failed" ? ("failed" as const) : ("running" as const), + }, + ], + }; + if (details.data.results.length === 0 && call.status === "failed") + return { detail, subagents: [{ ...base, status: "failed" as const }] }; + return { + detail, + ...collectRows(call.callId, details.data.results, args.data.task, call.status === "failed"), + }; + }; + return { + mapToolCall(call) { + if (call.toolName === "bg_wait") return mapWait(call); + if (call.toolName === "subagent") return mapSpawn(call); + return undefined; + }, + }; + }, +}; diff --git a/packages/server/src/server/agent/providers/pi/extensions/registry.ts b/packages/server/src/server/agent/providers/pi/extensions/registry.ts new file mode 100644 index 0000000000..c66fabad80 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/registry.ts @@ -0,0 +1,21 @@ +import { piAskUser } from "./pi-ask-user/index.js"; +import { piSubagents } from "./pi-subagents/index.js"; +import { piMcpAdapter } from "./pi-mcp-adapter/index.js"; +import { rpivTodo } from "./rpiv-todo/index.js"; +import { piExampleTodo } from "./pi-example-todo/index.js"; +import { piGoalX } from "./pi-goal-x/index.js"; +import { tintinwebPiSubagents } from "./tintinweb-pi-subagents/index.js"; +import { gotgenesPiSubagents } from "./gotgenes-pi-subagents/index.js"; +import { rpivAskUserQuestion } from "./rpiv-ask-user-question/index.js"; + +export const piExtensions = [ + piAskUser, // Dialog correlation + piSubagents, // Delegation calls + tintinwebPiSubagents, + gotgenesPiSubagents, + piMcpAdapter, // MCP proxy names + rpivTodo, + piExampleTodo, + piGoalX, + rpivAskUserQuestion, +]; diff --git a/packages/server/src/server/agent/providers/pi/extensions/rpiv-ask-user-question/fixtures/cancel-result.json b/packages/server/src/server/agent/providers/pi/extensions/rpiv-ask-user-question/fixtures/cancel-result.json new file mode 100644 index 0000000000..801757b33f --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/rpiv-ask-user-question/fixtures/cancel-result.json @@ -0,0 +1,23 @@ +{ + "provenance": { + "package": "@juicesharp/rpiv-ask-user-question", + "version": "2.11.0", + "sourceCommit": "61904e69e1a50e12585bdf15f0310e633a62ba36", + "piVersion": "0.84.4", + "captureDate": "2026-09-23", + "model": "openrouter/google/gemini-3.6-flash", + "source": "isolated Paseo web QA Pi session JSONL" + }, + "result": { + "content": [ + { + "type": "text", + "text": "User declined to answer questions" + } + ], + "details": { + "answers": [], + "cancelled": true + } + } +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/rpiv-ask-user-question/fixtures/rpc-session.json b/packages/server/src/server/agent/providers/pi/extensions/rpiv-ask-user-question/fixtures/rpc-session.json new file mode 100644 index 0000000000..eea1cde847 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/rpiv-ask-user-question/fixtures/rpc-session.json @@ -0,0 +1,134 @@ +{ + "provenance": { + "package": "@juicesharp/rpiv-ask-user-question", + "version": "2.11.0", + "sourceCommit": "61904e69e1a50e12585bdf15f0310e633a62ba36", + "piVersion": "0.84.4", + "captureDate": "2026-09-23", + "model": "openrouter/google/gemini-3.6-flash" + }, + "frames": [ + { + "type": "tool_execution_start", + "toolCallId": "call_1400136", + "toolName": "ask_user_question", + "args": { + "questions": [ + { + "options": [ + { + "label": "Apple", + "description": "Fresh fruit" + }, + { + "description": "Sweet treat", + "label": "Cookie" + } + ], + "question": "Which snack?", + "header": "Snack" + }, + { + "multiSelect": true, + "question": "Which colors?", + "options": [ + { + "description": "Warm", + "label": "Red" + }, + { + "description": "Cool", + "label": "Blue" + } + ], + "header": "Colors" + }, + { + "header": "Project", + "options": [ + { + "description": "Map theme", + "label": "Atlas" + }, + { + "description": "Light theme", + "label": "Beacon" + } + ], + "question": "What project name?" + } + ] + } + }, + { + "type": "extension_ui_request", + "id": "8b00ad15-da50-42d8-ab9c-6cbc1a4d31a5", + "method": "select", + "title": "[Snack] Which snack?", + "options": [ + "1. Apple \u2014 Fresh fruit", + "2. Cookie \u2014 Sweet treat", + "3. Type something." + ] + }, + { + "type": "extension_ui_request", + "id": "ade366ea-0cfd-4e9d-9fd0-0690736947c8", + "method": "input", + "title": "[Colors] Which colors?\n\n1. Red \u2014 Warm\n2. Blue \u2014 Cool\n\nEnter the numbers of all that apply, comma-separated (e.g. \"1,3\"), or type a custom answer as plain text.", + "placeholder": "1,3" + }, + { + "type": "extension_ui_request", + "id": "be2fd390-c4e3-4dcb-b14e-3aaf0a64ca70", + "method": "select", + "title": "[Project] What project name?", + "options": ["1. Atlas \u2014 Map theme", "2. Beacon \u2014 Light theme", "3. Type something."] + }, + { + "type": "extension_ui_request", + "id": "8b34e9ca-75d4-4cc2-8439-0bb9836ad128", + "method": "input", + "title": "[Project] What project name?\n\nType your answer:", + "placeholder": "" + }, + { + "type": "tool_execution_end", + "toolCallId": "call_1400136", + "toolName": "ask_user_question", + "result": { + "content": [ + { + "type": "text", + "text": "User has answered your questions: \"Which snack?\"=\"Cookie\". \"Which colors?\"=\"Red, Blue\". \"What project name?\"=\"Nebula\". You can now continue with the user's answers in mind." + } + ], + "details": { + "answers": [ + { + "questionIndex": 0, + "question": "Which snack?", + "kind": "option", + "answer": "Cookie" + }, + { + "questionIndex": 1, + "question": "Which colors?", + "kind": "multi", + "answer": null, + "selected": ["Red", "Blue"] + }, + { + "questionIndex": 2, + "question": "What project name?", + "kind": "custom", + "answer": "Nebula" + } + ], + "cancelled": false + } + }, + "isError": false + } + ] +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/rpiv-ask-user-question/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/rpiv-ask-user-question/index.test.ts new file mode 100644 index 0000000000..3ec9256a29 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/rpiv-ask-user-question/index.test.ts @@ -0,0 +1,171 @@ +import { readFileSync } from "node:fs"; +import { expect, test } from "vitest"; +import pino from "pino"; +import { PiRpcAgentClient } from "../../agent.js"; +import { PiHistoryMapper } from "../../history-mapper.js"; +import { parseToolResult } from "../../tool-call-mapper.js"; +import { FakePi } from "../../test-utils/fake-pi.js"; +import { createPiExtensionHost } from "../index.js"; +import { rpivAskUserQuestion } from "./index.js"; + +const fixture = JSON.parse( + readFileSync(new URL("./fixtures/rpc-session.json", import.meta.url), "utf8"), +); +const [start, ...rest] = fixture.frames; +const dialogs = rest.filter((frame: { type: string }) => frame.type === "extension_ui_request"); +const end = rest.find((frame: { type: string }) => frame.type === "tool_execution_end"); + +function startedHost() { + const host = createPiExtensionHost(); + const permission = host.onToolStart({ + callId: start.toolCallId, + toolName: start.toolName, + args: start.args, + status: "running", + result: null, + }); + if (!permission) throw new Error("Question permission missing"); + return { host, permission }; +} + +test("real rpiv RPC dialogs use one form and preserve choices, multi-select, and custom text", () => { + const { host, permission } = startedHost(); + expect(permission).toMatchObject({ + kind: "question", + input: { + questions: [ + { + header: "Snack", + options: [{ description: "Fresh fruit" }, { description: "Sweet treat" }], + allowOther: true, + multiSelect: false, + }, + { header: "Colors", multiSelect: true }, + { header: "Project", allowOther: true }, + ], + }, + }); + expect(host.mapDialog(dialogs[0], "pi")).toEqual({ type: "deferred" }); + expect( + host.respondToPermission(permission, { + behavior: "allow", + updatedInput: { + answers: { Snack: "Cookie", Colors: "Red, Blue", Project: "Nebula" }, + }, + }), + ).toEqual({ responses: [{ id: dialogs[0].id, response: { value: dialogs[0].options[1] } }] }); + expect(host.mapDialog(dialogs[1], "pi")).toEqual({ + type: "response", + response: { value: "1,2" }, + }); + expect(host.mapDialog(dialogs[2], "pi")).toEqual({ + type: "response", + response: { value: dialogs[2].options[2] }, + }); + expect(host.mapDialog(dialogs[3], "pi")).toEqual({ + type: "response", + response: { value: "Nebula" }, + }); +}); + +test("cancellation declines each remaining real RPC dialog", () => { + const { host, permission } = startedHost(); + expect(host.respondToPermission(permission, { behavior: "deny" })).toEqual({ responses: [] }); + for (const dialog of dialogs) { + expect(host.mapDialog(dialog, "pi")).toEqual({ + type: "response", + response: { cancelled: true }, + }); + } +}); + +test("captured result renders in live and history tool rows, while foreign shapes decline", () => { + const call = { + callId: end.toolCallId, + toolName: end.toolName, + args: start.args, + status: "completed" as const, + result: parseToolResult(end.result), + }; + const live = createPiExtensionHost().mapToolCall(call); + expect(live?.detail).toEqual({ + type: "plain_text", + label: "Questions", + text: "Which snack?: Cookie\nWhich colors?: Red, Blue\nWhat project name?: Nebula", + }); + const history = new PiHistoryMapper("pi").mapMessages([ + { + role: "assistant", + content: [ + { type: "toolCall", id: start.toolCallId, name: start.toolName, arguments: start.args }, + ], + }, + { + role: "toolResult", + toolCallId: end.toolCallId, + toolName: end.toolName, + content: end.result.content, + details: end.result.details, + }, + ]); + expect(history).toContainEqual( + expect.objectContaining({ + type: "timeline", + item: expect.objectContaining({ + type: "tool_call", + status: "completed", + detail: live?.detail, + }), + }), + ); + expect( + rpivAskUserQuestion.createSession().mapToolCall?.({ + ...call, + result: parseToolResult({ content: [], details: { unrelated: true } }), + }), + ).toBeUndefined(); +}); + +test("captured cancellation renders as cancelled in the tool row", () => { + const cancelled = JSON.parse( + readFileSync(new URL("./fixtures/cancel-result.json", import.meta.url), "utf8"), + ); + const mapped = createPiExtensionHost().mapToolCall({ + callId: "cancelled", + toolName: "ask_user_question", + args: start.args, + status: "completed", + result: parseToolResult(cancelled.result), + }); + expect(mapped?.detail).toEqual({ type: "plain_text", label: "Questions", text: "Cancelled" }); +}); + +test("the Pi agent releases a deferred captured dialog when the form is answered", async () => { + const pi = new FakePi(); + const client = new PiRpcAgentClient({ logger: pino({ level: "silent" }), runtime: pi }); + const session = await client.createSession({ + provider: "pi", + cwd: "/tmp/paseo-pi-question-test", + }); + const runtime = pi.latestSession(); + runtime.emit(start); + const [permission] = session.getPendingPermissions(); + expect(permission.input?.questions).toHaveLength(3); + runtime.emit(dialogs[0]); + expect(runtime.extensionUiResponses).toEqual([]); + await session.respondToPermission(permission.id, { + behavior: "allow", + updatedInput: { + answers: { Snack: "Cookie", Colors: "Red, Blue", Project: "Nebula" }, + }, + }); + for (const dialog of dialogs.slice(1)) runtime.emit(dialog); + expect(runtime.extensionUiResponses).toEqual([ + { id: dialogs[0].id, response: { value: dialogs[0].options[1] } }, + { id: dialogs[1].id, response: { value: "1,2" } }, + { id: dialogs[2].id, response: { value: dialogs[2].options[2] } }, + { id: dialogs[3].id, response: { value: "Nebula" } }, + ]); + expect(session.getPendingPermissions()).toEqual([]); + await session.close(); +}); diff --git a/packages/server/src/server/agent/providers/pi/extensions/rpiv-ask-user-question/index.ts b/packages/server/src/server/agent/providers/pi/extensions/rpiv-ask-user-question/index.ts new file mode 100644 index 0000000000..07906cc955 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/rpiv-ask-user-question/index.ts @@ -0,0 +1,212 @@ +import { z } from "zod"; +import type { AgentPermissionRequest } from "../../../../agent-sdk-types.js"; +import type { PiExtension, PiExtensionDialog, PiExtensionUiResponse } from "../contract.js"; + +const Params = z + .object({ + questions: z + .array( + z.object({ + question: z.string(), + header: z.string(), + options: z + .array( + z.object({ + label: z.string(), + description: z.string(), + preview: z.string().optional(), + }), + ) + .min(2) + .max(4), + multiSelect: z.boolean().optional(), + }), + ) + .min(1) + .max(4), + }) + .refine(({ questions }) => { + const texts = questions.map((question) => question.question); + return ( + new Set(texts).size === texts.length && + questions.every((question) => { + const labels = question.options.map((option) => option.label); + return ( + new Set(labels).size === labels.length && + labels.every( + (label) => label !== "Other" && label !== "Type something." && label !== "Next", + ) + ); + }) + ); + }); +const Result = z.object({ + answers: z.array( + z.object({ + question: z.string(), + kind: z.enum(["option", "custom", "multi"]), + answer: z.string().nullable(), + selected: z.array(z.string()).optional(), + }), + ), + cancelled: z.boolean(), + error: z.string().optional(), +}); +type Question = z.infer["questions"][number]; +const PERMISSION_PREFIX = "rpiv-question:"; + +function answerFor(answers: Record, question: Question): string | null { + const answer = answers[question.header]; + return typeof answer === "string" ? answer : null; +} + +function replyToDialog( + dialog: PiExtensionDialog, + question: Question, + answer: string, + customInput: boolean, +): { response: PiExtensionUiResponse; customInput: boolean } { + const options = z.array(z.string()).safeParse(dialog.options).data; + if (customInput) return { response: { value: answer }, customInput: false }; + if (question.multiSelect) { + const labels = answer.split(", "); + const indices = labels.map((label) => + question.options.findIndex((option) => option.label === label), + ); + if (indices.every((index) => index >= 0)) { + return { + response: { value: indices.map((index) => index + 1).join(",") }, + customInput: false, + }; + } + return { response: { value: answer }, customInput: false }; + } + const index = question.options.findIndex((option) => option.label === answer); + if (index >= 0) { + return { + response: { + value: + options?.[index] ?? `${index + 1}. ${answer} — ${question.options[index].description}`, + }, + customInput: false, + }; + } + return { + response: { + value: + options?.[question.options.length] ?? `${question.options.length + 1}. Type something.`, + }, + customInput: true, + }; +} + +export const rpivAskUserQuestion: PiExtension = { + id: "rpiv-ask-user-question", + createSession() { + let active: { + callId: string; + questions: Question[]; + answers: Record | null; + cancelled: boolean; + index: number; + customInput: boolean; + deferred: PiExtensionDialog | null; + } | null = null; + + function answerDialog(dialog: PiExtensionDialog): PiExtensionUiResponse { + if (!active || active.cancelled) return { cancelled: true }; + const question = active.questions[active.index]; + if (!question || !active.answers) return { cancelled: true }; + const answer = answerFor(active.answers, question); + if (answer === null) return { cancelled: true }; + const mapped = replyToDialog(dialog, question, answer, active.customInput); + active.customInput = mapped.customInput; + if (!active.customInput) active.index++; + return mapped.response; + } + + return { + onToolStart(call, provider): AgentPermissionRequest | undefined { + if (call.toolName !== "ask_user_question") return undefined; + const parsed = Params.safeParse(call.args); + if (!parsed.success) return undefined; + active = { + callId: call.callId, + questions: parsed.data.questions, + answers: null, + cancelled: false, + index: 0, + customInput: false, + deferred: null, + }; + return { + id: `${PERMISSION_PREFIX}${call.callId}`, + provider, + name: "Pi ask_user_question", + kind: "question", + title: parsed.data.questions[0].question, + input: { + questions: parsed.data.questions.map((question) => ({ + question: question.question, + header: question.header, + options: question.options, + multiSelect: question.multiSelect ?? false, + allowOther: true, + })), + }, + }; + }, + onToolEnd(call) { + if (active?.callId === call.callId) active = null; + }, + mapDialog(dialog) { + if (!active || (dialog.method !== "select" && dialog.method !== "input")) return undefined; + if (!active.answers && !active.cancelled) { + active.deferred = dialog; + return { type: "deferred" }; + } + return { type: "response", response: answerDialog(dialog) }; + }, + respondToPermission(request, response) { + if (!active || request.id !== `${PERMISSION_PREFIX}${active.callId}`) return undefined; + const answers = response.behavior === "allow" ? response.updatedInput?.answers : undefined; + active.answers = + answers && typeof answers === "object" && !Array.isArray(answers) + ? (answers as Record) + : null; + active.cancelled = response.behavior === "deny" || !active.answers; + const deferred = active.deferred; + active.deferred = null; + return { + responses: deferred ? [{ id: deferred.id, response: answerDialog(deferred) }] : [], + }; + }, + mapToolCall(call) { + if ( + call.toolName !== "ask_user_question" || + !call.result || + typeof call.result === "string" + ) + return undefined; + const result = Result.safeParse(call.result.details); + if (!result.success) return undefined; + return { + detail: { + type: "plain_text", + label: "Questions", + text: + result.data.error ?? + (result.data.cancelled + ? "Cancelled" + : result.data.answers + .map( + (answer) => + `${answer.question}: ${answer.kind === "multi" ? (answer.selected ?? []).join(", ") : (answer.answer ?? "")}`, + ) + .join("\n")), + }, + }; + }, + }; + }, +}; diff --git a/packages/server/src/server/agent/providers/pi/extensions/rpiv-todo/fixtures/rpc-session.json b/packages/server/src/server/agent/providers/pi/extensions/rpiv-todo/fixtures/rpc-session.json new file mode 100644 index 0000000000..41dbd8b755 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/rpiv-todo/fixtures/rpc-session.json @@ -0,0 +1,380 @@ +{ + "provenance": { + "package": "@juicesharp/rpiv-todo", + "version": "2.11.0", + "sourceCommit": "d74b1c99830a565f3df3f37e0a36616d17ffc574", + "piVersion": "0.84.4", + "captureDate": "2026-09-23", + "mode": "pi --mode rpc; OpenRouter google/gemini-3.6-flash" + }, + "events": [ + { + "type": "tool_execution_start", + "toolCallId": "call_406425", + "toolName": "todo", + "args": { + "action": "create", + "subject": "Inspect workspace" + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_406425", + "toolName": "todo", + "result": { + "content": [ + { + "type": "text", + "text": "Created #1: Inspect workspace (pending)" + } + ], + "details": { + "action": "create", + "params": { + "action": "create", + "subject": "Inspect workspace" + }, + "tasks": [ + { + "id": 1, + "subject": "Inspect workspace", + "status": "pending" + } + ], + "nextId": 2 + } + }, + "isError": false + }, + { + "type": "tool_execution_start", + "toolCallId": "call_1389683", + "toolName": "todo", + "args": { + "subject": "Summarize findings", + "action": "create" + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_1389683", + "toolName": "todo", + "result": { + "content": [ + { + "type": "text", + "text": "Created #2: Summarize findings (pending)" + } + ], + "details": { + "action": "create", + "params": { + "subject": "Summarize findings", + "action": "create" + }, + "tasks": [ + { + "id": 1, + "subject": "Inspect workspace", + "status": "pending" + }, + { + "id": 2, + "subject": "Summarize findings", + "status": "pending" + } + ], + "nextId": 3 + } + }, + "isError": false + }, + { + "type": "tool_execution_start", + "toolCallId": "call_1228319", + "toolName": "todo", + "args": { + "status": "in_progress", + "activeForm": "inspecting workspace", + "action": "update", + "id": 1 + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_1228319", + "toolName": "todo", + "result": { + "content": [ + { + "type": "text", + "text": "Updated #1 (pending → in_progress)" + } + ], + "details": { + "action": "update", + "params": { + "status": "in_progress", + "activeForm": "inspecting workspace", + "action": "update", + "id": 1 + }, + "tasks": [ + { + "id": 1, + "subject": "Inspect workspace", + "status": "in_progress", + "activeForm": "inspecting workspace" + }, + { + "id": 2, + "subject": "Summarize findings", + "status": "pending" + } + ], + "nextId": 3 + } + }, + "isError": false + }, + { + "type": "tool_execution_start", + "toolCallId": "call_1444595", + "toolName": "todo", + "args": { + "action": "update", + "status": "completed", + "id": 1 + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_1444595", + "toolName": "todo", + "result": { + "content": [ + { + "type": "text", + "text": "Updated #1 (in_progress → completed)" + } + ], + "details": { + "action": "update", + "params": { + "action": "update", + "status": "completed", + "id": 1 + }, + "tasks": [ + { + "id": 1, + "subject": "Inspect workspace", + "status": "completed", + "activeForm": "inspecting workspace" + }, + { + "id": 2, + "subject": "Summarize findings", + "status": "pending" + } + ], + "nextId": 3 + } + }, + "isError": false + } + ], + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "call_406425", + "name": "todo", + "arguments": { + "action": "create", + "subject": "Inspect workspace" + } + } + ] + }, + { + "role": "toolResult", + "toolCallId": "call_406425", + "toolName": "todo", + "content": [ + { + "type": "text", + "text": "Created #1: Inspect workspace (pending)" + } + ], + "details": { + "action": "create", + "params": { + "action": "create", + "subject": "Inspect workspace" + }, + "tasks": [ + { + "id": 1, + "subject": "Inspect workspace", + "status": "pending" + } + ], + "nextId": 2 + }, + "isError": false, + "timestamp": 1790195575419 + }, + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "call_1389683", + "name": "todo", + "arguments": { + "subject": "Summarize findings", + "action": "create" + } + } + ] + }, + { + "role": "toolResult", + "toolCallId": "call_1389683", + "toolName": "todo", + "content": [ + { + "type": "text", + "text": "Created #2: Summarize findings (pending)" + } + ], + "details": { + "action": "create", + "params": { + "subject": "Summarize findings", + "action": "create" + }, + "tasks": [ + { + "id": 1, + "subject": "Inspect workspace", + "status": "pending" + }, + { + "id": 2, + "subject": "Summarize findings", + "status": "pending" + } + ], + "nextId": 3 + }, + "isError": false, + "timestamp": 1790195576353 + }, + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "call_1228319", + "name": "todo", + "arguments": { + "status": "in_progress", + "activeForm": "inspecting workspace", + "action": "update", + "id": 1 + } + } + ] + }, + { + "role": "toolResult", + "toolCallId": "call_1228319", + "toolName": "todo", + "content": [ + { + "type": "text", + "text": "Updated #1 (pending → in_progress)" + } + ], + "details": { + "action": "update", + "params": { + "status": "in_progress", + "activeForm": "inspecting workspace", + "action": "update", + "id": 1 + }, + "tasks": [ + { + "id": 1, + "subject": "Inspect workspace", + "status": "in_progress", + "activeForm": "inspecting workspace" + }, + { + "id": 2, + "subject": "Summarize findings", + "status": "pending" + } + ], + "nextId": 3 + }, + "isError": false, + "timestamp": 1790195577401 + }, + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "call_1444595", + "name": "todo", + "arguments": { + "action": "update", + "status": "completed", + "id": 1 + } + } + ] + }, + { + "role": "toolResult", + "toolCallId": "call_1444595", + "toolName": "todo", + "content": [ + { + "type": "text", + "text": "Updated #1 (in_progress → completed)" + } + ], + "details": { + "action": "update", + "params": { + "action": "update", + "status": "completed", + "id": 1 + }, + "tasks": [ + { + "id": 1, + "subject": "Inspect workspace", + "status": "completed", + "activeForm": "inspecting workspace" + }, + { + "id": 2, + "subject": "Summarize findings", + "status": "pending" + } + ], + "nextId": 3 + }, + "isError": false, + "timestamp": 1790195578305 + } + ] +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/rpiv-todo/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/rpiv-todo/index.test.ts new file mode 100644 index 0000000000..9e5b28fd73 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/rpiv-todo/index.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from "vitest"; +import { capturedToolCall, verifyTaskFixture } from "../task-fixture-test.js"; +import { rpivTodo } from "./index.js"; + +test("rpiv-todo maps captured RPC tasks through live and history", async () => { + await verifyTaskFixture(new URL("./fixtures/rpc-session.json", import.meta.url), [ + "pending", + "pending,pending", + "in_progress,pending", + "completed,pending", + ]); +}); + +test("rpiv-todo declines the example todo result shape", () => { + const call = capturedToolCall( + new URL("../pi-example-todo/fixtures/rpc-session.json", import.meta.url), + ); + expect(rpivTodo.createSession().mapToolCall?.(call)).toBeUndefined(); +}); diff --git a/packages/server/src/server/agent/providers/pi/extensions/rpiv-todo/index.ts b/packages/server/src/server/agent/providers/pi/extensions/rpiv-todo/index.ts new file mode 100644 index 0000000000..d439e079fe --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/rpiv-todo/index.ts @@ -0,0 +1,55 @@ +import { z } from "zod"; +import type { AgentTaskItem } from "../../../../agent-sdk-types.js"; +import type { PiExtension } from "../contract.js"; + +const Details = z.object({ + action: z.enum(["create", "update", "list", "get", "delete", "clear"]), + params: z.record(z.string(), z.unknown()), + nextId: z.number().int(), + tasks: z.array( + z.object({ + id: z.number().int(), + subject: z.string(), + status: z.enum(["pending", "in_progress", "completed", "deleted"]), + activeForm: z.string().optional(), + }), + ), + error: z.string().optional(), +}); + +export const rpivTodo: PiExtension = { + id: "rpiv-todo", + createSession: () => ({ + mapToolCall(call) { + if ( + call.toolName !== "todo" || + call.status !== "completed" || + !call.result || + typeof call.result === "string" + ) + return undefined; + const parsed = Details.safeParse(call.result.details); + if (!parsed.success) return undefined; + return { + timeline: parsed.data.error + ? [] + : [ + { + type: "todo", + items: parsed.data.tasks.flatMap((task): AgentTaskItem[] => { + if (task.status === "deleted") return []; + const item: AgentTaskItem = { + id: String(task.id), + text: task.subject, + status: task.status, + completed: task.status === "completed", + }; + if (task.activeForm) item.activeForm = task.activeForm; + return [item]; + }), + }, + ], + }; + }, + }), +}; diff --git a/packages/server/src/server/agent/providers/pi/extensions/subagent-fixture-test.ts b/packages/server/src/server/agent/providers/pi/extensions/subagent-fixture-test.ts new file mode 100644 index 0000000000..56d541a680 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/subagent-fixture-test.ts @@ -0,0 +1,91 @@ +import { readFileSync } from "node:fs"; +import pino from "pino"; +import { expect, vi } from "vitest"; +import type { AgentStreamEvent } from "../../../agent-sdk-types.js"; +import { PiRpcAgentClient } from "../agent.js"; +import { streamPiHistory } from "../history-mapper.js"; +import type { PiAgentMessage, PiAgentSessionEvent } from "../rpc-types.js"; +import { FakePi } from "../test-utils/fake-pi.js"; + +interface Fixture { + provenance: { + package: string; + version: string; + sourceCommit: string; + piVersion: string; + captureDate: string; + }; + events: PiAgentSessionEvent[]; + messages: PiAgentMessage[]; +} + +export function readSubagentFixture( + path: URL, + replaceSessionFile?: { from: string; to: string }, +): Fixture { + let source = readFileSync(path, "utf8"); + if (replaceSessionFile) + source = source.replaceAll( + replaceSessionFile.from, + JSON.stringify(replaceSessionFile.to).slice(1, -1), + ); + const fixture = JSON.parse(source) as Fixture; + expect(fixture.provenance).toEqual({ + package: expect.any(String), + version: expect.any(String), + sourceCommit: expect.any(String), + piVersion: expect.any(String), + captureDate: expect.any(String), + }); + return fixture; +} + +export async function verifySubagentFixture( + fixture: Fixture, +): Promise[]> { + const pi = new FakePi(); + const client = new PiRpcAgentClient({ logger: pino({ level: "silent" }), runtime: pi }); + const session = await client.createSession({ + provider: "pi", + cwd: "/tmp/paseo-pi-subagent-fixture", + }); + const live: AgentStreamEvent[] = []; + session.subscribe((event) => live.push(event)); + try { + await session.startTurn("Delegate work"); + for (const event of fixture.events) pi.latestSession().emit(event); + const immediate = live.filter((event) => event.type === "provider_subagent"); + expect(immediate.some((event) => event.event.type === "timeline")).toBe(false); + pi.latestSession().finishTurn(); + const replay: AgentStreamEvent[] = []; + for await (const event of streamPiHistory("pi", fixture.messages)) replay.push(event); + const replaySubagents = replay.filter((event) => event.type === "provider_subagent"); + await vi.waitFor(() => { + expect(live.filter((event) => event.type === "provider_subagent")).toEqual(replaySubagents); + }); + for (const message of fixture.messages) { + if (message.role !== "custom" || typeof message.content !== "string") continue; + const assistantText = { + type: "assistant_message" as const, + text: message.content, + }; + expect(live).toContainEqual( + expect.objectContaining({ type: "timeline", item: assistantText }), + ); + expect(replay).toContainEqual( + expect.objectContaining({ type: "timeline", item: assistantText }), + ); + } + const liveSubagents = live.filter((event) => event.type === "provider_subagent"); + const firstTimeline = liveSubagents.findIndex((event) => event.event.type === "timeline"); + if (firstTimeline !== -1) { + expect( + liveSubagents.slice(0, firstTimeline).some((event) => event.event.type === "upsert"), + ).toBe(true); + } + expect(liveSubagents).toEqual(replaySubagents); + return liveSubagents; + } finally { + await session.close(); + } +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/task-fixture-test.ts b/packages/server/src/server/agent/providers/pi/extensions/task-fixture-test.ts new file mode 100644 index 0000000000..a3b2114cab --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/task-fixture-test.ts @@ -0,0 +1,105 @@ +import { readFileSync } from "node:fs"; +import pino from "pino"; +import { expect } from "vitest"; +import type { AgentStreamEvent } from "../../../agent-sdk-types.js"; +import { PiRpcAgentClient } from "../agent.js"; +import { PiHistoryMapper } from "../history-mapper.js"; +import type { PiAgentMessage, PiAgentSessionEvent } from "../rpc-types.js"; +import { FakePi } from "../test-utils/fake-pi.js"; +import { parseToolResult } from "../tool-call-mapper.js"; +import { createPiExtensionHost } from "./index.js"; +import type { PiExtensionToolCall } from "./contract.js"; + +interface TaskFixture { + provenance: { + package: string; + version: string; + sourceCommit: string; + piVersion: string; + captureDate: string; + }; + events: PiAgentSessionEvent[]; + messages: PiAgentMessage[]; +} + +function todos(events: readonly AgentStreamEvent[]) { + return events.flatMap((event) => + event.type === "timeline" && event.item.type === "todo" ? [event.item] : [], + ); +} + +export function capturedToolCall(path: URL, index = 0): PiExtensionToolCall { + const fixture = JSON.parse(readFileSync(path, "utf8")) as TaskFixture; + const ends = fixture.events.filter((event) => event.type === "tool_execution_end"); + const end = ends[index]; + if (!end) throw new Error(`Missing captured tool result ${index}`); + const start = fixture.events.find( + (event) => event.type === "tool_execution_start" && event.toolCallId === end.toolCallId, + ); + if (!start || start.type !== "tool_execution_start") + throw new Error("Missing captured tool start"); + return { + callId: end.toolCallId, + toolName: end.toolName, + args: start.args, + status: end.isError ? "failed" : "completed", + result: parseToolResult(end.result), + }; +} + +export async function verifyTaskFixture(path: URL, expectedStatuses: string[]): Promise { + const fixture = JSON.parse(readFileSync(path, "utf8")) as TaskFixture; + expect(fixture.provenance).toEqual( + expect.objectContaining({ + package: expect.any(String), + version: expect.any(String), + sourceCommit: expect.any(String), + piVersion: expect.any(String), + captureDate: expect.any(String), + }), + ); + const host = createPiExtensionHost(); + const starts = new Map>(); + const mapped = []; + for (const event of fixture.events) { + if (event.type === "tool_execution_start") starts.set(event.toolCallId, event); + if (event.type !== "tool_execution_end") continue; + const start = starts.get(event.toolCallId); + expect(start).toBeDefined(); + const mapping = host.mapToolCall({ + callId: event.toolCallId, + toolName: event.toolName, + args: start?.args, + status: event.isError ? "failed" : "completed", + result: parseToolResult(event.result), + }); + mapped.push(...(mapping?.timeline ?? []).filter((item) => item.type === "todo")); + } + expect(mapped.map((item) => item.items.map((task) => task.status))).toEqual( + expectedStatuses.map((status) => status.split(",")), + ); + + const pi = new FakePi(); + const client = new PiRpcAgentClient({ logger: pino({ level: "silent" }), runtime: pi }); + const session = await client.createSession({ provider: "pi", cwd: "/tmp/paseo-pi-task-fixture" }); + const live: AgentStreamEvent[] = []; + session.subscribe((event) => live.push(event)); + try { + await session.startTurn("Use tasks"); + for (const event of fixture.events) pi.latestSession().emit(event); + pi.latestSession().finishTurn(); + expect(todos(live)).toEqual(mapped); + const replay = new PiHistoryMapper("pi").mapMessages(fixture.messages); + expect(todos(replay)).toEqual(mapped); + expect( + replay.filter( + (event) => + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.status === "completed", + ), + ).toHaveLength(fixture.events.filter((event) => event.type === "tool_execution_end").length); + } finally { + await session.close(); + } +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/fixtures/background.json b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/fixtures/background.json new file mode 100644 index 0000000000..41ebae67f3 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/fixtures/background.json @@ -0,0 +1,154 @@ +{ + "provenance": { + "package": "@tintinweb/pi-subagents", + "version": "0.19.0", + "sourceCommit": "4f572eaa04c09d3dbc16e4a5f13a16b295e84e14", + "piVersion": "0.84.4", + "captureDate": "2026-09-23" + }, + "events": [ + { + "type": "tool_execution_start", + "toolCallId": "call_411074", + "toolName": "Agent", + "args": { + "prompt": "list files in this empty workspace.", + "subagent_type": "general-purpose", + "run_in_background": true, + "description": "list files" + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_411074", + "toolName": "Agent", + "result": { + "content": [ + { + "type": "text", + "text": "Agent started in background.\nAgent ID: 98cdb309-045e-45d\nType: Agent\nDescription: list files\nOutput file: /tmp/pi-subagents-1000/tmp-paseo-pi-extension-phase3-workspace/01a0d018-fd1c-7ee8-bafa-16831f748cf1/tasks/98cdb309-045e-45d.output\n\nYou will be notified when this agent completes.\nUse get_subagent_result to retrieve full results, or steer_subagent to send it messages.\nDo not duplicate this agent's work." + } + ], + "details": { + "displayName": "Agent", + "description": "list files", + "subagentType": "general-purpose", + "modelName": "google: gemini 3.6 flash", + "tags": ["twin", "background"], + "toolUses": 0, + "tokens": "", + "durationMs": 0, + "status": "background", + "agentId": "98cdb309-045e-45d" + } + }, + "isError": false + }, + { + "type": "message_end", + "message": { + "role": "custom", + "customType": "subagent-notification", + "content": "\n98cdb309-045e-45d\ncall_411074\n/tmp/pi-subagents-1000/tmp-paseo-pi-extension-phase3-workspace/01a0d018-fd1c-7ee8-bafa-16831f748cf1/tasks/98cdb309-045e-45d.output\nDone\nAgent \"list files\" completed\nThe workspace directory `/tmp/paseo-pi-extension-phase3/workspace` is empty.\n18642115292\n\nFull transcript available at: /tmp/pi-subagents-1000/tmp-paseo-pi-extension-phase3-workspace/01a0d018-fd1c-7ee8-bafa-16831f748cf1/tasks/98cdb309-045e-45d.output", + "display": true, + "details": { + "id": "98cdb309-045e-45d", + "description": "list files", + "status": "completed", + "toolUses": 1, + "turnCount": 0, + "totalTokens": 18642, + "totalCost": 0.015582600000000002, + "durationMs": 5292, + "outputFile": "/tmp/pi-subagents-1000/tmp-paseo-pi-extension-phase3-workspace/01a0d018-fd1c-7ee8-bafa-16831f748cf1/tasks/98cdb309-045e-45d.output", + "resultPreview": "The workspace directory `/tmp/paseo-pi-extension-phase3/workspace` is empty." + }, + "timestamp": 1790197702084 + } + } + ], + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "call_411074", + "name": "Agent", + "arguments": { + "prompt": "list files in this empty workspace.", + "subagent_type": "general-purpose", + "run_in_background": true, + "description": "list files" + } + } + ], + "api": "openai-completions", + "provider": "openrouter", + "model": "google/gemini-3.6-flash", + "usage": { + "input": 13438, + "output": 125, + "cacheRead": 0, + "cacheWrite": 0, + "reasoning": 79, + "totalTokens": 13563, + "cost": { + "input": 0.0100785, + "output": 0.00046875000000000004, + "cacheRead": 0, + "cacheWrite": 0, + "total": 0.010547250000000001 + } + }, + "stopReason": "toolUse", + "timestamp": 1790197694776, + "responseId": "gen-1790197694-XL8DSeExpZQLQE8377rX", + "rawStopReason": "tool_calls" + }, + { + "role": "toolResult", + "toolCallId": "call_411074", + "toolName": "Agent", + "content": [ + { + "type": "text", + "text": "Agent started in background.\nAgent ID: 98cdb309-045e-45d\nType: Agent\nDescription: list files\nOutput file: /tmp/pi-subagents-1000/tmp-paseo-pi-extension-phase3-workspace/01a0d018-fd1c-7ee8-bafa-16831f748cf1/tasks/98cdb309-045e-45d.output\n\nYou will be notified when this agent completes.\nUse get_subagent_result to retrieve full results, or steer_subagent to send it messages.\nDo not duplicate this agent's work." + } + ], + "details": { + "displayName": "Agent", + "description": "list files", + "subagentType": "general-purpose", + "modelName": "google: gemini 3.6 flash", + "tags": ["twin", "background"], + "toolUses": 0, + "tokens": "", + "durationMs": 0, + "status": "background", + "agentId": "98cdb309-045e-45d" + }, + "isError": false, + "timestamp": 1790197696594 + }, + { + "role": "custom", + "customType": "subagent-notification", + "content": "\n98cdb309-045e-45d\ncall_411074\n/tmp/pi-subagents-1000/tmp-paseo-pi-extension-phase3-workspace/01a0d018-fd1c-7ee8-bafa-16831f748cf1/tasks/98cdb309-045e-45d.output\nDone\nAgent \"list files\" completed\nThe workspace directory `/tmp/paseo-pi-extension-phase3/workspace` is empty.\n18642115292\n\nFull transcript available at: /tmp/pi-subagents-1000/tmp-paseo-pi-extension-phase3-workspace/01a0d018-fd1c-7ee8-bafa-16831f748cf1/tasks/98cdb309-045e-45d.output", + "display": true, + "details": { + "id": "98cdb309-045e-45d", + "description": "list files", + "status": "completed", + "toolUses": 1, + "turnCount": 0, + "totalTokens": 18642, + "totalCost": 0.015582600000000002, + "durationMs": 5292, + "outputFile": "/tmp/pi-subagents-1000/tmp-paseo-pi-extension-phase3-workspace/01a0d018-fd1c-7ee8-bafa-16831f748cf1/tasks/98cdb309-045e-45d.output", + "resultPreview": "The workspace directory `/tmp/paseo-pi-extension-phase3/workspace` is empty." + }, + "timestamp": 1790197702084 + } + ] +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/fixtures/child-session.jsonl b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/fixtures/child-session.jsonl new file mode 100644 index 0000000000..b25ac4a4a3 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/fixtures/child-session.jsonl @@ -0,0 +1,4 @@ +{"isSidechain":true,"agentId":"98cdb309-045e-45d","type":"user","message":{"role":"user","content":"list files in this empty workspace."},"timestamp":"2026-09-23T21:08:16.594Z","cwd":"/tmp/paseo-pi-extension-phase3/workspace"} +{"isSidechain":true,"agentId":"98cdb309-045e-45d","type":"assistant","message":{"role":"assistant","content":[{"type":"thinking","thinking":"**Exploring File Listing Options**\n\nI'm currently evaluating the available tools like `ls` and `find` to fulfill the request of listing files in the empty `/tmp/paseo-pi-extension-phase3/workspace`. My next step is to determine the most efficient command for this purpose.\n\n","thinkingSignature":"[{\"type\":\"reasoning.text\",\"text\":\"**Exploring File Listing Options**\\n\\nI'm currently evaluating the available tools like `ls` and `find` to fulfill the request of listing files in the empty `/tmp/paseo-pi-extension-phase3/workspace`. My next step is to determine the most efficient command for this purpose.\\n\\n\",\"format\":\"google-gemini-v1\",\"index\":0},{\"type\":\"reasoning.encrypted\",\"data\":\"AY89a192ypUlDWGHPw18bSatn/b/48MmqVr7V0YSiUNNQ+0T+GC5rJwP1JXRK5sYaYHmvC3vKWcK9ecoV7oK0HTgrg9sNj4x8EVGiIi0NnIBR5jRegqcJ/uzfPEWcrl4J2VZ3UDTrjLCQPvQhVm0cr2G0W3LQd+jDaH4OBDNufV7kQPoYi9RMJPV9WdX7Mqh0TTfg+/pyUDZ5J3IESZgs5P+MKhiH38wpd5+0FqvalefDkAhJSXPwOkyylSG8+8L3lQf4bR+cZlD0k+lN73AjihntAZhAFGfbQWzeUfqGRCC+nsdcE4Fb5nGBu4iToIiu7FNWKt8q7oYQL5g0i5VB0raV8He5OkZV3QzxjvjtWykchkb6wpJWRkBOYkNNQXZKEj1E9seJ+PhiJWaNCGEr1IUoOu9u4W2O4VGoKhnFrEK3QN0Ny8HJAASG4zcZpR1RmkUyImmnydgQelbll3i1osGfl2zAq9ndNtuWpiv31kw6mNntcM2GR8vj46wynIoVltSmrCf4Ki47Xzbew+8neOVJZ54DHWl4Un72VtfUAqvkrXNuPC4S9P6YQBH8XquUzvzpU7PeZZ/l+WjBFaBWPXz2DlPdHiCqa9bf8biI/fL4yL+maEx0DcWVG+1LT/HT6T9b6AzHDDr7XewKv5A0XL769IJDb+ROTUS6275Sj/GL4CYaz8ejfyDfgx8i/jI1SzehSeBnF99Pv3EY9jFTavNt86gLU31EsUNPt5iC18W4ReIoGt6AExRApczMd6fWhLagF6vTXzGq+qgS+BaCsLAMWiptdJaEfp5x9mcN6Iuddk1nxRUEuO7/PmJgC5NnD6xHVsWc6eEl6VGBS7uqrGtGmXfHhfQDtZxf+qLpJKBsHMbuOXe85ZwhQ==\",\"format\":\"google-gemini-v1\",\"id\":\"call_417499\",\"index\":0}]"},{"type":"toolCall","id":"call_417499","name":"ls","arguments":{"path":"/tmp/paseo-pi-extension-phase3/workspace"}}],"api":"openai-completions","provider":"openrouter","model":"google/gemini-3.6-flash","usage":{"input":15478,"output":175,"cacheRead":0,"cacheWrite":0,"reasoning":148,"totalTokens":15653,"cost":{"input":0.0116085,"output":0.00065625,"cacheRead":0,"cacheWrite":0,"total":0.012264750000000001}},"stopReason":"toolUse","timestamp":1790197697300,"responseId":"gen-1790197697-VIbshov8B0i407MK5sjj","rawStopReason":"tool_calls"},"timestamp":"2026-09-23T21:08:20.155Z","cwd":"/tmp/paseo-pi-extension-phase3/workspace"} +{"isSidechain":true,"agentId":"98cdb309-045e-45d","type":"toolResult","message":{"role":"toolResult","toolCallId":"call_417499","toolName":"ls","content":[{"type":"text","text":"(empty directory)"}],"isError":false,"timestamp":1790197700155},"timestamp":"2026-09-23T21:08:20.155Z","cwd":"/tmp/paseo-pi-extension-phase3/workspace"} +{"isSidechain":true,"agentId":"98cdb309-045e-45d","type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"The workspace directory `/tmp/paseo-pi-extension-phase3/workspace` is empty."}],"api":"openai-completions","provider":"openrouter","model":"google/gemini-3.6-flash","usage":{"input":2948,"output":41,"cacheRead":12708,"cacheWrite":0,"reasoning":20,"totalTokens":15697,"cost":{"input":0.002211,"output":0.00015375,"cacheRead":0.0009530999999999999,"cacheWrite":0,"total":0.00331785}},"stopReason":"stop","timestamp":1790197700155,"responseId":"gen-1790197700-mOWiePGBCZu5WhYs3r1n","rawStopReason":"stop"},"timestamp":"2026-09-23T21:08:21.881Z","cwd":"/tmp/paseo-pi-extension-phase3/workspace"} diff --git a/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/fixtures/foreground.json b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/fixtures/foreground.json new file mode 100644 index 0000000000..66857a3ced --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/fixtures/foreground.json @@ -0,0 +1,117 @@ +{ + "provenance": { + "package": "@tintinweb/pi-subagents", + "version": "0.19.0", + "sourceCommit": "4f572eaa04c09d3dbc16e4a5f13a16b295e84e14", + "piVersion": "0.84.4", + "captureDate": "2026-09-23" + }, + "events": [ + { + "type": "tool_execution_start", + "toolCallId": "call_458926", + "toolName": "Agent", + "args": { + "subagent_type": "general-purpose", + "run_in_background": false, + "description": "List files in empty workspace", + "prompt": "list the files in this empty workspace" + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_458926", + "toolName": "Agent", + "result": { + "content": [ + { + "type": "text", + "text": "Agent completed in 6.6s (1 tool uses, 18.8k token).\n\nThe workspace directory `/tmp/paseo-pi-extension-phase3/workspace` is empty (no files or hidden dotfiles)." + } + ], + "details": { + "displayName": "Agent", + "description": "List files in empty workspace", + "subagentType": "general-purpose", + "modelName": "google: gemini 3.6 flash", + "tags": ["twin", "thinking: high"], + "toolUses": 1, + "tokens": "18.8k token", + "cost": 0.016011675, + "turnCount": 2, + "durationMs": 6637, + "status": "completed", + "agentId": "4f961541-49c3-430" + } + }, + "isError": false + } + ], + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "call_458926", + "name": "Agent", + "arguments": { + "subagent_type": "general-purpose", + "run_in_background": false, + "description": "List files in empty workspace", + "prompt": "list the files in this empty workspace" + } + } + ], + "api": "openai-completions", + "provider": "openrouter", + "model": "google/gemini-3.6-flash", + "usage": { + "input": 13432, + "output": 49, + "cacheRead": 0, + "cacheWrite": 0, + "reasoning": 0, + "totalTokens": 13481, + "cost": { + "input": 0.010074, + "output": 0.00018375, + "cacheRead": 0, + "cacheWrite": 0, + "total": 0.01025775 + } + }, + "stopReason": "toolUse", + "timestamp": 1790197658831, + "responseId": "gen-1790197658-itiLcQoBdeRe3YHKtihF", + "rawStopReason": "tool_calls" + }, + { + "role": "toolResult", + "toolCallId": "call_458926", + "toolName": "Agent", + "content": [ + { + "type": "text", + "text": "Agent completed in 6.6s (1 tool uses, 18.8k token).\n\nThe workspace directory `/tmp/paseo-pi-extension-phase3/workspace` is empty (no files or hidden dotfiles)." + } + ], + "details": { + "displayName": "Agent", + "description": "List files in empty workspace", + "subagentType": "general-purpose", + "modelName": "google: gemini 3.6 flash", + "tags": ["twin", "thinking: high"], + "toolUses": 1, + "tokens": "18.8k token", + "cost": 0.016011675, + "turnCount": 2, + "durationMs": 6637, + "status": "completed", + "agentId": "4f961541-49c3-430" + }, + "isError": false, + "timestamp": 1790197667103 + } + ] +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.test.ts new file mode 100644 index 0000000000..62a8e5ea26 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "vitest"; +import { fileURLToPath } from "node:url"; +import { createPiExtensionHost } from "../index.js"; +import { readSubagentFixture, verifySubagentFixture } from "../subagent-fixture-test.js"; + +describe("@tintinweb/pi-subagents adapter", () => { + test("maps captured foreground lifecycle live and on replay", async () => { + const events = await verifySubagentFixture( + readSubagentFixture(new URL("./fixtures/foreground.json", import.meta.url)), + ); + expect( + events + .filter((event) => event.event.type === "upsert") + .map((event) => (event.event.type === "upsert" ? event.event.status : null)), + ).toEqual(["running", "completed"]); + }); + test("uses a structured notification to complete a background child", async () => { + const source = readSubagentFixture(new URL("./fixtures/background.json", import.meta.url)); + const file = ( + source.messages.find((message) => message.role === "custom") as { + details: { outputFile: string }; + } + ).details.outputFile; + const fixture = readSubagentFixture(new URL("./fixtures/background.json", import.meta.url), { + from: file, + to: fileURLToPath(new URL("./fixtures/child-session.jsonl", import.meta.url)), + }); + const events = await verifySubagentFixture(fixture); + expect( + events + .filter((event) => event.event.type === "upsert") + .map((event) => (event.event.type === "upsert" ? event.event.status : null)), + ).toEqual(["running", "running", "completed"]); + expect(events.filter((event) => event.event.type === "timeline").length).toBeGreaterThan(0); + }); + test("declines foreign Agent results", () => { + expect( + createPiExtensionHost().mapToolCall({ + callId: "foreign", + toolName: "Agent", + args: { prompt: "foo" }, + status: "completed", + result: { details: { agentId: "other", status: "completed" } }, + }), + ).toBeUndefined(); + }); +}); diff --git a/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.ts b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.ts new file mode 100644 index 0000000000..dcfe093328 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.ts @@ -0,0 +1,147 @@ +import { z } from "zod"; +import { extractTextFromToolResult } from "../../tool-call-mapper.js"; +import type { PiExtension, PiExtensionToolCall } from "../contract.js"; + +const SpawnArgs = z + .object({ + subagent_type: z.string().trim().min(1), + prompt: z.string().trim().min(1), + description: z.string().optional(), + }) + .passthrough(); +const FollowupArgs = z.object({ agent_id: z.string().trim().min(1) }).passthrough(); +const Details = z + .object({ + agentId: z.string().trim().min(1), + status: z.string(), + displayName: z.string().optional(), + description: z.string().optional(), + }) + .passthrough(); +const Notification = z + .object({ + id: z.string().trim().min(1), + status: z.string(), + description: z.string().optional(), + outputFile: z.string().trim().min(1).optional(), + }) + .passthrough(); +const status = (value: string): "running" | "completed" | "failed" | "canceled" => { + if (value === "completed") return "completed"; + if (value === "error") return "failed"; + if (value === "aborted" || value === "stopped") return "canceled"; + return "running"; +}; + +export const tintinwebPiSubagents: PiExtension = { + id: "@tintinweb/pi-subagents", + createSession: () => { + const callsByAgent = new Map(); + const readSessions = new Set(); + const mapSpawn = (call: PiExtensionToolCall) => { + const args = SpawnArgs.safeParse(call.args); + if (!args.success) return undefined; + const details = Details.safeParse( + typeof call.result === "object" ? call.result?.details : null, + ); + const title = args.data.subagent_type; + const description = args.data.description ?? args.data.prompt; + const detail = { + type: "sub_agent" as const, + subAgentType: title, + description, + log: extractTextFromToolResult(call.result)?.trim() ?? "", + }; + if (call.status === "running") + return { + detail, + subagents: [ + { + type: "upsert" as const, + id: call.callId, + title, + description, + toolCallId: call.callId, + status: "running" as const, + }, + ], + }; + if (!details.success) + return call.status === "failed" + ? { + detail, + subagents: [{ type: "upsert" as const, id: call.callId, status: "failed" as const }], + } + : { detail }; + callsByAgent.set(details.data.agentId, call.callId); + return { + detail, + subagents: [ + { + type: "upsert" as const, + id: call.callId, + title, + description, + toolCallId: call.callId, + status: status(details.data.status), + }, + ], + }; + }; + const mapFollowup = (call: PiExtensionToolCall) => { + const args = FollowupArgs.safeParse(call.args); + if (!args.success) return undefined; + const id = callsByAgent.get(args.data.agent_id); + if (!id) return undefined; + const details = Details.safeParse( + typeof call.result === "object" ? call.result?.details : null, + ); + const detail = { + type: "sub_agent" as const, + description: details.success ? details.data.description : undefined, + log: extractTextFromToolResult(call.result)?.trim() ?? "", + }; + if (call.status === "running" || !details.success) return { detail }; + return { + detail, + subagents: [{ type: "upsert" as const, id, status: status(details.data.status) }], + }; + }; + return { + mapToolCall(call) { + if (call.toolName === "Agent") return mapSpawn(call); + if (call.toolName === "get_subagent_result" || call.toolName === "steer_subagent") + return mapFollowup(call); + return undefined; + }, + mapCustomMessage(message) { + if ( + message.customType !== "subagent-notification" && + message.customType !== "subagent-update" + ) + return undefined; + const details = Notification.safeParse(message.details); + if (!details.success) return undefined; + const id = callsByAgent.get(details.data.id); + if (!id) return undefined; + const file = details.data.outputFile; + const childSessions = + file && status(details.data.status) !== "running" && !readSessions.has(file) + ? [{ id, file }] + : []; + if (childSessions.length && file) readSessions.add(file); + return { + subagents: [ + { + type: "upsert", + id, + description: details.data.description, + status: status(details.data.status), + }, + ], + childSessions, + }; + }, + }; + }, +}; diff --git a/packages/server/src/server/agent/providers/pi/history-mapper.ts b/packages/server/src/server/agent/providers/pi/history-mapper.ts index cdb2257daa..4ec02e0133 100644 --- a/packages/server/src/server/agent/providers/pi/history-mapper.ts +++ b/packages/server/src/server/agent/providers/pi/history-mapper.ts @@ -1,11 +1,15 @@ import type { AgentStreamEvent, AgentTimelineItem, ToolCallDetail } from "../../agent-sdk-types.js"; +import { + createPiExtensionHost, + type PiExtensionEventOutput, + type PiExtensionHost, +} from "./extensions/index.js"; import type { PiAgentMessage, PiImageContent, PiTextContent } from "./rpc-types.js"; import { extractTextFromToolResult, mapToolDetail, parseToolArgs, parseToolResult, - resolveToolCallName, type PiToolResult, type PiTrackedToolCall, } from "./tool-call-mapper.js"; @@ -54,6 +58,7 @@ export function getUserMessageText(content: string | (PiTextContent | PiImageCon export class PiHistoryMapper { private readonly pendingToolCalls = new Map(); + private readonly hydrations: Promise[] = []; private userIndex = 0; private assistantIndex = 0; @@ -61,6 +66,7 @@ export class PiHistoryMapper { private readonly provider: string, private readonly userEntries: readonly PiCapturedUserMessageEntry[] = [], private readonly hooks: PiHistoryMapperHooks = {}, + private readonly extensionHost: PiExtensionHost = createPiExtensionHost(), ) {} mapMessages(messages: readonly PiAgentMessage[]): AgentStreamEvent[] { @@ -78,10 +84,7 @@ export class PiHistoryMapper { events.push(...this.mapAssistantMessage(message)); break; case "toolResult": { - const event = this.mapToolResultMessage(message); - if (event) { - events.push(event); - } + events.push(...this.mapToolResultMessage(message)); break; } case "bashExecution": @@ -93,6 +96,10 @@ export class PiHistoryMapper { return events; } + async hydrate(): Promise { + return (await Promise.all(this.hydrations)).flat(); + } + private mapUserMessage(message: Extract): AgentStreamEvent[] { const text = getUserMessageText(message.content); this.userIndex += 1; @@ -116,20 +123,25 @@ export class PiHistoryMapper { private mapCustomMessage( message: Extract, ): AgentStreamEvent[] { + const extensionMapping = this.extensionHost.mapCustomMessage(message); + const extensionEvents = this.extensionEvents(extensionMapping); const text = getUserMessageText(message.content); const mappedEvent = text ? this.hooks.mapCustomMessage?.(text, this.provider) : null; if (mappedEvent) { - return [mappedEvent]; + return [...extensionEvents, mappedEvent]; } - return text - ? [ - { - type: "timeline", - provider: this.provider, - item: { type: "assistant_message", text }, - }, - ] - : []; + return [ + ...extensionEvents, + ...(text + ? [ + { + type: "timeline", + provider: this.provider, + item: { type: "assistant_message", text }, + } as AgentStreamEvent, + ] + : []), + ]; } private mapAssistantMessage( @@ -159,7 +171,14 @@ export class PiHistoryMapper { if (content.type === "toolCall") { const tracked = parseToolArgs(content.name, content.arguments); this.pendingToolCalls.set(content.id, tracked); - const detail = this.mapToolDetail(content.id, tracked, null); + const mapping = this.extensionHost.mapToolCall({ + callId: content.id, + toolName: tracked.toolName, + args: tracked.args, + status: "running", + result: null, + }); + const detail = this.mapToolDetail(content.id, tracked, null, mapping?.detail); if (!detail) { continue; } @@ -169,12 +188,13 @@ export class PiHistoryMapper { item: { type: "tool_call", callId: this.resolveToolCallId(content.id, tracked), - name: tracked.toolName, + name: mapping?.name ?? tracked.toolName, status: "running", detail, error: null, }, }); + events.push(...this.extensionEvents(mapping)); } } return events; @@ -182,26 +202,42 @@ export class PiHistoryMapper { private mapToolResultMessage( message: Extract, - ): AgentStreamEvent | null { + ): AgentStreamEvent[] { const tracked = this.pendingToolCalls.get(message.toolCallId) ?? parseToolArgs(message.toolName, null); this.pendingToolCalls.delete(message.toolCallId); const result = parseToolResult({ content: message.content, details: message.details }); - const detail = this.mapToolDetail(message.toolCallId, tracked, result); + const mapping = this.extensionHost.mapToolCall({ + callId: message.toolCallId, + toolName: tracked.toolName, + args: tracked.args, + status: message.isError ? "failed" : "completed", + result, + }); + const detail = this.mapToolDetail(message.toolCallId, tracked, result, mapping?.detail); if (!detail) { - return null; + return []; } - return { - type: "timeline", - provider: this.provider, - item: toToolResultTimelineItem({ - callId: this.resolveToolCallId(message.toolCallId, tracked), - name: resolveToolCallName(tracked, result), - isError: Boolean(message.isError), - detail, - errorText: extractTextFromToolResult(result) ?? "Tool call failed", - }), - }; + return [ + { + type: "timeline", + provider: this.provider, + item: toToolResultTimelineItem({ + callId: this.resolveToolCallId(message.toolCallId, tracked), + name: mapping?.name ?? tracked.toolName, + isError: Boolean(message.isError), + detail, + errorText: extractTextFromToolResult(result) ?? "Tool call failed", + }), + }, + ...this.extensionEvents(mapping), + ]; + } + + private extensionEvents(mapping: PiExtensionEventOutput | undefined): AgentStreamEvent[] { + if (!mapping) return []; + this.hydrations.push(mapping.hydration); + return mapping.events; } private mapBashExecutionMessage( @@ -235,9 +271,12 @@ export class PiHistoryMapper { toolCallId: string, toolCall: PiTrackedToolCall, result: PiToolResult, + extensionDetail?: ToolCallDetail, ): ToolCallDetail | null { const hook = this.hooks.mapToolDetail; - return hook ? hook(toolCall, result, { toolCallId }) : mapToolDetail(toolCall, result); + return hook + ? hook(toolCall, result, { toolCallId }) + : (extensionDetail ?? mapToolDetail(toolCall, result)); } } @@ -246,13 +285,21 @@ export async function* streamPiHistory( messages: PiAgentMessage[], userEntries: readonly PiCapturedUserMessageEntry[] = [], hooks: PiHistoryMapperHooks = {}, + // At most eight 2 MiB child files per replay; remaining cards keep their summaries. + extensionHost: PiExtensionHost = createPiExtensionHost(undefined, undefined, 16 * 1024 * 1024), + signal?: AbortSignal, ): AsyncGenerator { - const mapper = new PiHistoryMapper(provider, userEntries, hooks); + const mapper = new PiHistoryMapper(provider, userEntries, hooks, extensionHost); for (const event of mapper.mapMessages(messages)) { + if (signal?.aborted) return; if (event) { yield event; } } + for (const event of await mapper.hydrate()) { + if (signal?.aborted) return; + yield event; + } } function toToolResultTimelineItem(input: { diff --git a/packages/server/src/server/agent/providers/pi/rpc-types.ts b/packages/server/src/server/agent/providers/pi/rpc-types.ts index a0948ade15..cfa09daeb6 100644 --- a/packages/server/src/server/agent/providers/pi/rpc-types.ts +++ b/packages/server/src/server/agent/providers/pi/rpc-types.ts @@ -41,6 +41,9 @@ export type PiAgentMessage = | { role: "custom"; content: string | Array; + customType?: string; + details?: unknown; + display?: boolean; } | { role: "assistant"; diff --git a/packages/server/src/server/agent/providers/pi/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/pi/tool-call-mapper.test.ts index 224e861769..63b557f994 100644 --- a/packages/server/src/server/agent/providers/pi/tool-call-mapper.test.ts +++ b/packages/server/src/server/agent/providers/pi/tool-call-mapper.test.ts @@ -1,11 +1,6 @@ import { describe, expect, test } from "vitest"; -import { - mapToolDetail, - parseToolArgs, - parseToolResult, - resolveToolCallName, -} from "./tool-call-mapper.js"; +import { mapToolDetail, parseToolArgs, parseToolResult } from "./tool-call-mapper.js"; describe("Pi tool call mapper", () => { test("maps bash args and result to shell detail", () => { @@ -50,81 +45,6 @@ describe("Pi tool call mapper", () => { }); }); - test("maps executed xdev writes to their wrapped tool detail", () => { - const toolCall = parseToolArgs("write", { - path: "xd://browser", - content: "{}", - }); - const result = parseToolResult({ - content: [{ type: "text", text: "Opened Example Domain" }], - details: { - xdev: { - tool: "browser", - mode: "execute", - args: { action: "open", url: "https://example.com" }, - inner: { title: "Example Domain" }, - }, - }, - }); - - expect(mapToolDetail(toolCall, result)).toEqual({ - type: "unknown", - input: { action: "open", url: "https://example.com" }, - output: { - content: [{ type: "text", text: "Opened Example Domain" }], - details: { title: "Example Domain" }, - }, - }); - expect(resolveToolCallName(toolCall, result)).toBe("browser"); - }); - - test("does not treat xdev help metadata as an executed inner tool", () => { - const toolCall = parseToolArgs("write", { - path: "xd://browser", - content: "", - }); - const result = parseToolResult({ - details: { - xdev: { - tool: "browser", - mode: "help", - inner: "Browser help", - }, - }, - }); - - expect(mapToolDetail(toolCall, result)).toEqual({ - type: "unknown", - input: { path: "xd://browser", content: "" }, - output: result, - }); - expect(resolveToolCallName(toolCall, result)).toBe("write"); - }); - - test("does not treat malformed xdev metadata as an executed inner tool", () => { - const toolCall = parseToolArgs("write", { - path: "xd://browser", - content: "{}", - }); - const result = parseToolResult({ - details: { - xdev: { - tool: "", - mode: "execute", - args: { action: "open" }, - inner: { title: "must not surface" }, - }, - }, - }); - - expect(mapToolDetail(toolCall, result)).toEqual({ - type: "unknown", - input: { path: "xd://browser", content: "{}" }, - output: result, - }); - expect(resolveToolCallName(toolCall, result)).toBe("write"); - }); - test("preserves unknown tool input and parsed output", () => { const toolCall = parseToolArgs("custom_tool", { value: 42 }); const result = parseToolResult({ text: "custom result" }); @@ -135,61 +55,4 @@ describe("Pi tool call mapper", () => { output: { text: "custom result" }, }); }); - - test("maps task calls to sub-agent detail while running", () => { - const toolCall = parseToolArgs("task", { - agent: "explore", - task: "Trace the Pi provider tool mapper", - }); - - expect(mapToolDetail(toolCall, null)).toEqual({ - type: "sub_agent", - subAgentType: "explore", - description: "Trace the Pi provider tool mapper", - log: "", - }); - }); - - test("maps completed subagent calls with task input to sub-agent detail", () => { - const toolCall = parseToolArgs("subagent", { - agent: "reviewer", - task: "Review the Pi mapper change", - }); - const result = parseToolResult({ - content: [{ type: "text", text: "The mapper change preserves provider status." }], - }); - - expect(mapToolDetail(toolCall, result)).toEqual({ - type: "sub_agent", - subAgentType: "reviewer", - description: "Review the Pi mapper change", - log: "The mapper change preserves provider status.", - }); - }); - - test("normalizes Pi MCP proxy calls from requested tool args while running", () => { - const toolCall = parseToolArgs("mcp", { - tool: "paseo_list_models", - args: '{"provider":"pi"}', - }); - - expect(resolveToolCallName(toolCall, null)).toBe("paseo.list_models"); - }); - - test("normalizes Pi MCP proxy calls from result details when completed", () => { - const toolCall = parseToolArgs("mcp", { - tool: "paseo_list_models", - args: '{"provider":"pi"}', - }); - const result = parseToolResult({ - content: [{ type: "text", text: "(empty result)" }], - details: { - mode: "call", - server: "paseo", - tool: "list_models", - }, - }); - - expect(resolveToolCallName(toolCall, result)).toBe("paseo.list_models"); - }); }); diff --git a/packages/server/src/server/agent/providers/pi/tool-call-mapper.ts b/packages/server/src/server/agent/providers/pi/tool-call-mapper.ts index 499627377e..8c18a8fdbf 100644 --- a/packages/server/src/server/agent/providers/pi/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/pi/tool-call-mapper.ts @@ -59,11 +59,7 @@ interface PiToolResultObject { interface PiToolResultDetails { diff?: string; - mode?: string; - server?: string; - tool?: string; - mcpResult?: unknown; - xdev?: unknown; + [key: string]: unknown; } interface PiToolResultTextContent { @@ -163,13 +159,6 @@ const PiToolResultDetailsSchema = z }) .passthrough(); -const XdevExecuteDetailsSchema = z.object({ - tool: z.string().trim().min(1), - mode: z.literal("execute"), - args: z.unknown().optional(), - inner: z.unknown().optional(), -}); - const PiToolResultObjectSchema = z .object({ output: z.string().optional(), @@ -184,14 +173,6 @@ const PiToolResultObjectSchema = z const PiToolResultSchema = z.union([z.string(), PiToolResultObjectSchema, z.null()]); -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function readNonEmptyString(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; -} - const BashToolInputSchema: z.ZodType = z.object({ command: z.string(), timeout: z.number().optional(), @@ -295,55 +276,9 @@ export function parseToolArgs(toolName: string, rawArgs: unknown): PiTrackedTool return { kind: "unknown", toolName, args: rawArgs ?? null }; } -function stripMcpProxyPrefix(toolName: string, serverName: string): string { - const prefix = `${serverName}_`; - return toolName.startsWith(prefix) ? toolName.slice(prefix.length) : toolName; -} - -export function resolveToolCallName(toolCall: PiTrackedToolCall, result?: PiToolResult): string { - if (toolCall.kind === "write" && result && typeof result !== "string") { - const xdev = XdevExecuteDetailsSchema.safeParse(result.details?.xdev); - if (xdev.success) { - return xdev.data.tool; - } - } - - if (toolCall.toolName !== "mcp") { - return toolCall.toolName; - } - - if (result && typeof result !== "string") { - const serverName = readNonEmptyString(result.details?.server); - const toolName = readNonEmptyString(result.details?.tool); - if (serverName && toolName) { - return `${serverName}.${toolName}`; - } - } - - if (isRecord(toolCall.args)) { - const requestedTool = readNonEmptyString(toolCall.args.tool); - const requestedServer = readNonEmptyString(toolCall.args.server); - if (requestedTool && requestedServer) { - return `${requestedServer}.${stripMcpProxyPrefix(requestedTool, requestedServer)}`; - } - if (requestedTool) { - const [serverName, ...toolParts] = requestedTool.split("_"); - if (serverName && toolParts.length > 0) { - return `${serverName}.${toolParts.join("_")}`; - } - } - } - - return toolCall.toolName; -} - export function mapToolDetail(toolCall: PiTrackedToolCall, result?: PiToolResult): ToolCallDetail { const parsedResult = result ?? null; - if (isTaskToolCall(toolCall)) { - return mapTaskToolDetail(toolCall.args, parsedResult); - } - switch (toolCall.kind) { case "bash": { const summary = resolveToolCallOutput(parsedResult); @@ -376,7 +311,7 @@ export function mapToolDetail(toolCall: PiTrackedToolCall, result?: PiToolResult }; } case "write": - return mapWriteToolDetail(toolCall.args, parsedResult); + return { type: "write", filePath: toolCall.args.path, content: toolCall.args.content }; case "find": return mapFindToolDetail(toolCall.args, parsedResult); case "grep": @@ -392,58 +327,6 @@ export function mapToolDetail(toolCall: PiTrackedToolCall, result?: PiToolResult } } -function isTaskToolCall(toolCall: PiTrackedToolCall): boolean { - if (toolCall.toolName === "task") { - return true; - } - if (toolCall.toolName !== "subagent" || !isRecord(toolCall.args)) { - return false; - } - return ( - toolCall.args.action === undefined && - (readNonEmptyString(toolCall.args.agent) !== undefined || - readNonEmptyString(toolCall.args.task) !== undefined) - ); -} - -function mapTaskToolDetail(args: unknown, result: PiToolResult): ToolCallDetail { - const argRecord = isRecord(args) ? args : {}; - return { - type: "sub_agent", - subAgentType: readNonEmptyString(argRecord.agent), - description: readNonEmptyString(argRecord.task), - log: extractTextFromToolResult(result)?.trim() ?? "", - }; -} - -function mapWriteToolDetail(args: WriteToolInput, result: PiToolResult): ToolCallDetail { - if (result && typeof result !== "string" && result.details && "xdev" in result.details) { - const xdev = XdevExecuteDetailsSchema.safeParse(result.details.xdev); - if (xdev.success) { - return { - type: "unknown", - input: xdev.data.args ?? null, - output: { - ...result, - details: xdev.data.inner ?? null, - }, - }; - } - - return { - type: "unknown", - input: args, - output: result, - }; - } - - return { - type: "write", - filePath: args.path, - content: args.content, - }; -} - function resolveToolCallOutput(result: PiToolResult): ToolCallOutputSummary { if (typeof result === "string") { return { output: result };