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 };