diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c8a7d11..c0af562 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -29,8 +29,15 @@ jobs: - name: Unit tests run: npm run test:unit - - name: E2E tests - run: npx playwright test --reporter=html,list + - name: Build + run: npm run build + + - name: Functional realtime E2E suite + run: >- + npx playwright test --project=desktop --reporter=html,list + tests/e2e/custom-messages.spec.ts + tests/e2e/interleaving.spec.ts + tests/e2e/retry-errors.spec.ts - name: Upload Playwright report if: always() diff --git a/server/mock.ts b/server/mock.ts index 8a3172e..c627d89 100644 --- a/server/mock.ts +++ b/server/mock.ts @@ -1,5 +1,6 @@ import { join } from "node:path"; import type { PiWebSession, PiWebSessionInfo } from "./types.js"; +import { simplifyMessage } from "./session/projection.js"; interface MockSessionOptions { piCwd: string; @@ -217,12 +218,19 @@ export function createMockHarness(options: MockSessionOptions) { function broadcastPiEvent(event: Record, activityAt?: string | false) { const lastActivityAt = activityAt === false ? runtimeLastActivityAt : markRuntimeActivity(activityAt || new Date().toISOString()); + const committedMessage = event.type === "message_end" ? simplifyMessage(event.message) : undefined; broadcast({ type: "pi_event", sessionId: mockSession.sessionId, sessionFile: mockSession.sessionFile, event: lastActivityAt ? { ...event, lastActivityAt } : event, }); + if (committedMessage) broadcast({ + type: "committed_message", + sessionId: mockSession.sessionId, + sessionFile: mockSession.sessionFile, + message: committedMessage, + }); } async function runMockCompaction(customInstructions?: string, slow = false) { @@ -488,6 +496,7 @@ export function createMockHarness(options: MockSessionOptions) { const withoutAgentEnd = /missing agent end|no agent end/i.test(message); const withStaleRuntimeAfterEnd = /stale runtime after end/i.test(message); const withPendingToolRefresh = /pending tool refresh/i.test(message) || withProgressDemo; + const withLiveMessageKinds = /live message kinds/i.test(message); const withTools = !withShowcase && !withEditTool && !withMalformedEditTool && !withInterruptedTool && (/tool|interleav/i.test(message) || withProgressDemo || withLateToolTimestamp); mockSession.isStreaming = true; if (withQuietRuntime) { @@ -497,7 +506,26 @@ export function createMockHarness(options: MockSessionOptions) { } broadcastRuntimeChanged(); broadcastPiEvent({ type: "agent_start", startedAt: runtimeStartedAt }, runtimeLastActivityAt || runtimeStartedAt); - if (withQuietRuntime) { + if (withLiveMessageKinds) { + // Let the browser apply agent_start before exercising interleaved + // committed messages; this keeps the scenario deterministic on CI. + if (!(await waitForMockRun(150))) return; + if (!(await waitForMockRun(500))) return; + const timestamp = new Date().toISOString(); + const visibleCustom = { role: "custom", customType: "probe", content: "hello from an extension", details: { source: "mock-extension" }, display: true, timestamp }; + appendMockMessage(visibleCustom); + broadcastPiEvent({ type: "message_end", message: visibleCustom }); + if (!(await waitForMockRun(500))) return; + broadcastPiEvent({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "streamed prefix" } }); + const hiddenCustom = { role: "custom", customType: "probe-hidden", content: "hidden extension message", details: { source: "mock-extension" }, display: false, timestamp }; + appendMockMessage(hiddenCustom); + broadcastPiEvent({ type: "message_end", message: hiddenCustom }); + const unknownMessage = { role: "futureKind", content: "future message content", timestamp }; + appendMockMessage(unknownMessage); + broadcastPiEvent({ type: "message_end", message: unknownMessage }); + broadcastPiEvent({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "streamed suffix" } }); + } + if (withQuietRuntime || withLiveMessageKinds) { if (!(await waitForMockRun(60_000))) return; } else if (slow && !(await waitForMockRun(/queue demo/i.test(message) ? 2_500 : 750))) return; if (withProviderError) { diff --git a/server/session/dto.ts b/server/session/dto.ts index 68ca5a4..3e3d609 100644 --- a/server/session/dto.ts +++ b/server/session/dto.ts @@ -36,20 +36,25 @@ export interface BaseSessionStateDto { stats: SessionStatsDto; } -/** Serializable message projection consumed by the browser message list. */ -export interface MessageDto { +/** Serializable, role-discriminated projection consumed by every transcript path. */ +type MessageDtoBase = { entryId?: string; - role?: string; text?: string; - toolCalls?: Array<{ id?: string; toolName: string; args: JsonValue; startedAt?: string }>; - toolCallId?: string; - toolName?: string; - toolArgs?: JsonValue; - isError?: boolean; timestamp?: string; raw?: JsonValue; - [key: string]: JsonValue | undefined; -} +}; + +export type MessageDto = MessageDtoBase & ( + | { role: "user"; isError?: boolean } + | { role: "assistant"; toolCalls?: Array<{ id?: string; toolName: string; args: JsonValue; startedAt?: string }>; isError: boolean } + | { role: "system"; isError?: boolean } + | { role: "toolResult"; toolCallId?: string; toolName?: string; toolArgs?: JsonValue; isError: boolean } + | { role: "bashExecution"; command?: JsonValue; output?: JsonValue; exitCode?: JsonValue; cancelled: boolean; truncated: boolean; fullOutputPath?: JsonValue; excludeFromContext: boolean } + | { role: "compactionSummary"; isError?: boolean } + | { role: "branchSummary"; isError?: boolean } + | { role: "unknown"; originalRole: string; isError?: boolean } + | { role: "custom"; customType: string; details?: JsonValue; display: true } +); export interface TreeNodeDto { id: string; @@ -111,6 +116,7 @@ export interface DeleteSessionResultDto { export type SessionServiceEvent = | { type: "pi"; sessionId: string; sessionFile: string; event: JsonValue; clientMessageId?: string; sourceClientId?: string } | { type: "state"; state: BaseSessionStateDto; includeThinkingLevels?: boolean } + | { type: "committed"; sessionId: string; sessionFile: string; message: MessageDto } | { type: "stats"; sessionId: string; sessionFile: string; stats: SessionStatsDto } | { type: "models"; sessionId: string; models: ModelDto[] } | { type: "error"; sessionId?: string; sessionFile?: string; error: string; clientMessageId?: string } diff --git a/server/session/hostEvents.ts b/server/session/hostEvents.ts index 1169d39..3beab6a 100644 --- a/server/session/hostEvents.ts +++ b/server/session/hostEvents.ts @@ -57,7 +57,7 @@ export function decorateHostMessages(messages: MessageDto[], sessionFile: string : []; return { ...message, - ...(message.toolCalls ? { + ...(message.role === "assistant" && message.toolCalls ? { toolCalls: message.toolCalls.map((call, index) => { const startedAt = decoratedToolCalls[index]?.startedAt; return startedAt && !call.startedAt ? { ...call, startedAt } : call; @@ -96,6 +96,14 @@ export function createHostSessionEventHandler(deps: HostEventDependencies) { }); return; } + case "committed": + deps.broadcast({ + type: "committed_message", + sessionId: serviceEvent.sessionId, + sessionFile: serviceEvent.sessionFile, + message: decorateHostMessages([serviceEvent.message], serviceEvent.sessionFile, deps.sessionActivity)[0], + }); + return; case "state": { const target = deps.sessionForId(serviceEvent.state.sessionId); if (target) deps.broadcast({ type: "state_changed", ...decorate(serviceEvent.state, target, Boolean(serviceEvent.includeThinkingLevels)) }); diff --git a/server/session/projection.ts b/server/session/projection.ts index a8706f1..81aa2a2 100644 --- a/server/session/projection.ts +++ b/server/session/projection.ts @@ -1,8 +1,10 @@ import type { PiWebSession } from "../types.js"; -import type { BaseSessionStateDto, ConversationTreeDto, ModelDto, SessionStatsDto, SlashCommandDto } from "./dto.js"; +import { jsonRoundTrip, type BaseSessionStateDto, type ConversationTreeDto, type MessageDto, type ModelDto, type SessionStatsDto, type SlashCommandDto } from "./dto.js"; export type ContentDecorator = (content: unknown) => unknown; +const warnedUnknownMessageRoles = new Set(); + export function textFromContent(content: unknown): string { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; @@ -76,14 +78,14 @@ export function messageEntryRefs(targetSession: PiWebSession): Array<{ entryId?: export function simplifyMessage( message: unknown, options: { toolCallArgs?: Map>; decorateContent?: ContentDecorator; entryId?: string } = {}, -) { - if (!message || typeof message !== "object") return message; +): MessageDto | undefined { + if (!message || typeof message !== "object") return undefined; const m = message as Record; const content = options.decorateContent ? options.decorateContent(m.content) : m.content; const entry = options.entryId ? { entryId: options.entryId } : {}; const toolCallArgs = options.toolCallArgs; if (m.role === "bashExecution") { - return { + return jsonRoundTrip({ ...entry, role: "bashExecution", command: m.command, @@ -95,11 +97,11 @@ export function simplifyMessage( excludeFromContext: Boolean(m.excludeFromContext), timestamp: m.timestamp, raw: m, - }; + }) as MessageDto; } if (m.role === "toolResult") { const args = toolCallArgs?.get(m.toolCallId as string); - return { + return jsonRoundTrip({ ...entry, role: "toolResult", toolCallId: m.toolCallId, @@ -109,7 +111,35 @@ export function simplifyMessage( text: textFromContent(m.content), timestamp: m.timestamp, raw: m, - }; + }) as MessageDto; + } + if (m.role === "custom") { + if (m.display === false) return undefined; + return jsonRoundTrip({ + ...entry, + role: "custom", + customType: typeof m.customType === "string" ? m.customType : "", + text: textFromContent(content), + details: m.details, + display: true, + timestamp: m.timestamp, + raw: content === m.content ? m : { ...m, content }, + }) as MessageDto; + } + if (!["user", "assistant", "system", "compactionSummary", "branchSummary"].includes(String(m.role))) { + const originalRole = typeof m.role === "string" && m.role ? m.role : "unknown"; + if (!warnedUnknownMessageRoles.has(originalRole)) { + warnedUnknownMessageRoles.add(originalRole); + console.warn(`Projecting unknown transcript message role: ${originalRole}`); + } + return jsonRoundTrip({ + ...entry, + role: "unknown", + originalRole, + text: textFromContent(content), + timestamp: m.timestamp, + raw: content === m.content ? m : { ...m, content }, + }) as MessageDto; } const text = textFromContent(content); const errorText = m.role === "assistant" && m.errorMessage ? assistantErrorPreview(m) : ""; @@ -123,7 +153,7 @@ export function simplifyMessage( startedAt: part.startedAt, })) : undefined; - return { + return jsonRoundTrip({ ...entry, role: m.role, text: displayText, @@ -131,7 +161,33 @@ export function simplifyMessage( isError: Boolean(m.errorMessage || m.stopReason === "error" || stopReasonText), timestamp: m.timestamp, raw: content === m.content ? m : { ...m, content }, - }; + }) as MessageDto; +} + +function messageProjectionContext(targetSession: PiWebSession) { + const toolCallArgs = new Map>(); + for (const message of targetSession.messages as any[]) { + if (message?.role !== "assistant" || !Array.isArray(message.content)) continue; + for (const part of message.content) { + if (part?.type === "toolCall" && part.id) toolCallArgs.set(part.id, part.arguments || {}); + } + } + return { toolCallArgs, refs: messageEntryRefs(targetSession) }; +} + +export function projectMessages(targetSession: PiWebSession): MessageDto[] { + const { toolCallArgs, refs } = messageProjectionContext(targetSession); + return targetSession.messages.flatMap((message, index) => { + const projected = simplifyMessage(message, { toolCallArgs, entryId: refs[index]?.entryId }); + return projected ? [projected] : []; + }); +} + +export function projectCommittedMessage(targetSession: PiWebSession, committed: unknown): MessageDto | undefined { + const index = targetSession.messages.lastIndexOf(committed as never); + if (index < 0) return undefined; + const { toolCallArgs, refs } = messageProjectionContext(targetSession); + return simplifyMessage(targetSession.messages[index], { toolCallArgs, entryId: refs[index]?.entryId }); } export function truncatePreview(value: string, max = 220) { @@ -141,7 +197,14 @@ export function truncatePreview(value: string, max = 220) { export function entryMessage(entry: any) { if (entry?.type === "message") return entry.message; - if (entry?.type === "custom_message") return { role: "custom", content: entry.content, timestamp: entry.timestamp }; + if (entry?.type === "custom_message") return { + role: "custom", + customType: entry.customType, + content: entry.content, + details: entry.details, + display: entry.display, + timestamp: entry.timestamp, + }; return undefined; } diff --git a/server/session/service.ts b/server/session/service.ts index eba51a7..47c45db 100644 --- a/server/session/service.ts +++ b/server/session/service.ts @@ -33,10 +33,10 @@ import { isAssistantAbortedMessage, isAssistantFailureMessage, isIncompleteToolResultMessage, - messageEntryRefs, + projectCommittedMessage, + projectMessages, projectSessionState, sessionStats, - simplifyMessage, simplifyModel, } from "./projection.js"; @@ -266,19 +266,7 @@ export class LocalSessionService implements SessionService { } async messages(sessionId: string): Promise { - const value = await this.require(sessionId); - const toolCallArgs = new Map>(); - for (const message of value.messages as any[]) { - if (message?.role !== "assistant" || !Array.isArray(message.content)) continue; - for (const part of message.content) { - if (part?.type === "toolCall" && part.id) toolCallArgs.set(part.id, part.arguments || {}); - } - } - const refs = messageEntryRefs(value); - return jsonSafe(value.messages.map((message, index) => simplifyMessage(message, { - toolCallArgs, - entryId: refs[index]?.entryId, - }) as MessageDto)); + return jsonSafe(projectMessages(await this.require(sessionId))); } async commands(sessionId: string) { @@ -663,6 +651,16 @@ export class LocalSessionService implements SessionService { event: event as JsonValue, ...(correlation ? { clientMessageId: correlation.clientMessageId, sourceClientId: correlation.sourceClientId } : {}), }); + if (e?.type === "message_end") { + const committed = e.message; + // agent-core inserts this object before notifying listeners; the agent + // relay persists its entry after listeners return, while idle custom + // messages persist before emitting. Defer so both paths expose entry metadata. + queueMicrotask(() => { + const message = projectCommittedMessage(value, committed); + if (message) this.emit({ type: "committed", sessionId, sessionFile: value.sessionFile, message }); + }); + } if (e?.type === "session_info_changed") this.emit({ type: "state", state: this.projectState(value) }); if (e?.type === "message_end" || e?.type === "agent_end" || e?.type === "compaction_end") { this.emit({ type: "stats", sessionId, sessionFile, stats: sessionStats(value) }); diff --git a/src/messages/messageList.ts b/src/messages/messageList.ts index 2961dc9..57eb728 100644 --- a/src/messages/messageList.ts +++ b/src/messages/messageList.ts @@ -1,6 +1,7 @@ import type { ApiHeaders } from "../app/api.js"; import { iconElement, type IconName } from "../app/icons.js"; import type { AttachedImage, Role } from "../app/types.js"; +import type { MessageDto } from "../../server/session/dto.js"; import { attachImageActions } from "../components/imageActions.js"; import type { MarkdownRenderer } from "../markdown/render.js"; import { assistantErrorBody, cleanThinkingText, imageFileName, imagesFromRawContent, isRetryableAssistantError, messageText, normalizeAssistantError, shouldCollapseMessage, stripImagePathNote, thinkingTextSegments } from "./content.js"; @@ -67,6 +68,12 @@ export type MessageList = { }) => Promise; resetStreamingAssistant: () => void; invalidateRefreshes: () => void; + appendCommittedMessage: (message: MessageDto, options: { + addToolHistoryCard: AddToolHistoryCard; + addPendingToolCard: AddPendingToolCard; + addRuntimeErrorCard: AddRuntimeErrorCard; + isStreaming?: boolean; + }) => void; scrollToBottom: () => void; }; @@ -939,6 +946,83 @@ export function createMessageList(options: { } } + function assertNeverMessage(message: never): never { + throw new Error(`Unsupported transcript message: ${JSON.stringify(message)}`); + } + + function renderMessage(message: MessageDto, options: { + addToolHistoryCard: AddToolHistoryCard; + addPendingToolCard: AddPendingToolCard; + addRuntimeErrorCard: AddRuntimeErrorCard; + completedToolResults: Map; + renderedToolResultIds: Set; + isStreaming?: boolean; + }) { + const { addToolHistoryCard, addPendingToolCard, addRuntimeErrorCard, completedToolResults, renderedToolResultIds, isStreaming } = options; + switch (message.role) { + case "toolResult": { + const id = message.toolCallId; + if (id && renderedToolResultIds.has(id)) return; + renderToolResultMessage(message, addToolHistoryCard); + return; + } + case "bashExecution": { + const exitCode = typeof message.exitCode === "number" ? message.exitCode : undefined; + addToolHistoryCard("bash", Boolean(message.cancelled || (exitCode !== undefined && exitCode !== 0)), message, { command: String(message.command || "") }); + return; + } + case "assistant": + renderAssistantMessageParts(message, { addToolHistoryCard, addPendingToolCard, addRuntimeErrorCard, completedToolResults, renderedToolResultIds, isStreaming }); + return; + case "user": { + const text = messageText(message); + if (text) addMessage("user", text, message.isError ? "error" : "", imagesFromRawContent(rawContent(message)), { entryId: message.entryId }); + return; + } + case "system": + case "unknown": { + const text = messageText(message); + if (text) addMessage("system", text, message.isError ? "error" : "", [], { entryId: message.entryId }); + return; + } + case "compactionSummary": + case "branchSummary": { + const text = messageText(message); + if (text) addMessage("system", text, message.role === "compactionSummary" ? "compaction" : "", [], { entryId: message.entryId }); + return; + } + case "custom": { + const text = messageText(message); + if (!text) return; + const customType = message.customType.replace(/[^a-zA-Z0-9_-]+/g, "-"); + addMessage("system", text, `custom${customType ? ` custom--${customType}` : ""}`, [], { entryId: message.entryId }); + return; + } + default: + assertNeverMessage(message); + } + } + + function appendCommittedMessage(message: MessageDto, options: { + addToolHistoryCard: AddToolHistoryCard; + addPendingToolCard: AddPendingToolCard; + addRuntimeErrorCard: AddRuntimeErrorCard; + isStreaming?: boolean; + }) { + const streamingAnchor = streamingAssistant?.isConnected ? streamingAssistant : null; + const existingChildren = new Set(messagesEl.children); + renderMessage(message, { + ...options, + completedToolResults: new Map(), + renderedToolResultIds: new Set(), + }); + if (streamingAnchor) { + for (const child of Array.from(messagesEl.children)) { + if (!existingChildren.has(child)) messagesEl.insertBefore(child, streamingAnchor); + } + } + } + async function refreshMessages({ sessionId, headers, addToolHistoryCard, addPendingToolCard, addRuntimeErrorCard, clearActiveToolCards, isStreaming, updateEmptyCwdChooser, onTranscriptRuntimeState }: { sessionId: string; headers: ApiHeaders; @@ -964,14 +1048,13 @@ export function createMessageList(options: { try { clearInternal(false); clearActiveToolCards(); - const allMessages = data.messages || []; + const allMessages = (data.messages || []) as MessageDto[]; const runtimeState = transcriptRuntimeState(allMessages, isStreaming); bulkRendering = true; - const completedToolResults = new Map(); + const completedToolResults = new Map(); const renderedToolResultIds = new Set(); for (const message of allMessages) { - const id = message?.toolCallId || message?.raw?.toolCallId; - if (message?.role === "toolResult" && typeof id === "string") completedToolResults.set(id, message); + if (message.role === "toolResult" && message.toolCallId) completedToolResults.set(message.toolCallId, message); } for (let index = 0; index < allMessages.length; index += 1) { const message = allMessages[index]; @@ -984,31 +1067,7 @@ export function createMessageList(options: { continue; } - const id = message?.toolCallId || message?.raw?.toolCallId; - if (message.role === "toolResult") { - if (typeof id === "string" && renderedToolResultIds.has(id)) continue; - renderToolResultMessage(message, addToolHistoryCard); - continue; - } - - if (message.role === "bashExecution") { - const exitCode = typeof message.exitCode === "number" ? message.exitCode : undefined; - addToolHistoryCard("bash", Boolean(message.cancelled || (exitCode !== undefined && exitCode !== 0)), message, { command: String(message.command || "") }); - continue; - } - - const role = message.role === "assistant" ? "assistant" : message.role === "user" ? "user" : "system"; - if (role === "assistant") { - renderAssistantMessageParts(message, { addToolHistoryCard, addPendingToolCard, addRuntimeErrorCard, completedToolResults, renderedToolResultIds, isStreaming }); - continue; - } - - const text = messageText(message); - if (text) { - const rawImages = role === "user" ? imagesFromRawContent(rawContent(message)) : []; - const extraClass = message.role === "compactionSummary" ? "compaction" : message.isError ? "error" : ""; - addMessage(role, text, extraClass, rawImages, { entryId: message.entryId }); - } + renderMessage(message, { addToolHistoryCard, addPendingToolCard, addRuntimeErrorCard, completedToolResults, renderedToolResultIds, isStreaming }); } bulkRendering = false; if (wasFollowing) scrollToBottom(); @@ -1030,6 +1089,7 @@ export function createMessageList(options: { return { addMessage, + appendCommittedMessage, appendStreamingDelta, appendStreamingThinkingDelta, beginStreamFollow, diff --git a/src/realtime/realtime.ts b/src/realtime/realtime.ts index 1f588a9..151a775 100644 --- a/src/realtime/realtime.ts +++ b/src/realtime/realtime.ts @@ -1,6 +1,7 @@ import type { ApiClient } from "../app/api.js"; import type { AppElements } from "../app/elements.js"; import type { AppState, PiEvent } from "../app/types.js"; +import type { MessageDto } from "../../server/session/dto.js"; import { reconnectDelayMs } from "../app/types.js"; import type { ComposerController } from "../composer/composer.js"; import { messageText } from "../messages/content.js"; @@ -63,6 +64,7 @@ export function createRealtime(options: { let latestRetryAttempt: number | undefined; let latestRetryMaxAttempts: number | undefined; let sessionRefreshTimer: number | undefined; + let replayTranscriptRefreshTimer: number | undefined; let sessionRefreshInFlight = false; let sessionRefreshQueued = false; const sessionRuntimeKeys = new Map(); @@ -613,7 +615,8 @@ export function createRealtime(options: { break; case "message_end": { const deliveredMessage = messageFromEvent(event.message); - if (String(deliveredMessage?.role || deliveredMessage?.raw?.role || "") === "user") { + const deliveredRole = String(deliveredMessage?.role || deliveredMessage?.raw?.role || ""); + if (deliveredRole === "user") { composer.handleUserMessage(messageText(deliveredMessage), envelope?.clientMessageId, envelope?.sourceClientId); } const errorInfo = assistantErrorInfoFromMessage(event.message); @@ -821,6 +824,27 @@ export function createRealtime(options: { if (!data.sessionId || data.sessionId === state.currentSessionId) updateMeta(data); return; } + if (data.type === "committed_message") { + const appliesToCurrentSession = !data.sessionId || data.sessionId === state.currentSessionId; + if (isReplay && appliesToCurrentSession) { + if (replayTranscriptRefreshTimer !== undefined) window.clearTimeout(replayTranscriptRefreshTimer); + replayTranscriptRefreshTimer = window.setTimeout(() => { + replayTranscriptRefreshTimer = undefined; + void refreshMessages().catch((error) => console.error("Could not reconcile replayed transcript messages", error)); + }, 100); + return; + } + const committed = data.message as MessageDto; + if (appliesToCurrentSession && !["user", "assistant", "toolResult"].includes(committed.role)) { + messages.appendCommittedMessage(committed, { + addToolHistoryCard: tools.addToolHistoryCard, + addPendingToolCard: tools.startTool, + addRuntimeErrorCard: tools.addRuntimeErrorCard, + isStreaming: state.isStreaming || state.isRetrying, + }); + } + return; + } if (data.type === "pi_event") { const eventSessionKey = String(data.sessionId || data.sessionFile || ""); noteRuntimeEvent(eventSessionKey, data.event); diff --git a/tests/e2e/custom-messages.spec.ts b/tests/e2e/custom-messages.spec.ts new file mode 100644 index 0000000..6a99f12 --- /dev/null +++ b/tests/e2e/custom-messages.spec.ts @@ -0,0 +1,27 @@ +import { expect, test } from "@playwright/test"; + +test.beforeEach(async ({ page }) => { + await page.request.post("/api/mock/reset"); +}); + +test("renders custom and unknown committed messages without disrupting the live stream", async ({ page }) => { + await page.goto("/"); + await page.locator("#prompt").fill("slow live message kinds"); + await page.locator("#primaryButton").click(); + + const visibleCustom = page.locator(".message.custom--probe", { hasText: "hello from an extension" }); + await expect(page.locator("#stopButton")).toBeVisible(); + await expect(visibleCustom).toHaveCount(1, { timeout: 2_000 }); + const streamedAssistant = page.locator(".message.assistant", { hasText: "streamed prefix" }); + await expect(streamedAssistant).toHaveCount(1); + await expect(page.getByText("hidden extension message", { exact: true })).toHaveCount(0); + await expect(page.locator(".message.system", { hasText: "future message content" })).toHaveCount(1, { timeout: 2_000 }); + await expect(streamedAssistant).toHaveCount(1); + await expect(streamedAssistant).toContainText("streamed prefixstreamed suffix"); + await expect(page.locator("#stopButton")).toBeVisible(); + + await page.locator("#stopButton").click(); + await expect(page.locator("#stopButton")).toBeHidden(); + await expect(visibleCustom).toHaveCount(1); + await expect(page.locator(".message.system", { hasText: "future message content" })).toHaveCount(1); +}); diff --git a/tests/session-projection.test.ts b/tests/session-projection.test.ts index 835bc16..fa6f92a 100644 --- a/tests/session-projection.test.ts +++ b/tests/session-projection.test.ts @@ -3,8 +3,10 @@ import type { PiWebSession } from "../server/types.js"; import { jsonRoundTrip } from "../server/session/dto.js"; import { conversationTreeForSession, + entryMessage, getSessionSlashCommands, messageEntryRefs, + projectCommittedMessage, projectSessionState, sessionStats, simplifyMessage, @@ -67,6 +69,48 @@ describe("pure session projections", () => { expect(messageEntryRefs(session)).toEqual([{ entryId: "compact" }, { entryId: "kept" }, { entryId: "new" }]); }); + it("preserves visible custom metadata and omits hidden custom content", () => { + for (const content of ["hello", [{ type: "text", text: "hello" }]]) { + const message = entryMessage({ + type: "custom_message", + customType: "probe", + content, + details: { source: "extension" }, + display: true, + timestamp: "now", + }); + expect(simplifyMessage(message)).toEqual({ + role: "custom", + customType: "probe", + text: "hello", + details: { source: "extension" }, + display: true, + timestamp: "now", + raw: message, + }); + expect(simplifyMessage({ ...message, display: false })).toBeUndefined(); + } + }); + + it("recovers persisted metadata for the committed message reference", () => { + const session = fixtureSession(); + expect(projectCommittedMessage(session, session.messages[1])).toMatchObject({ + role: "assistant", + entryId: "assistant-1", + text: "Hi", + }); + }); + + it("projects unknown roles without dropping their content", () => { + expect(simplifyMessage({ role: "futureKind", content: "important text", timestamp: "now" })).toEqual({ + role: "unknown", + originalRole: "futureKind", + text: "important text", + timestamp: "now", + raw: { role: "futureKind", content: "important text", timestamp: "now" }, + }); + }); + it("accepts host decoration as explicit message projection input", () => { const projected = simplifyMessage({ role: "assistant", content: [{ type: "toolCall", id: "tool-1", toolName: "read", arguments: { path: "README.md" } }], timestamp: "now" }, { entryId: "entry-1", diff --git a/tests/session-service.test.ts b/tests/session-service.test.ts index 463629b..af099a2 100644 --- a/tests/session-service.test.ts +++ b/tests/session-service.test.ts @@ -124,6 +124,11 @@ describe("LocalSessionService contract", () => { expect(await service.messages(created.sessionId)).toContainEqual(expect.objectContaining({ role: "user", text: "hello" })); expect(events.map((event) => event.type)).toContain("pi"); expect(events.map((event) => event.type)).toContain("stats"); + expect(events).toContainEqual(expect.objectContaining({ + type: "committed", + sessionId: created.sessionId, + message: expect.objectContaining({ role: "user", text: "hello", entryId: "user-2" }), + })); initial.prompt = async () => { throw new Error("prompt failed"); }; await service.prompt(initial.sessionId, { message: "fail", mode: "steer", images: [] }); @@ -266,7 +271,12 @@ describe("LocalSessionService contract", () => { const message = { role: "assistant", model: "model", errorMessage: "model_not_supported", timestamp: messageAt }; fixture.emit({ type: "message_end", message, timestamp: messageAt }); expect(wire).toEqual([ - { type: "pi_event", sessionId: initial.sessionId, sessionFile: initial.sessionFile, event: { type: "message_end", message, timestamp: messageAt, lastActivityAt: messageAt } }, + { + type: "pi_event", + sessionId: initial.sessionId, + sessionFile: initial.sessionFile, + event: { type: "message_end", message, timestamp: messageAt, lastActivityAt: messageAt }, + }, { type: "session_runtime_changed", sessionId: initial.sessionId, sessionFile: initial.sessionFile, runtime: activity.runtimeForPath(initial.sessionFile) }, { type: "session_stats_changed", sessionId: initial.sessionId, sessionFile: initial.sessionFile, stats: (await service.stats(initial.sessionId)).stats }, { type: "models_updated", sessionId: initial.sessionId, models: [] }, @@ -284,6 +294,8 @@ describe("LocalSessionService contract", () => { const sessionFile = "/tmp/id-less.jsonl"; activity.enrichEvent({ sessionFile, sessionId: "id-less" } as PiWebSession, { type: "tool_execution_start", toolName: "read", startedAt: "2026-03-01T00:00:00.000Z" }); const messages: MessageDto[] = [{ + role: "assistant", + isError: false, toolCalls: [{ toolName: "read", args: {} }], raw: { role: "assistant", content: [{ type: "toolCall", toolName: "read", arguments: {} }] }, }];