mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 03:32:55 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a4def5004 | ||
|
|
16d7f5302e | ||
|
|
1aa38f4a99 |
@@ -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>();
|
||||
|
||||
+32
@@ -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);
|
||||
});
|
||||
|
||||
+3
-3
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
+13
-10
@@ -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\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\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\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\n\n`);
|
||||
])("encodes %s image paths as file URIs", async (_label, imagePath, expectedSource) => {
|
||||
expect(await renderImageMarkdown(imagePath)).toBe(`\n\n\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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
Reference in New Issue
Block a user