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