feat: add onboarding command and migrate git diff to checkout API

This commit is contained in:
Mohamed Boudra
2026-02-12 17:00:35 +07:00
parent 44c32fb8fb
commit 018edef3bb
39 changed files with 2038 additions and 965 deletions
@@ -1,7 +1,10 @@
import { readFileSync } from "node:fs";
import { afterEach, describe, expect, test, vi } from "vitest";
import { afterEach, describe, expect, expectTypeOf, test, vi } from "vitest";
import { DaemonClient, type DaemonTransport } from "./daemon-client";
expectTypeOf<"getGitDiff" extends keyof DaemonClient ? true : false>().toEqualTypeOf<false>();
expectTypeOf<"getHighlightedDiff" extends keyof DaemonClient ? true : false>().toEqualTypeOf<false>();
function createMockLogger() {
return {
debug: vi.fn(),
@@ -15,9 +15,7 @@ import type {
CreateAgentRequestMessage,
FileDownloadTokenResponse,
FileExplorerResponse,
GitDiffResponse,
GitSetupOptions,
HighlightedDiffResponse,
CheckoutStatusResponse,
CheckoutCommitResponse,
CheckoutMergeResponse,
@@ -178,8 +176,6 @@ export type CreateAgentRequestOptions = {
labels?: Record<string, string>;
} & AgentConfigOverrides;
type GitDiffPayload = GitDiffResponse["payload"];
type HighlightedDiffPayload = HighlightedDiffResponse["payload"];
type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
type SubscribeCheckoutDiffPayload = Extract<
SessionOutboundMessage,
@@ -1907,60 +1903,6 @@ export class DaemonClient {
});
}
async getGitDiff(
agentId: string,
requestId?: string
): Promise<GitDiffPayload> {
const resolvedRequestId = this.createRequestId(requestId);
const message = SessionInboundMessageSchema.parse({
type: "git_diff_request",
agentId,
requestId: resolvedRequestId,
});
return this.sendRequest({
requestId: resolvedRequestId,
message,
timeout: 10000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "git_diff_response") {
return null;
}
if (msg.payload.requestId !== resolvedRequestId) {
return null;
}
return msg.payload;
},
});
}
async getHighlightedDiff(
agentId: string,
requestId?: string
): Promise<HighlightedDiffPayload> {
const resolvedRequestId = this.createRequestId(requestId);
const message = SessionInboundMessageSchema.parse({
type: "highlighted_diff_request",
agentId,
requestId: resolvedRequestId,
});
return this.sendRequest({
requestId: resolvedRequestId,
message,
timeout: 10000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "highlighted_diff_response") {
return null;
}
if (msg.payload.requestId !== resolvedRequestId) {
return null;
}
return msg.payload;
},
});
}
async validateBranch(
options: { cwd: string; branchName: string },
requestId?: string
@@ -229,17 +229,6 @@ export class TTSManager {
isVoiceMode,
},
});
this.logger.info(
{
audioId,
chunkId,
chunkIndex,
isLastChunk: next.done,
bytes: chunkBuffer.length,
isVoiceMode,
},
"Emitted audio_output chunk to client"
);
chunkIndex += 1;
@@ -298,15 +287,6 @@ export class TTSManager {
}
pending.pendingChunks = Math.max(0, pending.pendingChunks - 1);
this.logger.info(
{
chunkId,
audioId,
remainingPendingChunks: pending.pendingChunks,
streamEnded: pending.streamEnded,
},
"Received audio playback confirmation from client"
);
if (pending.pendingChunks === 0 && pending.streamEnded) {
pending.resolve();
@@ -1012,63 +1012,11 @@ describe("daemon client E2E", () => {
expect(checkoutStatus.isGit).toBe(true);
expect(checkoutStatus.repoRoot).toContain(cwd);
const diffRequestId = `diff-${Date.now()}`;
const diffMessagePromise = waitForSignal(15000, (resolve) => {
const unsubscribeDiff = ctx.client.on("git_diff_response", (message) => {
if (message.type !== "git_diff_response") {
return;
}
if (message.payload.agentId !== agent.id) {
return;
}
if (message.payload.requestId !== diffRequestId) {
return;
}
resolve(message);
});
return unsubscribeDiff;
});
const diffResult = await ctx.client.getGitDiff(agent.id, diffRequestId);
const diffMessage = await diffMessagePromise;
const diffResult = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" });
expect(diffResult.error).toBeNull();
expect(diffResult.diff).toContain("test.txt");
expect(diffResult.diff).toContain("-original content");
expect(diffResult.diff).toContain("+modified content");
expect(diffResult.requestId).toBe(diffRequestId);
expect(diffMessage.payload.agentId).toBe(agent.id);
expect(diffMessage.payload.requestId).toBe(diffRequestId);
const highlightRequestId = `highlight-${Date.now()}`;
const highlightMessagePromise = waitForSignal(15000, (resolve) => {
const unsubscribeHighlight = ctx.client.on(
"highlighted_diff_response",
(message) => {
if (message.type !== "highlighted_diff_response") {
return;
}
if (message.payload.agentId !== agent.id) {
return;
}
if (message.payload.requestId !== highlightRequestId) {
return;
}
resolve(message);
}
);
return unsubscribeHighlight;
});
const highlightResult = await ctx.client.getHighlightedDiff(
agent.id,
highlightRequestId
);
const highlightMessage = await highlightMessagePromise;
expect(highlightResult.error).toBeNull();
expect(Array.isArray(highlightResult.files)).toBe(true);
expect(highlightResult.requestId).toBe(highlightRequestId);
expect(highlightMessage.payload.agentId).toBe(agent.id);
expect(highlightMessage.payload.requestId).toBe(highlightRequestId);
expect(Array.isArray(diffResult.files)).toBe(true);
expect(diffResult.files.length).toBeGreaterThan(0);
expect(diffResult.files.some((file) => file.path === "test.txt")).toBe(true);
const listRequestId = `list-${Date.now()}`;
const listMessagePromise = waitForSignal(15000, (resolve) => {
@@ -0,0 +1,101 @@
import { describe, test, expect } from "vitest";
import { execSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { performance } from "node:perf_hooks";
import { createDaemonTestContext } from "../test-utils/index.js";
const RUN = process.env.PASEO_GIT_DIFF_BOTTLENECK_E2E === "1";
const LARGE_CHANGESET_SIZE = Number.parseInt(
process.env.PASEO_GIT_DIFF_BOTTLENECK_FILE_COUNT ?? "1200",
10
);
function tmpRepo(): string {
return mkdtempSync(path.join(tmpdir(), "paseo-git-diff-bottleneck-"));
}
function initGitRepo(cwd: string): void {
execSync("git init -b main", { cwd, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" });
execSync("git config user.name 'Test'", { cwd, stdio: "pipe" });
}
function seedLargeDirtyRepo(cwd: string, fileCount: number): void {
mkdirSync(path.join(cwd, "files"), { recursive: true });
for (let i = 0; i < fileCount; i += 1) {
writeFileSync(path.join(cwd, "files", `f-${i}.txt`), `line ${i}\n`);
}
execSync("git add .", { cwd, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'init'", {
cwd,
stdio: "pipe",
});
for (let i = 0; i < fileCount; i += 1) {
writeFileSync(path.join(cwd, "files", `f-${i}.txt`), `line ${i} changed\n`);
}
// Explicit binary artifact to verify we do not diff binary contents.
writeFileSync(path.join(cwd, "blob.bin"), Buffer.from([0x00, 0xff, 0x10, 0x80, 0x00, 0x7f]));
}
const runDescribe = RUN ? describe : describe.skip;
runDescribe("daemon E2E git diff bottleneck profiling", () => {
test(
"shows per-file git diff subprocess fanout and timeout pressure",
async () => {
const cwd = tmpRepo();
try {
initGitRepo(cwd);
seedLargeDirtyRepo(cwd, LARGE_CHANGESET_SIZE);
const cliStart = performance.now();
const cliDiff = execSync("git diff HEAD", { cwd, stdio: "pipe" }).toString();
const cliMs = performance.now() - cliStart;
const ctx = await createDaemonTestContext();
try {
const checkoutStart = performance.now();
const checkoutPayload = await ctx.client.getCheckoutDiff(cwd, {
mode: "uncommitted",
});
const checkoutMs = performance.now() - checkoutStart;
expect(checkoutPayload.error).toBeNull();
expect(checkoutPayload.files.length).toBeGreaterThanOrEqual(LARGE_CHANGESET_SIZE);
const binaryEntry = checkoutPayload.files.find((file) => file.path === "blob.bin");
expect(binaryEntry).toBeTruthy();
expect(binaryEntry?.status).toBe("binary");
// Keep this visible in test output for local bottleneck analysis.
console.info(
"[git-diff-bottleneck]",
JSON.stringify(
{
fileCount: LARGE_CHANGESET_SIZE,
cliMs: Math.round(cliMs),
cliDiffBytes: cliDiff.length,
checkoutMs: Math.round(checkoutMs),
checkoutFiles: checkoutPayload.files.length,
speedRatio: Number((checkoutMs / Math.max(cliMs, 1)).toFixed(2)),
},
null,
2
)
);
expect(checkoutMs).toBeLessThan(cliMs * 10);
} finally {
await ctx.cleanup();
}
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
240000
);
});
@@ -110,7 +110,7 @@ describe("daemon E2E", () => {
await ctx.cleanup();
}, 60000);
describe("getGitDiff", () => {
describe("getCheckoutDiff", () => {
test(
"returns diff for modified file in git repo",
async () => {
@@ -134,28 +134,12 @@ describe("daemon E2E", () => {
// Modify the file (creates unstaged changes)
writeFileSync(testFile, "modified content\n");
// Create agent in the git repo
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Git Diff Test",
});
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
// Get git diff
const result = await ctx.client.getGitDiff(agent.id);
// Verify diff returned without error
const result = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" });
expect(result.error).toBeNull();
expect(result.diff).toBeTruthy();
expect(result.diff).toContain("test.txt");
expect(result.diff).toContain("-original content");
expect(result.diff).toContain("+modified content");
// Cleanup
await ctx.client.deleteAgent(agent.id);
expect(result.files.length).toBeGreaterThan(0);
const file = result.files.find((entry) => entry.path === "test.txt");
expect(file).toBeTruthy();
expect(file?.hunks.length).toBeGreaterThan(0);
rmSync(cwd, { recursive: true, force: true });
},
60000 // 1 minute timeout
@@ -181,23 +165,11 @@ describe("daemon E2E", () => {
stdio: "pipe",
});
// Create agent in the git repo (no modifications)
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Git Diff Clean Test",
});
expect(agent.id).toBeTruthy();
// Get git diff - should be empty
const result = await ctx.client.getGitDiff(agent.id);
const result = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" });
expect(result.error).toBeNull();
expect(result.diff).toBe("");
expect(result.files).toEqual([]);
// Cleanup
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
},
60000 // 1 minute timeout
@@ -209,24 +181,12 @@ describe("daemon E2E", () => {
const cwd = tmpCwd();
// Don't initialize git - just a regular directory
// Create agent in a non-git directory
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Git Diff Non-Git Test",
});
const result = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" });
expect(agent.id).toBeTruthy();
// Get git diff - should return error
const result = await ctx.client.getGitDiff(agent.id);
expect(result.diff).toBe("");
expect(result.files).toEqual([]);
expect(result.error).toBeTruthy();
expect(result.error).toContain("git");
expect(result.error?.code).toBe("NOT_GIT_REPO");
// Cleanup
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
},
60000 // 1 minute timeout
@@ -315,7 +315,7 @@ export class DictationStreamManager {
state.bytesSinceCommit += resampled.length;
state.peakSinceCommit = Math.max(state.peakSinceCommit, pcm16lePeakAbs(resampled));
try {
this.maybeAutoCommitDictationSegment(params.dictationId, state);
this.maybeAutoCommitDictationSegment(state);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
void this.failAndCleanupDictationStream(params.dictationId, message, true);
@@ -482,7 +482,7 @@ export class DictationStreamManager {
this.streams.delete(dictationId);
}
private maybeAutoCommitDictationSegment(dictationId: string, state: DictationStreamState): void {
private maybeAutoCommitDictationSegment(state: DictationStreamState): void {
if (state.finishRequested) {
return;
}
@@ -490,29 +490,12 @@ export class DictationStreamManager {
return;
}
if (state.peakSinceCommit < DICTATION_SILENCE_PEAK_THRESHOLD) {
this.logger.debug(
{
dictationId,
autoCommitBytes: state.autoCommitBytes,
bytesSinceCommit: state.bytesSinceCommit,
peakSinceCommit: state.peakSinceCommit,
},
"Dictation auto-segment: clearing silence-only segment"
);
state.stt.clear();
state.bytesSinceCommit = 0;
state.peakSinceCommit = 0;
return;
}
this.logger.debug(
{
dictationId,
autoCommitBytes: state.autoCommitBytes,
bytesSinceCommit: state.bytesSinceCommit,
},
"Dictation auto-segment: committing buffered audio"
);
state.bytesSinceCommit = 0;
state.peakSinceCommit = 0;
state.stt.commit();
+7
View File
@@ -6,6 +6,13 @@ export { createRootLogger, type LogLevel, type LogFormat } from "./logger.js";
export { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js";
export { generateLocalPairingOffer, type LocalPairingOffer } from "./pairing-offer.js";
export { DaemonClient, type DaemonClientConfig, type ConnectionState, type DaemonEvent } from "../client/daemon-client.js";
export {
ensureLocalSpeechModels,
listLocalSpeechModels,
type LocalSpeechModelId,
type LocalSttModelId,
type LocalTtsModelId,
} from "./speech/providers/local/models.js";
// Agent SDK types for CLI commands
export type {
@@ -41,6 +41,7 @@ const SpeechProviderIdSchema = z
const FeatureDictationSchema = z
.object({
enabled: z.boolean().optional(),
stt: z
.object({
provider: SpeechProviderIdSchema.optional(),
@@ -54,6 +55,7 @@ const FeatureDictationSchema = z
const FeatureVoiceModeSchema = z
.object({
enabled: z.boolean().optional(),
llm: z
.object({
provider: z.enum(AGENT_PROVIDER_IDS as [string, ...string[]]).optional(),
@@ -0,0 +1,191 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
const wsMock = vi.hoisted(() => {
class MockWebSocket {
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSING = 2;
static readonly CLOSED = 3;
static instances: MockWebSocket[] = [];
readonly url: string;
readonly options: unknown;
readyState = MockWebSocket.CONNECTING;
sent: string[] = [];
terminateCalls = 0;
private listeners = new Map<string, Array<(...args: any[]) => void>>();
constructor(url: string, options?: unknown) {
this.url = url;
this.options = options;
MockWebSocket.instances.push(this);
}
static reset() {
MockWebSocket.instances = [];
}
on(event: string, listener: (...args: any[]) => void) {
const handlers = this.listeners.get(event) ?? [];
handlers.push(listener);
this.listeners.set(event, handlers);
return this;
}
once(event: string, listener: (...args: any[]) => void) {
const wrapped = (...args: any[]) => {
this.off(event, wrapped);
listener(...args);
};
return this.on(event, wrapped);
}
close(code?: number, reason?: string) {
this.readyState = MockWebSocket.CLOSED;
this.emit("close", code ?? 1000, reason ?? "");
}
terminate() {
this.terminateCalls += 1;
this.readyState = MockWebSocket.CLOSED;
this.emit("close", 1006, "");
}
send(data: string) {
if (this.readyState !== MockWebSocket.OPEN) {
throw new Error(`WebSocket not open (readyState=${this.readyState})`);
}
this.sent.push(data);
}
open() {
this.readyState = MockWebSocket.OPEN;
this.emit("open");
}
message(data: unknown) {
this.emit("message", data);
}
error(err: unknown) {
this.emit("error", err);
}
private off(event: string, listener: (...args: any[]) => void) {
const handlers = this.listeners.get(event) ?? [];
this.listeners.set(
event,
handlers.filter((handler) => handler !== listener)
);
}
private emit(event: string, ...args: any[]) {
const handlers = this.listeners.get(event) ?? [];
for (const handler of [...handlers]) {
handler(...args);
}
}
}
return { MockWebSocket };
});
vi.mock("ws", () => ({ default: wsMock.MockWebSocket }));
import { startRelayTransport } from "./relay-transport";
function createMockLogger() {
const logger = {
child: vi.fn(() => logger),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
return logger;
}
function hasLogMessage(mockFn: ReturnType<typeof vi.fn>, message: string): boolean {
return mockFn.mock.calls.some((call) => call.some((arg) => arg === message));
}
describe("relay-transport control lifecycle", () => {
const controllers: Array<{ stop: () => Promise<void> }> = [];
const MockWebSocket = wsMock.MockWebSocket;
beforeEach(() => {
MockWebSocket.reset();
});
afterEach(async () => {
for (const controller of controllers) {
await controller.stop();
}
controllers.length = 0;
vi.useRealTimers();
});
test("logs relay_control_connected only after first valid control message", () => {
const logger = createMockLogger();
const controller = startRelayTransport({
logger: logger as any,
attachSocket: async () => {},
relayEndpoint: "relay.paseo.sh:443",
serverId: "srv_test",
});
controllers.push(controller);
const control = MockWebSocket.instances[0];
expect(control).toBeDefined();
control.open();
expect(hasLogMessage(logger.info, "relay_control_connected")).toBe(false);
expect(control.sent.length).toBeGreaterThan(0);
control.message(JSON.stringify({ type: "pong", ts: Date.now() }));
expect(hasLogMessage(logger.info, "relay_control_connected")).toBe(true);
});
test("terminates and reconnects when control socket opens but never becomes ready", () => {
vi.useFakeTimers();
const logger = createMockLogger();
const controller = startRelayTransport({
logger: logger as any,
attachSocket: async () => {},
relayEndpoint: "relay.paseo.sh:443",
serverId: "srv_test",
});
controllers.push(controller);
const firstControl = MockWebSocket.instances[0];
firstControl.open();
vi.advanceTimersByTime(8_000);
expect(hasLogMessage(logger.warn, "relay_control_ready_timeout_terminating")).toBe(true);
expect(firstControl.terminateCalls).toBe(1);
vi.advanceTimersByTime(1_000);
expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(2);
});
test("terminates stale control sockets in under one minute", () => {
vi.useFakeTimers();
const logger = createMockLogger();
const controller = startRelayTransport({
logger: logger as any,
attachSocket: async () => {},
relayEndpoint: "relay.paseo.sh:443",
serverId: "srv_test",
});
controllers.push(controller);
const control = MockWebSocket.instances[0];
control.open();
control.message(JSON.stringify({ type: "pong", ts: Date.now() }));
logger.warn.mockClear();
vi.advanceTimersByTime(40_000);
expect(hasLogMessage(logger.warn, "relay_control_stale_terminating")).toBe(true);
expect(control.terminateCalls).toBe(1);
});
});
+75 -11
View File
@@ -37,6 +37,10 @@ type ControlMessage =
| { type: "ping" }
| { type: "pong" };
const CONTROL_PING_INTERVAL_MS = 10_000;
const CONTROL_STALE_TIMEOUT_MS = 30_000;
const CONTROL_READY_TIMEOUT_MS = 8_000;
function tryParseControlMessage(raw: unknown): ControlMessage | null {
try {
const text =
@@ -76,7 +80,9 @@ export function startRelayTransport({
let reconnectAttempt = 0;
const dataSockets = new Map<string, WebSocket>(); // clientId -> ws
let controlKeepaliveInterval: ReturnType<typeof setInterval> | null = null;
let controlReadyTimeout: ReturnType<typeof setTimeout> | null = null;
let controlLastSeenAt = 0;
let controlConnectionSeq = 0;
const stop = async (): Promise<void> => {
stopped = true;
@@ -88,6 +94,10 @@ export function startRelayTransport({
clearInterval(controlKeepaliveInterval);
controlKeepaliveInterval = null;
}
if (controlReadyTimeout) {
clearTimeout(controlReadyTimeout);
controlReadyTimeout = null;
}
if (controlWs) {
try {
controlWs.close();
@@ -109,6 +119,7 @@ export function startRelayTransport({
const connectControl = (): void => {
if (stopped) return;
const connectionId = ++controlConnectionSeq;
const url = buildRelayWebSocketUrl({
endpoint: relayEndpoint,
serverId,
@@ -116,14 +127,46 @@ export function startRelayTransport({
});
const socket = new WebSocket(url, { handshakeTimeout: 10_000, perMessageDeflate: false });
controlWs = socket;
let controlConnected = false;
const markControlReady = () => {
if (controlWs !== socket) return;
if (controlConnected) return;
controlConnected = true;
reconnectAttempt = 0;
if (controlReadyTimeout) {
clearTimeout(controlReadyTimeout);
controlReadyTimeout = null;
}
relayLogger.info({ url, connectionId }, "relay_control_connected");
};
socket.on("open", () => {
reconnectAttempt = 0;
if (controlWs !== socket) return;
controlLastSeenAt = Date.now();
if (controlKeepaliveInterval) {
clearInterval(controlKeepaliveInterval);
controlKeepaliveInterval = null;
}
if (controlReadyTimeout) {
clearTimeout(controlReadyTimeout);
controlReadyTimeout = null;
}
controlReadyTimeout = setTimeout(() => {
if (stopped) return;
if (controlWs !== socket) return;
if (controlConnected) return;
relayLogger.warn(
{ url, connectionId, waitedMs: CONTROL_READY_TIMEOUT_MS },
"relay_control_ready_timeout_terminating"
);
try {
socket.terminate();
} catch {
// ignore
}
}, CONTROL_READY_TIMEOUT_MS);
controlKeepaliveInterval = setInterval(() => {
if (stopped) return;
if (controlWs !== socket) return;
@@ -133,8 +176,11 @@ export function startRelayTransport({
const staleForMs = now - controlLastSeenAt;
// If the control socket is half-open or silently dropped, ws may never emit "close".
// Use app-level ping/pong to detect staleness and force a reconnect.
if (staleForMs > 90_000) {
relayLogger.warn({ url, staleForMs }, "relay_control_stale_terminating");
if (staleForMs > CONTROL_STALE_TIMEOUT_MS) {
relayLogger.warn(
{ url, staleForMs, connectionId, staleTimeoutMs: CONTROL_STALE_TIMEOUT_MS },
"relay_control_stale_terminating"
);
try {
socket.terminate();
} catch {
@@ -146,40 +192,58 @@ export function startRelayTransport({
try {
socket.send(JSON.stringify({ type: "ping", ts: now }));
} catch (error) {
relayLogger.warn({ err: error, url }, "relay_control_ping_send_failed");
relayLogger.warn({ err: error, url, connectionId }, "relay_control_ping_send_failed");
try {
socket.terminate();
} catch {
// ignore
}
}
}, 20_000);
relayLogger.info({ url }, "relay_control_connected");
}, CONTROL_PING_INTERVAL_MS);
try {
socket.send(JSON.stringify({ type: "ping", ts: Date.now() }));
} catch (error) {
relayLogger.warn({ err: error, url, connectionId }, "relay_control_ping_send_failed");
try {
socket.terminate();
} catch {
// ignore
}
}
relayLogger.debug({ url, connectionId }, "relay_control_open_waiting_for_ready");
});
socket.on("close", (code, reason) => {
if (controlWs !== socket) return;
relayLogger.warn(
{ code, reason: reason?.toString?.(), url },
{ code, reason: reason?.toString?.(), url, connectionId },
"relay_control_disconnected"
);
if (controlWs === socket) {
controlWs = null;
}
controlWs = null;
if (controlKeepaliveInterval) {
clearInterval(controlKeepaliveInterval);
controlKeepaliveInterval = null;
}
if (controlReadyTimeout) {
clearTimeout(controlReadyTimeout);
controlReadyTimeout = null;
}
scheduleReconnect();
});
socket.on("error", (err) => {
relayLogger.warn({ err, url }, "relay_error");
if (controlWs !== socket) return;
relayLogger.warn({ err, url, connectionId }, "relay_error");
// close event will schedule reconnect
});
socket.on("message", (data) => {
if (controlWs !== socket) return;
controlLastSeenAt = Date.now();
const msg = tryParseControlMessage(data);
if (msg) {
markControlReady();
}
if (!msg) return;
if (msg.type === "ping") {
try {
+15 -315
View File
@@ -26,7 +26,6 @@ import {
type ProjectPlacementPayload,
} from "./messages.js";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import { parseAndHighlightDiff, type ParsedDiffFile } from "./utils/diff-highlighter.js";
import { TTSManager } from "./agent/tts-manager.js";
import { STTManager } from "./agent/stt-manager.js";
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js";
@@ -581,7 +580,7 @@ export class Session {
void this.initializeAgentMcp();
this.subscribeToAgentEvents();
this.sessionLogger.info("Session created");
this.sessionLogger.trace("Session created");
}
/**
@@ -750,7 +749,7 @@ export class Session {
this.agentTools = (await this.agentMcpClient.tools()) as ToolSet;
const agentToolCount = Object.keys(this.agentTools ?? {}).length;
this.sessionLogger.info(
this.sessionLogger.trace(
{ agentToolCount },
`Agent MCP initialized with ${agentToolCount} tools`
);
@@ -941,9 +940,17 @@ export class Session {
agentId,
extractTimestamps(record)
);
this.sessionLogger.info(
{ agentId, provider: record.provider },
"Agent resumed from persistence"
);
} else {
const config = buildSessionConfig(record);
snapshot = await this.agentManager.createAgent(config, agentId, { labels: record.labels });
this.sessionLogger.info(
{ agentId, provider: record.provider },
"Agent created from stored config"
);
}
await this.agentManager.hydrateTimelineFromProvider(agentId);
@@ -1215,10 +1222,6 @@ export class Session {
);
break;
case "git_diff_request":
await this.handleGitDiffRequest(msg.agentId, msg.requestId);
break;
case "checkout_status_request":
await this.handleCheckoutStatusRequest(msg);
break;
@@ -1267,10 +1270,6 @@ export class Session {
await this.handlePaseoWorktreeArchiveRequest(msg);
break;
case "highlighted_diff_request":
await this.handleHighlightedDiffRequest(msg.agentId, msg.requestId);
break;
case "file_explorer_request":
await this.handleFileExplorerRequest(msg);
break;
@@ -2087,11 +2086,6 @@ export class Session {
agentId: string,
requestId: string
): Promise<void> {
this.sessionLogger.info(
{ agentId },
`Initializing agent ${agentId} on demand`
);
try {
const snapshot = await this.ensureAgentLoaded(agentId);
await this.forwardAgentUpdate(snapshot);
@@ -2108,11 +2102,6 @@ export class Session {
requestId,
},
});
this.sessionLogger.info(
{ agentId, timelineSize, status: snapshot.lifecycle },
`Agent ${agentId} initialized with ${timelineSize} timeline item(s); status=${snapshot.lifecycle}`
);
} catch (error: any) {
this.sessionLogger.error(
{ err: error, agentId },
@@ -3393,66 +3382,6 @@ export class Session {
}
}
/**
* Handle git diff request for an agent
*/
private async handleGitDiffRequest(agentId: string, requestId: string): Promise<void> {
this.sessionLogger.debug(
{ agentId },
`Handling git diff request for agent ${agentId}`
);
try {
const agents = this.agentManager.listAgents();
const agent = agents.find((a) => a.id === agentId);
if (!agent) {
this.emit({
type: "git_diff_response",
payload: {
agentId,
diff: "",
error: `Agent not found: ${agentId}`,
requestId,
},
});
return;
}
const diffResult = await getCheckoutDiff(agent.cwd, { mode: "uncommitted" }, { paseoHome: this.paseoHome });
const combinedDiff = diffResult.diff;
this.emit({
type: "git_diff_response",
payload: {
agentId,
diff: combinedDiff,
error: null,
requestId,
},
});
this.sessionLogger.debug(
{ agentId, diffBytes: combinedDiff.length },
`Git diff for agent ${agentId} completed (${combinedDiff.length} bytes)`
);
} catch (error: any) {
this.sessionLogger.error(
{ err: error, agentId },
`Failed to get git diff for agent ${agentId}`
);
this.emit({
type: "git_diff_response",
payload: {
agentId,
diff: "",
error: error.message,
requestId,
},
});
}
}
private async handleCheckoutStatusRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_status_request" }>
): Promise<void> {
@@ -4190,12 +4119,13 @@ export class Session {
const { cwd, requestId } = msg;
try {
const status = await getPullRequestStatus(cwd);
const prStatus = await getPullRequestStatus(cwd);
this.emit({
type: "checkout_pr_status_response",
payload: {
cwd,
status,
status: prStatus.status,
githubFeaturesEnabled: prStatus.githubFeaturesEnabled,
error: null,
requestId,
},
@@ -4206,6 +4136,7 @@ export class Session {
payload: {
cwd,
status: null,
githubFeaturesEnabled: true,
error: this.toCheckoutError(error),
requestId,
},
@@ -4373,237 +4304,6 @@ export class Session {
}
}
/**
* Handle highlighted diff request - returns parsed and syntax-highlighted diff
*/
private async handleHighlightedDiffRequest(
agentId: string,
requestId: string
): Promise<void> {
this.sessionLogger.debug(
{ agentId },
`Handling highlighted diff request for agent ${agentId}`
);
// Maximum lines changed before we skip showing the diff content
const MAX_DIFF_LINES = 5000;
try {
const agents = this.agentManager.listAgents();
const agent = agents.find((a) => a.id === agentId);
if (!agent) {
this.emit({
type: "highlighted_diff_response",
payload: {
agentId,
files: [],
error: `Agent not found: ${agentId}`,
requestId,
},
});
return;
}
// Step 1: Get the list of changed files with their stats (numstat gives additions/deletions per file)
const { stdout: numstatOutput } = await execAsync(
"git diff --numstat HEAD",
{ cwd: agent.cwd }
);
// Get file statuses (A=added, D=deleted, M=modified) to detect deleted files
const { stdout: nameStatusOutput } = await execAsync(
"git diff --name-status HEAD",
{ cwd: agent.cwd }
);
const deletedFiles = new Set<string>();
const addedFiles = new Set<string>();
for (const line of nameStatusOutput.trim().split("\n").filter(Boolean)) {
const [status, ...pathParts] = line.split("\t");
const path = pathParts.join("\t");
if (status === "D") {
deletedFiles.add(path);
} else if (status === "A") {
addedFiles.add(path);
}
}
// Parse numstat output: "additions\tdeletions\tfilepath" or "-\t-\tfilepath" for binary
interface FileStats {
path: string;
additions: number;
deletions: number;
isBinary: boolean;
isTracked: boolean;
isDeleted: boolean;
isNew: boolean;
}
const fileStats: FileStats[] = [];
for (const line of numstatOutput.trim().split("\n").filter(Boolean)) {
const parts = line.split("\t");
if (parts.length >= 3) {
const [addStr, delStr, ...pathParts] = parts;
const path = pathParts.join("\t"); // Handle paths with tabs
const isBinary = addStr === "-" && delStr === "-";
fileStats.push({
path,
additions: isBinary ? 0 : parseInt(addStr, 10),
deletions: isBinary ? 0 : parseInt(delStr, 10),
isBinary,
isTracked: true,
isDeleted: deletedFiles.has(path),
isNew: addedFiles.has(path),
});
}
}
// Step 2: Get untracked files
try {
const { stdout: untrackedFiles } = await execAsync(
"git ls-files --others --exclude-standard",
{ cwd: agent.cwd }
);
for (const filePath of untrackedFiles.trim().split("\n").filter(Boolean)) {
// Use git's numstat with --no-index to detect binary files (cross-platform)
// Binary files show as "-\t-\tfilepath", text files show line counts
try {
const { stdout: numstatLine } = await execAsync(
`git diff --numstat --no-index /dev/null "${filePath}" || true`,
{ cwd: agent.cwd }
);
const parts = numstatLine.trim().split("\t");
const isBinary = parts[0] === "-" && parts[1] === "-";
const additions = isBinary ? 0 : (parseInt(parts[0], 10) || 0);
fileStats.push({
path: filePath,
additions,
deletions: 0,
isBinary,
isTracked: false,
isDeleted: false,
isNew: true,
});
} catch {
// If we can't determine, assume text and try to get it
fileStats.push({
path: filePath,
additions: 0,
deletions: 0,
isBinary: false,
isTracked: false,
isDeleted: false,
isNew: true,
});
}
}
} catch {
// Ignore errors getting untracked files
}
// Step 3: Fetch diffs per-file, respecting limits
const allFiles: ParsedDiffFile[] = [];
for (const stats of fileStats) {
const totalLines = stats.additions + stats.deletions;
// Handle binary files
if (stats.isBinary) {
allFiles.push({
path: stats.path,
isNew: stats.isNew,
isDeleted: stats.isDeleted,
additions: 0,
deletions: 0,
hunks: [],
status: "binary",
});
continue;
}
// Handle files that are too large
if (totalLines > MAX_DIFF_LINES) {
allFiles.push({
path: stats.path,
isNew: stats.isNew,
isDeleted: stats.isDeleted,
additions: stats.additions,
deletions: stats.deletions,
hunks: [],
status: "too_large",
});
continue;
}
// Fetch the actual diff for this file
try {
let fileDiff: string;
if (stats.isTracked) {
const { stdout } = await execAsync(
`git diff HEAD -- "${stats.path}"`,
{ cwd: agent.cwd }
);
fileDiff = stdout;
} else {
const { stdout } = await execAsync(
`git diff --no-index /dev/null "${stats.path}" || true`,
{ cwd: agent.cwd }
);
fileDiff = stdout;
}
if (fileDiff) {
const parsedFiles = await parseAndHighlightDiff(fileDiff, agent.cwd);
for (const file of parsedFiles) {
allFiles.push({ ...file, status: "ok" });
}
}
} catch {
// If diff fails for this file, add it with empty hunks
allFiles.push({
path: stats.path,
isNew: stats.isNew,
isDeleted: stats.isDeleted,
additions: stats.additions,
deletions: stats.deletions,
hunks: [],
status: "ok",
});
}
}
this.emit({
type: "highlighted_diff_response",
payload: {
agentId,
files: allFiles,
error: null,
requestId,
},
});
this.sessionLogger.debug(
{ agentId, fileCount: allFiles.length },
`Highlighted diff for agent ${agentId} completed (${allFiles.length} files)`
);
} catch (error: any) {
this.sessionLogger.error(
{ err: error, agentId },
`Failed to get highlighted diff for agent ${agentId}`
);
this.emit({
type: "highlighted_diff_response",
payload: {
agentId,
files: [],
error: error.message,
requestId,
},
});
}
}
/**
* Handle read-only file explorer requests scoped to an agent's cwd
*/
@@ -5871,7 +5571,7 @@ export class Session {
* Clean up session resources
*/
public async cleanup(): Promise<void> {
this.sessionLogger.info("Cleaning up");
this.sessionLogger.trace("Cleaning up");
if (this.unsubscribeAgentEvents) {
this.unsubscribeAgentEvents();
@@ -73,9 +73,10 @@ const LocalSpeechResolutionSchema = z.object({
function persistedLocalFeatureModel(
provider: RequestedSpeechProviders[keyof RequestedSpeechProviders]["provider"],
enabled: boolean | undefined,
model: string | undefined
): string | undefined {
if (provider !== "local") {
if (provider !== "local" || enabled === false) {
return undefined;
}
return model;
@@ -87,9 +88,12 @@ function shouldIncludeLocalProviderConfig(params: {
persisted: PersistedConfig;
}): boolean {
const localRequestedByFeature =
params.providers.dictationStt.provider === "local" ||
params.providers.voiceStt.provider === "local" ||
params.providers.voiceTts.provider === "local";
(params.providers.dictationStt.enabled !== false &&
params.providers.dictationStt.provider === "local") ||
(params.providers.voiceStt.enabled !== false &&
params.providers.voiceStt.provider === "local") ||
(params.providers.voiceTts.enabled !== false &&
params.providers.voiceTts.provider === "local");
return (
localRequestedByFeature ||
@@ -119,6 +123,7 @@ export function resolveLocalSpeechConfig(params: {
params.env.PASEO_DICTATION_LOCAL_STT_MODEL ??
persistedLocalFeatureModel(
params.providers.dictationStt.provider,
params.providers.dictationStt.enabled,
params.persisted.features?.dictation?.stt?.model
) ??
DEFAULT_LOCAL_STT_MODEL,
@@ -126,6 +131,7 @@ export function resolveLocalSpeechConfig(params: {
params.env.PASEO_VOICE_LOCAL_STT_MODEL ??
persistedLocalFeatureModel(
params.providers.voiceStt.provider,
params.providers.voiceStt.enabled,
params.persisted.features?.voiceMode?.stt?.model
) ??
DEFAULT_LOCAL_STT_MODEL,
@@ -133,6 +139,7 @@ export function resolveLocalSpeechConfig(params: {
params.env.PASEO_VOICE_LOCAL_TTS_MODEL ??
persistedLocalFeatureModel(
params.providers.voiceTts.provider,
params.providers.voiceTts.enabled,
params.persisted.features?.voiceMode?.tts?.model
) ??
DEFAULT_LOCAL_TTS_MODEL,
@@ -88,13 +88,22 @@ function computeRequiredLocalModelIds(params: {
models: ResolvedLocalModels;
}): LocalSpeechModelId[] {
const ids = new Set<LocalSpeechModelId>();
if (params.providers.dictationStt.provider === "local") {
if (
params.providers.dictationStt.enabled !== false &&
params.providers.dictationStt.provider === "local"
) {
ids.add(params.models.dictationLocalSttModel);
}
if (params.providers.voiceStt.provider === "local") {
if (
params.providers.voiceStt.enabled !== false &&
params.providers.voiceStt.provider === "local"
) {
ids.add(params.models.voiceLocalSttModel);
}
if (params.providers.voiceTts.provider === "local") {
if (
params.providers.voiceTts.enabled !== false &&
params.providers.voiceTts.provider === "local"
) {
ids.add(params.models.voiceLocalTtsModel);
}
return Array.from(ids);
@@ -258,7 +267,7 @@ export async function initializeLocalSpeechServices(params: {
}
};
if (providers.voiceStt.provider === "local") {
if (providers.voiceStt.enabled !== false && providers.voiceStt.provider === "local") {
if (!localConfig) {
logger.warn(
{ configured: false },
@@ -274,7 +283,7 @@ export async function initializeLocalSpeechServices(params: {
}
}
if (providers.dictationStt.provider === "local") {
if (providers.dictationStt.enabled !== false && providers.dictationStt.provider === "local") {
if (!localConfig) {
logger.warn(
{ configured: false },
@@ -297,7 +306,7 @@ export async function initializeLocalSpeechServices(params: {
}
}
if (providers.voiceTts.provider === "local") {
if (providers.voiceTts.enabled !== false && providers.voiceTts.provider === "local") {
if (!localConfig) {
logger.warn(
{ configured: false },
@@ -1,4 +1,4 @@
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -53,4 +53,46 @@ describe("sherpa model downloader", () => {
})
).rejects.toThrow(/auto-download/i);
});
test("ensureSherpaOnnxModel logs artifact download progress", async () => {
const modelsDir = makeTmpDir();
const progressLogs: Array<Record<string, unknown>> = [];
const loggerWithSpy = {
child: () => loggerWithSpy,
info: (obj?: unknown, msg?: string) => {
if (msg === "Downloading model artifact" && obj && typeof obj === "object") {
progressLogs.push(obj as Record<string, unknown>);
}
},
error: () => undefined,
} as unknown as pino.Logger;
const originalFetch = globalThis.fetch;
const payload = Buffer.alloc(128 * 1024, 7);
const fetchMock = vi.fn(async () => {
return new Response(payload, {
status: 200,
headers: { "content-length": String(payload.length) },
});
});
globalThis.fetch = fetchMock as typeof fetch;
try {
await ensureSherpaOnnxModel({
modelsDir,
modelId: "pocket-tts-onnx-int8",
autoDownload: true,
logger: loggerWithSpy,
});
} finally {
globalThis.fetch = originalFetch;
}
expect(fetchMock).toHaveBeenCalled();
expect(progressLogs.length).toBeGreaterThan(0);
const final = progressLogs.at(-1);
expect(final?.modelId).toBe("pocket-tts-onnx-int8");
expect(final?.pct).toBe(100);
});
});
@@ -1,7 +1,7 @@
import { createWriteStream } from "node:fs";
import { mkdir, rename, rm, stat } from "node:fs/promises";
import path from "node:path";
import { Readable } from "node:stream";
import { Readable, Transform } from "node:stream";
import { pipeline } from "node:stream/promises";
import { spawn } from "node:child_process";
import type pino from "pino";
@@ -39,7 +39,31 @@ async function hasRequiredFiles(modelDir: string, requiredFiles: string[]): Prom
return true;
}
async function downloadToFile(url: string, outputPath: string, logger: pino.Logger): Promise<void> {
const UNKNOWN_SIZE_PROGRESS_BYTES_STEP = 5 * 1024 * 1024;
const UNKNOWN_SIZE_PROGRESS_MS_STEP = 1000;
type DownloadToFileOptions = {
url: string;
outputPath: string;
logger: pino.Logger;
modelId: SherpaOnnxModelId;
artifact: string;
};
function parseContentLength(res: Response): number | null {
const raw = res.headers.get("content-length");
if (!raw) {
return null;
}
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return parsed;
}
async function downloadToFile(options: DownloadToFileOptions): Promise<void> {
const { url, outputPath, logger, modelId, artifact } = options;
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`);
@@ -51,24 +75,68 @@ async function downloadToFile(url: string, outputPath: string, logger: pino.Logg
const tmpPath = `${outputPath}.tmp-${Date.now()}`;
await mkdir(path.dirname(outputPath), { recursive: true });
const total = Number(res.headers.get("content-length") ?? "0");
let downloaded = 0;
let lastLoggedBucket = -1;
const nodeStream = Readable.fromWeb(res.body as any);
const totalBytes = parseContentLength(res);
const nodeStream = Readable.fromWeb(res.body as any).on("data", (chunk: Buffer) => {
downloaded += chunk.length;
if (total > 0) {
const pct = Math.floor((downloaded / total) * 100);
const bucket = Math.min(100, Math.floor(pct / 10) * 10);
if (bucket >= 0 && bucket <= 100 && bucket !== lastLoggedBucket) {
lastLoggedBucket = bucket;
logger.info({ pct: bucket, downloaded, total }, "Downloading model artifact");
let downloadedBytes = 0;
let lastLoggedPct = -1;
let lastLoggedBytes = 0;
let lastLoggedAt = 0;
const logProgress = (force: boolean): void => {
const pct =
totalBytes && totalBytes > 0
? Math.min(100, Math.floor((downloadedBytes * 100) / totalBytes))
: null;
if (!force) {
if (pct !== null) {
if (pct <= lastLoggedPct) {
return;
}
} else {
const now = Date.now();
const advancedBytes = downloadedBytes - lastLoggedBytes;
if (advancedBytes < UNKNOWN_SIZE_PROGRESS_BYTES_STEP && now - lastLoggedAt < UNKNOWN_SIZE_PROGRESS_MS_STEP) {
return;
}
}
}
logger.info(
{
modelId,
artifact,
url,
downloadedBytes,
totalBytes,
pct,
},
"Downloading model artifact"
);
lastLoggedPct = pct ?? -1;
lastLoggedBytes = downloadedBytes;
lastLoggedAt = Date.now();
};
logProgress(false);
const progressStream = new Transform({
transform(chunk: Buffer, _encoding, callback) {
downloadedBytes += chunk.length;
logProgress(false);
callback(null, chunk);
},
});
await pipeline(nodeStream, createWriteStream(tmpPath));
await rename(tmpPath, outputPath);
try {
await pipeline(nodeStream, progressStream, createWriteStream(tmpPath));
logProgress(true);
await rename(tmpPath, outputPath);
} catch (error) {
await rm(tmpPath, { force: true }).catch(() => undefined);
throw error;
}
}
async function extractTarArchive(archivePath: string, destDir: string): Promise<void> {
@@ -114,60 +182,103 @@ export async function ensureSherpaOnnxModel(options: EnsureSherpaOnnxModelOption
);
}
if (spec.archiveUrl) {
logger.info({ modelsDir: options.modelsDir, url: spec.archiveUrl }, "Model files missing; downloading");
logger.info({ modelsDir: options.modelsDir }, "Starting model download");
const downloadsDir = path.join(options.modelsDir, ".downloads");
const archiveFilename = path.basename(new URL(spec.archiveUrl).pathname);
const archivePath = path.join(downloadsDir, archiveFilename);
try {
if (spec.archiveUrl) {
const downloadsDir = path.join(options.modelsDir, ".downloads");
const archiveFilename = path.basename(new URL(spec.archiveUrl).pathname);
const archivePath = path.join(downloadsDir, archiveFilename);
if (!(await isNonEmptyFile(archivePath))) {
await downloadToFile(spec.archiveUrl, archivePath, logger);
} else {
logger.info({ archivePath }, "Using cached archive");
}
await extractTarArchive(archivePath, options.modelsDir);
if (!(await hasRequiredFiles(modelDir, spec.requiredFiles))) {
throw new Error(
`Downloaded and extracted ${archiveFilename}, but required files are still missing in ${modelDir}.`
);
}
try {
await rm(archivePath, { force: true });
} catch {
// ignore
}
logger.info({ modelDir }, "Model ready");
return modelDir;
}
if (spec.downloadFiles && spec.downloadFiles.length > 0) {
logger.info({ modelsDir: options.modelsDir, fileCount: spec.downloadFiles.length }, "Model files missing; downloading");
await mkdir(modelDir, { recursive: true });
for (const file of spec.downloadFiles) {
const dst = path.join(modelDir, file.relPath);
if (await isNonEmptyFile(dst)) {
continue;
if (!(await isNonEmptyFile(archivePath))) {
await downloadToFile({
url: spec.archiveUrl,
outputPath: archivePath,
logger,
modelId: options.modelId,
artifact: archiveFilename,
});
}
await downloadToFile(file.url, dst, logger);
}
if (!(await hasRequiredFiles(modelDir, spec.requiredFiles))) {
throw new Error(
`Downloaded files for ${options.modelId}, but required files are still missing in ${modelDir}.`
logger.info(
{
modelId: options.modelId,
archivePath,
modelDir,
},
"Extracting model archive"
);
await extractTarArchive(archivePath, options.modelsDir);
logger.info(
{
modelId: options.modelId,
modelDir,
},
"Verifying downloaded model files"
);
if (!(await hasRequiredFiles(modelDir, spec.requiredFiles))) {
throw new Error(
`Downloaded and extracted ${archiveFilename}, but required files are still missing in ${modelDir}.`
);
}
logger.info(
{
modelId: options.modelId,
archivePath,
},
"Finalizing model artifacts"
);
try {
await rm(archivePath, { force: true });
} catch {
// ignore
}
logger.info({ modelDir }, "Model download completed");
return modelDir;
}
logger.info({ modelDir }, "Model ready");
return modelDir;
}
if (spec.downloadFiles && spec.downloadFiles.length > 0) {
await mkdir(modelDir, { recursive: true });
throw new Error(`Model spec for ${options.modelId} has no archiveUrl or downloadFiles`);
for (const file of spec.downloadFiles) {
const dst = path.join(modelDir, file.relPath);
if (await isNonEmptyFile(dst)) {
continue;
}
await downloadToFile({
url: file.url,
outputPath: dst,
logger,
modelId: options.modelId,
artifact: file.relPath,
});
}
logger.info(
{
modelId: options.modelId,
modelDir,
},
"Verifying downloaded model files"
);
if (!(await hasRequiredFiles(modelDir, spec.requiredFiles))) {
throw new Error(
`Downloaded files for ${options.modelId}, but required files are still missing in ${modelDir}.`
);
}
logger.info({ modelDir }, "Model download completed");
return modelDir;
}
throw new Error(`Model spec for ${options.modelId} has no archiveUrl or downloadFiles`);
} catch (error) {
logger.error({ err: error }, "Model download failed");
throw error;
}
}
export async function ensureSherpaOnnxModels(options: {
@@ -74,27 +74,32 @@ export function resolveOpenAiSpeechConfig(params: {
params.persisted.features?.dictation?.stt?.confidenceThreshold,
sttModel:
params.env.STT_MODEL ??
(params.providers.voiceStt.provider === "openai"
(params.providers.voiceStt.enabled !== false &&
params.providers.voiceStt.provider === "openai"
? params.persisted.features?.voiceMode?.stt?.model
: undefined) ??
(params.providers.dictationStt.provider === "openai"
(params.providers.dictationStt.enabled !== false &&
params.providers.dictationStt.provider === "openai"
? params.persisted.features?.dictation?.stt?.model
: undefined),
ttsVoice:
params.env.TTS_VOICE ??
(params.providers.voiceTts.provider === "openai"
(params.providers.voiceTts.enabled !== false &&
params.providers.voiceTts.provider === "openai"
? params.persisted.features?.voiceMode?.tts?.voice
: undefined) ??
"alloy",
ttsModel:
params.env.TTS_MODEL ??
(params.providers.voiceTts.provider === "openai"
(params.providers.voiceTts.enabled !== false &&
params.providers.voiceTts.provider === "openai"
? params.persisted.features?.voiceMode?.tts?.model
: undefined) ??
DEFAULT_OPENAI_TTS_MODEL,
realtimeTranscriptionModel:
params.env.OPENAI_REALTIME_TRANSCRIPTION_MODEL ??
(params.providers.dictationStt.provider === "openai"
(params.providers.dictationStt.enabled !== false &&
params.providers.dictationStt.provider === "openai"
? params.persisted.features?.dictation?.stt?.model
: undefined) ??
DEFAULT_OPENAI_REALTIME_TRANSCRIPTION_MODEL,
@@ -60,13 +60,22 @@ export function validateOpenAiCredentialRequirements(params: {
const openAiCredentials = resolveOpenAiCredentials(openaiConfig);
const missingOpenAiCredentialsFor: string[] = [];
if (providers.voiceStt.provider === "openai" && !openAiCredentials.openaiSttApiKey) {
if (
providers.voiceStt.enabled !== false &&
providers.voiceStt.provider === "openai" &&
!openAiCredentials.openaiSttApiKey
) {
missingOpenAiCredentialsFor.push("voice.stt");
}
if (providers.voiceTts.provider === "openai" && !openAiCredentials.openaiTtsApiKey) {
if (
providers.voiceTts.enabled !== false &&
providers.voiceTts.provider === "openai" &&
!openAiCredentials.openaiTtsApiKey
) {
missingOpenAiCredentialsFor.push("voice.tts");
}
if (
providers.dictationStt.enabled !== false &&
providers.dictationStt.provider === "openai" &&
!openAiCredentials.openaiDictationApiKey
) {
@@ -104,10 +113,18 @@ export function initializeOpenAiSpeechServices(params: {
let ttsService = existing.ttsService;
let dictationSttService = existing.dictationSttService;
const needsOpenAiStt = !sttService && providers.voiceStt.provider === "openai";
const needsOpenAiTts = !ttsService && providers.voiceTts.provider === "openai";
const needsOpenAiStt =
!sttService &&
providers.voiceStt.enabled !== false &&
providers.voiceStt.provider === "openai";
const needsOpenAiTts =
!ttsService &&
providers.voiceTts.enabled !== false &&
providers.voiceTts.provider === "openai";
const needsOpenAiDictation =
!dictationSttService && providers.dictationStt.provider === "openai";
!dictationSttService &&
providers.dictationStt.enabled !== false &&
providers.dictationStt.provider === "openai";
if (
(needsOpenAiStt || needsOpenAiTts || needsOpenAiDictation) &&
@@ -21,14 +21,17 @@ describe("resolveSpeechConfig", () => {
expect(result.speech.providers.dictationStt).toEqual({
provider: "local",
explicit: false,
enabled: true,
});
expect(result.speech.providers.voiceStt).toEqual({
provider: "local",
explicit: false,
enabled: true,
});
expect(result.speech.providers.voiceTts).toEqual({
provider: "local",
explicit: false,
enabled: true,
});
expect(result.speech.local).toEqual({
modelsDir: path.join(paseoHome, "models", "local-speech"),
@@ -91,14 +94,17 @@ describe("resolveSpeechConfig", () => {
expect(result.speech.providers.dictationStt).toEqual({
provider: "local",
explicit: true,
enabled: true,
});
expect(result.speech.providers.voiceStt).toEqual({
provider: "openai",
explicit: true,
enabled: true,
});
expect(result.speech.providers.voiceTts).toEqual({
provider: "local",
explicit: true,
enabled: true,
});
expect(result.speech.local?.models.dictationStt).toBe("zipformer-bilingual-zh-en-2023-02-20");
expect(result.speech.local?.models.voiceStt).toBe("parakeet-tdt-0.6b-v3-int8");
@@ -127,4 +133,35 @@ describe("resolveSpeechConfig", () => {
expect(result.speech.local?.models.voiceTts).toBe("kokoro-en-v0_19");
expect(result.speech.local?.models.voiceTtsSpeakerId).toBe(0);
});
test("respects disabled dictation and voice mode feature flags", () => {
const persisted = PersistedConfigSchema.parse({
features: {
dictation: { enabled: false },
voiceMode: { enabled: false },
},
});
const result = resolveSpeechConfig({
paseoHome: "/tmp/paseo-home",
env: {} as NodeJS.ProcessEnv,
persisted,
});
expect(result.speech.providers.dictationStt).toEqual({
provider: "local",
explicit: false,
enabled: false,
});
expect(result.speech.providers.voiceStt).toEqual({
provider: "local",
explicit: false,
enabled: false,
});
expect(result.speech.providers.voiceTts).toEqual({
provider: "local",
explicit: false,
enabled: false,
});
});
});
@@ -17,6 +17,25 @@ const OptionalSpeechProviderSchema = z
.pipe(SpeechProviderIdSchema)
.optional();
const OptionalBooleanFlagSchema = z
.union([z.boolean(), z.string().trim().toLowerCase()])
.optional()
.transform((value) => {
if (typeof value === "boolean") {
return value;
}
if (value === undefined) {
return undefined;
}
if (["1", "true", "yes", "y", "on"].includes(value)) {
return true;
}
if (["0", "false", "no", "n", "off"].includes(value)) {
return false;
}
return undefined;
});
const RequestedSpeechProvidersSchema = z.object({
dictationStt: OptionalSpeechProviderSchema.default("local"),
voiceStt: OptionalSpeechProviderSchema.default("local"),
@@ -29,10 +48,12 @@ function resolveRequestedSpeechProviders(params: {
}): RequestedSpeechProviders {
const resolveFeatureProvider = (
configuredValue: string | undefined,
parsedValue: z.infer<typeof SpeechProviderIdSchema>
parsedValue: z.infer<typeof SpeechProviderIdSchema>,
enabled: boolean
): RequestedSpeechProvider => ({
provider: parsedValue,
explicit: configuredValue !== undefined,
enabled,
});
const dictationSttProviderFromConfig =
@@ -44,6 +65,14 @@ function resolveRequestedSpeechProviders(params: {
const voiceTtsProviderFromConfig =
params.env.PASEO_VOICE_TTS_PROVIDER ??
params.persisted.features?.voiceMode?.tts?.provider;
const dictationEnabled =
OptionalBooleanFlagSchema.parse(
params.env.PASEO_DICTATION_ENABLED ?? params.persisted.features?.dictation?.enabled
) ?? true;
const voiceModeEnabled =
OptionalBooleanFlagSchema.parse(
params.env.PASEO_VOICE_MODE_ENABLED ?? params.persisted.features?.voiceMode?.enabled
) ?? true;
const parsed = RequestedSpeechProvidersSchema.parse({
dictationStt: dictationSttProviderFromConfig ?? "local",
@@ -54,15 +83,18 @@ function resolveRequestedSpeechProviders(params: {
return {
dictationStt: resolveFeatureProvider(
dictationSttProviderFromConfig,
parsed.dictationStt
parsed.dictationStt,
dictationEnabled
),
voiceStt: resolveFeatureProvider(
voiceSttProviderFromConfig,
parsed.voiceStt
parsed.voiceStt,
voiceModeEnabled
),
voiceTts: resolveFeatureProvider(
voiceTtsProviderFromConfig,
parsed.voiceTts
parsed.voiceTts,
voiceModeEnabled
),
};
}
@@ -23,9 +23,9 @@ function resolveRequestedSpeechProviders(
}
return {
dictationStt: { provider: "local", explicit: false },
voiceStt: { provider: "local", explicit: false },
voiceTts: { provider: "local", explicit: false },
dictationStt: { provider: "local", explicit: false, enabled: true },
voiceStt: { provider: "local", explicit: false, enabled: true },
voiceTts: { provider: "local", explicit: false, enabled: true },
};
}
@@ -59,9 +59,18 @@ export async function initializeSpeechRuntime(params: {
logger.info(
{
requestedProviders: {
dictationStt: providers.dictationStt.provider,
voiceStt: providers.voiceStt.provider,
voiceTts: providers.voiceTts.provider,
dictationStt: {
provider: providers.dictationStt.provider,
enabled: providers.dictationStt.enabled !== false,
},
voiceStt: {
provider: providers.voiceStt.provider,
enabled: providers.voiceStt.enabled !== false,
},
voiceTts: {
provider: providers.voiceTts.provider,
enabled: providers.voiceTts.enabled !== false,
},
},
availability: {
openai: getOpenAiSpeechAvailability(openaiConfig),
@@ -99,9 +108,11 @@ export async function initializeSpeechRuntime(params: {
: "openai",
};
const unavailableFeatures = [
!openAiSpeech.dictationSttService ? "dictation.stt" : null,
!openAiSpeech.sttService ? "voice.stt" : null,
!openAiSpeech.ttsService ? "voice.tts" : null,
providers.dictationStt.enabled !== false && !openAiSpeech.dictationSttService
? "dictation.stt"
: null,
providers.voiceStt.enabled !== false && !openAiSpeech.sttService ? "voice.stt" : null,
providers.voiceTts.enabled !== false && !openAiSpeech.ttsService ? "voice.tts" : null,
].filter((feature): feature is string => feature !== null);
const explicitlyConfiguredUnavailableFeatures = unavailableFeatures.filter((feature) => {
if (feature === "dictation.stt") {
@@ -117,9 +128,18 @@ export async function initializeSpeechRuntime(params: {
logger.error(
{
requestedProviders: {
dictationStt: providers.dictationStt.provider,
voiceStt: providers.voiceStt.provider,
voiceTts: providers.voiceTts.provider,
dictationStt: {
provider: providers.dictationStt.provider,
enabled: providers.dictationStt.enabled !== false,
},
voiceStt: {
provider: providers.voiceStt.provider,
enabled: providers.voiceStt.enabled !== false,
},
voiceTts: {
provider: providers.voiceTts.provider,
enabled: providers.voiceTts.enabled !== false,
},
},
explicitProviders: {
dictationStt: providers.dictationStt.explicit,
@@ -140,9 +160,18 @@ export async function initializeSpeechRuntime(params: {
logger.warn(
{
requestedProviders: {
dictationStt: providers.dictationStt.provider,
voiceStt: providers.voiceStt.provider,
voiceTts: providers.voiceTts.provider,
dictationStt: {
provider: providers.dictationStt.provider,
enabled: providers.dictationStt.enabled !== false,
},
voiceStt: {
provider: providers.voiceStt.provider,
enabled: providers.voiceStt.enabled !== false,
},
voiceTts: {
provider: providers.voiceTts.provider,
enabled: providers.voiceTts.enabled !== false,
},
},
explicitProviders: {
dictationStt: providers.dictationStt.explicit,
@@ -6,6 +6,7 @@ export type SpeechProviderId = z.infer<typeof SpeechProviderIdSchema>;
export const RequestedSpeechProviderSchema = z.object({
provider: SpeechProviderIdSchema,
explicit: z.boolean(),
enabled: z.boolean().optional(),
});
export type RequestedSpeechProvider = z.infer<typeof RequestedSpeechProviderSchema>;
+2 -56
View File
@@ -279,7 +279,7 @@ export class VoiceAssistantWebSocketServer {
})
);
connectionLogger.info(
connectionLogger.trace(
{ clientId, totalSessions: this.sessions.size },
"Client connected"
);
@@ -319,7 +319,7 @@ export class VoiceAssistantWebSocketServer {
const session = this.sessions.get(ws);
if (!session) return;
connectionLogger.info(
connectionLogger.trace(
{ clientId, totalSessions: this.sessions.size - 1 },
"Client disconnected"
);
@@ -386,28 +386,12 @@ export class VoiceAssistantWebSocketServer {
const message = parsedMessage.data;
const messageSummary = {
type: message.type,
...(message.type === "session" && message.message
? { sessionMessageType: message.message.type }
: {}),
};
const isSessionNoise =
message.type === "session" &&
(message.message.type === "client_heartbeat" ||
message.message.type === "voice_audio_chunk" ||
message.message.type === "dictation_stream_chunk");
if (!isSessionNoise) {
this.logger.debug(messageSummary, "Received message");
}
if (message.type === "ping") {
this.sendToClient(ws, { type: "pong" });
return;
}
if (message.type === "recording_state") {
this.logger.debug({ isRecording: message.isRecording }, "Recording state");
return;
}
@@ -418,17 +402,6 @@ export class VoiceAssistantWebSocketServer {
}
if (message.type === "session") {
if (message.message.type === "create_agent_request") {
this.logger.debug(
{
cwd: message.message.config.cwd,
initialMode: message.message.config.modeId,
worktreeName: message.message.worktreeName,
requestId: message.message.requestId,
},
"create_agent_request details"
);
}
await session.handleMessage(message.message);
}
} catch (error) {
@@ -502,23 +475,11 @@ export class VoiceAssistantWebSocketServer {
} {
const activity = session.getClientActivity();
if (!activity) {
this.logger.debug("getClientActivityState: no activity for session");
return { deviceType: null, focusedAgentId: null, isStale: true, appVisible: false };
}
const now = Date.now();
const ageMs = now - activity.lastActivityAt.getTime();
const isStale = ageMs >= this.ACTIVITY_THRESHOLD_MS;
this.logger.debug(
{
deviceType: activity.deviceType,
focusedAgentId: activity.focusedAgentId,
lastActivityAt: activity.lastActivityAt.toISOString(),
ageMs,
isStale,
appVisible: activity.appVisible,
},
"getClientActivityState"
);
return {
deviceType: activity.deviceType,
focusedAgentId: activity.focusedAgentId,
@@ -603,16 +564,6 @@ export class VoiceAssistantWebSocketServer {
const allStates = clientEntries.map((e) => e.state);
this.logger.debug(
{
agentId: params.agentId,
reason: params.reason,
clientCount: clientEntries.length,
allStates,
},
"broadcastAgentAttention"
);
const hasActiveWebClient = allStates.some(
(state) => state.deviceType === "web" && !state.isStale
);
@@ -627,11 +578,6 @@ export class VoiceAssistantWebSocketServer {
!hasActiveWebClient &&
!hasActiveMobileForegroundClient;
this.logger.debug(
{ hasActiveWebClient, hasActiveMobileForegroundClient, shouldSendPush },
"Push gating check"
);
if (shouldSendPush) {
const tokens = this.pushTokenStore.getAllTokens();
this.logger.info({ tokenCount: tokens.length }, "Sending push notification");
@@ -4,6 +4,8 @@ import { describe, expect, it } from "vitest";
import {
AgentStreamMessageSchema,
AgentStreamSnapshotMessageSchema,
SessionInboundMessageSchema,
SessionOutboundMessageSchema,
WSOutboundMessageSchema,
} from "./messages.js";
@@ -64,4 +66,44 @@ describe("shared messages stream parsing", () => {
});
expect(wrapped.success).toBe(false);
});
it("rejects removed legacy git diff request messages", () => {
const gitDiffParsed = SessionInboundMessageSchema.safeParse({
type: "git_diff_request",
agentId: "agent-1",
requestId: "req-1",
});
expect(gitDiffParsed.success).toBe(false);
const highlightedParsed = SessionInboundMessageSchema.safeParse({
type: "highlighted_diff_request",
agentId: "agent-1",
requestId: "req-2",
});
expect(highlightedParsed.success).toBe(false);
});
it("rejects removed legacy git diff response messages", () => {
const gitDiffParsed = SessionOutboundMessageSchema.safeParse({
type: "git_diff_response",
payload: {
agentId: "agent-1",
diff: "",
error: null,
requestId: "req-1",
},
});
expect(gitDiffParsed.success).toBe(false);
const highlightedParsed = SessionOutboundMessageSchema.safeParse({
type: "highlighted_diff_response",
payload: {
agentId: "agent-1",
files: [],
error: null,
requestId: "req-2",
},
});
expect(highlightedParsed.success).toBe(false);
});
});
+1 -40
View File
@@ -696,12 +696,6 @@ export const AgentPermissionResponseMessageSchema = z.object({
response: AgentPermissionResponseSchema,
});
export const GitDiffRequestSchema = z.object({
type: z.literal("git_diff_request"),
agentId: z.string(),
requestId: z.string(),
});
const CheckoutErrorCodeSchema = z.enum([
"NOT_GIT_REPO",
"NOT_ALLOWED",
@@ -837,12 +831,6 @@ const ParsedDiffFileSchema = z.object({
status: z.enum(["ok", "too_large", "binary"]).optional(),
});
export const HighlightedDiffRequestSchema = z.object({
type: z.literal("highlighted_diff_request"),
agentId: z.string(),
requestId: z.string(),
});
const FileExplorerEntrySchema = z.object({
name: z.string(),
path: z.string(),
@@ -1010,7 +998,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
SetAgentModelRequestMessageSchema,
SetAgentThinkingRequestMessageSchema,
AgentPermissionResponseMessageSchema,
GitDiffRequestSchema,
CheckoutStatusRequestSchema,
SubscribeCheckoutDiffRequestSchema,
UnsubscribeCheckoutDiffRequestSchema,
@@ -1023,7 +1010,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
ValidateBranchRequestSchema,
PaseoWorktreeListRequestSchema,
PaseoWorktreeArchiveRequestSchema,
HighlightedDiffRequestSchema,
FileExplorerRequestSchema,
ProjectIconRequestSchema,
FileDownloadTokenRequestSchema,
@@ -1411,16 +1397,6 @@ export const AgentArchivedMessageSchema = z.object({
}),
});
export const GitDiffResponseSchema = z.object({
type: z.literal("git_diff_response"),
payload: z.object({
agentId: z.string(),
diff: z.string(),
error: z.string().nullable(),
requestId: z.string(),
}),
});
const AheadBehindSchema = z.object({
ahead: z.number(),
behind: z.number(),
@@ -1564,6 +1540,7 @@ export const CheckoutPrStatusResponseSchema = z.object({
payload: z.object({
cwd: z.string(),
status: CheckoutPrStatusSchema.nullable(),
githubFeaturesEnabled: z.boolean(),
error: CheckoutErrorSchema.nullable(),
requestId: z.string(),
}),
@@ -1605,16 +1582,6 @@ export const PaseoWorktreeArchiveResponseSchema = z.object({
}),
});
export const HighlightedDiffResponseSchema = z.object({
type: z.literal("highlighted_diff_response"),
payload: z.object({
agentId: z.string(),
files: z.array(ParsedDiffFileSchema),
error: z.string().nullable(),
requestId: z.string(),
}),
});
export const FileExplorerResponseSchema = z.object({
type: z.literal("file_explorer_response"),
payload: z.object({
@@ -1834,7 +1801,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
AgentPermissionResolvedMessageSchema,
AgentDeletedMessageSchema,
AgentArchivedMessageSchema,
GitDiffResponseSchema,
CheckoutStatusResponseSchema,
SubscribeCheckoutDiffResponseSchema,
CheckoutDiffUpdateSchema,
@@ -1847,7 +1813,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ValidateBranchResponseSchema,
PaseoWorktreeListResponseSchema,
PaseoWorktreeArchiveResponseSchema,
HighlightedDiffResponseSchema,
FileExplorerResponseSchema,
ProjectIconResponseSchema,
FileDownloadTokenResponseSchema,
@@ -1944,8 +1909,6 @@ export type SetAgentModeRequestMessage = z.infer<typeof SetAgentModeRequestMessa
export type SetAgentModelRequestMessage = z.infer<typeof SetAgentModelRequestMessageSchema>;
export type SetAgentThinkingRequestMessage = z.infer<typeof SetAgentThinkingRequestMessageSchema>;
export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>;
export type GitDiffRequest = z.infer<typeof GitDiffRequestSchema>;
export type GitDiffResponse = z.infer<typeof GitDiffResponseSchema>;
export type CheckoutStatusRequest = z.infer<typeof CheckoutStatusRequestSchema>;
export type CheckoutStatusResponse = z.infer<typeof CheckoutStatusResponseSchema>;
export type SubscribeCheckoutDiffRequest = z.infer<
@@ -1976,8 +1939,6 @@ export type PaseoWorktreeListRequest = z.infer<typeof PaseoWorktreeListRequestSc
export type PaseoWorktreeListResponse = z.infer<typeof PaseoWorktreeListResponseSchema>;
export type PaseoWorktreeArchiveRequest = z.infer<typeof PaseoWorktreeArchiveRequestSchema>;
export type PaseoWorktreeArchiveResponse = z.infer<typeof PaseoWorktreeArchiveResponseSchema>;
export type HighlightedDiffRequest = z.infer<typeof HighlightedDiffRequestSchema>;
export type HighlightedDiffResponse = z.infer<typeof HighlightedDiffResponseSchema>;
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
export type FileExplorerResponse = z.infer<typeof FileExplorerResponseSchema>;
export type ProjectIconRequest = z.infer<typeof ProjectIconRequestSchema>;
@@ -0,0 +1,82 @@
import { execSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
const spawnCounters = vi.hoisted(() => ({
trackedTextDiffCalls: 0,
}));
vi.mock("child_process", async () => {
const actual = await vi.importActual<typeof import("child_process")>("child_process");
return {
...actual,
spawn: (...args: Parameters<typeof actual.spawn>) => {
const [command, commandArgs] = args;
if (command === "git" && Array.isArray(commandArgs)) {
const normalizedArgs = commandArgs.map((arg) => String(arg));
const isTrackedTextDiff =
normalizedArgs[0] === "diff" &&
normalizedArgs.includes("HEAD") &&
!normalizedArgs.includes("--numstat") &&
!normalizedArgs.includes("--no-index");
if (isTrackedTextDiff) {
spawnCounters.trackedTextDiffCalls += 1;
}
}
return actual.spawn(...args);
},
};
});
import { getCheckoutDiff } from "./checkout-git.js";
function initRepoWithTrackedChanges(fileCount: number): { tempDir: string; repoDir: string } {
const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "checkout-git-batch-test-")));
const repoDir = join(tempDir, "repo");
execSync(`mkdir -p ${repoDir}`);
execSync("git init -b main", { cwd: repoDir });
execSync("git config user.email 'test@test.com'", { cwd: repoDir });
execSync("git config user.name 'Test'", { cwd: repoDir });
for (let i = 0; i < fileCount; i += 1) {
writeFileSync(join(repoDir, `file-${i}.txt`), `before-${i}\n`);
}
execSync("git add .", { cwd: repoDir });
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir });
for (let i = 0; i < fileCount; i += 1) {
writeFileSync(join(repoDir, `file-${i}.txt`), `after-${i}\n`);
}
return { tempDir, repoDir };
}
describe("checkout git diff batching", () => {
let tempDir: string;
let repoDir: string;
beforeEach(() => {
const setup = initRepoWithTrackedChanges(20);
tempDir = setup.tempDir;
repoDir = setup.repoDir;
spawnCounters.trackedTextDiffCalls = 0;
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it("uses a single tracked git diff command for tracked file diffs", async () => {
const result = await getCheckoutDiff(repoDir, {
mode: "uncommitted",
includeStructured: false,
});
expect(result.diff).toContain("file-0.txt");
expect(result.diff).toContain("file-19.txt");
expect(spawnCounters.trackedTextDiffCalls).toBe(1);
});
});
+46 -1
View File
@@ -1,11 +1,12 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { execSync } from "child_process";
import { mkdtempSync, rmSync, writeFileSync, realpathSync } from "fs";
import { mkdtempSync, rmSync, writeFileSync, realpathSync, mkdirSync, symlinkSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
import {
commitAll,
getCheckoutDiff,
getPullRequestStatus,
getCheckoutStatus,
getCheckoutStatusLite,
mergeToBase,
@@ -141,6 +142,27 @@ describe("checkout git utilities", () => {
);
});
it("short-circuits tracked binary files", async () => {
const trackedBinaryPath = join(repoDir, "tracked-blob.bin");
writeFileSync(trackedBinaryPath, Buffer.from([0x00, 0xff, 0x10, 0x80, 0x00]));
execSync("git add tracked-blob.bin", { cwd: repoDir });
execSync("git -c commit.gpgsign=false commit -m 'add tracked binary'", {
cwd: repoDir,
});
writeFileSync(trackedBinaryPath, Buffer.from([0x00, 0xff, 0x11, 0x81, 0x00]));
const diff = await getCheckoutDiff(repoDir, {
mode: "uncommitted",
includeStructured: true,
});
const entry = diff.structured?.find((file) => file.path === "tracked-blob.bin");
expect(entry).toBeTruthy();
expect(entry?.status).toBe("binary");
expect(diff.diff).toContain("# tracked-blob.bin: binary diff omitted");
});
it("short-circuits untracked binary files", async () => {
const binaryPath = join(repoDir, "blob.bin");
writeFileSync(binaryPath, Buffer.from([0x00, 0xff, 0x10, 0x80, 0x00, 0x7f, 0x00]));
@@ -380,6 +402,29 @@ describe("checkout git utilities", () => {
execSync(`git --git-dir ${remoteDir} show-ref --verify refs/heads/feature`);
});
it("disables GitHub features when gh is unavailable", async () => {
execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir });
const fakeBinDir = join(tempDir, "fake-bin");
mkdirSync(fakeBinDir);
const gitPath = execSync("command -v git", { stdio: "pipe" }).toString().trim();
symlinkSync(gitPath, join(fakeBinDir, "git"));
const originalPath = process.env.PATH;
process.env.PATH = fakeBinDir;
try {
const status = await getPullRequestStatus(repoDir);
expect(status.githubFeaturesEnabled).toBe(false);
expect(status.status).toBeNull();
} finally {
if (originalPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = originalPath;
}
}
});
it("returns typed MergeConflictError on merge conflicts", async () => {
const conflictFile = join(repoDir, "conflict.txt");
writeFileSync(conflictFile, "base\n");
+269 -87
View File
@@ -184,35 +184,81 @@ async function tryResolveMergeBase(cwd: string, baseRef: string): Promise<string
type FileStat = { additions: number; deletions: number; isBinary: boolean } | null;
async function tryGetNumstat(
cwd: string,
args: string[]
): Promise<FileStat> {
try {
const { text } = await spawnLimitedText({
cmd: "git",
args,
cwd,
env: READ_ONLY_GIT_ENV,
maxBytes: 64 * 1024,
acceptExitCodes: [0],
});
const line = text.trim().split("\n").map((l) => l.trim()).filter(Boolean)[0] ?? "";
if (!line) return null;
const [aRaw, dRaw] = line.split(/\s+/);
if (!aRaw || !dRaw) return null;
if (aRaw === "-" || dRaw === "-") {
return { additions: 0, deletions: 0, isBinary: true };
}
const additions = Number.parseInt(aRaw, 10);
const deletions = Number.parseInt(dRaw, 10);
if (Number.isNaN(additions) || Number.isNaN(deletions)) {
return null;
}
return { additions, deletions, isBinary: false };
} catch {
return null;
function normalizeNumstatPath(pathField: string): string {
const braceRenameMatch = pathField.match(/^(.*)\{(.*) => (.*)\}(.*)$/);
if (braceRenameMatch) {
const [, prefix, , renamed, suffix] = braceRenameMatch;
return `${prefix}${renamed}${suffix}`;
}
const inlineRenameMatch = pathField.match(/^(.*) => (.*)$/);
if (inlineRenameMatch) {
return inlineRenameMatch[2] ?? pathField;
}
return pathField;
}
const TRACKED_DIFF_NUMSTAT_MAX_BYTES = 2 * 1024 * 1024; // 2MB
const TRACKED_MAX_CHANGED_LINES = 40_000;
async function getTrackedNumstatByPath(
cwd: string,
ref: string
): Promise<Map<string, FileStat>> {
const result = await spawnLimitedText({
cmd: "git",
args: ["diff", "--numstat", ref],
cwd,
env: READ_ONLY_GIT_ENV,
maxBytes: TRACKED_DIFF_NUMSTAT_MAX_BYTES,
acceptExitCodes: [0],
});
const stats = new Map<string, FileStat>();
const lines = result.text
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
for (const line of lines) {
const parts = line.split("\t");
if (parts.length < 3) {
continue;
}
const additionsField = parts[0] ?? "";
const deletionsField = parts[1] ?? "";
const rawPath = parts.slice(2).join("\t");
const path = normalizeNumstatPath(rawPath);
if (!path) {
continue;
}
if (additionsField === "-" || deletionsField === "-") {
stats.set(path, { additions: 0, deletions: 0, isBinary: true });
continue;
}
const additions = Number.parseInt(additionsField, 10);
const deletions = Number.parseInt(deletionsField, 10);
if (Number.isNaN(additions) || Number.isNaN(deletions)) {
stats.set(path, null);
continue;
}
stats.set(path, { additions, deletions, isBinary: false });
}
return stats;
}
function isTrackedDiffTooLarge(stat: FileStat): boolean {
if (!stat || stat.isBinary) {
return false;
}
return stat.additions + stat.deletions > TRACKED_MAX_CHANGED_LINES;
}
export class NotGitRepoError extends Error {
@@ -784,49 +830,32 @@ function buildPlaceholderParsedDiffFile(
};
}
async function getPerFileDiffText(
async function getUntrackedDiffText(
cwd: string,
ref: string,
change: CheckoutFileChange
): Promise<{ text: string; truncated: boolean; stat: FileStat }> {
if (change.isUntracked) {
try {
const inspected = await inspectUntrackedFile(cwd, change.path);
if (inspected.stat?.isBinary || inspected.truncated) {
return { text: "", truncated: inspected.truncated, stat: inspected.stat };
}
} catch {
// Fall through to git diff path if metadata probing fails.
try {
const inspected = await inspectUntrackedFile(cwd, change.path);
if (inspected.stat?.isBinary || inspected.truncated) {
return { text: "", truncated: inspected.truncated, stat: inspected.stat };
}
const result = await spawnLimitedText({
cmd: "git",
args: ["diff", "--no-index", "/dev/null", "--", change.path],
cwd,
env: READ_ONLY_GIT_ENV,
maxBytes: PER_FILE_DIFF_MAX_BYTES,
acceptExitCodes: [0, 1],
});
return {
text: result.text,
truncated: result.truncated,
stat: { additions: 0, deletions: 0, isBinary: false },
};
}
const stat = await tryGetNumstat(cwd, ["diff", "--numstat", ref, "--", change.path]);
if (stat?.isBinary) {
return { text: "", truncated: false, stat };
} catch {
// Fall through to git diff path if metadata probing fails.
}
const result = await spawnLimitedText({
cmd: "git",
args: ["diff", ref, "--", change.path],
args: ["diff", "--no-index", "/dev/null", "--", change.path],
cwd,
env: READ_ONLY_GIT_ENV,
maxBytes: PER_FILE_DIFF_MAX_BYTES,
acceptExitCodes: [0, 1],
});
return { text: result.text, truncated: result.truncated, stat };
return {
text: result.text,
truncated: result.truncated,
stat: { additions: 0, deletions: 0, isBinary: false },
};
}
export async function getCheckoutStatus(
@@ -966,8 +995,111 @@ export async function getCheckoutDiff(
}
};
for (const change of changes) {
const { text, truncated, stat } = await getPerFileDiffText(cwd, refForDiff, change);
const trackedChanges = changes.filter((change) => !change.isUntracked);
const untrackedChanges = changes.filter((change) => change.isUntracked === true);
const trackedNumstatByPath =
trackedChanges.length > 0 ? await getTrackedNumstatByPath(cwd, refForDiff) : new Map<string, FileStat>();
const trackedDiffPaths: string[] = [];
const trackedPlaceholderByPath = new Map<
string,
{ status: "binary" | "too_large"; stat: FileStat }
>();
for (const change of trackedChanges) {
const stat = trackedNumstatByPath.get(change.path) ?? null;
if (stat?.isBinary) {
trackedPlaceholderByPath.set(change.path, { status: "binary", stat });
continue;
}
if (isTrackedDiffTooLarge(stat)) {
trackedPlaceholderByPath.set(change.path, { status: "too_large", stat });
continue;
}
trackedDiffPaths.push(change.path);
}
let trackedDiffText = "";
let trackedDiffTruncated = false;
if (trackedDiffPaths.length > 0) {
const trackedDiffResult = await spawnLimitedText({
cmd: "git",
args: ["diff", refForDiff, "--", ...trackedDiffPaths],
cwd,
env: READ_ONLY_GIT_ENV,
maxBytes: TOTAL_DIFF_MAX_BYTES,
});
trackedDiffText = trackedDiffResult.text;
trackedDiffTruncated = trackedDiffResult.truncated;
appendDiff(trackedDiffText);
if (trackedDiffTruncated) {
appendDiff("# tracked diff truncated\n");
}
}
const appendTrackedPlaceholderComment = (change: CheckoutFileChange, status: "binary" | "too_large") => {
if (status === "binary") {
appendDiff(`# ${change.path}: binary diff omitted\n`);
return;
}
appendDiff(`# ${change.path}: diff too large omitted\n`);
};
if (compare.includeStructured) {
const parsedTrackedFiles =
trackedDiffText.length > 0 ? await parseAndHighlightDiff(trackedDiffText, cwd) : [];
const parsedTrackedByPath = new Map(parsedTrackedFiles.map((file) => [file.path, file]));
for (const change of trackedChanges) {
const placeholder = trackedPlaceholderByPath.get(change.path);
if (placeholder) {
structured.push(
buildPlaceholderParsedDiffFile(change, {
status: placeholder.status,
stat: placeholder.stat,
})
);
appendTrackedPlaceholderComment(change, placeholder.status);
continue;
}
const stat = trackedNumstatByPath.get(change.path) ?? null;
const parsedFile = parsedTrackedByPath.get(change.path);
if (parsedFile) {
structured.push({
...parsedFile,
path: change.path,
isNew: change.isNew,
isDeleted: change.isDeleted,
status: "ok",
});
continue;
}
structured.push({
path: change.path,
isNew: change.isNew,
isDeleted: change.isDeleted,
additions: stat?.additions ?? 0,
deletions: stat?.deletions ?? 0,
hunks: [],
status: trackedDiffTruncated ? "too_large" : "ok",
});
}
} else {
for (const change of trackedChanges) {
const placeholder = trackedPlaceholderByPath.get(change.path);
if (placeholder) {
appendTrackedPlaceholderComment(change, placeholder.status);
}
}
}
for (const change of untrackedChanges) {
if (diffBytes >= TOTAL_DIFF_MAX_BYTES) {
break;
}
const { text, truncated, stat } = await getUntrackedDiffText(cwd, change);
if (!compare.includeStructured) {
if (stat?.isBinary) {
@@ -977,9 +1109,6 @@ export async function getCheckoutDiff(
} else {
appendDiff(text);
}
if (diffBytes >= TOTAL_DIFF_MAX_BYTES) {
break;
}
continue;
}
@@ -1268,6 +1397,11 @@ export interface PullRequestStatus {
headRefName: string;
}
export interface PullRequestStatusResult {
status: PullRequestStatus | null;
githubFeaturesEnabled: boolean;
}
async function ensureGhAvailable(cwd: string): Promise<void> {
try {
await execAsync("gh --version", { cwd });
@@ -1276,6 +1410,27 @@ async function ensureGhAvailable(cwd: string): Promise<void> {
}
}
function getCommandErrorText(error: unknown): string {
if (!(error instanceof Error)) {
return String(error);
}
const stderr = typeof (error as any)?.stderr === "string" ? (error as any).stderr : "";
const stdout = typeof (error as any)?.stdout === "string" ? (error as any).stdout : "";
return `${error.message}\n${stderr}\n${stdout}`.toLowerCase();
}
function isGhAuthError(error: unknown): boolean {
const text = getCommandErrorText(error);
return (
text.includes("gh auth login") ||
text.includes("not logged into any github hosts") ||
text.includes("authentication failed") ||
text.includes("authentication required") ||
text.includes("bad credentials") ||
text.includes("http 401")
);
}
async function resolveGitHubRepo(cwd: string): Promise<string | null> {
try {
const { stdout } = await execAsync("git config --get remote.origin.url", {
@@ -1356,39 +1511,66 @@ export async function createPullRequest(
return { url: parsed.url, number: parsed.number };
}
export async function getPullRequestStatus(cwd: string): Promise<PullRequestStatus | null> {
export async function getPullRequestStatus(cwd: string): Promise<PullRequestStatusResult> {
await requireGitRepo(cwd);
await ensureGhAvailable(cwd);
const repo = await resolveGitHubRepo(cwd);
const head = await getCurrentBranch(cwd);
if (!repo || !head) {
return null;
return {
status: null,
githubFeaturesEnabled: false,
};
}
try {
await ensureGhAvailable(cwd);
} catch {
return {
status: null,
githubFeaturesEnabled: false,
};
}
const owner = repo.split("/")[0];
const { stdout } = await execFileAsync(
"gh",
[
"api",
`repos/${repo}/pulls`,
"-X",
"GET",
"-F",
`head=${owner}:${head}`,
"-F",
"state=open",
],
{ cwd }
);
let stdout: string;
try {
({ stdout } = await execFileAsync(
"gh",
[
"api",
`repos/${repo}/pulls`,
"-X",
"GET",
"-F",
`head=${owner}:${head}`,
"-F",
"state=open",
],
{ cwd }
));
} catch (error) {
if (isGhAuthError(error)) {
return {
status: null,
githubFeaturesEnabled: false,
};
}
throw error;
}
const parsed = JSON.parse(stdout.trim());
const current = Array.isArray(parsed) && parsed.length > 0 ? parsed[0] : null;
if (!current) {
return null;
return {
status: null,
githubFeaturesEnabled: true,
};
}
return {
url: current.html_url ?? current.url,
title: current.title,
state: current.state,
baseRefName: current.base?.ref ?? "",
headRefName: current.head?.ref ?? head,
status: {
url: current.html_url ?? current.url,
title: current.title,
state: current.state,
baseRefName: current.base?.ref ?? "",
headRefName: current.head?.ref ?? head,
},
githubFeaturesEnabled: true,
};
}