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 969b221f02..760ea4bdc4 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,7 @@ import { transformPiModels, } from "./agent.js"; import { FakePi } from "./test-utils/fake-pi.js"; +import { HIDDEN_GOAL_CONTINUATION } from "./test-utils/hidden-custom-message.js"; import { createPiExtensionHost } from "./extensions/index.js"; import { PiExtensionHost } from "./extensions/host.js"; import type { PiModel, PiThinkingLevel } from "./rpc-types.js"; @@ -1080,6 +1081,17 @@ describe("PiRpcAgentSession", () => { ]); }); + test("hides Pi custom messages that the extension marks display: false", async () => { + const { pi, events } = await createSession(); + const fakeSession = pi.latestSession(); + + fakeSession.emit({ type: "agent_start" }); + fakeSession.emit({ type: "turn_start" }); + fakeSession.emit({ type: "message_end", message: HIDDEN_GOAL_CONTINUATION }); + + expect(events.timelineItems()).toEqual([]); + }); + test("canceling a silent Pi extension command leaves the session usable", 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 5fd7b051ef..2902f15aac 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -59,7 +59,7 @@ import { toDiagnosticErrorMessage, } from "../diagnostic-utils.js"; import { - getUserMessageText, + getCustomMessageText, streamPiHistory, type PiCapturedUserMessageEntry, } from "./history-mapper.js"; @@ -2300,7 +2300,7 @@ export class PiRpcAgentSession implements AgentSession { if (event.message.role === "custom") { const customMapping = this.extensionHost.mapCustomMessage(event.message); this.emitExtensionOutput(customMapping, turnId); - const text = getUserMessageText(event.message.content); + const text = getCustomMessageText(event.message); if (text) { this.emit({ type: "timeline", 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 index 56d541a680..d77192c6ca 100644 --- 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 @@ -65,16 +65,18 @@ export async function verifySubagentFixture( }); 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 assistantText = expect.objectContaining({ + type: "timeline", + item: { type: "assistant_message", text: message.content }, + }); + // Claimed notifications keep their text unless the extension hides it from the user. + if (message.display === false) { + expect(live).not.toContainEqual(assistantText); + expect(replay).not.toContainEqual(assistantText); + } else { + expect(live).toContainEqual(assistantText); + expect(replay).toContainEqual(assistantText); + } } const liveSubagents = live.filter((event) => event.type === "provider_subagent"); const firstTimeline = liveSubagents.findIndex((event) => event.event.type === "timeline"); diff --git a/packages/server/src/server/agent/providers/pi/history-mapper.test.ts b/packages/server/src/server/agent/providers/pi/history-mapper.test.ts index 380cf4ee75..36b0c35cb0 100644 --- a/packages/server/src/server/agent/providers/pi/history-mapper.test.ts +++ b/packages/server/src/server/agent/providers/pi/history-mapper.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from "vitest"; import type { AgentStreamEvent } from "../../agent-sdk-types.js"; import { streamPiHistory, type PiCapturedUserMessageEntry } from "./history-mapper.js"; import type { PiAgentMessage } from "./rpc-types.js"; +import { HIDDEN_GOAL_CONTINUATION } from "./test-utils/hidden-custom-message.js"; async function collectHistory( messages: PiAgentMessage[], @@ -140,6 +141,10 @@ describe("Pi history mapper", () => { ]); }); + test("drops custom messages that the extension marks display: false", async () => { + await expect(collectHistory([HIDDEN_GOAL_CONTINUATION])).resolves.toEqual([]); + }); + test("uses Pi tree entry ids for replayed user messages", async () => { await expect( collectHistory( 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 4ec02e0133..27ac3b5c1e 100644 --- a/packages/server/src/server/agent/providers/pi/history-mapper.ts +++ b/packages/server/src/server/agent/providers/pi/history-mapper.ts @@ -20,10 +20,6 @@ export interface PiCapturedUserMessageEntry { } export interface PiHistoryMapperHooks { - mapCustomMessage?: ( - text: string, - provider: string, - ) => Extract | null; resolveToolCallId?: (toolCallId: string, toolCall: PiTrackedToolCall) => string; mapToolDetail?: ( toolCall: PiTrackedToolCall, @@ -56,6 +52,11 @@ export function getUserMessageText(content: string | (PiTextContent | PiImageCon return textParts.join("\n\n"); } +/** The text Pi shows for a custom message. Extensions hide model-only messages with `display: false`. */ +export function getCustomMessageText(message: Extract): string { + return message.display === false ? "" : getUserMessageText(message.content); +} + export class PiHistoryMapper { private readonly pendingToolCalls = new Map(); private readonly hydrations: Promise[] = []; @@ -125,11 +126,7 @@ export class PiHistoryMapper { ): 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 [...extensionEvents, mappedEvent]; - } + const text = getCustomMessageText(message); return [ ...extensionEvents, ...(text diff --git a/packages/server/src/server/agent/providers/pi/test-utils/hidden-custom-message.ts b/packages/server/src/server/agent/providers/pi/test-utils/hidden-custom-message.ts new file mode 100644 index 0000000000..2ae92bd6c8 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/test-utils/hidden-custom-message.ts @@ -0,0 +1,23 @@ +import type { PiAgentMessage } from "../rpc-types.js"; + +// A pi-goal-x 0.31.9 (2e8ad767) checkpoint continuation, captured verbatim from a real +// `pi --mode rpc` run (Pi 0.84.4) on 2026-09-26. pi-goal-x sends it with `display: false`: +// the marker is for the model, and Pi's TUI never renders it. +export const HIDDEN_GOAL_CONTINUATION: PiAgentMessage = { + role: "custom", + customType: "pi-goal-event", + content: '', + display: false, + details: { + version: 3, + generation: "13a8082c-e249-4df5-ac0e-fdc863ad289e", + dispatchId: "a9c0ef02-d39c-42a2-8d5a-993a96b4e827", + kind: "checkpoint", + goalId: "muioeg51-6db1a7", + status: "active", + revision: 3, + checkpointSeq: 1, + timestamp: 1790444456255, + }, + timestamp: 1790444456256, +};