mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84478a2dba | ||
|
|
93065b20f7 | ||
|
|
bb8762e122 | ||
|
|
15a2e3bdcb | ||
|
|
64ba05cea5 | ||
|
|
1521ceb381 | ||
|
|
4338f5b46c | ||
|
|
e8a64fb569 | ||
|
|
cf1f849b68 | ||
|
|
bc0886ad7c | ||
|
|
12fcfe53eb | ||
|
|
a198a33ff5 | ||
|
|
7c85341a42 | ||
|
|
c534c857a4 | ||
|
|
6d8c7c4d2d | ||
|
|
f0d96f8e5a | ||
|
|
f8d4758e6b | ||
|
|
09b498da61 | ||
|
|
221b6665a0 | ||
|
|
94dd36a4c9 | ||
|
|
84c1ff560f | ||
|
|
b7f9092e93 | ||
|
|
ade05607d2 | ||
|
|
6d13796b2d | ||
|
|
191cea47a0 | ||
|
|
0aa4868b31 | ||
|
|
e7016ef6b8 | ||
|
|
4332eca9ca | ||
|
|
fc31d31493 | ||
|
|
8ba71cc189 |
9
.github/workflows/ci.yml
vendored
9
.github/workflows/ci.yml
vendored
@@ -155,6 +155,15 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
|
||||
- name: Build relay dependency
|
||||
run: npm run build --workspace=@getpaseo/relay
|
||||
|
||||
- name: Build server dependency
|
||||
run: npm run build --workspace=@getpaseo/server
|
||||
|
||||
- name: Run desktop tests
|
||||
run: npm run test --workspace=@getpaseo/desktop
|
||||
|
||||
|
||||
24
CHANGELOG.md
24
CHANGELOG.md
@@ -1,5 +1,29 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.68 - 2026-05-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- The desktop app no longer fails on first launch after a fresh install.
|
||||
|
||||
## 0.1.67 - 2026-05-03
|
||||
|
||||
### Fixed
|
||||
|
||||
- Archiving a worktree or workspace feels instant instead of waiting on the daemon, with automatic rollback if it fails.
|
||||
- The built-in daemon toggle in desktop settings now actually takes effect.
|
||||
- Desktop settings no longer reset on app launch after a legacy migration.
|
||||
- Desktop daemon startup failures now surface on the splash screen and respond to retry, instead of leaving the app silently stuck.
|
||||
- Internal LLM calls (branch names, commit messages, PR text) no longer leave behind ephemeral agent sessions in your provider history.
|
||||
|
||||
## 0.1.66 - 2026-05-03
|
||||
|
||||
### Fixed
|
||||
|
||||
- Streaming markdown preserves trailing newlines so paragraph spacing stays correct while the agent is still typing.
|
||||
- Agent initialization failures surface within 30 seconds instead of 5 minutes.
|
||||
- Terminals reply to ANSI cursor-position queries, so tools that ask for cursor location no longer hang.
|
||||
|
||||
## 0.1.65 - 2026-05-03
|
||||
|
||||
### Added
|
||||
|
||||
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
||||
|
||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||
npmDepsHash = "sha256-iiqzJ05xnnXyf5eRPqq9xKztwkaxO389vLZFjAh7IjU=";
|
||||
npmDepsHash = "sha256-Pjfl4RV+2keXdYWMPonsPkwPAbVNSgczmwSeQRwNSu4=";
|
||||
|
||||
# Prevent onnxruntime-node's install script from running during automatic
|
||||
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||
|
||||
27
package-lock.json
generated
27
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.65",
|
||||
"version": "0.1.68",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.65",
|
||||
"version": "0.1.68",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -38445,7 +38445,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.65",
|
||||
"version": "0.1.68",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -38571,10 +38571,10 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.65",
|
||||
"version": "0.1.68",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/server": "0.1.65",
|
||||
"@getpaseo/server": "0.1.68",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -38616,7 +38616,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.65",
|
||||
"version": "0.1.68",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -38665,7 +38665,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.65",
|
||||
"version": "0.1.68",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.25",
|
||||
@@ -38701,7 +38701,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.65",
|
||||
"version": "0.1.68",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
@@ -38727,7 +38727,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.65",
|
||||
"version": "0.1.68",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38742,12 +38742,12 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.65",
|
||||
"version": "0.1.68",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@getpaseo/highlight": "0.1.65",
|
||||
"@getpaseo/relay": "0.1.65",
|
||||
"@getpaseo/highlight": "0.1.68",
|
||||
"@getpaseo/relay": "0.1.68",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@mariozechner/pi-agent-core": "^0.70.2",
|
||||
"@mariozechner/pi-ai": "^0.70.2",
|
||||
@@ -38762,6 +38762,7 @@
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.18.2",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"node-pty": "1.2.0-beta.11",
|
||||
"onnxruntime-node": "^1.23.0",
|
||||
"openai": "^4.20.0",
|
||||
@@ -39199,7 +39200,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.65",
|
||||
"version": "0.1.68",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.20.3",
|
||||
"@cloudflare/workers-types": "^4.20260114.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.65",
|
||||
"version": "0.1.68",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
"keywords": [
|
||||
|
||||
@@ -43,11 +43,10 @@ jobs:
|
||||
name: Submit iOS for App Store review
|
||||
needs: [submit_ios]
|
||||
environment: production
|
||||
runs_on: macos-medium
|
||||
steps:
|
||||
- uses: eas/checkout
|
||||
- name: Install fastlane
|
||||
working_directory: ./packages/app
|
||||
run: bundle install
|
||||
- name: Submit for review
|
||||
working_directory: ./packages/app
|
||||
run: bundle exec fastlane ios submit_review
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import type { WebSocketRoute } from "@playwright/test";
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
@@ -133,28 +133,34 @@ test.describe("Workspace navigation regression", () => {
|
||||
const daemonGate = await installDaemonWebSocketGate(page, daemonPort);
|
||||
|
||||
const workspaceClient = await connectNewWorkspaceDaemonClient();
|
||||
const archiveClient = await connectArchiveTabDaemonClient();
|
||||
const workspaceIds = new Set<string>();
|
||||
const agentIds: string[] = [];
|
||||
const repo = await createTempGitRepo("workspace-reconnect-");
|
||||
|
||||
try {
|
||||
const workspace = await openProjectViaDaemon(workspaceClient, repo.path);
|
||||
workspaceIds.add(workspace.workspaceId);
|
||||
|
||||
const agent = await createIdleAgent(archiveClient, {
|
||||
cwd: repo.path,
|
||||
title: `workspace-reconnect-${Date.now()}`,
|
||||
});
|
||||
agentIds.push(agent.id);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: workspace.workspaceId,
|
||||
});
|
||||
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd));
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: workspace.workspaceName,
|
||||
subtitle: workspace.projectDisplayName,
|
||||
});
|
||||
await expect(page.getByTestId("workspace-tabs-row")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectWorkspaceTabVisible(page, agent.id);
|
||||
|
||||
await daemonGate.drop();
|
||||
await expect(page.getByTestId("agent-reconnecting-toast")).toBeVisible({
|
||||
@@ -184,9 +190,13 @@ test.describe("Workspace navigation regression", () => {
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
||||
} finally {
|
||||
daemonGate.restore();
|
||||
for (const agentId of agentIds) {
|
||||
await archiveAgentFromDaemon(archiveClient, agentId).catch(() => undefined);
|
||||
}
|
||||
for (const workspaceId of workspaceIds) {
|
||||
await archiveLocalWorkspaceFromDaemon(workspaceClient, workspaceId).catch(() => undefined);
|
||||
}
|
||||
await archiveClient.close().catch(() => undefined);
|
||||
await workspaceClient.close().catch(() => undefined);
|
||||
await repo.cleanup();
|
||||
}
|
||||
@@ -213,7 +223,7 @@ test.describe("Workspace navigation regression", () => {
|
||||
);
|
||||
|
||||
await expect(
|
||||
page.getByText(/Connecting to localhost|localhost is offline|Cannot reach localhost/i),
|
||||
page.getByText(/^Connecting$|localhost is offline|Cannot reach localhost/i),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("menu-button")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-header-title")).toHaveCount(0);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.65",
|
||||
"version": "0.1.68",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -26,7 +26,7 @@ import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-g
|
||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||
import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated";
|
||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { CommandCenter } from "@/components/command-center";
|
||||
import { WorktreeSetupCalloutSource } from "@/components/worktree-setup-callout-source";
|
||||
import { DownloadToast } from "@/components/download-toast";
|
||||
@@ -50,17 +50,18 @@ import {
|
||||
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
|
||||
import { ToastProvider } from "@/contexts/toast-context";
|
||||
import { VoiceProvider } from "@/contexts/voice-context";
|
||||
import { startHostRuntimeBootstrap } from "@/app/host-runtime-bootstrap";
|
||||
import { startDaemonIfGateAllows, startHostRuntimeBootstrap } from "@/app/host-runtime-bootstrap";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
||||
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { loadDesktopSettings } from "@/desktop/settings/desktop-settings";
|
||||
import { RosettaCalloutSource } from "@/desktop/updates/rosetta-callout-source";
|
||||
import { UpdateCalloutSource } from "@/desktop/updates/update-callout-source";
|
||||
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
|
||||
import { useColorScheme } from "@/hooks/use-color-scheme";
|
||||
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
|
||||
import { useOpenProject } from "@/hooks/use-open-project";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
@@ -312,6 +313,14 @@ function useDaemonStartIsRunning(): boolean {
|
||||
|
||||
const STARTUP_GIVE_UP_TIMEOUT_MS = 5_000;
|
||||
|
||||
async function shouldStartBuiltInDaemon(): Promise<boolean> {
|
||||
if (!shouldUseDesktopDaemon()) {
|
||||
return false;
|
||||
}
|
||||
const settings = await loadDesktopSettings();
|
||||
return settings.daemon.manageBuiltInDaemon;
|
||||
}
|
||||
|
||||
function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
const store = getHostRuntimeStore();
|
||||
@@ -319,7 +328,8 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
startHostRuntimeBootstrap({
|
||||
store,
|
||||
daemonStartService,
|
||||
shouldStartDaemon: shouldUseDesktopDaemon(),
|
||||
shouldStartDaemon: shouldStartBuiltInDaemon,
|
||||
onGateError: (message) => daemonStartService.recordError(message),
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -346,12 +356,18 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
}, [anyOnlineHostServerId, daemonStartError, daemonStartIsRunning, hasGivenUpWaitingForHost]);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
void getDaemonStartService({ store: getHostRuntimeStore() }).start();
|
||||
const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() });
|
||||
startDaemonIfGateAllows({
|
||||
daemonStartService,
|
||||
shouldStartDaemon: shouldStartBuiltInDaemon,
|
||||
onGateError: (message) => daemonStartService.recordError(message),
|
||||
});
|
||||
}, []);
|
||||
|
||||
const splashError = !anyOnlineHostServerId ? daemonStartError : null;
|
||||
const storeReady =
|
||||
const isCurrentlyStoreReady =
|
||||
Boolean(anyOnlineHostServerId) || Boolean(splashError) || hasGivenUpWaitingForHost;
|
||||
const storeReady = useLatchedBoolean(isCurrentlyStoreReady);
|
||||
|
||||
const state = useMemo<HostRuntimeBootstrapState>(
|
||||
() => ({ splashError, retry, hasGivenUpWaitingForHost, storeReady }),
|
||||
@@ -393,7 +409,6 @@ function AppContainer({
|
||||
selectedAgentId,
|
||||
chromeEnabled: chromeEnabledOverride,
|
||||
}: AppContainerProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const daemons = useHosts();
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
const toggleMobileAgentList = usePanelStore((state) => state.toggleMobileAgentList);
|
||||
@@ -450,13 +465,8 @@ function AppContainer({
|
||||
|
||||
useActiveWorktreeNewAction();
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => ({ flex: 1 as const, backgroundColor: theme.colors.surface0 }),
|
||||
[theme.colors.surface0],
|
||||
);
|
||||
|
||||
const content = (
|
||||
<View style={containerStyle}>
|
||||
<View style={layoutStyles.surfaceFill}>
|
||||
<View style={rowStyle}>
|
||||
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && (
|
||||
<LeftSidebar selectedAgentId={selectedAgentId} />
|
||||
@@ -594,9 +604,6 @@ function MobileGestureWrapper({
|
||||
function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
const { settings, isLoading: settingsLoading } = useAppSettings();
|
||||
const { upsertConnectionFromOfferUrl } = useHostMutations();
|
||||
const systemColorScheme = useColorScheme();
|
||||
const { theme } = useUnistyles();
|
||||
const resolvedTheme = settings.theme === "auto" ? (systemColorScheme ?? "light") : settings.theme;
|
||||
|
||||
// Apply theme setting on mount and when it changes
|
||||
useEffect(() => {
|
||||
@@ -609,21 +616,9 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, [settingsLoading, settings.theme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (settingsLoading || isNative) {
|
||||
return;
|
||||
}
|
||||
|
||||
void updateDesktopWindowControls({
|
||||
backgroundColor: theme.colors.surface0,
|
||||
foregroundColor: theme.colors.foreground,
|
||||
}).catch((error) => {
|
||||
console.warn("[DesktopWindow] Failed to update window controls overlay", error);
|
||||
});
|
||||
}, [settingsLoading, resolvedTheme, theme.colors.foreground, theme.colors.surface0]);
|
||||
|
||||
return (
|
||||
<VoiceProvider>
|
||||
<DesktopWindowControlsSync enabled={!settingsLoading} />
|
||||
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
|
||||
<HostSessionManager />
|
||||
<FaviconStatusSync />
|
||||
@@ -632,6 +627,24 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopWindowControlsSync({ enabled }: { enabled: boolean }) {
|
||||
const { theme } = useUnistyles();
|
||||
const surface0 = theme.colors.surface0;
|
||||
const foreground = theme.colors.foreground;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || isNative) return;
|
||||
void updateDesktopWindowControls({
|
||||
backgroundColor: surface0,
|
||||
foregroundColor: foreground,
|
||||
}).catch((error) => {
|
||||
console.warn("[DesktopWindow] Failed to update window controls overlay", error);
|
||||
});
|
||||
}, [enabled, surface0, foreground]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function OfferLinkListener({
|
||||
upsertDaemonFromOfferUrl,
|
||||
}: {
|
||||
@@ -911,20 +924,23 @@ function RootProviders({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
const { theme } = useUnistyles();
|
||||
const gestureRootStyle = useMemo(
|
||||
() => ({ flex: 1, backgroundColor: theme.colors.surface0 }),
|
||||
[theme.colors.surface0],
|
||||
);
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView style={gestureRootStyle}>
|
||||
<NavigationActiveWorkspaceObserver />
|
||||
<RootProviders>
|
||||
<RuntimeProviders>
|
||||
<AppShell />
|
||||
</RuntimeProviders>
|
||||
</RootProviders>
|
||||
<GestureHandlerRootView style={flexStyle}>
|
||||
<View style={layoutStyles.surfaceFill}>
|
||||
<NavigationActiveWorkspaceObserver />
|
||||
<RootProviders>
|
||||
<RuntimeProviders>
|
||||
<AppShell />
|
||||
</RuntimeProviders>
|
||||
</RootProviders>
|
||||
</View>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
const layoutStyles = StyleSheet.create((theme) => ({
|
||||
surfaceFill: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -55,6 +55,42 @@ describe("startHostRuntimeBootstrap", () => {
|
||||
expect(daemonStartService.start).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips daemon-start when the startup gate resolves false", async () => {
|
||||
const store = createFakeStore();
|
||||
const daemonStartService = createFakeDaemonStartService();
|
||||
|
||||
startHostRuntimeBootstrap({
|
||||
store,
|
||||
daemonStartService,
|
||||
shouldStartDaemon: async () => false,
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(store.boot).toHaveBeenCalledTimes(1);
|
||||
expect(daemonStartService.start).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces gate rejection to onGateError without starting the daemon", async () => {
|
||||
const store = createFakeStore();
|
||||
const daemonStartService = createFakeDaemonStartService();
|
||||
const onGateError = vi.fn();
|
||||
|
||||
startHostRuntimeBootstrap({
|
||||
store,
|
||||
daemonStartService,
|
||||
shouldStartDaemon: async () => {
|
||||
throw new Error("settings file unreadable");
|
||||
},
|
||||
onGateError,
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(onGateError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(daemonStartService.start).not.toHaveBeenCalled();
|
||||
expect(onGateError).toHaveBeenCalledWith(expect.stringContaining("settings file unreadable"));
|
||||
});
|
||||
|
||||
it("does not await the daemon-start promise", () => {
|
||||
const store = createFakeStore();
|
||||
let resolveStart: ((value: { ok: true }) => void) | undefined;
|
||||
|
||||
@@ -11,17 +11,49 @@ export interface HostRuntimeBootstrapDaemonStartService {
|
||||
start: () => Promise<DaemonStartResult>;
|
||||
}
|
||||
|
||||
type HostRuntimeBootstrapStartGate = boolean | (() => boolean | Promise<boolean>);
|
||||
|
||||
export interface StartHostRuntimeBootstrapInput {
|
||||
store: HostRuntimeBootstrapStore;
|
||||
daemonStartService: HostRuntimeBootstrapDaemonStartService;
|
||||
shouldStartDaemon: boolean;
|
||||
shouldStartDaemon: HostRuntimeBootstrapStartGate;
|
||||
onGateError?: (message: string) => void;
|
||||
}
|
||||
|
||||
export function startHostRuntimeBootstrap(input: StartHostRuntimeBootstrapInput): void {
|
||||
input.store.boot();
|
||||
if (input.shouldStartDaemon) {
|
||||
void input.daemonStartService.start();
|
||||
startDaemonIfGateAllows({
|
||||
daemonStartService: input.daemonStartService,
|
||||
shouldStartDaemon: input.shouldStartDaemon,
|
||||
onGateError: input.onGateError,
|
||||
});
|
||||
}
|
||||
|
||||
export function startDaemonIfGateAllows(input: {
|
||||
daemonStartService: HostRuntimeBootstrapDaemonStartService;
|
||||
shouldStartDaemon: HostRuntimeBootstrapStartGate;
|
||||
onGateError?: (message: string) => void;
|
||||
}): void {
|
||||
const gate = input.shouldStartDaemon;
|
||||
if (typeof gate === "boolean") {
|
||||
if (gate) {
|
||||
void input.daemonStartService.start();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
void Promise.resolve()
|
||||
.then(() => gate())
|
||||
.then((shouldStartDaemon) => {
|
||||
if (shouldStartDaemon) {
|
||||
void input.daemonStartService.start();
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
input.onGateError?.(`Failed to evaluate desktop daemon settings: ${message}`);
|
||||
});
|
||||
}
|
||||
|
||||
export const WELCOME_ROUTE: Href = "/welcome";
|
||||
|
||||
@@ -104,9 +104,14 @@ import {
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
import {
|
||||
clearWorkspaceArchivePending,
|
||||
markWorkspaceArchivePending,
|
||||
} from "@/contexts/session-workspace-upserts";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import {
|
||||
requireWorkspaceExecutionDirectory,
|
||||
resolveWorkspaceMapKeyByIdentity,
|
||||
resolveWorkspaceExecutionDirectory,
|
||||
} from "@/utils/workspace-execution";
|
||||
import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
|
||||
@@ -123,6 +128,36 @@ function toProjectIconDataUri(icon: { mimeType: string; data: string } | null):
|
||||
const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.workspaceKey;
|
||||
|
||||
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
|
||||
|
||||
function hideWorkspaceOptimistically(workspace: SidebarWorkspaceEntry): WorkspaceDescriptor | null {
|
||||
const workspaces = useSessionStore.getState().sessions[workspace.serverId]?.workspaces;
|
||||
const workspaceKey = resolveWorkspaceMapKeyByIdentity({
|
||||
workspaces,
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
const snapshot = workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null;
|
||||
markWorkspaceArchivePending({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
useSessionStore.getState().removeWorkspace(workspace.serverId, workspace.workspaceId);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function restoreOptimisticallyHiddenWorkspace(input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
snapshot: WorkspaceDescriptor | null;
|
||||
}): void {
|
||||
clearWorkspaceArchivePending({
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
if (input.snapshot) {
|
||||
useSessionStore.getState().mergeWorkspaces(input.serverId, [input.snapshot]);
|
||||
}
|
||||
}
|
||||
const WORKSPACE_STATUS_DOT_WIDTH = 14;
|
||||
const DEFAULT_STATUS_DOT_SIZE = 7;
|
||||
const EMPHASIZED_STATUS_DOT_SIZE = 9;
|
||||
@@ -1514,16 +1549,7 @@ function WorkspaceRowWithMenu({
|
||||
toast.error(message);
|
||||
});
|
||||
})();
|
||||
}, [
|
||||
archiveWorktree,
|
||||
isArchiving,
|
||||
redirectAfterArchive,
|
||||
toast,
|
||||
workspace.name,
|
||||
workspace.workspaceDirectory,
|
||||
workspace.serverId,
|
||||
workspace.workspaceId,
|
||||
]);
|
||||
}, [archiveWorktree, isArchiving, redirectAfterArchive, toast, workspace]);
|
||||
|
||||
const handleArchiveWorkspace = useCallback(() => {
|
||||
if (isArchivingWorkspace) {
|
||||
@@ -1549,6 +1575,7 @@ function WorkspaceRowWithMenu({
|
||||
}
|
||||
|
||||
setIsArchivingWorkspace(true);
|
||||
const snapshot = hideWorkspaceOptimistically(workspace);
|
||||
redirectAfterArchive();
|
||||
|
||||
void (async () => {
|
||||
@@ -1558,20 +1585,18 @@ function WorkspaceRowWithMenu({
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
} catch (error) {
|
||||
restoreOptimisticallyHiddenWorkspace({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
snapshot,
|
||||
});
|
||||
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
|
||||
} finally {
|
||||
setIsArchivingWorkspace(false);
|
||||
}
|
||||
})();
|
||||
})();
|
||||
}, [
|
||||
isArchivingWorkspace,
|
||||
redirectAfterArchive,
|
||||
toast,
|
||||
workspace.name,
|
||||
workspace.serverId,
|
||||
workspace.workspaceId,
|
||||
]);
|
||||
}, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]);
|
||||
|
||||
const handleCopyPath = useCallback(() => {
|
||||
let copyTargetDirectory: string;
|
||||
@@ -1693,6 +1718,7 @@ function NonGitProjectRowWithMenuContent({
|
||||
}
|
||||
|
||||
setIsArchivingWorkspace(true);
|
||||
const snapshot = hideWorkspaceOptimistically(workspace);
|
||||
redirectAfterArchive();
|
||||
|
||||
void (async () => {
|
||||
@@ -1702,20 +1728,18 @@ function NonGitProjectRowWithMenuContent({
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
} catch (error) {
|
||||
restoreOptimisticallyHiddenWorkspace({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
snapshot,
|
||||
});
|
||||
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
|
||||
} finally {
|
||||
setIsArchivingWorkspace(false);
|
||||
}
|
||||
})();
|
||||
})();
|
||||
}, [
|
||||
isArchivingWorkspace,
|
||||
redirectAfterArchive,
|
||||
toast,
|
||||
workspace.name,
|
||||
workspace.serverId,
|
||||
workspace.workspaceId,
|
||||
]);
|
||||
}, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -2120,13 +2144,28 @@ function ProjectBlock({
|
||||
}
|
||||
|
||||
setIsRemovingProject(true);
|
||||
const snapshots = new Map(
|
||||
project.workspaces.map((workspace) => [
|
||||
workspace.workspaceId,
|
||||
hideWorkspaceOptimistically(workspace),
|
||||
]),
|
||||
);
|
||||
|
||||
const isRejected = (r: PromiseSettledResult<unknown>) => r.status === "rejected";
|
||||
void Promise.allSettled(
|
||||
project.workspaces.map(async (ws) => {
|
||||
const payload = await client.archiveWorkspace(ws.workspaceId);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
try {
|
||||
const payload = await client.archiveWorkspace(ws.workspaceId);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
} catch (error) {
|
||||
restoreOptimisticallyHiddenWorkspace({
|
||||
serverId,
|
||||
workspaceId: ws.workspaceId,
|
||||
snapshot: snapshots.get(ws.workspaceId) ?? null,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
).then((results) => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { ITheme } from "@xterm/xterm";
|
||||
import type { TerminalState } from "@server/shared/messages";
|
||||
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
||||
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
|
||||
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
|
||||
import { openExternalUrl } from "../utils/open-external-url";
|
||||
import { focusWithRetries } from "../utils/web-focus";
|
||||
import {
|
||||
@@ -127,6 +128,7 @@ interface TerminalEmulatorProps {
|
||||
meta: boolean;
|
||||
}) => Promise<void> | void;
|
||||
onPendingModifiersConsumed?: () => Promise<void> | void;
|
||||
onRendererReadyChange?: (change: TerminalRendererReadyChange) => void;
|
||||
pendingModifiers?: PendingTerminalModifiers;
|
||||
focusRequestToken?: number;
|
||||
resizeRequestToken?: number;
|
||||
@@ -181,6 +183,7 @@ export default function TerminalEmulator({
|
||||
onResize,
|
||||
onTerminalKey,
|
||||
onPendingModifiersConsumed,
|
||||
onRendererReadyChange,
|
||||
pendingModifiers = { ctrl: false, shift: false, alt: false },
|
||||
focusRequestToken = 0,
|
||||
resizeRequestToken = 0,
|
||||
@@ -198,6 +201,8 @@ export default function TerminalEmulator({
|
||||
const themeKey = useMemo(() => buildXtermThemeKey(xtermTheme), [xtermTheme]);
|
||||
const xtermThemeRef = useRef(xtermTheme);
|
||||
xtermThemeRef.current = xtermTheme;
|
||||
const onRendererReadyChangeRef = useRef(onRendererReadyChange);
|
||||
onRendererReadyChangeRef.current = onRendererReadyChange;
|
||||
const mountCallbacksRef = useRef({
|
||||
onInput,
|
||||
onResize,
|
||||
@@ -389,9 +394,11 @@ export default function TerminalEmulator({
|
||||
initialSnapshot: initialSnapshotRef.current,
|
||||
theme: mountedThemeRef.current,
|
||||
});
|
||||
onRendererReadyChangeRef.current?.({ streamKey, isReady: true });
|
||||
|
||||
return () => {
|
||||
runtime.unmount();
|
||||
onRendererReadyChangeRef.current?.({ streamKey, isReady: false });
|
||||
if (runtimeRef.current === runtime) {
|
||||
runtimeRef.current = null;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,12 @@ import { usePanelStore } from "@/stores/panel-store";
|
||||
import { toXtermTheme } from "@/utils/to-xterm-theme";
|
||||
import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
applyTerminalRendererReadyChange,
|
||||
shouldReplayTerminalSnapshotForRenderer,
|
||||
shouldShowTerminalLoadingOverlay,
|
||||
type TerminalRendererReadyChange,
|
||||
} from "@/utils/terminal-renderer-readiness";
|
||||
|
||||
interface TerminalPaneProps {
|
||||
serverId: string;
|
||||
@@ -164,6 +170,7 @@ export function TerminalPane({
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
|
||||
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
|
||||
const terminalStreamKey = useMemo(() => `${scopeKey}:${terminalId}`, [scopeKey, terminalId]);
|
||||
// Keep the latest measured size for whichever client currently owns the pane,
|
||||
// but only dedupe resizes that this specific client has already pushed.
|
||||
const measuredTerminalSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
||||
@@ -175,6 +182,7 @@ export function TerminalPane({
|
||||
);
|
||||
const [isAttaching, setIsAttaching] = useState(false);
|
||||
const [streamError, setStreamError] = useState<string | null>(null);
|
||||
const [rendererReadyStreamKey, setRendererReadyStreamKey] = useState<string | null>(null);
|
||||
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
|
||||
const [focusRequestToken, setFocusRequestToken] = useState(0);
|
||||
const [resizeRequestToken, setResizeRequestToken] = useState(0);
|
||||
@@ -195,6 +203,20 @@ export function TerminalPane({
|
||||
const requestTerminalReflow = useCallback(() => {
|
||||
setResizeRequestToken((current) => current + 1);
|
||||
}, []);
|
||||
const handleRendererReadyChange = useCallback(
|
||||
(change: TerminalRendererReadyChange) => {
|
||||
setRendererReadyStreamKey((current) => applyTerminalRendererReadyChange(current, change));
|
||||
if (!shouldReplayTerminalSnapshotForRenderer({ change, terminalStreamKey })) {
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = workspaceTerminalSession.snapshots.get({ terminalId });
|
||||
if (snapshot) {
|
||||
emulatorRef.current?.renderSnapshot(snapshot);
|
||||
}
|
||||
},
|
||||
[terminalId, terminalStreamKey, workspaceTerminalSession.snapshots],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile || !isWorkspaceFocused || !isPaneFocused || !terminalId) {
|
||||
@@ -617,6 +639,13 @@ export function TerminalPane({
|
||||
if (!swipeGesturesEnabled) return;
|
||||
onOpenFileExplorer();
|
||||
}, [swipeGesturesEnabled, onOpenFileExplorer]);
|
||||
const showLoadingOverlay = shouldShowTerminalLoadingOverlay({
|
||||
isWorkspaceFocused,
|
||||
hasStreamError: Boolean(streamError),
|
||||
isAttaching,
|
||||
rendererReadyStreamKey,
|
||||
terminalStreamKey,
|
||||
});
|
||||
|
||||
if (!client || !isConnected) {
|
||||
return (
|
||||
@@ -634,11 +663,12 @@ export function TerminalPane({
|
||||
<TerminalEmulator
|
||||
ref={emulatorRef}
|
||||
dom={TERMINAL_EMULATOR_DOM_PROPS}
|
||||
streamKey={`${scopeKey}:${terminalId}`}
|
||||
streamKey={terminalStreamKey}
|
||||
testId="terminal-surface"
|
||||
xtermTheme={xtermTheme}
|
||||
swipeGesturesEnabled={swipeGesturesEnabled}
|
||||
initialSnapshot={initialSnapshot}
|
||||
onRendererReadyChange={handleRendererReadyChange}
|
||||
onSwipeRight={handleSwipeRight}
|
||||
onSwipeLeft={handleSwipeLeft}
|
||||
onInput={handleTerminalData}
|
||||
@@ -654,7 +684,7 @@ export function TerminalPane({
|
||||
<View style={styles.terminalGestureContainer} />
|
||||
)}
|
||||
|
||||
{isAttaching && isWorkspaceFocused ? (
|
||||
{showLoadingOverlay ? (
|
||||
<View style={styles.attachOverlay} pointerEvents="none" testID="terminal-attach-loading">
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
|
||||
@@ -42,7 +42,6 @@ import {
|
||||
normalizeWorkspaceDescriptor,
|
||||
} from "@/stores/session-store";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import { isLocalWorktreeArchivePending } from "@/stores/checkout-git-actions-store";
|
||||
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||
import { sendOsNotification } from "@/utils/os-notifications";
|
||||
import { getIsAppActivelyVisible } from "@/utils/app-visibility";
|
||||
@@ -60,7 +59,10 @@ import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
|
||||
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
|
||||
import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts";
|
||||
import { shouldSuppressWorkspaceUpsertForLocalArchive } from "@/contexts/session-workspace-upserts";
|
||||
import {
|
||||
clearWorkspaceArchivePending,
|
||||
shouldSuppressWorkspaceForLocalArchive,
|
||||
} from "@/contexts/session-workspace-upserts";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
@@ -543,6 +545,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
|
||||
for (const entry of payload.entries) {
|
||||
const workspace = normalizeWorkspaceDescriptor(entry);
|
||||
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
|
||||
continue;
|
||||
}
|
||||
workspaces.set(workspace.id, workspace);
|
||||
}
|
||||
|
||||
@@ -1239,18 +1244,16 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const unsubWorkspaceUpdate = client.on("workspace_update", (message) => {
|
||||
if (message.type !== "workspace_update") return;
|
||||
if (message.payload.kind === "remove") {
|
||||
clearWorkspaceArchivePending({
|
||||
serverId,
|
||||
workspaceId: String(message.payload.id),
|
||||
});
|
||||
removeWorkspaceSetup({ serverId, workspaceId: String(message.payload.id) });
|
||||
removeWorkspace(serverId, String(message.payload.id));
|
||||
return;
|
||||
}
|
||||
const workspace = normalizeWorkspaceDescriptor(message.payload.workspace);
|
||||
if (
|
||||
shouldSuppressWorkspaceUpsertForLocalArchive({
|
||||
serverId,
|
||||
workspace,
|
||||
isArchivePending: isLocalWorktreeArchivePending,
|
||||
})
|
||||
) {
|
||||
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
|
||||
return;
|
||||
}
|
||||
mergeWorkspaces(serverId, [workspace]);
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { shouldSuppressWorkspaceUpsertForLocalArchive } from "@/contexts/session-workspace-upserts";
|
||||
import {
|
||||
clearWorkspaceArchivePending,
|
||||
isWorkspaceArchivePending,
|
||||
markWorkspaceArchivePending,
|
||||
shouldSuppressWorkspaceForLocalArchive,
|
||||
} from "@/contexts/session-workspace-upserts";
|
||||
|
||||
const baseWorkspace: WorkspaceDescriptor = {
|
||||
id: "/repo/worktree",
|
||||
@@ -21,40 +26,83 @@ function workspace(input?: Partial<WorkspaceDescriptor>): WorkspaceDescriptor {
|
||||
return { ...baseWorkspace, ...input };
|
||||
}
|
||||
|
||||
describe("shouldSuppressWorkspaceUpsertForLocalArchive", () => {
|
||||
it("suppresses archiving upserts for a locally pending archive", () => {
|
||||
const isArchivePending = vi.fn(() => true);
|
||||
describe("workspace archive pending suppression", () => {
|
||||
it("tracks a locally pending workspace archive by id and directory", () => {
|
||||
markWorkspaceArchivePending({
|
||||
serverId: "server-1",
|
||||
workspaceId: "/repo/worktree",
|
||||
workspaceDirectory: "/repo/worktree",
|
||||
});
|
||||
|
||||
expect(
|
||||
shouldSuppressWorkspaceUpsertForLocalArchive({
|
||||
isWorkspaceArchivePending({
|
||||
serverId: "server-1",
|
||||
workspace: workspace({ workspaceDirectory: "/repo/worktree" }),
|
||||
isArchivePending,
|
||||
workspaceId: "/repo/worktree",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(isArchivePending).toHaveBeenCalledWith({
|
||||
serverId: "server-1",
|
||||
cwd: "/repo/worktree",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows archiving upserts when this client did not start the archive", () => {
|
||||
expect(
|
||||
shouldSuppressWorkspaceUpsertForLocalArchive({
|
||||
isWorkspaceArchivePending({
|
||||
serverId: "server-1",
|
||||
workspace: workspace(),
|
||||
isArchivePending: () => false,
|
||||
workspaceDirectory: "/repo/worktree",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows normal upserts while a local archive is pending", () => {
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldSuppressWorkspaceUpsertForLocalArchive({
|
||||
shouldSuppressWorkspaceForLocalArchive({
|
||||
serverId: "server-1",
|
||||
workspace: workspace({ archivingAt: null }),
|
||||
isArchivePending: () => true,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
clearWorkspaceArchivePending({ serverId: "server-1", workspaceId: "/repo/worktree" });
|
||||
|
||||
expect(
|
||||
isWorkspaceArchivePending({
|
||||
serverId: "server-1",
|
||||
workspaceId: "/repo/worktree",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("suppresses upserts for a locally pending archive", () => {
|
||||
markWorkspaceArchivePending({
|
||||
serverId: "server-1",
|
||||
workspaceId: "/repo/worktree",
|
||||
workspaceDirectory: "/repo/worktree",
|
||||
});
|
||||
|
||||
expect(
|
||||
shouldSuppressWorkspaceForLocalArchive({
|
||||
serverId: "server-1",
|
||||
workspace: workspace({ workspaceDirectory: "/repo/worktree" }),
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
clearWorkspaceArchivePending({ serverId: "server-1", workspaceId: "/repo/worktree" });
|
||||
});
|
||||
|
||||
it("allows upserts when this client did not start the archive", () => {
|
||||
expect(
|
||||
shouldSuppressWorkspaceForLocalArchive({
|
||||
serverId: "server-1",
|
||||
workspace: workspace(),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("suppresses stale normal upserts while a local archive is pending", () => {
|
||||
markWorkspaceArchivePending({
|
||||
serverId: "server-1",
|
||||
workspaceId: "/repo/worktree",
|
||||
workspaceDirectory: "/repo/worktree",
|
||||
});
|
||||
|
||||
expect(
|
||||
shouldSuppressWorkspaceForLocalArchive({
|
||||
serverId: "server-1",
|
||||
workspace: workspace({ archivingAt: null }),
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
clearWorkspaceArchivePending({ serverId: "server-1", workspaceId: "/repo/worktree" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,96 @@
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { normalizeWorkspaceOpaqueId, normalizeWorkspacePath } from "@/utils/workspace-identity";
|
||||
|
||||
export function shouldSuppressWorkspaceUpsertForLocalArchive(input: {
|
||||
interface PendingWorkspaceArchive {
|
||||
workspaceId: string;
|
||||
workspaceDirectory: string | null;
|
||||
}
|
||||
|
||||
const pendingWorkspaceArchivesByServer = new Map<string, Map<string, PendingWorkspaceArchive>>();
|
||||
|
||||
function pendingArchiveKey(input: { serverId: string; workspaceId: string }): string {
|
||||
return `${input.serverId.trim()}::${input.workspaceId.trim()}`;
|
||||
}
|
||||
|
||||
export function markWorkspaceArchivePending(input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
workspaceDirectory?: string | null;
|
||||
}): void {
|
||||
const serverId = input.serverId.trim();
|
||||
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
|
||||
if (!serverId || !workspaceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const archives = pendingWorkspaceArchivesByServer.get(serverId) ?? new Map();
|
||||
archives.set(pendingArchiveKey({ serverId, workspaceId }), {
|
||||
workspaceId,
|
||||
workspaceDirectory: normalizeWorkspacePath(input.workspaceDirectory),
|
||||
});
|
||||
pendingWorkspaceArchivesByServer.set(serverId, archives);
|
||||
}
|
||||
|
||||
export function clearWorkspaceArchivePending(input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}): void {
|
||||
const serverId = input.serverId.trim();
|
||||
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
|
||||
if (!serverId || !workspaceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const archives = pendingWorkspaceArchivesByServer.get(serverId);
|
||||
if (!archives) {
|
||||
return;
|
||||
}
|
||||
archives.delete(pendingArchiveKey({ serverId, workspaceId }));
|
||||
if (archives.size === 0) {
|
||||
pendingWorkspaceArchivesByServer.delete(serverId);
|
||||
}
|
||||
}
|
||||
|
||||
export function isWorkspaceArchivePending(input: {
|
||||
serverId: string;
|
||||
workspaceId?: string | null;
|
||||
workspaceDirectory?: string | null;
|
||||
}): boolean {
|
||||
const serverId = input.serverId.trim();
|
||||
if (!serverId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const archives = pendingWorkspaceArchivesByServer.get(serverId);
|
||||
if (!archives) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
|
||||
if (workspaceId && archives.has(pendingArchiveKey({ serverId, workspaceId }))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const workspaceDirectory = normalizeWorkspacePath(input.workspaceDirectory);
|
||||
if (!workspaceDirectory) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const archive of archives.values()) {
|
||||
if (archive.workspaceDirectory === workspaceDirectory) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function shouldSuppressWorkspaceForLocalArchive(input: {
|
||||
serverId: string;
|
||||
workspace: WorkspaceDescriptor;
|
||||
isArchivePending: (params: { serverId: string; cwd: string }) => boolean;
|
||||
}): boolean {
|
||||
return (
|
||||
input.workspace.archivingAt !== null &&
|
||||
input.isArchivePending({
|
||||
serverId: input.serverId,
|
||||
cwd: input.workspace.workspaceDirectory,
|
||||
})
|
||||
);
|
||||
return isWorkspaceArchivePending({
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspace.id,
|
||||
workspaceDirectory: input.workspace.workspaceDirectory,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -208,12 +208,14 @@ vi.mock("@/desktop/updates/desktop-updates", () => ({
|
||||
|
||||
const daemonCommandMocks = vi.hoisted(() => ({
|
||||
getCliDaemonStatusMock: vi.fn(),
|
||||
startDesktopDaemonMock: vi.fn(),
|
||||
stopDesktopDaemonMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/desktop/daemon/desktop-daemon", () => ({
|
||||
getCliDaemonStatus: daemonCommandMocks.getCliDaemonStatusMock,
|
||||
shouldUseDesktopDaemon: vi.fn(() => true),
|
||||
startDesktopDaemon: daemonCommandMocks.startDesktopDaemonMock,
|
||||
stopDesktopDaemon: daemonCommandMocks.stopDesktopDaemonMock,
|
||||
}));
|
||||
|
||||
@@ -234,8 +236,15 @@ describe("LocalDaemonSection", () => {
|
||||
settingsState.updateSettings.mockReset();
|
||||
settingsState.updateSettings.mockResolvedValue();
|
||||
daemonStatusState.data.status.status = "running";
|
||||
daemonStatusState.data.status.desktopManaged = true;
|
||||
daemonStatusState.setStatus.mockReset();
|
||||
daemonStatusState.refetch.mockReset();
|
||||
daemonCommandMocks.startDesktopDaemonMock.mockReset();
|
||||
daemonCommandMocks.startDesktopDaemonMock.mockResolvedValue({
|
||||
...daemonStatusState.data.status,
|
||||
status: "running",
|
||||
desktopManaged: true,
|
||||
});
|
||||
daemonCommandMocks.stopDesktopDaemonMock.mockReset();
|
||||
daemonCommandMocks.stopDesktopDaemonMock.mockResolvedValue({
|
||||
...daemonStatusState.data.status,
|
||||
@@ -297,4 +306,57 @@ describe("LocalDaemonSection", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("persists paused management before stopping the desktop-managed daemon", async () => {
|
||||
confirmDialogMock.mockResolvedValue(true);
|
||||
const screen = render(<LocalDaemonSection />);
|
||||
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Manage built-in daemon" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(daemonCommandMocks.stopDesktopDaemonMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(settingsState.updateSettings.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
daemonCommandMocks.stopDesktopDaemonMock.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it("does not stop a manually managed daemon when pausing built-in daemon management", async () => {
|
||||
confirmDialogMock.mockResolvedValue(true);
|
||||
daemonStatusState.data.status.desktopManaged = false;
|
||||
const screen = render(<LocalDaemonSection />);
|
||||
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Manage built-in daemon" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(settingsState.updateSettings).toHaveBeenCalledWith({
|
||||
daemon: {
|
||||
manageBuiltInDaemon: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(daemonCommandMocks.stopDesktopDaemonMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts the built-in daemon when management is re-enabled", async () => {
|
||||
settingsState.settings.daemon.manageBuiltInDaemon = false;
|
||||
const screen = render(<LocalDaemonSection />);
|
||||
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Manage built-in daemon" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(daemonCommandMocks.startDesktopDaemonMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(settingsState.updateSettings).toHaveBeenCalledWith({
|
||||
daemon: {
|
||||
manageBuiltInDaemon: true,
|
||||
},
|
||||
});
|
||||
expect(daemonStatusState.setStatus).toHaveBeenCalledWith({
|
||||
...daemonStatusState.data.status,
|
||||
status: "running",
|
||||
desktopManaged: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
|
||||
import {
|
||||
getCliDaemonStatus,
|
||||
shouldUseDesktopDaemon,
|
||||
startDesktopDaemon,
|
||||
stopDesktopDaemon,
|
||||
} from "@/desktop/daemon/desktop-daemon";
|
||||
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
|
||||
@@ -41,6 +42,12 @@ function useDaemonManagementToggle(args: {
|
||||
if (!settings.manageBuiltInDaemon) {
|
||||
setIsUpdatingDaemonManagement(true);
|
||||
void updateSettings({ manageBuiltInDaemon: true })
|
||||
.then(() => startDesktopDaemon())
|
||||
.then((newStatus) => {
|
||||
setStatus(newStatus);
|
||||
refetch();
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to update built-in daemon management", error);
|
||||
Alert.alert("Error", "Unable to update built-in daemon management.");
|
||||
@@ -66,25 +73,29 @@ function useDaemonManagementToggle(args: {
|
||||
|
||||
setIsUpdatingDaemonManagement(true);
|
||||
|
||||
const stopPromise =
|
||||
daemonStatus?.status === "running"
|
||||
? stopDesktopDaemon()
|
||||
: Promise.resolve(daemonStatus ?? null);
|
||||
|
||||
void stopPromise
|
||||
void updateSettings({ manageBuiltInDaemon: false })
|
||||
.then(() => {
|
||||
if (daemonStatus?.status === "running" && daemonStatus.desktopManaged) {
|
||||
return stopDesktopDaemon();
|
||||
}
|
||||
return daemonStatus ?? null;
|
||||
})
|
||||
.then((newStatus) => {
|
||||
if (newStatus) {
|
||||
setStatus(newStatus);
|
||||
}
|
||||
return updateSettings({ manageBuiltInDaemon: false });
|
||||
return;
|
||||
})
|
||||
.then(() => {
|
||||
refetch();
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to pause built-in daemon management", error);
|
||||
Alert.alert("Error", "Unable to pause built-in daemon management.");
|
||||
console.error("[Settings] Failed to stop built-in daemon", error);
|
||||
Alert.alert(
|
||||
"Error",
|
||||
"Built-in daemon management was paused, but Paseo could not stop the daemon.",
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsUpdatingDaemonManagement(false);
|
||||
|
||||
@@ -72,6 +72,34 @@ describe("useAgentInitialization", () => {
|
||||
expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("tail");
|
||||
});
|
||||
|
||||
it("times out initialization after 30 seconds", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = makeClient();
|
||||
useSessionStore.getState().initializeSession(serverId, client as never);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAgentInitialization({ serverId, client: client as never }),
|
||||
);
|
||||
|
||||
const promise = result.current.ensureAgentIsInitialized(agentId);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(29_999);
|
||||
});
|
||||
expect(getInitDeferred(getInitKey(serverId, agentId))).toBeDefined();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1);
|
||||
});
|
||||
|
||||
await expect(promise).rejects.toThrow("History sync timed out after 30s");
|
||||
expect(getInitDeferred(getInitKey(serverId, agentId))).toBeUndefined();
|
||||
expect(useSessionStore.getState().sessions[serverId]?.initializingAgents.get(agentId)).toBe(
|
||||
false,
|
||||
);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("refresh fetches a bounded canonical tail after refreshing the agent", async () => {
|
||||
const client = makeClient();
|
||||
const { result } = renderHook(() =>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "@/utils/agent-initialization";
|
||||
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
|
||||
|
||||
const INIT_TIMEOUT_MS = 5 * 60_000;
|
||||
const INIT_TIMEOUT_MS = 30_000;
|
||||
|
||||
export function useAgentInitialization({
|
||||
serverId,
|
||||
|
||||
22
packages/app/src/hooks/use-latched-boolean.test.tsx
Normal file
22
packages/app/src/hooks/use-latched-boolean.test.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { useLatchedBoolean } from "./use-latched-boolean";
|
||||
|
||||
describe("useLatchedBoolean", () => {
|
||||
it("stays true after the input first becomes true", () => {
|
||||
const { result, rerender } = renderHook(({ value }) => useLatchedBoolean(value), {
|
||||
initialProps: { value: false },
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
rerender({ value: true });
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
rerender({ value: false });
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
});
|
||||
13
packages/app/src/hooks/use-latched-boolean.ts
Normal file
13
packages/app/src/hooks/use-latched-boolean.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useLatchedBoolean(value: boolean): boolean {
|
||||
const [hasLatched, setHasLatched] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
setHasLatched(true);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return hasLatched || value;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@/stores/session-store-hooks";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import { shouldSuppressWorkspaceForLocalArchive } from "@/contexts/session-workspace-upserts";
|
||||
|
||||
const EMPTY_ORDER: string[] = [];
|
||||
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
|
||||
@@ -288,6 +289,9 @@ export function useSidebarWorkspacesList(options?: {
|
||||
});
|
||||
for (const entry of payload.entries) {
|
||||
const workspace = toWorkspaceDescriptor(entry);
|
||||
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
|
||||
continue;
|
||||
}
|
||||
next.set(workspace.id, workspace);
|
||||
}
|
||||
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
|
||||
|
||||
@@ -188,6 +188,21 @@ describe("DaemonStartService", () => {
|
||||
expect(service.getLastError()).toBeNull();
|
||||
});
|
||||
|
||||
it("recordError surfaces an external error and notifies subscribers", () => {
|
||||
const fake = createFakeStore();
|
||||
const service = new DaemonStartService({
|
||||
store: fake.store,
|
||||
startDesktopDaemon: async () => makeStatus(),
|
||||
});
|
||||
const notifications = vi.fn();
|
||||
service.subscribe(notifications);
|
||||
|
||||
service.recordError("settings file unreadable");
|
||||
|
||||
expect(service.getLastError()).toBe("settings file unreadable");
|
||||
expect(notifications).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stops notifying after a subscriber unsubscribes", async () => {
|
||||
const fake = createFakeStore();
|
||||
let notifications = 0;
|
||||
|
||||
@@ -53,6 +53,10 @@ export class DaemonStartService {
|
||||
return this.lastError;
|
||||
}
|
||||
|
||||
recordError(message: string): void {
|
||||
this.setLastError(message);
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return this.inFlightCount > 0;
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ import {
|
||||
buildLocalDaemonTransportUrl,
|
||||
createDesktopLocalDaemonTransportFactory,
|
||||
} from "@/desktop/daemon/desktop-daemon-transport";
|
||||
import { isDev } from "@/constants/platform";
|
||||
import { replaceFetchedAgentDirectory } from "@/utils/agent-directory-sync";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
@@ -453,7 +452,6 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||
clientType: "mobile" as const,
|
||||
appVersion: resolveAppVersion() ?? undefined,
|
||||
runtimeGeneration,
|
||||
...(isDev ? { runtimeMetricsIntervalMs: 10_000 } : {}),
|
||||
};
|
||||
if (connection.type === "directSocket" || connection.type === "directPipe") {
|
||||
return new DaemonClient({
|
||||
|
||||
@@ -45,7 +45,7 @@ function getWorkspaceHostStateTitle(
|
||||
state: Extract<WorkspaceRouteState, { kind: "unreachable" }>,
|
||||
): string {
|
||||
if (state.connectionStatus === "connecting" || state.connectionStatus === "idle") {
|
||||
return `Connecting to ${state.hostName}`;
|
||||
return "Connecting";
|
||||
}
|
||||
if (state.connectionStatus === "offline") {
|
||||
return `${state.hostName} is offline`;
|
||||
@@ -87,7 +87,9 @@ function WorkspaceUnreachable({
|
||||
<View style={styles.textStack}>
|
||||
<Text style={styles.title}>{getWorkspaceHostStateTitle(state)}</Text>
|
||||
<Text style={styles.description}>
|
||||
Host status: {formatConnectionStatus(state.connectionStatus)}
|
||||
{state.connectionStatus === "connecting" || state.connectionStatus === "idle"
|
||||
? state.hostName
|
||||
: `Host status: ${formatConnectionStatus(state.connectionStatus)}`}
|
||||
</Text>
|
||||
{state.lastError ? (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
|
||||
@@ -1259,7 +1259,7 @@ function renderWorkspaceScreenGateShell(input: {
|
||||
|
||||
return (
|
||||
<WorkspaceFocusProvider workspaceKey={input.workspaceKey}>
|
||||
<View style={containerWithWorkspaceBackgroundStyle}>
|
||||
<View style={styles.container}>
|
||||
<View style={styles.threePaneRow}>
|
||||
<View style={styles.centerColumn}>
|
||||
<WorkspaceScreenGateFrame>{input.gate}</WorkspaceScreenGateFrame>
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
isLocalWorktreeArchivePending,
|
||||
useCheckoutGitActionsStore,
|
||||
} from "@/stores/checkout-git-actions-store";
|
||||
import {
|
||||
clearWorkspaceArchivePending,
|
||||
isWorkspaceArchivePending,
|
||||
} from "@/contexts/session-workspace-upserts";
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: {
|
||||
@@ -53,6 +57,8 @@ describe("checkout-git-actions-store", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
__resetCheckoutGitActionsStoreForTests();
|
||||
clearWorkspaceArchivePending({ serverId, workspaceId: cwd });
|
||||
clearWorkspaceArchivePending({ serverId, workspaceId: "ws-feature" });
|
||||
appQueryClient.clear();
|
||||
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
|
||||
});
|
||||
@@ -60,6 +66,8 @@ describe("checkout-git-actions-store", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
__resetCheckoutGitActionsStoreForTests();
|
||||
clearWorkspaceArchivePending({ serverId, workspaceId: cwd });
|
||||
clearWorkspaceArchivePending({ serverId, workspaceId: "ws-feature" });
|
||||
appQueryClient.clear();
|
||||
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
|
||||
});
|
||||
@@ -213,6 +221,36 @@ describe("checkout-git-actions-store", () => {
|
||||
|
||||
deferred.resolve({});
|
||||
await archive;
|
||||
|
||||
expect(
|
||||
isWorkspaceArchivePending({
|
||||
serverId,
|
||||
workspaceId: cwd,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("hides an archived worktree when the workspace map is keyed by opaque id", async () => {
|
||||
const deferred = createDeferred<Record<string, never>>();
|
||||
const client = {
|
||||
archivePaseoWorktree: vi.fn(() => deferred.promise),
|
||||
};
|
||||
const featureWorkspace = workspace({
|
||||
id: "ws-feature",
|
||||
name: "feature",
|
||||
workspaceDirectory: cwd,
|
||||
});
|
||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
||||
useSessionStore.getState().setWorkspaces(serverId, new Map([["ws-feature", featureWorkspace]]));
|
||||
|
||||
const archive = useCheckoutGitActionsStore
|
||||
.getState()
|
||||
.archiveWorktree({ serverId, cwd, worktreePath: cwd });
|
||||
|
||||
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has("ws-feature")).toBe(false);
|
||||
|
||||
deferred.resolve({});
|
||||
await archive;
|
||||
});
|
||||
|
||||
it("restores an optimistically hidden worktree when archive fails", async () => {
|
||||
|
||||
@@ -8,6 +8,14 @@ import {
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { useWorkspaceTabsStore } from "@/stores/workspace-tabs-store";
|
||||
import {
|
||||
clearWorkspaceArchivePending,
|
||||
markWorkspaceArchivePending,
|
||||
} from "@/contexts/session-workspace-upserts";
|
||||
import {
|
||||
resolveWorkspaceIdByExecutionDirectory,
|
||||
resolveWorkspaceMapKeyByIdentity,
|
||||
} from "@/utils/workspace-execution";
|
||||
|
||||
const SUCCESS_DISPLAY_MS = 1000;
|
||||
|
||||
@@ -169,10 +177,15 @@ function snapshotWorktreeArchiveState(input: {
|
||||
serverId: string;
|
||||
worktreePath: string;
|
||||
}): WorktreeArchiveSnapshot {
|
||||
const workspaces = useSessionStore.getState().sessions[input.serverId]?.workspaces;
|
||||
const workspaceId =
|
||||
resolveWorkspaceIdByExecutionDirectory({
|
||||
workspaces: workspaces?.values(),
|
||||
workspaceDirectory: input.worktreePath,
|
||||
}) ?? input.worktreePath;
|
||||
const workspaceKey = resolveWorkspaceMapKeyByIdentity({ workspaces, workspaceId });
|
||||
return {
|
||||
workspace:
|
||||
useSessionStore.getState().sessions[input.serverId]?.workspaces.get(input.worktreePath) ??
|
||||
null,
|
||||
workspace: workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null,
|
||||
worktreeLists: appQueryClient.getQueriesData({
|
||||
predicate: (query) =>
|
||||
isWorktreeListQuery({ queryKey: query.queryKey, serverId: input.serverId }),
|
||||
@@ -437,14 +450,26 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
|
||||
run: async () => {
|
||||
const client = resolveClient(serverId);
|
||||
const snapshot = snapshotWorktreeArchiveState({ serverId, worktreePath });
|
||||
markWorkspaceArchivePending({
|
||||
serverId,
|
||||
workspaceId: snapshot.workspace?.id ?? worktreePath,
|
||||
workspaceDirectory: snapshot.workspace?.workspaceDirectory ?? worktreePath,
|
||||
});
|
||||
removeWorktreeFromCachedLists({ serverId, worktreePath });
|
||||
removeWorktreeFromSessionStore({ serverId, worktreePath });
|
||||
removeWorktreeFromSessionStore({
|
||||
serverId,
|
||||
worktreePath: snapshot.workspace?.id ?? worktreePath,
|
||||
});
|
||||
try {
|
||||
const payload = await client.archivePaseoWorktree({ worktreePath });
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
} catch (error) {
|
||||
clearWorkspaceArchivePending({
|
||||
serverId,
|
||||
workspaceId: snapshot.workspace?.id ?? worktreePath,
|
||||
});
|
||||
restoreWorktreeArchiveState({ serverId, snapshot });
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
WorkspaceDescriptorPayload,
|
||||
} from "@server/shared/messages";
|
||||
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
||||
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution";
|
||||
import {
|
||||
createAgentLastActivityCoalescer,
|
||||
type AgentLastActivityCommitter,
|
||||
@@ -1114,11 +1115,15 @@ export const useSessionStore = create<SessionStore>()(
|
||||
removeWorkspace: (serverId, workspaceId) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session || !session.workspaces.has(workspaceId)) {
|
||||
const workspaceKey = resolveWorkspaceMapKeyByIdentity({
|
||||
workspaces: session?.workspaces,
|
||||
workspaceId,
|
||||
});
|
||||
if (!session || !workspaceKey) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(session.workspaces);
|
||||
next.delete(workspaceId);
|
||||
next.delete(workspaceKey);
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
|
||||
@@ -955,6 +955,38 @@ describe("processAgentStreamEvents", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves a live block trailing newline after promoting completed markdown blocks", () => {
|
||||
const result = processAgentStreamEvents({
|
||||
events: [
|
||||
makeStreamReducerEvent(
|
||||
makeTimelineEvent(
|
||||
"Done. I added `[TimelineMerge] ...` logging around the suspicious merge",
|
||||
),
|
||||
238,
|
||||
),
|
||||
makeStreamReducerEvent(makeTimelineEvent("/reconcile paths.\n\nChanged:\n"), 239),
|
||||
makeStreamReducerEvent(makeTimelineEvent("- [timeline-debug.ts]"), 240),
|
||||
],
|
||||
currentTail: [],
|
||||
currentHead: [],
|
||||
currentCursor: undefined,
|
||||
currentAgent: null,
|
||||
});
|
||||
|
||||
expect(result.changedTail).toBe(true);
|
||||
expect(result.changedHead).toBe(true);
|
||||
expect(result.tail).toHaveLength(1);
|
||||
expect(result.tail[0]).toMatchObject({
|
||||
kind: "assistant_message",
|
||||
text: "Done. I added `[TimelineMerge] ...` logging around the suspicious merge/reconcile paths.",
|
||||
});
|
||||
expect(result.head).toHaveLength(1);
|
||||
expect(result.head[0]).toMatchObject({
|
||||
kind: "assistant_message",
|
||||
text: "Changed:\n- [timeline-debug.ts]",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not promote a markdown block that is still inside an open code fence", () => {
|
||||
const result = processAgentStreamEvents({
|
||||
events: [
|
||||
|
||||
@@ -793,6 +793,10 @@ function createAssistantBlockId(params: { groupId: string; blockIndex: number })
|
||||
return `${params.groupId}:block:${params.blockIndex}`;
|
||||
}
|
||||
|
||||
function getTrailingNewlineSuffix(text: string): string {
|
||||
return /\n+$/.exec(text)?.[0] ?? "";
|
||||
}
|
||||
|
||||
function getActiveAssistantHeadIndex(head: StreamItem[]): number {
|
||||
for (let index = head.length - 1; index >= 0; index -= 1) {
|
||||
if (head[index]?.kind === "assistant_message") {
|
||||
@@ -832,7 +836,7 @@ function promoteCompletedAssistantBlocks(params: { tail: StreamItem[]; head: Str
|
||||
const blockGroupId = activeItem.blockGroupId ?? activeItem.id;
|
||||
const firstBlockIndex = activeItem.blockIndex ?? 0;
|
||||
const completedBlocks = blocks.slice(0, -1);
|
||||
const liveBlock = blocks[blocks.length - 1] ?? "";
|
||||
const liveBlock = `${blocks[blocks.length - 1] ?? ""}${getTrailingNewlineSuffix(activeItem.text)}`;
|
||||
const promotedItems = completedBlocks.map<AssistantMessageItem>((block, offset) => ({
|
||||
kind: "assistant_message",
|
||||
id: createAssistantBlockId({
|
||||
|
||||
96
packages/app/src/utils/terminal-renderer-readiness.test.ts
Normal file
96
packages/app/src/utils/terminal-renderer-readiness.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
applyTerminalRendererReadyChange,
|
||||
shouldReplayTerminalSnapshotForRenderer,
|
||||
shouldShowTerminalLoadingOverlay,
|
||||
} from "./terminal-renderer-readiness";
|
||||
|
||||
describe("terminal-renderer-readiness", () => {
|
||||
it("preserves the attach loader even after the renderer is ready", () => {
|
||||
expect(
|
||||
shouldShowTerminalLoadingOverlay({
|
||||
isWorkspaceFocused: true,
|
||||
hasStreamError: false,
|
||||
isAttaching: true,
|
||||
rendererReadyStreamKey: "scope:terminal-1",
|
||||
terminalStreamKey: "scope:terminal-1",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the loader visible until the current renderer is ready", () => {
|
||||
expect(
|
||||
shouldShowTerminalLoadingOverlay({
|
||||
isWorkspaceFocused: true,
|
||||
hasStreamError: false,
|
||||
isAttaching: false,
|
||||
rendererReadyStreamKey: null,
|
||||
terminalStreamKey: "scope:terminal-1",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("hides the loader only after attach completes and the current renderer is ready", () => {
|
||||
expect(
|
||||
shouldShowTerminalLoadingOverlay({
|
||||
isWorkspaceFocused: true,
|
||||
hasStreamError: false,
|
||||
isAttaching: false,
|
||||
rendererReadyStreamKey: "scope:terminal-1",
|
||||
terminalStreamKey: "scope:terminal-1",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not cover stream errors", () => {
|
||||
expect(
|
||||
shouldShowTerminalLoadingOverlay({
|
||||
isWorkspaceFocused: true,
|
||||
hasStreamError: true,
|
||||
isAttaching: true,
|
||||
rendererReadyStreamKey: null,
|
||||
terminalStreamKey: "scope:terminal-1",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores stale unready events from an old renderer", () => {
|
||||
const current = applyTerminalRendererReadyChange("scope:terminal-2", {
|
||||
streamKey: "scope:terminal-1",
|
||||
isReady: false,
|
||||
});
|
||||
|
||||
expect(current).toBe("scope:terminal-2");
|
||||
});
|
||||
|
||||
it("clears readiness when the current renderer unmounts", () => {
|
||||
const current = applyTerminalRendererReadyChange("scope:terminal-1", {
|
||||
streamKey: "scope:terminal-1",
|
||||
isReady: false,
|
||||
});
|
||||
|
||||
expect(current).toBeNull();
|
||||
});
|
||||
|
||||
it("replays snapshots only for ready events from the current renderer", () => {
|
||||
expect(
|
||||
shouldReplayTerminalSnapshotForRenderer({
|
||||
change: { streamKey: "scope:terminal-1", isReady: true },
|
||||
terminalStreamKey: "scope:terminal-1",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldReplayTerminalSnapshotForRenderer({
|
||||
change: { streamKey: "scope:terminal-1", isReady: false },
|
||||
terminalStreamKey: "scope:terminal-1",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldReplayTerminalSnapshotForRenderer({
|
||||
change: { streamKey: "scope:terminal-1", isReady: true },
|
||||
terminalStreamKey: "scope:terminal-2",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
36
packages/app/src/utils/terminal-renderer-readiness.ts
Normal file
36
packages/app/src/utils/terminal-renderer-readiness.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export interface TerminalRendererReadyChange {
|
||||
streamKey: string;
|
||||
isReady: boolean;
|
||||
}
|
||||
|
||||
export function applyTerminalRendererReadyChange(
|
||||
currentReadyStreamKey: string | null,
|
||||
change: TerminalRendererReadyChange,
|
||||
): string | null {
|
||||
if (change.isReady) {
|
||||
return change.streamKey;
|
||||
}
|
||||
|
||||
return currentReadyStreamKey === change.streamKey ? null : currentReadyStreamKey;
|
||||
}
|
||||
|
||||
export function shouldReplayTerminalSnapshotForRenderer(input: {
|
||||
change: TerminalRendererReadyChange;
|
||||
terminalStreamKey: string;
|
||||
}): boolean {
|
||||
return input.change.isReady && input.change.streamKey === input.terminalStreamKey;
|
||||
}
|
||||
|
||||
export function shouldShowTerminalLoadingOverlay(input: {
|
||||
isWorkspaceFocused: boolean;
|
||||
hasStreamError: boolean;
|
||||
isAttaching: boolean;
|
||||
rendererReadyStreamKey: string | null;
|
||||
terminalStreamKey: string;
|
||||
}): boolean {
|
||||
return (
|
||||
input.isWorkspaceFocused &&
|
||||
!input.hasStreamError &&
|
||||
(input.isAttaching || input.rendererReadyStreamKey !== input.terminalStreamKey)
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
buildLocalDaemonTransportUrl,
|
||||
createDesktopLocalDaemonTransportFactory,
|
||||
} from "@/desktop/daemon/desktop-daemon-transport";
|
||||
import { isDev } from "@/constants/platform";
|
||||
|
||||
function normalizeNonEmptyString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
@@ -79,7 +78,6 @@ export async function buildClientConfig(
|
||||
appVersion: resolveAppVersion() ?? undefined,
|
||||
suppressSendErrors: true,
|
||||
reconnect: { enabled: false },
|
||||
...(isDev ? { runtimeMetricsIntervalMs: 10_000 } : {}),
|
||||
...((connection.type === "directSocket" || connection.type === "directPipe") &&
|
||||
localTransportFactory
|
||||
? { transportFactory: localTransportFactory }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.65",
|
||||
"version": "0.1.68",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -24,7 +24,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/server": "0.1.65",
|
||||
"@getpaseo/server": "0.1.68",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.65",
|
||||
"version": "0.1.68",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
115
packages/desktop/src/daemon/daemon-manager.test.ts
Normal file
115
packages/desktop/src/daemon/daemon-manager.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings";
|
||||
import { createDaemonCommandHandlers } from "./daemon-manager";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
settings: {
|
||||
releaseChannel: "stable",
|
||||
daemon: {
|
||||
manageBuiltInDaemon: true,
|
||||
keepRunningAfterQuit: true,
|
||||
},
|
||||
},
|
||||
runCliJsonCommand: vi.fn(),
|
||||
runCliTextCommand: vi.fn(),
|
||||
spawnProcess: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
app: {
|
||||
getPath: vi.fn(() => "/tmp/paseo-user-data"),
|
||||
getVersion: vi.fn(() => "1.2.3"),
|
||||
isPackaged: false,
|
||||
},
|
||||
ipcMain: { handle: vi.fn() },
|
||||
powerMonitor: { getSystemIdleTime: vi.fn(() => 0) },
|
||||
}));
|
||||
|
||||
vi.mock("electron-log/main", () => ({
|
||||
default: { info: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("@getpaseo/server", () => ({
|
||||
resolvePaseoHome: vi.fn(() => "/tmp/paseo-home"),
|
||||
spawnProcess: mocks.spawnProcess,
|
||||
}));
|
||||
|
||||
vi.mock("../settings/desktop-settings-electron.js", () => ({
|
||||
getDesktopSettingsStore: () => ({
|
||||
get: async () => mocks.settings,
|
||||
patch: vi.fn(),
|
||||
migrateLegacyRendererSettings: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./runtime-paths.js", () => ({
|
||||
createNodeEntrypointInvocation: vi.fn(() => ({
|
||||
command: "node",
|
||||
args: [],
|
||||
env: {},
|
||||
})),
|
||||
resolveDaemonRunnerEntrypoint: vi.fn(() => ({
|
||||
entryPath: "/tmp/daemon.js",
|
||||
execArgv: [],
|
||||
})),
|
||||
runCliJsonCommand: mocks.runCliJsonCommand,
|
||||
runCliTextCommand: mocks.runCliTextCommand,
|
||||
}));
|
||||
|
||||
function desktopSettingsWithManagement(enabled: boolean) {
|
||||
return {
|
||||
...DEFAULT_DESKTOP_SETTINGS,
|
||||
daemon: {
|
||||
...DEFAULT_DESKTOP_SETTINGS.daemon,
|
||||
manageBuiltInDaemon: enabled,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("daemon-manager commands", () => {
|
||||
beforeEach(() => {
|
||||
mocks.settings = DEFAULT_DESKTOP_SETTINGS;
|
||||
mocks.runCliJsonCommand.mockReset();
|
||||
mocks.runCliTextCommand.mockReset();
|
||||
mocks.spawnProcess.mockReset();
|
||||
});
|
||||
|
||||
it("refuses start and restart while built-in daemon management is disabled", async () => {
|
||||
mocks.settings = desktopSettingsWithManagement(false);
|
||||
const handlers = createDaemonCommandHandlers();
|
||||
|
||||
await expect(handlers.start_desktop_daemon()).rejects.toThrow(
|
||||
"Built-in daemon management is disabled.",
|
||||
);
|
||||
await expect(handlers.restart_desktop_daemon()).rejects.toThrow(
|
||||
"Built-in daemon management is disabled.",
|
||||
);
|
||||
|
||||
expect(mocks.runCliJsonCommand).not.toHaveBeenCalled();
|
||||
expect(mocks.spawnProcess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps stop callable while built-in daemon management is disabled", async () => {
|
||||
mocks.settings = desktopSettingsWithManagement(false);
|
||||
mocks.runCliJsonCommand.mockResolvedValue({
|
||||
localDaemon: "stopped",
|
||||
serverId: "",
|
||||
});
|
||||
const handlers = createDaemonCommandHandlers();
|
||||
|
||||
await expect(handlers.stop_desktop_daemon()).resolves.toEqual({
|
||||
serverId: "",
|
||||
status: "stopped",
|
||||
listen: null,
|
||||
hostname: null,
|
||||
pid: null,
|
||||
home: "/tmp/paseo-home",
|
||||
version: null,
|
||||
desktopManaged: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
expect(mocks.runCliJsonCommand).toHaveBeenCalledWith(["daemon", "status", "--json"]);
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
createDesktopSettingsCommandHandlers,
|
||||
type DesktopCommandHandler,
|
||||
} from "../settings/desktop-settings-commands.js";
|
||||
import type { DesktopSettings } from "../settings/desktop-settings.js";
|
||||
import { getDesktopSettingsStore } from "../settings/desktop-settings-electron.js";
|
||||
import { isRunningUnderARM64Translation } from "../system/arm64-translation.js";
|
||||
|
||||
@@ -285,6 +286,12 @@ function shouldRestartForVersion(current: DesktopDaemonStatus): boolean {
|
||||
return Boolean(appVersion && daemonVersion && appVersion !== daemonVersion);
|
||||
}
|
||||
|
||||
function assertBuiltInDaemonManagementEnabled(settings: DesktopSettings): void {
|
||||
if (!settings.daemon.manageBuiltInDaemon) {
|
||||
throw new Error("Built-in daemon management is disabled.");
|
||||
}
|
||||
}
|
||||
|
||||
function buildStartupFailureError(
|
||||
result: { code: number | null; signal: string | null; error?: Error },
|
||||
stdout: string,
|
||||
@@ -322,6 +329,8 @@ async function pollForRunningDaemon(): Promise<DesktopDaemonStatus> {
|
||||
}
|
||||
|
||||
async function startDaemon(): Promise<DesktopDaemonStatus> {
|
||||
assertBuiltInDaemonManagementEnabled(await getDesktopSettingsStore().get());
|
||||
|
||||
const current = await resolveDesktopDaemonStatus();
|
||||
logDesktopDaemonLifecycle("initial status check before start", {
|
||||
status: current.status,
|
||||
@@ -457,6 +466,7 @@ export async function stopDesktopDaemon(): Promise<DesktopDaemonStatus> {
|
||||
}
|
||||
|
||||
async function restartDaemon(): Promise<DesktopDaemonStatus> {
|
||||
assertBuiltInDaemonManagementEnabled(await getDesktopSettingsStore().get());
|
||||
await stopDesktopDaemon();
|
||||
return startDaemon();
|
||||
}
|
||||
|
||||
@@ -43,6 +43,22 @@ describe("desktop-settings", () => {
|
||||
expect(persisted.settings).toEqual(DEFAULT_DESKTOP_SETTINGS);
|
||||
});
|
||||
|
||||
it("handles concurrent first-launch reads without racing the settings write", async () => {
|
||||
const userDataPath = await createTempUserDataDir();
|
||||
directories.add(userDataPath);
|
||||
const store = createDesktopSettingsStore({ userDataPath });
|
||||
|
||||
const settings = await Promise.all(Array.from({ length: 20 }, () => store.get()));
|
||||
const persisted = JSON.parse(await readFile(settingsFilePath(userDataPath), "utf8")) as {
|
||||
settings: DesktopSettings;
|
||||
};
|
||||
const files = await readdir(userDataPath);
|
||||
|
||||
expect(settings).toEqual(Array.from({ length: 20 }, () => DEFAULT_DESKTOP_SETTINGS));
|
||||
expect(persisted.settings).toEqual(DEFAULT_DESKTOP_SETTINGS);
|
||||
expect(files).toEqual(["desktop-settings.json"]);
|
||||
});
|
||||
|
||||
it("coerces invalid persisted values back to safe defaults", async () => {
|
||||
const userDataPath = await createTempUserDataDir();
|
||||
directories.add(userDataPath);
|
||||
@@ -94,6 +110,58 @@ describe("desktop-settings", () => {
|
||||
expect(files).toEqual(["desktop-settings.json"]);
|
||||
});
|
||||
|
||||
it("does not let stale legacy renderer settings override an explicit desktop patch", async () => {
|
||||
const userDataPath = await createTempUserDataDir();
|
||||
directories.add(userDataPath);
|
||||
const store = createDesktopSettingsStore({ userDataPath });
|
||||
|
||||
const patched = await store.patch({
|
||||
daemon: {
|
||||
manageBuiltInDaemon: false,
|
||||
},
|
||||
});
|
||||
const migrated = await store.migrateLegacyRendererSettings({
|
||||
manageBuiltInDaemon: true,
|
||||
releaseChannel: "beta",
|
||||
});
|
||||
const persisted = JSON.parse(await readFile(settingsFilePath(userDataPath), "utf8")) as {
|
||||
migrations: { legacyRendererSettingsImported: boolean };
|
||||
settings: DesktopSettings;
|
||||
};
|
||||
|
||||
expect(patched.daemon.manageBuiltInDaemon).toBe(false);
|
||||
expect(migrated.daemon.manageBuiltInDaemon).toBe(false);
|
||||
expect(migrated.releaseChannel).toBe("stable");
|
||||
expect(persisted.migrations.legacyRendererSettingsImported).toBe(true);
|
||||
expect(persisted.settings.daemon.manageBuiltInDaemon).toBe(false);
|
||||
});
|
||||
|
||||
it("does not rewrite existing settings while reading them", async () => {
|
||||
const userDataPath = await createTempUserDataDir();
|
||||
directories.add(userDataPath);
|
||||
const raw = JSON.stringify({
|
||||
version: 1,
|
||||
settings: {
|
||||
releaseChannel: "stable",
|
||||
daemon: {
|
||||
manageBuiltInDaemon: false,
|
||||
keepRunningAfterQuit: true,
|
||||
},
|
||||
},
|
||||
migrations: {
|
||||
legacyRendererSettingsImported: false,
|
||||
},
|
||||
});
|
||||
await writeFile(settingsFilePath(userDataPath), raw);
|
||||
const store = createDesktopSettingsStore({ userDataPath });
|
||||
|
||||
const settings = await store.get();
|
||||
const persisted = await readFile(settingsFilePath(userDataPath), "utf8");
|
||||
|
||||
expect(settings.daemon.manageBuiltInDaemon).toBe(false);
|
||||
expect(persisted).toBe(raw);
|
||||
});
|
||||
|
||||
it("migrates desktop-owned values from legacy renderer settings once", async () => {
|
||||
const userDataPath = await createTempUserDataDir();
|
||||
directories.add(userDataPath);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { access, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type { AppReleaseChannel } from "../features/auto-updater.js";
|
||||
@@ -58,10 +59,17 @@ function coerceBoolean(value: unknown): boolean | null {
|
||||
return typeof value === "boolean" ? value : null;
|
||||
}
|
||||
|
||||
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
||||
return error instanceof Error;
|
||||
}
|
||||
|
||||
function buildDefaultDocument(): PersistedDesktopSettingsDocument {
|
||||
return {
|
||||
version: 1,
|
||||
settings: DEFAULT_DESKTOP_SETTINGS,
|
||||
settings: {
|
||||
releaseChannel: DEFAULT_DESKTOP_SETTINGS.releaseChannel,
|
||||
daemon: { ...DEFAULT_DESKTOP_SETTINGS.daemon },
|
||||
},
|
||||
migrations: {
|
||||
legacyRendererSettingsImported: false,
|
||||
},
|
||||
@@ -159,6 +167,10 @@ function mergeDesktopSettings(
|
||||
};
|
||||
}
|
||||
|
||||
function hasLegacyRendererOwnedPatch(patch: DesktopSettingsPatch): boolean {
|
||||
return patch.releaseChannel !== undefined || patch.daemon?.manageBuiltInDaemon !== undefined;
|
||||
}
|
||||
|
||||
function coerceDocument(input: unknown): PersistedDesktopSettingsDocument {
|
||||
if (!isRecord(input)) {
|
||||
return buildDefaultDocument();
|
||||
@@ -187,13 +199,19 @@ export function createDesktopSettingsStore({
|
||||
}): DesktopSettingsStore {
|
||||
const filePath = path.join(userDataPath, DESKTOP_SETTINGS_FILENAME);
|
||||
let cachedDocument: PersistedDesktopSettingsDocument | null = null;
|
||||
let persistQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
async function persistDocument(document: PersistedDesktopSettingsDocument): Promise<void> {
|
||||
await mkdir(userDataPath, { recursive: true });
|
||||
const tempFilePath = `${filePath}.tmp`;
|
||||
await writeFile(tempFilePath, `${JSON.stringify(document, null, 2)}\n`, "utf8");
|
||||
await rename(tempFilePath, filePath);
|
||||
cachedDocument = document;
|
||||
const write = async () => {
|
||||
await mkdir(userDataPath, { recursive: true });
|
||||
const tempFilePath = `${filePath}.tmp.${process.pid}.${randomUUID()}`;
|
||||
await writeFile(tempFilePath, `${JSON.stringify(document, null, 2)}\n`, "utf8");
|
||||
await rename(tempFilePath, filePath);
|
||||
cachedDocument = document;
|
||||
};
|
||||
const queued = persistQueue.then(write, write);
|
||||
persistQueue = queued.catch(() => undefined);
|
||||
await queued;
|
||||
}
|
||||
|
||||
async function loadDocument(): Promise<PersistedDesktopSettingsDocument> {
|
||||
@@ -201,19 +219,31 @@ export function createDesktopSettingsStore({
|
||||
return cachedDocument;
|
||||
}
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
await access(filePath);
|
||||
} catch {
|
||||
raw = await readFile(filePath, "utf8");
|
||||
} catch (error) {
|
||||
if (!isNodeError(error) || error.code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
const document = buildDefaultDocument();
|
||||
await persistDocument(document);
|
||||
return document;
|
||||
}
|
||||
const document = coerceDocument(JSON.parse(raw));
|
||||
cachedDocument = document;
|
||||
return document;
|
||||
}
|
||||
|
||||
async function loadWritableDocument(): Promise<PersistedDesktopSettingsDocument> {
|
||||
const document = await loadDocument();
|
||||
await persistDocument(document);
|
||||
return document;
|
||||
}
|
||||
|
||||
async function initializeLegacyRendererMigration(): Promise<PersistedDesktopSettingsDocument> {
|
||||
try {
|
||||
const raw = await readFile(filePath, "utf8");
|
||||
const document = coerceDocument(JSON.parse(raw));
|
||||
await persistDocument(document);
|
||||
return document;
|
||||
return await loadDocument();
|
||||
} catch {
|
||||
const document = buildDefaultDocument();
|
||||
await persistDocument(document);
|
||||
@@ -228,17 +258,24 @@ export function createDesktopSettingsStore({
|
||||
},
|
||||
|
||||
async patch(patch: unknown): Promise<DesktopSettings> {
|
||||
const current = await loadDocument();
|
||||
const next = mergeDesktopSettings(current.settings, coerceDesktopSettingsPatch(patch));
|
||||
const current = await loadWritableDocument();
|
||||
const coercedPatch = coerceDesktopSettingsPatch(patch);
|
||||
const next = mergeDesktopSettings(current.settings, coercedPatch);
|
||||
await persistDocument({
|
||||
...current,
|
||||
settings: next,
|
||||
migrations: {
|
||||
...current.migrations,
|
||||
legacyRendererSettingsImported:
|
||||
current.migrations.legacyRendererSettingsImported ||
|
||||
hasLegacyRendererOwnedPatch(coercedPatch),
|
||||
},
|
||||
});
|
||||
return next;
|
||||
},
|
||||
|
||||
async migrateLegacyRendererSettings(legacySettings: unknown): Promise<DesktopSettings> {
|
||||
const current = await loadDocument();
|
||||
const current = await initializeLegacyRendererMigration();
|
||||
if (current.migrations.legacyRendererSettingsImported) {
|
||||
return current.settings;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.65",
|
||||
"version": "0.1.68",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.65",
|
||||
"version": "0.1.68",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.65",
|
||||
"version": "0.1.68",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.65",
|
||||
"version": "0.1.68",
|
||||
"description": "Paseo backend server",
|
||||
"files": [
|
||||
"dist/server",
|
||||
@@ -58,8 +58,8 @@
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@getpaseo/highlight": "0.1.65",
|
||||
"@getpaseo/relay": "0.1.65",
|
||||
"@getpaseo/highlight": "0.1.68",
|
||||
"@getpaseo/relay": "0.1.68",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@mariozechner/pi-agent-core": "^0.70.2",
|
||||
"@mariozechner/pi-ai": "^0.70.2",
|
||||
@@ -74,6 +74,7 @@
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.18.2",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"node-pty": "1.2.0-beta.11",
|
||||
"onnxruntime-node": "^1.23.0",
|
||||
"openai": "^4.20.0",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { AgentManager } from "./agent-manager.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import type {
|
||||
AgentClient,
|
||||
AgentCreateSessionOptions,
|
||||
AgentFeature,
|
||||
AgentLaunchContext,
|
||||
AgentProvider,
|
||||
@@ -705,6 +706,48 @@ test("createAgent passes daemon launch env through the provider launch context",
|
||||
});
|
||||
});
|
||||
|
||||
test("createAgent passes persistSession to provider create options", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
|
||||
class CaptureClient extends TestAgentClient {
|
||||
lastCreateOptions: AgentCreateSessionOptions | undefined;
|
||||
|
||||
override async createSession(
|
||||
config: AgentSessionConfig,
|
||||
_launchContext?: AgentLaunchContext,
|
||||
options?: AgentCreateSessionOptions,
|
||||
): Promise<AgentSession> {
|
||||
this.lastCreateOptions = options;
|
||||
return new TestAgentSession(config);
|
||||
}
|
||||
}
|
||||
|
||||
const client = new CaptureClient();
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: client,
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000104",
|
||||
});
|
||||
|
||||
await manager.createAgent(
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
},
|
||||
undefined,
|
||||
{ persistSession: false },
|
||||
);
|
||||
|
||||
expect(client.lastCreateOptions).toEqual({ persistSession: false });
|
||||
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("createAgent injects paseo MCP server when manager has an MCP base URL", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentCreateSessionOptions,
|
||||
AgentFeature,
|
||||
AgentLaunchContext,
|
||||
AgentSlashCommand,
|
||||
@@ -727,6 +728,7 @@ export class AgentManager {
|
||||
labels?: Record<string, string>;
|
||||
workspaceId?: string;
|
||||
initialPrompt?: string;
|
||||
persistSession?: boolean;
|
||||
},
|
||||
): Promise<ManagedAgent> {
|
||||
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
|
||||
@@ -749,13 +751,22 @@ export class AgentManager {
|
||||
const client = await this.requireAvailableClient({
|
||||
provider: normalizedConfig.provider,
|
||||
});
|
||||
const session = await client.createSession(normalizedConfig, launchContext);
|
||||
const createOptions = this.buildCreateSessionOptions(options);
|
||||
const session = await client.createSession(normalizedConfig, launchContext, createOptions);
|
||||
return this.registerSession(session, normalizedConfig, resolvedAgentId, {
|
||||
labels: options?.labels,
|
||||
workspaceId: options?.workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
private buildCreateSessionOptions(options?: {
|
||||
persistSession?: boolean;
|
||||
}): AgentCreateSessionOptions | undefined {
|
||||
return options?.persistSession === undefined
|
||||
? undefined
|
||||
: { persistSession: options.persistSession };
|
||||
}
|
||||
|
||||
// Reconstruct an agent from provider persistence. Callers should explicitly
|
||||
// hydrate timeline history after resume.
|
||||
async resumeAgentFromPersistence(
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
||||
import { mkdtempSync, readdirSync, rmSync, statSync, realpathSync } from "fs";
|
||||
import { homedir, tmpdir } from "os";
|
||||
import path from "path";
|
||||
import pino from "pino";
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
|
||||
|
||||
import { AgentManager } from "./agent-manager.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import { createAllClients, shutdownProviders } from "./provider-registry.js";
|
||||
import { generateAndApplyAgentMetadata } from "./agent-metadata-generator.js";
|
||||
import { isProviderAvailable } from "../daemon-e2e/agent-configs.js";
|
||||
import { OpenCodeServerManager } from "./providers/opencode-agent.js";
|
||||
|
||||
const CODEX_TEST_MODEL = "gpt-5.4-mini";
|
||||
const CODEX_TEST_THINKING_OPTION_ID = "low";
|
||||
|
||||
function collectFilesRecursively(root: string, filter: (name: string) => boolean): Set<string> {
|
||||
const results = new Set<string>();
|
||||
try {
|
||||
statSync(root);
|
||||
} catch {
|
||||
return results;
|
||||
}
|
||||
const stack: string[] = [root];
|
||||
while (stack.length > 0) {
|
||||
const dir = stack.pop()!;
|
||||
let entries: ReturnType<typeof readdirSync<{ withFileTypes: true }>>;
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(full);
|
||||
} else if (entry.isFile() && filter(entry.name)) {
|
||||
results.add(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function collectCodexRolloutFiles(): Set<string> {
|
||||
const codexHome = process.env.CODEX_HOME ?? path.join(homedir(), ".codex");
|
||||
return collectFilesRecursively(path.join(codexHome, "sessions"), (name) =>
|
||||
name.startsWith("rollout-"),
|
||||
);
|
||||
}
|
||||
|
||||
function encodeClaudeProjectDir(cwd: string): string {
|
||||
return cwd.replaceAll("/", "-");
|
||||
}
|
||||
|
||||
function collectClaudeProjectFiles(cwd: string): Set<string> {
|
||||
const dir = path.join(homedir(), ".claude", "projects", encodeClaudeProjectDir(cwd));
|
||||
return collectFilesRecursively(dir, (name) => name.endsWith(".jsonl"));
|
||||
}
|
||||
|
||||
function tmpCwd(prefix: string): string {
|
||||
return realpathSync(mkdtempSync(path.join(tmpdir(), prefix)));
|
||||
}
|
||||
|
||||
describe("agent metadata generation (real agents)", () => {
|
||||
const logger = pino({ level: "silent" });
|
||||
let cwd: string;
|
||||
let paseoHome: string;
|
||||
let manager: AgentManager;
|
||||
let storage: AgentStorage;
|
||||
let codexAvailable = false;
|
||||
let claudeAvailable = false;
|
||||
let opencodeAvailable = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
[codexAvailable, claudeAvailable, opencodeAvailable] = await Promise.all([
|
||||
isProviderAvailable("codex"),
|
||||
isProviderAvailable("claude"),
|
||||
isProviderAvailable("opencode"),
|
||||
]);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
cwd = tmpCwd("metadata-cwd-");
|
||||
paseoHome = tmpCwd("metadata-paseo-home-");
|
||||
storage = new AgentStorage(path.join(paseoHome, "agents"), logger);
|
||||
manager = new AgentManager({
|
||||
clients: createAllClients(logger),
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await shutdownProviders(logger);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
rmSync(paseoHome, { recursive: true, force: true });
|
||||
}, 60000);
|
||||
|
||||
test("generates a title using a real Codex agent without persisting a rollout", async (ctx) => {
|
||||
if (!codexAvailable) {
|
||||
ctx.skip();
|
||||
}
|
||||
const agent = await manager.createAgent(
|
||||
{
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
modeId: "auto",
|
||||
cwd: cwd,
|
||||
title: "Main Agent",
|
||||
},
|
||||
"4e0a4508-e522-4fe9-8384-cf3bf889f16d",
|
||||
);
|
||||
|
||||
const rolloutsBefore = collectCodexRolloutFiles();
|
||||
|
||||
await generateAndApplyAgentMetadata({
|
||||
agentManager: manager,
|
||||
agentId: agent.id,
|
||||
cwd: cwd,
|
||||
initialPrompt: "Use the exact title 'Metadata Title E2E'.",
|
||||
explicitTitle: null,
|
||||
paseoHome,
|
||||
logger,
|
||||
});
|
||||
|
||||
await storage.flush();
|
||||
const record = await storage.get(agent.id);
|
||||
expect(record?.title).toBe("Metadata Title E2E");
|
||||
|
||||
const rolloutsAfter = collectCodexRolloutFiles();
|
||||
const newRollouts = [...rolloutsAfter].filter((file) => !rolloutsBefore.has(file));
|
||||
expect(newRollouts).toEqual([]);
|
||||
|
||||
await manager.closeAgent(agent.id);
|
||||
}, 180000);
|
||||
|
||||
test("generates a title using a real Claude agent without persisting a session", async (ctx) => {
|
||||
if (!claudeAvailable) {
|
||||
ctx.skip();
|
||||
}
|
||||
const agent = await manager.createAgent(
|
||||
{
|
||||
provider: "claude",
|
||||
model: "haiku",
|
||||
thinkingOptionId: "on",
|
||||
cwd: cwd,
|
||||
title: "Main Claude Agent",
|
||||
},
|
||||
"5e1b5619-f633-5fea-9495-d04bf990f27e",
|
||||
);
|
||||
|
||||
const sessionsBefore = collectClaudeProjectFiles(cwd);
|
||||
|
||||
await generateAndApplyAgentMetadata({
|
||||
agentManager: manager,
|
||||
agentId: agent.id,
|
||||
cwd: cwd,
|
||||
initialPrompt: "Use the exact title 'Claude Metadata Title'.",
|
||||
explicitTitle: null,
|
||||
paseoHome,
|
||||
logger,
|
||||
});
|
||||
|
||||
await storage.flush();
|
||||
const record = await storage.get(agent.id);
|
||||
expect(record?.title).toBe("Claude Metadata Title");
|
||||
|
||||
const sessionsAfter = collectClaudeProjectFiles(cwd);
|
||||
const newSessions = [...sessionsAfter].filter((file) => !sessionsBefore.has(file));
|
||||
expect(newSessions).toEqual([]);
|
||||
|
||||
await manager.closeAgent(agent.id);
|
||||
}, 180000);
|
||||
|
||||
test("generates a title using a real OpenCode agent and deletes the ephemeral session", async (ctx) => {
|
||||
if (!opencodeAvailable) {
|
||||
ctx.skip();
|
||||
}
|
||||
const agent = await manager.createAgent(
|
||||
{
|
||||
provider: "opencode",
|
||||
model: "opencode/gpt-5-nano",
|
||||
modeId: "build",
|
||||
cwd: cwd,
|
||||
title: "Main OpenCode Agent",
|
||||
},
|
||||
"6e2c6720-e744-6fdb-a5a6-e15cf0a1f380",
|
||||
);
|
||||
|
||||
const acquisition = await OpenCodeServerManager.getInstance(logger).acquire({ force: false });
|
||||
const inspectClient = createOpencodeClient({
|
||||
baseUrl: acquisition.server.url,
|
||||
directory: cwd,
|
||||
});
|
||||
try {
|
||||
const sessionsBeforeRes = await inspectClient.session.list({ directory: cwd });
|
||||
const sessionIdsBefore = new Set((sessionsBeforeRes.data ?? []).map((session) => session.id));
|
||||
|
||||
await generateAndApplyAgentMetadata({
|
||||
agentManager: manager,
|
||||
agentId: agent.id,
|
||||
cwd: cwd,
|
||||
initialPrompt: "Use the exact title 'OpenCode Metadata Title'.",
|
||||
explicitTitle: null,
|
||||
paseoHome,
|
||||
logger,
|
||||
});
|
||||
|
||||
await storage.flush();
|
||||
const record = await storage.get(agent.id);
|
||||
expect(record?.title).toBe("OpenCode Metadata Title");
|
||||
|
||||
const sessionsAfterRes = await inspectClient.session.list({ directory: cwd });
|
||||
const newSessions = (sessionsAfterRes.data ?? []).filter(
|
||||
(session) => !sessionIdsBefore.has(session.id),
|
||||
);
|
||||
expect(newSessions).toEqual([]);
|
||||
} finally {
|
||||
acquisition.release();
|
||||
await manager.closeAgent(agent.id);
|
||||
}
|
||||
}, 180000);
|
||||
});
|
||||
@@ -1,184 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { execSync } from "child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync, realpathSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import path from "path";
|
||||
import pino from "pino";
|
||||
|
||||
import { AgentManager } from "./agent-manager.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import { createAllClients, shutdownProviders } from "./provider-registry.js";
|
||||
import { generateAndApplyAgentMetadata } from "./agent-metadata-generator.js";
|
||||
import {
|
||||
createWorktree as createWorktreePrimitive,
|
||||
validateBranchSlug,
|
||||
type CreateWorktreeOptions,
|
||||
type WorktreeConfig,
|
||||
} from "../../utils/worktree.js";
|
||||
|
||||
interface LegacyCreateWorktreeTestOptions {
|
||||
branchName: string;
|
||||
cwd: string;
|
||||
baseBranch: string;
|
||||
worktreeSlug: string;
|
||||
runSetup?: boolean;
|
||||
paseoHome?: string;
|
||||
}
|
||||
|
||||
function createLegacyWorktreeForTest(
|
||||
options: CreateWorktreeOptions | LegacyCreateWorktreeTestOptions,
|
||||
): Promise<WorktreeConfig> {
|
||||
if ("source" in options) {
|
||||
return createWorktreePrimitive(options);
|
||||
}
|
||||
|
||||
return createWorktreePrimitive({
|
||||
cwd: options.cwd,
|
||||
worktreeSlug: options.worktreeSlug,
|
||||
source: {
|
||||
kind: "branch-off",
|
||||
baseBranch: options.baseBranch,
|
||||
newBranchName: options.branchName,
|
||||
},
|
||||
runSetup: options.runSetup ?? true,
|
||||
paseoHome: options.paseoHome,
|
||||
});
|
||||
}
|
||||
|
||||
const CODEX_TEST_MODEL = "gpt-5.4-mini";
|
||||
const CODEX_TEST_THINKING_OPTION_ID = "low";
|
||||
|
||||
const shouldRun = !process.env.CI && !!process.env.OPENAI_API_KEY;
|
||||
|
||||
function tmpCwd(prefix: string): string {
|
||||
return realpathSync(mkdtempSync(path.join(tmpdir(), prefix)));
|
||||
}
|
||||
|
||||
function initGitRepo(repoDir: string): void {
|
||||
execSync("git init -b main", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config user.email 'paseo-test@example.com'", {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execSync("git config user.name 'Paseo Test'", {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
writeFileSync(path.join(repoDir, "README.md"), "init\n");
|
||||
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
}
|
||||
|
||||
(shouldRun ? describe : describe.skip)("agent metadata generation (real agents)", () => {
|
||||
const logger = pino({ level: "silent" });
|
||||
let repoDir: string;
|
||||
let paseoHome: string;
|
||||
let storagePath: string;
|
||||
let manager: AgentManager;
|
||||
let storage: AgentStorage;
|
||||
let codexSessionDir: string;
|
||||
let previousCodexSessionDir: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
repoDir = tmpCwd("metadata-repo-");
|
||||
initGitRepo(repoDir);
|
||||
paseoHome = tmpCwd("metadata-paseo-home-");
|
||||
storagePath = path.join(paseoHome, "agents");
|
||||
storage = new AgentStorage(storagePath, logger);
|
||||
manager = new AgentManager({
|
||||
clients: createAllClients(logger),
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
codexSessionDir = tmpCwd("codex-sessions-");
|
||||
previousCodexSessionDir = process.env.CODEX_SESSION_DIR;
|
||||
process.env.CODEX_SESSION_DIR = codexSessionDir;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
process.env.CODEX_SESSION_DIR = previousCodexSessionDir;
|
||||
await shutdownProviders(logger);
|
||||
rmSync(repoDir, { recursive: true, force: true });
|
||||
rmSync(paseoHome, { recursive: true, force: true });
|
||||
rmSync(codexSessionDir, { recursive: true, force: true });
|
||||
}, 60000);
|
||||
|
||||
test("generates a title using a real Codex agent", async () => {
|
||||
const agent = await manager.createAgent(
|
||||
{
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
modeId: "auto",
|
||||
cwd: repoDir,
|
||||
title: "Main Agent",
|
||||
},
|
||||
"4e0a4508-e522-4fe9-8384-cf3bf889f16d",
|
||||
);
|
||||
|
||||
await generateAndApplyAgentMetadata({
|
||||
agentManager: manager,
|
||||
agentId: agent.id,
|
||||
cwd: repoDir,
|
||||
initialPrompt: "Use the exact title 'Metadata Title E2E'.",
|
||||
explicitTitle: null,
|
||||
paseoHome,
|
||||
logger,
|
||||
});
|
||||
|
||||
await storage.flush();
|
||||
const record = await storage.get(agent.id);
|
||||
expect(record?.title).toBe("Metadata Title E2E");
|
||||
|
||||
await manager.closeAgent(agent.id);
|
||||
}, 180000);
|
||||
|
||||
test("renames the worktree branch using a real Codex agent", async () => {
|
||||
const worktreeSlug = "metadata-worktree";
|
||||
const worktree = await createLegacyWorktreeForTest({
|
||||
branchName: worktreeSlug,
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug,
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
const agent = await manager.createAgent(
|
||||
{
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
modeId: "auto",
|
||||
cwd: worktree.worktreePath,
|
||||
title: "Worktree Agent",
|
||||
},
|
||||
"32bb765d-f637-44a2-9820-f2efd5261418",
|
||||
);
|
||||
|
||||
await generateAndApplyAgentMetadata({
|
||||
agentManager: manager,
|
||||
agentId: agent.id,
|
||||
cwd: worktree.worktreePath,
|
||||
initialPrompt: "Use the exact branch 'feat/metadata-worktree'.",
|
||||
explicitTitle: "Explicit Title",
|
||||
paseoHome,
|
||||
logger,
|
||||
});
|
||||
|
||||
const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", {
|
||||
cwd: worktree.worktreePath,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
const validation = validateBranchSlug(currentBranch);
|
||||
expect(validation.valid).toBe(true);
|
||||
expect(currentBranch).toBe("feat/metadata-worktree");
|
||||
|
||||
await manager.closeAgent(agent.id);
|
||||
}, 180000);
|
||||
});
|
||||
@@ -115,6 +115,7 @@ export async function generateAndApplyAgentMetadata(
|
||||
schemaName: "AgentMetadata",
|
||||
maxRetries: 2,
|
||||
providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
|
||||
persistSession: false,
|
||||
agentConfigOverrides: {
|
||||
title: "Agent metadata generator",
|
||||
internal: true,
|
||||
|
||||
@@ -86,7 +86,10 @@ describe("agent metadata generator auto-title", () => {
|
||||
});
|
||||
|
||||
expect(generateStructured).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: "/tmp/repo/metadata-worktree" }),
|
||||
expect.objectContaining({
|
||||
cwd: "/tmp/repo/metadata-worktree",
|
||||
persistSession: false,
|
||||
}),
|
||||
);
|
||||
expect(setTitle).toHaveBeenCalledWith("agent-suppressed-branch", "Generated title");
|
||||
});
|
||||
|
||||
@@ -142,7 +142,7 @@ describe("generateStructuredAgentResponseWithFallback", () => {
|
||||
}
|
||||
|
||||
it("uses the first available provider in the waterfall", async () => {
|
||||
const calls: Array<{ provider: string; model?: string }> = [];
|
||||
const calls: Array<{ provider: string; model?: string; persistSession?: boolean }> = [];
|
||||
const manager = createManager([
|
||||
{ provider: "claude", available: true, error: null },
|
||||
{ provider: "codex", available: true, error: null },
|
||||
@@ -158,17 +158,19 @@ describe("generateStructuredAgentResponseWithFallback", () => {
|
||||
{ provider: "claude", model: "haiku" },
|
||||
{ provider: "codex", model: "gpt-5.4-mini" },
|
||||
],
|
||||
persistSession: false,
|
||||
runner: async (options) => {
|
||||
calls.push({
|
||||
provider: options.agentConfig.provider,
|
||||
model: options.agentConfig.model ?? undefined,
|
||||
persistSession: options.persistSession,
|
||||
});
|
||||
return { summary: "ok" };
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ summary: "ok" });
|
||||
expect(calls).toEqual([{ provider: "claude", model: "haiku" }]);
|
||||
expect(calls).toEqual([{ provider: "claude", model: "haiku", persistSession: false }]);
|
||||
});
|
||||
|
||||
it("skips unavailable providers and uses the next available one", async () => {
|
||||
|
||||
@@ -70,6 +70,7 @@ export interface StructuredAgentGenerationOptions<T> {
|
||||
manager: AgentManager;
|
||||
agentConfig: AgentSessionConfig;
|
||||
agentId?: string;
|
||||
persistSession?: boolean;
|
||||
prompt: string;
|
||||
schema: z.ZodType<T> | JsonSchema;
|
||||
maxRetries?: number;
|
||||
@@ -86,6 +87,7 @@ export interface StructuredAgentGenerationWithFallbackOptions<T> {
|
||||
AgentSessionConfig,
|
||||
"provider" | "cwd" | "model" | "thinkingOptionId"
|
||||
>;
|
||||
persistSession?: boolean;
|
||||
maxRetries?: number;
|
||||
schemaName?: string;
|
||||
runner?: <TResult>(options: StructuredAgentGenerationOptions<TResult>) => Promise<TResult>;
|
||||
@@ -339,12 +341,15 @@ export async function getStructuredAgentResponse<T>(
|
||||
export async function generateStructuredAgentResponse<T>(
|
||||
options: StructuredAgentGenerationOptions<T>,
|
||||
): Promise<T> {
|
||||
const { manager, agentConfig, agentId, prompt, schema, maxRetries, schemaName } = options;
|
||||
const { manager, agentConfig, agentId, persistSession, prompt, schema, maxRetries, schemaName } =
|
||||
options;
|
||||
const modeId =
|
||||
agentConfig.modeId ??
|
||||
getAgentProviderDefinition(agentConfig.provider).defaultModeId ??
|
||||
undefined;
|
||||
const agent = await manager.createAgent({ ...agentConfig, modeId }, agentId);
|
||||
const agent = await manager.createAgent({ ...agentConfig, modeId }, agentId, {
|
||||
persistSession,
|
||||
});
|
||||
try {
|
||||
const caller: AgentCaller = async (nextPrompt) => {
|
||||
const result = await manager.runAgent(agent.id, nextPrompt);
|
||||
@@ -388,6 +393,7 @@ export async function generateStructuredAgentResponseWithFallback<T>(
|
||||
schema,
|
||||
providers,
|
||||
agentConfigOverrides,
|
||||
persistSession,
|
||||
maxRetries,
|
||||
schemaName,
|
||||
runner,
|
||||
@@ -423,6 +429,7 @@ export async function generateStructuredAgentResponseWithFallback<T>(
|
||||
schema,
|
||||
maxRetries,
|
||||
schemaName,
|
||||
persistSession,
|
||||
agentConfig: {
|
||||
...agentConfigOverrides,
|
||||
provider: candidate.provider,
|
||||
|
||||
@@ -472,6 +472,14 @@ export interface AgentLaunchContext {
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface AgentCreateSessionOptions {
|
||||
/**
|
||||
* Whether the provider should leave a durable native session behind.
|
||||
* Defaults to true. Providers that cannot honor false should no-op.
|
||||
*/
|
||||
persistSession?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returned by respondToPermission when the permission resolution requires
|
||||
* a follow-up turn (e.g. Codex plan approval → implementation).
|
||||
@@ -523,6 +531,7 @@ export interface AgentClient {
|
||||
createSession(
|
||||
config: AgentSessionConfig,
|
||||
launchContext?: AgentLaunchContext,
|
||||
options?: AgentCreateSessionOptions,
|
||||
): Promise<AgentSession>;
|
||||
resumeSession(
|
||||
handle: AgentPersistenceHandle,
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
type CreatePaseoWorktreeInput,
|
||||
} from "../paseo-worktree-service.js";
|
||||
import type { CreatePaseoWorktreeWorkflowFn } from "../worktree-session.js";
|
||||
import { createWorktreeCoreDeps } from "../worktree-core.js";
|
||||
import { WorkspaceGitServiceImpl } from "../workspace-git-service.js";
|
||||
import type { GitHubService } from "../../services/github-service.js";
|
||||
|
||||
@@ -307,9 +306,8 @@ function createPaseoWorktreeForMcpTest(options: {
|
||||
|
||||
return async (input, serviceOptions) => {
|
||||
options.setupContinuations?.push(serviceOptions?.setupContinuation?.kind);
|
||||
const coreDeps = createWorktreeCoreDeps(github);
|
||||
const result = await createPaseoWorktreeService(input, {
|
||||
...coreDeps,
|
||||
github,
|
||||
...(serviceOptions?.resolveDefaultBranch
|
||||
? { resolveDefaultBranch: serviceOptions.resolveDefaultBranch }
|
||||
: {}),
|
||||
@@ -685,7 +683,7 @@ describe("create_agent MCP tool", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("auto-names a create_agent branch-off worktree from the initial prompt without metadata branch rename", async () => {
|
||||
it("creates a create_agent branch-off worktree without invoking the legacy metadata branch rename", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-worktree-name-context-"));
|
||||
const repoDir = join(tempDir, "repo");
|
||||
@@ -739,9 +737,11 @@ describe("create_agent MCP tool", () => {
|
||||
});
|
||||
|
||||
const agentCwd = spies.agentManager.createAgent.mock.calls[0]?.[0].cwd as string;
|
||||
expect(
|
||||
execSync("git branch --show-current", { cwd: agentCwd, stdio: "pipe" }).toString().trim(),
|
||||
).toBe("fix-workspace-creation-naming");
|
||||
const initialBranch = execSync("git branch --show-current", { cwd: agentCwd, stdio: "pipe" })
|
||||
.toString()
|
||||
.trim();
|
||||
expect(initialBranch).not.toBe("");
|
||||
expect(initialBranch).not.toBe("main");
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||
expect(broadcasts).toHaveLength(1);
|
||||
|
||||
@@ -439,6 +439,68 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
});
|
||||
}
|
||||
|
||||
test("passes persistSession through to the Claude SDK query options", async () => {
|
||||
const createResultTurn = (sessionId: string) => [
|
||||
{
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: sessionId,
|
||||
permissionMode: "default",
|
||||
},
|
||||
{
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
duration_ms: 10,
|
||||
duration_api_ms: 8,
|
||||
is_error: false,
|
||||
num_turns: 1,
|
||||
result: "done",
|
||||
stop_reason: null,
|
||||
total_cost_usd: 0,
|
||||
usage: {},
|
||||
permission_denials: [],
|
||||
uuid: `${sessionId}-result`,
|
||||
session_id: sessionId,
|
||||
},
|
||||
];
|
||||
|
||||
const nonPersistedQueryFactory = createQueryFactoryForTurns([createResultTurn("session-1")]);
|
||||
const nonPersistedClient = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory: nonPersistedQueryFactory,
|
||||
});
|
||||
const nonPersistedSession = await nonPersistedClient.createSession(
|
||||
{
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
undefined,
|
||||
{ persistSession: false },
|
||||
);
|
||||
await nonPersistedSession.run("turn");
|
||||
await nonPersistedSession.close();
|
||||
|
||||
expect(nonPersistedQueryFactory.mock.calls[0]?.[0].options.persistSession).toBe(false);
|
||||
|
||||
const persistedQueryFactory = createQueryFactoryForTurns([createResultTurn("session-2")]);
|
||||
const persistedClient = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory: persistedQueryFactory,
|
||||
});
|
||||
const persistedSession = await persistedClient.createSession(
|
||||
{
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
undefined,
|
||||
{ persistSession: true },
|
||||
);
|
||||
await persistedSession.run("turn");
|
||||
await persistedSession.close();
|
||||
|
||||
expect(persistedQueryFactory.mock.calls[0]?.[0].options.persistSession).toBe(true);
|
||||
});
|
||||
|
||||
test("convertUsage includes contextWindowMaxTokens and derives used tokens from result usage as initial fallback", async () => {
|
||||
const session = await createSessionForTest();
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ import type {
|
||||
AgentPermissionAction,
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentCreateSessionOptions,
|
||||
AgentLaunchContext,
|
||||
AgentMetadata,
|
||||
AgentMode,
|
||||
@@ -177,6 +178,7 @@ interface ClaudeAgentSessionOptions {
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
handle?: AgentPersistenceHandle;
|
||||
launchEnv?: Record<string, string>;
|
||||
persistSession?: boolean;
|
||||
logger: Logger;
|
||||
queryFactory?: typeof query;
|
||||
}
|
||||
@@ -316,6 +318,7 @@ interface ClaudeOptionsLogSummary {
|
||||
hasSpawnOverride: boolean;
|
||||
hasStderrHandler: boolean;
|
||||
pathToClaudeCodeExecutable: string | null;
|
||||
persistSession: boolean | null;
|
||||
}
|
||||
|
||||
const MAX_RECENT_STDERR_CHARS = 4000;
|
||||
@@ -363,6 +366,7 @@ function summarizeClaudeOptionsForLog(options: ClaudeOptions): ClaudeOptionsLogS
|
||||
typeof options.pathToClaudeCodeExecutable === "string"
|
||||
? options.pathToClaudeCodeExecutable
|
||||
: null,
|
||||
persistSession: typeof options.persistSession === "boolean" ? options.persistSession : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1151,12 +1155,14 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
async createSession(
|
||||
config: AgentSessionConfig,
|
||||
launchContext?: AgentLaunchContext,
|
||||
options?: AgentCreateSessionOptions,
|
||||
): Promise<AgentSession> {
|
||||
const claudeConfig = this.assertConfig(config);
|
||||
return new ClaudeAgentSession(claudeConfig, {
|
||||
defaults: this.defaults,
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
launchEnv: launchContext?.env,
|
||||
persistSession: options?.persistSession,
|
||||
logger: this.logger,
|
||||
queryFactory: this.queryFactory,
|
||||
});
|
||||
@@ -1441,6 +1447,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private readonly launchEnv?: Record<string, string>;
|
||||
private readonly defaults?: { agents?: Record<string, AgentDefinition> };
|
||||
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||
private readonly persistSession?: boolean;
|
||||
private readonly logger: Logger;
|
||||
private readonly queryFactory: typeof query;
|
||||
private query: Query | null = null;
|
||||
@@ -1488,6 +1495,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.launchEnv = options.launchEnv;
|
||||
this.defaults = options.defaults;
|
||||
this.runtimeSettings = options.runtimeSettings;
|
||||
this.persistSession = options.persistSession;
|
||||
this.logger = options.logger;
|
||||
this.queryFactory = options.queryFactory ?? query;
|
||||
const handle = options.handle;
|
||||
@@ -2216,6 +2224,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
...(thinking ? { thinking } : {}),
|
||||
...(effort ? { effort } : {}),
|
||||
...extraClaudeOptions,
|
||||
...(this.persistSession === undefined ? {} : { persistSession: this.persistSession }),
|
||||
env: sdkEnv,
|
||||
};
|
||||
|
||||
|
||||
@@ -83,6 +83,66 @@ function asInternals(session: CodexTestSession): CodexSessionTestAccess {
|
||||
}
|
||||
|
||||
describe("Codex app-server provider", () => {
|
||||
test("passes ephemeral: true to thread/start when constructed as ephemeral", async () => {
|
||||
const requests: Array<{ method: string; params: unknown }> = [];
|
||||
const fakeClient: CodexClientLike = {
|
||||
async request(method: string, params?: unknown) {
|
||||
requests.push({ method, params });
|
||||
if (method === "thread/start") {
|
||||
return { thread: { id: "ephemeral-thread" } };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
const session = new __codexAppServerInternals.CodexAppServerAgentSession(
|
||||
createConfig({ thinkingOptionId: "medium" }),
|
||||
null,
|
||||
createTestLogger(),
|
||||
() => {
|
||||
throw new Error("Test session cannot spawn Codex app-server");
|
||||
},
|
||||
{},
|
||||
true,
|
||||
);
|
||||
(session as unknown as { client: CodexClientLike }).client = fakeClient;
|
||||
|
||||
await (session as unknown as { ensureThread: () => Promise<void> }).ensureThread();
|
||||
|
||||
const startCall = requests.find((req) => req.method === "thread/start");
|
||||
expect(startCall).toBeDefined();
|
||||
expect(startCall?.params).toMatchObject({ ephemeral: true });
|
||||
});
|
||||
|
||||
test("omits ephemeral from thread/start by default", async () => {
|
||||
const requests: Array<{ method: string; params: unknown }> = [];
|
||||
const fakeClient: CodexClientLike = {
|
||||
async request(method: string, params?: unknown) {
|
||||
requests.push({ method, params });
|
||||
if (method === "thread/start") {
|
||||
return { thread: { id: "persistent-thread" } };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
const session = new __codexAppServerInternals.CodexAppServerAgentSession(
|
||||
createConfig({ thinkingOptionId: "medium" }),
|
||||
null,
|
||||
createTestLogger(),
|
||||
() => {
|
||||
throw new Error("Test session cannot spawn Codex app-server");
|
||||
},
|
||||
);
|
||||
(session as unknown as { client: CodexClientLike }).client = fakeClient;
|
||||
|
||||
await (session as unknown as { ensureThread: () => Promise<void> }).ensureThread();
|
||||
|
||||
const startCall = requests.find((req) => req.method === "thread/start");
|
||||
expect(startCall).toBeDefined();
|
||||
expect((startCall!.params as Record<string, unknown>).ephemeral).toBeUndefined();
|
||||
});
|
||||
|
||||
test("disposes an unresponsive app-server child with SIGKILL", async () => {
|
||||
vi.useFakeTimers();
|
||||
const child = new EventEmitter() as ChildProcessWithoutNullStreams;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
AgentPermissionAction,
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentCreateSessionOptions,
|
||||
AgentFeature,
|
||||
AgentLaunchContext,
|
||||
AgentMode,
|
||||
@@ -2617,6 +2618,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
logger: Logger,
|
||||
private readonly spawnAppServer: () => Promise<ChildProcessWithoutNullStreams>,
|
||||
private readonly deps: CodexAppServerAgentDeps = {},
|
||||
private readonly ephemeral: boolean = false,
|
||||
) {
|
||||
this.logger = logger.child({ module: "agent", provider: CODEX_PROVIDER });
|
||||
if (config.modeId === undefined) {
|
||||
@@ -3480,6 +3482,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
? { developerInstructions: this.config.systemPrompt.trim() }
|
||||
: {}),
|
||||
...(innerConfig ? { config: innerConfig } : {}),
|
||||
...(this.ephemeral ? { ephemeral: true } : {}),
|
||||
})) as CodexThreadStartResponse;
|
||||
const threadId = response?.thread?.id;
|
||||
if (!threadId) {
|
||||
@@ -4460,6 +4463,7 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
async createSession(
|
||||
config: AgentSessionConfig,
|
||||
launchContext?: AgentLaunchContext,
|
||||
options?: AgentCreateSessionOptions,
|
||||
): Promise<AgentSession> {
|
||||
const sessionConfig: AgentSessionConfig = { ...config, provider: CODEX_PROVIDER };
|
||||
const session = new CodexAppServerAgentSession(
|
||||
@@ -4468,6 +4472,7 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
this.logger,
|
||||
() => this.spawnAppServer(launchContext?.env),
|
||||
this.deps,
|
||||
options?.persistSession === false,
|
||||
);
|
||||
await session.connect();
|
||||
return session;
|
||||
|
||||
@@ -566,6 +566,54 @@ describe("OpenCode adapter context-window normalization", () => {
|
||||
});
|
||||
|
||||
describe("OpenCode adapter startTurn error handling", () => {
|
||||
test("deletes provider session on close when persistence is disabled", async () => {
|
||||
const fakeClient = {
|
||||
session: {
|
||||
abort: vi.fn().mockResolvedValue({ error: null }),
|
||||
update: vi.fn().mockResolvedValue({ error: null }),
|
||||
delete: vi.fn().mockResolvedValue({ error: null }),
|
||||
},
|
||||
} as never;
|
||||
|
||||
const session = new __openCodeInternals.OpenCodeAgentSession(
|
||||
{ provider: "opencode", cwd: "/tmp/test" },
|
||||
fakeClient,
|
||||
"ses_unit_test",
|
||||
createTestLogger(),
|
||||
new Map(),
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
|
||||
await session.close();
|
||||
|
||||
expect(fakeClient.session.delete).toHaveBeenCalledWith({
|
||||
sessionID: "ses_unit_test",
|
||||
directory: "/tmp/test",
|
||||
});
|
||||
});
|
||||
|
||||
test("does not delete provider session on close by default", async () => {
|
||||
const fakeClient = {
|
||||
session: {
|
||||
abort: vi.fn().mockResolvedValue({ error: null }),
|
||||
update: vi.fn().mockResolvedValue({ error: null }),
|
||||
delete: vi.fn().mockResolvedValue({ error: null }),
|
||||
},
|
||||
} as never;
|
||||
|
||||
const session = new __openCodeInternals.OpenCodeAgentSession(
|
||||
{ provider: "opencode", cwd: "/tmp/test" },
|
||||
fakeClient,
|
||||
"ses_unit_test",
|
||||
createTestLogger(),
|
||||
);
|
||||
|
||||
await session.close();
|
||||
|
||||
expect(fakeClient.session.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("emits turn_failed when client.session.promptAsync throws synchronously", async () => {
|
||||
// Async iterable that never yields and never resolves. The IIFE in
|
||||
// startTurn synchronously hits the promptAsync throw and finishes the
|
||||
|
||||
@@ -16,6 +16,7 @@ import { z } from "zod";
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentCreateSessionOptions,
|
||||
AgentLaunchContext,
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
@@ -1039,6 +1040,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
async createSession(
|
||||
config: AgentSessionConfig,
|
||||
_launchContext?: AgentLaunchContext,
|
||||
options?: AgentCreateSessionOptions,
|
||||
): Promise<AgentSession> {
|
||||
const openCodeConfig = this.assertConfig(config);
|
||||
const acquisition = await this.serverManager.acquire({ force: false });
|
||||
@@ -1073,6 +1075,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
this.logger,
|
||||
new Map(this.modelContextWindows),
|
||||
acquisition.release,
|
||||
options?.persistSession,
|
||||
);
|
||||
} catch (error) {
|
||||
acquisition.release();
|
||||
@@ -2218,6 +2221,8 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
private pendingChildToolPartsBySessionId = new Map<string, OpenCodeToolPartEventPart[]>();
|
||||
private selectedModelContextWindowMaxTokens: number | undefined;
|
||||
private releaseServer: (() => void) | null;
|
||||
private readonly persistSession: boolean;
|
||||
private deletedFromProvider = false;
|
||||
constructor(
|
||||
config: OpenCodeAgentConfig,
|
||||
client: OpencodeClient,
|
||||
@@ -2225,6 +2230,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
logger: Logger,
|
||||
modelContextWindowsByModelKey: ReadonlyMap<string, number> = new Map(),
|
||||
releaseServer?: () => void,
|
||||
persistSession = true,
|
||||
) {
|
||||
this.config = config;
|
||||
this.client = client;
|
||||
@@ -2233,6 +2239,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
this.modelContextWindowsByModelKey = modelContextWindowsByModelKey;
|
||||
this.currentMode = normalizeOpenCodeModeId(config.modeId);
|
||||
this.releaseServer = releaseServer ?? null;
|
||||
this.persistSession = persistSession;
|
||||
this.selectedModelContextWindowMaxTokens = this.resolveConfiguredModelContextWindowMaxTokens(
|
||||
config.model,
|
||||
);
|
||||
@@ -2831,6 +2838,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
directory: this.config.cwd,
|
||||
logger: this.logger,
|
||||
});
|
||||
await this.deleteProviderSessionIfEphemeral();
|
||||
this.subscribers.clear();
|
||||
this.activeForegroundTurnId = null;
|
||||
} finally {
|
||||
@@ -2839,6 +2847,27 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteProviderSessionIfEphemeral(): Promise<void> {
|
||||
if (this.persistSession || this.deletedFromProvider) {
|
||||
return;
|
||||
}
|
||||
this.deletedFromProvider = true;
|
||||
try {
|
||||
const response = await this.client.session.delete({
|
||||
sessionID: this.sessionId,
|
||||
directory: this.config.cwd,
|
||||
});
|
||||
if (response.error) {
|
||||
throw new Error(`OpenCode session.delete failed: ${JSON.stringify(response.error)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.debug(
|
||||
{ err: error, sessionId: this.sessionId },
|
||||
"Failed to delete non-persistent OpenCode session",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private parseSlashCommandInput(text: string): { commandName: string; args?: string } | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed.startsWith("/") || trimmed.length <= 1) {
|
||||
|
||||
@@ -90,7 +90,6 @@ import { VoiceAssistantWebSocketServer } from "./websocket-server.js";
|
||||
import { createGitHubService } from "../services/github-service.js";
|
||||
import { createPaseoWorktree as createRegisteredPaseoWorktree } from "./paseo-worktree-service.js";
|
||||
import { createPaseoWorktreeWorkflow } from "./worktree-session.js";
|
||||
import { createWorktreeCoreDeps } from "./worktree-core.js";
|
||||
import { DownloadTokenStore } from "./file-download/token-store.js";
|
||||
import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/config.js";
|
||||
import type { LocalSpeechProviderConfig } from "./speech/providers/local/config.js";
|
||||
@@ -575,9 +574,8 @@ export async function createPaseoDaemon(
|
||||
{
|
||||
paseoHome: config.paseoHome,
|
||||
createPaseoWorktree: async (workflowInput, workflowOptions) => {
|
||||
const coreDeps = createWorktreeCoreDeps(github);
|
||||
return createRegisteredPaseoWorktree(workflowInput, {
|
||||
...coreDeps,
|
||||
github,
|
||||
...(workflowOptions?.resolveDefaultBranch
|
||||
? {
|
||||
resolveDefaultBranch: workflowOptions.resolveDefaultBranch,
|
||||
|
||||
@@ -33,7 +33,7 @@ function createLegacyWorktreeForTest(
|
||||
source: {
|
||||
kind: "branch-off",
|
||||
baseBranch: options.baseBranch,
|
||||
newBranchName: options.branchName,
|
||||
branchName: options.branchName,
|
||||
},
|
||||
runSetup: options.runSetup ?? true,
|
||||
paseoHome: options.paseoHome,
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
createPaseoWorktree,
|
||||
type CreatePaseoWorktreeDeps,
|
||||
} from "./paseo-worktree-service.js";
|
||||
import { createWorktreeCoreDeps } from "./worktree-core.js";
|
||||
import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js";
|
||||
|
||||
const cleanupPaths: string[] = [];
|
||||
@@ -105,32 +104,32 @@ test("reuses an existing worktree and still upserts the workspace", async () =>
|
||||
test("renames an eligible unnamed branch-off worktree once on first agent context", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const deps = createDeps({
|
||||
generateBranchName: (seed) => (seed ? "renamed-from-agent-context" : "unnamed-placeholder"),
|
||||
});
|
||||
const deps = createDeps();
|
||||
|
||||
const created = await createPaseoWorktree(
|
||||
{
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "dazzling-yak",
|
||||
runSetup: false,
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
},
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(created.worktree.branchName).toBe("unnamed-placeholder");
|
||||
expect(created.worktree.branchName).toBe("dazzling-yak");
|
||||
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
|
||||
version: 2,
|
||||
firstAgentBranchAutoName: {
|
||||
status: "pending",
|
||||
placeholderBranchName: "unnamed-placeholder",
|
||||
placeholderBranchName: "dazzling-yak",
|
||||
},
|
||||
});
|
||||
|
||||
const first = await attemptFirstAgentBranchAutoName({
|
||||
cwd: created.worktree.worktreePath,
|
||||
firstAgentContext: { prompt: "Build the agent context name" },
|
||||
generateBranchName: deps.generateBranchName,
|
||||
generateBranchNameFromContext: async ({ firstAgentContext }) =>
|
||||
firstAgentContext.prompt ? "renamed-from-agent-context" : null,
|
||||
});
|
||||
const branchAfterFirst = execSync("git branch --show-current", {
|
||||
cwd: created.worktree.worktreePath,
|
||||
@@ -149,14 +148,14 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex
|
||||
version: 2,
|
||||
firstAgentBranchAutoName: {
|
||||
status: "attempted",
|
||||
placeholderBranchName: "unnamed-placeholder",
|
||||
placeholderBranchName: "dazzling-yak",
|
||||
},
|
||||
});
|
||||
|
||||
const second = await attemptFirstAgentBranchAutoName({
|
||||
cwd: created.worktree.worktreePath,
|
||||
firstAgentContext: { prompt: "Try another name" },
|
||||
generateBranchName: () => "second-agent-name",
|
||||
generateBranchNameFromContext: async () => "second-agent-name",
|
||||
});
|
||||
const branchAfterSecond = execSync("git branch --show-current", {
|
||||
cwd: created.worktree.worktreePath,
|
||||
@@ -172,10 +171,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex
|
||||
test("renames the branch even when the app supplies a random placeholder slug", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const deps = createDeps({
|
||||
generateBranchName: (seed) =>
|
||||
seed === "Investigate the failing login flow" ? "renamed-from-prompt" : (seed ?? "fallback"),
|
||||
});
|
||||
const deps = createDeps();
|
||||
|
||||
const created = await createPaseoWorktree(
|
||||
{
|
||||
@@ -188,6 +184,18 @@ test("renames the branch even when the app supplies a random placeholder slug",
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(created.worktree.branchName).toBe("dazzling-yak");
|
||||
expect(created.workspace.displayName).toBe("dazzling-yak");
|
||||
|
||||
await attemptFirstAgentBranchAutoName({
|
||||
cwd: created.worktree.worktreePath,
|
||||
firstAgentContext: { prompt: "Investigate the failing login flow" },
|
||||
generateBranchNameFromContext: async ({ firstAgentContext }) =>
|
||||
firstAgentContext.prompt === "Investigate the failing login flow"
|
||||
? "renamed-from-prompt"
|
||||
: null,
|
||||
});
|
||||
|
||||
const branchAfter = execSync("git branch --show-current", {
|
||||
cwd: created.worktree.worktreePath,
|
||||
stdio: "pipe",
|
||||
@@ -195,20 +203,13 @@ test("renames the branch even when the app supplies a random placeholder slug",
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
expect(created.worktree.branchName).toBe("renamed-from-prompt");
|
||||
expect(branchAfter).toBe("renamed-from-prompt");
|
||||
expect(created.workspace.displayName).toBe("renamed-from-prompt");
|
||||
});
|
||||
|
||||
test("renames the branch from a github_pr attachment when no prompt is supplied", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const deps = createDeps({
|
||||
generateBranchName: (seed) =>
|
||||
seed?.includes("Investigate flaky checkout test")
|
||||
? "renamed-from-pr-attachment"
|
||||
: (seed ?? "fallback"),
|
||||
});
|
||||
const deps = createDeps();
|
||||
|
||||
const created = await createPaseoWorktree(
|
||||
{
|
||||
@@ -231,6 +232,27 @@ test("renames the branch from a github_pr attachment when no prompt is supplied"
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(created.worktree.branchName).toBe("dazzling-yak");
|
||||
|
||||
await attemptFirstAgentBranchAutoName({
|
||||
cwd: created.worktree.worktreePath,
|
||||
firstAgentContext: {
|
||||
attachments: [
|
||||
{
|
||||
type: "github_pr",
|
||||
mimeType: "application/github-pr",
|
||||
number: 42,
|
||||
title: "Investigate flaky checkout test",
|
||||
url: "https://github.com/acme/repo/pull/42",
|
||||
},
|
||||
],
|
||||
},
|
||||
generateBranchNameFromContext: async ({ firstAgentContext }) =>
|
||||
firstAgentContext.attachments?.[0]?.type === "github_pr"
|
||||
? "renamed-from-pr-attachment"
|
||||
: null,
|
||||
});
|
||||
|
||||
const branchAfter = execSync("git branch --show-current", {
|
||||
cwd: created.worktree.worktreePath,
|
||||
stdio: "pipe",
|
||||
@@ -238,9 +260,46 @@ test("renames the branch from a github_pr attachment when no prompt is supplied"
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
expect(created.worktree.branchName).toBe("renamed-from-pr-attachment");
|
||||
expect(branchAfter).toBe("renamed-from-pr-attachment");
|
||||
expect(created.workspace.displayName).toBe("renamed-from-pr-attachment");
|
||||
});
|
||||
|
||||
test("leaves the branch alone when generated branch text is invalid", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const created = await createPaseoWorktree(
|
||||
{
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "dazzling-yak",
|
||||
firstAgentContext: { prompt: "Name this branch" },
|
||||
runSetup: false,
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
},
|
||||
createDeps(),
|
||||
);
|
||||
|
||||
await expect(
|
||||
attemptFirstAgentBranchAutoName({
|
||||
cwd: created.worktree.worktreePath,
|
||||
firstAgentContext: { prompt: "Name this branch" },
|
||||
generateBranchNameFromContext: async () => "Invalid Branch Name",
|
||||
}),
|
||||
).resolves.toEqual({ attempted: true, renamed: false, branchName: null });
|
||||
|
||||
expect(
|
||||
execSync("git branch --show-current", {
|
||||
cwd: created.worktree.worktreePath,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim(),
|
||||
).toBe("dazzling-yak");
|
||||
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
|
||||
version: 2,
|
||||
firstAgentBranchAutoName: {
|
||||
status: "attempted",
|
||||
placeholderBranchName: "dazzling-yak",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("does not mark checkout branch worktrees as eligible for first-agent rename", async () => {
|
||||
@@ -271,7 +330,7 @@ test("does not mark checkout branch worktrees as eligible for first-agent rename
|
||||
attemptFirstAgentBranchAutoName({
|
||||
cwd: created.worktree.worktreePath,
|
||||
firstAgentContext: { prompt: "Rename checkout branch" },
|
||||
generateBranchName: () => "must-not-rename",
|
||||
generateBranchNameFromContext: async () => "must-not-rename",
|
||||
}),
|
||||
).resolves.toEqual({ attempted: false, renamed: false, branchName: null });
|
||||
expect(
|
||||
@@ -307,7 +366,7 @@ test("does not mark GitHub PR checkout worktrees as eligible for first-agent ren
|
||||
attemptFirstAgentBranchAutoName({
|
||||
cwd: created.worktree.worktreePath,
|
||||
firstAgentContext: { prompt: "Rename PR checkout" },
|
||||
generateBranchName: () => "must-not-rename",
|
||||
generateBranchNameFromContext: async () => "must-not-rename",
|
||||
}),
|
||||
).resolves.toEqual({ attempted: false, renamed: false, branchName: null });
|
||||
expect(
|
||||
@@ -350,15 +409,13 @@ function createDeps(options?: {
|
||||
events?: string[];
|
||||
projects?: Map<string, PersistedProjectRecord>;
|
||||
workspaces?: Map<string, PersistedWorkspaceRecord>;
|
||||
generateBranchName?: (seed: string | undefined) => string;
|
||||
}): TestDeps {
|
||||
const events = options?.events ?? [];
|
||||
const projects = options?.projects ?? new Map<string, PersistedProjectRecord>();
|
||||
const workspaces = options?.workspaces ?? new Map<string, PersistedWorkspaceRecord>();
|
||||
|
||||
return {
|
||||
...createWorktreeCoreDeps(createGitHubServiceStub()),
|
||||
...(options?.generateBranchName ? { generateBranchName: options.generateBranchName } : {}),
|
||||
github: createGitHubServiceStub(),
|
||||
projects,
|
||||
workspaces,
|
||||
projectRegistry: {
|
||||
|
||||
@@ -12,9 +12,8 @@ import {
|
||||
type CreateWorktreeCoreDeps,
|
||||
type CreateWorktreeCoreInput,
|
||||
} from "./worktree-core.js";
|
||||
import type { WorktreeConfig } from "../utils/worktree.js";
|
||||
import { validateBranchSlug } from "../utils/worktree.js";
|
||||
import { renameCurrentBranch } from "../utils/checkout-git.js";
|
||||
import { validateBranchSlug, type WorktreeConfig } from "../utils/worktree.js";
|
||||
import { getCurrentBranch, renameCurrentBranch } from "../utils/checkout-git.js";
|
||||
import {
|
||||
markPaseoWorktreeFirstAgentBranchAutoNameAttempted,
|
||||
readPaseoWorktreeMetadata,
|
||||
@@ -24,7 +23,7 @@ import type { WorktreeCreationIntent } from "./resolve-worktree-creation-intent.
|
||||
import { buildAgentBranchNameSeed } from "./agent/prompt-attachments.js";
|
||||
import type { FirstAgentContext } from "../shared/messages.js";
|
||||
|
||||
export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput {}
|
||||
export type CreatePaseoWorktreeInput = CreateWorktreeCoreInput;
|
||||
|
||||
export interface CreatePaseoWorktreeResult {
|
||||
worktree: WorktreeConfig;
|
||||
@@ -58,25 +57,18 @@ export async function createPaseoWorktree(
|
||||
deps: CreatePaseoWorktreeDeps,
|
||||
): Promise<CreatePaseoWorktreeResult> {
|
||||
const createdWorktree = await createWorktreeCore(input, deps);
|
||||
if (!buildAgentBranchNameSeed(input.firstAgentContext)) {
|
||||
maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree });
|
||||
}
|
||||
const worktree = await maybeAutoNameCreatedWorktree({
|
||||
input,
|
||||
createdWorktree,
|
||||
deps,
|
||||
});
|
||||
maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree });
|
||||
const workspace = await upsertWorkspaceForWorktree({
|
||||
inputCwd: input.cwd,
|
||||
repoRoot: createdWorktree.repoRoot,
|
||||
worktree,
|
||||
worktree: createdWorktree.worktree,
|
||||
deps,
|
||||
});
|
||||
|
||||
deps.github.invalidate({ cwd: worktree.worktreePath });
|
||||
deps.github.invalidate({ cwd: createdWorktree.worktree.worktreePath });
|
||||
|
||||
return {
|
||||
worktree,
|
||||
worktree: createdWorktree.worktree,
|
||||
intent: createdWorktree.intent,
|
||||
workspace,
|
||||
repoRoot: createdWorktree.repoRoot,
|
||||
@@ -87,11 +79,15 @@ export async function createPaseoWorktree(
|
||||
export async function attemptFirstAgentBranchAutoName(options: {
|
||||
cwd: string;
|
||||
firstAgentContext: FirstAgentContext | undefined;
|
||||
generateBranchName: (seed: string | undefined) => string;
|
||||
generateBranchNameFromContext: (input: {
|
||||
cwd: string;
|
||||
firstAgentContext: FirstAgentContext;
|
||||
}) => Promise<string | null>;
|
||||
getCurrentBranch?: typeof getCurrentBranch;
|
||||
renameCurrentBranch?: typeof renameCurrentBranch;
|
||||
}): Promise<AttemptFirstAgentBranchAutoNameResult> {
|
||||
const seed = buildAgentBranchNameSeed(options.firstAgentContext);
|
||||
if (!seed) {
|
||||
const firstAgentContext = options.firstAgentContext;
|
||||
if (!firstAgentContext || !buildAgentBranchNameSeed(firstAgentContext)) {
|
||||
return { attempted: false, renamed: false, branchName: null };
|
||||
}
|
||||
|
||||
@@ -109,11 +105,27 @@ export async function attemptFirstAgentBranchAutoName(options: {
|
||||
return { attempted: false, renamed: false, branchName: null };
|
||||
}
|
||||
|
||||
const getCurrentBranchImpl = options.getCurrentBranch ?? getCurrentBranch;
|
||||
const placeholderBranchName = metadata.firstAgentBranchAutoName.placeholderBranchName;
|
||||
if ((await getCurrentBranchImpl(options.cwd)) !== placeholderBranchName) {
|
||||
markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd);
|
||||
return { attempted: true, renamed: false, branchName: null };
|
||||
}
|
||||
|
||||
markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd);
|
||||
|
||||
const branchName = options.generateBranchName(seed);
|
||||
const branchName = await options.generateBranchNameFromContext({
|
||||
cwd: options.cwd,
|
||||
firstAgentContext,
|
||||
});
|
||||
if (!branchName) {
|
||||
return { attempted: true, renamed: false, branchName: null };
|
||||
}
|
||||
const validation = validateBranchSlug(branchName);
|
||||
if (!validation.valid || branchName === metadata.firstAgentBranchAutoName.placeholderBranchName) {
|
||||
if (!validation.valid || branchName === placeholderBranchName) {
|
||||
return { attempted: true, renamed: false, branchName: null };
|
||||
}
|
||||
if ((await getCurrentBranchImpl(options.cwd)) !== placeholderBranchName) {
|
||||
return { attempted: true, renamed: false, branchName: null };
|
||||
}
|
||||
|
||||
@@ -139,33 +151,6 @@ function maybeMarkFirstAgentBranchAutoNameEligible(options: {
|
||||
});
|
||||
}
|
||||
|
||||
async function maybeAutoNameCreatedWorktree(options: {
|
||||
input: CreatePaseoWorktreeInput;
|
||||
createdWorktree: Awaited<ReturnType<typeof createWorktreeCore>>;
|
||||
deps: Pick<CreatePaseoWorktreeDeps, "generateBranchName">;
|
||||
}): Promise<WorktreeConfig> {
|
||||
const { input, createdWorktree, deps } = options;
|
||||
const seed = buildAgentBranchNameSeed(input.firstAgentContext);
|
||||
if (!seed || !createdWorktree.created || createdWorktree.intent.kind !== "branch-off") {
|
||||
return createdWorktree.worktree;
|
||||
}
|
||||
|
||||
const branchName = deps.generateBranchName(seed);
|
||||
const validation = validateBranchSlug(branchName);
|
||||
if (!validation.valid || branchName === createdWorktree.worktree.branchName) {
|
||||
return createdWorktree.worktree;
|
||||
}
|
||||
|
||||
const renamedBranch = await renameCurrentBranch(
|
||||
createdWorktree.worktree.worktreePath,
|
||||
branchName,
|
||||
);
|
||||
return {
|
||||
...createdWorktree.worktree,
|
||||
branchName: renamedBranch.currentBranch ?? branchName,
|
||||
};
|
||||
}
|
||||
|
||||
async function upsertWorkspaceForWorktree(options: {
|
||||
inputCwd: string;
|
||||
repoRoot: string;
|
||||
|
||||
@@ -15,7 +15,6 @@ interface ResolverHarness {
|
||||
github: GitHubService;
|
||||
headRefLookups: GitHubHeadRefLookup[];
|
||||
resolveDefaultBranch: (repoRoot: string) => Promise<string>;
|
||||
generateBranchName: (seed: string | undefined) => string;
|
||||
}
|
||||
|
||||
function createResolverHarness(): ResolverHarness {
|
||||
@@ -51,7 +50,6 @@ function createResolverHarness(): ResolverHarness {
|
||||
github,
|
||||
headRefLookups,
|
||||
resolveDefaultBranch: async () => "main",
|
||||
generateBranchName: (seed) => seed ?? "generated-worktree",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -61,10 +59,12 @@ describe("resolveWorktreeCreationIntent", () => {
|
||||
test("branches off the repo default branch when no explicit fields are set", async () => {
|
||||
const deps = createResolverHarness();
|
||||
|
||||
await expect(resolveWorktreeCreationIntent({}, repoRoot, deps)).resolves.toEqual({
|
||||
await expect(
|
||||
resolveWorktreeCreationIntent({ worktreeSlug: "generated-worktree" }, repoRoot, deps),
|
||||
).resolves.toEqual({
|
||||
kind: "branch-off",
|
||||
baseBranch: "main",
|
||||
newBranchName: "generated-worktree",
|
||||
branchName: "generated-worktree",
|
||||
});
|
||||
expect(deps.headRefLookups).toEqual([]);
|
||||
});
|
||||
@@ -81,7 +81,7 @@ describe("resolveWorktreeCreationIntent", () => {
|
||||
).resolves.toEqual({
|
||||
kind: "branch-off",
|
||||
baseBranch: "dev",
|
||||
newBranchName: "feature",
|
||||
branchName: "feature",
|
||||
});
|
||||
expect(deps.headRefLookups).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -3,17 +3,29 @@ import type { WorktreeSource } from "../utils/worktree.js";
|
||||
|
||||
export type WorktreeCreationIntent = WorktreeSource;
|
||||
|
||||
export interface ResolveWorktreeCreationIntentInput {
|
||||
worktreeSlug?: string;
|
||||
refName?: string;
|
||||
action?: "branch-off" | "checkout";
|
||||
githubPrNumber?: number;
|
||||
}
|
||||
export type ResolveWorktreeCreationIntentInput =
|
||||
| {
|
||||
worktreeSlug: string;
|
||||
refName?: string;
|
||||
action?: "branch-off";
|
||||
githubPrNumber?: undefined;
|
||||
}
|
||||
| {
|
||||
worktreeSlug?: string;
|
||||
refName?: string;
|
||||
action: "checkout";
|
||||
githubPrNumber?: number;
|
||||
}
|
||||
| {
|
||||
worktreeSlug?: string;
|
||||
refName?: string;
|
||||
action?: undefined;
|
||||
githubPrNumber: number;
|
||||
};
|
||||
|
||||
export interface ResolveWorktreeCreationIntentDeps {
|
||||
github: GitHubService;
|
||||
resolveDefaultBranch: (repoRoot: string) => Promise<string>;
|
||||
generateBranchName: (seed: string | undefined) => string;
|
||||
}
|
||||
|
||||
export class MissingCheckoutTargetError extends Error {
|
||||
@@ -34,7 +46,7 @@ export async function resolveWorktreeCreationIntent(
|
||||
return {
|
||||
kind: "branch-off",
|
||||
baseBranch: input.refName?.trim() || (await resolveDefaultBranch(repoRoot, deps)),
|
||||
newBranchName: deps.generateBranchName(input.worktreeSlug),
|
||||
branchName: input.worktreeSlug,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -72,14 +84,14 @@ export async function resolveWorktreeCreationIntent(
|
||||
return {
|
||||
kind: "branch-off",
|
||||
baseBranch: input.refName.trim(),
|
||||
newBranchName: deps.generateBranchName(input.worktreeSlug),
|
||||
branchName: input.worktreeSlug,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "branch-off",
|
||||
baseBranch: await resolveDefaultBranch(repoRoot, deps),
|
||||
newBranchName: deps.generateBranchName(input.worktreeSlug),
|
||||
branchName: input.worktreeSlug,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1292,6 +1292,15 @@ describe("session checkout commit handling", () => {
|
||||
mode: "uncommitted",
|
||||
includeStructured: true,
|
||||
});
|
||||
expect(agentResponseMocks.generateStructuredAgentResponseWithFallback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
persistSession: false,
|
||||
agentConfigOverrides: expect.objectContaining({
|
||||
title: "Commit generator",
|
||||
internal: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(checkoutGitMocks.commitChanges).toHaveBeenCalledWith("/tmp/request-worktree", {
|
||||
message: "Update file",
|
||||
addAll: true,
|
||||
@@ -1381,6 +1390,15 @@ describe("session checkout pull request creation", () => {
|
||||
baseRef: "main",
|
||||
includeStructured: true,
|
||||
});
|
||||
expect(agentResponseMocks.generateStructuredAgentResponseWithFallback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
persistSession: false,
|
||||
agentConfigOverrides: expect.objectContaining({
|
||||
title: "PR generator",
|
||||
internal: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(checkoutGitMocks.createPullRequest).toHaveBeenCalledWith(
|
||||
"/tmp/request-worktree",
|
||||
{
|
||||
|
||||
@@ -207,7 +207,7 @@ import {
|
||||
type CreatePaseoWorktreeInput,
|
||||
type CreatePaseoWorktreeResult,
|
||||
} from "./paseo-worktree-service.js";
|
||||
import { createWorktreeCoreDeps } from "./worktree-core.js";
|
||||
import { generateBranchNameFromFirstAgentContext } from "./worktree-branch-name-generator.js";
|
||||
import {
|
||||
assertSafeGitRef as assertWorktreeSafeGitRef,
|
||||
buildAgentSessionConfig as buildWorktreeAgentSessionConfig,
|
||||
@@ -3002,7 +3002,7 @@ export class Session {
|
||||
if (!resolvedWorkspace) {
|
||||
throw new Error(`Workspace not found: ${msg.workspaceId}`);
|
||||
}
|
||||
resolvedWorkspace = await this.maybeAutoNameWorkspaceBranchForFirstAgent({
|
||||
this.scheduleAutoNameWorkspaceBranchForFirstAgent({
|
||||
workspace: resolvedWorkspace,
|
||||
firstAgentContext,
|
||||
});
|
||||
@@ -3394,15 +3394,35 @@ export class Session {
|
||||
);
|
||||
}
|
||||
|
||||
private scheduleAutoNameWorkspaceBranchForFirstAgent(input: {
|
||||
workspace: PersistedWorkspaceRecord;
|
||||
firstAgentContext: FirstAgentContext;
|
||||
}): void {
|
||||
setTimeout(() => {
|
||||
void this.maybeAutoNameWorkspaceBranchForFirstAgent(input).catch((error) => {
|
||||
this.sessionLogger.warn(
|
||||
{ err: error, cwd: input.workspace.cwd },
|
||||
"Failed to auto-name worktree branch",
|
||||
);
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
|
||||
private async maybeAutoNameWorkspaceBranchForFirstAgent(input: {
|
||||
workspace: PersistedWorkspaceRecord;
|
||||
firstAgentContext: FirstAgentContext;
|
||||
}): Promise<PersistedWorkspaceRecord> {
|
||||
const coreDeps = createWorktreeCoreDeps(this.github);
|
||||
const result = await attemptFirstAgentBranchAutoName({
|
||||
cwd: input.workspace.cwd,
|
||||
firstAgentContext: input.firstAgentContext,
|
||||
generateBranchName: coreDeps.generateBranchName,
|
||||
generateBranchNameFromContext: ({ cwd, firstAgentContext }) => {
|
||||
return generateBranchNameFromFirstAgentContext({
|
||||
agentManager: this.agentManager,
|
||||
cwd,
|
||||
firstAgentContext,
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
},
|
||||
});
|
||||
if (!result.renamed || !result.branchName) {
|
||||
return input.workspace;
|
||||
@@ -3877,6 +3897,7 @@ export class Session {
|
||||
schemaName: "CommitMessage",
|
||||
maxRetries: 2,
|
||||
providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
|
||||
persistSession: false,
|
||||
agentConfigOverrides: {
|
||||
title: "Commit generator",
|
||||
internal: true,
|
||||
@@ -3943,6 +3964,7 @@ export class Session {
|
||||
schemaName: "PullRequest",
|
||||
maxRetries: 2,
|
||||
providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
|
||||
persistSession: false,
|
||||
agentConfigOverrides: {
|
||||
title: "PR generator",
|
||||
internal: true,
|
||||
@@ -6373,9 +6395,8 @@ export class Session {
|
||||
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
|
||||
},
|
||||
): Promise<CreatePaseoWorktreeResult> {
|
||||
const coreDeps = createWorktreeCoreDeps(this.github);
|
||||
const result = await createPaseoWorktree(input, {
|
||||
...coreDeps,
|
||||
github: this.github,
|
||||
...(options?.resolveDefaultBranch
|
||||
? { resolveDefaultBranch: options.resolveDefaultBranch }
|
||||
: {}),
|
||||
@@ -6970,6 +6991,8 @@ export class Session {
|
||||
createPaseoWorktree: (workflowInput, serviceOptions) =>
|
||||
this.createPaseoWorktree(workflowInput, serviceOptions),
|
||||
warmWorkspaceGitData: (workspace) => this.warmWorkspaceGitDataForWorkspace(workspace),
|
||||
autoNameWorkspaceBranchForFirstAgent: (autoNameInput) =>
|
||||
this.scheduleAutoNameWorkspaceBranchForFirstAgent(autoNameInput),
|
||||
emitWorkspaceUpdateForCwd: (cwd, emitOptions) =>
|
||||
this.emitWorkspaceUpdateForCwd(cwd, emitOptions),
|
||||
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => {
|
||||
|
||||
@@ -55,7 +55,7 @@ async function createBootstrapWorktreeForTest(
|
||||
source: {
|
||||
kind: "branch-off",
|
||||
baseBranch: options.baseBranch,
|
||||
newBranchName: options.branchName,
|
||||
branchName: options.branchName,
|
||||
},
|
||||
runSetup: false,
|
||||
paseoHome: options.paseoHome,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { AgentManager } from "./agent/agent-manager.js";
|
||||
import { generateBranchNameFromFirstAgentContext } from "./worktree-branch-name-generator.js";
|
||||
|
||||
function createLogger() {
|
||||
return {
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("generateBranchNameFromFirstAgentContext", () => {
|
||||
test("calls the structured generator with first-agent prompt text", async () => {
|
||||
const generateStructured = vi.fn(async () => ({ branch: "fix-login-flow" }));
|
||||
|
||||
const branch = await generateBranchNameFromFirstAgentContext({
|
||||
agentManager: {} as AgentManager,
|
||||
cwd: "/tmp/repo",
|
||||
firstAgentContext: { prompt: "Fix the login flow" },
|
||||
logger: createLogger(),
|
||||
deps: { generateStructuredAgentResponseWithFallback: generateStructured },
|
||||
});
|
||||
|
||||
expect(branch).toBe("fix-login-flow");
|
||||
expect(generateStructured).toHaveBeenCalledTimes(1);
|
||||
expect(generateStructured.mock.calls[0]?.[0]).toMatchObject({
|
||||
cwd: "/tmp/repo",
|
||||
schemaName: "BranchName",
|
||||
maxRetries: 2,
|
||||
agentConfigOverrides: {
|
||||
title: "Branch name generator",
|
||||
internal: true,
|
||||
},
|
||||
});
|
||||
expect(generateStructured.mock.calls[0]?.[0].prompt).toContain("Fix the login flow");
|
||||
});
|
||||
|
||||
test("uses attachment-only context", async () => {
|
||||
const generateStructured = vi.fn(async () => ({ branch: "review-flaky-checkout" }));
|
||||
|
||||
const branch = await generateBranchNameFromFirstAgentContext({
|
||||
agentManager: {} as AgentManager,
|
||||
cwd: "/tmp/repo",
|
||||
firstAgentContext: {
|
||||
attachments: [
|
||||
{
|
||||
type: "github_pr",
|
||||
mimeType: "application/github-pr",
|
||||
number: 42,
|
||||
title: "Review flaky checkout",
|
||||
url: "https://github.com/acme/repo/pull/42",
|
||||
},
|
||||
],
|
||||
},
|
||||
logger: createLogger(),
|
||||
deps: { generateStructuredAgentResponseWithFallback: generateStructured },
|
||||
});
|
||||
|
||||
expect(branch).toBe("review-flaky-checkout");
|
||||
expect(generateStructured.mock.calls[0]?.[0].prompt).toContain("Review flaky checkout");
|
||||
});
|
||||
});
|
||||
82
packages/server/src/server/worktree-branch-name-generator.ts
Normal file
82
packages/server/src/server/worktree-branch-name-generator.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { z } from "zod";
|
||||
import type { FirstAgentContext } from "../shared/messages.js";
|
||||
import type { AgentManager } from "./agent/agent-manager.js";
|
||||
import {
|
||||
DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
|
||||
StructuredAgentFallbackError,
|
||||
StructuredAgentResponseError,
|
||||
generateStructuredAgentResponseWithFallback,
|
||||
} from "./agent/agent-response-loop.js";
|
||||
import { buildAgentBranchNameSeed } from "./agent/prompt-attachments.js";
|
||||
|
||||
interface BranchNameGeneratorLogger {
|
||||
warn: (obj: object, msg?: string) => void;
|
||||
error: (obj: object, msg?: string) => void;
|
||||
}
|
||||
|
||||
export interface GenerateBranchNameFromFirstAgentContextOptions {
|
||||
agentManager: AgentManager;
|
||||
cwd: string;
|
||||
firstAgentContext: FirstAgentContext | undefined;
|
||||
logger: BranchNameGeneratorLogger;
|
||||
deps?: {
|
||||
generateStructuredAgentResponseWithFallback?: typeof generateStructuredAgentResponseWithFallback;
|
||||
};
|
||||
}
|
||||
|
||||
const BranchNameSchema = z.object({
|
||||
branch: z.string().min(1).max(100),
|
||||
});
|
||||
|
||||
function buildPrompt(seed: string): string {
|
||||
return [
|
||||
"Generate a git branch name for a coding agent based on the user prompt and attachments.",
|
||||
"Branch: concise lowercase slug using letters, numbers, hyphens, and slashes only.",
|
||||
"No spaces, no uppercase, no leading or trailing hyphen, no consecutive hyphens.",
|
||||
"Return JSON only with a single field 'branch'.",
|
||||
"",
|
||||
"User context:",
|
||||
seed,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export async function generateBranchNameFromFirstAgentContext(
|
||||
options: GenerateBranchNameFromFirstAgentContextOptions,
|
||||
): Promise<string | null> {
|
||||
const seed = buildAgentBranchNameSeed(options.firstAgentContext);
|
||||
if (!seed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const generator =
|
||||
options.deps?.generateStructuredAgentResponseWithFallback ??
|
||||
generateStructuredAgentResponseWithFallback;
|
||||
|
||||
try {
|
||||
const result = await generator({
|
||||
manager: options.agentManager,
|
||||
cwd: options.cwd,
|
||||
prompt: buildPrompt(seed),
|
||||
schema: BranchNameSchema,
|
||||
schemaName: "BranchName",
|
||||
maxRetries: 2,
|
||||
providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
|
||||
persistSession: false,
|
||||
agentConfigOverrides: {
|
||||
title: "Branch name generator",
|
||||
internal: true,
|
||||
},
|
||||
});
|
||||
return result.branch.trim() || null;
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof StructuredAgentResponseError ||
|
||||
error instanceof StructuredAgentFallbackError
|
||||
) {
|
||||
options.logger.warn({ err: error }, "Structured branch name generation failed");
|
||||
return null;
|
||||
}
|
||||
options.logger.error({ err: error }, "Branch name generation failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -44,17 +44,13 @@ function createGitHubServiceStub(): GitHubService {
|
||||
};
|
||||
}
|
||||
|
||||
function createCoreDeps(options?: {
|
||||
github?: GitHubService;
|
||||
generateBranchName?: (seed: string | undefined) => string;
|
||||
}) {
|
||||
function createCoreDeps(options?: { github?: GitHubService }) {
|
||||
return {
|
||||
github: options?.github ?? createGitHubServiceStub(),
|
||||
workspaceGitService: {
|
||||
resolveRepoRoot: async (cwd: string) => cwd,
|
||||
},
|
||||
resolveDefaultBranch: async () => "main",
|
||||
generateBranchName: options?.generateBranchName ?? ((seed) => seed ?? "generated-worktree"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -191,13 +187,33 @@ describe.skipIf(process.platform === "win32")("createWorktreeCore", () => {
|
||||
expect(result.intent).toEqual({
|
||||
kind: "branch-off",
|
||||
baseBranch: "main",
|
||||
newBranchName: "legacy-rpc",
|
||||
branchName: "legacy-rpc",
|
||||
});
|
||||
expect(result.created).toBe(true);
|
||||
expect(result.worktree.branchName).toBe("legacy-rpc");
|
||||
expect(existsSync(result.worktree.worktreePath)).toBe(true);
|
||||
});
|
||||
|
||||
test("creates a branch-off worktree with a mnemonic slug when no slug is supplied", async () => {
|
||||
const { tempDir, repoDir, paseoHome } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
|
||||
const result = await createCoreWorktree(
|
||||
{
|
||||
cwd: repoDir,
|
||||
paseoHome,
|
||||
runSetup: false,
|
||||
},
|
||||
createCoreDeps(),
|
||||
);
|
||||
|
||||
expect(result.intent.kind).toBe("branch-off");
|
||||
expect(result.created).toBe(true);
|
||||
expect(result.worktree.branchName).toMatch(/^[a-z0-9]+-[a-z0-9]+$/);
|
||||
expect(result.worktree.branchName).toBe(path.basename(result.worktree.worktreePath));
|
||||
expect(existsSync(result.worktree.worktreePath)).toBe(true);
|
||||
});
|
||||
|
||||
test("checks out an explicit GitHub PR branch with legacy RPC fields", async () => {
|
||||
const { tempDir, repoDir, paseoHome } = createGitHubPrRemoteRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
@@ -259,7 +275,7 @@ describe.skipIf(process.platform === "win32")("createWorktreeCore", () => {
|
||||
expect(result.intent).toEqual({
|
||||
kind: "branch-off",
|
||||
baseBranch: "main",
|
||||
newBranchName: "mcp-standalone",
|
||||
branchName: "mcp-standalone",
|
||||
});
|
||||
expect(result.worktree.branchName).toBe("mcp-standalone");
|
||||
});
|
||||
@@ -290,7 +306,7 @@ describe.skipIf(process.platform === "win32")("createWorktreeCore", () => {
|
||||
expect(result.intent).toEqual({
|
||||
kind: "branch-off",
|
||||
baseBranch: "dev",
|
||||
newBranchName: "from-dev",
|
||||
branchName: "from-dev",
|
||||
});
|
||||
expect(mergeBase).toBe(devTip);
|
||||
});
|
||||
@@ -500,7 +516,7 @@ describe.skipIf(process.platform === "win32")("createWorktreeCore", () => {
|
||||
expect(result.intent).toEqual({
|
||||
kind: "branch-off",
|
||||
baseBranch: "main",
|
||||
newBranchName: "agent-worktree",
|
||||
branchName: "agent-worktree",
|
||||
});
|
||||
expect(result.worktree.branchName).toBe("agent-worktree");
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { createNameId } from "mnemonic-id";
|
||||
|
||||
import type { GitHubService } from "../services/github-service.js";
|
||||
import {
|
||||
@@ -16,8 +16,12 @@ import {
|
||||
import type { FirstAgentContext } from "../shared/messages.js";
|
||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
|
||||
export interface CreateWorktreeCoreInput extends ResolveWorktreeCreationIntentInput {
|
||||
export interface CreateWorktreeCoreInput {
|
||||
cwd: string;
|
||||
worktreeSlug?: string;
|
||||
refName?: string;
|
||||
action?: "branch-off" | "checkout";
|
||||
githubPrNumber?: number;
|
||||
firstAgentContext?: FirstAgentContext;
|
||||
paseoHome?: string;
|
||||
runSetup?: boolean;
|
||||
@@ -27,7 +31,6 @@ export interface CreateWorktreeCoreDeps {
|
||||
github: GitHubService;
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot" | "resolveDefaultBranch">;
|
||||
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
|
||||
generateBranchName: (seed: string | undefined) => string;
|
||||
}
|
||||
|
||||
export interface CreateWorktreeCoreResult {
|
||||
@@ -42,31 +45,51 @@ export async function createWorktreeCore(
|
||||
deps: CreateWorktreeCoreDeps,
|
||||
): Promise<CreateWorktreeCoreResult> {
|
||||
const repoRoot = await resolveWorktreeRepoRoot(input, deps.workspaceGitService);
|
||||
const requestedSlug = input.worktreeSlug ? slugify(input.worktreeSlug) : undefined;
|
||||
const requestedWorktreeSlug = input.worktreeSlug
|
||||
? normalizeWorktreeSlug(input.worktreeSlug)
|
||||
: undefined;
|
||||
|
||||
const intent = await resolveWorktreeCreationIntent(
|
||||
{ ...input, worktreeSlug: requestedSlug },
|
||||
repoRoot,
|
||||
{
|
||||
...deps,
|
||||
resolveDefaultBranch: (root) => resolveDefaultBranch(root, deps),
|
||||
},
|
||||
);
|
||||
let intentInput: ResolveWorktreeCreationIntentInput;
|
||||
if (input.action === "checkout") {
|
||||
intentInput = {
|
||||
action: "checkout",
|
||||
refName: input.refName,
|
||||
githubPrNumber: input.githubPrNumber,
|
||||
worktreeSlug: requestedWorktreeSlug,
|
||||
};
|
||||
} else if (input.githubPrNumber !== undefined) {
|
||||
intentInput = {
|
||||
githubPrNumber: input.githubPrNumber,
|
||||
refName: input.refName,
|
||||
worktreeSlug: requestedWorktreeSlug,
|
||||
};
|
||||
} else {
|
||||
const worktreeSlug = requestedWorktreeSlug ?? normalizeWorktreeSlug(createNameId());
|
||||
intentInput = {
|
||||
action: "branch-off",
|
||||
refName: input.refName,
|
||||
worktreeSlug,
|
||||
};
|
||||
}
|
||||
|
||||
const intent = await resolveWorktreeCreationIntent(intentInput, repoRoot, {
|
||||
...deps,
|
||||
resolveDefaultBranch: (root) => resolveDefaultBranch(root, deps),
|
||||
});
|
||||
let normalizedSlug: string;
|
||||
|
||||
switch (intent.kind) {
|
||||
case "branch-off": {
|
||||
normalizedSlug = validateWorktreeSlug(requestedSlug ?? slugify(intent.newBranchName));
|
||||
normalizedSlug = intent.branchName;
|
||||
break;
|
||||
}
|
||||
case "checkout-branch": {
|
||||
normalizedSlug = validateWorktreeSlug(requestedSlug ?? slugify(intent.branchName));
|
||||
normalizedSlug = requestedWorktreeSlug ?? normalizeWorktreeSlug(intent.branchName);
|
||||
break;
|
||||
}
|
||||
case "checkout-github-pr": {
|
||||
normalizedSlug = validateWorktreeSlug(
|
||||
requestedSlug ?? slugify(intent.localBranchName ?? intent.headRef),
|
||||
);
|
||||
normalizedSlug =
|
||||
requestedWorktreeSlug ?? normalizeWorktreeSlug(intent.localBranchName ?? intent.headRef);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -94,13 +117,6 @@ export async function createWorktreeCore(
|
||||
};
|
||||
}
|
||||
|
||||
export function createWorktreeCoreDeps(github: GitHubService): CreateWorktreeCoreDeps {
|
||||
return {
|
||||
github,
|
||||
generateBranchName: (seed) => slugify(seed ?? uuidv4()),
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveDefaultBranch(
|
||||
repoRoot: string,
|
||||
deps: CreateWorktreeCoreDeps,
|
||||
@@ -132,3 +148,7 @@ function validateWorktreeSlug(slug: string): string {
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
|
||||
function normalizeWorktreeSlug(value: string): string {
|
||||
return validateWorktreeSlug(slugify(value));
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@ import {
|
||||
createPaseoWorktree as createPaseoWorktreeService,
|
||||
type CreatePaseoWorktreeFn,
|
||||
} from "./paseo-worktree-service.js";
|
||||
import { createWorktreeCoreDeps } from "./worktree-core.js";
|
||||
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
|
||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
|
||||
@@ -65,7 +64,7 @@ function createLegacyWorktreeForTest(
|
||||
source: {
|
||||
kind: "branch-off",
|
||||
baseBranch: options.baseBranch,
|
||||
newBranchName: options.branchName,
|
||||
branchName: options.branchName,
|
||||
},
|
||||
runSetup: options.runSetup ?? true,
|
||||
paseoHome: options.paseoHome,
|
||||
@@ -262,9 +261,8 @@ function createPaseoWorktreeForTest(options: {
|
||||
});
|
||||
|
||||
return (input, serviceOptions) => {
|
||||
const coreDeps = createWorktreeCoreDeps(createGitHubServiceStub());
|
||||
return createPaseoWorktreeService(input, {
|
||||
...coreDeps,
|
||||
github: createGitHubServiceStub(),
|
||||
...(serviceOptions?.resolveDefaultBranch
|
||||
? { resolveDefaultBranch: serviceOptions.resolveDefaultBranch }
|
||||
: {}),
|
||||
@@ -1303,7 +1301,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
||||
intent: {
|
||||
kind: "branch-off" as const,
|
||||
baseBranch: "main",
|
||||
newBranchName: "fix-attached-pr-context",
|
||||
branchName: "fix-attached-pr-context",
|
||||
},
|
||||
workspace: {
|
||||
workspaceId: "/tmp/worktrees/fix-attached-pr-context",
|
||||
@@ -1445,7 +1443,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
||||
expect(result.intent).toMatchObject({
|
||||
kind: "branch-off",
|
||||
baseBranch: "main",
|
||||
newBranchName: "resolver-feature",
|
||||
branchName: "resolver-feature",
|
||||
});
|
||||
expect(resolveDefaultBranch).toHaveBeenCalledWith(repoDir);
|
||||
});
|
||||
|
||||
@@ -115,6 +115,10 @@ interface CreatePaseoWorktreeWorkflowDependencies extends CreatePaseoWorktreeInB
|
||||
},
|
||||
) => Promise<CreatePaseoWorktreeResult>;
|
||||
warmWorkspaceGitData: (workspace: PersistedWorkspaceRecord) => Promise<void>;
|
||||
autoNameWorkspaceBranchForFirstAgent?: (input: {
|
||||
workspace: PersistedWorkspaceRecord;
|
||||
firstAgentContext: FirstAgentContext;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
interface AgentWorktreeSetupContinuationInput {
|
||||
@@ -576,6 +580,12 @@ export async function createPaseoWorktreeWorkflow(
|
||||
const setupContinuation = options?.setupContinuation ?? { kind: "workspace" };
|
||||
|
||||
setTimeout(() => {
|
||||
if (input.firstAgentContext) {
|
||||
dependencies.autoNameWorkspaceBranchForFirstAgent?.({
|
||||
workspace,
|
||||
firstAgentContext: input.firstAgentContext,
|
||||
});
|
||||
}
|
||||
void dependencies.warmWorkspaceGitData(workspace).catch((error) => {
|
||||
dependencies.sessionLogger.warn(
|
||||
{ err: error, workspaceId: workspace.workspaceId },
|
||||
|
||||
@@ -999,6 +999,33 @@ process.stdin.on("data", (chunk) => {
|
||||
process.stdout.write("\\x1b[c");
|
||||
`;
|
||||
|
||||
const DSR_HELPER_SCRIPT = `process.stdin.setRawMode(true);
|
||||
process.stdin.resume();
|
||||
const mode = process.argv[2] || "cursor";
|
||||
const query = mode === "private-cursor" ? "\\x1b[?6n" : mode === "status" ? "\\x1b[5n" : "\\x1b[6n";
|
||||
const pattern = mode === "private-cursor" ? /\\x1b\\[\\?(\\d+);(\\d+)R/ : mode === "status" ? /\\x1b\\[0n/ : /\\x1b\\[(\\d+);(\\d+)R/;
|
||||
let buf = "";
|
||||
const timer = setTimeout(() => {
|
||||
process.stdout.write("DSR_TIMEOUT\\n");
|
||||
process.exit(2);
|
||||
}, 2500);
|
||||
process.stdin.on("data", (chunk) => {
|
||||
buf += chunk.toString("binary");
|
||||
const match = buf.match(pattern);
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(timer);
|
||||
if (mode === "status") {
|
||||
process.stdout.write("DSR_OK:status\\n");
|
||||
} else {
|
||||
process.stdout.write("DSR_OK:" + match[1] + ":" + match[2] + "\\n");
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
process.stdout.write(query);
|
||||
`;
|
||||
|
||||
function writeDaHelper(prefix: string): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), prefix));
|
||||
temporaryDirs.push(dir);
|
||||
@@ -1007,14 +1034,30 @@ function writeDaHelper(prefix: string): string {
|
||||
return path;
|
||||
}
|
||||
|
||||
function writeDsrHelper(prefix: string): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), prefix));
|
||||
temporaryDirs.push(dir);
|
||||
const path = join(dir, "helper.cjs");
|
||||
writeFileSync(path, DSR_HELPER_SCRIPT);
|
||||
return path;
|
||||
}
|
||||
|
||||
function isDaOkLine(line: string): boolean {
|
||||
return line.startsWith("DA_OK:");
|
||||
}
|
||||
|
||||
function isDsrOkLine(line: string): boolean {
|
||||
return line.startsWith("DSR_OK:");
|
||||
}
|
||||
|
||||
function hasDaOkLine(state: ReturnType<TerminalSession["getState"]>): boolean {
|
||||
return getLines(state).some(isDaOkLine);
|
||||
}
|
||||
|
||||
function hasDsrOkLine(state: ReturnType<TerminalSession["getState"]>): boolean {
|
||||
return getLines(state).some(isDsrOkLine);
|
||||
}
|
||||
|
||||
function lastNonEmptyLineIsPrompt(state: ReturnType<TerminalSession["getState"]>): boolean {
|
||||
const last =
|
||||
getLines(state)
|
||||
@@ -1059,6 +1102,63 @@ describe("terminal protocol queries", () => {
|
||||
await waitForState(session, hasDaOkLine);
|
||||
await waitForState(session, lastNonEmptyLineIsPrompt);
|
||||
});
|
||||
|
||||
it("delivers public DSR cursor-position replies to a foreground app on stdin", async () => {
|
||||
const helperPath = writeDsrHelper("terminal-dsr-helper-");
|
||||
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
}),
|
||||
);
|
||||
await waitForLines(session, ["$"]);
|
||||
|
||||
session.send({ type: "input", data: `${process.execPath} ${helperPath} cursor\r` });
|
||||
await waitForState(session, hasDsrOkLine);
|
||||
|
||||
const ack = getLines(session.getState()).find(isDsrOkLine) ?? "";
|
||||
expect(ack).toMatch(/^DSR_OK:\d+:\d+$/);
|
||||
});
|
||||
|
||||
it("delivers private DSR cursor-position replies to a foreground app on stdin", async () => {
|
||||
const helperPath = writeDsrHelper("terminal-dsr-private-helper-");
|
||||
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
}),
|
||||
);
|
||||
await waitForLines(session, ["$"]);
|
||||
|
||||
session.send({ type: "input", data: `${process.execPath} ${helperPath} private-cursor\r` });
|
||||
await waitForState(session, hasDsrOkLine);
|
||||
|
||||
const ack = getLines(session.getState()).find(isDsrOkLine) ?? "";
|
||||
expect(ack).toMatch(/^DSR_OK:\d+:\d+$/);
|
||||
});
|
||||
|
||||
it("delivers DSR terminal-status replies to a foreground app on stdin", async () => {
|
||||
const helperPath = writeDsrHelper("terminal-dsr-status-helper-");
|
||||
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
}),
|
||||
);
|
||||
await waitForLines(session, ["$"]);
|
||||
|
||||
session.send({ type: "input", data: `${process.execPath} ${helperPath} status\r` });
|
||||
await waitForState(session, hasDsrOkLine);
|
||||
|
||||
const ack = getLines(session.getState()).find(isDsrOkLine) ?? "";
|
||||
expect(ack).toBe("DSR_OK:status");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stream snapshots", () => {
|
||||
|
||||
@@ -670,6 +670,29 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
}
|
||||
return false;
|
||||
});
|
||||
terminal.parser.registerCsiHandler({ final: "n" }, (params) => {
|
||||
if (params.length !== 1) {
|
||||
return false;
|
||||
}
|
||||
if (params[0] === 5) {
|
||||
ptyProcess.write("\x1b[0n");
|
||||
return true;
|
||||
}
|
||||
if (params[0] === 6) {
|
||||
const buffer = terminal.buffer.active;
|
||||
ptyProcess.write(`\x1b[${buffer.cursorY + 1};${buffer.cursorX + 1}R`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
terminal.parser.registerCsiHandler({ prefix: "?", final: "n" }, (params) => {
|
||||
if (params.length !== 1 || params[0] !== 6) {
|
||||
return false;
|
||||
}
|
||||
const buffer = terminal.buffer.active;
|
||||
ptyProcess.write(`\x1b[?${buffer.cursorY + 1};${buffer.cursorX + 1}R`);
|
||||
return true;
|
||||
});
|
||||
|
||||
let disposeTitleChangeSubscription: { dispose(): void } | null = null;
|
||||
if (!lockedTitle) {
|
||||
|
||||
@@ -64,7 +64,7 @@ function createLegacyWorktreeForTest(
|
||||
source: {
|
||||
kind: "branch-off",
|
||||
baseBranch: options.baseBranch,
|
||||
newBranchName: options.branchName,
|
||||
branchName: options.branchName,
|
||||
},
|
||||
runSetup: options.runSetup ?? true,
|
||||
paseoHome: options.paseoHome,
|
||||
|
||||
@@ -62,7 +62,7 @@ function createLegacyWorktreeForTest(
|
||||
source: {
|
||||
kind: "branch-off",
|
||||
baseBranch: options.baseBranch,
|
||||
newBranchName: options.branchName,
|
||||
branchName: options.branchName,
|
||||
},
|
||||
runSetup: options.runSetup ?? true,
|
||||
paseoHome: options.paseoHome,
|
||||
@@ -182,7 +182,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
|
||||
const result = await createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "my-feature",
|
||||
source: { kind: "branch-off", baseBranch: "main", newBranchName: "feature/x" },
|
||||
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/x" },
|
||||
runSetup: true,
|
||||
paseoHome,
|
||||
});
|
||||
@@ -796,7 +796,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
|
||||
const result = await createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "seed-uncommitted",
|
||||
source: { kind: "branch-off", baseBranch: "main", newBranchName: "feature/seed" },
|
||||
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/seed" },
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
@@ -824,7 +824,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
|
||||
const result = await createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "preserve-committed",
|
||||
source: { kind: "branch-off", baseBranch: "main", newBranchName: "feature/preserve" },
|
||||
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/preserve" },
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
@@ -839,7 +839,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
|
||||
const result = await createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "no-config",
|
||||
source: { kind: "branch-off", baseBranch: "main", newBranchName: "feature/no-config" },
|
||||
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/no-config" },
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
@@ -150,7 +150,7 @@ export interface PaseoWorktreeOwnership {
|
||||
}
|
||||
|
||||
export type WorktreeSource =
|
||||
| { kind: "branch-off"; baseBranch: string; newBranchName: string }
|
||||
| { kind: "branch-off"; baseBranch: string; branchName: string }
|
||||
| { kind: "checkout-branch"; branchName: string }
|
||||
| {
|
||||
kind: "checkout-github-pr";
|
||||
@@ -1292,7 +1292,7 @@ async function resolveWorktreeSourcePlan({
|
||||
}: ResolveWorktreeSourcePlanOptions): Promise<WorktreeSourcePlan> {
|
||||
switch (source.kind) {
|
||||
case "branch-off": {
|
||||
const branchName = source.newBranchName;
|
||||
const branchName = source.branchName;
|
||||
validateWorktreeBranchName(branchName);
|
||||
const normalizedBaseBranch = normalizeRequiredBaseBranch(source.baseBranch);
|
||||
const resolvedBaseBranch = await resolveBaseBranchForWorktree(cwd, normalizedBaseBranch);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.65",
|
||||
"version": "0.1.68",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user