Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 9e4aab16cc fix(server): keep the daemon responsive during plugin builds (#7967)
* fix(server): keep plugin compilation off the daemon event loop

TypeScript import validation runs in esbuild callbacks on the daemon thread. Isolate preparation and compilation behind one bounded compiler queue, returning bundles and metadata only after runtime-boundary validation. Recheck lifecycle intent before executing a compiled plugin.

* fix(server): reuse the compiler worker during plugin build batches

Amortize TypeScript startup across built-ins and queued builds, then retire the worker after one idle second. Carry preparation environments explicitly across worker reuse. Keep dependency-backed example coverage with its existing app fixture.

* fix(server): retire the compiler worker after build errors

A failed parallel target can leave the other target compiling. Await worker termination before the next queued request instead of reusing an isolate with unfinished build work.

* fix(server): preserve compiler error metadata across workers

* test(server): isolate plugin validation in the loop-gap fixture

Exercise erased TypeScript syntax with a small runtime bundle so parser validation, not multi-MiB result transfer, determines the CI fixture. Keep the 20 ms gate and report event-loop active/idle time for scheduler diagnostics. Resolve the development loader at module initialization.

* test(server): probe event-loop turns during plugin compilation

Windows timer wake-up lateness measured 22 ms while the loop was idle for 21.96 ms. Use check-phase callbacks to measure loop availability, retaining the raw 20 ms limit and the failing base-branch reproduction.

* test: run plugin responsiveness probe in serial integration suite

* test: diagnose compiler probe gaps with per-thread CPU readings

* test: distinguish compiler CPU blocking from runner scheduling

* test: classify the largest compiler CPU accounting window

* test: isolate compiler responsiveness from harness GC and idle accounting

* test: measure callback blocking with a high-resolution clock
2026-10-27 10:30:41 +07:00
36 changed files with 1614 additions and 1681 deletions
@@ -13,8 +13,8 @@ const MATERIALIZED_PNG_PATH_PATTERN =
/paseo-attachments(?:-[^\\/]+)?[\\/](?:[^\\/]+[\\/])?[0-9a-f]{64}\.png$/; /paseo-attachments(?:-[^\\/]+)?[\\/](?:[^\\/]+[\\/])?[0-9a-f]{64}\.png$/;
interface ClaudeImageTestSession { interface ClaudeImageTestSession {
translateMessageToEvents(message: SDKMessage): Promise<AgentStreamEvent[]>; translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
convertHistoryEntry(entry: unknown): Promise<AgentTimelineItem[]>; convertHistoryEntry(entry: unknown): AgentTimelineItem[];
} }
async function createSession(): Promise<ClaudeImageTestSession> { async function createSession(): Promise<ClaudeImageTestSession> {
@@ -157,7 +157,7 @@ describe("Claude tool_result image rendering", () => {
test("emits the image as assistant markdown and keeps base64 out of the live tool output", async () => { test("emits the image as assistant markdown and keeps base64 out of the live tool output", async () => {
const session = await createSession(); const session = await createSession();
const events = await session.translateMessageToEvents(imageToolResultUserMessage()); const events = session.translateMessageToEvents(imageToolResultUserMessage());
const timelineItems = events const timelineItems = events
.filter((event) => event.type === "timeline") .filter((event) => event.type === "timeline")
@@ -181,7 +181,7 @@ describe("Claude tool_result image rendering", () => {
test("replays the image as assistant markdown through history conversion", async () => { test("replays the image as assistant markdown through history conversion", async () => {
const session = await createSession(); const session = await createSession();
const items = await session.convertHistoryEntry(imageToolResultHistoryEntry()); const items = session.convertHistoryEntry(imageToolResultHistoryEntry());
const [imageMessage, ...extraImages] = imageMessages(items); const [imageMessage, ...extraImages] = imageMessages(items);
expect(extraImages).toEqual([]); expect(extraImages).toEqual([]);
@@ -202,7 +202,7 @@ describe("Claude tool_result image rendering", () => {
test("keeps base64 out of an errored tool_result that carries an image", async () => { test("keeps base64 out of an errored tool_result that carries an image", async () => {
const session = await createSession(); const session = await createSession();
const events = await session.translateMessageToEvents(erroredImageToolResultUserMessage()); const events = session.translateMessageToEvents(erroredImageToolResultUserMessage());
const timelineItems = events const timelineItems = events
.filter((event) => event.type === "timeline") .filter((event) => event.type === "timeline")
@@ -226,7 +226,7 @@ describe("Claude tool_result image rendering", () => {
test("emits one image message per image block in a multi-image tool_result", async () => { test("emits one image message per image block in a multi-image tool_result", async () => {
const session = await createSession(); const session = await createSession();
const events = await session.translateMessageToEvents(multiImageToolResultUserMessage()); const events = session.translateMessageToEvents(multiImageToolResultUserMessage());
const timelineItems = events const timelineItems = events
.filter((event) => event.type === "timeline") .filter((event) => event.type === "timeline")
@@ -32,7 +32,7 @@ import type { AgentAttachment } from "@getpaseo/protocol/messages";
import { buildAgentPrompt, renderPromptAttachmentAsText } from "../../prompt-attachments.js"; import { buildAgentPrompt, renderPromptAttachmentAsText } from "../../prompt-attachments.js";
interface TestClaudeSession { interface TestClaudeSession {
translateMessageToEvents(message: SDKMessage): Promise<AgentStreamEvent[]>; translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
close(): Promise<void>; close(): Promise<void>;
} }
@@ -53,7 +53,7 @@ afterEach(() => {
}); });
describe("convertClaudeHistoryEntry", () => { describe("convertClaudeHistoryEntry", () => {
test("maps user tool results to timeline items", async () => { test("maps user tool results to timeline items", () => {
const toolUseId = "toolu_test"; const toolUseId = "toolu_test";
const entry = { const entry = {
type: "user", type: "user",
@@ -79,14 +79,14 @@ describe("convertClaudeHistoryEntry", () => {
]; ];
const mapBlocks = vi.fn().mockReturnValue(stubTimeline); const mapBlocks = vi.fn().mockReturnValue(stubTimeline);
const result = await convertClaudeHistoryEntry(entry, mapBlocks); const result = convertClaudeHistoryEntry(entry, mapBlocks);
expect(result).toEqual(stubTimeline); expect(result).toEqual(stubTimeline);
expect(mapBlocks).toHaveBeenCalledTimes(1); expect(mapBlocks).toHaveBeenCalledTimes(1);
expect(Array.isArray(mapBlocks.mock.calls[0][0])).toBe(true); expect(Array.isArray(mapBlocks.mock.calls[0][0])).toBe(true);
}); });
test("replays persisted Claude tool results as completed tool calls", async () => { test("replays persisted Claude tool results as completed tool calls", () => {
const entry = { const entry = {
type: "user", type: "user",
message: { message: {
@@ -124,12 +124,12 @@ describe("convertClaudeHistoryEntry", () => {
const mapPersistedToolResultBlocks = (): AgentTimelineItem[] => completedToolCall; const mapPersistedToolResultBlocks = (): AgentTimelineItem[] => completedToolCall;
expect(await convertClaudeHistoryEntry(entry, mapPersistedToolResultBlocks)).toEqual( expect(convertClaudeHistoryEntry(entry, mapPersistedToolResultBlocks)).toEqual(
completedToolCall, completedToolCall,
); );
}); });
test("returns user messages when no tool blocks exist", async () => { test("returns user messages when no tool blocks exist", () => {
const entry = { const entry = {
type: "user", type: "user",
message: { message: {
@@ -138,7 +138,7 @@ describe("convertClaudeHistoryEntry", () => {
}, },
}; };
expect(await convertClaudeHistoryEntry(entry, () => [])).toEqual([ expect(convertClaudeHistoryEntry(entry, () => [])).toEqual([
{ {
type: "user_message", type: "user_message",
text: "Run npm test", text: "Run npm test",
@@ -146,7 +146,7 @@ describe("convertClaudeHistoryEntry", () => {
]); ]);
}); });
test("converts compact boundary metadata variants", async () => { test("converts compact boundary metadata variants", () => {
const fixtures = [ const fixtures = [
{ {
entry: { entry: {
@@ -175,7 +175,7 @@ describe("convertClaudeHistoryEntry", () => {
] as const; ] as const;
for (const fixture of fixtures) { for (const fixture of fixtures) {
expect(await convertClaudeHistoryEntry(fixture.entry, () => [])).toEqual([ expect(convertClaudeHistoryEntry(fixture.entry, () => [])).toEqual([
{ {
type: "compaction", type: "compaction",
status: "completed", status: "completed",
@@ -186,7 +186,7 @@ describe("convertClaudeHistoryEntry", () => {
} }
}); });
test("skips synthetic user entries", async () => { test("skips synthetic user entries", () => {
const entry = { const entry = {
type: "user", type: "user",
isSynthetic: true, isSynthetic: true,
@@ -197,13 +197,13 @@ describe("convertClaudeHistoryEntry", () => {
}; };
const mapBlocks = vi.fn().mockReturnValue([]); const mapBlocks = vi.fn().mockReturnValue([]);
const result = await convertClaudeHistoryEntry(entry, mapBlocks); const result = convertClaudeHistoryEntry(entry, mapBlocks);
expect(result).toEqual([]); expect(result).toEqual([]);
expect(mapBlocks).not.toHaveBeenCalled(); expect(mapBlocks).not.toHaveBeenCalled();
}); });
test("skips meta user entries from Claude skill loading", async () => { test("skips meta user entries from Claude skill loading", () => {
const entry = { const entry = {
type: "user", type: "user",
isMeta: true, isMeta: true,
@@ -219,13 +219,13 @@ describe("convertClaudeHistoryEntry", () => {
}; };
const mapBlocks = vi.fn().mockReturnValue([]); const mapBlocks = vi.fn().mockReturnValue([]);
const result = await convertClaudeHistoryEntry(entry, mapBlocks); const result = convertClaudeHistoryEntry(entry, mapBlocks);
expect(result).toEqual([]); expect(result).toEqual([]);
expect(mapBlocks).not.toHaveBeenCalled(); expect(mapBlocks).not.toHaveBeenCalled();
}); });
test("skips interrupt placeholder transcript noise", async () => { test("skips interrupt placeholder transcript noise", () => {
const interruptEntry = { const interruptEntry = {
type: "user", type: "user",
message: { message: {
@@ -246,11 +246,11 @@ describe("convertClaudeHistoryEntry", () => {
.fn() .fn()
.mockReturnValue([{ type: "assistant_message", text: "No response requested." }]); .mockReturnValue([{ type: "assistant_message", text: "No response requested." }]);
expect(await convertClaudeHistoryEntry(interruptEntry, mapBlocks)).toEqual([]); expect(convertClaudeHistoryEntry(interruptEntry, mapBlocks)).toEqual([]);
expect(await convertClaudeHistoryEntry(assistantNoiseEntry, mapBlocks)).toEqual([]); expect(convertClaudeHistoryEntry(assistantNoiseEntry, mapBlocks)).toEqual([]);
}); });
test("skips <local-command-stdout> messages (model switch, /context, etc.)", async () => { test("skips <local-command-stdout> messages (model switch, /context, etc.)", () => {
// Real entries from Claude Code JSONL history files // Real entries from Claude Code JSONL history files
const modelSwitch = { const modelSwitch = {
type: "user", type: "user",
@@ -305,24 +305,24 @@ describe("convertClaudeHistoryEntry", () => {
const mapBlocks = vi.fn().mockReturnValue([]); const mapBlocks = vi.fn().mockReturnValue([]);
expect(await convertClaudeHistoryEntry(modelSwitch, mapBlocks)).toEqual([]); expect(convertClaudeHistoryEntry(modelSwitch, mapBlocks)).toEqual([]);
expect(await convertClaudeHistoryEntry(modelSwitchWithAnsi, mapBlocks)).toEqual([]); expect(convertClaudeHistoryEntry(modelSwitchWithAnsi, mapBlocks)).toEqual([]);
expect(await convertClaudeHistoryEntry(contextDump, mapBlocks)).toEqual([]); expect(convertClaudeHistoryEntry(contextDump, mapBlocks)).toEqual([]);
expect(await convertClaudeHistoryEntry(planMode, mapBlocks)).toEqual([]); expect(convertClaudeHistoryEntry(planMode, mapBlocks)).toEqual([]);
expect(await convertClaudeHistoryEntry(goodbye, mapBlocks)).toEqual([]); expect(convertClaudeHistoryEntry(goodbye, mapBlocks)).toEqual([]);
expect(await convertClaudeHistoryEntry(empty, mapBlocks)).toEqual([]); expect(convertClaudeHistoryEntry(empty, mapBlocks)).toEqual([]);
// Real user messages must NOT be filtered // Real user messages must NOT be filtered
const realMessage = { const realMessage = {
type: "user", type: "user",
message: { role: "user", content: "fix the bug in auth.ts" }, message: { role: "user", content: "fix the bug in auth.ts" },
}; };
expect(await convertClaudeHistoryEntry(realMessage, mapBlocks)).toEqual([ expect(convertClaudeHistoryEntry(realMessage, mapBlocks)).toEqual([
{ type: "user_message", text: "fix the bug in auth.ts" }, { type: "user_message", text: "fix the bug in auth.ts" },
]); ]);
}); });
test("maps task notifications to synthetic tool calls", async () => { test("maps task notifications to synthetic tool calls", () => {
const entry = { const entry = {
type: "system", type: "system",
subtype: "task_notification", subtype: "task_notification",
@@ -333,7 +333,7 @@ describe("convertClaudeHistoryEntry", () => {
output_file: "/tmp/bg-fail-1.txt", output_file: "/tmp/bg-fail-1.txt",
}; };
expect(await convertClaudeHistoryEntry(entry, () => [])).toEqual([ expect(convertClaudeHistoryEntry(entry, () => [])).toEqual([
{ {
type: "tool_call", type: "tool_call",
callId: "task_notification_task-note-system-1", callId: "task_notification_task-note-system-1",
@@ -357,7 +357,7 @@ describe("convertClaudeHistoryEntry", () => {
]); ]);
}); });
test("maps queue-operation task notifications to synthetic tool calls", async () => { test("maps queue-operation task notifications to synthetic tool calls", () => {
const entry = { const entry = {
type: "queue-operation", type: "queue-operation",
operation: "enqueue", operation: "enqueue",
@@ -372,7 +372,7 @@ describe("convertClaudeHistoryEntry", () => {
].join("\n"), ].join("\n"),
}; };
expect(await convertClaudeHistoryEntry(entry, () => [])).toEqual([ expect(convertClaudeHistoryEntry(entry, () => [])).toEqual([
{ {
type: "tool_call", type: "tool_call",
callId: "task_notification_task-note-queue-1", callId: "task_notification_task-note-queue-1",
@@ -396,7 +396,7 @@ describe("convertClaudeHistoryEntry", () => {
]); ]);
}); });
test("passes assistant content blocks through to the mapper", async () => { test("passes assistant content blocks through to the mapper", () => {
const entry = { const entry = {
type: "assistant", type: "assistant",
message: { message: {
@@ -414,7 +414,7 @@ describe("convertClaudeHistoryEntry", () => {
]; ];
const mapBlocks = vi.fn().mockReturnValue(mappedTimeline); const mapBlocks = vi.fn().mockReturnValue(mappedTimeline);
expect(await convertClaudeHistoryEntry(entry, mapBlocks)).toEqual(mappedTimeline); expect(convertClaudeHistoryEntry(entry, mapBlocks)).toEqual(mappedTimeline);
expect(mapBlocks).toHaveBeenCalledWith(entry.message.content); expect(mapBlocks).toHaveBeenCalledWith(entry.message.content);
}); });
}); });
@@ -1208,7 +1208,7 @@ describe("ClaudeAgentSession features", () => {
input: Record<string, unknown>, input: Record<string, unknown>,
options: Record<string, unknown>, options: Record<string, unknown>,
): Promise<PermissionResult>; ): Promise<PermissionResult>;
translateMessageToEvents(message: SDKMessage): Promise<AgentStreamEvent[]>; translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
}; };
try { try {
@@ -1248,7 +1248,7 @@ describe("ClaudeAgentSession features", () => {
), ),
).resolves.toMatchObject({ behavior: "deny", interrupt: undefined }); ).resolves.toMatchObject({ behavior: "deny", interrupt: undefined });
await internal.translateMessageToEvents({ internal.translateMessageToEvents({
type: "command_lifecycle", type: "command_lifecycle",
command_uuid: steerUuid, command_uuid: steerUuid,
state: "started", state: "started",
@@ -2395,7 +2395,7 @@ describe("ClaudeAgentSession context window usage", () => {
test("emits canonical task snapshots from Claude TaskCreate results", async () => { test("emits canonical task snapshots from Claude TaskCreate results", async () => {
const session = await createSessionForTest(); const session = await createSessionForTest();
try { try {
await session.translateMessageToEvents({ session.translateMessageToEvents({
type: "assistant", type: "assistant",
message: { message: {
content: [ content: [
@@ -2409,7 +2409,7 @@ describe("ClaudeAgentSession context window usage", () => {
}, },
} as unknown as SDKMessage); } as unknown as SDKMessage);
const events = await session.translateMessageToEvents({ const events = session.translateMessageToEvents({
type: "user", type: "user",
message: { message: {
content: [{ type: "tool_result", tool_use_id: "task-create-1", content: "created" }], content: [{ type: "tool_result", tool_use_id: "task-create-1", content: "created" }],
@@ -3611,9 +3611,9 @@ describe("ClaudeAgentSession context window usage", () => {
]); ]);
try { try {
const compactEvents = await ( const compactEvents = (session as unknown as TestClaudeSession).translateMessageToEvents(
session as unknown as TestClaudeSession createCompactBoundary(),
).translateMessageToEvents(createCompactBoundary()); );
expect(compactEvents).toContainEqual( expect(compactEvents).toContainEqual(
expect.objectContaining({ expect.objectContaining({
type: "usage_updated", type: "usage_updated",
@@ -3799,7 +3799,7 @@ describe("ClaudeAgentSession context window usage", () => {
test("result.result is surfaced as an assistant message when no model output was produced", async () => { test("result.result is surfaced as an assistant message when no model output was produced", async () => {
const session = await createSessionForTest(); const session = await createSessionForTest();
const events = await session.translateMessageToEvents({ const events = session.translateMessageToEvents({
type: "result", type: "result",
subtype: "success", subtype: "success",
result: "Unknown command: /foo-doesnt-exist", result: "Unknown command: /foo-doesnt-exist",
@@ -3834,7 +3834,7 @@ describe("ClaudeAgentSession context window usage", () => {
test("result.result is not duplicated when the model produced output during the turn", async () => { test("result.result is not duplicated when the model produced output during the turn", async () => {
const session = await createSessionForTest(); const session = await createSessionForTest();
const events = await session.translateMessageToEvents({ const events = session.translateMessageToEvents({
type: "result", type: "result",
subtype: "success", subtype: "success",
result: "Here is the answer.", result: "Here is the answer.",
@@ -2546,11 +2546,7 @@ class ClaudeAgentSession implements AgentSession {
this.input.push(sdkMessage); this.input.push(sdkMessage);
setTimeout(() => { setTimeout(() => {
if (this.activeForegroundTurnId === turnId) { if (this.activeForegroundTurnId === turnId) {
void this.emitSubmittedUserMessage(sdkMessage, turnId, options?.clientMessageId).catch( this.emitSubmittedUserMessage(sdkMessage, turnId, options?.clientMessageId);
(error: unknown) => {
this.logger.warn({ err: error }, "Failed to emit submitted Claude message");
},
);
} }
}, 0); }, 0);
} catch (error) { } catch (error) {
@@ -4138,7 +4134,7 @@ class ClaudeAgentSession implements AgentSession {
consecutiveInterruptAbortRecoveries = 0; consecutiveInterruptAbortRecoveries = 0;
if (await this.handleMissingResumedConversation(message, activeQuery)) return true; if (await this.handleMissingResumedConversation(message, activeQuery)) return true;
if (!this.ownsQuery(activeQuery)) return true; if (!this.ownsQuery(activeQuery)) return true;
await this.routeSdkMessageFromPump(message); this.routeSdkMessageFromPump(message);
return false; return false;
} finally { } finally {
this.processingSdkMessages--; this.processingSdkMessages--;
@@ -4262,7 +4258,7 @@ class ClaudeAgentSession implements AgentSession {
return !this.closed && this.query === query; return !this.closed && this.query === query;
} }
private async routeSdkMessageFromPump(message: SDKMessage): Promise<void> { private routeSdkMessageFromPump(message: SDKMessage): void {
if (this.consumeSessionStateChange(message)) { if (this.consumeSessionStateChange(message)) {
return; return;
} }
@@ -4296,7 +4292,7 @@ class ClaudeAgentSession implements AgentSession {
"provider.claude.parsed_event", "provider.claude.parsed_event",
); );
const events = await this.buildPumpedMessageEvents(message, identifiers.messageId, turnId); const events = this.buildPumpedMessageEvents(message, identifiers.messageId, turnId);
if (events.length === 0) { if (events.length === 0) {
return; return;
@@ -4331,22 +4327,15 @@ class ClaudeAgentSession implements AgentSession {
this.dispatchEvents(events); this.dispatchEvents(events);
} }
private async buildPumpedMessageEvents( private buildPumpedMessageEvents(
message: SDKMessage, message: SDKMessage,
messageIdHint: string | null, messageIdHint: string | null,
turnId: string | null, turnId: string | null,
): Promise<AgentStreamEvent[]> { ): AgentStreamEvent[] {
const query = this.query; const messageEvents = this.translateMessageToEvents(message, {
const messageEvents = await this.translateMessageToEvents(message, {
suppressAssistantText: true, suppressAssistantText: true,
suppressReasoning: true, suppressReasoning: true,
}); });
if (
this.closed ||
this.query !== query ||
(this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null) !== turnId
)
return [];
const assistantTimelineEvents = readClaudeParentToolUseId(message) const assistantTimelineEvents = readClaudeParentToolUseId(message)
? [] ? []
: this.timelineAssembler : this.timelineAssembler
@@ -4491,13 +4480,13 @@ class ClaudeAgentSession implements AgentSession {
return withdrewAll; return withdrewAll;
} }
private async translateMessageToEvents( private translateMessageToEvents(
message: SDKMessage, message: SDKMessage,
options?: { options?: {
suppressAssistantText?: boolean; suppressAssistantText?: boolean;
suppressReasoning?: boolean; suppressReasoning?: boolean;
}, },
): Promise<AgentStreamEvent[]> { ): AgentStreamEvent[] {
const parentToolUseId = readClaudeParentToolUseId(message); const parentToolUseId = readClaudeParentToolUseId(message);
if (parentToolUseId) { if (parentToolUseId) {
return this.translateSidechainFrameToEvents(message, parentToolUseId); return this.translateSidechainFrameToEvents(message, parentToolUseId);
@@ -4545,11 +4534,11 @@ class ClaudeAgentSession implements AgentSession {
this.appendSystemMessageEvents(message, events); this.appendSystemMessageEvents(message, events);
break; break;
case "user": case "user":
await this.appendUserMessageEvents(message, events); this.appendUserMessageEvents(message, events);
this.appendSidechainResultEvents(message, events); this.appendSidechainResultEvents(message, events);
break; break;
case "assistant": { case "assistant": {
const timelineItems = await this.mapBlocksToTimeline(message.message.content, { const timelineItems = this.mapBlocksToTimeline(message.message.content, {
suppressAssistantText: options?.suppressAssistantText ?? false, suppressAssistantText: options?.suppressAssistantText ?? false,
suppressReasoning: options?.suppressReasoning ?? false, suppressReasoning: options?.suppressReasoning ?? false,
}); });
@@ -4560,7 +4549,7 @@ class ClaudeAgentSession implements AgentSession {
break; break;
} }
case "stream_event": case "stream_event":
await this.appendStreamEventEvents(message, events, options); this.appendStreamEventEvents(message, events, options);
break; break;
case "result": case "result":
this.appendResultEvents(message, events); this.appendResultEvents(message, events);
@@ -4662,14 +4651,13 @@ class ClaudeAgentSession implements AgentSession {
} }
} }
private async emitSubmittedUserMessage( private emitSubmittedUserMessage(
message: Extract<SDKMessage, { type: "user" }>, message: Extract<SDKMessage, { type: "user" }>,
turnId: string, turnId: string,
clientMessageId?: string, clientMessageId?: string,
): Promise<void> { ): void {
const events: AgentStreamEvent[] = []; const events: AgentStreamEvent[] = [];
await this.appendUserMessageEvents(message, events); this.appendUserMessageEvents(message, events);
if (this.activeForegroundTurnId !== turnId) return;
if (events.length === 0) { if (events.length === 0) {
return; return;
} }
@@ -4799,10 +4787,10 @@ class ClaudeAgentSession implements AgentSession {
} }
} }
private async appendUserMessageEvents( private appendUserMessageEvents(
message: Extract<SDKMessage, { type: "user" }>, message: Extract<SDKMessage, { type: "user" }>,
events: AgentStreamEvent[], events: AgentStreamEvent[],
): Promise<void> { ): void {
if (isSyntheticUserEntry(message)) { if (isSyntheticUserEntry(message)) {
return; return;
} }
@@ -4841,7 +4829,7 @@ class ClaudeAgentSession implements AgentSession {
return; return;
} }
if (Array.isArray(content)) { if (Array.isArray(content)) {
await this.appendUserContentArrayEvents(content, messageId, events); this.appendUserContentArrayEvents(content, messageId, events);
} }
} }
@@ -4867,12 +4855,12 @@ class ClaudeAgentSession implements AgentSession {
events.push({ type: "timeline", item, provider: "claude" }); events.push({ type: "timeline", item, provider: "claude" });
} }
private async appendUserContentArrayEvents( private appendUserContentArrayEvents(
content: ReadonlyArray<unknown>, content: ReadonlyArray<unknown>,
messageId: string | undefined, messageId: string | undefined,
events: AgentStreamEvent[], events: AgentStreamEvent[],
): Promise<void> { ): void {
const timelineItems = await this.mapBlocksToTimeline(content, { const timelineItems = this.mapBlocksToTimeline(content, {
textMessageType: "user_message", textMessageType: "user_message",
}); });
for (const item of timelineItems) { for (const item of timelineItems) {
@@ -4888,16 +4876,16 @@ class ClaudeAgentSession implements AgentSession {
} }
} }
private async appendStreamEventEvents( private appendStreamEventEvents(
message: Extract<SDKMessage, { type: "stream_event" }>, message: Extract<SDKMessage, { type: "stream_event" }>,
events: AgentStreamEvent[], events: AgentStreamEvent[],
options: { suppressAssistantText?: boolean; suppressReasoning?: boolean } | undefined, options: { suppressAssistantText?: boolean; suppressReasoning?: boolean } | undefined,
): Promise<void> { ): void {
const usageUpdatedEvent = this.contextUsage.buildStreamUsageEvent(message.event); const usageUpdatedEvent = this.contextUsage.buildStreamUsageEvent(message.event);
if (usageUpdatedEvent) { if (usageUpdatedEvent) {
events.push(usageUpdatedEvent); events.push(usageUpdatedEvent);
} }
const timelineItems = await this.mapPartialEvent(message.event, { const timelineItems = this.mapPartialEvent(message.event, {
suppressAssistantText: options?.suppressAssistantText ?? false, suppressAssistantText: options?.suppressAssistantText ?? false,
suppressReasoning: options?.suppressReasoning ?? false, suppressReasoning: options?.suppressReasoning ?? false,
}); });
@@ -5363,7 +5351,7 @@ class ClaudeAgentSession implements AgentSession {
): Promise<void> { ): Promise<void> {
const timeline: PersistedTimelineEntry[] = []; const timeline: PersistedTimelineEntry[] = [];
for await (const entry of entries) { for await (const entry of entries) {
await this.ingestPersistedHistoryEntry(entry, timeline, replay); this.ingestPersistedHistoryEntry(entry, timeline, replay);
} }
if (timeline.length > 0) { if (timeline.length > 0) {
@@ -5423,11 +5411,11 @@ class ClaudeAgentSession implements AgentSession {
return replay; return replay;
} }
private async ingestPersistedHistoryEntry( private ingestPersistedHistoryEntry(
entry: ClaudeHistoryEntry, entry: ClaudeHistoryEntry,
timeline: PersistedTimelineEntry[], timeline: PersistedTimelineEntry[],
replay: ClaudeReplayOwnership, replay: ClaudeReplayOwnership,
): Promise<void> { ): void {
if (entry.isSidechain) { if (entry.isSidechain) {
return; return;
} }
@@ -5441,7 +5429,7 @@ class ClaudeAgentSession implements AgentSession {
? replay.toolOwners.get(notificationToolUseId) ? replay.toolOwners.get(notificationToolUseId)
: undefined; : undefined;
if (notificationOwner) { if (notificationOwner) {
for (const item of await this.convertHistoryEntry(entry)) { for (const item of this.convertHistoryEntry(entry)) {
this.persistedProviderSubagentEvents.push({ this.persistedProviderSubagentEvents.push({
type: "provider_subagent", type: "provider_subagent",
provider: "claude", provider: "claude",
@@ -5458,7 +5446,7 @@ class ClaudeAgentSession implements AgentSession {
const taskSnapshot = this.taskState.observe(entry); const taskSnapshot = this.taskState.observe(entry);
const items = [ const items = [
...(taskSnapshot ? [taskSnapshot] : []), ...(taskSnapshot ? [taskSnapshot] : []),
...(await this.convertHistoryEntry(entry)).map((item) => ...this.convertHistoryEntry(entry).map((item) =>
labelReplayedSubagentCard(item, replay.subagentToolCalls), labelReplayedSubagentCard(item, replay.subagentToolCalls),
), ),
]; ];
@@ -5495,10 +5483,10 @@ class ClaudeAgentSession implements AgentSession {
}); });
} }
private async convertHistoryEntry(entry: ClaudeHistoryEntry): Promise<AgentTimelineItem[]> { private convertHistoryEntry(entry: ClaudeHistoryEntry): AgentTimelineItem[] {
return ( return convertClaudeHistoryEntry(entry, (content) => this.mapBlocksToTimeline(content)).map(
await convertClaudeHistoryEntry(entry, (content) => this.mapBlocksToTimeline(content)) limitAgentTimelineItemContent,
).map(limitAgentTimelineItemContent); );
} }
// Maps Claude content blocks into AgentTimelineItems. // Maps Claude content blocks into AgentTimelineItems.
@@ -5513,14 +5501,14 @@ class ClaudeAgentSession implements AgentSession {
// //
// NOTE: convertClaudeHistoryEntry uses extractUserMessageText directly instead of this function // NOTE: convertClaudeHistoryEntry uses extractUserMessageText directly instead of this function
// for user entries. Both paths must produce equivalent user_message items. // for user entries. Both paths must produce equivalent user_message items.
private async mapBlocksToTimeline( private mapBlocksToTimeline(
content: string | ReadonlyArray<unknown>, content: string | ReadonlyArray<unknown>,
options?: { options?: {
textMessageType?: "assistant_message" | "user_message"; textMessageType?: "assistant_message" | "user_message";
suppressAssistantText?: boolean; suppressAssistantText?: boolean;
suppressReasoning?: boolean; suppressReasoning?: boolean;
}, },
): Promise<AgentTimelineItem[]> { ): AgentTimelineItem[] {
const textMessageType = options?.textMessageType ?? "assistant_message"; const textMessageType = options?.textMessageType ?? "assistant_message";
const suppressText = const suppressText =
textMessageType === "assistant_message" && (options?.suppressAssistantText ?? false); textMessageType === "assistant_message" && (options?.suppressAssistantText ?? false);
@@ -5547,7 +5535,7 @@ class ClaudeAgentSession implements AgentSession {
if (!isClaudeContentChunk(block) || this.mapSubagentHandbackBlock(block, items)) { if (!isClaudeContentChunk(block) || this.mapSubagentHandbackBlock(block, items)) {
continue; continue;
} }
await this.mapBlockToTimeline(block, { this.mapBlockToTimeline(block, {
items, items,
userTextParts, userTextParts,
textMessageType, textMessageType,
@@ -5592,7 +5580,7 @@ class ClaudeAgentSession implements AgentSession {
} }
} }
private async mapBlockToTimeline( private mapBlockToTimeline(
block: ClaudeContentChunk, block: ClaudeContentChunk,
context: { context: {
items: AgentTimelineItem[]; items: AgentTimelineItem[];
@@ -5601,7 +5589,7 @@ class ClaudeAgentSession implements AgentSession {
suppressText: boolean; suppressText: boolean;
suppressReasoning: boolean; suppressReasoning: boolean;
}, },
): Promise<void> { ): void {
switch (block.type) { switch (block.type) {
case "text": case "text":
case "text_delta": case "text_delta":
@@ -5625,7 +5613,7 @@ class ClaudeAgentSession implements AgentSession {
case "code_execution_tool_result": case "code_execution_tool_result":
case "bash_code_execution_tool_result": case "bash_code_execution_tool_result":
case "text_editor_code_execution_tool_result": case "text_editor_code_execution_tool_result":
await this.handleToolResult(block, context.items); this.handleToolResult(block, context.items);
break; break;
default: default:
break; break;
@@ -5671,10 +5659,7 @@ class ClaudeAgentSession implements AgentSession {
); );
} }
private async handleToolResult( private handleToolResult(block: ClaudeContentChunk, items: AgentTimelineItem[]): void {
block: ClaudeContentChunk,
items: AgentTimelineItem[],
): Promise<void> {
const entry = const entry =
typeof block.tool_use_id === "string" ? this.toolUseCache.get(block.tool_use_id) : undefined; typeof block.tool_use_id === "string" ? this.toolUseCache.get(block.tool_use_id) : undefined;
const blockToolName = typeof block.tool_name === "string" ? block.tool_name : undefined; const blockToolName = typeof block.tool_name === "string" ? block.tool_name : undefined;
@@ -5707,7 +5692,7 @@ class ClaudeAgentSession implements AgentSession {
this.rememberSettledSubagentToolCall(settledToolCall, entry); this.rememberSettledSubagentToolCall(settledToolCall, entry);
for (const image of images) { for (const image of images) {
const imageItem = await renderProviderImageOutputAsAssistantMarkdown(image, { const imageItem = renderProviderImageOutputAsAssistantMarkdown(image, {
materialize: materializeProviderImage, materialize: materializeProviderImage,
}); });
if (imageItem) { if (imageItem) {
@@ -5931,13 +5916,13 @@ class ClaudeAgentSession implements AgentSession {
return false; return false;
} }
private async mapPartialEvent( private mapPartialEvent(
event: SDKPartialAssistantMessage["event"], event: SDKPartialAssistantMessage["event"],
options?: { options?: {
suppressAssistantText?: boolean; suppressAssistantText?: boolean;
suppressReasoning?: boolean; suppressReasoning?: boolean;
}, },
): Promise<AgentTimelineItem[]> { ): AgentTimelineItem[] {
if (this.updatePartialEventToolState(event)) { if (this.updatePartialEventToolState(event)) {
return []; return [];
} }
@@ -5945,14 +5930,14 @@ class ClaudeAgentSession implements AgentSession {
switch (event.type) { switch (event.type) {
case "content_block_start": case "content_block_start":
return isClaudeContentChunk(event.content_block) return isClaudeContentChunk(event.content_block)
? await this.mapBlocksToTimeline([event.content_block], { ? this.mapBlocksToTimeline([event.content_block], {
suppressAssistantText: options?.suppressAssistantText, suppressAssistantText: options?.suppressAssistantText,
suppressReasoning: options?.suppressReasoning, suppressReasoning: options?.suppressReasoning,
}) })
: []; : [];
case "content_block_delta": case "content_block_delta":
return isClaudeContentChunk(event.delta) return isClaudeContentChunk(event.delta)
? await this.mapBlocksToTimeline([event.delta], { ? this.mapBlocksToTimeline([event.delta], {
suppressAssistantText: options?.suppressAssistantText, suppressAssistantText: options?.suppressAssistantText,
suppressReasoning: options?.suppressReasoning, suppressReasoning: options?.suppressReasoning,
}) })
@@ -6315,14 +6300,12 @@ interface ClaudeHistoryEntry {
[key: string]: unknown; [key: string]: unknown;
} }
async function mapAssistantHistoryBlocksWithMessageId( function mapAssistantHistoryBlocksWithMessageId(
entry: ClaudeHistoryEntry, entry: ClaudeHistoryEntry,
content: string | ClaudeContentChunk[], content: string | ClaudeContentChunk[],
mapBlocks: ( mapBlocks: (content: string | ClaudeContentChunk[]) => AgentTimelineItem[],
content: string | ClaudeContentChunk[], ): AgentTimelineItem[] {
) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>, const items = mapBlocks(content);
): Promise<AgentTimelineItem[]> {
const items = await mapBlocks(content);
const assistantMessageId = const assistantMessageId =
typeof entry.uuid === "string" && entry.uuid.length > 0 ? entry.uuid : null; typeof entry.uuid === "string" && entry.uuid.length > 0 ? entry.uuid : null;
if (!assistantMessageId) { if (!assistantMessageId) {
@@ -6390,12 +6373,10 @@ function isProviderImageMessage(item: AgentTimelineItem): boolean {
return item.type === "assistant_message" && isProviderImageMarkdown(item.text); return item.type === "assistant_message" && isProviderImageMarkdown(item.text);
} }
export async function convertClaudeHistoryEntry( export function convertClaudeHistoryEntry(
entry: ClaudeHistoryEntry, entry: ClaudeHistoryEntry,
mapBlocks: ( mapBlocks: (content: string | ClaudeContentChunk[]) => AgentTimelineItem[],
content: string | ClaudeContentChunk[], ): AgentTimelineItem[] {
) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>,
): Promise<AgentTimelineItem[]> {
const preamble = convertClaudeHistoryEntryPreamble(entry); const preamble = convertClaudeHistoryEntryPreamble(entry);
if ("shortCircuit" in preamble) { if ("shortCircuit" in preamble) {
return preamble.shortCircuit; return preamble.shortCircuit;
@@ -6434,7 +6415,7 @@ export async function convertClaudeHistoryEntry(
} }
if (hasToolBlock && normalizedBlocks) { if (hasToolBlock && normalizedBlocks) {
const mapped = await mapBlocks(normalizedBlocks); const mapped = mapBlocks(normalizedBlocks);
if (entry.type === "user") { if (entry.type === "user") {
// tool_result handling (handleToolResult) emits image markdown as an assistant_message // tool_result handling (handleToolResult) emits image markdown as an assistant_message
// alongside the tool_call. User-entry text blocks also map to assistant_message in this path // alongside the tool_call. User-entry text blocks also map to assistant_message in this path
@@ -6448,7 +6429,7 @@ export async function convertClaudeHistoryEntry(
} }
if (entry.type === "assistant" && contentValue) { if (entry.type === "assistant" && contentValue) {
return await mapAssistantHistoryBlocksWithMessageId(entry, contentValue, mapBlocks); return mapAssistantHistoryBlocksWithMessageId(entry, contentValue, mapBlocks);
} }
return timeline; return timeline;
@@ -1,9 +1,3 @@
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import {
materializeProviderImage,
renderProviderImageOutputAsAssistantMarkdown,
} from "../../provider-image-output.js";
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
@@ -666,34 +660,3 @@ describe("live and replay agree", () => {
}); });
}); });
}); });
it("awaits image materialization before publishing replay observations", async () => {
const bytes = Buffer.from("async replay image fixture");
const convertEntry = async () => {
const item = await renderProviderImageOutputAsAssistantMarkdown(
{ data: bytes.toString("base64"), mimeType: "image/png" },
{ materialize: materializeProviderImage },
);
return item ? [item] : [];
};
const { observations } = await observeReplaySubagents({
parent: parentWithTaskCall(),
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [{ type: "assistant", message: { content: [] } }],
},
],
convertEntry,
});
const image = observations.find(
(observation) =>
observation.kind === "timeline" && observation.item.type === "assistant_message",
);
if (!image || image.kind !== "timeline" || image.item.type !== "assistant_message")
throw new Error("Expected replay image");
const source = image.item.text.match(/!\[Image\]\(([^)]+)\)/)?.[1];
expect(source).toBeTruthy();
expect(await readFile(fileURLToPath(source!))).toEqual(bytes);
});
@@ -253,7 +253,7 @@ function observeSubtitle(
async function observeSubagent( async function observeSubagent(
subagent: ClaudeReplaySubagentInput, subagent: ClaudeReplaySubagentInput,
parent: ClaudeReplayParentFacts, parent: ClaudeReplayParentFacts,
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>, convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[],
toolOwners: Map<string, string>, toolOwners: Map<string, string>,
parentSubagentId?: string, parentSubagentId?: string,
): Promise<SubagentObservation[]> { ): Promise<SubagentObservation[]> {
@@ -280,7 +280,7 @@ async function observeSubagent(
childTerminalStatus = entry.message?.stop_reason === "end_turn" ? "completed" : null; childTerminalStatus = entry.message?.stop_reason === "end_turn" ? "completed" : null;
} }
recordReplayToolOwners(toolOwners, entry, link.id); recordReplayToolOwners(toolOwners, entry, link.id);
for (const item of await convertEntry(entry)) { for (const item of convertEntry(entry)) {
timeline.push({ kind: "timeline", id: link.id, item, ...(timestamp ? { timestamp } : {}) }); timeline.push({ kind: "timeline", id: link.id, item, ...(timestamp ? { timestamp } : {}) });
} }
} }
@@ -323,7 +323,7 @@ function recordReplayToolOwners(
export async function observeReplaySubagents(input: { export async function observeReplaySubagents(input: {
subagents: readonly ClaudeReplaySubagentInput[]; subagents: readonly ClaudeReplaySubagentInput[];
parent: ClaudeReplayParentFacts; parent: ClaudeReplayParentFacts;
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>; convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[];
}): Promise<{ observations: SubagentObservation[]; toolOwners: ReadonlyMap<string, string> }> { }): Promise<{ observations: SubagentObservation[]; toolOwners: ReadonlyMap<string, string> }> {
const observations: SubagentObservation[] = []; const observations: SubagentObservation[] = [];
const toolOwners = new Map<string, string>(); const toolOwners = new Map<string, string>();
@@ -1,9 +1,3 @@
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import {
materializeProviderImage,
renderProviderImageOutputAsAssistantMarkdown,
} from "../../provider-image-output.js";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { ProviderSubagentStore } from "../../../provider-subagents/store.js"; import { ProviderSubagentStore } from "../../../provider-subagents/store.js";
@@ -201,29 +195,3 @@ describe("Claude workflow replay", () => {
}, },
); );
}); });
it("awaits image materialization before publishing replay observations", async () => {
const bytes = Buffer.from("async replay image fixture");
const convertEntry = async () => {
const item = await renderProviderImageOutputAsAssistantMarkdown(
{ data: bytes.toString("base64"), mimeType: "image/png" },
{ materialize: materializeProviderImage },
);
return item ? [item] : [];
};
const observations = await observeReplayWorkflows({
workflows: [{ runId: RUN_ID, status: "completed" }],
parentEntries: parentEntries(),
entriesByRunId: new Map([[RUN_ID, [{ type: "assistant", message: { content: [] } }]]]),
convertEntry,
});
const image = observations.find(
(observation) =>
observation.kind === "timeline" && observation.item.type === "assistant_message",
);
if (!image || image.kind !== "timeline" || image.item.type !== "assistant_message")
throw new Error("Expected replay image");
const source = image.item.text.match(/!\[Image\]\(([^)]+)\)/)?.[1];
expect(source).toBeTruthy();
expect(await readFile(fileURLToPath(source!))).toEqual(bytes);
});
@@ -53,7 +53,7 @@ export async function observeReplayWorkflows(input: {
string, string,
Iterable<ClaudeReplayEntry> | AsyncIterable<ClaudeReplayEntry> Iterable<ClaudeReplayEntry> | AsyncIterable<ClaudeReplayEntry>
>; >;
convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>; convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[];
}): Promise<SubagentObservation[]> { }): Promise<SubagentObservation[]> {
const toolCallIdByRunId = await readWorkflowLinks(input.parentEntries); const toolCallIdByRunId = await readWorkflowLinks(input.parentEntries);
const observations: SubagentObservation[] = []; const observations: SubagentObservation[] = [];
@@ -122,7 +122,7 @@ async function observeWorkflowTimeline(input: {
id: string; id: string;
result: unknown; result: unknown;
entries: Iterable<ClaudeReplayEntry> | AsyncIterable<ClaudeReplayEntry>; entries: Iterable<ClaudeReplayEntry> | AsyncIterable<ClaudeReplayEntry>;
convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>; convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[];
finishedAt?: string; finishedAt?: string;
}): Promise<SubagentObservation[]> { }): Promise<SubagentObservation[]> {
const observations: SubagentObservation[] = []; const observations: SubagentObservation[] = [];
@@ -130,7 +130,7 @@ async function observeWorkflowTimeline(input: {
for await (const entry of input.entries) { for await (const entry of input.entries) {
const timestamp = normalizeProviderReplayTimestamp(entry.timestamp); const timestamp = normalizeProviderReplayTimestamp(entry.timestamp);
const items = (await input.convertEntry?.(entry)) ?? []; const items = input.convertEntry?.(entry) ?? [];
for (const item of items) { for (const item of items) {
if (item.type === "assistant_message") replayedAssistantText.add(item.text.trim()); if (item.type === "assistant_message") replayedAssistantText.add(item.text.trim());
observations.push({ observations.push({
File diff suppressed because it is too large Load Diff
@@ -1939,12 +1939,12 @@ function codexImageOutputFromResult(result: unknown): ProviderImageOutput | null
}; };
} }
async function mapCodexThreadImageItem( function mapCodexThreadImageItem(
normalizedType: string, normalizedType: string,
normalizedItem: Record<string, unknown>, normalizedItem: Record<string, unknown>,
): Promise<AgentTimelineItem | null> { ): AgentTimelineItem | null {
if (normalizedType === "imageView") { if (normalizedType === "imageView") {
return await renderProviderImageOutputAsAssistantMarkdown({ return renderProviderImageOutputAsAssistantMarkdown({
path: firstStringField(normalizedItem, ["path"]), path: firstStringField(normalizedItem, ["path"]),
}); });
} }
@@ -1960,7 +1960,7 @@ async function mapCodexThreadImageItem(
const savedPath = firstStringField(normalizedItem, ["savedPath", "saved_path"]); const savedPath = firstStringField(normalizedItem, ["savedPath", "saved_path"]);
const result = codexImageOutputFromResult(normalizedItem.result); const result = codexImageOutputFromResult(normalizedItem.result);
return await renderProviderImageOutputAsAssistantMarkdown( return renderProviderImageOutputAsAssistantMarkdown(
{ {
path: savedPath ?? result?.path ?? null, path: savedPath ?? result?.path ?? null,
url: result?.url ?? null, url: result?.url ?? null,
@@ -1982,10 +1982,10 @@ function mapCodexAgentMessage(item: Record<string, unknown>): AgentTimelineItem
}; };
} }
export async function threadItemToTimeline( export function threadItemToTimeline(
item: unknown, item: unknown,
options?: { includeUserMessage?: boolean; cwd?: string | null }, options?: { includeUserMessage?: boolean; cwd?: string | null },
): Promise<AgentTimelineItem | null> { ): AgentTimelineItem | null {
const itemRecord = toObjectRecord(item); const itemRecord = toObjectRecord(item);
if (!itemRecord) return null; if (!itemRecord) return null;
const includeUserMessage = options?.includeUserMessage ?? true; const includeUserMessage = options?.includeUserMessage ?? true;
@@ -1999,7 +1999,7 @@ export async function threadItemToTimeline(
: itemRecord; : itemRecord;
if (normalizedType === "imageView" || normalizedType === "imageGeneration") { if (normalizedType === "imageView" || normalizedType === "imageGeneration") {
return await mapCodexThreadImageItem(normalizedType, normalizedItem); return mapCodexThreadImageItem(normalizedType, normalizedItem);
} }
if (normalizedType && CODEX_TOOL_THREAD_ITEM_TYPES.has(normalizedType)) { if (normalizedType && CODEX_TOOL_THREAD_ITEM_TYPES.has(normalizedType)) {
return mapCodexToolCallFromThreadItem(normalizedItem, { cwd }); return mapCodexToolCallFromThreadItem(normalizedItem, { cwd });
@@ -2024,7 +2024,7 @@ export async function threadItemToTimeline(
} }
} }
async function mcpToolResultImagesToTimeline(item: unknown): Promise<AgentTimelineItem[]> { function mcpToolResultImagesToTimeline(item: unknown): AgentTimelineItem[] {
const itemRecord = toObjectRecord(item); const itemRecord = toObjectRecord(item);
if (!itemRecord) { if (!itemRecord) {
return []; return [];
@@ -2037,25 +2037,24 @@ async function mcpToolResultImagesToTimeline(item: unknown): Promise<AgentTimeli
} }
const { images } = splitCodexMcpToolResultImages(itemRecord.result); const { images } = splitCodexMcpToolResultImages(itemRecord.result);
const items: AgentTimelineItem[] = []; return images
for (const image of images) { .map((image) =>
const imageItem = await renderProviderImageOutputAsAssistantMarkdown(image, { renderProviderImageOutputAsAssistantMarkdown(image, {
materialize: materializeProviderImage, materialize: materializeProviderImage,
}); }),
if (imageItem) items.push(imageItem); )
} .filter((timelineItem): timelineItem is AgentTimelineItem => timelineItem !== null);
return items;
} }
async function threadItemToTimelineEntries( function threadItemToTimelineEntries(
item: unknown, item: unknown,
options?: { includeUserMessage?: boolean; cwd?: string | null }, options?: { includeUserMessage?: boolean; cwd?: string | null },
): Promise<AgentTimelineItem[]> { ): AgentTimelineItem[] {
const timelineItem = await threadItemToTimeline(item, options); const timelineItem = threadItemToTimeline(item, options);
if (!timelineItem) { if (!timelineItem) {
return []; return [];
} }
return [timelineItem, ...(await mcpToolResultImagesToTimeline(item))]; return [timelineItem, ...mcpToolResultImagesToTimeline(item)];
} }
const CodexThreadReadResponseSchema = z const CodexThreadReadResponseSchema = z
@@ -2104,7 +2103,7 @@ async function loadCodexThreadHistoryTimeline(params: {
historicalSubAgentActivity.agentThreadId, historicalSubAgentActivity.agentThreadId,
); );
if (existingIndex !== undefined) { if (existingIndex !== undefined) {
const activityTimelineItem = await threadItemToTimeline(item, { cwd: params.cwd }); const activityTimelineItem = threadItemToTimeline(item, { cwd: params.cwd });
updateHistoricalSubAgentActivity( updateHistoricalSubAgentActivity(
timeline, timeline,
existingIndex, existingIndex,
@@ -2117,7 +2116,7 @@ async function loadCodexThreadHistoryTimeline(params: {
continue; continue;
} }
} }
for (const timelineItem of await threadItemToTimelineEntries(item, { cwd: params.cwd })) { for (const timelineItem of threadItemToTimelineEntries(item, { cwd: params.cwd })) {
const timestamp = const timestamp =
readCodexHistoryTimestamp(item) ?? readCodexTurnHistoryTimestamp(turn, timelineItem); readCodexHistoryTimestamp(item) ?? readCodexTurnHistoryTimestamp(turn, timelineItem);
const settledTimelineItem = const settledTimelineItem =
@@ -3291,12 +3290,10 @@ export async function codexAppServerTurnInputFromPrompt(
} }
if (block.type === "image") { if (block.type === "image") {
try { try {
const filePath = ( const filePath = materializeProviderImage({
await materializeProviderImage({ data: block.data,
data: block.data, mimeType: block.mimeType,
mimeType: block.mimeType, }).path;
})
).path;
output.push({ type: "localImage", path: filePath }); output.push({ type: "localImage", path: filePath });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
@@ -4373,7 +4370,6 @@ export class CodexAppServerAgentSession implements AgentSession {
this.dismissPendingPlanApprovals("Dismissed by a new prompt"); this.dismissPendingPlanApprovals("Dismissed by a new prompt");
try { try {
await this.pendingNotifications;
await this.connect(); await this.connect();
if (!this.client) { if (!this.client) {
throw new Error("Codex client not initialized"); throw new Error("Codex client not initialized");
@@ -5441,21 +5437,7 @@ export class CodexAppServerAgentSession implements AgentSession {
return `codex-turn-${this.nextTurnOrdinal++}`; return `codex-turn-${this.nextTurnOrdinal++}`;
} }
private pendingNotifications: Promise<void> = Promise.resolve(); private handleNotification(method: string, params: unknown): void {
private handleNotification(method: string, params: unknown): Promise<void> {
// An image must exist before its event, and completion must follow that event.
// Transport callbacks cannot await: the session owns their ordered drain.
const next = this.pendingNotifications.then(() => {
return this.closed ? undefined : this.processNotification(method, params);
});
this.pendingNotifications = next.catch((error: unknown) => {
this.handleUnexpectedTermination(error instanceof Error ? error : new Error(String(error)));
});
return this.pendingNotifications;
}
private async processNotification(method: string, params: unknown): Promise<void> {
const notificationParams = toObjectRecord(params); const notificationParams = toObjectRecord(params);
if (method === "serverRequest/resolved" && typeof notificationParams?.requestId === "number") { if (method === "serverRequest/resolved" && typeof notificationParams?.requestId === "number") {
const requestId = this.mcpElicitationPermissionIds.get(notificationParams.requestId); const requestId = this.mcpElicitationPermissionIds.get(notificationParams.requestId);
@@ -5485,16 +5467,13 @@ export class CodexAppServerAgentSession implements AgentSession {
return; return;
} }
if (route.kind === "sub_agent") { if (route.kind === "sub_agent") {
await this.dispatchSubAgentNotification(parsed, route.callId); this.dispatchSubAgentNotification(parsed, route.callId);
return; return;
} }
await this.dispatchParsedNotification(parsed); this.dispatchParsedNotification(parsed);
} }
private async dispatchSubAgentNotification( private dispatchSubAgentNotification(parsed: ParsedCodexNotification, callId: string): void {
parsed: ParsedCodexNotification,
callId: string,
): Promise<void> {
switch (parsed.kind) { switch (parsed.kind) {
case "thread_started": case "thread_started":
this.emitSubAgentActivityUpdate(callId, "running", { reopen: true }); this.emitSubAgentActivityUpdate(callId, "running", { reopen: true });
@@ -5505,7 +5484,7 @@ export class CodexAppServerAgentSession implements AgentSession {
case "reasoning_delta": case "reasoning_delta":
case "item_started": case "item_started":
case "item_completed": case "item_completed":
await this.dispatchParsedNotification(parsed); this.dispatchParsedNotification(parsed);
return; return;
case "exec_command_output_delta": case "exec_command_output_delta":
case "file_change_output_delta": case "file_change_output_delta":
@@ -5531,7 +5510,7 @@ export class CodexAppServerAgentSession implements AgentSession {
} }
} }
private async dispatchParsedNotification(parsed: ParsedCodexNotification): Promise<void> { private dispatchParsedNotification(parsed: ParsedCodexNotification): void {
if (isCodexDeltaNotification(parsed)) { if (isCodexDeltaNotification(parsed)) {
this.handleCodexDeltaNotification(parsed); this.handleCodexDeltaNotification(parsed);
return; return;
@@ -5576,10 +5555,10 @@ export class CodexAppServerAgentSession implements AgentSession {
this.handlePatchApplyCompletedNotification(parsed); this.handlePatchApplyCompletedNotification(parsed);
return; return;
case "item_completed": case "item_completed":
await this.handleItemCompletedNotification(parsed); this.handleItemCompletedNotification(parsed);
return; return;
case "item_started": case "item_started":
await this.handleItemStartedNotification(parsed); this.handleItemStartedNotification(parsed);
return; return;
case "invalid_payload": case "invalid_payload":
this.warnInvalidNotificationPayload(parsed.method, parsed.params); this.warnInvalidNotificationPayload(parsed.method, parsed.params);
@@ -5621,7 +5600,7 @@ export class CodexAppServerAgentSession implements AgentSession {
pending.push(parsed); pending.push(parsed);
} }
private async replayPendingSubAgentNotifications(threadIds: readonly string[]): Promise<void> { private replayPendingSubAgentNotifications(threadIds: readonly string[]): void {
for (const threadId of threadIds) { for (const threadId of threadIds) {
const pending = this.pendingSubAgentNotificationsByThreadId.get(threadId); const pending = this.pendingSubAgentNotificationsByThreadId.get(threadId);
if (!pending) { if (!pending) {
@@ -5633,7 +5612,7 @@ export class CodexAppServerAgentSession implements AgentSession {
continue; continue;
} }
for (const parsed of pending) { for (const parsed of pending) {
await this.dispatchSubAgentNotification(parsed, callId); this.dispatchSubAgentNotification(parsed, callId);
} }
} }
} }
@@ -6599,13 +6578,9 @@ export class CodexAppServerAgentSession implements AgentSession {
} }
} }
private ownsNotificationTurn(turnId: string | null): boolean { private handleItemCompletedNotification(
return !this.closed && this.activeForegroundTurnId === turnId;
}
private async handleItemCompletedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "item_completed" }>, parsed: Extract<ParsedCodexNotification, { kind: "item_completed" }>,
): Promise<void> { ): void {
// Codex emits mirrored lifecycle notifications via both `codex/event/item_*` // Codex emits mirrored lifecycle notifications via both `codex/event/item_*`
// and canonical `item/*`. Render ordinary items only from the canonical // and canonical `item/*`. Render ordinary items only from the canonical
// channel, but accept a legacy-only child announcement so it can establish // channel, but accept a legacy-only child announcement so it can establish
@@ -6615,23 +6590,20 @@ export class CodexAppServerAgentSession implements AgentSession {
} }
this.receiveAsyncQuestion(parsed.threadId, parsed.item); this.receiveAsyncQuestion(parsed.threadId, parsed.item);
if (this.isUserMessageItem(parsed.item)) { if (this.isUserMessageItem(parsed.item)) {
await this.handleUserMessageItem(parsed); this.handleUserMessageItem(parsed);
return; return;
} }
const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId); const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
if (this.handleCompletedSpecialItem(parsed, childSubAgentCallId)) { if (this.handleCompletedSpecialItem(parsed, childSubAgentCallId)) {
return; return;
} }
const foregroundTurnId = this.activeForegroundTurnId; const timelineItem = threadItemToTimeline(parsed.item, {
const timelineItem = await threadItemToTimeline(parsed.item, {
includeUserMessage: false, includeUserMessage: false,
cwd: this.config.cwd ?? null, cwd: this.config.cwd ?? null,
}); });
if (!timelineItem) { if (!timelineItem) {
return; return;
} }
const imageItems = await mcpToolResultImagesToTimeline(parsed.item);
if (!this.ownsNotificationTurn(foregroundTurnId)) return;
const registeredChildThreadIds = const registeredChildThreadIds =
timelineItem.type === "tool_call" timelineItem.type === "tool_call"
? this.registerSubAgentToolCall({ ? this.registerSubAgentToolCall({
@@ -6641,11 +6613,12 @@ export class CodexAppServerAgentSession implements AgentSession {
parentSubagentId: resolveCodexParentSubagentId(childSubAgentCallId, parsed.threadId), parentSubagentId: resolveCodexParentSubagentId(childSubAgentCallId, parsed.threadId),
}) })
: []; : [];
const imageItems = mcpToolResultImagesToTimeline(parsed.item);
if (childSubAgentCallId) { if (childSubAgentCallId) {
this.emitCompletedProviderSubagentItem(parsed, timelineItem); this.emitCompletedProviderSubagentItem(parsed, timelineItem);
this.handleSubAgentChildItemCompleted(childSubAgentCallId, parsed.item.id, timelineItem); this.handleSubAgentChildItemCompleted(childSubAgentCallId, parsed.item.id, timelineItem);
this.emitProviderSubagentTimelineItems(parsed.threadId, imageItems); this.emitProviderSubagentTimelineItems(parsed.threadId, imageItems);
await this.replayPendingSubAgentNotifications(registeredChildThreadIds); this.replayPendingSubAgentNotifications(registeredChildThreadIds);
return; return;
} }
const normalizedItemType = normalizeCodexThreadItemType( const normalizedItemType = normalizeCodexThreadItemType(
@@ -6653,7 +6626,7 @@ export class CodexAppServerAgentSession implements AgentSession {
); );
const itemId = parsed.item.id; const itemId = parsed.item.id;
if (this.shouldSkipCompletedThreadItem(timelineItem, normalizedItemType, itemId)) { if (this.shouldSkipCompletedThreadItem(timelineItem, normalizedItemType, itemId)) {
await this.replayPendingSubAgentNotifications(registeredChildThreadIds); this.replayPendingSubAgentNotifications(registeredChildThreadIds);
return; return;
} }
if (this.consumeStreamedTextCompletion(timelineItem, itemId)) { if (this.consumeStreamedTextCompletion(timelineItem, itemId)) {
@@ -6664,7 +6637,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.emittedItemCompletedIds.add(itemId); this.emittedItemCompletedIds.add(itemId);
this.emittedItemStartedIds.delete(itemId); this.emittedItemStartedIds.delete(itemId);
} }
await this.replayPendingSubAgentNotifications(registeredChildThreadIds); this.replayPendingSubAgentNotifications(registeredChildThreadIds);
return; return;
} }
this.applyBufferedDeltaTextToTimelineItem(timelineItem, itemId); this.applyBufferedDeltaTextToTimelineItem(timelineItem, itemId);
@@ -6693,7 +6666,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.pendingCommandOutputDeltas.delete(itemId); this.pendingCommandOutputDeltas.delete(itemId);
this.pendingFileChangeOutputDeltas.delete(itemId); this.pendingFileChangeOutputDeltas.delete(itemId);
} }
await this.replayPendingSubAgentNotifications(registeredChildThreadIds); this.replayPendingSubAgentNotifications(registeredChildThreadIds);
} }
private consumeStreamedTextCompletion( private consumeStreamedTextCompletion(
@@ -6765,14 +6738,14 @@ export class CodexAppServerAgentSession implements AgentSession {
} }
} }
private async handleItemStartedNotification( private handleItemStartedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "item_started" }>, parsed: Extract<ParsedCodexNotification, { kind: "item_started" }>,
): Promise<void> { ): void {
if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) { if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) {
return; return;
} }
if (this.isUserMessageItem(parsed.item)) { if (this.isUserMessageItem(parsed.item)) {
await this.handleUserMessageItem(parsed); this.handleUserMessageItem(parsed);
return; return;
} }
const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId); const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
@@ -6794,7 +6767,7 @@ export class CodexAppServerAgentSession implements AgentSession {
if (this.handleRegisteredSubAgentActivity(parsed.item)) { if (this.handleRegisteredSubAgentActivity(parsed.item)) {
return; return;
} }
const timelineItem = await threadItemToTimeline(parsed.item, { const timelineItem = threadItemToTimeline(parsed.item, {
includeUserMessage: false, includeUserMessage: false,
cwd: this.config.cwd ?? null, cwd: this.config.cwd ?? null,
}); });
@@ -6813,7 +6786,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.upsertSubAgentChildItem(childSubAgentCallId, parsed.item.id, timelineItem); this.upsertSubAgentChildItem(childSubAgentCallId, parsed.item.id, timelineItem);
} }
this.emitSubAgentActivityUpdate(childSubAgentCallId, "running"); this.emitSubAgentActivityUpdate(childSubAgentCallId, "running");
await this.replayPendingSubAgentNotifications(registeredChildThreadIds); this.replayPendingSubAgentNotifications(registeredChildThreadIds);
return; return;
} }
const normalizedItemType = normalizeCodexThreadItemType( const normalizedItemType = normalizeCodexThreadItemType(
@@ -6837,14 +6810,14 @@ export class CodexAppServerAgentSession implements AgentSession {
this.pendingCommandOutputDeltas.delete(itemId); this.pendingCommandOutputDeltas.delete(itemId);
this.pendingFileChangeOutputDeltas.delete(itemId); this.pendingFileChangeOutputDeltas.delete(itemId);
} }
await this.replayPendingSubAgentNotifications(registeredChildThreadIds); this.replayPendingSubAgentNotifications(registeredChildThreadIds);
} }
private async handleUserMessageItem( private handleUserMessageItem(
parsed: Extract<ParsedCodexNotification, { kind: "item_started" | "item_completed" }>, parsed: Extract<ParsedCodexNotification, { kind: "item_started" | "item_completed" }>,
): Promise<void> { ): void {
const itemId = parsed.item.id; const itemId = parsed.item.id;
const timelineItem = await threadItemToTimeline(parsed.item, { const timelineItem = threadItemToTimeline(parsed.item, {
includeUserMessage: true, includeUserMessage: true,
cwd: this.config.cwd ?? null, cwd: this.config.cwd ?? null,
}); });
@@ -1343,14 +1343,13 @@ describe("OMP agent client and session", () => {
expect(omp.runtimeLaunches()).toHaveLength(1); expect(omp.runtimeLaunches()).toHaveLength(1);
}); });
test("rejects a pending recovery start and closes its processes when the session closes", async () => { test("closes a replacement process if the session closes during relaunch", async () => {
const omp = new OmpHarness(); const omp = new OmpHarness();
await omp.start(); await omp.start();
omp.processExit("OMP RPC process exited with code 1 and signal null"); omp.processExit("OMP RPC process exited with code 1 and signal null");
const turn = omp.startTurnDetached("continue"); const turn = omp.startTurnDetached("continue");
const rejected = expect(turn).rejects.toThrow("closed");
await omp.close(); await omp.close();
await rejected; await turn;
await waitForImmediate(); await waitForImmediate();
expect(omp.runtimeSessions().every((session) => session.closed)).toBe(true); expect(omp.runtimeSessions().every((session) => session.closed)).toBe(true);
}); });
@@ -1639,93 +1638,3 @@ test.each([
expect(await session.canStopRuntime!()).toBe(expected); expect(await session.canStopRuntime!()).toBe(expected);
}, },
); );
test.each([false, true])(
"image prompt keeps OMP's vision capability contract (vision: %s)",
async (vision) => {
const runtime = new FakeOmp();
runtime.queueSessionSetup((session) => {
session.state = {
...session.state,
model: {
provider: "test",
id: "image-model",
input: vision ? ["text", "image"] : ["text"],
},
};
});
const client = new OmpAgentClient({ logger: createTestLogger(), runtime });
const session = await client.createSession({ provider: "omp", cwd: process.cwd() });
onTestFinished(() => session.close());
const bytes = Buffer.from("OMP attachment fixture");
const first = session.startTurn([
{ type: "text", text: "Describe this image." },
{ type: "image", data: bytes.toString("base64"), mimeType: "image/png" },
]);
await expect(session.startTurn("overlapping prompt")).rejects.toThrow("already active");
await first;
const [prompt] = runtime.latestSession().prompts;
expect(prompt.imageCount).toBe(vision ? 1 : 0);
if (vision) expect(prompt.message).toBe("Describe this image.");
else {
const imagePath = prompt.message.match(/\[Image available at: (.+)\]/)?.[1];
expect(imagePath).toBeTruthy();
const { readFile, rm } = await import("node:fs/promises");
expect(await readFile(imagePath!)).toEqual(bytes);
await rm(imagePath!);
}
},
);
test("interrupting OMP during image conversion cancels the admitted turn and permits a follow-up", async () => {
const runtime = new FakeOmp();
const client = new OmpAgentClient({ logger: createTestLogger(), runtime });
const session = await client.createSession({ provider: "omp", cwd: process.cwd() });
onTestFinished(() => session.close());
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
let admitted = false;
const first = session
.startTurn([
{
type: "image",
data: Buffer.alloc(16 * 1024 * 1024, 53).toString("base64"),
mimeType: "image/png",
},
])
.then((result) => {
admitted = true;
return result;
});
await waitForImmediate();
expect(admitted).toBe(false);
expect(runtime.latestSession().prompts).toHaveLength(0);
await session.interrupt();
const followup = await session.startTurn("follow-up after image interrupt");
const original = await first;
expect(events).toContainEqual(
expect.objectContaining({ type: "turn_canceled", turnId: original.turnId }),
);
expect(followup.turnId).not.toBe(original.turnId);
expect(runtime.latestSession().prompts).toEqual([
{ message: "follow-up after image interrupt", imageCount: 0 },
]);
});
test("closing OMP during image conversion rejects its pending start", async () => {
const runtime = new FakeOmp();
const client = new OmpAgentClient({ logger: createTestLogger(), runtime });
const session = await client.createSession({ provider: "omp", cwd: process.cwd() });
const first = session.startTurn([
{
type: "image",
data: Buffer.alloc(16 * 1024 * 1024, 54).toString("base64"),
mimeType: "image/png",
},
]);
const rejected = expect(first).rejects.toThrow("closed");
await waitForImmediate();
expect(runtime.latestSession().prompts).toHaveLength(0);
await session.close();
await rejected;
});
@@ -317,9 +317,9 @@ function ompModelSupportsImageInput(model: OmpModel | null | undefined): boolean
return model?.input?.includes("image") === true; return model?.input?.includes("image") === true;
} }
async function renderTextOnlyImageHint(image: { data: string; mimeType: string }): Promise<string> { function renderTextOnlyImageHint(image: { data: string; mimeType: string }): string {
try { try {
const materialized = await materializeProviderImage({ const materialized = materializeProviderImage({
data: image.data, data: image.data,
mimeType: image.mimeType, mimeType: image.mimeType,
}); });
@@ -329,10 +329,10 @@ async function renderTextOnlyImageHint(image: { data: string; mimeType: string }
} }
} }
async function convertPromptInput( function convertPromptInput(
prompt: AgentPromptInput, prompt: AgentPromptInput,
options: { model: OmpModel | null | undefined }, options: { model: OmpModel | null | undefined },
): Promise<OmpPromptPayload> { ): OmpPromptPayload {
if (typeof prompt === "string") { if (typeof prompt === "string") {
return { text: prompt }; return { text: prompt };
} }
@@ -355,7 +355,7 @@ async function convertPromptInput(
mimeType: block.mimeType, mimeType: block.mimeType,
}); });
} else { } else {
textParts.push(await renderTextOnlyImageHint(block)); textParts.push(renderTextOnlyImageHint(block));
} }
continue; continue;
} }
@@ -868,9 +868,11 @@ export class OmpAgentSession implements AgentSession {
throw new Error("An OMP turn is already active"); throw new Error("An OMP turn is already active");
} }
const payload = convertPromptInput(prompt, { model: this.state.model });
const turnId = randomUUID(); const turnId = randomUUID();
this.live = true; this.live = true;
this.activeTurnId = turnId; this.activeTurnId = turnId;
this.rememberClientMessage(options?.clientMessageId ?? null, payload.text);
this.activeAssistantMessageId = null; this.activeAssistantMessageId = null;
this.activeTurnTerminalAssistantMessage = null; this.activeTurnTerminalAssistantMessage = null;
this.activeTurnStarted = false; this.activeTurnStarted = false;
@@ -878,10 +880,6 @@ export class OmpAgentSession implements AgentSession {
this.activeTurnHasUserMessage = false; this.activeTurnHasUserMessage = false;
this.activePromptRequestId = null; this.activePromptRequestId = null;
this.clearNoTurnBuffers(); this.clearNoTurnBuffers();
const payload = await convertPromptInput(prompt, { model: this.state.model });
if (this.closed) throw new Error("OMP session is closed");
if (this.activeTurnId !== turnId) return { turnId };
this.rememberClientMessage(options?.clientMessageId ?? null, payload.text);
this.activeNoTurnPromptText = payload.text; this.activeNoTurnPromptText = payload.text;
this.usagePoller.startTurn(); this.usagePoller.startTurn();
if (announceTurn) { if (announceTurn) {
@@ -948,9 +946,7 @@ export class OmpAgentSession implements AgentSession {
): Promise<SteerResult> { ): Promise<SteerResult> {
if (this.closed || this.activeTurnId !== options.expectedTurnId) if (this.closed || this.activeTurnId !== options.expectedTurnId)
return { status: "unavailable" }; return { status: "unavailable" };
const payload = await convertPromptInput(prompt, { model: this.state.model }); const payload = convertPromptInput(prompt, { model: this.state.model });
if (this.closed || this.activeTurnId !== options.expectedTurnId)
return { status: "unavailable" };
if (this.parseSlashCommandInput(payload.text)) return { status: "unavailable" }; if (this.parseSlashCommandInput(payload.text)) return { status: "unavailable" };
const submission = this.rememberClientMessage(options.clientMessageId ?? null, payload.text); const submission = this.rememberClientMessage(options.clientMessageId ?? null, payload.text);
try { try {
@@ -1,4 +1,3 @@
import { collectTimelineSnapshot } from "./test-utils/v2-timeline.js";
import type { SessionMessageAssistant, SessionMessageUser } from "@opencode/client"; import type { SessionMessageAssistant, SessionMessageUser } from "@opencode/client";
import { V2Timeline } from "./v2/timeline.js"; import { V2Timeline } from "./v2/timeline.js";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
@@ -1561,7 +1560,7 @@ describe("translateOpenCodeEvent", () => {
}); });
describe("OpenCode v2 history reconciliation", () => { describe("OpenCode v2 history reconciliation", () => {
it("keeps client message identity on live echoes but omits it from history", async () => { it("keeps client message identity on live echoes but omits it from history", () => {
const user: SessionMessageUser = { const user: SessionMessageUser = {
id: "native-message", id: "native-message",
type: "user", type: "user",
@@ -1569,13 +1568,13 @@ describe("OpenCode v2 history reconciliation", () => {
time: { created: 1 }, time: { created: 1 },
metadata: { paseoClientMessageId: "client-message" }, metadata: { paseoClientMessageId: "client-message" },
}; };
expect(await collectTimelineSnapshot(new V2Timeline(), [user])).toMatchObject([ expect(new V2Timeline().messages([user])).toMatchObject([
{ item: { clientMessageId: "client-message", messageId: "native-message" } }, { item: { clientMessageId: "client-message", messageId: "native-message" } },
]); ]);
expect(await collectTimelineSnapshot(new V2Timeline(false), [user])).toMatchObject([ expect(new V2Timeline(false).messages([user])).toMatchObject([
{ item: { type: "user_message", messageId: "native-message" } }, { item: { type: "user_message", messageId: "native-message" } },
]); ]);
const [event] = await collectTimelineSnapshot(new V2Timeline(false), [user]); const [event] = new V2Timeline(false).messages([user]);
expect(event && "item" in event && event.item).not.toHaveProperty("clientMessageId"); expect(event && "item" in event && event.item).not.toHaveProperty("clientMessageId");
}); });
function assistant(text: string): SessionMessageAssistant { function assistant(text: string): SessionMessageAssistant {
@@ -1588,35 +1587,31 @@ describe("OpenCode v2 history reconciliation", () => {
content: [{ type: "text", text }], content: [{ type: "text", text }],
}; };
} }
it("emits only the missing suffix after reconnect, even if an intermediate snapshot lags", async () => { it("emits only the missing suffix after reconnect, even if an intermediate snapshot lags", () => {
const timeline = new V2Timeline(); const timeline = new V2Timeline();
const text = (messages: Awaited<ReturnType<typeof collectTimelineSnapshot>>) => const text = (messages: ReturnType<V2Timeline["messages"]>) =>
messages.flatMap((event) => messages.flatMap((event) =>
event.type === "timeline" && event.item.type === "assistant_message" event.type === "timeline" && event.item.type === "assistant_message"
? [event.item.text] ? [event.item.text]
: [], : [],
); );
expect(text(await collectTimelineSnapshot(timeline, [assistant("hello")]))).toEqual(["hello"]); expect(text(timeline.messages([assistant("hello")]))).toEqual(["hello"]);
expect(await collectTimelineSnapshot(timeline, [assistant("hel")])).toEqual([]); expect(timeline.messages([assistant("hel")])).toEqual([]);
expect(text(await collectTimelineSnapshot(timeline, [assistant("hello world")]))).toEqual([ expect(text(timeline.messages([assistant("hello world")]))).toEqual([" world"]);
" world", expect(timeline.messages([assistant("hello world")])).toEqual([]);
]);
expect(await collectTimelineSnapshot(timeline, [assistant("hello world")])).toEqual([]);
}); });
it("keeps reasoning separate from assistant output", async () => { it("keeps reasoning separate from assistant output", () => {
const timeline = new V2Timeline(); const timeline = new V2Timeline();
const message = assistant("answer"); const message = assistant("answer");
message.content.unshift({ type: "reasoning", text: "thinking" }); message.content.unshift({ type: "reasoning", text: "thinking" });
expect( expect(
(await collectTimelineSnapshot(timeline, [message])).map((event) => timeline.messages([message]).map((event) => (event.type === "timeline" ? event.item : null)),
event.type === "timeline" ? event.item : null,
),
).toEqual([ ).toEqual([
{ type: "reasoning", text: "thinking" }, { type: "reasoning", text: "thinking" },
{ type: "assistant_message", text: "answer", messageId: "message" }, { type: "assistant_message", text: "answer", messageId: "message" },
]); ]);
}); });
it("emits tool state transitions once and keeps their call identity", async () => { it("emits tool state transitions once and keeps their call identity", () => {
const timeline = new V2Timeline(); const timeline = new V2Timeline();
const message = assistant(""); const message = assistant("");
message.content = [ message.content = [
@@ -1628,7 +1623,7 @@ describe("OpenCode v2 history reconciliation", () => {
state: { status: "running", input: { command: "pwd" }, metadata: {} }, state: { status: "running", input: { command: "pwd" }, metadata: {} },
}, },
]; ];
expect(await collectTimelineSnapshot(timeline, [message])).toMatchObject([ expect(timeline.messages([message])).toMatchObject([
{ item: { type: "tool_call", callId: "call", status: "running" } }, { item: { type: "tool_call", callId: "call", status: "running" } },
]); ]);
message.content = [ message.content = [
@@ -1644,9 +1639,9 @@ describe("OpenCode v2 history reconciliation", () => {
}, },
}, },
]; ];
expect(await collectTimelineSnapshot(timeline, [message])).toMatchObject([ expect(timeline.messages([message])).toMatchObject([
{ item: { type: "tool_call", callId: "call", status: "completed" } }, { item: { type: "tool_call", callId: "call", status: "completed" } },
]); ]);
expect(await collectTimelineSnapshot(timeline, [message])).toEqual([]); expect(timeline.messages([message])).toEqual([]);
}); });
}); });
@@ -1,4 +1,3 @@
import { collectTimelineSnapshot } from "./v2-timeline.js";
import { TimelineProjection } from "../../../timeline-projection.js"; import { TimelineProjection } from "../../../timeline-projection.js";
import { V2Timeline } from "../v2/timeline.js"; import { V2Timeline } from "../v2/timeline.js";
@@ -22,18 +21,16 @@ for (let index = 0; index < chunks; index++) {
const event = const event =
mode === "delta" mode === "delta"
? timeline.delta({ ...part, delta }) ? timeline.delta({ ...part, delta })
: ( : timeline.messages([
await collectTimelineSnapshot(timeline, [ {
{ id: "answer",
id: "answer", type: "assistant",
type: "assistant", agent: "build",
agent: "build", model: { providerID: "test", id: "model" },
model: { providerID: "test", id: "model" }, time: { created: 2 },
time: { created: 2 }, content: [{ type: "reasoning", text }],
content: [{ type: "reasoning", text }], },
}, ])[0];
])
)[0];
if (event?.type !== "timeline") throw new Error("Expected a timeline event"); if (event?.type !== "timeline") throw new Error("Expected a timeline event");
projection.append({ projection.append({
seq: index + 1, seq: index + 1,
@@ -1,15 +0,0 @@
import type { SessionMessageInfo } from "@opencode/client";
import type { AgentStreamEvent } from "../../../agent-sdk-types.js";
import type { V2Timeline } from "../v2/timeline.js";
// Snapshot assertions without a live stream can collect the publisher's events.
export async function collectTimelineSnapshot(
timeline: V2Timeline,
messages: SessionMessageInfo[],
): Promise<AgentStreamEvent[]> {
const events: AgentStreamEvent[] = [];
await timeline.messages(messages, (event) => {
events.push(event);
});
return events;
}
@@ -92,13 +92,13 @@ export class SessionChildren {
this.childStates.set(info.id, signature); this.childStates.set(info.id, signature);
this.options.emit({ type: "provider_subagent", provider: "opencode", event: presentation }); this.options.emit({ type: "provider_subagent", provider: "opencode", event: presentation });
} }
await timeline.messages(await messages(this.options.client(), info.id), (event) => { for (const event of timeline.messages(await messages(this.options.client(), info.id))) {
if (event.type === "timeline") if (event.type === "timeline")
this.options.emit({ this.options.emit({
type: "provider_subagent", type: "provider_subagent",
provider: "opencode", provider: "opencode",
event: { type: "timeline", id: info.id, item: event.item, timestamp: event.timestamp }, event: { type: "timeline", id: info.id, item: event.item, timestamp: event.timestamp },
}); });
}); }
} }
} }
@@ -131,7 +131,7 @@ export class OpenCodeV2Session implements AgentSession {
await this.configureConnection(); await this.configureConnection();
const location = { directory: this.config.cwd }; const location = { directory: this.config.cwd };
this.modes = modesFromV2((await this.client.agent.list({ location })).data); this.modes = modesFromV2((await this.client.agent.list({ location })).data);
await this.timeline.messages(await messages(this.client, this.id), () => {}); this.timeline.messages(await messages(this.client, this.id));
await this.startStream(); await this.startStream();
await this.children.reconcile(this.id); await this.children.reconcile(this.id);
} }
@@ -261,11 +261,7 @@ export class OpenCodeV2Session implements AgentSession {
async *streamHistory() { async *streamHistory() {
await this.reconnectIfExited(); await this.reconnectIfExited();
const history = new V2Timeline(false); const history = new V2Timeline(false);
const events: AgentStreamEvent[] = []; yield* history.messages(await messages(this.client, this.id));
await history.messages(await messages(this.client, this.id), (event) => {
events.push(event);
});
yield* events;
} }
async getRuntimeInfo() { async getRuntimeInfo() {
await this.reconnectIfExited(); await this.reconnectIfExited();
@@ -421,7 +417,7 @@ export class OpenCodeV2Session implements AgentSession {
]); ]);
this.info = info; this.info = info;
this.history = history; this.history = history;
await this.timeline.messages(history, (event) => this.emitTimeline(event)); for (const event of this.timeline.messages(history)) this.emitTimeline(event);
await this.permissions.reconcile(this.id); await this.permissions.reconcile(this.id);
} }
private async reconcile() { private async reconcile() {
@@ -1,4 +1,3 @@
import { collectTimelineSnapshot } from "../test-utils/v2-timeline.js";
import { V2Timeline } from "./timeline.js"; import { V2Timeline } from "./timeline.js";
import type { SessionMessageAssistant } from "@opencode/client"; import type { SessionMessageAssistant } from "@opencode/client";
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
@@ -81,7 +80,7 @@ describe("OpenCode v2 token streaming", () => {
} }
}); });
test("deduplicates snapshots before and after deltas using per-type ordinals", async () => { test("deduplicates snapshots before and after deltas using per-type ordinals", () => {
const timeline = new V2Timeline(); const timeline = new V2Timeline();
const text = { assistantMessageID: "answer", type: "text", ordinal: 0 } as const; const text = { assistantMessageID: "answer", type: "text", ordinal: 0 } as const;
const reasoning = { ...text, type: "reasoning" } as const; const reasoning = { ...text, type: "reasoning" } as const;
@@ -100,28 +99,24 @@ describe("OpenCode v2 token streaming", () => {
}, },
{ type: "text", text: "Hello" }, { type: "text", text: "Hello" },
]); ]);
expect(await collectTimelineSnapshot(timeline, [snapshot])).toMatchObject([ expect(timeline.messages([snapshot])).toMatchObject([{ item: { text: "lo" } }]);
{ item: { text: "lo" } },
]);
expect(timeline.delta({ ...text, delta: "lo" })).toBeNull(); expect(timeline.delta({ ...text, delta: "lo" })).toBeNull();
expect(await collectTimelineSnapshot(timeline, [snapshot])).toEqual([]); expect(timeline.messages([snapshot])).toEqual([]);
expect(timeline.delta({ ...text, delta: "!" })).toMatchObject({ item: { text: "!" } }); expect(timeline.delta({ ...text, delta: "!" })).toMatchObject({ item: { text: "!" } });
expect(await collectTimelineSnapshot(timeline, [snapshot])).toEqual([]); expect(timeline.messages([snapshot])).toEqual([]);
}); });
test("recovers missed fragments from snapshots after reconnect", async () => { test("recovers missed fragments from snapshots after reconnect", () => {
const timeline = new V2Timeline(); const timeline = new V2Timeline();
const part = { assistantMessageID: "answer", type: "text", ordinal: 0 } as const; const part = { assistantMessageID: "answer", type: "text", ordinal: 0 } as const;
timeline.startPart(part); timeline.startPart(part);
expect(timeline.delta({ ...part, delta: "Hel" })).toMatchObject({ item: { text: "Hel" } }); expect(timeline.delta({ ...part, delta: "Hel" })).toMatchObject({ item: { text: "Hel" } });
timeline.resetStreams(); timeline.resetStreams();
expect(timeline.delta({ ...part, delta: "!" })).toBeNull(); expect(timeline.delta({ ...part, delta: "!" })).toBeNull();
expect( expect(timeline.messages([assistant([{ type: "text", text: "Hello!" }])])).toMatchObject([
await collectTimelineSnapshot(timeline, [assistant([{ type: "text", text: "Hello!" }])]), { item: { text: "lo!" } },
).toMatchObject([{ item: { text: "lo!" } }]); ]);
expect( expect(timeline.messages([assistant([{ type: "text", text: "Hello!" }])])).toEqual([]);
await collectTimelineSnapshot(timeline, [assistant([{ type: "text", text: "Hello!" }])]),
).toEqual([]);
}); });
test("withholds streamed prose during a structured-output turn", async () => { test("withholds streamed prose during a structured-output turn", async () => {
@@ -208,62 +203,3 @@ describe("OpenCode v2 token streaming", () => {
} }
}); });
}); });
test.each(["text", "reasoning"] as const)(
"publishes snapshot %s before a live suffix during image materialization",
async (type) => {
const timeline = new V2Timeline();
const part = { assistantMessageID: "answer", type, ordinal: 0 };
timeline.startPart(part);
const emitted: AgentStreamEvent[] = [];
let settled = false;
const snapshot = timeline
.messages(
[
assistant([
{ type, text: "Hello" },
{
type: "tool",
id: "screenshot",
name: "paseo_browser_screenshot",
executed: false,
state: {
status: "completed",
input: {},
content: [
{ type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png" },
],
metadata: {},
},
time: { created: 2, ran: 2, completed: 2 },
},
]),
],
(event) => {
emitted.push(event);
},
)
.then(() => {
settled = true;
return undefined;
});
await new Promise<void>((resolve) => setImmediate(resolve));
expect(settled).toBe(false);
const delta = timeline.delta({ ...part, delta: "Hello world" });
if (delta) emitted.push(delta);
await snapshot;
const text = emitted.flatMap((event) => {
if (event.type !== "timeline") return [];
if (
type === "text" &&
event.item.type === "assistant_message" &&
!event.item.text.includes("![Image]")
)
return [event.item.text];
if (type === "reasoning" && event.item.type === "reasoning") return [event.item.text];
return [];
});
expect(text).toEqual(["Hello", " world"]);
expect(text.join("")).toBe("Hello world");
},
);
@@ -1,4 +1,3 @@
import { collectTimelineSnapshot } from "../test-utils/v2-timeline.js";
import type { SessionMessageAssistant } from "@opencode/client"; import type { SessionMessageAssistant } from "@opencode/client";
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@@ -16,7 +15,7 @@ function assistant(content: SessionMessageAssistant["content"]): SessionMessageA
}; };
} }
test("a completed edit carries the replaced and replacement text", async () => { test("a completed edit carries the replaced and replacement text", () => {
// Captured from OpenCode 2.0.18, whose edit tool takes `path` instead of `filePath`. // Captured from OpenCode 2.0.18, whose edit tool takes `path` instead of `filePath`.
const edit = assistant([ const edit = assistant([
{ {
@@ -46,7 +45,7 @@ test("a completed edit carries the replaced and replacement text", async () => {
}, },
]); ]);
expect(await collectTimelineSnapshot(new V2Timeline(), [edit])).toMatchObject([ expect(new V2Timeline().messages([edit])).toMatchObject([
{ {
item: { item: {
type: "tool_call", type: "tool_call",
@@ -58,7 +57,7 @@ test("a completed edit carries the replaced and replacement text", async () => {
]); ]);
}); });
test("a tool's image output is shown as an image, not as base64 in the tool output", async () => { test("a tool's image output is shown as an image, not as base64 in the tool output", () => {
const png = const png =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="; "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==";
const screenshot = assistant([ const screenshot = assistant([
@@ -81,7 +80,7 @@ test("a tool's image output is shown as an image, not as base64 in the tool outp
]); ]);
const timeline = new V2Timeline(); const timeline = new V2Timeline();
const events = await collectTimelineSnapshot(timeline, [screenshot]); const events = timeline.messages([screenshot]);
expect(JSON.stringify(events)).not.toContain(png); expect(JSON.stringify(events)).not.toContain(png);
expect(events.map((event) => event.type === "timeline" && event.item)).toMatchObject([ expect(events.map((event) => event.type === "timeline" && event.item)).toMatchObject([
@@ -96,7 +95,7 @@ test("a tool's image output is shown as an image, not as base64 in the tool outp
text: expect.stringMatching(/^\n\n!\[Image\]\(file:\/\/.*paseo-attachments.*\.png\)\n\n$/), text: expect.stringMatching(/^\n\n!\[Image\]\(file:\/\/.*paseo-attachments.*\.png\)\n\n$/),
}, },
]); ]);
expect(await collectTimelineSnapshot(timeline, [screenshot])).toEqual([]); expect(timeline.messages([screenshot])).toEqual([]);
}); });
test("a surrogate pair split across deltas streams back intact", () => { test("a surrogate pair split across deltas streams back intact", () => {
@@ -55,32 +55,18 @@ export class V2Timeline {
return { type: "timeline", provider: "opencode", item, timestamp: new Date().toISOString() }; return { type: "timeline", provider: "opencode", item, timestamp: new Date().toISOString() };
} }
private pendingMessages: Promise<unknown> = Promise.resolve(); messages(messages: SessionMessageInfo[]): AgentStreamEvent[] {
const events: AgentStreamEvent[] = [];
// Publish each cursor advance in the same synchronous step. Buffering snapshot
// events until image IO finishes lets live deltas overtake already committed text.
messages(
messages: SessionMessageInfo[],
publish: (event: AgentStreamEvent) => void,
): Promise<void> {
const result = this.pendingMessages.then(() => this.publishMessages(messages, publish));
this.pendingMessages = result.catch(() => {});
return result;
}
private async publishMessages(
messages: SessionMessageInfo[],
publish: (event: AgentStreamEvent) => void,
): Promise<void> {
const state: TimelineState = { structured: false, accepted: false }; const state: TimelineState = { structured: false, accepted: false };
for (const message of messages) { for (const message of messages) {
const timestamp = new Date(message.time.created).toISOString(); const timestamp = new Date(message.time.created).toISOString();
const push = (item: AgentTimelineItem) => const push = (item: AgentTimelineItem) =>
publish({ type: "timeline", provider: "opencode", item, timestamp }); events.push({ type: "timeline", provider: "opencode", item, timestamp });
if (message.type === "user") this.userMessage(message, state, push); if (message.type === "user") this.userMessage(message, state, push);
else if (message.type === "assistant") await this.assistantMessage(message, state, push); else if (message.type === "assistant") this.assistantMessage(message, state, push);
else if (message.type === "compaction") this.compactionMessage(message, push); else if (message.type === "compaction") this.compactionMessage(message, push);
} }
return events;
} }
private userMessage( private userMessage(
@@ -103,7 +89,7 @@ export class V2Timeline {
}); });
} }
private async assistantMessage( private assistantMessage(
message: Extract<SessionMessageInfo, { type: "assistant" }>, message: Extract<SessionMessageInfo, { type: "assistant" }>,
state: TimelineState, state: TimelineState,
push: (item: AgentTimelineItem) => void, push: (item: AgentTimelineItem) => void,
@@ -113,16 +99,16 @@ export class V2Timeline {
// paths address the same cursor entry. // paths address the same cursor entry.
let textOrdinal = 0; let textOrdinal = 0;
let reasoningOrdinal = 0; let reasoningOrdinal = 0;
for (const [partIndex, part] of message.content.entries()) { message.content.forEach((part, partIndex) => {
if (part.type === "tool" && part.name === STRUCTURED_OUTPUT_TOOL) { if (part.type === "tool" && part.name === STRUCTURED_OUTPUT_TOOL) {
this.structuredOutputPart(message.id, partIndex, part, state, push); this.structuredOutputPart(message.id, partIndex, part, state, push);
} else if (part.type === "text" || part.type === "reasoning") { } else if (part.type === "text" || part.type === "reasoning") {
const ordinal = part.type === "text" ? textOrdinal++ : reasoningOrdinal++; const ordinal = part.type === "text" ? textOrdinal++ : reasoningOrdinal++;
this.textPart(message.id, part, ordinal, state, push); this.textPart(message.id, part, ordinal, state, push);
} else { } else {
await this.toolPart(message.id, partIndex, part, push); this.toolPart(message.id, partIndex, part, push);
} }
} });
} }
private structuredOutputPart( private structuredOutputPart(
@@ -170,7 +156,7 @@ export class V2Timeline {
else push({ type: "reasoning", text: suffix }); else push({ type: "reasoning", text: suffix });
} }
private async toolPart( private toolPart(
messageID: string, messageID: string,
partIndex: number, partIndex: number,
part: Extract< part: Extract<
@@ -188,16 +174,16 @@ export class V2Timeline {
if (item) push(item); if (item) push(item);
// Images follow their tool call as markdown, as Claude and Codex emit them, // Images follow their tool call as markdown, as Claude and Codex emit them,
// so base64 never reaches the tool output. // so base64 never reaches the tool output.
for (const [index, image] of images.entries()) { images.forEach((image, index) => {
const imageKey = `${key}:image:${index}`; const imageKey = `${key}:image:${index}`;
if (this.content.has(imageKey)) continue; if (this.content.has(imageKey)) return;
this.content.set(imageKey, ""); this.content.set(imageKey, "");
const imageItem = await renderProviderImageOutputAsAssistantMarkdown( const imageItem = renderProviderImageOutputAsAssistantMarkdown(
{ url: image.uri, mimeType: image.mime }, { url: image.uri, mimeType: image.mime },
{ materialize: materializeProviderImage }, { materialize: materializeProviderImage },
); );
if (imageItem) push(imageItem); if (imageItem) push(imageItem);
} });
} }
private compactionMessage( private compactionMessage(
@@ -3583,63 +3583,3 @@ test("pi runtime safety protects queued replies and runtime-owned activity", asy
native.getStateError = new Error("Unable to prove idle"); native.getStateError = new Error("Unable to prove idle");
await expect(session.canStopRuntime!()).rejects.toThrow("Unable to prove idle"); await expect(session.canStopRuntime!()).rejects.toThrow("Unable to prove idle");
}); });
test("reserves the Pi turn while an image prompt is materialized", async () => {
const { pi, session } = await createSession();
onTestFinished(() => session.close());
const first = session.startTurn([
{ type: "image", data: ONE_BY_ONE_PNG_BASE64, mimeType: "image/png" },
]);
await expect(session.startTurn("overlapping prompt")).rejects.toThrow("already active");
await first;
expect(pi.latestSession().prompts).toHaveLength(1);
});
test("interrupting Pi during image conversion cancels the admitted turn and permits a follow-up", async () => {
const { pi, session } = await createSession();
onTestFinished(() => session.close());
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
let admitted = false;
const first = session
.startTurn([
{
type: "image",
data: Buffer.alloc(16 * 1024 * 1024, 51).toString("base64"),
mimeType: "image/png",
},
])
.then((result) => {
admitted = true;
return result;
});
await waitForImmediate();
expect(admitted).toBe(false);
expect(pi.latestSession().prompts).toHaveLength(0);
await session.interrupt();
const followup = await session.startTurn("follow-up after image interrupt");
const original = await first;
expect(events).toContainEqual(
expect.objectContaining({ type: "turn_canceled", turnId: original.turnId }),
);
expect(followup.turnId).not.toBe(original.turnId);
expect(pi.latestSession().prompts).toEqual([
{ message: "follow-up after image interrupt", imageCount: 0 },
]);
});
test("closing Pi during image conversion rejects its pending start", async () => {
const { pi, session } = await createSession();
const first = session.startTurn([
{
type: "image",
data: Buffer.alloc(16 * 1024 * 1024, 52).toString("base64"),
mimeType: "image/png",
},
]);
const rejected = expect(first).rejects.toThrow("closed");
await waitForImmediate();
expect(pi.latestSession().prompts).toHaveLength(0);
await session.close();
await rejected;
});
@@ -367,9 +367,9 @@ function piModelSupportsImageInput(model: PiModel | null | undefined): boolean {
return model?.input?.includes("image") === true; return model?.input?.includes("image") === true;
} }
async function renderTextOnlyImageHint(image: { data: string; mimeType: string }): Promise<string> { function renderTextOnlyImageHint(image: { data: string; mimeType: string }): string {
try { try {
const materialized = await materializeProviderImage({ const materialized = materializeProviderImage({
data: image.data, data: image.data,
mimeType: image.mimeType, mimeType: image.mimeType,
}); });
@@ -379,10 +379,10 @@ async function renderTextOnlyImageHint(image: { data: string; mimeType: string }
} }
} }
async function convertPromptInput( function convertPromptInput(
prompt: AgentPromptInput, prompt: AgentPromptInput,
options: { model: PiModel | null | undefined }, options: { model: PiModel | null | undefined },
): Promise<PiPromptPayload> { ): PiPromptPayload {
if (typeof prompt === "string") { if (typeof prompt === "string") {
return { text: prompt }; return { text: prompt };
} }
@@ -405,7 +405,7 @@ async function convertPromptInput(
mimeType: block.mimeType, mimeType: block.mimeType,
}); });
} else { } else {
textParts.push(await renderTextOnlyImageHint(block)); textParts.push(renderTextOnlyImageHint(block));
} }
continue; continue;
} }
@@ -1280,6 +1280,7 @@ export class PiRpcAgentSession implements AgentSession {
throw new Error("A Pi turn is already active"); throw new Error("A Pi turn is already active");
} }
const payload = convertPromptInput(prompt, { model: this.state.model });
const turnId = randomUUID(); const turnId = randomUUID();
this.activeTurnId = turnId; this.activeTurnId = turnId;
this.usagePoller.startTurn(); this.usagePoller.startTurn();
@@ -1291,9 +1292,6 @@ export class PiRpcAgentSession implements AgentSession {
this.activePromptRequestId = null; this.activePromptRequestId = null;
this.pendingSteerSubmissions.length = 0; this.pendingSteerSubmissions.length = 0;
this.clearNoTurnBuffers(); this.clearNoTurnBuffers();
const payload = await convertPromptInput(prompt, { model: this.state.model });
if (this.closed) throw new Error("Pi session is closed");
if (this.activeTurnId !== turnId) return { turnId };
this.activeNoTurnPromptText = payload.text; this.activeNoTurnPromptText = payload.text;
const shouldProbeForNoTurnPrompt = this.parseSlashCommandInput(payload.text) !== null; const shouldProbeForNoTurnPrompt = this.parseSlashCommandInput(payload.text) !== null;
@@ -1365,9 +1363,7 @@ export class PiRpcAgentSession implements AgentSession {
if (this.closed || this.activeTurnId !== options.expectedTurnId) { if (this.closed || this.activeTurnId !== options.expectedTurnId) {
return { status: "unavailable" }; return { status: "unavailable" };
} }
const payload = await convertPromptInput(prompt, { model: this.state.model }); const payload = convertPromptInput(prompt, { model: this.state.model });
if (this.closed || this.activeTurnId !== options.expectedTurnId)
return { status: "unavailable" };
// Pi rejects steer RPCs that are extension commands, so slash inputs keep the // Pi rejects steer RPCs that are extension commands, so slash inputs keep the
// interrupt-and-replace fallback where they can run directly. // interrupt-and-replace fallback where they can run directly.
if (this.parseSlashCommandInput(payload.text)) { if (this.parseSlashCommandInput(payload.text)) {
@@ -5,8 +5,8 @@ import { describe, expect, test } from "vitest";
import { materializeProviderImage } from "./provider-image-output.js"; import { materializeProviderImage } from "./provider-image-output.js";
describe.skipIf(process.platform === "win32")("materializeProviderImage", () => { describe.skipIf(process.platform === "win32")("materializeProviderImage", () => {
test("writes image attachments under a private temp directory", async () => { test("writes image attachments under a private temp directory", () => {
const materialized = await materializeProviderImage({ const materialized = materializeProviderImage({
data: "YWJjMTIz", data: "YWJjMTIz",
mimeType: "image/png", mimeType: "image/png",
}); });
@@ -1,11 +1,10 @@
import { existsSync, rmSync, statSync, readFileSync } from "node:fs"; import { existsSync, rmSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import MarkdownIt from "markdown-it"; import MarkdownIt from "markdown-it";
import { projectTimelineRows } from "../timeline-projection.js"; import { projectTimelineRows } from "../timeline-projection.js";
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { import {
MAX_PROVIDER_IMAGE_BYTES,
isProviderImageMarkdown, isProviderImageMarkdown,
materializeProviderImage, materializeProviderImage,
renderProviderImageOutputAsAssistantMarkdown, renderProviderImageOutputAsAssistantMarkdown,
@@ -13,8 +12,8 @@ import {
const HASH = "a".repeat(64); const HASH = "a".repeat(64);
async function renderImageMarkdown(imagePath: string): Promise<string> { function renderImageMarkdown(imagePath: string): string {
const item = await renderProviderImageOutputAsAssistantMarkdown({ path: imagePath }); const item = renderProviderImageOutputAsAssistantMarkdown({ path: imagePath });
if (!item || item.type !== "assistant_message") { if (!item || item.type !== "assistant_message") {
throw new Error("Expected provider image output to render as assistant markdown."); throw new Error("Expected provider image output to render as assistant markdown.");
} }
@@ -41,8 +40,8 @@ describe("isProviderImageMarkdown", () => {
).toBe(true); ).toBe(true);
}); });
test("emits Windows file paths as file URIs", async () => { test("emits Windows file paths as file URIs", () => {
const markdown = await renderImageMarkdown( const markdown = renderImageMarkdown(
`C:\\Users\\me\\AppData\\Local\\Temp\\paseo-attachments\\${HASH}.png`, `C:\\Users\\me\\AppData\\Local\\Temp\\paseo-attachments\\${HASH}.png`,
); );
@@ -52,24 +51,22 @@ describe("isProviderImageMarkdown", () => {
expect(isProviderImageMarkdown(markdown)).toBe(true); expect(isProviderImageMarkdown(markdown)).toBe(true);
}); });
test("emits POSIX file paths with spaces as valid file URI markdown", async () => { test("emits POSIX file paths with spaces as valid file URI markdown", () => {
const markdown = await renderImageMarkdown( const markdown = renderImageMarkdown("/home/user/Projects/Project With Spaces/screenshot.png");
"/home/user/Projects/Project With Spaces/screenshot.png",
);
expect(markdown).toBe( expect(markdown).toBe(
"\n\n![Image](file:///home/user/Projects/Project%20With%20Spaces/screenshot.png)\n\n", "\n\n![Image](file:///home/user/Projects/Project%20With%20Spaces/screenshot.png)\n\n",
); );
}); });
test("encodes URI-significant characters in POSIX file paths", async () => { test("encodes URI-significant characters in POSIX file paths", () => {
const markdown = await renderImageMarkdown("/tmp/screenshot#1?draft.png"); const markdown = renderImageMarkdown("/tmp/screenshot#1?draft.png");
expect(markdown).toBe("\n\n![Image](file:///tmp/screenshot%231%3Fdraft.png)\n\n"); expect(markdown).toBe("\n\n![Image](file:///tmp/screenshot%231%3Fdraft.png)\n\n");
}); });
test("preserves double-leading slashes in POSIX file paths", async () => { test("preserves double-leading slashes in POSIX file paths", () => {
const markdown = await renderImageMarkdown("//tmp/screenshot#1.png"); const markdown = renderImageMarkdown("//tmp/screenshot#1.png");
expect(markdown).toBe("\n\n![Image](file:////tmp/screenshot%231.png)\n\n"); expect(markdown).toBe("\n\n![Image](file:////tmp/screenshot%231.png)\n\n");
}); });
@@ -81,8 +78,8 @@ describe("isProviderImageMarkdown", () => {
"\\\\?\\UNC\\server\\share\\shot?draft.png", "\\\\?\\UNC\\server\\share\\shot?draft.png",
"file://server/share/shot%3Fdraft.png", "file://server/share/shot%3Fdraft.png",
], ],
])("encodes %s image paths as file URIs", async (_label, imagePath, expectedSource) => { ])("encodes %s image paths as file URIs", (_label, imagePath, expectedSource) => {
expect(await renderImageMarkdown(imagePath)).toBe(`\n\n![Image](${expectedSource})\n\n`); expect(renderImageMarkdown(imagePath)).toBe(`\n\n![Image](${expectedSource})\n\n`);
}); });
test("rejects user-authored markdown that is not a materialized attachment", () => { test("rejects user-authored markdown that is not a materialized attachment", () => {
@@ -95,8 +92,8 @@ describe("isProviderImageMarkdown", () => {
}); });
describe("materializeProviderImage", () => { describe("materializeProviderImage", () => {
test("recreates the private temp directory if the cached directory is removed", async () => { test("recreates the private temp directory if the cached directory is removed", () => {
const first = await materializeProviderImage({ const first = materializeProviderImage({
data: "YWJjMTIz", data: "YWJjMTIz",
mimeType: "image/png", mimeType: "image/png",
}); });
@@ -105,7 +102,7 @@ describe("materializeProviderImage", () => {
rmSync(firstDir, { recursive: true, force: true }); rmSync(firstDir, { recursive: true, force: true });
const second = await materializeProviderImage({ const second = materializeProviderImage({
data: "ZGVmNDU2", data: "ZGVmNDU2",
mimeType: "image/png", mimeType: "image/png",
}); });
@@ -120,8 +117,8 @@ describe("materializeProviderImage", () => {
}); });
describe("provider image paragraph boundaries", () => { describe("provider image paragraph boundaries", () => {
test.each(["", "\n"])("keeps image outputs separate from prose ending in %j", async (ending) => { test.each(["", "\n"])("keeps image outputs separate from prose ending in %j", (ending) => {
const image = await renderProviderImageOutputAsAssistantMarkdown({ const image = renderProviderImageOutputAsAssistantMarkdown({
url: "https://example.com/chart.png", url: "https://example.com/chart.png",
}); });
if (!image) throw new Error("Expected image output"); if (!image) throw new Error("Expected image output");
@@ -155,10 +152,10 @@ describe("provider image paragraph boundaries", () => {
}); });
}); });
test("materialized image output retains paragraph boundaries and attachment recognition", async () => { test("materialized image output retains paragraph boundaries and attachment recognition", () => {
const item = await renderProviderImageOutputAsAssistantMarkdown( const item = renderProviderImageOutputAsAssistantMarkdown(
{ data: "YWJjMTIz", mimeType: "image/png" }, { data: "YWJjMTIz", mimeType: "image/png" },
{ materialize: async () => ({ path: `/tmp/paseo-attachments/${HASH}.png` }) }, { materialize: () => ({ path: `/tmp/paseo-attachments/${HASH}.png` }) },
); );
expect(item).toEqual({ expect(item).toEqual({
type: "assistant_message", type: "assistant_message",
@@ -167,76 +164,3 @@ test("materialized image output retains paragraph boundaries and attachment reco
if (!item || item.type !== "assistant_message") throw new Error("Expected image output"); if (!item || item.type !== "assistant_message") throw new Error("Expected image output");
expect(isProviderImageMarkdown(item.text)).toBe(true); expect(isProviderImageMarkdown(item.text)).toBe(true);
}); });
test("repeated content is reused without rewriting the attachment", async () => {
const image = {
data: Buffer.from("dedupe regression").toString("base64"),
mimeType: "image/png",
};
const first = await materializeProviderImage(image);
const before = statSync(first.path, { bigint: true });
await new Promise((resolve) => setTimeout(resolve, 20));
const second = await materializeProviderImage(image);
expect(second.path).toBe(first.path);
expect(statSync(second.path, { bigint: true }).mtimeNs).toBe(before.mtimeNs);
});
test("64 MiB materialization leaves the event loop responsive and preserves bytes", async () => {
const bytes = Buffer.alloc(64 * 1024 * 1024, 37);
const data = bytes.toString("base64");
let previous = performance.now();
let longest = 0;
const probe = setInterval(() => {
const now = performance.now();
longest = Math.max(longest, now - previous);
previous = now;
}, 1);
try {
const result = await materializeProviderImage({ data, mimeType: "image/png" });
await new Promise((resolve) => setTimeout(resolve, 5));
console.log(`64 MiB attachment longest loop gap: ${longest.toFixed(2)} ms`);
expect(longest).toBeLessThan(50);
expect(readFileSync(result.path).equals(bytes)).toBe(true);
rmSync(result.path, { force: true });
} finally {
clearInterval(probe);
}
});
test("preserves base64 decoding across whitespace and chunk boundaries", async () => {
const bytes = Buffer.alloc(512 * 1024, 123);
const base64 = bytes.toString("base64");
const data = base64.match(/.{1,77}/g)!.join("\n");
const result = await materializeProviderImage({ data, mimeType: "image/png" });
expect(readFileSync(result.path).equals(bytes)).toBe(true);
});
test("rejects oversized materialization before work and leaves the queue usable", async () => {
const data = "A".repeat(Math.ceil(MAX_PROVIDER_IMAGE_BYTES / 3) * 4 + 260);
await expect(materializeProviderImage({ data, mimeType: "image/png" })).rejects.toThrow(
"exceeds",
);
const omitted = await renderProviderImageOutputAsAssistantMarkdown(
{ data, mimeType: "image/png" },
{ materialize: materializeProviderImage },
);
expect(omitted).toEqual({
type: "assistant_message",
text: "Image output was omitted because it was not available as a file path or URL.",
});
const image = await materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png" });
expect(readFileSync(image.path).toString()).toBe("abc123");
});
test("simultaneous requests for the same content share the published file", async () => {
const image = {
data: Buffer.from("concurrent dedupe fixture").toString("base64"),
mimeType: "image/png",
};
const results = await Promise.all([
materializeProviderImage(image),
materializeProviderImage(image),
]);
expect(results[0]).toEqual(results[1]);
expect(readFileSync(results[0].path).toString()).toBe("concurrent dedupe fixture");
});
@@ -1,6 +1,5 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import * as fs from "node:fs/promises"; import * as fsSync from "node:fs";
import { setImmediate } from "node:timers/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
@@ -24,26 +23,32 @@ const PRIVATE_ATTACHMENT_DIR_MODE = 0o700;
const MATERIALIZED_IMAGE_FILE_MODE = 0o600; const MATERIALIZED_IMAGE_FILE_MODE = 0o600;
let materializedImageAttachmentDir: string | null = null; let materializedImageAttachmentDir: string | null = null;
// Serialize materializations: one decoder and one file operation at a time, including
// concurrent history replay and live turns. A rejected operation must not poison the queue.
let pendingMaterialization: Promise<unknown> = Promise.resolve();
async function getMaterializedImageAttachmentDir(): Promise<string> { function canReuseMaterializedImageAttachmentDir(dir: string): boolean {
if (materializedImageAttachmentDir) { try {
try { const stats = fsSync.lstatSync(dir);
const stats = await fs.lstat(materializedImageAttachmentDir); if (!stats.isDirectory()) {
if (stats.isDirectory()) { return false;
await fs.chmod(materializedImageAttachmentDir, PRIVATE_ATTACHMENT_DIR_MODE);
return materializedImageAttachmentDir;
}
} catch {
/* Temp storage may have been removed between turns. */
} }
fsSync.chmodSync(dir, PRIVATE_ATTACHMENT_DIR_MODE);
return true;
} catch {
return false;
} }
materializedImageAttachmentDir = await fs.mkdtemp( }
function getMaterializedImageAttachmentDir(): string {
if (
materializedImageAttachmentDir &&
canReuseMaterializedImageAttachmentDir(materializedImageAttachmentDir)
) {
return materializedImageAttachmentDir;
}
materializedImageAttachmentDir = fsSync.mkdtempSync(
path.join(os.tmpdir(), PROVIDER_IMAGE_ATTACHMENT_DIR_PREFIX), path.join(os.tmpdir(), PROVIDER_IMAGE_ATTACHMENT_DIR_PREFIX),
); );
await fs.chmod(materializedImageAttachmentDir, PRIVATE_ATTACHMENT_DIR_MODE); fsSync.chmodSync(materializedImageAttachmentDir, PRIVATE_ATTACHMENT_DIR_MODE);
return materializedImageAttachmentDir; return materializedImageAttachmentDir;
} }
@@ -66,92 +71,32 @@ function getImageExtension(mimeType: string): string {
} }
} }
// 128 MiB admits the measured 64 MiB fixture with headroom while bounding disk function normalizeImageData(mimeType: string, data: string): { mimeType: string; data: string } {
// and decoding per image. This is materialization admission, not a wire schema limit. if (data.startsWith("data:")) {
export const MAX_PROVIDER_IMAGE_BYTES = 128 * 1024 * 1024; const match = data.match(/^data:([^;]+);base64,(.*)$/);
const BASE64_CHUNK_CHARACTERS = 256 * 1024; // Multiple of four; decode units stay aligned. if (match) {
return { mimeType: match[1], data: match[2] };
}
}
return { mimeType, data };
}
// Filenames are a content hash of the bytes so re-materializing the same image
// within a process reuses the existing temp file instead of leaking a fresh one
// for repeated image blocks or history replay.
export function materializeProviderImage(image: { export function materializeProviderImage(image: {
data: string; data: string;
mimeType: string | null; mimeType: string | null;
}): Promise<MaterializedProviderImage> { }): MaterializedProviderImage {
const result = pendingMaterialization.then(() => materializeImage(image)); const attachmentsDir = getMaterializedImageAttachmentDir();
pendingMaterialization = result.catch(() => {}); const normalized = normalizeImageData(image.mimeType ?? "image/png", image.data);
return result; const bytes = Buffer.from(normalized.data, "base64");
} const extension = getImageExtension(normalized.mimeType);
const hash = createHash("sha256").update(bytes).digest("hex");
async function materializeImage(image: { const filePath = path.join(attachmentsDir, `${hash}.${extension}`);
data: string; fsSync.writeFileSync(filePath, bytes, { mode: MATERIALIZED_IMAGE_FILE_MODE });
mimeType: string | null; fsSync.chmodSync(filePath, MATERIALIZED_IMAGE_FILE_MODE);
}): Promise<MaterializedProviderImage> { return { path: filePath };
const maxEncodedCharacters = Math.ceil(MAX_PROVIDER_IMAGE_BYTES / 3) * 4;
// Check length before inspecting/copying the payload, including a bounded data URI header.
if (image.data.length > maxEncodedCharacters + 256) {
throw new Error(`Image attachment exceeds ${MAX_PROVIDER_IMAGE_BYTES} bytes`);
}
let data = image.data;
let mimeType = image.mimeType ?? "image/png";
if (data.startsWith("data:")) {
const separator = data.indexOf(";base64,");
if (separator >= 0 && separator < 256) {
mimeType = data.slice(5, separator);
data = data.slice(separator + 8);
}
}
if (data.length > maxEncodedCharacters) {
throw new Error(`Image attachment exceeds ${MAX_PROVIDER_IMAGE_BYTES} bytes`);
}
const hash = createHash("sha256");
for await (const bytes of decodeImage(data)) hash.update(bytes);
const dir = await getMaterializedImageAttachmentDir();
const filePath = path.join(dir, `${hash.digest("hex")}.${getImageExtension(mimeType)}`);
try {
await fs.stat(filePath);
return { path: filePath };
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
const staging = await fs.mkdtemp(path.join(dir, ".pending-"));
const stagingPath = path.join(staging, "image");
try {
await fs.chmod(staging, PRIVATE_ATTACHMENT_DIR_MODE);
const file = await fs.open(stagingPath, "wx", MATERIALIZED_IMAGE_FILE_MODE);
try {
await file.chmod(MATERIALIZED_IMAGE_FILE_MODE);
// Decode again only for a new hash. This bounds retained memory to one chunk
// and performs no writes at all for repeated content, including history replay.
for await (const bytes of decodeImage(data)) await file.writeFile(bytes);
} finally {
await file.close();
}
await fs.rename(stagingPath, filePath);
return { path: filePath };
} finally {
await fs.rm(staging, { recursive: true, force: true });
}
}
async function* decodeImage(data: string): AsyncGenerator<Buffer> {
let carry = "";
let decodedBytes = 0;
for (let offset = 0; offset < data.length; offset += BASE64_CHUNK_CHARACTERS) {
// Buffer.from ignores non-base64 characters. Carry incomplete quartets so
// line-wrapped payloads decode identically across chunk boundaries.
const chunk =
carry +
data.slice(offset, offset + BASE64_CHUNK_CHARACTERS).replace(/[^A-Za-z0-9+/_=-]/g, "");
const finalChunk = offset + BASE64_CHUNK_CHARACTERS >= data.length || chunk.includes("=");
const end = finalChunk ? chunk.length : chunk.length - (chunk.length % 4);
const bytes = Buffer.from(chunk.slice(0, end), "base64");
carry = chunk.slice(end);
decodedBytes += bytes.length;
if (decodedBytes > MAX_PROVIDER_IMAGE_BYTES)
throw new Error("Image attachment exceeds size limit");
yield bytes;
await setImmediate();
if (finalChunk) break;
}
} }
// Recognizes markdown rendered for a materialized provider image: its source is a content-hashed // Recognizes markdown rendered for a materialized provider image: its source is a content-hashed
@@ -167,10 +112,7 @@ export function isProviderImageMarkdown(text: string): boolean {
} }
interface RenderProviderImageOutputOptions { interface RenderProviderImageOutputOptions {
materialize?: (image: { materialize?: (image: { data: string; mimeType: string | null }) => MaterializedProviderImage;
data: string;
mimeType: string | null;
}) => Promise<MaterializedProviderImage>;
} }
function nonEmptyString(value: string | null | undefined): string | null { function nonEmptyString(value: string | null | undefined): string | null {
@@ -227,10 +169,10 @@ function escapeMarkdownImageSource(value: string): string {
return markdownImageSource(value).replace(/\\/g, "\\\\").replace(/\)/g, "\\)"); return markdownImageSource(value).replace(/\\/g, "\\\\").replace(/\)/g, "\\)");
} }
export async function renderProviderImageOutputAsAssistantMarkdown( export function renderProviderImageOutputAsAssistantMarkdown(
image: ProviderImageOutput, image: ProviderImageOutput,
options: RenderProviderImageOutputOptions = {}, options: RenderProviderImageOutputOptions = {},
): Promise<AgentTimelineItem | null> { ): AgentTimelineItem | null {
const source = nonEmptyString(image.path) ?? nonEmptyString(image.url); const source = nonEmptyString(image.path) ?? nonEmptyString(image.url);
if (source && !isDataImageSource(source)) { if (source && !isDataImageSource(source)) {
const altText = escapeMarkdownImageAlt(nonEmptyString(image.altText) ?? "Image"); const altText = escapeMarkdownImageAlt(nonEmptyString(image.altText) ?? "Image");
@@ -249,7 +191,7 @@ export async function renderProviderImageOutputAsAssistantMarkdown(
let materialized: MaterializedProviderImage | null = null; let materialized: MaterializedProviderImage | null = null;
try { try {
materialized = options.materialize materialized = options.materialize
? await options.materialize({ ? options.materialize({
data, data,
mimeType: nonEmptyString(image.mimeType), mimeType: nonEmptyString(image.mimeType),
}) })
@@ -0,0 +1,431 @@
import { stat } from "node:fs/promises";
import { readPluginManifest } from "./manifest.js";
import { assertPluginCompatibility } from "@getpaseo/protocol/plugin-requirements";
import { existsSync, readFileSync, realpathSync } from "node:fs";
import { isBuiltin } from "node:module";
import path from "node:path";
import { createPluginImportReader, type PluginImportKind } from "./compiler-imports.js";
import type { Metafile, OnResolveResult, Plugin } from "esbuild";
import {
isPluginClientOnlySdkSpecifier,
isPluginServerOnlySdkSpecifier,
PLUGIN_SDK_SPECIFIERS,
} from "./plugin-sdk-specifiers.js";
import { loadEsbuild, SERVER_HOST_MODULES } from "./compiler-esbuild.js";
type PluginBuildTarget = "client" | "server";
type PluginModuleLocation = PluginBuildTarget | "shared" | "invalid";
function directoryTarget(filePath: string, pluginDirectory: string): PluginModuleLocation | null {
const relative = path.relative(pluginDirectory, filePath);
if (relative.startsWith("..") || path.isAbsolute(relative)) return "invalid";
const segments = relative.split(path.sep);
if (segments.includes("node_modules")) return null;
if (segments[0] === "client") return "client";
if (segments[0] === "server") return "server";
if (segments[0] === "shared") return "shared";
if (/^index\.client\.tsx?$/.test(relative)) return "client";
if (/^index\.server\.tsx?$/.test(relative)) return "server";
return "invalid";
}
function containsPath(directory: string, filePath: string): boolean {
const relative = path.relative(directory, filePath);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function dependencyName(specifier: string): string {
const segments = specifier.split("/");
return specifier.startsWith("@") ? segments.slice(0, 2).join("/") : segments[0];
}
function findDependencyRoot(
resolvedPath: string,
specifier: string,
pluginDirectory: string,
): string | null {
const expectedName = dependencyName(specifier);
const ambientName = `@types/${expectedName.replace(/^@/, "").replace("/", "__")}`;
let directory = path.dirname(resolvedPath);
for (;;) {
const manifestPath = path.join(directory, "package.json");
if (existsSync(manifestPath)) {
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { name?: unknown };
if (manifest.name === expectedName || manifest.name === ambientName) {
if (containsPath(pluginDirectory, directory) || containsPath(directory, pluginDirectory)) {
return null;
}
return directory;
}
}
const parent = path.dirname(directory);
if (parent === directory) return null;
directory = parent;
}
}
function moduleBoundaryError(
moduleLocation: PluginModuleLocation | null,
target: PluginBuildTarget | "shared",
filePath: string,
): OnResolveResult | null {
if (moduleLocation === "invalid") {
return {
errors: [{ text: `Plugin modules belong in client/, server/, or shared/: ${filePath}` }],
};
}
if (moduleLocation === null || moduleLocation === "shared" || moduleLocation === target) {
return null;
}
return {
errors: [
{
text: `${moduleLocation}-only module cannot be imported into the plugin ${target} bundle: ${filePath}`,
},
],
};
}
function lexicalBoundaryError(
specifier: string,
resolveDirectory: string,
pluginDirectory: string,
target: PluginBuildTarget | "shared",
): OnResolveResult | null {
if (!specifier.startsWith(".") && !path.isAbsolute(specifier)) return null;
const lexicalPath = path.resolve(resolveDirectory, specifier);
if (containsPath(pluginDirectory, lexicalPath)) {
return moduleBoundaryError(directoryTarget(lexicalPath, pluginDirectory), target, lexicalPath);
}
// Normalize only a containing root alias. Resolving the whole import would erase
// an authored server/ or client/ location when the final file is a symlink.
for (let ancestor = path.dirname(lexicalPath); ; ancestor = path.dirname(ancestor)) {
if (existsSync(ancestor) && realpathSync.native(ancestor) === pluginDirectory) {
const ownedPath = path.join(pluginDirectory, path.relative(ancestor, lexicalPath));
return moduleBoundaryError(directoryTarget(ownedPath, pluginDirectory), target, lexicalPath);
}
if (path.dirname(ancestor) === ancestor) return null;
}
}
function createRuntimeBoundaryPlugin(target: PluginBuildTarget, pluginDirectory: string): Plugin {
const boundaryResolution = {};
const linkedDependencyRoots = new Set<string>();
return {
name: `paseo-plugin-${target}-runtime-boundary`,
setup(buildContext) {
const checked = new Set<string>();
const imports = createPluginImportReader(pluginDirectory);
function resolvedBoundaryError(
resolvedFile: string,
specifier: string,
importer: string,
owner: PluginBuildTarget | "shared",
) {
const file = realpathSync.native(resolvedFile);
const location = directoryTarget(file, pluginDirectory);
if (location === "invalid") {
if (
[...linkedDependencyRoots].some(
(root) => containsPath(root, importer) && containsPath(root, file),
)
)
return null;
if (!specifier.startsWith(".") && !path.isAbsolute(specifier)) {
const root = findDependencyRoot(file, specifier, pluginDirectory);
if (root) {
linkedDependencyRoots.add(root);
return null;
}
}
}
return moduleBoundaryError(location, owner, file);
}
async function resolveImportFiles(
file: string,
specifier: string,
kind: PluginImportKind,
typeOnly: boolean,
): Promise<Set<string>> {
const declaration = imports.resolve(specifier, file, kind);
const dependencyFiles = new Set<string>();
if (declaration && (typeOnly || /\.d\.[cm]?ts$/.test(declaration)))
dependencyFiles.add(declaration);
if (typeOnly && !declaration) {
throw new Error(`Could not resolve type dependency "${specifier}" imported by ${file}`);
}
if (!typeOnly && kind !== "type-reference") {
const resolution = await buildContext.resolve(specifier, {
importer: file,
resolveDir: path.dirname(file),
kind,
});
// esbuild owns missing-runtime errors: erased imports and guarded optional
// requires are legal. Inspect every dependency that actually resolves.
if (!resolution.errors.length && !resolution.external && resolution.namespace === "file")
dependencyFiles.add(realpathSync.native(resolution.path));
}
return dependencyFiles;
}
async function checkSourceImports(
sourcePath: string,
inheritedOwner: PluginBuildTarget | "shared",
): Promise<OnResolveResult | null> {
const file = realpathSync.native(sourcePath);
const owner =
directoryTarget(file, pluginDirectory) === "shared" ? "shared" : inheritedOwner;
const key = `${owner}:${file}`;
if (checked.has(key) || !/\.[cm]?[jt]sx?$/.test(file)) return null;
checked.add(key);
for (const { specifier, kind, typeOnly } of imports.read(file)) {
const packageSpecifier =
kind === "type-reference"
? `@types/${specifier.replace(/^@/, "").replace("/", "__")}`
: specifier;
const error =
runtimeSpecifierError(specifier, owner, file) ??
runtimeSpecifierError(packageSpecifier, owner, file) ??
lexicalBoundaryError(specifier, path.dirname(file), pluginDirectory, owner);
if (error) return error;
// Protocol types are shared host contracts, also exposed by the SDK's peer
// dependency during development. Erased imports need no plugin-local install;
// runtime imports still go through normal dependency resolution below.
if (typeOnly && specifier.startsWith("@getpaseo/protocol/")) continue;
// Host modules have separately enforced SDK boundaries and need no local installation.
if (
(PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier) ||
/^(zod|react|react-native|@tanstack\/react-query)(\/|$)/.test(specifier) ||
isBuiltin(specifier) ||
packageSpecifier === "@types/node"
)
continue;
const dependencyFiles = await resolveImportFiles(file, specifier, kind, typeOnly);
for (const dependencyFile of dependencyFiles) {
const boundaryError = resolvedBoundaryError(dependencyFile, specifier, file, owner);
if (boundaryError) return boundaryError;
const dependencyError = await checkSourceImports(dependencyFile, owner);
if (dependencyError) return dependencyError;
}
}
return null;
}
buildContext.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, (args) =>
checkSourceImports(args.path, target),
);
buildContext.onResolve({ filter: /.*/ }, async (args) => {
if (args.kind === "entry-point") return null;
if (args.pluginData === boundaryResolution) return null;
const importer =
args.namespace === "file" ? realpathSync.native(args.importer) : args.importer;
const resolveDir = args.namespace === "file" ? path.dirname(importer) : args.resolveDir;
const owner = directoryTarget(importer, pluginDirectory) === "shared" ? "shared" : target;
const specifierError = runtimeSpecifierError(args.path, owner, importer);
if (specifierError) return specifierError;
const lexicalError = lexicalBoundaryError(args.path, resolveDir, pluginDirectory, owner);
if (lexicalError) return lexicalError;
const resolution = await buildContext.resolve(args.path, {
importer,
namespace: args.namespace,
resolveDir,
kind: args.kind,
pluginData: boundaryResolution,
with: args.with,
});
if (
resolution.errors.length > 0 ||
resolution.external ||
resolution.namespace !== "file"
) {
return null;
}
return resolvedBoundaryError(resolution.path, args.path, importer, owner);
});
},
};
}
function wrapCommonJsBundle(code: string): string {
return `(function(require) {\nconst module = { exports: {} };\nconst exports = module.exports;\n${code}\nreturn module.exports;\n})`;
}
function makeHermesInteropEager(code: string): string {
// Hermes evaluates esbuild's lazy CommonJS interop getters from a string with
// the final loop binding, so every named import can resolve to the last export.
// Plugin bundles execute once and do not need live bindings from host modules.
return code.replaceAll("get: () => from[key]", "value: from[key]");
}
function runtimeSpecifierError(
specifier: string,
target: PluginBuildTarget | "shared",
importer: string,
): OnResolveResult | null {
let kind: string | null = null;
if (specifier === "@getpaseo/plugin/client/host") kind = "host-private";
else if (
(specifier === "@getpaseo/plugin" ||
specifier.startsWith("@getpaseo/plugin/") ||
specifier === "@paseo/plugin" ||
specifier.startsWith("@paseo/plugin/")) &&
!(PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier)
)
kind = "Unknown SDK";
else if (target !== "server" && (isBuiltin(specifier) || specifier === "@types/node"))
kind = "Node";
else if (target !== "server" && isPluginServerOnlySdkSpecifier(specifier)) kind = "server-only";
else if (
target !== "client" &&
(isPluginClientOnlySdkSpecifier(specifier) ||
/^((?:@types\/)?react(?:-dom|-native)?|use-sync-external-store|@tanstack\/react-query)(\/|$)/.test(
specifier,
))
)
kind = "client-only";
return kind
? {
errors: [
{
text: `${kind} module cannot be imported into the plugin ${target} bundle: ${specifier} imported by ${importer}`,
},
],
}
: null;
}
function checkSharedDependencies(inputs: Metafile["inputs"], pluginDirectory: string): void {
function inputLocation(file: string): PluginModuleLocation | null {
const absolutePath = path.resolve(file);
// Metafile keys must stay unchanged for graph traversal. Only filesystem
// inputs have canonical paths; data URLs and external specifiers do not.
return directoryTarget(
existsSync(absolutePath) ? realpathSync.native(absolutePath) : absolutePath,
pluginDirectory,
);
}
const pending = Object.keys(inputs).filter((file) => inputLocation(file) === "shared");
const visited = new Set<string>();
while (pending.length) {
const file = pending.pop()!;
if (visited.has(file)) continue;
visited.add(file);
for (const dependency of inputs[file]?.imports ?? []) {
const location = dependency.external ? null : inputLocation(dependency.path);
const error =
runtimeSpecifierError(dependency.original ?? dependency.path, "shared", file) ??
(dependency.external
? null
: moduleBoundaryError(
location === "invalid" ? null : location,
"shared",
dependency.path,
));
if (error?.errors?.length) throw new Error(error.errors[0].text);
if (!dependency.external) pending.push(dependency.path);
}
}
}
async function compileTarget(entryPath: string, target: PluginBuildTarget): Promise<string> {
const { build } = loadEsbuild();
// Use native canonical paths throughout: TypeScript expands Windows short names
// when resolving type references, while the JS realpath implementation retains them.
const pluginDirectory = realpathSync.native(path.dirname(entryPath));
const result = await build({
entryPoints: [realpathSync.native(entryPath)],
bundle: true,
format: "cjs",
jsx: "automatic",
// `import icon from "./icon.svg"` yields the markup for an inline `{ svg }` icon.
loader: { ".svg": "text" },
platform: target === "server" ? "node" : "neutral",
target: target === "server" ? "node20" : "es2020",
// The neutral platform reads no package.json entry fields, so packages without
// `exports` would not resolve. Read the same fields as esbuild's browser platform
// minus `browser`, since the bundle also runs in React Native.
mainFields: target === "client" ? ["module", "main"] : undefined,
// Metro lowers async syntax before Hermes sees app code. Plugin client bundles bypass Metro,
// so apply the same compatibility transform before the app evaluates them from source.
supported: target === "client" ? { "async-await": false } : undefined,
external:
target === "client"
? [
...PLUGIN_SDK_SPECIFIERS,
"@tanstack/react-query",
"react",
"react/jsx-runtime",
"react-native",
"zod",
]
: SERVER_HOST_MODULES,
plugins: [createRuntimeBoundaryPlugin(target, pluginDirectory)],
metafile: true,
logLevel: "silent",
treeShaking: true,
write: false,
});
checkSharedDependencies(result.metafile.inputs, pluginDirectory);
const output = result.outputFiles[0]?.text;
if (!output) throw new Error(`Plugin ${target} compilation produced no output`);
return wrapCommonJsBundle(makeHermesInteropEager(output));
}
export async function compilePlugin(entryPaths: {
client: string | null;
server: string | null;
}): Promise<{
clientBundle: string | null;
serverBundle: string | null;
}> {
const [clientBundle, serverBundle] = await Promise.all([
entryPaths.client ? compileTarget(entryPaths.client, "client") : null,
entryPaths.server ? compileTarget(entryPaths.server, "server") : null,
]);
return { clientBundle, serverBundle };
}
const CLIENT_ENTRY_FILENAMES = ["index.client.ts", "index.client.tsx"] as const;
const SERVER_ENTRY_FILENAMES = ["index.server.ts", "index.server.tsx"] as const;
async function findEntry(directory: string, filenames: readonly string[]): Promise<string | null> {
for (const filename of filenames) {
const filePath = path.join(directory, filename);
const info = await stat(filePath).catch(() => null);
if (info?.isFile()) return filePath;
}
return null;
}
async function resolveEntryPaths(directory: string): Promise<{
client: string | null;
server: string | null;
}> {
const [client, server] = await Promise.all([
findEntry(directory, CLIENT_ENTRY_FILENAMES),
findEntry(directory, SERVER_ENTRY_FILENAMES),
]);
if (client || server) return { client, server };
const legacyEntry = await findEntry(directory, ["index.ts", "index.tsx"]);
if (legacyEntry) {
throw new Error(
"This plugin was made for an older version of Paseo and cannot run on Paseo v0.8. Ask its author to update it. Plugin authors can follow the migration guide: https://paseo.sh/docs/plugins/migration",
);
}
throw new Error(
`Plugin entry points are missing: expected ${CLIENT_ENTRY_FILENAMES.join(" or ")} and/or ${SERVER_ENTRY_FILENAMES.join(" or ")} in ${directory}`,
);
}
export async function compileDirectoryPlugin(
directory: string,
version: string,
builtinId?: string,
) {
const manifest = await readPluginManifest(directory);
if (builtinId !== undefined && manifest.id !== builtinId) {
throw new Error(`Built-in plugin ${builtinId} has manifest ID ${manifest.id}`);
}
assertPluginCompatibility({ ...manifest, version, runtime: "daemon" });
const bundles = await compilePlugin(await resolveEntryPaths(directory));
return { ...bundles, manifest };
}
@@ -0,0 +1,64 @@
import { existsSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { PLUGIN_SDK_SPECIFIERS } from "./plugin-sdk-specifiers.js";
const nodeRequire = createRequire(import.meta.url);
export const SERVER_HOST_MODULES = [...PLUGIN_SDK_SPECIFIERS, "zod"];
const ESBUILD_BINARY_PATH = "ESBUILD_BINARY_PATH";
// esbuild resolves its own platform binary via require.resolve() the first time its
// module is evaluated. Inside the packaged desktop app that resolves to a path under
// app.asar even though electron-builder unpacks the real binary to app.asar.unpacked.
// child_process.spawn bypasses Electron's asar fs shim, so the OS rejects that path
// with ENOTDIR. Point esbuild at the real unpacked binary before its module loads.
export function unpackedEsbuildBinaryFromPackageDir(
esbuildPackageDir: string,
platform: NodeJS.Platform,
arch: string,
): string | null {
const asarSegment = `${path.sep}app.asar${path.sep}`;
const asarIndex = esbuildPackageDir.indexOf(asarSegment);
if (asarIndex === -1) return null;
return path.join(
esbuildPackageDir.slice(0, asarIndex),
"app.asar.unpacked",
"node_modules",
`@esbuild/${platform}-${arch}`,
...(platform === "win32" ? ["esbuild.exe"] : ["bin", "esbuild"]),
);
}
export function resolveExistingAsarUnpackedEsbuildBinary(
esbuildPackageDir: string,
platform: NodeJS.Platform = process.platform,
arch: string = process.arch,
exists: (file: string) => boolean = existsSync,
): string | null {
const binaryPath = unpackedEsbuildBinaryFromPackageDir(esbuildPackageDir, platform, arch);
return binaryPath && exists(binaryPath) ? binaryPath : null;
}
function resolveAsarUnpackedEsbuildBinary(): string | null {
let esbuildDir: string;
try {
esbuildDir = path.dirname(nodeRequire.resolve("esbuild/package.json"));
} catch {
return null;
}
return resolveExistingAsarUnpackedEsbuildBinary(esbuildDir);
}
export function loadEsbuild(): typeof import("esbuild") {
const previousBinaryPath = process.env[ESBUILD_BINARY_PATH];
const unpackedBinary = resolveAsarUnpackedEsbuildBinary();
if (unpackedBinary) process.env[ESBUILD_BINARY_PATH] = unpackedBinary;
try {
// esbuild reads this variable while its CommonJS module is evaluated. Keep
// the compatibility bridge local so it cannot become an agent's environment.
return nodeRequire("esbuild") as typeof import("esbuild");
} finally {
if (previousBinaryPath === undefined) delete process.env[ESBUILD_BINARY_PATH];
else process.env[ESBUILD_BINARY_PATH] = previousBinaryPath;
}
}
@@ -0,0 +1,62 @@
import { parentPort } from "node:worker_threads";
import type { ZodIssue } from "zod";
import { compilePlugin, compileDirectoryPlugin } from "./compiler-build.js";
import { runPluginBuild } from "./preparation-build.js";
import type { CompilerTask } from "./compiler.js";
export type CompilerReply =
| { kind: "log"; data: Record<string, unknown>; message: string }
| { kind: "result"; value: unknown }
| {
kind: "error";
error: Error;
properties: Record<string, unknown>;
issues?: ZodIssue[];
errors?: unknown[];
warnings?: unknown[];
};
const port = parentPort!;
// The compiler owner serializes requests; the port queues the first request
// until this entrypoint has loaded, including in source/tsx development.
port.on("message", (task: CompilerTask) => {
void run(task);
});
async function run(task: CompilerTask): Promise<void> {
try {
let value: unknown;
switch (task.kind) {
case "entries":
value = await compilePlugin(task.entries);
break;
case "directory":
value = await compileDirectoryPlugin(task.directory, task.version, task.builtinId);
break;
case "prepare":
await runPluginBuild(
task.directory,
task.commands,
{
info: (data, message) =>
port.postMessage({ kind: "log", data, message } satisfies CompilerReply),
},
task.environment,
);
break;
}
port.postMessage({ kind: "result", value } satisfies CompilerReply);
} catch (cause) {
const error = cause instanceof Error ? cause : new Error(String(cause));
port.postMessage({
kind: "error",
error,
// Error cloning drops own metadata such as filesystem code/path. Zod's
// readonly issues are reconstructed separately by the compiler owner.
properties: Object.fromEntries(Object.entries(error).filter(([key]) => key !== "issues")),
...("issues" in error ? { issues: error.issues as ZodIssue[] } : {}),
...("errors" in error ? { errors: error.errors as unknown[] } : {}),
...("warnings" in error ? { warnings: error.warnings as unknown[] } : {}),
} satisfies CompilerReply);
}
}
@@ -3,8 +3,10 @@ import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { ZodError } from "zod";
import { import {
compilePlugin, compilePlugin,
compileDirectoryPlugin,
resolveExistingAsarUnpackedEsbuildBinary, resolveExistingAsarUnpackedEsbuildBinary,
unpackedEsbuildBinaryFromPackageDir, unpackedEsbuildBinaryFromPackageDir,
} from "./compiler.js"; } from "./compiler.js";
@@ -975,3 +977,52 @@ export default function contribute() { void value; void secret; return () => und
expect(serverBundle).toBeNull(); expect(serverBundle).toBeNull();
}); });
}); });
it("preserves esbuild diagnostics across the compiler boundary", async () => {
const { server } = await createSplitPlugin();
await writeFile(server, "export default function contribute( {");
const error = await compilePlugin({ client: null, server }).catch((cause: unknown) => cause);
expect(error).toBeInstanceOf(Error);
expect(error).toMatchObject({
name: "Error",
message: expect.stringContaining("Expected identifier but found end of file"),
errors: [
expect.objectContaining({
text: "Expected identifier but found end of file",
location: expect.objectContaining({
line: 1,
lineText: "export default function contribute( {",
}),
}),
],
warnings: [],
});
// A failed task does not poison the next build.
await writeFile(server, 'export default () => "recovered";');
const bundles = await compilePlugin({ client: null, server });
expect(bundles.serverBundle).toContain("recovered");
});
it("preserves manifest validation errors across the compiler boundary", async () => {
const { directory } = await createSplitPlugin();
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: 42 }));
const error = await compileDirectoryPlugin(directory, "0.13.2").catch((cause: unknown) => cause);
expect(error).toBeInstanceOf(ZodError);
expect(error).toMatchObject({
name: "ZodError",
issues: [expect.objectContaining({ path: ["id"], code: "invalid_type" })],
});
await writeFile(path.join(directory, "paseo-plugin.json"), "{");
await expect(compileDirectoryPlugin(directory, "0.13.2")).rejects.toBeInstanceOf(SyntaxError);
});
it("preserves filesystem error metadata for a missing entry", async () => {
const { server } = await createSplitPlugin();
await rm(server);
await expect(compilePlugin({ client: null, server })).rejects.toMatchObject({
code: "ENOENT",
errno: expect.any(Number),
syscall: "realpath",
path: server,
});
});
+137 -427
View File
@@ -1,442 +1,152 @@
import { existsSync, readFileSync, realpathSync } from "node:fs"; import { createRequire } from "node:module";
import { createRequire, isBuiltin } from "node:module"; import { pathToFileURL } from "node:url";
import path from "node:path"; import { Worker } from "node:worker_threads";
import { createPluginImportReader, type PluginImportKind } from "./compiler-imports.js"; import pLimit from "p-limit";
import type { Metafile, OnResolveResult, Plugin } from "esbuild"; import { ZodError } from "zod";
import { import type * as build from "./compiler-build.js";
isPluginClientOnlySdkSpecifier, import type { PluginManifest } from "./manifest.js";
isPluginServerOnlySdkSpecifier, import type { CompilerReply } from "./compiler-worker.js";
PLUGIN_SDK_SPECIFIERS,
} from "./plugin-sdk-specifiers.js";
const nodeRequire = createRequire(import.meta.url); export {
export const SERVER_HOST_MODULES = [...PLUGIN_SDK_SPECIFIERS, "zod"]; SERVER_HOST_MODULES,
const ESBUILD_BINARY_PATH = "ESBUILD_BINARY_PATH"; resolveExistingAsarUnpackedEsbuildBinary,
unpackedEsbuildBinaryFromPackageDir,
} from "./compiler-esbuild.js";
// esbuild resolves its own platform binary via require.resolve() the first time its export type CompilerTask =
// module is evaluated. Inside the packaged desktop app that resolves to a path under | { kind: "entries"; entries: Parameters<typeof build.compilePlugin>[0] }
// app.asar even though electron-builder unpacks the real binary to app.asar.unpacked. | { kind: "directory"; directory: string; version: string; builtinId?: string }
// child_process.spawn bypasses Electron's asar fs shim, so the OS rejects that path | {
// with ENOTDIR. Point esbuild at the real unpacked binary before its module loads. kind: "prepare";
export function unpackedEsbuildBinaryFromPackageDir( directory: string;
esbuildPackageDir: string, commands: PluginManifest["build"];
platform: NodeJS.Platform, environment: NodeJS.ProcessEnv;
arch: string,
): string | null {
const asarSegment = `${path.sep}app.asar${path.sep}`;
const asarIndex = esbuildPackageDir.indexOf(asarSegment);
if (asarIndex === -1) return null;
return path.join(
esbuildPackageDir.slice(0, asarIndex),
"app.asar.unpacked",
"node_modules",
`@esbuild/${platform}-${arch}`,
...(platform === "win32" ? ["esbuild.exe"] : ["bin", "esbuild"]),
);
}
export function resolveExistingAsarUnpackedEsbuildBinary(
esbuildPackageDir: string,
platform: NodeJS.Platform = process.platform,
arch: string = process.arch,
exists: (file: string) => boolean = existsSync,
): string | null {
const binaryPath = unpackedEsbuildBinaryFromPackageDir(esbuildPackageDir, platform, arch);
return binaryPath && exists(binaryPath) ? binaryPath : null;
}
function resolveAsarUnpackedEsbuildBinary(): string | null {
let esbuildDir: string;
try {
esbuildDir = path.dirname(nodeRequire.resolve("esbuild/package.json"));
} catch {
return null;
}
return resolveExistingAsarUnpackedEsbuildBinary(esbuildDir);
}
function loadEsbuild(): typeof import("esbuild") {
const previousBinaryPath = process.env[ESBUILD_BINARY_PATH];
const unpackedBinary = resolveAsarUnpackedEsbuildBinary();
if (unpackedBinary) process.env[ESBUILD_BINARY_PATH] = unpackedBinary;
try {
// esbuild reads this variable while its CommonJS module is evaluated. Keep
// the compatibility bridge local so it cannot become an agent's environment.
return nodeRequire("esbuild") as typeof import("esbuild");
} finally {
if (previousBinaryPath === undefined) delete process.env[ESBUILD_BINARY_PATH];
else process.env[ESBUILD_BINARY_PATH] = previousBinaryPath;
}
}
type PluginBuildTarget = "client" | "server";
type PluginModuleLocation = PluginBuildTarget | "shared" | "invalid";
function directoryTarget(filePath: string, pluginDirectory: string): PluginModuleLocation | null {
const relative = path.relative(pluginDirectory, filePath);
if (relative.startsWith("..") || path.isAbsolute(relative)) return "invalid";
const segments = relative.split(path.sep);
if (segments.includes("node_modules")) return null;
if (segments[0] === "client") return "client";
if (segments[0] === "server") return "server";
if (segments[0] === "shared") return "shared";
if (/^index\.client\.tsx?$/.test(relative)) return "client";
if (/^index\.server\.tsx?$/.test(relative)) return "server";
return "invalid";
}
function containsPath(directory: string, filePath: string): boolean {
const relative = path.relative(directory, filePath);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function dependencyName(specifier: string): string {
const segments = specifier.split("/");
return specifier.startsWith("@") ? segments.slice(0, 2).join("/") : segments[0];
}
function findDependencyRoot(
resolvedPath: string,
specifier: string,
pluginDirectory: string,
): string | null {
const expectedName = dependencyName(specifier);
const ambientName = `@types/${expectedName.replace(/^@/, "").replace("/", "__")}`;
let directory = path.dirname(resolvedPath);
for (;;) {
const manifestPath = path.join(directory, "package.json");
if (existsSync(manifestPath)) {
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { name?: unknown };
if (manifest.name === expectedName || manifest.name === ambientName) {
if (containsPath(pluginDirectory, directory) || containsPath(directory, pluginDirectory)) {
return null;
}
return directory;
}
}
const parent = path.dirname(directory);
if (parent === directory) return null;
directory = parent;
}
}
function moduleBoundaryError(
moduleLocation: PluginModuleLocation | null,
target: PluginBuildTarget | "shared",
filePath: string,
): OnResolveResult | null {
if (moduleLocation === "invalid") {
return {
errors: [{ text: `Plugin modules belong in client/, server/, or shared/: ${filePath}` }],
}; };
}
if (moduleLocation === null || moduleLocation === "shared" || moduleLocation === target) { interface TaskResult {
return null; entries: Awaited<ReturnType<typeof build.compilePlugin>>;
} directory: Awaited<ReturnType<typeof build.compileDirectoryPlugin>>;
return { prepare: void;
errors: [
{
text: `${moduleLocation}-only module cannot be imported into the plugin ${target} bundle: ${filePath}`,
},
],
};
} }
function lexicalBoundaryError( const IDLE_TIMEOUT_MS = 1_000;
specifier: string, const source = import.meta.url.endsWith(".ts");
resolveDirectory: string, const workerUrl = new URL(
pluginDirectory: string, source ? "./compiler-worker.ts" : "./compiler-worker.js",
target: PluginBuildTarget | "shared", import.meta.url,
): OnResolveResult | null { );
if (!specifier.startsWith(".") && !path.isAbsolute(specifier)) return null; // Resolve the development loader once at module initialization, never during a
const lexicalPath = path.resolve(resolveDirectory, specifier); // compile request. Packaged workers execute ordinary JavaScript without tsx.
if (containsPath(pluginDirectory, lexicalPath)) { const workerEntrypoint = source
return moduleBoundaryError(directoryTarget(lexicalPath, pluginDirectory), target, lexicalPath); ? `import(${JSON.stringify(pathToFileURL(createRequire(import.meta.url).resolve("tsx/esm/api")).href)}).then(({ register }) => { register(); return import(${JSON.stringify(workerUrl.href)}); });`
} : workerUrl;
// Normalize only a containing root alias. Resolving the whole import would erase
// an authored server/ or client/ location when the final file is a symlink.
for (let ancestor = path.dirname(lexicalPath); ; ancestor = path.dirname(ancestor)) {
if (existsSync(ancestor) && realpathSync.native(ancestor) === pluginDirectory) {
const ownedPath = path.join(pluginDirectory, path.relative(ancestor, lexicalPath));
return moduleBoundaryError(directoryTarget(ownedPath, pluginDirectory), target, lexicalPath);
}
if (path.dirname(ancestor) === ancestor) return null;
}
}
function createRuntimeBoundaryPlugin(target: PluginBuildTarget, pluginDirectory: string): Plugin { // Reuse the isolate during a build batch (notably daemon bootstrap), then release
const boundaryResolution = {}; // TypeScript's heap when idle. Only this owner admits work or stops the worker.
const linkedDependencyRoots = new Set<string>(); class PluginCompiler {
return { private readonly queue = pLimit(1);
name: `paseo-plugin-${target}-runtime-boundary`, private worker: Worker | null = null;
setup(buildContext) { private idleTimer: ReturnType<typeof setTimeout> | null = null;
const checked = new Set<string>(); private stopping: Promise<unknown> = Promise.resolve();
const imports = createPluginImportReader(pluginDirectory);
function resolvedBoundaryError( run<T extends CompilerTask>(
resolvedFile: string, task: T,
specifier: string, onLog?: (data: Record<string, unknown>, message: string) => void,
importer: string, ): Promise<TaskResult[T["kind"]]> {
owner: PluginBuildTarget | "shared", return this.queue(async () => {
) { if (this.idleTimer) clearTimeout(this.idleTimer);
const file = realpathSync.native(resolvedFile); this.idleTimer = null;
const location = directoryTarget(file, pluginDirectory); await this.stopping;
if (location === "invalid") { const worker = this.worker ?? this.startWorker();
if ( worker.ref();
[...linkedDependencyRoots].some( try {
(root) => containsPath(root, importer) && containsPath(root, file), return await this.request<T>(worker, task, onLog);
) } finally {
) if (this.worker === worker) {
return null; worker.unref();
if (!specifier.startsWith(".") && !path.isAbsolute(specifier)) { this.idleTimer = setTimeout(() => this.stopWorker(worker), IDLE_TIMEOUT_MS);
const root = findDependencyRoot(file, specifier, pluginDirectory); this.idleTimer.unref();
if (root) { } else {
linkedDependencyRoots.add(root); await this.stopping;
return null; }
} }
});
}
private startWorker(): Worker {
const worker = new Worker(workerEntrypoint, { eval: source, execArgv: [] });
this.worker = worker;
// Keep error/exit ownership while idle too. The next caller gets a fresh
// isolate after a crash; author build commands are never retried.
worker.on("error", () => this.stopWorker(worker));
worker.on("exit", () => {
if (this.worker === worker) this.stopWorker(worker);
});
return worker;
}
private stopWorker(worker: Worker): void {
if (this.worker !== worker) return;
this.worker = null;
if (this.idleTimer) clearTimeout(this.idleTimer);
this.idleTimer = null;
this.stopping = worker.terminate();
}
private async request<T extends CompilerTask>(
worker: Worker,
task: T,
onLog?: (data: Record<string, unknown>, message: string) => void,
): Promise<TaskResult[T["kind"]]> {
let onMessage: (reply: CompilerReply) => void;
let onError: (error: Error) => void;
let onExit: (code: number) => void;
try {
return await new Promise<TaskResult[T["kind"]]>((resolve, reject) => {
onError = reject;
onExit = (code) => reject(new Error(`Plugin compiler worker exited (code ${code})`));
onMessage = (reply) => {
if (reply.kind === "log") {
onLog?.(reply.data, reply.message);
} else if (reply.kind === "result") {
resolve(reply.value as TaskResult[T["kind"]]);
} else {
const error = reply.issues ? new ZodError(reply.issues) : reply.error;
Object.assign(error, reply.properties);
if (reply.errors) Object.assign(error, { errors: reply.errors });
if (reply.warnings) Object.assign(error, { warnings: reply.warnings });
// Promise.all can reject while the other target is still compiling.
// Retire the isolate before another request can enter it.
this.stopWorker(worker);
reject(error);
} }
} };
return moduleBoundaryError(location, owner, file); worker.on("error", onError);
} worker.on("exit", onExit);
async function resolveImportFiles( worker.on("message", onMessage);
file: string, worker.postMessage(task, []);
specifier: string,
kind: PluginImportKind,
typeOnly: boolean,
): Promise<Set<string>> {
const declaration = imports.resolve(specifier, file, kind);
const dependencyFiles = new Set<string>();
if (declaration && (typeOnly || /\.d\.[cm]?ts$/.test(declaration)))
dependencyFiles.add(declaration);
if (typeOnly && !declaration) {
throw new Error(`Could not resolve type dependency "${specifier}" imported by ${file}`);
}
if (!typeOnly && kind !== "type-reference") {
const resolution = await buildContext.resolve(specifier, {
importer: file,
resolveDir: path.dirname(file),
kind,
});
// esbuild owns missing-runtime errors: erased imports and guarded optional
// requires are legal. Inspect every dependency that actually resolves.
if (!resolution.errors.length && !resolution.external && resolution.namespace === "file")
dependencyFiles.add(realpathSync.native(resolution.path));
}
return dependencyFiles;
}
async function checkSourceImports(
sourcePath: string,
inheritedOwner: PluginBuildTarget | "shared",
): Promise<OnResolveResult | null> {
const file = realpathSync.native(sourcePath);
const owner =
directoryTarget(file, pluginDirectory) === "shared" ? "shared" : inheritedOwner;
const key = `${owner}:${file}`;
if (checked.has(key) || !/\.[cm]?[jt]sx?$/.test(file)) return null;
checked.add(key);
for (const { specifier, kind, typeOnly } of imports.read(file)) {
const packageSpecifier =
kind === "type-reference"
? `@types/${specifier.replace(/^@/, "").replace("/", "__")}`
: specifier;
const error =
runtimeSpecifierError(specifier, owner, file) ??
runtimeSpecifierError(packageSpecifier, owner, file) ??
lexicalBoundaryError(specifier, path.dirname(file), pluginDirectory, owner);
if (error) return error;
// Protocol types are shared host contracts, also exposed by the SDK's peer
// dependency during development. Erased imports need no plugin-local install;
// runtime imports still go through normal dependency resolution below.
if (typeOnly && specifier.startsWith("@getpaseo/protocol/")) continue;
// Host modules have separately enforced SDK boundaries and need no local installation.
if (
(PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier) ||
/^(zod|react|react-native|@tanstack\/react-query)(\/|$)/.test(specifier) ||
isBuiltin(specifier) ||
packageSpecifier === "@types/node"
)
continue;
const dependencyFiles = await resolveImportFiles(file, specifier, kind, typeOnly);
for (const dependencyFile of dependencyFiles) {
const boundaryError = resolvedBoundaryError(dependencyFile, specifier, file, owner);
if (boundaryError) return boundaryError;
const dependencyError = await checkSourceImports(dependencyFile, owner);
if (dependencyError) return dependencyError;
}
}
return null;
}
buildContext.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, (args) =>
checkSourceImports(args.path, target),
);
buildContext.onResolve({ filter: /.*/ }, async (args) => {
if (args.kind === "entry-point") return null;
if (args.pluginData === boundaryResolution) return null;
const importer =
args.namespace === "file" ? realpathSync.native(args.importer) : args.importer;
const resolveDir = args.namespace === "file" ? path.dirname(importer) : args.resolveDir;
const owner = directoryTarget(importer, pluginDirectory) === "shared" ? "shared" : target;
const specifierError = runtimeSpecifierError(args.path, owner, importer);
if (specifierError) return specifierError;
const lexicalError = lexicalBoundaryError(args.path, resolveDir, pluginDirectory, owner);
if (lexicalError) return lexicalError;
const resolution = await buildContext.resolve(args.path, {
importer,
namespace: args.namespace,
resolveDir,
kind: args.kind,
pluginData: boundaryResolution,
with: args.with,
});
if (
resolution.errors.length > 0 ||
resolution.external ||
resolution.namespace !== "file"
) {
return null;
}
return resolvedBoundaryError(resolution.path, args.path, importer, owner);
}); });
}, } finally {
}; worker.off("message", onMessage!);
} worker.off("error", onError!);
worker.off("exit", onExit!);
function wrapCommonJsBundle(code: string): string {
return `(function(require) {\nconst module = { exports: {} };\nconst exports = module.exports;\n${code}\nreturn module.exports;\n})`;
}
function makeHermesInteropEager(code: string): string {
// Hermes evaluates esbuild's lazy CommonJS interop getters from a string with
// the final loop binding, so every named import can resolve to the last export.
// Plugin bundles execute once and do not need live bindings from host modules.
return code.replaceAll("get: () => from[key]", "value: from[key]");
}
function runtimeSpecifierError(
specifier: string,
target: PluginBuildTarget | "shared",
importer: string,
): OnResolveResult | null {
let kind: string | null = null;
if (specifier === "@getpaseo/plugin/client/host") kind = "host-private";
else if (
(specifier === "@getpaseo/plugin" ||
specifier.startsWith("@getpaseo/plugin/") ||
specifier === "@paseo/plugin" ||
specifier.startsWith("@paseo/plugin/")) &&
!(PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier)
)
kind = "Unknown SDK";
else if (target !== "server" && (isBuiltin(specifier) || specifier === "@types/node"))
kind = "Node";
else if (target !== "server" && isPluginServerOnlySdkSpecifier(specifier)) kind = "server-only";
else if (
target !== "client" &&
(isPluginClientOnlySdkSpecifier(specifier) ||
/^((?:@types\/)?react(?:-dom|-native)?|use-sync-external-store|@tanstack\/react-query)(\/|$)/.test(
specifier,
))
)
kind = "client-only";
return kind
? {
errors: [
{
text: `${kind} module cannot be imported into the plugin ${target} bundle: ${specifier} imported by ${importer}`,
},
],
}
: null;
}
function checkSharedDependencies(inputs: Metafile["inputs"], pluginDirectory: string): void {
function inputLocation(file: string): PluginModuleLocation | null {
const absolutePath = path.resolve(file);
// Metafile keys must stay unchanged for graph traversal. Only filesystem
// inputs have canonical paths; data URLs and external specifiers do not.
return directoryTarget(
existsSync(absolutePath) ? realpathSync.native(absolutePath) : absolutePath,
pluginDirectory,
);
}
const pending = Object.keys(inputs).filter((file) => inputLocation(file) === "shared");
const visited = new Set<string>();
while (pending.length) {
const file = pending.pop()!;
if (visited.has(file)) continue;
visited.add(file);
for (const dependency of inputs[file]?.imports ?? []) {
const location = dependency.external ? null : inputLocation(dependency.path);
const error =
runtimeSpecifierError(dependency.original ?? dependency.path, "shared", file) ??
(dependency.external
? null
: moduleBoundaryError(
location === "invalid" ? null : location,
"shared",
dependency.path,
));
if (error?.errors?.length) throw new Error(error.errors[0].text);
if (!dependency.external) pending.push(dependency.path);
} }
} }
} }
async function compileTarget(entryPath: string, target: PluginBuildTarget): Promise<string> { const compiler = new PluginCompiler();
const { build } = loadEsbuild();
// Use native canonical paths throughout: TypeScript expands Windows short names export function runCompilerTask<T extends CompilerTask>(
// when resolving type references, while the JS realpath implementation retains them. task: T,
const pluginDirectory = realpathSync.native(path.dirname(entryPath)); onLog?: (data: Record<string, unknown>, message: string) => void,
const result = await build({ ): Promise<TaskResult[T["kind"]]> {
entryPoints: [realpathSync.native(entryPath)], return compiler.run(task, onLog);
bundle: true,
format: "cjs",
jsx: "automatic",
// `import icon from "./icon.svg"` yields the markup for an inline `{ svg }` icon.
loader: { ".svg": "text" },
platform: target === "server" ? "node" : "neutral",
target: target === "server" ? "node20" : "es2020",
// The neutral platform reads no package.json entry fields, so packages without
// `exports` would not resolve. Read the same fields as esbuild's browser platform
// minus `browser`, since the bundle also runs in React Native.
mainFields: target === "client" ? ["module", "main"] : undefined,
// Metro lowers async syntax before Hermes sees app code. Plugin client bundles bypass Metro,
// so apply the same compatibility transform before the app evaluates them from source.
supported: target === "client" ? { "async-await": false } : undefined,
external:
target === "client"
? [
...PLUGIN_SDK_SPECIFIERS,
"@tanstack/react-query",
"react",
"react/jsx-runtime",
"react-native",
"zod",
]
: SERVER_HOST_MODULES,
plugins: [createRuntimeBoundaryPlugin(target, pluginDirectory)],
metafile: true,
logLevel: "silent",
treeShaking: true,
write: false,
});
checkSharedDependencies(result.metafile.inputs, pluginDirectory);
const output = result.outputFiles[0]?.text;
if (!output) throw new Error(`Plugin ${target} compilation produced no output`);
return wrapCommonJsBundle(makeHermesInteropEager(output));
} }
export async function compilePlugin(entryPaths: { export function compilePlugin(entries: Parameters<typeof build.compilePlugin>[0]) {
client: string | null; return runCompilerTask({ kind: "entries", entries });
server: string | null; }
}): Promise<{
clientBundle: string | null; export function compileDirectoryPlugin(directory: string, version: string, builtinId?: string) {
serverBundle: string | null; return runCompilerTask({ kind: "directory", directory, version, builtinId });
}> {
const [clientBundle, serverBundle] = await Promise.all([
entryPaths.client ? compileTarget(entryPaths.client, "client") : null,
entryPaths.server ? compileTarget(entryPaths.server, "server") : null,
]);
return { clientBundle, serverBundle };
} }
@@ -1,3 +1,6 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { performance } from "node:perf_hooks";
import pino from "pino"; import pino from "pino";
import { createPaseoDaemon } from "../bootstrap.js"; import { createPaseoDaemon } from "../bootstrap.js";
import { loadConfig } from "../config.js"; import { loadConfig } from "../config.js";
@@ -522,3 +525,24 @@ export default function contribute(server: PluginServerContext) {
await daemon.close(); await daemon.close();
} }
}, 60_000); }, 60_000);
test("keeps reload and enable responsive while compiling a real plugin", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-responsive-plugin-"));
roots.push(directory);
const { stdout, stderr } = await promisify(execFile)(
process.execPath,
[
"--import",
"tsx",
fileURLToPath(new URL("./test-fixtures/compile-responsiveness.e2e.ts", import.meta.url)),
directory,
],
{ timeout: 20_000 },
);
process.stderr.write(stderr);
const results = z
.array(z.object({ blockingMs: z.number() }))
.length(2)
.parse(JSON.parse(stdout));
for (const result of results) expect(result.blockingMs).toBeLessThan(20);
});
@@ -0,0 +1,76 @@
import { spawnProcess } from "../../utils/spawn.js";
import type { PluginManifest } from "./manifest.js";
const OUTPUT_LIMIT = 64 * 1024;
export async function runPluginBuild(
directory: string,
commands: PluginManifest["build"],
logger: { info(data: Record<string, unknown>, message: string): void },
environment: NodeJS.ProcessEnv,
): Promise<void> {
for (const command of commands ?? []) {
logger.info({ directory, command }, "Running plugin build command");
const result = await run(command, directory, environment);
if (result.stdout)
logger.info({ directory, command, output: result.stdout }, "Plugin build stdout");
if (result.stderr)
logger.info({ directory, command, output: result.stderr }, "Plugin build stderr");
if (result.error) {
throw new Error(
`Plugin build command failed to start: ${formatCommand(command)}\n${result.error.message}`,
);
}
if (result.exitCode !== 0) {
throw new Error(
`Plugin build command failed (exit ${result.exitCode}): ${formatCommand(command)}${formatOutput(result)}`,
);
}
}
}
function run(
command: string[],
directory: string,
environment: NodeJS.ProcessEnv,
): Promise<CommandResult> {
return new Promise((resolve) => {
const executable = command[0]!;
const arguments_ = command.slice(1);
const child = spawnProcess(executable, arguments_, { cwd: directory, env: environment });
let stdout = "";
let stderr = "";
let error: Error | undefined;
child.stdout?.on("data", (chunk: Buffer | string) => {
stdout = appendOutput(stdout, chunk.toString());
});
child.stderr?.on("data", (chunk: Buffer | string) => {
stderr = appendOutput(stderr, chunk.toString());
});
child.once("error", (spawnError) => {
error = spawnError;
});
child.once("close", (exitCode) => resolve({ exitCode, stdout, stderr, error }));
});
}
function appendOutput(current: string, next: string): string {
return `${current}${next}`.slice(-OUTPUT_LIMIT);
}
function formatCommand(command: string[]): string {
return command.map((argument) => JSON.stringify(argument)).join(" ");
}
function formatOutput(result: CommandResult): string {
const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
return output ? `\n${output}` : "";
}
interface CommandResult {
exitCode: number | null;
stdout: string;
stderr: string;
error?: Error;
}
@@ -1,6 +1,8 @@
import { chmod, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; import { chmod, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { Writable } from "node:stream";
import pino from "pino";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js"; import { createTestLogger } from "../../test-utils/test-logger.js";
import { runPluginBuild } from "./preparation.js"; import { runPluginBuild } from "./preparation.js";
@@ -117,6 +119,52 @@ describe("runPluginBuild", () => {
).rejects.toThrow(/Plugin build command failed \(exit 2\)/); ).rejects.toThrow(/Plugin build command failed \(exit 2\)/);
}); });
it("forwards build progress and output from the worker to the caller's logger", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-build-"));
directories.push(directory);
const records: Array<{ msg: string; output?: string }> = [];
const logger = pino(
new Writable({
write(chunk, _encoding, callback) {
records.push(JSON.parse(chunk.toString()));
callback();
},
}),
);
await runPluginBuild(
directory,
[[process.execPath, "-e", 'console.log("prepared"); console.error("diagnostic");']],
logger,
);
expect(records).toMatchObject([
{ msg: "Running plugin build command" },
{ msg: "Plugin build stdout", output: "prepared\n" },
{ msg: "Plugin build stderr", output: expect.stringContaining("diagnostic\n") },
]);
});
it("uses the caller's current environment across consecutive builds", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-build-"));
directories.push(directory);
const previous = process.env.PASEO_PLUGIN_TEST_BUILD_VALUE;
const command = [
process.execPath,
"-e",
'require("node:fs").writeFileSync(process.argv[1], process.env.PASEO_PLUGIN_TEST_BUILD_VALUE);',
];
try {
process.env.PASEO_PLUGIN_TEST_BUILD_VALUE = "first";
await runPluginBuild(directory, [[...command, "first.txt"]], createTestLogger());
process.env.PASEO_PLUGIN_TEST_BUILD_VALUE = "second";
await runPluginBuild(directory, [[...command, "second.txt"]], createTestLogger());
expect(await readFile(path.join(directory, "first.txt"), "utf8")).toBe("first");
expect(await readFile(path.join(directory, "second.txt"), "utf8")).toBe("second");
} finally {
if (previous === undefined) delete process.env.PASEO_PLUGIN_TEST_BUILD_VALUE;
else process.env.PASEO_PLUGIN_TEST_BUILD_VALUE = previous;
}
});
it("handles empty or undefined build commands gracefully", async () => { it("handles empty or undefined build commands gracefully", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-build-")); const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-build-"));
directories.push(directory); directories.push(directory);
@@ -1,71 +1,15 @@
import { spawnProcess } from "../../utils/spawn.js";
import type pino from "pino"; import type pino from "pino";
import type { PluginManifest } from "./manifest.js"; import type { PluginManifest } from "./manifest.js";
import { runCompilerTask } from "./compiler.js";
const OUTPUT_LIMIT = 64 * 1024;
export async function runPluginBuild( export async function runPluginBuild(
directory: string, directory: string,
commands: PluginManifest["build"], commands: PluginManifest["build"],
logger: pino.Logger, logger: pino.Logger,
): Promise<void> { ): Promise<void> {
for (const command of commands ?? []) { if (!commands?.length) return;
logger.info({ directory, command }, "Running plugin build command"); await runCompilerTask(
const result = await run(command, directory); { kind: "prepare", directory, commands, environment: { ...process.env } },
if (result.stdout) (data, message) => logger.info(data, message),
logger.info({ directory, command, output: result.stdout }, "Plugin build stdout"); );
if (result.stderr)
logger.info({ directory, command, output: result.stderr }, "Plugin build stderr");
if (result.error) {
throw new Error(
`Plugin build command failed to start: ${formatCommand(command)}\n${result.error.message}`,
);
}
if (result.exitCode !== 0) {
throw new Error(
`Plugin build command failed (exit ${result.exitCode}): ${formatCommand(command)}${formatOutput(result)}`,
);
}
}
}
function run(command: string[], directory: string): Promise<CommandResult> {
return new Promise((resolve) => {
const executable = command[0]!;
const arguments_ = command.slice(1);
const child = spawnProcess(executable, arguments_, { cwd: directory });
let stdout = "";
let stderr = "";
let error: Error | undefined;
child.stdout?.on("data", (chunk: Buffer | string) => {
stdout = appendOutput(stdout, chunk.toString());
});
child.stderr?.on("data", (chunk: Buffer | string) => {
stderr = appendOutput(stderr, chunk.toString());
});
child.once("error", (spawnError) => {
error = spawnError;
});
child.once("close", (exitCode) => resolve({ exitCode, stdout, stderr, error }));
});
}
function appendOutput(current: string, next: string): string {
return `${current}${next}`.slice(-OUTPUT_LIMIT);
}
function formatCommand(command: string[]): string {
return command.map((argument) => JSON.stringify(argument)).join(" ");
}
function formatOutput(result: CommandResult): string {
const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
return output ? `\n${output}` : "";
}
interface CommandResult {
exitCode: number | null;
stdout: string;
stderr: string;
error?: Error;
} }
@@ -1,4 +1,4 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { fork } from "node:child_process"; import { fork } from "node:child_process";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { PassThrough } from "node:stream"; import { PassThrough } from "node:stream";
@@ -1849,9 +1849,9 @@ export default function contribute(plugin: any) {
const child = createReloadChild("blocked", events); const child = createReloadChild("blocked", events);
const runtime = createTestRuntime({ spawnChild: () => child }); const runtime = createTestRuntime({ spawnChild: () => child });
await expect(runtime.startPlugin("blocked", directory, () => false)).rejects.toThrow( await expect(
"Plugin start cancelled: blocked", runtime.startPlugin("blocked", directory, () => events.length === 0),
); ).rejects.toThrow("Plugin start cancelled: blocked");
expect(runtime.catalog()).toEqual([]); expect(runtime.catalog()).toEqual([]);
expect(events).toEqual(["start:blocked", "shutdown:blocked", "exit:blocked"]); expect(events).toEqual(["start:blocked", "shutdown:blocked", "exit:blocked"]);
@@ -2055,3 +2055,50 @@ export default function contribute(server) {
await runtime.stopAll(); await runtime.stopAll();
} }
}); });
const examplesDirectory = fileURLToPath(
new URL("../../../../../plugin-examples/", import.meta.url),
);
// The Markdown example owns npm preparation in the real app fixture:
// packages/app/e2e/browser/plugin-file-panel-examples.spec.ts.
const examples = (await readdir(examplesDirectory, { withFileTypes: true }))
.filter((entry) => entry.isDirectory() && entry.name !== "markdown-panel")
.map((entry) => entry.name);
it.each(examples)("builds and loads the %s plugin example", async (example) => {
const settingsDirectory = await mkdtemp(path.join(tmpdir(), "paseo-example-settings-"));
temporaryDirectories.push(settingsDirectory);
const runtime = createTestRuntime({ settingsDirectory }, undefined, "0.13.2");
try {
await runtime.startPlugin(example, path.join(examplesDirectory, example));
expect(runtime.catalog()).toMatchObject([{ id: example }]);
expect(pluginMessages(runtime, example)).toContain("[paseo] Plugin ready");
} finally {
await runtime.stopAll();
}
});
it("does not initialize a plugin cancelled during compilation", async () => {
const directory = await createPlugin("cancelled-build", "export default () => {};");
const marker = path.join(directory, "initialized.txt");
await writeFile(
path.join(directory, "index.server.ts"),
`
import { writeFileSync } from "node:fs";
export default function contribute() {
writeFileSync(${JSON.stringify(marker)}, "initialized");
return () => {};
}`,
);
const runtime = createTestRuntime();
let allowed = true;
try {
const loading = runtime.startPlugin("cancelled-build", directory, () => allowed);
allowed = false;
await expect(loading).rejects.toThrow("Plugin start cancelled: cancelled-build");
expect(await readTextIfPresent(marker)).toBeNull();
expect(runtime.catalog()).toEqual([]);
} finally {
await runtime.stopAll();
}
});
+18 -54
View File
@@ -13,7 +13,6 @@ import type {
} from "@getpaseo/plugin/server"; } from "@getpaseo/plugin/server";
import { validateBeforeRequest, validateBeforeResult } from "./lifecycle/index.js"; import { validateBeforeRequest, validateBeforeResult } from "./lifecycle/index.js";
import { fork } from "node:child_process"; import { fork } from "node:child_process";
import { stat } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
@@ -28,10 +27,8 @@ import {
type ProviderInput, type ProviderInput,
} from "@getpaseo/plugin/server/provider"; } from "@getpaseo/plugin/server/provider";
import type { PluginLogEntry } from "@getpaseo/protocol/messages"; import type { PluginLogEntry } from "@getpaseo/protocol/messages";
import { compilePlugin } from "./compiler.js"; import { compileDirectoryPlugin } from "./compiler.js";
import { readPluginManifest } from "./manifest.js";
import type { PluginRequirements } from "@getpaseo/protocol/messages"; import type { PluginRequirements } from "@getpaseo/protocol/messages";
import { assertPluginCompatibility } from "@getpaseo/protocol/plugin-requirements";
import type { import type {
PluginProcessMessage, PluginProcessMessage,
PluginProcessRequest, PluginProcessRequest,
@@ -43,8 +40,6 @@ import { PluginSessionSocket } from "./session-socket.js";
import { InternalPluginChild } from "./internal-child.js"; import { InternalPluginChild } from "./internal-child.js";
import { evaluateBundle } from "./bundle-evaluator.js"; import { evaluateBundle } from "./bundle-evaluator.js";
const CLIENT_ENTRY_FILENAMES = ["index.client.ts", "index.client.tsx"] as const;
const SERVER_ENTRY_FILENAMES = ["index.server.ts", "index.server.tsx"] as const;
const REQUEST_TIMEOUT_MS = 30_000; const REQUEST_TIMEOUT_MS = 30_000;
const TOOL_CALL_TIMEOUT_MS = 10 * 60_000; const TOOL_CALL_TIMEOUT_MS = 10 * 60_000;
const MAX_LOG_ENTRIES = 500; const MAX_LOG_ENTRIES = 500;
@@ -273,35 +268,6 @@ function send(child: PluginChild, message: PluginProcessRequest): Promise<void>
}); });
} }
async function findEntry(directory: string, filenames: readonly string[]): Promise<string | null> {
for (const filename of filenames) {
const filePath = path.join(directory, filename);
const info = await stat(filePath).catch(() => null);
if (info?.isFile()) return filePath;
}
return null;
}
async function resolveEntryPaths(directory: string): Promise<{
client: string | null;
server: string | null;
}> {
const [client, server] = await Promise.all([
findEntry(directory, CLIENT_ENTRY_FILENAMES),
findEntry(directory, SERVER_ENTRY_FILENAMES),
]);
if (client || server) return { client, server };
const legacyEntry = await findEntry(directory, ["index.ts", "index.tsx"]);
if (legacyEntry) {
throw new Error(
"This plugin was made for an older version of Paseo and cannot run on Paseo v0.8. Ask its author to update it. Plugin authors can follow the migration guide: https://paseo.sh/docs/plugins/migration",
);
}
throw new Error(
`Plugin entry points are missing: expected ${CLIENT_ENTRY_FILENAMES.join(" or ")} and/or ${SERVER_ENTRY_FILENAMES.join(" or ")} in ${directory}`,
);
}
export class PluginRuntime { export class PluginRuntime {
private readonly plugins = new Map<string, LoadedPlugin>(); private readonly plugins = new Map<string, LoadedPlugin>();
private readonly pendingEvents = new Set<Promise<void>>(); private readonly pendingEvents = new Set<Promise<void>>();
@@ -350,10 +316,16 @@ export class PluginRuntime {
): Promise<void> { ): Promise<void> {
if (this.plugins.has(pluginId)) throw new Error(`Plugin is already running: ${pluginId}`); if (this.plugins.has(pluginId)) throw new Error(`Plugin is already running: ${pluginId}`);
this.appendLog(pluginId, "stdout", "[paseo] Loading plugin"); this.appendLog(pluginId, "stdout", "[paseo] Loading plugin");
const loaded = await this.loadDirectoryPlugin(pluginId, configuredPath).catch((error) => { const loaded = await this.loadDirectoryPlugin(pluginId, configuredPath, canPublish).catch(
this.appendLog(pluginId, "stderr", `[paseo] Plugin failed to load: ${describeError(error)}`); (error) => {
throw error; this.appendLog(
}); pluginId,
"stderr",
`[paseo] Plugin failed to load: ${describeError(error)}`,
);
throw error;
},
);
if (!canPublish()) { if (!canPublish()) {
await this.stopPlugin(loaded); await this.stopPlugin(loaded);
throw new Error(`Plugin start cancelled: ${pluginId}`); throw new Error(`Plugin start cancelled: ${pluginId}`);
@@ -367,12 +339,8 @@ export class PluginRuntime {
if (this.plugins.has(input.id)) throw new Error(`Plugin is already running: ${input.id}`); if (this.plugins.has(input.id)) throw new Error(`Plugin is already running: ${input.id}`);
this.appendLog(input.id, "stdout", "[paseo] Loading plugin"); this.appendLog(input.id, "stdout", "[paseo] Loading plugin");
const directory = path.resolve(input.directory); const directory = path.resolve(input.directory);
const manifest = await readPluginManifest(directory); const bundles = await compileDirectoryPlugin(directory, this.daemonVersion, input.id);
if (manifest.id !== input.id) { const { manifest } = bundles;
throw new Error(`Built-in plugin ${input.id} has manifest ID ${manifest.id}`);
}
assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" });
const bundles = await compilePlugin(await resolveEntryPaths(directory));
if (!bundles.serverBundle) throw new Error(`Built-in plugin ${input.id} needs a server entry`); if (!bundles.serverBundle) throw new Error(`Built-in plugin ${input.id} needs a server entry`);
const loaded = await this.launchPlugin({ const loaded = await this.launchPlugin({
pluginId: input.id, pluginId: input.id,
@@ -388,11 +356,7 @@ export class PluginRuntime {
} }
async validatePlugin(configuredPath: string): Promise<void> { async validatePlugin(configuredPath: string): Promise<void> {
const directory = path.resolve(configuredPath); await compileDirectoryPlugin(path.resolve(configuredPath), this.daemonVersion);
const manifest = await readPluginManifest(directory);
assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" });
const entryPaths = await resolveEntryPaths(directory);
await compilePlugin(entryPaths);
} }
async stopPluginById(pluginId: string): Promise<boolean> { async stopPluginById(pluginId: string): Promise<boolean> {
@@ -738,12 +702,12 @@ export class PluginRuntime {
private async loadDirectoryPlugin( private async loadDirectoryPlugin(
pluginId: string, pluginId: string,
configuredPath: string, configuredPath: string,
canPublish: () => boolean,
): Promise<LoadedPlugin> { ): Promise<LoadedPlugin> {
const directory = path.resolve(configuredPath); const directory = path.resolve(configuredPath);
const manifest = await readPluginManifest(directory); const bundles = await compileDirectoryPlugin(directory, this.daemonVersion);
assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" }); const { manifest } = bundles;
const entryPaths = await resolveEntryPaths(directory); if (!canPublish()) throw new Error(`Plugin start cancelled: ${pluginId}`);
const bundles = await compilePlugin(entryPaths);
const serverBundle = bundles.serverBundle; const serverBundle = bundles.serverBundle;
if (!serverBundle) { if (!serverBundle) {
return { return {
@@ -0,0 +1,187 @@
import assert from "node:assert/strict";
import { createHook, executionAsyncResource } from "node:async_hooks";
import { open as openFile, writeFile } from "node:fs/promises";
import { performance, PerformanceObserver } from "node:perf_hooks";
import path from "node:path";
import { MessagePort } from "node:worker_threads";
import pino from "pino";
import { DaemonConfigStore } from "../../daemon-config-store.js";
import { PluginService } from "../index.js";
// A fresh process excludes the E2E harness's transform/bootstrap garbage from
// the compiler measurement. All work and dependencies below are real.
const directory = process.argv[2];
assert.ok(directory, "Fixture directory is required");
const results: { blockingMs: number }[] = [];
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "responsive-build" }),
);
// Erased types stress TypeScript's AST walk without a multi-MiB runtime bundle.
// Payload transfer is a different cost from import validation.
const entryFile = await openFile(path.join(directory, "index.client.ts"), "w");
try {
await entryFile.write("type FixtureValues = [");
for (let offset = 0; offset < 350_000; offset += 10_000) {
await entryFile.write(
Array.from({ length: 10_000 }, (_, index) => offset + index).join(",") + ",",
);
}
await entryFile.write(
']; export default function contribute() { return () => "compiled fixture"; }',
);
} finally {
await entryFile.close();
}
const store = new DaemonConfigStore(directory, {
mcp: { injectIntoAgents: true },
browserTools: { enabled: false },
providers: {},
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,
enableTerminalAgentHooks: false,
appendSystemPrompt: "",
pluginsEnabled: true,
plugins: {
"responsive-build": { source: "directory", path: directory, enabled: false },
},
});
const service = new PluginService(pino({ level: "silent" }), store, "0.4.0");
await service.start();
try {
for (const action of [
() => service.enablePlugin("responsive-build"),
() => service.reloadPlugin("responsive-build"),
]) {
// Node 22.19+ exposes per-thread CPU accounting; the repository's older
// Node declarations do not include it yet. .tool-versions pins Node 22.20.
const threadCpuUsage: (previous?: NodeJS.CpuUsage) => NodeJS.CpuUsage = Reflect.get(
process,
"threadCpuUsage",
);
const startedAt = performance.now();
let previous = startedAt;
let cpu = threadCpuUsage();
let longestWall = 0;
let longestBlocking = 0;
let longestWallCpu = 0;
let workerStarts = 0;
let portCallbacks = 0;
let longestWallEvents = { workerStarts: 0, portCallbacks: 0 };
let longestBlockingWindow = { wallMs: 0, cpuMs: 0, resource: "" };
let probe: ReturnType<typeof setInterval>;
const callbacks = new Map<number, { wall: number; cpu: NodeJS.CpuUsage; resource: string }>();
const recordBlocking = (
blockingMs: number,
window: { wallMs: number; cpuMs: number; resource: string },
) => {
if (blockingMs > longestBlocking) {
longestBlocking = blockingMs;
longestBlockingWindow = window;
}
};
let longestGc = 0;
const observeGc = (entries: readonly import("node:perf_hooks").PerformanceEntry[]) => {
for (const entry of entries) {
if (entry.startTime >= startedAt) longestGc = Math.max(longestGc, entry.duration);
}
};
const gc = new PerformanceObserver((list) => observeGc(list.getEntries()));
gc.observe({ entryTypes: ["gc"] });
// Measure work within callbacks, not CPU-counter increments attributed to
// the idle interval between them. Exclude this probe's own timer callback.
const activity = createHook({
init(_id, type) {
if (type === "WORKER") workerStarts++;
},
before(id) {
const resource = executionAsyncResource();
if (resource instanceof MessagePort) portCallbacks++;
if (resource === probe) return;
callbacks.set(id, {
wall: performance.now(),
cpu: threadCpuUsage(),
resource: Object.prototype.toString.call(resource),
});
},
after(id) {
const start = callbacks.get(id);
if (!start) return;
callbacks.delete(id);
const wallMs = performance.now() - start.wall;
const { user, system } = threadCpuUsage(start.cpu);
const cpuMs = (user + system) / 1_000;
// A callback prevents the loop from serving IO for its entire span.
// Use the high-resolution clock, not Windows' coarse CPU increments.
recordBlocking(wallMs, { wallMs, cpuMs, resource: start.resource });
},
}).enable();
const sample = () => {
const now = performance.now();
const currentCpu = threadCpuUsage();
const wallMs = now - previous;
const cpuMs = (currentCpu.user - cpu.user + currentCpu.system - cpu.system) / 1_000;
// Native worker construction and message deserialization can precede
// async_hooks.before. Include their interval when those events occurred.
if (workerStarts || portCallbacks) {
recordBlocking(Math.min(wallMs, cpuMs), {
wallMs,
cpuMs,
resource: "worker/port interval",
});
}
if (wallMs > longestWall) {
longestWall = wallMs;
longestWallCpu = cpuMs;
longestWallEvents = { workerStarts, portCallbacks };
}
workerStarts = 0;
portCallbacks = 0;
previous = now;
cpu = currentCpu;
};
// Sleep between probes: a continuously runnable setImmediate loop consumes
// a core while the worker compiles. Raw gaps remain diagnostics; the
// assertion measures elapsed daemon-thread callback spans and GC pauses.
probe = setInterval(sample, 1);
try {
// The caller's current callback began before the hook was enabled.
// Include synchronous work before the action's first await explicitly.
const actionStartedAt = performance.now();
const actionCpu = threadCpuUsage();
const pending = action();
const actionWallMs = performance.now() - actionStartedAt;
const actionCost = threadCpuUsage(actionCpu);
recordBlocking(actionWallMs, {
wallMs: actionWallMs,
cpuMs: (actionCost.user + actionCost.system) / 1_000,
resource: "action()",
});
await pending;
await new Promise<void>((resolve) => setImmediate(resolve));
sample();
observeGc(gc.takeRecords());
} finally {
clearInterval(probe);
activity.disable();
gc.disconnect();
}
longestBlocking = Math.max(longestBlocking, longestGc);
process.stderr.write(
`plugin compile main-thread blocking: ${longestBlocking.toFixed(2)} ms ${JSON.stringify({ longestWall, longestWallCpu, ...longestWallEvents, longestBlockingWindow, longestGc })}\n`,
);
assert.ok(service.catalog()[0]?.clientBundle.includes("compiled fixture"));
results.push({ blockingMs: longestBlocking });
}
// Evaluate after both probes so parsing the returned code is not charged to compilation.
const factory = globalThis.eval(service.catalog()[0]!.clientBundle) as (
require: (specifier: string) => never,
) => { default: () => () => string };
const result = factory((specifier) => {
throw new Error(`Unexpected fixture dependency: ${specifier}`);
}).default()();
assert.equal(result, "compiled fixture");
} finally {
await service.stopAllPlugins();
}
process.stdout.write(JSON.stringify(results));