Update files
This commit is contained in:
@@ -137,7 +137,7 @@ async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
|
|||||||
export const gotoHome = async (page: Page) => {
|
export const gotoHome = async (page: Page) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await ensureE2EStorageSeeded(page);
|
await ensureE2EStorageSeeded(page);
|
||||||
await expect(page.getByText('New Agent', { exact: true }).first()).toBeVisible();
|
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible();
|
||||||
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeVisible();
|
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeVisible();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -851,7 +851,6 @@ export interface GitOptionsSectionProps {
|
|||||||
currentBranch: string | null;
|
currentBranch: string | null;
|
||||||
baseBranch: string;
|
baseBranch: string;
|
||||||
onBaseBranchChange: (value: string) => void;
|
onBaseBranchChange: (value: string) => void;
|
||||||
branches: Array<{ name: string; isCurrent: boolean }>;
|
|
||||||
status: "idle" | "loading" | "ready" | "error";
|
status: "idle" | "loading" | "ready" | "error";
|
||||||
repoError: string | null;
|
repoError: string | null;
|
||||||
gitValidationError: string | null;
|
gitValidationError: string | null;
|
||||||
@@ -871,7 +870,6 @@ export function GitOptionsSection({
|
|||||||
currentBranch,
|
currentBranch,
|
||||||
baseBranch,
|
baseBranch,
|
||||||
onBaseBranchChange,
|
onBaseBranchChange,
|
||||||
branches,
|
|
||||||
status,
|
status,
|
||||||
repoError,
|
repoError,
|
||||||
gitValidationError,
|
gitValidationError,
|
||||||
|
|||||||
@@ -373,16 +373,14 @@ function AgentScreenContent({
|
|||||||
const modelDisplayValue = agentModel ?? "Unknown";
|
const modelDisplayValue = agentModel ?? "Unknown";
|
||||||
|
|
||||||
const repoInfoQuery = useQuery({
|
const repoInfoQuery = useQuery({
|
||||||
queryKey: ["gitRepoInfo", serverId, agent?.cwd ?? ""],
|
queryKey: ["checkoutStatus", serverId, agent?.cwd ?? ""],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
throw new Error("Daemon client unavailable");
|
throw new Error("Daemon client unavailable");
|
||||||
}
|
}
|
||||||
const payload = await client.getGitRepoInfo({
|
const payload = await client.getCheckoutStatus(agent?.cwd ?? ".");
|
||||||
cwd: agent?.cwd ?? ".",
|
|
||||||
});
|
|
||||||
if (payload.error) {
|
if (payload.error) {
|
||||||
throw new Error(payload.error);
|
throw new Error(payload.error.message);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
cwd: payload.cwd,
|
cwd: payload.cwd,
|
||||||
|
|||||||
@@ -312,29 +312,29 @@ export function DraftAgentScreen({
|
|||||||
type RepoInfoState = {
|
type RepoInfoState = {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
repoRoot: string;
|
repoRoot: string;
|
||||||
branches: Array<{ name: string; isCurrent: boolean }>;
|
|
||||||
currentBranch: string | null;
|
currentBranch: string | null;
|
||||||
isDirty: boolean;
|
isDirty: boolean;
|
||||||
};
|
};
|
||||||
const repoInfoQuery = useQuery({
|
const repoInfoQuery = useQuery({
|
||||||
queryKey: ["gitRepoInfo", selectedServerId, trimmedWorkingDir],
|
queryKey: ["checkoutStatus", selectedServerId, trimmedWorkingDir],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const client = sessionClient;
|
const client = sessionClient;
|
||||||
if (!client) {
|
if (!client) {
|
||||||
throw new Error("Daemon client unavailable");
|
throw new Error("Daemon client unavailable");
|
||||||
}
|
}
|
||||||
const payload = await client.getGitRepoInfo({
|
const payload = await client.getCheckoutStatus(trimmedWorkingDir || ".");
|
||||||
cwd: trimmedWorkingDir || ".",
|
|
||||||
});
|
|
||||||
if (payload.error) {
|
if (payload.error) {
|
||||||
throw new Error(payload.error);
|
throw new Error(payload.error.message);
|
||||||
}
|
}
|
||||||
|
if (!payload.isGit) {
|
||||||
|
throw new Error("Not a git repository");
|
||||||
|
}
|
||||||
|
// After the isGit check, TypeScript knows we have a git repo
|
||||||
return {
|
return {
|
||||||
cwd: payload.cwd,
|
cwd: payload.cwd,
|
||||||
repoRoot: payload.repoRoot,
|
repoRoot: payload.repoRoot,
|
||||||
branches: payload.branches ?? [],
|
currentBranch: payload.currentBranch,
|
||||||
currentBranch: payload.currentBranch ?? null,
|
isDirty: payload.isDirty,
|
||||||
isDirty: Boolean(payload.isDirty),
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
enabled:
|
enabled:
|
||||||
@@ -501,6 +501,30 @@ export function DraftAgentScreen({
|
|||||||
validateWorktreeName,
|
validateWorktreeName,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Validate branch exists (checks local first, then remote)
|
||||||
|
const branchValidationQuery = useQuery({
|
||||||
|
queryKey: ["validateBranch", selectedServerId, trimmedWorkingDir, baseBranch],
|
||||||
|
queryFn: async () => {
|
||||||
|
const client = sessionClient;
|
||||||
|
if (!client) {
|
||||||
|
throw new Error("Daemon client unavailable");
|
||||||
|
}
|
||||||
|
return client.validateBranch({
|
||||||
|
cwd: trimmedWorkingDir || ".",
|
||||||
|
branchName: baseBranch,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
enabled:
|
||||||
|
isCreateWorktree &&
|
||||||
|
!isNonGitDirectory &&
|
||||||
|
Boolean(baseBranch) &&
|
||||||
|
Boolean(trimmedWorkingDir) &&
|
||||||
|
Boolean(sessionClient) &&
|
||||||
|
isConnected,
|
||||||
|
retry: false,
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
const baseBranchError = useMemo(() => {
|
const baseBranchError = useMemo(() => {
|
||||||
if (!isCreateWorktree || isNonGitDirectory) {
|
if (!isCreateWorktree || isNonGitDirectory) {
|
||||||
return null;
|
return null;
|
||||||
@@ -508,16 +532,21 @@ export function DraftAgentScreen({
|
|||||||
if (!baseBranch) {
|
if (!baseBranch) {
|
||||||
return "Base branch is required";
|
return "Base branch is required";
|
||||||
}
|
}
|
||||||
const branches = repoInfo?.branches ?? [];
|
// While validating, don't show error
|
||||||
if (branches.length === 0) {
|
if (branchValidationQuery.isPending || branchValidationQuery.isFetching) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const branchExists = branches.some((b) => b.name === baseBranch);
|
// If validation query errored, show generic error
|
||||||
if (!branchExists) {
|
if (branchValidationQuery.isError) {
|
||||||
|
return "Failed to validate branch";
|
||||||
|
}
|
||||||
|
// If validation completed and branch doesn't exist
|
||||||
|
const validationResult = branchValidationQuery.data;
|
||||||
|
if (validationResult && !validationResult.exists) {
|
||||||
return `Branch "${baseBranch}" not found in repository`;
|
return `Branch "${baseBranch}" not found in repository`;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}, [isCreateWorktree, isNonGitDirectory, baseBranch, repoInfo?.branches]);
|
}, [isCreateWorktree, isNonGitDirectory, baseBranch, branchValidationQuery]);
|
||||||
|
|
||||||
const handleBaseBranchChange = useCallback((value: string) => {
|
const handleBaseBranchChange = useCallback((value: string) => {
|
||||||
setBaseBranch(value);
|
setBaseBranch(value);
|
||||||
@@ -865,7 +894,6 @@ export function DraftAgentScreen({
|
|||||||
currentBranch={repoInfo?.currentBranch ?? null}
|
currentBranch={repoInfo?.currentBranch ?? null}
|
||||||
baseBranch={baseBranch}
|
baseBranch={baseBranch}
|
||||||
onBaseBranchChange={handleBaseBranchChange}
|
onBaseBranchChange={handleBaseBranchChange}
|
||||||
branches={repoInfo?.branches ?? []}
|
|
||||||
status={repoInfoStatus}
|
status={repoInfoStatus}
|
||||||
repoError={repoInfoError}
|
repoError={repoInfoError}
|
||||||
gitValidationError={gitBlockingError}
|
gitValidationError={gitBlockingError}
|
||||||
|
|||||||
@@ -19,17 +19,17 @@ import type {
|
|||||||
FileExplorerResponse,
|
FileExplorerResponse,
|
||||||
GitDiffResponse,
|
GitDiffResponse,
|
||||||
GitSetupOptions,
|
GitSetupOptions,
|
||||||
GitRepoInfoResponse,
|
|
||||||
HighlightedDiffResponse,
|
HighlightedDiffResponse,
|
||||||
CheckoutStatusResponse,
|
CheckoutStatusResponse,
|
||||||
CheckoutDiffResponse,
|
CheckoutDiffResponse,
|
||||||
CheckoutCommitResponse,
|
CheckoutCommitResponse,
|
||||||
CheckoutMergeResponse,
|
CheckoutMergeResponse,
|
||||||
CheckoutMergeFromBaseResponse,
|
CheckoutMergeFromBaseResponse,
|
||||||
CheckoutPushResponse,
|
CheckoutPushResponse,
|
||||||
CheckoutPrCreateResponse,
|
CheckoutPrCreateResponse,
|
||||||
CheckoutPrStatusResponse,
|
CheckoutPrStatusResponse,
|
||||||
PaseoWorktreeListResponse,
|
ValidateBranchResponse,
|
||||||
|
PaseoWorktreeListResponse,
|
||||||
PaseoWorktreeArchiveResponse,
|
PaseoWorktreeArchiveResponse,
|
||||||
ProjectIconResponse,
|
ProjectIconResponse,
|
||||||
ListCommandsResponse,
|
ListCommandsResponse,
|
||||||
@@ -177,7 +177,6 @@ type ListVoiceConversationsPayload = ListVoiceConversationsResponseMessage["payl
|
|||||||
type DeleteVoiceConversationPayload = DeleteVoiceConversationResponseMessage["payload"];
|
type DeleteVoiceConversationPayload = DeleteVoiceConversationResponseMessage["payload"];
|
||||||
type GitDiffPayload = GitDiffResponse["payload"];
|
type GitDiffPayload = GitDiffResponse["payload"];
|
||||||
type HighlightedDiffPayload = HighlightedDiffResponse["payload"];
|
type HighlightedDiffPayload = HighlightedDiffResponse["payload"];
|
||||||
type GitRepoInfoPayload = GitRepoInfoResponse["payload"];
|
|
||||||
type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
||||||
type CheckoutDiffPayload = CheckoutDiffResponse["payload"];
|
type CheckoutDiffPayload = CheckoutDiffResponse["payload"];
|
||||||
type CheckoutCommitPayload = CheckoutCommitResponse["payload"];
|
type CheckoutCommitPayload = CheckoutCommitResponse["payload"];
|
||||||
@@ -186,6 +185,7 @@ type CheckoutMergeFromBasePayload = CheckoutMergeFromBaseResponse["payload"];
|
|||||||
type CheckoutPushPayload = CheckoutPushResponse["payload"];
|
type CheckoutPushPayload = CheckoutPushResponse["payload"];
|
||||||
type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"];
|
type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"];
|
||||||
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
|
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
|
||||||
|
type ValidateBranchPayload = ValidateBranchResponse["payload"];
|
||||||
type PaseoWorktreeListPayload = PaseoWorktreeListResponse["payload"];
|
type PaseoWorktreeListPayload = PaseoWorktreeListResponse["payload"];
|
||||||
type PaseoWorktreeArchivePayload = PaseoWorktreeArchiveResponse["payload"];
|
type PaseoWorktreeArchivePayload = PaseoWorktreeArchiveResponse["payload"];
|
||||||
type FileExplorerPayload = FileExplorerResponse["payload"];
|
type FileExplorerPayload = FileExplorerResponse["payload"];
|
||||||
@@ -1596,37 +1596,20 @@ export class DaemonClientV2 {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getGitRepoInfo(
|
async validateBranch(
|
||||||
input: string | { cwd: string } | { agentId: string },
|
options: { cwd: string; branchName: string },
|
||||||
requestId?: string
|
requestId?: string
|
||||||
): Promise<GitRepoInfoPayload> {
|
): Promise<ValidateBranchPayload> {
|
||||||
const normalizedInput =
|
|
||||||
typeof input === "string" ? { agentId: input } : input;
|
|
||||||
const resolvedRequestId = this.createRequestId(requestId);
|
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({
|
const message = SessionInboundMessageSchema.parse({
|
||||||
type: "git_repo_info_request",
|
type: "validate_branch_request",
|
||||||
cwd,
|
cwd: options.cwd,
|
||||||
|
branchName: options.branchName,
|
||||||
requestId: resolvedRequestId,
|
requestId: resolvedRequestId,
|
||||||
});
|
});
|
||||||
const response = this.waitFor(
|
const response = this.waitFor(
|
||||||
(msg) => {
|
(msg) => {
|
||||||
if (msg.type !== "git_repo_info_response") {
|
if (msg.type !== "validate_branch_response") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (msg.payload.requestId !== resolvedRequestId) {
|
if (msg.payload.requestId !== resolvedRequestId) {
|
||||||
|
|||||||
@@ -784,36 +784,11 @@ describe("daemon client v2 E2E", () => {
|
|||||||
title: "Git/File Test",
|
title: "Git/File Test",
|
||||||
});
|
});
|
||||||
|
|
||||||
const repoInfoRequestId = `repo-info-${Date.now()}`;
|
// Test checkout status RPC
|
||||||
const repoInfoMessagePromise = waitForSignal(15000, (resolve) => {
|
const checkoutStatus = await ctx.client.getCheckoutStatus(cwd);
|
||||||
const unsubscribeRepo = ctx.client.on(
|
expect(checkoutStatus.error).toBeNull();
|
||||||
"git_repo_info_response",
|
expect(checkoutStatus.isGit).toBe(true);
|
||||||
(message) => {
|
expect(checkoutStatus.repoRoot).toContain(cwd);
|
||||||
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);
|
|
||||||
|
|
||||||
const diffRequestId = `diff-${Date.now()}`;
|
const diffRequestId = `diff-${Date.now()}`;
|
||||||
const diffMessagePromise = waitForSignal(15000, (resolve) => {
|
const diffMessagePromise = waitForSignal(15000, (resolve) => {
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ describe("daemon E2E", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
describe("getGitRepoInfo", () => {
|
describe("getCheckoutStatus", () => {
|
||||||
test(
|
test(
|
||||||
"returns repo info for git repo with branch and dirty state",
|
"returns repo info for git repo with branch and dirty state",
|
||||||
async () => {
|
async () => {
|
||||||
@@ -268,16 +268,15 @@ describe("daemon E2E", () => {
|
|||||||
expect(agent.id).toBeTruthy();
|
expect(agent.id).toBeTruthy();
|
||||||
expect(agent.status).toBe("idle");
|
expect(agent.status).toBe("idle");
|
||||||
|
|
||||||
// Get git repo info
|
// Get checkout status
|
||||||
const result = await ctx.client.getGitRepoInfo(agent.id);
|
const result = await ctx.client.getCheckoutStatus(cwd);
|
||||||
|
|
||||||
// Verify repo info returned without error
|
// Verify repo info returned without error
|
||||||
expect(result.error).toBeNull();
|
expect(result.error).toBeNull();
|
||||||
|
expect(result.isGit).toBe(true);
|
||||||
// macOS symlinks /var to /private/var, so we check containment
|
// macOS symlinks /var to /private/var, so we check containment
|
||||||
expect(result.repoRoot).toContain("daemon-e2e-");
|
expect(result.repoRoot).toContain("daemon-e2e-");
|
||||||
expect(result.currentBranch).toBeTruthy();
|
expect(result.currentBranch).toBeTruthy();
|
||||||
expect(result.branches.length).toBeGreaterThan(0);
|
|
||||||
expect(result.branches.some((b) => b.isCurrent)).toBe(true);
|
|
||||||
expect(result.isDirty).toBe(true);
|
expect(result.isDirty).toBe(true);
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
@@ -316,10 +315,11 @@ describe("daemon E2E", () => {
|
|||||||
|
|
||||||
expect(agent.id).toBeTruthy();
|
expect(agent.id).toBeTruthy();
|
||||||
|
|
||||||
// Get git repo info
|
// Get checkout status
|
||||||
const result = await ctx.client.getGitRepoInfo(agent.id);
|
const result = await ctx.client.getCheckoutStatus(cwd);
|
||||||
|
|
||||||
expect(result.error).toBeNull();
|
expect(result.error).toBeNull();
|
||||||
|
expect(result.isGit).toBe(true);
|
||||||
expect(result.isDirty).toBe(false);
|
expect(result.isDirty).toBe(false);
|
||||||
expect(result.currentBranch).toBeTruthy();
|
expect(result.currentBranch).toBeTruthy();
|
||||||
|
|
||||||
@@ -331,7 +331,7 @@ describe("daemon E2E", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
test(
|
test(
|
||||||
"returns error for non-git directory",
|
"returns isGit false for non-git directory",
|
||||||
async () => {
|
async () => {
|
||||||
const cwd = tmpCwd();
|
const cwd = tmpCwd();
|
||||||
// Don't initialize git - just a regular directory
|
// Don't initialize git - just a regular directory
|
||||||
@@ -345,11 +345,10 @@ describe("daemon E2E", () => {
|
|||||||
|
|
||||||
expect(agent.id).toBeTruthy();
|
expect(agent.id).toBeTruthy();
|
||||||
|
|
||||||
// Get git repo info - should return error
|
// Get checkout status - should return isGit: false
|
||||||
const result = await ctx.client.getGitRepoInfo(agent.id);
|
const result = await ctx.client.getCheckoutStatus(cwd);
|
||||||
|
|
||||||
// Server returns cwd as repoRoot even on error, so we just check for error
|
expect(result.isGit).toBe(false);
|
||||||
expect(result.error).toBeTruthy();
|
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
await ctx.client.deleteAgent(agent.id);
|
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_CHANNELS = 1;
|
||||||
const PCM_BITS_PER_SAMPLE = 16;
|
const PCM_BITS_PER_SAMPLE = 16;
|
||||||
const DICTATION_PCM_OUTPUT_RATE = 24000;
|
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(
|
const DICTATION_SILENCE_PEAK_THRESHOLD = Number.parseInt(
|
||||||
process.env.OPENAI_REALTIME_DICTATION_SILENCE_PEAK_THRESHOLD ?? "300",
|
process.env.OPENAI_REALTIME_DICTATION_SILENCE_PEAK_THRESHOLD ?? "300",
|
||||||
10
|
10
|
||||||
@@ -101,6 +105,15 @@ export type RealtimeTranscriptionSession = {
|
|||||||
on(event: "error", handler: (err: unknown) => void): unknown;
|
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(
|
function convertPCMToWavBuffer(
|
||||||
pcmBuffer: Buffer,
|
pcmBuffer: Buffer,
|
||||||
sampleRate: number,
|
sampleRate: number,
|
||||||
@@ -149,10 +162,13 @@ type DictationStreamState = {
|
|||||||
transcriptsByItemId: Map<string, string>;
|
transcriptsByItemId: Map<string, string>;
|
||||||
finalTranscriptItemIds: Set<string>;
|
finalTranscriptItemIds: Set<string>;
|
||||||
awaitingFinalCommit: boolean;
|
awaitingFinalCommit: boolean;
|
||||||
|
vadGraceTimeout: ReturnType<typeof setTimeout> | null;
|
||||||
|
fallbackCommitAttempted: boolean;
|
||||||
finishRequested: boolean;
|
finishRequested: boolean;
|
||||||
finishSealed: boolean;
|
finishSealed: boolean;
|
||||||
finalSeq: number | null;
|
finalSeq: number | null;
|
||||||
finalTimeout: ReturnType<typeof setTimeout> | null;
|
finalTimeout: ReturnType<typeof setTimeout> | null;
|
||||||
|
isSemanticVad: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DictationStreamOutboundMessage =
|
export type DictationStreamOutboundMessage =
|
||||||
@@ -177,6 +193,7 @@ export class DictationStreamManager {
|
|||||||
private readonly sessionId: string;
|
private readonly sessionId: string;
|
||||||
private readonly openaiApiKey: string | null;
|
private readonly openaiApiKey: string | null;
|
||||||
private readonly finalTimeoutMs: number;
|
private readonly finalTimeoutMs: number;
|
||||||
|
private readonly createSession: RealtimeTranscriptionSessionFactory;
|
||||||
private readonly streams = new Map<string, DictationStreamState>();
|
private readonly streams = new Map<string, DictationStreamState>();
|
||||||
|
|
||||||
constructor(params: {
|
constructor(params: {
|
||||||
@@ -185,12 +202,17 @@ export class DictationStreamManager {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
openaiApiKey?: string | null;
|
openaiApiKey?: string | null;
|
||||||
finalTimeoutMs?: number;
|
finalTimeoutMs?: number;
|
||||||
|
sessionFactory?: RealtimeTranscriptionSessionFactory;
|
||||||
}) {
|
}) {
|
||||||
this.logger = params.logger.child({ component: "dictation-stream-manager" });
|
this.logger = params.logger.child({ component: "dictation-stream-manager" });
|
||||||
this.emit = params.emit;
|
this.emit = params.emit;
|
||||||
this.sessionId = params.sessionId;
|
this.sessionId = params.sessionId;
|
||||||
this.openaiApiKey = params.openaiApiKey ?? null;
|
this.openaiApiKey = params.openaiApiKey ?? null;
|
||||||
this.finalTimeoutMs = params.finalTimeoutMs ?? DEFAULT_DICTATION_FINAL_TIMEOUT_MS;
|
this.finalTimeoutMs = params.finalTimeoutMs ?? DEFAULT_DICTATION_FINAL_TIMEOUT_MS;
|
||||||
|
this.createSession =
|
||||||
|
params.sessionFactory ??
|
||||||
|
((factoryParams) =>
|
||||||
|
new OpenAIRealtimeTranscriptionSession(factoryParams));
|
||||||
}
|
}
|
||||||
|
|
||||||
public cleanupAll(): void {
|
public cleanupAll(): void {
|
||||||
@@ -214,13 +236,14 @@ export class DictationStreamManager {
|
|||||||
process.env.OPENAI_REALTIME_DICTATION_TRANSCRIPTION_PROMPT ??
|
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.";
|
"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,
|
apiKey,
|
||||||
logger: this.logger.child({ dictationId }),
|
logger: this.logger.child({ dictationId }),
|
||||||
transcriptionModel,
|
transcriptionModel,
|
||||||
language: "en",
|
language: "en",
|
||||||
prompt: transcriptionPrompt,
|
prompt: transcriptionPrompt,
|
||||||
turnDetection: parseDictationTurnDetection(),
|
turnDetection,
|
||||||
});
|
});
|
||||||
|
|
||||||
openai.on("committed", ({ itemId }: { itemId: string }) => {
|
openai.on("committed", ({ itemId }: { itemId: string }) => {
|
||||||
@@ -228,6 +251,7 @@ export class DictationStreamManager {
|
|||||||
if (!state) {
|
if (!state) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.clearVadGraceTimeout(state);
|
||||||
state.committedItemIds.push(itemId);
|
state.committedItemIds.push(itemId);
|
||||||
state.bytesSinceCommit = 0;
|
state.bytesSinceCommit = 0;
|
||||||
state.peakSinceCommit = 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),
|
// If we triggered a finish commit but OpenAI doesn't emit committed events (or they arrive late),
|
||||||
// allow final transcripts to unblock finalization.
|
// allow final transcripts to unblock finalization.
|
||||||
if (state.finishRequested && state.awaitingFinalCommit && isFinal) {
|
if (state.finishRequested && state.awaitingFinalCommit && isFinal) {
|
||||||
|
this.clearVadGraceTimeout(state);
|
||||||
state.awaitingFinalCommit = false;
|
state.awaitingFinalCommit = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,6 +307,15 @@ export class DictationStreamManager {
|
|||||||
|
|
||||||
openai.on("error", (err) => {
|
openai.on("error", (err) => {
|
||||||
const message = err instanceof Error ? err.message : String(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);
|
void this.failAndCleanupDictationStream(dictationId, message, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -329,10 +363,13 @@ export class DictationStreamManager {
|
|||||||
transcriptsByItemId: new Map(),
|
transcriptsByItemId: new Map(),
|
||||||
finalTranscriptItemIds: new Set(),
|
finalTranscriptItemIds: new Set(),
|
||||||
awaitingFinalCommit: false,
|
awaitingFinalCommit: false,
|
||||||
|
vadGraceTimeout: null,
|
||||||
|
fallbackCommitAttempted: false,
|
||||||
finishRequested: false,
|
finishRequested: false,
|
||||||
finishSealed: false,
|
finishSealed: false,
|
||||||
finalSeq: null,
|
finalSeq: null,
|
||||||
finalTimeout: null,
|
finalTimeout: null,
|
||||||
|
isSemanticVad: turnDetection?.type === "semantic_vad",
|
||||||
});
|
});
|
||||||
|
|
||||||
this.emitDictationAck(dictationId, -1);
|
this.emitDictationAck(dictationId, -1);
|
||||||
@@ -529,6 +566,7 @@ export class DictationStreamManager {
|
|||||||
if (!state) {
|
if (!state) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.clearVadGraceTimeout(state);
|
||||||
if (state.finalTimeout) {
|
if (state.finalTimeout) {
|
||||||
clearTimeout(state.finalTimeout);
|
clearTimeout(state.finalTimeout);
|
||||||
}
|
}
|
||||||
@@ -585,13 +623,17 @@ export class DictationStreamManager {
|
|||||||
state.bytesSinceCommit += silenceBytes;
|
state.bytesSinceCommit += silenceBytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
state.awaitingFinalCommit = true;
|
||||||
state.awaitingFinalCommit = true;
|
if (state.isSemanticVad) {
|
||||||
state.openai.commit();
|
this.startVadGraceTimeout(state);
|
||||||
} catch (error) {
|
} else {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
try {
|
||||||
void this.failAndCleanupDictationStream(dictationId, message, true);
|
state.openai.commit();
|
||||||
return;
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
void this.failAndCleanupDictationStream(dictationId, message, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -690,4 +732,43 @@ export class DictationStreamManager {
|
|||||||
this.cleanupDictationStream(dictationId);
|
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);
|
await this.handleFileDownloadTokenRequest(msg);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "git_repo_info_request":
|
|
||||||
await this.handleGitRepoInfoRequest(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "list_provider_models_request":
|
case "list_provider_models_request":
|
||||||
await this.handleListProviderModelsRequest(msg);
|
await this.handleListProviderModelsRequest(msg);
|
||||||
break;
|
break;
|
||||||
@@ -1791,59 +1787,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(
|
private async handleListProviderModelsRequest(
|
||||||
msg: Extract<SessionInboundMessage, { type: "list_provider_models_request" }>
|
msg: Extract<SessionInboundMessage, { type: "list_provider_models_request" }>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|||||||
@@ -623,6 +623,13 @@ export const CheckoutPrStatusRequestSchema = z.object({
|
|||||||
requestId: z.string(),
|
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({
|
export const PaseoWorktreeListRequestSchema = z.object({
|
||||||
type: z.literal("paseo_worktree_list_request"),
|
type: z.literal("paseo_worktree_list_request"),
|
||||||
cwd: z.string().optional(),
|
cwd: z.string().optional(),
|
||||||
@@ -842,13 +849,13 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
CheckoutPushRequestSchema,
|
CheckoutPushRequestSchema,
|
||||||
CheckoutPrCreateRequestSchema,
|
CheckoutPrCreateRequestSchema,
|
||||||
CheckoutPrStatusRequestSchema,
|
CheckoutPrStatusRequestSchema,
|
||||||
|
ValidateBranchRequestSchema,
|
||||||
PaseoWorktreeListRequestSchema,
|
PaseoWorktreeListRequestSchema,
|
||||||
PaseoWorktreeArchiveRequestSchema,
|
PaseoWorktreeArchiveRequestSchema,
|
||||||
HighlightedDiffRequestSchema,
|
HighlightedDiffRequestSchema,
|
||||||
FileExplorerRequestSchema,
|
FileExplorerRequestSchema,
|
||||||
ProjectIconRequestSchema,
|
ProjectIconRequestSchema,
|
||||||
FileDownloadTokenRequestSchema,
|
FileDownloadTokenRequestSchema,
|
||||||
GitRepoInfoRequestMessageSchema,
|
|
||||||
ClearAgentAttentionMessageSchema,
|
ClearAgentAttentionMessageSchema,
|
||||||
ClientHeartbeatMessageSchema,
|
ClientHeartbeatMessageSchema,
|
||||||
ListCommandsRequestSchema,
|
ListCommandsRequestSchema,
|
||||||
@@ -1335,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({
|
const PaseoWorktreeSchema = z.object({
|
||||||
worktreePath: z.string(),
|
worktreePath: z.string(),
|
||||||
branchName: z.string().nullable().optional(),
|
branchName: z.string().nullable().optional(),
|
||||||
@@ -1412,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({
|
export const ListProviderModelsResponseMessageSchema = z.object({
|
||||||
type: z.literal("list_provider_models_response"),
|
type: z.literal("list_provider_models_response"),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
@@ -1583,13 +1583,13 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
CheckoutPushResponseSchema,
|
CheckoutPushResponseSchema,
|
||||||
CheckoutPrCreateResponseSchema,
|
CheckoutPrCreateResponseSchema,
|
||||||
CheckoutPrStatusResponseSchema,
|
CheckoutPrStatusResponseSchema,
|
||||||
|
ValidateBranchResponseSchema,
|
||||||
PaseoWorktreeListResponseSchema,
|
PaseoWorktreeListResponseSchema,
|
||||||
PaseoWorktreeArchiveResponseSchema,
|
PaseoWorktreeArchiveResponseSchema,
|
||||||
HighlightedDiffResponseSchema,
|
HighlightedDiffResponseSchema,
|
||||||
FileExplorerResponseSchema,
|
FileExplorerResponseSchema,
|
||||||
ProjectIconResponseSchema,
|
ProjectIconResponseSchema,
|
||||||
FileDownloadTokenResponseSchema,
|
FileDownloadTokenResponseSchema,
|
||||||
GitRepoInfoResponseSchema,
|
|
||||||
ListProviderModelsResponseMessageSchema,
|
ListProviderModelsResponseMessageSchema,
|
||||||
ListCommandsResponseSchema,
|
ListCommandsResponseSchema,
|
||||||
ExecuteCommandResponseSchema,
|
ExecuteCommandResponseSchema,
|
||||||
@@ -1687,6 +1687,8 @@ export type CheckoutPrCreateRequest = z.infer<typeof CheckoutPrCreateRequestSche
|
|||||||
export type CheckoutPrCreateResponse = z.infer<typeof CheckoutPrCreateResponseSchema>;
|
export type CheckoutPrCreateResponse = z.infer<typeof CheckoutPrCreateResponseSchema>;
|
||||||
export type CheckoutPrStatusRequest = z.infer<typeof CheckoutPrStatusRequestSchema>;
|
export type CheckoutPrStatusRequest = z.infer<typeof CheckoutPrStatusRequestSchema>;
|
||||||
export type CheckoutPrStatusResponse = z.infer<typeof CheckoutPrStatusResponseSchema>;
|
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 PaseoWorktreeListRequest = z.infer<typeof PaseoWorktreeListRequestSchema>;
|
||||||
export type PaseoWorktreeListResponse = z.infer<typeof PaseoWorktreeListResponseSchema>;
|
export type PaseoWorktreeListResponse = z.infer<typeof PaseoWorktreeListResponseSchema>;
|
||||||
export type PaseoWorktreeArchiveRequest = z.infer<typeof PaseoWorktreeArchiveRequestSchema>;
|
export type PaseoWorktreeArchiveRequest = z.infer<typeof PaseoWorktreeArchiveRequestSchema>;
|
||||||
@@ -1700,7 +1702,6 @@ export type ProjectIconResponse = z.infer<typeof ProjectIconResponseSchema>;
|
|||||||
export type ProjectIcon = z.infer<typeof ProjectIconSchema>;
|
export type ProjectIcon = z.infer<typeof ProjectIconSchema>;
|
||||||
export type FileDownloadTokenRequest = z.infer<typeof FileDownloadTokenRequestSchema>;
|
export type FileDownloadTokenRequest = z.infer<typeof FileDownloadTokenRequestSchema>;
|
||||||
export type FileDownloadTokenResponse = z.infer<typeof FileDownloadTokenResponseSchema>;
|
export type FileDownloadTokenResponse = z.infer<typeof FileDownloadTokenResponseSchema>;
|
||||||
export type GitRepoInfoResponse = z.infer<typeof GitRepoInfoResponseSchema>;
|
|
||||||
export type RestartServerRequestMessage = z.infer<typeof RestartServerRequestMessageSchema>;
|
export type RestartServerRequestMessage = z.infer<typeof RestartServerRequestMessageSchema>;
|
||||||
export type ClearAgentAttentionMessage = z.infer<typeof ClearAgentAttentionMessageSchema>;
|
export type ClearAgentAttentionMessage = z.infer<typeof ClearAgentAttentionMessageSchema>;
|
||||||
export type ClientHeartbeatMessage = z.infer<typeof ClientHeartbeatMessageSchema>;
|
export type ClientHeartbeatMessage = z.infer<typeof ClientHeartbeatMessageSchema>;
|
||||||
|
|||||||
@@ -542,10 +542,19 @@ export async function createWorktree({
|
|||||||
if (normalizedBaseBranch === "HEAD") {
|
if (normalizedBaseBranch === "HEAD") {
|
||||||
throw new Error("Base branch cannot be HEAD when creating a Paseo worktree");
|
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 {
|
try {
|
||||||
await execAsync(`git rev-parse --verify ${normalizedBaseBranch}`, { cwd });
|
await execAsync(`git rev-parse --verify ${normalizedBaseBranch}`, { cwd });
|
||||||
} catch {
|
} 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;
|
let worktreePath: string;
|
||||||
@@ -568,8 +577,8 @@ export async function createWorktree({
|
|||||||
|
|
||||||
// Always create a new branch for the worktree
|
// 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 already exists, use it as base and create worktree-slug as branch name
|
||||||
// If branchName doesn't exist, create it from baseBranch
|
// If branchName doesn't exist, create it from baseBranch (resolved to remote if needed)
|
||||||
const base = branchExists ? branchName : normalizedBaseBranch;
|
const base = branchExists ? branchName : resolvedBaseBranch;
|
||||||
const candidateBranch = branchExists ? desiredSlug : branchName;
|
const candidateBranch = branchExists ? desiredSlug : branchName;
|
||||||
|
|
||||||
// Find unique branch name if collision
|
// Find unique branch name if collision
|
||||||
|
|||||||
Reference in New Issue
Block a user