Merge branch 'main' of github.com:getpaseo/paseo
This commit is contained in:
@@ -257,9 +257,9 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
|||||||
export async function openDesktopSettings(page: Page, serverId: string): Promise<void> {
|
export async function openDesktopSettings(page: Page, serverId: string): Promise<void> {
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
await openSettingsHost(page, serverId);
|
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.
|
// 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({
|
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toBeVisible({
|
||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const SECTION_LABELS = {
|
|||||||
|
|
||||||
export type SettingsSection = keyof typeof SECTION_LABELS | "projects";
|
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<void> {
|
export async function openSettingsSection(page: Page, section: SettingsSection): Promise<void> {
|
||||||
const sidebar = page.getByTestId("settings-sidebar");
|
const sidebar = page.getByTestId("settings-sidebar");
|
||||||
@@ -231,9 +231,9 @@ export async function openHostSection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function expectHostActionCards(page: Page, serverId: string): Promise<void> {
|
export async function expectHostActionCards(page: Page, serverId: string): Promise<void> {
|
||||||
// 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).
|
// 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-card")).toBeVisible();
|
||||||
await expect(page.getByTestId("host-page-restart-button")).toBeVisible();
|
await expect(page.getByTestId("host-page-restart-button")).toBeVisible();
|
||||||
await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible();
|
await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible();
|
||||||
@@ -296,9 +296,10 @@ export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise<vo
|
|||||||
|
|
||||||
// Host group rows are now flat top-level sections (no drill-in).
|
// Host group rows are now flat top-level sections (no drill-in).
|
||||||
await expect(sidebar.getByTestId("settings-host-section-connections")).toBeVisible();
|
await expect(sidebar.getByTestId("settings-host-section-connections")).toBeVisible();
|
||||||
await expect(sidebar.getByTestId("settings-host-section-orchestration")).toBeVisible();
|
await expect(sidebar.getByTestId("settings-host-section-agents")).toBeVisible();
|
||||||
|
await expect(sidebar.getByTestId("settings-host-section-workspaces")).toBeVisible();
|
||||||
await expect(sidebar.getByTestId("settings-host-section-providers")).toBeVisible();
|
await expect(sidebar.getByTestId("settings-host-section-providers")).toBeVisible();
|
||||||
await expect(sidebar.getByTestId("settings-host-section-daemon")).toBeVisible();
|
await expect(sidebar.getByTestId("settings-host-section-host")).toBeVisible();
|
||||||
|
|
||||||
// The old per-host entry rows are replaced by the host picker.
|
// The old per-host entry rows are replaced by the host picker.
|
||||||
await expect(sidebar.locator('[data-testid^="settings-host-entry-"]')).toHaveCount(0);
|
await expect(sidebar.locator('[data-testid^="settings-host-entry-"]')).toHaveCount(0);
|
||||||
|
|||||||
@@ -33,15 +33,15 @@ test.describe("Settings host page", () => {
|
|||||||
await expectHostConnectionsCard(page, port);
|
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();
|
const serverId = getServerId();
|
||||||
|
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
await openSettingsHost(page, serverId);
|
await openSettingsHost(page, serverId);
|
||||||
|
|
||||||
await openHostSection(page, serverId, "orchestration");
|
await openHostSection(page, serverId, "agents");
|
||||||
await expectSettingsHeader(page, "Orchestration");
|
await expectSettingsHeader(page, "Agents");
|
||||||
await expectHostInjectMcpCard(page);
|
await expectHostInjectMcpCard(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -56,15 +56,15 @@ test.describe("Settings host page", () => {
|
|||||||
await expectSettingsHeader(page, "Providers");
|
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();
|
const serverId = getServerId();
|
||||||
|
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
await openSettingsHost(page, serverId);
|
await openSettingsHost(page, serverId);
|
||||||
|
|
||||||
await openHostSection(page, serverId, "daemon");
|
await openHostSection(page, serverId, "host");
|
||||||
await expectSettingsHeader(page, "Daemon");
|
await expectSettingsHeader(page, "Host");
|
||||||
await expectHostLabelDisplayed(page);
|
await expectHostLabelDisplayed(page);
|
||||||
await expectHostActionCards(page, serverId);
|
await expectHostActionCards(page, serverId);
|
||||||
});
|
});
|
||||||
@@ -75,14 +75,14 @@ test.describe("Settings host page", () => {
|
|||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
await openSettingsHost(page, serverId);
|
await openSettingsHost(page, serverId);
|
||||||
await openHostSection(page, serverId, "daemon");
|
await openHostSection(page, serverId, "host");
|
||||||
|
|
||||||
await expectHostLabelDisplayed(page);
|
await expectHostLabelDisplayed(page);
|
||||||
await clickEditHostLabel(page);
|
await clickEditHostLabel(page);
|
||||||
await expectHostLabelEditMode(page, TEST_HOST_LABEL);
|
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,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const serverId = getServerId();
|
const serverId = getServerId();
|
||||||
@@ -90,7 +90,7 @@ test.describe("Settings host page", () => {
|
|||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
await openSettingsHost(page, serverId);
|
await openSettingsHost(page, serverId);
|
||||||
await openHostSection(page, serverId, "daemon");
|
await openHostSection(page, serverId, "host");
|
||||||
|
|
||||||
// TODO: add local-daemon fixture for positive Pair/Daemon coverage.
|
// TODO: add local-daemon fixture for positive Pair/Daemon coverage.
|
||||||
await expectHostNoLocalOnlyRows(page);
|
await expectHostNoLocalOnlyRows(page);
|
||||||
@@ -113,7 +113,7 @@ test.describe("Settings host page", () => {
|
|||||||
|
|
||||||
await expectHostPageVisible(page, serverId);
|
await expectHostPageVisible(page, serverId);
|
||||||
await expectSettingsHeader(page, "Connections");
|
await expectSettingsHeader(page, "Connections");
|
||||||
await openHostSection(page, serverId, "daemon");
|
await openHostSection(page, serverId, "host");
|
||||||
await expectHostLabelDisplayed(page);
|
await expectHostLabelDisplayed(page);
|
||||||
await expectHostActionCards(page, serverId);
|
await expectHostActionCards(page, serverId);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { useLocalSearchParams } from "expo-router";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||||
import SettingsScreen from "@/screens/settings-screen";
|
import SettingsScreen from "@/screens/settings-screen";
|
||||||
import { type HostSectionSlug, isHostSectionSlug } from "@/utils/host-routes";
|
import { normalizeHostSectionSlug } from "@/utils/host-routes";
|
||||||
|
|
||||||
export default function SettingsHostSectionRoute() {
|
export default function SettingsHostSectionRoute() {
|
||||||
const params = useLocalSearchParams<{ serverId?: string; hostSection?: string }>();
|
const params = useLocalSearchParams<{ serverId?: string; hostSection?: string }>();
|
||||||
const serverId = typeof params.serverId === "string" ? params.serverId.trim() : "";
|
const serverId = typeof params.serverId === "string" ? params.serverId.trim() : "";
|
||||||
const rawSection = typeof params.hostSection === "string" ? params.hostSection : "";
|
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]);
|
const view = useMemo(() => ({ kind: "host" as const, serverId, section }), [serverId, section]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import {
|
|||||||
Palette,
|
Palette,
|
||||||
Server,
|
Server,
|
||||||
Network,
|
Network,
|
||||||
Workflow,
|
Bot,
|
||||||
Boxes,
|
Boxes,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
Stethoscope,
|
Stethoscope,
|
||||||
@@ -93,9 +93,10 @@ import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pc
|
|||||||
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
|
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
|
||||||
import {
|
import {
|
||||||
HostConnectionsPage,
|
HostConnectionsPage,
|
||||||
HostDaemonPage,
|
HostAgentsPage,
|
||||||
HostOrchestrationPage,
|
HostSettingsPage,
|
||||||
HostProvidersPage,
|
HostProvidersPage,
|
||||||
|
HostWorkspacesPage,
|
||||||
} from "@/screens/settings/host-page";
|
} from "@/screens/settings/host-page";
|
||||||
import ProjectsScreen from "@/screens/projects-screen";
|
import ProjectsScreen from "@/screens/projects-screen";
|
||||||
import ProjectSettingsScreen from "@/screens/project-settings-screen";
|
import ProjectSettingsScreen from "@/screens/project-settings-screen";
|
||||||
@@ -148,9 +149,10 @@ interface HostSectionItem {
|
|||||||
|
|
||||||
const HOST_SECTION_ITEMS: HostSectionItem[] = [
|
const HOST_SECTION_ITEMS: HostSectionItem[] = [
|
||||||
{ id: "connections", label: "Connections", icon: Network },
|
{ 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: "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) {
|
switch (view.section) {
|
||||||
case "connections":
|
case "connections":
|
||||||
return <HostConnectionsPage serverId={view.serverId} />;
|
return <HostConnectionsPage serverId={view.serverId} />;
|
||||||
case "orchestration":
|
case "agents":
|
||||||
return <HostOrchestrationPage serverId={view.serverId} />;
|
return <HostAgentsPage serverId={view.serverId} />;
|
||||||
|
case "workspaces":
|
||||||
|
return <HostWorkspacesPage serverId={view.serverId} />;
|
||||||
case "providers":
|
case "providers":
|
||||||
return <HostProvidersPage serverId={view.serverId} />;
|
return <HostProvidersPage serverId={view.serverId} />;
|
||||||
case "daemon":
|
case "host":
|
||||||
return <HostDaemonPage serverId={view.serverId} onHostRemoved={handleHostRemoved} />;
|
return <HostSettingsPage serverId={view.serverId} onHostRemoved={handleHostRemoved} />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (view.kind === "projects") {
|
if (view.kind === "projects") {
|
||||||
|
|||||||
@@ -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 host = useHostProfile(serverId);
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
|
|
||||||
@@ -199,13 +199,36 @@ export function HostOrchestrationPage({ serverId }: { serverId: string }) {
|
|||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
{isConnected ? (
|
{isConnected ? (
|
||||||
<SettingsSection title="Orchestration">
|
<SettingsSection title="Agents">
|
||||||
<InjectPaseoToolsCard serverId={serverId} />
|
<InjectPaseoToolsCard serverId={serverId} />
|
||||||
<AppendSystemPromptCard serverId={serverId} />
|
<AppendSystemPromptCard serverId={serverId} />
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
) : (
|
) : (
|
||||||
<View style={EMPTY_CARD_STYLE}>
|
<View style={EMPTY_CARD_STYLE}>
|
||||||
<Text style={styles.emptyText}>Connect to this host to manage orchestration</Text>
|
<Text style={styles.emptyText}>Connect to this host to manage agents</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HostWorkspacesPage({ serverId }: { serverId: string }) {
|
||||||
|
const host = useHostProfile(serverId);
|
||||||
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
|
|
||||||
|
if (!host) {
|
||||||
|
return <HostNotFound />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
{isConnected ? (
|
||||||
|
<SettingsSection title="Workspaces">
|
||||||
|
<AutoArchiveMergedWorkspacesCard serverId={serverId} />
|
||||||
|
</SettingsSection>
|
||||||
|
) : (
|
||||||
|
<View style={EMPTY_CARD_STYLE}>
|
||||||
|
<Text style={styles.emptyText}>Connect to this host to manage workspaces</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -226,7 +249,7 @@ export function HostProvidersPage({ serverId }: { serverId: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HostDaemonPage({
|
export function HostSettingsPage({
|
||||||
serverId,
|
serverId,
|
||||||
onHostRemoved,
|
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 (
|
||||||
|
<View style={settingsStyles.card} testID="host-page-auto-archive-merged-workspaces-card">
|
||||||
|
<View style={settingsStyles.row}>
|
||||||
|
<View style={settingsStyles.rowContent}>
|
||||||
|
<Text style={settingsStyles.rowTitle}>Archive merged PR workspaces</Text>
|
||||||
|
<Text style={settingsStyles.rowHint}>
|
||||||
|
Automatically archive clean Paseo workspaces after their pull request is merged
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={config?.autoArchiveAfterMerge === true}
|
||||||
|
onValueChange={handleValueChange}
|
||||||
|
accessibilityLabel="Archive merged PR workspaces"
|
||||||
|
testID="host-page-auto-archive-merged-workspaces-switch"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function AppendSystemPromptCard({ serverId }: { serverId: string }) {
|
function AppendSystemPromptCard({ serverId }: { serverId: string }) {
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
const { config, patchConfig } = useDaemonConfig(serverId);
|
const { config, patchConfig } = useDaemonConfig(serverId);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
decodeWorkspaceIdFromPathSegment,
|
decodeWorkspaceIdFromPathSegment,
|
||||||
encodeFilePathForPathSegment,
|
encodeFilePathForPathSegment,
|
||||||
encodeWorkspaceIdForPathSegment,
|
encodeWorkspaceIdForPathSegment,
|
||||||
|
normalizeHostSectionSlug,
|
||||||
parseHostAgentRouteFromPathname,
|
parseHostAgentRouteFromPathname,
|
||||||
parseHostWorkspaceOpenIntentFromPathname,
|
parseHostWorkspaceOpenIntentFromPathname,
|
||||||
parseHostWorkspaceRouteFromPathname,
|
parseHostWorkspaceRouteFromPathname,
|
||||||
@@ -160,3 +161,18 @@ describe("projects settings routes", () => {
|
|||||||
expect(decodeURIComponent(segment)).toBe(projectKey);
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -391,14 +391,32 @@ export function isSettingsSectionSlug(value: string): value is SettingsSectionSl
|
|||||||
return (SETTINGS_SECTION_SLUGS as readonly string[]).includes(value);
|
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];
|
export type HostSectionSlug = (typeof HOST_SECTION_SLUGS)[number];
|
||||||
|
|
||||||
|
const LEGACY_HOST_SECTION_SLUGS: Record<string, HostSectionSlug> = {
|
||||||
|
orchestration: "agents",
|
||||||
|
daemon: "host",
|
||||||
|
};
|
||||||
|
|
||||||
export function isHostSectionSlug(value: string): value is HostSectionSlug {
|
export function isHostSectionSlug(value: string): value is HostSectionSlug {
|
||||||
return (HOST_SECTION_SLUGS as readonly string[]).includes(value);
|
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() {
|
export function buildSettingsRoute() {
|
||||||
return "/settings" as const;
|
return "/settings" as const;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ export interface ResolveAgentCreateConfigInput {
|
|||||||
requestedMode: string | undefined;
|
requestedMode: string | undefined;
|
||||||
featureValues: Record<string, unknown> | undefined;
|
featureValues: Record<string, unknown> | undefined;
|
||||||
parent: AgentCreateConfigParent | null;
|
parent: AgentCreateConfigParent | null;
|
||||||
|
unattended: boolean;
|
||||||
availableModes: AgentMode[] | undefined;
|
availableModes: AgentMode[] | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,17 @@ const CLAUDE_MODES = ["default", "acceptEdits", "plan", "bypassPermissions"];
|
|||||||
const OPENCODE_MODES = ["build", "plan"];
|
const OPENCODE_MODES = ["build", "plan"];
|
||||||
const CODEX_MODES = ["auto", "full-access"];
|
const CODEX_MODES = ["auto", "full-access"];
|
||||||
|
|
||||||
|
function agentParent(provider: string, modeId: string | null, isUnattended = false) {
|
||||||
|
return { provider, modeId, isUnattended };
|
||||||
|
}
|
||||||
|
|
||||||
describe("resolveAndValidateCreateAgentMode", () => {
|
describe("resolveAndValidateCreateAgentMode", () => {
|
||||||
it("returns the requested mode when it is valid for the target provider", () => {
|
it("returns the requested mode when it is valid for the target provider", () => {
|
||||||
const resolved = resolveAndValidateCreateAgentMode({
|
const resolved = resolveAndValidateCreateAgentMode({
|
||||||
requestedMode: "plan",
|
requestedMode: "plan",
|
||||||
targetProvider: "opencode",
|
targetProvider: "opencode",
|
||||||
parent: null,
|
parent: null,
|
||||||
|
unattended: false,
|
||||||
availableModes: OPENCODE_MODES,
|
availableModes: OPENCODE_MODES,
|
||||||
});
|
});
|
||||||
expect(resolved).toBe("plan");
|
expect(resolved).toBe("plan");
|
||||||
@@ -22,6 +27,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
|
|||||||
requestedMode: "bypassPermissions",
|
requestedMode: "bypassPermissions",
|
||||||
targetProvider: "opencode",
|
targetProvider: "opencode",
|
||||||
parent: null,
|
parent: null,
|
||||||
|
unattended: false,
|
||||||
availableModes: OPENCODE_MODES,
|
availableModes: OPENCODE_MODES,
|
||||||
}),
|
}),
|
||||||
).toThrow(
|
).toThrow(
|
||||||
@@ -34,6 +40,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
|
|||||||
requestedMode: undefined,
|
requestedMode: undefined,
|
||||||
targetProvider: "claude",
|
targetProvider: "claude",
|
||||||
parent: null,
|
parent: null,
|
||||||
|
unattended: false,
|
||||||
availableModes: CLAUDE_MODES,
|
availableModes: CLAUDE_MODES,
|
||||||
});
|
});
|
||||||
expect(resolved).toBeUndefined();
|
expect(resolved).toBeUndefined();
|
||||||
@@ -43,7 +50,8 @@ describe("resolveAndValidateCreateAgentMode", () => {
|
|||||||
const resolved = resolveAndValidateCreateAgentMode({
|
const resolved = resolveAndValidateCreateAgentMode({
|
||||||
requestedMode: undefined,
|
requestedMode: undefined,
|
||||||
targetProvider: "claude",
|
targetProvider: "claude",
|
||||||
parent: { provider: "claude", modeId: "bypassPermissions" },
|
parent: agentParent("claude", "bypassPermissions"),
|
||||||
|
unattended: false,
|
||||||
availableModes: CLAUDE_MODES,
|
availableModes: CLAUDE_MODES,
|
||||||
});
|
});
|
||||||
expect(resolved).toBe("bypassPermissions");
|
expect(resolved).toBe("bypassPermissions");
|
||||||
@@ -53,7 +61,8 @@ describe("resolveAndValidateCreateAgentMode", () => {
|
|||||||
const resolved = resolveAndValidateCreateAgentMode({
|
const resolved = resolveAndValidateCreateAgentMode({
|
||||||
requestedMode: undefined,
|
requestedMode: undefined,
|
||||||
targetProvider: "claude",
|
targetProvider: "claude",
|
||||||
parent: { provider: "claude", modeId: null },
|
parent: agentParent("claude", null),
|
||||||
|
unattended: false,
|
||||||
availableModes: CLAUDE_MODES,
|
availableModes: CLAUDE_MODES,
|
||||||
});
|
});
|
||||||
expect(resolved).toBeUndefined();
|
expect(resolved).toBeUndefined();
|
||||||
@@ -64,7 +73,8 @@ describe("resolveAndValidateCreateAgentMode", () => {
|
|||||||
resolveAndValidateCreateAgentMode({
|
resolveAndValidateCreateAgentMode({
|
||||||
requestedMode: undefined,
|
requestedMode: undefined,
|
||||||
targetProvider: "opencode",
|
targetProvider: "opencode",
|
||||||
parent: { provider: "claude", modeId: "bypassPermissions" },
|
parent: agentParent("claude", "bypassPermissions"),
|
||||||
|
unattended: false,
|
||||||
availableModes: OPENCODE_MODES,
|
availableModes: OPENCODE_MODES,
|
||||||
}),
|
}),
|
||||||
).toThrow(
|
).toThrow(
|
||||||
@@ -77,7 +87,8 @@ describe("resolveAndValidateCreateAgentMode", () => {
|
|||||||
resolveAndValidateCreateAgentMode({
|
resolveAndValidateCreateAgentMode({
|
||||||
requestedMode: undefined,
|
requestedMode: undefined,
|
||||||
targetProvider: "codex",
|
targetProvider: "codex",
|
||||||
parent: { provider: "opencode", modeId: null },
|
parent: agentParent("opencode", null),
|
||||||
|
unattended: false,
|
||||||
availableModes: CODEX_MODES,
|
availableModes: CODEX_MODES,
|
||||||
}),
|
}),
|
||||||
).toThrow(
|
).toThrow(
|
||||||
@@ -90,6 +101,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
|
|||||||
requestedMode: "default",
|
requestedMode: "default",
|
||||||
targetProvider: "zai-custom",
|
targetProvider: "zai-custom",
|
||||||
parent: null,
|
parent: null,
|
||||||
|
unattended: false,
|
||||||
availableModes: undefined,
|
availableModes: undefined,
|
||||||
});
|
});
|
||||||
expect(resolved).toBe("default");
|
expect(resolved).toBe("default");
|
||||||
@@ -100,7 +112,8 @@ describe("resolveAndValidateCreateAgentMode", () => {
|
|||||||
resolveAndValidateCreateAgentMode({
|
resolveAndValidateCreateAgentMode({
|
||||||
requestedMode: undefined,
|
requestedMode: undefined,
|
||||||
targetProvider: "zai-custom",
|
targetProvider: "zai-custom",
|
||||||
parent: { provider: "claude", modeId: "default" },
|
parent: agentParent("claude", "default"),
|
||||||
|
unattended: false,
|
||||||
availableModes: undefined,
|
availableModes: undefined,
|
||||||
}),
|
}),
|
||||||
).toThrow("Available modes for 'zai-custom': unknown");
|
).toThrow("Available modes for 'zai-custom': unknown");
|
||||||
@@ -110,7 +123,20 @@ describe("resolveAndValidateCreateAgentMode", () => {
|
|||||||
const resolved = resolveAndValidateCreateAgentMode({
|
const resolved = resolveAndValidateCreateAgentMode({
|
||||||
requestedMode: undefined,
|
requestedMode: undefined,
|
||||||
targetProvider: "codex",
|
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,
|
availableModes: CODEX_MODES,
|
||||||
targetUnattendedMode: "full-access",
|
targetUnattendedMode: "full-access",
|
||||||
});
|
});
|
||||||
@@ -122,7 +148,8 @@ describe("resolveAndValidateCreateAgentMode", () => {
|
|||||||
resolveAndValidateCreateAgentMode({
|
resolveAndValidateCreateAgentMode({
|
||||||
requestedMode: undefined,
|
requestedMode: undefined,
|
||||||
targetProvider: "codex",
|
targetProvider: "codex",
|
||||||
parent: { provider: "claude", modeId: "default", isUnattended: false },
|
parent: agentParent("claude", "default"),
|
||||||
|
unattended: false,
|
||||||
availableModes: CODEX_MODES,
|
availableModes: CODEX_MODES,
|
||||||
targetUnattendedMode: "full-access",
|
targetUnattendedMode: "full-access",
|
||||||
}),
|
}),
|
||||||
@@ -136,7 +163,8 @@ describe("resolveAndValidateCreateAgentMode", () => {
|
|||||||
resolveAndValidateCreateAgentMode({
|
resolveAndValidateCreateAgentMode({
|
||||||
requestedMode: undefined,
|
requestedMode: undefined,
|
||||||
targetProvider: "zai-custom",
|
targetProvider: "zai-custom",
|
||||||
parent: { provider: "claude", modeId: "bypassPermissions", isUnattended: true },
|
parent: agentParent("claude", "bypassPermissions", true),
|
||||||
|
unattended: false,
|
||||||
availableModes: undefined,
|
availableModes: undefined,
|
||||||
targetUnattendedMode: undefined,
|
targetUnattendedMode: undefined,
|
||||||
}),
|
}),
|
||||||
@@ -149,7 +177,8 @@ describe("resolveAndValidateCreateAgentMode", () => {
|
|||||||
const resolved = resolveAndValidateCreateAgentMode({
|
const resolved = resolveAndValidateCreateAgentMode({
|
||||||
requestedMode: "auto",
|
requestedMode: "auto",
|
||||||
targetProvider: "codex",
|
targetProvider: "codex",
|
||||||
parent: { provider: "claude", modeId: "bypassPermissions", isUnattended: true },
|
parent: agentParent("claude", "bypassPermissions", true),
|
||||||
|
unattended: false,
|
||||||
availableModes: CODEX_MODES,
|
availableModes: CODEX_MODES,
|
||||||
targetUnattendedMode: "full-access",
|
targetUnattendedMode: "full-access",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AgentCreateConfigParent,
|
||||||
AgentCreateConfigUnattendedInput,
|
AgentCreateConfigUnattendedInput,
|
||||||
AgentMode,
|
AgentMode,
|
||||||
AgentProvider,
|
AgentProvider,
|
||||||
@@ -6,16 +7,11 @@ import type {
|
|||||||
ResolveAgentCreateConfigResult,
|
ResolveAgentCreateConfigResult,
|
||||||
} from "./agent-sdk-types.js";
|
} from "./agent-sdk-types.js";
|
||||||
|
|
||||||
interface CreateAgentModeParent {
|
|
||||||
provider: AgentProvider;
|
|
||||||
modeId: string | null;
|
|
||||||
isUnattended: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ResolveCreateAgentModeInput {
|
export interface ResolveCreateAgentModeInput {
|
||||||
requestedMode: string | undefined;
|
requestedMode: string | undefined;
|
||||||
targetProvider: AgentProvider;
|
targetProvider: AgentProvider;
|
||||||
parent: CreateAgentModeParent | null;
|
parent: AgentCreateConfigParent | null;
|
||||||
|
unattended: boolean;
|
||||||
// `undefined` = target provider's modes unknown: explicit modes pass through
|
// `undefined` = target provider's modes unknown: explicit modes pass through
|
||||||
// unvalidated, but cross-provider inheritance is still refused.
|
// unvalidated, but cross-provider inheritance is still refused.
|
||||||
availableModes: string[] | undefined;
|
availableModes: string[] | undefined;
|
||||||
@@ -31,6 +27,18 @@ function listModes(modes: string[] | undefined): string {
|
|||||||
return modes.length > 0 ? modes.join(", ") : "(none)";
|
return modes.length > 0 ? modes.join(", ") : "(none)";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isUnattendedCreateConfigParent(parent: AgentCreateConfigParent): boolean {
|
||||||
|
return parent.isUnattended;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCreateConfigParentMode(parent: AgentCreateConfigParent): string {
|
||||||
|
return parent.modeId ?? "<none>";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCreateConfigParentSource(parent: AgentCreateConfigParent): string {
|
||||||
|
return `caller (provider '${parent.provider}')`;
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveAndValidateCreateAgentMode(
|
export function resolveAndValidateCreateAgentMode(
|
||||||
input: ResolveCreateAgentModeInput,
|
input: ResolveCreateAgentModeInput,
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
@@ -46,6 +54,9 @@ export function resolveAndValidateCreateAgentMode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!parent) {
|
if (!parent) {
|
||||||
|
if (input.unattended && input.targetUnattendedMode !== undefined) {
|
||||||
|
return input.targetUnattendedMode;
|
||||||
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,12 +64,15 @@ export function resolveAndValidateCreateAgentMode(
|
|||||||
return parent.modeId ?? undefined;
|
return parent.modeId ?? undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parent.isUnattended && input.targetUnattendedMode !== undefined) {
|
if (
|
||||||
|
(input.unattended || isUnattendedCreateConfigParent(parent)) &&
|
||||||
|
input.targetUnattendedMode !== undefined
|
||||||
|
) {
|
||||||
return input.targetUnattendedMode;
|
return input.targetUnattendedMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`cannot inherit mode '${parent.modeId ?? "<none>"}' 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,
|
requestedMode: input.requestedMode,
|
||||||
targetProvider: input.provider,
|
targetProvider: input.provider,
|
||||||
parent: input.parent,
|
parent: input.parent,
|
||||||
|
unattended: input.unattended,
|
||||||
availableModes: availableModeIds,
|
availableModes: availableModeIds,
|
||||||
targetUnattendedMode: input.availableModes?.find(isUnattendedMode)?.id,
|
targetUnattendedMode: input.availableModes?.find(isUnattendedMode)?.id,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -264,6 +264,7 @@ async function resolveMcpCreateAgent(
|
|||||||
requestedMode: input.mode,
|
requestedMode: input.mode,
|
||||||
featureValues: input.features,
|
featureValues: input.features,
|
||||||
parent: parentAgent,
|
parent: parentAgent,
|
||||||
|
unattended: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const labels = mergeLabels({
|
const labels = mergeLabels({
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { createAgentMcpServer } from "./mcp-server.js";
|
|||||||
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
|
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
|
||||||
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
|
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
|
||||||
import type { AgentMode, AgentProvider, ProviderSnapshotEntry } from "./agent-sdk-types.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 { createProviderSnapshotManagerStub } from "../test-utils/session-stubs.js";
|
||||||
import {
|
import {
|
||||||
AgentListItemPayloadSchema,
|
AgentListItemPayloadSchema,
|
||||||
@@ -255,25 +254,33 @@ function buildSnapshotEntry(entry: ConfigureProviderEntry): ProviderSnapshotEntr
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Shared helper used by ~60 create_agent / update_agent / list_agents tests that
|
// Shared helper used by ~60 create_agent / update_agent / list_agents tests that
|
||||||
// only need a "normal" provider catalog (claude, codex, opencode) and the
|
// only need a "normal" provider catalog (claude, codex, opencode). OpenCode
|
||||||
// OpenCode resolveCreateConfig quirk: requestedMode="full-access" or an
|
// create-config behavior delegates to the production provider client.
|
||||||
// unattended parent maps to build mode + auto_accept feature.
|
|
||||||
//
|
//
|
||||||
// NOTE: This is NOT a registry. It directly configures the public stub surface.
|
// NOTE: This is NOT a registry. It directly configures the public stub surface.
|
||||||
// Per-test customization is done by overriding individual stub methods after
|
// Per-test customization is done by overriding individual stub methods after
|
||||||
// calling this helper.
|
// calling this helper.
|
||||||
function configureOpenCodeProviderStub(stub: ProviderSnapshotManagerStub): void {
|
interface ConfigureOpenCodeProviderStubOptions {
|
||||||
|
customOpenCodeProvider?: AgentProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
function configureOpenCodeProviderStub(
|
||||||
|
stub: ProviderSnapshotManagerStub,
|
||||||
|
options: ConfigureOpenCodeProviderStubOptions = {},
|
||||||
|
): void {
|
||||||
const claudeModes: AgentMode[] = [
|
const claudeModes: AgentMode[] = [
|
||||||
{ id: "default", label: "Default", description: "Ask first" },
|
{ id: "default", label: "Default", description: "Ask first" },
|
||||||
{ id: "bypassPermissions", label: "Bypass", description: "No prompts", isUnattended: true },
|
{ id: "bypassPermissions", label: "Bypass", description: "No prompts", isUnattended: true },
|
||||||
];
|
];
|
||||||
const codexModes: AgentMode[] = [
|
const codexModes: AgentMode[] = [
|
||||||
{ id: "default", label: "Default", description: "Default" },
|
{ 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[] = [
|
const opencodeModes: AgentMode[] = [
|
||||||
{ id: "build", label: "Build", description: "Can edit" },
|
{ id: "build", label: "Build", description: "Can edit" },
|
||||||
{ id: "plan", label: "Plan", description: "Read-only" },
|
{ id: "plan", label: "Plan", description: "Read-only" },
|
||||||
|
{ id: "paseo-custom", label: "Paseo Custom", description: "Custom OpenCode agent" },
|
||||||
];
|
];
|
||||||
const entries: ProviderSnapshotEntry[] = [
|
const entries: ProviderSnapshotEntry[] = [
|
||||||
buildSnapshotEntry({
|
buildSnapshotEntry({
|
||||||
@@ -298,13 +305,31 @@ function configureOpenCodeProviderStub(stub: ProviderSnapshotManagerStub): void
|
|||||||
modes: opencodeModes,
|
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<string, AgentMode[]> = {
|
const modesByProvider: Record<string, AgentMode[]> = {
|
||||||
claude: claudeModes,
|
claude: claudeModes,
|
||||||
codex: codexModes,
|
codex: codexModes,
|
||||||
opencode: opencodeModes,
|
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) =>
|
stub.hasProvider.mockImplementation((provider) =>
|
||||||
Object.prototype.hasOwnProperty.call(modesByProvider, provider),
|
Object.prototype.hasOwnProperty.call(modesByProvider, provider),
|
||||||
);
|
);
|
||||||
@@ -334,56 +359,18 @@ function configureOpenCodeProviderStub(stub: ProviderSnapshotManagerStub): void
|
|||||||
provider: AgentProvider;
|
provider: AgentProvider;
|
||||||
requestedMode: string | undefined;
|
requestedMode: string | undefined;
|
||||||
featureValues: Record<string, unknown> | undefined;
|
featureValues: Record<string, unknown> | undefined;
|
||||||
parent: ManagedAgent | null;
|
|
||||||
};
|
};
|
||||||
if (opts.provider === "opencode") {
|
return { modeId: opts.requestedMode, featureValues: opts.featureValues };
|
||||||
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,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quick helper: returns a manager configured with the standard OpenCode catalog.
|
// Quick helper: returns a manager configured with the standard OpenCode catalog.
|
||||||
function createOpenCodeManager(): {
|
function createOpenCodeManager(options?: ConfigureOpenCodeProviderStubOptions): {
|
||||||
manager: ProviderSnapshotManagerStub["manager"];
|
manager: ProviderSnapshotManagerStub["manager"];
|
||||||
stub: ProviderSnapshotManagerStub;
|
stub: ProviderSnapshotManagerStub;
|
||||||
} {
|
} {
|
||||||
const stub = createProviderSnapshotManagerStub();
|
const stub = createProviderSnapshotManagerStub();
|
||||||
configureOpenCodeProviderStub(stub);
|
configureOpenCodeProviderStub(stub, options);
|
||||||
return { manager: stub.manager, stub };
|
return { manager: stub.manager, stub };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1843,12 +1830,17 @@ describe("create_agent MCP tool", () => {
|
|||||||
availableModes: [],
|
availableModes: [],
|
||||||
config: { title: "Child", featureValues: { fast_mode: true } },
|
config: { title: "Child", featureValues: { fast_mode: true } },
|
||||||
} as ManagedAgent);
|
} as ManagedAgent);
|
||||||
|
const providerSnapshot = createOpenCodeManager();
|
||||||
|
providerSnapshot.stub.resolveCreateConfig.mockImplementation(async (input) => {
|
||||||
|
const opts = input as { featureValues: Record<string, unknown> | undefined };
|
||||||
|
return { modeId: undefined, featureValues: opts.featureValues };
|
||||||
|
});
|
||||||
|
|
||||||
const server = await createAgentMcpServer({
|
const server = await createAgentMcpServer({
|
||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
callerAgentId: "parent-agent",
|
callerAgentId: "parent-agent",
|
||||||
providerSnapshotManager: createOpenCodeManager().manager,
|
providerSnapshotManager: providerSnapshot.manager,
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "create_agent");
|
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 () => {
|
it("rejects an explicit mode that is not valid for the target provider", async () => {
|
||||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||||
|
const providerSnapshot = createOpenCodeManager();
|
||||||
|
providerSnapshot.stub.resolveCreateConfig.mockImplementation(async () => {
|
||||||
|
throw new Error("resolver rejected mode");
|
||||||
|
});
|
||||||
const server = await createAgentMcpServer({
|
const server = await createAgentMcpServer({
|
||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
providerSnapshotManager: createOpenCodeManager().manager,
|
providerSnapshotManager: providerSnapshot.manager,
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "create_agent");
|
const tool = registeredTool(server, "create_agent");
|
||||||
@@ -1933,9 +1929,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
settings: { modeId: "bypassPermissions" },
|
settings: { modeId: "bypassPermissions" },
|
||||||
initialPrompt: "Do work",
|
initialPrompt: "Do work",
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow(
|
).rejects.toThrow("resolver rejected mode");
|
||||||
"Invalid mode 'bypassPermissions' for provider 'opencode'. Available modes: build, plan",
|
|
||||||
);
|
|
||||||
expect(spies.agentManager.createAgent).not.toHaveBeenCalled();
|
expect(spies.agentManager.createAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1963,13 +1957,8 @@ describe("create_agent MCP tool", () => {
|
|||||||
provStub.listModes.mockResolvedValue(dynamicModes);
|
provStub.listModes.mockResolvedValue(dynamicModes);
|
||||||
provStub.resolveCreateConfig.mockImplementation(async (input) => {
|
provStub.resolveCreateConfig.mockImplementation(async (input) => {
|
||||||
const opts = input as { requestedMode: string | undefined };
|
const opts = input as { requestedMode: string | undefined };
|
||||||
return resolveDefaultAgentCreateConfig({
|
expect(opts.requestedMode).toBe("dynamic");
|
||||||
provider: "codex",
|
return { modeId: "dynamic", featureValues: undefined };
|
||||||
requestedMode: opts.requestedMode,
|
|
||||||
featureValues: undefined,
|
|
||||||
parent: null,
|
|
||||||
availableModes: dynamicModes,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
const server = await createAgentMcpServer({
|
const server = await createAgentMcpServer({
|
||||||
agentManager,
|
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();
|
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||||
spies.agentManager.createAgent.mockResolvedValue({
|
spies.agentManager.createAgent.mockResolvedValue({
|
||||||
id: "child-agent",
|
id: "child-agent",
|
||||||
@@ -2004,10 +1993,15 @@ describe("create_agent MCP tool", () => {
|
|||||||
availableModes: [],
|
availableModes: [],
|
||||||
config: { title: "Child", featureValues: { auto_accept: true } },
|
config: { title: "Child", featureValues: { auto_accept: true } },
|
||||||
} as ManagedAgent);
|
} as ManagedAgent);
|
||||||
|
const providerSnapshot = createOpenCodeManager();
|
||||||
|
providerSnapshot.stub.resolveCreateConfig.mockResolvedValue({
|
||||||
|
modeId: "build",
|
||||||
|
featureValues: { auto_accept: true },
|
||||||
|
});
|
||||||
const server = await createAgentMcpServer({
|
const server = await createAgentMcpServer({
|
||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
providerSnapshotManager: createOpenCodeManager().manager,
|
providerSnapshotManager: providerSnapshot.manager,
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "create_agent");
|
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();
|
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||||
spies.agentManager.getAgent.mockReturnValue({
|
const parentAgent = {
|
||||||
id: "parent-agent",
|
id: "parent-agent",
|
||||||
cwd: existingCwd,
|
cwd: existingCwd,
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
currentModeId: "bypassPermissions",
|
currentModeId: "bypassPermissions",
|
||||||
} as ManagedAgent);
|
} as ManagedAgent;
|
||||||
|
spies.agentManager.getAgent.mockReturnValue(parentAgent);
|
||||||
spies.agentManager.createAgent.mockResolvedValue({
|
spies.agentManager.createAgent.mockResolvedValue({
|
||||||
id: "child-agent",
|
id: "child-agent",
|
||||||
cwd: existingCwd,
|
cwd: existingCwd,
|
||||||
lifecycle: "idle",
|
lifecycle: "idle",
|
||||||
currentModeId: "bypassPermissions",
|
currentModeId: "resolver-mode",
|
||||||
availableModes: [],
|
availableModes: [],
|
||||||
config: { title: "Child" },
|
config: { title: "Child" },
|
||||||
} as ManagedAgent);
|
} as ManagedAgent);
|
||||||
|
const providerSnapshot = createOpenCodeManager();
|
||||||
|
providerSnapshot.stub.resolveCreateConfig.mockResolvedValue({
|
||||||
|
modeId: "resolver-mode",
|
||||||
|
featureValues: { resolver_feature: true },
|
||||||
|
});
|
||||||
|
|
||||||
const server = await createAgentMcpServer({
|
const server = await createAgentMcpServer({
|
||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
callerAgentId: "parent-agent",
|
callerAgentId: "parent-agent",
|
||||||
providerSnapshotManager: createOpenCodeManager().manager,
|
providerSnapshotManager: providerSnapshot.manager,
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "create_agent");
|
const tool = registeredTool(server, "create_agent");
|
||||||
@@ -2058,76 +2058,14 @@ describe("create_agent MCP tool", () => {
|
|||||||
initialPrompt: "Do work",
|
initialPrompt: "Do work",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
expect(providerSnapshot.stub.resolveCreateConfig).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ modeId: "bypassPermissions" }),
|
expect.objectContaining({ parent: parentAgent, unattended: false }),
|
||||||
undefined,
|
|
||||||
expect.any(Object),
|
|
||||||
);
|
);
|
||||||
});
|
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
it("refuses cross-provider mode inheritance when caller is not in an unattended mode", async () => {
|
modeId: "resolver-mode",
|
||||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
featureValues: { resolver_feature: true },
|
||||||
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",
|
|
||||||
}),
|
}),
|
||||||
).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,
|
undefined,
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -40,15 +40,23 @@ describe("provider model catalogs (e2e)", () => {
|
|||||||
}, 180_000);
|
}, 180_000);
|
||||||
|
|
||||||
test.runIf(hasCodex)(
|
test.runIf(hasCodex)(
|
||||||
"Codex catalog exposes gpt-5.1-codex",
|
"Codex catalog exposes normalized models",
|
||||||
async () => {
|
async () => {
|
||||||
const ctx = await createDaemonTestContext();
|
const ctx = await createDaemonTestContext();
|
||||||
try {
|
try {
|
||||||
const result = await ctx.client.listProviderModels("codex");
|
const result = await ctx.client.listProviderModels("codex");
|
||||||
|
|
||||||
expect(result.error).toBeNull();
|
expect(result.error).toBeNull();
|
||||||
const ids = result.models.map((model) => model.id);
|
expect(result.models.length).toBeGreaterThan(0);
|
||||||
expect(ids.some((id) => id.includes("codex"))).toBe(true);
|
expect(result.models).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
provider: "codex",
|
||||||
|
id: expect.any(String),
|
||||||
|
label: expect.any(String),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
await ctx.cleanup();
|
await ctx.cleanup();
|
||||||
}
|
}
|
||||||
@@ -66,17 +74,15 @@ describe("provider model catalogs (e2e)", () => {
|
|||||||
expect(result.error).toBeNull();
|
expect(result.error).toBeNull();
|
||||||
expect(result.models.length).toBeGreaterThan(0);
|
expect(result.models.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
for (const model of result.models) {
|
expect(result.models).toEqual(
|
||||||
expect(model.provider).toBe("opencode");
|
expect.arrayContaining([
|
||||||
expect(model.id).toContain("/");
|
expect.objectContaining({
|
||||||
expect(model.label).toBeTruthy();
|
provider: "opencode",
|
||||||
expect(model.metadata).toBeDefined();
|
id: expect.any(String),
|
||||||
expect(model.metadata?.providerId).toBeTruthy();
|
label: expect.any(String),
|
||||||
expect(model.metadata?.modelId).toBeTruthy();
|
}),
|
||||||
}
|
]),
|
||||||
|
);
|
||||||
const providerIds = new Set(result.models.map((m) => m.metadata?.providerId));
|
|
||||||
expect(providerIds.size).toBeGreaterThan(0);
|
|
||||||
} finally {
|
} finally {
|
||||||
await ctx.cleanup();
|
await ctx.cleanup();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import { describe, expect, test, vi } from "vitest";
|
|||||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||||
import type {
|
import type {
|
||||||
AgentClient,
|
AgentClient,
|
||||||
|
AgentMode,
|
||||||
AgentModelDefinition,
|
AgentModelDefinition,
|
||||||
AgentProvider,
|
AgentProvider,
|
||||||
ListModelsOptions,
|
ListModelsOptions,
|
||||||
|
ResolveAgentCreateConfigInput,
|
||||||
} from "./agent-sdk-types.js";
|
} from "./agent-sdk-types.js";
|
||||||
|
import type { ManagedAgent } from "./agent-manager.js";
|
||||||
import { ProviderSnapshotManager } from "./provider-snapshot-manager.js";
|
import { ProviderSnapshotManager } from "./provider-snapshot-manager.js";
|
||||||
|
|
||||||
const TEST_CAPABILITIES = {
|
const TEST_CAPABILITIES = {
|
||||||
@@ -309,6 +312,143 @@ describe("ProviderSnapshotManager public surface", () => {
|
|||||||
manager.destroy();
|
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", () => {
|
describe("ProviderSnapshotManager applyMutableProviderConfig", () => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { expandTilde } from "../../utils/path.js";
|
|||||||
import { withTimeout } from "../../utils/promise-timeout.js";
|
import { withTimeout } from "../../utils/promise-timeout.js";
|
||||||
import type {
|
import type {
|
||||||
AgentClient,
|
AgentClient,
|
||||||
|
AgentCreateConfigParent,
|
||||||
AgentMode,
|
AgentMode,
|
||||||
AgentModelDefinition,
|
AgentModelDefinition,
|
||||||
AgentProvider,
|
AgentProvider,
|
||||||
@@ -58,12 +59,13 @@ interface ProviderSnapshotProviderOptions {
|
|||||||
wait?: boolean;
|
wait?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ResolveProviderCreateConfigOptions {
|
export interface ResolveProviderCreateConfigOptions {
|
||||||
cwd?: string | null;
|
cwd?: string | null;
|
||||||
provider: AgentProvider;
|
provider: AgentProvider;
|
||||||
requestedMode: string | undefined;
|
requestedMode: string | undefined;
|
||||||
featureValues: Record<string, unknown> | undefined;
|
featureValues: Record<string, unknown> | undefined;
|
||||||
parent: ManagedAgent | null;
|
parent: ManagedAgent | null;
|
||||||
|
unattended: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResolvedProviderCreateConfig {
|
export interface ResolvedProviderCreateConfig {
|
||||||
@@ -301,11 +303,13 @@ export class ProviderSnapshotManager {
|
|||||||
wait: true,
|
wait: true,
|
||||||
});
|
});
|
||||||
const definition = this.requireProvider(input.provider);
|
const definition = this.requireProvider(input.provider);
|
||||||
|
const parent = input.parent ? this.resolveParent(input.parent) : null;
|
||||||
return definition.resolveCreateConfig({
|
return definition.resolveCreateConfig({
|
||||||
provider: input.provider,
|
provider: input.provider,
|
||||||
requestedMode: input.requestedMode,
|
requestedMode: input.requestedMode,
|
||||||
featureValues: input.featureValues,
|
featureValues: input.featureValues,
|
||||||
parent: input.parent ? this.resolveParent(input.parent) : null,
|
parent,
|
||||||
|
unattended: input.unattended || parent?.isUnattended === true,
|
||||||
availableModes: entry.modes ?? [],
|
availableModes: entry.modes ?? [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -369,15 +373,34 @@ export class ProviderSnapshotManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildRegistry(): Record<AgentProvider, ProviderDefinition> {
|
private buildRegistry(): Record<AgentProvider, ProviderDefinition> {
|
||||||
return buildProviderRegistry(this.logger, {
|
const registry = buildProviderRegistry(this.logger, {
|
||||||
runtimeSettings: this.runtimeSettings,
|
runtimeSettings: this.runtimeSettings,
|
||||||
providerOverrides: this.providerOverrides,
|
providerOverrides: this.providerOverrides,
|
||||||
workspaceGitService: this.workspaceGitService,
|
workspaceGitService: this.workspaceGitService,
|
||||||
isDev: this.isDev,
|
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);
|
const definition = this.requireProvider(parent.provider);
|
||||||
return {
|
return {
|
||||||
provider: parent.provider,
|
provider: parent.provider,
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ describe("OpenCode auto_accept feature", () => {
|
|||||||
requestedMode: "full-access",
|
requestedMode: "full-access",
|
||||||
featureValues: undefined,
|
featureValues: undefined,
|
||||||
parent: null,
|
parent: null,
|
||||||
|
unattended: false,
|
||||||
availableModes: [
|
availableModes: [
|
||||||
{ id: "build", label: "Build" },
|
{ id: "build", label: "Build" },
|
||||||
{ id: "plan", label: "Plan" },
|
{ id: "plan", label: "Plan" },
|
||||||
@@ -155,6 +156,7 @@ describe("OpenCode auto_accept feature", () => {
|
|||||||
modeId: "bypassPermissions",
|
modeId: "bypassPermissions",
|
||||||
isUnattended: true,
|
isUnattended: true,
|
||||||
},
|
},
|
||||||
|
unattended: true,
|
||||||
availableModes: [
|
availableModes: [
|
||||||
{ id: "build", label: "Build" },
|
{ id: "build", label: "Build" },
|
||||||
{ id: "plan", label: "Plan" },
|
{ id: "plan", label: "Plan" },
|
||||||
@@ -163,6 +165,47 @@ describe("OpenCode auto_accept feature", () => {
|
|||||||
).toEqual({ modeId: "build", featureValues: { auto_accept: true } });
|
).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 () => {
|
test("auto-approves tool permissions when auto accept is enabled", async () => {
|
||||||
const { openCodeClient, runtime } = mockOpenCodeClient({
|
const { openCodeClient, runtime } = mockOpenCodeClient({
|
||||||
events: [toolPermissionEvent(), idleEvent()],
|
events: [toolPermissionEvent(), idleEvent()],
|
||||||
|
|||||||
@@ -131,9 +131,16 @@ function resolveOpenCodeCreateConfig(
|
|||||||
input: ResolveAgentCreateConfigInput,
|
input: ResolveAgentCreateConfigInput,
|
||||||
): ResolveAgentCreateConfigResult {
|
): ResolveAgentCreateConfigResult {
|
||||||
const legacyFullAccess = input.requestedMode === OPENCODE_LEGACY_FULL_ACCESS_MODE_ID;
|
const legacyFullAccess = input.requestedMode === OPENCODE_LEGACY_FULL_ACCESS_MODE_ID;
|
||||||
|
const parent = input.parent;
|
||||||
const inheritsUnattended =
|
const inheritsUnattended =
|
||||||
input.requestedMode === undefined && input.parent?.isUnattended === true;
|
input.requestedMode === undefined && (input.unattended || parent?.isUnattended === true);
|
||||||
const requestedMode = legacyFullAccess ? OPENCODE_BUILD_MODE_ID : input.requestedMode;
|
const inheritedOpenCodeMode =
|
||||||
|
inheritsUnattended && parent?.provider === input.provider
|
||||||
|
? (parent.modeId ?? undefined)
|
||||||
|
: undefined;
|
||||||
|
const requestedMode = legacyFullAccess
|
||||||
|
? OPENCODE_BUILD_MODE_ID
|
||||||
|
: (input.requestedMode ?? inheritedOpenCodeMode);
|
||||||
const featureValues =
|
const featureValues =
|
||||||
legacyFullAccess ||
|
legacyFullAccess ||
|
||||||
(inheritsUnattended && input.featureValues?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === undefined)
|
(inheritsUnattended && input.featureValues?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === undefined)
|
||||||
|
|||||||
@@ -233,6 +233,17 @@ describe("archiveIfSafe", () => {
|
|||||||
expect(harness.deps.archivePaseoWorktree).not.toHaveBeenCalled();
|
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 () => {
|
test("does nothing when the cwd is not a Paseo-owned worktree", async () => {
|
||||||
const harness = createHarness({
|
const harness = createHarness({
|
||||||
isPaseoOwnedWorktreeCwd: async () => ({ allowed: false, worktreePath: CWD }),
|
isPaseoOwnedWorktreeCwd: async () => ({ allowed: false, worktreePath: CWD }),
|
||||||
|
|||||||
@@ -78,7 +78,10 @@ export async function archiveIfSafe(input: {
|
|||||||
return;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -597,6 +597,7 @@ export async function createPaseoDaemon(
|
|||||||
paseoHome: config.paseoHome,
|
paseoHome: config.paseoHome,
|
||||||
logger,
|
logger,
|
||||||
agentManager,
|
agentManager,
|
||||||
|
providerSnapshotManager,
|
||||||
});
|
});
|
||||||
await loopService.initialize();
|
await loopService.initialize();
|
||||||
logger.info({ elapsed: elapsed() }, "Loop service initialized");
|
logger.info({ elapsed: elapsed() }, "Loop service initialized");
|
||||||
@@ -605,6 +606,7 @@ export async function createPaseoDaemon(
|
|||||||
logger,
|
logger,
|
||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
|
providerSnapshotManager,
|
||||||
});
|
});
|
||||||
await scheduleService.start();
|
await scheduleService.start();
|
||||||
agentManager.setAgentArchivedCallback(async (agentId) => {
|
agentManager.setAgentArchivedCallback(async (agentId) => {
|
||||||
|
|||||||
@@ -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);
|
const ids = Array.from(new Set(models.map((m) => m.id))).filter(Boolean);
|
||||||
if (ids.length < 2) {
|
const first = ids[0];
|
||||||
throw new Error(`Need at least 2 models to test switching; got ${ids.length}`);
|
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 {
|
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) {
|
if (!modelList.models || modelList.models.length === 0) {
|
||||||
throw new Error(`No models returned for provider ${provider}`);
|
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({
|
const agent = await ctx.client.createAgent({
|
||||||
provider,
|
provider,
|
||||||
@@ -162,18 +189,10 @@ test.runIf(hasCodex)(
|
|||||||
throw new Error("No Codex models returned");
|
throw new Error("No Codex models returned");
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelWithOptions = modelList.models.find((m) => (m.thinkingOptions?.length ?? 0) > 1);
|
const { model: modelWithOptions, thinkingOptionId } = pickThinkingSwitchOption(
|
||||||
if (!modelWithOptions) {
|
"Codex",
|
||||||
throw new Error("No Codex model with thinkingOptions returned");
|
modelList.models,
|
||||||
}
|
);
|
||||||
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 agent = await ctx.client.createAgent({
|
const agent = await ctx.client.createAgent({
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
@@ -183,16 +202,16 @@ test.runIf(hasCodex)(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const startIndex = messages.length;
|
const startIndex = messages.length;
|
||||||
await ctx.client.setAgentThinkingOption(agent.id, nonDefault);
|
await ctx.client.setAgentThinkingOption(agent.id, thinkingOptionId);
|
||||||
|
|
||||||
const updated = await waitForAgentUpdate(
|
const updated = await waitForAgentUpdate(
|
||||||
messages,
|
messages,
|
||||||
startIndex,
|
startIndex,
|
||||||
(a) => a.id === agent.id && a.thinkingOptionId === nonDefault,
|
(a) => a.id === agent.id && a.thinkingOptionId === thinkingOptionId,
|
||||||
{ timeoutMs: 20000 },
|
{ timeoutMs: 20000 },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(updated.thinkingOptionId).toBe(nonDefault);
|
expect(updated.thinkingOptionId).toBe(thinkingOptionId);
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
@@ -210,12 +229,10 @@ test.runIf(hasOpenCode)(
|
|||||||
throw new Error("No OpenCode models returned");
|
throw new Error("No OpenCode models returned");
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelWithThinkingOptions = modelList.models.find(
|
const { model: modelWithThinkingOptions, thinkingOptionId } = pickThinkingSwitchOption(
|
||||||
(m) => (m.thinkingOptions?.length ?? 0) > 1,
|
"OpenCode",
|
||||||
|
modelList.models,
|
||||||
);
|
);
|
||||||
if (!modelWithThinkingOptions) {
|
|
||||||
throw new Error("No OpenCode model with thinkingOptions returned");
|
|
||||||
}
|
|
||||||
|
|
||||||
const agent = await ctx.client.createAgent({
|
const agent = await ctx.client.createAgent({
|
||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
@@ -224,23 +241,15 @@ test.runIf(hasOpenCode)(
|
|||||||
model: modelWithThinkingOptions.id,
|
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;
|
const startIndex = messages.length;
|
||||||
await ctx.client.setAgentThinkingOption(agent.id, thinkingId);
|
await ctx.client.setAgentThinkingOption(agent.id, thinkingOptionId);
|
||||||
const updatedThinking = await waitForAgentUpdate(
|
const updatedThinking = await waitForAgentUpdate(
|
||||||
messages,
|
messages,
|
||||||
startIndex,
|
startIndex,
|
||||||
(a) => a.id === agent.id && a.thinkingOptionId === thinkingId,
|
(a) => a.id === agent.id && a.thinkingOptionId === thinkingOptionId,
|
||||||
{ timeoutMs: 20000 },
|
{ timeoutMs: 20000 },
|
||||||
);
|
);
|
||||||
expect(updatedThinking.thinkingOptionId).toBe(thinkingId);
|
expect(updatedThinking.thinkingOptionId).toBe(thinkingOptionId);
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import type {
|
|||||||
} from "./agent/agent-sdk-types.js";
|
} from "./agent/agent-sdk-types.js";
|
||||||
import { AgentStorage } from "./agent/agent-storage.js";
|
import { AgentStorage } from "./agent/agent-storage.js";
|
||||||
import { AgentManager } from "./agent/agent-manager.js";
|
import { AgentManager } from "./agent/agent-manager.js";
|
||||||
|
import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
|
||||||
import { LoopService } from "./loop-service.js";
|
import { LoopService } from "./loop-service.js";
|
||||||
import { isPlatform } from "../test-utils/platform.js";
|
import { isPlatform } from "../test-utils/platform.js";
|
||||||
import { createTestLogger } from "../test-utils/test-logger.js";
|
import { createTestLogger } from "../test-utils/test-logger.js";
|
||||||
@@ -44,6 +45,13 @@ const TEST_CAPABILITIES: AgentCapabilityFlags = {
|
|||||||
supportsToolInvocations: false,
|
supportsToolInvocations: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const NO_UNATTENDED_LOOP_POLICY: Pick<ProviderSnapshotManager, "resolveCreateConfig"> = {
|
||||||
|
async resolveCreateConfig(input) {
|
||||||
|
expect(input).toMatchObject({ parent: null, unattended: true, requestedMode: undefined });
|
||||||
|
return { modeId: undefined, featureValues: input.featureValues };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
interface ScriptedAgentBehavior {
|
interface ScriptedAgentBehavior {
|
||||||
onRun(input: { config: AgentSessionConfig; prompt: string; turnId: string }): Promise<string>;
|
onRun(input: { config: AgentSessionConfig; prompt: string; turnId: string }): Promise<string>;
|
||||||
}
|
}
|
||||||
@@ -266,7 +274,12 @@ describe("LoopService", () => {
|
|||||||
registry: storage,
|
registry: storage,
|
||||||
logger,
|
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();
|
await service.initialize();
|
||||||
|
|
||||||
const loop = await service.runLoop({
|
const loop = await service.runLoop({
|
||||||
@@ -317,7 +330,12 @@ describe("LoopService", () => {
|
|||||||
registry: storage,
|
registry: storage,
|
||||||
logger,
|
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();
|
await service.initialize();
|
||||||
|
|
||||||
const loop = await service.runLoop({
|
const loop = await service.runLoop({
|
||||||
@@ -378,7 +396,12 @@ describe("LoopService", () => {
|
|||||||
archivedAgentIds.push(agentId);
|
archivedAgentIds.push(agentId);
|
||||||
await archiveAgent(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();
|
await service.initialize();
|
||||||
|
|
||||||
const loop = await service.runLoop({
|
const loop = await service.runLoop({
|
||||||
@@ -430,7 +453,12 @@ describe("LoopService", () => {
|
|||||||
registry: storage,
|
registry: storage,
|
||||||
logger,
|
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();
|
await service.initialize();
|
||||||
|
|
||||||
const loop = await service.runLoop({
|
const loop = await service.runLoop({
|
||||||
@@ -472,7 +500,17 @@ describe("LoopService", () => {
|
|||||||
registry: storage,
|
registry: storage,
|
||||||
logger,
|
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();
|
await service.initialize();
|
||||||
|
|
||||||
const loop = await service.runLoop({
|
const loop = await service.runLoop({
|
||||||
@@ -488,6 +526,71 @@ describe("LoopService", () => {
|
|||||||
expect(verifierConfigs[0]?.modeId).toBe("bypassPermissions");
|
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<AgentSession> {
|
||||||
|
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 () => {
|
test("explicit modeId wins over unattended default", async () => {
|
||||||
const workerConfigs: AgentSessionConfig[] = [];
|
const workerConfigs: AgentSessionConfig[] = [];
|
||||||
const verifierConfigs: AgentSessionConfig[] = [];
|
const verifierConfigs: AgentSessionConfig[] = [];
|
||||||
@@ -508,7 +611,12 @@ describe("LoopService", () => {
|
|||||||
registry: storage,
|
registry: storage,
|
||||||
logger,
|
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();
|
await service.initialize();
|
||||||
|
|
||||||
const loop = await service.runLoop({
|
const loop = await service.runLoop({
|
||||||
@@ -546,7 +654,12 @@ describe("LoopService", () => {
|
|||||||
registry: storage,
|
registry: storage,
|
||||||
logger,
|
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();
|
await service.initialize();
|
||||||
|
|
||||||
const loop = await service.runLoop({
|
const loop = await service.runLoop({
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ import type {
|
|||||||
AgentProvider,
|
AgentProvider,
|
||||||
} from "./agent/agent-sdk-types.js";
|
} from "./agent/agent-sdk-types.js";
|
||||||
import { execCommand, platformShell } from "../utils/spawn.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 LOOP_ID_LENGTH = 8;
|
||||||
const DEFAULT_LOOP_PROVIDER: AgentProvider = "claude";
|
const DEFAULT_LOOP_PROVIDER: AgentProvider = "claude";
|
||||||
@@ -207,6 +211,8 @@ function buildVerifierTitle(loop: LoopRecord, iterationIndex: number): string {
|
|||||||
return `${prefix} [loop ${iterationIndex} verifier]`;
|
return `${prefix} [loop ${iterationIndex} verifier]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CreateConfigResolver = Pick<ProviderSnapshotManager, "resolveCreateConfig">;
|
||||||
|
|
||||||
function formatStreamLog(event: AgentStreamEvent): string | null {
|
function formatStreamLog(event: AgentStreamEvent): string | null {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "timeline": {
|
case "timeline": {
|
||||||
@@ -305,6 +311,7 @@ export class LoopService {
|
|||||||
paseoHome: string;
|
paseoHome: string;
|
||||||
agentManager: AgentManager;
|
agentManager: AgentManager;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
|
providerSnapshotManager: CreateConfigResolver;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
this.storePath = path.join(options.paseoHome, "loops", "loops.json");
|
this.storePath = path.join(options.paseoHome, "loops", "loops.json");
|
||||||
@@ -614,7 +621,7 @@ export class LoopService {
|
|||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const agent = await this.options.agentManager.createAgent(
|
const agent = await this.options.agentManager.createAgent(
|
||||||
this.buildWorkerConfig(loop, iteration),
|
await this.buildWorkerConfig(loop, iteration),
|
||||||
);
|
);
|
||||||
iteration.workerAgentId = agent.id;
|
iteration.workerAgentId = agent.id;
|
||||||
loop.activeWorkerAgentId = agent.id;
|
loop.activeWorkerAgentId = agent.id;
|
||||||
@@ -721,7 +728,7 @@ export class LoopService {
|
|||||||
|
|
||||||
const startedAt = nowIso();
|
const startedAt = nowIso();
|
||||||
const verifierAgent = await this.options.agentManager.createAgent(
|
const verifierAgent = await this.options.agentManager.createAgent(
|
||||||
this.buildVerifierConfig(loop, iteration),
|
await this.buildVerifierConfig(loop, iteration),
|
||||||
);
|
);
|
||||||
iteration.verifierAgentId = verifierAgent.id;
|
iteration.verifierAgentId = verifierAgent.id;
|
||||||
loop.activeVerifierAgentId = 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<AgentSessionConfig> {
|
||||||
const provider = loop.workerProvider ?? loop.provider;
|
const provider = loop.workerProvider ?? loop.provider;
|
||||||
|
const resolvedUnattendedConfig = loop.modeId
|
||||||
|
? { modeId: loop.modeId, featureValues: undefined }
|
||||||
|
: await this.resolveProviderCreateConfig({ provider, cwd: loop.cwd });
|
||||||
return {
|
return {
|
||||||
provider,
|
provider,
|
||||||
cwd: loop.cwd,
|
cwd: loop.cwd,
|
||||||
model: loop.workerModel ?? loop.model ?? undefined,
|
model: loop.workerModel ?? loop.model ?? undefined,
|
||||||
modeId: loop.modeId ?? getUnattendedModeId(provider),
|
modeId: resolvedUnattendedConfig.modeId,
|
||||||
|
featureValues: resolvedUnattendedConfig.featureValues,
|
||||||
title: buildWorkerTitle(loop, iteration.index),
|
title: buildWorkerTitle(loop, iteration.index),
|
||||||
internal: true,
|
internal: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildVerifierConfig(
|
private async buildVerifierConfig(
|
||||||
loop: LoopRecord,
|
loop: LoopRecord,
|
||||||
iteration: LoopIterationRecord,
|
iteration: LoopIterationRecord,
|
||||||
): AgentSessionConfig {
|
): Promise<AgentSessionConfig> {
|
||||||
const provider = loop.verifierProvider ?? loop.provider;
|
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 {
|
return {
|
||||||
provider,
|
provider,
|
||||||
cwd: loop.cwd,
|
cwd: loop.cwd,
|
||||||
model: loop.verifierModel ?? loop.model ?? undefined,
|
model: loop.verifierModel ?? loop.model ?? undefined,
|
||||||
modeId: loop.verifierModeId ?? loop.modeId ?? getUnattendedModeId(provider),
|
modeId: resolvedUnattendedConfig.modeId,
|
||||||
|
featureValues: resolvedUnattendedConfig.featureValues,
|
||||||
title: buildVerifierTitle(loop, iteration.index),
|
title: buildVerifierTitle(loop, iteration.index),
|
||||||
internal: true,
|
internal: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveProviderCreateConfig(
|
||||||
|
input: Pick<ResolveProviderCreateConfigOptions, "provider" | "cwd">,
|
||||||
|
): Promise<ResolvedProviderCreateConfig> {
|
||||||
|
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 {
|
private resolveFinalText(timeline: AgentTimelineItem[], finalText: string): string {
|
||||||
if (finalText.trim()) {
|
if (finalText.trim()) {
|
||||||
return finalText;
|
return finalText;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import type {
|
|||||||
} from "../agent/agent-sdk-types.js";
|
} from "../agent/agent-sdk-types.js";
|
||||||
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
|
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
|
||||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||||
|
import type { ProviderSnapshotManager } from "../agent/provider-snapshot-manager.js";
|
||||||
import { ScheduleService } from "./service.js";
|
import { ScheduleService } from "./service.js";
|
||||||
import type { ScheduleExecutionResult, StoredSchedule } from "@getpaseo/protocol/schedule/types";
|
import type { ScheduleExecutionResult, StoredSchedule } from "@getpaseo/protocol/schedule/types";
|
||||||
|
|
||||||
@@ -38,6 +39,13 @@ const SCHEDULE_TEST_CAPABILITIES: AgentCapabilityFlags = {
|
|||||||
supportsToolInvocations: true,
|
supportsToolInvocations: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const NO_UNATTENDED_SCHEDULE_POLICY: Pick<ProviderSnapshotManager, "resolveCreateConfig"> = {
|
||||||
|
async resolveCreateConfig(input) {
|
||||||
|
expect(input).toMatchObject({ parent: null, unattended: true, requestedMode: undefined });
|
||||||
|
return { modeId: undefined, featureValues: input.featureValues };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
describe("ScheduleService", () => {
|
describe("ScheduleService", () => {
|
||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
let agentStorage: AgentStorage;
|
let agentStorage: AgentStorage;
|
||||||
@@ -64,6 +72,7 @@ describe("ScheduleService", () => {
|
|||||||
logger: createTestLogger(),
|
logger: createTestLogger(),
|
||||||
agentManager: new AgentManager({ logger: createTestLogger() }),
|
agentManager: new AgentManager({ logger: createTestLogger() }),
|
||||||
agentStorage,
|
agentStorage,
|
||||||
|
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||||
now: () => now,
|
now: () => now,
|
||||||
runner: async (schedule) => ({
|
runner: async (schedule) => ({
|
||||||
agentId: "00000000-0000-0000-0000-000000000001",
|
agentId: "00000000-0000-0000-0000-000000000001",
|
||||||
@@ -102,6 +111,7 @@ describe("ScheduleService", () => {
|
|||||||
logger: createTestLogger(),
|
logger: createTestLogger(),
|
||||||
agentManager: new AgentManager({ logger: createTestLogger() }),
|
agentManager: new AgentManager({ logger: createTestLogger() }),
|
||||||
agentStorage,
|
agentStorage,
|
||||||
|
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||||
now: () => now,
|
now: () => now,
|
||||||
runner: async () => ({
|
runner: async () => ({
|
||||||
agentId: null,
|
agentId: null,
|
||||||
@@ -137,6 +147,7 @@ describe("ScheduleService", () => {
|
|||||||
logger: createTestLogger(),
|
logger: createTestLogger(),
|
||||||
agentManager: new AgentManager({ logger: createTestLogger() }),
|
agentManager: new AgentManager({ logger: createTestLogger() }),
|
||||||
agentStorage,
|
agentStorage,
|
||||||
|
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||||
now: () => now,
|
now: () => now,
|
||||||
runner: async () => ({
|
runner: async () => ({
|
||||||
agentId: null,
|
agentId: null,
|
||||||
@@ -176,6 +187,7 @@ describe("ScheduleService", () => {
|
|||||||
logger: createTestLogger(),
|
logger: createTestLogger(),
|
||||||
agentManager: manager,
|
agentManager: manager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
|
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||||
now: () => now,
|
now: () => now,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -350,6 +362,7 @@ describe("ScheduleService", () => {
|
|||||||
logger: createTestLogger(),
|
logger: createTestLogger(),
|
||||||
agentManager: manager,
|
agentManager: manager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
|
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||||
now: () => now,
|
now: () => now,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -391,6 +404,12 @@ describe("ScheduleService", () => {
|
|||||||
logger: createTestLogger(),
|
logger: createTestLogger(),
|
||||||
agentManager: manager,
|
agentManager: manager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
|
providerSnapshotManager: {
|
||||||
|
async resolveCreateConfig(input) {
|
||||||
|
expect(input).toMatchObject({ parent: null, unattended: true, requestedMode: undefined });
|
||||||
|
return { modeId: "bypassPermissions", featureValues: input.featureValues };
|
||||||
|
},
|
||||||
|
},
|
||||||
now: () => now,
|
now: () => now,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -419,12 +438,77 @@ describe("ScheduleService", () => {
|
|||||||
expect(agent?.archivedAt).toBeTruthy();
|
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 () => {
|
test("advances stale nextRunAt on daemon restart", async () => {
|
||||||
const service1 = new ScheduleService({
|
const service1 = new ScheduleService({
|
||||||
paseoHome: tempDir,
|
paseoHome: tempDir,
|
||||||
logger: createTestLogger(),
|
logger: createTestLogger(),
|
||||||
agentManager: new AgentManager({ logger: createTestLogger() }),
|
agentManager: new AgentManager({ logger: createTestLogger() }),
|
||||||
agentStorage,
|
agentStorage,
|
||||||
|
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||||
now: () => now,
|
now: () => now,
|
||||||
runner: async () => ({ agentId: null, output: "ok" }),
|
runner: async () => ({ agentId: null, output: "ok" }),
|
||||||
});
|
});
|
||||||
@@ -449,6 +533,7 @@ describe("ScheduleService", () => {
|
|||||||
logger: createTestLogger(),
|
logger: createTestLogger(),
|
||||||
agentManager: new AgentManager({ logger: createTestLogger() }),
|
agentManager: new AgentManager({ logger: createTestLogger() }),
|
||||||
agentStorage,
|
agentStorage,
|
||||||
|
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||||
now: () => now,
|
now: () => now,
|
||||||
runner: async () => ({ agentId: null, output: "ok" }),
|
runner: async () => ({ agentId: null, output: "ok" }),
|
||||||
});
|
});
|
||||||
@@ -474,6 +559,7 @@ describe("ScheduleService", () => {
|
|||||||
logger: createTestLogger(),
|
logger: createTestLogger(),
|
||||||
agentManager: new AgentManager({ logger: createTestLogger() }),
|
agentManager: new AgentManager({ logger: createTestLogger() }),
|
||||||
agentStorage,
|
agentStorage,
|
||||||
|
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||||
now: () => now,
|
now: () => now,
|
||||||
runner: async () => {
|
runner: async () => {
|
||||||
releaseRun?.();
|
releaseRun?.();
|
||||||
@@ -522,6 +608,7 @@ describe("ScheduleService", () => {
|
|||||||
logger: createTestLogger(),
|
logger: createTestLogger(),
|
||||||
agentManager: manager,
|
agentManager: manager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
|
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||||
now: () => now,
|
now: () => now,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -579,6 +666,7 @@ describe("ScheduleService", () => {
|
|||||||
logger: createTestLogger(),
|
logger: createTestLogger(),
|
||||||
agentManager: new AgentManager({ logger: createTestLogger() }),
|
agentManager: new AgentManager({ logger: createTestLogger() }),
|
||||||
agentStorage,
|
agentStorage,
|
||||||
|
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||||
now: () => now,
|
now: () => now,
|
||||||
runner: async () => ({ agentId: null, output: "ok" }),
|
runner: async () => ({ agentId: null, output: "ok" }),
|
||||||
});
|
});
|
||||||
@@ -601,6 +689,7 @@ describe("ScheduleService", () => {
|
|||||||
logger: createTestLogger(),
|
logger: createTestLogger(),
|
||||||
agentManager: new AgentManager({ logger: createTestLogger() }),
|
agentManager: new AgentManager({ logger: createTestLogger() }),
|
||||||
agentStorage,
|
agentStorage,
|
||||||
|
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||||
now: () => now,
|
now: () => now,
|
||||||
runner: async () => ({ agentId: null, output: "ok" }),
|
runner: async () => ({ agentId: null, output: "ok" }),
|
||||||
});
|
});
|
||||||
@@ -624,6 +713,7 @@ describe("ScheduleService", () => {
|
|||||||
logger: createTestLogger(),
|
logger: createTestLogger(),
|
||||||
agentManager: new AgentManager({ logger: createTestLogger() }),
|
agentManager: new AgentManager({ logger: createTestLogger() }),
|
||||||
agentStorage,
|
agentStorage,
|
||||||
|
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||||
now: () => now,
|
now: () => now,
|
||||||
runner: async () => ({ agentId: null, output: "ok" }),
|
runner: async () => ({ agentId: null, output: "ok" }),
|
||||||
});
|
});
|
||||||
@@ -646,6 +736,7 @@ describe("ScheduleService", () => {
|
|||||||
logger: createTestLogger(),
|
logger: createTestLogger(),
|
||||||
agentManager: new AgentManager({ logger: createTestLogger() }),
|
agentManager: new AgentManager({ logger: createTestLogger() }),
|
||||||
agentStorage,
|
agentStorage,
|
||||||
|
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||||
now: () => now,
|
now: () => now,
|
||||||
runner: async () => ({ agentId: null, output: "ok" }),
|
runner: async () => ({ agentId: null, output: "ok" }),
|
||||||
});
|
});
|
||||||
@@ -669,6 +760,7 @@ describe("ScheduleService", () => {
|
|||||||
logger: createTestLogger(),
|
logger: createTestLogger(),
|
||||||
agentManager: new AgentManager({ logger: createTestLogger() }),
|
agentManager: new AgentManager({ logger: createTestLogger() }),
|
||||||
agentStorage,
|
agentStorage,
|
||||||
|
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||||
now: () => now,
|
now: () => now,
|
||||||
runner: async (schedule) => ({
|
runner: async (schedule) => ({
|
||||||
agentId: "00000000-0000-0000-0000-000000000099",
|
agentId: "00000000-0000-0000-0000-000000000099",
|
||||||
@@ -704,6 +796,7 @@ describe("ScheduleService", () => {
|
|||||||
logger: createTestLogger(),
|
logger: createTestLogger(),
|
||||||
agentManager: new AgentManager({ logger: createTestLogger() }),
|
agentManager: new AgentManager({ logger: createTestLogger() }),
|
||||||
agentStorage,
|
agentStorage,
|
||||||
|
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||||
now: () => now,
|
now: () => now,
|
||||||
runner: async () => ({ agentId: null, output: "ok" }),
|
runner: async () => ({ agentId: null, output: "ok" }),
|
||||||
});
|
});
|
||||||
@@ -756,6 +849,7 @@ describe("ScheduleService", () => {
|
|||||||
logger: createTestLogger(),
|
logger: createTestLogger(),
|
||||||
agentManager: new AgentManager({ logger: createTestLogger() }),
|
agentManager: new AgentManager({ logger: createTestLogger() }),
|
||||||
agentStorage,
|
agentStorage,
|
||||||
|
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||||
now: () => now,
|
now: () => now,
|
||||||
runner: async () => ({ agentId: null, output: "ok" }),
|
runner: async () => ({ agentId: null, output: "ok" }),
|
||||||
});
|
});
|
||||||
@@ -788,6 +882,7 @@ describe("ScheduleService", () => {
|
|||||||
logger: createTestLogger(),
|
logger: createTestLogger(),
|
||||||
agentManager: new AgentManager({ logger: createTestLogger() }),
|
agentManager: new AgentManager({ logger: createTestLogger() }),
|
||||||
agentStorage,
|
agentStorage,
|
||||||
|
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||||
now: () => now,
|
now: () => now,
|
||||||
runner: async () => ({ agentId: null, output: "ran" }),
|
runner: async () => ({ agentId: null, output: "ran" }),
|
||||||
});
|
});
|
||||||
@@ -819,6 +914,7 @@ describe("ScheduleService", () => {
|
|||||||
logger: createTestLogger(),
|
logger: createTestLogger(),
|
||||||
agentManager: new AgentManager({ logger: createTestLogger() }),
|
agentManager: new AgentManager({ logger: createTestLogger() }),
|
||||||
agentStorage,
|
agentStorage,
|
||||||
|
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||||
now: () => now,
|
now: () => now,
|
||||||
runner: async () => ({ agentId: null, output: "ok" }),
|
runner: async () => ({ agentId: null, output: "ok" }),
|
||||||
});
|
});
|
||||||
@@ -844,6 +940,7 @@ describe("ScheduleService", () => {
|
|||||||
logger: createTestLogger(),
|
logger: createTestLogger(),
|
||||||
agentManager: new AgentManager({ logger: createTestLogger() }),
|
agentManager: new AgentManager({ logger: createTestLogger() }),
|
||||||
agentStorage,
|
agentStorage,
|
||||||
|
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||||
now: () => now,
|
now: () => now,
|
||||||
runner: async () => ({ agentId: null, output: "ok" }),
|
runner: async () => ({ agentId: null, output: "ok" }),
|
||||||
});
|
});
|
||||||
@@ -868,6 +965,7 @@ describe("ScheduleService", () => {
|
|||||||
logger: createTestLogger(),
|
logger: createTestLogger(),
|
||||||
agentManager: new AgentManager({ logger: createTestLogger() }),
|
agentManager: new AgentManager({ logger: createTestLogger() }),
|
||||||
agentStorage,
|
agentStorage,
|
||||||
|
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||||
now: () => now,
|
now: () => now,
|
||||||
runner: async () => ({ agentId: null, output: "ok" }),
|
runner: async () => ({ agentId: null, output: "ok" }),
|
||||||
});
|
});
|
||||||
@@ -912,6 +1010,7 @@ describe("ScheduleService", () => {
|
|||||||
logger: createTestLogger(),
|
logger: createTestLogger(),
|
||||||
agentManager: new AgentManager({ logger: createTestLogger() }),
|
agentManager: new AgentManager({ logger: createTestLogger() }),
|
||||||
agentStorage,
|
agentStorage,
|
||||||
|
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||||
now: () => now,
|
now: () => now,
|
||||||
runner: async () => ({ agentId: null, output: "ok" }),
|
runner: async () => ({ agentId: null, output: "ok" }),
|
||||||
});
|
});
|
||||||
@@ -942,6 +1041,7 @@ describe("ScheduleService", () => {
|
|||||||
logger: createTestLogger(),
|
logger: createTestLogger(),
|
||||||
agentManager: new AgentManager({ logger: createTestLogger() }),
|
agentManager: new AgentManager({ logger: createTestLogger() }),
|
||||||
agentStorage,
|
agentStorage,
|
||||||
|
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||||
now: () => now,
|
now: () => now,
|
||||||
runner: async () => ({ agentId: null, output: "ok" }),
|
runner: async () => ({ agentId: null, output: "ok" }),
|
||||||
});
|
});
|
||||||
@@ -967,6 +1067,7 @@ describe("ScheduleService", () => {
|
|||||||
logger: createTestLogger(),
|
logger: createTestLogger(),
|
||||||
agentManager: new AgentManager({ logger: createTestLogger() }),
|
agentManager: new AgentManager({ logger: createTestLogger() }),
|
||||||
agentStorage,
|
agentStorage,
|
||||||
|
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||||
now: () => now,
|
now: () => now,
|
||||||
runner: async () => ({ agentId: null, output: "ok" }),
|
runner: async () => ({ agentId: null, output: "ok" }),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,9 +7,13 @@ import type { AgentSessionConfig } from "../agent/agent-sdk-types.js";
|
|||||||
import { curateAgentActivity } from "../agent/activity-curator.js";
|
import { curateAgentActivity } from "../agent/activity-curator.js";
|
||||||
import { ensureAgentLoaded } from "../agent/agent-loading.js";
|
import { ensureAgentLoaded } from "../agent/agent-loading.js";
|
||||||
import { formatSystemNotificationPrompt } from "../agent/agent-prompt.js";
|
import { formatSystemNotificationPrompt } from "../agent/agent-prompt.js";
|
||||||
import { getUnattendedModeId } from "@getpaseo/protocol/provider-manifest";
|
|
||||||
import { ScheduleStore } from "./store.js";
|
import { ScheduleStore } from "./store.js";
|
||||||
import { computeNextRunAt, validateScheduleCadence } from "./cron.js";
|
import { computeNextRunAt, validateScheduleCadence } from "./cron.js";
|
||||||
|
import type {
|
||||||
|
ProviderSnapshotManager,
|
||||||
|
ResolvedProviderCreateConfig,
|
||||||
|
ResolveProviderCreateConfigOptions,
|
||||||
|
} from "../agent/provider-snapshot-manager.js";
|
||||||
import type {
|
import type {
|
||||||
CreateScheduleInput,
|
CreateScheduleInput,
|
||||||
ScheduleExecutionResult,
|
ScheduleExecutionResult,
|
||||||
@@ -134,11 +138,14 @@ function buildRunOutput(params: {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CreateConfigResolver = Pick<ProviderSnapshotManager, "resolveCreateConfig">;
|
||||||
|
|
||||||
export interface ScheduleServiceOptions {
|
export interface ScheduleServiceOptions {
|
||||||
paseoHome: string;
|
paseoHome: string;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
agentManager: AgentManager;
|
agentManager: AgentManager;
|
||||||
agentStorage: AgentStorage;
|
agentStorage: AgentStorage;
|
||||||
|
providerSnapshotManager: CreateConfigResolver;
|
||||||
now?: () => Date;
|
now?: () => Date;
|
||||||
runner?: (schedule: StoredSchedule, runId: string) => Promise<ScheduleExecutionResult>;
|
runner?: (schedule: StoredSchedule, runId: string) => Promise<ScheduleExecutionResult>;
|
||||||
}
|
}
|
||||||
@@ -148,6 +155,7 @@ export class ScheduleService {
|
|||||||
private readonly logger: Logger;
|
private readonly logger: Logger;
|
||||||
private readonly agentManager: AgentManager;
|
private readonly agentManager: AgentManager;
|
||||||
private readonly agentStorage: AgentStorage;
|
private readonly agentStorage: AgentStorage;
|
||||||
|
private readonly createConfigResolver: CreateConfigResolver;
|
||||||
private readonly now: () => Date;
|
private readonly now: () => Date;
|
||||||
private readonly runner: (
|
private readonly runner: (
|
||||||
schedule: StoredSchedule,
|
schedule: StoredSchedule,
|
||||||
@@ -161,6 +169,7 @@ export class ScheduleService {
|
|||||||
this.logger = options.logger.child({ module: "schedule-service" });
|
this.logger = options.logger.child({ module: "schedule-service" });
|
||||||
this.agentManager = options.agentManager;
|
this.agentManager = options.agentManager;
|
||||||
this.agentStorage = options.agentStorage;
|
this.agentStorage = options.agentStorage;
|
||||||
|
this.createConfigResolver = options.providerSnapshotManager;
|
||||||
this.now = options.now ?? (() => new Date());
|
this.now = options.now ?? (() => new Date());
|
||||||
this.runner = options.runner ?? ((schedule, runId) => this.executeSchedule(schedule, runId));
|
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 = {
|
const config: AgentSessionConfig = {
|
||||||
provider: schedule.target.config.provider,
|
provider: targetConfig.provider,
|
||||||
cwd: schedule.target.config.cwd,
|
cwd: targetConfig.cwd,
|
||||||
modeId: schedule.target.config.modeId ?? getUnattendedModeId(schedule.target.config.provider),
|
modeId: resolvedUnattendedConfig.modeId,
|
||||||
model: schedule.target.config.model,
|
model: targetConfig.model,
|
||||||
thinkingOptionId: schedule.target.config.thinkingOptionId,
|
thinkingOptionId: targetConfig.thinkingOptionId,
|
||||||
title: schedule.target.config.title,
|
title: targetConfig.title,
|
||||||
approvalPolicy: schedule.target.config.approvalPolicy,
|
approvalPolicy: targetConfig.approvalPolicy,
|
||||||
sandboxMode: schedule.target.config.sandboxMode,
|
sandboxMode: targetConfig.sandboxMode,
|
||||||
networkAccess: schedule.target.config.networkAccess,
|
networkAccess: targetConfig.networkAccess,
|
||||||
webSearch: schedule.target.config.webSearch,
|
webSearch: targetConfig.webSearch,
|
||||||
featureValues: schedule.target.config.featureValues,
|
featureValues: resolvedUnattendedConfig.featureValues,
|
||||||
extra: schedule.target.config.extra,
|
extra: targetConfig.extra,
|
||||||
systemPrompt: schedule.target.config.systemPrompt,
|
systemPrompt: targetConfig.systemPrompt,
|
||||||
mcpServers: schedule.target.config.mcpServers as AgentSessionConfig["mcpServers"],
|
mcpServers: targetConfig.mcpServers as AgentSessionConfig["mcpServers"],
|
||||||
};
|
};
|
||||||
const labels = {
|
const labels = {
|
||||||
"paseo.schedule-id": schedule.id,
|
"paseo.schedule-id": schedule.id,
|
||||||
@@ -594,4 +614,10 @@ export class ScheduleService {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolveProviderCreateConfig(
|
||||||
|
input: ResolveProviderCreateConfigOptions,
|
||||||
|
): Promise<ResolvedProviderCreateConfig> {
|
||||||
|
return this.createConfigResolver.resolveCreateConfig(input);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,6 @@ function createCheckoutFacts(
|
|||||||
comparisonBaseRef: null,
|
comparisonBaseRef: null,
|
||||||
branchRemoteName: null,
|
branchRemoteName: null,
|
||||||
branchMergeRef: null,
|
branchMergeRef: null,
|
||||||
trackedOriginBranch: null,
|
|
||||||
pullRequestLookupTarget: { headRef: "main" },
|
pullRequestLookupTarget: { headRef: "main" },
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
@@ -890,7 +889,6 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
currentBranch: "fork-owner/open-button-targets-active-file",
|
currentBranch: "fork-owner/open-button-targets-active-file",
|
||||||
branchRemoteName: "paseo-pr-1285",
|
branchRemoteName: "paseo-pr-1285",
|
||||||
branchMergeRef: "refs/heads/open-button-targets-active-file",
|
branchMergeRef: "refs/heads/open-button-targets-active-file",
|
||||||
trackedOriginBranch: "paseo-pr-1285/open-button-targets-active-file",
|
|
||||||
pullRequestLookupTarget: {
|
pullRequestLookupTarget: {
|
||||||
headRef: "open-button-targets-active-file",
|
headRef: "open-button-targets-active-file",
|
||||||
headRepositoryOwner: "fork-owner",
|
headRepositoryOwner: "fork-owner",
|
||||||
|
|||||||
@@ -121,7 +121,6 @@ function createCheckoutSnapshotFacts(cwd: string): CheckoutSnapshotFacts {
|
|||||||
comparisonBaseRef: null,
|
comparisonBaseRef: null,
|
||||||
branchRemoteName: "origin",
|
branchRemoteName: "origin",
|
||||||
branchMergeRef: "refs/heads/main",
|
branchMergeRef: "refs/heads/main",
|
||||||
trackedOriginBranch: "main",
|
|
||||||
pullRequestLookupTarget: { headRef: "main" },
|
pullRequestLookupTarget: { headRef: "main" },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -522,6 +522,92 @@ const x = 1;
|
|||||||
expect(divergedStatus.behindOfOrigin).toBe(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 () => {
|
it("does not report the full branch history as ahead when the current branch remote is gone", async () => {
|
||||||
setupRemoteTrackingMain(repoDir, tempDir);
|
setupRemoteTrackingMain(repoDir, tempDir);
|
||||||
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
|
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
|
||||||
@@ -557,11 +643,11 @@ const x = 1;
|
|||||||
isPaseoOwnedWorktree: true,
|
isPaseoOwnedWorktree: true,
|
||||||
baseRef: "main",
|
baseRef: "main",
|
||||||
aheadBehind: { ahead: 0, behind: 0 },
|
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);
|
setupRemoteTrackingMain(repoDir, tempDir);
|
||||||
commitFile(repoDir, "second.txt", "second\n", "second commit");
|
commitFile(repoDir, "second.txt", "second\n", "second commit");
|
||||||
execFileSync("git", ["push"], { cwd: repoDir });
|
execFileSync("git", ["push"], { cwd: repoDir });
|
||||||
@@ -581,7 +667,7 @@ const x = 1;
|
|||||||
isPaseoOwnedWorktree: true,
|
isPaseoOwnedWorktree: true,
|
||||||
baseRef: "main",
|
baseRef: "main",
|
||||||
aheadBehind: { ahead: 1, behind: 0 },
|
aheadBehind: { ahead: 1, behind: 0 },
|
||||||
aheadOfOrigin: 1,
|
aheadOfOrigin: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -802,7 +802,6 @@ export type CheckoutSnapshotFacts =
|
|||||||
comparisonBaseRef: string | null;
|
comparisonBaseRef: string | null;
|
||||||
branchRemoteName: string | null;
|
branchRemoteName: string | null;
|
||||||
branchMergeRef: string | null;
|
branchMergeRef: string | null;
|
||||||
trackedOriginBranch: string | null;
|
|
||||||
pullRequestLookupTarget: PullRequestStatusLookupTarget | null;
|
pullRequestLookupTarget: PullRequestStatusLookupTarget | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1407,57 +1406,46 @@ async function getAheadBehind(
|
|||||||
async function getAheadOfOrigin(
|
async function getAheadOfOrigin(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
currentBranch: string,
|
currentBranch: string,
|
||||||
baseRef: string | null,
|
|
||||||
context?: CheckoutContext,
|
context?: CheckoutContext,
|
||||||
): Promise<number | null> {
|
): Promise<number | null> {
|
||||||
if (!currentBranch) {
|
if (!currentBranch) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const trackedOriginBranch = await getTrackedOriginBranch(cwd, currentBranch, context);
|
const upstreamRef = await getConfiguredUpstreamRef(cwd, currentBranch, context);
|
||||||
const originBranch = trackedOriginBranch ?? currentBranch;
|
if (!upstreamRef) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const { stdout } = await runGitCommand(
|
const { stdout } = await runGitCommand(
|
||||||
["rev-list", "--count", `origin/${originBranch}..${currentBranch}`],
|
["rev-list", "--count", `${upstreamRef}..${currentBranch}`],
|
||||||
{ cwd, envOverlay: READ_ONLY_GIT_ENV, logger: context?.logger },
|
{ cwd, envOverlay: READ_ONLY_GIT_ENV, logger: context?.logger },
|
||||||
);
|
);
|
||||||
const count = Number.parseInt(stdout.trim(), 10);
|
const count = Number.parseInt(stdout.trim(), 10);
|
||||||
return Number.isNaN(count) ? null : count;
|
return Number.isNaN(count) ? null : count;
|
||||||
} catch {
|
} catch {
|
||||||
if (trackedOriginBranch) {
|
return null;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getTrackedOriginBranch(
|
async function getConfiguredUpstreamRef(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
currentBranch: string,
|
currentBranch: string,
|
||||||
context?: CheckoutContext,
|
context?: CheckoutContext,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
if (context?.facts?.isGit && context.facts.currentBranch === currentBranch) {
|
const remoteName =
|
||||||
return context.facts.trackedOriginBranch;
|
context?.facts?.isGit && context.facts.currentBranch === currentBranch
|
||||||
}
|
? context.facts.branchRemoteName
|
||||||
const remoteName = await getGitConfigValue(cwd, `branch.${currentBranch}.remote`, context);
|
: await getGitConfigValue(cwd, `branch.${currentBranch}.remote`, context);
|
||||||
if (remoteName !== "origin") {
|
if (!remoteName) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mergeRef = await getGitConfigValue(cwd, `branch.${currentBranch}.merge`, context);
|
const mergeRef =
|
||||||
return parseBranchMergeHeadRef(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(
|
async function getBehindOfOrigin(
|
||||||
@@ -1468,9 +1456,13 @@ async function getBehindOfOrigin(
|
|||||||
if (!currentBranch) {
|
if (!currentBranch) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const upstreamRef = await getConfiguredUpstreamRef(cwd, currentBranch, context);
|
||||||
|
if (!upstreamRef) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const { stdout } = await runGitCommand(
|
const { stdout } = await runGitCommand(
|
||||||
["rev-list", "--count", `${currentBranch}..origin/${currentBranch}`],
|
["rev-list", "--count", `${currentBranch}..${upstreamRef}`],
|
||||||
{ cwd, envOverlay: READ_ONLY_GIT_ENV, logger: context?.logger },
|
{ cwd, envOverlay: READ_ONLY_GIT_ENV, logger: context?.logger },
|
||||||
);
|
);
|
||||||
const count = Number.parseInt(stdout.trim(), 10);
|
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
|
const pullRequestLookupTarget = inspected.currentBranch
|
||||||
? buildPullRequestLookupTargetFromBranchConfig({
|
? buildPullRequestLookupTargetFromBranchConfig({
|
||||||
currentBranch: inspected.currentBranch,
|
currentBranch: inspected.currentBranch,
|
||||||
@@ -1627,7 +1617,6 @@ export async function getCheckoutSnapshotFacts(
|
|||||||
comparisonBaseRef,
|
comparisonBaseRef,
|
||||||
branchRemoteName,
|
branchRemoteName,
|
||||||
branchMergeRef,
|
branchMergeRef,
|
||||||
trackedOriginBranch,
|
|
||||||
pullRequestLookupTarget,
|
pullRequestLookupTarget,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1765,7 +1754,7 @@ export async function getCheckoutStatus(
|
|||||||
? getAheadBehind(cwd, baseRef, currentBranch, factsContext)
|
? getAheadBehind(cwd, baseRef, currentBranch, factsContext)
|
||||||
: Promise.resolve(null),
|
: Promise.resolve(null),
|
||||||
hasRemote && currentBranch
|
hasRemote && currentBranch
|
||||||
? getAheadOfOrigin(cwd, currentBranch, baseRef, factsContext)
|
? getAheadOfOrigin(cwd, currentBranch, factsContext)
|
||||||
: Promise.resolve(null),
|
: Promise.resolve(null),
|
||||||
hasRemote && currentBranch
|
hasRemote && currentBranch
|
||||||
? getBehindOfOrigin(cwd, currentBranch, factsContext)
|
? getBehindOfOrigin(cwd, currentBranch, factsContext)
|
||||||
|
|||||||
Reference in New Issue
Block a user