feat(server): checkout RPCs + ship loop e2es
This commit is contained in:
@@ -21,6 +21,14 @@ import type {
|
|||||||
GitSetupOptions,
|
GitSetupOptions,
|
||||||
GitRepoInfoResponse,
|
GitRepoInfoResponse,
|
||||||
HighlightedDiffResponse,
|
HighlightedDiffResponse,
|
||||||
|
CheckoutStatusResponse,
|
||||||
|
CheckoutDiffResponse,
|
||||||
|
CheckoutCommitResponse,
|
||||||
|
CheckoutMergeResponse,
|
||||||
|
CheckoutPrCreateResponse,
|
||||||
|
CheckoutPrStatusResponse,
|
||||||
|
PaseoWorktreeListResponse,
|
||||||
|
PaseoWorktreeArchiveResponse,
|
||||||
ListCommandsResponse,
|
ListCommandsResponse,
|
||||||
ExecuteCommandResponse,
|
ExecuteCommandResponse,
|
||||||
ListVoiceConversationsResponseMessage,
|
ListVoiceConversationsResponseMessage,
|
||||||
@@ -167,6 +175,14 @@ type DeleteVoiceConversationPayload = DeleteVoiceConversationResponseMessage["pa
|
|||||||
type GitDiffPayload = GitDiffResponse["payload"];
|
type GitDiffPayload = GitDiffResponse["payload"];
|
||||||
type HighlightedDiffPayload = HighlightedDiffResponse["payload"];
|
type HighlightedDiffPayload = HighlightedDiffResponse["payload"];
|
||||||
type GitRepoInfoPayload = GitRepoInfoResponse["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 FileExplorerPayload = FileExplorerResponse["payload"];
|
||||||
type FileDownloadTokenPayload = FileDownloadTokenResponse["payload"];
|
type FileDownloadTokenPayload = FileDownloadTokenResponse["payload"];
|
||||||
type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"];
|
type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"];
|
||||||
@@ -925,6 +941,238 @@ export class DaemonClientV2 {
|
|||||||
// Git Operations
|
// 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(
|
async getGitDiff(
|
||||||
agentId: string,
|
agentId: string,
|
||||||
requestId?: string
|
requestId?: string
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1971,8 +1971,6 @@ async function collectRecentClaudeSessions(root: string, limit: number): Promise
|
|||||||
.slice(0, limit);
|
.slice(0, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
const CLAUDE_PERSISTED_TIMELINE_LIMIT = 20;
|
|
||||||
|
|
||||||
async function parseClaudeSessionDescriptor(
|
async function parseClaudeSessionDescriptor(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
mtime: Date
|
mtime: Date
|
||||||
@@ -2047,7 +2045,7 @@ async function parseClaudeSessionDescriptor(
|
|||||||
title: (title ?? "").trim() || `Claude session ${sessionId.slice(0, 8)}`,
|
title: (title ?? "").trim() || `Claude session ${sessionId.slice(0, 8)}`,
|
||||||
lastActivityAt: mtime,
|
lastActivityAt: mtime,
|
||||||
persistence,
|
persistence,
|
||||||
timeline: timeline.slice(0, CLAUDE_PERSISTED_TIMELINE_LIMIT),
|
timeline,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import type {
|
|||||||
PersistedAgentDescriptor,
|
PersistedAgentDescriptor,
|
||||||
} from "../agent-sdk-types.js";
|
} from "../agent-sdk-types.js";
|
||||||
import { getSelfIdentificationInstructions } from "../self-identification-instructions.js";
|
import { getSelfIdentificationInstructions } from "../self-identification-instructions.js";
|
||||||
|
import { curateAgentActivity } from "../activity-curator.js";
|
||||||
|
|
||||||
type CodexMcpAgentConfig = AgentSessionConfig & { provider: "codex" };
|
type CodexMcpAgentConfig = AgentSessionConfig & { provider: "codex" };
|
||||||
|
|
||||||
@@ -3033,6 +3034,7 @@ class CodexMcpAgentSession implements AgentSession {
|
|||||||
private historyPending = false;
|
private historyPending = false;
|
||||||
private persistedHistory: AgentTimelineItem[] = [];
|
private persistedHistory: AgentTimelineItem[] = [];
|
||||||
private resumeContextHistory: AgentTimelineItem[] = [];
|
private resumeContextHistory: AgentTimelineItem[] = [];
|
||||||
|
private previousCuratedHistory: string | null = null;
|
||||||
private pendingHistory: AgentTimelineItem[] = [];
|
private pendingHistory: AgentTimelineItem[] = [];
|
||||||
private turnState: TurnState | null = null;
|
private turnState: TurnState | null = null;
|
||||||
private pendingPatchChanges = new Map<string, PatchFileChange[]>();
|
private pendingPatchChanges = new Map<string, PatchFileChange[]>();
|
||||||
@@ -3061,6 +3063,10 @@ class CodexMcpAgentSession implements AgentSession {
|
|||||||
if (parsed.conversationId) {
|
if (parsed.conversationId) {
|
||||||
this.conversationId = 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
|
// Always lock conversation ID on resume to preserve the original
|
||||||
// Even if metadata didn't have conversationId, we'll use sessionId as fallback
|
// Even if metadata didn't have conversationId, we'll use sessionId as fallback
|
||||||
@@ -3452,6 +3458,7 @@ class CodexMcpAgentSession implements AgentSession {
|
|||||||
describePersistence(): AgentPersistenceHandle | null {
|
describePersistence(): AgentPersistenceHandle | null {
|
||||||
if (this.persistence) {
|
if (this.persistence) {
|
||||||
this.updatePersistenceConversationId();
|
this.updatePersistenceConversationId();
|
||||||
|
this.updatePersistenceCuratedHistory();
|
||||||
return this.persistence;
|
return this.persistence;
|
||||||
}
|
}
|
||||||
const persistenceId = this.sessionId ?? this.conversationId;
|
const persistenceId = this.sessionId ?? this.conversationId;
|
||||||
@@ -3474,6 +3481,7 @@ class CodexMcpAgentSession implements AgentSession {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
this.updatePersistenceConversationId();
|
this.updatePersistenceConversationId();
|
||||||
|
this.updatePersistenceCuratedHistory();
|
||||||
return this.persistence;
|
return this.persistence;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3486,6 +3494,18 @@ class CodexMcpAgentSession implements AgentSession {
|
|||||||
metadata.conversationId = conversationId;
|
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> {
|
async close(): Promise<void> {
|
||||||
for (const pending of this.pendingPermissionHandlers.values()) {
|
for (const pending of this.pendingPermissionHandlers.values()) {
|
||||||
pending.reject(new Error("Codex MCP session closed"));
|
pending.reject(new Error("Codex MCP session closed"));
|
||||||
@@ -3740,6 +3760,9 @@ class CodexMcpAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private recordHistory(item: AgentTimelineItem): void {
|
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) {
|
if (this.sessionId) {
|
||||||
this.persistedHistory.push(item);
|
this.persistedHistory.push(item);
|
||||||
return;
|
return;
|
||||||
@@ -4428,21 +4451,31 @@ class CodexMcpAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildResumePrompt(prompt: string): string {
|
private buildResumePrompt(prompt: string): string {
|
||||||
const historyLines: string[] = [];
|
|
||||||
// Use resumeContextHistory instead of persistedHistory because
|
// Use resumeContextHistory instead of persistedHistory because
|
||||||
// persistedHistory gets cleared by streamHistory() before the first message is sent
|
// persistedHistory gets cleared by streamHistory() before the first message is sent
|
||||||
for (const item of this.resumeContextHistory) {
|
|
||||||
if (item.type === "user_message") {
|
// Combine previous curated history (from earlier resume cycles) with current history
|
||||||
historyLines.push(`User: ${item.text}`);
|
// This ensures conversation context survives across multiple resume cycles
|
||||||
}
|
const historyParts: string[] = [];
|
||||||
if (item.type === "assistant_message") {
|
|
||||||
historyLines.push(`Assistant: ${item.text}`);
|
// 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 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 {
|
private buildPermissionRequest(params: unknown): AgentPermissionRequest {
|
||||||
@@ -4806,7 +4839,6 @@ class CodexAppServerClient {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
const MAX_ROLLOUT_SEARCH_DEPTH = 4;
|
const MAX_ROLLOUT_SEARCH_DEPTH = 4;
|
||||||
const PERSISTED_TIMELINE_LIMIT = 100;
|
|
||||||
const CODEX_ROLLOUT_PREFIX = "rollout-";
|
const CODEX_ROLLOUT_PREFIX = "rollout-";
|
||||||
const CODEX_ROLLOUT_EXTENSIONS = [".jsonl", ".json"];
|
const CODEX_ROLLOUT_EXTENSIONS = [".jsonl", ".json"];
|
||||||
|
|
||||||
@@ -4861,44 +4893,60 @@ async function findRolloutFile(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type RolloutEntry = {
|
const RolloutContentItemSchema = z.object({
|
||||||
type: "response_item" | "event_msg";
|
type: z.string(),
|
||||||
payload?: unknown;
|
text: z.string(),
|
||||||
};
|
}).passthrough();
|
||||||
|
|
||||||
type RolloutResponsePayload = {
|
const RolloutContentArraySchema = z.array(RolloutContentItemSchema);
|
||||||
type?: string;
|
|
||||||
role?: string;
|
|
||||||
content?: unknown;
|
|
||||||
name?: string;
|
|
||||||
call_id?: string;
|
|
||||||
arguments?: string;
|
|
||||||
output?: string;
|
|
||||||
summary?: Array<{ text?: string }>;
|
|
||||||
text?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type RolloutEventPayload = {
|
function extractContentTextByType(content: unknown, itemType: string): string {
|
||||||
type?: string;
|
const parsed = RolloutContentArraySchema.safeParse(content);
|
||||||
text?: string;
|
if (!parsed.success) {
|
||||||
};
|
return "";
|
||||||
|
|
||||||
function isRolloutEntry(value: unknown): value is RolloutEntry {
|
|
||||||
if (!value || typeof value !== "object" || !("type" in value)) {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
const type = (value as { type?: unknown }).type;
|
return parsed.data
|
||||||
return type === "response_item" || type === "event_msg";
|
.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 {
|
function parseRolloutEntryFromLine(line: string): RolloutEntry | null {
|
||||||
if (!line) {
|
if (!line) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(line);
|
const parsed = JSON.parse(line);
|
||||||
if (isRolloutEntry(parsed)) {
|
const result = RolloutEntrySchema.safeParse(parsed);
|
||||||
return parsed;
|
if (result.success) {
|
||||||
|
return result.data;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
parsed &&
|
parsed &&
|
||||||
@@ -4966,6 +5014,11 @@ function extractReasoningText(payload: RolloutResponsePayload): string {
|
|||||||
return text;
|
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") {
|
if (typeof payload?.text === "string") {
|
||||||
return payload.text;
|
return payload.text;
|
||||||
}
|
}
|
||||||
@@ -5015,7 +5068,7 @@ function parseJsonRolloutTimeline(
|
|||||||
return timeline;
|
return timeline;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function parseRolloutFile(
|
export async function parseRolloutFile(
|
||||||
filePath: string
|
filePath: string
|
||||||
): Promise<AgentTimelineItem[]> {
|
): Promise<AgentTimelineItem[]> {
|
||||||
const content = await fs.readFile(filePath, "utf8");
|
const content = await fs.readFile(filePath, "utf8");
|
||||||
@@ -5036,6 +5089,21 @@ async function parseRolloutFile(
|
|||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
.filter(Boolean);
|
.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[] = [];
|
const timeline: AgentTimelineItem[] = [];
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
@@ -5043,8 +5111,9 @@ async function parseRolloutFile(
|
|||||||
if (!entry) continue;
|
if (!entry) continue;
|
||||||
|
|
||||||
if (entry.type === "response_item") {
|
if (entry.type === "response_item") {
|
||||||
const payload = entry.payload as RolloutResponsePayload | undefined;
|
const payloadResult = RolloutResponsePayloadSchema.safeParse(entry.payload);
|
||||||
if (!payload || typeof payload !== "object") continue;
|
if (!payloadResult.success) continue;
|
||||||
|
const payload = payloadResult.data;
|
||||||
|
|
||||||
switch (payload.type) {
|
switch (payload.type) {
|
||||||
case "message": {
|
case "message": {
|
||||||
@@ -5067,18 +5136,75 @@ async function parseRolloutFile(
|
|||||||
}
|
}
|
||||||
break;
|
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:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} else if (entry.type === "event_msg") {
|
} else if (entry.type === "event_msg") {
|
||||||
const payload = entry.payload as RolloutEventPayload | undefined;
|
const payloadResult = RolloutEventPayloadSchema.safeParse(entry.payload);
|
||||||
if (
|
if (!payloadResult.success) continue;
|
||||||
payload &&
|
const payload = payloadResult.data;
|
||||||
typeof payload === "object" &&
|
|
||||||
payload.type === "agent_reasoning" &&
|
if (payload.type === "agent_reasoning" && payload.text) {
|
||||||
typeof payload.text === "string"
|
|
||||||
) {
|
|
||||||
timeline.push({ type: "reasoning", text: 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;
|
rolloutPath?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function loadCodexPersistedTimeline(
|
export async function loadCodexPersistedTimeline(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
options?: CodexPersistedTimelineOptions,
|
options?: CodexPersistedTimelineOptions,
|
||||||
logger?: Logger
|
logger?: Logger
|
||||||
@@ -5103,7 +5229,7 @@ async function loadCodexPersistedTimeline(
|
|||||||
if (stat.isFile()) {
|
if (stat.isFile()) {
|
||||||
const timeline = await parseRolloutFile(rolloutPath);
|
const timeline = await parseRolloutFile(rolloutPath);
|
||||||
if (timeline.length > 0) {
|
if (timeline.length > 0) {
|
||||||
return timeline.slice(0, PERSISTED_TIMELINE_LIMIT);
|
return timeline;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -5131,7 +5257,7 @@ async function loadCodexPersistedTimeline(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const timeline = await parseRolloutFile(rolloutFile);
|
const timeline = await parseRolloutFile(rolloutFile);
|
||||||
return timeline.slice(0, PERSISTED_TIMELINE_LIMIT);
|
return timeline;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger?.warn(
|
logger?.warn(
|
||||||
{ err: error, sessionId },
|
{ err: error, sessionId },
|
||||||
|
|||||||
@@ -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!",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -2,11 +2,12 @@ import { v4 as uuidv4 } from "uuid";
|
|||||||
import { readFile, mkdir, writeFile, stat } from "fs/promises";
|
import { readFile, mkdir, writeFile, stat } from "fs/promises";
|
||||||
import { exec } from "child_process";
|
import { exec } from "child_process";
|
||||||
import { promisify, inspect } from "util";
|
import { promisify, inspect } from "util";
|
||||||
import { join } from "path";
|
import { join, resolve, sep } from "path";
|
||||||
import invariant from "tiny-invariant";
|
import invariant from "tiny-invariant";
|
||||||
import { streamText, stepCountIs } from "ai";
|
import { streamText, stepCountIs } from "ai";
|
||||||
import type { ToolSet } from "ai";
|
import type { ToolSet } from "ai";
|
||||||
import type { ModelMessage } from "@ai-sdk/provider-utils";
|
import type { ModelMessage } from "@ai-sdk/provider-utils";
|
||||||
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
createOpenRouter,
|
createOpenRouter,
|
||||||
OpenRouterProviderOptions,
|
OpenRouterProviderOptions,
|
||||||
@@ -45,6 +46,7 @@ import { buildProviderRegistry } from "./agent/provider-registry.js";
|
|||||||
import { AgentManager } from "./agent/agent-manager.js";
|
import { AgentManager } from "./agent/agent-manager.js";
|
||||||
import type { ManagedAgent } from "./agent/agent-manager.js";
|
import type { ManagedAgent } from "./agent/agent-manager.js";
|
||||||
import { toAgentPayload } from "./agent/agent-projections.js";
|
import { toAgentPayload } from "./agent/agent-projections.js";
|
||||||
|
import { getStructuredAgentResponse } from "./agent/agent-response-loop.js";
|
||||||
import type {
|
import type {
|
||||||
AgentPermissionResponse,
|
AgentPermissionResponse,
|
||||||
AgentPromptContentBlock,
|
AgentPromptContentBlock,
|
||||||
@@ -63,11 +65,23 @@ import {
|
|||||||
} from "./file-explorer/service.js";
|
} from "./file-explorer/service.js";
|
||||||
import { DownloadTokenStore } from "./file-download/token-store.js";
|
import { DownloadTokenStore } from "./file-download/token-store.js";
|
||||||
import { PushTokenStore } from "./push/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 {
|
import {
|
||||||
getCheckoutDiff,
|
getCheckoutDiff,
|
||||||
getCheckoutStatus,
|
getCheckoutStatus,
|
||||||
NotGitRepoError,
|
NotGitRepoError,
|
||||||
|
MergeConflictError,
|
||||||
|
commitChanges,
|
||||||
|
mergeToBase,
|
||||||
|
createPullRequest,
|
||||||
|
getPullRequestStatus,
|
||||||
} from "../utils/checkout-git.js";
|
} from "../utils/checkout-git.js";
|
||||||
import { expandTilde } from "../utils/path.js";
|
import { expandTilde } from "../utils/path.js";
|
||||||
import type pino from "pino";
|
import type pino from "pino";
|
||||||
@@ -97,6 +111,13 @@ type NormalizedGitOptions = {
|
|||||||
worktreeSlug?: string;
|
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_SAMPLE_RATE = 16000;
|
||||||
const PCM_CHANNELS = 1;
|
const PCM_CHANNELS = 1;
|
||||||
const PCM_BITS_PER_SAMPLE = 16;
|
const PCM_BITS_PER_SAMPLE = 16;
|
||||||
@@ -750,6 +771,38 @@ export class Session {
|
|||||||
await this.handleGitDiffRequest(msg.agentId, msg.requestId);
|
await this.handleGitDiffRequest(msg.agentId, msg.requestId);
|
||||||
break;
|
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":
|
case "highlighted_diff_request":
|
||||||
await this.handleHighlightedDiffRequest(msg.agentId, msg.requestId);
|
await this.handleHighlightedDiffRequest(msg.agentId, msg.requestId);
|
||||||
break;
|
break;
|
||||||
@@ -978,7 +1031,6 @@ export class Session {
|
|||||||
agentId: string,
|
agentId: string,
|
||||||
requestId: string
|
requestId: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
console.error(`[DELETE_AGENT] handleDeleteAgentRequest called for ${agentId} requestId=${requestId}`, new Error().stack);
|
|
||||||
this.sessionLogger.info(
|
this.sessionLogger.info(
|
||||||
{ agentId },
|
{ agentId },
|
||||||
`Deleting agent ${agentId} from registry`
|
`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> {
|
private async ensureCleanWorkingTree(cwd: string): Promise<void> {
|
||||||
const dirty = await this.isWorkingTreeDirty(cwd);
|
const dirty = await this.isWorkingTreeDirty(cwd);
|
||||||
if (dirty) {
|
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
|
* Handle highlighted diff request - returns parsed and syntax-highlighted diff
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -162,6 +162,11 @@ export const AgentStreamEventPayloadSchema = z.discriminatedUnion("type", [
|
|||||||
sessionId: z.string(),
|
sessionId: z.string(),
|
||||||
provider: AgentProviderSchema,
|
provider: AgentProviderSchema,
|
||||||
}),
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal("provider_event"),
|
||||||
|
provider: AgentProviderSchema,
|
||||||
|
raw: z.unknown(),
|
||||||
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
type: z.literal("turn_started"),
|
type: z.literal("turn_started"),
|
||||||
provider: AgentProviderSchema,
|
provider: AgentProviderSchema,
|
||||||
@@ -430,6 +435,83 @@ export const GitDiffRequestSchema = z.object({
|
|||||||
requestId: z.string(),
|
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
|
// Highlighted diff token schema
|
||||||
// Note: style can be a compound class name (e.g., "heading meta") from the syntax highlighter
|
// Note: style can be a compound class name (e.g., "heading meta") from the syntax highlighter
|
||||||
const HighlightTokenSchema = z.object({
|
const HighlightTokenSchema = z.object({
|
||||||
@@ -612,6 +694,14 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
SetAgentModeMessageSchema,
|
SetAgentModeMessageSchema,
|
||||||
AgentPermissionResponseMessageSchema,
|
AgentPermissionResponseMessageSchema,
|
||||||
GitDiffRequestSchema,
|
GitDiffRequestSchema,
|
||||||
|
CheckoutStatusRequestSchema,
|
||||||
|
CheckoutDiffRequestSchema,
|
||||||
|
CheckoutCommitRequestSchema,
|
||||||
|
CheckoutMergeRequestSchema,
|
||||||
|
CheckoutPrCreateRequestSchema,
|
||||||
|
CheckoutPrStatusRequestSchema,
|
||||||
|
PaseoWorktreeListRequestSchema,
|
||||||
|
PaseoWorktreeArchiveRequestSchema,
|
||||||
HighlightedDiffRequestSchema,
|
HighlightedDiffRequestSchema,
|
||||||
FileExplorerRequestSchema,
|
FileExplorerRequestSchema,
|
||||||
FileDownloadTokenRequestSchema,
|
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({
|
export const HighlightedDiffResponseSchema = z.object({
|
||||||
type: z.literal("highlighted_diff_response"),
|
type: z.literal("highlighted_diff_response"),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
@@ -1063,6 +1259,14 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
AgentPermissionResolvedMessageSchema,
|
AgentPermissionResolvedMessageSchema,
|
||||||
AgentDeletedMessageSchema,
|
AgentDeletedMessageSchema,
|
||||||
GitDiffResponseSchema,
|
GitDiffResponseSchema,
|
||||||
|
CheckoutStatusResponseSchema,
|
||||||
|
CheckoutDiffResponseSchema,
|
||||||
|
CheckoutCommitResponseSchema,
|
||||||
|
CheckoutMergeResponseSchema,
|
||||||
|
CheckoutPrCreateResponseSchema,
|
||||||
|
CheckoutPrStatusResponseSchema,
|
||||||
|
PaseoWorktreeListResponseSchema,
|
||||||
|
PaseoWorktreeArchiveResponseSchema,
|
||||||
HighlightedDiffResponseSchema,
|
HighlightedDiffResponseSchema,
|
||||||
FileExplorerResponseSchema,
|
FileExplorerResponseSchema,
|
||||||
FileDownloadTokenResponseSchema,
|
FileDownloadTokenResponseSchema,
|
||||||
@@ -1131,6 +1335,22 @@ export type SetAgentModeMessage = z.infer<typeof SetAgentModeMessageSchema>;
|
|||||||
export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>;
|
export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>;
|
||||||
export type GitDiffRequest = z.infer<typeof GitDiffRequestSchema>;
|
export type GitDiffRequest = z.infer<typeof GitDiffRequestSchema>;
|
||||||
export type GitDiffResponse = z.infer<typeof GitDiffResponseSchema>;
|
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 HighlightedDiffRequest = z.infer<typeof HighlightedDiffRequestSchema>;
|
||||||
export type HighlightedDiffResponse = z.infer<typeof HighlightedDiffResponseSchema>;
|
export type HighlightedDiffResponse = z.infer<typeof HighlightedDiffResponseSchema>;
|
||||||
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
|
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { exec, execFile } from "child_process";
|
import { exec, execFile } from "child_process";
|
||||||
import { promisify } from "util";
|
import { promisify } from "util";
|
||||||
|
import { resolve } from "path";
|
||||||
import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
|
import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
|
||||||
import { parseDiff } from "../server/utils/diff-highlighter.js";
|
import { parseDiff } from "../server/utils/diff-highlighter.js";
|
||||||
import { detectRepoInfo } from "./worktree.js";
|
import { detectRepoInfo } from "./worktree.js";
|
||||||
@@ -267,14 +268,23 @@ export async function getCheckoutDiff(
|
|||||||
return { diff };
|
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 requireRepoInfo(cwd);
|
||||||
await execFileAsync("git", ["add", "-A"], { cwd });
|
if (options.addAll ?? true) {
|
||||||
await execFileAsync("git", ["-c", "commit.gpgsign=false", "commit", "-m", message], {
|
await execFileAsync("git", ["add", "-A"], { cwd });
|
||||||
|
}
|
||||||
|
await execFileAsync("git", ["-c", "commit.gpgsign=false", "commit", "-m", options.message], {
|
||||||
cwd,
|
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> {
|
export async function mergeToBase(cwd: string, options: MergeToBaseOptions = {}): Promise<void> {
|
||||||
const repoInfo = await requireRepoInfo(cwd);
|
const repoInfo = await requireRepoInfo(cwd);
|
||||||
const currentBranch = await getCurrentBranch(cwd);
|
const currentBranch = await getCurrentBranch(cwd);
|
||||||
@@ -289,16 +299,22 @@ export async function mergeToBase(cwd: string, options: MergeToBaseOptions = {})
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const originalBranch = currentBranch;
|
const operationCwd = repoInfo.path;
|
||||||
|
const isSameCheckout = resolve(operationCwd) === resolve(cwd);
|
||||||
|
const originalBranch = await getCurrentBranch(operationCwd);
|
||||||
const mode = options.mode ?? "merge";
|
const mode = options.mode ?? "merge";
|
||||||
try {
|
try {
|
||||||
await execAsync(`git checkout ${baseRef}`, { cwd });
|
await execAsync(`git checkout ${baseRef}`, { cwd: operationCwd });
|
||||||
if (mode === "squash") {
|
if (mode === "squash") {
|
||||||
await execAsync(`git merge --squash ${originalBranch}`, { cwd });
|
await execAsync(`git merge --squash ${currentBranch}`, { cwd: operationCwd });
|
||||||
const message = options.commitMessage ?? `Squash merge ${originalBranch} into ${baseRef}`;
|
const message = options.commitMessage ?? `Squash merge ${currentBranch} into ${baseRef}`;
|
||||||
await execFileAsync("git", ["-c", "commit.gpgsign=false", "commit", "-m", message], { cwd });
|
await execFileAsync(
|
||||||
|
"git",
|
||||||
|
["-c", "commit.gpgsign=false", "commit", "-m", message],
|
||||||
|
{ cwd: operationCwd }
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
await execAsync(`git merge ${originalBranch}`, { cwd });
|
await execAsync(`git merge ${currentBranch}`, { cwd: operationCwd });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorDetails =
|
const errorDetails =
|
||||||
@@ -307,9 +323,9 @@ export async function mergeToBase(cwd: string, options: MergeToBaseOptions = {})
|
|||||||
: String(error);
|
: String(error);
|
||||||
try {
|
try {
|
||||||
const [unmergedOutput, lsFilesOutput, statusOutput] = await Promise.all([
|
const [unmergedOutput, lsFilesOutput, statusOutput] = await Promise.all([
|
||||||
execAsync("git diff --name-only --diff-filter=U", { cwd }),
|
execAsync("git diff --name-only --diff-filter=U", { cwd: operationCwd }),
|
||||||
execAsync("git ls-files -u", { cwd }),
|
execAsync("git ls-files -u", { cwd: operationCwd }),
|
||||||
execAsync("git status --porcelain", { cwd }),
|
execAsync("git status --porcelain", { cwd: operationCwd }),
|
||||||
]);
|
]);
|
||||||
const statusConflicts = statusOutput.stdout
|
const statusConflicts = statusOutput.stdout
|
||||||
.split("\n")
|
.split("\n")
|
||||||
@@ -333,13 +349,13 @@ export async function mergeToBase(cwd: string, options: MergeToBaseOptions = {})
|
|||||||
conflicts.length > 0 || /CONFLICT|Automatic merge failed/i.test(errorDetails);
|
conflicts.length > 0 || /CONFLICT|Automatic merge failed/i.test(errorDetails);
|
||||||
if (conflictDetected) {
|
if (conflictDetected) {
|
||||||
try {
|
try {
|
||||||
await execAsync("git merge --abort", { cwd });
|
await execAsync("git merge --abort", { cwd: operationCwd });
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
throw new MergeConflictError({
|
throw new MergeConflictError({
|
||||||
baseRef,
|
baseRef,
|
||||||
currentBranch: originalBranch,
|
currentBranch,
|
||||||
conflictFiles: conflicts.length > 0 ? conflicts : [],
|
conflictFiles: conflicts.length > 0 ? conflicts : [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -352,9 +368,9 @@ export async function mergeToBase(cwd: string, options: MergeToBaseOptions = {})
|
|||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
if (originalBranch !== baseRef) {
|
if (isSameCheckout && originalBranch && originalBranch !== baseRef) {
|
||||||
try {
|
try {
|
||||||
await execAsync(`git checkout ${originalBranch}`, { cwd });
|
await execAsync(`git checkout ${originalBranch}`, { cwd: operationCwd });
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// 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 requireRepoInfo(cwd);
|
||||||
await ensureGhAvailable(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) {
|
if (options.body) {
|
||||||
args.push("--body", options.body);
|
args.push("-f", `body=${options.body}`);
|
||||||
}
|
}
|
||||||
if (options.base) {
|
const { stdout } = await execFileAsync("gh", args, { cwd });
|
||||||
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 parsed = JSON.parse(stdout.trim());
|
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 };
|
return { url: parsed.url, number: parsed.number };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPullRequestStatus(cwd: string): Promise<PullRequestStatus | null> {
|
export async function getPullRequestStatus(cwd: string): Promise<PullRequestStatus | null> {
|
||||||
await requireRepoInfo(cwd);
|
await requireRepoInfo(cwd);
|
||||||
await ensureGhAvailable(cwd);
|
await ensureGhAvailable(cwd);
|
||||||
const { stdout } = await execAsync(
|
const repo = await resolveGitHubRepo(cwd);
|
||||||
"gh pr status --json url,title,state,baseRefName,headRefName",
|
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 }
|
{ cwd }
|
||||||
);
|
);
|
||||||
const parsed = JSON.parse(stdout.trim());
|
const parsed = JSON.parse(stdout.trim());
|
||||||
const current = parsed.currentBranch;
|
const current = Array.isArray(parsed) && parsed.length > 0 ? parsed[0] : null;
|
||||||
if (!current) {
|
if (!current) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
url: current.url,
|
url: current.html_url ?? current.url,
|
||||||
title: current.title,
|
title: current.title,
|
||||||
state: current.state,
|
state: current.state,
|
||||||
baseRefName: current.baseRefName,
|
baseRefName: current.base?.ref ?? "",
|
||||||
headRefName: current.headRefName,
|
headRefName: current.head?.ref ?? head,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user