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