mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 20:34:57 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agents use tools through company-scoped connections and provider sessions. > - Resolving a tool connection currently forces a fresh session even when the provider can load new tools into the existing conversation. > - A fresh provider conversation can receive too little history to continue the task. > - This pull request adds explicit tool-refresh capabilities and uses them in both runner paths. > - Fresh attempts receive bounded task history with source IDs and retrieval instructions. > - The benefit is that agents can continue the same task after a connection changes. ## Linked Issues or Issue Description **What happened?** A resolved tool connection forced a fresh provider conversation. The new conversation could lose the original goal and prior answers. Claude also rejected resume when only the MCP server set changed. **Expected behavior** Resume the provider conversation when its harness can refresh tools. When a fresh session is required, supply enough bounded history to continue the task. Preserve company, agent, task, workspace, model, instruction, and skill checks. **Steps to reproduce** 1. Start a conversation and agree on a task and its constraints. 2. Request and connect a tool needed for the task. 3. Continue the conversation after the connection resolves. 4. Check that the agent remembers the task and can use the new tool. **Paperclip version or commit** The bug was reproduced on master at `c46e41e81`. This branch is rebased on current master. **Deployment mode** Self-hosted server. Both legacy adapters and the native runner are affected. Related public work: Refs #13282 for task-backed conversations. Refs #13057 for the broader session-compaction proposal. Refs #14659 for another report about local CLI session continuity. This change fixes tool-connection continuation. Provider authentication repairs keep their existing recovery behavior. ## What Changed - Expose tool-refresh support in native harness descriptors and legacy adapter metadata. - Request tool refresh after connection resolution. Keep provider authentication repair as a fresh-session wake. - Reload current tools and credentials while retaining supported Claude, Codex, Grok, and other provider conversations. - Allow MCP-only changes during qualified native recovery. Keep all other compatibility checks. - Refresh managed-provider and ACPX tool bindings when attaching a new run. - Add a fresh-session handoff for both runner paths. Bound database reads, excerpts, and the final packet to 24,000 bytes. - Include the original request, recent messages, decisions, plans, prior answers, and source IDs. Mark omitted content. Apply reset boundaries, wake cutoffs, quarantine, and secret redaction. - Add regression tests and document the capabilities and handoff behavior. ## Verification - `pnpm -r typecheck` and `pnpm build` passed. Rust formatting passed. - Final review fixes passed 314 server tests, 333 adapter utility tests, 139 native-session runtime tests, and 12 managed-provider Rust tests. They verify historical quarantine, raised budgets across attachment, no history reads on successful resume, and handoff delivery on fresh retry. - Broader branch verification also passed 1,401 adapter utility tests, 1,047 runner TypeScript tests, 43 Grok adapter tests, and 311 Rust core tests. - Live Claude CLI and Grok ACP probes preserved the provider session ID, recalled a prior task constraint, and called a newly added read-only MCP tool. - GitHub CI passed on `b21486d18084a7aa4cafbe8e012f7cad6585d9cc`: 55 successful checks and 4 skipped checks. This includes all test shards, all eight browser shards, runner checks, and the Grok clean public npm install canary. [CI run](https://github.com/paperclipai/paperclip/actions/runs/37057514976). - A full local test attempt encountered a separate Git snapshot timeout. All affected local suites passed after the final edits, and the full CI test gates passed. - Review the capability matrix in `packages/paperclip-runner/README.md`. Repeat the four reproduction steps with a supported provider and with an unsupported harness. ## Risks - Provider tool refresh can fail. Existing recovery falls back to a fresh conversation where policy permits it. - A new transport can replace an old process while preserving the provider conversation. Tests cover current credentials and unchanged identity. - Long history can omit older context. Explicit markers and source IDs let the agent retrieve needed context within task scope. - Unknown and unqualified harnesses use the fresh-session path. No database migration is required. ## Model Used OpenAI Codex, GPT-6, with reasoning, tool use, code execution, and live provider testing. The exact model ID and context-window size are not exposed in this session. Claude and Grok also ran as test subjects. ## 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>
519 lines
19 KiB
TypeScript
519 lines
19 KiB
TypeScript
import { probeAcpxClaudeInstallation } from "@paperclipai/paperclip-runner/live";
|
|
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
|
|
import { buildSandboxNpmInstallCommand } from "@paperclipai/adapter-utils";
|
|
import type { ServerAdapterModule } from "../adapters/index.js";
|
|
|
|
import {
|
|
detectAdapterModel,
|
|
findActiveServerAdapter,
|
|
findServerAdapter,
|
|
listAdapterModels,
|
|
registerServerAdapter,
|
|
requireServerAdapter,
|
|
unregisterServerAdapter,
|
|
} from "../adapters/index.js";
|
|
import {
|
|
resolveExternalAdapterRegistration,
|
|
setOverridePaused,
|
|
} from "../adapters/registry.js";
|
|
|
|
vi.mock("@paperclipai/paperclip-runner/live", () => ({
|
|
probeAcpxClaudeInstallation: vi.fn(async () => undefined),
|
|
probeAcpxGrokInstallation: vi.fn(async () => undefined),
|
|
}));
|
|
|
|
it("advertises tool-refresh recovery for the selected legacy harness", () => {
|
|
for (const type of ["claude_local", "codex_local", "grok_local", "gemini_local", "kimi_local", "cursor", "opencode_local", "pi_local"]) {
|
|
expect(requireServerAdapter(type).supportsToolRefreshOnResume).toBe(true);
|
|
expect(requireServerAdapter(type).sessionManagement?.supportsSessionResume).toBe(true);
|
|
}
|
|
expect(requireServerAdapter("process").supportsToolRefreshOnResume).toBeUndefined();
|
|
});
|
|
|
|
const externalAdapter: ServerAdapterModule = {
|
|
type: "external_test",
|
|
execute: async () => ({
|
|
exitCode: 0,
|
|
signal: null,
|
|
timedOut: false,
|
|
}),
|
|
testEnvironment: async () => ({
|
|
adapterType: "external_test",
|
|
status: "pass",
|
|
checks: [],
|
|
testedAt: new Date(0).toISOString(),
|
|
}),
|
|
models: [{ id: "external-model", label: "External Model" }],
|
|
supportsLocalAgentJwt: false,
|
|
};
|
|
|
|
describe("server adapter registry", () => {
|
|
beforeEach(() => {
|
|
unregisterServerAdapter("external_test");
|
|
unregisterServerAdapter("hermes_local");
|
|
unregisterServerAdapter("hermes_gateway");
|
|
unregisterServerAdapter("claude_local");
|
|
setOverridePaused("claude_local", false);
|
|
});
|
|
|
|
afterEach(() => {
|
|
unregisterServerAdapter("external_test");
|
|
unregisterServerAdapter("hermes_local");
|
|
unregisterServerAdapter("hermes_gateway");
|
|
unregisterServerAdapter("claude_local");
|
|
setOverridePaused("claude_local", false);
|
|
});
|
|
|
|
it("registers external adapters and exposes them through lookup helpers", async () => {
|
|
expect(findServerAdapter("external_test")).toBeNull();
|
|
|
|
registerServerAdapter(externalAdapter);
|
|
|
|
expect(requireServerAdapter("external_test")).toBe(externalAdapter);
|
|
expect(await listAdapterModels("external_test")).toEqual([
|
|
{ id: "external-model", label: "External Model" },
|
|
]);
|
|
});
|
|
|
|
it("removes external adapters when unregistered", () => {
|
|
registerServerAdapter(externalAdapter);
|
|
|
|
unregisterServerAdapter("external_test");
|
|
|
|
expect(findServerAdapter("external_test")).toBeNull();
|
|
expect(() => requireServerAdapter("external_test")).toThrow(
|
|
"Unknown adapter type: external_test",
|
|
);
|
|
});
|
|
|
|
it("allows external plugin to override a built-in adapter type", () => {
|
|
// claude_local is always built-in
|
|
const builtIn = findServerAdapter("claude_local");
|
|
expect(builtIn).not.toBeNull();
|
|
|
|
const plugin: ServerAdapterModule = {
|
|
type: "claude_local",
|
|
execute: async () => ({
|
|
exitCode: 0,
|
|
signal: null,
|
|
timedOut: false,
|
|
}),
|
|
testEnvironment: async () => ({
|
|
adapterType: "claude_local",
|
|
status: "pass",
|
|
checks: [],
|
|
testedAt: new Date(0).toISOString(),
|
|
}),
|
|
models: [{ id: "plugin-model", label: "Plugin Override" }],
|
|
supportsLocalAgentJwt: false,
|
|
};
|
|
|
|
registerServerAdapter(plugin);
|
|
|
|
// Plugin wins
|
|
const resolved = requireServerAdapter("claude_local");
|
|
expect(resolved).toBe(plugin);
|
|
expect(resolved.models).toEqual([
|
|
{ id: "plugin-model", label: "Plugin Override" },
|
|
]);
|
|
});
|
|
|
|
it("ships Hermes adapters as built-ins and still accepts external overrides", () => {
|
|
const builtInLocal = findServerAdapter("hermes_local");
|
|
const builtInGateway = findServerAdapter("hermes_gateway");
|
|
|
|
expect(builtInLocal).not.toBeNull();
|
|
expect(builtInLocal?.supportsLocalAgentJwt).toBe(true);
|
|
expect(builtInLocal?.supportsInstructionsBundle).toBe(true);
|
|
expect(builtInLocal?.requiresMaterializedRuntimeSkills).toBe(false);
|
|
expect(builtInLocal?.detectModel).toBeTypeOf("function");
|
|
expect(builtInLocal?.getConfigSchema).toBeTypeOf("function");
|
|
|
|
expect(builtInGateway).not.toBeNull();
|
|
expect(builtInGateway?.supportsLocalAgentJwt).toBe(false);
|
|
expect(builtInGateway?.supportsInstructionsBundle).toBe(false);
|
|
expect(builtInGateway?.requiresMaterializedRuntimeSkills).toBe(false);
|
|
expect(builtInGateway?.getConfigSchema).toBeTypeOf("function");
|
|
|
|
const hermesLocalExternalAdapter: ServerAdapterModule = {
|
|
type: "hermes_local",
|
|
execute: async () => ({ exitCode: 0, signal: null, timedOut: false }),
|
|
testEnvironment: async () => ({
|
|
adapterType: "hermes_local",
|
|
status: "pass",
|
|
checks: [],
|
|
testedAt: new Date(0).toISOString(),
|
|
}),
|
|
supportsLocalAgentJwt: true,
|
|
supportsInstructionsBundle: true,
|
|
instructionsPathKey: "instructionsFilePath",
|
|
requiresMaterializedRuntimeSkills: false,
|
|
listSkills: async () => ({
|
|
adapterType: "hermes_local",
|
|
supported: true,
|
|
mode: "ephemeral",
|
|
desiredSkills: [],
|
|
entries: [],
|
|
warnings: [],
|
|
}),
|
|
getConfigSchema: () => ({ fields: [{ key: "provider", label: "Provider", type: "text" }] }),
|
|
detectModel: async () => ({
|
|
model: "hermes-model",
|
|
provider: "openrouter",
|
|
source: "test",
|
|
}),
|
|
};
|
|
|
|
const hermesGatewayExternalAdapter: ServerAdapterModule = {
|
|
type: "hermes_gateway",
|
|
execute: async () => ({ exitCode: 0, signal: null, timedOut: false }),
|
|
testEnvironment: async () => ({
|
|
adapterType: "hermes_gateway",
|
|
status: "pass",
|
|
checks: [],
|
|
testedAt: new Date(0).toISOString(),
|
|
}),
|
|
supportsLocalAgentJwt: false,
|
|
supportsInstructionsBundle: false,
|
|
requiresMaterializedRuntimeSkills: false,
|
|
getConfigSchema: () => ({
|
|
fields: [{ key: "apiBaseUrl", label: "API URL", type: "text" }],
|
|
}),
|
|
};
|
|
|
|
registerServerAdapter(hermesLocalExternalAdapter);
|
|
|
|
expect(requireServerAdapter("hermes_local")).toBe(hermesLocalExternalAdapter);
|
|
expect(findActiveServerAdapter("hermes_local")?.supportsLocalAgentJwt).toBe(true);
|
|
|
|
unregisterServerAdapter("hermes_local");
|
|
|
|
expect(requireServerAdapter("hermes_local")).toBe(builtInLocal);
|
|
|
|
registerServerAdapter(hermesGatewayExternalAdapter);
|
|
|
|
expect(requireServerAdapter("hermes_gateway")).toBe(hermesGatewayExternalAdapter);
|
|
expect(findActiveServerAdapter("hermes_gateway")?.supportsLocalAgentJwt).toBe(false);
|
|
|
|
unregisterServerAdapter("hermes_gateway");
|
|
|
|
expect(requireServerAdapter("hermes_gateway")).toBe(builtInGateway);
|
|
});
|
|
|
|
it("exposes capability flags from registered adapters", () => {
|
|
const adapterWithCaps: ServerAdapterModule = {
|
|
type: "external_test",
|
|
execute: async () => ({ exitCode: 0, signal: null, timedOut: false }),
|
|
testEnvironment: async () => ({
|
|
adapterType: "external_test",
|
|
status: "pass" as const,
|
|
checks: [],
|
|
testedAt: new Date(0).toISOString(),
|
|
}),
|
|
supportsLocalAgentJwt: true,
|
|
supportsInstructionsBundle: true,
|
|
instructionsPathKey: "customPathKey",
|
|
requiresMaterializedRuntimeSkills: true,
|
|
};
|
|
|
|
registerServerAdapter(adapterWithCaps);
|
|
|
|
const resolved = findActiveServerAdapter("external_test");
|
|
expect(resolved).not.toBeNull();
|
|
expect(resolved!.supportsInstructionsBundle).toBe(true);
|
|
expect(resolved!.instructionsPathKey).toBe("customPathKey");
|
|
expect(resolved!.requiresMaterializedRuntimeSkills).toBe(true);
|
|
expect(resolved!.supportsLocalAgentJwt).toBe(true);
|
|
});
|
|
|
|
it("returns undefined for capability flags on adapters that do not set them", () => {
|
|
registerServerAdapter(externalAdapter);
|
|
|
|
const resolved = findActiveServerAdapter("external_test");
|
|
expect(resolved).not.toBeNull();
|
|
expect(resolved!.supportsInstructionsBundle).toBeUndefined();
|
|
expect(resolved!.instructionsPathKey).toBeUndefined();
|
|
expect(resolved!.requiresMaterializedRuntimeSkills).toBeUndefined();
|
|
});
|
|
|
|
it("built-in claude_local adapter declares capability flags", () => {
|
|
const adapter = findActiveServerAdapter("claude_local");
|
|
expect(adapter).not.toBeNull();
|
|
expect(adapter!.supportsInstructionsBundle).toBe(true);
|
|
expect(adapter!.instructionsPathKey).toBe("instructionsFilePath");
|
|
expect(adapter!.requiresMaterializedRuntimeSkills).toBe(false);
|
|
expect(adapter!.supportsLocalAgentJwt).toBe(true);
|
|
});
|
|
|
|
it("rejects an incomplete managed runner provider before probing Codex", async () => {
|
|
const adapter = requireServerAdapter("paperclip_runner");
|
|
expect(adapter.supportsInstructionsBundle).toBe(true);
|
|
expect(adapter.instructionsPathKey).toBe("instructionsFilePath");
|
|
const result = await adapter.testEnvironment({
|
|
companyId: "company-1",
|
|
adapterType: "paperclip_runner",
|
|
config: { provider: "claude_managed" },
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
adapterType: "paperclip_runner",
|
|
status: "fail",
|
|
checks: [{
|
|
code: "paperclip_runner_claude_managed_profile_required",
|
|
level: "error",
|
|
}],
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
["claude_managed", {
|
|
managedProfileId: "managed-primary",
|
|
managedAgentsRetentionAcknowledged: true,
|
|
}, "claude_managed_profile_selected"],
|
|
["aws_agentcore", {
|
|
agentCoreProfileId: "agentcore-primary",
|
|
agentCoreRetentionAcknowledged: true,
|
|
}, "aws_agentcore_profile_selected"],
|
|
] as const)("accepts a complete %s profile selection", async (provider, config, code) => {
|
|
const result = await requireServerAdapter("paperclip_runner").testEnvironment({
|
|
companyId: "company-1",
|
|
adapterType: "paperclip_runner",
|
|
config: { provider, ...config },
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
adapterType: "paperclip_runner",
|
|
status: "warn",
|
|
checks: expect.arrayContaining([expect.objectContaining({ code, level: "info" })]),
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
["claude", "claude-sonnet-5"],
|
|
] as const)("does not claim runtime readiness from the remote ACPX %s platform alone", async (acpxAgent, model) => {
|
|
const result = await requireServerAdapter("paperclip_runner").testEnvironment({
|
|
companyId: "company-1",
|
|
adapterType: "paperclip_runner",
|
|
config: { provider: "acpx", acpxAgent, model },
|
|
executionTarget: {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
remoteCwd: "/workspace",
|
|
providerKey: "test-provider",
|
|
runner: { execute: vi.fn().mockResolvedValue({ exitCode: 0, timedOut: false, stdout: "Linux\nx86_64\n" }) },
|
|
},
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
adapterType: "paperclip_runner",
|
|
status: "warn",
|
|
checks: [{ code: "acpx_remote_runtime_unverified", level: "warn" }],
|
|
});
|
|
});
|
|
|
|
it.each([true, false])("checks actual local ACPX installation readiness (%s)", async (ready) => {
|
|
const probe = vi.mocked(probeAcpxClaudeInstallation);
|
|
if (ready) probe.mockResolvedValueOnce(undefined);
|
|
else probe.mockRejectedValueOnce(new Error("Runtime package integrity verification failed"));
|
|
const result = await requireServerAdapter("paperclip_runner").testEnvironment({
|
|
companyId: "company-1", adapterType: "paperclip_runner",
|
|
config: { provider: "acpx", acpxAgent: "claude", model: "custom-claude-model" },
|
|
});
|
|
expect(probe).toHaveBeenLastCalledWith("custom-claude-model");
|
|
expect(result).toMatchObject({
|
|
status: ready ? "pass" : "fail",
|
|
checks: [expect.objectContaining({ code: ready ? "acpx_runtime_ready" : "acpx_runtime_unavailable" })],
|
|
});
|
|
});
|
|
|
|
it("keeps the ACPX Pi profile unavailable", async () => {
|
|
const result = await requireServerAdapter("paperclip_runner").testEnvironment({
|
|
companyId: "company-1",
|
|
adapterType: "paperclip_runner",
|
|
config: {
|
|
provider: "acpx",
|
|
acpxAgent: "pi",
|
|
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
|
},
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
status: "fail",
|
|
checks: [{ code: "paperclip_runner_acpx_agent_unavailable" }],
|
|
});
|
|
});
|
|
it("reports qualification-only readiness for an exact host-authorized candidate", async () => {
|
|
const key = "PAPERCLIP_RUNNER_ACPX_QUALIFICATION";
|
|
const previous = process.env[key];
|
|
process.env[key] = JSON.stringify([{ agent: "cursor", model: "exact-model" }]);
|
|
try {
|
|
const result = await requireServerAdapter("paperclip_runner").testEnvironment({
|
|
companyId: "company-1", adapterType: "paperclip_runner",
|
|
config: { provider: "acpx", acpxAgent: "cursor", model: "exact-model" },
|
|
});
|
|
expect(result).toMatchObject({ status: "warn", checks: [{ code: "acpx_candidate_qualification_only" }] });
|
|
} finally {
|
|
if (previous === undefined) delete process.env[key];
|
|
else process.env[key] = previous;
|
|
}
|
|
});
|
|
it("wraps built-in npm runtime installs with the sandbox-aware install helper", () => {
|
|
const expectedClaudeInstall = `if ! command -v 'claude' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("@anthropic-ai/claude-code")}; fi`;
|
|
const expectedCodexInstall = `if ! command -v 'codex' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("@openai/codex")}; fi`;
|
|
const expectedGeminiInstall = `if ! command -v 'gemini' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("@google/gemini-cli")}; fi`;
|
|
const expectedOpenCodeInstall = `if ! command -v 'opencode' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("opencode-ai")}; fi`;
|
|
const expectedRunnerCodexInstall = `if ! command -v 'codex' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("@openai/codex@0.156.0")}; fi`;
|
|
const expectedRunnerOpenCodeInstall = `if ! command -v 'opencode' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("opencode-ai@1.18.32")}; fi`;
|
|
|
|
expect(findActiveServerAdapter("claude_local")?.getRuntimeCommandSpec?.({})).toEqual({
|
|
command: "claude",
|
|
detectCommand: "claude",
|
|
installCommand: expectedClaudeInstall,
|
|
});
|
|
expect(findActiveServerAdapter("codex_local")?.getRuntimeCommandSpec?.({})).toEqual({
|
|
command: "codex",
|
|
detectCommand: "codex",
|
|
installCommand: expectedCodexInstall,
|
|
});
|
|
expect(findActiveServerAdapter("gemini_local")?.getRuntimeCommandSpec?.({})).toEqual({
|
|
command: "gemini",
|
|
detectCommand: "gemini",
|
|
installCommand: expectedGeminiInstall,
|
|
});
|
|
expect(findActiveServerAdapter("opencode_local")?.getRuntimeCommandSpec?.({})).toEqual({
|
|
command: "opencode",
|
|
detectCommand: "opencode",
|
|
installCommand: expectedOpenCodeInstall,
|
|
});
|
|
expect(findActiveServerAdapter("paperclip_runner")?.getRuntimeCommandSpec?.({ provider: "codex" })).toEqual({
|
|
command: "codex",
|
|
detectCommand: "codex",
|
|
installCommand: expectedRunnerCodexInstall,
|
|
});
|
|
expect(findActiveServerAdapter("paperclip_runner")?.getRuntimeCommandSpec?.({ provider: "opencode" })).toEqual({
|
|
command: "opencode",
|
|
detectCommand: "opencode",
|
|
installCommand: expectedRunnerOpenCodeInstall,
|
|
});
|
|
expect(findActiveServerAdapter("paperclip_runner")?.getRuntimeCommandSpec?.({ provider: "acpx" })).toEqual({
|
|
command: "paperclip-runnerd",
|
|
detectCommand: null,
|
|
installCommand: null,
|
|
});
|
|
});
|
|
|
|
it("switches active adapter behavior back to the builtin when an override is paused", async () => {
|
|
const builtIn = findServerAdapter("claude_local");
|
|
expect(builtIn).not.toBeNull();
|
|
|
|
const detectModel = vi.fn(async () => ({
|
|
model: "plugin-model",
|
|
provider: "plugin-provider",
|
|
source: "plugin-source",
|
|
}));
|
|
const plugin: ServerAdapterModule = {
|
|
type: "claude_local",
|
|
execute: async () => ({
|
|
exitCode: 0,
|
|
signal: null,
|
|
timedOut: false,
|
|
}),
|
|
testEnvironment: async () => ({
|
|
adapterType: "claude_local",
|
|
status: "pass",
|
|
checks: [],
|
|
testedAt: new Date(0).toISOString(),
|
|
}),
|
|
models: [{ id: "plugin-model", label: "Plugin Override" }],
|
|
detectModel,
|
|
supportsLocalAgentJwt: false,
|
|
};
|
|
|
|
registerServerAdapter(plugin);
|
|
|
|
expect(findActiveServerAdapter("claude_local")).toBe(plugin);
|
|
expect(await listAdapterModels("claude_local")).toEqual([
|
|
{ id: "plugin-model", label: "Plugin Override" },
|
|
]);
|
|
expect(await detectAdapterModel("claude_local")).toMatchObject({
|
|
model: "plugin-model",
|
|
provider: "plugin-provider",
|
|
});
|
|
|
|
expect(setOverridePaused("claude_local", true)).toBe(true);
|
|
|
|
expect(findActiveServerAdapter("claude_local")).not.toBe(plugin);
|
|
expect(await listAdapterModels("claude_local")).toEqual(builtIn?.models ?? []);
|
|
expect(await detectAdapterModel("claude_local")).toBeNull();
|
|
expect(detectModel).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("resolveExternalAdapterRegistration", () => {
|
|
it("preserves module-provided sessionManagement", () => {
|
|
const sessionManagement = {
|
|
supportsSessionResume: true,
|
|
nativeContextManagement: "unknown" as const,
|
|
defaultSessionCompaction: {
|
|
enabled: true,
|
|
maxSessionRuns: 200,
|
|
maxRawInputTokens: 2_000_000,
|
|
maxSessionAgeHours: 72,
|
|
},
|
|
};
|
|
const adapter: ServerAdapterModule = {
|
|
type: "external_session_test",
|
|
execute: async () => ({ exitCode: 0, signal: null, timedOut: false }),
|
|
testEnvironment: async () => ({
|
|
adapterType: "external_session_test",
|
|
status: "pass",
|
|
checks: [],
|
|
testedAt: new Date(0).toISOString(),
|
|
}),
|
|
sessionManagement,
|
|
};
|
|
|
|
const resolved = resolveExternalAdapterRegistration(adapter);
|
|
|
|
expect(resolved.sessionManagement).toBe(sessionManagement);
|
|
});
|
|
|
|
it("falls back to the hardcoded registry when the module omits sessionManagement", () => {
|
|
// An external that overrides a built-in type should inherit the built-in's
|
|
// sessionManagement when it does not provide its own.
|
|
const adapter: ServerAdapterModule = {
|
|
type: "claude_local",
|
|
execute: async () => ({ exitCode: 0, signal: null, timedOut: false }),
|
|
testEnvironment: async () => ({
|
|
adapterType: "claude_local",
|
|
status: "pass",
|
|
checks: [],
|
|
testedAt: new Date(0).toISOString(),
|
|
}),
|
|
};
|
|
|
|
const resolved = resolveExternalAdapterRegistration(adapter);
|
|
|
|
expect(resolved.sessionManagement).toBeDefined();
|
|
expect(resolved.sessionManagement?.supportsSessionResume).toBe(true);
|
|
expect(resolved.sessionManagement?.nativeContextManagement).toBe("confirmed");
|
|
});
|
|
|
|
it("leaves sessionManagement undefined when neither module nor registry provides one", () => {
|
|
const adapter: ServerAdapterModule = {
|
|
type: "external_unknown_test",
|
|
execute: async () => ({ exitCode: 0, signal: null, timedOut: false }),
|
|
testEnvironment: async () => ({
|
|
adapterType: "external_unknown_test",
|
|
status: "pass",
|
|
checks: [],
|
|
testedAt: new Date(0).toISOString(),
|
|
}),
|
|
};
|
|
|
|
const resolved = resolveExternalAdapterRegistration(adapter);
|
|
|
|
expect(resolved.sessionManagement).toBeUndefined();
|
|
});
|
|
});
|