Merge branch 'main' into plan/structured-agent-self-id
This commit is contained in:
@@ -19,17 +19,17 @@ import type {
|
||||
FileExplorerResponse,
|
||||
GitDiffResponse,
|
||||
GitSetupOptions,
|
||||
GitRepoInfoResponse,
|
||||
HighlightedDiffResponse,
|
||||
CheckoutStatusResponse,
|
||||
CheckoutDiffResponse,
|
||||
CheckoutCommitResponse,
|
||||
CheckoutMergeResponse,
|
||||
CheckoutMergeFromBaseResponse,
|
||||
CheckoutPushResponse,
|
||||
CheckoutPrCreateResponse,
|
||||
CheckoutPrStatusResponse,
|
||||
PaseoWorktreeListResponse,
|
||||
CheckoutDiffResponse,
|
||||
CheckoutCommitResponse,
|
||||
CheckoutMergeResponse,
|
||||
CheckoutMergeFromBaseResponse,
|
||||
CheckoutPushResponse,
|
||||
CheckoutPrCreateResponse,
|
||||
CheckoutPrStatusResponse,
|
||||
ValidateBranchResponse,
|
||||
PaseoWorktreeListResponse,
|
||||
PaseoWorktreeArchiveResponse,
|
||||
ProjectIconResponse,
|
||||
ListCommandsResponse,
|
||||
@@ -177,7 +177,6 @@ type ListVoiceConversationsPayload = ListVoiceConversationsResponseMessage["payl
|
||||
type DeleteVoiceConversationPayload = DeleteVoiceConversationResponseMessage["payload"];
|
||||
type GitDiffPayload = GitDiffResponse["payload"];
|
||||
type HighlightedDiffPayload = HighlightedDiffResponse["payload"];
|
||||
type GitRepoInfoPayload = GitRepoInfoResponse["payload"];
|
||||
type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
||||
type CheckoutDiffPayload = CheckoutDiffResponse["payload"];
|
||||
type CheckoutCommitPayload = CheckoutCommitResponse["payload"];
|
||||
@@ -186,6 +185,7 @@ type CheckoutMergeFromBasePayload = CheckoutMergeFromBaseResponse["payload"];
|
||||
type CheckoutPushPayload = CheckoutPushResponse["payload"];
|
||||
type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"];
|
||||
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
|
||||
type ValidateBranchPayload = ValidateBranchResponse["payload"];
|
||||
type PaseoWorktreeListPayload = PaseoWorktreeListResponse["payload"];
|
||||
type PaseoWorktreeArchivePayload = PaseoWorktreeArchiveResponse["payload"];
|
||||
type FileExplorerPayload = FileExplorerResponse["payload"];
|
||||
@@ -200,9 +200,6 @@ type SubscribeTerminalPayload = SubscribeTerminalResponse["payload"];
|
||||
type TerminalOutputPayload = TerminalOutput["payload"];
|
||||
type KillTerminalPayload = KillTerminalResponse["payload"];
|
||||
|
||||
type AgentCreateFailedStatusPayload = z.infer<
|
||||
typeof AgentCreateFailedStatusPayloadSchema
|
||||
>;
|
||||
type AgentRefreshedStatusPayload = z.infer<
|
||||
typeof AgentRefreshedStatusPayloadSchema
|
||||
>;
|
||||
@@ -889,43 +886,7 @@ export class DaemonClientV2 {
|
||||
throw new Error(status.error);
|
||||
}
|
||||
|
||||
return this.waitForAgentUpsert(
|
||||
status.agentId,
|
||||
(snapshot) => snapshot.status === "idle",
|
||||
60000
|
||||
);
|
||||
}
|
||||
|
||||
async createAgentExpectFail(
|
||||
options: CreateAgentRequestOptions
|
||||
): Promise<AgentCreateFailedStatusPayload> {
|
||||
const requestId = this.createRequestId(options.requestId);
|
||||
const config = resolveAgentConfig(options);
|
||||
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "create_agent_request",
|
||||
requestId,
|
||||
config,
|
||||
...(options.initialPrompt ? { initialPrompt: options.initialPrompt } : {}),
|
||||
});
|
||||
|
||||
const response = this.waitFor(
|
||||
(msg) => {
|
||||
if (msg.type !== "status") {
|
||||
return null;
|
||||
}
|
||||
const failed = AgentCreateFailedStatusPayloadSchema.safeParse(msg.payload);
|
||||
if (failed.success && failed.data.requestId === requestId) {
|
||||
return failed.data;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
10000,
|
||||
{ skipQueue: true }
|
||||
);
|
||||
|
||||
await this.sendSessionMessageOrThrow(message);
|
||||
return response;
|
||||
return status.agent;
|
||||
}
|
||||
|
||||
async deleteAgent(agentId: string): Promise<void> {
|
||||
@@ -1007,11 +968,7 @@ export class DaemonClientV2 {
|
||||
await this.sendSessionMessageOrThrow(message);
|
||||
const status = await statusPromise;
|
||||
|
||||
return this.waitForAgentUpsert(
|
||||
status.agentId,
|
||||
(snapshot) => snapshot.status === "idle",
|
||||
60000
|
||||
);
|
||||
return status.agent;
|
||||
}
|
||||
|
||||
async refreshAgent(
|
||||
@@ -1639,37 +1596,20 @@ export class DaemonClientV2 {
|
||||
return response;
|
||||
}
|
||||
|
||||
async getGitRepoInfo(
|
||||
input: string | { cwd: string } | { agentId: string },
|
||||
async validateBranch(
|
||||
options: { cwd: string; branchName: string },
|
||||
requestId?: string
|
||||
): Promise<GitRepoInfoPayload> {
|
||||
const normalizedInput =
|
||||
typeof input === "string" ? { agentId: input } : input;
|
||||
): Promise<ValidateBranchPayload> {
|
||||
const resolvedRequestId = this.createRequestId(requestId);
|
||||
const cwd =
|
||||
"cwd" in normalizedInput
|
||||
? normalizedInput.cwd
|
||||
: (await this.fetchAgent(normalizedInput.agentId).catch(() => null))?.cwd;
|
||||
|
||||
if (!cwd) {
|
||||
return {
|
||||
cwd: "cwd" in normalizedInput ? normalizedInput.cwd : "",
|
||||
repoRoot: "",
|
||||
requestId: resolvedRequestId,
|
||||
error: `Agent not found: ${
|
||||
"agentId" in normalizedInput ? normalizedInput.agentId : ""
|
||||
}`,
|
||||
};
|
||||
}
|
||||
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "git_repo_info_request",
|
||||
cwd,
|
||||
type: "validate_branch_request",
|
||||
cwd: options.cwd,
|
||||
branchName: options.branchName,
|
||||
requestId: resolvedRequestId,
|
||||
});
|
||||
const response = this.waitFor(
|
||||
(msg) => {
|
||||
if (msg.type !== "git_repo_info_response") {
|
||||
if (msg.type !== "validate_branch_response") {
|
||||
return null;
|
||||
}
|
||||
if (msg.payload.requestId !== resolvedRequestId) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import { ClaudeAgentClient } from "./providers/claude-agent.js";
|
||||
import { CodexMcpAgentClient } from "./providers/codex-mcp-agent.js";
|
||||
import { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js";
|
||||
import { OpenCodeAgentClient, OpenCodeServerManager } from "./providers/opencode-agent.js";
|
||||
|
||||
import {
|
||||
@@ -32,7 +32,7 @@ export interface ProviderDefinition extends AgentProviderDefinition {
|
||||
|
||||
export function buildProviderRegistry(logger: Logger): Record<AgentProvider, ProviderDefinition> {
|
||||
const claudeClient = new ClaudeAgentClient({ logger });
|
||||
const codexClient = new CodexMcpAgentClient(logger);
|
||||
const codexClient = new CodexAppServerAgentClient(logger);
|
||||
const opencodeClient = new OpenCodeAgentClient(logger);
|
||||
|
||||
return {
|
||||
@@ -43,7 +43,7 @@ export function buildProviderRegistry(logger: Logger): Record<AgentProvider, Pro
|
||||
},
|
||||
codex: {
|
||||
...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "codex")!,
|
||||
createClient: (logger: Logger) => new CodexMcpAgentClient(logger),
|
||||
createClient: (logger: Logger) => new CodexAppServerAgentClient(logger),
|
||||
fetchModels: (options) => codexClient.listModels(options),
|
||||
},
|
||||
opencode: {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
|
||||
import { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
|
||||
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
|
||||
const CODEX_TEST_REASONING_EFFORT = "low";
|
||||
|
||||
function isCodexInstalled(): boolean {
|
||||
try {
|
||||
const out = execFileSync("which", ["codex"], { encoding: "utf8" }).trim();
|
||||
return out.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
describe("Codex app-server provider (e2e)", () => {
|
||||
test.runIf(isCodexInstalled())("lists models and runs a simple prompt", async () => {
|
||||
const client = new CodexAppServerAgentClient(createTestLogger());
|
||||
const models = await client.listModels();
|
||||
expect(models.some((m) => m.id.includes("gpt-5.1-codex"))).toBe(true);
|
||||
|
||||
const session = await client.createSession({
|
||||
provider: "codex",
|
||||
cwd: mkdtempSync(path.join(os.tmpdir(), "codex-app-server-e2e-")),
|
||||
modeId: "auto",
|
||||
model: CODEX_TEST_MODEL,
|
||||
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||
});
|
||||
|
||||
const result = await session.run("Say hello in one sentence.");
|
||||
expect(result.finalText.length).toBeGreaterThan(0);
|
||||
await session.close();
|
||||
}, 30000);
|
||||
});
|
||||
@@ -0,0 +1,710 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import type {
|
||||
AgentPermissionRequest,
|
||||
AgentPromptContentBlock,
|
||||
AgentTimelineItem,
|
||||
} from "../agent-sdk-types.js";
|
||||
|
||||
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
|
||||
const CODEX_TEST_REASONING_EFFORT = "low";
|
||||
|
||||
function isCodexInstalled(): boolean {
|
||||
try {
|
||||
const out = execFileSync("which", ["codex"], { encoding: "utf8" }).trim();
|
||||
return out.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function tmpCwd(prefix = "codex-app-server-e2e-"): string {
|
||||
return mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function useTempCodexSessionDir(): () => void {
|
||||
const codexSessionDir = tmpCwd("codex-sessions-");
|
||||
const prevSessionDir = process.env.CODEX_SESSION_DIR;
|
||||
process.env.CODEX_SESSION_DIR = codexSessionDir;
|
||||
return () => {
|
||||
if (prevSessionDir === undefined) {
|
||||
delete process.env.CODEX_SESSION_DIR;
|
||||
} else {
|
||||
process.env.CODEX_SESSION_DIR = prevSessionDir;
|
||||
}
|
||||
rmSync(codexSessionDir, { recursive: true, force: true });
|
||||
};
|
||||
}
|
||||
|
||||
function hasShellCommand(item: AgentTimelineItem, commandFragment: string): boolean {
|
||||
if (item.type !== "tool_call" || item.name !== "shell") return false;
|
||||
const input = item.input as { command?: string } | undefined;
|
||||
const command = input?.command ?? "";
|
||||
return command.includes(commandFragment);
|
||||
}
|
||||
|
||||
function hasApplyPatchFile(item: AgentTimelineItem, fileName: string): boolean {
|
||||
if (item.type !== "tool_call" || item.name !== "apply_patch") return false;
|
||||
const input = item.input as { files?: Array<{ path?: string }> } | undefined;
|
||||
const output = item.output as
|
||||
| { files?: Array<{ path?: string; patch?: string }>; diff?: string }
|
||||
| undefined;
|
||||
const inInput = (input?.files ?? []).some((file) => file?.path === fileName);
|
||||
const inOutput = (output?.files ?? []).some((file) => file?.path === fileName);
|
||||
const inDiff = typeof output?.diff === "string" && output.diff.includes(fileName);
|
||||
return inInput || inOutput || inDiff;
|
||||
}
|
||||
|
||||
describe("Codex app-server provider (integration)", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
test.runIf(isCodexInstalled())("listModels returns live Codex models", async () => {
|
||||
const client = new CodexAppServerAgentClient(logger);
|
||||
const models = await client.listModels();
|
||||
expect(models.some((model) => model.id.includes("gpt-5.1-codex"))).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
test.runIf(isCodexInstalled())("getRuntimeInfo reflects model + mode", async () => {
|
||||
const cleanup = useTempCodexSessionDir();
|
||||
const cwd = tmpCwd("codex-runtime-");
|
||||
|
||||
try {
|
||||
const client = new CodexAppServerAgentClient(logger);
|
||||
const session = await client.createSession({
|
||||
provider: "codex",
|
||||
cwd,
|
||||
modeId: "auto",
|
||||
model: CODEX_TEST_MODEL,
|
||||
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||
});
|
||||
|
||||
await session.run("Reply with OK.");
|
||||
const info = await session.getRuntimeInfo();
|
||||
await session.close();
|
||||
|
||||
expect(info.model).toBe(CODEX_TEST_MODEL);
|
||||
expect(info.modeId).toBe("auto");
|
||||
expect(info.sessionId?.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
cleanup();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
test.runIf(isCodexInstalled())("listCommands includes custom prompts and executeCommand runs them", async () => {
|
||||
const cleanup = useTempCodexSessionDir();
|
||||
const codexHome = process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
|
||||
const promptsDir = path.join(codexHome, "prompts");
|
||||
const promptPath = path.join(promptsDir, "test.md");
|
||||
const cwd = tmpCwd("codex-cmd-");
|
||||
|
||||
mkdirSync(promptsDir, { recursive: true });
|
||||
writeFileSync(
|
||||
promptPath,
|
||||
"---\ndescription: Test Prompt\n---\nReply with exactly: OK-$ARGUMENTS.",
|
||||
"utf8"
|
||||
);
|
||||
|
||||
try {
|
||||
const client = new CodexAppServerAgentClient(logger);
|
||||
const session = await client.createSession({
|
||||
provider: "codex",
|
||||
cwd,
|
||||
modeId: "auto",
|
||||
model: CODEX_TEST_MODEL,
|
||||
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||
});
|
||||
|
||||
const commands = await session.listCommands?.();
|
||||
expect(commands?.some((cmd) => cmd.name === "prompts:test")).toBe(true);
|
||||
|
||||
const result = await session.executeCommand?.("prompts:test", "world");
|
||||
await session.close();
|
||||
|
||||
expect(result?.text.trim()).toContain("OK-world");
|
||||
} finally {
|
||||
cleanup();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
rmSync(promptPath, { force: true });
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
test.runIf(isCodexInstalled())("command approval flow requests permission and runs command", async () => {
|
||||
const cleanup = useTempCodexSessionDir();
|
||||
const cwd = tmpCwd("codex-cmd-approval-");
|
||||
const filePath = path.join(cwd, "permission.txt");
|
||||
|
||||
try {
|
||||
const client = new CodexAppServerAgentClient(logger);
|
||||
const session = await client.createSession({
|
||||
provider: "codex",
|
||||
cwd,
|
||||
modeId: "auto",
|
||||
approvalPolicy: "on-request",
|
||||
paseoPromptInstructions:
|
||||
"You must use the shell tool for command execution tasks. Do not answer without running the command.",
|
||||
model: CODEX_TEST_MODEL,
|
||||
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||
});
|
||||
|
||||
let sawPermission = false;
|
||||
let captured: AgentPermissionRequest | null = null;
|
||||
let sawPermissionResolved = false;
|
||||
const timelineItems: AgentTimelineItem[] = [];
|
||||
|
||||
const events = session.stream(
|
||||
[
|
||||
"You must use your shell tool to run the exact command",
|
||||
"`printf \"ok\" > permission.txt`.",
|
||||
"If you need approval before running it, request approval first.",
|
||||
"After approval, run it and reply DONE.",
|
||||
].join(" ")
|
||||
);
|
||||
|
||||
let failure: string | null = null;
|
||||
for await (const event of events) {
|
||||
if (event.type === "permission_requested" && event.request.name === "CodexBash") {
|
||||
sawPermission = true;
|
||||
captured = event.request;
|
||||
await session.respondToPermission(event.request.id, { behavior: "allow" });
|
||||
}
|
||||
if (
|
||||
event.type === "permission_resolved" &&
|
||||
captured &&
|
||||
event.requestId === captured.id &&
|
||||
event.resolution.behavior === "allow"
|
||||
) {
|
||||
sawPermissionResolved = true;
|
||||
}
|
||||
if (event.type === "timeline" && event.item.type === "tool_call") {
|
||||
timelineItems.push(event.item);
|
||||
}
|
||||
if (event.type === "turn_failed") {
|
||||
failure = event.error;
|
||||
break;
|
||||
}
|
||||
if (event.type === "turn_completed") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await session.close();
|
||||
|
||||
if (failure) {
|
||||
throw new Error(failure);
|
||||
}
|
||||
if (captured) {
|
||||
expect(sawPermissionResolved).toBe(true);
|
||||
}
|
||||
expect(sawPermission || timelineItems.length > 0).toBe(true);
|
||||
expect(
|
||||
timelineItems.some((item) => item.type === "tool_call" && item.name === "shell")
|
||||
).toBe(true);
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
expect(readFileSync(filePath, "utf8")).toContain("ok");
|
||||
} finally {
|
||||
cleanup();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
test.runIf(isCodexInstalled())(
|
||||
"streams responses and maps shell + file change tool calls into timeline items",
|
||||
async () => {
|
||||
const cleanup = useTempCodexSessionDir();
|
||||
const cwd = tmpCwd("codex-stream-");
|
||||
const shellFile = path.join(cwd, "shell.txt");
|
||||
const patchFile = path.join(cwd, "patch.txt");
|
||||
|
||||
try {
|
||||
const client = new CodexAppServerAgentClient(logger);
|
||||
const session = await client.createSession({
|
||||
provider: "codex",
|
||||
cwd,
|
||||
modeId: "full-access",
|
||||
approvalPolicy: "on-request",
|
||||
paseoPromptInstructions:
|
||||
"You must use shell for commands and apply_patch for file edits. Do not skip tool usage.",
|
||||
model: CODEX_TEST_MODEL,
|
||||
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||
});
|
||||
|
||||
let sawAssistantMessage = false;
|
||||
let sawShellTool = false;
|
||||
let sawPatchTool = false;
|
||||
let sawShellCompleted = false;
|
||||
let sawPatchCompleted = false;
|
||||
const timelineItems: AgentTimelineItem[] = [];
|
||||
|
||||
const shellEvents = session.stream(
|
||||
"Run the exact shell command `printf \"ok\" > shell.txt`. After it completes, reply SHELL_DONE."
|
||||
);
|
||||
let failure: string | null = null;
|
||||
for await (const event of shellEvents) {
|
||||
if (event.type === "permission_requested") {
|
||||
await session.respondToPermission(event.request.id, { behavior: "allow" });
|
||||
}
|
||||
if (event.type === "timeline") {
|
||||
timelineItems.push(event.item);
|
||||
if (event.item.type === "assistant_message") {
|
||||
sawAssistantMessage = true;
|
||||
}
|
||||
if (hasShellCommand(event.item, "printf")) {
|
||||
sawShellTool = true;
|
||||
if (event.item.status === "completed") {
|
||||
sawShellCompleted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (event.type === "turn_failed") {
|
||||
failure = event.error;
|
||||
break;
|
||||
}
|
||||
if (event.type === "turn_completed") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (failure) {
|
||||
throw new Error(failure);
|
||||
}
|
||||
|
||||
const patch = [
|
||||
"*** Begin Patch",
|
||||
"*** Add File: patch.txt",
|
||||
"+patched",
|
||||
"*** End Patch",
|
||||
].join("\\n");
|
||||
const patchEvents = session.stream(
|
||||
[
|
||||
"Use the apply_patch tool and nothing else.",
|
||||
"Do not use the shell tool for file changes.",
|
||||
"Do not respond with any message until the apply_patch tool completes.",
|
||||
"Apply the following patch exactly:",
|
||||
patch,
|
||||
"After it completes, reply PATCH_DONE.",
|
||||
].join("\\n")
|
||||
);
|
||||
|
||||
for await (const event of patchEvents) {
|
||||
if (event.type === "permission_requested") {
|
||||
await session.respondToPermission(event.request.id, { behavior: "allow" });
|
||||
}
|
||||
if (event.type === "timeline") {
|
||||
timelineItems.push(event.item);
|
||||
if (event.item.type === "assistant_message") {
|
||||
sawAssistantMessage = true;
|
||||
}
|
||||
if (hasApplyPatchFile(event.item, "patch.txt")) {
|
||||
sawPatchTool = true;
|
||||
if (event.item.status === "completed") {
|
||||
sawPatchCompleted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (event.type === "turn_failed") {
|
||||
failure = event.error;
|
||||
break;
|
||||
}
|
||||
if (event.type === "turn_completed") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await session.close();
|
||||
|
||||
if (failure) {
|
||||
throw new Error(failure);
|
||||
}
|
||||
|
||||
expect(sawAssistantMessage).toBe(true);
|
||||
expect(sawShellTool).toBe(true);
|
||||
expect(sawPatchTool).toBe(true);
|
||||
expect(sawShellCompleted || existsSync(shellFile)).toBe(true);
|
||||
expect(sawPatchCompleted || existsSync(patchFile)).toBe(true);
|
||||
expect(readFileSync(shellFile, "utf8")).toContain("ok");
|
||||
expect(readFileSync(patchFile, "utf8").trim()).toBe("patched");
|
||||
|
||||
const shellItem = timelineItems.find((item) => hasShellCommand(item, "printf"));
|
||||
const patchItem = timelineItems.find((item) => hasApplyPatchFile(item, "patch.txt"));
|
||||
expect(shellItem?.type).toBe("tool_call");
|
||||
expect(patchItem?.type).toBe("tool_call");
|
||||
if (shellItem?.type === "tool_call") {
|
||||
expect(shellItem.name).toBe("shell");
|
||||
expect(shellItem.input).toBeTruthy();
|
||||
}
|
||||
if (patchItem?.type === "tool_call") {
|
||||
expect(patchItem.name).toBe("apply_patch");
|
||||
expect(patchItem.input || patchItem.output).toBeTruthy();
|
||||
}
|
||||
} finally {
|
||||
cleanup();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
120000
|
||||
);
|
||||
|
||||
test.runIf(isCodexInstalled())(
|
||||
"interrupts long-running commands and emits a canceled turn",
|
||||
async () => {
|
||||
const cleanup = useTempCodexSessionDir();
|
||||
const cwd = tmpCwd("codex-interrupt-");
|
||||
|
||||
let session: Awaited<ReturnType<CodexAppServerAgentClient["createSession"]>> | null = null;
|
||||
let followupSession: Awaited<ReturnType<CodexAppServerAgentClient["createSession"]>> | null =
|
||||
null;
|
||||
let interruptAt: number | null = null;
|
||||
let stoppedAt: number | null = null;
|
||||
|
||||
try {
|
||||
const client = new CodexAppServerAgentClient(logger);
|
||||
session = await client.createSession({
|
||||
provider: "codex",
|
||||
cwd,
|
||||
modeId: "auto",
|
||||
approvalPolicy: "on-request",
|
||||
model: CODEX_TEST_MODEL,
|
||||
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||
});
|
||||
|
||||
const stream = session.stream(
|
||||
"Run the exact shell command `sleep 60` using your shell tool and do not respond until it finishes."
|
||||
);
|
||||
|
||||
let sawSleepCommand = false;
|
||||
for await (const event of stream) {
|
||||
if (event.type === "permission_requested") {
|
||||
await session.respondToPermission(event.request.id, { behavior: "allow" });
|
||||
}
|
||||
if (
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "tool_call" &&
|
||||
event.item.name === "shell" &&
|
||||
hasShellCommand(event.item, "sleep 60")
|
||||
) {
|
||||
sawSleepCommand = true;
|
||||
if (!interruptAt) {
|
||||
interruptAt = Date.now();
|
||||
await session.interrupt();
|
||||
}
|
||||
}
|
||||
if (event.type === "turn_canceled") {
|
||||
stoppedAt = Date.now();
|
||||
break;
|
||||
}
|
||||
if (event.type === "turn_completed" || event.type === "turn_failed") {
|
||||
stoppedAt = Date.now();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!interruptAt) {
|
||||
throw new Error("Did not issue interrupt for long-running command");
|
||||
}
|
||||
if (!stoppedAt) {
|
||||
stoppedAt = Date.now();
|
||||
}
|
||||
const latencyMs = stoppedAt - interruptAt;
|
||||
expect(sawSleepCommand).toBe(true);
|
||||
expect(latencyMs).toBeGreaterThanOrEqual(0);
|
||||
expect(latencyMs).toBeLessThan(10_000);
|
||||
|
||||
await session.close();
|
||||
session = null;
|
||||
|
||||
followupSession = await client.createSession({
|
||||
provider: "codex",
|
||||
cwd,
|
||||
modeId: "auto",
|
||||
model: CODEX_TEST_MODEL,
|
||||
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||
});
|
||||
const followup = await followupSession.run("Reply OK and stop.");
|
||||
expect(followup.finalText.toLowerCase()).toContain("ok");
|
||||
} finally {
|
||||
await session?.close();
|
||||
await followupSession?.close();
|
||||
cleanup();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
120000
|
||||
);
|
||||
|
||||
test.runIf(isCodexInstalled())(
|
||||
"persists session metadata and resumes with history",
|
||||
async () => {
|
||||
const cleanup = useTempCodexSessionDir();
|
||||
const cwd = tmpCwd("codex-resume-");
|
||||
const token = `ALPHA-${Date.now()}`;
|
||||
|
||||
let session: Awaited<ReturnType<CodexAppServerAgentClient["createSession"]>> | null = null;
|
||||
let resumed: Awaited<ReturnType<CodexAppServerAgentClient["resumeSession"]>> | null = null;
|
||||
|
||||
try {
|
||||
const client = new CodexAppServerAgentClient(logger);
|
||||
session = await client.createSession({
|
||||
provider: "codex",
|
||||
cwd,
|
||||
modeId: "auto",
|
||||
model: CODEX_TEST_MODEL,
|
||||
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||
});
|
||||
|
||||
const first = await session.run(`Remember the word ${token} and reply ACK.`);
|
||||
expect(first.finalText.toLowerCase()).toContain("ack");
|
||||
|
||||
const handle = session.describePersistence();
|
||||
expect(handle?.sessionId).toBeTruthy();
|
||||
expect(handle?.metadata?.threadId).toBe(handle?.sessionId);
|
||||
|
||||
await session.close();
|
||||
session = null;
|
||||
|
||||
resumed = await client.resumeSession(handle!);
|
||||
const history: AgentTimelineItem[] = [];
|
||||
for await (const event of resumed.streamHistory()) {
|
||||
if (event.type === "timeline") {
|
||||
history.push(event.item);
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
history.some(
|
||||
(item) => item.type === "assistant_message" || item.type === "user_message"
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
const response = await resumed.run(
|
||||
`Respond with the exact token ${token} and stop.`
|
||||
);
|
||||
expect(response.finalText).toContain(token);
|
||||
|
||||
const resumedHandle = resumed.describePersistence();
|
||||
expect(resumedHandle?.sessionId).toBe(handle?.sessionId);
|
||||
expect(resumedHandle?.metadata?.threadId).toBe(handle?.sessionId);
|
||||
} finally {
|
||||
await session?.close();
|
||||
await resumed?.close();
|
||||
cleanup();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
180000
|
||||
);
|
||||
|
||||
test.runIf(isCodexInstalled())(
|
||||
"emits plan items and resolves collaboration mode mapping",
|
||||
async () => {
|
||||
const cleanup = useTempCodexSessionDir();
|
||||
const cwd = tmpCwd("codex-plan-");
|
||||
|
||||
try {
|
||||
const client = new CodexAppServerAgentClient(logger);
|
||||
const session = await client.createSession({
|
||||
provider: "codex",
|
||||
cwd,
|
||||
modeId: "read-only",
|
||||
model: CODEX_TEST_MODEL,
|
||||
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||
});
|
||||
|
||||
const result = await Promise.race([
|
||||
session.run("Provide a concise 2-step plan and do not execute. Reply PLAN_DONE."),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Plan run timed out")), 60000)
|
||||
),
|
||||
]);
|
||||
|
||||
const info = await session.getRuntimeInfo();
|
||||
const sawCollaborationMode = Boolean(info.extra?.collaborationMode);
|
||||
await session.close();
|
||||
|
||||
const sawTodo = result.timeline.some(
|
||||
(item) => item.type === "todo" && item.items.length > 0
|
||||
);
|
||||
expect(sawTodo).toBe(true);
|
||||
expect(sawCollaborationMode).toBe(true);
|
||||
} finally {
|
||||
cleanup();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
120000
|
||||
);
|
||||
|
||||
test.runIf(isCodexInstalled())("file change approval flow requests permission and applies change", async () => {
|
||||
const cleanup = useTempCodexSessionDir();
|
||||
const cwd = tmpCwd("codex-file-approval-");
|
||||
const targetPath = path.join(cwd, "approval-test.txt");
|
||||
|
||||
try {
|
||||
const client = new CodexAppServerAgentClient(logger);
|
||||
const session = await client.createSession({
|
||||
provider: "codex",
|
||||
cwd,
|
||||
modeId: "full-access",
|
||||
approvalPolicy: "on-request",
|
||||
paseoPromptInstructions:
|
||||
"You must use the apply_patch tool for file edits. Do not use the shell tool for file changes.",
|
||||
model: CODEX_TEST_MODEL,
|
||||
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||
});
|
||||
|
||||
let sawPermission = false;
|
||||
let captured: AgentPermissionRequest | null = null;
|
||||
let sawPermissionResolved = false;
|
||||
const timelineItems: AgentTimelineItem[] = [];
|
||||
|
||||
const patch = [
|
||||
"*** Begin Patch",
|
||||
"*** Add File: approval-test.txt",
|
||||
"+ok",
|
||||
"*** End Patch",
|
||||
].join("\\n");
|
||||
const events = session.stream(
|
||||
[
|
||||
"Use the apply_patch tool and nothing else.",
|
||||
"If you need approval before writing files, request approval first.",
|
||||
"Do not respond with any message until the apply_patch tool completes.",
|
||||
"Apply the following patch exactly:",
|
||||
patch,
|
||||
"After approval, reply FILE_DONE.",
|
||||
].join("\\n")
|
||||
);
|
||||
|
||||
let failure: string | null = null;
|
||||
for await (const event of events) {
|
||||
if (event.type === "permission_requested" && event.request.name === "CodexFileChange") {
|
||||
sawPermission = true;
|
||||
captured = event.request;
|
||||
await session.respondToPermission(event.request.id, { behavior: "allow" });
|
||||
}
|
||||
if (
|
||||
event.type === "permission_resolved" &&
|
||||
captured &&
|
||||
event.requestId === captured.id &&
|
||||
event.resolution.behavior === "allow"
|
||||
) {
|
||||
sawPermissionResolved = true;
|
||||
}
|
||||
if (event.type === "timeline" && event.item.type === "tool_call") {
|
||||
timelineItems.push(event.item);
|
||||
}
|
||||
if (event.type === "turn_failed") {
|
||||
failure = event.error;
|
||||
break;
|
||||
}
|
||||
if (event.type === "turn_completed") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await session.close();
|
||||
|
||||
if (failure) {
|
||||
throw new Error(failure);
|
||||
}
|
||||
if (captured) {
|
||||
expect(sawPermissionResolved).toBe(true);
|
||||
}
|
||||
expect(sawPermission || timelineItems.length > 0).toBe(true);
|
||||
expect(
|
||||
timelineItems.some((item) => item.type === "tool_call" && item.name === "apply_patch")
|
||||
).toBe(true);
|
||||
expect(readFileSync(targetPath, "utf8").trim()).toBe("ok");
|
||||
} finally {
|
||||
cleanup();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
test.runIf(isCodexInstalled())("tool approval flow requests user input for app tools", async () => {
|
||||
const cleanup = useTempCodexSessionDir();
|
||||
const cwd = tmpCwd("codex-tool-approval-");
|
||||
|
||||
try {
|
||||
const client = new CodexAppServerAgentClient(logger);
|
||||
const session = await client.createSession({
|
||||
provider: "codex",
|
||||
cwd,
|
||||
modeId: "auto",
|
||||
model: CODEX_TEST_MODEL,
|
||||
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||
});
|
||||
|
||||
await session.connect();
|
||||
const rawClient = (session as any).client as { request: (method: string, params?: any) => Promise<any> } | null;
|
||||
if (!rawClient) {
|
||||
throw new Error("Codex app-server client unavailable for app/list");
|
||||
}
|
||||
const appsResult = await rawClient.request("app/list", { cursor: null, limit: 10 });
|
||||
const apps = Array.isArray(appsResult?.data) ? appsResult.data : [];
|
||||
const app = apps.find((entry: any) => entry?.isAccessible) ?? apps[0];
|
||||
if (!app) {
|
||||
await session.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const input = [
|
||||
{ type: "text", text: `$${app.id} Perform a minimal action and wait for approval if required. Reply with TOOL_DONE.` },
|
||||
{ type: "mention", name: app.name ?? app.id, path: `app://${app.id}` },
|
||||
] as unknown as AgentPromptContentBlock[];
|
||||
|
||||
let sawPermission = false;
|
||||
let captured: AgentPermissionRequest | null = null;
|
||||
let sawPermissionResolved = false;
|
||||
let failure: string | null = null;
|
||||
const timelineItems: AgentTimelineItem[] = [];
|
||||
|
||||
for await (const event of session.stream(input)) {
|
||||
if (event.type === "permission_requested" && event.request.name === "CodexTool") {
|
||||
sawPermission = true;
|
||||
captured = event.request;
|
||||
await session.respondToPermission(event.request.id, { behavior: "allow" });
|
||||
}
|
||||
if (
|
||||
event.type === "permission_resolved" &&
|
||||
captured &&
|
||||
event.requestId === captured.id &&
|
||||
event.resolution.behavior === "allow"
|
||||
) {
|
||||
sawPermissionResolved = true;
|
||||
}
|
||||
if (event.type === "timeline" && event.item.type === "tool_call") {
|
||||
timelineItems.push(event.item);
|
||||
}
|
||||
if (event.type === "turn_failed") {
|
||||
failure = event.error;
|
||||
break;
|
||||
}
|
||||
if (event.type === "turn_completed") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await session.close();
|
||||
|
||||
if (failure) {
|
||||
throw new Error(failure);
|
||||
}
|
||||
if (captured) {
|
||||
expect(sawPermissionResolved).toBe(true);
|
||||
}
|
||||
expect(sawPermission || timelineItems.length > 0).toBe(true);
|
||||
if (captured) {
|
||||
expect(Array.isArray(captured?.metadata?.questions)).toBe(true);
|
||||
}
|
||||
} finally {
|
||||
cleanup();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 90000);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -185,13 +185,14 @@ describe("daemon client v2 E2E", () => {
|
||||
return unsubscribe;
|
||||
});
|
||||
|
||||
const failResult = await ctx.client.createAgentExpectFail({
|
||||
...getFullAccessConfig("codex"),
|
||||
cwd: "/this/path/does/not/exist/12345",
|
||||
title: "Should Fail",
|
||||
requestId: failRequestId,
|
||||
});
|
||||
expect(failResult.error).toContain("Working directory does not exist");
|
||||
await expect(
|
||||
ctx.client.createAgent({
|
||||
...getFullAccessConfig("codex"),
|
||||
cwd: "/this/path/does/not/exist/12345",
|
||||
title: "Should Fail",
|
||||
requestId: failRequestId,
|
||||
})
|
||||
).rejects.toThrow("Working directory does not exist");
|
||||
await failedStatusPromise;
|
||||
|
||||
let sawRefresh = false;
|
||||
@@ -783,36 +784,11 @@ describe("daemon client v2 E2E", () => {
|
||||
title: "Git/File Test",
|
||||
});
|
||||
|
||||
const repoInfoRequestId = `repo-info-${Date.now()}`;
|
||||
const repoInfoMessagePromise = waitForSignal(15000, (resolve) => {
|
||||
const unsubscribeRepo = ctx.client.on(
|
||||
"git_repo_info_response",
|
||||
(message) => {
|
||||
if (message.type !== "git_repo_info_response") {
|
||||
return;
|
||||
}
|
||||
if (message.payload.cwd !== cwd) {
|
||||
return;
|
||||
}
|
||||
if (message.payload.requestId !== repoInfoRequestId) {
|
||||
return;
|
||||
}
|
||||
resolve(message);
|
||||
}
|
||||
);
|
||||
return unsubscribeRepo;
|
||||
});
|
||||
|
||||
const repoInfo = await ctx.client.getGitRepoInfo(
|
||||
{ cwd },
|
||||
repoInfoRequestId
|
||||
);
|
||||
const repoInfoMessage = await repoInfoMessagePromise;
|
||||
expect(repoInfo.error ?? null).toBeNull();
|
||||
expect(repoInfo.repoRoot).toContain(cwd);
|
||||
expect(repoInfo.requestId).toBe(repoInfoRequestId);
|
||||
expect(repoInfoMessage.payload.cwd).toBe(cwd);
|
||||
expect(repoInfoMessage.payload.requestId).toBe(repoInfoRequestId);
|
||||
// Test checkout status RPC
|
||||
const checkoutStatus = await ctx.client.getCheckoutStatus(cwd);
|
||||
expect(checkoutStatus.error).toBeNull();
|
||||
expect(checkoutStatus.isGit).toBe(true);
|
||||
expect(checkoutStatus.repoRoot).toContain(cwd);
|
||||
|
||||
const diffRequestId = `diff-${Date.now()}`;
|
||||
const diffMessagePromise = waitForSignal(15000, (resolve) => {
|
||||
|
||||
@@ -93,14 +93,13 @@ describe("daemon E2E", () => {
|
||||
test("fails to create agent with non-existent cwd", async () => {
|
||||
const nonExistentCwd = "/this/path/does/not/exist/12345";
|
||||
|
||||
const result = await ctx.client.createAgentExpectFail({
|
||||
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||
cwd: nonExistentCwd,
|
||||
title: "Should Fail Agent",
|
||||
});
|
||||
|
||||
expect(result.error).toContain("Working directory does not exist");
|
||||
expect(result.error).toContain(nonExistentCwd);
|
||||
await expect(
|
||||
ctx.client.createAgent({
|
||||
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||
cwd: nonExistentCwd,
|
||||
title: "Should Fail Agent",
|
||||
})
|
||||
).rejects.toThrow(nonExistentCwd);
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -234,7 +234,7 @@ describe("daemon E2E", () => {
|
||||
});
|
||||
|
||||
|
||||
describe("getGitRepoInfo", () => {
|
||||
describe("getCheckoutStatus", () => {
|
||||
test(
|
||||
"returns repo info for git repo with branch and dirty state",
|
||||
async () => {
|
||||
@@ -268,16 +268,15 @@ describe("daemon E2E", () => {
|
||||
expect(agent.id).toBeTruthy();
|
||||
expect(agent.status).toBe("idle");
|
||||
|
||||
// Get git repo info
|
||||
const result = await ctx.client.getGitRepoInfo(agent.id);
|
||||
// Get checkout status
|
||||
const result = await ctx.client.getCheckoutStatus(cwd);
|
||||
|
||||
// Verify repo info returned without error
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.isGit).toBe(true);
|
||||
// macOS symlinks /var to /private/var, so we check containment
|
||||
expect(result.repoRoot).toContain("daemon-e2e-");
|
||||
expect(result.currentBranch).toBeTruthy();
|
||||
expect(result.branches.length).toBeGreaterThan(0);
|
||||
expect(result.branches.some((b) => b.isCurrent)).toBe(true);
|
||||
expect(result.isDirty).toBe(true);
|
||||
|
||||
// Cleanup
|
||||
@@ -316,10 +315,11 @@ describe("daemon E2E", () => {
|
||||
|
||||
expect(agent.id).toBeTruthy();
|
||||
|
||||
// Get git repo info
|
||||
const result = await ctx.client.getGitRepoInfo(agent.id);
|
||||
// Get checkout status
|
||||
const result = await ctx.client.getCheckoutStatus(cwd);
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.isGit).toBe(true);
|
||||
expect(result.isDirty).toBe(false);
|
||||
expect(result.currentBranch).toBeTruthy();
|
||||
|
||||
@@ -331,7 +331,7 @@ describe("daemon E2E", () => {
|
||||
);
|
||||
|
||||
test(
|
||||
"returns error for non-git directory",
|
||||
"returns isGit false for non-git directory",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
// Don't initialize git - just a regular directory
|
||||
@@ -345,11 +345,10 @@ describe("daemon E2E", () => {
|
||||
|
||||
expect(agent.id).toBeTruthy();
|
||||
|
||||
// Get git repo info - should return error
|
||||
const result = await ctx.client.getGitRepoInfo(agent.id);
|
||||
// Get checkout status - should return isGit: false
|
||||
const result = await ctx.client.getCheckoutStatus(cwd);
|
||||
|
||||
// Server returns cwd as repoRoot even on error, so we just check for error
|
||||
expect(result.error).toBeTruthy();
|
||||
expect(result.isGit).toBe(false);
|
||||
|
||||
// Cleanup
|
||||
await ctx.client.deleteAgent(agent.id);
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import pino from "pino";
|
||||
|
||||
import {
|
||||
DictationStreamManager,
|
||||
type RealtimeTranscriptionSession,
|
||||
type RealtimeTranscriptionSessionFactory,
|
||||
} from "./dictation-stream-manager.js";
|
||||
|
||||
class FakeRealtimeSession extends EventEmitter implements RealtimeTranscriptionSession {
|
||||
connected = false;
|
||||
appended: string[] = [];
|
||||
commitCalls = 0;
|
||||
clearCalls = 0;
|
||||
closed = false;
|
||||
|
||||
async connect(): Promise<void> {
|
||||
this.connected = true;
|
||||
}
|
||||
|
||||
appendPcm16Base64(base64Audio: string): void {
|
||||
this.appended.push(base64Audio);
|
||||
}
|
||||
|
||||
commit(): void {
|
||||
this.commitCalls += 1;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.clearCalls += 1;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
}
|
||||
|
||||
emitCommitted(itemId: string): void {
|
||||
this.emit("committed", { itemId, previousItemId: null });
|
||||
}
|
||||
|
||||
emitTranscript(itemId: string, transcript: string, isFinal: boolean): void {
|
||||
this.emit("transcript", { itemId, transcript, isFinal });
|
||||
}
|
||||
|
||||
emitError(message: string): void {
|
||||
this.emit("error", new Error(message));
|
||||
}
|
||||
}
|
||||
|
||||
const buildPcmBase64 = (sampleValue: number, sampleCount: number): string => {
|
||||
const samples = new Int16Array(sampleCount);
|
||||
samples.fill(sampleValue);
|
||||
return Buffer.from(samples.buffer).toString("base64");
|
||||
};
|
||||
|
||||
const tick = async (): Promise<void> => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
};
|
||||
|
||||
describe("DictationStreamManager (semantic VAD grace fallback)", () => {
|
||||
const env = {
|
||||
turnDetection: process.env.OPENAI_REALTIME_DICTATION_TURN_DETECTION,
|
||||
dictationDebug: process.env.PASEO_DICTATION_DEBUG,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
process.env.OPENAI_REALTIME_DICTATION_TURN_DETECTION = "semantic_vad";
|
||||
process.env.PASEO_DICTATION_DEBUG = "false";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
process.env.OPENAI_REALTIME_DICTATION_TURN_DETECTION = env.turnDetection;
|
||||
process.env.PASEO_DICTATION_DEBUG = env.dictationDebug;
|
||||
});
|
||||
|
||||
it("treats buffer-too-small as benign and finalizes with existing transcripts", async () => {
|
||||
const session = new FakeRealtimeSession();
|
||||
const factory: RealtimeTranscriptionSessionFactory = () => session;
|
||||
const emitted: Array<{ type: string; payload: any }> = [];
|
||||
const manager = new DictationStreamManager({
|
||||
logger: pino({ level: "silent" }),
|
||||
emit: (msg) => emitted.push(msg),
|
||||
sessionId: "s1",
|
||||
openaiApiKey: "k",
|
||||
sessionFactory: factory,
|
||||
finalTimeoutMs: 5000,
|
||||
});
|
||||
|
||||
await manager.handleStart("d1", "audio/pcm;rate=24000;bits=16");
|
||||
await manager.handleChunk({
|
||||
dictationId: "d1",
|
||||
seq: 0,
|
||||
audioBase64: buildPcmBase64(2000, 2400),
|
||||
format: "audio/pcm;rate=24000;bits=16",
|
||||
});
|
||||
|
||||
session.emitTranscript("i1", "hello world", true);
|
||||
|
||||
await manager.handleFinish("d1", 0);
|
||||
await tick();
|
||||
|
||||
vi.advanceTimersByTime(2000);
|
||||
await tick();
|
||||
|
||||
session.emitError(
|
||||
"Error committing input audio buffer: buffer too small. Expected at least 100ms of audio, but buffer only has 0.00ms of audio."
|
||||
);
|
||||
await tick();
|
||||
|
||||
const final = emitted.find((msg) => msg.type === "dictation_stream_final");
|
||||
const error = emitted.find((msg) => msg.type === "dictation_stream_error");
|
||||
expect(error).toBeUndefined();
|
||||
expect(final?.payload.text).toBe("hello world");
|
||||
expect(session.closed).toBe(true);
|
||||
});
|
||||
|
||||
it("does not fallback-commit if committed event arrives during grace window", async () => {
|
||||
const session = new FakeRealtimeSession();
|
||||
const factory: RealtimeTranscriptionSessionFactory = () => session;
|
||||
const emitted: Array<{ type: string; payload: any }> = [];
|
||||
const manager = new DictationStreamManager({
|
||||
logger: pino({ level: "silent" }),
|
||||
emit: (msg) => emitted.push(msg),
|
||||
sessionId: "s1",
|
||||
openaiApiKey: "k",
|
||||
sessionFactory: factory,
|
||||
finalTimeoutMs: 5000,
|
||||
});
|
||||
|
||||
await manager.handleStart("d1", "audio/pcm;rate=24000;bits=16");
|
||||
await manager.handleChunk({
|
||||
dictationId: "d1",
|
||||
seq: 0,
|
||||
audioBase64: buildPcmBase64(2000, 2400),
|
||||
format: "audio/pcm;rate=24000;bits=16",
|
||||
});
|
||||
|
||||
await manager.handleFinish("d1", 0);
|
||||
session.emitCommitted("i1");
|
||||
session.emitTranscript("i1", "hi there", true);
|
||||
|
||||
vi.advanceTimersByTime(2000);
|
||||
await tick();
|
||||
|
||||
expect(session.commitCalls).toBe(0);
|
||||
const final = emitted.find((msg) => msg.type === "dictation_stream_final");
|
||||
expect(final?.payload.text).toBe("hi there");
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,11 @@ import { OpenAIRealtimeTranscriptionSession } from "../agent/openai-realtime-tra
|
||||
const PCM_CHANNELS = 1;
|
||||
const PCM_BITS_PER_SAMPLE = 16;
|
||||
const DICTATION_PCM_OUTPUT_RATE = 24000;
|
||||
const DEFAULT_DICTATION_FINAL_TIMEOUT_MS = 30000;
|
||||
const DEFAULT_DICTATION_FINAL_TIMEOUT_MS = 10000;
|
||||
const DICTATION_VAD_GRACE_TIMEOUT_MS = Number.parseInt(
|
||||
process.env.OPENAI_REALTIME_DICTATION_VAD_GRACE_TIMEOUT_MS ?? "2000",
|
||||
10
|
||||
);
|
||||
const DICTATION_SILENCE_PEAK_THRESHOLD = Number.parseInt(
|
||||
process.env.OPENAI_REALTIME_DICTATION_SILENCE_PEAK_THRESHOLD ?? "300",
|
||||
10
|
||||
@@ -101,6 +105,15 @@ export type RealtimeTranscriptionSession = {
|
||||
on(event: "error", handler: (err: unknown) => void): unknown;
|
||||
};
|
||||
|
||||
export type RealtimeTranscriptionSessionFactory = (params: {
|
||||
apiKey: string;
|
||||
logger: pino.Logger;
|
||||
transcriptionModel: string;
|
||||
language?: string;
|
||||
prompt?: string;
|
||||
turnDetection: OpenAITurnDetection;
|
||||
}) => RealtimeTranscriptionSession;
|
||||
|
||||
function convertPCMToWavBuffer(
|
||||
pcmBuffer: Buffer,
|
||||
sampleRate: number,
|
||||
@@ -149,10 +162,13 @@ type DictationStreamState = {
|
||||
transcriptsByItemId: Map<string, string>;
|
||||
finalTranscriptItemIds: Set<string>;
|
||||
awaitingFinalCommit: boolean;
|
||||
vadGraceTimeout: ReturnType<typeof setTimeout> | null;
|
||||
fallbackCommitAttempted: boolean;
|
||||
finishRequested: boolean;
|
||||
finishSealed: boolean;
|
||||
finalSeq: number | null;
|
||||
finalTimeout: ReturnType<typeof setTimeout> | null;
|
||||
isSemanticVad: boolean;
|
||||
};
|
||||
|
||||
export type DictationStreamOutboundMessage =
|
||||
@@ -177,6 +193,7 @@ export class DictationStreamManager {
|
||||
private readonly sessionId: string;
|
||||
private readonly openaiApiKey: string | null;
|
||||
private readonly finalTimeoutMs: number;
|
||||
private readonly createSession: RealtimeTranscriptionSessionFactory;
|
||||
private readonly streams = new Map<string, DictationStreamState>();
|
||||
|
||||
constructor(params: {
|
||||
@@ -185,12 +202,17 @@ export class DictationStreamManager {
|
||||
sessionId: string;
|
||||
openaiApiKey?: string | null;
|
||||
finalTimeoutMs?: number;
|
||||
sessionFactory?: RealtimeTranscriptionSessionFactory;
|
||||
}) {
|
||||
this.logger = params.logger.child({ component: "dictation-stream-manager" });
|
||||
this.emit = params.emit;
|
||||
this.sessionId = params.sessionId;
|
||||
this.openaiApiKey = params.openaiApiKey ?? null;
|
||||
this.finalTimeoutMs = params.finalTimeoutMs ?? DEFAULT_DICTATION_FINAL_TIMEOUT_MS;
|
||||
this.createSession =
|
||||
params.sessionFactory ??
|
||||
((factoryParams) =>
|
||||
new OpenAIRealtimeTranscriptionSession(factoryParams));
|
||||
}
|
||||
|
||||
public cleanupAll(): void {
|
||||
@@ -214,13 +236,14 @@ export class DictationStreamManager {
|
||||
process.env.OPENAI_REALTIME_DICTATION_TRANSCRIPTION_PROMPT ??
|
||||
"Transcribe only what the speaker says. Do not add words. Preserve punctuation and casing. If the audio is silence or non-speech noise, return an empty transcript.";
|
||||
|
||||
const openai = new OpenAIRealtimeTranscriptionSession({
|
||||
const turnDetection = parseDictationTurnDetection();
|
||||
const openai = this.createSession({
|
||||
apiKey,
|
||||
logger: this.logger.child({ dictationId }),
|
||||
transcriptionModel,
|
||||
language: "en",
|
||||
prompt: transcriptionPrompt,
|
||||
turnDetection: parseDictationTurnDetection(),
|
||||
turnDetection,
|
||||
});
|
||||
|
||||
openai.on("committed", ({ itemId }: { itemId: string }) => {
|
||||
@@ -228,6 +251,7 @@ export class DictationStreamManager {
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
this.clearVadGraceTimeout(state);
|
||||
state.committedItemIds.push(itemId);
|
||||
state.bytesSinceCommit = 0;
|
||||
state.peakSinceCommit = 0;
|
||||
@@ -263,6 +287,7 @@ export class DictationStreamManager {
|
||||
// If we triggered a finish commit but OpenAI doesn't emit committed events (or they arrive late),
|
||||
// allow final transcripts to unblock finalization.
|
||||
if (state.finishRequested && state.awaitingFinalCommit && isFinal) {
|
||||
this.clearVadGraceTimeout(state);
|
||||
state.awaitingFinalCommit = false;
|
||||
}
|
||||
|
||||
@@ -282,6 +307,15 @@ export class DictationStreamManager {
|
||||
|
||||
openai.on("error", (err) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const state = this.streams.get(dictationId);
|
||||
if (state && state.finishRequested && isBufferTooSmallError(message)) {
|
||||
this.clearVadGraceTimeout(state);
|
||||
if (state.awaitingFinalCommit) {
|
||||
state.awaitingFinalCommit = false;
|
||||
}
|
||||
this.maybeFinalizeDictationStream(dictationId);
|
||||
return;
|
||||
}
|
||||
void this.failAndCleanupDictationStream(dictationId, message, true);
|
||||
});
|
||||
|
||||
@@ -329,10 +363,13 @@ export class DictationStreamManager {
|
||||
transcriptsByItemId: new Map(),
|
||||
finalTranscriptItemIds: new Set(),
|
||||
awaitingFinalCommit: false,
|
||||
vadGraceTimeout: null,
|
||||
fallbackCommitAttempted: false,
|
||||
finishRequested: false,
|
||||
finishSealed: false,
|
||||
finalSeq: null,
|
||||
finalTimeout: null,
|
||||
isSemanticVad: turnDetection?.type === "semantic_vad",
|
||||
});
|
||||
|
||||
this.emitDictationAck(dictationId, -1);
|
||||
@@ -529,6 +566,7 @@ export class DictationStreamManager {
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
this.clearVadGraceTimeout(state);
|
||||
if (state.finalTimeout) {
|
||||
clearTimeout(state.finalTimeout);
|
||||
}
|
||||
@@ -585,13 +623,17 @@ export class DictationStreamManager {
|
||||
state.bytesSinceCommit += silenceBytes;
|
||||
}
|
||||
|
||||
try {
|
||||
state.awaitingFinalCommit = true;
|
||||
state.openai.commit();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
void this.failAndCleanupDictationStream(dictationId, message, true);
|
||||
return;
|
||||
state.awaitingFinalCommit = true;
|
||||
if (state.isSemanticVad) {
|
||||
this.startVadGraceTimeout(state);
|
||||
} else {
|
||||
try {
|
||||
state.openai.commit();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
void this.failAndCleanupDictationStream(dictationId, message, true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -690,4 +732,43 @@ export class DictationStreamManager {
|
||||
this.cleanupDictationStream(dictationId);
|
||||
})();
|
||||
}
|
||||
|
||||
private startVadGraceTimeout(state: DictationStreamState): void {
|
||||
if (state.vadGraceTimeout || DICTATION_VAD_GRACE_TIMEOUT_MS <= 0) {
|
||||
return;
|
||||
}
|
||||
state.vadGraceTimeout = setTimeout(() => {
|
||||
state.vadGraceTimeout = null;
|
||||
if (!state.finishRequested || !state.awaitingFinalCommit) {
|
||||
return;
|
||||
}
|
||||
if (state.bytesSinceCommit <= 0 || state.fallbackCommitAttempted) {
|
||||
return;
|
||||
}
|
||||
state.fallbackCommitAttempted = true;
|
||||
try {
|
||||
state.openai.commit();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (isBufferTooSmallError(message)) {
|
||||
state.awaitingFinalCommit = false;
|
||||
this.maybeFinalizeDictationStream(state.dictationId);
|
||||
return;
|
||||
}
|
||||
void this.failAndCleanupDictationStream(state.dictationId, message, true);
|
||||
}
|
||||
}, DICTATION_VAD_GRACE_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
private clearVadGraceTimeout(state: DictationStreamState): void {
|
||||
if (!state.vadGraceTimeout) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(state.vadGraceTimeout);
|
||||
state.vadGraceTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
function isBufferTooSmallError(message: string): boolean {
|
||||
return /buffer too small/i.test(message);
|
||||
}
|
||||
|
||||
@@ -922,10 +922,6 @@ export class Session {
|
||||
await this.handleFileDownloadTokenRequest(msg);
|
||||
break;
|
||||
|
||||
case "git_repo_info_request":
|
||||
await this.handleGitRepoInfoRequest(msg);
|
||||
break;
|
||||
|
||||
case "list_provider_models_request":
|
||||
await this.handleListProviderModelsRequest(msg);
|
||||
break;
|
||||
@@ -1441,12 +1437,17 @@ export class Session {
|
||||
}
|
||||
|
||||
if (requestId) {
|
||||
const agentPayload = await this.getAgentPayloadById(snapshot.id);
|
||||
if (!agentPayload) {
|
||||
throw new Error(`Agent ${snapshot.id} not found after creation`);
|
||||
}
|
||||
this.emit({
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "agent_created",
|
||||
agentId: snapshot.id,
|
||||
requestId,
|
||||
agent: agentPayload,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1516,6 +1517,10 @@ export class Session {
|
||||
await this.forwardAgentUpdate(snapshot);
|
||||
const timelineSize = this.emitAgentTimelineSnapshot(snapshot);
|
||||
if (requestId) {
|
||||
const agentPayload = await this.getAgentPayloadById(snapshot.id);
|
||||
if (!agentPayload) {
|
||||
throw new Error(`Agent ${snapshot.id} not found after resume`);
|
||||
}
|
||||
this.emit({
|
||||
type: "status",
|
||||
payload: {
|
||||
@@ -1523,6 +1528,7 @@ export class Session {
|
||||
agentId: snapshot.id,
|
||||
requestId,
|
||||
timelineSize,
|
||||
agent: agentPayload,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1787,59 +1793,6 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleGitRepoInfoRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "git_repo_info_request" }>
|
||||
): Promise<void> {
|
||||
const { cwd, requestId } = msg;
|
||||
const resolvedCwd = expandTilde(cwd);
|
||||
|
||||
try {
|
||||
const status = await getCheckoutStatus(resolvedCwd, { paseoHome: this.paseoHome });
|
||||
if (!status.isGit) {
|
||||
throw new NotGitRepoError(resolvedCwd);
|
||||
}
|
||||
const repoRoot = status.repoRoot ?? resolvedCwd;
|
||||
const { stdout: branchesRaw } = await execAsync(
|
||||
"git branch --format='%(refname:short)'",
|
||||
{ cwd: repoRoot, env: READ_ONLY_GIT_ENV }
|
||||
);
|
||||
const currentBranch = status.currentBranch ?? "";
|
||||
const branches = branchesRaw
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.map((name) => ({
|
||||
name,
|
||||
isCurrent: name === currentBranch,
|
||||
}));
|
||||
|
||||
const isDirty = status.isDirty ?? false;
|
||||
|
||||
this.emit({
|
||||
type: "git_repo_info_response",
|
||||
payload: {
|
||||
cwd: resolvedCwd,
|
||||
repoRoot,
|
||||
requestId,
|
||||
branches,
|
||||
currentBranch: currentBranch || null,
|
||||
isDirty,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.emit({
|
||||
type: "git_repo_info_response",
|
||||
payload: {
|
||||
cwd,
|
||||
repoRoot: cwd,
|
||||
requestId,
|
||||
error: (error as Error)?.message ?? String(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleListProviderModelsRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "list_provider_models_request" }>
|
||||
): Promise<void> {
|
||||
|
||||
@@ -623,6 +623,13 @@ export const CheckoutPrStatusRequestSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const ValidateBranchRequestSchema = z.object({
|
||||
type: z.literal("validate_branch_request"),
|
||||
cwd: z.string(),
|
||||
branchName: z.string(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const PaseoWorktreeListRequestSchema = z.object({
|
||||
type: z.literal("paseo_worktree_list_request"),
|
||||
cwd: z.string().optional(),
|
||||
@@ -842,13 +849,13 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
CheckoutPushRequestSchema,
|
||||
CheckoutPrCreateRequestSchema,
|
||||
CheckoutPrStatusRequestSchema,
|
||||
ValidateBranchRequestSchema,
|
||||
PaseoWorktreeListRequestSchema,
|
||||
PaseoWorktreeArchiveRequestSchema,
|
||||
HighlightedDiffRequestSchema,
|
||||
FileExplorerRequestSchema,
|
||||
ProjectIconRequestSchema,
|
||||
FileDownloadTokenRequestSchema,
|
||||
GitRepoInfoRequestMessageSchema,
|
||||
ClearAgentAttentionMessageSchema,
|
||||
ClientHeartbeatMessageSchema,
|
||||
ListCommandsRequestSchema,
|
||||
@@ -979,6 +986,7 @@ const AgentStatusWithTimelineSchema = AgentStatusWithRequestSchema.extend({
|
||||
export const AgentCreatedStatusPayloadSchema = z
|
||||
.object({
|
||||
status: z.literal("agent_created"),
|
||||
agent: AgentSnapshotPayloadSchema,
|
||||
})
|
||||
.extend(AgentStatusWithRequestSchema.shape);
|
||||
|
||||
@@ -991,6 +999,7 @@ export const AgentCreateFailedStatusPayloadSchema = z.object({
|
||||
export const AgentResumedStatusPayloadSchema = z
|
||||
.object({
|
||||
status: z.literal("agent_resumed"),
|
||||
agent: AgentSnapshotPayloadSchema,
|
||||
})
|
||||
.extend(AgentStatusWithTimelineSchema.shape);
|
||||
|
||||
@@ -1333,6 +1342,17 @@ export const CheckoutPrStatusResponseSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const ValidateBranchResponseSchema = z.object({
|
||||
type: z.literal("validate_branch_response"),
|
||||
payload: z.object({
|
||||
exists: z.boolean(),
|
||||
resolvedRef: z.string().nullable(),
|
||||
isRemote: z.boolean(),
|
||||
error: z.string().nullable(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const PaseoWorktreeSchema = z.object({
|
||||
worktreePath: z.string(),
|
||||
branchName: z.string().nullable().optional(),
|
||||
@@ -1410,24 +1430,6 @@ export const FileDownloadTokenResponseSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
const GitBranchInfoSchema = z.object({
|
||||
name: z.string(),
|
||||
isCurrent: z.boolean(),
|
||||
});
|
||||
|
||||
export const GitRepoInfoResponseSchema = z.object({
|
||||
type: z.literal("git_repo_info_response"),
|
||||
payload: z.object({
|
||||
cwd: z.string(),
|
||||
repoRoot: z.string(),
|
||||
requestId: z.string(),
|
||||
branches: z.array(GitBranchInfoSchema).optional(),
|
||||
currentBranch: z.string().nullable().optional(),
|
||||
isDirty: z.boolean().optional(),
|
||||
error: z.string().nullable().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ListProviderModelsResponseMessageSchema = z.object({
|
||||
type: z.literal("list_provider_models_response"),
|
||||
payload: z.object({
|
||||
@@ -1581,13 +1583,13 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
CheckoutPushResponseSchema,
|
||||
CheckoutPrCreateResponseSchema,
|
||||
CheckoutPrStatusResponseSchema,
|
||||
ValidateBranchResponseSchema,
|
||||
PaseoWorktreeListResponseSchema,
|
||||
PaseoWorktreeArchiveResponseSchema,
|
||||
HighlightedDiffResponseSchema,
|
||||
FileExplorerResponseSchema,
|
||||
ProjectIconResponseSchema,
|
||||
FileDownloadTokenResponseSchema,
|
||||
GitRepoInfoResponseSchema,
|
||||
ListProviderModelsResponseMessageSchema,
|
||||
ListCommandsResponseSchema,
|
||||
ExecuteCommandResponseSchema,
|
||||
@@ -1685,6 +1687,8 @@ export type CheckoutPrCreateRequest = z.infer<typeof CheckoutPrCreateRequestSche
|
||||
export type CheckoutPrCreateResponse = z.infer<typeof CheckoutPrCreateResponseSchema>;
|
||||
export type CheckoutPrStatusRequest = z.infer<typeof CheckoutPrStatusRequestSchema>;
|
||||
export type CheckoutPrStatusResponse = z.infer<typeof CheckoutPrStatusResponseSchema>;
|
||||
export type ValidateBranchRequest = z.infer<typeof ValidateBranchRequestSchema>;
|
||||
export type ValidateBranchResponse = z.infer<typeof ValidateBranchResponseSchema>;
|
||||
export type PaseoWorktreeListRequest = z.infer<typeof PaseoWorktreeListRequestSchema>;
|
||||
export type PaseoWorktreeListResponse = z.infer<typeof PaseoWorktreeListResponseSchema>;
|
||||
export type PaseoWorktreeArchiveRequest = z.infer<typeof PaseoWorktreeArchiveRequestSchema>;
|
||||
@@ -1698,7 +1702,6 @@ export type ProjectIconResponse = z.infer<typeof ProjectIconResponseSchema>;
|
||||
export type ProjectIcon = z.infer<typeof ProjectIconSchema>;
|
||||
export type FileDownloadTokenRequest = z.infer<typeof FileDownloadTokenRequestSchema>;
|
||||
export type FileDownloadTokenResponse = z.infer<typeof FileDownloadTokenResponseSchema>;
|
||||
export type GitRepoInfoResponse = z.infer<typeof GitRepoInfoResponseSchema>;
|
||||
export type RestartServerRequestMessage = z.infer<typeof RestartServerRequestMessageSchema>;
|
||||
export type ClearAgentAttentionMessage = z.infer<typeof ClearAgentAttentionMessageSchema>;
|
||||
export type ClientHeartbeatMessage = z.infer<typeof ClientHeartbeatMessageSchema>;
|
||||
|
||||
@@ -542,10 +542,19 @@ export async function createWorktree({
|
||||
if (normalizedBaseBranch === "HEAD") {
|
||||
throw new Error("Base branch cannot be HEAD when creating a Paseo worktree");
|
||||
}
|
||||
|
||||
// Resolve the base branch - try local first, then remote
|
||||
let resolvedBaseBranch = normalizedBaseBranch;
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify ${normalizedBaseBranch}`, { cwd });
|
||||
} catch {
|
||||
throw new Error(`Base branch not found: ${normalizedBaseBranch}`);
|
||||
// Local branch doesn't exist, try remote (origin/{branch})
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify origin/${normalizedBaseBranch}`, { cwd });
|
||||
resolvedBaseBranch = `origin/${normalizedBaseBranch}`;
|
||||
} catch {
|
||||
throw new Error(`Base branch not found: ${normalizedBaseBranch}`);
|
||||
}
|
||||
}
|
||||
|
||||
let worktreePath: string;
|
||||
@@ -568,8 +577,8 @@ export async function createWorktree({
|
||||
|
||||
// Always create a new branch for the worktree
|
||||
// If branchName already exists, use it as base and create worktree-slug as branch name
|
||||
// If branchName doesn't exist, create it from baseBranch
|
||||
const base = branchExists ? branchName : normalizedBaseBranch;
|
||||
// If branchName doesn't exist, create it from baseBranch (resolved to remote if needed)
|
||||
const base = branchExists ? branchName : resolvedBaseBranch;
|
||||
const candidateBranch = branchExists ? desiredSlug : branchName;
|
||||
|
||||
// Find unique branch name if collision
|
||||
|
||||
Reference in New Issue
Block a user