From 369adced529d61376dbe573a653dec1d89c47433 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 3 Jun 2026 23:17:08 +0800 Subject: [PATCH 1/2] Archive merged PR workspaces from settings (#1313) * Add workspace settings for merged PR cleanup * Fix worktree branch ahead status * Handle PR worktree upstream status * Remove stale checkout tracking field * Update settings E2E host section slugs * Treat unknown upstream as unsafe for auto-archive --- packages/app/e2e/helpers/desktop-updates.ts | 4 +- packages/app/e2e/helpers/settings.ts | 11 ++- packages/app/e2e/settings-host-page.spec.ts | 20 ++-- .../hosts/[serverId]/[hostSection].tsx | 4 +- packages/app/src/screens/settings-screen.tsx | 22 +++-- .../app/src/screens/settings/host-page.tsx | 70 +++++++++++++- packages/app/src/utils/host-routes.test.ts | 16 ++++ packages/app/src/utils/host-routes.ts | 20 +++- .../archive-if-safe.test.ts | 11 +++ .../auto-archive-on-merge/archive-if-safe.ts | 5 +- .../workspace-git-service.primitive.test.ts | 2 - .../src/server/workspace-git-service.test.ts | 1 - .../server/src/utils/checkout-git.test.ts | 92 ++++++++++++++++++- packages/server/src/utils/checkout-git.ts | 59 +++++------- 14 files changed, 262 insertions(+), 75 deletions(-) diff --git a/packages/app/e2e/helpers/desktop-updates.ts b/packages/app/e2e/helpers/desktop-updates.ts index c6c728fca..4023f4f54 100644 --- a/packages/app/e2e/helpers/desktop-updates.ts +++ b/packages/app/e2e/helpers/desktop-updates.ts @@ -257,9 +257,9 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi export async function openDesktopSettings(page: Page, serverId: string): Promise { await openSettings(page); await openSettingsHost(page, serverId); - // The daemon-lifecycle card moved to the Daemon section in the flat-settings + // The daemon-lifecycle card moved to the Host section in the flat-settings // layout; navigate there before asserting it. - await openSettingsHostSection(page, serverId, "daemon"); + await openSettingsHostSection(page, serverId, "host"); await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toBeVisible({ timeout: 15_000, }); diff --git a/packages/app/e2e/helpers/settings.ts b/packages/app/e2e/helpers/settings.ts index 6164ee673..e7648e0ea 100644 --- a/packages/app/e2e/helpers/settings.ts +++ b/packages/app/e2e/helpers/settings.ts @@ -15,7 +15,7 @@ const SECTION_LABELS = { export type SettingsSection = keyof typeof SECTION_LABELS | "projects"; -type HostSection = "connections" | "orchestration" | "providers" | "daemon"; +type HostSection = "connections" | "agents" | "workspaces" | "providers" | "host"; export async function openSettingsSection(page: Page, section: SettingsSection): Promise { const sidebar = page.getByTestId("settings-sidebar"); @@ -231,9 +231,9 @@ export async function openHostSection( } export async function expectHostActionCards(page: Page, serverId: string): Promise { - // Restart + remove cards live on the Daemon section; providers moved to its + // Restart + remove cards live on the Host section; providers moved to its // own Providers section (asserted via expectHostProvidersCard). - await openSettingsHostSection(page, serverId, "daemon"); + await openSettingsHostSection(page, serverId, "host"); await expect(page.getByTestId("host-page-restart-card")).toBeVisible(); await expect(page.getByTestId("host-page-restart-button")).toBeVisible(); await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible(); @@ -296,9 +296,10 @@ export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise { await expectHostConnectionsCard(page, port); }); - test("orchestration section shows the inject MCP toggle", async ({ page }) => { + test("agents section shows the inject MCP toggle", async ({ page }) => { const serverId = getServerId(); await gotoAppShell(page); await openSettings(page); await openSettingsHost(page, serverId); - await openHostSection(page, serverId, "orchestration"); - await expectSettingsHeader(page, "Orchestration"); + await openHostSection(page, serverId, "agents"); + await expectSettingsHeader(page, "Agents"); await expectHostInjectMcpCard(page); }); @@ -56,15 +56,15 @@ test.describe("Settings host page", () => { await expectSettingsHeader(page, "Providers"); }); - test("daemon section shows the host label and restart/remove action cards", async ({ page }) => { + test("host section shows the host label and restart/remove action cards", async ({ page }) => { const serverId = getServerId(); await gotoAppShell(page); await openSettings(page); await openSettingsHost(page, serverId); - await openHostSection(page, serverId, "daemon"); - await expectSettingsHeader(page, "Daemon"); + await openHostSection(page, serverId, "host"); + await expectSettingsHeader(page, "Host"); await expectHostLabelDisplayed(page); await expectHostActionCards(page, serverId); }); @@ -75,14 +75,14 @@ test.describe("Settings host page", () => { await gotoAppShell(page); await openSettings(page); await openSettingsHost(page, serverId); - await openHostSection(page, serverId, "daemon"); + await openHostSection(page, serverId, "host"); await expectHostLabelDisplayed(page); await clickEditHostLabel(page); await expectHostLabelEditMode(page, TEST_HOST_LABEL); }); - test("daemon section does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({ + test("host section does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({ page, }) => { const serverId = getServerId(); @@ -90,7 +90,7 @@ test.describe("Settings host page", () => { await gotoAppShell(page); await openSettings(page); await openSettingsHost(page, serverId); - await openHostSection(page, serverId, "daemon"); + await openHostSection(page, serverId, "host"); // TODO: add local-daemon fixture for positive Pair/Daemon coverage. await expectHostNoLocalOnlyRows(page); @@ -113,7 +113,7 @@ test.describe("Settings host page", () => { await expectHostPageVisible(page, serverId); await expectSettingsHeader(page, "Connections"); - await openHostSection(page, serverId, "daemon"); + await openHostSection(page, serverId, "host"); await expectHostLabelDisplayed(page); await expectHostActionCards(page, serverId); }); diff --git a/packages/app/src/app/settings/hosts/[serverId]/[hostSection].tsx b/packages/app/src/app/settings/hosts/[serverId]/[hostSection].tsx index 7ca4283ce..dd870e681 100644 --- a/packages/app/src/app/settings/hosts/[serverId]/[hostSection].tsx +++ b/packages/app/src/app/settings/hosts/[serverId]/[hostSection].tsx @@ -2,13 +2,13 @@ import { useLocalSearchParams } from "expo-router"; import { useMemo } from "react"; import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; import SettingsScreen from "@/screens/settings-screen"; -import { type HostSectionSlug, isHostSectionSlug } from "@/utils/host-routes"; +import { normalizeHostSectionSlug } from "@/utils/host-routes"; export default function SettingsHostSectionRoute() { const params = useLocalSearchParams<{ serverId?: string; hostSection?: string }>(); const serverId = typeof params.serverId === "string" ? params.serverId.trim() : ""; const rawSection = typeof params.hostSection === "string" ? params.hostSection : ""; - const section: HostSectionSlug = isHostSectionSlug(rawSection) ? rawSection : "connections"; + const section = normalizeHostSectionSlug(rawSection) ?? "connections"; const view = useMemo(() => ({ kind: "host" as const, serverId, section }), [serverId, section]); return ( diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index a1acac1e8..82d82ae17 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -30,7 +30,7 @@ import { Palette, Server, Network, - Workflow, + Bot, Boxes, Keyboard, Stethoscope, @@ -93,9 +93,10 @@ import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pc import { useVoiceAudioEngineOptional } from "@/contexts/voice-context"; import { HostConnectionsPage, - HostDaemonPage, - HostOrchestrationPage, + HostAgentsPage, + HostSettingsPage, HostProvidersPage, + HostWorkspacesPage, } from "@/screens/settings/host-page"; import ProjectsScreen from "@/screens/projects-screen"; import ProjectSettingsScreen from "@/screens/project-settings-screen"; @@ -148,9 +149,10 @@ interface HostSectionItem { const HOST_SECTION_ITEMS: HostSectionItem[] = [ { id: "connections", label: "Connections", icon: Network }, - { id: "orchestration", label: "Orchestration", icon: Workflow }, + { id: "agents", label: "Agents", icon: Bot }, + { id: "workspaces", label: "Workspaces", icon: FolderGit2 }, { id: "providers", label: "Providers", icon: Boxes }, - { id: "daemon", label: "Daemon", icon: Server }, + { id: "host", label: "Host", icon: Server }, ]; // --------------------------------------------------------------------------- @@ -1304,12 +1306,14 @@ export default function SettingsScreen({ view }: SettingsScreenProps) { switch (view.section) { case "connections": return ; - case "orchestration": - return ; + case "agents": + return ; + case "workspaces": + return ; case "providers": return ; - case "daemon": - return ; + case "host": + return ; } } if (view.kind === "projects") { diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx index dc561c28a..78835e378 100644 --- a/packages/app/src/screens/settings/host-page.tsx +++ b/packages/app/src/screens/settings/host-page.tsx @@ -188,7 +188,7 @@ export function HostConnectionsPage({ serverId }: { serverId: string }) { ); } -export function HostOrchestrationPage({ serverId }: { serverId: string }) { +export function HostAgentsPage({ serverId }: { serverId: string }) { const host = useHostProfile(serverId); const isConnected = useHostRuntimeIsConnected(serverId); @@ -199,13 +199,36 @@ export function HostOrchestrationPage({ serverId }: { serverId: string }) { return ( {isConnected ? ( - + ) : ( - Connect to this host to manage orchestration + Connect to this host to manage agents + + )} + + ); +} + +export function HostWorkspacesPage({ serverId }: { serverId: string }) { + const host = useHostProfile(serverId); + const isConnected = useHostRuntimeIsConnected(serverId); + + if (!host) { + return ; + } + + return ( + + {isConnected ? ( + + + + ) : ( + + Connect to this host to manage workspaces )} @@ -226,7 +249,7 @@ export function HostProvidersPage({ serverId }: { serverId: string }) { ); } -export function HostDaemonPage({ +export function HostSettingsPage({ serverId, onHostRemoved, }: { @@ -626,6 +649,45 @@ function InjectPaseoToolsCard({ serverId }: { serverId: string }) { ); } +function AutoArchiveMergedWorkspacesCard({ serverId }: { serverId: string }) { + const isConnected = useHostRuntimeIsConnected(serverId); + const { config, patchConfig } = useDaemonConfig(serverId); + + const handleValueChange = useCallback( + (next: boolean) => { + void patchConfig({ autoArchiveAfterMerge: next }).catch((error) => { + console.error("[HostPage] Failed to update auto-archive after merge", error); + Alert.alert( + "Unable to update workspaces", + error instanceof Error ? error.message : String(error), + ); + }); + }, + [patchConfig], + ); + + if (!isConnected) return null; + + return ( + + + + Archive merged PR workspaces + + Automatically archive clean Paseo workspaces after their pull request is merged + + + + + + ); +} + function AppendSystemPromptCard({ serverId }: { serverId: string }) { const isConnected = useHostRuntimeIsConnected(serverId); const { config, patchConfig } = useDaemonConfig(serverId); diff --git a/packages/app/src/utils/host-routes.test.ts b/packages/app/src/utils/host-routes.test.ts index 02712d2f4..f9c23ec47 100644 --- a/packages/app/src/utils/host-routes.test.ts +++ b/packages/app/src/utils/host-routes.test.ts @@ -10,6 +10,7 @@ import { decodeWorkspaceIdFromPathSegment, encodeFilePathForPathSegment, encodeWorkspaceIdForPathSegment, + normalizeHostSectionSlug, parseHostAgentRouteFromPathname, parseHostWorkspaceOpenIntentFromPathname, parseHostWorkspaceRouteFromPathname, @@ -160,3 +161,18 @@ describe("projects settings routes", () => { expect(decodeURIComponent(segment)).toBe(projectKey); }); }); + +describe("host settings section slugs", () => { + it("keeps current host settings sections", () => { + expect(normalizeHostSectionSlug("connections")).toBe("connections"); + expect(normalizeHostSectionSlug("agents")).toBe("agents"); + expect(normalizeHostSectionSlug("workspaces")).toBe("workspaces"); + expect(normalizeHostSectionSlug("providers")).toBe("providers"); + expect(normalizeHostSectionSlug("host")).toBe("host"); + }); + + it("maps old host settings sections to their new names", () => { + expect(normalizeHostSectionSlug("orchestration")).toBe("agents"); + expect(normalizeHostSectionSlug("daemon")).toBe("host"); + }); +}); diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index 7d210c194..9d4f2edd5 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -391,14 +391,32 @@ export function isSettingsSectionSlug(value: string): value is SettingsSectionSl return (SETTINGS_SECTION_SLUGS as readonly string[]).includes(value); } -export const HOST_SECTION_SLUGS = ["connections", "orchestration", "providers", "daemon"] as const; +export const HOST_SECTION_SLUGS = [ + "connections", + "agents", + "workspaces", + "providers", + "host", +] as const; export type HostSectionSlug = (typeof HOST_SECTION_SLUGS)[number]; +const LEGACY_HOST_SECTION_SLUGS: Record = { + orchestration: "agents", + daemon: "host", +}; + export function isHostSectionSlug(value: string): value is HostSectionSlug { return (HOST_SECTION_SLUGS as readonly string[]).includes(value); } +export function normalizeHostSectionSlug(value: string): HostSectionSlug | null { + if (isHostSectionSlug(value)) { + return value; + } + return LEGACY_HOST_SECTION_SLUGS[value] ?? null; +} + export function buildSettingsRoute() { return "/settings" as const; } diff --git a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts index 3b531c428..789326195 100644 --- a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts +++ b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts @@ -233,6 +233,17 @@ describe("archiveIfSafe", () => { expect(harness.deps.archivePaseoWorktree).not.toHaveBeenCalled(); }); + test("does nothing when the upstream status is unknown", async () => { + const harness = createHarness({ + getSnapshot: async () => createSnapshot({ git: { aheadOfOrigin: null } }), + }); + + await runArchiveIfSafe(harness); + + expect(harness.deps.isPaseoOwnedWorktreeCwd).not.toHaveBeenCalled(); + expect(harness.deps.archivePaseoWorktree).not.toHaveBeenCalled(); + }); + test("does nothing when the cwd is not a Paseo-owned worktree", async () => { const harness = createHarness({ isPaseoOwnedWorktreeCwd: async () => ({ allowed: false, worktreePath: CWD }), diff --git a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts index 40fd706e8..d98ceaa86 100644 --- a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts +++ b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts @@ -78,7 +78,10 @@ export async function archiveIfSafe(input: { return; } - if (snapshot.git.isDirty === true || (snapshot.git.aheadOfOrigin ?? 0) > 0) { + if (snapshot.git.isDirty === true || snapshot.git.aheadOfOrigin === null) { + return; + } + if (snapshot.git.aheadOfOrigin > 0) { return; } diff --git a/packages/server/src/server/workspace-git-service.primitive.test.ts b/packages/server/src/server/workspace-git-service.primitive.test.ts index 967467292..10131ee60 100644 --- a/packages/server/src/server/workspace-git-service.primitive.test.ts +++ b/packages/server/src/server/workspace-git-service.primitive.test.ts @@ -79,7 +79,6 @@ function createCheckoutFacts( comparisonBaseRef: null, branchRemoteName: null, branchMergeRef: null, - trackedOriginBranch: null, pullRequestLookupTarget: { headRef: "main" }, ...overrides, }; @@ -890,7 +889,6 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { currentBranch: "fork-owner/open-button-targets-active-file", branchRemoteName: "paseo-pr-1285", branchMergeRef: "refs/heads/open-button-targets-active-file", - trackedOriginBranch: "paseo-pr-1285/open-button-targets-active-file", pullRequestLookupTarget: { headRef: "open-button-targets-active-file", headRepositoryOwner: "fork-owner", diff --git a/packages/server/src/server/workspace-git-service.test.ts b/packages/server/src/server/workspace-git-service.test.ts index f99a39e2b..eb07fc11f 100644 --- a/packages/server/src/server/workspace-git-service.test.ts +++ b/packages/server/src/server/workspace-git-service.test.ts @@ -121,7 +121,6 @@ function createCheckoutSnapshotFacts(cwd: string): CheckoutSnapshotFacts { comparisonBaseRef: null, branchRemoteName: "origin", branchMergeRef: "refs/heads/main", - trackedOriginBranch: "main", pullRequestLookupTarget: { headRef: "main" }, }; } diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index fcccea51f..1c45e8869 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -522,6 +522,92 @@ const x = 1; expect(divergedStatus.behindOfOrigin).toBe(1); }); + it("reports a PR worktree as not ahead when its branch is pushed to the configured PR remote", async () => { + setupRemoteTrackingMain(repoDir, tempDir); + const prRemoteDir = join(tempDir, "pr-remote.git"); + execFileSync("git", ["init", "--bare", "-b", "main", prRemoteDir]); + execFileSync("git", ["checkout", "-b", "aaronzhongg/open-button-targets-active-file"], { + cwd: repoDir, + }); + commitFile(repoDir, "feature.txt", "feature\n", "feature commit"); + execFileSync("git", ["remote", "add", "paseo-pr-1285", prRemoteDir], { cwd: repoDir }); + execFileSync( + "git", + ["push", "paseo-pr-1285", "HEAD:refs/heads/open-button-targets-active-file"], + { cwd: repoDir }, + ); + execFileSync( + "git", + ["config", "branch.aaronzhongg/open-button-targets-active-file.remote", "paseo-pr-1285"], + { + cwd: repoDir, + }, + ); + execFileSync( + "git", + [ + "config", + "branch.aaronzhongg/open-button-targets-active-file.merge", + "refs/heads/open-button-targets-active-file", + ], + { cwd: repoDir }, + ); + + const status = await getCheckoutStatus(repoDir); + + expect(status).toMatchObject({ + isGit: true, + currentBranch: "aaronzhongg/open-button-targets-active-file", + aheadOfOrigin: 0, + }); + }); + + it("reports a PR worktree as behind when its configured PR remote has newer commits", async () => { + setupRemoteTrackingMain(repoDir, tempDir); + const prRemoteDir = join(tempDir, "pr-remote.git"); + const prCloneDir = join(tempDir, "pr-clone"); + execFileSync("git", ["init", "--bare", "-b", "main", prRemoteDir]); + execFileSync("git", ["checkout", "-b", "aaronzhongg/open-button-targets-active-file"], { + cwd: repoDir, + }); + commitFile(repoDir, "feature.txt", "feature\n", "feature commit"); + execFileSync("git", ["remote", "add", "paseo-pr-1285", prRemoteDir], { cwd: repoDir }); + execFileSync( + "git", + ["push", "paseo-pr-1285", "HEAD:refs/heads/open-button-targets-active-file"], + { cwd: repoDir }, + ); + execFileSync( + "git", + ["config", "branch.aaronzhongg/open-button-targets-active-file.remote", "paseo-pr-1285"], + { cwd: repoDir }, + ); + execFileSync( + "git", + [ + "config", + "branch.aaronzhongg/open-button-targets-active-file.merge", + "refs/heads/open-button-targets-active-file", + ], + { cwd: repoDir }, + ); + execFileSync("git", ["clone", prRemoteDir, prCloneDir]); + execFileSync("git", ["checkout", "open-button-targets-active-file"], { cwd: prCloneDir }); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: prCloneDir }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: prCloneDir }); + commitFile(prCloneDir, "remote.txt", "remote\n", "remote update"); + execFileSync("git", ["push"], { cwd: prCloneDir }); + execFileSync("git", ["fetch", "paseo-pr-1285"], { cwd: repoDir }); + + const status = await getCheckoutStatus(repoDir); + + expect(status).toMatchObject({ + isGit: true, + currentBranch: "aaronzhongg/open-button-targets-active-file", + behindOfOrigin: 1, + }); + }); + it("does not report the full branch history as ahead when the current branch remote is gone", async () => { setupRemoteTrackingMain(repoDir, tempDir); execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); @@ -557,11 +643,11 @@ const x = 1; isPaseoOwnedWorktree: true, baseRef: "main", aheadBehind: { ahead: 0, behind: 0 }, - aheadOfOrigin: 0, + aheadOfOrigin: null, }); }); - it("reports local-only worktree commits as unpushed relative to base", async () => { + it("does not report local-only no-track worktree commits as ahead of origin", async () => { setupRemoteTrackingMain(repoDir, tempDir); commitFile(repoDir, "second.txt", "second\n", "second commit"); execFileSync("git", ["push"], { cwd: repoDir }); @@ -581,7 +667,7 @@ const x = 1; isPaseoOwnedWorktree: true, baseRef: "main", aheadBehind: { ahead: 1, behind: 0 }, - aheadOfOrigin: 1, + aheadOfOrigin: null, }); }); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index b3d8bca9b..f6bbc6e00 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -802,7 +802,6 @@ export type CheckoutSnapshotFacts = comparisonBaseRef: string | null; branchRemoteName: string | null; branchMergeRef: string | null; - trackedOriginBranch: string | null; pullRequestLookupTarget: PullRequestStatusLookupTarget | null; }; @@ -1407,57 +1406,46 @@ async function getAheadBehind( async function getAheadOfOrigin( cwd: string, currentBranch: string, - baseRef: string | null, context?: CheckoutContext, ): Promise { if (!currentBranch) { return null; } - const trackedOriginBranch = await getTrackedOriginBranch(cwd, currentBranch, context); - const originBranch = trackedOriginBranch ?? currentBranch; + const upstreamRef = await getConfiguredUpstreamRef(cwd, currentBranch, context); + if (!upstreamRef) { + return null; + } try { const { stdout } = await runGitCommand( - ["rev-list", "--count", `origin/${originBranch}..${currentBranch}`], + ["rev-list", "--count", `${upstreamRef}..${currentBranch}`], { cwd, envOverlay: READ_ONLY_GIT_ENV, logger: context?.logger }, ); const count = Number.parseInt(stdout.trim(), 10); return Number.isNaN(count) ? null : count; } catch { - if (trackedOriginBranch) { - return null; - } - if (!baseRef || normalizeLocalBranchRefName(baseRef) === currentBranch) { - return null; - } - try { - const comparisonBaseRef = await resolveBestComparisonBaseRef(cwd, baseRef, context); - const { stdout } = await runGitCommand( - ["rev-list", "--count", `${comparisonBaseRef}..${currentBranch}`], - { cwd, envOverlay: READ_ONLY_GIT_ENV, logger: context?.logger }, - ); - const count = Number.parseInt(stdout.trim(), 10); - return Number.isNaN(count) ? null : count; - } catch { - return null; - } + return null; } } -async function getTrackedOriginBranch( +async function getConfiguredUpstreamRef( cwd: string, currentBranch: string, context?: CheckoutContext, ): Promise { - if (context?.facts?.isGit && context.facts.currentBranch === currentBranch) { - return context.facts.trackedOriginBranch; - } - const remoteName = await getGitConfigValue(cwd, `branch.${currentBranch}.remote`, context); - if (remoteName !== "origin") { + const remoteName = + context?.facts?.isGit && context.facts.currentBranch === currentBranch + ? context.facts.branchRemoteName + : await getGitConfigValue(cwd, `branch.${currentBranch}.remote`, context); + if (!remoteName) { return null; } - const mergeRef = await getGitConfigValue(cwd, `branch.${currentBranch}.merge`, context); - return parseBranchMergeHeadRef(mergeRef); + const mergeRef = + context?.facts?.isGit && context.facts.currentBranch === currentBranch + ? context.facts.branchMergeRef + : await getGitConfigValue(cwd, `branch.${currentBranch}.merge`, context); + const upstreamBranch = parseBranchMergeHeadRef(mergeRef); + return upstreamBranch ? `${remoteName}/${upstreamBranch}` : null; } async function getBehindOfOrigin( @@ -1468,9 +1456,13 @@ async function getBehindOfOrigin( if (!currentBranch) { return null; } + const upstreamRef = await getConfiguredUpstreamRef(cwd, currentBranch, context); + if (!upstreamRef) { + return null; + } try { const { stdout } = await runGitCommand( - ["rev-list", "--count", `${currentBranch}..origin/${currentBranch}`], + ["rev-list", "--count", `${currentBranch}..${upstreamRef}`], { cwd, envOverlay: READ_ONLY_GIT_ENV, logger: context?.logger }, ); const count = Number.parseInt(stdout.trim(), 10); @@ -1602,8 +1594,6 @@ export async function getCheckoutSnapshotFacts( } } } - const trackedOriginBranch = - branchRemoteName === "origin" ? parseBranchMergeHeadRef(branchMergeRef) : null; const pullRequestLookupTarget = inspected.currentBranch ? buildPullRequestLookupTargetFromBranchConfig({ currentBranch: inspected.currentBranch, @@ -1627,7 +1617,6 @@ export async function getCheckoutSnapshotFacts( comparisonBaseRef, branchRemoteName, branchMergeRef, - trackedOriginBranch, pullRequestLookupTarget, }; } @@ -1765,7 +1754,7 @@ export async function getCheckoutStatus( ? getAheadBehind(cwd, baseRef, currentBranch, factsContext) : Promise.resolve(null), hasRemote && currentBranch - ? getAheadOfOrigin(cwd, currentBranch, baseRef, factsContext) + ? getAheadOfOrigin(cwd, currentBranch, factsContext) : Promise.resolve(null), hasRemote && currentBranch ? getBehindOfOrigin(cwd, currentBranch, factsContext) From b3ba1572b397a296eac1798f2fa1aed45543591e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 3 Jun 2026 23:17:14 +0800 Subject: [PATCH 2/2] Keep child agents unattended across providers (#1315) * Keep child agents unattended across providers * Clean up unattended create config resolution * Make provider catalog tests tolerate narrow CI fixtures --- .../src/server/agent/agent-sdk-types.ts | 1 + .../server/agent/create-agent-mode.test.ts | 47 +++- .../src/server/agent/create-agent-mode.ts | 33 ++- .../src/server/agent/create-agent/create.ts | 1 + .../src/server/agent/mcp-server.test.ts | 208 ++++++------------ .../server/agent/model-catalog.e2e.test.ts | 34 +-- .../agent/provider-snapshot-manager.test.ts | 140 ++++++++++++ .../server/agent/provider-snapshot-manager.ts | 31 ++- .../opencode-agent.full-access.test.ts | 43 ++++ .../server/agent/providers/opencode-agent.ts | 11 +- packages/server/src/server/bootstrap.ts | 2 + .../daemon-e2e/live-preferences.e2e.test.ts | 81 ++++--- .../server/src/server/loop-service.test.ts | 127 ++++++++++- packages/server/src/server/loop-service.ts | 48 +++- .../src/server/schedule/service.test.ts | 101 +++++++++ .../server/src/server/schedule/service.ts | 56 +++-- 16 files changed, 725 insertions(+), 239 deletions(-) diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 7227de4ea..9e1f69447 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -105,6 +105,7 @@ export interface ResolveAgentCreateConfigInput { requestedMode: string | undefined; featureValues: Record | undefined; parent: AgentCreateConfigParent | null; + unattended: boolean; availableModes: AgentMode[] | undefined; } diff --git a/packages/server/src/server/agent/create-agent-mode.test.ts b/packages/server/src/server/agent/create-agent-mode.test.ts index 3cb2c95a9..3f5c5cb35 100644 --- a/packages/server/src/server/agent/create-agent-mode.test.ts +++ b/packages/server/src/server/agent/create-agent-mode.test.ts @@ -5,12 +5,17 @@ const CLAUDE_MODES = ["default", "acceptEdits", "plan", "bypassPermissions"]; const OPENCODE_MODES = ["build", "plan"]; const CODEX_MODES = ["auto", "full-access"]; +function agentParent(provider: string, modeId: string | null, isUnattended = false) { + return { provider, modeId, isUnattended }; +} + describe("resolveAndValidateCreateAgentMode", () => { it("returns the requested mode when it is valid for the target provider", () => { const resolved = resolveAndValidateCreateAgentMode({ requestedMode: "plan", targetProvider: "opencode", parent: null, + unattended: false, availableModes: OPENCODE_MODES, }); expect(resolved).toBe("plan"); @@ -22,6 +27,7 @@ describe("resolveAndValidateCreateAgentMode", () => { requestedMode: "bypassPermissions", targetProvider: "opencode", parent: null, + unattended: false, availableModes: OPENCODE_MODES, }), ).toThrow( @@ -34,6 +40,7 @@ describe("resolveAndValidateCreateAgentMode", () => { requestedMode: undefined, targetProvider: "claude", parent: null, + unattended: false, availableModes: CLAUDE_MODES, }); expect(resolved).toBeUndefined(); @@ -43,7 +50,8 @@ describe("resolveAndValidateCreateAgentMode", () => { const resolved = resolveAndValidateCreateAgentMode({ requestedMode: undefined, targetProvider: "claude", - parent: { provider: "claude", modeId: "bypassPermissions" }, + parent: agentParent("claude", "bypassPermissions"), + unattended: false, availableModes: CLAUDE_MODES, }); expect(resolved).toBe("bypassPermissions"); @@ -53,7 +61,8 @@ describe("resolveAndValidateCreateAgentMode", () => { const resolved = resolveAndValidateCreateAgentMode({ requestedMode: undefined, targetProvider: "claude", - parent: { provider: "claude", modeId: null }, + parent: agentParent("claude", null), + unattended: false, availableModes: CLAUDE_MODES, }); expect(resolved).toBeUndefined(); @@ -64,7 +73,8 @@ describe("resolveAndValidateCreateAgentMode", () => { resolveAndValidateCreateAgentMode({ requestedMode: undefined, targetProvider: "opencode", - parent: { provider: "claude", modeId: "bypassPermissions" }, + parent: agentParent("claude", "bypassPermissions"), + unattended: false, availableModes: OPENCODE_MODES, }), ).toThrow( @@ -77,7 +87,8 @@ describe("resolveAndValidateCreateAgentMode", () => { resolveAndValidateCreateAgentMode({ requestedMode: undefined, targetProvider: "codex", - parent: { provider: "opencode", modeId: null }, + parent: agentParent("opencode", null), + unattended: false, availableModes: CODEX_MODES, }), ).toThrow( @@ -90,6 +101,7 @@ describe("resolveAndValidateCreateAgentMode", () => { requestedMode: "default", targetProvider: "zai-custom", parent: null, + unattended: false, availableModes: undefined, }); expect(resolved).toBe("default"); @@ -100,7 +112,8 @@ describe("resolveAndValidateCreateAgentMode", () => { resolveAndValidateCreateAgentMode({ requestedMode: undefined, targetProvider: "zai-custom", - parent: { provider: "claude", modeId: "default" }, + parent: agentParent("claude", "default"), + unattended: false, availableModes: undefined, }), ).toThrow("Available modes for 'zai-custom': unknown"); @@ -110,7 +123,20 @@ describe("resolveAndValidateCreateAgentMode", () => { const resolved = resolveAndValidateCreateAgentMode({ requestedMode: undefined, targetProvider: "codex", - parent: { provider: "claude", modeId: "bypassPermissions", isUnattended: true }, + parent: agentParent("claude", "bypassPermissions", true), + unattended: false, + availableModes: CODEX_MODES, + targetUnattendedMode: "full-access", + }); + expect(resolved).toBe("full-access"); + }); + + it("inherits target's unattended mode for unattended creation without a parent", () => { + const resolved = resolveAndValidateCreateAgentMode({ + requestedMode: undefined, + targetProvider: "codex", + parent: null, + unattended: true, availableModes: CODEX_MODES, targetUnattendedMode: "full-access", }); @@ -122,7 +148,8 @@ describe("resolveAndValidateCreateAgentMode", () => { resolveAndValidateCreateAgentMode({ requestedMode: undefined, targetProvider: "codex", - parent: { provider: "claude", modeId: "default", isUnattended: false }, + parent: agentParent("claude", "default"), + unattended: false, availableModes: CODEX_MODES, targetUnattendedMode: "full-access", }), @@ -136,7 +163,8 @@ describe("resolveAndValidateCreateAgentMode", () => { resolveAndValidateCreateAgentMode({ requestedMode: undefined, targetProvider: "zai-custom", - parent: { provider: "claude", modeId: "bypassPermissions", isUnattended: true }, + parent: agentParent("claude", "bypassPermissions", true), + unattended: false, availableModes: undefined, targetUnattendedMode: undefined, }), @@ -149,7 +177,8 @@ describe("resolveAndValidateCreateAgentMode", () => { const resolved = resolveAndValidateCreateAgentMode({ requestedMode: "auto", targetProvider: "codex", - parent: { provider: "claude", modeId: "bypassPermissions", isUnattended: true }, + parent: agentParent("claude", "bypassPermissions", true), + unattended: false, availableModes: CODEX_MODES, targetUnattendedMode: "full-access", }); diff --git a/packages/server/src/server/agent/create-agent-mode.ts b/packages/server/src/server/agent/create-agent-mode.ts index ae9cc23ab..191565071 100644 --- a/packages/server/src/server/agent/create-agent-mode.ts +++ b/packages/server/src/server/agent/create-agent-mode.ts @@ -1,4 +1,5 @@ import type { + AgentCreateConfigParent, AgentCreateConfigUnattendedInput, AgentMode, AgentProvider, @@ -6,16 +7,11 @@ import type { ResolveAgentCreateConfigResult, } from "./agent-sdk-types.js"; -interface CreateAgentModeParent { - provider: AgentProvider; - modeId: string | null; - isUnattended: boolean; -} - export interface ResolveCreateAgentModeInput { requestedMode: string | undefined; targetProvider: AgentProvider; - parent: CreateAgentModeParent | null; + parent: AgentCreateConfigParent | null; + unattended: boolean; // `undefined` = target provider's modes unknown: explicit modes pass through // unvalidated, but cross-provider inheritance is still refused. availableModes: string[] | undefined; @@ -31,6 +27,18 @@ function listModes(modes: string[] | undefined): string { return modes.length > 0 ? modes.join(", ") : "(none)"; } +function isUnattendedCreateConfigParent(parent: AgentCreateConfigParent): boolean { + return parent.isUnattended; +} + +function formatCreateConfigParentMode(parent: AgentCreateConfigParent): string { + return parent.modeId ?? ""; +} + +function formatCreateConfigParentSource(parent: AgentCreateConfigParent): string { + return `caller (provider '${parent.provider}')`; +} + export function resolveAndValidateCreateAgentMode( input: ResolveCreateAgentModeInput, ): string | undefined { @@ -46,6 +54,9 @@ export function resolveAndValidateCreateAgentMode( } if (!parent) { + if (input.unattended && input.targetUnattendedMode !== undefined) { + return input.targetUnattendedMode; + } return undefined; } @@ -53,12 +64,15 @@ export function resolveAndValidateCreateAgentMode( return parent.modeId ?? undefined; } - if (parent.isUnattended && input.targetUnattendedMode !== undefined) { + if ( + (input.unattended || isUnattendedCreateConfigParent(parent)) && + input.targetUnattendedMode !== undefined + ) { return input.targetUnattendedMode; } throw new Error( - `cannot inherit mode '${parent.modeId ?? ""}' from caller (provider '${parent.provider}') for new agent (provider '${targetProvider}'). Pass an explicit mode. Available modes for '${targetProvider}': ${listModes(availableModes)}`, + `cannot inherit mode '${formatCreateConfigParentMode(parent)}' from ${formatCreateConfigParentSource(parent)} for new agent (provider '${targetProvider}'). Pass an explicit mode. Available modes for '${targetProvider}': ${listModes(availableModes)}`, ); } @@ -71,6 +85,7 @@ export function resolveDefaultAgentCreateConfig( requestedMode: input.requestedMode, targetProvider: input.provider, parent: input.parent, + unattended: input.unattended, availableModes: availableModeIds, targetUnattendedMode: input.availableModes?.find(isUnattendedMode)?.id, }), diff --git a/packages/server/src/server/agent/create-agent/create.ts b/packages/server/src/server/agent/create-agent/create.ts index d38a35c43..4e69ed466 100644 --- a/packages/server/src/server/agent/create-agent/create.ts +++ b/packages/server/src/server/agent/create-agent/create.ts @@ -264,6 +264,7 @@ async function resolveMcpCreateAgent( requestedMode: input.mode, featureValues: input.features, parent: parentAgent, + unattended: false, }); const labels = mergeLabels({ diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 57c000a4d..e9a0cde1f 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -13,7 +13,6 @@ import { createAgentMcpServer } from "./mcp-server.js"; import type { AgentManager, ManagedAgent } from "./agent-manager.js"; import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js"; import type { AgentMode, AgentProvider, ProviderSnapshotEntry } from "./agent-sdk-types.js"; -import { resolveDefaultAgentCreateConfig } from "./create-agent-mode.js"; import { createProviderSnapshotManagerStub } from "../test-utils/session-stubs.js"; import { AgentListItemPayloadSchema, @@ -255,25 +254,33 @@ function buildSnapshotEntry(entry: ConfigureProviderEntry): ProviderSnapshotEntr } // Shared helper used by ~60 create_agent / update_agent / list_agents tests that -// only need a "normal" provider catalog (claude, codex, opencode) and the -// OpenCode resolveCreateConfig quirk: requestedMode="full-access" or an -// unattended parent maps to build mode + auto_accept feature. +// only need a "normal" provider catalog (claude, codex, opencode). OpenCode +// create-config behavior delegates to the production provider client. // // NOTE: This is NOT a registry. It directly configures the public stub surface. // Per-test customization is done by overriding individual stub methods after // calling this helper. -function configureOpenCodeProviderStub(stub: ProviderSnapshotManagerStub): void { +interface ConfigureOpenCodeProviderStubOptions { + customOpenCodeProvider?: AgentProvider; +} + +function configureOpenCodeProviderStub( + stub: ProviderSnapshotManagerStub, + options: ConfigureOpenCodeProviderStubOptions = {}, +): void { const claudeModes: AgentMode[] = [ { id: "default", label: "Default", description: "Ask first" }, { id: "bypassPermissions", label: "Bypass", description: "No prompts", isUnattended: true }, ]; const codexModes: AgentMode[] = [ { id: "default", label: "Default", description: "Default" }, - { id: "auto", label: "Auto", description: "Auto", isUnattended: true }, + { id: "auto", label: "Auto", description: "Auto" }, + { id: "full-access", label: "Full Access", description: "No prompts", isUnattended: true }, ]; const opencodeModes: AgentMode[] = [ { id: "build", label: "Build", description: "Can edit" }, { id: "plan", label: "Plan", description: "Read-only" }, + { id: "paseo-custom", label: "Paseo Custom", description: "Custom OpenCode agent" }, ]; const entries: ProviderSnapshotEntry[] = [ buildSnapshotEntry({ @@ -298,13 +305,31 @@ function configureOpenCodeProviderStub(stub: ProviderSnapshotManagerStub): void modes: opencodeModes, }), ]; + const customOpenCodeModes: AgentMode[] = [ + ...opencodeModes, + { id: "paseo-custom", label: "Paseo Custom" }, + ]; + if (options.customOpenCodeProvider) { + entries.push( + buildSnapshotEntry({ + provider: options.customOpenCodeProvider, + label: "OpenCode Custom", + description: "Custom OpenCode agent", + defaultModeId: "build", + modes: customOpenCodeModes, + }), + ); + } const modesByProvider: Record = { claude: claudeModes, codex: codexModes, opencode: opencodeModes, }; + if (options.customOpenCodeProvider) { + modesByProvider[options.customOpenCodeProvider] = customOpenCodeModes; + } - stub.listRegisteredProviderIds.mockReturnValue(["claude", "codex", "opencode"]); + stub.listRegisteredProviderIds.mockReturnValue(entries.map((entry) => entry.provider)); stub.hasProvider.mockImplementation((provider) => Object.prototype.hasOwnProperty.call(modesByProvider, provider), ); @@ -334,56 +359,18 @@ function configureOpenCodeProviderStub(stub: ProviderSnapshotManagerStub): void provider: AgentProvider; requestedMode: string | undefined; featureValues: Record | undefined; - parent: ManagedAgent | null; }; - if (opts.provider === "opencode") { - if (opts.requestedMode === "full-access") { - return { - modeId: "build", - featureValues: { ...opts.featureValues, auto_accept: true }, - }; - } - // Cross-provider unattended inheritance: caller is in unattended mode and - // requested no explicit mode — map to build + auto_accept. - if (opts.requestedMode === undefined && opts.parent) { - const parentModes = modesByProvider[opts.parent.provider] ?? []; - const parentMode = parentModes.find((m) => m.id === opts.parent?.currentModeId); - if (parentMode?.isUnattended === true) { - return { - modeId: "build", - featureValues: { ...opts.featureValues, auto_accept: true }, - }; - } - } - } - const availableModes = modesByProvider[opts.provider] ?? []; - const parentModes = opts.parent ? (modesByProvider[opts.parent.provider] ?? []) : []; - const parentMode = opts.parent - ? parentModes.find((m) => m.id === opts.parent?.currentModeId) - : null; - return resolveDefaultAgentCreateConfig({ - provider: opts.provider, - requestedMode: opts.requestedMode, - featureValues: opts.featureValues, - parent: opts.parent - ? { - provider: opts.parent.provider, - modeId: opts.parent.currentModeId, - isUnattended: parentMode?.isUnattended === true, - } - : null, - availableModes, - }); + return { modeId: opts.requestedMode, featureValues: opts.featureValues }; }); } // Quick helper: returns a manager configured with the standard OpenCode catalog. -function createOpenCodeManager(): { +function createOpenCodeManager(options?: ConfigureOpenCodeProviderStubOptions): { manager: ProviderSnapshotManagerStub["manager"]; stub: ProviderSnapshotManagerStub; } { const stub = createProviderSnapshotManagerStub(); - configureOpenCodeProviderStub(stub); + configureOpenCodeProviderStub(stub, options); return { manager: stub.manager, stub }; } @@ -1843,12 +1830,17 @@ describe("create_agent MCP tool", () => { availableModes: [], config: { title: "Child", featureValues: { fast_mode: true } }, } as ManagedAgent); + const providerSnapshot = createOpenCodeManager(); + providerSnapshot.stub.resolveCreateConfig.mockImplementation(async (input) => { + const opts = input as { featureValues: Record | undefined }; + return { modeId: undefined, featureValues: opts.featureValues }; + }); const server = await createAgentMcpServer({ agentManager, agentStorage, callerAgentId: "parent-agent", - providerSnapshotManager: createOpenCodeManager().manager, + providerSnapshotManager: providerSnapshot.manager, logger, }); const tool = registeredTool(server, "create_agent"); @@ -1917,10 +1909,14 @@ describe("create_agent MCP tool", () => { it("rejects an explicit mode that is not valid for the target provider", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); + const providerSnapshot = createOpenCodeManager(); + providerSnapshot.stub.resolveCreateConfig.mockImplementation(async () => { + throw new Error("resolver rejected mode"); + }); const server = await createAgentMcpServer({ agentManager, agentStorage, - providerSnapshotManager: createOpenCodeManager().manager, + providerSnapshotManager: providerSnapshot.manager, logger, }); const tool = registeredTool(server, "create_agent"); @@ -1933,9 +1929,7 @@ describe("create_agent MCP tool", () => { settings: { modeId: "bypassPermissions" }, initialPrompt: "Do work", }), - ).rejects.toThrow( - "Invalid mode 'bypassPermissions' for provider 'opencode'. Available modes: build, plan", - ); + ).rejects.toThrow("resolver rejected mode"); expect(spies.agentManager.createAgent).not.toHaveBeenCalled(); }); @@ -1963,13 +1957,8 @@ describe("create_agent MCP tool", () => { provStub.listModes.mockResolvedValue(dynamicModes); provStub.resolveCreateConfig.mockImplementation(async (input) => { const opts = input as { requestedMode: string | undefined }; - return resolveDefaultAgentCreateConfig({ - provider: "codex", - requestedMode: opts.requestedMode, - featureValues: undefined, - parent: null, - availableModes: dynamicModes, - }); + expect(opts.requestedMode).toBe("dynamic"); + return { modeId: "dynamic", featureValues: undefined }; }); const server = await createAgentMcpServer({ agentManager, @@ -1994,7 +1983,7 @@ describe("create_agent MCP tool", () => { ); }); - it("accepts legacy OpenCode full-access as build plus auto accept", async () => { + it("passes resolver-returned mode and features into createAgent", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); spies.agentManager.createAgent.mockResolvedValue({ id: "child-agent", @@ -2004,10 +1993,15 @@ describe("create_agent MCP tool", () => { availableModes: [], config: { title: "Child", featureValues: { auto_accept: true } }, } as ManagedAgent); + const providerSnapshot = createOpenCodeManager(); + providerSnapshot.stub.resolveCreateConfig.mockResolvedValue({ + modeId: "build", + featureValues: { auto_accept: true }, + }); const server = await createAgentMcpServer({ agentManager, agentStorage, - providerSnapshotManager: createOpenCodeManager().manager, + providerSnapshotManager: providerSnapshot.manager, logger, }); const tool = registeredTool(server, "create_agent"); @@ -2027,28 +2021,34 @@ describe("create_agent MCP tool", () => { ); }); - it("inherits the caller mode when the new agent uses the same provider", async () => { + it("passes the real parent agent and explicit unattended intent to the resolver", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); - spies.agentManager.getAgent.mockReturnValue({ + const parentAgent = { id: "parent-agent", cwd: existingCwd, provider: "claude", currentModeId: "bypassPermissions", - } as ManagedAgent); + } as ManagedAgent; + spies.agentManager.getAgent.mockReturnValue(parentAgent); spies.agentManager.createAgent.mockResolvedValue({ id: "child-agent", cwd: existingCwd, lifecycle: "idle", - currentModeId: "bypassPermissions", + currentModeId: "resolver-mode", availableModes: [], config: { title: "Child" }, } as ManagedAgent); + const providerSnapshot = createOpenCodeManager(); + providerSnapshot.stub.resolveCreateConfig.mockResolvedValue({ + modeId: "resolver-mode", + featureValues: { resolver_feature: true }, + }); const server = await createAgentMcpServer({ agentManager, agentStorage, callerAgentId: "parent-agent", - providerSnapshotManager: createOpenCodeManager().manager, + providerSnapshotManager: providerSnapshot.manager, logger, }); const tool = registeredTool(server, "create_agent"); @@ -2058,76 +2058,14 @@ describe("create_agent MCP tool", () => { initialPrompt: "Do work", }); - expect(spies.agentManager.createAgent).toHaveBeenCalledWith( - expect.objectContaining({ modeId: "bypassPermissions" }), - undefined, - expect.any(Object), + expect(providerSnapshot.stub.resolveCreateConfig).toHaveBeenCalledWith( + expect.objectContaining({ parent: parentAgent, unattended: false }), ); - }); - - it("refuses cross-provider mode inheritance when caller is not in an unattended mode", async () => { - const { agentManager, agentStorage, spies } = createTestDeps(); - spies.agentManager.getAgent.mockReturnValue({ - id: "parent-agent", - cwd: existingCwd, - provider: "claude", - currentModeId: "default", - } as ManagedAgent); - - const server = await createAgentMcpServer({ - agentManager, - agentStorage, - callerAgentId: "parent-agent", - providerSnapshotManager: createOpenCodeManager().manager, - logger, - }); - const tool = registeredTool(server, "create_agent"); - - await expect( - tool.handler({ - title: "Child", - provider: "opencode/gpt-5.4", - initialPrompt: "Do work", + expect(spies.agentManager.createAgent).toHaveBeenCalledWith( + expect.objectContaining({ + modeId: "resolver-mode", + featureValues: { resolver_feature: true }, }), - ).rejects.toThrow( - "cannot inherit mode 'default' from caller (provider 'claude') for new agent (provider 'opencode'). Pass an explicit mode. Available modes for 'opencode': build, plan", - ); - expect(spies.agentManager.createAgent).not.toHaveBeenCalled(); - }); - - it("maps unattended callers to OpenCode auto accept", async () => { - const { agentManager, agentStorage, spies } = createTestDeps(); - spies.agentManager.getAgent.mockReturnValue({ - id: "parent-agent", - cwd: existingCwd, - provider: "claude", - currentModeId: "bypassPermissions", - } as ManagedAgent); - spies.agentManager.createAgent.mockResolvedValue({ - id: "child-agent", - cwd: existingCwd, - lifecycle: "idle", - currentModeId: "build", - availableModes: [], - config: { title: "Child", featureValues: { auto_accept: true } }, - } as ManagedAgent); - - const server = await createAgentMcpServer({ - agentManager, - agentStorage, - callerAgentId: "parent-agent", - providerSnapshotManager: createOpenCodeManager().manager, - logger, - }); - const tool = registeredTool(server, "create_agent"); - await tool.handler({ - title: "Child", - provider: "opencode/gpt-5.4", - initialPrompt: "Do work", - }); - - expect(spies.agentManager.createAgent).toHaveBeenCalledWith( - expect.objectContaining({ modeId: "build", featureValues: { auto_accept: true } }), undefined, expect.any(Object), ); diff --git a/packages/server/src/server/agent/model-catalog.e2e.test.ts b/packages/server/src/server/agent/model-catalog.e2e.test.ts index 63fa4829a..9d74a132c 100644 --- a/packages/server/src/server/agent/model-catalog.e2e.test.ts +++ b/packages/server/src/server/agent/model-catalog.e2e.test.ts @@ -40,15 +40,23 @@ describe("provider model catalogs (e2e)", () => { }, 180_000); test.runIf(hasCodex)( - "Codex catalog exposes gpt-5.1-codex", + "Codex catalog exposes normalized models", async () => { const ctx = await createDaemonTestContext(); try { const result = await ctx.client.listProviderModels("codex"); expect(result.error).toBeNull(); - const ids = result.models.map((model) => model.id); - expect(ids.some((id) => id.includes("codex"))).toBe(true); + expect(result.models.length).toBeGreaterThan(0); + expect(result.models).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + provider: "codex", + id: expect.any(String), + label: expect.any(String), + }), + ]), + ); } finally { await ctx.cleanup(); } @@ -66,17 +74,15 @@ describe("provider model catalogs (e2e)", () => { expect(result.error).toBeNull(); expect(result.models.length).toBeGreaterThan(0); - for (const model of result.models) { - expect(model.provider).toBe("opencode"); - expect(model.id).toContain("/"); - expect(model.label).toBeTruthy(); - expect(model.metadata).toBeDefined(); - expect(model.metadata?.providerId).toBeTruthy(); - expect(model.metadata?.modelId).toBeTruthy(); - } - - const providerIds = new Set(result.models.map((m) => m.metadata?.providerId)); - expect(providerIds.size).toBeGreaterThan(0); + expect(result.models).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + provider: "opencode", + id: expect.any(String), + label: expect.any(String), + }), + ]), + ); } finally { await ctx.cleanup(); } diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index 508c6026a..c48479647 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -4,10 +4,13 @@ import { describe, expect, test, vi } from "vitest"; import { createTestLogger } from "../../test-utils/test-logger.js"; import type { AgentClient, + AgentMode, AgentModelDefinition, AgentProvider, ListModelsOptions, + ResolveAgentCreateConfigInput, } from "./agent-sdk-types.js"; +import type { ManagedAgent } from "./agent-manager.js"; import { ProviderSnapshotManager } from "./provider-snapshot-manager.js"; const TEST_CAPABILITIES = { @@ -309,6 +312,143 @@ describe("ProviderSnapshotManager public surface", () => { manager.destroy(); } }); + + test("resolveCreateConfig reduces a managed parent to provider mode and unattended data", async () => { + const resolverInputs: ResolveAgentCreateConfigInput[] = []; + const childModes: AgentMode[] = [ + { id: "child-unattended", label: "Child", isUnattended: true }, + ]; + const parentModes: AgentMode[] = [ + { id: "parent-unattended", label: "Parent", isUnattended: true }, + ]; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + }, + extraClients: { + codex: createExtraClient("codex", { + async isAvailable() { + return true; + }, + async listModes() { + return childModes; + }, + async resolveCreateConfig(input) { + resolverInputs.push(input); + return { + modeId: input.parent?.isUnattended ? "child-unattended" : undefined, + featureValues: undefined, + }; + }, + }), + claude: createExtraClient("claude", { + async isAvailable() { + return true; + }, + async listModes() { + return parentModes; + }, + isCreateConfigUnattended(input) { + return input.modeId === "parent-unattended"; + }, + }), + }, + }); + try { + const parent = { + id: "parent-agent", + provider: "claude", + currentModeId: "parent-unattended", + availableModes: parentModes, + config: { provider: "claude", cwd: "/tmp/project" }, + } as ManagedAgent; + + const resolved = await manager.resolveCreateConfig({ + cwd: "/tmp/project", + provider: "codex", + requestedMode: undefined, + featureValues: undefined, + parent, + unattended: false, + }); + + expect(resolved).toEqual({ modeId: "child-unattended", featureValues: undefined }); + expect(resolverInputs).toEqual([ + { + provider: "codex", + requestedMode: undefined, + featureValues: undefined, + parent: { + provider: "claude", + modeId: "parent-unattended", + isUnattended: true, + }, + unattended: true, + availableModes: childModes, + }, + ]); + } finally { + manager.destroy(); + } + }); + + test("resolveCreateConfig passes explicit unattended intent to provider policy", async () => { + const resolverInputs: ResolveAgentCreateConfigInput[] = []; + const modes: AgentMode[] = [{ id: "worker", label: "Worker", isUnattended: true }]; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { + claude: { enabled: false }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + }, + extraClients: { + codex: createExtraClient("codex", { + async isAvailable() { + return true; + }, + async listModes() { + return modes; + }, + async resolveCreateConfig(input) { + resolverInputs.push(input); + return { + modeId: input.unattended ? "worker" : undefined, + featureValues: undefined, + }; + }, + }), + }, + }); + try { + const resolved = await manager.resolveCreateConfig({ + cwd: "/tmp/project", + provider: "codex", + requestedMode: undefined, + featureValues: { fast_mode: true }, + parent: null, + unattended: true, + }); + + expect(resolved).toEqual({ modeId: "worker", featureValues: undefined }); + expect(resolverInputs).toEqual([ + { + provider: "codex", + requestedMode: undefined, + featureValues: { fast_mode: true }, + parent: null, + unattended: true, + availableModes: modes, + }, + ]); + } finally { + manager.destroy(); + } + }); }); describe("ProviderSnapshotManager applyMutableProviderConfig", () => { diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index 1d2d2fe17..08a51c2c5 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -8,6 +8,7 @@ import { expandTilde } from "../../utils/path.js"; import { withTimeout } from "../../utils/promise-timeout.js"; import type { AgentClient, + AgentCreateConfigParent, AgentMode, AgentModelDefinition, AgentProvider, @@ -58,12 +59,13 @@ interface ProviderSnapshotProviderOptions { wait?: boolean; } -interface ResolveProviderCreateConfigOptions { +export interface ResolveProviderCreateConfigOptions { cwd?: string | null; provider: AgentProvider; requestedMode: string | undefined; featureValues: Record | undefined; parent: ManagedAgent | null; + unattended: boolean; } export interface ResolvedProviderCreateConfig { @@ -301,11 +303,13 @@ export class ProviderSnapshotManager { wait: true, }); const definition = this.requireProvider(input.provider); + const parent = input.parent ? this.resolveParent(input.parent) : null; return definition.resolveCreateConfig({ provider: input.provider, requestedMode: input.requestedMode, featureValues: input.featureValues, - parent: input.parent ? this.resolveParent(input.parent) : null, + parent, + unattended: input.unattended || parent?.isUnattended === true, availableModes: entry.modes ?? [], }); } @@ -369,15 +373,34 @@ export class ProviderSnapshotManager { } private buildRegistry(): Record { - return buildProviderRegistry(this.logger, { + const registry = buildProviderRegistry(this.logger, { runtimeSettings: this.runtimeSettings, providerOverrides: this.providerOverrides, workspaceGitService: this.workspaceGitService, isDev: this.isDev, }); + + for (const [provider, client] of Object.entries(this.extraClients) as Array< + [AgentProvider, AgentClient] + >) { + const definition = registry[provider]; + if (!definition) continue; + registry[provider] = { + ...definition, + createClient: () => client, + resolveCreateConfig: + client.resolveCreateConfig?.bind(client) ?? definition.resolveCreateConfig, + isCreateConfigUnattended: + client.isCreateConfigUnattended?.bind(client) ?? definition.isCreateConfigUnattended, + fetchModels: client.listModels.bind(client), + fetchModes: client.listModes?.bind(client) ?? definition.fetchModes, + }; + } + + return registry; } - private resolveParent(parent: ManagedAgent) { + private resolveParent(parent: ManagedAgent): AgentCreateConfigParent { const definition = this.requireProvider(parent.provider); return { provider: parent.provider, diff --git a/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts b/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts index 5842ec572..1e6442092 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts @@ -134,6 +134,7 @@ describe("OpenCode auto_accept feature", () => { requestedMode: "full-access", featureValues: undefined, parent: null, + unattended: false, availableModes: [ { id: "build", label: "Build" }, { id: "plan", label: "Plan" }, @@ -155,6 +156,7 @@ describe("OpenCode auto_accept feature", () => { modeId: "bypassPermissions", isUnattended: true, }, + unattended: true, availableModes: [ { id: "build", label: "Build" }, { id: "plan", label: "Plan" }, @@ -163,6 +165,47 @@ describe("OpenCode auto_accept feature", () => { ).toEqual({ modeId: "build", featureValues: { auto_accept: true } }); }); + test("defaults unattended creation without a parent to build plus auto accept", () => { + const client = new OpenCodeAgentClient(createTestLogger()); + + expect( + client.resolveCreateConfig({ + provider: "opencode", + requestedMode: undefined, + featureValues: undefined, + parent: null, + unattended: true, + availableModes: [ + { id: "build", label: "Build" }, + { id: "plan", label: "Plan" }, + ], + }), + ).toEqual({ modeId: "build", featureValues: { auto_accept: true } }); + }); + + test("preserves the selected OpenCode agent when inheriting auto accept from an OpenCode parent", () => { + const client = new OpenCodeAgentClient(createTestLogger()); + + expect( + client.resolveCreateConfig({ + provider: "opencode", + requestedMode: undefined, + featureValues: undefined, + parent: { + provider: "opencode", + modeId: "paseo-custom", + isUnattended: true, + }, + unattended: true, + availableModes: [ + { id: "build", label: "Build" }, + { id: "plan", label: "Plan" }, + { id: "paseo-custom", label: "Paseo Custom" }, + ], + }), + ).toEqual({ modeId: "paseo-custom", featureValues: { auto_accept: true } }); + }); + test("auto-approves tool permissions when auto accept is enabled", async () => { const { openCodeClient, runtime } = mockOpenCodeClient({ events: [toolPermissionEvent(), idleEvent()], diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 09485c659..49cce78bc 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -131,9 +131,16 @@ function resolveOpenCodeCreateConfig( input: ResolveAgentCreateConfigInput, ): ResolveAgentCreateConfigResult { const legacyFullAccess = input.requestedMode === OPENCODE_LEGACY_FULL_ACCESS_MODE_ID; + const parent = input.parent; const inheritsUnattended = - input.requestedMode === undefined && input.parent?.isUnattended === true; - const requestedMode = legacyFullAccess ? OPENCODE_BUILD_MODE_ID : input.requestedMode; + input.requestedMode === undefined && (input.unattended || parent?.isUnattended === true); + const inheritedOpenCodeMode = + inheritsUnattended && parent?.provider === input.provider + ? (parent.modeId ?? undefined) + : undefined; + const requestedMode = legacyFullAccess + ? OPENCODE_BUILD_MODE_ID + : (input.requestedMode ?? inheritedOpenCodeMode); const featureValues = legacyFullAccess || (inheritsUnattended && input.featureValues?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === undefined) diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 1a7c61b13..b33803051 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -597,6 +597,7 @@ export async function createPaseoDaemon( paseoHome: config.paseoHome, logger, agentManager, + providerSnapshotManager, }); await loopService.initialize(); logger.info({ elapsed: elapsed() }, "Loop service initialized"); @@ -605,6 +606,7 @@ export async function createPaseoDaemon( logger, agentManager, agentStorage, + providerSnapshotManager, }); await scheduleService.start(); agentManager.setAgentArchivedCallback(async (agentId) => { diff --git a/packages/server/src/server/daemon-e2e/live-preferences.e2e.test.ts b/packages/server/src/server/daemon-e2e/live-preferences.e2e.test.ts index 06ba99469..25c74c62a 100644 --- a/packages/server/src/server/daemon-e2e/live-preferences.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/live-preferences.e2e.test.ts @@ -39,12 +39,39 @@ function waitForAgentUpdate( }); } -function pickTwoDistinctModels(models: Array<{ id: string }>): [string, string] { +function pickModelSwitchPair(provider: string, models: Array<{ id: string }>): [string, string] { const ids = Array.from(new Set(models.map((m) => m.id))).filter(Boolean); - if (ids.length < 2) { - throw new Error(`Need at least 2 models to test switching; got ${ids.length}`); + const first = ids[0]; + if (!first) { + throw new Error(`No models returned for provider ${provider}`); } - return [ids[0], ids[1]]; + return [first, ids[1] ?? `${first}-switch-target`]; +} + +function pickThinkingSwitchOption( + provider: string, + models: Array<{ + thinkingOptions?: Array<{ id: string }>; + defaultThinkingOptionId?: string; + }>, +): { model: (typeof models)[number]; thinkingOptionId: string } { + const modelWithOptions = models.find((m) => (m.thinkingOptions?.length ?? 0) > 0); + if (!modelWithOptions) { + const first = models[0]; + if (!first) { + throw new Error(`No ${provider} models returned`); + } + return { model: first, thinkingOptionId: "test-thinking-option" }; + } + + const defaultThinkingId = modelWithOptions.defaultThinkingOptionId ?? "default"; + const thinkingOptionId = + modelWithOptions.thinkingOptions?.find((o) => o.id !== defaultThinkingId)?.id ?? + modelWithOptions.thinkingOptions?.[0]?.id; + if (!thinkingOptionId) { + throw new Error(`No ${provider} thinking option found`); + } + return { model: modelWithOptions, thinkingOptionId }; } function isBinaryInstalled(binary: string): boolean { @@ -92,7 +119,7 @@ describe.each(["claude", "codex", "opencode"] as const)("live model switching (% if (!modelList.models || modelList.models.length === 0) { throw new Error(`No models returned for provider ${provider}`); } - const [modelA, modelB] = pickTwoDistinctModels(modelList.models); + const [modelA, modelB] = pickModelSwitchPair(provider, modelList.models); const agent = await ctx.client.createAgent({ provider, @@ -162,18 +189,10 @@ test.runIf(hasCodex)( throw new Error("No Codex models returned"); } - const modelWithOptions = modelList.models.find((m) => (m.thinkingOptions?.length ?? 0) > 1); - if (!modelWithOptions) { - throw new Error("No Codex model with thinkingOptions returned"); - } - const defaultThinkingId = modelWithOptions.defaultThinkingOptionId ?? "default"; - const nonDefault = - modelWithOptions.thinkingOptions?.find((o) => o.id !== defaultThinkingId)?.id ?? - modelWithOptions.thinkingOptions?.[0]?.id ?? - null; - if (!nonDefault) { - throw new Error("No non-default Codex thinking option found"); - } + const { model: modelWithOptions, thinkingOptionId } = pickThinkingSwitchOption( + "Codex", + modelList.models, + ); const agent = await ctx.client.createAgent({ provider: "codex", @@ -183,16 +202,16 @@ test.runIf(hasCodex)( }); const startIndex = messages.length; - await ctx.client.setAgentThinkingOption(agent.id, nonDefault); + await ctx.client.setAgentThinkingOption(agent.id, thinkingOptionId); const updated = await waitForAgentUpdate( messages, startIndex, - (a) => a.id === agent.id && a.thinkingOptionId === nonDefault, + (a) => a.id === agent.id && a.thinkingOptionId === thinkingOptionId, { timeoutMs: 20000 }, ); - expect(updated.thinkingOptionId).toBe(nonDefault); + expect(updated.thinkingOptionId).toBe(thinkingOptionId); } finally { rmSync(cwd, { recursive: true, force: true }); } @@ -210,12 +229,10 @@ test.runIf(hasOpenCode)( throw new Error("No OpenCode models returned"); } - const modelWithThinkingOptions = modelList.models.find( - (m) => (m.thinkingOptions?.length ?? 0) > 1, + const { model: modelWithThinkingOptions, thinkingOptionId } = pickThinkingSwitchOption( + "OpenCode", + modelList.models, ); - if (!modelWithThinkingOptions) { - throw new Error("No OpenCode model with thinkingOptions returned"); - } const agent = await ctx.client.createAgent({ provider: "opencode", @@ -224,23 +241,15 @@ test.runIf(hasOpenCode)( model: modelWithThinkingOptions.id, }); - const defaultThinkingId = modelWithThinkingOptions.defaultThinkingOptionId ?? "default"; - const thinkingId = - modelWithThinkingOptions.thinkingOptions?.find((o) => o.id !== defaultThinkingId)?.id ?? - modelWithThinkingOptions.thinkingOptions?.[0]?.id ?? - null; - if (!thinkingId) { - throw new Error("No non-default OpenCode thinking option found"); - } const startIndex = messages.length; - await ctx.client.setAgentThinkingOption(agent.id, thinkingId); + await ctx.client.setAgentThinkingOption(agent.id, thinkingOptionId); const updatedThinking = await waitForAgentUpdate( messages, startIndex, - (a) => a.id === agent.id && a.thinkingOptionId === thinkingId, + (a) => a.id === agent.id && a.thinkingOptionId === thinkingOptionId, { timeoutMs: 20000 }, ); - expect(updatedThinking.thinkingOptionId).toBe(thinkingId); + expect(updatedThinking.thinkingOptionId).toBe(thinkingOptionId); } finally { rmSync(cwd, { recursive: true, force: true }); } diff --git a/packages/server/src/server/loop-service.test.ts b/packages/server/src/server/loop-service.test.ts index 58204b900..2be498e96 100644 --- a/packages/server/src/server/loop-service.test.ts +++ b/packages/server/src/server/loop-service.test.ts @@ -31,6 +31,7 @@ import type { } from "./agent/agent-sdk-types.js"; import { AgentStorage } from "./agent/agent-storage.js"; import { AgentManager } from "./agent/agent-manager.js"; +import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; import { LoopService } from "./loop-service.js"; import { isPlatform } from "../test-utils/platform.js"; import { createTestLogger } from "../test-utils/test-logger.js"; @@ -44,6 +45,13 @@ const TEST_CAPABILITIES: AgentCapabilityFlags = { supportsToolInvocations: false, }; +const NO_UNATTENDED_LOOP_POLICY: Pick = { + async resolveCreateConfig(input) { + expect(input).toMatchObject({ parent: null, unattended: true, requestedMode: undefined }); + return { modeId: undefined, featureValues: input.featureValues }; + }, +}; + interface ScriptedAgentBehavior { onRun(input: { config: AgentSessionConfig; prompt: string; turnId: string }): Promise; } @@ -266,7 +274,12 @@ describe("LoopService", () => { registry: storage, logger, }); - const service = new LoopService({ paseoHome, agentManager: manager, logger }); + const service = new LoopService({ + paseoHome, + agentManager: manager, + logger, + providerSnapshotManager: NO_UNATTENDED_LOOP_POLICY, + }); await service.initialize(); const loop = await service.runLoop({ @@ -317,7 +330,12 @@ describe("LoopService", () => { registry: storage, logger, }); - const service = new LoopService({ paseoHome, agentManager: manager, logger }); + const service = new LoopService({ + paseoHome, + agentManager: manager, + logger, + providerSnapshotManager: NO_UNATTENDED_LOOP_POLICY, + }); await service.initialize(); const loop = await service.runLoop({ @@ -378,7 +396,12 @@ describe("LoopService", () => { archivedAgentIds.push(agentId); await archiveAgent(agentId); }; - const service = new LoopService({ paseoHome, agentManager: manager, logger }); + const service = new LoopService({ + paseoHome, + agentManager: manager, + logger, + providerSnapshotManager: NO_UNATTENDED_LOOP_POLICY, + }); await service.initialize(); const loop = await service.runLoop({ @@ -430,7 +453,12 @@ describe("LoopService", () => { registry: storage, logger, }); - const service = new LoopService({ paseoHome, agentManager: manager, logger }); + const service = new LoopService({ + paseoHome, + agentManager: manager, + logger, + providerSnapshotManager: NO_UNATTENDED_LOOP_POLICY, + }); await service.initialize(); const loop = await service.runLoop({ @@ -472,7 +500,17 @@ describe("LoopService", () => { registry: storage, logger, }); - const service = new LoopService({ paseoHome, agentManager: manager, logger }); + const service = new LoopService({ + paseoHome, + agentManager: manager, + logger, + providerSnapshotManager: { + async resolveCreateConfig(input) { + expect(input).toMatchObject({ parent: null, unattended: true, requestedMode: undefined }); + return { modeId: "bypassPermissions", featureValues: input.featureValues }; + }, + }, + }); await service.initialize(); const loop = await service.runLoop({ @@ -488,6 +526,71 @@ describe("LoopService", () => { expect(verifierConfigs[0]?.modeId).toBe("bypassPermissions"); }); + test("defaults OpenCode workers and verifiers to build plus auto accept", async () => { + class CapturingScriptedAgentClient extends ScriptedAgentClient { + readonly createdConfigs: AgentSessionConfig[] = []; + + override async createSession( + config: AgentSessionConfig, + launchContext?: AgentLaunchContext, + ): Promise { + this.createdConfigs.push(config); + return super.createSession(config, launchContext); + } + } + + const opencodeClient = new CapturingScriptedAgentClient("opencode", { + async onRun({ config }) { + if (config.title?.includes("worker")) { + writeFileSync(path.join(workspaceDir, "done.txt"), "ok"); + return "created done.txt"; + } + return '{"passed":true,"reason":"ok"}'; + }, + }); + const manager = new AgentManager({ + clients: { + opencode: opencodeClient, + }, + registry: storage, + logger, + }); + const service = new LoopService({ + paseoHome, + agentManager: manager, + logger, + providerSnapshotManager: { + async resolveCreateConfig(input) { + expect(input).toMatchObject({ parent: null, unattended: true, requestedMode: undefined }); + return { + modeId: "build", + featureValues: { ...input.featureValues, auto_accept: true }, + }; + }, + }, + }); + await service.initialize(); + + const loop = await service.runLoop({ + prompt: "Create done.txt", + cwd: workspaceDir, + provider: "opencode", + verifyPrompt: "Confirm that done.txt exists in the workspace.", + maxIterations: 1, + }); + + await waitForLoopCompletion(service, loop.id); + + expect(opencodeClient.createdConfigs[0]).toMatchObject({ + modeId: "build", + featureValues: { auto_accept: true }, + }); + expect(opencodeClient.createdConfigs[1]).toMatchObject({ + modeId: "build", + featureValues: { auto_accept: true }, + }); + }); + test("explicit modeId wins over unattended default", async () => { const workerConfigs: AgentSessionConfig[] = []; const verifierConfigs: AgentSessionConfig[] = []; @@ -508,7 +611,12 @@ describe("LoopService", () => { registry: storage, logger, }); - const service = new LoopService({ paseoHome, agentManager: manager, logger }); + const service = new LoopService({ + paseoHome, + agentManager: manager, + logger, + providerSnapshotManager: NO_UNATTENDED_LOOP_POLICY, + }); await service.initialize(); const loop = await service.runLoop({ @@ -546,7 +654,12 @@ describe("LoopService", () => { registry: storage, logger, }); - const service = new LoopService({ paseoHome, agentManager: manager, logger }); + const service = new LoopService({ + paseoHome, + agentManager: manager, + logger, + providerSnapshotManager: NO_UNATTENDED_LOOP_POLICY, + }); await service.initialize(); const loop = await service.runLoop({ diff --git a/packages/server/src/server/loop-service.ts b/packages/server/src/server/loop-service.ts index 784f197f7..5ce736bd2 100644 --- a/packages/server/src/server/loop-service.ts +++ b/packages/server/src/server/loop-service.ts @@ -15,7 +15,11 @@ import type { AgentProvider, } from "./agent/agent-sdk-types.js"; import { execCommand, platformShell } from "../utils/spawn.js"; -import { getUnattendedModeId } from "@getpaseo/protocol/provider-manifest"; +import type { + ProviderSnapshotManager, + ResolvedProviderCreateConfig, + ResolveProviderCreateConfigOptions, +} from "./agent/provider-snapshot-manager.js"; const LOOP_ID_LENGTH = 8; const DEFAULT_LOOP_PROVIDER: AgentProvider = "claude"; @@ -207,6 +211,8 @@ function buildVerifierTitle(loop: LoopRecord, iterationIndex: number): string { return `${prefix} [loop ${iterationIndex} verifier]`; } +type CreateConfigResolver = Pick; + function formatStreamLog(event: AgentStreamEvent): string | null { switch (event.type) { case "timeline": { @@ -305,6 +311,7 @@ export class LoopService { paseoHome: string; agentManager: AgentManager; logger: Logger; + providerSnapshotManager: CreateConfigResolver; }, ) { this.storePath = path.join(options.paseoHome, "loops", "loops.json"); @@ -614,7 +621,7 @@ export class LoopService { signal: AbortSignal, ): Promise { const agent = await this.options.agentManager.createAgent( - this.buildWorkerConfig(loop, iteration), + await this.buildWorkerConfig(loop, iteration), ); iteration.workerAgentId = agent.id; loop.activeWorkerAgentId = agent.id; @@ -721,7 +728,7 @@ export class LoopService { const startedAt = nowIso(); const verifierAgent = await this.options.agentManager.createAgent( - this.buildVerifierConfig(loop, iteration), + await this.buildVerifierConfig(loop, iteration), ); iteration.verifierAgentId = verifierAgent.id; loop.activeVerifierAgentId = verifierAgent.id; @@ -796,33 +803,58 @@ export class LoopService { } } - private buildWorkerConfig(loop: LoopRecord, iteration: LoopIterationRecord): AgentSessionConfig { + private async buildWorkerConfig( + loop: LoopRecord, + iteration: LoopIterationRecord, + ): Promise { const provider = loop.workerProvider ?? loop.provider; + const resolvedUnattendedConfig = loop.modeId + ? { modeId: loop.modeId, featureValues: undefined } + : await this.resolveProviderCreateConfig({ provider, cwd: loop.cwd }); return { provider, cwd: loop.cwd, model: loop.workerModel ?? loop.model ?? undefined, - modeId: loop.modeId ?? getUnattendedModeId(provider), + modeId: resolvedUnattendedConfig.modeId, + featureValues: resolvedUnattendedConfig.featureValues, title: buildWorkerTitle(loop, iteration.index), internal: true, }; } - private buildVerifierConfig( + private async buildVerifierConfig( loop: LoopRecord, iteration: LoopIterationRecord, - ): AgentSessionConfig { + ): Promise { const provider = loop.verifierProvider ?? loop.provider; + const explicitModeId = loop.verifierModeId ?? loop.modeId; + const resolvedUnattendedConfig = explicitModeId + ? { modeId: explicitModeId, featureValues: undefined } + : await this.resolveProviderCreateConfig({ provider, cwd: loop.cwd }); return { provider, cwd: loop.cwd, model: loop.verifierModel ?? loop.model ?? undefined, - modeId: loop.verifierModeId ?? loop.modeId ?? getUnattendedModeId(provider), + modeId: resolvedUnattendedConfig.modeId, + featureValues: resolvedUnattendedConfig.featureValues, title: buildVerifierTitle(loop, iteration.index), internal: true, }; } + private resolveProviderCreateConfig( + input: Pick, + ): Promise { + return this.options.providerSnapshotManager.resolveCreateConfig({ + provider: input.provider, + cwd: input.cwd, + requestedMode: undefined, + featureValues: undefined, + parent: null, + unattended: true, + }); + } + private resolveFinalText(timeline: AgentTimelineItem[], finalText: string): string { if (finalText.trim()) { return finalText; diff --git a/packages/server/src/server/schedule/service.test.ts b/packages/server/src/server/schedule/service.test.ts index 78d23284e..7cacb731e 100644 --- a/packages/server/src/server/schedule/service.test.ts +++ b/packages/server/src/server/schedule/service.test.ts @@ -22,6 +22,7 @@ import type { } from "../agent/agent-sdk-types.js"; import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; import { createTestLogger } from "../../test-utils/test-logger.js"; +import type { ProviderSnapshotManager } from "../agent/provider-snapshot-manager.js"; import { ScheduleService } from "./service.js"; import type { ScheduleExecutionResult, StoredSchedule } from "@getpaseo/protocol/schedule/types"; @@ -38,6 +39,13 @@ const SCHEDULE_TEST_CAPABILITIES: AgentCapabilityFlags = { supportsToolInvocations: true, }; +const NO_UNATTENDED_SCHEDULE_POLICY: Pick = { + async resolveCreateConfig(input) { + expect(input).toMatchObject({ parent: null, unattended: true, requestedMode: undefined }); + return { modeId: undefined, featureValues: input.featureValues }; + }, +}; + describe("ScheduleService", () => { let tempDir: string; let agentStorage: AgentStorage; @@ -64,6 +72,7 @@ describe("ScheduleService", () => { logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, runner: async (schedule) => ({ agentId: "00000000-0000-0000-0000-000000000001", @@ -102,6 +111,7 @@ describe("ScheduleService", () => { logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, runner: async () => ({ agentId: null, @@ -137,6 +147,7 @@ describe("ScheduleService", () => { logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, runner: async () => ({ agentId: null, @@ -176,6 +187,7 @@ describe("ScheduleService", () => { logger: createTestLogger(), agentManager: manager, agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, }); @@ -350,6 +362,7 @@ describe("ScheduleService", () => { logger: createTestLogger(), agentManager: manager, agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, }); @@ -391,6 +404,12 @@ describe("ScheduleService", () => { logger: createTestLogger(), agentManager: manager, agentStorage, + providerSnapshotManager: { + async resolveCreateConfig(input) { + expect(input).toMatchObject({ parent: null, unattended: true, requestedMode: undefined }); + return { modeId: "bypassPermissions", featureValues: input.featureValues }; + }, + }, now: () => now, }); @@ -419,12 +438,77 @@ describe("ScheduleService", () => { expect(agent?.archivedAt).toBeTruthy(); }); + test("defaults OpenCode new-agent schedules to build plus auto accept", async () => { + const createdConfigs: AgentSessionConfig[] = []; + const clients = createTestAgentClients(); + const opencodeClient = clients.opencode; + if (!opencodeClient) { + throw new Error("Expected OpenCode test client"); + } + clients.opencode = { + provider: opencodeClient.provider, + capabilities: opencodeClient.capabilities, + createSession: async (...args) => { + createdConfigs.push(args[0]); + return opencodeClient.createSession(...args); + }, + resumeSession: (...args) => opencodeClient.resumeSession(...args), + listModels: (...args) => opencodeClient.listModels(...args), + isAvailable: () => opencodeClient.isAvailable(), + } satisfies AgentClient; + const manager = new AgentManager({ + logger: createTestLogger(), + clients, + registry: agentStorage, + }); + const service = new ScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: manager, + agentStorage, + providerSnapshotManager: { + async resolveCreateConfig(input) { + expect(input).toMatchObject({ parent: null, unattended: true, requestedMode: undefined }); + return { + modeId: "build", + featureValues: { ...input.featureValues, auto_accept: true }, + }; + }, + }, + now: () => now, + }); + + const created = await service.create({ + prompt: "Respond with exactly hello", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { + provider: "opencode", + cwd: tempDir, + }, + }, + maxRuns: 1, + }); + + now = new Date("2026-01-01T00:01:00.000Z"); + await service.tick(); + + const inspected = await service.inspect(created.id); + expect(inspected.runs[0]?.error).toBeNull(); + expect(createdConfigs[0]).toMatchObject({ + modeId: "build", + featureValues: { auto_accept: true }, + }); + }); + test("advances stale nextRunAt on daemon restart", async () => { const service1 = new ScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, runner: async () => ({ agentId: null, output: "ok" }), }); @@ -449,6 +533,7 @@ describe("ScheduleService", () => { logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, runner: async () => ({ agentId: null, output: "ok" }), }); @@ -474,6 +559,7 @@ describe("ScheduleService", () => { logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, runner: async () => { releaseRun?.(); @@ -522,6 +608,7 @@ describe("ScheduleService", () => { logger: createTestLogger(), agentManager: manager, agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, }); @@ -579,6 +666,7 @@ describe("ScheduleService", () => { logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, runner: async () => ({ agentId: null, output: "ok" }), }); @@ -601,6 +689,7 @@ describe("ScheduleService", () => { logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, runner: async () => ({ agentId: null, output: "ok" }), }); @@ -624,6 +713,7 @@ describe("ScheduleService", () => { logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, runner: async () => ({ agentId: null, output: "ok" }), }); @@ -646,6 +736,7 @@ describe("ScheduleService", () => { logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, runner: async () => ({ agentId: null, output: "ok" }), }); @@ -669,6 +760,7 @@ describe("ScheduleService", () => { logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, runner: async (schedule) => ({ agentId: "00000000-0000-0000-0000-000000000099", @@ -704,6 +796,7 @@ describe("ScheduleService", () => { logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, runner: async () => ({ agentId: null, output: "ok" }), }); @@ -756,6 +849,7 @@ describe("ScheduleService", () => { logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, runner: async () => ({ agentId: null, output: "ok" }), }); @@ -788,6 +882,7 @@ describe("ScheduleService", () => { logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, runner: async () => ({ agentId: null, output: "ran" }), }); @@ -819,6 +914,7 @@ describe("ScheduleService", () => { logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, runner: async () => ({ agentId: null, output: "ok" }), }); @@ -844,6 +940,7 @@ describe("ScheduleService", () => { logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, runner: async () => ({ agentId: null, output: "ok" }), }); @@ -868,6 +965,7 @@ describe("ScheduleService", () => { logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, runner: async () => ({ agentId: null, output: "ok" }), }); @@ -912,6 +1010,7 @@ describe("ScheduleService", () => { logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, runner: async () => ({ agentId: null, output: "ok" }), }); @@ -942,6 +1041,7 @@ describe("ScheduleService", () => { logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, runner: async () => ({ agentId: null, output: "ok" }), }); @@ -967,6 +1067,7 @@ describe("ScheduleService", () => { logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, runner: async () => ({ agentId: null, output: "ok" }), }); diff --git a/packages/server/src/server/schedule/service.ts b/packages/server/src/server/schedule/service.ts index 0d47ecb47..c99ca4381 100644 --- a/packages/server/src/server/schedule/service.ts +++ b/packages/server/src/server/schedule/service.ts @@ -7,9 +7,13 @@ import type { AgentSessionConfig } from "../agent/agent-sdk-types.js"; import { curateAgentActivity } from "../agent/activity-curator.js"; import { ensureAgentLoaded } from "../agent/agent-loading.js"; import { formatSystemNotificationPrompt } from "../agent/agent-prompt.js"; -import { getUnattendedModeId } from "@getpaseo/protocol/provider-manifest"; import { ScheduleStore } from "./store.js"; import { computeNextRunAt, validateScheduleCadence } from "./cron.js"; +import type { + ProviderSnapshotManager, + ResolvedProviderCreateConfig, + ResolveProviderCreateConfigOptions, +} from "../agent/provider-snapshot-manager.js"; import type { CreateScheduleInput, ScheduleExecutionResult, @@ -134,11 +138,14 @@ function buildRunOutput(params: { return null; } +type CreateConfigResolver = Pick; + export interface ScheduleServiceOptions { paseoHome: string; logger: Logger; agentManager: AgentManager; agentStorage: AgentStorage; + providerSnapshotManager: CreateConfigResolver; now?: () => Date; runner?: (schedule: StoredSchedule, runId: string) => Promise; } @@ -148,6 +155,7 @@ export class ScheduleService { private readonly logger: Logger; private readonly agentManager: AgentManager; private readonly agentStorage: AgentStorage; + private readonly createConfigResolver: CreateConfigResolver; private readonly now: () => Date; private readonly runner: ( schedule: StoredSchedule, @@ -161,6 +169,7 @@ export class ScheduleService { this.logger = options.logger.child({ module: "schedule-service" }); this.agentManager = options.agentManager; this.agentStorage = options.agentStorage; + this.createConfigResolver = options.providerSnapshotManager; this.now = options.now ?? (() => new Date()); this.runner = options.runner ?? ((schedule, runId) => this.executeSchedule(schedule, runId)); } @@ -547,21 +556,32 @@ export class ScheduleService { }; } + const targetConfig = schedule.target.config; + const resolvedUnattendedConfig = targetConfig.modeId + ? { modeId: targetConfig.modeId, featureValues: targetConfig.featureValues } + : await this.resolveProviderCreateConfig({ + provider: targetConfig.provider, + cwd: targetConfig.cwd, + requestedMode: undefined, + featureValues: targetConfig.featureValues, + parent: null, + unattended: true, + }); const config: AgentSessionConfig = { - provider: schedule.target.config.provider, - cwd: schedule.target.config.cwd, - modeId: schedule.target.config.modeId ?? getUnattendedModeId(schedule.target.config.provider), - model: schedule.target.config.model, - thinkingOptionId: schedule.target.config.thinkingOptionId, - title: schedule.target.config.title, - approvalPolicy: schedule.target.config.approvalPolicy, - sandboxMode: schedule.target.config.sandboxMode, - networkAccess: schedule.target.config.networkAccess, - webSearch: schedule.target.config.webSearch, - featureValues: schedule.target.config.featureValues, - extra: schedule.target.config.extra, - systemPrompt: schedule.target.config.systemPrompt, - mcpServers: schedule.target.config.mcpServers as AgentSessionConfig["mcpServers"], + provider: targetConfig.provider, + cwd: targetConfig.cwd, + modeId: resolvedUnattendedConfig.modeId, + model: targetConfig.model, + thinkingOptionId: targetConfig.thinkingOptionId, + title: targetConfig.title, + approvalPolicy: targetConfig.approvalPolicy, + sandboxMode: targetConfig.sandboxMode, + networkAccess: targetConfig.networkAccess, + webSearch: targetConfig.webSearch, + featureValues: resolvedUnattendedConfig.featureValues, + extra: targetConfig.extra, + systemPrompt: targetConfig.systemPrompt, + mcpServers: targetConfig.mcpServers as AgentSessionConfig["mcpServers"], }; const labels = { "paseo.schedule-id": schedule.id, @@ -594,4 +614,10 @@ export class ScheduleService { }), }; } + + private async resolveProviderCreateConfig( + input: ResolveProviderCreateConfigOptions, + ): Promise { + return this.createConfigResolver.resolveCreateConfig(input); + } }