From d41267e8a578c056af15390b33451ba0386aaa10 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 22 Jan 2026 20:37:12 +0700 Subject: [PATCH] feat(server): checkout RPCs + ship loop e2es --- .../server/src/client/daemon-client-v2.ts | 248 +++++++ .../src/server/agent/activity-curator.test.ts | 332 +++++++++ .../server/agent/providers/claude-agent.ts | 4 +- .../server/agent/providers/codex-mcp-agent.ts | 226 ++++-- .../providers/codex-rollout-parsing.test.ts | 301 ++++++++ .../daemon-e2e/checkout-ship.e2e.test.ts | 378 ++++++++++ .../daemon-e2e/two-cycle-resume.e2e.test.ts | 168 +++++ packages/server/src/server/session.ts | 650 +++++++++++++++++- packages/server/src/shared/messages.ts | 220 ++++++ packages/server/src/utils/checkout-git.ts | 159 ++++- 10 files changed, 2593 insertions(+), 93 deletions(-) create mode 100644 packages/server/src/server/agent/activity-curator.test.ts create mode 100644 packages/server/src/server/agent/providers/codex-rollout-parsing.test.ts create mode 100644 packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts create mode 100644 packages/server/src/server/daemon-e2e/two-cycle-resume.e2e.test.ts diff --git a/packages/server/src/client/daemon-client-v2.ts b/packages/server/src/client/daemon-client-v2.ts index 502eb4f1e..0d0ea9bbf 100644 --- a/packages/server/src/client/daemon-client-v2.ts +++ b/packages/server/src/client/daemon-client-v2.ts @@ -21,6 +21,14 @@ import type { GitSetupOptions, GitRepoInfoResponse, HighlightedDiffResponse, + CheckoutStatusResponse, + CheckoutDiffResponse, + CheckoutCommitResponse, + CheckoutMergeResponse, + CheckoutPrCreateResponse, + CheckoutPrStatusResponse, + PaseoWorktreeListResponse, + PaseoWorktreeArchiveResponse, ListCommandsResponse, ExecuteCommandResponse, ListVoiceConversationsResponseMessage, @@ -167,6 +175,14 @@ type DeleteVoiceConversationPayload = DeleteVoiceConversationResponseMessage["pa type GitDiffPayload = GitDiffResponse["payload"]; type HighlightedDiffPayload = HighlightedDiffResponse["payload"]; type GitRepoInfoPayload = GitRepoInfoResponse["payload"]; +type CheckoutStatusPayload = CheckoutStatusResponse["payload"]; +type CheckoutDiffPayload = CheckoutDiffResponse["payload"]; +type CheckoutCommitPayload = CheckoutCommitResponse["payload"]; +type CheckoutMergePayload = CheckoutMergeResponse["payload"]; +type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"]; +type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"]; +type PaseoWorktreeListPayload = PaseoWorktreeListResponse["payload"]; +type PaseoWorktreeArchivePayload = PaseoWorktreeArchiveResponse["payload"]; type FileExplorerPayload = FileExplorerResponse["payload"]; type FileDownloadTokenPayload = FileDownloadTokenResponse["payload"]; type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"]; @@ -925,6 +941,238 @@ export class DaemonClientV2 { // Git Operations // ============================================================================ + async getCheckoutStatus( + agentId: string, + requestId?: string + ): Promise { + const resolvedRequestId = this.createRequestId(requestId); + const message = SessionInboundMessageSchema.parse({ + type: "checkout_status_request", + agentId, + requestId: resolvedRequestId, + }); + const response = this.waitFor( + (msg) => { + if (msg.type !== "checkout_status_response") { + return null; + } + if (msg.payload.requestId !== resolvedRequestId) { + return null; + } + return msg.payload; + }, + 20000, + { skipQueue: true } + ); + this.sendSessionMessage(message); + return response; + } + + async getCheckoutDiff( + agentId: string, + compare: { mode: "uncommitted" | "base"; baseRef?: string }, + requestId?: string + ): Promise { + const resolvedRequestId = this.createRequestId(requestId); + const message = SessionInboundMessageSchema.parse({ + type: "checkout_diff_request", + agentId, + compare, + requestId: resolvedRequestId, + }); + const response = this.waitFor( + (msg) => { + if (msg.type !== "checkout_diff_response") { + return null; + } + if (msg.payload.requestId !== resolvedRequestId) { + return null; + } + return msg.payload; + }, + 20000, + { skipQueue: true } + ); + this.sendSessionMessage(message); + return response; + } + + async checkoutCommit( + agentId: string, + input: { message?: string; addAll?: boolean }, + requestId?: string + ): Promise { + const resolvedRequestId = this.createRequestId(requestId); + const message = SessionInboundMessageSchema.parse({ + type: "checkout_commit_request", + agentId, + message: input.message, + addAll: input.addAll, + requestId: resolvedRequestId, + }); + const response = this.waitFor( + (msg) => { + if (msg.type !== "checkout_commit_response") { + return null; + } + if (msg.payload.requestId !== resolvedRequestId) { + return null; + } + return msg.payload; + }, + 20000, + { skipQueue: true } + ); + this.sendSessionMessage(message); + return response; + } + + async checkoutMerge( + agentId: string, + input: { baseRef?: string; strategy?: "merge" | "squash"; requireCleanTarget?: boolean }, + requestId?: string + ): Promise { + const resolvedRequestId = this.createRequestId(requestId); + const message = SessionInboundMessageSchema.parse({ + type: "checkout_merge_request", + agentId, + baseRef: input.baseRef, + strategy: input.strategy, + requireCleanTarget: input.requireCleanTarget, + requestId: resolvedRequestId, + }); + const response = this.waitFor( + (msg) => { + if (msg.type !== "checkout_merge_response") { + return null; + } + if (msg.payload.requestId !== resolvedRequestId) { + return null; + } + return msg.payload; + }, + 20000, + { skipQueue: true } + ); + this.sendSessionMessage(message); + return response; + } + + async checkoutPrCreate( + agentId: string, + input: { title?: string; body?: string; baseRef?: string }, + requestId?: string + ): Promise { + const resolvedRequestId = this.createRequestId(requestId); + const message = SessionInboundMessageSchema.parse({ + type: "checkout_pr_create_request", + agentId, + title: input.title, + body: input.body, + baseRef: input.baseRef, + requestId: resolvedRequestId, + }); + const response = this.waitFor( + (msg) => { + if (msg.type !== "checkout_pr_create_response") { + return null; + } + if (msg.payload.requestId !== resolvedRequestId) { + return null; + } + return msg.payload; + }, + 30000, + { skipQueue: true } + ); + this.sendSessionMessage(message); + return response; + } + + async checkoutPrStatus( + agentId: string, + requestId?: string + ): Promise { + const resolvedRequestId = this.createRequestId(requestId); + const message = SessionInboundMessageSchema.parse({ + type: "checkout_pr_status_request", + agentId, + requestId: resolvedRequestId, + }); + const response = this.waitFor( + (msg) => { + if (msg.type !== "checkout_pr_status_response") { + return null; + } + if (msg.payload.requestId !== resolvedRequestId) { + return null; + } + return msg.payload; + }, + 30000, + { skipQueue: true } + ); + this.sendSessionMessage(message); + return response; + } + + async getPaseoWorktreeList( + input: { cwd?: string; repoRoot?: string }, + requestId?: string + ): Promise { + const resolvedRequestId = this.createRequestId(requestId); + const message = SessionInboundMessageSchema.parse({ + type: "paseo_worktree_list_request", + cwd: input.cwd, + repoRoot: input.repoRoot, + requestId: resolvedRequestId, + }); + const response = this.waitFor( + (msg) => { + if (msg.type !== "paseo_worktree_list_response") { + return null; + } + if (msg.payload.requestId !== resolvedRequestId) { + return null; + } + return msg.payload; + }, + 20000, + { skipQueue: true } + ); + this.sendSessionMessage(message); + return response; + } + + async archivePaseoWorktree( + input: { worktreePath?: string; repoRoot?: string; branchName?: string }, + requestId?: string + ): Promise { + const resolvedRequestId = this.createRequestId(requestId); + const message = SessionInboundMessageSchema.parse({ + type: "paseo_worktree_archive_request", + worktreePath: input.worktreePath, + repoRoot: input.repoRoot, + branchName: input.branchName, + requestId: resolvedRequestId, + }); + const response = this.waitFor( + (msg) => { + if (msg.type !== "paseo_worktree_archive_response") { + return null; + } + if (msg.payload.requestId !== resolvedRequestId) { + return null; + } + return msg.payload; + }, + 20000, + { skipQueue: true } + ); + this.sendSessionMessage(message); + return response; + } + async getGitDiff( agentId: string, requestId?: string diff --git a/packages/server/src/server/agent/activity-curator.test.ts b/packages/server/src/server/agent/activity-curator.test.ts new file mode 100644 index 000000000..01d0b66b1 --- /dev/null +++ b/packages/server/src/server/agent/activity-curator.test.ts @@ -0,0 +1,332 @@ +import { describe, test, expect } from "vitest"; +import { curateAgentActivity } from "./activity-curator.js"; +import type { AgentTimelineItem } from "./agent-sdk-types.js"; + +describe("curateAgentActivity", () => { + describe("serializes all timeline item types", () => { + test("serializes user_message", () => { + const timeline: AgentTimelineItem[] = [ + { type: "user_message", text: "Hello, can you help me?" }, + ]; + + const result = curateAgentActivity(timeline); + + expect(result).toBe("[User] Hello, can you help me?"); + }); + + test("serializes assistant_message", () => { + const timeline: AgentTimelineItem[] = [ + { type: "assistant_message", text: "I can help you with that." }, + ]; + + const result = curateAgentActivity(timeline); + + expect(result).toBe("I can help you with that."); + }); + + test("serializes reasoning as [Thought]", () => { + const timeline: AgentTimelineItem[] = [ + { type: "reasoning", text: "The user wants to understand X." }, + ]; + + const result = curateAgentActivity(timeline); + + expect(result).toBe("[Thought] The user wants to understand X."); + }); + + test("serializes tool_call with name", () => { + const timeline: AgentTimelineItem[] = [ + { + type: "tool_call", + callId: "call-1", + name: "Read", + input: { file_path: "/src/index.ts" }, + status: "completed", + }, + ]; + + const result = curateAgentActivity(timeline); + + expect(result).toBe("[Read] /src/index.ts"); + }); + + test("serializes tool_call without principal param", () => { + const timeline: AgentTimelineItem[] = [ + { + type: "tool_call", + callId: "call-1", + name: "ListFiles", + input: {}, + status: "completed", + }, + ]; + + const result = curateAgentActivity(timeline); + + expect(result).toBe("[ListFiles]"); + }); + + test("serializes todo items as [Plan]", () => { + const timeline: AgentTimelineItem[] = [ + { + type: "todo", + items: [ + { text: "Read the file", completed: true }, + { text: "Fix the bug", completed: false }, + { text: "Run tests", completed: false }, + ], + }, + ]; + + const result = curateAgentActivity(timeline); + + expect(result).toContain("[Plan]"); + expect(result).toContain("- [x] Read the file"); + expect(result).toContain("- [ ] Fix the bug"); + expect(result).toContain("- [ ] Run tests"); + }); + + test("serializes error items", () => { + const timeline: AgentTimelineItem[] = [ + { type: "error", message: "File not found: /missing.ts" }, + ]; + + const result = curateAgentActivity(timeline); + + expect(result).toBe("[Error] File not found: /missing.ts"); + }); + }); + + describe("handles complex conversations", () => { + test("serializes full conversation with multiple item types", () => { + const timeline: AgentTimelineItem[] = [ + { type: "user_message", text: "Fix the bug in auth.ts" }, + { type: "reasoning", text: "I need to read the file first." }, + { + type: "tool_call", + callId: "call-1", + name: "Read", + input: { file_path: "/src/auth.ts" }, + status: "completed", + }, + { type: "assistant_message", text: "I found the issue." }, + { + type: "tool_call", + callId: "call-2", + name: "Edit", + input: { file_path: "/src/auth.ts", old_string: "bug", new_string: "fix" }, + status: "completed", + }, + { type: "assistant_message", text: "The bug has been fixed." }, + ]; + + const result = curateAgentActivity(timeline); + + expect(result).toContain("[User] Fix the bug in auth.ts"); + expect(result).toContain("[Thought] I need to read the file first."); + expect(result).toContain("[Read] /src/auth.ts"); + expect(result).toContain("I found the issue."); + expect(result).toContain("[Edit] /src/auth.ts"); + expect(result).toContain("The bug has been fixed."); + }); + + test("preserves order of items", () => { + const timeline: AgentTimelineItem[] = [ + { type: "user_message", text: "Step 1" }, + { type: "assistant_message", text: "Step 2" }, + { type: "user_message", text: "Step 3" }, + { type: "assistant_message", text: "Step 4" }, + ]; + + const result = curateAgentActivity(timeline); + const lines = result.split("\n"); + + expect(lines[0]).toContain("Step 1"); + expect(lines[1]).toContain("Step 2"); + expect(lines[2]).toContain("Step 3"); + expect(lines[3]).toContain("Step 4"); + }); + }); + + describe("handles edge cases", () => { + test("returns default message for empty timeline", () => { + const result = curateAgentActivity([]); + + expect(result).toBe("No activity to display."); + }); + + test("handles whitespace-only messages", () => { + const timeline: AgentTimelineItem[] = [ + { type: "user_message", text: " \n " }, + { type: "assistant_message", text: "Real message" }, + ]; + + const result = curateAgentActivity(timeline); + + expect(result).toContain("Real message"); + }); + + test("trims whitespace from messages", () => { + const timeline: AgentTimelineItem[] = [ + { type: "user_message", text: " Hello \n" }, + ]; + + const result = curateAgentActivity(timeline); + + expect(result).toBe("[User] Hello"); + }); + }); + + describe("collapsing behavior", () => { + test("merges consecutive assistant_message items", () => { + const timeline: AgentTimelineItem[] = [ + { type: "assistant_message", text: "Part 1. " }, + { type: "assistant_message", text: "Part 2. " }, + { type: "assistant_message", text: "Part 3." }, + ]; + + const result = curateAgentActivity(timeline); + + expect(result).toBe("Part 1. Part 2. Part 3."); + }); + + test("merges consecutive reasoning items", () => { + const timeline: AgentTimelineItem[] = [ + { type: "reasoning", text: "First thought. " }, + { type: "reasoning", text: "Second thought." }, + ]; + + const result = curateAgentActivity(timeline); + + expect(result).toBe("[Thought] First thought. Second thought."); + }); + + test("deduplicates tool calls by callId", () => { + const timeline: AgentTimelineItem[] = [ + { + type: "tool_call", + callId: "call-1", + name: "Read", + input: { file_path: "/src/a.ts" }, + status: "pending", + }, + { + type: "tool_call", + callId: "call-1", + name: "Read", + input: { file_path: "/src/a.ts" }, + status: "completed", + }, + ]; + + const result = curateAgentActivity(timeline); + + // Should only appear once + const matches = result.match(/\[Read\]/g); + expect(matches?.length).toBe(1); + }); + }); + + describe("maxItems limit", () => { + test("respects maxItems option", () => { + const timeline: AgentTimelineItem[] = [ + { type: "user_message", text: "Message 1" }, + { type: "user_message", text: "Message 2" }, + { type: "user_message", text: "Message 3" }, + { type: "user_message", text: "Message 4" }, + { type: "user_message", text: "Message 5" }, + ]; + + const result = curateAgentActivity(timeline, { maxItems: 3 }); + + // Should only have the last 3 messages + expect(result).not.toContain("Message 1"); + expect(result).not.toContain("Message 2"); + expect(result).toContain("Message 3"); + expect(result).toContain("Message 4"); + expect(result).toContain("Message 5"); + }); + + test("uses default maxItems of 40", () => { + const timeline: AgentTimelineItem[] = []; + for (let i = 0; i < 50; i++) { + timeline.push({ type: "user_message", text: `Message ${i}` }); + } + + const result = curateAgentActivity(timeline); + + // First 10 should be truncated + expect(result).not.toContain("Message 0"); + expect(result).not.toContain("Message 9"); + // Last 40 should be present + expect(result).toContain("Message 10"); + expect(result).toContain("Message 49"); + }); + }); + + describe("tool call principal extraction", () => { + test("extracts file_path from Read tool", () => { + const timeline: AgentTimelineItem[] = [ + { + type: "tool_call", + callId: "1", + name: "Read", + input: { file_path: "/src/index.ts" }, + status: "completed", + }, + ]; + + const result = curateAgentActivity(timeline); + + expect(result).toBe("[Read] /src/index.ts"); + }); + + test("extracts command from Bash tool", () => { + const timeline: AgentTimelineItem[] = [ + { + type: "tool_call", + callId: "1", + name: "Bash", + input: { command: "npm test" }, + status: "completed", + }, + ]; + + const result = curateAgentActivity(timeline); + + expect(result).toBe("[Bash] npm test"); + }); + + test("extracts pattern from Glob tool", () => { + const timeline: AgentTimelineItem[] = [ + { + type: "tool_call", + callId: "1", + name: "Glob", + input: { pattern: "**/*.ts" }, + status: "completed", + }, + ]; + + const result = curateAgentActivity(timeline); + + expect(result).toBe("[Glob] **/*.ts"); + }); + + test("extracts pattern from Grep tool", () => { + const timeline: AgentTimelineItem[] = [ + { + type: "tool_call", + callId: "1", + name: "Grep", + input: { pattern: "TODO" }, + status: "completed", + }, + ]; + + const result = curateAgentActivity(timeline); + + expect(result).toBe("[Grep] TODO"); + }); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 9609f779a..c0986aeb5 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -1971,8 +1971,6 @@ async function collectRecentClaudeSessions(root: string, limit: number): Promise .slice(0, limit); } -const CLAUDE_PERSISTED_TIMELINE_LIMIT = 20; - async function parseClaudeSessionDescriptor( filePath: string, mtime: Date @@ -2047,7 +2045,7 @@ async function parseClaudeSessionDescriptor( title: (title ?? "").trim() || `Claude session ${sessionId.slice(0, 8)}`, lastActivityAt: mtime, persistence, - timeline: timeline.slice(0, CLAUDE_PERSISTED_TIMELINE_LIMIT), + timeline, }; } diff --git a/packages/server/src/server/agent/providers/codex-mcp-agent.ts b/packages/server/src/server/agent/providers/codex-mcp-agent.ts index 4656e71e0..43e3488f2 100644 --- a/packages/server/src/server/agent/providers/codex-mcp-agent.ts +++ b/packages/server/src/server/agent/providers/codex-mcp-agent.ts @@ -40,6 +40,7 @@ import type { PersistedAgentDescriptor, } from "../agent-sdk-types.js"; import { getSelfIdentificationInstructions } from "../self-identification-instructions.js"; +import { curateAgentActivity } from "../activity-curator.js"; type CodexMcpAgentConfig = AgentSessionConfig & { provider: "codex" }; @@ -3033,6 +3034,7 @@ class CodexMcpAgentSession implements AgentSession { private historyPending = false; private persistedHistory: AgentTimelineItem[] = []; private resumeContextHistory: AgentTimelineItem[] = []; + private previousCuratedHistory: string | null = null; private pendingHistory: AgentTimelineItem[] = []; private turnState: TurnState | null = null; private pendingPatchChanges = new Map(); @@ -3061,6 +3063,10 @@ class CodexMcpAgentSession implements AgentSession { if (parsed.conversationId) { this.conversationId = parsed.conversationId; } + // Extract curated history from previous session for cross-resume preservation + if (typeof (metadata as Record).curatedHistory === "string") { + this.previousCuratedHistory = (metadata as Record).curatedHistory as string; + } } // Always lock conversation ID on resume to preserve the original // Even if metadata didn't have conversationId, we'll use sessionId as fallback @@ -3452,6 +3458,7 @@ class CodexMcpAgentSession implements AgentSession { describePersistence(): AgentPersistenceHandle | null { if (this.persistence) { this.updatePersistenceConversationId(); + this.updatePersistenceCuratedHistory(); return this.persistence; } const persistenceId = this.sessionId ?? this.conversationId; @@ -3474,6 +3481,7 @@ class CodexMcpAgentSession implements AgentSession { }, }; this.updatePersistenceConversationId(); + this.updatePersistenceCuratedHistory(); return this.persistence; } @@ -3486,6 +3494,18 @@ class CodexMcpAgentSession implements AgentSession { metadata.conversationId = conversationId; } + private updatePersistenceCuratedHistory(): void { + if (!this.persistence?.metadata) { + return; + } + // Store curated history in metadata so it survives across resume cycles + // This is essential for preserving conversation context when Codex creates new sessions + if (this.resumeContextHistory.length > 0) { + const metadata = this.persistence.metadata; + metadata.curatedHistory = curateAgentActivity(this.resumeContextHistory); + } + } + async close(): Promise { for (const pending of this.pendingPermissionHandlers.values()) { pending.reject(new Error("Codex MCP session closed")); @@ -3740,6 +3760,9 @@ class CodexMcpAgentSession implements AgentSession { } private recordHistory(item: AgentTimelineItem): void { + // Always accumulate in resumeContextHistory for proper history preservation + // across multiple resume cycles (buildResumePrompt uses this) + this.resumeContextHistory.push(item); if (this.sessionId) { this.persistedHistory.push(item); return; @@ -4428,21 +4451,31 @@ class CodexMcpAgentSession implements AgentSession { } private buildResumePrompt(prompt: string): string { - const historyLines: string[] = []; // Use resumeContextHistory instead of persistedHistory because // persistedHistory gets cleared by streamHistory() before the first message is sent - for (const item of this.resumeContextHistory) { - if (item.type === "user_message") { - historyLines.push(`User: ${item.text}`); - } - if (item.type === "assistant_message") { - historyLines.push(`Assistant: ${item.text}`); + + // Combine previous curated history (from earlier resume cycles) with current history + // This ensures conversation context survives across multiple resume cycles + const historyParts: string[] = []; + + // Include history from previous resume cycles (stored in persistence metadata) + if (this.previousCuratedHistory && this.previousCuratedHistory !== "No activity to display.") { + historyParts.push(this.previousCuratedHistory); + } + + // Include history from current session + if (this.resumeContextHistory.length > 0) { + const curatedHistory = curateAgentActivity(this.resumeContextHistory); + if (curatedHistory !== "No activity to display.") { + historyParts.push(curatedHistory); } } - if (historyLines.length === 0) { + + if (historyParts.length === 0) { return prompt; } - return ["Previous conversation:", ...historyLines, "", `User: ${prompt}`].join("\n"); + + return `Previous conversation:\n${historyParts.join("\n\n")}\n\nUser: ${prompt}`; } private buildPermissionRequest(params: unknown): AgentPermissionRequest { @@ -4806,7 +4839,6 @@ class CodexAppServerClient { // ============================================================================ const MAX_ROLLOUT_SEARCH_DEPTH = 4; -const PERSISTED_TIMELINE_LIMIT = 100; const CODEX_ROLLOUT_PREFIX = "rollout-"; const CODEX_ROLLOUT_EXTENSIONS = [".jsonl", ".json"]; @@ -4861,44 +4893,60 @@ async function findRolloutFile( return null; } -type RolloutEntry = { - type: "response_item" | "event_msg"; - payload?: unknown; -}; +const RolloutContentItemSchema = z.object({ + type: z.string(), + text: z.string(), +}).passthrough(); -type RolloutResponsePayload = { - type?: string; - role?: string; - content?: unknown; - name?: string; - call_id?: string; - arguments?: string; - output?: string; - summary?: Array<{ text?: string }>; - text?: string; -}; +const RolloutContentArraySchema = z.array(RolloutContentItemSchema); -type RolloutEventPayload = { - type?: string; - text?: string; -}; - -function isRolloutEntry(value: unknown): value is RolloutEntry { - if (!value || typeof value !== "object" || !("type" in value)) { - return false; +function extractContentTextByType(content: unknown, itemType: string): string { + const parsed = RolloutContentArraySchema.safeParse(content); + if (!parsed.success) { + return ""; } - const type = (value as { type?: unknown }).type; - return type === "response_item" || type === "event_msg"; + return parsed.data + .filter((item) => item.type === itemType) + .map((item) => item.text) + .join("\n") + .trim(); } +const RolloutResponsePayloadSchema = z.object({ + type: z.string().optional(), + role: z.string().optional(), + content: z.unknown().optional(), + name: z.string().optional(), + call_id: z.string().optional(), + arguments: z.string().optional(), + output: z.string().optional(), + summary: z.array(z.object({ text: z.string().optional() })).optional(), + text: z.string().optional(), +}); + +const RolloutEventPayloadSchema = z.object({ + type: z.string().optional(), + text: z.string().optional(), + message: z.string().optional(), +}); + +const RolloutEntrySchema = z.object({ + type: z.enum(["response_item", "event_msg"]), + payload: z.unknown().optional(), +}); + +type RolloutEntry = z.infer; +type RolloutResponsePayload = z.infer; + function parseRolloutEntryFromLine(line: string): RolloutEntry | null { if (!line) { return null; } try { const parsed = JSON.parse(line); - if (isRolloutEntry(parsed)) { - return parsed; + const result = RolloutEntrySchema.safeParse(parsed); + if (result.success) { + return result.data; } if ( parsed && @@ -4966,6 +5014,11 @@ function extractReasoningText(payload: RolloutResponsePayload): string { return text; } } + // Handle content array with reasoning_text items + const contentText = extractContentTextByType(payload.content, "reasoning_text"); + if (contentText) { + return contentText; + } if (typeof payload?.text === "string") { return payload.text; } @@ -5015,7 +5068,7 @@ function parseJsonRolloutTimeline( return timeline; } -async function parseRolloutFile( +export async function parseRolloutFile( filePath: string ): Promise { const content = await fs.readFile(filePath, "utf8"); @@ -5036,6 +5089,21 @@ async function parseRolloutFile( .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); + + // First pass: collect function_call_output entries by call_id + const outputsByCallId = new Map(); + for (const line of lines) { + const entry = parseRolloutEntryFromLine(line); + if (!entry || entry.type !== "response_item") continue; + const payloadResult = RolloutResponsePayloadSchema.safeParse(entry.payload); + if (!payloadResult.success) continue; + const payload = payloadResult.data; + if (payload.type === "function_call_output" && payload.call_id && payload.output) { + outputsByCallId.set(payload.call_id, payload.output); + } + } + + // Second pass: build timeline const timeline: AgentTimelineItem[] = []; for (const line of lines) { @@ -5043,8 +5111,9 @@ async function parseRolloutFile( if (!entry) continue; if (entry.type === "response_item") { - const payload = entry.payload as RolloutResponsePayload | undefined; - if (!payload || typeof payload !== "object") continue; + const payloadResult = RolloutResponsePayloadSchema.safeParse(entry.payload); + if (!payloadResult.success) continue; + const payload = payloadResult.data; switch (payload.type) { case "message": { @@ -5067,18 +5136,75 @@ async function parseRolloutFile( } break; } + case "function_call": + case "custom_tool_call": { + const rawName = payload.name ?? "unknown"; + const callId = payload.call_id; + + // Skip internal polling calls + if (rawName === "write_stdin") { + break; + } + + let input: unknown; + if (payload.arguments) { + try { + input = JSON.parse(payload.arguments); + } catch { + input = payload.arguments; + } + } + + // Map exec_command and shell to Bash with normalized input + let name = rawName; + if (rawName === "exec_command" && input && typeof input === "object") { + const execInput = input as { cmd?: string }; + if (execInput.cmd) { + name = "Bash"; + input = { command: execInput.cmd }; + } + } else if (rawName === "shell" && input && typeof input === "object") { + // Older format: { command: ["bash", "-lc", "actual cmd"], workdir: "..." } + const shellInput = input as { command?: string[] }; + if (Array.isArray(shellInput.command) && shellInput.command.length >= 3) { + name = "Bash"; + // command[2] is the actual shell command after "bash -lc" + input = { command: shellInput.command[2] }; + } + } + + // Attach output if available + const output = callId ? outputsByCallId.get(callId) : undefined; + + timeline.push({ + type: "tool_call", + name, + callId, + status: "completed", + input, + ...(output ? { output } : {}), + }); + break; + } + case "function_call_output": + // Already processed in first pass + break; default: break; } } else if (entry.type === "event_msg") { - const payload = entry.payload as RolloutEventPayload | undefined; - if ( - payload && - typeof payload === "object" && - payload.type === "agent_reasoning" && - typeof payload.text === "string" - ) { + const payloadResult = RolloutEventPayloadSchema.safeParse(entry.payload); + if (!payloadResult.success) continue; + const payload = payloadResult.data; + + if (payload.type === "agent_reasoning" && payload.text) { timeline.push({ type: "reasoning", text: payload.text }); + } else if (payload.type === "agent_message" && payload.message) { + timeline.push({ type: "assistant_message", text: payload.message }); + } else if (payload.type === "user_message" && payload.message) { + if (!isSyntheticRolloutUserMessage(payload.message)) { + timeline.push({ type: "user_message", text: payload.message }); + } } } } @@ -5091,7 +5217,7 @@ type CodexPersistedTimelineOptions = { rolloutPath?: string | null; }; -async function loadCodexPersistedTimeline( +export async function loadCodexPersistedTimeline( sessionId: string, options?: CodexPersistedTimelineOptions, logger?: Logger @@ -5103,7 +5229,7 @@ async function loadCodexPersistedTimeline( if (stat.isFile()) { const timeline = await parseRolloutFile(rolloutPath); if (timeline.length > 0) { - return timeline.slice(0, PERSISTED_TIMELINE_LIMIT); + return timeline; } } } catch { @@ -5131,7 +5257,7 @@ async function loadCodexPersistedTimeline( } const timeline = await parseRolloutFile(rolloutFile); - return timeline.slice(0, PERSISTED_TIMELINE_LIMIT); + return timeline; } catch (error) { logger?.warn( { err: error, sessionId }, diff --git a/packages/server/src/server/agent/providers/codex-rollout-parsing.test.ts b/packages/server/src/server/agent/providers/codex-rollout-parsing.test.ts new file mode 100644 index 000000000..dfa050e16 --- /dev/null +++ b/packages/server/src/server/agent/providers/codex-rollout-parsing.test.ts @@ -0,0 +1,301 @@ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { rmSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parseRolloutFile } from "./codex-mcp-agent.js"; + +describe("codex rollout parsing", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "rollout-test-")); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + describe("exec_command parsing", () => { + test("parses exec_command as Bash tool call with command", async () => { + const rolloutPath = join(tmpDir, "rollout.jsonl"); + const lines = [ + JSON.stringify({ + timestamp: "2026-01-22T07:09:01.348Z", + type: "response_item", + payload: { + type: "function_call", + name: "exec_command", + arguments: '{"cmd":"task show cc4ea7d1"}', + call_id: "call_MhTWDF2mpM4dhbNmHNt6ikDF", + }, + }), + ]; + writeFileSync(rolloutPath, lines.join("\n") + "\n"); + + const timeline = await parseRolloutFile(rolloutPath); + + const toolCalls = timeline.filter((i) => i.type === "tool_call"); + expect(toolCalls.length).toBe(1); + expect(toolCalls[0]).toMatchObject({ + type: "tool_call", + name: "Bash", + callId: "call_MhTWDF2mpM4dhbNmHNt6ikDF", + input: { command: "task show cc4ea7d1" }, + }); + }); + + test("includes function_call_output as tool call output", async () => { + const rolloutPath = join(tmpDir, "rollout.jsonl"); + const lines = [ + JSON.stringify({ + timestamp: "2026-01-22T07:09:01.348Z", + type: "response_item", + payload: { + type: "function_call", + name: "exec_command", + arguments: '{"cmd":"echo hello"}', + call_id: "call_abc123", + }, + }), + JSON.stringify({ + timestamp: "2026-01-22T07:09:01.785Z", + type: "response_item", + payload: { + type: "function_call_output", + call_id: "call_abc123", + output: + "Chunk ID: 13d232\nWall time: 0.2667 seconds\nProcess exited with code 0\nOriginal token count: 10\nOutput:\nhello", + }, + }), + ]; + writeFileSync(rolloutPath, lines.join("\n") + "\n"); + + const timeline = await parseRolloutFile(rolloutPath); + + const toolCalls = timeline.filter((i) => i.type === "tool_call"); + expect(toolCalls.length).toBe(1); + expect(toolCalls[0]).toMatchObject({ + type: "tool_call", + name: "Bash", + callId: "call_abc123", + input: { command: "echo hello" }, + output: expect.stringContaining("hello"), + }); + }); + + test("skips write_stdin function calls (polling)", async () => { + const rolloutPath = join(tmpDir, "rollout.jsonl"); + const lines = [ + JSON.stringify({ + timestamp: "2026-01-22T07:09:01.348Z", + type: "response_item", + payload: { + type: "function_call", + name: "exec_command", + arguments: '{"cmd":"npm test"}', + call_id: "call_real", + }, + }), + JSON.stringify({ + timestamp: "2026-01-22T07:28:16.497Z", + type: "response_item", + payload: { + type: "function_call", + name: "write_stdin", + arguments: + '{"session_id":7144,"chars":"","yield_time_ms":1000,"max_output_tokens":6000}', + call_id: "call_polling", + }, + }), + ]; + writeFileSync(rolloutPath, lines.join("\n") + "\n"); + + const timeline = await parseRolloutFile(rolloutPath); + + const toolCalls = timeline.filter((i) => i.type === "tool_call"); + expect(toolCalls.length).toBe(1); + expect(toolCalls[0].name).toBe("Bash"); + }); + }); + + describe("older shell format", () => { + test("parses shell command as Bash tool call", async () => { + const rolloutPath = join(tmpDir, "rollout.jsonl"); + const lines = [ + JSON.stringify({ + timestamp: "2025-11-03T14:37:50.400Z", + type: "response_item", + payload: { + type: "function_call", + name: "shell", + arguments: '{"command":["bash","-lc","ls -la"],"workdir":"/Users/test/project"}', + call_id: "call_shell123", + }, + }), + ]; + writeFileSync(rolloutPath, lines.join("\n") + "\n"); + + const timeline = await parseRolloutFile(rolloutPath); + + const toolCalls = timeline.filter((i) => i.type === "tool_call"); + expect(toolCalls.length).toBe(1); + expect(toolCalls[0]).toMatchObject({ + type: "tool_call", + name: "Bash", + callId: "call_shell123", + input: { command: "ls -la" }, + }); + }); + }); + + describe("real rollout file structure", () => { + test("parses user message correctly", async () => { + const rolloutPath = join(tmpDir, "rollout.jsonl"); + const lines = [ + JSON.stringify({ + timestamp: "2026-01-22T07:08:54.378Z", + type: "response_item", + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: "Fix the bug in auth.ts" }], + }, + }), + ]; + writeFileSync(rolloutPath, lines.join("\n") + "\n"); + + const timeline = await parseRolloutFile(rolloutPath); + + expect(timeline).toContainEqual({ + type: "user_message", + text: "Fix the bug in auth.ts", + }); + }); + + test("parses assistant message correctly", async () => { + const rolloutPath = join(tmpDir, "rollout.jsonl"); + const lines = [ + JSON.stringify({ + timestamp: "2026-01-22T07:08:54.378Z", + type: "response_item", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "I'll fix that for you." }], + }, + }), + ]; + writeFileSync(rolloutPath, lines.join("\n") + "\n"); + + const timeline = await parseRolloutFile(rolloutPath); + + expect(timeline).toContainEqual({ + type: "assistant_message", + text: "I'll fix that for you.", + }); + }); + + test("parses reasoning correctly", async () => { + const rolloutPath = join(tmpDir, "rollout.jsonl"); + const lines = [ + JSON.stringify({ + timestamp: "2026-01-22T07:08:54.378Z", + type: "response_item", + payload: { + type: "reasoning", + content: [{ type: "reasoning_text", text: "Let me think about this." }], + }, + }), + ]; + writeFileSync(rolloutPath, lines.join("\n") + "\n"); + + const timeline = await parseRolloutFile(rolloutPath); + + expect(timeline).toContainEqual({ + type: "reasoning", + text: "Let me think about this.", + }); + }); + }); + + describe("complex conversation", () => { + test("parses a full conversation with commands and outputs", async () => { + const rolloutPath = join(tmpDir, "rollout.jsonl"); + const lines = [ + // User message + JSON.stringify({ + timestamp: "2026-01-22T07:08:54.378Z", + type: "response_item", + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: "Run npm test" }], + }, + }), + // Reasoning + JSON.stringify({ + timestamp: "2026-01-22T07:08:55.000Z", + type: "response_item", + payload: { + type: "reasoning", + content: [{ type: "reasoning_text", text: "I need to run the tests." }], + }, + }), + // Command + JSON.stringify({ + timestamp: "2026-01-22T07:09:01.348Z", + type: "response_item", + payload: { + type: "function_call", + name: "exec_command", + arguments: '{"cmd":"npm test"}', + call_id: "call_test", + }, + }), + // Command output + JSON.stringify({ + timestamp: "2026-01-22T07:09:05.000Z", + type: "response_item", + payload: { + type: "function_call_output", + call_id: "call_test", + output: + "Chunk ID: abc\nWall time: 3.5 seconds\nProcess exited with code 0\nOutput:\nAll tests passed!", + }, + }), + // Assistant response + JSON.stringify({ + timestamp: "2026-01-22T07:09:06.000Z", + type: "response_item", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "All tests passed!" }], + }, + }), + ]; + writeFileSync(rolloutPath, lines.join("\n") + "\n"); + + const timeline = await parseRolloutFile(rolloutPath); + + // Should have: user_message, reasoning, tool_call (with output), assistant_message + expect(timeline.length).toBe(4); + + expect(timeline[0]).toMatchObject({ type: "user_message", text: "Run npm test" }); + expect(timeline[1]).toMatchObject({ + type: "reasoning", + text: "I need to run the tests.", + }); + expect(timeline[2]).toMatchObject({ + type: "tool_call", + name: "Bash", + input: { command: "npm test" }, + output: expect.stringContaining("All tests passed!"), + }); + expect(timeline[3]).toMatchObject({ + type: "assistant_message", + text: "All tests passed!", + }); + }); + }); +}); diff --git a/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts b/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts new file mode 100644 index 000000000..35998f3e1 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts @@ -0,0 +1,378 @@ +import { beforeEach, afterEach, describe, expect, test } from "vitest"; +import { mkdtempSync, writeFileSync, rmSync, existsSync, realpathSync } from "fs"; +import { tmpdir } from "os"; +import path from "path"; +import { execSync } from "child_process"; +import { experimental_createMCPClient } from "ai"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; + +import { + createDaemonTestContext, + type DaemonTestContext, +} from "../test-utils/index.js"; +import { createWorktree } from "../../utils/worktree.js"; + +const CODEX_TEST_MODEL = "gpt-5.1-codex-mini"; +const CODEX_TEST_REASONING_EFFORT = "low"; + +function tmpCwd(prefix: string): string { + return realpathSync(mkdtempSync(path.join(tmpdir(), prefix))); +} + +type McpToolResult = { + structuredContent?: Record; + content?: Array<{ structuredContent?: Record } | Record>; + toolResult?: unknown; + isError?: boolean; +}; + +type McpClient = { + callTool: (input: { name: string; args?: Record }) => Promise; + close: () => Promise; +}; + +function getStructuredContent(result: McpToolResult): Record | null { + if (result.structuredContent && typeof result.structuredContent === "object") { + return result.structuredContent; + } + const content = result.content?.[0]; + if (content && "structuredContent" in content && content.structuredContent) { + return content.structuredContent; + } + if (content && typeof content === "object") { + return content; + } + return null; +} + +function getToolResultText(result: McpToolResult): string { + const chunks: string[] = []; + if (result.structuredContent) { + chunks.push(JSON.stringify(result.structuredContent)); + } + if (result.toolResult !== undefined) { + chunks.push(JSON.stringify(result.toolResult)); + } + for (const entry of result.content ?? []) { + if (entry && typeof entry === "object") { + if ("text" in entry && typeof (entry as { text?: unknown }).text === "string") { + chunks.push(String((entry as { text?: unknown }).text)); + } + if ( + "structuredContent" in entry && + (entry as { structuredContent?: unknown }).structuredContent + ) { + chunks.push(JSON.stringify((entry as { structuredContent?: unknown }).structuredContent)); + } + } + } + return chunks.join(" ").trim(); +} + +async function createMcpClient(port: number, agentId: string): Promise { + const url = new URL(`http://127.0.0.1:${port}/mcp/agents`); + url.searchParams.set("callerAgentId", agentId); + const transport = new StreamableHTTPClientTransport(url); + return (await experimental_createMCPClient({ transport })) as McpClient; +} + +function initGitRepo(repoDir: string): void { + execSync("git init -b main", { cwd: repoDir, stdio: "pipe" }); + execSync("git config user.email 'paseo-test@example.com'", { + cwd: repoDir, + stdio: "pipe", + }); + execSync("git config user.name 'Paseo Test'", { + cwd: repoDir, + stdio: "pipe", + }); + writeFileSync(path.join(repoDir, "README.md"), "init\n"); + execSync("git add README.md", { cwd: repoDir, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", { + cwd: repoDir, + stdio: "pipe", + }); +} + +function createTempRepoName(): string { + const rand = Math.random().toString(16).slice(2, 8); + return `paseo-checkout-ship-${Date.now()}-${rand}`; +} + +function getGhLogin(): string { + return execSync("gh api user --jq .login", { stdio: "pipe" }) + .toString() + .trim(); +} + +function createPrivateRepo(repoName: string): void { + execSync(`gh api -X POST user/repos -f name=${repoName} -f private=true`, { + stdio: "pipe", + }); +} + +function getGhToken(): string { + return execSync("gh auth token", { stdio: "pipe" }).toString().trim(); +} + +function deleteRepoBestEffort(fullName: string | null): void { + if (!fullName) { + return; + } + try { + execSync(`gh repo delete ${fullName} --yes`, { stdio: "pipe" }); + } catch { + // best-effort cleanup + } +} + +describe("daemon checkout ship loop", () => { + let ctx: DaemonTestContext; + + beforeEach(async () => { + ctx = await createDaemonTestContext(); + }); + + afterEach(async () => { + await ctx.cleanup(); + }, 60000); + + test( + "runs the full checkout ship loop via checkout RPCs", + async () => { + const repoDir = tmpCwd("checkout-ship-"); + let repoFullName: string | null = null; + let mcpClient: McpClient | null = null; + let agentId: string | null = null; + + try { + initGitRepo(repoDir); + + const owner = getGhLogin(); + const repoName = createTempRepoName(); + repoFullName = `${owner}/${repoName}`; + createPrivateRepo(repoName); + + const token = encodeURIComponent(getGhToken()); + execSync( + `git remote add origin https://x-access-token:${token}@github.com/${repoFullName}.git`, + { + cwd: repoDir, + stdio: "pipe", + } + ); + execSync("git push -u origin main", { cwd: repoDir, stdio: "pipe" }); + + const worktree = await createWorktree({ + branchName: "ship-loop", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "ship-loop", + }); + + const agent = await ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + reasoningEffort: CODEX_TEST_REASONING_EFFORT, + cwd: worktree.worktreePath, + title: "Checkout Ship Loop", + }); + agentId = agent.id; + + const status = await ctx.client.getCheckoutStatus(agent.id); + expect(status.isGit).toBe(true); + expect(status.isPaseoOwnedWorktree).toBe(true); + expect(status.repoRoot).toContain(repoDir); + + mcpClient = await createMcpClient(ctx.daemon.port, agent.id); + const renameResult = (await mcpClient.callTool({ + name: "set_branch", + args: { name: "ship-loop-ready" }, + })) as McpToolResult; + const renamePayload = getStructuredContent(renameResult); + expect(renamePayload?.success).toBe(true); + + const updatedStatus = await ctx.client.getCheckoutStatus(agent.id); + expect(updatedStatus.currentBranch).toBe("ship-loop-ready"); + + const readmePath = path.join(worktree.worktreePath, "README.md"); + writeFileSync(readmePath, "init\nship loop update\n"); + + const diffUncommitted = await ctx.client.getCheckoutDiff(agent.id, { + mode: "uncommitted", + }); + expect(diffUncommitted.error).toBeNull(); + expect(diffUncommitted.files.length).toBeGreaterThan(0); + + const commitResult = await ctx.client.checkoutCommit(agent.id, { + message: "Ship loop update", + addAll: true, + }); + expect(commitResult.error).toBeNull(); + expect(commitResult.success).toBe(true); + + const diffAfterCommit = await ctx.client.getCheckoutDiff(agent.id, { + mode: "uncommitted", + }); + expect(diffAfterCommit.files.length).toBe(0); + + const baseDiff = await ctx.client.getCheckoutDiff(agent.id, { + mode: "base", + baseRef: "main", + }); + expect(baseDiff.files.length).toBeGreaterThan(0); + + const prCreate = await ctx.client.checkoutPrCreate(agent.id, { + title: "Ship loop update", + body: "Testing checkout ship loop", + baseRef: "main", + }); + expect(prCreate.error).toBeNull(); + expect(prCreate.url).toContain(repoName); + + const prStatus = await ctx.client.checkoutPrStatus(agent.id); + expect(prStatus.error).toBeNull(); + expect(prStatus.status?.url).toContain(repoName); + expect(prStatus.status?.state).toBeTruthy(); + + const mergeResult = await ctx.client.checkoutMerge(agent.id, { + baseRef: "main", + strategy: "merge", + requireCleanTarget: true, + }); + expect(mergeResult.error).toBeNull(); + expect(mergeResult.success).toBe(true); + + const baseDiffAfterMerge = await ctx.client.getCheckoutDiff(agent.id, { + mode: "base", + baseRef: "main", + }); + expect(baseDiffAfterMerge.files.length).toBe(0); + + const worktreeList = await ctx.client.getPaseoWorktreeList({ + cwd: repoDir, + }); + expect(worktreeList.error).toBeNull(); + expect( + worktreeList.worktrees.some( + (entry) => + entry.worktreePath === worktree.worktreePath && + entry.branchName === "ship-loop-ready" + ) + ).toBe(true); + + const archiveResult = await ctx.client.archivePaseoWorktree({ + worktreePath: worktree.worktreePath, + }); + expect(archiveResult.error).toBeNull(); + expect(archiveResult.success).toBe(true); + + const worktreeListAfter = await ctx.client.getPaseoWorktreeList({ + cwd: repoDir, + }); + expect( + worktreeListAfter.worktrees.some( + (entry) => entry.worktreePath === worktree.worktreePath + ) + ).toBe(false); + expect(existsSync(worktree.worktreePath)).toBe(false); + + const remainingAgents = ctx.client.listAgents(); + expect(remainingAgents.some((entry) => entry.id === agent.id)).toBe(false); + } finally { + if (mcpClient) { + await mcpClient.close().catch(() => undefined); + } + if (agentId) { + await ctx.client.deleteAgent(agentId).catch(() => undefined); + } + deleteRepoBestEffort(repoFullName); + rmSync(repoDir, { recursive: true, force: true }); + } + }, + 180000 + ); + + test( + "checkout RPCs return NOT_GIT_REPO for non-git directories", + async () => { + const cwd = tmpCwd("checkout-ship-non-git-"); + let agentId: string | null = null; + + try { + const agent = await ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + reasoningEffort: CODEX_TEST_REASONING_EFFORT, + cwd, + title: "Checkout Non-Git", + }); + agentId = agent.id; + + const status = await ctx.client.getCheckoutStatus(agent.id); + expect(status.isGit).toBe(false); + + const diff = await ctx.client.getCheckoutDiff(agent.id, { + mode: "uncommitted", + }); + expect(diff.error?.code).toBe("NOT_GIT_REPO"); + + const commit = await ctx.client.checkoutCommit(agent.id, { + message: "Should fail", + addAll: true, + }); + expect(commit.error?.code).toBe("NOT_GIT_REPO"); + } finally { + if (agentId) { + await ctx.client.deleteAgent(agentId).catch(() => undefined); + } + rmSync(cwd, { recursive: true, force: true }); + } + }, + 60000 + ); + + test( + "set_branch is rejected outside Paseo-owned worktrees", + async () => { + const repoDir = tmpCwd("checkout-ship-non-paseo-"); + let agentId: string | null = null; + let mcpClient: McpClient | null = null; + + try { + initGitRepo(repoDir); + + const agent = await ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + reasoningEffort: CODEX_TEST_REASONING_EFFORT, + cwd: repoDir, + title: "Checkout Non-Paseo", + }); + agentId = agent.id; + + mcpClient = await createMcpClient(ctx.daemon.port, agent.id); + let errorMessage = ""; + try { + const result = (await mcpClient.callTool({ + name: "set_branch", + args: { name: "not-allowed" }, + })) as McpToolResult; + errorMessage = getToolResultText(result); + } catch (error) { + errorMessage = error instanceof Error ? error.message : String(error); + } + expect(errorMessage).toMatch(/NOT_ALLOWED|Branch renames are only allowed/); + } finally { + if (mcpClient) { + await mcpClient.close().catch(() => undefined); + } + if (agentId) { + await ctx.client.deleteAgent(agentId).catch(() => undefined); + } + rmSync(repoDir, { recursive: true, force: true }); + } + }, + 60000 + ); +}); diff --git a/packages/server/src/server/daemon-e2e/two-cycle-resume.e2e.test.ts b/packages/server/src/server/daemon-e2e/two-cycle-resume.e2e.test.ts new file mode 100644 index 000000000..00a70b4e7 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/two-cycle-resume.e2e.test.ts @@ -0,0 +1,168 @@ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import path from "path"; +import { + createDaemonTestContext, + type DaemonTestContext, +} from "../test-utils/index.js"; +import type { AgentTimelineItem } from "../agent/agent-sdk-types.js"; +import type { PersistenceHandle } from "../../shared/messages.js"; + +function tmpCwd(): string { + return mkdtempSync(path.join(tmpdir(), "two-cycle-resume-")); +} + +const CODEX_TEST_MODEL = "gpt-5.1-codex-mini"; +const CODEX_TEST_REASONING_EFFORT = "low"; + +describe("two-cycle Codex agent resume", () => { + let ctx: DaemonTestContext; + + beforeEach(async () => { + ctx = await createDaemonTestContext(); + }); + + afterEach(async () => { + await ctx.cleanup(); + }, 60000); + + test( + "Codex agent remembers original marker after two resume cycles", + async () => { + const cwd = tmpCwd(); + // Use a memorable marker - a fake project name that's easy to recall + const MARKER = `project-unicorn-${Date.now()}`; + + // === CYCLE 0: Create agent and establish marker === + const agent = await ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + reasoningEffort: CODEX_TEST_REASONING_EFFORT, + cwd, + title: "Two Cycle Resume Test", + modeId: "full-access", + }); + + expect(agent.id).toBeTruthy(); + expect(agent.status).toBe("idle"); + + // Send the marker - phrase it as a test instruction + await ctx.client.sendMessage( + agent.id, + `For this test session, remember this project name: "${MARKER}". Just confirm you've noted it.` + ); + + const afterSecret = await ctx.client.waitForAgentIdle(agent.id, 120000); + expect(afterSecret.status).toBe("idle"); + expect(afterSecret.lastError).toBeUndefined(); + + // Verify agent confirmed + const queue0 = ctx.client.getMessageQueue(); + const confirmations: string[] = []; + for (const m of queue0) { + if ( + m.type === "agent_stream" && + m.payload.agentId === agent.id && + m.payload.event.type === "timeline" + ) { + const item = m.payload.event.item; + if (item.type === "assistant_message" && item.text) { + confirmations.push(item.text); + } + } + } + expect(confirmations.join("").length).toBeGreaterThan(0); + + // Get persistence handle + expect(afterSecret.persistence).toBeTruthy(); + const persistence0 = afterSecret.persistence as PersistenceHandle; + expect(persistence0.provider).toBe("codex"); + expect(persistence0.sessionId).toBeTruthy(); + + // === KILL: Delete agent and verify it's gone === + await ctx.client.deleteAgent(agent.id); + + // CRITICAL: Verify the agent is actually gone from the daemon + const agentsAfterDelete0 = ctx.client.listAgents(); + const stillExists0 = agentsAfterDelete0.some((a) => a.id === agent.id); + expect(stillExists0).toBe(false); + + // === CYCLE 1: First resume === + ctx.client.clearMessageQueue(); + const resumed1 = await ctx.client.resumeAgent(persistence0); + + expect(resumed1.id).toBeTruthy(); + expect(resumed1.status).toBe("idle"); + expect(resumed1.provider).toBe("codex"); + + // Send a new message to create activity in the resumed session + // This forces Codex to create a new session when it gets "session not found" + await ctx.client.sendMessage( + resumed1.id, + "Acknowledge you still remember the project name. Just say yes or no." + ); + + const afterAck = await ctx.client.waitForAgentIdle(resumed1.id, 120000); + expect(afterAck.status).toBe("idle"); + expect(afterAck.lastError).toBeUndefined(); + + // Get new persistence handle (session ID may have changed) + expect(afterAck.persistence).toBeTruthy(); + const persistence1 = afterAck.persistence as PersistenceHandle; + + // === KILL: Delete agent and verify it's gone === + await ctx.client.deleteAgent(resumed1.id); + + const agentsAfterDelete1 = ctx.client.listAgents(); + const stillExists1 = agentsAfterDelete1.some((a) => a.id === resumed1.id); + expect(stillExists1).toBe(false); + + // === CYCLE 2: Second resume === + ctx.client.clearMessageQueue(); + const resumed2 = await ctx.client.resumeAgent(persistence1); + + expect(resumed2.id).toBeTruthy(); + expect(resumed2.status).toBe("idle"); + expect(resumed2.provider).toBe("codex"); + + // === CRITICAL TEST: Ask about the ORIGINAL marker === + // If history is properly accumulated, the agent should remember. + // If history is lost on resume-of-resume, this will fail. + await ctx.client.sendMessage( + resumed2.id, + "What was the project name I asked you to remember at the very beginning of our conversation? Reply with the exact name." + ); + + const afterRecall = await ctx.client.waitForAgentIdle(resumed2.id, 120000); + expect(afterRecall.status).toBe("idle"); + expect(afterRecall.lastError).toBeUndefined(); + + // Collect the response + const queue2 = ctx.client.getMessageQueue(); + const responses: string[] = []; + for (const m of queue2) { + if ( + m.type === "agent_stream" && + m.payload.agentId === resumed2.id && + m.payload.event.type === "timeline" + ) { + const item = m.payload.event.item; + if (item.type === "assistant_message" && item.text) { + responses.push(item.text); + } + } + } + const fullResponse = responses.join(""); + + // CRITICAL ASSERTION: The agent should remember the original marker + // This proves history is properly accumulated across multiple resume cycles + expect(fullResponse).toContain(MARKER); + + // Cleanup + await ctx.client.deleteAgent(resumed2.id); + rmSync(cwd, { recursive: true, force: true }); + }, + 600000 // 10 minute timeout for multiple API calls and resume cycles + ); +}); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 9dbf7c80b..1c83ca51a 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -2,11 +2,12 @@ import { v4 as uuidv4 } from "uuid"; import { readFile, mkdir, writeFile, stat } from "fs/promises"; import { exec } from "child_process"; import { promisify, inspect } from "util"; -import { join } from "path"; +import { join, resolve, sep } from "path"; import invariant from "tiny-invariant"; import { streamText, stepCountIs } from "ai"; import type { ToolSet } from "ai"; import type { ModelMessage } from "@ai-sdk/provider-utils"; +import { z } from "zod"; import { createOpenRouter, OpenRouterProviderOptions, @@ -45,6 +46,7 @@ import { buildProviderRegistry } from "./agent/provider-registry.js"; import { AgentManager } from "./agent/agent-manager.js"; import type { ManagedAgent } from "./agent/agent-manager.js"; import { toAgentPayload } from "./agent/agent-projections.js"; +import { getStructuredAgentResponse } from "./agent/agent-response-loop.js"; import type { AgentPermissionResponse, AgentPromptContentBlock, @@ -63,11 +65,23 @@ import { } from "./file-explorer/service.js"; import { DownloadTokenStore } from "./file-download/token-store.js"; import { PushTokenStore } from "./push/token-store.js"; -import { createWorktree, slugify, validateBranchSlug } from "../utils/worktree.js"; +import { + createWorktree, + slugify, + validateBranchSlug, + listPaseoWorktrees, + deletePaseoWorktree, + isPaseoOwnedWorktreeCwd, +} from "../utils/worktree.js"; import { getCheckoutDiff, getCheckoutStatus, NotGitRepoError, + MergeConflictError, + commitChanges, + mergeToBase, + createPullRequest, + getPullRequestStatus, } from "../utils/checkout-git.js"; import { expandTilde } from "../utils/path.js"; import type pino from "pino"; @@ -97,6 +111,13 @@ type NormalizedGitOptions = { worktreeSlug?: string; }; +type CheckoutErrorCode = "NOT_GIT_REPO" | "NOT_ALLOWED" | "MERGE_CONFLICT" | "UNKNOWN"; + +type CheckoutErrorPayload = { + code: CheckoutErrorCode; + message: string; +}; + const PCM_SAMPLE_RATE = 16000; const PCM_CHANNELS = 1; const PCM_BITS_PER_SAMPLE = 16; @@ -750,6 +771,38 @@ export class Session { await this.handleGitDiffRequest(msg.agentId, msg.requestId); break; + case "checkout_status_request": + await this.handleCheckoutStatusRequest(msg); + break; + + case "checkout_diff_request": + await this.handleCheckoutDiffRequest(msg); + break; + + case "checkout_commit_request": + await this.handleCheckoutCommitRequest(msg); + break; + + case "checkout_merge_request": + await this.handleCheckoutMergeRequest(msg); + break; + + case "checkout_pr_create_request": + await this.handleCheckoutPrCreateRequest(msg); + break; + + case "checkout_pr_status_request": + await this.handleCheckoutPrStatusRequest(msg); + break; + + case "paseo_worktree_list_request": + await this.handlePaseoWorktreeListRequest(msg); + break; + + case "paseo_worktree_archive_request": + await this.handlePaseoWorktreeArchiveRequest(msg); + break; + case "highlighted_diff_request": await this.handleHighlightedDiffRequest(msg.agentId, msg.requestId); break; @@ -978,7 +1031,6 @@ export class Session { agentId: string, requestId: string ): Promise { - console.error(`[DELETE_AGENT] handleDeleteAgentRequest called for ${agentId} requestId=${requestId}`, new Error().stack); this.sessionLogger.info( { agentId }, `Deleting agent ${agentId} from registry` @@ -1693,6 +1745,86 @@ export class Session { } } + private toCheckoutError(error: unknown): CheckoutErrorPayload { + if (error instanceof NotGitRepoError) { + return { code: "NOT_GIT_REPO", message: error.message }; + } + if (error instanceof MergeConflictError) { + return { code: "MERGE_CONFLICT", message: error.message }; + } + if (error instanceof Error) { + return { code: "UNKNOWN", message: error.message }; + } + return { code: "UNKNOWN", message: String(error) }; + } + + private isPathWithinRoot(rootPath: string, candidatePath: string): boolean { + const resolvedRoot = resolve(rootPath); + const resolvedCandidate = resolve(candidatePath); + if (resolvedCandidate === resolvedRoot) { + return true; + } + return resolvedCandidate.startsWith(resolvedRoot + sep); + } + + private async generateCommitMessage(agent: ManagedAgent): Promise { + const diff = await getCheckoutDiff(agent.cwd, { mode: "uncommitted" }); + const schema = z.object({ + message: z + .string() + .min(1) + .max(72) + .describe("Concise git commit message, imperative mood, no trailing period."), + }); + const prompt = [ + "Write a concise git commit message for the changes below.", + "Return JSON only with a single field 'message'.", + "", + diff.diff.length > 0 ? diff.diff : "(No diff available)", + ].join("\n"); + const result = await getStructuredAgentResponse({ + caller: async (nextPrompt) => { + const run = await this.agentManager.runAgent(agent.id, nextPrompt); + return run.finalText; + }, + prompt, + schema, + schemaName: "CommitMessage", + maxRetries: 2, + }); + return result.message; + } + + private async generatePullRequestText(agent: ManagedAgent, baseRef?: string): Promise<{ + title: string; + body: string; + }> { + const diff = await getCheckoutDiff(agent.cwd, { + mode: "base", + baseRef, + }); + const schema = z.object({ + title: z.string().min(1).max(72), + body: z.string().min(1), + }); + const prompt = [ + "Write a pull request title and body for the changes below.", + "Return JSON only with fields 'title' and 'body'.", + "", + diff.diff.length > 0 ? diff.diff : "(No diff available)", + ].join("\n"); + return await getStructuredAgentResponse({ + caller: async (nextPrompt) => { + const run = await this.agentManager.runAgent(agent.id, nextPrompt); + return run.finalText; + }, + prompt, + schema, + schemaName: "PullRequest", + maxRetries: 2, + }); + } + private async ensureCleanWorkingTree(cwd: string): Promise { const dirty = await this.isWorkingTreeDirty(cwd); if (dirty) { @@ -2101,6 +2233,518 @@ export class Session { } } + private async handleCheckoutStatusRequest( + msg: Extract + ): Promise { + const { agentId, requestId } = msg; + const agent = this.agentManager.getAgent(agentId); + if (!agent) { + this.emit({ + type: "checkout_status_response", + payload: { + agentId, + cwd: "", + isGit: false, + repoRoot: null, + currentBranch: null, + isDirty: null, + baseRef: null, + aheadBehind: null, + isPaseoOwnedWorktree: null, + error: { code: "UNKNOWN", message: `Agent not found: ${agentId}` }, + requestId, + }, + }); + return; + } + + try { + const status = await getCheckoutStatus(agent.cwd); + if (!status.isGit) { + this.emit({ + type: "checkout_status_response", + payload: { + agentId, + cwd: agent.cwd, + isGit: false, + repoRoot: null, + currentBranch: null, + isDirty: null, + baseRef: null, + aheadBehind: null, + isPaseoOwnedWorktree: false, + error: null, + requestId, + }, + }); + return; + } + + let isPaseoOwnedWorktree = false; + try { + const ownership = await isPaseoOwnedWorktreeCwd(agent.cwd); + isPaseoOwnedWorktree = ownership.allowed; + } catch { + isPaseoOwnedWorktree = false; + } + + this.emit({ + type: "checkout_status_response", + payload: { + agentId, + cwd: agent.cwd, + isGit: true, + repoRoot: status.repoRoot ?? null, + currentBranch: status.currentBranch ?? null, + isDirty: status.isDirty ?? null, + baseRef: status.baseRef ?? null, + aheadBehind: status.aheadBehind ?? null, + isPaseoOwnedWorktree, + error: null, + requestId, + }, + }); + } catch (error) { + this.emit({ + type: "checkout_status_response", + payload: { + agentId, + cwd: agent.cwd, + isGit: false, + repoRoot: null, + currentBranch: null, + isDirty: null, + baseRef: null, + aheadBehind: null, + isPaseoOwnedWorktree: null, + error: this.toCheckoutError(error), + requestId, + }, + }); + } + } + + private async handleCheckoutDiffRequest( + msg: Extract + ): Promise { + const { agentId, requestId, compare } = msg; + const agent = this.agentManager.getAgent(agentId); + if (!agent) { + this.emit({ + type: "checkout_diff_response", + payload: { + agentId, + files: [], + error: { code: "UNKNOWN", message: `Agent not found: ${agentId}` }, + requestId, + }, + }); + return; + } + + try { + const diffResult = await getCheckoutDiff(agent.cwd, { + mode: compare.mode, + baseRef: compare.baseRef, + includeStructured: true, + }); + this.emit({ + type: "checkout_diff_response", + payload: { + agentId, + files: diffResult.structured ?? [], + error: null, + requestId, + }, + }); + } catch (error) { + this.emit({ + type: "checkout_diff_response", + payload: { + agentId, + files: [], + error: this.toCheckoutError(error), + requestId, + }, + }); + } + } + + private async handleCheckoutCommitRequest( + msg: Extract + ): Promise { + const { agentId, requestId } = msg; + const agent = this.agentManager.getAgent(agentId); + if (!agent) { + this.emit({ + type: "checkout_commit_response", + payload: { + agentId, + success: false, + error: { code: "UNKNOWN", message: `Agent not found: ${agentId}` }, + requestId, + }, + }); + return; + } + + try { + let message = msg.message?.trim() ?? ""; + if (!message) { + message = await this.generateCommitMessage(agent); + } + if (!message) { + throw new Error("Commit message is required"); + } + + await commitChanges(agent.cwd, { + message, + addAll: msg.addAll ?? true, + }); + + this.emit({ + type: "checkout_commit_response", + payload: { + agentId, + success: true, + error: null, + requestId, + }, + }); + } catch (error) { + this.emit({ + type: "checkout_commit_response", + payload: { + agentId, + success: false, + error: this.toCheckoutError(error), + requestId, + }, + }); + } + } + + private async handleCheckoutMergeRequest( + msg: Extract + ): Promise { + const { agentId, requestId } = msg; + const agent = this.agentManager.getAgent(agentId); + if (!agent) { + this.emit({ + type: "checkout_merge_response", + payload: { + agentId, + success: false, + error: { code: "UNKNOWN", message: `Agent not found: ${agentId}` }, + requestId, + }, + }); + return; + } + + try { + const status = await getCheckoutStatus(agent.cwd); + if (!status.isGit) { + throw new NotGitRepoError(agent.cwd); + } + if (msg.requireCleanTarget && status.isDirty) { + throw new Error("Working directory has uncommitted changes."); + } + + let baseRef = msg.baseRef ?? status.baseRef ?? null; + if (!baseRef) { + throw new Error("Base branch is required for merge"); + } + if (baseRef.startsWith("origin/")) { + baseRef = baseRef.slice("origin/".length); + } + + await mergeToBase(agent.cwd, { + baseRef, + mode: msg.strategy === "squash" ? "squash" : "merge", + }); + + this.emit({ + type: "checkout_merge_response", + payload: { + agentId, + success: true, + error: null, + requestId, + }, + }); + } catch (error) { + this.emit({ + type: "checkout_merge_response", + payload: { + agentId, + success: false, + error: this.toCheckoutError(error), + requestId, + }, + }); + } + } + + private async handleCheckoutPrCreateRequest( + msg: Extract + ): Promise { + const { agentId, requestId } = msg; + const agent = this.agentManager.getAgent(agentId); + if (!agent) { + this.emit({ + type: "checkout_pr_create_response", + payload: { + agentId, + url: null, + number: null, + error: { code: "UNKNOWN", message: `Agent not found: ${agentId}` }, + requestId, + }, + }); + return; + } + + try { + let title = msg.title?.trim() ?? ""; + let body = msg.body?.trim() ?? ""; + if (!title || !body) { + const generated = await this.generatePullRequestText(agent, msg.baseRef); + if (!title) { + title = generated.title; + } + if (!body) { + body = generated.body; + } + } + if (!title) { + throw new Error("Pull request title is required"); + } + + const result = await createPullRequest(agent.cwd, { + title, + body, + base: msg.baseRef, + }); + + this.emit({ + type: "checkout_pr_create_response", + payload: { + agentId, + url: result.url ?? null, + number: result.number ?? null, + error: null, + requestId, + }, + }); + } catch (error) { + this.emit({ + type: "checkout_pr_create_response", + payload: { + agentId, + url: null, + number: null, + error: this.toCheckoutError(error), + requestId, + }, + }); + } + } + + private async handleCheckoutPrStatusRequest( + msg: Extract + ): Promise { + const { agentId, requestId } = msg; + const agent = this.agentManager.getAgent(agentId); + if (!agent) { + this.emit({ + type: "checkout_pr_status_response", + payload: { + agentId, + status: null, + error: { code: "UNKNOWN", message: `Agent not found: ${agentId}` }, + requestId, + }, + }); + return; + } + + try { + const status = await getPullRequestStatus(agent.cwd); + this.emit({ + type: "checkout_pr_status_response", + payload: { + agentId, + status, + error: null, + requestId, + }, + }); + } catch (error) { + this.emit({ + type: "checkout_pr_status_response", + payload: { + agentId, + status: null, + error: this.toCheckoutError(error), + requestId, + }, + }); + } + } + + private async handlePaseoWorktreeListRequest( + msg: Extract + ): Promise { + const { requestId } = msg; + const cwd = msg.repoRoot ?? msg.cwd; + if (!cwd) { + this.emit({ + type: "paseo_worktree_list_response", + payload: { + worktrees: [], + error: { code: "UNKNOWN", message: "cwd or repoRoot is required" }, + requestId, + }, + }); + return; + } + + try { + const worktrees = await listPaseoWorktrees({ cwd }); + this.emit({ + type: "paseo_worktree_list_response", + payload: { + worktrees: worktrees.map((entry) => ({ + worktreePath: entry.path, + branchName: entry.branchName ?? null, + head: entry.head ?? null, + })), + error: null, + requestId, + }, + }); + } catch (error) { + this.emit({ + type: "paseo_worktree_list_response", + payload: { + worktrees: [], + error: this.toCheckoutError(error), + requestId, + }, + }); + } + } + + private async handlePaseoWorktreeArchiveRequest( + msg: Extract + ): Promise { + const { requestId } = msg; + let targetPath = msg.worktreePath; + let repoRoot = msg.repoRoot ?? null; + + try { + if (!targetPath) { + if (!repoRoot || !msg.branchName) { + throw new Error("worktreePath or repoRoot+branchName is required"); + } + const worktrees = await listPaseoWorktrees({ cwd: repoRoot }); + const match = worktrees.find((entry) => entry.branchName === msg.branchName); + if (!match) { + throw new Error(`Paseo worktree not found for branch ${msg.branchName}`); + } + targetPath = match.path; + } + + const ownership = await isPaseoOwnedWorktreeCwd(targetPath); + if (!ownership.allowed) { + this.emit({ + type: "paseo_worktree_archive_response", + payload: { + success: false, + removedAgents: [], + error: { + code: "NOT_ALLOWED", + message: "Worktree is not a Paseo-owned worktree", + }, + requestId, + }, + }); + return; + } + + repoRoot = ownership.repoRoot ?? repoRoot ?? null; + if (!repoRoot) { + throw new Error("Unable to resolve repo root for worktree"); + } + + const removedAgents = new Set(); + const agents = this.agentManager.listAgents(); + for (const agent of agents) { + if (this.isPathWithinRoot(targetPath, agent.cwd)) { + removedAgents.add(agent.id); + try { + await this.agentManager.closeAgent(agent.id); + } catch { + // ignore cleanup errors + } + try { + await this.agentRegistry.remove(agent.id); + } catch { + // ignore cleanup errors + } + } + } + + const registryRecords = await this.agentRegistry.list(); + for (const record of registryRecords) { + if (this.isPathWithinRoot(targetPath, record.cwd)) { + removedAgents.add(record.id); + try { + await this.agentRegistry.remove(record.id); + } catch { + // ignore cleanup errors + } + } + } + + await deletePaseoWorktree({ + cwd: repoRoot, + worktreePath: targetPath, + }); + + for (const agentId of removedAgents) { + this.emit({ + type: "agent_deleted", + payload: { + agentId, + requestId, + }, + }); + } + + this.emit({ + type: "paseo_worktree_archive_response", + payload: { + success: true, + removedAgents: Array.from(removedAgents), + error: null, + requestId, + }, + }); + } catch (error) { + this.emit({ + type: "paseo_worktree_archive_response", + payload: { + success: false, + removedAgents: [], + error: this.toCheckoutError(error), + requestId, + }, + }); + } + } + /** * Handle highlighted diff request - returns parsed and syntax-highlighted diff */ diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 52bc8a86f..ea2f5adbf 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -162,6 +162,11 @@ export const AgentStreamEventPayloadSchema = z.discriminatedUnion("type", [ sessionId: z.string(), provider: AgentProviderSchema, }), + z.object({ + type: z.literal("provider_event"), + provider: AgentProviderSchema, + raw: z.unknown(), + }), z.object({ type: z.literal("turn_started"), provider: AgentProviderSchema, @@ -430,6 +435,83 @@ export const GitDiffRequestSchema = z.object({ requestId: z.string(), }); +const CheckoutErrorCodeSchema = z.enum([ + "NOT_GIT_REPO", + "NOT_ALLOWED", + "MERGE_CONFLICT", + "UNKNOWN", +]); + +const CheckoutErrorSchema = z.object({ + code: CheckoutErrorCodeSchema, + message: z.string(), +}); + +const CheckoutDiffCompareSchema = z.object({ + mode: z.enum(["uncommitted", "base"]), + baseRef: z.string().optional(), +}); + +export const CheckoutStatusRequestSchema = z.object({ + type: z.literal("checkout_status_request"), + agentId: z.string(), + requestId: z.string(), +}); + +export const CheckoutDiffRequestSchema = z.object({ + type: z.literal("checkout_diff_request"), + agentId: z.string(), + compare: CheckoutDiffCompareSchema, + requestId: z.string(), +}); + +export const CheckoutCommitRequestSchema = z.object({ + type: z.literal("checkout_commit_request"), + agentId: z.string(), + message: z.string().optional(), + addAll: z.boolean().optional(), + requestId: z.string(), +}); + +export const CheckoutMergeRequestSchema = z.object({ + type: z.literal("checkout_merge_request"), + agentId: z.string(), + baseRef: z.string().optional(), + strategy: z.enum(["merge", "squash"]).optional(), + requireCleanTarget: z.boolean().optional(), + requestId: z.string(), +}); + +export const CheckoutPrCreateRequestSchema = z.object({ + type: z.literal("checkout_pr_create_request"), + agentId: z.string(), + title: z.string().optional(), + body: z.string().optional(), + baseRef: z.string().optional(), + requestId: z.string(), +}); + +export const CheckoutPrStatusRequestSchema = z.object({ + type: z.literal("checkout_pr_status_request"), + agentId: z.string(), + requestId: z.string(), +}); + +export const PaseoWorktreeListRequestSchema = z.object({ + type: z.literal("paseo_worktree_list_request"), + cwd: z.string().optional(), + repoRoot: z.string().optional(), + requestId: z.string(), +}); + +export const PaseoWorktreeArchiveRequestSchema = z.object({ + type: z.literal("paseo_worktree_archive_request"), + worktreePath: z.string().optional(), + repoRoot: z.string().optional(), + branchName: z.string().optional(), + requestId: z.string(), +}); + // Highlighted diff token schema // Note: style can be a compound class name (e.g., "heading meta") from the syntax highlighter const HighlightTokenSchema = z.object({ @@ -612,6 +694,14 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ SetAgentModeMessageSchema, AgentPermissionResponseMessageSchema, GitDiffRequestSchema, + CheckoutStatusRequestSchema, + CheckoutDiffRequestSchema, + CheckoutCommitRequestSchema, + CheckoutMergeRequestSchema, + CheckoutPrCreateRequestSchema, + CheckoutPrStatusRequestSchema, + PaseoWorktreeListRequestSchema, + PaseoWorktreeArchiveRequestSchema, HighlightedDiffRequestSchema, FileExplorerRequestSchema, FileDownloadTokenRequestSchema, @@ -871,6 +961,112 @@ export const GitDiffResponseSchema = z.object({ }), }); +const AheadBehindSchema = z.object({ + ahead: z.number(), + behind: z.number(), +}); + +export const CheckoutStatusResponseSchema = z.object({ + type: z.literal("checkout_status_response"), + payload: z.object({ + agentId: z.string(), + cwd: z.string(), + isGit: z.boolean(), + repoRoot: z.string().nullable().optional(), + currentBranch: z.string().nullable().optional(), + isDirty: z.boolean().nullable().optional(), + baseRef: z.string().nullable().optional(), + aheadBehind: AheadBehindSchema.nullable().optional(), + isPaseoOwnedWorktree: z.boolean().nullable().optional(), + error: CheckoutErrorSchema.nullable(), + requestId: z.string(), + }), +}); + +export const CheckoutDiffResponseSchema = z.object({ + type: z.literal("checkout_diff_response"), + payload: z.object({ + agentId: z.string(), + files: z.array(ParsedDiffFileSchema), + error: CheckoutErrorSchema.nullable(), + requestId: z.string(), + }), +}); + +export const CheckoutCommitResponseSchema = z.object({ + type: z.literal("checkout_commit_response"), + payload: z.object({ + agentId: z.string(), + success: z.boolean(), + error: CheckoutErrorSchema.nullable(), + requestId: z.string(), + }), +}); + +export const CheckoutMergeResponseSchema = z.object({ + type: z.literal("checkout_merge_response"), + payload: z.object({ + agentId: z.string(), + success: z.boolean(), + error: CheckoutErrorSchema.nullable(), + requestId: z.string(), + }), +}); + +export const CheckoutPrCreateResponseSchema = z.object({ + type: z.literal("checkout_pr_create_response"), + payload: z.object({ + agentId: z.string(), + url: z.string().nullable(), + number: z.number().nullable(), + error: CheckoutErrorSchema.nullable(), + requestId: z.string(), + }), +}); + +const CheckoutPrStatusSchema = z.object({ + url: z.string(), + title: z.string(), + state: z.string(), + baseRefName: z.string(), + headRefName: z.string(), +}); + +export const CheckoutPrStatusResponseSchema = z.object({ + type: z.literal("checkout_pr_status_response"), + payload: z.object({ + agentId: z.string(), + status: CheckoutPrStatusSchema.nullable(), + error: CheckoutErrorSchema.nullable(), + requestId: z.string(), + }), +}); + +const PaseoWorktreeSchema = z.object({ + worktreePath: z.string(), + branchName: z.string().nullable().optional(), + head: z.string().nullable().optional(), +}); + +export const PaseoWorktreeListResponseSchema = z.object({ + type: z.literal("paseo_worktree_list_response"), + payload: z.object({ + worktrees: z.array(PaseoWorktreeSchema), + error: CheckoutErrorSchema.nullable(), + requestId: z.string(), + }), +}); + +export const PaseoWorktreeArchiveResponseSchema = z.object({ + type: z.literal("paseo_worktree_archive_response"), + payload: z.object({ + success: z.boolean(), + removedAgents: z.array(z.string()).optional(), + error: CheckoutErrorSchema.nullable(), + requestId: z.string(), + }), +}); + export const HighlightedDiffResponseSchema = z.object({ type: z.literal("highlighted_diff_response"), payload: z.object({ @@ -1063,6 +1259,14 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ AgentPermissionResolvedMessageSchema, AgentDeletedMessageSchema, GitDiffResponseSchema, + CheckoutStatusResponseSchema, + CheckoutDiffResponseSchema, + CheckoutCommitResponseSchema, + CheckoutMergeResponseSchema, + CheckoutPrCreateResponseSchema, + CheckoutPrStatusResponseSchema, + PaseoWorktreeListResponseSchema, + PaseoWorktreeArchiveResponseSchema, HighlightedDiffResponseSchema, FileExplorerResponseSchema, FileDownloadTokenResponseSchema, @@ -1131,6 +1335,22 @@ export type SetAgentModeMessage = z.infer; export type AgentPermissionResponseMessage = z.infer; export type GitDiffRequest = z.infer; export type GitDiffResponse = z.infer; +export type CheckoutStatusRequest = z.infer; +export type CheckoutStatusResponse = z.infer; +export type CheckoutDiffRequest = z.infer; +export type CheckoutDiffResponse = z.infer; +export type CheckoutCommitRequest = z.infer; +export type CheckoutCommitResponse = z.infer; +export type CheckoutMergeRequest = z.infer; +export type CheckoutMergeResponse = z.infer; +export type CheckoutPrCreateRequest = z.infer; +export type CheckoutPrCreateResponse = z.infer; +export type CheckoutPrStatusRequest = z.infer; +export type CheckoutPrStatusResponse = z.infer; +export type PaseoWorktreeListRequest = z.infer; +export type PaseoWorktreeListResponse = z.infer; +export type PaseoWorktreeArchiveRequest = z.infer; +export type PaseoWorktreeArchiveResponse = z.infer; export type HighlightedDiffRequest = z.infer; export type HighlightedDiffResponse = z.infer; export type FileExplorerRequest = z.infer; diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index ae64fc5c0..30d298726 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -1,5 +1,6 @@ import { exec, execFile } from "child_process"; import { promisify } from "util"; +import { resolve } from "path"; import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js"; import { parseDiff } from "../server/utils/diff-highlighter.js"; import { detectRepoInfo } from "./worktree.js"; @@ -267,14 +268,23 @@ export async function getCheckoutDiff( return { diff }; } -export async function commitAll(cwd: string, message: string): Promise { +export async function commitChanges( + cwd: string, + options: { message: string; addAll?: boolean } +): Promise { await requireRepoInfo(cwd); - await execFileAsync("git", ["add", "-A"], { cwd }); - await execFileAsync("git", ["-c", "commit.gpgsign=false", "commit", "-m", message], { + if (options.addAll ?? true) { + await execFileAsync("git", ["add", "-A"], { cwd }); + } + await execFileAsync("git", ["-c", "commit.gpgsign=false", "commit", "-m", options.message], { cwd, }); } +export async function commitAll(cwd: string, message: string): Promise { + await commitChanges(cwd, { message, addAll: true }); +} + export async function mergeToBase(cwd: string, options: MergeToBaseOptions = {}): Promise { const repoInfo = await requireRepoInfo(cwd); const currentBranch = await getCurrentBranch(cwd); @@ -289,16 +299,22 @@ export async function mergeToBase(cwd: string, options: MergeToBaseOptions = {}) return; } - const originalBranch = currentBranch; + const operationCwd = repoInfo.path; + const isSameCheckout = resolve(operationCwd) === resolve(cwd); + const originalBranch = await getCurrentBranch(operationCwd); const mode = options.mode ?? "merge"; try { - await execAsync(`git checkout ${baseRef}`, { cwd }); + await execAsync(`git checkout ${baseRef}`, { cwd: operationCwd }); if (mode === "squash") { - await execAsync(`git merge --squash ${originalBranch}`, { cwd }); - const message = options.commitMessage ?? `Squash merge ${originalBranch} into ${baseRef}`; - await execFileAsync("git", ["-c", "commit.gpgsign=false", "commit", "-m", message], { cwd }); + await execAsync(`git merge --squash ${currentBranch}`, { cwd: operationCwd }); + const message = options.commitMessage ?? `Squash merge ${currentBranch} into ${baseRef}`; + await execFileAsync( + "git", + ["-c", "commit.gpgsign=false", "commit", "-m", message], + { cwd: operationCwd } + ); } else { - await execAsync(`git merge ${originalBranch}`, { cwd }); + await execAsync(`git merge ${currentBranch}`, { cwd: operationCwd }); } } catch (error) { const errorDetails = @@ -307,9 +323,9 @@ export async function mergeToBase(cwd: string, options: MergeToBaseOptions = {}) : String(error); try { const [unmergedOutput, lsFilesOutput, statusOutput] = await Promise.all([ - execAsync("git diff --name-only --diff-filter=U", { cwd }), - execAsync("git ls-files -u", { cwd }), - execAsync("git status --porcelain", { cwd }), + execAsync("git diff --name-only --diff-filter=U", { cwd: operationCwd }), + execAsync("git ls-files -u", { cwd: operationCwd }), + execAsync("git status --porcelain", { cwd: operationCwd }), ]); const statusConflicts = statusOutput.stdout .split("\n") @@ -333,13 +349,13 @@ export async function mergeToBase(cwd: string, options: MergeToBaseOptions = {}) conflicts.length > 0 || /CONFLICT|Automatic merge failed/i.test(errorDetails); if (conflictDetected) { try { - await execAsync("git merge --abort", { cwd }); + await execAsync("git merge --abort", { cwd: operationCwd }); } catch { // ignore } throw new MergeConflictError({ baseRef, - currentBranch: originalBranch, + currentBranch, conflictFiles: conflicts.length > 0 ? conflicts : [], }); } @@ -352,9 +368,9 @@ export async function mergeToBase(cwd: string, options: MergeToBaseOptions = {}) throw error; } finally { - if (originalBranch !== baseRef) { + if (isSameCheckout && originalBranch && originalBranch !== baseRef) { try { - await execAsync(`git checkout ${originalBranch}`, { cwd }); + await execAsync(`git checkout ${originalBranch}`, { cwd: operationCwd }); } catch { // ignore } @@ -386,46 +402,115 @@ async function ensureGhAvailable(cwd: string): Promise { } } -export async function createPullRequest(cwd: string, options: CreatePullRequestOptions): Promise<{ url: string; number: number }> { +async function resolveGitHubRepo(cwd: string): Promise { + try { + const { stdout } = await execAsync("git config --get remote.origin.url", { + cwd, + env: READ_ONLY_GIT_ENV, + }); + const url = stdout.trim(); + if (!url) { + return null; + } + let cleaned = url; + if (cleaned.startsWith("git@github.com:")) { + cleaned = cleaned.slice("git@github.com:".length); + } else if (cleaned.startsWith("https://github.com/")) { + cleaned = cleaned.slice("https://github.com/".length); + } else if (cleaned.startsWith("http://github.com/")) { + cleaned = cleaned.slice("http://github.com/".length); + } else { + const marker = "github.com/"; + const index = cleaned.indexOf(marker); + if (index !== -1) { + cleaned = cleaned.slice(index + marker.length); + } else { + return null; + } + } + if (cleaned.endsWith(".git")) { + cleaned = cleaned.slice(0, -".git".length); + } + if (!cleaned.includes("/")) { + return null; + } + return cleaned; + } catch { + // ignore + } + return null; +} + +export async function createPullRequest( + cwd: string, + options: CreatePullRequestOptions +): Promise<{ url: string; number: number }> { await requireRepoInfo(cwd); await ensureGhAvailable(cwd); - const args = ["pr", "create", "--json", "url,number", "--title", options.title]; + const repo = await resolveGitHubRepo(cwd); + if (!repo) { + throw new Error("Unable to determine GitHub repo from git remote"); + } + + const repoInfo = await detectRepoInfo(cwd); + const head = options.head ?? (await getCurrentBranch(cwd)); + const base = options.base ?? (await resolveBaseRef(repoInfo.path)); + if (!head) { + throw new Error("Unable to determine head branch for PR"); + } + if (!base) { + throw new Error("Unable to determine base branch for PR"); + } + + await execAsync(`git push -u origin ${head}`, { cwd }); + + const args = ["api", "-X", "POST", `repos/${repo}/pulls`, "-f", `title=${options.title}`]; + args.push("-f", `head=${head}`); + args.push("-f", `base=${base}`); if (options.body) { - args.push("--body", options.body); + args.push("-f", `body=${options.body}`); } - if (options.base) { - args.push("--base", options.base); - } - if (options.head) { - args.push("--head", options.head); - } - if (options.draft) { - args.push("--draft"); - } - const { stdout } = await execAsync(`gh ${args.map((arg) => `"${arg}"`).join(" ")}`, { - cwd, - }); + const { stdout } = await execFileAsync("gh", args, { cwd }); const parsed = JSON.parse(stdout.trim()); + if (!parsed?.url || !parsed?.number) { + throw new Error("GitHub CLI did not return PR url/number"); + } return { url: parsed.url, number: parsed.number }; } export async function getPullRequestStatus(cwd: string): Promise { await requireRepoInfo(cwd); await ensureGhAvailable(cwd); - const { stdout } = await execAsync( - "gh pr status --json url,title,state,baseRefName,headRefName", + const repo = await resolveGitHubRepo(cwd); + const head = await getCurrentBranch(cwd); + if (!repo || !head) { + return null; + } + const owner = repo.split("/")[0]; + const { stdout } = await execFileAsync( + "gh", + [ + "api", + `repos/${repo}/pulls`, + "-X", + "GET", + "-F", + `head=${owner}:${head}`, + "-F", + "state=open", + ], { cwd } ); const parsed = JSON.parse(stdout.trim()); - const current = parsed.currentBranch; + const current = Array.isArray(parsed) && parsed.length > 0 ? parsed[0] : null; if (!current) { return null; } return { - url: current.url, + url: current.html_url ?? current.url, title: current.title, state: current.state, - baseRefName: current.baseRefName, - headRefName: current.headRefName, + baseRefName: current.base?.ref ?? "", + headRefName: current.head?.ref ?? head, }; }