Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 2a4def5004 fix(server): publish OpenCode snapshots before live suffixes
Commit snapshot cursors and publish each event together. Holding those events until attachment IO completed let live deltas overtake text that had already advanced the cursor. Require snapshot publishers and exercise text and reasoning interleaving against real image materialization.
2026-10-27 04:24:24 +01:00
Mohamed Boudra 16d7f5302e fix(server): reject attachment starts closed during conversion
Keep interruption terminal for prompts still being materialized, and reject a start if its session closes before provider submission. Cover both providers with real large-image conversions and make history reconciliation tests await the async timeline capability.
2026-10-27 04:24:24 +01:00
Mohamed Boudra 1aa38f4a99 fix(server): keep image materialization responsive
Decode and hash attachments in bounded yielding units, deduplicate before writing, and publish complete private files before provider timeline events. Await mapping through existing replay boundaries and serialize notification dispatch so turn completion cannot overtake an image.
2026-10-27 04:24:24 +01:00
36 changed files with 1674 additions and 1607 deletions
@@ -13,8 +13,8 @@ const MATERIALIZED_PNG_PATH_PATTERN =
/paseo-attachments(?:-[^\\/]+)?[\\/](?:[^\\/]+[\\/])?[0-9a-f]{64}\.png$/;
interface ClaudeImageTestSession {
translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
convertHistoryEntry(entry: unknown): AgentTimelineItem[];
translateMessageToEvents(message: SDKMessage): Promise<AgentStreamEvent[]>;
convertHistoryEntry(entry: unknown): Promise<AgentTimelineItem[]>;
}
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 () => {
const session = await createSession();
const events = session.translateMessageToEvents(imageToolResultUserMessage());
const events = await session.translateMessageToEvents(imageToolResultUserMessage());
const timelineItems = events
.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 () => {
const session = await createSession();
const items = session.convertHistoryEntry(imageToolResultHistoryEntry());
const items = await session.convertHistoryEntry(imageToolResultHistoryEntry());
const [imageMessage, ...extraImages] = imageMessages(items);
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 () => {
const session = await createSession();
const events = session.translateMessageToEvents(erroredImageToolResultUserMessage());
const events = await session.translateMessageToEvents(erroredImageToolResultUserMessage());
const timelineItems = events
.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 () => {
const session = await createSession();
const events = session.translateMessageToEvents(multiImageToolResultUserMessage());
const events = await session.translateMessageToEvents(multiImageToolResultUserMessage());
const timelineItems = events
.filter((event) => event.type === "timeline")
@@ -32,7 +32,7 @@ import type { AgentAttachment } from "@getpaseo/protocol/messages";
import { buildAgentPrompt, renderPromptAttachmentAsText } from "../../prompt-attachments.js";
interface TestClaudeSession {
translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
translateMessageToEvents(message: SDKMessage): Promise<AgentStreamEvent[]>;
close(): Promise<void>;
}
@@ -53,7 +53,7 @@ afterEach(() => {
});
describe("convertClaudeHistoryEntry", () => {
test("maps user tool results to timeline items", () => {
test("maps user tool results to timeline items", async () => {
const toolUseId = "toolu_test";
const entry = {
type: "user",
@@ -79,14 +79,14 @@ describe("convertClaudeHistoryEntry", () => {
];
const mapBlocks = vi.fn().mockReturnValue(stubTimeline);
const result = convertClaudeHistoryEntry(entry, mapBlocks);
const result = await convertClaudeHistoryEntry(entry, mapBlocks);
expect(result).toEqual(stubTimeline);
expect(mapBlocks).toHaveBeenCalledTimes(1);
expect(Array.isArray(mapBlocks.mock.calls[0][0])).toBe(true);
});
test("replays persisted Claude tool results as completed tool calls", () => {
test("replays persisted Claude tool results as completed tool calls", async () => {
const entry = {
type: "user",
message: {
@@ -124,12 +124,12 @@ describe("convertClaudeHistoryEntry", () => {
const mapPersistedToolResultBlocks = (): AgentTimelineItem[] => completedToolCall;
expect(convertClaudeHistoryEntry(entry, mapPersistedToolResultBlocks)).toEqual(
expect(await convertClaudeHistoryEntry(entry, mapPersistedToolResultBlocks)).toEqual(
completedToolCall,
);
});
test("returns user messages when no tool blocks exist", () => {
test("returns user messages when no tool blocks exist", async () => {
const entry = {
type: "user",
message: {
@@ -138,7 +138,7 @@ describe("convertClaudeHistoryEntry", () => {
},
};
expect(convertClaudeHistoryEntry(entry, () => [])).toEqual([
expect(await convertClaudeHistoryEntry(entry, () => [])).toEqual([
{
type: "user_message",
text: "Run npm test",
@@ -146,7 +146,7 @@ describe("convertClaudeHistoryEntry", () => {
]);
});
test("converts compact boundary metadata variants", () => {
test("converts compact boundary metadata variants", async () => {
const fixtures = [
{
entry: {
@@ -175,7 +175,7 @@ describe("convertClaudeHistoryEntry", () => {
] as const;
for (const fixture of fixtures) {
expect(convertClaudeHistoryEntry(fixture.entry, () => [])).toEqual([
expect(await convertClaudeHistoryEntry(fixture.entry, () => [])).toEqual([
{
type: "compaction",
status: "completed",
@@ -186,7 +186,7 @@ describe("convertClaudeHistoryEntry", () => {
}
});
test("skips synthetic user entries", () => {
test("skips synthetic user entries", async () => {
const entry = {
type: "user",
isSynthetic: true,
@@ -197,13 +197,13 @@ describe("convertClaudeHistoryEntry", () => {
};
const mapBlocks = vi.fn().mockReturnValue([]);
const result = convertClaudeHistoryEntry(entry, mapBlocks);
const result = await convertClaudeHistoryEntry(entry, mapBlocks);
expect(result).toEqual([]);
expect(mapBlocks).not.toHaveBeenCalled();
});
test("skips meta user entries from Claude skill loading", () => {
test("skips meta user entries from Claude skill loading", async () => {
const entry = {
type: "user",
isMeta: true,
@@ -219,13 +219,13 @@ describe("convertClaudeHistoryEntry", () => {
};
const mapBlocks = vi.fn().mockReturnValue([]);
const result = convertClaudeHistoryEntry(entry, mapBlocks);
const result = await convertClaudeHistoryEntry(entry, mapBlocks);
expect(result).toEqual([]);
expect(mapBlocks).not.toHaveBeenCalled();
});
test("skips interrupt placeholder transcript noise", () => {
test("skips interrupt placeholder transcript noise", async () => {
const interruptEntry = {
type: "user",
message: {
@@ -246,11 +246,11 @@ describe("convertClaudeHistoryEntry", () => {
.fn()
.mockReturnValue([{ type: "assistant_message", text: "No response requested." }]);
expect(convertClaudeHistoryEntry(interruptEntry, mapBlocks)).toEqual([]);
expect(convertClaudeHistoryEntry(assistantNoiseEntry, mapBlocks)).toEqual([]);
expect(await convertClaudeHistoryEntry(interruptEntry, mapBlocks)).toEqual([]);
expect(await convertClaudeHistoryEntry(assistantNoiseEntry, mapBlocks)).toEqual([]);
});
test("skips <local-command-stdout> messages (model switch, /context, etc.)", () => {
test("skips <local-command-stdout> messages (model switch, /context, etc.)", async () => {
// Real entries from Claude Code JSONL history files
const modelSwitch = {
type: "user",
@@ -305,24 +305,24 @@ describe("convertClaudeHistoryEntry", () => {
const mapBlocks = vi.fn().mockReturnValue([]);
expect(convertClaudeHistoryEntry(modelSwitch, mapBlocks)).toEqual([]);
expect(convertClaudeHistoryEntry(modelSwitchWithAnsi, mapBlocks)).toEqual([]);
expect(convertClaudeHistoryEntry(contextDump, mapBlocks)).toEqual([]);
expect(convertClaudeHistoryEntry(planMode, mapBlocks)).toEqual([]);
expect(convertClaudeHistoryEntry(goodbye, mapBlocks)).toEqual([]);
expect(convertClaudeHistoryEntry(empty, mapBlocks)).toEqual([]);
expect(await convertClaudeHistoryEntry(modelSwitch, mapBlocks)).toEqual([]);
expect(await convertClaudeHistoryEntry(modelSwitchWithAnsi, mapBlocks)).toEqual([]);
expect(await convertClaudeHistoryEntry(contextDump, mapBlocks)).toEqual([]);
expect(await convertClaudeHistoryEntry(planMode, mapBlocks)).toEqual([]);
expect(await convertClaudeHistoryEntry(goodbye, mapBlocks)).toEqual([]);
expect(await convertClaudeHistoryEntry(empty, mapBlocks)).toEqual([]);
// Real user messages must NOT be filtered
const realMessage = {
type: "user",
message: { role: "user", content: "fix the bug in auth.ts" },
};
expect(convertClaudeHistoryEntry(realMessage, mapBlocks)).toEqual([
expect(await convertClaudeHistoryEntry(realMessage, mapBlocks)).toEqual([
{ type: "user_message", text: "fix the bug in auth.ts" },
]);
});
test("maps task notifications to synthetic tool calls", () => {
test("maps task notifications to synthetic tool calls", async () => {
const entry = {
type: "system",
subtype: "task_notification",
@@ -333,7 +333,7 @@ describe("convertClaudeHistoryEntry", () => {
output_file: "/tmp/bg-fail-1.txt",
};
expect(convertClaudeHistoryEntry(entry, () => [])).toEqual([
expect(await convertClaudeHistoryEntry(entry, () => [])).toEqual([
{
type: "tool_call",
callId: "task_notification_task-note-system-1",
@@ -357,7 +357,7 @@ describe("convertClaudeHistoryEntry", () => {
]);
});
test("maps queue-operation task notifications to synthetic tool calls", () => {
test("maps queue-operation task notifications to synthetic tool calls", async () => {
const entry = {
type: "queue-operation",
operation: "enqueue",
@@ -372,7 +372,7 @@ describe("convertClaudeHistoryEntry", () => {
].join("\n"),
};
expect(convertClaudeHistoryEntry(entry, () => [])).toEqual([
expect(await convertClaudeHistoryEntry(entry, () => [])).toEqual([
{
type: "tool_call",
callId: "task_notification_task-note-queue-1",
@@ -396,7 +396,7 @@ describe("convertClaudeHistoryEntry", () => {
]);
});
test("passes assistant content blocks through to the mapper", () => {
test("passes assistant content blocks through to the mapper", async () => {
const entry = {
type: "assistant",
message: {
@@ -414,7 +414,7 @@ describe("convertClaudeHistoryEntry", () => {
];
const mapBlocks = vi.fn().mockReturnValue(mappedTimeline);
expect(convertClaudeHistoryEntry(entry, mapBlocks)).toEqual(mappedTimeline);
expect(await convertClaudeHistoryEntry(entry, mapBlocks)).toEqual(mappedTimeline);
expect(mapBlocks).toHaveBeenCalledWith(entry.message.content);
});
});
@@ -1208,7 +1208,7 @@ describe("ClaudeAgentSession features", () => {
input: Record<string, unknown>,
options: Record<string, unknown>,
): Promise<PermissionResult>;
translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
translateMessageToEvents(message: SDKMessage): Promise<AgentStreamEvent[]>;
};
try {
@@ -1248,7 +1248,7 @@ describe("ClaudeAgentSession features", () => {
),
).resolves.toMatchObject({ behavior: "deny", interrupt: undefined });
internal.translateMessageToEvents({
await internal.translateMessageToEvents({
type: "command_lifecycle",
command_uuid: steerUuid,
state: "started",
@@ -2395,7 +2395,7 @@ describe("ClaudeAgentSession context window usage", () => {
test("emits canonical task snapshots from Claude TaskCreate results", async () => {
const session = await createSessionForTest();
try {
session.translateMessageToEvents({
await session.translateMessageToEvents({
type: "assistant",
message: {
content: [
@@ -2409,7 +2409,7 @@ describe("ClaudeAgentSession context window usage", () => {
},
} as unknown as SDKMessage);
const events = session.translateMessageToEvents({
const events = await session.translateMessageToEvents({
type: "user",
message: {
content: [{ type: "tool_result", tool_use_id: "task-create-1", content: "created" }],
@@ -3611,9 +3611,9 @@ describe("ClaudeAgentSession context window usage", () => {
]);
try {
const compactEvents = (session as unknown as TestClaudeSession).translateMessageToEvents(
createCompactBoundary(),
);
const compactEvents = await (
session as unknown as TestClaudeSession
).translateMessageToEvents(createCompactBoundary());
expect(compactEvents).toContainEqual(
expect.objectContaining({
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 () => {
const session = await createSessionForTest();
const events = session.translateMessageToEvents({
const events = await session.translateMessageToEvents({
type: "result",
subtype: "success",
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 () => {
const session = await createSessionForTest();
const events = session.translateMessageToEvents({
const events = await session.translateMessageToEvents({
type: "result",
subtype: "success",
result: "Here is the answer.",
@@ -2546,7 +2546,11 @@ class ClaudeAgentSession implements AgentSession {
this.input.push(sdkMessage);
setTimeout(() => {
if (this.activeForegroundTurnId === turnId) {
this.emitSubmittedUserMessage(sdkMessage, turnId, options?.clientMessageId);
void this.emitSubmittedUserMessage(sdkMessage, turnId, options?.clientMessageId).catch(
(error: unknown) => {
this.logger.warn({ err: error }, "Failed to emit submitted Claude message");
},
);
}
}, 0);
} catch (error) {
@@ -4134,7 +4138,7 @@ class ClaudeAgentSession implements AgentSession {
consecutiveInterruptAbortRecoveries = 0;
if (await this.handleMissingResumedConversation(message, activeQuery)) return true;
if (!this.ownsQuery(activeQuery)) return true;
this.routeSdkMessageFromPump(message);
await this.routeSdkMessageFromPump(message);
return false;
} finally {
this.processingSdkMessages--;
@@ -4258,7 +4262,7 @@ class ClaudeAgentSession implements AgentSession {
return !this.closed && this.query === query;
}
private routeSdkMessageFromPump(message: SDKMessage): void {
private async routeSdkMessageFromPump(message: SDKMessage): Promise<void> {
if (this.consumeSessionStateChange(message)) {
return;
}
@@ -4292,7 +4296,7 @@ class ClaudeAgentSession implements AgentSession {
"provider.claude.parsed_event",
);
const events = this.buildPumpedMessageEvents(message, identifiers.messageId, turnId);
const events = await this.buildPumpedMessageEvents(message, identifiers.messageId, turnId);
if (events.length === 0) {
return;
@@ -4327,15 +4331,22 @@ class ClaudeAgentSession implements AgentSession {
this.dispatchEvents(events);
}
private buildPumpedMessageEvents(
private async buildPumpedMessageEvents(
message: SDKMessage,
messageIdHint: string | null,
turnId: string | null,
): AgentStreamEvent[] {
const messageEvents = this.translateMessageToEvents(message, {
): Promise<AgentStreamEvent[]> {
const query = this.query;
const messageEvents = await this.translateMessageToEvents(message, {
suppressAssistantText: true,
suppressReasoning: true,
});
if (
this.closed ||
this.query !== query ||
(this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null) !== turnId
)
return [];
const assistantTimelineEvents = readClaudeParentToolUseId(message)
? []
: this.timelineAssembler
@@ -4480,13 +4491,13 @@ class ClaudeAgentSession implements AgentSession {
return withdrewAll;
}
private translateMessageToEvents(
private async translateMessageToEvents(
message: SDKMessage,
options?: {
suppressAssistantText?: boolean;
suppressReasoning?: boolean;
},
): AgentStreamEvent[] {
): Promise<AgentStreamEvent[]> {
const parentToolUseId = readClaudeParentToolUseId(message);
if (parentToolUseId) {
return this.translateSidechainFrameToEvents(message, parentToolUseId);
@@ -4534,11 +4545,11 @@ class ClaudeAgentSession implements AgentSession {
this.appendSystemMessageEvents(message, events);
break;
case "user":
this.appendUserMessageEvents(message, events);
await this.appendUserMessageEvents(message, events);
this.appendSidechainResultEvents(message, events);
break;
case "assistant": {
const timelineItems = this.mapBlocksToTimeline(message.message.content, {
const timelineItems = await this.mapBlocksToTimeline(message.message.content, {
suppressAssistantText: options?.suppressAssistantText ?? false,
suppressReasoning: options?.suppressReasoning ?? false,
});
@@ -4549,7 +4560,7 @@ class ClaudeAgentSession implements AgentSession {
break;
}
case "stream_event":
this.appendStreamEventEvents(message, events, options);
await this.appendStreamEventEvents(message, events, options);
break;
case "result":
this.appendResultEvents(message, events);
@@ -4651,13 +4662,14 @@ class ClaudeAgentSession implements AgentSession {
}
}
private emitSubmittedUserMessage(
private async emitSubmittedUserMessage(
message: Extract<SDKMessage, { type: "user" }>,
turnId: string,
clientMessageId?: string,
): void {
): Promise<void> {
const events: AgentStreamEvent[] = [];
this.appendUserMessageEvents(message, events);
await this.appendUserMessageEvents(message, events);
if (this.activeForegroundTurnId !== turnId) return;
if (events.length === 0) {
return;
}
@@ -4787,10 +4799,10 @@ class ClaudeAgentSession implements AgentSession {
}
}
private appendUserMessageEvents(
private async appendUserMessageEvents(
message: Extract<SDKMessage, { type: "user" }>,
events: AgentStreamEvent[],
): void {
): Promise<void> {
if (isSyntheticUserEntry(message)) {
return;
}
@@ -4829,7 +4841,7 @@ class ClaudeAgentSession implements AgentSession {
return;
}
if (Array.isArray(content)) {
this.appendUserContentArrayEvents(content, messageId, events);
await this.appendUserContentArrayEvents(content, messageId, events);
}
}
@@ -4855,12 +4867,12 @@ class ClaudeAgentSession implements AgentSession {
events.push({ type: "timeline", item, provider: "claude" });
}
private appendUserContentArrayEvents(
private async appendUserContentArrayEvents(
content: ReadonlyArray<unknown>,
messageId: string | undefined,
events: AgentStreamEvent[],
): void {
const timelineItems = this.mapBlocksToTimeline(content, {
): Promise<void> {
const timelineItems = await this.mapBlocksToTimeline(content, {
textMessageType: "user_message",
});
for (const item of timelineItems) {
@@ -4876,16 +4888,16 @@ class ClaudeAgentSession implements AgentSession {
}
}
private appendStreamEventEvents(
private async appendStreamEventEvents(
message: Extract<SDKMessage, { type: "stream_event" }>,
events: AgentStreamEvent[],
options: { suppressAssistantText?: boolean; suppressReasoning?: boolean } | undefined,
): void {
): Promise<void> {
const usageUpdatedEvent = this.contextUsage.buildStreamUsageEvent(message.event);
if (usageUpdatedEvent) {
events.push(usageUpdatedEvent);
}
const timelineItems = this.mapPartialEvent(message.event, {
const timelineItems = await this.mapPartialEvent(message.event, {
suppressAssistantText: options?.suppressAssistantText ?? false,
suppressReasoning: options?.suppressReasoning ?? false,
});
@@ -5351,7 +5363,7 @@ class ClaudeAgentSession implements AgentSession {
): Promise<void> {
const timeline: PersistedTimelineEntry[] = [];
for await (const entry of entries) {
this.ingestPersistedHistoryEntry(entry, timeline, replay);
await this.ingestPersistedHistoryEntry(entry, timeline, replay);
}
if (timeline.length > 0) {
@@ -5411,11 +5423,11 @@ class ClaudeAgentSession implements AgentSession {
return replay;
}
private ingestPersistedHistoryEntry(
private async ingestPersistedHistoryEntry(
entry: ClaudeHistoryEntry,
timeline: PersistedTimelineEntry[],
replay: ClaudeReplayOwnership,
): void {
): Promise<void> {
if (entry.isSidechain) {
return;
}
@@ -5429,7 +5441,7 @@ class ClaudeAgentSession implements AgentSession {
? replay.toolOwners.get(notificationToolUseId)
: undefined;
if (notificationOwner) {
for (const item of this.convertHistoryEntry(entry)) {
for (const item of await this.convertHistoryEntry(entry)) {
this.persistedProviderSubagentEvents.push({
type: "provider_subagent",
provider: "claude",
@@ -5446,7 +5458,7 @@ class ClaudeAgentSession implements AgentSession {
const taskSnapshot = this.taskState.observe(entry);
const items = [
...(taskSnapshot ? [taskSnapshot] : []),
...this.convertHistoryEntry(entry).map((item) =>
...(await this.convertHistoryEntry(entry)).map((item) =>
labelReplayedSubagentCard(item, replay.subagentToolCalls),
),
];
@@ -5483,10 +5495,10 @@ class ClaudeAgentSession implements AgentSession {
});
}
private convertHistoryEntry(entry: ClaudeHistoryEntry): AgentTimelineItem[] {
return convertClaudeHistoryEntry(entry, (content) => this.mapBlocksToTimeline(content)).map(
limitAgentTimelineItemContent,
);
private async convertHistoryEntry(entry: ClaudeHistoryEntry): Promise<AgentTimelineItem[]> {
return (
await convertClaudeHistoryEntry(entry, (content) => this.mapBlocksToTimeline(content))
).map(limitAgentTimelineItemContent);
}
// Maps Claude content blocks into AgentTimelineItems.
@@ -5501,14 +5513,14 @@ class ClaudeAgentSession implements AgentSession {
//
// NOTE: convertClaudeHistoryEntry uses extractUserMessageText directly instead of this function
// for user entries. Both paths must produce equivalent user_message items.
private mapBlocksToTimeline(
private async mapBlocksToTimeline(
content: string | ReadonlyArray<unknown>,
options?: {
textMessageType?: "assistant_message" | "user_message";
suppressAssistantText?: boolean;
suppressReasoning?: boolean;
},
): AgentTimelineItem[] {
): Promise<AgentTimelineItem[]> {
const textMessageType = options?.textMessageType ?? "assistant_message";
const suppressText =
textMessageType === "assistant_message" && (options?.suppressAssistantText ?? false);
@@ -5535,7 +5547,7 @@ class ClaudeAgentSession implements AgentSession {
if (!isClaudeContentChunk(block) || this.mapSubagentHandbackBlock(block, items)) {
continue;
}
this.mapBlockToTimeline(block, {
await this.mapBlockToTimeline(block, {
items,
userTextParts,
textMessageType,
@@ -5580,7 +5592,7 @@ class ClaudeAgentSession implements AgentSession {
}
}
private mapBlockToTimeline(
private async mapBlockToTimeline(
block: ClaudeContentChunk,
context: {
items: AgentTimelineItem[];
@@ -5589,7 +5601,7 @@ class ClaudeAgentSession implements AgentSession {
suppressText: boolean;
suppressReasoning: boolean;
},
): void {
): Promise<void> {
switch (block.type) {
case "text":
case "text_delta":
@@ -5613,7 +5625,7 @@ class ClaudeAgentSession implements AgentSession {
case "code_execution_tool_result":
case "bash_code_execution_tool_result":
case "text_editor_code_execution_tool_result":
this.handleToolResult(block, context.items);
await this.handleToolResult(block, context.items);
break;
default:
break;
@@ -5659,7 +5671,10 @@ class ClaudeAgentSession implements AgentSession {
);
}
private handleToolResult(block: ClaudeContentChunk, items: AgentTimelineItem[]): void {
private async handleToolResult(
block: ClaudeContentChunk,
items: AgentTimelineItem[],
): Promise<void> {
const entry =
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;
@@ -5692,7 +5707,7 @@ class ClaudeAgentSession implements AgentSession {
this.rememberSettledSubagentToolCall(settledToolCall, entry);
for (const image of images) {
const imageItem = renderProviderImageOutputAsAssistantMarkdown(image, {
const imageItem = await renderProviderImageOutputAsAssistantMarkdown(image, {
materialize: materializeProviderImage,
});
if (imageItem) {
@@ -5916,13 +5931,13 @@ class ClaudeAgentSession implements AgentSession {
return false;
}
private mapPartialEvent(
private async mapPartialEvent(
event: SDKPartialAssistantMessage["event"],
options?: {
suppressAssistantText?: boolean;
suppressReasoning?: boolean;
},
): AgentTimelineItem[] {
): Promise<AgentTimelineItem[]> {
if (this.updatePartialEventToolState(event)) {
return [];
}
@@ -5930,14 +5945,14 @@ class ClaudeAgentSession implements AgentSession {
switch (event.type) {
case "content_block_start":
return isClaudeContentChunk(event.content_block)
? this.mapBlocksToTimeline([event.content_block], {
? await this.mapBlocksToTimeline([event.content_block], {
suppressAssistantText: options?.suppressAssistantText,
suppressReasoning: options?.suppressReasoning,
})
: [];
case "content_block_delta":
return isClaudeContentChunk(event.delta)
? this.mapBlocksToTimeline([event.delta], {
? await this.mapBlocksToTimeline([event.delta], {
suppressAssistantText: options?.suppressAssistantText,
suppressReasoning: options?.suppressReasoning,
})
@@ -6300,12 +6315,14 @@ interface ClaudeHistoryEntry {
[key: string]: unknown;
}
function mapAssistantHistoryBlocksWithMessageId(
async function mapAssistantHistoryBlocksWithMessageId(
entry: ClaudeHistoryEntry,
content: string | ClaudeContentChunk[],
mapBlocks: (content: string | ClaudeContentChunk[]) => AgentTimelineItem[],
): AgentTimelineItem[] {
const items = mapBlocks(content);
mapBlocks: (
content: string | ClaudeContentChunk[],
) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>,
): Promise<AgentTimelineItem[]> {
const items = await mapBlocks(content);
const assistantMessageId =
typeof entry.uuid === "string" && entry.uuid.length > 0 ? entry.uuid : null;
if (!assistantMessageId) {
@@ -6373,10 +6390,12 @@ function isProviderImageMessage(item: AgentTimelineItem): boolean {
return item.type === "assistant_message" && isProviderImageMarkdown(item.text);
}
export function convertClaudeHistoryEntry(
export async function convertClaudeHistoryEntry(
entry: ClaudeHistoryEntry,
mapBlocks: (content: string | ClaudeContentChunk[]) => AgentTimelineItem[],
): AgentTimelineItem[] {
mapBlocks: (
content: string | ClaudeContentChunk[],
) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>,
): Promise<AgentTimelineItem[]> {
const preamble = convertClaudeHistoryEntryPreamble(entry);
if ("shortCircuit" in preamble) {
return preamble.shortCircuit;
@@ -6415,7 +6434,7 @@ export function convertClaudeHistoryEntry(
}
if (hasToolBlock && normalizedBlocks) {
const mapped = mapBlocks(normalizedBlocks);
const mapped = await mapBlocks(normalizedBlocks);
if (entry.type === "user") {
// 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
@@ -6429,7 +6448,7 @@ export function convertClaudeHistoryEntry(
}
if (entry.type === "assistant" && contentValue) {
return mapAssistantHistoryBlocksWithMessageId(entry, contentValue, mapBlocks);
return await mapAssistantHistoryBlocksWithMessageId(entry, contentValue, mapBlocks);
}
return timeline;
@@ -1,3 +1,9 @@
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 { describe, expect, it } from "vitest";
@@ -660,3 +666,34 @@ 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(
subagent: ClaudeReplaySubagentInput,
parent: ClaudeReplayParentFacts,
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[],
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>,
toolOwners: Map<string, string>,
parentSubagentId?: string,
): Promise<SubagentObservation[]> {
@@ -280,7 +280,7 @@ async function observeSubagent(
childTerminalStatus = entry.message?.stop_reason === "end_turn" ? "completed" : null;
}
recordReplayToolOwners(toolOwners, entry, link.id);
for (const item of convertEntry(entry)) {
for (const item of await convertEntry(entry)) {
timeline.push({ kind: "timeline", id: link.id, item, ...(timestamp ? { timestamp } : {}) });
}
}
@@ -323,7 +323,7 @@ function recordReplayToolOwners(
export async function observeReplaySubagents(input: {
subagents: readonly ClaudeReplaySubagentInput[];
parent: ClaudeReplayParentFacts;
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[];
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>;
}): Promise<{ observations: SubagentObservation[]; toolOwners: ReadonlyMap<string, string> }> {
const observations: SubagentObservation[] = [];
const toolOwners = new Map<string, string>();
@@ -1,3 +1,9 @@
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 { ProviderSubagentStore } from "../../../provider-subagents/store.js";
@@ -195,3 +201,29 @@ 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,
Iterable<ClaudeReplayEntry> | AsyncIterable<ClaudeReplayEntry>
>;
convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[];
convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>;
}): Promise<SubagentObservation[]> {
const toolCallIdByRunId = await readWorkflowLinks(input.parentEntries);
const observations: SubagentObservation[] = [];
@@ -122,7 +122,7 @@ async function observeWorkflowTimeline(input: {
id: string;
result: unknown;
entries: Iterable<ClaudeReplayEntry> | AsyncIterable<ClaudeReplayEntry>;
convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[];
convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>;
finishedAt?: string;
}): Promise<SubagentObservation[]> {
const observations: SubagentObservation[] = [];
@@ -130,7 +130,7 @@ async function observeWorkflowTimeline(input: {
for await (const entry of input.entries) {
const timestamp = normalizeProviderReplayTimestamp(entry.timestamp);
const items = input.convertEntry?.(entry) ?? [];
const items = (await input.convertEntry?.(entry)) ?? [];
for (const item of items) {
if (item.type === "assistant_message") replayedAssistantText.add(item.text.trim());
observations.push({
File diff suppressed because it is too large Load Diff
@@ -1939,12 +1939,12 @@ function codexImageOutputFromResult(result: unknown): ProviderImageOutput | null
};
}
function mapCodexThreadImageItem(
async function mapCodexThreadImageItem(
normalizedType: string,
normalizedItem: Record<string, unknown>,
): AgentTimelineItem | null {
): Promise<AgentTimelineItem | null> {
if (normalizedType === "imageView") {
return renderProviderImageOutputAsAssistantMarkdown({
return await renderProviderImageOutputAsAssistantMarkdown({
path: firstStringField(normalizedItem, ["path"]),
});
}
@@ -1960,7 +1960,7 @@ function mapCodexThreadImageItem(
const savedPath = firstStringField(normalizedItem, ["savedPath", "saved_path"]);
const result = codexImageOutputFromResult(normalizedItem.result);
return renderProviderImageOutputAsAssistantMarkdown(
return await renderProviderImageOutputAsAssistantMarkdown(
{
path: savedPath ?? result?.path ?? null,
url: result?.url ?? null,
@@ -1982,10 +1982,10 @@ function mapCodexAgentMessage(item: Record<string, unknown>): AgentTimelineItem
};
}
export function threadItemToTimeline(
export async function threadItemToTimeline(
item: unknown,
options?: { includeUserMessage?: boolean; cwd?: string | null },
): AgentTimelineItem | null {
): Promise<AgentTimelineItem | null> {
const itemRecord = toObjectRecord(item);
if (!itemRecord) return null;
const includeUserMessage = options?.includeUserMessage ?? true;
@@ -1999,7 +1999,7 @@ export function threadItemToTimeline(
: itemRecord;
if (normalizedType === "imageView" || normalizedType === "imageGeneration") {
return mapCodexThreadImageItem(normalizedType, normalizedItem);
return await mapCodexThreadImageItem(normalizedType, normalizedItem);
}
if (normalizedType && CODEX_TOOL_THREAD_ITEM_TYPES.has(normalizedType)) {
return mapCodexToolCallFromThreadItem(normalizedItem, { cwd });
@@ -2024,7 +2024,7 @@ export function threadItemToTimeline(
}
}
function mcpToolResultImagesToTimeline(item: unknown): AgentTimelineItem[] {
async function mcpToolResultImagesToTimeline(item: unknown): Promise<AgentTimelineItem[]> {
const itemRecord = toObjectRecord(item);
if (!itemRecord) {
return [];
@@ -2037,24 +2037,25 @@ function mcpToolResultImagesToTimeline(item: unknown): AgentTimelineItem[] {
}
const { images } = splitCodexMcpToolResultImages(itemRecord.result);
return images
.map((image) =>
renderProviderImageOutputAsAssistantMarkdown(image, {
materialize: materializeProviderImage,
}),
)
.filter((timelineItem): timelineItem is AgentTimelineItem => timelineItem !== null);
const items: AgentTimelineItem[] = [];
for (const image of images) {
const imageItem = await renderProviderImageOutputAsAssistantMarkdown(image, {
materialize: materializeProviderImage,
});
if (imageItem) items.push(imageItem);
}
return items;
}
function threadItemToTimelineEntries(
async function threadItemToTimelineEntries(
item: unknown,
options?: { includeUserMessage?: boolean; cwd?: string | null },
): AgentTimelineItem[] {
const timelineItem = threadItemToTimeline(item, options);
): Promise<AgentTimelineItem[]> {
const timelineItem = await threadItemToTimeline(item, options);
if (!timelineItem) {
return [];
}
return [timelineItem, ...mcpToolResultImagesToTimeline(item)];
return [timelineItem, ...(await mcpToolResultImagesToTimeline(item))];
}
const CodexThreadReadResponseSchema = z
@@ -2103,7 +2104,7 @@ async function loadCodexThreadHistoryTimeline(params: {
historicalSubAgentActivity.agentThreadId,
);
if (existingIndex !== undefined) {
const activityTimelineItem = threadItemToTimeline(item, { cwd: params.cwd });
const activityTimelineItem = await threadItemToTimeline(item, { cwd: params.cwd });
updateHistoricalSubAgentActivity(
timeline,
existingIndex,
@@ -2116,7 +2117,7 @@ async function loadCodexThreadHistoryTimeline(params: {
continue;
}
}
for (const timelineItem of threadItemToTimelineEntries(item, { cwd: params.cwd })) {
for (const timelineItem of await threadItemToTimelineEntries(item, { cwd: params.cwd })) {
const timestamp =
readCodexHistoryTimestamp(item) ?? readCodexTurnHistoryTimestamp(turn, timelineItem);
const settledTimelineItem =
@@ -3290,10 +3291,12 @@ export async function codexAppServerTurnInputFromPrompt(
}
if (block.type === "image") {
try {
const filePath = materializeProviderImage({
data: block.data,
mimeType: block.mimeType,
}).path;
const filePath = (
await materializeProviderImage({
data: block.data,
mimeType: block.mimeType,
})
).path;
output.push({ type: "localImage", path: filePath });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
@@ -4370,6 +4373,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.dismissPendingPlanApprovals("Dismissed by a new prompt");
try {
await this.pendingNotifications;
await this.connect();
if (!this.client) {
throw new Error("Codex client not initialized");
@@ -5437,7 +5441,21 @@ export class CodexAppServerAgentSession implements AgentSession {
return `codex-turn-${this.nextTurnOrdinal++}`;
}
private handleNotification(method: string, params: unknown): void {
private pendingNotifications: Promise<void> = Promise.resolve();
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);
if (method === "serverRequest/resolved" && typeof notificationParams?.requestId === "number") {
const requestId = this.mcpElicitationPermissionIds.get(notificationParams.requestId);
@@ -5467,13 +5485,16 @@ export class CodexAppServerAgentSession implements AgentSession {
return;
}
if (route.kind === "sub_agent") {
this.dispatchSubAgentNotification(parsed, route.callId);
await this.dispatchSubAgentNotification(parsed, route.callId);
return;
}
this.dispatchParsedNotification(parsed);
await this.dispatchParsedNotification(parsed);
}
private dispatchSubAgentNotification(parsed: ParsedCodexNotification, callId: string): void {
private async dispatchSubAgentNotification(
parsed: ParsedCodexNotification,
callId: string,
): Promise<void> {
switch (parsed.kind) {
case "thread_started":
this.emitSubAgentActivityUpdate(callId, "running", { reopen: true });
@@ -5484,7 +5505,7 @@ export class CodexAppServerAgentSession implements AgentSession {
case "reasoning_delta":
case "item_started":
case "item_completed":
this.dispatchParsedNotification(parsed);
await this.dispatchParsedNotification(parsed);
return;
case "exec_command_output_delta":
case "file_change_output_delta":
@@ -5510,7 +5531,7 @@ export class CodexAppServerAgentSession implements AgentSession {
}
}
private dispatchParsedNotification(parsed: ParsedCodexNotification): void {
private async dispatchParsedNotification(parsed: ParsedCodexNotification): Promise<void> {
if (isCodexDeltaNotification(parsed)) {
this.handleCodexDeltaNotification(parsed);
return;
@@ -5555,10 +5576,10 @@ export class CodexAppServerAgentSession implements AgentSession {
this.handlePatchApplyCompletedNotification(parsed);
return;
case "item_completed":
this.handleItemCompletedNotification(parsed);
await this.handleItemCompletedNotification(parsed);
return;
case "item_started":
this.handleItemStartedNotification(parsed);
await this.handleItemStartedNotification(parsed);
return;
case "invalid_payload":
this.warnInvalidNotificationPayload(parsed.method, parsed.params);
@@ -5600,7 +5621,7 @@ export class CodexAppServerAgentSession implements AgentSession {
pending.push(parsed);
}
private replayPendingSubAgentNotifications(threadIds: readonly string[]): void {
private async replayPendingSubAgentNotifications(threadIds: readonly string[]): Promise<void> {
for (const threadId of threadIds) {
const pending = this.pendingSubAgentNotificationsByThreadId.get(threadId);
if (!pending) {
@@ -5612,7 +5633,7 @@ export class CodexAppServerAgentSession implements AgentSession {
continue;
}
for (const parsed of pending) {
this.dispatchSubAgentNotification(parsed, callId);
await this.dispatchSubAgentNotification(parsed, callId);
}
}
}
@@ -6578,9 +6599,13 @@ export class CodexAppServerAgentSession implements AgentSession {
}
}
private handleItemCompletedNotification(
private ownsNotificationTurn(turnId: string | null): boolean {
return !this.closed && this.activeForegroundTurnId === turnId;
}
private async handleItemCompletedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "item_completed" }>,
): void {
): Promise<void> {
// Codex emits mirrored lifecycle notifications via both `codex/event/item_*`
// and canonical `item/*`. Render ordinary items only from the canonical
// channel, but accept a legacy-only child announcement so it can establish
@@ -6590,20 +6615,23 @@ export class CodexAppServerAgentSession implements AgentSession {
}
this.receiveAsyncQuestion(parsed.threadId, parsed.item);
if (this.isUserMessageItem(parsed.item)) {
this.handleUserMessageItem(parsed);
await this.handleUserMessageItem(parsed);
return;
}
const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
if (this.handleCompletedSpecialItem(parsed, childSubAgentCallId)) {
return;
}
const timelineItem = threadItemToTimeline(parsed.item, {
const foregroundTurnId = this.activeForegroundTurnId;
const timelineItem = await threadItemToTimeline(parsed.item, {
includeUserMessage: false,
cwd: this.config.cwd ?? null,
});
if (!timelineItem) {
return;
}
const imageItems = await mcpToolResultImagesToTimeline(parsed.item);
if (!this.ownsNotificationTurn(foregroundTurnId)) return;
const registeredChildThreadIds =
timelineItem.type === "tool_call"
? this.registerSubAgentToolCall({
@@ -6613,12 +6641,11 @@ export class CodexAppServerAgentSession implements AgentSession {
parentSubagentId: resolveCodexParentSubagentId(childSubAgentCallId, parsed.threadId),
})
: [];
const imageItems = mcpToolResultImagesToTimeline(parsed.item);
if (childSubAgentCallId) {
this.emitCompletedProviderSubagentItem(parsed, timelineItem);
this.handleSubAgentChildItemCompleted(childSubAgentCallId, parsed.item.id, timelineItem);
this.emitProviderSubagentTimelineItems(parsed.threadId, imageItems);
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
return;
}
const normalizedItemType = normalizeCodexThreadItemType(
@@ -6626,7 +6653,7 @@ export class CodexAppServerAgentSession implements AgentSession {
);
const itemId = parsed.item.id;
if (this.shouldSkipCompletedThreadItem(timelineItem, normalizedItemType, itemId)) {
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
return;
}
if (this.consumeStreamedTextCompletion(timelineItem, itemId)) {
@@ -6637,7 +6664,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.emittedItemCompletedIds.add(itemId);
this.emittedItemStartedIds.delete(itemId);
}
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
return;
}
this.applyBufferedDeltaTextToTimelineItem(timelineItem, itemId);
@@ -6666,7 +6693,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.pendingCommandOutputDeltas.delete(itemId);
this.pendingFileChangeOutputDeltas.delete(itemId);
}
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
}
private consumeStreamedTextCompletion(
@@ -6738,14 +6765,14 @@ export class CodexAppServerAgentSession implements AgentSession {
}
}
private handleItemStartedNotification(
private async handleItemStartedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "item_started" }>,
): void {
): Promise<void> {
if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) {
return;
}
if (this.isUserMessageItem(parsed.item)) {
this.handleUserMessageItem(parsed);
await this.handleUserMessageItem(parsed);
return;
}
const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
@@ -6767,7 +6794,7 @@ export class CodexAppServerAgentSession implements AgentSession {
if (this.handleRegisteredSubAgentActivity(parsed.item)) {
return;
}
const timelineItem = threadItemToTimeline(parsed.item, {
const timelineItem = await threadItemToTimeline(parsed.item, {
includeUserMessage: false,
cwd: this.config.cwd ?? null,
});
@@ -6786,7 +6813,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.upsertSubAgentChildItem(childSubAgentCallId, parsed.item.id, timelineItem);
}
this.emitSubAgentActivityUpdate(childSubAgentCallId, "running");
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
return;
}
const normalizedItemType = normalizeCodexThreadItemType(
@@ -6810,14 +6837,14 @@ export class CodexAppServerAgentSession implements AgentSession {
this.pendingCommandOutputDeltas.delete(itemId);
this.pendingFileChangeOutputDeltas.delete(itemId);
}
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
}
private handleUserMessageItem(
private async handleUserMessageItem(
parsed: Extract<ParsedCodexNotification, { kind: "item_started" | "item_completed" }>,
): void {
): Promise<void> {
const itemId = parsed.item.id;
const timelineItem = threadItemToTimeline(parsed.item, {
const timelineItem = await threadItemToTimeline(parsed.item, {
includeUserMessage: true,
cwd: this.config.cwd ?? null,
});
@@ -1343,13 +1343,14 @@ describe("OMP agent client and session", () => {
expect(omp.runtimeLaunches()).toHaveLength(1);
});
test("closes a replacement process if the session closes during relaunch", async () => {
test("rejects a pending recovery start and closes its processes when the session closes", async () => {
const omp = new OmpHarness();
await omp.start();
omp.processExit("OMP RPC process exited with code 1 and signal null");
const turn = omp.startTurnDetached("continue");
const rejected = expect(turn).rejects.toThrow("closed");
await omp.close();
await turn;
await rejected;
await waitForImmediate();
expect(omp.runtimeSessions().every((session) => session.closed)).toBe(true);
});
@@ -1638,3 +1639,93 @@ test.each([
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;
}
function renderTextOnlyImageHint(image: { data: string; mimeType: string }): string {
async function renderTextOnlyImageHint(image: { data: string; mimeType: string }): Promise<string> {
try {
const materialized = materializeProviderImage({
const materialized = await materializeProviderImage({
data: image.data,
mimeType: image.mimeType,
});
@@ -329,10 +329,10 @@ function renderTextOnlyImageHint(image: { data: string; mimeType: string }): str
}
}
function convertPromptInput(
async function convertPromptInput(
prompt: AgentPromptInput,
options: { model: OmpModel | null | undefined },
): OmpPromptPayload {
): Promise<OmpPromptPayload> {
if (typeof prompt === "string") {
return { text: prompt };
}
@@ -355,7 +355,7 @@ function convertPromptInput(
mimeType: block.mimeType,
});
} else {
textParts.push(renderTextOnlyImageHint(block));
textParts.push(await renderTextOnlyImageHint(block));
}
continue;
}
@@ -868,11 +868,9 @@ export class OmpAgentSession implements AgentSession {
throw new Error("An OMP turn is already active");
}
const payload = convertPromptInput(prompt, { model: this.state.model });
const turnId = randomUUID();
this.live = true;
this.activeTurnId = turnId;
this.rememberClientMessage(options?.clientMessageId ?? null, payload.text);
this.activeAssistantMessageId = null;
this.activeTurnTerminalAssistantMessage = null;
this.activeTurnStarted = false;
@@ -880,6 +878,10 @@ export class OmpAgentSession implements AgentSession {
this.activeTurnHasUserMessage = false;
this.activePromptRequestId = null;
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.usagePoller.startTurn();
if (announceTurn) {
@@ -946,7 +948,9 @@ export class OmpAgentSession implements AgentSession {
): Promise<SteerResult> {
if (this.closed || this.activeTurnId !== options.expectedTurnId)
return { status: "unavailable" };
const payload = convertPromptInput(prompt, { model: this.state.model });
const payload = await convertPromptInput(prompt, { model: this.state.model });
if (this.closed || this.activeTurnId !== options.expectedTurnId)
return { status: "unavailable" };
if (this.parseSlashCommandInput(payload.text)) return { status: "unavailable" };
const submission = this.rememberClientMessage(options.clientMessageId ?? null, payload.text);
try {
@@ -1,3 +1,4 @@
import { collectTimelineSnapshot } from "./test-utils/v2-timeline.js";
import type { SessionMessageAssistant, SessionMessageUser } from "@opencode/client";
import { V2Timeline } from "./v2/timeline.js";
import { describe, expect, it } from "vitest";
@@ -1560,7 +1561,7 @@ describe("translateOpenCodeEvent", () => {
});
describe("OpenCode v2 history reconciliation", () => {
it("keeps client message identity on live echoes but omits it from history", () => {
it("keeps client message identity on live echoes but omits it from history", async () => {
const user: SessionMessageUser = {
id: "native-message",
type: "user",
@@ -1568,13 +1569,13 @@ describe("OpenCode v2 history reconciliation", () => {
time: { created: 1 },
metadata: { paseoClientMessageId: "client-message" },
};
expect(new V2Timeline().messages([user])).toMatchObject([
expect(await collectTimelineSnapshot(new V2Timeline(), [user])).toMatchObject([
{ item: { clientMessageId: "client-message", messageId: "native-message" } },
]);
expect(new V2Timeline(false).messages([user])).toMatchObject([
expect(await collectTimelineSnapshot(new V2Timeline(false), [user])).toMatchObject([
{ item: { type: "user_message", messageId: "native-message" } },
]);
const [event] = new V2Timeline(false).messages([user]);
const [event] = await collectTimelineSnapshot(new V2Timeline(false), [user]);
expect(event && "item" in event && event.item).not.toHaveProperty("clientMessageId");
});
function assistant(text: string): SessionMessageAssistant {
@@ -1587,31 +1588,35 @@ describe("OpenCode v2 history reconciliation", () => {
content: [{ type: "text", text }],
};
}
it("emits only the missing suffix after reconnect, even if an intermediate snapshot lags", () => {
it("emits only the missing suffix after reconnect, even if an intermediate snapshot lags", async () => {
const timeline = new V2Timeline();
const text = (messages: ReturnType<V2Timeline["messages"]>) =>
const text = (messages: Awaited<ReturnType<typeof collectTimelineSnapshot>>) =>
messages.flatMap((event) =>
event.type === "timeline" && event.item.type === "assistant_message"
? [event.item.text]
: [],
);
expect(text(timeline.messages([assistant("hello")]))).toEqual(["hello"]);
expect(timeline.messages([assistant("hel")])).toEqual([]);
expect(text(timeline.messages([assistant("hello world")]))).toEqual([" world"]);
expect(timeline.messages([assistant("hello world")])).toEqual([]);
expect(text(await collectTimelineSnapshot(timeline, [assistant("hello")]))).toEqual(["hello"]);
expect(await collectTimelineSnapshot(timeline, [assistant("hel")])).toEqual([]);
expect(text(await collectTimelineSnapshot(timeline, [assistant("hello world")]))).toEqual([
" world",
]);
expect(await collectTimelineSnapshot(timeline, [assistant("hello world")])).toEqual([]);
});
it("keeps reasoning separate from assistant output", () => {
it("keeps reasoning separate from assistant output", async () => {
const timeline = new V2Timeline();
const message = assistant("answer");
message.content.unshift({ type: "reasoning", text: "thinking" });
expect(
timeline.messages([message]).map((event) => (event.type === "timeline" ? event.item : null)),
(await collectTimelineSnapshot(timeline, [message])).map((event) =>
event.type === "timeline" ? event.item : null,
),
).toEqual([
{ type: "reasoning", text: "thinking" },
{ type: "assistant_message", text: "answer", messageId: "message" },
]);
});
it("emits tool state transitions once and keeps their call identity", () => {
it("emits tool state transitions once and keeps their call identity", async () => {
const timeline = new V2Timeline();
const message = assistant("");
message.content = [
@@ -1623,7 +1628,7 @@ describe("OpenCode v2 history reconciliation", () => {
state: { status: "running", input: { command: "pwd" }, metadata: {} },
},
];
expect(timeline.messages([message])).toMatchObject([
expect(await collectTimelineSnapshot(timeline, [message])).toMatchObject([
{ item: { type: "tool_call", callId: "call", status: "running" } },
]);
message.content = [
@@ -1639,9 +1644,9 @@ describe("OpenCode v2 history reconciliation", () => {
},
},
];
expect(timeline.messages([message])).toMatchObject([
expect(await collectTimelineSnapshot(timeline, [message])).toMatchObject([
{ item: { type: "tool_call", callId: "call", status: "completed" } },
]);
expect(timeline.messages([message])).toEqual([]);
expect(await collectTimelineSnapshot(timeline, [message])).toEqual([]);
});
});
@@ -1,3 +1,4 @@
import { collectTimelineSnapshot } from "./v2-timeline.js";
import { TimelineProjection } from "../../../timeline-projection.js";
import { V2Timeline } from "../v2/timeline.js";
@@ -21,16 +22,18 @@ for (let index = 0; index < chunks; index++) {
const event =
mode === "delta"
? timeline.delta({ ...part, delta })
: timeline.messages([
{
id: "answer",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "model" },
time: { created: 2 },
content: [{ type: "reasoning", text }],
},
])[0];
: (
await collectTimelineSnapshot(timeline, [
{
id: "answer",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "model" },
time: { created: 2 },
content: [{ type: "reasoning", text }],
},
])
)[0];
if (event?.type !== "timeline") throw new Error("Expected a timeline event");
projection.append({
seq: index + 1,
@@ -0,0 +1,15 @@
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.options.emit({ type: "provider_subagent", provider: "opencode", event: presentation });
}
for (const event of timeline.messages(await messages(this.options.client(), info.id))) {
await timeline.messages(await messages(this.options.client(), info.id), (event) => {
if (event.type === "timeline")
this.options.emit({
type: "provider_subagent",
provider: "opencode",
event: { type: "timeline", id: info.id, item: event.item, timestamp: event.timestamp },
});
}
});
}
}
@@ -131,7 +131,7 @@ export class OpenCodeV2Session implements AgentSession {
await this.configureConnection();
const location = { directory: this.config.cwd };
this.modes = modesFromV2((await this.client.agent.list({ location })).data);
this.timeline.messages(await messages(this.client, this.id));
await this.timeline.messages(await messages(this.client, this.id), () => {});
await this.startStream();
await this.children.reconcile(this.id);
}
@@ -261,7 +261,11 @@ export class OpenCodeV2Session implements AgentSession {
async *streamHistory() {
await this.reconnectIfExited();
const history = new V2Timeline(false);
yield* history.messages(await messages(this.client, this.id));
const events: AgentStreamEvent[] = [];
await history.messages(await messages(this.client, this.id), (event) => {
events.push(event);
});
yield* events;
}
async getRuntimeInfo() {
await this.reconnectIfExited();
@@ -417,7 +421,7 @@ export class OpenCodeV2Session implements AgentSession {
]);
this.info = info;
this.history = history;
for (const event of this.timeline.messages(history)) this.emitTimeline(event);
await this.timeline.messages(history, (event) => this.emitTimeline(event));
await this.permissions.reconcile(this.id);
}
private async reconcile() {
@@ -1,3 +1,4 @@
import { collectTimelineSnapshot } from "../test-utils/v2-timeline.js";
import { V2Timeline } from "./timeline.js";
import type { SessionMessageAssistant } from "@opencode/client";
import { describe, expect, test } from "vitest";
@@ -80,7 +81,7 @@ describe("OpenCode v2 token streaming", () => {
}
});
test("deduplicates snapshots before and after deltas using per-type ordinals", () => {
test("deduplicates snapshots before and after deltas using per-type ordinals", async () => {
const timeline = new V2Timeline();
const text = { assistantMessageID: "answer", type: "text", ordinal: 0 } as const;
const reasoning = { ...text, type: "reasoning" } as const;
@@ -99,24 +100,28 @@ describe("OpenCode v2 token streaming", () => {
},
{ type: "text", text: "Hello" },
]);
expect(timeline.messages([snapshot])).toMatchObject([{ item: { text: "lo" } }]);
expect(await collectTimelineSnapshot(timeline, [snapshot])).toMatchObject([
{ item: { text: "lo" } },
]);
expect(timeline.delta({ ...text, delta: "lo" })).toBeNull();
expect(timeline.messages([snapshot])).toEqual([]);
expect(await collectTimelineSnapshot(timeline, [snapshot])).toEqual([]);
expect(timeline.delta({ ...text, delta: "!" })).toMatchObject({ item: { text: "!" } });
expect(timeline.messages([snapshot])).toEqual([]);
expect(await collectTimelineSnapshot(timeline, [snapshot])).toEqual([]);
});
test("recovers missed fragments from snapshots after reconnect", () => {
test("recovers missed fragments from snapshots after reconnect", async () => {
const timeline = new V2Timeline();
const part = { assistantMessageID: "answer", type: "text", ordinal: 0 } as const;
timeline.startPart(part);
expect(timeline.delta({ ...part, delta: "Hel" })).toMatchObject({ item: { text: "Hel" } });
timeline.resetStreams();
expect(timeline.delta({ ...part, delta: "!" })).toBeNull();
expect(timeline.messages([assistant([{ type: "text", text: "Hello!" }])])).toMatchObject([
{ item: { text: "lo!" } },
]);
expect(timeline.messages([assistant([{ type: "text", text: "Hello!" }])])).toEqual([]);
expect(
await collectTimelineSnapshot(timeline, [assistant([{ type: "text", text: "Hello!" }])]),
).toMatchObject([{ item: { text: "lo!" } }]);
expect(
await collectTimelineSnapshot(timeline, [assistant([{ type: "text", text: "Hello!" }])]),
).toEqual([]);
});
test("withholds streamed prose during a structured-output turn", async () => {
@@ -203,3 +208,62 @@ 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,3 +1,4 @@
import { collectTimelineSnapshot } from "../test-utils/v2-timeline.js";
import type { SessionMessageAssistant } from "@opencode/client";
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
@@ -15,7 +16,7 @@ function assistant(content: SessionMessageAssistant["content"]): SessionMessageA
};
}
test("a completed edit carries the replaced and replacement text", () => {
test("a completed edit carries the replaced and replacement text", async () => {
// Captured from OpenCode 2.0.18, whose edit tool takes `path` instead of `filePath`.
const edit = assistant([
{
@@ -45,7 +46,7 @@ test("a completed edit carries the replaced and replacement text", () => {
},
]);
expect(new V2Timeline().messages([edit])).toMatchObject([
expect(await collectTimelineSnapshot(new V2Timeline(), [edit])).toMatchObject([
{
item: {
type: "tool_call",
@@ -57,7 +58,7 @@ test("a completed edit carries the replaced and replacement text", () => {
]);
});
test("a tool's image output is shown as an image, not as base64 in the tool output", () => {
test("a tool's image output is shown as an image, not as base64 in the tool output", async () => {
const png =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==";
const screenshot = assistant([
@@ -80,7 +81,7 @@ test("a tool's image output is shown as an image, not as base64 in the tool outp
]);
const timeline = new V2Timeline();
const events = timeline.messages([screenshot]);
const events = await collectTimelineSnapshot(timeline, [screenshot]);
expect(JSON.stringify(events)).not.toContain(png);
expect(events.map((event) => event.type === "timeline" && event.item)).toMatchObject([
@@ -95,7 +96,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$/),
},
]);
expect(timeline.messages([screenshot])).toEqual([]);
expect(await collectTimelineSnapshot(timeline, [screenshot])).toEqual([]);
});
test("a surrogate pair split across deltas streams back intact", () => {
@@ -55,18 +55,32 @@ export class V2Timeline {
return { type: "timeline", provider: "opencode", item, timestamp: new Date().toISOString() };
}
messages(messages: SessionMessageInfo[]): AgentStreamEvent[] {
const events: AgentStreamEvent[] = [];
private pendingMessages: Promise<unknown> = Promise.resolve();
// 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 };
for (const message of messages) {
const timestamp = new Date(message.time.created).toISOString();
const push = (item: AgentTimelineItem) =>
events.push({ type: "timeline", provider: "opencode", item, timestamp });
publish({ type: "timeline", provider: "opencode", item, timestamp });
if (message.type === "user") this.userMessage(message, state, push);
else if (message.type === "assistant") this.assistantMessage(message, state, push);
else if (message.type === "assistant") await this.assistantMessage(message, state, push);
else if (message.type === "compaction") this.compactionMessage(message, push);
}
return events;
}
private userMessage(
@@ -89,7 +103,7 @@ export class V2Timeline {
});
}
private assistantMessage(
private async assistantMessage(
message: Extract<SessionMessageInfo, { type: "assistant" }>,
state: TimelineState,
push: (item: AgentTimelineItem) => void,
@@ -99,16 +113,16 @@ export class V2Timeline {
// paths address the same cursor entry.
let textOrdinal = 0;
let reasoningOrdinal = 0;
message.content.forEach((part, partIndex) => {
for (const [partIndex, part] of message.content.entries()) {
if (part.type === "tool" && part.name === STRUCTURED_OUTPUT_TOOL) {
this.structuredOutputPart(message.id, partIndex, part, state, push);
} else if (part.type === "text" || part.type === "reasoning") {
const ordinal = part.type === "text" ? textOrdinal++ : reasoningOrdinal++;
this.textPart(message.id, part, ordinal, state, push);
} else {
this.toolPart(message.id, partIndex, part, push);
await this.toolPart(message.id, partIndex, part, push);
}
});
}
}
private structuredOutputPart(
@@ -156,7 +170,7 @@ export class V2Timeline {
else push({ type: "reasoning", text: suffix });
}
private toolPart(
private async toolPart(
messageID: string,
partIndex: number,
part: Extract<
@@ -174,16 +188,16 @@ export class V2Timeline {
if (item) push(item);
// Images follow their tool call as markdown, as Claude and Codex emit them,
// so base64 never reaches the tool output.
images.forEach((image, index) => {
for (const [index, image] of images.entries()) {
const imageKey = `${key}:image:${index}`;
if (this.content.has(imageKey)) return;
if (this.content.has(imageKey)) continue;
this.content.set(imageKey, "");
const imageItem = renderProviderImageOutputAsAssistantMarkdown(
const imageItem = await renderProviderImageOutputAsAssistantMarkdown(
{ url: image.uri, mimeType: image.mime },
{ materialize: materializeProviderImage },
);
if (imageItem) push(imageItem);
});
}
}
private compactionMessage(
@@ -3583,3 +3583,63 @@ test("pi runtime safety protects queued replies and runtime-owned activity", asy
native.getStateError = new Error("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;
}
function renderTextOnlyImageHint(image: { data: string; mimeType: string }): string {
async function renderTextOnlyImageHint(image: { data: string; mimeType: string }): Promise<string> {
try {
const materialized = materializeProviderImage({
const materialized = await materializeProviderImage({
data: image.data,
mimeType: image.mimeType,
});
@@ -379,10 +379,10 @@ function renderTextOnlyImageHint(image: { data: string; mimeType: string }): str
}
}
function convertPromptInput(
async function convertPromptInput(
prompt: AgentPromptInput,
options: { model: PiModel | null | undefined },
): PiPromptPayload {
): Promise<PiPromptPayload> {
if (typeof prompt === "string") {
return { text: prompt };
}
@@ -405,7 +405,7 @@ function convertPromptInput(
mimeType: block.mimeType,
});
} else {
textParts.push(renderTextOnlyImageHint(block));
textParts.push(await renderTextOnlyImageHint(block));
}
continue;
}
@@ -1280,7 +1280,6 @@ export class PiRpcAgentSession implements AgentSession {
throw new Error("A Pi turn is already active");
}
const payload = convertPromptInput(prompt, { model: this.state.model });
const turnId = randomUUID();
this.activeTurnId = turnId;
this.usagePoller.startTurn();
@@ -1292,6 +1291,9 @@ export class PiRpcAgentSession implements AgentSession {
this.activePromptRequestId = null;
this.pendingSteerSubmissions.length = 0;
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;
const shouldProbeForNoTurnPrompt = this.parseSlashCommandInput(payload.text) !== null;
@@ -1363,7 +1365,9 @@ export class PiRpcAgentSession implements AgentSession {
if (this.closed || this.activeTurnId !== options.expectedTurnId) {
return { status: "unavailable" };
}
const payload = convertPromptInput(prompt, { model: this.state.model });
const payload = await 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
// interrupt-and-replace fallback where they can run directly.
if (this.parseSlashCommandInput(payload.text)) {
@@ -5,8 +5,8 @@ import { describe, expect, test } from "vitest";
import { materializeProviderImage } from "./provider-image-output.js";
describe.skipIf(process.platform === "win32")("materializeProviderImage", () => {
test("writes image attachments under a private temp directory", () => {
const materialized = materializeProviderImage({
test("writes image attachments under a private temp directory", async () => {
const materialized = await materializeProviderImage({
data: "YWJjMTIz",
mimeType: "image/png",
});
@@ -1,10 +1,11 @@
import { existsSync, rmSync } from "node:fs";
import { existsSync, rmSync, statSync, readFileSync } from "node:fs";
import path from "node:path";
import MarkdownIt from "markdown-it";
import { projectTimelineRows } from "../timeline-projection.js";
import { describe, expect, test } from "vitest";
import {
MAX_PROVIDER_IMAGE_BYTES,
isProviderImageMarkdown,
materializeProviderImage,
renderProviderImageOutputAsAssistantMarkdown,
@@ -12,8 +13,8 @@ import {
const HASH = "a".repeat(64);
function renderImageMarkdown(imagePath: string): string {
const item = renderProviderImageOutputAsAssistantMarkdown({ path: imagePath });
async function renderImageMarkdown(imagePath: string): Promise<string> {
const item = await renderProviderImageOutputAsAssistantMarkdown({ path: imagePath });
if (!item || item.type !== "assistant_message") {
throw new Error("Expected provider image output to render as assistant markdown.");
}
@@ -40,8 +41,8 @@ describe("isProviderImageMarkdown", () => {
).toBe(true);
});
test("emits Windows file paths as file URIs", () => {
const markdown = renderImageMarkdown(
test("emits Windows file paths as file URIs", async () => {
const markdown = await renderImageMarkdown(
`C:\\Users\\me\\AppData\\Local\\Temp\\paseo-attachments\\${HASH}.png`,
);
@@ -51,22 +52,24 @@ describe("isProviderImageMarkdown", () => {
expect(isProviderImageMarkdown(markdown)).toBe(true);
});
test("emits POSIX file paths with spaces as valid file URI markdown", () => {
const markdown = renderImageMarkdown("/home/user/Projects/Project With Spaces/screenshot.png");
test("emits POSIX file paths with spaces as valid file URI markdown", async () => {
const markdown = await renderImageMarkdown(
"/home/user/Projects/Project With Spaces/screenshot.png",
);
expect(markdown).toBe(
"\n\n![Image](file:///home/user/Projects/Project%20With%20Spaces/screenshot.png)\n\n",
);
});
test("encodes URI-significant characters in POSIX file paths", () => {
const markdown = renderImageMarkdown("/tmp/screenshot#1?draft.png");
test("encodes URI-significant characters in POSIX file paths", async () => {
const markdown = await renderImageMarkdown("/tmp/screenshot#1?draft.png");
expect(markdown).toBe("\n\n![Image](file:///tmp/screenshot%231%3Fdraft.png)\n\n");
});
test("preserves double-leading slashes in POSIX file paths", () => {
const markdown = renderImageMarkdown("//tmp/screenshot#1.png");
test("preserves double-leading slashes in POSIX file paths", async () => {
const markdown = await renderImageMarkdown("//tmp/screenshot#1.png");
expect(markdown).toBe("\n\n![Image](file:////tmp/screenshot%231.png)\n\n");
});
@@ -78,8 +81,8 @@ describe("isProviderImageMarkdown", () => {
"\\\\?\\UNC\\server\\share\\shot?draft.png",
"file://server/share/shot%3Fdraft.png",
],
])("encodes %s image paths as file URIs", (_label, imagePath, expectedSource) => {
expect(renderImageMarkdown(imagePath)).toBe(`\n\n![Image](${expectedSource})\n\n`);
])("encodes %s image paths as file URIs", async (_label, imagePath, expectedSource) => {
expect(await renderImageMarkdown(imagePath)).toBe(`\n\n![Image](${expectedSource})\n\n`);
});
test("rejects user-authored markdown that is not a materialized attachment", () => {
@@ -92,8 +95,8 @@ describe("isProviderImageMarkdown", () => {
});
describe("materializeProviderImage", () => {
test("recreates the private temp directory if the cached directory is removed", () => {
const first = materializeProviderImage({
test("recreates the private temp directory if the cached directory is removed", async () => {
const first = await materializeProviderImage({
data: "YWJjMTIz",
mimeType: "image/png",
});
@@ -102,7 +105,7 @@ describe("materializeProviderImage", () => {
rmSync(firstDir, { recursive: true, force: true });
const second = materializeProviderImage({
const second = await materializeProviderImage({
data: "ZGVmNDU2",
mimeType: "image/png",
});
@@ -117,8 +120,8 @@ describe("materializeProviderImage", () => {
});
describe("provider image paragraph boundaries", () => {
test.each(["", "\n"])("keeps image outputs separate from prose ending in %j", (ending) => {
const image = renderProviderImageOutputAsAssistantMarkdown({
test.each(["", "\n"])("keeps image outputs separate from prose ending in %j", async (ending) => {
const image = await renderProviderImageOutputAsAssistantMarkdown({
url: "https://example.com/chart.png",
});
if (!image) throw new Error("Expected image output");
@@ -152,10 +155,10 @@ describe("provider image paragraph boundaries", () => {
});
});
test("materialized image output retains paragraph boundaries and attachment recognition", () => {
const item = renderProviderImageOutputAsAssistantMarkdown(
test("materialized image output retains paragraph boundaries and attachment recognition", async () => {
const item = await renderProviderImageOutputAsAssistantMarkdown(
{ data: "YWJjMTIz", mimeType: "image/png" },
{ materialize: () => ({ path: `/tmp/paseo-attachments/${HASH}.png` }) },
{ materialize: async () => ({ path: `/tmp/paseo-attachments/${HASH}.png` }) },
);
expect(item).toEqual({
type: "assistant_message",
@@ -164,3 +167,76 @@ test("materialized image output retains paragraph boundaries and attachment reco
if (!item || item.type !== "assistant_message") throw new Error("Expected image output");
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,5 +1,6 @@
import { createHash } from "node:crypto";
import * as fsSync from "node:fs";
import * as fs from "node:fs/promises";
import { setImmediate } from "node:timers/promises";
import os from "node:os";
import path from "node:path";
@@ -23,32 +24,26 @@ const PRIVATE_ATTACHMENT_DIR_MODE = 0o700;
const MATERIALIZED_IMAGE_FILE_MODE = 0o600;
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();
function canReuseMaterializedImageAttachmentDir(dir: string): boolean {
try {
const stats = fsSync.lstatSync(dir);
if (!stats.isDirectory()) {
return false;
async function getMaterializedImageAttachmentDir(): Promise<string> {
if (materializedImageAttachmentDir) {
try {
const stats = await fs.lstat(materializedImageAttachmentDir);
if (stats.isDirectory()) {
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;
}
}
function getMaterializedImageAttachmentDir(): string {
if (
materializedImageAttachmentDir &&
canReuseMaterializedImageAttachmentDir(materializedImageAttachmentDir)
) {
return materializedImageAttachmentDir;
}
materializedImageAttachmentDir = fsSync.mkdtempSync(
materializedImageAttachmentDir = await fs.mkdtemp(
path.join(os.tmpdir(), PROVIDER_IMAGE_ATTACHMENT_DIR_PREFIX),
);
fsSync.chmodSync(materializedImageAttachmentDir, PRIVATE_ATTACHMENT_DIR_MODE);
await fs.chmod(materializedImageAttachmentDir, PRIVATE_ATTACHMENT_DIR_MODE);
return materializedImageAttachmentDir;
}
@@ -71,32 +66,92 @@ function getImageExtension(mimeType: string): string {
}
}
function normalizeImageData(mimeType: string, data: string): { mimeType: string; data: string } {
if (data.startsWith("data:")) {
const match = data.match(/^data:([^;]+);base64,(.*)$/);
if (match) {
return { mimeType: match[1], data: match[2] };
}
}
return { mimeType, data };
}
// 128 MiB admits the measured 64 MiB fixture with headroom while bounding disk
// and decoding per image. This is materialization admission, not a wire schema limit.
export const MAX_PROVIDER_IMAGE_BYTES = 128 * 1024 * 1024;
const BASE64_CHUNK_CHARACTERS = 256 * 1024; // Multiple of four; decode units stay aligned.
// 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: {
data: string;
mimeType: string | null;
}): MaterializedProviderImage {
const attachmentsDir = getMaterializedImageAttachmentDir();
const normalized = normalizeImageData(image.mimeType ?? "image/png", image.data);
const bytes = Buffer.from(normalized.data, "base64");
const extension = getImageExtension(normalized.mimeType);
const hash = createHash("sha256").update(bytes).digest("hex");
const filePath = path.join(attachmentsDir, `${hash}.${extension}`);
fsSync.writeFileSync(filePath, bytes, { mode: MATERIALIZED_IMAGE_FILE_MODE });
fsSync.chmodSync(filePath, MATERIALIZED_IMAGE_FILE_MODE);
return { path: filePath };
}): Promise<MaterializedProviderImage> {
const result = pendingMaterialization.then(() => materializeImage(image));
pendingMaterialization = result.catch(() => {});
return result;
}
async function materializeImage(image: {
data: string;
mimeType: string | null;
}): Promise<MaterializedProviderImage> {
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
@@ -112,7 +167,10 @@ export function isProviderImageMarkdown(text: string): boolean {
}
interface RenderProviderImageOutputOptions {
materialize?: (image: { data: string; mimeType: string | null }) => MaterializedProviderImage;
materialize?: (image: {
data: string;
mimeType: string | null;
}) => Promise<MaterializedProviderImage>;
}
function nonEmptyString(value: string | null | undefined): string | null {
@@ -169,10 +227,10 @@ function escapeMarkdownImageSource(value: string): string {
return markdownImageSource(value).replace(/\\/g, "\\\\").replace(/\)/g, "\\)");
}
export function renderProviderImageOutputAsAssistantMarkdown(
export async function renderProviderImageOutputAsAssistantMarkdown(
image: ProviderImageOutput,
options: RenderProviderImageOutputOptions = {},
): AgentTimelineItem | null {
): Promise<AgentTimelineItem | null> {
const source = nonEmptyString(image.path) ?? nonEmptyString(image.url);
if (source && !isDataImageSource(source)) {
const altText = escapeMarkdownImageAlt(nonEmptyString(image.altText) ?? "Image");
@@ -191,7 +249,7 @@ export function renderProviderImageOutputAsAssistantMarkdown(
let materialized: MaterializedProviderImage | null = null;
try {
materialized = options.materialize
? options.materialize({
? await options.materialize({
data,
mimeType: nonEmptyString(image.mimeType),
})
@@ -1,431 +0,0 @@
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 };
}
@@ -1,64 +0,0 @@
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;
}
}
@@ -1,62 +0,0 @@
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,10 +3,8 @@ import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { ZodError } from "zod";
import {
compilePlugin,
compileDirectoryPlugin,
resolveExistingAsarUnpackedEsbuildBinary,
unpackedEsbuildBinaryFromPackageDir,
} from "./compiler.js";
@@ -977,52 +975,3 @@ export default function contribute() { void value; void secret; return () => und
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,
});
});
+420 -130
View File
@@ -1,152 +1,442 @@
import { createRequire } from "node:module";
import { pathToFileURL } from "node:url";
import { Worker } from "node:worker_threads";
import pLimit from "p-limit";
import { ZodError } from "zod";
import type * as build from "./compiler-build.js";
import type { PluginManifest } from "./manifest.js";
import type { CompilerReply } from "./compiler-worker.js";
import { existsSync, readFileSync, realpathSync } from "node:fs";
import { createRequire, 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";
export {
SERVER_HOST_MODULES,
resolveExistingAsarUnpackedEsbuildBinary,
unpackedEsbuildBinaryFromPackageDir,
} from "./compiler-esbuild.js";
const nodeRequire = createRequire(import.meta.url);
export const SERVER_HOST_MODULES = [...PLUGIN_SDK_SPECIFIERS, "zod"];
const ESBUILD_BINARY_PATH = "ESBUILD_BINARY_PATH";
export type CompilerTask =
| { kind: "entries"; entries: Parameters<typeof build.compilePlugin>[0] }
| { kind: "directory"; directory: string; version: string; builtinId?: string }
| {
kind: "prepare";
directory: string;
commands: PluginManifest["build"];
environment: NodeJS.ProcessEnv;
};
interface TaskResult {
entries: Awaited<ReturnType<typeof build.compilePlugin>>;
directory: Awaited<ReturnType<typeof build.compileDirectoryPlugin>>;
prepare: void;
// 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"]),
);
}
const IDLE_TIMEOUT_MS = 1_000;
const source = import.meta.url.endsWith(".ts");
const workerUrl = new URL(
source ? "./compiler-worker.ts" : "./compiler-worker.js",
import.meta.url,
);
// Resolve the development loader once at module initialization, never during a
// compile request. Packaged workers execute ordinary JavaScript without tsx.
const workerEntrypoint = source
? `import(${JSON.stringify(pathToFileURL(createRequire(import.meta.url).resolve("tsx/esm/api")).href)}).then(({ register }) => { register(); return import(${JSON.stringify(workerUrl.href)}); });`
: workerUrl;
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;
}
// Reuse the isolate during a build batch (notably daemon bootstrap), then release
// TypeScript's heap when idle. Only this owner admits work or stops the worker.
class PluginCompiler {
private readonly queue = pLimit(1);
private worker: Worker | null = null;
private idleTimer: ReturnType<typeof setTimeout> | null = null;
private stopping: Promise<unknown> = Promise.resolve();
function resolveAsarUnpackedEsbuildBinary(): string | null {
let esbuildDir: string;
try {
esbuildDir = path.dirname(nodeRequire.resolve("esbuild/package.json"));
} catch {
return null;
}
return resolveExistingAsarUnpackedEsbuildBinary(esbuildDir);
}
run<T extends CompilerTask>(
task: T,
onLog?: (data: Record<string, unknown>, message: string) => void,
): Promise<TaskResult[T["kind"]]> {
return this.queue(async () => {
if (this.idleTimer) clearTimeout(this.idleTimer);
this.idleTimer = null;
await this.stopping;
const worker = this.worker ?? this.startWorker();
worker.ref();
try {
return await this.request<T>(worker, task, onLog);
} finally {
if (this.worker === worker) {
worker.unref();
this.idleTimer = setTimeout(() => this.stopWorker(worker), IDLE_TIMEOUT_MS);
this.idleTimer.unref();
} else {
await this.stopping;
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;
}
}
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;
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}` }],
};
}
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();
if (moduleLocation === null || moduleLocation === "shared" || moduleLocation === target) {
return null;
}
return {
errors: [
{
text: `${moduleLocation}-only module cannot be imported into the plugin ${target} bundle: ${filePath}`,
},
],
};
}
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);
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;
}
}
};
worker.on("error", onError);
worker.on("exit", onExit);
worker.on("message", onMessage);
worker.postMessage(task, []);
}
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);
});
} 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);
}
}
}
const compiler = new PluginCompiler();
export function runCompilerTask<T extends CompilerTask>(
task: T,
onLog?: (data: Record<string, unknown>, message: string) => void,
): Promise<TaskResult[T["kind"]]> {
return compiler.run(task, onLog);
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 function compilePlugin(entries: Parameters<typeof build.compilePlugin>[0]) {
return runCompilerTask({ kind: "entries", entries });
}
export function compileDirectoryPlugin(directory: string, version: string, builtinId?: string) {
return runCompilerTask({ kind: "directory", directory, version, builtinId });
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 };
}
@@ -1,6 +1,3 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { performance } from "node:perf_hooks";
import pino from "pino";
import { createPaseoDaemon } from "../bootstrap.js";
import { loadConfig } from "../config.js";
@@ -525,24 +522,3 @@ export default function contribute(server: PluginServerContext) {
await daemon.close();
}
}, 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);
});
@@ -1,76 +0,0 @@
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,8 +1,6 @@
import { chmod, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { Writable } from "node:stream";
import pino from "pino";
import { afterEach, describe, expect, it } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { runPluginBuild } from "./preparation.js";
@@ -119,52 +117,6 @@ describe("runPluginBuild", () => {
).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 () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-build-"));
directories.push(directory);
@@ -1,15 +1,71 @@
import { spawnProcess } from "../../utils/spawn.js";
import type pino from "pino";
import type { PluginManifest } from "./manifest.js";
import { runCompilerTask } from "./compiler.js";
const OUTPUT_LIMIT = 64 * 1024;
export async function runPluginBuild(
directory: string,
commands: PluginManifest["build"],
logger: pino.Logger,
): Promise<void> {
if (!commands?.length) return;
await runCompilerTask(
{ kind: "prepare", directory, commands, environment: { ...process.env } },
(data, message) => logger.info(data, message),
);
for (const command of commands ?? []) {
logger.info({ directory, command }, "Running plugin build command");
const result = await run(command, directory);
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): 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, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { fork } from "node:child_process";
import { tmpdir } from "node:os";
import { PassThrough } from "node:stream";
@@ -1849,9 +1849,9 @@ export default function contribute(plugin: any) {
const child = createReloadChild("blocked", events);
const runtime = createTestRuntime({ spawnChild: () => child });
await expect(
runtime.startPlugin("blocked", directory, () => events.length === 0),
).rejects.toThrow("Plugin start cancelled: blocked");
await expect(runtime.startPlugin("blocked", directory, () => false)).rejects.toThrow(
"Plugin start cancelled: blocked",
);
expect(runtime.catalog()).toEqual([]);
expect(events).toEqual(["start:blocked", "shutdown:blocked", "exit:blocked"]);
@@ -2055,50 +2055,3 @@ export default function contribute(server) {
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();
}
});
+54 -18
View File
@@ -13,6 +13,7 @@ import type {
} from "@getpaseo/plugin/server";
import { validateBeforeRequest, validateBeforeResult } from "./lifecycle/index.js";
import { fork } from "node:child_process";
import { stat } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { randomUUID } from "node:crypto";
@@ -27,8 +28,10 @@ import {
type ProviderInput,
} from "@getpaseo/plugin/server/provider";
import type { PluginLogEntry } from "@getpaseo/protocol/messages";
import { compileDirectoryPlugin } from "./compiler.js";
import { compilePlugin } from "./compiler.js";
import { readPluginManifest } from "./manifest.js";
import type { PluginRequirements } from "@getpaseo/protocol/messages";
import { assertPluginCompatibility } from "@getpaseo/protocol/plugin-requirements";
import type {
PluginProcessMessage,
PluginProcessRequest,
@@ -40,6 +43,8 @@ import { PluginSessionSocket } from "./session-socket.js";
import { InternalPluginChild } from "./internal-child.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 TOOL_CALL_TIMEOUT_MS = 10 * 60_000;
const MAX_LOG_ENTRIES = 500;
@@ -268,6 +273,35 @@ 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 {
private readonly plugins = new Map<string, LoadedPlugin>();
private readonly pendingEvents = new Set<Promise<void>>();
@@ -316,16 +350,10 @@ export class PluginRuntime {
): Promise<void> {
if (this.plugins.has(pluginId)) throw new Error(`Plugin is already running: ${pluginId}`);
this.appendLog(pluginId, "stdout", "[paseo] Loading plugin");
const loaded = await this.loadDirectoryPlugin(pluginId, configuredPath, canPublish).catch(
(error) => {
this.appendLog(
pluginId,
"stderr",
`[paseo] Plugin failed to load: ${describeError(error)}`,
);
throw error;
},
);
const loaded = await this.loadDirectoryPlugin(pluginId, configuredPath).catch((error) => {
this.appendLog(pluginId, "stderr", `[paseo] Plugin failed to load: ${describeError(error)}`);
throw error;
});
if (!canPublish()) {
await this.stopPlugin(loaded);
throw new Error(`Plugin start cancelled: ${pluginId}`);
@@ -339,8 +367,12 @@ export class PluginRuntime {
if (this.plugins.has(input.id)) throw new Error(`Plugin is already running: ${input.id}`);
this.appendLog(input.id, "stdout", "[paseo] Loading plugin");
const directory = path.resolve(input.directory);
const bundles = await compileDirectoryPlugin(directory, this.daemonVersion, input.id);
const { manifest } = bundles;
const manifest = await readPluginManifest(directory);
if (manifest.id !== input.id) {
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`);
const loaded = await this.launchPlugin({
pluginId: input.id,
@@ -356,7 +388,11 @@ export class PluginRuntime {
}
async validatePlugin(configuredPath: string): Promise<void> {
await compileDirectoryPlugin(path.resolve(configuredPath), this.daemonVersion);
const directory = path.resolve(configuredPath);
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> {
@@ -702,12 +738,12 @@ export class PluginRuntime {
private async loadDirectoryPlugin(
pluginId: string,
configuredPath: string,
canPublish: () => boolean,
): Promise<LoadedPlugin> {
const directory = path.resolve(configuredPath);
const bundles = await compileDirectoryPlugin(directory, this.daemonVersion);
const { manifest } = bundles;
if (!canPublish()) throw new Error(`Plugin start cancelled: ${pluginId}`);
const manifest = await readPluginManifest(directory);
assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" });
const entryPaths = await resolveEntryPaths(directory);
const bundles = await compilePlugin(entryPaths);
const serverBundle = bundles.serverBundle;
if (!serverBundle) {
return {
@@ -1,187 +0,0 @@
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));