feat: add prompt attachment support across sessions
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
} from "@/contexts/session-stream-reducers";
|
||||
import type {
|
||||
ActivityLogPayload,
|
||||
AgentAttachment,
|
||||
AgentStreamEventPayload,
|
||||
SessionOutboundMessage,
|
||||
} from "@server/shared/messages";
|
||||
@@ -300,7 +301,12 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
|
||||
const previousAgentStatusRef = useRef<Map<string, AgentLifecycleStatus>>(new Map());
|
||||
const sendAgentMessageRef = useRef<
|
||||
((agentId: string, message: string, images?: AttachmentMetadata[]) => Promise<void>) | null
|
||||
((
|
||||
agentId: string,
|
||||
message: string,
|
||||
images?: AttachmentMetadata[],
|
||||
attachments?: AgentAttachment[],
|
||||
) => Promise<void>) | null
|
||||
>(null);
|
||||
const sessionStateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const attentionNotifiedRef = useRef<Map<string, number>>(new Map());
|
||||
@@ -1584,7 +1590,12 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
]);
|
||||
|
||||
const sendAgentMessage = useCallback(
|
||||
async (agentId: string, message: string, images?: AttachmentMetadata[]) => {
|
||||
async (
|
||||
agentId: string,
|
||||
message: string,
|
||||
images?: AttachmentMetadata[],
|
||||
attachments?: AgentAttachment[],
|
||||
) => {
|
||||
const messageId = generateMessageId();
|
||||
const userMessage: StreamItem = {
|
||||
kind: "user_message",
|
||||
@@ -1624,6 +1635,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
.sendAgentMessage(agentId, message, {
|
||||
messageId,
|
||||
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
|
||||
...(attachments && attachments.length > 0 ? { attachments } : {}),
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Session] Failed to send agent message:", error);
|
||||
@@ -1692,6 +1704,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
config,
|
||||
initialPrompt,
|
||||
images,
|
||||
attachments,
|
||||
git,
|
||||
worktreeName,
|
||||
requestId,
|
||||
@@ -1699,6 +1712,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
config: any;
|
||||
initialPrompt: string;
|
||||
images?: AttachmentMetadata[];
|
||||
attachments?: AgentAttachment[];
|
||||
git?: any;
|
||||
worktreeName?: string;
|
||||
requestId?: string;
|
||||
@@ -1718,6 +1732,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
config,
|
||||
...(trimmedPrompt ? { initialPrompt: trimmedPrompt } : {}),
|
||||
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
|
||||
...(attachments && attachments.length > 0 ? { attachments } : {}),
|
||||
...(git ? { git } : {}),
|
||||
...(worktreeName ? { worktreeName } : {}),
|
||||
...(requestId ? { requestId } : {}),
|
||||
|
||||
@@ -25,6 +25,7 @@ import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { buildGitHubAttachmentFromSearchItem } from "@/utils/review-attachments";
|
||||
import {
|
||||
requireWorkspaceExecutionAuthority,
|
||||
} from "@/utils/workspace-execution";
|
||||
@@ -32,23 +33,6 @@ import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import type { ImageAttachment, MessagePayload } from "@/components/message-input";
|
||||
import type { GitHubSearchItem } from "@server/shared/messages";
|
||||
|
||||
function buildInitialPrompt(userText: string, githubItem: GitHubSearchItem | null): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (githubItem) {
|
||||
const kind = githubItem.kind === "pr" ? "Pull Request" : "Issue";
|
||||
const header = `GitHub ${kind} #${githubItem.number}: ${githubItem.title}`;
|
||||
const body = githubItem.body?.trim();
|
||||
parts.push(body ? `${header}\n\n${body}` : header);
|
||||
}
|
||||
|
||||
if (userText) {
|
||||
parts.push(userText);
|
||||
}
|
||||
|
||||
return parts.join("\n\n---\n\n");
|
||||
}
|
||||
|
||||
interface NewWorkspaceScreenProps {
|
||||
serverId: string;
|
||||
sourceDirectory: string;
|
||||
@@ -176,9 +160,11 @@ export function NewWorkspaceScreen({
|
||||
}
|
||||
|
||||
const connectedClient = withConnectedClient();
|
||||
const reviewAttachment = buildGitHubAttachmentFromSearchItem(selectedGithubItem);
|
||||
const payload = await connectedClient.createPaseoWorktree({
|
||||
cwd: sourceDirectory,
|
||||
worktreeSlug: createNameId(),
|
||||
...(reviewAttachment ? { attachments: [reviewAttachment] } : {}),
|
||||
});
|
||||
|
||||
if (payload.error || !payload.workspace) {
|
||||
@@ -193,6 +179,7 @@ export function NewWorkspaceScreen({
|
||||
createdWorkspace,
|
||||
mergeWorkspaces,
|
||||
serverId,
|
||||
selectedGithubItem,
|
||||
sourceDirectory,
|
||||
withConnectedClient,
|
||||
]);
|
||||
@@ -208,8 +195,9 @@ export function NewWorkspaceScreen({
|
||||
throw new Error("Composer state is required");
|
||||
}
|
||||
|
||||
const initialPrompt = buildInitialPrompt(text.trim(), selectedGithubItem);
|
||||
const initialPrompt = text.trim();
|
||||
const encodedImages = await encodeImages(images);
|
||||
const reviewAttachment = buildGitHubAttachmentFromSearchItem(selectedGithubItem);
|
||||
const workspaceDirectory = requireWorkspaceExecutionAuthority({ workspace }).workspaceDirectory;
|
||||
const agent = await connectedClient.createAgent({
|
||||
provider: composerState.selectedProvider,
|
||||
@@ -224,6 +212,7 @@ export function NewWorkspaceScreen({
|
||||
: {}),
|
||||
...(initialPrompt ? { initialPrompt } : {}),
|
||||
...(encodedImages && encodedImages.length > 0 ? { images: encodedImages } : {}),
|
||||
...(reviewAttachment ? { attachments: [reviewAttachment] } : {}),
|
||||
});
|
||||
|
||||
setAgents(serverId, (previous) => {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildGitHubAttachmentFromSearchItem } from "./review-attachments";
|
||||
|
||||
describe("buildGitHubAttachmentFromSearchItem", () => {
|
||||
it("builds a github_pr attachment for pull requests", () => {
|
||||
const attachment = buildGitHubAttachmentFromSearchItem({
|
||||
kind: "pr",
|
||||
number: 123,
|
||||
title: "Fix race in worktree setup",
|
||||
url: "https://github.com/getpaseo/paseo/pull/123",
|
||||
state: "OPEN",
|
||||
body: "PR body",
|
||||
labels: ["bug"],
|
||||
baseRefName: "main",
|
||||
headRefName: "fix/worktree-race",
|
||||
});
|
||||
|
||||
expect(attachment).toEqual({
|
||||
type: "github_pr",
|
||||
mimeType: "application/github-pr",
|
||||
number: 123,
|
||||
title: "Fix race in worktree setup",
|
||||
url: "https://github.com/getpaseo/paseo/pull/123",
|
||||
body: "PR body",
|
||||
baseRefName: "main",
|
||||
headRefName: "fix/worktree-race",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds a github_issue attachment for issues", () => {
|
||||
const attachment = buildGitHubAttachmentFromSearchItem({
|
||||
kind: "issue",
|
||||
number: 55,
|
||||
title: "Improve startup error details",
|
||||
url: "https://github.com/getpaseo/paseo/issues/55",
|
||||
state: "OPEN",
|
||||
body: "Issue body",
|
||||
labels: ["bug"],
|
||||
});
|
||||
|
||||
expect(attachment).toEqual({
|
||||
type: "github_issue",
|
||||
mimeType: "application/github-issue",
|
||||
number: 55,
|
||||
title: "Improve startup error details",
|
||||
url: "https://github.com/getpaseo/paseo/issues/55",
|
||||
body: "Issue body",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when no item is selected", () => {
|
||||
expect(buildGitHubAttachmentFromSearchItem(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages";
|
||||
|
||||
export function buildGitHubAttachmentFromSearchItem(
|
||||
item: GitHubSearchItem | null,
|
||||
): AgentAttachment | null {
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (item.kind === "pr") {
|
||||
return {
|
||||
type: "github_pr",
|
||||
mimeType: "application/github-pr",
|
||||
number: item.number,
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
...(item.body ? { body: item.body } : {}),
|
||||
...(item.baseRefName ? { baseRefName: item.baseRefName } : {}),
|
||||
...(item.headRefName ? { headRefName: item.headRefName } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "github_issue",
|
||||
mimeType: "application/github-issue",
|
||||
number: item.number,
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
...(item.body ? { body: item.body } : {}),
|
||||
};
|
||||
}
|
||||
@@ -344,6 +344,145 @@ describe("DaemonClient", () => {
|
||||
await expect(createPromise).rejects.toThrow("compat test sentinel");
|
||||
});
|
||||
|
||||
test("sends structured attachments with create_agent_request", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const createPromise = client.createAgent({
|
||||
provider: "codex",
|
||||
cwd: "/tmp/project",
|
||||
initialPrompt: "Review this PR",
|
||||
attachments: [
|
||||
{
|
||||
type: "github_pr",
|
||||
mimeType: "application/github-pr",
|
||||
number: 123,
|
||||
title: "Fix race in worktree setup",
|
||||
url: "https://github.com/getpaseo/paseo/pull/123",
|
||||
baseRefName: "main",
|
||||
headRefName: "fix/worktree-race",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = JSON.parse(String(mock.sent[0])) as {
|
||||
type: "session";
|
||||
message: {
|
||||
type: "create_agent_request";
|
||||
requestId: string;
|
||||
attachments: Array<{ type: string; mimeType: string; number: number }>;
|
||||
};
|
||||
};
|
||||
expect(request.message.attachments).toEqual([
|
||||
{
|
||||
type: "github_pr",
|
||||
mimeType: "application/github-pr",
|
||||
number: 123,
|
||||
title: "Fix race in worktree setup",
|
||||
url: "https://github.com/getpaseo/paseo/pull/123",
|
||||
baseRefName: "main",
|
||||
headRefName: "fix/worktree-race",
|
||||
},
|
||||
]);
|
||||
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "agent_create_failed",
|
||||
requestId: request.message.requestId,
|
||||
error: "attachment test sentinel",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(createPromise).rejects.toThrow("attachment test sentinel");
|
||||
});
|
||||
|
||||
test("sends structured attachments with create_paseo_worktree_request", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const createPromise = client.createPaseoWorktree({
|
||||
cwd: "/tmp/project",
|
||||
worktreeSlug: "review-pr-123",
|
||||
attachments: [
|
||||
{
|
||||
type: "github_pr",
|
||||
mimeType: "application/github-pr",
|
||||
number: 123,
|
||||
title: "Fix race in worktree setup",
|
||||
url: "https://github.com/getpaseo/paseo/pull/123",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = JSON.parse(String(mock.sent[0])) as {
|
||||
type: "session";
|
||||
message: {
|
||||
type: "create_paseo_worktree_request";
|
||||
requestId: string;
|
||||
attachments: Array<{ type: string; mimeType: string; number: number }>;
|
||||
};
|
||||
};
|
||||
expect(request.message.attachments).toEqual([
|
||||
{
|
||||
type: "github_pr",
|
||||
mimeType: "application/github-pr",
|
||||
number: 123,
|
||||
title: "Fix race in worktree setup",
|
||||
url: "https://github.com/getpaseo/paseo/pull/123",
|
||||
},
|
||||
]);
|
||||
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "create_paseo_worktree_response",
|
||||
payload: {
|
||||
requestId: request.message.requestId,
|
||||
workspace: null,
|
||||
error: "worktree attachment sentinel",
|
||||
setupTerminalId: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(createPromise).resolves.toEqual({
|
||||
requestId: request.message.requestId,
|
||||
workspace: null,
|
||||
error: "worktree attachment sentinel",
|
||||
setupTerminalId: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("sends explicit shutdown_server_request via shutdownServer", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
AgentSnapshotPayload,
|
||||
ProjectPlacementPayload,
|
||||
AgentPermissionResolvedMessage,
|
||||
AgentAttachment,
|
||||
CreateAgentRequestMessage,
|
||||
FileDownloadTokenResponse,
|
||||
FileExplorerResponse,
|
||||
@@ -63,6 +64,7 @@ import type {
|
||||
TerminalInput,
|
||||
SessionInboundMessage,
|
||||
SessionOutboundMessage,
|
||||
SendAgentMessageRequest,
|
||||
EditorTargetId,
|
||||
} from "../shared/messages.js";
|
||||
import type {
|
||||
@@ -198,6 +200,7 @@ export type DaemonClientConfig = {
|
||||
export type SendMessageOptions = {
|
||||
messageId?: string;
|
||||
images?: Array<{ data: string; mimeType: string }>;
|
||||
attachments?: SendAgentMessageRequest["attachments"];
|
||||
};
|
||||
|
||||
type AgentConfigOverrides = Partial<Omit<AgentSessionConfig, "provider" | "cwd">>;
|
||||
@@ -211,6 +214,7 @@ export type CreateAgentRequestOptions = {
|
||||
clientMessageId?: string;
|
||||
outputSchema?: Record<string, unknown>;
|
||||
images?: CreateAgentRequestMessage["images"];
|
||||
attachments?: CreateAgentRequestMessage["attachments"];
|
||||
git?: GitSetupOptions;
|
||||
worktreeName?: string;
|
||||
requestId?: string;
|
||||
@@ -1494,6 +1498,9 @@ export class DaemonClient {
|
||||
...(options.clientMessageId ? { clientMessageId: options.clientMessageId } : {}),
|
||||
...(options.outputSchema ? { outputSchema: options.outputSchema } : {}),
|
||||
...(options.images && options.images.length > 0 ? { images: options.images } : {}),
|
||||
...(options.attachments && options.attachments.length > 0
|
||||
? { attachments: options.attachments }
|
||||
: {}),
|
||||
...(options.git ? { git: options.git } : {}),
|
||||
...(options.worktreeName ? { worktreeName: options.worktreeName } : {}),
|
||||
...(options.labels && Object.keys(options.labels).length > 0
|
||||
@@ -1723,6 +1730,7 @@ export class DaemonClient {
|
||||
text,
|
||||
...(messageId ? { messageId } : {}),
|
||||
...(options?.images ? { images: options.images } : {}),
|
||||
...(options?.attachments ? { attachments: options.attachments } : {}),
|
||||
});
|
||||
const payload = await this.sendRequest({
|
||||
requestId,
|
||||
@@ -2544,7 +2552,11 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
async createPaseoWorktree(
|
||||
input: { cwd: string; worktreeSlug?: string },
|
||||
input: {
|
||||
cwd: string;
|
||||
worktreeSlug?: string;
|
||||
attachments?: AgentAttachment[];
|
||||
},
|
||||
requestId?: string,
|
||||
): Promise<CreatePaseoWorktreePayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
@@ -2553,6 +2565,9 @@ export class DaemonClient {
|
||||
type: "create_paseo_worktree_request",
|
||||
cwd: input.cwd,
|
||||
worktreeSlug: input.worktreeSlug,
|
||||
...(input.attachments && input.attachments.length > 0
|
||||
? { attachments: input.attachments }
|
||||
: {}),
|
||||
},
|
||||
responseType: "create_paseo_worktree_response",
|
||||
timeout: 60000,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Options as ClaudeAgentOptions } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { AgentAttachment } from "../../shared/messages.js";
|
||||
|
||||
export type AgentProvider = string;
|
||||
|
||||
@@ -117,7 +118,8 @@ export type AgentPersistenceHandle = {
|
||||
|
||||
export type AgentPromptContentBlock =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "image"; data: string; mimeType: string };
|
||||
| { type: "image"; data: string; mimeType: string }
|
||||
| AgentAttachment;
|
||||
|
||||
export type AgentPromptInput = string | AgentPromptContentBlock[];
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { findGitHubPrAttachment, renderPromptAttachmentAsText } from "./prompt-attachments.js";
|
||||
|
||||
describe("prompt attachments", () => {
|
||||
it("renders github_pr attachments as readable text", () => {
|
||||
expect(
|
||||
renderPromptAttachmentAsText({
|
||||
type: "github_pr",
|
||||
mimeType: "application/github-pr",
|
||||
number: 123,
|
||||
title: "Fix race in worktree setup",
|
||||
url: "https://github.com/getpaseo/paseo/pull/123",
|
||||
body: "PR body",
|
||||
baseRefName: "main",
|
||||
headRefName: "fix/worktree-race",
|
||||
}),
|
||||
).toContain("GitHub PR #123: Fix race in worktree setup");
|
||||
});
|
||||
|
||||
it("finds the first github_pr attachment", () => {
|
||||
expect(
|
||||
findGitHubPrAttachment([
|
||||
{
|
||||
type: "github_issue",
|
||||
mimeType: "application/github-issue",
|
||||
number: 55,
|
||||
title: "Issue",
|
||||
url: "https://github.com/getpaseo/paseo/issues/55",
|
||||
},
|
||||
{
|
||||
type: "github_pr",
|
||||
mimeType: "application/github-pr",
|
||||
number: 123,
|
||||
title: "PR",
|
||||
url: "https://github.com/getpaseo/paseo/pull/123",
|
||||
},
|
||||
]),
|
||||
).toEqual({
|
||||
type: "github_pr",
|
||||
mimeType: "application/github-pr",
|
||||
number: 123,
|
||||
title: "PR",
|
||||
url: "https://github.com/getpaseo/paseo/pull/123",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { AgentAttachment } from "../../shared/messages.js";
|
||||
|
||||
export function renderPromptAttachmentAsText(attachment: AgentAttachment): string {
|
||||
switch (attachment.type) {
|
||||
case "github_pr": {
|
||||
const lines = [`GitHub PR #${attachment.number}: ${attachment.title}`, attachment.url];
|
||||
if (attachment.baseRefName) {
|
||||
lines.push(`Base: ${attachment.baseRefName}`);
|
||||
}
|
||||
if (attachment.headRefName) {
|
||||
lines.push(`Head: ${attachment.headRefName}`);
|
||||
}
|
||||
if (attachment.body) {
|
||||
lines.push("", attachment.body);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
case "github_issue": {
|
||||
const lines = [`GitHub Issue #${attachment.number}: ${attachment.title}`, attachment.url];
|
||||
if (attachment.body) {
|
||||
lines.push("", attachment.body);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function findGitHubPrAttachment(
|
||||
attachments: readonly AgentAttachment[] | undefined,
|
||||
): Extract<AgentAttachment, { type: "github_pr" }> | null {
|
||||
if (!attachments) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
attachments.find(
|
||||
(attachment): attachment is Extract<AgentAttachment, { type: "github_pr" }> =>
|
||||
attachment.type === "github_pr",
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
@@ -87,6 +87,7 @@ import {
|
||||
resolveProviderCommandPrefix,
|
||||
type ProviderRuntimeSettings,
|
||||
} from "../provider-launch-config.js";
|
||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||
import { findExecutable } from "../../../utils/executable.js";
|
||||
import { spawnProcess } from "../../../utils/spawn.js";
|
||||
|
||||
@@ -1739,6 +1740,9 @@ function toACPContentBlocks(prompt: AgentPromptInput): ContentBlock[] {
|
||||
if (block.type === "text") {
|
||||
return { type: "text", text: block.text };
|
||||
}
|
||||
if (block.type === "github_pr" || block.type === "github_issue") {
|
||||
return { type: "text", text: renderPromptAttachmentAsText(block) };
|
||||
}
|
||||
return {
|
||||
type: "image",
|
||||
data: block.data,
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
formatProviderDiagnosticError,
|
||||
toDiagnosticErrorMessage,
|
||||
} from "./diagnostic-utils.js";
|
||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||
|
||||
import type {
|
||||
AgentPermissionAction,
|
||||
@@ -2186,6 +2187,8 @@ class ClaudeAgentSession implements AgentSession {
|
||||
data: chunk.data,
|
||||
},
|
||||
});
|
||||
} else if (chunk.type === "github_pr" || chunk.type === "github_issue") {
|
||||
content.push({ type: "text", text: renderPromptAttachmentAsText(chunk) });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -235,6 +235,31 @@ describe("Codex app-server provider", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("maps github_pr prompt attachments to Codex text input", async () => {
|
||||
const input = await codexAppServerTurnInputFromPrompt(
|
||||
[
|
||||
{
|
||||
type: "github_pr",
|
||||
mimeType: "application/github-pr",
|
||||
number: 123,
|
||||
title: "Fix race in worktree setup",
|
||||
url: "https://github.com/getpaseo/paseo/pull/123",
|
||||
body: "Review body",
|
||||
baseRefName: "main",
|
||||
headRefName: "fix/worktree-race",
|
||||
},
|
||||
],
|
||||
logger,
|
||||
);
|
||||
|
||||
expect(input).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: expect.stringContaining("GitHub PR #123: Fix race in worktree setup"),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("maps patch notifications with array-style changes and alias diff keys", () => {
|
||||
const item = __codexAppServerInternals.mapCodexPatchNotificationToToolCall({
|
||||
callId: "patch-array-alias",
|
||||
|
||||
@@ -38,6 +38,7 @@ import path from "node:path";
|
||||
import readline from "node:readline";
|
||||
import { z } from "zod";
|
||||
import { loadCodexPersistedTimeline } from "./codex-rollout-timeline.js";
|
||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||
import {
|
||||
mapCodexRolloutToolCall,
|
||||
mapCodexToolCallFromThreadItem,
|
||||
@@ -2356,6 +2357,15 @@ export async function codexAppServerTurnInputFromPrompt(
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (record.type === "github_pr" || record.type === "github_issue") {
|
||||
output.push({
|
||||
type: "text",
|
||||
text: renderPromptAttachmentAsText(
|
||||
record as Extract<AgentPromptContentBlock, { type: "github_pr" | "github_issue" }>,
|
||||
),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
output.push(block);
|
||||
}
|
||||
return output;
|
||||
|
||||
@@ -475,4 +475,24 @@ describe("OpenCode adapter context-window normalization", () => {
|
||||
|
||||
expect(onAssistantModelContextWindowResolved).toHaveBeenCalledWith(400_000);
|
||||
});
|
||||
|
||||
test("renders github issue attachments as text prompt parts", () => {
|
||||
const parts = __openCodeInternals.buildOpenCodePromptParts([
|
||||
{
|
||||
type: "github_issue",
|
||||
mimeType: "application/github-issue",
|
||||
number: 55,
|
||||
title: "Improve startup error details",
|
||||
url: "https://github.com/getpaseo/paseo/issues/55",
|
||||
body: "Issue body",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(parts).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: expect.stringContaining("GitHub Issue #55: Improve startup error details"),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
resolveBinaryVersion,
|
||||
toDiagnosticErrorMessage,
|
||||
} from "./diagnostic-utils.js";
|
||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||
|
||||
const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
@@ -540,6 +541,10 @@ function buildOpenCodePromptParts(
|
||||
output.push({ type: "text", text: part.text });
|
||||
continue;
|
||||
}
|
||||
if (part.type === "github_pr" || part.type === "github_issue") {
|
||||
output.push({ type: "text", text: renderPromptAttachmentAsText(part) });
|
||||
continue;
|
||||
}
|
||||
attachmentOrdinal += 1;
|
||||
const normalized = toOpenCodeDataUrl(part.mimeType, part.data);
|
||||
output.push({
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
isLegacyEditorTargetId,
|
||||
serializeAgentStreamEvent,
|
||||
type AgentSnapshotPayload,
|
||||
type AgentAttachment,
|
||||
type SessionInboundMessage,
|
||||
type SessionOutboundMessage,
|
||||
type FileExplorerRequest,
|
||||
@@ -836,18 +837,24 @@ export class Session {
|
||||
private buildAgentPrompt(
|
||||
text: string,
|
||||
images?: Array<{ data: string; mimeType: string }>,
|
||||
attachments?: AgentAttachment[],
|
||||
): AgentPromptInput {
|
||||
const normalized = text?.trim() ?? "";
|
||||
if (!images || images.length === 0) {
|
||||
const hasImages = Boolean(images && images.length > 0);
|
||||
const hasAttachments = Boolean(attachments && attachments.length > 0);
|
||||
if (!hasImages && !hasAttachments) {
|
||||
return normalized;
|
||||
}
|
||||
const blocks: AgentPromptContentBlock[] = [];
|
||||
if (normalized.length > 0) {
|
||||
blocks.push({ type: "text", text: normalized });
|
||||
}
|
||||
for (const image of images) {
|
||||
for (const image of images ?? []) {
|
||||
blocks.push({ type: "image", data: image.data, mimeType: image.mimeType });
|
||||
}
|
||||
for (const attachment of attachments ?? []) {
|
||||
blocks.push(attachment);
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
@@ -2763,12 +2770,24 @@ export class Session {
|
||||
text: string,
|
||||
messageId?: string,
|
||||
images?: Array<{ data: string; mimeType: string }>,
|
||||
attachments?: AgentAttachment[],
|
||||
runOptions?: AgentRunOptions,
|
||||
options?: { spokenInput?: boolean },
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
this.sessionLogger.info(
|
||||
{ agentId, textPreview: text.substring(0, 50), imageCount: images?.length ?? 0 },
|
||||
`Sending text to agent ${agentId}${images && images.length > 0 ? ` with ${images.length} image attachment(s)` : ""}`,
|
||||
{
|
||||
agentId,
|
||||
textPreview: text.substring(0, 50),
|
||||
imageCount: images?.length ?? 0,
|
||||
attachmentCount: attachments?.length ?? 0,
|
||||
},
|
||||
`Sending text to agent ${agentId}${
|
||||
images && images.length > 0 ? ` with ${images.length} image attachment(s)` : ""
|
||||
}${
|
||||
attachments && attachments.length > 0
|
||||
? ` and ${attachments.length} structured attachment(s)`
|
||||
: ""
|
||||
}`,
|
||||
);
|
||||
|
||||
await this.unarchiveAgentState(agentId);
|
||||
@@ -2796,7 +2815,7 @@ export class Session {
|
||||
}
|
||||
|
||||
const promptText = options?.spokenInput ? wrapSpokenInput(text) : text;
|
||||
const prompt = this.buildAgentPrompt(promptText, images);
|
||||
const prompt = this.buildAgentPrompt(promptText, images, attachments);
|
||||
|
||||
return this.startAgentStream(agentId, prompt, runOptions);
|
||||
}
|
||||
@@ -2816,6 +2835,7 @@ export class Session {
|
||||
outputSchema,
|
||||
git,
|
||||
images,
|
||||
attachments,
|
||||
labels,
|
||||
} = msg;
|
||||
this.sessionLogger.info(
|
||||
@@ -2840,7 +2860,7 @@ export class Session {
|
||||
resolvedConfig,
|
||||
git,
|
||||
worktreeName,
|
||||
labels,
|
||||
attachments,
|
||||
);
|
||||
const resolvedWorkspace =
|
||||
msg.workspaceId
|
||||
@@ -2864,7 +2884,7 @@ export class Session {
|
||||
);
|
||||
await this.forwardAgentUpdate(snapshot);
|
||||
|
||||
if (trimmedPrompt) {
|
||||
if (trimmedPrompt || (images?.length ?? 0) > 0 || (attachments?.length ?? 0) > 0) {
|
||||
scheduleAgentMetadataGeneration({
|
||||
agentManager: this.agentManager,
|
||||
agentId: snapshot.id,
|
||||
@@ -2877,9 +2897,10 @@ export class Session {
|
||||
|
||||
const started = await this.handleSendAgentMessage(
|
||||
snapshot.id,
|
||||
trimmedPrompt,
|
||||
trimmedPrompt ?? "",
|
||||
resolveClientMessageId(clientMessageId),
|
||||
images,
|
||||
attachments,
|
||||
outputSchema ? { outputSchema } : undefined,
|
||||
);
|
||||
if (!started.ok) {
|
||||
@@ -2903,7 +2924,7 @@ export class Session {
|
||||
});
|
||||
}
|
||||
|
||||
if (trimmedPrompt) {
|
||||
if (trimmedPrompt || (images?.length ?? 0) > 0 || (attachments?.length ?? 0) > 0) {
|
||||
scheduleAgentMetadataGeneration({
|
||||
agentManager: this.agentManager,
|
||||
agentId: snapshot.id,
|
||||
@@ -2916,9 +2937,10 @@ export class Session {
|
||||
|
||||
void this.handleSendAgentMessage(
|
||||
snapshot.id,
|
||||
trimmedPrompt,
|
||||
trimmedPrompt ?? "",
|
||||
resolveClientMessageId(clientMessageId),
|
||||
images,
|
||||
attachments,
|
||||
outputSchema ? { outputSchema } : undefined,
|
||||
).catch((promptError) => {
|
||||
this.sessionLogger.error(
|
||||
@@ -3100,7 +3122,7 @@ export class Session {
|
||||
config: AgentSessionConfig,
|
||||
gitOptions?: GitSetupOptions,
|
||||
legacyWorktreeName?: string,
|
||||
_labels?: Record<string, string>,
|
||||
attachments?: AgentAttachment[],
|
||||
): Promise<{
|
||||
sessionConfig: AgentSessionConfig;
|
||||
worktreeBootstrap?: { worktree: WorktreeConfig; shouldBootstrap: boolean };
|
||||
@@ -3115,7 +3137,7 @@ export class Session {
|
||||
config,
|
||||
gitOptions,
|
||||
legacyWorktreeName,
|
||||
_labels,
|
||||
attachments,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6824,7 +6846,7 @@ export class Session {
|
||||
);
|
||||
}
|
||||
|
||||
const prompt = this.buildAgentPrompt(msg.text, msg.images);
|
||||
const prompt = this.buildAgentPrompt(msg.text, msg.images, msg.attachments);
|
||||
this.sessionLogger.trace(
|
||||
{ agentId, messageId: msg.messageId },
|
||||
"send_agent_message_request: starting agent stream",
|
||||
@@ -7387,6 +7409,7 @@ export class Session {
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ spokenInput: true },
|
||||
);
|
||||
await this.flushPendingAudioSegments("transcription complete");
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { SessionOutboundMessage } from "./messages.js";
|
||||
import { ScriptRouteStore } from "./script-proxy.js";
|
||||
import * as worktreeBootstrap from "./worktree-bootstrap.js";
|
||||
import {
|
||||
buildAgentSessionConfig,
|
||||
createPaseoWorktreeInBackground,
|
||||
handleCreatePaseoWorktreeRequest,
|
||||
handleWorkspaceSetupStatusRequest,
|
||||
@@ -93,6 +94,35 @@ function createGitRepo(options?: { paseoConfig?: Record<string, unknown> }) {
|
||||
return { tempDir, repoDir };
|
||||
}
|
||||
|
||||
function createGitHubPrRemoteRepo() {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
const featureBranch = "feature/review-pr";
|
||||
execSync(`git checkout -b ${JSON.stringify(featureBranch)}`, { cwd: repoDir, stdio: "pipe" });
|
||||
writeFileSync(path.join(repoDir, "README.md"), "review branch\n");
|
||||
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'review branch'", {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
const featureSha = execSync("git rev-parse HEAD", { cwd: repoDir, stdio: "pipe" })
|
||||
.toString()
|
||||
.trim();
|
||||
execSync("git checkout main", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync(`git branch -D ${JSON.stringify(featureBranch)}`, { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
const remoteDir = path.join(tempDir, "remote.git");
|
||||
execSync(`git clone --bare ${JSON.stringify(repoDir)} ${JSON.stringify(remoteDir)}`, {
|
||||
stdio: "pipe",
|
||||
});
|
||||
execSync(`git --git-dir=${JSON.stringify(remoteDir)} update-ref refs/pull/123/head ${featureSha}`, {
|
||||
stdio: "pipe",
|
||||
});
|
||||
execSync(`git remote add origin ${JSON.stringify(remoteDir)}`, { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git fetch origin", { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
return { tempDir, repoDir };
|
||||
}
|
||||
|
||||
describe("createPaseoWorktreeInBackground", () => {
|
||||
const cleanupPaths: string[] = [];
|
||||
|
||||
@@ -687,6 +717,148 @@ describe("createPaseoWorktreeInBackground", () => {
|
||||
|
||||
});
|
||||
|
||||
describe("handleCreatePaseoWorktreeRequest", () => {
|
||||
const cleanupPaths: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const target of cleanupPaths.splice(0)) {
|
||||
rmSync(target, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("checks out the GitHub PR branch when a github_pr attachment is present", async () => {
|
||||
const { tempDir, repoDir } = createGitHubPrRemoteRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const logger = createLogger();
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
|
||||
await handleCreatePaseoWorktreeRequest(
|
||||
{
|
||||
paseoHome,
|
||||
describeWorkspaceRecord: async (workspace) =>
|
||||
({
|
||||
id: String(workspace.id),
|
||||
projectId: String(workspace.projectId),
|
||||
projectDisplayName: "repo",
|
||||
projectRootPath: repoDir,
|
||||
workspaceDirectory: workspace.directory,
|
||||
workspaceKind: "worktree",
|
||||
projectKind: "git",
|
||||
name: workspace.displayName,
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
}) as any,
|
||||
emit: (message) => emitted.push(message),
|
||||
registerPendingWorktreeWorkspace: async ({ worktreePath, branchName }) =>
|
||||
({
|
||||
id: 1,
|
||||
projectId: 1,
|
||||
directory: worktreePath,
|
||||
displayName: branchName,
|
||||
kind: "worktree",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
archivedAt: null,
|
||||
}) as any,
|
||||
sessionLogger: logger,
|
||||
createPaseoWorktreeInBackground: async () => {},
|
||||
},
|
||||
{
|
||||
type: "create_paseo_worktree_request",
|
||||
requestId: "req-pr-worktree",
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "review-pr-123",
|
||||
attachments: [
|
||||
{
|
||||
type: "github_pr",
|
||||
mimeType: "application/github-pr",
|
||||
number: 123,
|
||||
title: "Review branch",
|
||||
url: "https://github.com/getpaseo/paseo/pull/123",
|
||||
baseRefName: "main",
|
||||
headRefName: "feature/review-pr",
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
const response = emitted.find(
|
||||
(message): message is Extract<SessionOutboundMessage, { type: "create_paseo_worktree_response" }> =>
|
||||
message.type === "create_paseo_worktree_response",
|
||||
);
|
||||
|
||||
expect(response?.payload.error).toBeNull();
|
||||
expect(response?.payload.workspace?.workspaceDirectory).toBeTruthy();
|
||||
|
||||
const worktreePath = response?.payload.workspace?.workspaceDirectory;
|
||||
expect(worktreePath).toBeTruthy();
|
||||
if (!worktreePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const branch = execSync("git branch --show-current", { cwd: worktreePath, stdio: "pipe" })
|
||||
.toString()
|
||||
.trim();
|
||||
expect(branch).toBe("feature/review-pr");
|
||||
|
||||
const readme = readFileSync(path.join(worktreePath, "README.md"), "utf8");
|
||||
expect(readme).toContain("review branch");
|
||||
});
|
||||
|
||||
test("buildAgentSessionConfig checks out the GitHub PR branch for agent worktrees", async () => {
|
||||
const { tempDir, repoDir } = createGitHubPrRemoteRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
|
||||
const result = await buildAgentSessionConfig(
|
||||
{
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
sessionLogger: createLogger(),
|
||||
checkoutExistingBranch: async () => {
|
||||
throw new Error("should not checkout existing branch");
|
||||
},
|
||||
createBranchFromBase: async () => {
|
||||
throw new Error("should not create a new branch from base");
|
||||
},
|
||||
},
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: repoDir,
|
||||
},
|
||||
{
|
||||
createWorktree: true,
|
||||
worktreeSlug: "agent-review-pr-123",
|
||||
},
|
||||
undefined,
|
||||
[
|
||||
{
|
||||
type: "github_pr",
|
||||
mimeType: "application/github-pr",
|
||||
number: 123,
|
||||
title: "Review branch",
|
||||
url: "https://github.com/getpaseo/paseo/pull/123",
|
||||
baseRefName: "main",
|
||||
headRefName: "feature/review-pr",
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(result.worktreeBootstrap?.worktree.branchName).toBe("feature/review-pr");
|
||||
expect(result.worktreeBootstrap?.worktree.worktreePath).toContain("agent-review-pr-123");
|
||||
|
||||
const branch = execSync("git branch --show-current", {
|
||||
cwd: result.sessionConfig.cwd,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
expect(branch).toBe("feature/review-pr");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleCreatePaseoWorktreeRequest", () => {
|
||||
test("invokes worktree creation once for a create request", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { exec } from "node:child_process";
|
||||
import { exec, execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import type { Logger } from "pino";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
@@ -7,6 +7,7 @@ import type { AgentSessionConfig } from "./agent/agent-sdk-types.js";
|
||||
import type { AgentManager } from "./agent/agent-manager.js";
|
||||
import type { AgentStorage } from "./agent/agent-storage.js";
|
||||
import {
|
||||
type AgentAttachment,
|
||||
type GitSetupOptions,
|
||||
type ProjectPlacementPayload,
|
||||
type SessionInboundMessage,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
type WorkspaceSetupSnapshot,
|
||||
type WorkspaceDescriptorPayload,
|
||||
} from "./messages.js";
|
||||
import { findGitHubPrAttachment } from "./agent/prompt-attachments.js";
|
||||
import type {
|
||||
PersistedWorkspaceRecord,
|
||||
ProjectRegistry,
|
||||
@@ -48,9 +50,11 @@ import {
|
||||
type WorktreeSetupCommandResult,
|
||||
WorktreeSetupError,
|
||||
} from "../utils/worktree.js";
|
||||
import { writePaseoWorktreeMetadata } from "../utils/worktree-metadata.js";
|
||||
import { READ_ONLY_GIT_ENV, toCheckoutError } from "./checkout-git-utils.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/;
|
||||
|
||||
export type NormalizedGitOptions = {
|
||||
@@ -149,7 +153,7 @@ export async function buildAgentSessionConfig(
|
||||
config: AgentSessionConfig,
|
||||
gitOptions?: GitSetupOptions,
|
||||
legacyWorktreeName?: string,
|
||||
_labels?: Record<string, string>,
|
||||
attachments?: AgentAttachment[],
|
||||
): Promise<{
|
||||
sessionConfig: AgentSessionConfig;
|
||||
worktreeBootstrap?: { worktree: WorktreeConfig; shouldBootstrap: boolean };
|
||||
@@ -168,6 +172,35 @@ export async function buildAgentSessionConfig(
|
||||
}
|
||||
|
||||
if (normalized.createWorktree) {
|
||||
const githubPrAttachment = findGitHubPrAttachment(attachments);
|
||||
if (githubPrAttachment) {
|
||||
const baseBranch =
|
||||
githubPrAttachment.baseRefName?.trim() ||
|
||||
normalized.baseBranch ||
|
||||
(await resolveGitCreateBaseBranch(cwd, dependencies.paseoHome));
|
||||
const worktreeSlug = normalized.worktreeSlug ?? slugify(resolveGitHubPrBranchName(githubPrAttachment));
|
||||
const createdWorktree = await createGitHubPrWorktree({
|
||||
cwd,
|
||||
branchName: resolveGitHubPrBranchName(githubPrAttachment),
|
||||
baseBranch,
|
||||
prNumber: githubPrAttachment.number,
|
||||
worktreeSlug,
|
||||
paseoHome: dependencies.paseoHome,
|
||||
});
|
||||
cwd = createdWorktree.worktreePath;
|
||||
worktreeBootstrap = {
|
||||
worktree: createdWorktree,
|
||||
shouldBootstrap: true,
|
||||
};
|
||||
return {
|
||||
sessionConfig: {
|
||||
...config,
|
||||
cwd,
|
||||
},
|
||||
worktreeBootstrap,
|
||||
};
|
||||
}
|
||||
|
||||
let targetBranch: string;
|
||||
|
||||
if (normalized.createNewBranch) {
|
||||
@@ -304,6 +337,56 @@ export async function resolveGitCreateBaseBranch(
|
||||
return baseBranch;
|
||||
}
|
||||
|
||||
function resolveGitHubPrBranchName(
|
||||
attachment: Extract<AgentAttachment, { type: "github_pr" }>,
|
||||
): string {
|
||||
const trimmed = attachment.headRefName?.trim();
|
||||
return trimmed && trimmed.length > 0 ? trimmed : `pr-${attachment.number}`;
|
||||
}
|
||||
|
||||
async function fetchGitHubPrBranch(params: {
|
||||
cwd: string;
|
||||
branchName: string;
|
||||
prNumber: number;
|
||||
}): Promise<void> {
|
||||
await execFileAsync(
|
||||
"git",
|
||||
[
|
||||
"fetch",
|
||||
"origin",
|
||||
`refs/pull/${params.prNumber}/head:refs/heads/${params.branchName}`,
|
||||
"--force",
|
||||
],
|
||||
{ cwd: params.cwd },
|
||||
);
|
||||
}
|
||||
|
||||
async function createGitHubPrWorktree(params: {
|
||||
cwd: string;
|
||||
branchName: string;
|
||||
baseBranch: string;
|
||||
prNumber: number;
|
||||
worktreeSlug: string;
|
||||
paseoHome?: string;
|
||||
}): Promise<WorktreeConfig> {
|
||||
assertSafeGitRef(params.branchName, "github pr branch");
|
||||
await computeWorktreePath(params.cwd, params.worktreeSlug, params.paseoHome);
|
||||
await fetchGitHubPrBranch({
|
||||
cwd: params.cwd,
|
||||
branchName: params.branchName,
|
||||
prNumber: params.prNumber,
|
||||
});
|
||||
const worktreePath = await computeWorktreePath(params.cwd, params.worktreeSlug, params.paseoHome);
|
||||
await execFileAsync("git", ["worktree", "add", worktreePath, params.branchName], {
|
||||
cwd: params.cwd,
|
||||
});
|
||||
writePaseoWorktreeMetadata(worktreePath, { baseRefName: params.baseBranch });
|
||||
return {
|
||||
branchName: params.branchName,
|
||||
worktreePath,
|
||||
};
|
||||
}
|
||||
|
||||
export async function handlePaseoWorktreeListRequest(
|
||||
dependencies: { emit: EmitSessionMessage; paseoHome?: string },
|
||||
msg: Extract<SessionInboundMessage, { type: "paseo_worktree_list_request" }>,
|
||||
@@ -588,14 +671,26 @@ export async function handleCreatePaseoWorktreeRequest(
|
||||
throw new Error(`Invalid worktree name: ${validation.error}`);
|
||||
}
|
||||
|
||||
await computeWorktreePath(repoRoot, normalizedSlug, dependencies.paseoHome);
|
||||
const createdWorktree = await createAgentWorktree({
|
||||
cwd: repoRoot,
|
||||
branchName: normalizedSlug,
|
||||
baseBranch,
|
||||
worktreeSlug: normalizedSlug,
|
||||
paseoHome: dependencies.paseoHome,
|
||||
});
|
||||
const githubPrAttachment = findGitHubPrAttachment(request.attachments);
|
||||
const createdWorktree = githubPrAttachment
|
||||
? {
|
||||
worktree: await createGitHubPrWorktree({
|
||||
cwd: repoRoot,
|
||||
branchName: resolveGitHubPrBranchName(githubPrAttachment),
|
||||
baseBranch: githubPrAttachment.baseRefName?.trim() || baseBranch,
|
||||
prNumber: githubPrAttachment.number,
|
||||
worktreeSlug: normalizedSlug,
|
||||
paseoHome: dependencies.paseoHome,
|
||||
}),
|
||||
shouldBootstrap: true,
|
||||
}
|
||||
: await createAgentWorktree({
|
||||
cwd: repoRoot,
|
||||
branchName: normalizedSlug,
|
||||
baseBranch,
|
||||
worktreeSlug: normalizedSlug,
|
||||
paseoHome: dependencies.paseoHome,
|
||||
});
|
||||
const workspace = await dependencies.registerPendingWorktreeWorkspace({
|
||||
repoRoot,
|
||||
worktreePath: createdWorktree.worktree.worktreePath,
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
CreateAgentRequestMessageSchema,
|
||||
CreatePaseoWorktreeRequestSchema,
|
||||
SendAgentMessageRequestSchema,
|
||||
} from "./messages.js";
|
||||
|
||||
describe("shared messages attachments", () => {
|
||||
it("keeps known attachments and drops unknown create-agent attachments", () => {
|
||||
const parsed = CreateAgentRequestMessageSchema.parse({
|
||||
type: "create_agent_request",
|
||||
requestId: "req-1",
|
||||
config: {
|
||||
provider: "codex",
|
||||
cwd: "/tmp/repo",
|
||||
},
|
||||
initialPrompt: "Review this PR",
|
||||
attachments: [
|
||||
{
|
||||
type: "github_pr",
|
||||
mimeType: "application/github-pr",
|
||||
number: 123,
|
||||
title: "Fix race in worktree setup",
|
||||
url: "https://github.com/getpaseo/paseo/pull/123",
|
||||
body: "Body",
|
||||
baseRefName: "main",
|
||||
headRefName: "fix/worktree-race",
|
||||
},
|
||||
{
|
||||
type: "future_attachment",
|
||||
mimeType: "application/future",
|
||||
foo: "bar",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(parsed.attachments).toEqual([
|
||||
{
|
||||
type: "github_pr",
|
||||
mimeType: "application/github-pr",
|
||||
number: 123,
|
||||
title: "Fix race in worktree setup",
|
||||
url: "https://github.com/getpaseo/paseo/pull/123",
|
||||
body: "Body",
|
||||
baseRefName: "main",
|
||||
headRefName: "fix/worktree-race",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps known attachments and drops unknown send-message attachments", () => {
|
||||
const parsed = SendAgentMessageRequestSchema.parse({
|
||||
type: "send_agent_message_request",
|
||||
requestId: "req-2",
|
||||
agentId: "agent-1",
|
||||
text: "Review",
|
||||
attachments: [
|
||||
{
|
||||
type: "github_issue",
|
||||
mimeType: "application/github-issue",
|
||||
number: 55,
|
||||
title: "Improve startup error details",
|
||||
url: "https://github.com/getpaseo/paseo/issues/55",
|
||||
body: "Body",
|
||||
},
|
||||
{
|
||||
type: "future_attachment",
|
||||
mimeType: "application/future",
|
||||
foo: "bar",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(parsed.attachments).toEqual([
|
||||
{
|
||||
type: "github_issue",
|
||||
mimeType: "application/github-issue",
|
||||
number: 55,
|
||||
title: "Improve startup error details",
|
||||
url: "https://github.com/getpaseo/paseo/issues/55",
|
||||
body: "Body",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps known attachments and drops unknown worktree-create attachments", () => {
|
||||
const parsed = CreatePaseoWorktreeRequestSchema.parse({
|
||||
type: "create_paseo_worktree_request",
|
||||
requestId: "req-3",
|
||||
cwd: "/tmp/repo",
|
||||
attachments: [
|
||||
{
|
||||
type: "github_pr",
|
||||
mimeType: "application/github-pr",
|
||||
number: 99,
|
||||
title: "Fork-safe PR checkout",
|
||||
url: "https://github.com/getpaseo/paseo/pull/99",
|
||||
},
|
||||
{
|
||||
type: "future_attachment",
|
||||
mimeType: "application/future",
|
||||
foo: "bar",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(parsed.attachments).toEqual([
|
||||
{
|
||||
type: "github_pr",
|
||||
mimeType: "application/github-pr",
|
||||
number: 99,
|
||||
title: "Fork-safe PR checkout",
|
||||
url: "https://github.com/getpaseo/paseo/pull/99",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -613,19 +613,59 @@ export const SetVoiceModeMessageSchema = z.object({
|
||||
requestId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const GitHubPrAttachmentSchema = z.object({
|
||||
type: z.literal("github_pr"),
|
||||
mimeType: z.literal("application/github-pr"),
|
||||
number: z.number().int().positive(),
|
||||
title: z.string(),
|
||||
url: z.string(),
|
||||
body: z.string().nullable().optional(),
|
||||
baseRefName: z.string().nullable().optional(),
|
||||
headRefName: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export const GitHubIssueAttachmentSchema = z.object({
|
||||
type: z.literal("github_issue"),
|
||||
mimeType: z.literal("application/github-issue"),
|
||||
number: z.number().int().positive(),
|
||||
title: z.string(),
|
||||
url: z.string(),
|
||||
body: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export const AgentAttachmentSchema = z.discriminatedUnion("type", [
|
||||
GitHubPrAttachmentSchema,
|
||||
GitHubIssueAttachmentSchema,
|
||||
]);
|
||||
|
||||
function normalizeAgentAttachments(input: unknown): AgentAttachment[] {
|
||||
if (!Array.isArray(input)) {
|
||||
return [];
|
||||
}
|
||||
const normalized: AgentAttachment[] = [];
|
||||
for (const item of input) {
|
||||
const parsed = AgentAttachmentSchema.safeParse(item);
|
||||
if (parsed.success) {
|
||||
normalized.push(parsed.data);
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const AgentAttachmentsSchema = z.unknown().transform(normalizeAgentAttachments).optional();
|
||||
|
||||
const ImageAttachmentSchema = z.object({
|
||||
data: z.string(), // base64 encoded image
|
||||
mimeType: z.string(), // e.g., "image/jpeg", "image/png"
|
||||
});
|
||||
|
||||
export const SendAgentMessageSchema = z.object({
|
||||
type: z.literal("send_agent_message"),
|
||||
agentId: z.string(),
|
||||
text: z.string(),
|
||||
messageId: z.string().optional(), // Client-provided ID for deduplication
|
||||
images: z
|
||||
.array(
|
||||
z.object({
|
||||
data: z.string(), // base64 encoded image
|
||||
mimeType: z.string(), // e.g., "image/jpeg", "image/png"
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
images: z.array(ImageAttachmentSchema).optional(),
|
||||
attachments: AgentAttachmentsSchema,
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
@@ -710,14 +750,8 @@ export const SendAgentMessageRequestSchema = z.object({
|
||||
agentId: z.string(),
|
||||
text: z.string(),
|
||||
messageId: z.string().optional(), // Client-provided ID for deduplication
|
||||
images: z
|
||||
.array(
|
||||
z.object({
|
||||
data: z.string(), // base64 encoded image
|
||||
mimeType: z.string(), // e.g., "image/jpeg", "image/png"
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
images: z.array(ImageAttachmentSchema).optional(),
|
||||
attachments: AgentAttachmentsSchema,
|
||||
});
|
||||
|
||||
export const WaitForFinishRequestSchema = z.object({
|
||||
@@ -775,14 +809,8 @@ export const CreateAgentRequestMessageSchema = z.object({
|
||||
initialPrompt: z.string().optional(),
|
||||
clientMessageId: z.string().optional(),
|
||||
outputSchema: z.record(z.unknown()).optional(),
|
||||
images: z
|
||||
.array(
|
||||
z.object({
|
||||
data: z.string(), // base64 encoded image
|
||||
mimeType: z.string(), // e.g., "image/jpeg", "image/png"
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
images: z.array(ImageAttachmentSchema).optional(),
|
||||
attachments: AgentAttachmentsSchema,
|
||||
git: GitSetupOptionsSchema.optional(),
|
||||
labels: z.record(z.string()).default({}),
|
||||
requestId: z.string(),
|
||||
@@ -1119,6 +1147,8 @@ export const GitHubSearchItemSchema = z.object({
|
||||
state: z.string(),
|
||||
body: z.string().nullable(),
|
||||
labels: z.array(z.string()),
|
||||
baseRefName: z.string().nullable().optional(),
|
||||
headRefName: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export const GitHubSearchRequestSchema = z.object({
|
||||
@@ -1158,6 +1188,7 @@ export const CreatePaseoWorktreeRequestSchema = z.object({
|
||||
type: z.literal("create_paseo_worktree_request"),
|
||||
cwd: z.string(),
|
||||
worktreeSlug: z.string().optional(),
|
||||
attachments: AgentAttachmentsSchema,
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
@@ -3012,6 +3043,7 @@ export type DictationStreamChunkMessage = z.infer<typeof DictationStreamChunkMes
|
||||
export type DictationStreamFinishMessage = z.infer<typeof DictationStreamFinishMessageSchema>;
|
||||
export type DictationStreamCancelMessage = z.infer<typeof DictationStreamCancelMessageSchema>;
|
||||
export type CreateAgentRequestMessage = z.infer<typeof CreateAgentRequestMessageSchema>;
|
||||
export type AgentAttachment = z.infer<typeof AgentAttachmentSchema>;
|
||||
export type ListProviderModelsRequestMessage = z.infer<
|
||||
typeof ListProviderModelsRequestMessageSchema
|
||||
>;
|
||||
@@ -3096,6 +3128,7 @@ export type BranchSuggestionsResponse = z.infer<typeof BranchSuggestionsResponse
|
||||
export type GitHubSearchItem = z.infer<typeof GitHubSearchItemSchema>;
|
||||
export type GitHubSearchRequest = z.infer<typeof GitHubSearchRequestSchema>;
|
||||
export type GitHubSearchResponse = z.infer<typeof GitHubSearchResponseSchema>;
|
||||
export type CreatePaseoWorktreeRequest = z.infer<typeof CreatePaseoWorktreeRequestSchema>;
|
||||
export type DirectorySuggestionsRequest = z.infer<typeof DirectorySuggestionsRequestSchema>;
|
||||
export type DirectorySuggestionsResponse = z.infer<typeof DirectorySuggestionsResponseSchema>;
|
||||
export type PaseoWorktreeListRequest = z.infer<typeof PaseoWorktreeListRequestSchema>;
|
||||
|
||||
@@ -2387,6 +2387,8 @@ export interface GitHubSearchResult {
|
||||
state: string;
|
||||
body: string | null;
|
||||
labels: string[];
|
||||
baseRefName?: string | null;
|
||||
headRefName?: string | null;
|
||||
}>;
|
||||
githubFeaturesEnabled: boolean;
|
||||
}
|
||||
@@ -2428,7 +2430,7 @@ export async function searchGitHubIssuesAndPrs(
|
||||
"--search",
|
||||
query,
|
||||
"--json",
|
||||
"number,title,url,state,body,labels",
|
||||
"number,title,url,state,body,labels,baseRefName,headRefName",
|
||||
"--limit",
|
||||
String(limit),
|
||||
],
|
||||
@@ -2452,6 +2454,8 @@ export async function searchGitHubIssuesAndPrs(
|
||||
labels: Array.isArray(item.labels)
|
||||
? item.labels.map((l: { name?: string }) => l.name ?? "").filter(Boolean)
|
||||
: [],
|
||||
baseRefName: item.baseRefName ?? null,
|
||||
headRefName: item.headRefName ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user