mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
Default new Pi agents to Paperclip Runner
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
0f15f82b19
commit
5f1a56ea31
30 files changed
+303
-93
No files matched your search
+17
-8
@@ -1933,22 +1933,22 @@ agent conversion are outside this playbook.
|
||||
|
||||
## Paperclip Runner Adapter Conversion
|
||||
|
||||
Paperclip Runner offers native Codex, OpenCode, and **ACPX Claude**. New
|
||||
Paperclip Runner offers native Codex, OpenCode, **ACPX Claude**, and **ACPX Pi**. New
|
||||
Claude Code agents default to ACPX Claude on Apple Silicon, Intel Mac, Linux x64
|
||||
and Linux ARM64 with supported glibc. New OpenCode agents default to native
|
||||
OpenCode on Apple Silicon, Intel Mac, and Linux x64 with supported glibc;
|
||||
Linux ARM64 remains Legacy until the pinned OpenCode runtime supports it.
|
||||
and Linux ARM64 with supported glibc. New OpenCode and Pi agents default to their native
|
||||
runtimes on Apple Silicon, Intel Mac, and Linux x64 with supported glibc;
|
||||
Linux ARM64 remains Legacy until the pinned runtimes support it.
|
||||
Unsupported or unqualified targets retain Legacy. Claude over Darwin SSH keeps
|
||||
an automatic Legacy default; explicit native execution
|
||||
requires matching remote assets. The runner override is in Advanced. Ordinary Claude models default to `claude-opus-5-5`; explicit models
|
||||
and provider routing remain authoritative. Existing agents keep their saved runner. Converting an existing Claude, Codex, or OpenCode agent selects its
|
||||
and provider routing remain authoritative. Existing agents keep their saved runner. Converting an existing Claude, Codex, OpenCode, or Pi agent selects its
|
||||
corresponding provider, preserves compatible models, credentials, workspace,
|
||||
and instructions, and resets execution sessions while retaining run history.
|
||||
Other adapters require an explicit provider choice. Legacy ACPX Codex agent
|
||||
settings normalize to native Codex on configuration updates and before fresh
|
||||
runs; immutable run descriptors remain readable. The **Paperclip Runner**
|
||||
experimental setting applies to other native providers. Native Codex, OpenCode,
|
||||
and ACPX Claude do not require it. Company access checks still apply.
|
||||
ACPX Claude, and ACPX Pi do not require it. Company access checks still apply.
|
||||
|
||||
OpenCode requires an explicit `provider/model` before testing or finishing setup.
|
||||
An inactive setup draft can be saved before choosing a model. Automatic native
|
||||
@@ -1956,8 +1956,17 @@ selection uses `allow` permissions and a `per_turn` lifecycle. Native `ask` and
|
||||
`deny` policies remain available in agent configuration. Legacy custom commands,
|
||||
extra arguments, model variants, and inherited permission rules require the
|
||||
Legacy runner; incompatible settings return remediation instead of being ignored.
|
||||
Saved OpenCode agents keep their runner and runtime policies. Pi defaults are
|
||||
unchanged.
|
||||
Saved OpenCode agents keep their runner and runtime policies.
|
||||
|
||||
Pi also requires an explicit `provider/model` before testing or hiring; inactive
|
||||
setup drafts may precede model selection. Install its pinned runtime on the
|
||||
execution host with `paperclipai runtime setup pi` before testing the connection.
|
||||
The native default uses `approve-all` permissions, `per_turn` lifecycle, and
|
||||
`low` thinking. Exact native thinking levels are `off`, `low`, `high`, and `max`.
|
||||
Legacy `minimal`, `medium`, and `xhigh` thinking, custom commands/arguments, and
|
||||
raw stdout mode require Legacy. Switching back to Legacy rejects native `max`
|
||||
thinking, nondefault permission policy, or warm lifecycle instead of discarding
|
||||
them. Existing Pi agents retain their saved runner and native policies.
|
||||
|
||||
Agent configuration uses the same section layout across adapters: model and
|
||||
provider belong to **Adapter**, environment variables have their own section,
|
||||
|
||||
@@ -70,7 +70,13 @@ flag on macOS and GNU/Linux x64; Linux ARM64 retains Legacy. An explicit
|
||||
inactive setup drafts may omit it. Saved runners and permission/lifecycle
|
||||
policies remain authoritative. Actor/company checks and board-only discovery
|
||||
remain unchanged. This is scoped source/control evidence for OpenCode selection,
|
||||
not live provider execution or agent authorization proof; Pi is unchanged.
|
||||
not live provider execution or agent authorization proof.
|
||||
Pi follows the same supported platform/default and model-free inactive-draft
|
||||
boundary, with explicit pinned-runtime setup required on the execution host.
|
||||
Native ACPX Pi is admitted with the generic flag off. Existing direct Pi agents
|
||||
remain Legacy; saved native policies remain authoritative. Incompatible thinking,
|
||||
launch, permission, or lifecycle settings return remediation on conversion.
|
||||
This adds no agent permissions, cross-company access, or runtime installation API.
|
||||
An absent forced-Kubernetes environment is an unqualified sandbox target, never
|
||||
a local target. Discovery and automatic creation keep the Legacy path without
|
||||
provisioning it; explicit Paperclip selection returns qualification guidance.
|
||||
|
||||
@@ -73,7 +73,14 @@ unrelated edit. Check that saved native permission/lifecycle choices survive
|
||||
resuming setup. Custom commands, arguments, variants, and inherited permissions
|
||||
must give Legacy remediation when native is selected. A live task with the
|
||||
selected account is separate proof from these setup and persistence controls.
|
||||
Pi retains its existing default and is outside this rollout.
|
||||
Repeat with Pi on macOS or GNU/Linux x64 after explicit operator setup of the
|
||||
pinned runtime (`paperclipai runtime setup pi`). Linux ARM64 retains Legacy.
|
||||
Resume an inactive model-free draft, connect an account, choose a `provider/model`,
|
||||
and finish setup with the generic native flag off. Verify Pi branding and native
|
||||
ACPX Pi execution by completing a simple assigned task. Preserve saved native
|
||||
thinking/permission/lifecycle settings on resume and unrelated edits. Native
|
||||
thinking supports `off`, `low`, `high`, `max`; legacy aliases must give remediation,
|
||||
and returning to Legacy must reject settings it cannot honor.
|
||||
On a fresh forced-Kubernetes instance without an environment row, inspect discovery
|
||||
and create with automatic selection. Both must retain Legacy without provisioning;
|
||||
an explicit Paperclip request must give target-qualification guidance. Begin the
|
||||
|
||||
@@ -43,10 +43,16 @@ describe("Paperclip Runner permission defaults", () => {
|
||||
["claude_local", "acpxPermissionMode", "approve-all"],
|
||||
["codex_local", "codexPermissionMode", "never"],
|
||||
["opencode_local", "opencodePermissionMode", "allow"],
|
||||
["pi_local", "acpxPermissionMode", "approve-all"],
|
||||
])("uses full auto when converting %s to the new runner", (adapter, key, value) => {
|
||||
expect(paperclipRunnerTransitionConfig(adapter, undefined)).toMatchObject({ [key]: value });
|
||||
});
|
||||
|
||||
it("keeps Pi models explicit during conversion", () => {
|
||||
expect(paperclipRunnerTransitionConfig("pi_local", undefined)).toMatchObject({ provider: "acpx", acpxAgent: "pi", model: "" });
|
||||
expect(paperclipRunnerTransitionConfig("pi_local", "openrouter/provider/model")).toMatchObject({ provider: "acpx", acpxAgent: "pi", model: "openrouter/provider/model" });
|
||||
});
|
||||
|
||||
it("recognizes only exact provider identifiers", () => {
|
||||
expect(isPaperclipRunnerProvider("codex")).toBe(true);
|
||||
expect(isPaperclipRunnerProvider("opencode")).toBe(true);
|
||||
|
||||
@@ -205,7 +205,7 @@ export function paperclipRunnerTransitionConfig(
|
||||
providerOverride?: unknown,
|
||||
): Record<string, unknown> {
|
||||
const previousProvider =
|
||||
previousAdapterType === "claude_local"
|
||||
(previousAdapterType === "claude_local" || previousAdapterType === "pi_local")
|
||||
? "acpx"
|
||||
: previousAdapterType === "opencode_local"
|
||||
? "opencode"
|
||||
@@ -218,11 +218,13 @@ export function paperclipRunnerTransitionConfig(
|
||||
: previousProvider;
|
||||
return {
|
||||
provider,
|
||||
model: resolvePaperclipRunnerModel(
|
||||
model: provider === "acpx" && previousAdapterType === "pi_local"
|
||||
? typeof previousModel === "string" ? previousModel.trim() : ""
|
||||
: resolvePaperclipRunnerModel(
|
||||
provider,
|
||||
provider === previousProvider ? previousModel : undefined,
|
||||
),
|
||||
...(provider === "acpx" ? { acpxAgent: "claude" } : {}),
|
||||
...(provider === "acpx" ? { acpxAgent: previousAdapterType === "pi_local" ? "pi" : "claude" } : {}),
|
||||
[PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES[provider].configKey]:
|
||||
PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES[provider].defaultMode,
|
||||
lifecycleMode: "per_turn",
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
isPaperclipExternalChatQuestionResponseTurn,
|
||||
isPaperclipExternalChatTurn,
|
||||
materializePaperclipSkillCopy,
|
||||
normalizePaperclipRunnerAdapterConfig,
|
||||
PAPERCLIP_OPERATIONAL_SKILL_KEY,
|
||||
PAPERCLIP_FEEDBACK_SKILL_KEYS,
|
||||
refreshPaperclipWorkspaceEnvForExecution,
|
||||
@@ -43,6 +44,12 @@ import {
|
||||
WATCHDOG_DEFAULT_MANDATE,
|
||||
} from "./server-utils.js";
|
||||
|
||||
it("keeps Pi draft models empty and preserves saved native Pi settings", () => {
|
||||
expect(normalizePaperclipRunnerAdapterConfig("paperclip_runner", { provider: "acpx", acpxAgent: "pi", model: "" })).toMatchObject({ provider: "acpx", acpxAgent: "pi", model: "" });
|
||||
const saved = { provider: "acpx", acpxAgent: "pi", model: "openrouter/provider/model", piThinkingLevel: "max", acpxPermissionMode: "approve-reads", lifecycleMode: "warm" };
|
||||
expect(normalizePaperclipRunnerAdapterConfig("paperclip_runner", saved)).toMatchObject(saved);
|
||||
});
|
||||
|
||||
it("reserves identity credentials even for mixed-case inherited or configured keys", () => {
|
||||
const keys = ["PAPERCLIP_AGENT_KEY_ID", "Paperclip_Agent_Public_Key", "paperclip_agent_private_key"];
|
||||
const inherited = Object.fromEntries(keys.map(key => [key, "host-override"]));
|
||||
|
||||
@@ -4299,7 +4299,7 @@ export function normalizePaperclipRunnerAdapterConfig(
|
||||
}
|
||||
if (next.provider === "acpx") {
|
||||
next.acpxAgent ??= "claude";
|
||||
next.model = resolvePaperclipRunnerModel("acpx", config.model);
|
||||
if (next.acpxAgent !== "pi") next.model = resolvePaperclipRunnerModel("acpx", config.model);
|
||||
}
|
||||
return normalizePaperclipOperationalSkillPreference(adapterType, next);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,15 @@ export function buildPiLocalConfig(v: CreateConfigValues): Record<string, unknow
|
||||
if (v.cwd) ac.cwd = v.cwd;
|
||||
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
||||
if (v.model) ac.model = v.model;
|
||||
if (v.thinkingEffort) ac.thinking = v.thinkingEffort;
|
||||
const nativeSelected = v.runner === "paperclip"
|
||||
|| ((v.runner === undefined || v.runner === "auto") && v.resolvedRunner === "paperclip");
|
||||
if (nativeSelected) {
|
||||
const thinking = v.adapterSchemaValues?.piThinkingLevel ?? v.thinkingEffort;
|
||||
if (thinking) ac.piThinkingLevel = thinking;
|
||||
if (v.adapterSchemaValues?.acpxPermissionMode !== undefined) ac.acpxPermissionMode = v.adapterSchemaValues.acpxPermissionMode;
|
||||
if (v.paperclipRunnerLifecycleMode !== undefined) ac.lifecycleMode = v.paperclipRunnerLifecycleMode;
|
||||
if (v.paperclipRunnerIdleTimeoutMs !== undefined) ac.idleTimeoutMs = v.paperclipRunnerIdleTimeoutMs;
|
||||
} else if (v.thinkingEffort) ac.thinking = v.thinkingEffort;
|
||||
|
||||
// Pi sessions can run until the CLI exits naturally; keep timeout disabled (0)
|
||||
ac.timeoutSec = 0;
|
||||
|
||||
@@ -6,9 +6,10 @@ export interface AgentRunnerAvailability {
|
||||
defaultRunner: AgentRunner;
|
||||
}
|
||||
|
||||
export function paperclipRunnerProfileForHarness(harness: string): { provider: "codex" | "opencode" } | { provider: "acpx"; acpxAgent: "claude" } | undefined {
|
||||
export function paperclipRunnerProfileForHarness(harness: string): { provider: "codex" | "opencode" } | { provider: "acpx"; acpxAgent: "claude" | "pi" } | undefined {
|
||||
if (harness === "codex_local") return { provider: "codex" };
|
||||
if (harness === "claude_local") return { provider: "acpx", acpxAgent: "claude" };
|
||||
if (harness === "pi_local") return { provider: "acpx", acpxAgent: "pi" };
|
||||
if (harness === "opencode_local") return { provider: "opencode" };
|
||||
return undefined;
|
||||
}
|
||||
@@ -28,7 +29,7 @@ function supportsLinuxLibc(libc?: "glibc" | "musl" | "unknown", version?: string
|
||||
|
||||
export function paperclipRunnerSupportsPlatform(harness: string, platform: string, architecture: string, libc?: "glibc" | "musl" | "unknown", glibcVersion?: string): boolean {
|
||||
return !!paperclipRunnerProfileForHarness(harness)
|
||||
&& !(harness === "opencode_local" && platform === "linux" && architecture === "arm64")
|
||||
&& !(["opencode_local", "pi_local"].includes(harness) && platform === "linux" && architecture === "arm64")
|
||||
&& ((platform === "linux" && supportsLinuxLibc(libc, glibcVersion) && (architecture === "x64" || architecture === "arm64"))
|
||||
|| (platform === "darwin" && (architecture === "arm64" || architecture === "x64")));
|
||||
}
|
||||
|
||||
@@ -722,7 +722,7 @@ describe("agent routes adapter validation", () => {
|
||||
const app = await createApp();
|
||||
const res = await requestApp(app, baseUrl => request(baseUrl)
|
||||
.post("/api/companies/company-1/adapters/paperclip_runner/test-environment")
|
||||
.send({ agentId: "11111111-1111-4111-8111-111111111111", adapterConfig: { provider: "acpx", acpxAgent: "pi" } }));
|
||||
.send({ agentId: "11111111-1111-4111-8111-111111111111", adapterConfig: { provider: "acpx", acpxAgent: "grok" } }));
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(422);
|
||||
expect(res.body.details).toMatchObject({ code: "paperclip_runner_rollout_disabled" });
|
||||
expect(probe).not.toHaveBeenCalled();
|
||||
@@ -793,9 +793,40 @@ describe("agent routes adapter validation", () => {
|
||||
expect(mockAgentService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["auto", "legacy"] as const)("creates Pi through the public route with %s selection and the experimental flag off", async runner => {
|
||||
const selection = await import("../services/agent-runner-selection.js");
|
||||
vi.spyOn(selection, "resolveNewAgentRunnerForCompany").mockImplementation(async (_db, _companyId, input) =>
|
||||
selection.resolveNewAgentRunner({ ...input, target: { driver: "local", platform: "darwin", architecture: "arm64" } }));
|
||||
const app = await createApp();
|
||||
const model = "openrouter/anthropic/claude-sonnet-4.6";
|
||||
const response = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents").send({ name: "Pi", adapterType: "pi_local", runner, adapterConfig: { model } }));
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(201);
|
||||
expect(mockAgentService.create.mock.calls.at(-1)?.[1]).toMatchObject({ adapterType: runner === "legacy" ? "pi_local" : "paperclip_runner", adapterConfig: runner === "legacy" ? { model } : { provider: "acpx", acpxAgent: "pi", model, piThinkingLevel: "low", acpxPermissionMode: "approve-all" } });
|
||||
});
|
||||
|
||||
it("allows model-free Pi only as an inactive setup draft", async () => {
|
||||
const selection = await import("../services/agent-runner-selection.js");
|
||||
vi.spyOn(selection, "resolveNewAgentRunnerForCompany").mockImplementation(async (_db, _companyId, input) =>
|
||||
selection.resolveNewAgentRunner({ ...input, target: { driver: "local", platform: "darwin", architecture: "arm64" } }));
|
||||
const originalCreate = mockAgentService.create.getMockImplementation()!;
|
||||
mockAgentService.create.mockImplementation(async (...args) => ({ ...await originalCreate(...args), lifecycleHolds: ["setup"] }));
|
||||
const app = await createApp();
|
||||
const input = { name: "Pi draft", adapterType: "pi_local", adapterConfig: {} };
|
||||
const draft = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents")
|
||||
.send({ ...input, setupId: "00000000-0000-4000-8000-000000000092" }));
|
||||
expect(draft.status, JSON.stringify(draft.body)).toBe(201);
|
||||
expect(mockAgentService.create.mock.calls.at(-1)?.[1]).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "pi", model: "" } });
|
||||
expect(mockAgentService.create.mock.calls.at(-1)?.[2]).toMatchObject({ setupOnly: true, runnerResolved: true });
|
||||
mockAgentService.create.mockClear();
|
||||
const active = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents")
|
||||
.send({ ...input, setupOnly: true }));
|
||||
expect(active.status).toBe(422);
|
||||
expect(mockAgentService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps other native provider selection behind its existing flag", async () => {
|
||||
const app = await createApp();
|
||||
const response = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents").send({ name: "Native Pi", adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731" } }));
|
||||
const response = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents").send({ name: "Native Grok", adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "grok", model: "openrouter/deepseek/deepseek-v4-flash-0731" } }));
|
||||
expect(response.status).toBe(422);
|
||||
expect(response.body.details).toMatchObject({ code: "paperclip_runner_rollout_disabled" });
|
||||
});
|
||||
|
||||
@@ -1837,7 +1837,7 @@ describe("agent permission routes", () => {
|
||||
|
||||
for (const adapterCase of sshCapableAdapterCases) {
|
||||
it(`allows creating a ${adapterCase.adapterType} agent with an SSH default environment`, async () => {
|
||||
const expectedAdapterType = ["codex_local", "claude_local", "opencode_local"].includes(adapterCase.adapterType) ? "paperclip_runner" : adapterCase.adapterType;
|
||||
const expectedAdapterType = ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterCase.adapterType) ? "paperclip_runner" : adapterCase.adapterType;
|
||||
const environmentId = "33333333-3333-4333-8333-333333333333";
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: environmentId,
|
||||
@@ -1879,7 +1879,7 @@ describe("agent permission routes", () => {
|
||||
}),
|
||||
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
||||
);
|
||||
if (["codex_local", "claude_local", "opencode_local"].includes(adapterCase.adapterType)) {
|
||||
if (["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterCase.adapterType)) {
|
||||
if (adapterCase.adapterType === "codex_local") expect(mockAgentService.create.mock.calls[0][1].adapterConfig).toMatchObject({ provider: "codex" });
|
||||
else if (adapterCase.adapterType === "opencode_local") expect(mockAgentService.create.mock.calls[0][1].adapterConfig).toMatchObject({ provider: "opencode", model: adapterCase.adapterConfig.model });
|
||||
else expect(mockAgentService.create.mock.calls[0][1].adapterConfig).toMatchObject({ provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5" });
|
||||
|
||||
@@ -109,7 +109,7 @@ describe("durable Dot Runner integration", () => {
|
||||
expect(() => resolveNativeRuntimeMode(input)).toThrow("experimental settings");
|
||||
expect(resolveNativeRuntimeMode({ ...input, enabled: false, dotEnabled: true })).toMatchObject({ kind: "native", profile: { backend: "openai_dot_mcp" } });
|
||||
expect(() => resolveNativeRuntimeMode({ ...input, enabled: false, dotEnabled: true,
|
||||
adapterConfig: { provider: "acpx", acpxAgent: "pi", model: "anthropic/claude-sonnet-4-6" },
|
||||
adapterConfig: { provider: "acpx", acpxAgent: "grok", model: "anthropic/claude-sonnet-4-6" },
|
||||
})).toThrow("Paperclip Runner is experimental and disabled");
|
||||
expect(resolveHeartbeatNativeRuntimeMode({ ...input, enabled: false, dotEnabled: false,
|
||||
persisted: { runtimeMode: "native", runtimeModeReason: null, runtimeModeResolvedAt: new Date(), driverKind: "openai_dot_mcp" },
|
||||
|
||||
@@ -850,7 +850,7 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => {
|
||||
) {
|
||||
const experimentalAdapterConfig = {
|
||||
provider: "acpx",
|
||||
acpxAgent: "pi",
|
||||
acpxAgent: "grok",
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
};
|
||||
const runtimeConfig = {
|
||||
|
||||
@@ -1865,7 +1865,7 @@ export function agentRoutes(
|
||||
throw unprocessable("Enable Grok Bot, Assistant connections (MCP), and Paperclip Runner before connecting this agent.", { code: "paperclip_runner_grok_disabled" });
|
||||
} else if (!([undefined, "codex", "opencode"].includes(asRecord(config)?.provider as string | undefined)) &&
|
||||
!(asRecord(config)?.provider === "acpx" && asRecord(config)?.acpxAgent === "copilot") &&
|
||||
agentHarnessType("paperclip_runner", asRecord(config) ?? {}) !== "claude_local" &&
|
||||
!["claude_local", "pi_local"].includes(agentHarnessType("paperclip_runner", asRecord(config) ?? {})) &&
|
||||
experimental.enableNativeRunner !== true) {
|
||||
throw unprocessable(
|
||||
"Paperclip Runner is experimental and disabled on this instance.",
|
||||
@@ -1936,7 +1936,7 @@ export function agentRoutes(
|
||||
}
|
||||
if (["openai_dot", "muse"].includes(String(input.nextAdapterConfig.provider))) return input.nextAdapterConfig;
|
||||
const defaults = paperclipRunnerTransitionConfig(input.previousAdapterType, input.previousAdapterConfig.model, input.nextAdapterConfig.provider);
|
||||
if (!["claude_local", "codex_local", "opencode_local"].includes(input.previousAdapterType)
|
||||
if (!["claude_local", "codex_local", "opencode_local", "pi_local"].includes(input.previousAdapterType)
|
||||
&& !isPaperclipRunnerProvider(input.nextAdapterConfig.provider)) {
|
||||
throw unprocessable("Select a Paperclip Runner provider before converting this agent.");
|
||||
}
|
||||
@@ -2175,11 +2175,12 @@ export function agentRoutes(
|
||||
adapterType: input.adapterType ?? null,
|
||||
},
|
||||
);
|
||||
const incompleteOpenCodeSetup = input.setupOnly && input.adapterType === "paperclip_runner"
|
||||
&& normalizedAdapterConfig.provider === "opencode"
|
||||
const incompleteModelSetup = input.setupOnly && input.adapterType === "paperclip_runner"
|
||||
&& (normalizedAdapterConfig.provider === "opencode"
|
||||
|| (normalizedAdapterConfig.provider === "acpx" && normalizedAdapterConfig.acpxAgent === "pi"))
|
||||
&& (normalizedAdapterConfig.model === undefined || normalizedAdapterConfig.model === "");
|
||||
// Inactive drafts can precede model selection. Test/hire always validate the complete profile.
|
||||
if (!incompleteOpenCodeSetup) await assertAdapterConfigConstraints(
|
||||
if (!incompleteModelSetup) await assertAdapterConfigConstraints(
|
||||
input.companyId,
|
||||
input.adapterType,
|
||||
input.constraintAdapterConfig
|
||||
|
||||
@@ -74,9 +74,49 @@ describe("server-owned Codex runner selection", () => {
|
||||
expect(resolveNewAgentRunner(resolved)).toEqual(resolved);
|
||||
expect(input.adapterConfig).toHaveProperty("search", false);
|
||||
});
|
||||
it.each(["grok_local", "cursor", "pi_local", "process", "external"])("keeps %s default unchanged", adapterType => {
|
||||
it.each(["grok_local", "cursor", "process", "external"])("keeps %s default unchanged", adapterType => {
|
||||
expect(resolveNewAgentRunner({ adapterType, target, adapterConfig: { custom: "preserved" } })).toEqual({ adapterType, adapterConfig: { custom: "preserved" } });
|
||||
});
|
||||
it.each(qualifiedTargets.filter(target => !(target.platform === "linux" && target.architecture === "arm64")))("defaults Pi to native on $platform/$architecture and round trips exact thinking", target => {
|
||||
const adapterConfig = { model: "openrouter/anthropic/claude-sonnet-4.6", thinking: "high", command: "pi", env: { OPENROUTER_API_KEY: { type: "secret_ref", secretId: "connection" } }, cwd: "/workspace" };
|
||||
const selected = resolveNewAgentRunner({ adapterType: "pi_local", adapterConfig, target });
|
||||
expect(agentRunnerAvailability("pi_local", target).defaultRunner).toBe("paperclip");
|
||||
expect(selected).toEqual({ adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "pi", model: adapterConfig.model, piThinkingLevel: "high", acpxPermissionMode: "approve-all", lifecycleMode: "per_turn", cwd: adapterConfig.cwd, env: adapterConfig.env } });
|
||||
expect(resolveNewAgentRunner(selected)).toEqual(selected);
|
||||
expect(resolveNewAgentRunner({ ...selected, runner: "legacy" })).toEqual({ adapterType: "pi_local", adapterConfig: { model: adapterConfig.model, thinking: "high", cwd: adapterConfig.cwd, env: adapterConfig.env } });
|
||||
});
|
||||
it.each(["minimal", "medium", "xhigh"])("keeps unsupported Pi thinking %s explicit", thinking => {
|
||||
const input = { adapterType: "pi_local", target, adapterConfig: { model: "custom/model", thinking } };
|
||||
expect(() => resolveNewAgentRunner(input)).toThrow("Legacy runner");
|
||||
expect(resolveNewAgentRunner({ ...input, runner: "legacy" })).toEqual({ adapterType: input.adapterType, adapterConfig: input.adapterConfig });
|
||||
});
|
||||
it.each([{ command: "custom-pi" }, { args: ["--extension", "custom.ts"] }, { extraArgs: "--no-tools" }, { stdoutLogMode: "raw" }, { thinking: "low", piThinkingLevel: "high" }])("rejects unsupported Pi configuration %j", extra => {
|
||||
expect(() => resolveNewAgentRunner({ adapterType: "pi_local", target, adapterConfig: { model: "custom/model", ...extra } })).toThrow("Legacy runner");
|
||||
});
|
||||
it.each([undefined, "", "model", "provider/"])("requires a Pi provider/model outside inactive setup: %s", model => {
|
||||
expect(() => resolveNewAgentRunner({ adapterType: "pi_local", target, adapterConfig: { model } })).toThrow("provider/model");
|
||||
});
|
||||
it("allows an inactive Pi draft before model selection and preserves saved native policy", () => {
|
||||
expect(resolveNewAgentRunner({ adapterType: "pi_local", target, setupOnly: true })).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "pi", model: "", piThinkingLevel: "low" } });
|
||||
const saved = { adapterType: "paperclip_runner", target, adapterConfig: { provider: "acpx", acpxAgent: "pi", model: "custom/model", piThinkingLevel: "max", acpxPermissionMode: "approve-reads", lifecycleMode: "warm", idleTimeoutMs: 1234 } };
|
||||
expect(resolveNewAgentRunner(saved)).toEqual({ adapterType: saved.adapterType, adapterConfig: saved.adapterConfig });
|
||||
expect(() => resolveNewAgentRunner({ ...saved, runner: "legacy" })).toThrow("Legacy Pi cannot honor");
|
||||
});
|
||||
it.each([{ driver: "local", platform: "win32", architecture: "x64" }, { driver: "local", platform: "linux", architecture: "arm64", libc: "glibc" as const, glibcVersion: "2.39" }, { driver: "ssh", platform: "linux", architecture: "arm64", libc: "glibc" as const, glibcVersion: "2.39" }, { driver: "sandbox" }, { driver: "local", platform: "linux", architecture: "x64", libc: "musl" as const }])("keeps Pi legacy on unqualified $driver/$platform targets", target => {
|
||||
const input = { adapterType: "pi_local", adapterConfig: { model: "custom/model", thinking: "medium" }, target };
|
||||
expect(agentRunnerAvailability("pi_local", target).defaultRunner).toBe("legacy");
|
||||
expect(resolveNewAgentRunner(input)).toEqual({ adapterType: input.adapterType, adapterConfig: input.adapterConfig });
|
||||
expect(() => resolveNewAgentRunner({ ...input, runner: "paperclip" })).toThrow("unavailable");
|
||||
});
|
||||
it("honors disabled and overridden Pi adapters", () => {
|
||||
const input = { adapterType: "pi_local", target, adapterConfig: { model: "custom/model" } };
|
||||
state.overridden.add("pi_local");
|
||||
expect(resolveNewAgentRunner(input).adapterType).toBe("pi_local");
|
||||
state.overridden.clear(); state.disabled = ["paperclip_runner"];
|
||||
expect(resolveNewAgentRunner(input).adapterType).toBe("pi_local");
|
||||
state.disabled = ["pi_local"];
|
||||
expect(() => resolveNewAgentRunner(input)).toThrow("Pi is disabled");
|
||||
});
|
||||
it.each(qualifiedTargets.filter(target => !(target.platform === "linux" && target.architecture === "arm64")))("defaults OpenCode to its native provider on $platform/$architecture", target => {
|
||||
const adapterConfig = { model: "openrouter/anthropic/claude-sonnet-4.6", dangerouslySkipPermissions: true, command: "opencode", env: { OPENROUTER_API_KEY: { type: "secret_ref", secretId: "connection" } }, cwd: "/workspace", instructionsFilePath: "/workspace/AGENTS.md" };
|
||||
const selected = resolveNewAgentRunner({ adapterType: "opencode_local", adapterConfig, target });
|
||||
@@ -422,11 +462,12 @@ describe("server-owned Codex runner selection", () => {
|
||||
});
|
||||
|
||||
describe("Codex creation runner contract", () => {
|
||||
it("graduates only Codex and retains saved provider identity", () => {
|
||||
it("graduates qualified harnesses and retains saved provider identity", () => {
|
||||
expect(paperclipRunnerProfileForHarness("codex_local")).toEqual({ provider: "codex" });
|
||||
expect(paperclipRunnerProfileForHarness("claude_local")).toEqual({ provider: "acpx", acpxAgent: "claude" });
|
||||
expect(paperclipRunnerProfileForHarness("opencode_local")).toEqual({ provider: "opencode" });
|
||||
for (const harness of ["grok_local", "cursor", "pi_local", "process"]) expect(paperclipRunnerProfileForHarness(harness)).toBeUndefined();
|
||||
expect(paperclipRunnerProfileForHarness("pi_local")).toEqual({ provider: "acpx", acpxAgent: "pi" });
|
||||
for (const harness of ["grok_local", "cursor", "process"]) expect(paperclipRunnerProfileForHarness(harness)).toBeUndefined();
|
||||
expect(agentHarnessType("paperclip_runner", { provider: "codex" })).toBe("codex_local");
|
||||
expect(agentHarnessType("paperclip_runner", { provider: "acpx", acpxAgent: "claude" })).toBe("claude_local");
|
||||
expect(agentHarnessType("paperclip_runner", { provider: "unknown" })).toBe("unknown");
|
||||
|
||||
@@ -23,7 +23,7 @@ export interface AgentRunnerSelection {
|
||||
|
||||
function runnerAdaptersAvailable(harness: string, driver: string): boolean {
|
||||
const disabled = new Set(getDisabledAdapterTypes());
|
||||
return ["codex_local", "claude_local", "opencode_local"].includes(harness)
|
||||
return ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(harness)
|
||||
&& !disabled.has(harness) && !disabled.has("paperclip_runner")
|
||||
&& !!findActiveServerAdapter(harness) && !!findActiveServerAdapter("paperclip_runner")
|
||||
&& !hasActiveAdapterOverride(harness) && !hasActiveAdapterOverride("paperclip_runner")
|
||||
@@ -45,6 +45,28 @@ function incompatible(fields: string[]): never {
|
||||
throw unprocessable(`Paperclip Runner cannot honor these settings: ${fields.join(", ")}. Remove them or select Legacy runner in Advanced.`, { code: "agent_runner_config_incompatible", fields });
|
||||
}
|
||||
|
||||
function resolvePiRunnerConfig(config: Record<string, unknown>): Record<string, unknown> {
|
||||
const unsupported = ["extraArgs", "args", "modelReasoningEffort", "reasoningEffort", "effort"]
|
||||
.filter(key => config[key] !== undefined && config[key] !== "" && !(Array.isArray(config[key]) && config[key].length === 0));
|
||||
if (config.command && config.command !== "pi") unsupported.push("command");
|
||||
if (config.model !== undefined && typeof config.model !== "string") unsupported.push("model");
|
||||
if (config.stdoutLogMode !== undefined && config.stdoutLogMode !== "compact") unsupported.push("stdoutLogMode");
|
||||
const thinking = config.piThinkingLevel ?? config.thinking;
|
||||
if (thinking !== undefined && !["off", "low", "high", "max"].includes(String(thinking))) unsupported.push("thinking");
|
||||
if (config.piThinkingLevel !== undefined && config.thinking !== undefined && config.piThinkingLevel !== config.thinking) unsupported.push("thinking", "piThinkingLevel");
|
||||
if (unsupported.length) incompatible(unsupported);
|
||||
const translated = { ...config };
|
||||
for (const key of ["command", "extraArgs", "args", "thinking", "stdoutLogMode"]) delete translated[key];
|
||||
return {
|
||||
...translated,
|
||||
provider: "acpx", acpxAgent: "pi",
|
||||
model: typeof config.model === "string" ? config.model.trim() : "",
|
||||
piThinkingLevel: thinking ?? "low",
|
||||
acpxPermissionMode: config.acpxPermissionMode ?? "approve-all",
|
||||
lifecycleMode: config.lifecycleMode ?? "per_turn",
|
||||
};
|
||||
}
|
||||
|
||||
function resolveOpenCodeRunnerConfig(config: Record<string, unknown>): Record<string, unknown> {
|
||||
const unsupported = ["extraArgs", "args", "variant", "modelReasoningEffort", "reasoningEffort", "effort"]
|
||||
.filter(key => config[key] !== undefined && config[key] !== "" && !(Array.isArray(config[key]) && config[key].length === 0));
|
||||
@@ -103,12 +125,25 @@ export function resolveNewAgentRunner(input: AgentRunnerSelection): { adapterTyp
|
||||
let config = { ...input.adapterConfig };
|
||||
const harness = agentHarnessType(adapterType, config);
|
||||
const native = adapterType === "paperclip_runner";
|
||||
if (["codex_local", "claude_local", "opencode_local"].includes(adapterType) && getDisabledAdapterTypes().includes(adapterType)) throw unprocessable(`${adapterType === "opencode_local" ? "OpenCode" : adapterType === "claude_local" ? "Claude" : "Codex"} is disabled on this instance. Enable its adapter or choose another harness.`, { code: "agent_runner_unavailable" });
|
||||
if (["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) && getDisabledAdapterTypes().includes(adapterType)) throw unprocessable(`${adapterType === "pi_local" ? "Pi" : adapterType === "opencode_local" ? "OpenCode" : adapterType === "claude_local" ? "Claude" : "Codex"} is disabled on this instance. Enable its adapter or choose another harness.`, { code: "agent_runner_unavailable" });
|
||||
if (native && (input.runner === undefined || input.runner === "paperclip" || (harness !== "codex_local" && input.runner !== "legacy"))) return { adapterType, adapterConfig: normalizeLegacyRunnerProvider(config) };
|
||||
const availability = agentRunnerAvailability(harness, input.target);
|
||||
const choice = input.runner ?? "auto";
|
||||
const useNative = choice === "paperclip" || (choice === "auto" && availability.defaultRunner === "paperclip");
|
||||
if (!useNative) {
|
||||
if (harness === "pi_local") {
|
||||
if (!native) return { adapterType, adapterConfig: config };
|
||||
const unsupported = [
|
||||
...(config.acpxPermissionMode !== undefined && config.acpxPermissionMode !== "approve-all" ? ["acpxPermissionMode"] : []),
|
||||
...(config.lifecycleMode !== undefined && config.lifecycleMode !== "per_turn" ? ["lifecycleMode"] : []),
|
||||
...(config.acpxSessionMode !== undefined ? ["acpxSessionMode"] : []),
|
||||
...(config.piThinkingLevel !== undefined && !["off", "low", "high"].includes(String(config.piThinkingLevel)) ? ["piThinkingLevel"] : []),
|
||||
];
|
||||
if (unsupported.length) throw unprocessable(`Legacy Pi cannot honor these settings: ${unsupported.join(", ")}. Keep Paperclip Runner or explicitly choose compatible settings.`, { code: "agent_runner_config_incompatible", fields: unsupported });
|
||||
const thinking = config.piThinkingLevel ?? "low";
|
||||
for (const key of ["provider", "acpxAgent", "acpxPermissionMode", "acpxSessionMode", "piThinkingLevel", "lifecycleMode", "idleTimeoutMs"]) delete config[key];
|
||||
return { adapterType: "pi_local", adapterConfig: { ...config, thinking } };
|
||||
}
|
||||
if (harness === "opencode_local") {
|
||||
if (!native) return { adapterType, adapterConfig: config };
|
||||
if (config.opencodePermissionMode !== undefined && config.opencodePermissionMode !== "allow") incompatible(["opencodePermissionMode"]);
|
||||
@@ -138,6 +173,17 @@ export function resolveNewAgentRunner(input: AgentRunnerSelection): { adapterTyp
|
||||
? "Paperclip Runner is unavailable for this sandbox. Choose an immutable OCI image digest and run its environment connection probe to qualify the OS, architecture and libc, or select Legacy runner in Advanced."
|
||||
: "Paperclip Runner is unavailable for this harness and environment. Select Legacy runner in Advanced.", { code: "agent_runner_unavailable" });
|
||||
if (native) return { adapterType, adapterConfig: normalizeLegacyRunnerProvider(config) };
|
||||
if (harness === "pi_local") {
|
||||
config = resolvePiRunnerConfig(config);
|
||||
if (input.setupOnly && !config.model) return { adapterType: "paperclip_runner", adapterConfig: config };
|
||||
if (typeof config.model !== "string" || !/^[^/\s]+\/\S+$/.test(config.model)) throw unprocessable("Pi requires an explicit provider/model. Select a model before testing or hiring.", { code: "agent_runner_config_incompatible", fields: ["model"] });
|
||||
try { resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime", adapterConfig: config }); }
|
||||
catch (error) {
|
||||
if (error instanceof PaperclipRunnerProviderProfileError) throw unprocessable(error.message, { code: error.code });
|
||||
throw error;
|
||||
}
|
||||
return { adapterType: "paperclip_runner", adapterConfig: config };
|
||||
}
|
||||
if (harness === "opencode_local") {
|
||||
config = resolveOpenCodeRunnerConfig(config);
|
||||
if (input.setupOnly && !config.model) return { adapterType: "paperclip_runner", adapterConfig: config };
|
||||
@@ -212,7 +258,7 @@ export function resolveNewAgentRunner(input: AgentRunnerSelection): { adapterTyp
|
||||
}
|
||||
|
||||
export async function resolveNewAgentRunnerForCompany(db: Db, companyId: string, input: AgentRunnerSelection & { defaultEnvironmentId?: string | null }) {
|
||||
if (!["codex_local", "claude_local", "opencode_local"].includes(agentHarnessType(input.adapterType ?? "process", input.adapterConfig))
|
||||
if (!["codex_local", "claude_local", "opencode_local", "pi_local"].includes(agentHarnessType(input.adapterType ?? "process", input.adapterConfig))
|
||||
|| input.runner === "legacy" || (input.adapterType === "paperclip_runner" && (input.runner === undefined || input.runner === "paperclip"))) return resolveNewAgentRunner(input);
|
||||
const target = await resolveAgentRunnerTargetForCompany(db, companyId, input.defaultEnvironmentId);
|
||||
return resolveNewAgentRunner({ ...input, target });
|
||||
@@ -249,7 +295,7 @@ export async function resolveAgentRunnerTargetForCompany(db: Db, companyId: stri
|
||||
const qualified = await readSandboxPlatformQualification(db, environment, companyId);
|
||||
if (qualified) target = { driver, ...qualified };
|
||||
}
|
||||
if (driver === "ssh" && environment && ["codex_local", "claude_local", "opencode_local"].some(harness => runnerAdaptersAvailable(harness, driver))) {
|
||||
if (driver === "ssh" && environment && ["codex_local", "claude_local", "opencode_local", "pi_local"].some(harness => runnerAdaptersAvailable(harness, driver))) {
|
||||
const parsed = await resolveEnvironmentDriverConfigForRuntime(db, companyId, environment);
|
||||
if (parsed.driver !== "ssh") throw unprocessable("The selected SSH environment is unavailable.");
|
||||
let output: string;
|
||||
|
||||
@@ -1762,7 +1762,7 @@ export function builtInAgentService(db: Db) {
|
||||
const adapterConfig = resolvedInput.adapterConfig ?? existing.adapterConfig;
|
||||
assertAdapterAllowed(definition, adapterType, adapterConfig);
|
||||
const selectedHarness = agentHarnessType(adapterType, adapterConfig);
|
||||
const sameQualifiedHarness = ["codex_local", "claude_local", "opencode_local"].includes(selectedHarness)
|
||||
const sameQualifiedHarness = ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(selectedHarness)
|
||||
&& agentHarnessType(existing.adapterType, existing.adapterConfig) === selectedHarness;
|
||||
const runner = resolvedInput.runner ?? (sameQualifiedHarness && existing.metadata?.paperclipBuiltInSetupRequired !== true
|
||||
? agentRunner(existing.adapterType) : undefined);
|
||||
|
||||
@@ -5307,7 +5307,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
"OpenAI Dot is experimental and disabled on this instance.",
|
||||
{ code: "paperclip_runner_dot_disabled" },
|
||||
);
|
||||
} else if (config.provider !== "codex" && config.provider !== "opencode" && config.provider !== undefined && agentHarnessType("paperclip_runner", config) !== "claude_local" && experimental.enableNativeRunner !== true) {
|
||||
} else if (config.provider !== "codex" && config.provider !== "opencode" && config.provider !== undefined && !["claude_local", "pi_local"].includes(agentHarnessType("paperclip_runner", config)) && experimental.enableNativeRunner !== true) {
|
||||
throw unprocessable(
|
||||
"Paperclip Runner is experimental and disabled on this instance.",
|
||||
{ code: "paperclip_runner_rollout_disabled" },
|
||||
@@ -5651,7 +5651,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
runner = agentRunner(existingImportAgent.adapterType);
|
||||
}
|
||||
}
|
||||
const retainedNative = runner === "paperclip" && ["codex_local", "claude_local", "opencode_local"].includes(selectedHarness)
|
||||
const retainedNative = runner === "paperclip" && ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(selectedHarness)
|
||||
? [manifestAgent, existingImportAgent].find((agent) => agent?.adapterType === "paperclip_runner"
|
||||
&& agentHarnessType(agent.adapterType, agent.adapterConfig) === selectedHarness)
|
||||
: undefined;
|
||||
|
||||
@@ -54,7 +54,7 @@ describe("resolveNativeRuntimeMode", () => {
|
||||
expect(resolveNativeRuntimeMode({ ...eligible, enabled: false,
|
||||
adapterConfig: { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" },
|
||||
})).toMatchObject({ kind: "native", profile: { backend: "acpx_runtime" } });
|
||||
for (const acpxAgent of ["pi", "cursor", "codex"]) {
|
||||
for (const acpxAgent of ["grok", "cursor", "codex"]) {
|
||||
expect(() => resolveNativeRuntimeMode({ ...eligible, enabled: false,
|
||||
adapterConfig: { provider: "acpx", acpxAgent, model: "gpt-5.6-luna" },
|
||||
})).toThrow(expect.objectContaining({ code: "paperclip_runner_rollout_disabled" }));
|
||||
@@ -96,16 +96,22 @@ describe("resolveNativeRuntimeMode", () => {
|
||||
})).toMatchObject({ kind: "native", profile: { backend: "opencode_server" } });
|
||||
});
|
||||
|
||||
it("still rejects Pi while the rollout flag is disabled", () => {
|
||||
it("still rejects Grok while the rollout flag is disabled", () => {
|
||||
expect(() => resolveNativeRuntimeMode({
|
||||
...eligible,
|
||||
enabled: false,
|
||||
adapterConfig: { provider: "acpx", acpxAgent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731" },
|
||||
adapterConfig: { provider: "acpx", acpxAgent: "grok", model: "openrouter/deepseek/deepseek-v4-flash-0731" },
|
||||
})).toThrow(expect.objectContaining({
|
||||
code: "paperclip_runner_rollout_disabled",
|
||||
}));
|
||||
});
|
||||
|
||||
it("admits Pi without the generic rollout flag", () => {
|
||||
expect(resolveNativeRuntimeMode({ ...eligible, enabled: false,
|
||||
adapterConfig: { provider: "acpx", acpxAgent: "pi", model: "openrouter/anthropic/claude-sonnet-4.6", piThinkingLevel: "max" },
|
||||
})).toMatchObject({ kind: "native", profile: { backend: "acpx_runtime" } });
|
||||
});
|
||||
|
||||
it("rejects unknown Paperclip Runner providers", () => {
|
||||
expect(() => resolveNativeRuntimeMode({
|
||||
...eligible,
|
||||
@@ -250,7 +256,7 @@ describe("resolveNativeRuntimeMode", () => {
|
||||
|
||||
it("keeps a persisted experimental run native while the flag rejects a fresh start", () => {
|
||||
const disabled = { ...eligible, enabled: false,
|
||||
adapterConfig: { provider: "acpx", acpxAgent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731" },
|
||||
adapterConfig: { provider: "acpx", acpxAgent: "grok", model: "openrouter/deepseek/deepseek-v4-flash-0731" },
|
||||
};
|
||||
expect(resolveHeartbeatNativeRuntimeMode({
|
||||
...disabled,
|
||||
@@ -522,7 +528,7 @@ describe("resolveHeartbeatRuntimeMode compatibility", () => {
|
||||
...compatibilityInput,
|
||||
enabled: false,
|
||||
adapterType: "paperclip_runner",
|
||||
adapterConfig: { provider: "acpx", acpxAgent: "pi", model: "openrouter/anthropic/claude-sonnet-4.6" },
|
||||
adapterConfig: { provider: "acpx", acpxAgent: "grok", model: "openrouter/anthropic/claude-sonnet-4.6" },
|
||||
})).toThrow(NativeRunnerSelectionError);
|
||||
});
|
||||
it("admits native Claude without enabling other ACPX providers", () => {
|
||||
|
||||
@@ -140,7 +140,7 @@ export function resolveNativeRuntimeMode(input: {
|
||||
throw ineligible("paperclip_runner_grok_disabled", "Enable Grok Bot, Paperclip Runner, and Assistant connections (MCP) before assigning new work.");
|
||||
} else if (runnerProfile.provider !== "codex" && runnerProfile.provider !== "opencode"
|
||||
&& !(runnerProfile.provider === "acpx"
|
||||
&& (runnerProfile.acpxAgent === "claude" || runnerProfile.acpxAgent === "copilot"))
|
||||
&& (["claude", "copilot", "pi"].includes(runnerProfile.acpxAgent ?? "")))
|
||||
&& !input.enabled) {
|
||||
throw ineligible(
|
||||
"paperclip_runner_rollout_disabled",
|
||||
|
||||
@@ -31,6 +31,7 @@ describe("adapter display registry", () => {
|
||||
expect(getAdapterLabel("hermes_gateway")).toBe("Hermes Gateway");
|
||||
expect(getAdapterLabel("opencode_local")).toBe("OpenCode");
|
||||
expect(getAdapterLabel("pi_local")).toBe("Pi");
|
||||
expect(agentDisplayAdapterType({ adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "pi" } })).toBe("pi_local");
|
||||
|
||||
expect(getAdapterLabels()).toMatchObject({
|
||||
codex_local: "Codex",
|
||||
|
||||
@@ -222,7 +222,7 @@ export function agentDisplayAdapterType(agent: { adapterType: string; adapterCon
|
||||
return agent.adapterConfig.provider;
|
||||
}
|
||||
const harness = agentHarnessType(agent.adapterType, agent.adapterConfig);
|
||||
return ["codex_local", "claude_local", "opencode_local"].includes(harness) ? harness : agent.adapterType;
|
||||
return ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(harness) ? harness : agent.adapterType;
|
||||
}
|
||||
|
||||
export function isKnownAdapterType(type: string): boolean {
|
||||
|
||||
@@ -437,7 +437,7 @@ export function AgentActionButtons({
|
||||
<Button variant="outline" size={size} disabled={disabled} onClick={() => navigate(`/agents/new?${new URLSearchParams({
|
||||
name: agent.name, adapterType: agent.adapterType, setupAgentId: agent.id,
|
||||
runnerProvider: String(agent.adapterConfig.provider === "acpx" ? agent.adapterConfig.acpxAgent : agent.adapterConfig.provider ?? "codex"),
|
||||
...(agent.adapterType === "paperclip_runner" ? { runner: "paperclip" } : ["codex_local", "claude_local", "opencode_local"].includes(agent.adapterType) ? { runner: "legacy" } : {}),
|
||||
...(agent.adapterType === "paperclip_runner" ? { runner: "paperclip" } : ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(agent.adapterType) ? { runner: "legacy" } : {}),
|
||||
})}`)}>Finish setup</Button>
|
||||
) : (
|
||||
<PauseResumeButton
|
||||
|
||||
@@ -887,15 +887,19 @@ describe("AgentConfigForm environment selector", () => {
|
||||
expect(result.onSave).toHaveBeenCalledWith({ name: "Renamed Claude" });
|
||||
});
|
||||
|
||||
it.each([{ provider: "opencode", model: "openrouter/anthropic/claude-sonnet-4.6" }, { provider: "unknown-provider" }])("preserves saved native $provider configuration with the experimental gate off", async adapterConfig => {
|
||||
it.each([{ provider: "opencode", model: "openrouter/anthropic/claude-sonnet-4.6" }, { provider: "acpx", acpxAgent: "pi", model: "openrouter/anthropic/claude-sonnet-4.6", piThinkingLevel: "max", acpxPermissionMode: "approve-reads", lifecycleMode: "warm" }, { provider: "unknown-provider" }])("preserves saved native $provider configuration with the experimental gate off", async adapterConfig => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableNativeRunner: false });
|
||||
const result = await renderForm([], { adapterType: "paperclip_runner", adapterConfig }, { showAdapterTypeField: true });
|
||||
roots.push(result.root);
|
||||
if (adapterConfig.provider === "opencode") {
|
||||
expect(result.container.querySelector('[aria-label="Harness"]')?.textContent).toContain("OpenCode");
|
||||
if (adapterConfig.provider === "opencode" || adapterConfig.acpxAgent === "pi") {
|
||||
expect(result.container.querySelector('[aria-label="Harness"]')?.textContent).toContain(adapterConfig.provider === "opencode" ? "OpenCode" : "Pi");
|
||||
await clickByText(result.container, "Advanced");
|
||||
expect(result.container.querySelector('[aria-label="Runner"]')?.textContent).toContain("Paperclip Runner");
|
||||
} else expect(result.container.querySelector('[aria-label="Runner"]')).toBeNull();
|
||||
if (adapterConfig.acpxAgent === "pi") {
|
||||
expect(result.container.querySelector('[aria-label="Pi thinking level"]')).not.toBeNull();
|
||||
expect(result.container.querySelector('[aria-label="Thinking effort"]')).toBeNull();
|
||||
}
|
||||
await act(async () => setInputValue(result.container.querySelector<HTMLInputElement>('[placeholder="Agent name"]')!, "Renamed native agent"));
|
||||
await flushReact();
|
||||
await clickByText(result.container, "Save");
|
||||
|
||||
@@ -1400,7 +1400,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
: adapterType === "opencode_local"
|
||||
? eff("adapterConfig", "variant", String(config.variant ?? ""))
|
||||
: eff("adapterConfig", thinkingEffortKey, String(config[thinkingEffortKey] ?? ""));
|
||||
const showThinkingEffort = adapterType !== "gemini_local"
|
||||
const showThinkingEffort = !(harnessType === "pi_local" && fieldsAdapterType === "paperclip_runner")
|
||||
&& adapterType !== "gemini_local"
|
||||
&& adapterType !== "cursor_cloud"
|
||||
&& (adapterType !== "paperclip_runner" || isRunnerHarness);
|
||||
const codexSearchEnabled = adapterType === "codex_local"
|
||||
@@ -1758,7 +1759,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
setOverlay((prev) => ({
|
||||
...prev,
|
||||
adapterType: t,
|
||||
runner: ["codex_local", "claude_local", "opencode_local"].includes(t) ? "auto" : undefined,
|
||||
runner: ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(t) ? "auto" : undefined,
|
||||
adapterConfig: {
|
||||
model:
|
||||
t === "gemini_local"
|
||||
|
||||
@@ -68,7 +68,7 @@ export function ConfigureBuiltInAgentModal({
|
||||
const { definition } = state;
|
||||
|
||||
const [adapterType, setAdapterType] = useState<string>(
|
||||
() => state.agent && ["codex_local", "claude_local", "opencode_local"].includes(agentHarnessType(state.agent.adapterType, state.agent.adapterConfig)) ? agentHarnessType(state.agent.adapterType, state.agent.adapterConfig) : state.agent?.adapterType ?? defaultAdapterType(state),
|
||||
() => state.agent && ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(agentHarnessType(state.agent.adapterType, state.agent.adapterConfig)) ? agentHarnessType(state.agent.adapterType, state.agent.adapterConfig) : state.agent?.adapterType ?? defaultAdapterType(state),
|
||||
);
|
||||
const [runner, setRunner] = useState<AgentRunnerChoice | undefined>();
|
||||
const environmentId = state.agent?.defaultEnvironmentId ?? null;
|
||||
@@ -143,7 +143,7 @@ export function ConfigureBuiltInAgentModal({
|
||||
: { adapterType, adapterConfig };
|
||||
const result = await builtInAgentsApi.provision(companyId, definition.key, {
|
||||
...adapterInput,
|
||||
...(["codex_local", "claude_local", "opencode_local"].includes(adapterType) && runner !== undefined ? { runner } : {}),
|
||||
...(["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) && runner !== undefined ? { runner } : {}),
|
||||
...(budgetMonthlyCents !== undefined ? { budgetMonthlyCents } : {}),
|
||||
});
|
||||
return result;
|
||||
@@ -189,7 +189,7 @@ export function ConfigureBuiltInAgentModal({
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{["codex_local", "claude_local", "opencode_local"].includes(adapterType) && <details className="space-y-3">
|
||||
{["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) && <details className="space-y-3">
|
||||
<summary className="cursor-pointer text-sm text-muted-foreground">Advanced</summary>
|
||||
<AgentRunnerSelect value={runner ?? (state.agent ? agentRunner(state.agent.adapterType) : "auto")}
|
||||
pending={adapters.isFetching} error={adapters.error?.message} onRetry={() => { void adapters.refetch(); }}
|
||||
|
||||
@@ -169,6 +169,7 @@ const copilotOnboardingQualified = PAPERCLIP_RUNNER_ACPX_PROFILES.some(
|
||||
);
|
||||
|
||||
function restoreOnboardingAdapterType(savedAdapterType: unknown, provider?: unknown, savedCopilot?: unknown): AdapterType {
|
||||
if (savedAdapterType === "paperclip_runner" && provider === "pi") return "pi_local";
|
||||
if (savedAdapterType === "paperclip_runner" && provider === "opencode") return "opencode_local";
|
||||
if (savedAdapterType === "paperclip_runner" && savedCopilot !== true && (provider === undefined || provider === "codex")) return "codex_local";
|
||||
return typeof savedAdapterType === "string" && (savedAdapterType !== "paperclip_runner" || (savedCopilot === true && copilotOnboardingQualified))
|
||||
@@ -612,7 +613,7 @@ function OnboardingWizardInner({
|
||||
restoreOnboardingAdapterType(saved?.adapterType, saved?.runnerProvider, saved?.onboardingCopilot),
|
||||
);
|
||||
const [runner, setRunner] = useState<AgentRunnerChoice>(() => saved?.runner === "legacy" || saved?.runner === "paperclip"
|
||||
? saved.runner : saved?.adapterType === "paperclip_runner" && (saved?.runnerProvider === undefined || ["codex", "claude", "acpx", "opencode"].includes(String(saved?.runnerProvider))) ? "paperclip" : "auto");
|
||||
? saved.runner : saved?.adapterType === "paperclip_runner" && (saved?.runnerProvider === undefined || ["codex", "claude", "acpx", "opencode", "pi"].includes(String(saved?.runnerProvider))) ? "paperclip" : "auto");
|
||||
/**
|
||||
* Whether a model source has been chosen, as opposed to which one
|
||||
* `adapterType` happens to hold.
|
||||
@@ -642,7 +643,7 @@ function OnboardingWizardInner({
|
||||
const savedNativeRunnerDraft = saved?.adapterType === "paperclip_runner" && !(saved?.onboardingCopilot === true && copilotOnboardingQualified);
|
||||
const [cwd, setCwd] = useState((saved?.cwd as string) ?? "");
|
||||
const [model, setModel] = useState(
|
||||
savedNativeRunnerDraft && saved?.runnerProvider !== undefined && !["codex", "claude", "acpx", "opencode"].includes(String(saved.runnerProvider)) ? "" : (saved?.model as string) ?? "",
|
||||
savedNativeRunnerDraft && saved?.runnerProvider !== undefined && !["codex", "claude", "acpx", "opencode", "pi"].includes(String(saved.runnerProvider)) ? "" : (saved?.model as string) ?? "",
|
||||
);
|
||||
const [command, setCommand] = useState(
|
||||
saved?.adapterType === "paperclip_runner" ? "" : (saved?.command as string) ?? "",
|
||||
@@ -1048,7 +1049,7 @@ function OnboardingWizardInner({
|
||||
if (requestCopilotModels) void refreshAdapterModels();
|
||||
}, [requestCopilotModels, copilotModelScope, refreshAdapterModels]);
|
||||
const copilotModelReady = Boolean(copilotConnection && copilotEnvironmentReady && !adapterModelsError && !adapterModelsFetching && adapterModels?.some((entry) => entry.id === model.trim()));
|
||||
const needsRunnerDiscovery = ["codex_local", "claude_local", "opencode_local"].includes(adapterType) && runner === "auto";
|
||||
const needsRunnerDiscovery = ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) && runner === "auto";
|
||||
const resolvedRunner = runner === "auto"
|
||||
? runnerDiscovery.data?.find(item => item.type === adapterType)?.defaultRunner
|
||||
: runner;
|
||||
@@ -2032,7 +2033,7 @@ function OnboardingWizardInner({
|
||||
...defaultCreateValues,
|
||||
adapterType,
|
||||
runner,
|
||||
...(isCopilot ? { adapterSchemaValues: { provider: "acpx", acpxAgent: "copilot" } } : {}),
|
||||
...(isCopilot ? { adapterSchemaValues: { provider: "acpx", acpxAgent: "copilot" } } : adapterType === "pi_local" ? { adapterSchemaValues: savedConfig } : {}),
|
||||
resolvedRunner: setupRunner ?? resolvedRunner,
|
||||
model:
|
||||
adapterType === "gemini_local"
|
||||
@@ -2262,7 +2263,7 @@ function OnboardingWizardInner({
|
||||
{
|
||||
adapterConfig: testedConfig,
|
||||
...(restoredSetup?.companyId === createdCompanyId ? { agentId: restoredSetup.id } : {}),
|
||||
...(["codex_local", "claude_local", "opencode_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}),
|
||||
...(["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}),
|
||||
...(managedBindingForStep() ? { aiConnection: managedBindingForStep() } : {}),
|
||||
environmentId,
|
||||
}
|
||||
@@ -2524,7 +2525,7 @@ function OnboardingWizardInner({
|
||||
...(restoredSetup?.companyId === createdCompanyId ? { reportsTo: restoredSetup.reportsTo } : {}),
|
||||
adapterType,
|
||||
adapterConfig: hireAdapterConfig,
|
||||
...(["codex_local", "claude_local", "opencode_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}),
|
||||
...(["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}),
|
||||
...(shouldApplyStoredClaudeLogin ? { applyStoredClaudeLogin: true } : {}),
|
||||
// The server owns what the first agent is told now: this marker seeds
|
||||
// the chief-of-staff persona over the agent's entry instruction file.
|
||||
@@ -3257,7 +3258,7 @@ function OnboardingWizardInner({
|
||||
) : null}
|
||||
</motion.div>
|
||||
|
||||
{sourceSelected && ["codex_local", "claude_local", "opencode_local"].includes(adapterType) && <details className="space-y-3">
|
||||
{sourceSelected && ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) && <details className="space-y-3">
|
||||
<summary className="cursor-pointer text-sm text-muted-foreground">Advanced</summary>
|
||||
<AgentRunnerSelect value={runner} disabled={loading || adapterEnvLoading}
|
||||
pending={runnerDiscovery.isFetching} error={runnerDiscovery.error?.message} onRetry={() => { void runnerDiscovery.refetch(); }}
|
||||
|
||||
@@ -28,7 +28,7 @@ import type {
|
||||
Agent,
|
||||
EnvBinding,
|
||||
} from "@paperclipai/shared";
|
||||
import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "@paperclipai/shared";
|
||||
import { ADAPTER_AUTH_MISSING_CHECK_CODE, paperclipRunnerProfileForHarness } from "@paperclipai/shared";
|
||||
import { useNavigate, useSearchParams } from "@/lib/router";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { ApiError } from "@/api/client";
|
||||
@@ -96,9 +96,9 @@ export function NewAgentSetup() {
|
||||
key={`${selectedCompanyId}:${params.get("name")}:${params.get("adapterType")}:${params.get("runnerProvider") ?? "codex"}:${params.get("runner") ?? "auto"}`}
|
||||
companyId={selectedCompanyId}
|
||||
name={params.get("name") ?? ""}
|
||||
adapterType={params.get("adapterType") === "paperclip_runner" && ["codex", "claude", "opencode"].includes(params.get("runnerProvider") ?? "codex") ? params.get("runnerProvider") === "claude" ? "claude_local" : params.get("runnerProvider") === "opencode" ? "opencode_local" : "codex_local" : params.get("adapterType") ?? ""}
|
||||
initialRunner={params.get("runner") ?? (params.get("adapterType") === "paperclip_runner" && ["codex", "claude", "opencode"].includes(params.get("runnerProvider") ?? "codex") ? "paperclip" : params.has("setupAgentId") && ["codex_local", "claude_local", "opencode_local"].includes(params.get("adapterType") ?? "") ? "legacy" : "auto")}
|
||||
preserveNativeHarness={params.get("adapterType") === "paperclip_runner" && ["codex", "claude", "opencode"].includes(params.get("runnerProvider") ?? "codex")}
|
||||
adapterType={params.get("adapterType") === "paperclip_runner" && ["codex", "claude", "opencode", "pi"].includes(params.get("runnerProvider") ?? "codex") ? params.get("runnerProvider") === "claude" ? "claude_local" : params.get("runnerProvider") === "opencode" ? "opencode_local" : params.get("runnerProvider") === "pi" ? "pi_local" : "codex_local" : params.get("adapterType") ?? ""}
|
||||
initialRunner={params.get("runner") ?? (params.get("adapterType") === "paperclip_runner" && ["codex", "claude", "opencode", "pi"].includes(params.get("runnerProvider") ?? "codex") ? "paperclip" : params.has("setupAgentId") && ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(params.get("adapterType") ?? "") ? "legacy" : "auto")}
|
||||
preserveNativeHarness={params.get("adapterType") === "paperclip_runner" && ["codex", "claude", "opencode", "pi"].includes(params.get("runnerProvider") ?? "codex")}
|
||||
runnerProvider={params.get("runnerProvider") ?? "codex"}
|
||||
createdAgentId={params.get("createdAgentId")}
|
||||
setupAgentId={params.get("setupAgentId")}
|
||||
@@ -141,7 +141,7 @@ function Setup({
|
||||
? "claude_local"
|
||||
: runnerProvider === "opencode"
|
||||
? "opencode_local"
|
||||
: runnerProvider === "codex" ? "codex_local" : runnerProvider
|
||||
: runnerProvider === "pi" ? "pi_local" : runnerProvider === "codex" ? "codex_local" : runnerProvider
|
||||
: adapterType;
|
||||
const connectionAdapter =
|
||||
brandType === "claude_local" || brandType === "codex_local" || brandType === "grok_local"
|
||||
@@ -163,7 +163,6 @@ function Setup({
|
||||
createdAgentId ? "saved" : connectionAdapter ? "connect" : "runtime",
|
||||
);
|
||||
const [model, setModel] = useState("");
|
||||
const efforts = isRunner ? [] : setupEfforts(adapterType, model);
|
||||
const [effort, setEffort] = useState("");
|
||||
const [modelOpen, setModelOpen] = useState(false);
|
||||
const [environmentOverride, setEnvironmentOverride] = useState("");
|
||||
@@ -175,7 +174,7 @@ function Setup({
|
||||
const [runtimeAiBinding, setRuntimeAiBinding] = useState<AiConnectionBinding | undefined>(() =>
|
||||
brandType === "copilot_runtime"
|
||||
? { provider: "github", method: "api_key", mode: "responsible_user" }
|
||||
: brandType === "opencode_local"
|
||||
: multiProvider
|
||||
? { provider: "openrouter", method: "api_key", mode: "responsible_user" }
|
||||
: undefined,
|
||||
);
|
||||
@@ -202,9 +201,9 @@ function Setup({
|
||||
currentCreated ??
|
||||
(savedAgent.data?.companyId === companyId ? savedAgent.data : null);
|
||||
const completed = Boolean(created && created.pauseReason !== "setup");
|
||||
const savedNativeOpenCode = brandType === "opencode_local" && (Boolean(setupAgentId) || runner === "legacy")
|
||||
const savedNativeMultiProvider = multiProvider && (Boolean(setupAgentId) || runner === "legacy")
|
||||
&& created?.adapterType === "paperclip_runner";
|
||||
const requestAdapterType = savedNativeOpenCode || (preserveNativeHarness && runner === "paperclip")
|
||||
const requestAdapterType = savedNativeMultiProvider || (preserveNativeHarness && runner === "paperclip")
|
||||
? "paperclip_runner" : adapterType;
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testState, setTestState] = useState<TestState | "warn">("idle");
|
||||
@@ -328,6 +327,8 @@ function Setup({
|
||||
queryFn: () => adaptersApi.list({ companyId, environmentId }),
|
||||
});
|
||||
const defaultRunner = adapters.data?.find(adapter => adapter.type === adapterType)?.defaultRunner;
|
||||
const efforts = brandType === "pi_local" && (runner === "paperclip" || (runner === "auto" && defaultRunner === "paperclip"))
|
||||
? ["off", "low", "high", "max"] : isRunner ? [] : setupEfforts(adapterType, model);
|
||||
useEffect(() => {
|
||||
const transition = qualificationTransition.current;
|
||||
if (transition?.run === generation.current && transition.companyId === companyId
|
||||
@@ -385,7 +386,7 @@ function Setup({
|
||||
) && (requestAdapterType !== "paperclip_runner" || adapterType === "paperclip_runner" || adapterInventory.data?.some(adapter => adapter.type === "paperclip_runner" && adapter.loaded && !adapter.disabled));
|
||||
const connectionSettingsReady = Boolean(
|
||||
available &&
|
||||
(!["codex_local", "claude_local", "opencode_local"].includes(adapterType) || runner !== "auto" || Boolean(adapters.data)) &&
|
||||
(!["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) || runner !== "auto" || Boolean(adapters.data)) &&
|
||||
!environmentError &&
|
||||
!envs.isPending &&
|
||||
!settings.isPending &&
|
||||
@@ -401,7 +402,7 @@ function Setup({
|
||||
!caps.error &&
|
||||
(!(managedOnly || forced.forced) || environmentId),
|
||||
);
|
||||
const needsCurrentSetup = ["codex_local", "claude_local", "opencode_local"].includes(adapterType);
|
||||
const needsCurrentSetup = ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType);
|
||||
const ready = Boolean(created) && connectionSettingsReady && (!needsCurrentSetup || (
|
||||
envs.isSuccess && !envs.isFetching
|
||||
&& settings.isSuccess && !settings.isFetching
|
||||
@@ -429,32 +430,42 @@ function Setup({
|
||||
...(isRunner
|
||||
? {
|
||||
adapterSchemaValues: {
|
||||
provider: (runnerProvider === "claude" || runnerProvider === "grok" || runnerProvider === "copilot") ? "acpx" : runnerProvider,
|
||||
provider: ["claude", "grok", "copilot", "pi"].includes(runnerProvider) ? "acpx" : runnerProvider,
|
||||
...(isDot ? { allowUnmeteredProvider } : {}),
|
||||
...((runnerProvider === "claude" || runnerProvider === "grok" || runnerProvider === "copilot") ? { acpxAgent: runnerProvider } : {}),
|
||||
...(["claude", "grok", "copilot", "pi"].includes(runnerProvider) ? { acpxAgent: runnerProvider } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const config = getUIAdapter(preserveNativeHarness && runner === "paperclip" ? "paperclip_runner" : adapterType).buildAdapterConfig(preserveNativeHarness && runner === "paperclip" ? { ...values, adapterSchemaValues: runnerProvider === "claude" ? { provider: "acpx", acpxAgent: "claude", acpxPermissionMode: "approve-all" } : { provider: runnerProvider } } : values);
|
||||
if (preserveNativeHarness && runner === "paperclip") {
|
||||
Object.assign(config, runnerProvider === "claude" ? { provider: "acpx", acpxAgent: "claude", acpxPermissionMode: "approve-all", lifecycleMode: "per_turn" } : runnerProvider === "opencode" ? { provider: "opencode", opencodePermissionMode: "allow", lifecycleMode: "per_turn" } : { provider: "codex", codexPermissionMode: "never", lifecycleMode: "per_turn" });
|
||||
const profile = paperclipRunnerProfileForHarness(brandType);
|
||||
const nativeSchema = profile ? {
|
||||
...profile,
|
||||
...(profile.provider === "acpx" ? { acpxPermissionMode: "approve-all" } : {}),
|
||||
...(brandType === "pi_local" && effort ? { piThinkingLevel: effort } : {}),
|
||||
} : {};
|
||||
const config = getUIAdapter(preserveNativeHarness && runner === "paperclip" && brandType !== "pi_local" ? "paperclip_runner" : adapterType).buildAdapterConfig(preserveNativeHarness && runner === "paperclip" ? { ...values, adapterSchemaValues: nativeSchema } : values);
|
||||
if (preserveNativeHarness && runner === "paperclip" && profile) {
|
||||
Object.assign(config, nativeSchema, profile.provider === "opencode" ? { opencodePermissionMode: "allow" } : profile.provider === "codex" ? { codexPermissionMode: "never" } : {}, { lifecycleMode: "per_turn" });
|
||||
}
|
||||
if (isRunner)
|
||||
Object.assign(config, {
|
||||
provider: (runnerProvider === "claude" || runnerProvider === "grok" || runnerProvider === "copilot") ? "acpx" : runnerProvider,
|
||||
...((runnerProvider === "claude" || runnerProvider === "grok" || runnerProvider === "copilot") ? { acpxAgent: runnerProvider } : {}),
|
||||
provider: ["claude", "grok", "copilot", "pi"].includes(runnerProvider) ? "acpx" : runnerProvider,
|
||||
...(["claude", "grok", "copilot", "pi"].includes(runnerProvider) ? { acpxAgent: runnerProvider } : {}),
|
||||
...(model ? { model } : {}),
|
||||
});
|
||||
if (brandType === "opencode_local" && created) {
|
||||
if (multiProvider && created) {
|
||||
// The simplified setup page does not edit these saved runtime policies.
|
||||
const saved = created.adapterConfig;
|
||||
for (const [key, value] of Object.entries(saved)) {
|
||||
if (!(key in config)) config[key] = value;
|
||||
}
|
||||
for (const key of ["dangerouslySkipPermissions", "opencodePermissionMode", "lifecycleMode", "idleTimeoutMs"]) {
|
||||
for (const key of ["dangerouslySkipPermissions", "opencodePermissionMode", "acpxPermissionMode", "acpxSessionMode", "lifecycleMode", "idleTimeoutMs"]) {
|
||||
if (saved[key] !== undefined) config[key] = saved[key];
|
||||
}
|
||||
if (brandType === "pi_local" && created.adapterType === "paperclip_runner" && effort) {
|
||||
config.piThinkingLevel = effort;
|
||||
delete config.thinking;
|
||||
}
|
||||
config.model = model;
|
||||
config.env = { ...((saved.env as object) ?? {}), ...((config.env as object) ?? {}) };
|
||||
}
|
||||
@@ -544,8 +555,8 @@ function Setup({
|
||||
role: existing.length ? "general" : "ceo", ...(leader ? { reportsTo: leader.id } : {}),
|
||||
adapterType: requestAdapterType, runner,
|
||||
adapterConfig: requestAdapterType === "paperclip_runner" ? {
|
||||
provider: ["claude", "grok", "copilot"].includes(runnerProvider) ? "acpx" : runnerProvider,
|
||||
...(["claude", "grok", "copilot"].includes(runnerProvider) ? { acpxAgent: runnerProvider } : {}),
|
||||
provider: ["claude", "grok", "copilot", "pi"].includes(runnerProvider) ? "acpx" : runnerProvider,
|
||||
...(["claude", "grok", "copilot", "pi"].includes(runnerProvider) ? { acpxAgent: runnerProvider } : {}),
|
||||
} : {}, defaultEnvironmentId: environmentOverride || (forced.forced || managedOnly ? environmentId : null),
|
||||
runtimeConfig: buildNewAgentRuntimeConfig({ heartbeatEnabled: false }),
|
||||
}).then(agent => {
|
||||
@@ -562,9 +573,9 @@ function Setup({
|
||||
const agent = savedAgent.data;
|
||||
if (agent.pauseReason !== "setup") { setScreen("saved"); return; }
|
||||
const config = agent.adapterConfig;
|
||||
if (brandType === "opencode_local") setRunner(agent.adapterType === "paperclip_runner" ? "paperclip" : "legacy");
|
||||
if (multiProvider) setRunner(agent.adapterType === "paperclip_runner" ? "paperclip" : "legacy");
|
||||
setModel(typeof config.model === "string" ? config.model : "");
|
||||
setEffort(String(config.modelReasoningEffort ?? config.reasoningEffort ?? config.effort ?? config.thinkingEffort ?? ""));
|
||||
setEffort(String(config.modelReasoningEffort ?? config.reasoningEffort ?? config.effort ?? config.thinkingEffort ?? config.piThinkingLevel ?? config.thinking ?? ""));
|
||||
setEnvironmentOverride(agent.defaultEnvironmentId ?? "");
|
||||
setRepository(typeof config.repoUrl === "string" ? config.repoUrl : "");
|
||||
setBranch(typeof config.repoStartingRef === "string" ? config.repoStartingRef : "");
|
||||
@@ -662,7 +673,7 @@ function Setup({
|
||||
companyId,
|
||||
adapterType: requestAdapterType,
|
||||
providerAdapter: brandType,
|
||||
...(["codex_local", "claude_local", "opencode_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}),
|
||||
...(["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}),
|
||||
adapterConfig: config,
|
||||
testCredentials: pendingCredentials(nextConnection),
|
||||
aiConnection: runtimeAiBinding ?? nextConnection?.aiConnection,
|
||||
@@ -717,7 +728,7 @@ function Setup({
|
||||
role: created.role,
|
||||
reportsTo: created.reportsTo,
|
||||
adapterType: requestAdapterType,
|
||||
...(["codex_local", "claude_local", "opencode_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}),
|
||||
...(["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}),
|
||||
adapterConfig: config,
|
||||
defaultEnvironmentId:
|
||||
environmentOverride ||
|
||||
@@ -983,7 +994,7 @@ function Setup({
|
||||
}).catch(cause => setError(cause instanceof Error ? cause.message : "Could not save agent setup."));
|
||||
}}
|
||||
/></fieldset> : <p role="status" className="text-sm text-muted-foreground">Loading connection settings…</p>}
|
||||
{["codex_local", "claude_local", "opencode_local"].includes(adapterType) && <details className="mt-5 space-y-3">
|
||||
{["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) && <details className="mt-5 space-y-3">
|
||||
<summary className="cursor-pointer text-sm text-muted-foreground">Advanced</summary>
|
||||
<AgentRunnerSelect value={runner} disabled={busy}
|
||||
pending={adapters.isFetching} error={adapters.error?.message} onRetry={() => { void adapters.refetch(); }}
|
||||
@@ -1336,7 +1347,7 @@ function Setup({
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
{["codex_local", "claude_local", "opencode_local"].includes(adapterType) && <details className="space-y-3">
|
||||
{["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) && <details className="space-y-3">
|
||||
<summary className="cursor-pointer text-sm text-muted-foreground">Advanced</summary>
|
||||
<AgentRunnerSelect value={runner} disabled={busy}
|
||||
pending={adapters.isFetching} error={adapters.error?.message} onRetry={() => { void adapters.refetch(); }}
|
||||
|
||||
@@ -1227,6 +1227,26 @@ describe("CompanyImport", () => {
|
||||
expect(lastImportMeta().adapterOverrides?.coder).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps native Pi identity and saved policies with the experimental flag off", async () => {
|
||||
mockAdaptersApi.list.mockResolvedValue([
|
||||
{ type: "claude_local", disabled: false },
|
||||
{ type: "pi_local", disabled: false, supportedRunners: ["paperclip", "legacy"], defaultRunner: "paperclip" },
|
||||
{ type: "paperclip_runner", disabled: false },
|
||||
]);
|
||||
const preview = buildMixedAdapterPreviewResult();
|
||||
preview.manifest.agents[0]!.adapterType = "paperclip_runner";
|
||||
preview.manifest.agents[0]!.adapterConfig = { provider: "acpx", acpxAgent: "pi", model: "provider/model", piThinkingLevel: "max", acpxPermissionMode: "approve-reads", lifecycleMode: "warm" };
|
||||
mockCompaniesApi.importPreview.mockResolvedValue(preview);
|
||||
await renderPage();
|
||||
await enterGithubUrl();
|
||||
await clickButton(text => text === "Preview import");
|
||||
expect(findAdapterSelects()[0]!.value).toBe("pi_local");
|
||||
expect(container.textContent).not.toContain("requires its experimental feature to be enabled");
|
||||
await clickButton(text => text.startsWith("Import 3 file"));
|
||||
await settle();
|
||||
expect(lastImportMeta().adapterOverrides?.coder).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ sourceType: "codex_local", sourceRunner: "legacy" as const, runner: "paperclip" as const, sourcePolicy: {}, expectedBypass: undefined },
|
||||
{ sourceType: "codex_local", sourceRunner: undefined, runner: "legacy" as const, sourcePolicy: { dangerouslyBypassApprovalsAndSandbox: false }, expectedBypass: false },
|
||||
|
||||
@@ -576,7 +576,7 @@ function importAdapterValues(
|
||||
sourceAdapterType?: string,
|
||||
runner?: AgentRunnerChoice,
|
||||
): CreateConfigValues {
|
||||
const nativeHarness = sourceAdapterType === "paperclip_runner" && ["codex_local", "claude_local", "opencode_local"].includes(choice);
|
||||
const nativeHarness = sourceAdapterType === "paperclip_runner" && ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(choice);
|
||||
const nativeCodex = nativeHarness && choice === "codex_local";
|
||||
const savedBypass = sourceConfig?.dangerouslyBypassApprovalsAndSandbox
|
||||
?? (nativeCodex ? undefined : sourceConfig?.dangerouslyBypassSandbox);
|
||||
@@ -594,6 +594,7 @@ function importAdapterValues(
|
||||
: resolveCodexLocalCliPolicy(sourceConfig).bypass,
|
||||
} : {}),
|
||||
...(sourceConfig && choice === "opencode_local" ? { model: typeof sourceConfig.model === "string" ? sourceConfig.model : "" } : {}),
|
||||
...(sourceConfig && choice === "pi_local" ? { model: typeof sourceConfig.model === "string" ? sourceConfig.model : "", thinkingEffort: String(sourceConfig.piThinkingLevel ?? sourceConfig.thinking ?? "") } : {}),
|
||||
...(sourceConfig && choice === "claude_local" ? { model: typeof sourceConfig.model === "string" ? sourceConfig.model : "", thinkingEffort: String(sourceConfig.modelReasoningEffort ?? sourceConfig.reasoningEffort ?? sourceConfig.effort ?? "") } : {}),
|
||||
...(nativeHarness && sourceConfig ? {
|
||||
cwd: typeof sourceConfig.cwd === "string" ? sourceConfig.cwd : "",
|
||||
@@ -1683,7 +1684,7 @@ export function CompanyImport() {
|
||||
const nativeCodex = a.adapterType === "paperclip_runner"
|
||||
&& (a.adapterConfig?.provider === "codex" || a.adapterConfig?.provider === undefined);
|
||||
const nativeHarness = nativeCodex || (a.adapterType === "paperclip_runner"
|
||||
&& ["claude_local", "opencode_local"].includes(agentHarnessType(a.adapterType, a.adapterConfig)));
|
||||
&& ["claude_local", "opencode_local", "pi_local"].includes(agentHarnessType(a.adapterType, a.adapterConfig)));
|
||||
const adapterChoice = a.adapterType === "paperclip_runner" && a.adapterConfig?.provider === "openai_dot"
|
||||
? "openai_dot" : a.adapterType === "paperclip_runner" && a.adapterConfig?.provider === "muse" ? "muse" : nativeHarness ? agentHarnessType(a.adapterType, a.adapterConfig) : a.adapterType;
|
||||
let fallbackAdapterType: string | null = null;
|
||||
@@ -1737,7 +1738,7 @@ export function CompanyImport() {
|
||||
if (selectedType === agent.adapterChoice && !adapterConfigEditedSlugs.has(agent.slug)
|
||||
&& (!configVals || configVals.runner === importAdapterValues(selectedType, agent.adapterConfig, agent.adapterType, agent.runner).runner)) continue;
|
||||
const retainsNativeHarness = agent.adapterType === "paperclip_runner"
|
||||
&& selectedType === agent.adapterChoice && ["codex_local", "claude_local", "opencode_local"].includes(selectedType);
|
||||
&& selectedType === agent.adapterChoice && ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(selectedType);
|
||||
const apiType = selectedType === "openai_dot" || selectedType === "muse" || retainsNativeHarness ? "paperclip_runner" : selectedType;
|
||||
const override: CompanyPortabilityAdapterOverride = { adapterType: apiType };
|
||||
if (configVals?.runner !== undefined) override.runner = configVals.runner;
|
||||
@@ -1758,7 +1759,7 @@ export function CompanyImport() {
|
||||
const build = getUIAdapter(nativeClaudeForm ? "paperclip_runner" : configVals.adapterType).buildAdapterConfig;
|
||||
const edited = build(configVals);
|
||||
if (agent.adapterType === "paperclip_runner" && selectedType === agent.adapterChoice
|
||||
&& ["codex_local", "claude_local", "opencode_local"].includes(selectedType) && configVals.adapterType === selectedType
|
||||
&& ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(selectedType) && configVals.adapterType === selectedType
|
||||
&& agentHarnessType("paperclip_runner", configVals.adapterSchemaValues) === selectedType) {
|
||||
const original = importAdapterValues(selectedType, agent.adapterConfig, agent.adapterType, configVals.runner);
|
||||
const baseline = build(original);
|
||||
|
||||
Reference in new issue
Block a user