mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 01:41:42 +02:00
## Thinking Path > - Paperclip manages AI agents and their work. > - Users choose a harness during agent setup. > - Claude, Codex, and OpenCode already receive their qualified Runner default. > - Pi has a qualified native integration but new Pi agents still use the direct adapter. > - This change gives new Pi agents the native default on supported targets. > - Saved agents keep their runner and compatible settings. Legacy remains available in Advanced. ## Linked Issues or Issue Description Refs #17120, #14921, #14922, #14923, #14924, and #14956. Related generic default work: #15422. This PR is the separate Pi follow-up to the merged OpenCode change. **Current behavior** Choosing Pi for a new agent saves the direct Pi adapter. The native Pi integration remains behind the generic experimental flag. **Proposed behavior** Default new Pi agents to ACPX Pi on macOS ARM64/x64 and GNU/Linux x64 with supported glibc. Keep unsupported targets and explicit Legacy selections on the direct adapter. Require an explicit provider/model before test or hire. Permit model-free inactive setup drafts. **Reason and benefit** Users choose Pi once and receive its qualified runner. Saved agents retain their identity and settings. Conversions reject unsupported legacy thinking and launch settings instead of dropping them. ## What Changed - Add Pi to the shared platform/default contract and server creation selection. - Admit native Pi without the generic experimental flag. - Preserve provider models, credentials, workspace settings, exact thinking, and saved native policies. - Add Pi runner controls, branding, setup resume, onboarding, built-in configuration, and import handling. - Add managed Pi OpenRouter routing and matching native provider API-key accounts. Keep subscription and custom gateway exclusions. Clear alternate credentials and reject custom Pi provider overrides. - Keep Pi draft models empty instead of applying Claude's default model. - Verify setup against the pinned Pi runtime. For native Pi on the official OpenRouter route, reverify the API key live without requiring a global Legacy Pi CLI. - Match Pi’s bounded controller close window to its native snapshot cleanup and lifetime verification, preserving explicit caller deadlines and the mandatory drain/suspension proof. - Bind Pi sidecars to the package that owns the pinned native assets, including source and vendored server layouts. Other providers retain their existing package authority. - Keep protocol-owned finish/block tools out of the ACPX forwarded catalog. The trusted bridge installs these tools itself; the complete Rust authorization catalog and terminal receipt ledger remain intact. This fixes actual Pi session startup and applies to other ACPX consumers. - Advance Copilot’s shared-source attestation to v58 because it pins the shared files touched by Pi. Preserve immutable v57 bytes and historical decoding; Pi’s own release identity is unchanged. - Add regression coverage and update the paired maps and developer documentation. ## Verification - Passed: full repository typecheck and full production build on final head656d304783, including the generated attestation repair. - Passed: focused shared/server/UI suites covering default selection, platform fallback, conversion preservation, inactive drafts, managed account compatibility, credential isolation, OpenRouter routing/model IDs, exact thinking controls, and saved-account selection. Relevant batches include 375 server/conversion tests, 267 account/routing tests, and 110 UI setup/account tests. - Passed: 179 attestation, historical-profile, qualified-profile, and native-execution contract tests after CI identified stale Copilot source hashes. The identity generator check passes; the only delta after the passing Pi browser run is this Copilot attestation/history repair. - Passed: 145 managed-account tests, including actual service preparation and API-key adoption/rejection for native Pi without the Legacy CLI; 6 pinned-installation probe tests. - Red then green: an actual task failed session.open because its 54-operation catalog included the bridge-owned terminal tools. The regression with those tools failed before the filter; all 399 Rust library tests pass after it. The full product retry exposed an additional package-authority mismatch. Four authority tests failed before that repair; 11 authority checks and 14 installation/asset checks pass afterward. Direct real Pi session initialization with the corrected package root and catalog succeeds. No model prompt was sent by that diagnostic. - Red then green: real Pi posted Hello from Pi but its controller closed before native cleanup could finish. The cleanup-budget test failed with the old default and passes with the bounded Pi window. All 46 selected suspension, drain, and package-authority tests pass after building their native fixture. - Passed: token gates, paired map checks, Rust formatting, and git diff --check. - Passed real browser acceptance on macOS ARM64, generic native runner flag disabled, sourcea433748a12(Pi code unchanged afterward): the UI selects Paperclip Runner by default, retains Pi and the selected model/account, and verifies the pinned runtime plus live OpenRouter key. A fresh UI-created task, assigned to the UI-created native Pi agent, posted exactly `Hello from Pi` and reached Done without retry or restart. The native run and workspace finalization succeeded; the response and Done persisted after Reload. - Evidence: 8 preserved browser attempts, including the final passing 18-step journey with all screenshots. Prior failures remain recorded. The earlier failed checkpoint run recovered on server restart; that recovery is separate from the fresh final task’s clean success. - Preserved browser failures: missing account control/inherited Claude draft model; incompatible managed-account/model-prefix handling; generic npm probe for Pi; Legacy CLI hello check; duplicate terminal tools and wrong provider-package authority at actual native session.open; a real Hello from Pi comment followed by premature checkpoint cleanup timeout. Each failure has retained screenshots/logs and a corresponding focused repair. The fresh final task proves these repairs end to end. - The exact Runner Vitest shard was also run locally after the attestation fix: the stale-hash failure is gone; 1,833 tests passed, 10 skipped, and two Codex retained-control settlement scenarios failed with `native_cleanup_maintenance_unproven` during terminal ACK/suspension replay. Both scenarios passed in an isolated rerun (2/2), and the same 19-test file passed in the preceding Linux CI run. The precise full-shard timing cause is not established. These Codex scenarios are outside the changed Pi paths; the original local failures are retained; no complete local Runner-shard pass is claimed. The corresponding Runner shard now passes on the final head in hosted Linux CI. All 60 final-head check runs are now terminal and clean (55 successful, 1 neutral, 4 skipped), and the legacy Snyk status is successful. - Local full repository tests were attempted but not completed: provisioning briefly confused a workspace symlink, then a later run was stopped before browser-driven repairs. No full local-suite pass is claimed. Final-head hosted CI is green and fresh Greptile review is 5/5 with all review threads resolved; earlier jobs canceled by runner shutdown are not counted as a pass. ## Risks - Pi cleanup has a finite 45-second default window, accounting for the native 30-second snapshot cleanup and 5-second lifetime fence. Other providers and explicit caller deadlines retain their prior behavior. - Pi requires explicit operator setup with `paperclipai runtime setup pi` on the execution host. This PR does not add automatic downloads. - Linux ARM64, Windows, musl, and unqualified targets retain Legacy. - Legacy thinking levels minimal, medium, and xhigh cannot be translated exactly. Native max thinking, warm lifecycle, or restricted permissions cannot be dropped during a return to Legacy. - Copilot’s bound profile advances to v58 with exact current launch admission; v57 remains decodable for recovery. No historical identity is rewritten. - Existing agents and immutable run descriptors are not migrated. Cloud Pi availability is unchanged. ## Model Used OpenAI GPT-6 through Codex with reasoning, code editing, command execution, and browser use. The exact serving model ID and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
554 lines
20 KiB
TypeScript
554 lines
20 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import { BUILTIN_ADAPTER_TYPES } from "../../adapters/builtin-adapter-types.js";
|
|
import {
|
|
NativeRunnerSelectionError,
|
|
NativeRuntimeEligibilityError,
|
|
isRunnerIngressAuthorized,
|
|
resolveHeartbeatNativeRuntimeMode,
|
|
resolveHeartbeatRuntimeMode,
|
|
resolveNativeRuntimeMode,
|
|
} from "./runtime-mode.js";
|
|
|
|
const eligible = {
|
|
enabled: true,
|
|
runtimeConfig: {},
|
|
adapterConfig: { provider: "codex" },
|
|
agent: { status: "running", adapterType: "paperclip_runner" },
|
|
issue: { id: "issue", workMode: "standard" },
|
|
target: { kind: "local" },
|
|
workspaceId: "workspace",
|
|
} as const;
|
|
|
|
describe("resolveNativeRuntimeMode", () => {
|
|
it("keeps every direct built-in adapter outside native arbitration", () => {
|
|
for (const adapterType of BUILTIN_ADAPTER_TYPES) {
|
|
if (adapterType === "paperclip_runner") continue;
|
|
expect(resolveNativeRuntimeMode({
|
|
...eligible,
|
|
enabled: false,
|
|
runtimeConfig: {
|
|
nativeRunner: {
|
|
mode: "native",
|
|
backend: "codex_app_server",
|
|
protocolVersion: 1,
|
|
},
|
|
},
|
|
agent: { ...eligible.agent, adapterType },
|
|
})).toEqual({
|
|
kind: "legacy",
|
|
resolverVersion: "phase6-v1",
|
|
reason: "direct_adapter",
|
|
});
|
|
}
|
|
});
|
|
|
|
it("admits fresh native Codex when the experimental rollout is disabled", () => {
|
|
expect(resolveNativeRuntimeMode({
|
|
...eligible,
|
|
enabled: false,
|
|
})).toMatchObject({ kind: "native", profile: { backend: "codex_app_server" } });
|
|
});
|
|
|
|
it("admits saved-token Copilot with the generic Runner rollout disabled", () => {
|
|
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 ["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" }));
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
[false, false],
|
|
[false, true],
|
|
[true, false],
|
|
])("retains the Grok Bot gate with runner enabled=%s and Grok enabled=%s", (enabled, grokEnabled) => {
|
|
expect(() => resolveNativeRuntimeMode({
|
|
...eligible,
|
|
enabled,
|
|
grokEnabled,
|
|
adapterConfig: {
|
|
provider: "grok_bot",
|
|
externalBindingId: "00000000-0000-4000-8000-000000000001",
|
|
allowUnmeteredProvider: true,
|
|
},
|
|
})).toThrow(expect.objectContaining({ code: "paperclip_runner_grok_disabled" }));
|
|
});
|
|
|
|
it("admits a bound Grok Bot when its existing experimental gates are enabled", () => {
|
|
expect(resolveNativeRuntimeMode({
|
|
...eligible,
|
|
grokEnabled: true,
|
|
adapterConfig: {
|
|
provider: "grok_bot",
|
|
externalBindingId: "00000000-0000-4000-8000-000000000001",
|
|
allowUnmeteredProvider: true,
|
|
},
|
|
})).toMatchObject({ kind: "native", profile: { backend: "grok_bot_mcp" } });
|
|
});
|
|
|
|
it("admits OpenCode without the experimental flag", () => {
|
|
expect(resolveNativeRuntimeMode({ ...eligible, enabled: false,
|
|
adapterConfig: { provider: "opencode", model: "openrouter/anthropic/claude-sonnet-4.6" },
|
|
})).toMatchObject({ kind: "native", profile: { backend: "opencode_server" } });
|
|
});
|
|
|
|
it("still rejects Grok while the rollout flag is disabled", () => {
|
|
expect(() => resolveNativeRuntimeMode({
|
|
...eligible,
|
|
enabled: false,
|
|
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,
|
|
runtimeConfig: {},
|
|
adapterConfig: { provider: "claude" },
|
|
agent: { ...eligible.agent, adapterType: "paperclip_runner" },
|
|
})).toThrow(expect.objectContaining({
|
|
code: "paperclip_runner_provider_unsupported",
|
|
}));
|
|
});
|
|
|
|
it("admits fresh local, managed, and qualified ACPX profiles", () => {
|
|
expect(resolveNativeRuntimeMode({
|
|
...eligible,
|
|
adapterConfig: { provider: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731" },
|
|
})).toMatchObject({
|
|
kind: "native",
|
|
profile: { backend: "opencode_server" },
|
|
});
|
|
expect(resolveNativeRuntimeMode({
|
|
...eligible,
|
|
adapterConfig: {
|
|
provider: "claude_managed",
|
|
managedProfileId: "managed-primary",
|
|
managedAgentsRetentionAcknowledged: true,
|
|
},
|
|
})).toMatchObject({
|
|
kind: "native",
|
|
profile: { backend: "claude_managed_agents_api" },
|
|
});
|
|
expect(resolveNativeRuntimeMode({
|
|
...eligible,
|
|
adapterConfig: {
|
|
provider: "aws_agentcore",
|
|
agentCoreProfileId: "agentcore-primary",
|
|
agentCoreRetentionAcknowledged: true,
|
|
},
|
|
})).toMatchObject({
|
|
kind: "native",
|
|
profile: { backend: "aws_agentcore_harness_api" },
|
|
});
|
|
expect(resolveNativeRuntimeMode({
|
|
...eligible,
|
|
adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5" },
|
|
})).toMatchObject({
|
|
kind: "native",
|
|
profile: { backend: "acpx_runtime" },
|
|
});
|
|
expect(resolveNativeRuntimeMode({
|
|
...eligible,
|
|
adapterConfig: { provider: "acpx", acpxAgent: "codex", model: "gpt-5.6-sol" },
|
|
})).toMatchObject({
|
|
kind: "native",
|
|
profile: { backend: "acpx_runtime" },
|
|
});
|
|
expect(resolveNativeRuntimeMode({
|
|
...eligible,
|
|
adapterConfig: { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" },
|
|
})).toMatchObject({
|
|
kind: "native",
|
|
profile: { backend: "acpx_runtime" },
|
|
});
|
|
});
|
|
|
|
it("rejects malformed OpenCode and unregistered ACPX profiles", () => {
|
|
expect(() => resolveNativeRuntimeMode({
|
|
...eligible,
|
|
adapterConfig: { provider: "opencode", model: "gpt-5.6-sol" },
|
|
})).toThrow(expect.objectContaining({
|
|
code: "paperclip_runner_opencode_model_invalid",
|
|
}));
|
|
for (const acpxAgent of ["unregistered-acpx-agent"]) {
|
|
expect(() => resolveNativeRuntimeMode({
|
|
...eligible,
|
|
adapterConfig: { provider: "acpx", acpxAgent, model: "explicit-provider-model" },
|
|
})).toThrow(expect.objectContaining({
|
|
code: "paperclip_runner_acpx_agent_unavailable",
|
|
}));
|
|
}
|
|
expect(() => resolveNativeRuntimeMode({
|
|
...eligible,
|
|
adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "claude-opus-5" },
|
|
})).not.toThrow();
|
|
});
|
|
|
|
it("rejects incomplete managed-provider selections before a run is persisted", () => {
|
|
expect(() => resolveNativeRuntimeMode({
|
|
...eligible,
|
|
adapterConfig: {
|
|
provider: "claude_managed",
|
|
managedProfileId: "managed-primary",
|
|
},
|
|
})).toThrow(expect.objectContaining({
|
|
code: "paperclip_runner_claude_managed_retention_required",
|
|
}));
|
|
expect(() => resolveNativeRuntimeMode({
|
|
...eligible,
|
|
adapterConfig: {
|
|
provider: "aws_agentcore",
|
|
agentCoreRetentionAcknowledged: true,
|
|
},
|
|
})).toThrow(expect.objectContaining({
|
|
code: "paperclip_runner_aws_agentcore_profile_required",
|
|
}));
|
|
});
|
|
|
|
it("uses adapterConfig as the fresh provider authority", () => {
|
|
expect(resolveNativeRuntimeMode({
|
|
...eligible,
|
|
runtimeConfig: {
|
|
nativeRunner: { provider: "opencode" },
|
|
},
|
|
adapterConfig: { provider: "codex" },
|
|
})).toMatchObject({
|
|
kind: "native",
|
|
profile: { backend: "codex_app_server" },
|
|
});
|
|
});
|
|
|
|
it("preserves legacy as the default and as the kill-switch behavior", () => {
|
|
const direct = {
|
|
...eligible,
|
|
agent: { ...eligible.agent, adapterType: "codex_local" },
|
|
runtimeConfig: { nativeRunner: { mode: "native", backend: "codex_app_server", protocolVersion: 1 } },
|
|
};
|
|
expect(resolveNativeRuntimeMode(direct)).toEqual(expect.objectContaining({
|
|
kind: "legacy",
|
|
reason: "direct_adapter",
|
|
}));
|
|
expect(resolveNativeRuntimeMode({ ...direct, enabled: false })).toEqual(expect.objectContaining({
|
|
kind: "legacy",
|
|
reason: "direct_adapter",
|
|
}));
|
|
});
|
|
|
|
it("selects native only for an eligible explicit profile", () => {
|
|
expect(resolveNativeRuntimeMode(eligible)).toEqual(expect.objectContaining({
|
|
kind: "native",
|
|
reason: "eligible_opt_in",
|
|
}));
|
|
});
|
|
|
|
it("keeps a persisted experimental run native while the flag rejects a fresh start", () => {
|
|
const disabled = { ...eligible, enabled: false,
|
|
adapterConfig: { provider: "acpx", acpxAgent: "grok", model: "openrouter/deepseek/deepseek-v4-flash-0731" },
|
|
};
|
|
expect(resolveHeartbeatNativeRuntimeMode({
|
|
...disabled,
|
|
persisted: {
|
|
runtimeMode: "native",
|
|
runtimeModeReason: "eligible_opt_in",
|
|
runtimeModeResolvedAt: new Date(),
|
|
driverKind: "acpx_runtime",
|
|
},
|
|
})).toEqual(expect.objectContaining({
|
|
kind: "native",
|
|
reason: "eligible_opt_in",
|
|
authorityDecision: expect.objectContaining({ reasonCode: "live_continuation_registered" }),
|
|
}));
|
|
expect(() => resolveHeartbeatNativeRuntimeMode({
|
|
...disabled,
|
|
persisted: { runtimeMode: null, runtimeModeReason: null, runtimeModeResolvedAt: null },
|
|
})).toThrow(expect.objectContaining({
|
|
code: "paperclip_runner_rollout_disabled",
|
|
}));
|
|
});
|
|
|
|
it("authorizes ingress from the native runtime decision without a second flag", () => {
|
|
const freshNative = resolveHeartbeatNativeRuntimeMode({
|
|
...eligible,
|
|
persisted: {
|
|
runtimeMode: null,
|
|
runtimeModeReason: null,
|
|
runtimeModeResolvedAt: null,
|
|
},
|
|
});
|
|
const persistedNative = resolveHeartbeatNativeRuntimeMode({
|
|
...eligible,
|
|
enabled: false,
|
|
persisted: {
|
|
runtimeMode: "native",
|
|
runtimeModeReason: "eligible_opt_in",
|
|
runtimeModeResolvedAt: new Date(),
|
|
},
|
|
});
|
|
const directLegacy = resolveHeartbeatNativeRuntimeMode({
|
|
...eligible,
|
|
enabled: false,
|
|
agent: { ...eligible.agent, adapterType: "codex_local" },
|
|
persisted: {
|
|
runtimeMode: null,
|
|
runtimeModeReason: null,
|
|
runtimeModeResolvedAt: null,
|
|
},
|
|
});
|
|
|
|
expect(isRunnerIngressAuthorized(freshNative)).toBe(true);
|
|
expect(isRunnerIngressAuthorized(persistedNative)).toBe(true);
|
|
expect(isRunnerIngressAuthorized(directLegacy)).toBe(false);
|
|
});
|
|
|
|
it.each(["paused", "terminated", "pending_approval"])(
|
|
"refuses persisted native recovery for a %s agent",
|
|
(status) => {
|
|
expect(() => resolveHeartbeatNativeRuntimeMode({
|
|
...eligible,
|
|
enabled: false,
|
|
agent: { ...eligible.agent, status },
|
|
persisted: {
|
|
runtimeMode: "native",
|
|
runtimeModeReason: "eligible_opt_in",
|
|
runtimeModeResolvedAt: new Date(),
|
|
driverKind: "codex_app_server",
|
|
},
|
|
})).toThrow(expect.objectContaining({
|
|
code: "paperclip_runner_agent_ineligible",
|
|
}));
|
|
},
|
|
);
|
|
|
|
it("fails closed for an unknown persisted driver", () => {
|
|
expect(() => resolveHeartbeatNativeRuntimeMode({
|
|
...eligible,
|
|
enabled: false,
|
|
persisted: {
|
|
runtimeMode: "native",
|
|
runtimeModeReason: "eligible_opt_in",
|
|
runtimeModeResolvedAt: new Date(),
|
|
driverKind: "unknown_driver",
|
|
},
|
|
})).toThrow(expect.objectContaining({
|
|
code: "paperclip_runner_driver_unsupported",
|
|
}));
|
|
});
|
|
|
|
it("does not recover a native run through a direct adapter", () => {
|
|
expect(() => resolveHeartbeatNativeRuntimeMode({
|
|
...eligible,
|
|
enabled: false,
|
|
agent: { ...eligible.agent, adapterType: "codex_local" },
|
|
persisted: {
|
|
runtimeMode: "native",
|
|
runtimeModeReason: "eligible_opt_in",
|
|
runtimeModeResolvedAt: new Date(),
|
|
driverKind: "codex_app_server",
|
|
},
|
|
})).toThrow(expect.objectContaining({
|
|
code: "paperclip_runner_adapter_binding_mismatch",
|
|
}));
|
|
});
|
|
|
|
it("keeps a persisted OpenCode recovery on its immutable driver", () => {
|
|
expect(resolveHeartbeatNativeRuntimeMode({
|
|
...eligible,
|
|
enabled: false,
|
|
persisted: {
|
|
runtimeMode: "native",
|
|
runtimeModeReason: "eligible_opt_in",
|
|
runtimeModeResolvedAt: new Date(),
|
|
driverKind: "opencode_server",
|
|
},
|
|
})).toEqual(expect.objectContaining({
|
|
profile: { mode: "native", backend: "opencode_server", protocolVersion: 1 },
|
|
}));
|
|
});
|
|
|
|
it.each([
|
|
"claude_managed_agents_api",
|
|
"aws_agentcore_harness_api",
|
|
] as const)("keeps a persisted %s recovery on its immutable driver", (driverKind) => {
|
|
expect(resolveHeartbeatNativeRuntimeMode({
|
|
...eligible,
|
|
enabled: false,
|
|
persisted: {
|
|
runtimeMode: "native",
|
|
runtimeModeReason: "eligible_opt_in",
|
|
runtimeModeResolvedAt: new Date(),
|
|
driverKind,
|
|
},
|
|
})).toEqual(expect.objectContaining({
|
|
profile: { mode: "native", backend: driverKind, protocolVersion: 1 },
|
|
}));
|
|
});
|
|
|
|
it("keeps a persisted ACPX recovery even when fresh selection is disabled", () => {
|
|
expect(resolveHeartbeatNativeRuntimeMode({
|
|
...eligible,
|
|
enabled: false,
|
|
adapterConfig: {
|
|
provider: "acpx",
|
|
acpxAgent: "pi",
|
|
model: "historical",
|
|
},
|
|
persisted: {
|
|
runtimeMode: "native",
|
|
runtimeModeReason: "eligible_opt_in",
|
|
runtimeModeResolvedAt: new Date(),
|
|
driverKind: "acpx_runtime",
|
|
},
|
|
})).toEqual(expect.objectContaining({
|
|
profile: { mode: "native", backend: "acpx_runtime", protocolVersion: 1 },
|
|
}));
|
|
});
|
|
|
|
it("rejects an explicit native profile outside the approved boundary", () => {
|
|
expect(resolveNativeRuntimeMode({ ...eligible, agent: { ...eligible.agent, adapterType: "claude_local" } }))
|
|
.toEqual(expect.objectContaining({ kind: "legacy", reason: "direct_adapter" }));
|
|
expect(() => resolveNativeRuntimeMode({ ...eligible, issue: { id: "issue", workMode: "skill_test" } }))
|
|
.toThrow(NativeRuntimeEligibilityError);
|
|
});
|
|
|
|
it("admits remote targets only through paperclip_runner", () => {
|
|
expect(resolveNativeRuntimeMode({
|
|
...eligible,
|
|
target: { kind: "remote" },
|
|
runtimeConfig: {},
|
|
adapterConfig: { provider: "codex" },
|
|
agent: { ...eligible.agent, adapterType: "paperclip_runner" },
|
|
})).toMatchObject({ kind: "native" });
|
|
expect(resolveNativeRuntimeMode({
|
|
...eligible,
|
|
target: { kind: "remote" },
|
|
agent: { ...eligible.agent, adapterType: "codex_local" },
|
|
})).toMatchObject({ kind: "legacy", reason: "direct_adapter" });
|
|
});
|
|
|
|
it("allows paperclip_runner to use a transient local workspace for projectless issues", () => {
|
|
expect(resolveNativeRuntimeMode({
|
|
...eligible,
|
|
workspaceId: null,
|
|
agent: { ...eligible.agent, adapterType: "paperclip_runner" },
|
|
runtimeConfig: {},
|
|
adapterConfig: { provider: "codex" },
|
|
})).toEqual(expect.objectContaining({ kind: "native" }));
|
|
});
|
|
|
|
it("admits planning only through paperclip_runner", () => {
|
|
expect(resolveNativeRuntimeMode({
|
|
...eligible,
|
|
issue: { id: "plan-issue", workMode: "planning" },
|
|
runtimeConfig: {},
|
|
adapterConfig: { provider: "codex" },
|
|
agent: { ...eligible.agent, adapterType: "paperclip_runner" },
|
|
})).toMatchObject({ kind: "native", profile: { backend: "codex_app_server" } });
|
|
expect(resolveNativeRuntimeMode({
|
|
...eligible,
|
|
issue: { id: "plan-issue", workMode: "planning" },
|
|
agent: { ...eligible.agent, adapterType: "codex_local" },
|
|
})).toEqual(expect.objectContaining({ kind: "legacy", reason: "direct_adapter" }));
|
|
});
|
|
|
|
it("admits ask mode through paperclip_runner while preserving the legacy native boundary", () => {
|
|
expect(resolveNativeRuntimeMode({
|
|
...eligible,
|
|
issue: { id: "ask-issue", workMode: "ask" },
|
|
runtimeConfig: {},
|
|
adapterConfig: { provider: "codex" },
|
|
agent: { ...eligible.agent, adapterType: "paperclip_runner" },
|
|
})).toMatchObject({ kind: "native", profile: { backend: "codex_app_server" } });
|
|
expect(resolveNativeRuntimeMode({
|
|
...eligible,
|
|
issue: { id: "ask-issue", workMode: "ask" },
|
|
agent: { ...eligible.agent, adapterType: "codex_local" },
|
|
})).toEqual(expect.objectContaining({ kind: "legacy", reason: "direct_adapter" }));
|
|
});
|
|
});
|
|
|
|
const compatibilityInput = {
|
|
persisted: { runtimeMode: "legacy", runtimeModeResolvedAt: null },
|
|
enabled: true,
|
|
adapterConfig: { provider: "codex" },
|
|
agentStatus: "running",
|
|
issue: { workMode: "standard" },
|
|
executionTarget: { kind: "local" },
|
|
} as const;
|
|
|
|
describe("resolveHeartbeatRuntimeMode compatibility", () => {
|
|
it("keeps every direct built-in adapter on the legacy path", () => {
|
|
for (const adapterType of BUILTIN_ADAPTER_TYPES) {
|
|
if (adapterType === "paperclip_runner") continue;
|
|
expect(resolveHeartbeatRuntimeMode({
|
|
...compatibilityInput,
|
|
adapterType,
|
|
})).toEqual({
|
|
kind: "legacy",
|
|
resolverVersion: "paperclip-runner-v1",
|
|
reason: "direct_adapter",
|
|
});
|
|
}
|
|
});
|
|
|
|
it("preserves the original public result and error contracts", () => {
|
|
expect(resolveHeartbeatRuntimeMode({
|
|
...compatibilityInput,
|
|
adapterType: "paperclip_runner",
|
|
})).toEqual({
|
|
kind: "native",
|
|
resolverVersion: "paperclip-runner-v1",
|
|
reason: "explicit_paperclip_runner",
|
|
provider: "codex",
|
|
});
|
|
expect(resolveHeartbeatRuntimeMode({
|
|
...compatibilityInput,
|
|
enabled: false,
|
|
adapterType: "paperclip_runner",
|
|
persisted: { runtimeMode: "native", runtimeModeResolvedAt: new Date() },
|
|
})).toEqual({
|
|
kind: "native",
|
|
resolverVersion: "paperclip-runner-v1",
|
|
reason: "persisted_native_selection",
|
|
provider: "codex",
|
|
});
|
|
expect(() => resolveHeartbeatRuntimeMode({
|
|
...compatibilityInput,
|
|
enabled: false,
|
|
adapterType: "paperclip_runner",
|
|
adapterConfig: { provider: "acpx", acpxAgent: "grok", model: "openrouter/anthropic/claude-sonnet-4.6" },
|
|
})).toThrow(NativeRunnerSelectionError);
|
|
});
|
|
it("admits native Claude without enabling other ACPX providers", () => {
|
|
expect(resolveNativeRuntimeMode({ ...eligible, enabled: false, adapterConfig: { provider: "acpx", acpxAgent: "claude" } })).toMatchObject({ kind: "native", profile: { backend: "acpx_runtime" } });
|
|
expect(() => resolveNativeRuntimeMode({ ...eligible, enabled: false, adapterConfig: { provider: "acpx", acpxAgent: "grok" } })).toThrow(expect.objectContaining({ code: "paperclip_runner_rollout_disabled" }));
|
|
});
|
|
});
|
|
|
|
|
|
describe("Muse native selection", () => {
|
|
const muse = { ...eligible, museEnabled: true, adapterConfig: { provider: "muse", museBindingId: "00000000-0000-4000-8000-000000000001", allowUnmeteredProvider: true, lifecycleMode: "per_turn" } };
|
|
it("requires both Runner and Muse rollout gates for fresh work", () => {
|
|
expect(() => resolveNativeRuntimeMode({ ...muse, museEnabled: false })).toThrow("Enable Muse");
|
|
expect(() => resolveNativeRuntimeMode({ ...muse, enabled: false })).toThrow("disabled");
|
|
expect(resolveNativeRuntimeMode(muse)).toMatchObject({ kind: "native", profile: { backend: "muse_external" } });
|
|
});
|
|
it("rejects model, workspace and inherited Dot attachment grants", () => {
|
|
for (const extra of [{ model: "local-model" }, { dotWorkspaceAccess: true }, { dotAttachmentAccess: true }, { lifecycleMode: "warm" }]) {
|
|
expect(() => resolveNativeRuntimeMode({ ...muse, adapterConfig: { ...muse.adapterConfig, ...extra } })).toThrow("Muse requires");
|
|
}
|
|
});
|
|
});
|