From 3e989009299b4879547530b722fdca6991a76ad3 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 2 Feb 2026 13:44:51 +0700 Subject: [PATCH] Update files --- packages/app/e2e/helpers/app.ts | 2 +- .../agent-form/agent-form-dropdowns.tsx | 2 - .../src/screens/agent/agent-ready-screen.tsx | 8 +- .../src/screens/agent/draft-agent-screen.tsx | 58 +++++-- .../server/src/client/daemon-client-v2.ts | 51 ++---- .../src/server/daemon-client-v2.e2e.test.ts | 35 +--- .../daemon-e2e/git-operations.e2e.test.ts | 23 ++- .../dictation-stream-manager.test.ts | 153 ++++++++++++++++++ .../dictation/dictation-stream-manager.ts | 101 ++++++++++-- packages/server/src/server/session.ts | 57 ------- packages/server/src/shared/messages.ts | 43 ++--- packages/server/src/utils/worktree.ts | 15 +- 12 files changed, 358 insertions(+), 190 deletions(-) create mode 100644 packages/server/src/server/dictation/dictation-stream-manager.test.ts diff --git a/packages/app/e2e/helpers/app.ts b/packages/app/e2e/helpers/app.ts index 025819f0f..767444b6f 100644 --- a/packages/app/e2e/helpers/app.ts +++ b/packages/app/e2e/helpers/app.ts @@ -137,7 +137,7 @@ async function assertE2EUsesSeededTestDaemon(page: Page): Promise { export const gotoHome = async (page: Page) => { await page.goto('/'); 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(); }; diff --git a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx index d9bbc7346..2cbdcf2e1 100644 --- a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx +++ b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx @@ -851,7 +851,6 @@ export interface GitOptionsSectionProps { currentBranch: string | null; baseBranch: string; onBaseBranchChange: (value: string) => void; - branches: Array<{ name: string; isCurrent: boolean }>; status: "idle" | "loading" | "ready" | "error"; repoError: string | null; gitValidationError: string | null; @@ -871,7 +870,6 @@ export function GitOptionsSection({ currentBranch, baseBranch, onBaseBranchChange, - branches, status, repoError, gitValidationError, diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx index ff9872855..221350fd2 100644 --- a/packages/app/src/screens/agent/agent-ready-screen.tsx +++ b/packages/app/src/screens/agent/agent-ready-screen.tsx @@ -373,16 +373,14 @@ function AgentScreenContent({ const modelDisplayValue = agentModel ?? "Unknown"; const repoInfoQuery = useQuery({ - queryKey: ["gitRepoInfo", serverId, agent?.cwd ?? ""], + queryKey: ["checkoutStatus", serverId, agent?.cwd ?? ""], queryFn: async () => { if (!client) { throw new Error("Daemon client unavailable"); } - const payload = await client.getGitRepoInfo({ - cwd: agent?.cwd ?? ".", - }); + const payload = await client.getCheckoutStatus(agent?.cwd ?? "."); if (payload.error) { - throw new Error(payload.error); + throw new Error(payload.error.message); } return { cwd: payload.cwd, diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index 58cc392c6..8d58ebc55 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -312,29 +312,29 @@ export function DraftAgentScreen({ type RepoInfoState = { cwd: string; repoRoot: string; - branches: Array<{ name: string; isCurrent: boolean }>; currentBranch: string | null; isDirty: boolean; }; const repoInfoQuery = useQuery({ - queryKey: ["gitRepoInfo", selectedServerId, trimmedWorkingDir], + queryKey: ["checkoutStatus", selectedServerId, trimmedWorkingDir], queryFn: async () => { const client = sessionClient; if (!client) { throw new Error("Daemon client unavailable"); } - const payload = await client.getGitRepoInfo({ - cwd: trimmedWorkingDir || ".", - }); + const payload = await client.getCheckoutStatus(trimmedWorkingDir || "."); 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 { cwd: payload.cwd, repoRoot: payload.repoRoot, - branches: payload.branches ?? [], - currentBranch: payload.currentBranch ?? null, - isDirty: Boolean(payload.isDirty), + currentBranch: payload.currentBranch, + isDirty: payload.isDirty, }; }, enabled: @@ -501,6 +501,30 @@ export function DraftAgentScreen({ 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(() => { if (!isCreateWorktree || isNonGitDirectory) { return null; @@ -508,16 +532,21 @@ export function DraftAgentScreen({ if (!baseBranch) { return "Base branch is required"; } - const branches = repoInfo?.branches ?? []; - if (branches.length === 0) { + // While validating, don't show error + if (branchValidationQuery.isPending || branchValidationQuery.isFetching) { return null; } - const branchExists = branches.some((b) => b.name === baseBranch); - if (!branchExists) { + // If validation query errored, show generic error + 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 null; - }, [isCreateWorktree, isNonGitDirectory, baseBranch, repoInfo?.branches]); + }, [isCreateWorktree, isNonGitDirectory, baseBranch, branchValidationQuery]); const handleBaseBranchChange = useCallback((value: string) => { setBaseBranch(value); @@ -865,7 +894,6 @@ export function DraftAgentScreen({ currentBranch={repoInfo?.currentBranch ?? null} baseBranch={baseBranch} onBaseBranchChange={handleBaseBranchChange} - branches={repoInfo?.branches ?? []} status={repoInfoStatus} repoError={repoInfoError} gitValidationError={gitBlockingError} diff --git a/packages/server/src/client/daemon-client-v2.ts b/packages/server/src/client/daemon-client-v2.ts index c37572b21..1cc400a4e 100644 --- a/packages/server/src/client/daemon-client-v2.ts +++ b/packages/server/src/client/daemon-client-v2.ts @@ -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"]; @@ -1596,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 { - const normalizedInput = - typeof input === "string" ? { agentId: input } : input; + ): Promise { 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) { diff --git a/packages/server/src/server/daemon-client-v2.e2e.test.ts b/packages/server/src/server/daemon-client-v2.e2e.test.ts index 9a91f1c31..d959154ff 100644 --- a/packages/server/src/server/daemon-client-v2.e2e.test.ts +++ b/packages/server/src/server/daemon-client-v2.e2e.test.ts @@ -784,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) => { diff --git a/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts index 2cba0af9e..67be69383 100644 --- a/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts @@ -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); diff --git a/packages/server/src/server/dictation/dictation-stream-manager.test.ts b/packages/server/src/server/dictation/dictation-stream-manager.test.ts new file mode 100644 index 000000000..c0581942c --- /dev/null +++ b/packages/server/src/server/dictation/dictation-stream-manager.test.ts @@ -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 { + 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 => { + 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"); + }); +}); diff --git a/packages/server/src/server/dictation/dictation-stream-manager.ts b/packages/server/src/server/dictation/dictation-stream-manager.ts index 4ae9ec317..20a9581cf 100644 --- a/packages/server/src/server/dictation/dictation-stream-manager.ts +++ b/packages/server/src/server/dictation/dictation-stream-manager.ts @@ -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; finalTranscriptItemIds: Set; awaitingFinalCommit: boolean; + vadGraceTimeout: ReturnType | null; + fallbackCommitAttempted: boolean; finishRequested: boolean; finishSealed: boolean; finalSeq: number | null; finalTimeout: ReturnType | 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(); 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); } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index ba1f59726..67bd914e7 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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; @@ -1791,59 +1787,6 @@ export class Session { } } - private async handleGitRepoInfoRequest( - msg: Extract - ): Promise { - 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 ): Promise { diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 7affaf35a..728c67eda 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -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, @@ -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({ worktreePath: z.string(), 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({ type: z.literal("list_provider_models_response"), payload: z.object({ @@ -1583,13 +1583,13 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ CheckoutPushResponseSchema, CheckoutPrCreateResponseSchema, CheckoutPrStatusResponseSchema, + ValidateBranchResponseSchema, PaseoWorktreeListResponseSchema, PaseoWorktreeArchiveResponseSchema, HighlightedDiffResponseSchema, FileExplorerResponseSchema, ProjectIconResponseSchema, FileDownloadTokenResponseSchema, - GitRepoInfoResponseSchema, ListProviderModelsResponseMessageSchema, ListCommandsResponseSchema, ExecuteCommandResponseSchema, @@ -1687,6 +1687,8 @@ export type CheckoutPrCreateRequest = z.infer; export type CheckoutPrStatusRequest = z.infer; export type CheckoutPrStatusResponse = z.infer; +export type ValidateBranchRequest = z.infer; +export type ValidateBranchResponse = z.infer; export type PaseoWorktreeListRequest = z.infer; export type PaseoWorktreeListResponse = z.infer; export type PaseoWorktreeArchiveRequest = z.infer; @@ -1700,7 +1702,6 @@ export type ProjectIconResponse = z.infer; export type ProjectIcon = z.infer; export type FileDownloadTokenRequest = z.infer; export type FileDownloadTokenResponse = z.infer; -export type GitRepoInfoResponse = z.infer; export type RestartServerRequestMessage = z.infer; export type ClearAgentAttentionMessage = z.infer; export type ClientHeartbeatMessage = z.infer; diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index ba30eaf5a..9de91341b 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -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