feat: add prompt attachment support across sessions

This commit is contained in:
Mohamed Boudra
2026-04-11 16:58:39 +07:00
parent aa1d33f0a0
commit 28f45a41a5
21 changed files with 915 additions and 70 deletions
+17 -2
View File
@@ -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 -1
View File
@@ -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({
+36 -13
View File
@@ -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();
+105 -10
View File
@@ -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",
},
]);
});
});
+57 -24
View File
@@ -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>;
+5 -1
View File
@@ -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,
});
}
}