Update files

This commit is contained in:
Mohamed Boudra
2026-02-02 13:44:51 +07:00
parent 49b66caa63
commit 3e98900929
12 changed files with 358 additions and 190 deletions
+1 -1
View File
@@ -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}
+17 -34
View File
@@ -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);
} }
-57
View File
@@ -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> {
+22 -21
View File
@@ -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>;
+12 -3
View File
@@ -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