feat(server): checkout RPCs + ship loop e2es

This commit is contained in:
Mohamed Boudra
2026-01-22 20:37:12 +07:00
parent ef9c538842
commit d41267e8a5
10 changed files with 2593 additions and 93 deletions

View File

@@ -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<CheckoutStatusPayload> {
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<CheckoutDiffPayload> {
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<CheckoutCommitPayload> {
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<CheckoutMergePayload> {
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<CheckoutPrCreatePayload> {
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<CheckoutPrStatusPayload> {
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<PaseoWorktreeListPayload> {
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<PaseoWorktreeArchivePayload> {
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

View File

@@ -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");
});
});
});

View File

@@ -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,
};
}

View File

@@ -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<string, PatchFileChange[]>();
@@ -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<string, unknown>).curatedHistory === "string") {
this.previousCuratedHistory = (metadata as Record<string, unknown>).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<void> {
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<typeof RolloutEntrySchema>;
type RolloutResponsePayload = z.infer<typeof RolloutResponsePayloadSchema>;
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<AgentTimelineItem[]> {
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<string, string>();
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 },

View File

@@ -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!",
});
});
});
});

View File

@@ -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<string, unknown>;
content?: Array<{ structuredContent?: Record<string, unknown> } | Record<string, unknown>>;
toolResult?: unknown;
isError?: boolean;
};
type McpClient = {
callTool: (input: { name: string; args?: Record<string, unknown> }) => Promise<unknown>;
close: () => Promise<void>;
};
function getStructuredContent(result: McpToolResult): Record<string, unknown> | 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<McpClient> {
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
);
});

View File

@@ -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
);
});

View File

@@ -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<void> {
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<string> {
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<void> {
const dirty = await this.isWorkingTreeDirty(cwd);
if (dirty) {
@@ -2101,6 +2233,518 @@ export class Session {
}
}
private async handleCheckoutStatusRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_status_request" }>
): Promise<void> {
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<SessionInboundMessage, { type: "checkout_diff_request" }>
): Promise<void> {
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<SessionInboundMessage, { type: "checkout_commit_request" }>
): Promise<void> {
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<SessionInboundMessage, { type: "checkout_merge_request" }>
): Promise<void> {
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<SessionInboundMessage, { type: "checkout_pr_create_request" }>
): Promise<void> {
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<SessionInboundMessage, { type: "checkout_pr_status_request" }>
): Promise<void> {
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<SessionInboundMessage, { type: "paseo_worktree_list_request" }>
): Promise<void> {
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<SessionInboundMessage, { type: "paseo_worktree_archive_request" }>
): Promise<void> {
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<string>();
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
*/

View File

@@ -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<typeof SetAgentModeMessageSchema>;
export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>;
export type GitDiffRequest = z.infer<typeof GitDiffRequestSchema>;
export type GitDiffResponse = z.infer<typeof GitDiffResponseSchema>;
export type CheckoutStatusRequest = z.infer<typeof CheckoutStatusRequestSchema>;
export type CheckoutStatusResponse = z.infer<typeof CheckoutStatusResponseSchema>;
export type CheckoutDiffRequest = z.infer<typeof CheckoutDiffRequestSchema>;
export type CheckoutDiffResponse = z.infer<typeof CheckoutDiffResponseSchema>;
export type CheckoutCommitRequest = z.infer<typeof CheckoutCommitRequestSchema>;
export type CheckoutCommitResponse = z.infer<typeof CheckoutCommitResponseSchema>;
export type CheckoutMergeRequest = z.infer<typeof CheckoutMergeRequestSchema>;
export type CheckoutMergeResponse = z.infer<typeof CheckoutMergeResponseSchema>;
export type CheckoutPrCreateRequest = z.infer<typeof CheckoutPrCreateRequestSchema>;
export type CheckoutPrCreateResponse = z.infer<typeof CheckoutPrCreateResponseSchema>;
export type CheckoutPrStatusRequest = z.infer<typeof CheckoutPrStatusRequestSchema>;
export type CheckoutPrStatusResponse = z.infer<typeof CheckoutPrStatusResponseSchema>;
export type PaseoWorktreeListRequest = z.infer<typeof PaseoWorktreeListRequestSchema>;
export type PaseoWorktreeListResponse = z.infer<typeof PaseoWorktreeListResponseSchema>;
export type PaseoWorktreeArchiveRequest = z.infer<typeof PaseoWorktreeArchiveRequestSchema>;
export type PaseoWorktreeArchiveResponse = z.infer<typeof PaseoWorktreeArchiveResponseSchema>;
export type HighlightedDiffRequest = z.infer<typeof HighlightedDiffRequestSchema>;
export type HighlightedDiffResponse = z.infer<typeof HighlightedDiffResponseSchema>;
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;

View File

@@ -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<void> {
export async function commitChanges(
cwd: string,
options: { message: string; addAll?: boolean }
): Promise<void> {
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<void> {
await commitChanges(cwd, { message, addAll: true });
}
export async function mergeToBase(cwd: string, options: MergeToBaseOptions = {}): Promise<void> {
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<void> {
}
}
export async function createPullRequest(cwd: string, options: CreatePullRequestOptions): Promise<{ url: string; number: number }> {
async function resolveGitHubRepo(cwd: string): Promise<string | null> {
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<PullRequestStatus | null> {
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,
};
}